UNPKG

@eviljs/reactx

Version:
588 lines (587 loc) 24.9 kB
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 = ''; }, };