Select
diff --git a/packages/components/src/components/Select/__tests__/index.browser.test.tsx b/packages/components/src/components/Select/__tests__/index.browser.test.tsx
index 116572383..26c4a8135 100644
--- a/packages/components/src/components/Select/__tests__/index.browser.test.tsx
+++ b/packages/components/src/components/Select/__tests__/index.browser.test.tsx
@@ -24,7 +24,7 @@ const children = (
Option 4
-
+
@@ -49,7 +49,7 @@ const children = (
describe('Select', () => {
it('should render', () => {
- const { container } = render({children} )
+ const { container } = render({children} )
expect(container).toMatchSnapshot()
})
@@ -62,7 +62,7 @@ describe('Select', () => {
it('should require one element when SelectTrigger uses asChild', () => {
expect(() => {
render(
-
+
Text
,
)
@@ -72,7 +72,7 @@ describe('Select', () => {
it('should close select when clicking outside', () => {
const { container } = render(
- {children}
+ {children}
,
)
const selectToggle = container.querySelector('[aria-label="Select toggle"]')
@@ -88,7 +88,7 @@ describe('Select', () => {
it('should call onOpenChange function when it is provided', () => {
const onOpenChange = mock()
const { container } = render(
-
+
{children}
,
)
@@ -100,7 +100,7 @@ describe('Select', () => {
it('should call onValueChange function when it is provided', () => {
const onValueChange = mock()
const { container } = render(
-
+
{children}
,
)
@@ -113,7 +113,11 @@ describe('Select', () => {
})
it('should do nothing when onValueChange is not provided and type is default', () => {
- const { container } = render({children} )
+ const { container } = render(
+
+ {children}
+ ,
+ )
const selectToggle = container.querySelector('[aria-label="Select toggle"]')
fireEvent.click(selectToggle!)
const option2 = container.querySelector('[data-value="Option 2"]')
@@ -125,7 +129,11 @@ describe('Select', () => {
})
it('should select option when type is radio and the option should have a check', () => {
- const { container } = render({children} )
+ const { container } = render(
+
+ {children}
+ ,
+ )
const selectToggle = container.querySelector('[aria-label="Select toggle"]')
fireEvent.click(selectToggle!)
const option2 = container.querySelector('[data-value="Option 2"]')
@@ -137,7 +145,11 @@ describe('Select', () => {
})
it('should have multiple check marks when type is checkbox and multiple options are selected', () => {
- const { container } = render({children} )
+ const { container } = render(
+
+ {children}
+ ,
+ )
const selectToggle = container.querySelector('[aria-label="Select toggle"]')
fireEvent.click(selectToggle!)
const option2 = container.querySelector('[data-value="Option 2"]')
@@ -151,7 +163,11 @@ describe('Select', () => {
})
it('should not have a check mark when type is checkbox and the option is not selected', () => {
- const { container } = render({children} )
+ const { container } = render(
+
+ {children}
+ ,
+ )
const selectToggle = container.querySelector('[aria-label="Select toggle"]')
fireEvent.click(selectToggle!)
const option2 = container.querySelector('[data-value="Option 2"]')
@@ -164,7 +180,7 @@ describe('Select', () => {
it('should call onClick function when it is provided to SelectOption', () => {
const onClick = mock()
const { container } = render(
-
+
Select
@@ -186,7 +202,7 @@ describe('Select', () => {
it('should have a check mark when type is radio and defaultValue is provided', () => {
const { container } = render(
-
+
{children}
,
)
@@ -198,7 +214,11 @@ describe('Select', () => {
})
it('should not have a check mark when type is radio and defaultValue is not provided', () => {
- const { container } = render({children} )
+ const { container } = render(
+
+ {children}
+ ,
+ )
const selectToggle = container.querySelector('[aria-label="Select toggle"]')
fireEvent.click(selectToggle!)
const selectContainer = container.querySelector(
@@ -209,7 +229,11 @@ describe('Select', () => {
})
it('should have 10px gap in an option when type is checkbox', () => {
- const { container } = render({children} )
+ const { container } = render(
+
+ {children}
+ ,
+ )
const selectToggle = container.querySelector('[aria-label="Select toggle"]')
fireEvent.click(selectToggle!)
const option2 = container.querySelector('[data-value="Option 2"]')
@@ -217,7 +241,11 @@ describe('Select', () => {
})
it('should have 6px gap in an option when type is radio', () => {
- const { container } = render({children} )
+ const { container } = render(
+
+ {children}
+ ,
+ )
const selectToggle = container.querySelector('[aria-label="Select toggle"]')
fireEvent.click(selectToggle!)
const option2 = container.querySelector('[data-value="Option 2"]')
@@ -225,7 +253,11 @@ describe('Select', () => {
})
it('should have 0 gap in an option when type is default', () => {
- const { container } = render({children} )
+ const { container } = render(
+
+ {children}
+ ,
+ )
const selectToggle = container.querySelector('[aria-label="Select toggle"]')
fireEvent.click(selectToggle!)
const option2 = container.querySelector('[data-value="Option 2"]')
@@ -237,7 +269,7 @@ describe('Select', () => {
type: 'no-type',
children,
} as unknown as React.ComponentProps
- const { container } = render( )
+ const { container } = render( )
const selectToggle = container.querySelector('[aria-label="Select toggle"]')
fireEvent.click(selectToggle!)
const option2 = container.querySelector('[data-value="Option 2"]')
@@ -255,6 +287,7 @@ describe('Select', () => {
title: 'purple',
}}
data-testid="select"
+ id="select"
>
{children}
,
@@ -271,7 +304,7 @@ describe('Select', () => {
it('should have disabled check color when type is checkbox and the option is disabled', () => {
const { container } = render(
-
+
Select
@@ -290,7 +323,7 @@ describe('Select', () => {
it('should show confirm button when type is checkbox and showConfirmButton is true', () => {
const { container } = render(
-
+
Select
@@ -312,7 +345,7 @@ describe('Select', () => {
it('should close select when clicking confirm button', () => {
const { container } = render(
-
+
Select
Option 1
@@ -331,7 +364,7 @@ describe('Select', () => {
it('should not show confirm button when type is checkbox and showConfirmButton is false', () => {
const { container } = render(
-
+
Select
@@ -353,7 +386,7 @@ describe('Select', () => {
it('should render IconCheck when type is checkbox and the option is selected', () => {
const { container } = render(
-
+
{children}
,
)
@@ -365,7 +398,7 @@ describe('Select', () => {
it('should not check the option when type is checkbox and the option is not selected', () => {
const { container } = render(
-
+
Select
@@ -384,6 +417,7 @@ describe('Select', () => {
it('should render with options properties', () => {
const { container } = render(
{
const onValueChange = mock()
const { container } = render(
{
it('should render with x and y properties', () => {
const { container } = render(
-
+
Select
Option 1
@@ -433,7 +468,9 @@ describe('Select', () => {
})
it('should render with overflow screen', () => {
- const { container, rerender } = render({children} )
+ const { container, rerender } = render(
+ {children} ,
+ )
// open selectContainer
const selectToggle = container.querySelector('[aria-label="Select toggle"]')
@@ -450,7 +487,7 @@ describe('Select', () => {
Object.defineProperty(selectContainer, 'offsetWidth', { value: 1100 })
// rerender
- rerender({children} )
+ rerender({children} )
expect(container).toMatchSnapshot()
})
@@ -458,7 +495,7 @@ describe('Select', () => {
it('should change value when clicking on SelectOption without value prop', () => {
const onChange = mock()
const { container } = render(
-
+
Select
Option 1
@@ -476,7 +513,7 @@ describe('Select', () => {
it('should render with typography prop', () => {
const onChange = mock()
const { container } = render(
-
+
Select
Option 1
@@ -485,4 +522,103 @@ describe('Select', () => {
)
expect(container).toMatchSnapshot()
})
+ it('is a listbox operated by keyboard', () => {
+ const onChange = mock()
+ const { container, getByRole, getAllByRole } = render(
+
+ Select
+
+ a
+
+ b
+
+ c
+
+ ,
+ )
+ const trigger = container.querySelector(
+ '[aria-haspopup=listbox]',
+ )!
+ expect(trigger).toHaveAttribute('aria-controls', 'kbd-listbox')
+ fireEvent.keyDown(trigger, { key: 'Tab' })
+ expect(trigger).toHaveAttribute('aria-expanded', 'false')
+ fireEvent.keyDown(trigger, { key: 'ArrowDown' })
+ expect(getByRole('listbox')).toHaveAttribute('id', 'kbd-listbox')
+ fireEvent.keyDown(trigger, { key: 'ArrowDown' })
+ const [a, b, c] = getAllByRole('option')
+ expect(document.activeElement).toBe(a)
+ expect(b).toHaveAttribute('aria-disabled', 'true')
+ const listbox = getByRole('listbox')
+ fireEvent.keyDown(listbox, { key: 'ArrowDown' })
+ expect(document.activeElement).toBe(c)
+ fireEvent.keyDown(listbox, { key: 'ArrowDown' })
+ expect(document.activeElement).toBe(a)
+ fireEvent.keyDown(listbox, { key: 'ArrowUp' })
+ expect(document.activeElement).toBe(c)
+ fireEvent.keyDown(listbox, { key: 'Home' })
+ expect(document.activeElement).toBe(a)
+ fireEvent.keyDown(listbox, { key: 'End' })
+ expect(document.activeElement).toBe(c)
+ fireEvent.keyDown(listbox, { key: 'x' })
+ fireEvent.keyDown(c, { key: 'x' })
+ fireEvent.keyDown(b, { key: 'Enter' })
+ expect(onChange).not.toHaveBeenCalled()
+ fireEvent.keyDown(c, { key: 'Enter' })
+ expect(onChange).toHaveBeenCalledWith('c')
+ expect(document.activeElement).toBe(trigger)
+ expect(container.querySelector('[role=listbox]')).toBeNull()
+ fireEvent.keyDown(trigger, { key: 'ArrowUp' })
+ expect(document.activeElement).toBe(getAllByRole('option')[2])
+ expect(getAllByRole('option')[2]).toHaveAttribute('aria-selected', 'true')
+ fireEvent.keyDown(getByRole('listbox'), { key: 'Escape' })
+ expect(container.querySelector('[role=listbox]')).toBeNull()
+ expect(document.activeElement).toBe(trigger)
+ })
+
+ it('opens with ArrowDown on an asChild trigger and marks checkbox lists', () => {
+ const { container, getByRole } = render(
+
+
+ t
+
+
+ a
+
+ ,
+ )
+ fireEvent.keyDown(container.querySelector('button')!, { key: 'ArrowDown' })
+ expect(getByRole('listbox')).toHaveAttribute('aria-multiselectable', 'true')
+ fireEvent.keyDown(getByRole('option'), { key: ' ' })
+ expect(getByRole('option')).toHaveAttribute('aria-selected', 'true')
+ })
+
+ it('asks a controlled owner to close on an outside click', () => {
+ const onOpenChange = mock()
+ const { container } = render(
+
+ t
+
+ a
+
+ ,
+ )
+ fireEvent.click(document.body)
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ expect(container.querySelector('[role=listbox]')).not.toBeNull()
+ })
+
+ it('closes an uncontrolled select on an outside click', () => {
+ const { container } = render(
+
+ t
+
+ a
+
+ ,
+ )
+ fireEvent.click(container.querySelector('[role=option]')!.parentElement!)
+ expect(container.querySelector('[role=listbox]')).not.toBeNull()
+ fireEvent.click(document.body)
+ expect(container.querySelector('[role=listbox]')).toBeNull()
+ })
})
diff --git a/packages/components/src/components/Select/index.tsx b/packages/components/src/components/Select/index.tsx
index 4ce1b0220..3fa08b052 100644
--- a/packages/components/src/components/Select/index.tsx
+++ b/packages/components/src/components/Select/index.tsx
@@ -12,6 +12,7 @@ import {
ComponentProps,
isValidElement,
useEffect,
+ useId,
useRef,
useState,
} from 'react'
@@ -75,23 +76,29 @@ export function Select({
...props
}: SelectProps) {
const ref = useRef(null)
+ const generatedId = useId()
+ const listboxId = `${props.id ?? generatedId}-listbox`
const [open, setOpen] = useState(defaultOpen ?? false)
+ const isOpen = openProp ?? open
const [value, setValue] = useState>(
defaultValue ?? (type === 'checkbox' ? [] : ''),
)
+ // A controlled select only tells its owner, which decides whether it closes
useEffect(() => {
+ if (!isOpen) return
const handleOutsideClick = (e: MouseEvent) => {
if (ref.current && ref.current.contains(e.target as Node)) return
- setOpen(false)
+ onOpenChange?.(false)
+ if (openProp === undefined) setOpen(false)
}
document.addEventListener('click', handleOutsideClick)
return () => document.removeEventListener('click', handleOutsideClick)
- }, [open, setOpen])
+ }, [isOpen, openProp, onOpenChange])
- const handleOpenChange = (open: boolean) => {
- onOpenChange?.(open)
- setOpen(open)
+ const handleOpenChange = (next: boolean) => {
+ onOpenChange?.(next)
+ if (openProp === undefined) setOpen(next)
}
const handleValueChange = (nextValue: string) => {
@@ -112,8 +119,9 @@ export function Select({
return (
{
setOpen(!open)
}
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (open || (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')) return
+ e.preventDefault()
+ setOpen(true)
+ }
if (asChild) {
if (!isValidElement>(children)) {
@@ -188,8 +201,11 @@ export function SelectTrigger({
const Comp = children.type
const childProps = {
+ 'aria-controls': listboxId,
'aria-expanded': open,
+ 'aria-haspopup': 'listbox',
'aria-label': 'Select toggle',
+ onKeyDown: handleKeyDown,
onClick: children.props.onClick ?? handleClick,
...children.props,
}
@@ -198,7 +214,9 @@ export function SelectTrigger({
return (
{children}
@@ -229,7 +248,51 @@ export function SelectContainer({
y = 0,
...props
}: SelectContainerProps) {
- const { open, setOpen, type, ref } = useSelect()
+ const { open, setOpen, type, ref, listboxId } = useSelect()
+
+ // Opening moves focus to the selected option, or the first one
+ useEffect(() => {
+ if (!open) return
+ const listbox = document.getElementById(listboxId)
+ const option =
+ listbox?.querySelector(
+ '[role=option][aria-selected=true]:not([aria-disabled=true])',
+ ) ??
+ listbox?.querySelector(
+ '[role=option]:not([aria-disabled=true])',
+ )
+ option?.focus()
+ }, [open, listboxId])
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ const options = Array.from(
+ e.currentTarget.querySelectorAll(
+ '[role=option]:not([aria-disabled=true])',
+ ),
+ )
+ const index = options.findIndex(
+ (option) => option === document.activeElement,
+ )
+ const moves: Partial> = {
+ ArrowDown: index + 1,
+ ArrowUp: index - 1,
+ Home: 0,
+ End: options.length - 1,
+ }
+ const next = moves[e.key]
+ if (next !== undefined) {
+ e.preventDefault()
+ options[(next + options.length) % options.length]?.focus()
+ return
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ setOpen(false)
+ ref.current
+ ?.querySelector('[aria-haspopup=listbox]')
+ ?.focus()
+ }
+ }
if (!open) return null
return (
@@ -279,6 +342,7 @@ export function SelectContainer({
}
}}
aria-label="Select container"
+ aria-multiselectable={type === 'checkbox' || undefined}
bg="var(--inputBg, light-dark(#FFF,#2E2E2E))"
border="1px solid var(--border, light-dark(#E4E4E4,#434343))"
borderRadius="8px"
@@ -286,9 +350,12 @@ export function SelectContainer({
boxShadow="0 2px 2px 0 var(--base10, light-dark(#0000001A,#FFFFFF1A))"
boxSize="fit-content"
gap="6px"
+ id={listboxId}
minW="232px"
+ onKeyDown={handleKeyDown}
p="10px"
pos="fixed"
+ role="listbox"
styleOrder={1}
userSelect="none"
zIndex={1}
@@ -338,17 +405,15 @@ export function SelectOption({
showCheck = true,
...props
}: SelectOptionProps) {
- const { setOpen, setValue, value: selectedValue, type } = useSelect()
+ const { setOpen, setValue, value: selectedValue, type, ref } = useSelect()
const handleClose = () => {
if (type === 'checkbox') return
setOpen(false)
+ ref.current?.querySelector('[aria-haspopup=listbox]')?.focus()
}
- const handleClick = (
- value: string | undefined,
- e: React.MouseEvent,
- ) => {
+ const handleClick = (e?: React.MouseEvent) => {
if (onClick) {
onClick(value, e)
return
@@ -374,7 +439,9 @@ export function SelectOption({
}
}
alignItems="center"
+ aria-disabled={disabled}
aria-label="Select option"
+ aria-selected={!!isSelected}
borderRadius="6px"
color={
disabled
@@ -394,9 +461,16 @@ export function SelectOption({
}[type]
}
h="40px"
- onClick={disabled ? undefined : (e) => handleClick(value, e)}
+ onClick={disabled ? undefined : (e) => handleClick(e)}
+ onKeyDown={(e) => {
+ if (disabled || (e.key !== 'Enter' && e.key !== ' ')) return
+ e.preventDefault()
+ handleClick()
+ }}
px="10px"
+ role="option"
styleOrder={1}
+ tabIndex={disabled ? undefined : -1}
transition="background-color 0.1s ease-in-out"
{...props}
>
diff --git a/packages/components/src/contexts/useSelect.ts b/packages/components/src/contexts/useSelect.ts
index cb716bae2..775683bc6 100644
--- a/packages/components/src/contexts/useSelect.ts
+++ b/packages/components/src/contexts/useSelect.ts
@@ -10,6 +10,7 @@ export const SelectContext = createContext<{
value: SelectValue
setValue: (value: string) => void
type: SelectType
+ listboxId: string
ref: React.RefObject
} | null>(null)