import type { Accessor, JSX } from 'solid-js'; import { createComponent, createContext, createMemo, createSignal, untrack, useContext, } from 'solid-js'; import assert from '../utils/assert'; import isEqual from '../utils/is-equal'; import type { Ref } from '../utils/types'; export interface SelectStateProperties { isSelected: (value: T) => boolean; select: (value: T) => void; hasSelected: () => boolean; isActive: (value: T) => boolean; hasActive: () => boolean; focus: (value: T) => void; blur: () => void; disabled: () => boolean; } export interface SingleSelectStateControlledOptions { multiple?: false; toggleable?: boolean; /** `undefined` means nothing is selected, which is also what `toggleable` produces. */ value: T | undefined; onChange?: (value?: T) => void; disabled?: boolean; by?: (a: T, b: T) => boolean; } export interface SingleSelectStateUncontrolledOptions { multiple?: false; toggleable?: boolean; /** `undefined` starts with nothing selected. */ defaultValue: T | undefined; onChange?: (value?: T) => void; disabled?: boolean; by?: (a: T, b: T) => boolean; } export type SingleSelectStateOptions = | SingleSelectStateControlledOptions | SingleSelectStateUncontrolledOptions; /** * Creates a selection state that holds at most one value, and tracks which * value is active. Backs `Accordion`, `Select`, `Listbox`, `RadioGroup` and * `TabGroup`. * * Pass `by` when the values are objects, since the default comparison is by * reference. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/states.md#select-state} */ export function createSingleSelectState( options: SingleSelectStateOptions, ): SelectStateProperties { const [active, setActive] = createSignal>(); let selectedValue: Accessor; let setSelectedValue: (value: T | undefined) => void; const equals = options.by ?? isEqual; if ('defaultValue' in options) { const [selected, setSelected] = createSignal(options.defaultValue); selectedValue = selected; setSelectedValue = (value): void => { setSelected(() => value); if (options.onChange) { options.onChange(value); } }; } else { selectedValue = createMemo(() => options.value); setSelectedValue = (value): void => { if (options.onChange) { options.onChange(value); } }; } const isDisabled = createMemo(() => !!options.disabled); return { isSelected(value): boolean { return isEqual(value, selectedValue()); }, select(value): void { if (!untrack(isDisabled)) { if (options.toggleable && equals(untrack(selectedValue) as T, value)) { setSelectedValue(undefined); } else { setSelectedValue(value); } } }, hasSelected(): boolean { return selectedValue() != null; }, disabled: isDisabled, hasActive(): boolean { return !!active(); }, isActive(value): boolean { const ref = active(); return ref ? equals(value, ref.value) : false; }, focus(value): void { if (!untrack(isDisabled)) { setActive({ value, }); } }, blur(): void { if (!untrack(isDisabled)) { setActive(undefined); } }, }; } export interface MultipleSelectStateControlledOptions { multiple: true; toggleable?: boolean; value: T[]; onChange?: (value: T[]) => void; disabled?: boolean; by?: (a: T, b: T) => boolean; } export interface MultipleSelectStateUncontrolledOptions { multiple: true; toggleable?: boolean; defaultValue: T[]; onChange?: (value: T[]) => void; disabled?: boolean; by?: (a: T, b: T) => boolean; } export type MultipleSelectStateOptions = | MultipleSelectStateControlledOptions | MultipleSelectStateUncontrolledOptions; /** * Creates a selection state that holds an array of values, and tracks which * value is active. Requires `multiple: true`. * * Pass `by` when the values are objects, since the default comparison is by * reference. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/states.md#select-state} */ export function createMultipleSelectState( options: MultipleSelectStateOptions, ): SelectStateProperties { const [active, setActive] = createSignal>(); let selectedValues: Accessor; let setSelectedValues: (value: T[]) => void; const equals = options.by ?? isEqual; if ('defaultValue' in options) { const [selected, setSelected] = createSignal(options.defaultValue); selectedValues = selected; setSelectedValues = (value): void => { setSelected(() => value); if (options.onChange) { options.onChange(value); } }; } else { selectedValues = createMemo(() => options.value); setSelectedValues = (value): void => { if (options.onChange) { options.onChange(value); } }; } const isDisabled = createMemo(() => !!options.disabled); return { isSelected(value): boolean { const values = selectedValues(); // Looks up for the value for (let i = 0, len = values.length; i < len; i += 1) { if (equals(value, values[i])) { return true; } } return false; }, select(value): void { if (!untrack(isDisabled)) { const newValues: T[] = []; const currentValues = untrack(selectedValues); let hasValue = false; for (let i = 0, len = currentValues.length; i < len; i += 1) { const item = currentValues[i]; // Compare ahead const isSame = equals(item, value); // If it's the same then we mark the the target value // as already existing in the array if (isSame) { hasValue = true; } // If it's the same and the list is toggleable // don't push the item if (!(options.toggleable && isSame)) { newValues.push(item); } } // The value doesn't exist, push the new value if (!hasValue) { newValues.push(value); } setSelectedValues(newValues); } }, hasSelected: createMemo(() => selectedValues().length > 0), disabled: isDisabled, hasActive: createMemo(() => !!active()), isActive(value): boolean { const ref = active(); if (ref) { return equals(value, ref.value); } return false; }, focus(value): void { if (!untrack(isDisabled)) { setActive({ value, }); } }, blur(): void { if (!untrack(isDisabled)) { setActive(undefined); } }, }; } export interface SelectStateRenderProps { children?: JSX.Element | ((state: SelectStateProperties) => JSX.Element); } export interface SelectStateProviderProps extends SelectStateRenderProps { state: SelectStateProperties; } const SelectStateContext = createContext>(); export function SelectStateProvider(props: SelectStateProviderProps): JSX.Element { return createComponent(SelectStateContext.Provider, { // The context erases the value type; every consumer re-applies its own `T` // through `useSelectState`. value: props.state as SelectStateProperties, get children() { const current = props.children; if (typeof current === 'function') { return current(props.state); } return current; }, }); } /** * Reads the nearest select state from context. Throws when there is none. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/states.md#select-state} */ export function useSelectState(): SelectStateProperties { const ctx = useContext(SelectStateContext); assert(ctx, new Error('Missing ')); return ctx; } /** * Passes the nearest select state to a render prop. A function child is * treated as a render prop only when it declares exactly one parameter. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/states.md#select-state} */ export function SelectStateChild(props: SelectStateRenderProps): JSX.Element { const state = useSelectState(); return createMemo(() => { const current = props.children; if (typeof current === 'function' && current.length === 1) { return createMemo(() => current(state)); } return current; }) as unknown as JSX.Element; }