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 {
Button,
ComboBox,
@@ -6,8 +8,16 @@ import {
ListBox,
Popover,
Label,
ComboBoxStateContext,
} from 'react-aria-components'
import { useState } from 'react'
import {
PropsWithChildren,
RefObject,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import { MaterialIcon } from '../Icons/MaterialIcon'
import { Typography } from '../Typography'
@@ -17,6 +27,60 @@ import type { SelectFilterProps } from './types'
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({
name,
label,
@@ -35,6 +99,8 @@ export function SelectFilter({
const [value, setValue] = useState<Key | null>(defaultSelectedKey ?? null)
const iconColor = isDisabled ? 'Icon/Interactive/Disabled' : 'Icon/Default'
const inputRef = useRef<HTMLInputElement>(null)
return (
<ComboBox
className={styles.select}
@@ -56,58 +122,64 @@ export function SelectFilter({
defaultSelectedKey={defaultSelectedKey}
{...props}
>
<Label className={styles.inner}>
{icon ? (
<MaterialIcon
icon={icon}
size={24}
color={iconColor}
aria-hidden="true"
/>
) : null}
<ComboBoxInner inputRef={inputRef}>
<Label className={styles.inner}>
{icon ? (
<MaterialIcon
icon={icon}
size={24}
color={iconColor}
aria-hidden="true"
/>
) : null}
<span className={styles.displayText}>
<Typography
variant={
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}
<span className={styles.displayText}>
<Typography
variant={
focus || value ? 'Label/xsRegular' : 'Body/Paragraph/mdRegular'
}
>
{item.label}
</SelectItem>
))}
</ListBox>
</Popover>
<span className={styles.label}>{label}</span>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<Input
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>
)
}