fix: outside click refocus bug (#114)

* add watch script

* make interactions in Vue and React consistent

* re-work focus restoration

When we click outside of the Menu or Listbox, we want to
restore the focus to the Button, *unless* we clicked on/in an element
that is focusable in itself. For example, when the Menu is open and you
click in an input field, the input field should stay focused. We should
also close the Menu itself at this point.

* add examples with multiple elements

* bump dependencies
This commit is contained in:
Robin Malfait
2020-10-20 15:38:12 +02:00
committed by GitHub
parent 47b3ad1387
commit 24725216e4
22 changed files with 696 additions and 222 deletions
+2 -2
View File
@@ -34,11 +34,11 @@
"devDependencies": {
"@tailwindcss/ui": "^0.6.2",
"@testing-library/jest-dom": "^5.11.4",
"@types/node": "^14.11.10",
"@types/node": "^14.14.0",
"husky": "^4.3.0",
"lint-staged": "^10.4.2",
"prismjs": "^1.22.0",
"tailwindcss": "^1.9.4",
"tailwindcss": "^1.9.5",
"tsdx": "^0.14.1",
"tslib": "^2.0.3",
"typescript": "^3.9.7"
+1 -1
View File
@@ -35,7 +35,7 @@
"@types/react-dom": "^16.9.8",
"@popperjs/core": "^2.5.3",
"@testing-library/react": "^11.1.0",
"framer-motion": "^2.9.1",
"framer-motion": "^2.9.3",
"next": "9.5.5",
"react": "^16.14.0",
"react-dom": "^16.14.0",
@@ -0,0 +1,137 @@
import * as React from 'react'
import { Listbox } from '@headlessui/react'
function classNames(...classes) {
return classes.filter(Boolean).join(' ')
}
const people = [
'Wade Cooper',
'Arlene Mccoy',
'Devon Webb',
'Tom Cook',
'Tanya Fox',
'Hellen Schmidt',
'Caroline Schultz',
'Mason Heaney',
'Claudie Smitham',
'Emil Schaefer',
]
export default function Home() {
return (
<div className="flex justify-center w-screen h-full p-12 space-x-4 bg-gray-50">
<PeopleList />
<div>
<label htmlFor="email" className="block text-sm font-medium leading-5 text-gray-700">
Email
</label>
<div className="relative mt-1 rounded-md shadow-sm">
<input
className="block w-full form-input sm:text-sm sm:leading-5"
placeholder="you@example.com"
/>
</div>
</div>
<PeopleList />
</div>
)
}
function PeopleList() {
const [active, setActivePerson] = React.useState(people[2])
// Choose a random person on mount
React.useEffect(() => {
setActivePerson(people[Math.floor(Math.random() * people.length)])
}, [])
return (
<div className="w-64">
<div className="space-y-1">
<Listbox
value={active}
onChange={value => {
console.log('value:', value)
setActivePerson(value)
}}
>
<Listbox.Label className="block text-sm font-medium leading-5 text-gray-700">
Assigned to
</Listbox.Label>
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
<span className="block truncate">{active}</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg
className="w-5 h-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Listbox.Button>
</span>
<div className="absolute w-full mt-1 bg-white rounded-md shadow-lg">
<Listbox.Options className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5">
{people.map(name => (
<Listbox.Option
key={name}
value={name}
className={({ active }) => {
return classNames(
'relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none',
active ? 'text-white bg-indigo-600' : 'text-gray-900'
)
}}
>
{({ active, selected }) => (
<>
<span
className={classNames(
'block truncate',
selected ? 'font-semibold' : 'font-normal'
)}
>
{name}
</span>
{selected && (
<span
className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}
>
<svg className="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</div>
</div>
</Listbox>
</div>
</div>
)
}
@@ -0,0 +1,86 @@
import * as React from 'react'
import { Menu } from '@headlessui/react'
function classNames(...classes) {
return classes.filter(Boolean).join(' ')
}
export default function Home() {
return (
<div className="flex justify-center w-screen h-full p-12 space-x-4 bg-gray-50">
<Dropdown />
<div>
<div className="relative rounded-md shadow-sm">
<input
className="block w-full form-input sm:text-sm sm:leading-5"
placeholder="you@example.com"
/>
</div>
</div>
<Dropdown />
</div>
)
}
function Dropdown() {
function resolveClass({ active, disabled }) {
return classNames(
'block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700',
active && 'bg-gray-100 text-gray-900',
disabled && 'cursor-not-allowed opacity-50'
)
}
return (
<div className="relative inline-block text-left">
<Menu>
<span className="inline-flex rounded-md shadow-sm">
<Menu.Button className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800">
<span>Options</span>
<svg className="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</Menu.Button>
</span>
<Menu.Items className="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none">
<div className="px-4 py-3">
<p className="text-sm leading-5">Signed in as</p>
<p className="text-sm font-medium leading-5 text-gray-900 truncate">tom@example.com</p>
</div>
<div className="py-1">
<Menu.Item as="a" href="#account-settings" className={resolveClass}>
Account settings
</Menu.Item>
<Menu.Item>
{data => (
<a href="#support" className={resolveClass(data)}>
Support
</a>
)}
</Menu.Item>
<Menu.Item as="a" disabled href="#new-feature" className={resolveClass}>
New feature (soon)
</Menu.Item>
<Menu.Item as="a" href="#license" className={resolveClass}>
License
</Menu.Item>
</div>
<div className="py-1">
<Menu.Item as="a" href="#sign-out" className={resolveClass}>
Sign out
</Menu.Item>
</div>
</Menu.Items>
</Menu>
</div>
)
}
@@ -23,11 +23,11 @@ export async function resolveAllExamples(...paths: string[]) {
}
const bucket: ExamplesType = {
name: file.name.replace(/-/g, ' ').replace(/.tsx?/g, ''),
name: file.name.replace(/-/g, ' ').replace(/\.tsx?/g, ''),
path: [...paths, file.name]
.join('/')
.replace(/^pages/, '')
.replace(/.tsx?/g, '')
.replace(/\.tsx?/g, '')
.replace(/\/+/g, '/'),
}
@@ -210,7 +210,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: false, focused: false }),
textContent: JSON.stringify({ open: false }),
})
assertListbox({ state: ListboxState.InvisibleUnmounted })
@@ -219,7 +219,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.Visible,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: true, focused: false }),
textContent: JSON.stringify({ open: true }),
})
assertListbox({ state: ListboxState.Visible })
})
@@ -244,7 +244,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: false, focused: false }),
textContent: JSON.stringify({ open: false }),
})
assertListbox({ state: ListboxState.InvisibleUnmounted })
@@ -253,7 +253,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.Visible,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: true, focused: false }),
textContent: JSON.stringify({ open: true }),
})
assertListbox({ state: ListboxState.Visible })
})
@@ -2896,31 +2896,6 @@ describe('Mouse interactions', () => {
})
)
it('should focus the listbox when you try to focus the button again (when the listbox is already open)', async () => {
render(
<Listbox value={undefined} onChange={console.log}>
<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>
)
// Open listbox
await click(getListboxButton())
// Verify listbox is focused
assertActiveElement(getListbox())
// Try to Re-focus the button
getListboxButton()?.focus()
// Verify listbox is still focused
assertActiveElement(getListbox())
})
it(
'should be a no-op when we click outside of a closed listbox',
suppressConsoleLogs(async () => {
@@ -169,11 +169,14 @@ export function Listbox<
React.useEffect(() => {
function handler(event: MouseEvent) {
const target = event.target as HTMLElement
const active = document.activeElement
if (listboxState !== ListboxStates.Open) return
if (buttonRef.current?.contains(target)) return
if (!optionsRef.current?.contains(target)) dispatch({ type: ActionTypes.CloseListbox })
if (!event.defaultPrevented) d.nextFrame(() => buttonRef.current?.focus())
if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
if (!event.defaultPrevented) buttonRef.current?.focus()
}
window.addEventListener('click', handler)
@@ -195,7 +198,7 @@ export function Listbox<
// ---
const DEFAULT_BUTTON_TAG = 'button'
type ButtonRenderPropArg = { open: boolean; focused: boolean }
type ButtonRenderPropArg = { open: boolean }
type ButtonPropsWeControl =
| 'ref'
| 'id'
@@ -205,8 +208,6 @@ type ButtonPropsWeControl =
| 'aria-expanded'
| 'aria-labelledby'
| 'onKeyDown'
| 'onFocus'
| 'onBlur'
| 'onPointerUp'
const Button = forwardRefWithAs(function Button<
@@ -217,7 +218,6 @@ const Button = forwardRefWithAs(function Button<
) {
const [state, dispatch] = useListboxContext([Listbox.name, Button.name].join('.'))
const buttonRef = useSyncRefs(state.buttonRef, ref)
const [focused, setFocused] = React.useState(false)
const id = `headlessui-listbox-button-${useId()}`
const d = useDisposables()
@@ -268,20 +268,14 @@ const Button = forwardRefWithAs(function Button<
[dispatch, d, state, props.disabled]
)
const handleFocus = React.useCallback(() => {
if (state.listboxState === ListboxStates.Open) return state.optionsRef.current?.focus()
setFocused(true)
}, [state, setFocused])
const handleBlur = React.useCallback(() => setFocused(false), [setFocused])
const labelledby = useComputed(() => {
if (!state.labelRef.current) return undefined
return [state.labelRef.current.id, id].join(' ')
}, [state.labelRef.current, id])
const propsBag = React.useMemo<ButtonRenderPropArg>(
() => ({ open: state.listboxState === ListboxStates.Open, focused }),
[state, focused]
() => ({ open: state.listboxState === ListboxStates.Open }),
[state]
)
const passthroughProps = props
const propsWeControl = {
@@ -293,8 +287,6 @@ const Button = forwardRefWithAs(function Button<
'aria-expanded': state.listboxState === ListboxStates.Open ? true : undefined,
'aria-labelledby': labelledby,
onKeyDown: handleKeyDown,
onFocus: handleFocus,
onBlur: handleBlur,
onPointerUp: handlePointerUp,
}
@@ -133,7 +133,7 @@ describe('Rendering', () => {
assertMenuButton({
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
textContent: JSON.stringify({ open: false, focused: false }),
textContent: JSON.stringify({ open: false }),
})
assertMenu({ state: MenuState.InvisibleUnmounted })
@@ -142,7 +142,7 @@ describe('Rendering', () => {
assertMenuButton({
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-button-1' },
textContent: JSON.stringify({ open: true, focused: false }),
textContent: JSON.stringify({ open: true }),
})
assertMenu({ state: MenuState.Visible })
})
@@ -167,7 +167,7 @@ describe('Rendering', () => {
assertMenuButton({
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
textContent: JSON.stringify({ open: false, focused: false }),
textContent: JSON.stringify({ open: false }),
})
assertMenu({ state: MenuState.InvisibleUnmounted })
@@ -176,7 +176,7 @@ describe('Rendering', () => {
assertMenuButton({
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-button-1' },
textContent: JSON.stringify({ open: true, focused: false }),
textContent: JSON.stringify({ open: true }),
})
assertMenu({ state: MenuState.Visible })
})
@@ -2464,31 +2464,6 @@ describe('Mouse interactions', () => {
})
)
it('should focus the menu when you try to focus the button again (when the menu is already open)', 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>
)
// Open menu
await click(getMenuButton())
// Verify menu is focused
assertActiveElement(getMenu())
// Try to Re-focus the button
getMenuButton()?.focus()
// Verify menu is still focused
assertActiveElement(getMenu())
})
it(
'should be a no-op when we click outside of a closed menu',
suppressConsoleLogs(async () => {
@@ -151,11 +151,14 @@ export function Menu<TTag extends React.ElementType = typeof DEFAULT_MENU_TAG>(
React.useEffect(() => {
function handler(event: MouseEvent) {
const target = event.target as HTMLElement
const active = document.activeElement
if (menuState !== MenuStates.Open) return
if (buttonRef.current?.contains(target)) return
if (!itemsRef.current?.contains(target)) dispatch({ type: ActionTypes.CloseMenu })
if (!event.defaultPrevented) d.nextFrame(() => buttonRef.current?.focus())
if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
if (!event.defaultPrevented) buttonRef.current?.focus()
}
window.addEventListener('click', handler)
@@ -174,7 +177,7 @@ export function Menu<TTag extends React.ElementType = typeof DEFAULT_MENU_TAG>(
// ---
const DEFAULT_BUTTON_TAG = 'button'
type ButtonRenderPropArg = { open: boolean; focused: boolean }
type ButtonRenderPropArg = { open: boolean }
type ButtonPropsWeControl =
| 'ref'
| 'id'
@@ -183,8 +186,6 @@ type ButtonPropsWeControl =
| 'aria-controls'
| 'aria-expanded'
| 'onKeyDown'
| 'onFocus'
| 'onBlur'
| 'onPointerUp'
const Button = forwardRefWithAs(function Button<
@@ -195,7 +196,6 @@ const Button = forwardRefWithAs(function Button<
) {
const [state, dispatch] = useMenuContext([Menu.name, Button.name].join('.'))
const buttonRef = useSyncRefs(state.buttonRef, ref)
const [focused, setFocused] = React.useState(false)
const id = `headlessui-menu-button-${useId()}`
const d = useDisposables()
@@ -244,17 +244,7 @@ const Button = forwardRefWithAs(function Button<
[dispatch, d, state, props.disabled]
)
const handleFocus = React.useCallback(() => {
if (state.menuState === MenuStates.Open) state.itemsRef.current?.focus()
setFocused(true)
}, [state, setFocused])
const handleBlur = React.useCallback(() => setFocused(false), [setFocused])
const propsBag = React.useMemo(() => ({ open: state.menuState === MenuStates.Open, focused }), [
state,
focused,
])
const propsBag = React.useMemo(() => ({ open: state.menuState === MenuStates.Open }), [state])
const passthroughProps = props
const propsWeControl = {
ref: buttonRef,
@@ -264,8 +254,6 @@ const Button = forwardRefWithAs(function Button<
'aria-controls': state.itemsRef.current?.id,
'aria-expanded': state.menuState === MenuStates.Open ? true : undefined,
onKeyDown: handleKeyDown,
onFocus: handleFocus,
onBlur: handleBlur,
onPointerUp: handlePointerUp,
}
@@ -431,7 +419,7 @@ function Item<TTag extends React.ElementType = typeof DEFAULT_ITEM_TAG>(
(event: { preventDefault: Function }) => {
if (disabled) return event.preventDefault()
dispatch({ type: ActionTypes.CloseMenu })
d.nextFrame(() => state.buttonRef.current?.focus())
disposables().nextFrame(() => state.buttonRef.current?.focus())
if (onClick) return onClick(event)
},
[d, dispatch, state.buttonRef, disabled, onClick]
@@ -1,7 +1,12 @@
import { fireEvent } from '@testing-library/react'
import { disposables } from '../utils/disposables'
const d = disposables()
function nextFrame(cb: Function): void {
setImmediate(() =>
setImmediate(() => {
cb()
})
)
}
export const Keys: Record<string, Partial<KeyboardEvent>> = {
Space: { key: ' ', keyCode: 32 },
@@ -57,7 +62,7 @@ export async function type(events: Partial<KeyboardEvent>[]) {
// We don't want to actually wait in our tests, so let's advance
jest.runAllTimers()
await new Promise(d.nextFrame)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, type)
throw err
@@ -80,7 +85,7 @@ export async function click(element: Document | Element | Window | Node | null)
fireEvent.mouseUp(element)
fireEvent.click(element)
await new Promise(d.nextFrame)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, click)
throw err
@@ -93,7 +98,7 @@ export async function focus(element: Document | Element | Window | Node | null)
fireEvent.focus(element)
await new Promise(d.nextFrame)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, focus)
throw err
@@ -107,7 +112,7 @@ export async function mouseEnter(element: Document | Element | Window | null) {
fireEvent.pointerEnter(element)
fireEvent.mouseOver(element)
await new Promise(d.nextFrame)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseEnter)
throw err
@@ -121,7 +126,7 @@ export async function mouseMove(element: Document | Element | Window | null) {
fireEvent.pointerMove(element)
fireEvent.mouseMove(element)
await new Promise(d.nextFrame)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseMove)
throw err
@@ -137,7 +142,7 @@ export async function mouseLeave(element: Document | Element | Window | null) {
fireEvent.mouseOut(element)
fireEvent.mouseLeave(element)
await new Promise(d.nextFrame)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseLeave)
throw err
@@ -67,7 +67,7 @@
<!-- TODO: Position this in the correct spot -->
<div
v-if="sourceCode"
class="container fixed bottom-0 left-0 right-0 my-12 overflow-scroll rounded-md max-h-96"
class="container fixed bottom-0 left-0 right-0 hidden my-12 overflow-scroll rounded-md max-h-96"
v-html="sourceCode"
/>
</main>
@@ -0,0 +1,210 @@
<template>
<div class="flex justify-center w-screen h-full p-12 space-x-4 bg-gray-50">
<div class="w-64">
<div class="space-y-1">
<Listbox v-model="active">
<ListboxLabel class="block text-sm font-medium leading-5 text-gray-700"
>Assigned to</ListboxLabel
>
<div class="relative">
<span class="inline-block w-full rounded-md shadow-sm">
<ListboxButton
class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5"
>
<span class="block truncate">{{ active.name }}</span>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg
class="w-5 h-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</ListboxButton>
</span>
<div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
<ListboxOptions
class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
>
<ListboxOption
v-for="person in people"
:key="person.id"
:value="person"
:className="resolveListboxOptionClassName"
v-slot="{ active, selected }"
>
<span
:class="
classNames('block truncate', selected ? 'font-semibold' : 'font-normal')
"
>
{{ person.name }}
</span>
<span
v-if="selected"
:class="
classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)
"
>
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
</ListboxOption>
</ListboxOptions>
</div>
</div>
</Listbox>
</div>
</div>
<div>
<label for="email" class="block text-sm font-medium leading-5 text-gray-700">
Email
</label>
<div class="relative mt-1 rounded-md shadow-sm">
<input
class="block w-full form-input sm:text-sm sm:leading-5"
placeholder="you@example.com"
/>
</div>
</div>
<div class="w-64">
<div class="space-y-1">
<Listbox v-model="active">
<ListboxLabel class="block text-sm font-medium leading-5 text-gray-700"
>Assigned to</ListboxLabel
>
<div class="relative">
<span class="inline-block w-full rounded-md shadow-sm">
<ListboxButton
class="relative w-full py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5"
>
<span class="block truncate">{{ active.name }}</span>
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg
class="w-5 h-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</ListboxButton>
</span>
<div class="absolute w-full mt-1 bg-white rounded-md shadow-lg">
<ListboxOptions
class="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
>
<ListboxOption
v-for="person in people"
:key="person.id"
:value="person"
:className="resolveListboxOptionClassName"
v-slot="{ active, selected }"
>
<span
:class="
classNames('block truncate', selected ? 'font-semibold' : 'font-normal')
"
>
{{ person.name }}
</span>
<span
v-if="selected"
:class="
classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)
"
>
<svg class="w-5 h-5" viewbox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</span>
</ListboxOption>
</ListboxOptions>
</div>
</div>
</Listbox>
</div>
</div>
</div>
</template>
<script>
import { defineComponent, h, ref, onMounted, watchEffect, watch } from 'vue'
import {
Listbox,
ListboxLabel,
ListboxButton,
ListboxOptions,
ListboxOption,
} from '@headlessui/vue'
function classNames(...classes) {
return classes.filter(Boolean).join(' ')
}
export default {
components: { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption },
setup(props, context) {
const people = [
{ id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' },
{ id: 4, name: 'Tom Cook' },
{ id: 5, name: 'Tanya Fox' },
{ id: 6, name: 'Hellen Schmidt' },
{ id: 7, name: 'Caroline Schultz' },
{ id: 8, name: 'Mason Heaney' },
{ id: 9, name: 'Claudie Smitham' },
{ id: 10, name: 'Emil Schaefer' },
]
const active = ref(people[Math.floor(Math.random() * people.length)])
return {
people,
active,
classNames,
resolveListboxOptionClassName({ active }) {
return classNames(
'relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none',
active ? 'text-white bg-indigo-600' : 'text-gray-900'
)
},
}
},
}
</script>
@@ -0,0 +1,125 @@
<template>
<div class="flex justify-center w-screen h-full p-12 space-x-4 bg-gray-50">
<div class="relative inline-block text-left">
<Menu>
<span class="rounded-md shadow-sm">
<MenuButton
class="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
>
<span>Options</span>
<svg class="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</MenuButton>
</span>
<MenuItems
class="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"
>
<div class="px-4 py-3">
<p class="text-sm leading-5">Signed in as</p>
<p class="text-sm font-medium leading-5 text-gray-900 truncate">tom@example.com</p>
</div>
<div class="py-1">
<MenuItem as="a" :className="resolveClass" href="#account-settings"
>Account settings</MenuItem
>
<MenuItem as="a" :className="resolveClass" href="#support">Support</MenuItem>
<MenuItem as="a" :className="resolveClass" disabled href="#new-feature"
>New feature (soon)</MenuItem
>
<MenuItem as="a" :className="resolveClass" href="#license">License</MenuItem>
</div>
<div class="py-1">
<MenuItem as="a" :className="resolveClass" href="#sign-out">Sign out</MenuItem>
</div>
</MenuItems>
</Menu>
</div>
<div>
<div class="relative rounded-md shadow-sm">
<input
class="block w-full form-input sm:text-sm sm:leading-5"
placeholder="you@example.com"
/>
</div>
</div>
<div class="relative inline-block text-left">
<Menu>
<span class="rounded-md shadow-sm">
<MenuButton
class="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800"
>
<span>Options</span>
<svg class="w-5 h-5 ml-2 -mr-1" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</MenuButton>
</span>
<MenuItems
class="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none"
>
<div class="px-4 py-3">
<p class="text-sm leading-5">Signed in as</p>
<p class="text-sm font-medium leading-5 text-gray-900 truncate">tom@example.com</p>
</div>
<div class="py-1">
<MenuItem as="a" :className="resolveClass" href="#account-settings"
>Account settings</MenuItem
>
<MenuItem as="a" :className="resolveClass" href="#support">Support</MenuItem>
<MenuItem as="a" :className="resolveClass" disabled href="#new-feature"
>New feature (soon)</MenuItem
>
<MenuItem as="a" :className="resolveClass" href="#license">License</MenuItem>
</div>
<div class="py-1">
<MenuItem as="a" :className="resolveClass" href="#sign-out">Sign out</MenuItem>
</div>
</MenuItems>
</Menu>
</div>
</div>
</template>
<script>
import { defineComponent, h } from 'vue'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
function classNames(...classes) {
return classes.filter(Boolean).join(' ')
}
export default {
components: {
Menu,
MenuButton,
MenuItems,
MenuItem,
},
setup() {
return {
resolveClass({ active, disabled }) {
return classNames(
'block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700',
active && 'bg-gray-100 text-gray-900',
disabled && 'cursor-not-allowed opacity-50'
)
},
}
},
}
</script>
@@ -26,6 +26,11 @@
"name": "Menu with Popper + Transition",
"path": "/menu/menu-with-transition-and-popper",
"component": "./components/menu/menu-with-transition-and-popper.vue"
},
{
"name": "Menu multiple elements",
"path": "/menu/multiple-elements",
"component": "./components/menu/multiple-elements.vue"
}
]
},
@@ -37,6 +42,11 @@
"name": "Listbox (basic)",
"path": "/listbox/listbox",
"component": "./components/listbox/listbox.vue"
},
{
"name": "Listbox multiple elements",
"path": "/listbox/multiple-elements",
"component": "./components/listbox/multiple-elements.vue"
}
]
},
+1
View File
@@ -24,6 +24,7 @@
"playground:build": "NODE_ENV=production vite build examples",
"prepublishOnly": "npm run build",
"build": "../../scripts/build.sh",
"watch": "../../scripts/watch.sh",
"test": "../../scripts/test.sh",
"lint": "../../scripts/lint.sh"
},
@@ -36,6 +36,13 @@ import {
jest.mock('../../hooks/use-id')
beforeAll(() => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
})
afterAll(() => jest.restoreAllMocks())
function renderTemplate(input: string | Partial<Parameters<typeof defineComponent>[0]>) {
const defaultComponents = { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption }
@@ -233,7 +240,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: false, focused: false }),
textContent: JSON.stringify({ open: false }),
})
assertListbox({ state: ListboxState.InvisibleUnmounted })
@@ -242,7 +249,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.Visible,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: true, focused: false }),
textContent: JSON.stringify({ open: true }),
})
assertListbox({ state: ListboxState.Visible })
})
@@ -268,7 +275,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.InvisibleUnmounted,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: false, focused: false }),
textContent: JSON.stringify({ open: false }),
})
assertListbox({ state: ListboxState.InvisibleUnmounted })
@@ -277,7 +284,7 @@ describe('Rendering', () => {
assertListboxButton({
state: ListboxState.Visible,
attributes: { id: 'headlessui-listbox-button-1' },
textContent: JSON.stringify({ open: true, focused: false }),
textContent: JSON.stringify({ open: true }),
})
assertListbox({ state: ListboxState.Visible })
})
@@ -3104,34 +3111,6 @@ describe('Mouse interactions', () => {
})
)
it('should focus the listbox when you try to focus the button again (when the listbox is already open)', async () => {
renderTemplate({
template: `
<Listbox v-model="value">
<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) }),
})
// Open listbox
await click(getListboxButton())
// Verify listbox is focused
assertActiveElement(getListbox())
// Try to Re-focus the button
getListboxButton()?.focus()
// Verify listbox is still focused
assertActiveElement(getListbox())
})
it(
'should be a no-op when we click outside of a closed listbox',
suppressConsoleLogs(async () => {
@@ -26,6 +26,10 @@ enum ListboxStates {
Closed,
}
function nextFrame(cb: () => void) {
requestAnimationFrame(() => requestAnimationFrame(cb))
}
type ListboxOptionDataRef = Ref<{ textValue: string; disabled: boolean; value: unknown }>
type StateDefinition = {
// State
@@ -155,13 +159,15 @@ export const Listbox = defineComponent({
onMounted(() => {
function handler(event: MouseEvent) {
if (listboxState.value !== ListboxStates.Open) return
if (buttonRef.value?.contains(event.target as HTMLElement)) return
const target = event.target as HTMLElement
const active = document.activeElement
if (!optionsRef.value?.contains(event.target as HTMLElement)) {
api.closeListbox()
}
if (!event.defaultPrevented) nextTick(() => buttonRef.value?.focus())
if (listboxState.value !== ListboxStates.Open) return
if (buttonRef.value?.contains(target)) return
if (!optionsRef.value?.contains(target)) api.closeListbox()
if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
if (!event.defaultPrevented) buttonRef.value?.focus()
}
window.addEventListener('click', handler)
@@ -221,7 +227,7 @@ export const ListboxButton = defineComponent({
render() {
const api = useListboxContext('ListboxButton')
const slot = { open: api.listboxState.value === ListboxStates.Open, focused: this.focused }
const slot = { open: api.listboxState.value === ListboxStates.Open }
const propsWeControl = {
ref: 'el',
id: this.id,
@@ -233,8 +239,6 @@ export const ListboxButton = defineComponent({
? [api.labelRef.value.id, this.id].join(' ')
: undefined,
onKeyDown: this.handleKeyDown,
onFocus: this.handleFocus,
onBlur: this.handleBlur,
onPointerUp: this.handlePointerUp,
}
@@ -248,7 +252,6 @@ export const ListboxButton = defineComponent({
setup(props) {
const api = useListboxContext('ListboxButton')
const id = `headlessui-listbox-button-${useId()}`
const focused = ref(false)
function handleKeyDown(event: KeyboardEvent) {
switch (event.key) {
@@ -284,28 +287,11 @@ export const ListboxButton = defineComponent({
} else {
event.preventDefault()
api.openListbox()
nextTick(() => api.optionsRef.value?.focus())
nextFrame(() => api.optionsRef.value?.focus())
}
}
function handleFocus() {
if (api.listboxState.value === ListboxStates.Open) return api.optionsRef.value?.focus()
focused.value = true
}
function handleBlur() {
focused.value = false
}
return {
id,
el: api.buttonRef,
focused,
handleKeyDown,
handlePointerUp,
handleFocus,
handleBlur,
}
return { id, el: api.buttonRef, handleKeyDown, handlePointerUp }
},
})
@@ -31,6 +31,13 @@ import {
jest.mock('../../hooks/use-id')
beforeAll(() => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
})
afterAll(() => jest.restoreAllMocks())
function renderTemplate(input: string | Partial<Parameters<typeof defineComponent>[0]>) {
const defaultComponents = { Menu, MenuButton, MenuItems, MenuItem }
@@ -2381,31 +2388,6 @@ describe('Mouse interactions', () => {
assertMenu({ state: MenuState.InvisibleUnmounted })
})
it('should focus the menu when you try to focus the button again (when the menu is already open)', async () => {
renderTemplate(`
<Menu>
<MenuButton>Trigger</MenuButton>
<MenuItems>
<MenuItem>Item A</MenuItem>
<MenuItem>Item B</MenuItem>
<MenuItem>Item C</MenuItem>
</MenuItems>
</Menu>
`)
// Open menu
await click(getMenuButton())
// Verify menu is focused
assertActiveElement(getMenu())
// Try to Re-focus the button
getMenuButton()?.focus()
// Verify menu is still focused
assertActiveElement(getMenu())
})
it('should be a no-op when we click outside of a closed menu', async () => {
renderTemplate(`
<Menu>
@@ -21,6 +21,10 @@ enum MenuStates {
Closed,
}
function nextFrame(cb: () => void) {
requestAnimationFrame(() => requestAnimationFrame(cb))
}
type MenuItemDataRef = Ref<{ textValue: string; disabled: boolean }>
type StateDefinition = {
// State
@@ -132,11 +136,15 @@ export const Menu = defineComponent({
onMounted(() => {
function handler(event: MouseEvent) {
if (menuState.value !== MenuStates.Open) return
if (buttonRef.value?.contains(event.target as HTMLElement)) return
const target = event.target as HTMLElement
const active = document.activeElement
if (!itemsRef.value?.contains(event.target as HTMLElement)) api.closeMenu()
if (!event.defaultPrevented) nextTick(() => buttonRef.value?.focus())
if (menuState.value !== MenuStates.Open) return
if (buttonRef.value?.contains(target)) return
if (!itemsRef.value?.contains(target)) api.closeMenu()
if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element
if (!event.defaultPrevented) buttonRef.value?.focus()
}
window.addEventListener('click', handler)
@@ -170,7 +178,6 @@ export const MenuButton = defineComponent({
'aria-controls': api.itemsRef.value?.id,
'aria-expanded': api.menuState.value === MenuStates.Open ? true : undefined,
onKeyDown: this.handleKeyDown,
onFocus: this.handleFocus,
onPointerUp: this.handlePointerUp,
}
@@ -219,20 +226,15 @@ export const MenuButton = defineComponent({
} else {
event.preventDefault()
api.openMenu()
nextTick(() => api.itemsRef.value?.focus())
nextFrame(() => api.itemsRef.value?.focus())
}
}
function handleFocus() {
if (api.menuState.value === MenuStates.Open) api.itemsRef.value?.focus()
}
return {
id,
el: api.buttonRef,
handleKeyDown,
handlePointerUp,
handleFocus,
}
},
})
@@ -1,6 +1,13 @@
import { nextTick } from 'vue'
import { fireEvent } from '@testing-library/dom'
function nextFrame(cb: Function): void {
setImmediate(() =>
setImmediate(() => {
cb()
})
)
}
export const Keys: Record<string, Partial<KeyboardEvent>> = {
Space: { key: ' ', keyCode: 32 },
Enter: { key: 'Enter', keyCode: 13 },
@@ -55,7 +62,7 @@ export async function type(events: Partial<KeyboardEvent>[]) {
// We don't want to actually wait in our tests, so let's advance
jest.runAllTimers()
await new Promise<void>(nextTick)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, type)
throw err
@@ -78,7 +85,7 @@ export async function click(element: Document | Element | Window | null) {
fireEvent.mouseUp(element)
fireEvent.click(element)
await new Promise<void>(nextTick)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, click)
throw err
@@ -91,7 +98,7 @@ export async function focus(element: Document | Element | Window | null) {
fireEvent.focus(element)
await new Promise<void>(nextTick)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, focus)
throw err
@@ -106,7 +113,7 @@ export async function mouseEnter(element: Document | Element | Window | null) {
fireEvent.pointerEnter(element)
fireEvent.mouseOver(element)
await new Promise<void>(nextTick)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseEnter)
throw err
@@ -120,7 +127,7 @@ export async function mouseMove(element: Document | Element | Window | null) {
fireEvent.pointerMove(element)
fireEvent.mouseMove(element)
await new Promise<void>(nextTick)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseMove)
throw err
@@ -136,7 +143,7 @@ export async function mouseLeave(element: Document | Element | Window | null) {
fireEvent.mouseOut(element)
fireEvent.mouseLeave(element)
await new Promise<void>(nextTick)
await new Promise(nextFrame)
} catch (err) {
Error.captureStackTrace(err, mouseLeave)
throw err
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
set -e
node="yarn node"
tsdxArgs=()
# Add script name
tsdxArgs+=("watch" "--name" "headlessui" "--format" "cjs,esm,umd" "--tsconfig" "./tsconfig.tsdx.json")
# Passthrough arguments and flags
tsdxArgs+=($@)
# Execute
$node "$(yarn bin tsdx)" "${tsdxArgs[@]}"
+12 -12
View File
@@ -1807,10 +1807,10 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.1.tgz#56af902ad157e763f9ba63d671c39cda3193c835"
integrity sha512-oTQgnd0hblfLsJ6BvJzzSL+Inogp3lq9fGgqRkMB/ziKMgEUaFl801OncOzUmalfzt14N0oPHMK47ipl+wbTIw==
"@types/node@^14.11.10":
version "14.11.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.10.tgz#8c102aba13bf5253f35146affbf8b26275069bef"
integrity sha512-yV1nWZPlMFpoXyoknm4S56y2nlTAuFYaJuQtYRAOU7xA/FJ9RY0Xm7QOkaYMMmr8ESdHIuUb6oQgR/0+2NqlyA==
"@types/node@^14.14.0":
version "14.14.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.0.tgz#f1091b6ad5de18e8e91bdbd43ec63f13de372538"
integrity sha512-BfbIHP9IapdupGhq/hc+jT5dyiBVZ2DdeC5WwJWQWDb0GijQlzUFAeIQn/2GtvZcd2HVUU7An8felIICFTC2qg==
"@types/normalize-package-data@^2.4.0":
version "2.4.0"
@@ -5000,10 +5000,10 @@ fragment-cache@^0.2.1:
dependencies:
map-cache "^0.2.2"
framer-motion@^2.9.1:
version "2.9.1"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-2.9.1.tgz#86713730e9503922eedcf5e1bb91a5acc30076f5"
integrity sha512-NjEF5u1FkCTS+zRsDWOPPCxWBUWk255WXy4d1FDCC3j6ETJzDXx0V/NUPvwhzyATHbahfX5JdsHWdRe1whNHJg==
framer-motion@^2.9.3:
version "2.9.3"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-2.9.3.tgz#dcdf62cb7f8a55cd8f536d2f3be5373ae785fd4d"
integrity sha512-4uoPCaSf4/TzOttJVlneKnO/A9PGdcN0YZaw372m9ejpMu8083m5AWXXENEqOBjbhOSjX2L0Vl+AYGinjcwgBg==
dependencies:
framesync "^4.1.0"
hey-listen "^1.0.8"
@@ -9797,10 +9797,10 @@ table@^5.2.3:
slice-ansi "^2.1.0"
string-width "^3.0.0"
tailwindcss@^1.9.4:
version "1.9.4"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.9.4.tgz#5ae8ff84bc8234df22ba5f2c7feafb64bb14da55"
integrity sha512-CVeP4J1pDluBM/AF11JPku9Cx+VwQ6MbOcnlobnWVVZnq+xku8sa+XXmYzy/GvE08qD8w+OmpSdN21ZFPoVDRg==
tailwindcss@^1.9.5:
version "1.9.5"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.9.5.tgz#3339b790a68bc1f09a8efd8eb94cb05aed5235c2"
integrity sha512-Je5t1fAfyW333YTpSxF+8uJwbnrkpyBskDtZYgSMMKQbNp6QUhEKJ4g/JIevZjD2Zidz9VxLraEUq/yWOx6nQg==
dependencies:
"@fullhuman/postcss-purgecss" "^2.1.2"
autoprefixer "^9.4.5"