@eviljs/reactx
Version:
Awesome React UI Widgets
588 lines (587 loc) • 24.9 kB
JavaScript
import { defineContext } from '@eviljs/react/ctx';
import { useClickOutside } from '@eviljs/react/gesture';
import { call } from '@eviljs/std/fn-call';
import { compute } from '@eviljs/std/fn-compute';
import { clamp } from '@eviljs/std/math';
import { isArray, isUndefined } from '@eviljs/std/type-is';
import { classes } from '@eviljs/web/classes';
import { KeyboardKey } from '@eviljs/web/keybinding';
import { useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { mapSome } from '@eviljs/std/fn-monad';
import { setRef } from '@eviljs/react/ref';
const NoItems = [];
export const SelectContext = (defineContext('SelectContext'));
export function useSelectOneContext() {
return useContext(SelectContext);
}
export function useSelectManyContext() {
return useContext(SelectContext);
}
export function useSelectOneProvider(args) {
const { initialOpen, initialSelected, open: openControlled, selected: selectedControlled, setOpen: setOpenControlled, setSelected: setSelectedControlled, ...otherArgs } = args;
const [openUncontrolled, setOpenUncontrolled] = useState(initialOpen ?? openControlled ?? false);
const [selectedUncontrolled, setSelectedUncontrolled] = useState(initialSelected);
const open = openControlled ?? openUncontrolled;
const selected = selectedControlled ?? selectedUncontrolled;
const setOpen = useCallback((open) => {
setOpenUncontrolled(open);
setOpenControlled?.(open);
}, [setOpenUncontrolled, setOpenControlled]);
const setSelected = useCallback((selected) => {
setSelectedUncontrolled(selected);
setSelectedControlled(selected);
}, [setSelectedUncontrolled, setSelectedControlled]);
const clearSelected = useCallback(() => {
setSelected(undefined);
}, [setSelected]);
const onOptionSelection = useCallback((option, optionIdx, event) => {
setSelected(option);
setOpen(false);
}, [setSelected, setOpen]);
return useSelectGenericProvider({
...otherArgs,
open: open,
selected: selected,
clearSelected: clearSelected,
setOpen: setOpen,
setSelected: setSelected,
onOptionSelection: onOptionSelection,
});
}
export function useSelectManyProvider(args) {
const { initialOpen, initialSelected, open: openControlled, selected: selectedControlled, setOpen: setOpenControlled, setSelected: setSelectedControlled, ...otherArgs } = args;
const [openUncontrolled, setOpenUncontrolled] = useState(initialOpen ?? openControlled ?? false);
const [selectedUncontrolled, setSelectedUncontrolled] = useState(initialSelected ?? NoItems);
const open = openControlled ?? openUncontrolled;
const selected = selectedControlled ?? selectedUncontrolled;
const setOpen = useCallback((value) => {
setOpenUncontrolled(value);
setOpenControlled?.(value);
}, [setOpenUncontrolled, setOpenControlled]);
const setSelected = useCallback((selected) => {
setSelectedUncontrolled(selected);
setSelectedControlled(selected);
}, [setSelectedUncontrolled, setSelectedControlled]);
const clearSelected = useCallback(() => {
setSelected(NoItems);
}, [setSelected]);
const onOptionSelection = useCallback((option, optionIdx, event) => {
setSelected(selected.some(it => it.value === option.value)
? selected.filter(it => it.value !== option.value)
: [...selected, option]);
}, [selected, setSelected, setOpen]);
return useSelectGenericProvider({
...otherArgs,
open: open,
selected: selected,
clearSelected: clearSelected,
setOpen: setOpen,
setSelected: setSelected,
onOptionSelection: onOptionSelection,
});
}
export function useSelectGenericProvider(args) {
const [PRIVATE_optionFocused, PRIVATE_setOptionFocused] = useState();
const [PRIVATE_optionTabbed, PRIVATE_setOptionTabbed] = useState();
const refs = {
rootRef: useRef(undefined),
controlRef: useRef(undefined),
optionsRootRef: useRef(undefined),
optionsListRef: useRef(undefined),
optionsRef: useRef(NoItems),
};
const state = {
disabled: args.disabled ?? false,
mounted: args.mounted ?? false,
open: args.open,
optionFocused: PRIVATE_optionFocused,
optionTabbed: PRIVATE_optionTabbed,
placement: args.placement ?? 'center',
readonly: args.readonly ?? false,
required: args.required ?? false,
selected: args.selected,
teleport: args.teleport ?? false,
valid: args.valid ?? true,
clearSelected: args.clearSelected,
setOpen() { },
setOptionFocused: PRIVATE_setOptionFocused,
setOptionTabbed: PRIVATE_setOptionTabbed,
setSelected() { },
};
state.setOpen = useCallback((open) => {
if (state.disabled) {
return;
}
/*
* // Readonly select can be opened.
* if (state.readonly) {
* return
* }
*/
args.setOpen(open);
}, [state.disabled, state.readonly, args.setOpen]);
state.setSelected = useCallback((selected) => {
if (state.disabled) {
return;
}
if (state.readonly) {
return;
}
args.setSelected(selected);
}, [state.disabled, state.readonly, args.setSelected]);
const onOptionSelection = useCallback((option, optionIdx, event) => {
if (state.disabled) {
return;
}
if (state.readonly) {
return;
}
if (option.disabled) {
return;
}
args.onOptionSelection(option, optionIdx, event);
}, [state.disabled, state.readonly, args.onOptionSelection]);
const computedPropsContext = {
open: state.open,
selected: state.selected,
};
const computedProps = {
rootProps: compute(args.rootProps, computedPropsContext),
controlProps: compute(args.controlProps, computedPropsContext),
optionsRootProps: compute(args.optionsRootProps, computedPropsContext),
optionsListProps: compute(args.optionsListProps, computedPropsContext),
optionProps: compute(args.optionProps, computedPropsContext),
};
const optionsRootId = useId();
const props = {
rootProps: {
...computedProps.rootProps,
ref: useCallback((element) => {
refs.rootRef.current = element ?? undefined;
mapSome(computedProps.rootProps?.ref, ref => setRef(ref, element));
}, [computedProps.rootProps?.ref]),
className: classes(computedProps.rootProps?.className),
['aria-controls']: optionsRootId,
['aria-disabled']: state.disabled,
['aria-expanded']: state.open,
['data-placement']: state.placement,
style: {
...call(() => {
if (state.teleport) {
return;
}
if (state.open) {
return {
position: 'relative',
zIndex: 'var(--Select-root-zindex, var(--Select-root-zindex--default, 1))',
};
}
if (state.mounted) {
return {
position: 'relative',
};
}
return;
}),
...computedProps.rootProps?.style,
},
onBlur: useCallback((event) => {
if (state.disabled) {
return;
}
/*
* // Readonly select can be closed.
* if (state.readonly) {
* return
* }
*/
if (refs.rootRef.current?.contains(event.relatedTarget)) {
// Focus is inside the options list.
return;
}
computedProps.rootProps?.onBlur?.(event);
// User is tabbing out of the select.
state.setOpen(false);
}, [state.disabled, state.readonly, state.setOpen, computedProps.rootProps?.onBlur]),
},
controlProps: {
...computedProps.controlProps,
ref: useCallback((element) => {
refs.controlRef.current = element ?? undefined;
mapSome(computedProps.controlProps?.ref, ref => setRef(ref, element));
}, [computedProps.controlProps?.ref]),
className: classes(computedProps.controlProps?.className),
role: 'combobox',
['aria-invalid']: !state.valid,
['aria-readonly']: state.readonly,
['aria-required']: state.required,
tabIndex: state.disabled ? -1 : 0,
onClick: useCallback((event) => {
if (state.disabled) {
return;
}
/*
* // Readonly select can be opened.
* if (state.readonly) {
* return
* }
*/
/*
* // Click target is never the control element, but always an element inside it.
* if (event.target !== event.currentTarget) {
* return
* }
*/
computedProps.controlProps?.onClick?.(event);
state.setOpen(!state.open);
// On click we should reset focused/tabbed option.
state.setOptionFocused(undefined);
state.setOptionTabbed(undefined);
}, [state.disabled, state.readonly, state.open, state.setOptionFocused, state.setOptionTabbed, computedProps.controlProps?.onClick]),
onFocus: useCallback((event) => {
if (state.disabled) {
return;
}
if (state.readonly) {
return;
}
if (event.target !== event.currentTarget) {
return;
}
computedProps.controlProps?.onFocus?.(event);
state.setOptionFocused(undefined);
}, [state.disabled, state.readonly, state.setOptionFocused, computedProps.controlProps?.onFocus]),
onKeyDown: useCallback((event) => {
if (state.disabled) {
return;
}
/*
* // Readonly select can be opened.
* if (state.readonly) {
* return
* }
*/
if (event.target !== event.currentTarget) {
return;
}
computedProps.controlProps?.onKeyDown?.(event);
switch (event.key) {
case KeyboardKey.ArrowDown:
{
event.preventDefault();
event.stopPropagation();
state.setOpen(true);
state.setOptionFocused(state.optionTabbed ?? 0); // We move focus to last tabbed/focused option.
state.setOptionTabbed(state.optionTabbed ?? 0);
}
break;
case KeyboardKey.Enter:
case KeyboardKey.Space:
event.preventDefault();
event.stopPropagation();
state.setOpen(!state.open);
break;
case KeyboardKey.Escape:
event.preventDefault();
event.stopPropagation();
state.setOpen(false);
break;
}
}, [
state.disabled,
state.readonly,
state.open,
state.optionTabbed,
state.setOpen,
state.setOptionFocused,
state.setOptionTabbed,
computedProps.controlProps?.onKeyDown,
]),
},
optionsRootProps: {
...computedProps.optionsRootProps,
className: classes(computedProps.optionsRootProps?.className),
ref: useCallback((element) => {
refs.optionsRootRef.current = element ?? undefined;
mapSome(computedProps.optionsRootProps?.ref, ref => setRef(ref, element));
}, [computedProps.optionsRootProps?.ref]),
['aria-hidden']: !state.open,
style: {
...call(() => {
if (state.teleport) {
return;
}
if (!state.open) {
return {
display: 'var(--Select-options-display--hidden, var(--Select-options-display--hidden--default, none))',
};
}
switch (state.placement) {
case 'top': return {
position: 'var(--Select-options-position, var(--Select-options-position--default, absolute))',
zIndex: 'var(--Select-options-zindex, var(--Select-options-zindex--default, 1))',
top: 'calc(-1 * var(--Select-options-gap, var(--Select-options-gap--default, 0px)))',
left: 0,
right: 0,
width: '100%',
height: 0,
margin: 'auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-end',
};
case 'center': return {
position: 'var(--Select-options-position, var(--Select-options-position--default, absolute))',
zIndex: 'var(--Select-options-zindex, var(--Select-options-zindex--default, 1))',
inset: 0,
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
};
case 'bottom': return {
position: 'var(--Select-options-position, var(--Select-options-position--default, absolute))',
zIndex: 'var(--Select-options-zindex, var(--Select-options-zindex--default, 1))',
left: 0,
right: 0,
bottom: 'calc(-1 * var(--Select-options-gap, var(--Select-options-gap--default, 0px)))',
width: '100%',
height: 0,
margin: 'auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
};
case 'positioned': return {
position: 'var(--Select-options-position, var(--Select-options-position--default, absolute))',
zIndex: 'var(--Select-options-zindex, var(--Select-options-zindex--default, 1))',
inset: 0,
width: '100%',
height: '100%',
margin: 'auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
};
}
}),
...computedProps.optionsRootProps?.style,
},
},
optionsListProps: {
...computedProps.optionsListProps,
className: classes(computedProps.optionsListProps?.className),
ref: useCallback((element) => {
refs.optionsListRef.current = element ?? undefined;
mapSome(computedProps.optionsListProps?.ref, ref => setRef(ref, element));
}, [computedProps.optionsListProps?.ref]),
role: 'listbox',
},
optionPropsFor: (option, optionIdx) => ({
...computedProps.optionProps,
ref(element) {
refs.optionsRef.current[optionIdx] = element ?? undefined;
mapSome(computedProps.optionProps?.ref, ref => setRef(ref, element));
},
className: classes(),
role: 'option',
['aria-disabled']: option.disabled ?? false,
['aria-selected']: isArray(state.selected)
? state.selected.some(it => it.value === option.value)
: (option.value === state.selected?.value),
tabIndex: optionIdx === state.optionTabbed ? 0 : -1,
onClick(event) {
if (state.disabled) {
return;
}
if (state.readonly) {
return;
}
if (option.disabled) {
return;
}
computedProps.optionProps?.onClick?.(event);
onOptionSelection(option, optionIdx, event);
},
onKeyDown(event) {
if (state.disabled) {
return;
}
/*
* // Readonly select can be navigated and closed.
* if (state.readonly) {
* return
* }
*/
computedProps.optionProps?.onKeyDown?.(event);
switch (event.key) {
case KeyboardKey.ArrowUp:
{
event.preventDefault();
const prevOptionIdx = optionIdx - 1;
const prevOptionElement = refs.optionsRef.current[prevOptionIdx];
if (!prevOptionElement) {
return;
}
state.setOptionFocused(prevOptionIdx);
state.setOptionTabbed(prevOptionIdx);
}
break;
case KeyboardKey.ArrowDown:
{
event.preventDefault();
const optionIdx = refs.optionsRef.current.indexOf(event.currentTarget);
if (optionIdx === -1) {
return;
}
const nextOptionIdx = optionIdx + 1;
const nextOptionElement = refs.optionsRef.current[nextOptionIdx];
if (!nextOptionElement) {
return;
}
state.setOptionFocused(nextOptionIdx);
state.setOptionTabbed(nextOptionIdx);
}
break;
case KeyboardKey.Enter:
case KeyboardKey.Space:
{
event.preventDefault();
if (state.readonly) {
return;
}
if (option.disabled) {
return;
}
onOptionSelection(option, optionIdx, event);
}
break;
case KeyboardKey.Escape:
event.preventDefault();
event.stopPropagation();
state.setOpen(false);
refs.controlRef.current?.focus(); // We move back the focus to the control element.
break;
}
},
}),
};
useClickOutside(refs.rootRef, useCallback(event => {
if (event.target && !document.documentElement.contains(event.target)) {
// A DOM Node has been removed from the DOM tree by React.
// Not an actual click outside event.
return;
}
state.setOpen(false);
// We reset focused and tabbed option when clicking outside.
state.setOptionFocused(undefined);
state.setOptionTabbed(undefined);
}, [state.setOpen, state.setOptionFocused, state.setOptionTabbed]), { active: state.open });
useEffect(() => {
if (state.open) {
// On open we focus last tabbed element.
state.setOptionFocused(state.optionTabbed);
}
else {
// On close we reset the focused element.
state.setOptionFocused(undefined);
}
}, [state.open, state.setOptionFocused]);
useEffect(() => {
if (isUndefined(state.optionFocused)) {
return;
}
refs.optionsRef.current[state.optionFocused]?.focus();
}, [state.optionFocused]);
useEffect(() => {
refs.optionsRef.current = refs.optionsRef.current.slice(0, args.options?.length ?? 0);
}, [args.options?.length]);
useLayoutEffect(() => {
if (state.placement !== 'positioned') {
return;
}
if (state.teleport) {
return;
}
if (!args.options) {
return;
}
if (!state.open) {
ElementTranslate.clean(refs.optionsRootRef.current);
return;
}
if (!state.selected) {
ElementTranslate.clean(refs.optionsRootRef.current);
return;
}
if (isArray(state.selected)) {
return;
}
const selected = state.selected;
const optionIdx = args.options.findIndex(it => it.value === selected.value);
const controlElement = refs.controlRef.current;
const optionsRootElement = refs.optionsRootRef.current;
const optionsListElement = refs.optionsListRef.current;
const optionElement = refs.optionsRef.current[optionIdx];
if (optionIdx < 0) {
return;
}
if (!controlElement) {
return;
}
if (!optionsRootElement) {
return;
}
if (!optionsListElement) {
return;
}
if (!optionElement) {
return;
}
const hasScrolling = (optionsListElement.scrollHeight - optionsListElement.clientHeight);
if (hasScrolling) {
const scrollHeight = optionsListElement.scrollHeight;
const optionsListElementRect = optionsListElement.getBoundingClientRect();
const optionElementRect = optionElement.getBoundingClientRect();
const optionElementOffsetY = optionElement.offsetTop;
const optionElementScrollY = (optionElementOffsetY
- (optionsListElementRect.height / 2) // Centered inside the options list.
+ (optionElementRect.height / 2) // Centered inside the options list.
);
optionsListElement.scrollTop = clamp(0, optionElementScrollY, scrollHeight);
return;
}
const [currentXOptional, currentYOptional] = ElementTranslate.read(refs.optionsRootRef.current);
const currentY = currentYOptional ?? 0;
const optionElementRect = optionElement.getBoundingClientRect();
const controlElementRect = controlElement.getBoundingClientRect();
const heightDelta = controlElementRect.height - optionElementRect.height;
const yDelta = controlElementRect.y - optionElementRect.y;
const transformY = currentY + yDelta + (heightDelta / 2);
ElementTranslate.write(refs.optionsRootRef.current, 0, transformY);
}, [state.placement, state.teleport, state.open, state.selected, args.options]);
return { props, refs, state };
}
const ElementTranslate = {
read(element) {
return element?.style.translate
.split(' ')
.filter(Boolean)
.map(it => it.replace('px', ''))
.map(Number)
?? [];
},
write(element, xPx, yPx) {
if (!element) {
return;
}
element.style.translate = `${xPx}px ${yPx}px`;
},
clean(element) {
if (!element) {
return;
}
element.style.translate = '';
},
};