Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add aria-orientation to the Listbox component #683

Merged
merged 2 commits into from
Jul 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
- Make `Disclosure.Button` close the disclosure inside a `Disclosure.Panel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683))

## [Unreleased - Vue]

### Added

- Add new `Tabs` component ([#674](https://github.com/tailwindlabs/headlessui/pull/674))
- Make `DisclosureButton` close the disclosure inside a `DisclosurePanel` ([#682](https://github.com/tailwindlabs/headlessui/pull/682))
- Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683))

## [@headlessui/[email protected]] - 2021-06-21

Expand Down
106 changes: 106 additions & 0 deletions packages/@headlessui-react/src/components/listbox/listbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1837,6 +1837,54 @@ describe('Keyboard interactions', () => {
)
})

describe('`ArrowRight` key', () => {
it(
'should be possible to use ArrowRight to navigate the listbox options',
suppressConsoleLogs(async () => {
render(
<Listbox value={undefined} onChange={console.log} horizontal>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
<Listbox.Option value="c">Option C</Listbox.Option>
</Listbox.Options>
</Listbox>
)

assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
})
assertListbox({ state: ListboxState.InvisibleUnmounted })

// Focus the button
getListboxButton()?.focus()

// Open listbox
await press(Keys.Enter)

// Verify we have listbox options
let options = getListboxOptions()
expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0])

// We should be able to go right once
await press(Keys.ArrowRight)
assertActiveListboxOption(options[1])

// We should be able to go right again
await press(Keys.ArrowRight)
assertActiveListboxOption(options[2])

// We should NOT be able to go right again (because last option). Current implementation won't go around.
await press(Keys.ArrowRight)
assertActiveListboxOption(options[2])
})
)
})

describe('`ArrowUp` key', () => {
it(
'should be possible to open the listbox with ArrowUp and the last option should be active',
Expand Down Expand Up @@ -2127,6 +2175,64 @@ describe('Keyboard interactions', () => {
)
})

describe('`ArrowLeft` key', () => {
it(
'should be possible to use ArrowLeft to navigate the listbox options',
suppressConsoleLogs(async () => {
render(
<Listbox value={undefined} onChange={console.log} horizontal>
<Listbox.Button>Trigger</Listbox.Button>
<Listbox.Options>
<Listbox.Option value="a">Option A</Listbox.Option>
<Listbox.Option value="b">Option B</Listbox.Option>
<Listbox.Option value="c">Option C</Listbox.Option>
</Listbox.Options>
</Listbox>
)

assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
})
assertListbox({ state: ListboxState.InvisibleUnmounted })

// Focus the button
getListboxButton()?.focus()

// Open listbox
await press(Keys.ArrowUp)

// Verify it is visible
assertListboxButton({ state: ListboxState.Visible })
assertListbox({
state: ListboxState.Visible,
attributes: { id: 'headlessui-listbox-options-2' },
orientation: 'horizontal',
})
assertActiveElement(getListbox())
assertListboxButtonLinkedWithListbox()

// Verify we have listbox options
let options = getListboxOptions()
expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[2])

// We should be able to go left once
await press(Keys.ArrowLeft)
assertActiveListboxOption(options[1])

// We should be able to go left again
await press(Keys.ArrowLeft)
assertActiveListboxOption(options[0])

// We should NOT be able to go left again (because first option). Current implementation won't go around.
await press(Keys.ArrowLeft)
assertActiveListboxOption(options[0])
})
)
})

describe('`End` key', () => {
it(
'should be possible to use the End key to go to the last listbox option',
Expand Down
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
112 changes: 112 additions & 0 deletions packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1933,6 +1933,57 @@ describe('Keyboard interactions', () => {
)
})

describe('`ArrowRight` key', () => {
it(
'should be possible to use ArrowRight to navigate the listbox options',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Listbox v-model="value" horizontal>
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="a">Option A</ListboxOption>
<ListboxOption value="b">Option B</ListboxOption>
<ListboxOption value="c">Option C</ListboxOption>
</ListboxOptions>
</Listbox>
`,
setup: () => ({ value: ref(null) }),
})

assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
})
assertListbox({ state: ListboxState.InvisibleUnmounted })

// Focus the button
getListboxButton()?.focus()

// Open listbox
await press(Keys.Enter)

// Verify we have listbox options
let options = getListboxOptions()
expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0])

// We should be able to go right once
await press(Keys.ArrowRight)
assertActiveListboxOption(options[1])

// We should be able to go right again
await press(Keys.ArrowRight)
assertActiveListboxOption(options[2])

// We should NOT be able to go right again (because last option). Current implementation won't go around.
await press(Keys.ArrowRight)
assertActiveListboxOption(options[2])
})
)
})

describe('`ArrowUp` key', () => {
it(
'should be possible to open the listbox with ArrowUp and the last option should be active',
Expand Down Expand Up @@ -2244,6 +2295,67 @@ describe('Keyboard interactions', () => {
)
})

describe('`ArrowLeft` key', () => {
it(
'should be possible to use ArrowLeft to navigate the listbox options',
suppressConsoleLogs(async () => {
renderTemplate({
template: html`
<Listbox v-model="value" horizontal>
<ListboxButton>Trigger</ListboxButton>
<ListboxOptions>
<ListboxOption value="a">Option A</ListboxOption>
<ListboxOption value="b">Option B</ListboxOption>
<ListboxOption value="c">Option C</ListboxOption>
</ListboxOptions>
</Listbox>
`,
setup: () => ({ value: ref(null) }),
})

assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
})
assertListbox({ state: ListboxState.InvisibleUnmounted })

// Focus the button
getListboxButton()?.focus()

// Open listbox
await press(Keys.ArrowUp)

// Verify it is visible
assertListboxButton({ state: ListboxState.Visible })
assertListbox({
state: ListboxState.Visible,
attributes: { id: 'headlessui-listbox-options-2' },
orientation: 'horizontal',
})
assertActiveElement(getListbox())
assertListboxButtonLinkedWithListbox()

// Verify we have listbox options
let options = getListboxOptions()
expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[2])

// We should be able to go left once
await press(Keys.ArrowLeft)
assertActiveListboxOption(options[1])

// We should be able to go left again
await press(Keys.ArrowLeft)
assertActiveListboxOption(options[0])

// We should NOT be able to go left again (because first option). Current implementation won't go around.
await press(Keys.ArrowLeft)
assertActiveListboxOption(options[0])
})
)
})

describe('`End` key', () => {
it(
'should be possible to use the End key to go to the last listbox option',
Expand Down
Loading