Skip to content

Commit

Permalink
add aria-orientation to the Listbox component
Browse files Browse the repository at this point in the history
By default the `Listbox` will have an orientation of `vertical`. When
you pass the `horizontal` prop to the `Listbox` component then the
`aria-orientation` will be set to `horizontal`.

Additionally, we swap the previous/next keys:

- Vertical: ArrowUp/ArrowDown
- Horizontal: ArrowLeft/ArrowRight
  • Loading branch information
RobinMalfait committed Jul 13, 2021
1 parent 10110a9 commit 1f89ceb
Show file tree
Hide file tree
Showing 4 changed files with 44 additions and 6 deletions.
25 changes: 22 additions & 3 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,14 @@ type ListboxOptionDataRef = MutableRefObject<{

interface StateDefinition {
listboxState: ListboxStates

orientation: 'horizontal' | 'vertical'

propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }>
labelRef: MutableRefObject<HTMLLabelElement | null>
buttonRef: MutableRefObject<HTMLButtonElement | null>
optionsRef: MutableRefObject<HTMLUListElement | null>

disabled: boolean
options: { id: string; dataRef: ListboxOptionDataRef }[]
searchQuery: string
Expand All @@ -61,6 +65,7 @@ enum ActionTypes {
CloseListbox,

SetDisabled,
SetOrientation,

GoToOption,
Search,
Expand All @@ -74,6 +79,7 @@ type Actions =
| { type: ActionTypes.CloseListbox }
| { type: ActionTypes.OpenListbox }
| { type: ActionTypes.SetDisabled; disabled: boolean }
| { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] }
| { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string }
| { type: ActionTypes.GoToOption; focus: Exclude<Focus, Focus.Specific> }
| { type: ActionTypes.Search; value: string }
Expand Down Expand Up @@ -101,6 +107,10 @@ let reducers: {
if (state.disabled === action.disabled) return state
return { ...state, disabled: action.disabled }
},
[ActionTypes.SetOrientation](state, action) {
if (state.orientation === action.orientation) return state
return { ...state, orientation: action.orientation }
},
[ActionTypes.GoToOption](state, action) {
if (state.disabled) return state
if (state.listboxState === ListboxStates.Closed) return state
Expand Down Expand Up @@ -193,16 +203,20 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
value: TType
onChange(value: TType): void
disabled?: boolean
horizontal?: boolean
}
) {
let { value, onChange, disabled = false, ...passThroughProps } = props
let { value, onChange, disabled = false, horizontal = false, ...passThroughProps } = props
const orientation = horizontal ? 'horizontal' : 'vertical'

let reducerBag = useReducer(stateReducer, {
listboxState: ListboxStates.Closed,
propsRef: { current: { value, onChange } },
labelRef: createRef(),
buttonRef: createRef(),
optionsRef: createRef(),
disabled,
orientation,
options: [],
searchQuery: '',
activeOptionIndex: null,
Expand All @@ -216,6 +230,9 @@ export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, T
propsRef.current.onChange = onChange
}, [onChange, propsRef])
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled])
useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetOrientation, orientation }), [
orientation,
])

// Handle outside click
useWindowEvent('mousedown', event => {
Expand Down Expand Up @@ -413,6 +430,7 @@ interface OptionsRenderPropArg {
type OptionsPropsWeControl =
| 'aria-activedescendant'
| 'aria-labelledby'
| 'aria-orientation'
| 'id'
| 'onKeyDown'
| 'role'
Expand Down Expand Up @@ -478,12 +496,12 @@ let Options = forwardRefWithAs(function Options<
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
break

case Keys.ArrowDown:
case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next })

case Keys.ArrowUp:
case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous })
Expand Down Expand Up @@ -535,6 +553,7 @@ let Options = forwardRefWithAs(function Options<
'aria-activedescendant':
state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
'aria-labelledby': labelledby,
'aria-orientation': state.orientation,
id,
onKeyDown: handleKeyDown,
role: 'listbox',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,12 @@ export function assertListbox(
attributes?: Record<string, string | null>
textContent?: string
state: ListboxState
orientation?: 'horizontal' | 'vertical'
},
listbox = getListbox()
) {
let { orientation = 'vertical' } = options

try {
switch (options.state) {
case ListboxState.InvisibleHidden:
Expand All @@ -274,6 +277,7 @@ export function assertListbox(
assertHidden(listbox)

expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')

if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
Expand All @@ -289,6 +293,7 @@ export function assertListbox(
assertVisible(listbox)

expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')

if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
Expand Down
15 changes: 12 additions & 3 deletions packages/@headlessui-vue/src/components/listbox/listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ type StateDefinition = {
// State
listboxState: Ref<ListboxStates>
value: ComputedRef<unknown>
orientation: Ref<'vertical' | 'horizontal'>

labelRef: Ref<HTMLLabelElement | null>
buttonRef: Ref<HTMLButtonElement | null>
optionsRef: Ref<HTMLDivElement | null>

disabled: Ref<boolean>
options: Ref<{ id: string; dataRef: ListboxOptionDataRef }[]>
searchQuery: Ref<string>
Expand Down Expand Up @@ -79,6 +82,7 @@ export let Listbox = defineComponent({
props: {
as: { type: [Object, String], default: 'template' },
disabled: { type: [Boolean], default: false },
horizontal: { type: [Boolean], default: false },
modelValue: { type: [Object, String, Number, Boolean] },
},
setup(props, { slots, attrs, emit }) {
Expand All @@ -95,6 +99,7 @@ export let Listbox = defineComponent({
let api = {
listboxState,
value,
orientation: computed(() => (props.horizontal ? 'horizontal' : 'vertical')),
labelRef,
buttonRef,
optionsRef,
Expand Down Expand Up @@ -206,7 +211,7 @@ export let Listbox = defineComponent({
return () => {
let slot = { open: listboxState.value === ListboxStates.Open, disabled: props.disabled }
return render({
props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled']),
props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled', 'horizontal']),
slot,
slots,
attrs,
Expand Down Expand Up @@ -362,6 +367,7 @@ export let ListboxOptions = defineComponent({
? undefined
: api.options.value[api.activeOptionIndex.value]?.id,
'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id,
'aria-orientation': api.orientation.value,
id: this.id,
onKeydown: this.handleKeyDown,
role: 'listbox',
Expand Down Expand Up @@ -410,12 +416,15 @@ export let ListboxOptions = defineComponent({
nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true }))
break

case Keys.ArrowDown:
case match(api.orientation.value, {
vertical: Keys.ArrowDown,
horizontal: Keys.ArrowRight,
}):
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.Next)

case Keys.ArrowUp:
case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }):
event.preventDefault()
event.stopPropagation()
return api.goToOption(Focus.Previous)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,9 +263,12 @@ export function assertListbox(
attributes?: Record<string, string | null>
textContent?: string
state: ListboxState
orientation?: 'horizontal' | 'vertical'
},
listbox = getListbox()
) {
let { orientation = 'vertical' } = options

try {
switch (options.state) {
case ListboxState.InvisibleHidden:
Expand All @@ -274,6 +277,7 @@ export function assertListbox(
assertHidden(listbox)

expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')

if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
Expand All @@ -289,6 +293,7 @@ export function assertListbox(
assertVisible(listbox)

expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('aria-orientation', orientation)
expect(listbox).toHaveAttribute('role', 'listbox')

if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
Expand Down

0 comments on commit 1f89ceb

Please sign in to comment.