Last active
April 6, 2026 15:25
-
-
Save ryanflorence/646f9ad1aa86344a170643800ce5767f to your computer and use it in GitHub Desktop.
select.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { on, ref, type Handle, type Props } from 'remix/component' | |
| import { Glyph } from '../glyph/glyph.tsx' | |
| import { popover } from '../popover/popover.ts' | |
| import { listbox, type ListboxValue, type ListboxOption } from '../listbox/listbox.ts' | |
| import { ui } from '../theme/theme.ts' | |
| import { onKeyDown } from '../keydown/keydown.ts' | |
| import { waitForCssTransition } from '../utils/wait-for-css-transition.ts' | |
| import { wait } from '../utils/wait.ts' | |
| interface SelectProps extends Props<'button'> { | |
| initialLabel: string | |
| defaultValue: string | |
| } | |
| enum State { | |
| Initializing = 'initializing', | |
| Closed = 'closed', | |
| Open = 'open', | |
| Selecting = 'selecting', | |
| } | |
| export function Select(handle: Handle) { | |
| let state: State = State.Initializing | |
| let value: ListboxValue = null | |
| let activeValue: ListboxValue = null | |
| let selectedId: string | null = null | |
| let defaultLabel = '' | |
| let label = '' | |
| let buttonRef: HTMLElement | |
| let surfaceRef: HTMLElement | |
| function openPopover() { | |
| if (state !== State.Closed) return | |
| state = State.Open | |
| activeValue = value | |
| handle.update() | |
| } | |
| function syncPopoverMinWidth() { | |
| if (state !== State.Open) return | |
| surfaceRef.style.minWidth = `${buttonRef.offsetWidth}px` | |
| } | |
| function closePopover() { | |
| if (state !== State.Open) return | |
| state = State.Closed | |
| handle.update() | |
| } | |
| async function selectOption(nextValue: ListboxValue, option: ListboxOption | undefined) { | |
| if (state !== State.Open) return | |
| state = State.Selecting | |
| value = nextValue | |
| activeValue = nextValue | |
| selectedId = option ? option.id : null | |
| // wait for the popover transition | |
| await Promise.all([handle.update(), waitForCssTransition(surfaceRef, handle.signal)]) | |
| // delay the label update | |
| await wait(75) | |
| if (handle.signal.aborted) return | |
| state = State.Closed | |
| label = option ? option.label : defaultLabel | |
| handle.update() | |
| } | |
| function highlightOption(nextActiveValue: ListboxValue) { | |
| if (state !== State.Open) return | |
| activeValue = nextActiveValue | |
| handle.update() | |
| } | |
| return (props: SelectProps) => { | |
| let { initialLabel, defaultValue, mix, ...buttonProps } = props | |
| defaultLabel = initialLabel | |
| if (state === State.Initializing) { | |
| label = defaultLabel | |
| value = defaultValue ?? null | |
| activeValue = value | |
| state = State.Closed | |
| } | |
| return ( | |
| <popover.context> | |
| <button | |
| {...buttonProps} | |
| mix={[ | |
| ui.button.select, | |
| ref((node) => (buttonRef = node)), | |
| popover.focusOnHide(), | |
| popover.anchor({ | |
| placement: 'left', | |
| inset: true, | |
| relativeTo: selectedId ? `#${selectedId}` : '[role="option"]', | |
| }), | |
| on('pointerdown', openPopover), | |
| on('click', openPopover), // AT activation | |
| onKeyDown('ArrowDown', openPopover), | |
| onKeyDown('ArrowUp', openPopover), | |
| mix, | |
| ]} | |
| > | |
| <span mix={ui.button.label}>{label}</span> | |
| <Glyph mix={ui.button.icon} name="chevronDown" /> | |
| </button> | |
| <div | |
| mix={[ | |
| ui.popover.surface, | |
| ref((node) => (surfaceRef = node)), | |
| popover.surface({ | |
| open: state === State.Open, | |
| hasHideTransition: true, | |
| onHide: closePopover, | |
| }), | |
| on('beforetoggle', syncPopoverMinWidth), | |
| ]} | |
| > | |
| <listbox.context | |
| flashSelection={true} | |
| value={value} | |
| activeValue={activeValue} | |
| onSelect={selectOption} | |
| onHighlight={highlightOption} | |
| > | |
| <div mix={[ui.listbox.surface, popover.focusOnShow(), listbox.list()]}> | |
| {props.children} | |
| </div> | |
| </listbox.context> | |
| </div> | |
| </popover.context> | |
| ) | |
| } | |
| } | |
| type OptionProps = Props<'div'> & Omit<ListboxOption, 'id'> | |
| export function Option() { | |
| return (props: OptionProps) => { | |
| let { label, value, disabled, textValue, children, mix, ...divProps } = props | |
| return ( | |
| <div | |
| {...divProps} | |
| mix={[ui.listbox.option, listbox.option({ value, label, disabled, textValue }), mix]} | |
| > | |
| <Glyph mix={ui.listbox.glyph} name="check" /> | |
| <span mix={ui.listbox.label}>{children ?? props.label}</span> | |
| </div> | |
| ) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment