implement type ahead mode on Menu

This will allow us to do 2 things:

- When we are in "type ahead" mode, aka search, we can use spaces to
  search. E.g.: "Account Settings" (notice the space)
- When we are not in "type ahead" mode, we can use `Space` to invoke the
  menu item. We used to only allow `Enter` and `Click`.
This commit is contained in:
Robin Malfait
2020-09-29 15:34:17 +02:00
parent 57ad598202
commit 279b021a2b
4 changed files with 246 additions and 0 deletions
@@ -880,6 +880,86 @@ describe('Keyboard interactions', () => {
assertNoActiveMenuItem(getMenu())
})
)
it(
'should be possible to close the menu with Space when there is no active menuitem',
suppressConsoleLogs(async () => {
render(
<Menu>
<Menu.Button>Trigger</Menu.Button>
<Menu.Items>
<Menu.Item as="a">Item A</Menu.Item>
<Menu.Item as="a">Item B</Menu.Item>
<Menu.Item as="a">Item C</Menu.Item>
</Menu.Items>
</Menu>
)
assertMenuButton(getMenuButton(), {
state: MenuButtonState.Closed,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu(getMenu(), { state: MenuState.Closed })
// Open menu
await click(getMenuButton())
// Verify it is open
assertMenuButton(getMenuButton(), { state: MenuButtonState.Open })
// Close menu
await press(Keys.Space)
// Verify it is closed
assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed })
assertMenu(getMenu(), { state: MenuState.Closed })
})
)
it(
'should be possible to close the menu with Space and invoke the active menu item',
suppressConsoleLogs(async () => {
const clickHandler = jest.fn()
render(
<Menu>
<Menu.Button>Trigger</Menu.Button>
<Menu.Items>
<Menu.Item as="a" onClick={clickHandler}>
Item A
</Menu.Item>
<Menu.Item as="a">Item B</Menu.Item>
<Menu.Item as="a">Item C</Menu.Item>
</Menu.Items>
</Menu>
)
assertMenuButton(getMenuButton(), {
state: MenuButtonState.Closed,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu(getMenu(), { state: MenuState.Closed })
// Open menu
await click(getMenuButton())
// Verify it is open
assertMenuButton(getMenuButton(), { state: MenuButtonState.Open })
// Activate the first menu item
const items = getMenuItems()
await mouseMove(items[0])
// Close menu, and invoke the item
await press(Keys.Space)
// Verify it is closed
assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed })
assertMenu(getMenu(), { state: MenuState.Closed })
// Verify the "click" went through on the `a` tag
expect(clickHandler).toHaveBeenCalled()
})
)
})
describe('`Escape` key', () => {
@@ -2052,6 +2132,45 @@ describe('Keyboard interactions', () => {
})
)
it(
'should be possible to type words with spaces',
suppressConsoleLogs(async () => {
render(
<Menu>
<Menu.Button>Trigger</Menu.Button>
<Menu.Items>
<Menu.Item as="a">value a</Menu.Item>
<Menu.Item as="a">value b</Menu.Item>
<Menu.Item as="a">value c</Menu.Item>
</Menu.Items>
</Menu>
)
// Focus the button
getMenuButton()?.focus()
// Open menu
await press(Keys.ArrowUp)
const items = getMenuItems()
// We should be on the last item
assertMenuLinkedWithMenuItem(getMenu(), items[2])
// We should be able to go to the second item
await type(word('value b'))
assertMenuLinkedWithMenuItem(getMenu(), items[1])
// We should be able to go to the first item
await type(word('value a'))
assertMenuLinkedWithMenuItem(getMenu(), items[0])
// We should be able to go to the last item
await type(word('value c'))
assertMenuLinkedWithMenuItem(getMenu(), items[2])
})
)
it(
'should not be possible to search for a disabled item',
suppressConsoleLogs(async () => {
@@ -397,6 +397,10 @@ const Items = forwardRefWithAs(function Items<
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
case Key.Space:
if (state.searchQuery !== '')
return dispatch({ type: ActionTypes.Search, value: event.key })
// When in type ahead mode, fallthrough
case Key.Enter:
event.preventDefault()
dispatch({ type: ActionTypes.CloseMenu })
@@ -1031,6 +1031,87 @@ describe('Keyboard interactions', () => {
assertNoActiveMenuItem(getMenu())
})
it(
'should be possible to close the menu with Space when there is no active menuitem',
suppressConsoleLogs(async () => {
renderTemplate(`
<Menu>
<MenuButton>Trigger</MenuButton>
<MenuItems>
<MenuItem>Item A</MenuItem>
<MenuItem>Item B</MenuItem>
<MenuItem>Item C</MenuItem>
</MenuItems>
</Menu>
`)
assertMenuButton(getMenuButton(), {
state: MenuButtonState.Closed,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu(getMenu(), { state: MenuState.Closed })
// Open menu
await click(getMenuButton())
// Verify it is open
assertMenuButton(getMenuButton(), { state: MenuButtonState.Open })
// Close menu
await press(Keys.Space)
// Verify it is closed
assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed })
assertMenu(getMenu(), { state: MenuState.Closed })
})
)
it(
'should be possible to close the menu with Space and invoke the active menu item',
suppressConsoleLogs(async () => {
const clickHandler = jest.fn()
renderTemplate({
template: `
<Menu>
<MenuButton>Trigger</MenuButton>
<MenuItems>
<MenuItem as="a" @click="clickHandler">Item A</MenuItem>
<MenuItem as="a">Item B</MenuItem>
<MenuItem as="a">Item C</MenuItem>
</MenuItems>
</Menu>
`,
setup: () => ({ clickHandler }),
})
assertMenuButton(getMenuButton(), {
state: MenuButtonState.Closed,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu(getMenu(), { state: MenuState.Closed })
// Open menu
await click(getMenuButton())
// Verify it is open
assertMenuButton(getMenuButton(), { state: MenuButtonState.Open })
// Activate the first menu item
const items = getMenuItems()
await mouseMove(items[0])
// Close menu, and invoke the item
await press(Keys.Space)
// Verify it is closed
assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed })
assertMenu(getMenu(), { state: MenuState.Closed })
// Verify the "click" went through on the `a` tag
expect(clickHandler).toHaveBeenCalled()
})
)
})
describe('`Escape` key', () => {
@@ -2024,6 +2105,45 @@ describe('Keyboard interactions', () => {
assertMenuLinkedWithMenuItem(getMenu(), items[2])
})
it(
'should be possible to type words with spaces',
suppressConsoleLogs(async () => {
renderTemplate(`
<Menu>
<MenuButton>Trigger</MenuButton>
<MenuItems>
<MenuItem as="a">value a</MenuItem>
<MenuItem as="a">value b</MenuItem>
<MenuItem as="a">value c</MenuItem>
</MenuItems>
</Menu>
`)
// Focus the button
getMenuButton()?.focus()
// Open menu
await press(Keys.ArrowUp)
const items = getMenuItems()
// We should be on the last item
assertMenuLinkedWithMenuItem(getMenu(), items[2])
// We should be able to go to the second item
await type(word('value b'))
assertMenuLinkedWithMenuItem(getMenu(), items[1])
// We should be able to go to the first item
await type(word('value a'))
assertMenuLinkedWithMenuItem(getMenu(), items[0])
// We should be able to go to the last item
await type(word('value c'))
assertMenuLinkedWithMenuItem(getMenu(), items[2])
})
)
it('should not be possible to search for a disabled item', async () => {
renderTemplate(`
<Menu>
@@ -335,6 +335,9 @@ export const MenuItems = defineComponent({
switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12
case Key.Space:
if (api.searchQuery.value !== '') return api.search(event.key)
// When in type ahead mode, fallthrough
case Key.Enter:
event.preventDefault()
api.closeMenu()