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:
+2
-2
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+14
@@ -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[@]}"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user