diff --git a/package.json b/package.json
index 249cfff..cbc46b8 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json
index 29ab3ea..9d843fc 100644
--- a/packages/@headlessui-react/package.json
+++ b/packages/@headlessui-react/package.json
@@ -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",
diff --git a/packages/@headlessui-react/pages/listbox/multiple-elements.tsx b/packages/@headlessui-react/pages/listbox/multiple-elements.tsx
new file mode 100644
index 0000000..eaa0468
--- /dev/null
+++ b/packages/@headlessui-react/pages/listbox/multiple-elements.tsx
@@ -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 (
+
+ )
+}
+
+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 (
+
+
+
{
+ console.log('value:', value)
+ setActivePerson(value)
+ }}
+ >
+
+ Assigned to
+
+
+
+
+
+ {active}
+
+
+
+
+
+
+
+
+ {people.map(name => (
+ {
+ 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 }) => (
+ <>
+
+ {name}
+
+ {selected && (
+
+
+
+ )}
+ >
+ )}
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/packages/@headlessui-react/pages/menu/multiple-elements.tsx b/packages/@headlessui-react/pages/menu/multiple-elements.tsx
new file mode 100644
index 0000000..d27b96c
--- /dev/null
+++ b/packages/@headlessui-react/pages/menu/multiple-elements.tsx
@@ -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 (
+
+ )
+}
+
+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 (
+
+
+
+ )
+}
diff --git a/packages/@headlessui-react/playground-utils/resolve-all-examples.ts b/packages/@headlessui-react/playground-utils/resolve-all-examples.ts
index 8acefca..7f500a6 100644
--- a/packages/@headlessui-react/playground-utils/resolve-all-examples.ts
+++ b/packages/@headlessui-react/playground-utils/resolve-all-examples.ts
@@ -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, '/'),
}
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
index 50d19e7..3394ade 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx
@@ -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(
-
- Trigger
-
- Option A
- Option B
- Option C
-
-
- )
-
- // 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 () => {
diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx
index 89e63f6..a4be69a 100644
--- a/packages/@headlessui-react/src/components/listbox/listbox.tsx
+++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx
@@ -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(
- () => ({ 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,
}
diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx
index d0fb46f..a63662e 100644
--- a/packages/@headlessui-react/src/components/menu/menu.test.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx
@@ -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(
-
- )
-
- // 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 () => {
diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx
index 74f0e05..b7d186a 100644
--- a/packages/@headlessui-react/src/components/menu/menu.tsx
+++ b/packages/@headlessui-react/src/components/menu/menu.tsx
@@ -151,11 +151,14 @@ export function Menu(
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(
// ---
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(
(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]
diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts
index 4475c4f..1fd0c30 100644
--- a/packages/@headlessui-react/src/test-utils/interactions.ts
+++ b/packages/@headlessui-react/src/test-utils/interactions.ts
@@ -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> = {
Space: { key: ' ', keyCode: 32 },
@@ -57,7 +62,7 @@ export async function type(events: Partial[]) {
// 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
diff --git a/packages/@headlessui-vue/examples/src/App.vue b/packages/@headlessui-vue/examples/src/App.vue
index b3362a5..9eec170 100644
--- a/packages/@headlessui-vue/examples/src/App.vue
+++ b/packages/@headlessui-vue/examples/src/App.vue
@@ -67,7 +67,7 @@
diff --git a/packages/@headlessui-vue/examples/src/components/listbox/multiple-elements.vue b/packages/@headlessui-vue/examples/src/components/listbox/multiple-elements.vue
new file mode 100644
index 0000000..77b4892
--- /dev/null
+++ b/packages/@headlessui-vue/examples/src/components/listbox/multiple-elements.vue
@@ -0,0 +1,210 @@
+
+
+
+
+
+ Assigned to
+
+
+
+
+ {{ active.name }}
+
+
+
+
+
+
+
+
+
+
+ {{ person.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Assigned to
+
+
+
+
+ {{ active.name }}
+
+
+
+
+
+
+
+
+
+
+ {{ person.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/@headlessui-vue/examples/src/components/menu/multiple-elements.vue b/packages/@headlessui-vue/examples/src/components/menu/multiple-elements.vue
new file mode 100644
index 0000000..3b037a4
--- /dev/null
+++ b/packages/@headlessui-vue/examples/src/components/menu/multiple-elements.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/@headlessui-vue/examples/src/routes.json b/packages/@headlessui-vue/examples/src/routes.json
index 6e81dd6..4fe95c0 100644
--- a/packages/@headlessui-vue/examples/src/routes.json
+++ b/packages/@headlessui-vue/examples/src/routes.json
@@ -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"
}
]
},
diff --git a/packages/@headlessui-vue/package.json b/packages/@headlessui-vue/package.json
index d635a1b..da6baff 100644
--- a/packages/@headlessui-vue/package.json
+++ b/packages/@headlessui-vue/package.json
@@ -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"
},
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
index 4417cde..ec8c4e7 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx
@@ -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[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: `
-
- Trigger
-
- Option A
- Option B
- Option C
-
-
- `,
- 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 () => {
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts
index ac43857..52d8a0b 100644
--- a/packages/@headlessui-vue/src/components/listbox/listbox.ts
+++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts
@@ -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 }
},
})
diff --git a/packages/@headlessui-vue/src/components/menu/menu.test.tsx b/packages/@headlessui-vue/src/components/menu/menu.test.tsx
index bd7d041..e951073 100644
--- a/packages/@headlessui-vue/src/components/menu/menu.test.tsx
+++ b/packages/@headlessui-vue/src/components/menu/menu.test.tsx
@@ -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[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(`
-
- `)
-
- // 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(`