import type { Accessor, JSX } from 'solid-js'; import { createComponent, createContext, createMemo, createSignal, untrack, useContext, } from 'solid-js'; import assert from '../utils/assert'; import createInputReader from '../utils/create-input-reader'; import isEqual from '../utils/is-equal'; import type { Ref } from '../utils/types'; export interface AutocompleteStateProperties { isSelected: (value: T) => boolean; select: (value: T) => void; hasSelected: () => boolean; isActive: (value: T) => boolean; hasActive: () => boolean; focus: (value: T) => void; blur: () => void; disabled: () => boolean; query: () => string; setQuery: (value: string) => void; matches: (value: T) => boolean; hasQuery: () => boolean; } export interface SingleAutocompleteStateControlledOptions { multiple?: false; toggleable?: boolean; /** `undefined` means nothing is selected, which is also what `toggleable` produces. */ value: T | undefined; matchBy: (value: T, query: string) => boolean; onChange?: (value?: T) => void; disabled?: boolean; by?: (a: T, b: T) => boolean; } export interface SingleAutocompleteStateUncontrolledOptions { multiple?: false; toggleable?: boolean; /** `undefined` starts with nothing selected. */ defaultValue: T | undefined; matchBy: (value: T, query: string) => boolean; onChange?: (value?: T) => void; disabled?: boolean; by?: (a: T, b: T) => boolean; } export type SingleAutocompleteStateOptions = | SingleAutocompleteStateControlledOptions | SingleAutocompleteStateUncontrolledOptions; /** * Creates a single-selection state with a debounced text query on top. Backs * `Combobox` and `Command`. * * The required `matchBy` decides which values survive the current query. * Writes to the query are debounced by 250ms, so `query()` lags typing. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/states.md#autocomplete-state} */ export function createSingleAutocompleteState( options: SingleAutocompleteStateOptions, ): AutocompleteStateProperties { 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); const [input, setInput] = createInputReader(); 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); } }, query(): string { return input(); }, setQuery(value): void { if (!untrack(isDisabled)) { setInput(value); } }, matches(value): boolean { return options.matchBy(value, input()); }, hasQuery: createMemo(() => !!input()), }; } export interface MultipleAutocompleteStateControlledOptions { multiple: true; toggleable?: boolean; value: T[]; matchBy: (value: T, query: string) => boolean; onChange?: (value: T[]) => void; disabled?: boolean; by?: (a: T, b: T) => boolean; } export interface MultipleAutocompleteStateUncontrolledOptions { multiple: true; toggleable?: boolean; defaultValue: T[]; matchBy: (value: T, query: string) => boolean; onChange?: (value: T[]) => void; disabled?: boolean; by?: (a: T, b: T) => boolean; } export type MultipleAutocompleteStateOptions = | MultipleAutocompleteStateControlledOptions | MultipleAutocompleteStateUncontrolledOptions; /** * Creates a multiple-selection state with a debounced text query on top. * Requires `multiple: true`. * * The required `matchBy` decides which values survive the current query. * Writes to the query are debounced by 250ms, so `query()` lags typing. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/states.md#autocomplete-state} */ export function createMultipleAutocompleteState( options: MultipleAutocompleteStateOptions, ): AutocompleteStateProperties { 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); const [input, setInput] = createInputReader(); 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); } }, query(): string { return input(); }, setQuery(value): void { setInput(value); }, matches(value): boolean { return options.matchBy(value, input()); }, hasQuery: createMemo(() => !!input()), }; } export interface AutocompleteStateRenderProps { children?: JSX.Element | ((state: AutocompleteStateProperties) => JSX.Element); } export interface AutocompleteStateProviderProps extends AutocompleteStateRenderProps { state: AutocompleteStateProperties; } const AutocompleteStateContext = createContext>(); export function AutocompleteStateProvider( props: AutocompleteStateProviderProps, ): JSX.Element { return createComponent(AutocompleteStateContext.Provider, { // The context erases the value type; every consumer re-applies its own `T` // through `useAutocompleteState`. value: props.state as AutocompleteStateProperties, get children() { const current = props.children; if (typeof current === 'function') { return current(props.state); } return current; }, }); } /** * Reads the nearest autocomplete state from context. Throws when there is * none. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/states.md#autocomplete-state} */ export function useAutocompleteState(): AutocompleteStateProperties { const ctx = useContext(AutocompleteStateContext); assert(ctx, new Error('Missing ')); return ctx; } /** * Passes the nearest autocomplete 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#autocomplete-state} */ export function AutocompleteStateChild(props: AutocompleteStateRenderProps): JSX.Element { const state = useAutocompleteState(); return createMemo(() => { const current = props.children; if (typeof current === 'function' && current.length === 1) { return createMemo(() => current(state)); } return current; }) as unknown as JSX.Element; }