fix: preselect combobox on focus

This commit is contained in:
Michael Zetterberg
2025-05-26 15:32:57 +02:00
parent 3d95a08ab4
commit 32cc0cbe88

View File

@@ -1,3 +1,5 @@
'use client'
import { import {
Button, Button,
ComboBox, ComboBox,
@@ -6,8 +8,16 @@ import {
ListBox, ListBox,
Popover, Popover,
Label, Label,
ComboBoxStateContext,
} from 'react-aria-components' } from 'react-aria-components'
import { useState } from 'react' import {
PropsWithChildren,
RefObject,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import { MaterialIcon } from '../Icons/MaterialIcon' import { MaterialIcon } from '../Icons/MaterialIcon'
import { Typography } from '../Typography' import { Typography } from '../Typography'
@@ -17,6 +27,60 @@ import type { SelectFilterProps } from './types'
import styles from './select.module.css' import styles from './select.module.css'
/**
* ComboBoxInner
* Used to simulate a press on down arrow key to highlight the first item.
* We do this so that a user can type some characters and then select the first
* option directly with tab. Might require revisit later when assistive
* technologies test it out as it might be a bit verbose for the announcements
* because it will most likely announce the "first item" for each typed
* character from the user.
*/
function ComboBoxInner({
inputRef,
children,
}: PropsWithChildren<{
inputRef: RefObject<HTMLInputElement>
}>) {
// Get the state for the ComboBox from RAC
const comboBoxState = useContext(ComboBoxStateContext)
const { isFocused, selectedKey, inputValue } = comboBoxState ?? {}
// Act after render
useEffect(() => {
let timeout: Timer
// We only want to act on focused field which has rerendered due to
// changes to its input value. Selecting a value will also rerender, but
// that we want to ignore as that is not a change to its input value.
if (isFocused && selectedKey !== inputValue) {
// The simlulated event has to originate from the input field.
if (inputRef.current) {
// Simulate a press on down arrow key.
const e = new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true /* important so RAC can act on it */,
})
// Dispatch after everything has rendered completely
timeout = setTimeout(() => {
inputRef.current?.dispatchEvent(e)
}, 0)
}
}
// Clean up
return () => {
if (timeout) {
clearTimeout(timeout)
}
}
}, [inputRef, inputValue, isFocused, selectedKey])
return children
}
export function SelectFilter({ export function SelectFilter({
name, name,
label, label,
@@ -35,6 +99,8 @@ export function SelectFilter({
const [value, setValue] = useState<Key | null>(defaultSelectedKey ?? null) const [value, setValue] = useState<Key | null>(defaultSelectedKey ?? null)
const iconColor = isDisabled ? 'Icon/Interactive/Disabled' : 'Icon/Default' const iconColor = isDisabled ? 'Icon/Interactive/Disabled' : 'Icon/Default'
const inputRef = useRef<HTMLInputElement>(null)
return ( return (
<ComboBox <ComboBox
className={styles.select} className={styles.select}
@@ -56,58 +122,64 @@ export function SelectFilter({
defaultSelectedKey={defaultSelectedKey} defaultSelectedKey={defaultSelectedKey}
{...props} {...props}
> >
<Label className={styles.inner}> <ComboBoxInner inputRef={inputRef}>
{icon ? ( <Label className={styles.inner}>
<MaterialIcon {icon ? (
icon={icon} <MaterialIcon
size={24} icon={icon}
color={iconColor} size={24}
aria-hidden="true" color={iconColor}
/> aria-hidden="true"
) : null} />
) : null}
<span className={styles.displayText}> <span className={styles.displayText}>
<Typography <Typography
variant={ variant={
focus || value ? 'Label/xsRegular' : 'Body/Paragraph/mdRegular' focus || value ? 'Label/xsRegular' : 'Body/Paragraph/mdRegular'
} }
>
<span className={styles.label}>{label}</span>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<Input className={styles.input} autoComplete={autoComplete} />
</Typography>
</span>
<Button className={styles.button}>
<MaterialIcon
icon="chevron_right"
size={24}
color={iconColor}
aria-hidden="true"
className={styles.chevron}
/>
</Button>
</Label>
<Popover
className={styles.popover}
shouldFlip={false}
crossOffset={icon ? -40 : -8}
offset={22}
>
<ListBox className={styles.listBox}>
{items.map((item) => (
<SelectItem
key={item.value}
id={item.value}
icon={item.icon || itemIcon}
isDisabled={item.isDisabled}
> >
{item.label} <span className={styles.label}>{label}</span>
</SelectItem> </Typography>
))} <Typography variant="Body/Paragraph/mdRegular">
</ListBox> <Input
</Popover> ref={inputRef}
className={styles.input}
autoComplete={autoComplete}
/>
</Typography>
</span>
<Button className={styles.button}>
<MaterialIcon
icon="chevron_right"
size={24}
color={iconColor}
aria-hidden="true"
className={styles.chevron}
/>
</Button>
</Label>
<Popover
className={styles.popover}
shouldFlip={false}
crossOffset={icon ? -40 : -8}
offset={22}
>
<ListBox className={styles.listBox}>
{items.map((item) => (
<SelectItem
key={item.value}
id={item.value}
icon={item.icon || itemIcon}
isDisabled={item.isDisabled}
>
{item.label}
</SelectItem>
))}
</ListBox>
</Popover>
</ComboBoxInner>
</ComboBox> </ComboBox>
) )
} }