import { attrs, createMixin, css, on, type CSSMixinDescriptor, type ElementProps, type Handle, type MixinFactory, type RemixNode, } from '@remix-run/ui' import { theme } from '../../theme/theme.ts' import { hiddenTypeahead, matchNextItemBySearchText, type SearchValue, } from '../../interactions/typeahead/typeahead-mixin.ts' import { flashAttribute } from '../../utils/flash-attribute.ts' export type ListboxValue = string | null type NavigationStrategy = 'next' | 'previous' | 'first' | 'last' type State = 'idle' | 'selecting' interface Values { value: ListboxValue activeValue: ListboxValue } export interface ListboxContext extends Values { registerOption: (option: RegisteredOption) => void select: (value: ListboxValue) => Promise highlight: (value: ListboxValue) => void highlightSearchMatch: (text: string) => void navigate: (direction: NavigationStrategy) => void scrollActiveOptionIntoView: () => void } export interface ListboxProviderProps extends Values { children?: RemixNode ref?: (ref: ListboxRef) => void flashSelection?: boolean selectionFlashAttribute?: string onSelect: (value: ListboxValue, option?: ListboxRegisteredOption) => void onSelectSettled?: (value: ListboxValue, option?: ListboxRegisteredOption) => void | Promise onHighlight: (value: ListboxValue, option?: ListboxRegisteredOption) => void } export interface ListboxRef { active: ListboxRegisteredOption | undefined options: ReadonlyArray selected: ListboxRegisteredOption | undefined highlight: (value: ListboxValue) => void highlightSearchMatch: (text: string) => void matchSearchText: (text: string, fromValue?: ListboxValue) => ListboxRegisteredOption | null navigateFirst: () => void navigateLast: () => void navigateNext: () => void navigatePrevious: () => void scrollActiveOptionIntoView: () => void select: (value: ListboxValue) => Promise selectActive: () => Promise } export interface ListboxRegisteredOption extends ListboxOption { readonly hidden: boolean readonly node: HTMLElement } interface RegisteredOption extends ListboxRegisteredOption {} const listCss: CSSMixinDescriptor = css({ display: 'flex', flexDirection: 'column', outline: 'none', userSelect: 'none', WebkitUserSelect: 'none', '--rmx-ui-item-inset': theme.space.sm, '--rmx-ui-item-indicator-gap': theme.space.xs, '--rmx-ui-item-indicator-width': theme.fontSize.sm, }) const itemCss: CSSMixinDescriptor = css({ display: 'grid', gridTemplateColumns: 'max-content minmax(0, 1fr)', alignItems: 'center', width: '100%', minHeight: theme.control.height.md, padding: `${theme.space.xs} ${theme.space.sm}`, borderRadius: theme.radius.md, backgroundColor: 'transparent', color: theme.colors.text.primary, fontFamily: theme.fontFamily.sans, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, lineHeight: theme.lineHeight.normal, textAlign: 'left', userSelect: 'none', WebkitUserSelect: 'none', '&:focus': { outline: 'none', }, '&[data-highlighted="true"]': { backgroundColor: theme.colors.action.primary.background, color: theme.colors.action.primary.foreground, }, '&[aria-disabled="true"]': { opacity: 0.5, }, scrollMarginBlock: theme.space.xs, '--rmx-listbox-option-indicator-opacity': '0', '&[hidden]': { display: 'none', }, '&[data-listbox-flash="true"], &[data-select-flash="true"], &[data-combobox-flash="true"]': { backgroundColor: 'transparent', color: theme.colors.text.primary, }, '&[aria-selected="true"]': { '--rmx-listbox-option-indicator-opacity': '1', }, }) const itemGlyphCss: CSSMixinDescriptor = css({ gridColumn: '1', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: '1em', height: '1em', color: 'currentColor', flexShrink: 0, opacity: 'var(--rmx-listbox-option-indicator-opacity)', '& > svg': { display: 'block', width: '100%', height: '100%', }, }) const itemLabelCss: CSSMixinDescriptor = css({ display: 'inline-flex', alignItems: 'center', minWidth: 0, paddingInline: theme.space.xs, WebkitUserSelect: 'none', }) export const listStyle = listCss export const optionStyle = itemCss export const glyphStyle = itemGlyphCss export const labelStyle = itemLabelCss function ListboxProvider(handle: Handle): () => RemixNode { let options: RegisteredOption[] = [] let state: State = 'idle' function getOption(value: ListboxValue) { return options.find((option) => option.value === value) } function isVisibleOption(option: RegisteredOption | undefined): option is RegisteredOption { return !!option?.node?.isConnected && !option.hidden } function isInteractableOption(option: RegisteredOption | undefined) { return isVisibleOption(option) && !option?.disabled } function getInteractableOptions() { return options.filter(isInteractableOption) } function scrollOptionIntoView(option: RegisteredOption | undefined) { if (!isVisibleOption(option)) { return } option.node.scrollIntoView({ block: 'nearest', inline: 'nearest', }) } function findSearchMatch(text: string, fromValue = handle.props.activeValue) { let interactableOptions = getInteractableOptions() let fromIndex = interactableOptions.findIndex((option) => option.value === fromValue) return matchNextItemBySearchText(text, interactableOptions, { fromIndex, getSearchValues: (option) => option.textValue ?? option.label, }) } let context: ListboxContext let ref: ListboxRef = { get active() { return getOption(handle.props.activeValue) }, get options() { return options }, get selected() { return getOption(handle.props.value) }, highlight(value) { context.highlight(value) }, highlightSearchMatch(text) { context.highlightSearchMatch(text) }, matchSearchText(text, fromValue = handle.props.activeValue) { return findSearchMatch(text, fromValue) }, navigateFirst() { context.navigate('first') }, navigateLast() { context.navigate('last') }, navigateNext() { context.navigate('next') }, navigatePrevious() { context.navigate('previous') }, scrollActiveOptionIntoView() { context.scrollActiveOptionIntoView() }, select(value) { return context.select(value) }, selectActive() { return context.select(handle.props.activeValue) }, } handle.queueTask(() => { handle.props.ref?.(ref) }) context = { get value() { return handle.props.value }, get activeValue() { return handle.props.activeValue }, registerOption(option) { options.push(option) }, async select(value) { if (state === 'selecting') { return } state = 'selecting' let option = getOption(value) if (!isInteractableOption(option)) { state = 'idle' return } handle.props.onSelect(value, option) if (option && handle.props.flashSelection) { await flashAttribute( option.node, handle.props.selectionFlashAttribute ?? 'data-listbox-flash', 60, ) } await handle.props.onSelectSettled?.(value, option) state = 'idle' }, highlight(value) { if (state === 'selecting') return let option = getOption(value) handle.props.onHighlight(value, option) }, highlightSearchMatch(text) { if (state === 'selecting') return let option = findSearchMatch(text, handle.props.activeValue) if (option) { handle.props.onHighlight(option.value, option) scrollOptionIntoView(option) } }, navigate(strategy: NavigationStrategy) { if (state === 'selecting') return let option: RegisteredOption | undefined let interactableOptions = getInteractableOptions() let activeIndex = interactableOptions.findIndex( (option) => option.value === handle.props.activeValue, ) switch (strategy) { case 'next': option = interactableOptions[activeIndex + 1] ?? interactableOptions[0] break case 'previous': option = activeIndex === -1 ? interactableOptions[interactableOptions.length - 1] : interactableOptions[activeIndex - 1] break case 'first': option = interactableOptions[0] break case 'last': option = interactableOptions[interactableOptions.length - 1] break } if (option) { handle.props.onHighlight(option.value, option) scrollOptionIntoView(option) } }, scrollActiveOptionIntoView() { scrollOptionIntoView(getOption(handle.props.activeValue)) }, } handle.context.set(context) return () => { options = [] return handle.props.children } } const listMixin: MixinFactory = createMixin( (handle) => (props) => { let context = handle.context.get(ListboxProvider) return [ attrs({ tabIndex: props.tabIndex ?? -1, role: props.role ?? 'listbox', }), on('focus', () => { context.scrollActiveOptionIntoView() }), on('keydown', (event) => { switch (event.key) { case 'ArrowDown': event.preventDefault() context.navigate('next') break case 'ArrowUp': event.preventDefault() context.navigate('previous') break case 'Tab': event.preventDefault() context.navigate('first') break case 'Enter': case ' ': event.preventDefault() void context.select(context.activeValue) break case 'Home': event.preventDefault() context.navigate('first') break case 'End': event.preventDefault() context.navigate('last') } }), hiddenTypeahead((text) => { context.highlightSearchMatch(text) }), ] }, ) export interface ListboxOption { id: string value: string label: string disabled?: boolean textValue?: SearchValue } const optionMixin: MixinFactory], ElementProps> = createMixin]>((handle) => { let optionRef: HTMLElement | undefined handle.queueTask((node) => { optionRef = node }) return (option) => { let context = handle.context.get(ListboxProvider) context.registerOption({ ...option, id: handle.id, get hidden() { return optionRef?.hidden === true }, get node() { return optionRef as HTMLElement }, }) return [ attrs({ role: 'option', id: handle.id, 'aria-selected': context.value === option.value ? 'true' : 'false', 'aria-disabled': option.disabled ? 'true' : 'false', 'data-highlighted': context.activeValue === option.value ? 'true' : 'false', }), !option.disabled && [ on('click', () => { context.select(option.value) }), on('mousemove', () => { if (context.activeValue === option.value) return context.highlight(option.value) }), on('mouseleave', () => { if (context.activeValue !== option.value) return context.highlight(null) }), ], ] } }) export const Context = ListboxProvider export const list = listMixin export const option = optionMixin