cleanup and consistency (#213)

- Made the use of `const` and `let` consistent
- import required functions and types from 'react' instead of using the
  `React.` namespace.
- Added `Expand` type, which can expand complex types to their "final"
  result.
- Ensured that we use `as const` for DEFAULT_XXX_TAG where we used a
  string. So that we have the type of `div` instead of `string` for
  example.
- Used `interface` over `type` where possible. I'm personally more of a
  `type` fan. But the TypeScript recommends `interfaces` where possible
  because they are faster, yield better error messages and so on.
This commit is contained in:
Robin Malfait
2021-01-30 14:46:54 +01:00
committed by GitHub
parent da179ca72b
commit ef00732685
78 changed files with 1115 additions and 1049 deletions
+6 -6
View File
@@ -1,17 +1,17 @@
import '@testing-library/jest-dom/extend-expect' import '@testing-library/jest-dom/extend-expect'
// Assuming requestAnimationFrame is roughly 60 frames per second // Assuming requestAnimationFrame is roughly 60 frames per second
const frame = 1000 / 60 let frame = 1000 / 60
const amountOfFrames = 2 let amountOfFrames = 2
const formatter = new Intl.NumberFormat('en') let formatter = new Intl.NumberFormat('en')
expect.extend({ expect.extend({
toBeWithinRenderFrame(actual, expected) { toBeWithinRenderFrame(actual, expected) {
const min = expected - frame * amountOfFrames let min = expected - frame * amountOfFrames
const max = expected + frame * amountOfFrames let max = expected + frame * amountOfFrames
const pass = actual >= min && actual <= max let pass = actual >= min && actual <= max
return { return {
message: pass message: pass
+9 -9
View File
@@ -1,4 +1,4 @@
import React from 'react' import React, { useState, useEffect } from 'react'
import Link from 'next/link' import Link from 'next/link'
import Head from 'next/head' import Head from 'next/head'
@@ -7,7 +7,7 @@ import { useDisposables } from '../src/hooks/use-disposables'
import { PropsOf } from '../src/types' import { PropsOf } from '../src/types'
function NextLink(props: PropsOf<'a'>) { function NextLink(props: PropsOf<'a'>) {
const { href, children, ...rest } = props let { href, children, ...rest } = props
return ( return (
<Link href={href}> <Link href={href}>
<a {...rest}>{children}</a> <a {...rest}>{children}</a>
@@ -58,23 +58,23 @@ function tap<T>(value: T, cb: (value: T) => void) {
} }
function useKeyDisplay() { function useKeyDisplay() {
const [mounted, setMounted] = React.useState(false) let [mounted, setMounted] = useState(false)
React.useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
}, []) }, [])
if (!mounted) return {} if (!mounted) return {}
const isMac = navigator.userAgent.indexOf('Mac OS X') !== -1 let isMac = navigator.userAgent.indexOf('Mac OS X') !== -1
return isMac ? KeyDisplayMac : KeyDisplayWindows return isMac ? KeyDisplayMac : KeyDisplayWindows
} }
function KeyCaster() { function KeyCaster() {
const [keys, setKeys] = React.useState<string[]>([]) let [keys, setKeys] = useState<string[]>([])
const d = useDisposables() let d = useDisposables()
const KeyDisplay = useKeyDisplay() let KeyDisplay = useKeyDisplay()
React.useEffect(() => { useEffect(() => {
function handler(event: KeyboardEvent) { function handler(event: KeyboardEvent) {
setKeys(current => [ setKeys(current => [
event.shiftKey && event.key !== 'Shift' event.shiftKey && event.key !== 'Shift'
+4 -4
View File
@@ -1,5 +1,5 @@
import * as React from 'react' import React from 'react'
import Error from 'next/error' import ErrorPage from 'next/error'
import Head from 'next/head' import Head from 'next/head'
import Link from 'next/link' import Link from 'next/link'
@@ -7,7 +7,7 @@ import { ExamplesType, resolveAllExamples } from '../playground-utils/resolve-al
import { PropsOf } from '../src/types' import { PropsOf } from '../src/types'
function NextLink(props: PropsOf<'a'>) { function NextLink(props: PropsOf<'a'>) {
const { href, children, ...rest } = props let { href, children, ...rest } = props
return ( return (
<Link href={href}> <Link href={href}>
<a {...rest}>{children}</a> <a {...rest}>{children}</a>
@@ -25,7 +25,7 @@ export async function getStaticProps() {
export default function Page(props: { examples: false | ExamplesType[] }) { export default function Page(props: { examples: false | ExamplesType[] }) {
if (props.examples === false) { if (props.examples === false) {
return <Error statusCode={404} /> return <ErrorPage statusCode={404} />
} }
return ( return (
@@ -1,9 +1,9 @@
import * as React from 'react' import React, { useState, useEffect } from 'react'
import { Listbox } from '@headlessui/react' import { Listbox } from '@headlessui/react'
import { classNames } from '../../src/utils/class-names' import { classNames } from '../../src/utils/class-names'
const people = [ let people = [
'Wade Cooper', 'Wade Cooper',
'Arlene Mccoy', 'Arlene Mccoy',
'Devon Webb', 'Devon Webb',
@@ -17,10 +17,10 @@ const people = [
] ]
export default function Home() { export default function Home() {
const [active, setActivePerson] = React.useState(people[2]) let [active, setActivePerson] = useState(people[2])
// Choose a random person on mount // Choose a random person on mount
React.useEffect(() => { useEffect(() => {
setActivePerson(people[Math.floor(Math.random() * people.length)]) setActivePerson(people[Math.floor(Math.random() * people.length)])
}, []) }, [])
@@ -1,11 +1,11 @@
import * as React from 'react' import React, { useState, useEffect } from 'react'
import { Listbox } from '@headlessui/react' import { Listbox } from '@headlessui/react'
function classNames(...classes) { function classNames(...classes) {
return classes.filter(Boolean).join(' ') return classes.filter(Boolean).join(' ')
} }
const people = [ let people = [
'Wade Cooper', 'Wade Cooper',
'Arlene Mccoy', 'Arlene Mccoy',
'Devon Webb', 'Devon Webb',
@@ -41,10 +41,10 @@ export default function Home() {
} }
function PeopleList() { function PeopleList() {
const [active, setActivePerson] = React.useState(people[2]) let [active, setActivePerson] = useState(people[2])
// Choose a random person on mount // Choose a random person on mount
React.useEffect(() => { useEffect(() => {
setActivePerson(people[Math.floor(Math.random() * people.length)]) setActivePerson(people[Math.floor(Math.random() * people.length)])
}, []) }, [])
@@ -1,4 +1,4 @@
import * as React from 'react' import React from 'react'
import Link from 'next/link' import Link from 'next/link'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { Menu } from '../../src/components/menu/menu' import { Menu } from '../../src/components/menu/menu'
@@ -69,7 +69,7 @@ export default function Home() {
} }
function NextLink(props: PropsOf<'a'>) { function NextLink(props: PropsOf<'a'>) {
const { href, children, ...rest } = props let { href, children, ...rest } = props
return ( return (
<Link href={href}> <Link href={href}>
<a {...rest}>{children}</a> <a {...rest}>{children}</a>
@@ -1,5 +1,5 @@
import * as React from 'react' import React, { ReactNode, useState, useEffect } from 'react'
import * as ReactDOM from 'react-dom' import { createPortal } from 'react-dom'
import { Menu } from '@headlessui/react' import { Menu } from '@headlessui/react'
import { usePopper } from '../../playground-utils/hooks/use-popper' import { usePopper } from '../../playground-utils/hooks/use-popper'
@@ -9,7 +9,7 @@ function classNames(...classes) {
} }
export default function Home() { export default function Home() {
const [trigger, container] = usePopper({ let [trigger, container] = usePopper({
placement: 'bottom-end', placement: 'bottom-end',
strategy: 'fixed', strategy: 'fixed',
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }], modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
@@ -87,12 +87,12 @@ export default function Home() {
) )
} }
function Portal(props: { children: React.ReactNode }) { function Portal(props: { children: ReactNode }) {
const { children } = props let { children } = props
const [mounted, setMounted] = React.useState(false) let [mounted, setMounted] = useState(false)
React.useEffect(() => setMounted(true), []) useEffect(() => setMounted(true), [])
if (!mounted) return null if (!mounted) return null
return ReactDOM.createPortal(children, document.body) return createPortal(children, document.body)
} }
@@ -1,4 +1,4 @@
import * as React from 'react' import React from 'react'
import { Menu, Transition } from '@headlessui/react' import { Menu, Transition } from '@headlessui/react'
import { usePopper } from '../../playground-utils/hooks/use-popper' import { usePopper } from '../../playground-utils/hooks/use-popper'
@@ -8,7 +8,7 @@ function classNames(...classes) {
} }
export default function Home() { export default function Home() {
const [trigger, container] = usePopper({ let [trigger, container] = usePopper({
placement: 'bottom-end', placement: 'bottom-end',
strategy: 'fixed', strategy: 'fixed',
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }], modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
@@ -1,4 +1,4 @@
import * as React from 'react' import React from 'react'
import { Menu, Transition } from '@headlessui/react' import { Menu, Transition } from '@headlessui/react'
function classNames(...classes) { function classNames(...classes) {
@@ -1,4 +1,4 @@
import * as React from 'react' import React from 'react'
import { Menu } from '@headlessui/react' import { Menu } from '@headlessui/react'
import { PropsOf } from '../../src/types' import { PropsOf } from '../../src/types'
@@ -55,7 +55,7 @@ export default function Home() {
) )
} }
function CustomMenuItem(props: PropsOf<Menu.Item<'a'>>) { function CustomMenuItem(props: PropsOf<typeof Menu.Item>) {
return ( return (
<Menu.Item {...props}> <Menu.Item {...props}>
{({ active, disabled }) => ( {({ active, disabled }) => (
@@ -1,4 +1,4 @@
import * as React from 'react' import React from 'react'
import { Menu } from '@headlessui/react' import { Menu } from '@headlessui/react'
function classNames(...classes) { function classNames(...classes) {
@@ -1,10 +1,10 @@
import * as React from 'react' import React, { useState } from 'react'
import { Switch } from '@headlessui/react' import { Switch } from '@headlessui/react'
import { classNames } from '../../src/utils/class-names' import { classNames } from '../../src/utils/class-names'
export default function Home() { export default function Home() {
const [state, setState] = React.useState(false) let [state, setState] = useState(false)
return ( return (
<div className="flex items-start justify-center w-screen h-full p-12 bg-gray-50"> <div className="flex items-start justify-center w-screen h-full p-12 bg-gray-50">
@@ -17,7 +17,7 @@ export default function Home() {
} }
function Dropdown() { function Dropdown() {
const [isOpen, setIsOpen] = useState(false) let [isOpen, setIsOpen] = useState(false)
return ( return (
<div className="relative inline-block text-left"> <div className="relative inline-block text-left">
@@ -2,14 +2,14 @@ import React, { useRef, useState } from 'react'
import { Transition } from '@headlessui/react' import { Transition } from '@headlessui/react'
export default function Home() { export default function Home() {
const [isOpen, setIsOpen] = useState(false) let [isOpen, setIsOpen] = useState(false)
function toggle() { function toggle() {
setIsOpen(v => !v) setIsOpen(v => !v)
} }
const [email, setEmail] = useState('') let [email, setEmail] = useState('')
const [events, setEvents] = useState([]) let [events, setEvents] = useState([])
const inputRef = useRef(null) let inputRef = useRef(null)
function addEvent(name) { function addEvent(name) {
setEvents(existing => [...existing, `${new Date().toJSON()} - ${name}`]) setEvents(existing => [...existing, `${new Date().toJSON()} - ${name}`])
@@ -1,8 +1,8 @@
import React, { useState } from 'react' import React, { useState, ReactNode } from 'react'
import { Transition } from '@headlessui/react' import { Transition } from '@headlessui/react'
export default function Home() { export default function Home() {
const [isOpen, setIsOpen] = useState(true) let [isOpen, setIsOpen] = useState(true)
return ( return (
<> <>
@@ -40,7 +40,7 @@ export default function Home() {
) )
} }
function Box({ children }: { children?: React.ReactNode }) { function Box({ children }: { children?: ReactNode }) {
return ( return (
<Transition.Child <Transition.Child
unmount={false} unmount={false}
@@ -1,8 +1,8 @@
import React, { useState } from 'react' import React, { useState, ReactNode } from 'react'
import { Transition } from '@headlessui/react' import { Transition } from '@headlessui/react'
export default function Home() { export default function Home() {
const [isOpen, setIsOpen] = useState(true) let [isOpen, setIsOpen] = useState(true)
return ( return (
<> <>
@@ -40,7 +40,7 @@ export default function Home() {
) )
} }
function Box({ children }: { children?: React.ReactNode }) { function Box({ children }: { children?: ReactNode }) {
return ( return (
<Transition.Child <Transition.Child
unmount={true} unmount={true}
@@ -2,7 +2,7 @@ import React, { useState } from 'react'
import { Transition } from '@headlessui/react' import { Transition } from '@headlessui/react'
export default function Home() { export default function Home() {
const [isOpen, setIsOpen] = useState(true) let [isOpen, setIsOpen] = useState(true)
return ( return (
<> <>
@@ -21,7 +21,7 @@ export default function Shell() {
} }
function usePrevious<T>(value: T) { function usePrevious<T>(value: T) {
const ref = useRef(value) let ref = useRef(value)
useEffect(() => { useEffect(() => {
ref.current = value ref.current = value
}, [value]) }, [value])
@@ -33,8 +33,8 @@ enum Direction {
Backwards = ' <- ', Backwards = ' <- ',
} }
const pages = ['Dashboard', 'Team', 'Projects', 'Calendar', 'Reports'] let pages = ['Dashboard', 'Team', 'Projects', 'Calendar', 'Reports']
const colors = [ let colors = [
'bg-gradient-to-r from-teal-400 to-blue-400', 'bg-gradient-to-r from-teal-400 to-blue-400',
'bg-gradient-to-r from-blue-400 to-orange-400', 'bg-gradient-to-r from-blue-400 to-orange-400',
'bg-gradient-to-r from-orange-400 to-purple-400', 'bg-gradient-to-r from-orange-400 to-purple-400',
@@ -43,12 +43,12 @@ const colors = [
] ]
function FullPageTransition() { function FullPageTransition() {
const [activePage, setActivePage] = useState(0) let [activePage, setActivePage] = useState(0)
const previousPage = usePrevious(activePage) let previousPage = usePrevious(activePage)
const direction = activePage > previousPage ? Direction.Forwards : Direction.Backwards let direction = activePage > previousPage ? Direction.Forwards : Direction.Backwards
const transitions = match(direction, { let transitions = match(direction, {
[Direction.Forwards]: { [Direction.Forwards]: {
enter: 'transition transform ease-in-out duration-500', enter: 'transition transform ease-in-out duration-500',
enterFrom: 'translate-x-full', enterFrom: 'translate-x-full',
@@ -3,7 +3,7 @@ import Head from 'next/head'
import { Transition } from '@headlessui/react' import { Transition } from '@headlessui/react'
export default function App() { export default function App() {
const [mobileOpen, setMobileOpen] = useState(false) let [mobileOpen, setMobileOpen] = useState(false)
useEffect(() => { useEffect(() => {
function handleEscape(event) { function handleEscape(event) {
@@ -1,4 +1,4 @@
import React from 'react' import { RefCallback, useRef, useCallback, useMemo } from 'react'
import { createPopper, Options } from '@popperjs/core' import { createPopper, Options } from '@popperjs/core'
/** /**
@@ -6,13 +6,13 @@ import { createPopper, Options } from '@popperjs/core'
*/ */
export function usePopper( export function usePopper(
options?: Partial<Options> options?: Partial<Options>
): [React.RefCallback<Element | null>, React.RefCallback<HTMLElement | null>] { ): [RefCallback<Element | null>, RefCallback<HTMLElement | null>] {
const reference = React.useRef<Element>(null) let reference = useRef<Element>(null)
const popper = React.useRef<HTMLElement>(null) let popper = useRef<HTMLElement>(null)
const cleanupCallback = React.useRef(() => {}) let cleanupCallback = useRef(() => {})
const instantiatePopper = React.useCallback(() => { let instantiatePopper = useCallback(() => {
if (!reference.current) return if (!reference.current) return
if (!popper.current) return if (!popper.current) return
@@ -21,7 +21,7 @@ export function usePopper(
cleanupCallback.current = createPopper(reference.current, popper.current, options).destroy cleanupCallback.current = createPopper(reference.current, popper.current, options).destroy
}, [reference, popper, cleanupCallback, options]) }, [reference, popper, cleanupCallback, options])
return React.useMemo( return useMemo(
() => [ () => [
referenceDomNode => { referenceDomNode => {
reference.current = referenceDomNode reference.current = referenceDomNode
@@ -7,14 +7,14 @@ export type ExamplesType = {
} }
export async function resolveAllExamples(...paths: string[]) { export async function resolveAllExamples(...paths: string[]) {
const base = path.resolve(process.cwd(), ...paths) let base = path.resolve(process.cwd(), ...paths)
if (!fs.existsSync(base)) { if (!fs.existsSync(base)) {
return false return false
} }
const files = await fs.promises.readdir(base, { withFileTypes: true }) let files = await fs.promises.readdir(base, { withFileTypes: true })
const items: ExamplesType[] = [] let items: ExamplesType[] = []
for (let file of files) { for (let file of files) {
// Skip reserved filenames from Next. E.g.: _app.tsx, _error.tsx // Skip reserved filenames from Next. E.g.: _app.tsx, _error.tsx
@@ -22,7 +22,7 @@ export async function resolveAllExamples(...paths: string[]) {
continue continue
} }
const bucket: ExamplesType = { let bucket: ExamplesType = {
name: file.name.replace(/-/g, ' ').replace(/\.tsx?/g, ''), name: file.name.replace(/-/g, ' ').replace(/\.tsx?/g, ''),
path: [...paths, file.name] path: [...paths, file.name]
.join('/') .join('/')
@@ -32,7 +32,7 @@ export async function resolveAllExamples(...paths: string[]) {
} }
if (file.isDirectory()) { if (file.isDirectory()) {
const children = await resolveAllExamples(...paths, file.name) let children = await resolveAllExamples(...paths, file.name)
if (children) { if (children) {
bucket.children = children bucket.children = children
@@ -1,4 +1,4 @@
import React from 'react' import React, { createElement, useState } from 'react'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import { Listbox } from './listbox' import { Listbox } from './listbox'
@@ -54,7 +54,7 @@ describe('safeguards', () => {
])( ])(
'should error when we are using a <%s /> without a parent <Listbox />', 'should error when we are using a <%s /> without a parent <Listbox />',
suppressConsoleLogs((name, Component) => { suppressConsoleLogs((name, Component) => {
expect(() => render(React.createElement(Component))).toThrowError( expect(() => render(createElement(Component))).toThrowError(
`<${name} /> is missing a parent <Listbox /> component.` `<${name} /> is missing a parent <Listbox /> component.`
) )
}) })
@@ -426,7 +426,7 @@ describe('Rendering composition', () => {
// Open Listbox // Open Listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// Verify correct classNames // Verify correct classNames
expect('' + options[0].classList).toEqual( expect('' + options[0].classList).toEqual(
@@ -545,7 +545,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option, { selected: false })) options.forEach(option => assertListboxOption(option, { selected: false }))
@@ -626,7 +626,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -670,7 +670,7 @@ describe('Keyboard interactions', () => {
assertActiveElement(getListbox()) assertActiveElement(getListbox())
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
const options = getListboxOptions() let options = getListboxOptions()
// Hover over Option A // Hover over Option A
await mouseMove(options[0]) await mouseMove(options[0])
@@ -699,12 +699,12 @@ describe('Keyboard interactions', () => {
it( it(
'should be possible to open the listbox with Enter, and focus the selected option (with a list of objects)', 'should be possible to open the listbox with Enter, and focus the selected option (with a list of objects)',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const myOptions = [ let myOptions = [
{ id: 'a', name: 'Option A' }, { id: 'a', name: 'Option A' },
{ id: 'b', name: 'Option B' }, { id: 'b', name: 'Option B' },
{ id: 'c', name: 'Option C' }, { id: 'c', name: 'Option C' },
] ]
const selectedOption = myOptions[1] let selectedOption = myOptions[1]
render( render(
<Listbox value={selectedOption} onChange={console.log}> <Listbox value={selectedOption} onChange={console.log}>
<Listbox.Button>Trigger</Listbox.Button> <Listbox.Button>Trigger</Listbox.Button>
@@ -740,7 +740,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -801,7 +801,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// Verify that the first non-disabled listbox option is active // Verify that the first non-disabled listbox option is active
assertActiveListboxOption(options[1]) assertActiveListboxOption(options[1])
@@ -838,7 +838,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// Verify that the first non-disabled listbox option is active // Verify that the first non-disabled listbox option is active
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -922,10 +922,10 @@ describe('Keyboard interactions', () => {
it( it(
'should be possible to close the listbox with Enter and choose the active listbox option', 'should be possible to close the listbox with Enter and choose the active listbox option',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
function Example() { function Example() {
const [value, setValue] = React.useState(undefined) let [value, setValue] = useState(undefined)
return ( return (
<Listbox <Listbox
@@ -960,7 +960,7 @@ describe('Keyboard interactions', () => {
assertListboxButton({ state: ListboxState.Visible }) assertListboxButton({ state: ListboxState.Visible })
// Activate the first listbox option // Activate the first listbox option
const options = getListboxOptions() let options = getListboxOptions()
await mouseMove(options[0]) await mouseMove(options[0])
// Choose option, and close listbox // Choose option, and close listbox
@@ -1023,7 +1023,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -1101,7 +1101,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -1162,7 +1162,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Space) await press(Keys.Space)
const options = getListboxOptions() let options = getListboxOptions()
// Verify that the first non-disabled listbox option is active // Verify that the first non-disabled listbox option is active
assertActiveListboxOption(options[1]) assertActiveListboxOption(options[1])
@@ -1199,7 +1199,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Space) await press(Keys.Space)
const options = getListboxOptions() let options = getListboxOptions()
// Verify that the first non-disabled listbox option is active // Verify that the first non-disabled listbox option is active
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -1245,10 +1245,10 @@ describe('Keyboard interactions', () => {
it( it(
'should be possible to close the listbox with Space and choose the active listbox option', 'should be possible to close the listbox with Space and choose the active listbox option',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
function Example() { function Example() {
const [value, setValue] = React.useState(undefined) let [value, setValue] = useState(undefined)
return ( return (
<Listbox <Listbox
@@ -1283,7 +1283,7 @@ describe('Keyboard interactions', () => {
assertListboxButton({ state: ListboxState.Visible }) assertListboxButton({ state: ListboxState.Visible })
// Activate the first listbox option // Activate the first listbox option
const options = getListboxOptions() let options = getListboxOptions()
await mouseMove(options[0]) await mouseMove(options[0])
// Choose option, and close listbox // Choose option, and close listbox
@@ -1389,7 +1389,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -1440,7 +1440,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -1493,7 +1493,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
@@ -1573,7 +1573,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -1633,7 +1633,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -1681,7 +1681,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[1]) assertActiveListboxOption(options[1])
@@ -1723,7 +1723,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -1768,7 +1768,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
@@ -1848,7 +1848,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -1912,7 +1912,7 @@ describe('Keyboard interactions', () => {
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -1950,7 +1950,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2001,7 +2001,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2042,7 +2042,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first option // We should be on the first option
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -2078,7 +2078,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first option // We should be on the first option
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -2119,7 +2119,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.End) await press(Keys.End)
const options = getListboxOptions() let options = getListboxOptions()
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
}) })
) )
@@ -2182,7 +2182,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first option // We should be on the first option
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -2218,7 +2218,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first option // We should be on the first option
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -2259,7 +2259,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageDown) await press(Keys.PageDown)
const options = getListboxOptions() let options = getListboxOptions()
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
}) })
) )
@@ -2322,7 +2322,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the last option // We should be on the last option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2361,7 +2361,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.Home) await press(Keys.Home)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first non-disabled option // We should be on the first non-disabled option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2398,7 +2398,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.Home) await press(Keys.Home)
const options = getListboxOptions() let options = getListboxOptions()
assertActiveListboxOption(options[3]) assertActiveListboxOption(options[3])
}) })
) )
@@ -2461,7 +2461,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the last option // We should be on the last option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2500,7 +2500,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageUp) await press(Keys.PageUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first non-disabled option // We should be on the first non-disabled option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2537,7 +2537,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageUp) await press(Keys.PageUp)
const options = getListboxOptions() let options = getListboxOptions()
assertActiveListboxOption(options[3]) assertActiveListboxOption(options[3])
}) })
) )
@@ -2597,7 +2597,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to go to the second option // We should be able to go to the second option
await type(word('bob')) await type(word('bob'))
@@ -2633,7 +2633,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the last option // We should be on the last option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2672,7 +2672,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the last option // We should be on the last option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2713,7 +2713,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the last option // We should be on the last option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2814,7 +2814,7 @@ describe('Mouse interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
}) })
@@ -2913,7 +2913,7 @@ describe('Mouse interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -3031,7 +3031,7 @@ describe('Mouse interactions', () => {
</div> </div>
) )
const [button1, button2] = getListboxButtons() let [button1, button2] = getListboxButtons()
// Click the first menu button // Click the first menu button
await click(button1) await click(button1)
@@ -3097,7 +3097,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to go to the second option // We should be able to go to the second option
await mouseMove(options[1]) await mouseMove(options[1])
assertActiveListboxOption(options[1]) assertActiveListboxOption(options[1])
@@ -3129,7 +3129,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to go to the second option // We should be able to go to the second option
await mouseMove(options[1]) await mouseMove(options[1])
assertActiveListboxOption(options[1]) assertActiveListboxOption(options[1])
@@ -3153,7 +3153,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to go to the second option // We should be able to go to the second option
await mouseMove(options[1]) await mouseMove(options[1])
@@ -3185,7 +3185,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
await mouseMove(options[1]) await mouseMove(options[1])
assertNoActiveListboxOption() assertNoActiveListboxOption()
@@ -3211,7 +3211,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// Try to hover over option 1, which is disabled // Try to hover over option 1, which is disabled
await mouseMove(options[1]) await mouseMove(options[1])
@@ -3238,7 +3238,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to go to the second option // We should be able to go to the second option
await mouseMove(options[1]) await mouseMove(options[1])
@@ -3282,7 +3282,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// Try to hover over option 1, which is disabled // Try to hover over option 1, which is disabled
await mouseMove(options[1]) await mouseMove(options[1])
@@ -3296,9 +3296,9 @@ describe('Mouse interactions', () => {
it( it(
'should be possible to click a listbox option, which closes the listbox', 'should be possible to click a listbox option, which closes the listbox',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
function Example() { function Example() {
const [value, setValue] = React.useState(undefined) let [value, setValue] = useState(undefined)
return ( return (
<Listbox <Listbox
@@ -3325,7 +3325,7 @@ describe('Mouse interactions', () => {
assertListbox({ state: ListboxState.Visible }) assertListbox({ state: ListboxState.Visible })
assertActiveElement(getListbox()) assertActiveElement(getListbox())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to click the first option // We should be able to click the first option
await click(options[1]) await click(options[1])
@@ -3347,9 +3347,9 @@ describe('Mouse interactions', () => {
it( it(
'should be possible to click a disabled listbox option, which is a no-op', 'should be possible to click a disabled listbox option, which is a no-op',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
function Example() { function Example() {
const [value, setValue] = React.useState(undefined) let [value, setValue] = useState(undefined)
return ( return (
<Listbox <Listbox
@@ -3378,7 +3378,7 @@ describe('Mouse interactions', () => {
assertListbox({ state: ListboxState.Visible }) assertListbox({ state: ListboxState.Visible })
assertActiveElement(getListbox()) assertActiveElement(getListbox())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to click the first option // We should be able to click the first option
await click(options[1]) await click(options[1])
@@ -3416,7 +3416,7 @@ describe('Mouse interactions', () => {
assertListbox({ state: ListboxState.Visible }) assertListbox({ state: ListboxState.Visible })
assertActiveElement(getListbox()) assertActiveElement(getListbox())
const options = getListboxOptions() let options = getListboxOptions()
// Verify that nothing is active yet // Verify that nothing is active yet
assertNoActiveListboxOption() assertNoActiveListboxOption()
@@ -3448,7 +3448,7 @@ describe('Mouse interactions', () => {
assertListbox({ state: ListboxState.Visible }) assertListbox({ state: ListboxState.Visible })
assertActiveElement(getListbox()) assertActiveElement(getListbox())
const options = getListboxOptions() let options = getListboxOptions()
// We should not be able to focus the first option // We should not be able to focus the first option
await focus(options[1]) await focus(options[1])
@@ -1,4 +1,22 @@
import * as React from 'react' import React, {
createContext,
createRef,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
Fragment,
// Types
Dispatch,
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
} from 'react'
import { useDisposables } from '../../hooks/use-disposables' import { useDisposables } from '../../hooks/use-disposables'
import { useId } from '../../hooks/use-id' import { useId } from '../../hooks/use-id'
@@ -19,18 +37,18 @@ enum ListboxStates {
Closed, Closed,
} }
type ListboxOptionDataRef = React.MutableRefObject<{ type ListboxOptionDataRef = MutableRefObject<{
textValue?: string textValue?: string
disabled: boolean disabled: boolean
value: unknown value: unknown
}> }>
type StateDefinition = { interface StateDefinition {
listboxState: ListboxStates listboxState: ListboxStates
propsRef: React.MutableRefObject<{ value: unknown; onChange(value: unknown): void }> propsRef: MutableRefObject<{ value: unknown; onChange(value: unknown): void }>
labelRef: React.MutableRefObject<HTMLLabelElement | null> labelRef: MutableRefObject<HTMLLabelElement | null>
buttonRef: React.MutableRefObject<HTMLButtonElement | null> buttonRef: MutableRefObject<HTMLButtonElement | null>
optionsRef: React.MutableRefObject<HTMLUListElement | null> optionsRef: MutableRefObject<HTMLUListElement | null>
options: { id: string; dataRef: ListboxOptionDataRef }[] options: { id: string; dataRef: ListboxOptionDataRef }[]
searchQuery: string searchQuery: string
activeOptionIndex: number | null activeOptionIndex: number | null
@@ -58,7 +76,7 @@ type Actions =
| { type: ActionTypes.RegisterOption; id: string; dataRef: ListboxOptionDataRef } | { type: ActionTypes.RegisterOption; id: string; dataRef: ListboxOptionDataRef }
| { type: ActionTypes.UnregisterOption; id: string } | { type: ActionTypes.UnregisterOption; id: string }
const reducers: { let reducers: {
[P in ActionTypes]: ( [P in ActionTypes]: (
state: StateDefinition, state: StateDefinition,
action: Extract<Actions, { type: P }> action: Extract<Actions, { type: P }>
@@ -71,7 +89,7 @@ const reducers: {
}), }),
[ActionTypes.OpenListbox]: state => ({ ...state, listboxState: ListboxStates.Open }), [ActionTypes.OpenListbox]: state => ({ ...state, listboxState: ListboxStates.Open }),
[ActionTypes.GoToOption]: (state, action) => { [ActionTypes.GoToOption]: (state, action) => {
const activeOptionIndex = calculateActiveIndex(action, { let activeOptionIndex = calculateActiveIndex(action, {
resolveItems: () => state.options, resolveItems: () => state.options,
resolveActiveIndex: () => state.activeOptionIndex, resolveActiveIndex: () => state.activeOptionIndex,
resolveId: item => item.id, resolveId: item => item.id,
@@ -82,8 +100,8 @@ const reducers: {
return { ...state, searchQuery: '', activeOptionIndex } return { ...state, searchQuery: '', activeOptionIndex }
}, },
[ActionTypes.Search]: (state, action) => { [ActionTypes.Search]: (state, action) => {
const searchQuery = state.searchQuery + action.value let searchQuery = state.searchQuery + action.value
const match = state.options.findIndex( let match = state.options.findIndex(
option => option =>
!option.dataRef.current.disabled && !option.dataRef.current.disabled &&
option.dataRef.current.textValue?.startsWith(searchQuery) option.dataRef.current.textValue?.startsWith(searchQuery)
@@ -98,11 +116,11 @@ const reducers: {
options: [...state.options, { id: action.id, dataRef: action.dataRef }], options: [...state.options, { id: action.id, dataRef: action.dataRef }],
}), }),
[ActionTypes.UnregisterOption]: (state, action) => { [ActionTypes.UnregisterOption]: (state, action) => {
const nextOptions = state.options.slice() let nextOptions = state.options.slice()
const currentActiveOption = let currentActiveOption =
state.activeOptionIndex !== null ? nextOptions[state.activeOptionIndex] : null state.activeOptionIndex !== null ? nextOptions[state.activeOptionIndex] : null
const idx = nextOptions.findIndex(a => a.id === action.id) let idx = nextOptions.findIndex(a => a.id === action.id)
if (idx !== -1) nextOptions.splice(idx, 1) if (idx !== -1) nextOptions.splice(idx, 1)
@@ -121,13 +139,13 @@ const reducers: {
}, },
} }
const ListboxContext = React.createContext<[StateDefinition, React.Dispatch<Actions>] | null>(null) let ListboxContext = createContext<[StateDefinition, Dispatch<Actions>] | null>(null)
ListboxContext.displayName = 'ListboxContext' ListboxContext.displayName = 'ListboxContext'
function useListboxContext(component: string) { function useListboxContext(component: string) {
const context = React.useContext(ListboxContext) let context = useContext(ListboxContext)
if (context === null) { if (context === null) {
const err = new Error(`<${component} /> is missing a parent <${Listbox.name} /> component.`) let err = new Error(`<${component} /> is missing a parent <${Listbox.name} /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useListboxContext) if (Error.captureStackTrace) Error.captureStackTrace(err, useListboxContext)
throw err throw err
} }
@@ -140,31 +158,30 @@ function stateReducer(state: StateDefinition, action: Actions) {
// --- // ---
const DEFAULT_LISTBOX_TAG = React.Fragment let DEFAULT_LISTBOX_TAG = Fragment
type ListboxRenderPropArg = { open: boolean } interface ListboxRenderPropArg {
open: boolean
}
export function Listbox< export function Listbox<TTag extends ElementType = typeof DEFAULT_LISTBOX_TAG, TType = string>(
TTag extends React.ElementType = typeof DEFAULT_LISTBOX_TAG,
TType = string
>(
props: Props<TTag, ListboxRenderPropArg, 'value' | 'onChange'> & { props: Props<TTag, ListboxRenderPropArg, 'value' | 'onChange'> & {
value: TType value: TType
onChange(value: TType): void onChange(value: TType): void
} }
) { ) {
const { value, onChange, ...passThroughProps } = props let { value, onChange, ...passThroughProps } = props
const d = useDisposables() let d = useDisposables()
const reducerBag = React.useReducer(stateReducer, { let reducerBag = useReducer(stateReducer, {
listboxState: ListboxStates.Closed, listboxState: ListboxStates.Closed,
propsRef: { current: { value, onChange } }, propsRef: { current: { value, onChange } },
labelRef: React.createRef(), labelRef: createRef(),
buttonRef: React.createRef(), buttonRef: createRef(),
optionsRef: React.createRef(), optionsRef: createRef(),
options: [], options: [],
searchQuery: '', searchQuery: '',
activeOptionIndex: null, activeOptionIndex: null,
} as StateDefinition) } as StateDefinition)
const [{ listboxState, propsRef, optionsRef, buttonRef }, dispatch] = reducerBag let [{ listboxState, propsRef, optionsRef, buttonRef }, dispatch] = reducerBag
useIsoMorphicEffect(() => { useIsoMorphicEffect(() => {
propsRef.current.value = value propsRef.current.value = value
@@ -173,10 +190,10 @@ export function Listbox<
propsRef.current.onChange = onChange propsRef.current.onChange = onChange
}, [onChange, propsRef]) }, [onChange, propsRef])
React.useEffect(() => { useEffect(() => {
function handler(event: MouseEvent) { function handler(event: MouseEvent) {
const target = event.target as HTMLElement let target = event.target as HTMLElement
const active = document.activeElement let active = document.activeElement
if (listboxState !== ListboxStates.Open) return if (listboxState !== ListboxStates.Open) return
if (buttonRef.current?.contains(target)) return if (buttonRef.current?.contains(target)) return
@@ -190,7 +207,7 @@ export function Listbox<
return () => window.removeEventListener('mousedown', handler) return () => window.removeEventListener('mousedown', handler)
}, [listboxState, optionsRef, buttonRef, d, dispatch]) }, [listboxState, optionsRef, buttonRef, d, dispatch])
const propsBag = React.useMemo<ListboxRenderPropArg>( let propsBag = useMemo<ListboxRenderPropArg>(
() => ({ open: listboxState === ListboxStates.Open }), () => ({ open: listboxState === ListboxStates.Open }),
[listboxState] [listboxState]
) )
@@ -204,8 +221,10 @@ export function Listbox<
// --- // ---
const DEFAULT_BUTTON_TAG = 'button' let DEFAULT_BUTTON_TAG = 'button' as const
type ButtonRenderPropArg = { open: boolean } interface ButtonRenderPropArg {
open: boolean
}
type ButtonPropsWeControl = type ButtonPropsWeControl =
| 'id' | 'id'
| 'type' | 'type'
@@ -216,20 +235,18 @@ type ButtonPropsWeControl =
| 'onKeyDown' | 'onKeyDown'
| 'onClick' | 'onClick'
const Button = forwardRefWithAs(function Button< let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
TTag extends React.ElementType = typeof DEFAULT_BUTTON_TAG
>(
props: Props<TTag, ButtonRenderPropArg, ButtonPropsWeControl>, props: Props<TTag, ButtonRenderPropArg, ButtonPropsWeControl>,
ref: React.Ref<HTMLButtonElement> ref: Ref<HTMLButtonElement>
) { ) {
const [state, dispatch] = useListboxContext([Listbox.name, Button.name].join('.')) let [state, dispatch] = useListboxContext([Listbox.name, Button.name].join('.'))
const buttonRef = useSyncRefs(state.buttonRef, ref) let buttonRef = useSyncRefs(state.buttonRef, ref)
const id = `headlessui-listbox-button-${useId()}` let id = `headlessui-listbox-button-${useId()}`
const d = useDisposables() let d = useDisposables()
const handleKeyDown = React.useCallback( let handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => { (event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) { switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
@@ -259,8 +276,8 @@ const Button = forwardRefWithAs(function Button<
[dispatch, state, d] [dispatch, state, d]
) )
const handleClick = React.useCallback( let handleClick = useCallback(
(event: React.MouseEvent) => { (event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (props.disabled) return if (props.disabled) return
if (state.listboxState === ListboxStates.Open) { if (state.listboxState === ListboxStates.Open) {
@@ -275,17 +292,17 @@ const Button = forwardRefWithAs(function Button<
[dispatch, d, state, props.disabled] [dispatch, d, state, props.disabled]
) )
const labelledby = useComputed(() => { let labelledby = useComputed(() => {
if (!state.labelRef.current) return undefined if (!state.labelRef.current) return undefined
return [state.labelRef.current.id, id].join(' ') return [state.labelRef.current.id, id].join(' ')
}, [state.labelRef.current, id]) }, [state.labelRef.current, id])
const propsBag = React.useMemo<ButtonRenderPropArg>( let propsBag = useMemo<ButtonRenderPropArg>(
() => ({ open: state.listboxState === ListboxStates.Open }), () => ({ open: state.listboxState === ListboxStates.Open }),
[state] [state]
) )
const passthroughProps = props let passthroughProps = props
const propsWeControl = { let propsWeControl = {
ref: buttonRef, ref: buttonRef,
id, id,
type: 'button', type: 'button',
@@ -302,33 +319,36 @@ const Button = forwardRefWithAs(function Button<
// --- // ---
const DEFAULT_LABEL_TAG = 'label' let DEFAULT_LABEL_TAG = 'label' as const
interface LabelRenderPropArg {
open: boolean
}
type LabelPropsWeControl = 'id' | 'ref' | 'onClick' type LabelPropsWeControl = 'id' | 'ref' | 'onClick'
type LabelRenderPropArg = { open: boolean }
function Label<TTag extends React.ElementType = typeof DEFAULT_LABEL_TAG>( function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl> props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl>
) { ) {
const [state] = useListboxContext([Listbox.name, Label.name].join('.')) let [state] = useListboxContext([Listbox.name, Label.name].join('.'))
const id = `headlessui-listbox-label-${useId()}` let id = `headlessui-listbox-label-${useId()}`
const handleClick = React.useCallback( let handleClick = useCallback(() => state.buttonRef.current?.focus({ preventScroll: true }), [
() => state.buttonRef.current?.focus({ preventScroll: true }), state.buttonRef,
[state.buttonRef] ])
)
const propsBag = React.useMemo<OptionsRenderPropArg>( let propsBag = useMemo<OptionsRenderPropArg>(
() => ({ open: state.listboxState === ListboxStates.Open }), () => ({ open: state.listboxState === ListboxStates.Open }),
[state] [state]
) )
const propsWeControl = { ref: state.labelRef, id, onClick: handleClick } let propsWeControl = { ref: state.labelRef, id, onClick: handleClick }
return render({ ...props, ...propsWeControl }, propsBag, DEFAULT_LABEL_TAG) return render({ ...props, ...propsWeControl }, propsBag, DEFAULT_LABEL_TAG)
} }
// --- // ---
const DEFAULT_OPTIONS_TAG = 'ul' let DEFAULT_OPTIONS_TAG = 'ul' as const
type OptionsRenderPropArg = { open: boolean } interface OptionsRenderPropArg {
open: boolean
}
type OptionsPropsWeControl = type OptionsPropsWeControl =
| 'aria-activedescendant' | 'aria-activedescendant'
| 'aria-labelledby' | 'aria-labelledby'
@@ -337,24 +357,24 @@ type OptionsPropsWeControl =
| 'role' | 'role'
| 'tabIndex' | 'tabIndex'
const OptionsRenderFeatures = Features.RenderStrategy | Features.Static let OptionsRenderFeatures = Features.RenderStrategy | Features.Static
const Options = forwardRefWithAs(function Options< let Options = forwardRefWithAs(function Options<
TTag extends React.ElementType = typeof DEFAULT_OPTIONS_TAG TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG
>( >(
props: Props<TTag, OptionsRenderPropArg, OptionsPropsWeControl> & props: Props<TTag, OptionsRenderPropArg, OptionsPropsWeControl> &
PropsForFeatures<typeof OptionsRenderFeatures>, PropsForFeatures<typeof OptionsRenderFeatures>,
ref: React.Ref<HTMLUListElement> ref: Ref<HTMLUListElement>
) { ) {
const [state, dispatch] = useListboxContext([Listbox.name, Options.name].join('.')) let [state, dispatch] = useListboxContext([Listbox.name, Options.name].join('.'))
const optionsRef = useSyncRefs(state.optionsRef, ref) let optionsRef = useSyncRefs(state.optionsRef, ref)
const id = `headlessui-listbox-options-${useId()}` let id = `headlessui-listbox-options-${useId()}`
const d = useDisposables() let d = useDisposables()
const searchDisposables = useDisposables() let searchDisposables = useDisposables()
const handleKeyDown = React.useCallback( let handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLUListElement>) => { (event: ReactKeyboardEvent<HTMLUListElement>) => {
searchDisposables.dispose() searchDisposables.dispose()
switch (event.key) { switch (event.key) {
@@ -371,7 +391,7 @@ const Options = forwardRefWithAs(function Options<
event.preventDefault() event.preventDefault()
dispatch({ type: ActionTypes.CloseListbox }) dispatch({ type: ActionTypes.CloseListbox })
if (state.activeOptionIndex !== null) { if (state.activeOptionIndex !== null) {
const { dataRef } = state.options[state.activeOptionIndex] let { dataRef } = state.options[state.activeOptionIndex]
state.propsRef.current.onChange(dataRef.current.value) state.propsRef.current.onChange(dataRef.current.value)
} }
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
@@ -414,16 +434,16 @@ const Options = forwardRefWithAs(function Options<
[d, dispatch, searchDisposables, state] [d, dispatch, searchDisposables, state]
) )
const labelledby = useComputed(() => state.labelRef.current?.id ?? state.buttonRef.current?.id, [ let labelledby = useComputed(() => state.labelRef.current?.id ?? state.buttonRef.current?.id, [
state.labelRef.current, state.labelRef.current,
state.buttonRef.current, state.buttonRef.current,
]) ])
const propsBag = React.useMemo<OptionsRenderPropArg>( let propsBag = useMemo<OptionsRenderPropArg>(
() => ({ open: state.listboxState === ListboxStates.Open }), () => ({ open: state.listboxState === ListboxStates.Open }),
[state] [state]
) )
const propsWeControl = { let propsWeControl = {
'aria-activedescendant': 'aria-activedescendant':
state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id, state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id,
'aria-labelledby': labelledby, 'aria-labelledby': labelledby,
@@ -433,7 +453,7 @@ const Options = forwardRefWithAs(function Options<
tabIndex: 0, tabIndex: 0,
ref: optionsRef, ref: optionsRef,
} }
const passthroughProps = props let passthroughProps = props
return render( return render(
{ ...passthroughProps, ...propsWeControl }, { ...passthroughProps, ...propsWeControl },
@@ -446,8 +466,12 @@ const Options = forwardRefWithAs(function Options<
// --- // ---
const DEFAULT_OPTION_TAG = 'li' let DEFAULT_OPTION_TAG = 'li' as const
type OptionRenderPropArg = { active: boolean; selected: boolean; disabled: boolean } interface OptionRenderPropArg {
active: boolean
selected: boolean
disabled: boolean
}
type ListboxOptionPropsWeControl = type ListboxOptionPropsWeControl =
| 'id' | 'id'
| 'role' | 'role'
@@ -461,7 +485,7 @@ type ListboxOptionPropsWeControl =
| 'onFocus' | 'onFocus'
function Option< function Option<
TTag extends React.ElementType = typeof DEFAULT_OPTION_TAG, TTag extends ElementType = typeof DEFAULT_OPTION_TAG,
// TODO: One day we will be able to infer this type from the generic in Listbox itself. // TODO: One day we will be able to infer this type from the generic in Listbox itself.
// But today is not that day.. // But today is not that day..
TType = Parameters<typeof Listbox>[0]['value'] TType = Parameters<typeof Listbox>[0]['value']
@@ -474,14 +498,14 @@ function Option<
className?: ((bag: OptionRenderPropArg) => string) | string className?: ((bag: OptionRenderPropArg) => string) | string
} }
) { ) {
const { disabled = false, value, className, ...passthroughProps } = props let { disabled = false, value, className, ...passthroughProps } = props
const [state, dispatch] = useListboxContext([Listbox.name, Option.name].join('.')) let [state, dispatch] = useListboxContext([Listbox.name, Option.name].join('.'))
const id = `headlessui-listbox-option-${useId()}` let id = `headlessui-listbox-option-${useId()}`
const active = let active =
state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false
const selected = state.propsRef.current.value === value let selected = state.propsRef.current.value === value
const bag = React.useRef<ListboxOptionDataRef['current']>({ disabled, value }) let bag = useRef<ListboxOptionDataRef['current']>({ disabled, value })
useIsoMorphicEffect(() => { useIsoMorphicEffect(() => {
bag.current.disabled = disabled bag.current.disabled = disabled
@@ -493,10 +517,7 @@ function Option<
bag.current.textValue = document.getElementById(id)?.textContent?.toLowerCase() bag.current.textValue = document.getElementById(id)?.textContent?.toLowerCase()
}, [bag, id]) }, [bag, id])
const select = React.useCallback(() => state.propsRef.current.onChange(value), [ let select = useCallback(() => state.propsRef.current.onChange(value), [state.propsRef, value])
state.propsRef,
value,
])
useIsoMorphicEffect(() => { useIsoMorphicEffect(() => {
dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag }) dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag })
@@ -513,12 +534,12 @@ function Option<
useIsoMorphicEffect(() => { useIsoMorphicEffect(() => {
if (state.listboxState !== ListboxStates.Open) return if (state.listboxState !== ListboxStates.Open) return
if (!active) return if (!active) return
const d = disposables() let d = disposables()
d.nextFrame(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) d.nextFrame(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }))
return d.dispose return d.dispose
}, [active, state.listboxState]) }, [active, state.listboxState])
const handleClick = React.useCallback( let handleClick = useCallback(
(event: { preventDefault: Function }) => { (event: { preventDefault: Function }) => {
if (disabled) return event.preventDefault() if (disabled) return event.preventDefault()
select() select()
@@ -528,29 +549,25 @@ function Option<
[dispatch, state.buttonRef, disabled, select] [dispatch, state.buttonRef, disabled, select]
) )
const handleFocus = React.useCallback(() => { let handleFocus = useCallback(() => {
if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
}, [disabled, id, dispatch]) }, [disabled, id, dispatch])
const handleMove = React.useCallback(() => { let handleMove = useCallback(() => {
if (disabled) return if (disabled) return
if (active) return if (active) return
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
}, [disabled, active, id, dispatch]) }, [disabled, active, id, dispatch])
const handleLeave = React.useCallback(() => { let handleLeave = useCallback(() => {
if (disabled) return if (disabled) return
if (!active) return if (!active) return
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing })
}, [disabled, active, dispatch]) }, [disabled, active, dispatch])
const propsBag = React.useMemo(() => ({ active, selected, disabled }), [ let propsBag = useMemo(() => ({ active, selected, disabled }), [active, selected, disabled])
active, let propsWeControl = {
selected,
disabled,
])
const propsWeControl = {
id, id,
role: 'option', role: 'option',
tabIndex: -1, tabIndex: -1,
@@ -1,4 +1,4 @@
import React from 'react' import React, { createElement } from 'react'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import { Menu } from './menu' import { Menu } from './menu'
@@ -48,7 +48,7 @@ describe('Safe guards', () => {
])( ])(
'should error when we are using a <%s /> without a parent <Menu />', 'should error when we are using a <%s /> without a parent <Menu />',
suppressConsoleLogs((name, Component) => { suppressConsoleLogs((name, Component) => {
expect(() => render(React.createElement(Component))).toThrowError( expect(() => render(createElement(Component))).toThrowError(
`<${name} /> is missing a parent <Menu /> component.` `<${name} /> is missing a parent <Menu /> component.`
) )
}) })
@@ -321,7 +321,7 @@ describe('Rendering composition', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// Verify correct classNames // Verify correct classNames
expect('' + items[0].classList).toEqual(JSON.stringify({ active: false, disabled: false })) expect('' + items[0].classList).toEqual(JSON.stringify({ active: false, disabled: false }))
@@ -379,7 +379,7 @@ describe('Rendering composition', () => {
await click(getMenuButton()) await click(getMenuButton())
// Verify items are buttons now // Verify items are buttons now
const items = getMenuItems() let items = getMenuItems()
items.forEach(item => assertMenuItem(item, { tag: 'button' })) items.forEach(item => assertMenuItem(item, { tag: 'button' }))
}) })
) )
@@ -422,7 +422,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
@@ -517,7 +517,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// Verify that the first non-disabled menu item is active // Verify that the first non-disabled menu item is active
assertMenuLinkedWithMenuItem(items[1]) assertMenuLinkedWithMenuItem(items[1])
@@ -554,7 +554,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// Verify that the first non-disabled menu item is active // Verify that the first non-disabled menu item is active
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -638,7 +638,7 @@ describe('Keyboard interactions', () => {
it( it(
'should be possible to close the menu with Enter and invoke the active menu item', 'should be possible to close the menu with Enter and invoke the active menu item',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
render( render(
<Menu> <Menu>
<Menu.Button>Trigger</Menu.Button> <Menu.Button>Trigger</Menu.Button>
@@ -665,7 +665,7 @@ describe('Keyboard interactions', () => {
assertMenuButton({ state: MenuState.Visible }) assertMenuButton({ state: MenuState.Visible })
// Activate the first menu item // Activate the first menu item
const items = getMenuItems() let items = getMenuItems()
await mouseMove(items[0]) await mouseMove(items[0])
// Close menu, and invoke the item // Close menu, and invoke the item
@@ -687,7 +687,7 @@ describe('Keyboard interactions', () => {
it( it(
'should be possible to use a button as a menu item and invoke it upon Enter', 'should be possible to use a button as a menu item and invoke it upon Enter',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
render( render(
<Menu> <Menu>
@@ -717,7 +717,7 @@ describe('Keyboard interactions', () => {
assertMenuButton({ state: MenuState.Visible }) assertMenuButton({ state: MenuState.Visible })
// Activate the second menu item // Activate the second menu item
const items = getMenuItems() let items = getMenuItems()
await mouseMove(items[1]) await mouseMove(items[1])
// Close menu, and invoke the item // Close menu, and invoke the item
@@ -786,7 +786,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -879,7 +879,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Space) await press(Keys.Space)
const items = getMenuItems() let items = getMenuItems()
// Verify that the first non-disabled menu item is active // Verify that the first non-disabled menu item is active
assertMenuLinkedWithMenuItem(items[1]) assertMenuLinkedWithMenuItem(items[1])
@@ -916,7 +916,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Space) await press(Keys.Space)
const items = getMenuItems() let items = getMenuItems()
// Verify that the first non-disabled menu item is active // Verify that the first non-disabled menu item is active
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -1000,7 +1000,7 @@ describe('Keyboard interactions', () => {
it( it(
'should be possible to close the menu with Space and invoke the active menu item', 'should be possible to close the menu with Space and invoke the active menu item',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
render( render(
<Menu> <Menu>
<Menu.Button>Trigger</Menu.Button> <Menu.Button>Trigger</Menu.Button>
@@ -1027,7 +1027,7 @@ describe('Keyboard interactions', () => {
assertMenuButton({ state: MenuState.Visible }) assertMenuButton({ state: MenuState.Visible })
// Activate the first menu item // Activate the first menu item
const items = getMenuItems() let items = getMenuItems()
await mouseMove(items[0]) await mouseMove(items[0])
// Close menu, and invoke the item // Close menu, and invoke the item
@@ -1124,7 +1124,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1173,7 +1173,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1224,7 +1224,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
@@ -1318,7 +1318,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1366,7 +1366,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[1]) assertMenuLinkedWithMenuItem(items[1])
@@ -1408,7 +1408,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -1452,7 +1452,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
@@ -1550,7 +1550,7 @@ describe('Keyboard interactions', () => {
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1588,7 +1588,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -1638,7 +1638,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -1679,7 +1679,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first item // We should be on the first item
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1715,7 +1715,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first item // We should be on the first item
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1756,7 +1756,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.End) await press(Keys.End)
const items = getMenuItems() let items = getMenuItems()
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
}) })
) )
@@ -1819,7 +1819,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first item // We should be on the first item
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1855,7 +1855,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first item // We should be on the first item
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1896,7 +1896,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageDown) await press(Keys.PageDown)
const items = getMenuItems() let items = getMenuItems()
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
}) })
) )
@@ -1959,7 +1959,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the last item // We should be on the last item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -1998,7 +1998,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.Home) await press(Keys.Home)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first non-disabled item // We should be on the first non-disabled item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2035,7 +2035,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.Home) await press(Keys.Home)
const items = getMenuItems() let items = getMenuItems()
assertMenuLinkedWithMenuItem(items[3]) assertMenuLinkedWithMenuItem(items[3])
}) })
) )
@@ -2098,7 +2098,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the last item // We should be on the last item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2137,7 +2137,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageUp) await press(Keys.PageUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first non-disabled item // We should be on the first non-disabled item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2174,7 +2174,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageUp) await press(Keys.PageUp)
const items = getMenuItems() let items = getMenuItems()
assertMenuLinkedWithMenuItem(items[3]) assertMenuLinkedWithMenuItem(items[3])
}) })
) )
@@ -2234,7 +2234,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// We should be able to go to the second item // We should be able to go to the second item
await type(word('bob')) await type(word('bob'))
@@ -2270,7 +2270,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the last item // We should be on the last item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2309,7 +2309,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the last item // We should be on the last item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2350,7 +2350,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the last item // We should be on the last item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2398,7 +2398,7 @@ describe('Mouse interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
}) })
@@ -2601,7 +2601,7 @@ describe('Mouse interactions', () => {
</div> </div>
) )
const [button1, button2] = getMenuButtons() let [button1, button2] = getMenuButtons()
// Click the first menu button // Click the first menu button
await click(button1) await click(button1)
@@ -2637,7 +2637,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// We should be able to go to the second item // We should be able to go to the second item
await mouseMove(items[1]) await mouseMove(items[1])
assertMenuLinkedWithMenuItem(items[1]) assertMenuLinkedWithMenuItem(items[1])
@@ -2669,7 +2669,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// We should be able to go to the second item // We should be able to go to the second item
await mouseMove(items[1]) await mouseMove(items[1])
assertMenuLinkedWithMenuItem(items[1]) assertMenuLinkedWithMenuItem(items[1])
@@ -2693,7 +2693,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// We should be able to go to the second item // We should be able to go to the second item
await mouseMove(items[1]) await mouseMove(items[1])
@@ -2725,7 +2725,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
await mouseMove(items[1]) await mouseMove(items[1])
assertNoActiveMenuItem() assertNoActiveMenuItem()
@@ -2751,7 +2751,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// Try to hover over item 1, which is disabled // Try to hover over item 1, which is disabled
await mouseMove(items[1]) await mouseMove(items[1])
@@ -2778,7 +2778,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// We should be able to go to the second item // We should be able to go to the second item
await mouseMove(items[1]) await mouseMove(items[1])
@@ -2822,7 +2822,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// Try to hover over item 1, which is disabled // Try to hover over item 1, which is disabled
await mouseMove(items[1]) await mouseMove(items[1])
@@ -2836,7 +2836,7 @@ describe('Mouse interactions', () => {
it( it(
'should be possible to click a menu item, which closes the menu', 'should be possible to click a menu item, which closes the menu',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
render( render(
<Menu> <Menu>
<Menu.Button>Trigger</Menu.Button> <Menu.Button>Trigger</Menu.Button>
@@ -2854,7 +2854,7 @@ describe('Mouse interactions', () => {
await click(getMenuButton()) await click(getMenuButton())
assertMenu({ state: MenuState.Visible }) assertMenu({ state: MenuState.Visible })
const items = getMenuItems() let items = getMenuItems()
// We should be able to click the first item // We should be able to click the first item
await click(items[1]) await click(items[1])
@@ -2867,7 +2867,7 @@ describe('Mouse interactions', () => {
it( it(
'should be possible to click a menu item, which closes the menu and invokes the @click handler', 'should be possible to click a menu item, which closes the menu and invokes the @click handler',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
render( render(
<Menu> <Menu>
<Menu.Button>Trigger</Menu.Button> <Menu.Button>Trigger</Menu.Button>
@@ -2926,7 +2926,7 @@ describe('Mouse interactions', () => {
await click(getMenuButton()) await click(getMenuButton())
assertMenu({ state: MenuState.Visible }) assertMenu({ state: MenuState.Visible })
const items = getMenuItems() let items = getMenuItems()
// We should be able to click the first item // We should be able to click the first item
await click(items[1]) await click(items[1])
@@ -2952,7 +2952,7 @@ describe('Mouse interactions', () => {
await click(getMenuButton()) await click(getMenuButton())
assertMenu({ state: MenuState.Visible }) assertMenu({ state: MenuState.Visible })
const items = getMenuItems() let items = getMenuItems()
// Verify that nothing is active yet // Verify that nothing is active yet
assertNoActiveMenuItem() assertNoActiveMenuItem()
@@ -2983,7 +2983,7 @@ describe('Mouse interactions', () => {
await click(getMenuButton()) await click(getMenuButton())
assertMenu({ state: MenuState.Visible }) assertMenu({ state: MenuState.Visible })
const items = getMenuItems() let items = getMenuItems()
// We should not be able to focus the first item // We should not be able to focus the first item
await focus(items[1]) await focus(items[1])
@@ -2994,7 +2994,7 @@ describe('Mouse interactions', () => {
it( it(
'should not be possible to activate a disabled item', 'should not be possible to activate a disabled item',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
render( render(
<Menu> <Menu>
@@ -3017,7 +3017,7 @@ describe('Mouse interactions', () => {
await click(getMenuButton()) await click(getMenuButton())
assertMenu({ state: MenuState.Visible }) assertMenu({ state: MenuState.Visible })
const items = getMenuItems() let items = getMenuItems()
await focus(items[0]) await focus(items[0])
await click(items[1]) await click(items[1])
@@ -1,5 +1,23 @@
// WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#menubutton // WAI-ARIA: https://www.w3.org/TR/wai-aria-practices-1.2/#menubutton
import * as React from 'react' import React, {
createContext,
createRef,
useCallback,
useContext,
useEffect,
useMemo,
useReducer,
useRef,
Fragment,
// Types
Dispatch,
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
} from 'react'
import { Props } from '../../types' import { Props } from '../../types'
import { match } from '../../utils/match' import { match } from '../../utils/match'
@@ -19,12 +37,12 @@ enum MenuStates {
Closed, Closed,
} }
type MenuItemDataRef = React.MutableRefObject<{ textValue?: string; disabled: boolean }> type MenuItemDataRef = MutableRefObject<{ textValue?: string; disabled: boolean }>
type StateDefinition = { interface StateDefinition {
menuState: MenuStates menuState: MenuStates
buttonRef: React.MutableRefObject<HTMLButtonElement | null> buttonRef: MutableRefObject<HTMLButtonElement | null>
itemsRef: React.MutableRefObject<HTMLDivElement | null> itemsRef: MutableRefObject<HTMLDivElement | null>
items: { id: string; dataRef: MenuItemDataRef }[] items: { id: string; dataRef: MenuItemDataRef }[]
searchQuery: string searchQuery: string
activeItemIndex: number | null activeItemIndex: number | null
@@ -52,7 +70,7 @@ type Actions =
| { type: ActionTypes.RegisterItem; id: string; dataRef: MenuItemDataRef } | { type: ActionTypes.RegisterItem; id: string; dataRef: MenuItemDataRef }
| { type: ActionTypes.UnregisterItem; id: string } | { type: ActionTypes.UnregisterItem; id: string }
const reducers: { let reducers: {
[P in ActionTypes]: ( [P in ActionTypes]: (
state: StateDefinition, state: StateDefinition,
action: Extract<Actions, { type: P }> action: Extract<Actions, { type: P }>
@@ -65,7 +83,7 @@ const reducers: {
}), }),
[ActionTypes.OpenMenu]: state => ({ ...state, menuState: MenuStates.Open }), [ActionTypes.OpenMenu]: state => ({ ...state, menuState: MenuStates.Open }),
[ActionTypes.GoToItem]: (state, action) => { [ActionTypes.GoToItem]: (state, action) => {
const activeItemIndex = calculateActiveIndex(action, { let activeItemIndex = calculateActiveIndex(action, {
resolveItems: () => state.items, resolveItems: () => state.items,
resolveActiveIndex: () => state.activeItemIndex, resolveActiveIndex: () => state.activeItemIndex,
resolveId: item => item.id, resolveId: item => item.id,
@@ -76,8 +94,8 @@ const reducers: {
return { ...state, searchQuery: '', activeItemIndex } return { ...state, searchQuery: '', activeItemIndex }
}, },
[ActionTypes.Search]: (state, action) => { [ActionTypes.Search]: (state, action) => {
const searchQuery = state.searchQuery + action.value let searchQuery = state.searchQuery + action.value
const match = state.items.findIndex( let match = state.items.findIndex(
item => item =>
item.dataRef.current.textValue?.startsWith(searchQuery) && !item.dataRef.current.disabled item.dataRef.current.textValue?.startsWith(searchQuery) && !item.dataRef.current.disabled
) )
@@ -91,11 +109,10 @@ const reducers: {
items: [...state.items, { id: action.id, dataRef: action.dataRef }], items: [...state.items, { id: action.id, dataRef: action.dataRef }],
}), }),
[ActionTypes.UnregisterItem]: (state, action) => { [ActionTypes.UnregisterItem]: (state, action) => {
const nextItems = state.items.slice() let nextItems = state.items.slice()
const currentActiveItem = let currentActiveItem = state.activeItemIndex !== null ? nextItems[state.activeItemIndex] : null
state.activeItemIndex !== null ? nextItems[state.activeItemIndex] : null
const idx = nextItems.findIndex(a => a.id === action.id) let idx = nextItems.findIndex(a => a.id === action.id)
if (idx !== -1) nextItems.splice(idx, 1) if (idx !== -1) nextItems.splice(idx, 1)
@@ -114,13 +131,13 @@ const reducers: {
}, },
} }
const MenuContext = React.createContext<[StateDefinition, React.Dispatch<Actions>] | null>(null) let MenuContext = createContext<[StateDefinition, Dispatch<Actions>] | null>(null)
MenuContext.displayName = 'MenuContext' MenuContext.displayName = 'MenuContext'
function useMenuContext(component: string) { function useMenuContext(component: string) {
const context = React.useContext(MenuContext) let context = useContext(MenuContext)
if (context === null) { if (context === null) {
const err = new Error(`<${component} /> is missing a parent <${Menu.name} /> component.`) let err = new Error(`<${component} /> is missing a parent <${Menu.name} /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useMenuContext) if (Error.captureStackTrace) Error.captureStackTrace(err, useMenuContext)
throw err throw err
} }
@@ -133,26 +150,28 @@ function stateReducer(state: StateDefinition, action: Actions) {
// --- // ---
const DEFAULT_MENU_TAG = React.Fragment let DEFAULT_MENU_TAG = Fragment
type MenuRenderPropArg = { open: boolean } interface MenuRenderPropArg {
open: boolean
}
export function Menu<TTag extends React.ElementType = typeof DEFAULT_MENU_TAG>( export function Menu<TTag extends ElementType = typeof DEFAULT_MENU_TAG>(
props: Props<TTag, MenuRenderPropArg> props: Props<TTag, MenuRenderPropArg>
) { ) {
const reducerBag = React.useReducer(stateReducer, { let reducerBag = useReducer(stateReducer, {
menuState: MenuStates.Closed, menuState: MenuStates.Closed,
buttonRef: React.createRef(), buttonRef: createRef(),
itemsRef: React.createRef(), itemsRef: createRef(),
items: [], items: [],
searchQuery: '', searchQuery: '',
activeItemIndex: null, activeItemIndex: null,
} as StateDefinition) } as StateDefinition)
const [{ menuState, itemsRef, buttonRef }, dispatch] = reducerBag let [{ menuState, itemsRef, buttonRef }, dispatch] = reducerBag
React.useEffect(() => { useEffect(() => {
function handler(event: MouseEvent) { function handler(event: MouseEvent) {
const target = event.target as HTMLElement let target = event.target as HTMLElement
const active = document.activeElement let active = document.activeElement
if (menuState !== MenuStates.Open) return if (menuState !== MenuStates.Open) return
if (buttonRef.current?.contains(target)) return if (buttonRef.current?.contains(target)) return
@@ -166,7 +185,7 @@ export function Menu<TTag extends React.ElementType = typeof DEFAULT_MENU_TAG>(
return () => window.removeEventListener('mousedown', handler) return () => window.removeEventListener('mousedown', handler)
}, [menuState, itemsRef, buttonRef, dispatch]) }, [menuState, itemsRef, buttonRef, dispatch])
const propsBag = React.useMemo(() => ({ open: menuState === MenuStates.Open }), [menuState]) let propsBag = useMemo(() => ({ open: menuState === MenuStates.Open }), [menuState])
return ( return (
<MenuContext.Provider value={reducerBag}> <MenuContext.Provider value={reducerBag}>
@@ -177,8 +196,10 @@ export function Menu<TTag extends React.ElementType = typeof DEFAULT_MENU_TAG>(
// --- // ---
const DEFAULT_BUTTON_TAG = 'button' let DEFAULT_BUTTON_TAG = 'button' as const
type ButtonRenderPropArg = { open: boolean } interface ButtonRenderPropArg {
open: boolean
}
type ButtonPropsWeControl = type ButtonPropsWeControl =
| 'id' | 'id'
| 'type' | 'type'
@@ -188,20 +209,18 @@ type ButtonPropsWeControl =
| 'onKeyDown' | 'onKeyDown'
| 'onClick' | 'onClick'
const Button = forwardRefWithAs(function Button< let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
TTag extends React.ElementType = typeof DEFAULT_BUTTON_TAG
>(
props: Props<TTag, ButtonRenderPropArg, ButtonPropsWeControl>, props: Props<TTag, ButtonRenderPropArg, ButtonPropsWeControl>,
ref: React.Ref<HTMLButtonElement> ref: Ref<HTMLButtonElement>
) { ) {
const [state, dispatch] = useMenuContext([Menu.name, Button.name].join('.')) let [state, dispatch] = useMenuContext([Menu.name, Button.name].join('.'))
const buttonRef = useSyncRefs(state.buttonRef, ref) let buttonRef = useSyncRefs(state.buttonRef, ref)
const id = `headlessui-menu-button-${useId()}` let id = `headlessui-menu-button-${useId()}`
const d = useDisposables() let d = useDisposables()
const handleKeyDown = React.useCallback( let handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => { (event: ReactKeyboardEvent<HTMLButtonElement>) => {
switch (event.key) { switch (event.key) {
// Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13
@@ -229,8 +248,8 @@ const Button = forwardRefWithAs(function Button<
[dispatch, state, d] [dispatch, state, d]
) )
const handleClick = React.useCallback( let handleClick = useCallback(
(event: React.MouseEvent) => { (event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
if (props.disabled) return if (props.disabled) return
if (state.menuState === MenuStates.Open) { if (state.menuState === MenuStates.Open) {
@@ -245,9 +264,9 @@ const Button = forwardRefWithAs(function Button<
[dispatch, d, state, props.disabled] [dispatch, d, state, props.disabled]
) )
const propsBag = React.useMemo(() => ({ open: state.menuState === MenuStates.Open }), [state]) let propsBag = useMemo(() => ({ open: state.menuState === MenuStates.Open }), [state])
const passthroughProps = props let passthroughProps = props
const propsWeControl = { let propsWeControl = {
ref: buttonRef, ref: buttonRef,
id, id,
type: 'button', type: 'button',
@@ -263,8 +282,10 @@ const Button = forwardRefWithAs(function Button<
// --- // ---
const DEFAULT_ITEMS_TAG = 'div' let DEFAULT_ITEMS_TAG = 'div' as const
type ItemsRenderPropArg = { open: boolean } interface ItemsRenderPropArg {
open: boolean
}
type ItemsPropsWeControl = type ItemsPropsWeControl =
| 'aria-activedescendant' | 'aria-activedescendant'
| 'aria-labelledby' | 'aria-labelledby'
@@ -273,23 +294,21 @@ type ItemsPropsWeControl =
| 'role' | 'role'
| 'tabIndex' | 'tabIndex'
const ItemsRenderFeatures = Features.RenderStrategy | Features.Static let ItemsRenderFeatures = Features.RenderStrategy | Features.Static
const Items = forwardRefWithAs(function Items< let Items = forwardRefWithAs(function Items<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
TTag extends React.ElementType = typeof DEFAULT_ITEMS_TAG
>(
props: Props<TTag, ItemsRenderPropArg, ItemsPropsWeControl> & props: Props<TTag, ItemsRenderPropArg, ItemsPropsWeControl> &
PropsForFeatures<typeof ItemsRenderFeatures>, PropsForFeatures<typeof ItemsRenderFeatures>,
ref: React.Ref<HTMLDivElement> ref: Ref<HTMLDivElement>
) { ) {
const [state, dispatch] = useMenuContext([Menu.name, Items.name].join('.')) let [state, dispatch] = useMenuContext([Menu.name, Items.name].join('.'))
const itemsRef = useSyncRefs(state.itemsRef, ref) let itemsRef = useSyncRefs(state.itemsRef, ref)
const id = `headlessui-menu-items-${useId()}` let id = `headlessui-menu-items-${useId()}`
const searchDisposables = useDisposables() let searchDisposables = useDisposables()
const handleKeyDown = React.useCallback( let handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: ReactKeyboardEvent<HTMLDivElement>) => {
searchDisposables.dispose() searchDisposables.dispose()
switch (event.key) { switch (event.key) {
@@ -306,7 +325,7 @@ const Items = forwardRefWithAs(function Items<
event.preventDefault() event.preventDefault()
dispatch({ type: ActionTypes.CloseMenu }) dispatch({ type: ActionTypes.CloseMenu })
if (state.activeItemIndex !== null) { if (state.activeItemIndex !== null) {
const { id } = state.items[state.activeItemIndex] let { id } = state.items[state.activeItemIndex]
document.getElementById(id)?.click() document.getElementById(id)?.click()
} }
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
@@ -350,8 +369,8 @@ const Items = forwardRefWithAs(function Items<
[dispatch, searchDisposables, state] [dispatch, searchDisposables, state]
) )
const propsBag = React.useMemo(() => ({ open: state.menuState === MenuStates.Open }), [state]) let propsBag = useMemo(() => ({ open: state.menuState === MenuStates.Open }), [state])
const propsWeControl = { let propsWeControl = {
'aria-activedescendant': 'aria-activedescendant':
state.activeItemIndex === null ? undefined : state.items[state.activeItemIndex]?.id, state.activeItemIndex === null ? undefined : state.items[state.activeItemIndex]?.id,
'aria-labelledby': state.buttonRef.current?.id, 'aria-labelledby': state.buttonRef.current?.id,
@@ -361,7 +380,7 @@ const Items = forwardRefWithAs(function Items<
tabIndex: 0, tabIndex: 0,
ref: itemsRef, ref: itemsRef,
} }
const passthroughProps = props let passthroughProps = props
return render( return render(
{ ...passthroughProps, ...propsWeControl }, { ...passthroughProps, ...propsWeControl },
@@ -374,8 +393,11 @@ const Items = forwardRefWithAs(function Items<
// --- // ---
const DEFAULT_ITEM_TAG = React.Fragment let DEFAULT_ITEM_TAG = Fragment
type ItemRenderPropArg = { active: boolean; disabled: boolean } interface ItemRenderPropArg {
active: boolean
disabled: boolean
}
type MenuItemPropsWeControl = type MenuItemPropsWeControl =
| 'id' | 'id'
| 'role' | 'role'
@@ -387,7 +409,7 @@ type MenuItemPropsWeControl =
| 'onMouseMove' | 'onMouseMove'
| 'onFocus' | 'onFocus'
function Item<TTag extends React.ElementType = typeof DEFAULT_ITEM_TAG>( function Item<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
props: Props<TTag, ItemRenderPropArg, MenuItemPropsWeControl | 'className'> & { props: Props<TTag, ItemRenderPropArg, MenuItemPropsWeControl | 'className'> & {
disabled?: boolean disabled?: boolean
onClick?: (event: { preventDefault: Function }) => void onClick?: (event: { preventDefault: Function }) => void
@@ -396,13 +418,12 @@ function Item<TTag extends React.ElementType = typeof DEFAULT_ITEM_TAG>(
className?: ((bag: ItemRenderPropArg) => string) | string className?: ((bag: ItemRenderPropArg) => string) | string
} }
) { ) {
const { disabled = false, className, onClick, ...passthroughProps } = props let { disabled = false, className, onClick, ...passthroughProps } = props
const [state, dispatch] = useMenuContext([Menu.name, Item.name].join('.')) let [state, dispatch] = useMenuContext([Menu.name, Item.name].join('.'))
const id = `headlessui-menu-item-${useId()}` let id = `headlessui-menu-item-${useId()}`
const active = let active = state.activeItemIndex !== null ? state.items[state.activeItemIndex].id === id : false
state.activeItemIndex !== null ? state.items[state.activeItemIndex].id === id : false
const bag = React.useRef<MenuItemDataRef['current']>({ disabled }) let bag = useRef<MenuItemDataRef['current']>({ disabled })
useIsoMorphicEffect(() => { useIsoMorphicEffect(() => {
bag.current.disabled = disabled bag.current.disabled = disabled
@@ -417,8 +438,8 @@ function Item<TTag extends React.ElementType = typeof DEFAULT_ITEM_TAG>(
return () => dispatch({ type: ActionTypes.UnregisterItem, id }) return () => dispatch({ type: ActionTypes.UnregisterItem, id })
}, [bag, id]) }, [bag, id])
const handleClick = React.useCallback( let handleClick = useCallback(
(event: React.MouseEvent) => { (event: MouseEvent) => {
if (disabled) return event.preventDefault() if (disabled) return event.preventDefault()
dispatch({ type: ActionTypes.CloseMenu }) dispatch({ type: ActionTypes.CloseMenu })
disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true })) disposables().nextFrame(() => state.buttonRef.current?.focus({ preventScroll: true }))
@@ -427,25 +448,25 @@ function Item<TTag extends React.ElementType = typeof DEFAULT_ITEM_TAG>(
[dispatch, state.buttonRef, disabled, onClick] [dispatch, state.buttonRef, disabled, onClick]
) )
const handleFocus = React.useCallback(() => { let handleFocus = useCallback(() => {
if (disabled) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) if (disabled) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id }) dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id })
}, [disabled, id, dispatch]) }, [disabled, id, dispatch])
const handleMove = React.useCallback(() => { let handleMove = useCallback(() => {
if (disabled) return if (disabled) return
if (active) return if (active) return
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id }) dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id })
}, [disabled, active, id, dispatch]) }, [disabled, active, id, dispatch])
const handleLeave = React.useCallback(() => { let handleLeave = useCallback(() => {
if (disabled) return if (disabled) return
if (!active) return if (!active) return
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
}, [disabled, active, dispatch]) }, [disabled, active, dispatch])
const propsBag = React.useMemo(() => ({ active, disabled }), [active, disabled]) let propsBag = useMemo(() => ({ active, disabled }), [active, disabled])
const propsWeControl = { let propsWeControl = {
id, id,
role: 'menuitem', role: 'menuitem',
tabIndex: -1, tabIndex: -1,
@@ -1,4 +1,4 @@
import React from 'react' import React, { createElement, useState } from 'react'
import { render } from '@testing-library/react' import { render } from '@testing-library/react'
import { Switch } from './switch' import { Switch } from './switch'
@@ -18,7 +18,7 @@ describe('Safe guards', () => {
it.each([['Switch.Label', Switch.Label]])( it.each([['Switch.Label', Switch.Label]])(
'should error when we are using a <%s /> without a parent <Switch.Group />', 'should error when we are using a <%s /> without a parent <Switch.Group />',
suppressConsoleLogs((name, Component) => { suppressConsoleLogs((name, Component) => {
expect(() => render(React.createElement(Component))).toThrowError( expect(() => render(createElement(Component))).toThrowError(
`<${name} /> is missing a parent <Switch.Group /> component.` `<${name} /> is missing a parent <Switch.Group /> component.`
) )
}) })
@@ -120,9 +120,9 @@ describe('Render composition', () => {
describe('Keyboard interactions', () => { describe('Keyboard interactions', () => {
describe('`Space` key', () => { describe('`Space` key', () => {
it('should be possible to toggle the Switch with Space', async () => { it('should be possible to toggle the Switch with Space', async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
function Example() { function Example() {
const [state, setState] = React.useState(false) let [state, setState] = useState(false)
return ( return (
<Switch <Switch
checked={state} checked={state}
@@ -158,7 +158,7 @@ describe('Keyboard interactions', () => {
describe('`Enter` key', () => { describe('`Enter` key', () => {
it('should not be possible to use Enter to toggle the Switch', async () => { it('should not be possible to use Enter to toggle the Switch', async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
render(<Switch checked={false} onChange={handleChange} />) render(<Switch checked={false} onChange={handleChange} />)
// Ensure checkbox is off // Ensure checkbox is off
@@ -203,9 +203,9 @@ describe('Keyboard interactions', () => {
describe('Mouse interactions', () => { describe('Mouse interactions', () => {
it('should be possible to toggle the Switch with a click', async () => { it('should be possible to toggle the Switch with a click', async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
function Example() { function Example() {
const [state, setState] = React.useState(false) let [state, setState] = useState(false)
return ( return (
<Switch <Switch
checked={state} checked={state}
@@ -236,9 +236,9 @@ describe('Mouse interactions', () => {
}) })
it('should be possible to toggle the Switch with a click on the Label', async () => { it('should be possible to toggle the Switch with a click on the Label', async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
function Example() { function Example() {
const [state, setState] = React.useState(false) let [state, setState] = useState(false)
return ( return (
<Switch.Group> <Switch.Group>
<Switch <Switch
@@ -1,4 +1,16 @@
import * as React from 'react' import React, {
createContext,
useCallback,
useContext,
useMemo,
useState,
Fragment,
// Types
ElementType,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
} from 'react'
import { Props } from '../../types' import { Props } from '../../types'
import { render } from '../../utils/render' import { render } from '../../utils/render'
@@ -7,7 +19,7 @@ import { Keys } from '../keyboard'
import { resolvePropValue } from '../../utils/resolve-prop-value' import { resolvePropValue } from '../../utils/resolve-prop-value'
import { isDisabledReactIssue7711 } from '../../utils/bugs' import { isDisabledReactIssue7711 } from '../../utils/bugs'
type StateDefinition = { interface StateDefinition {
switch: HTMLButtonElement | null switch: HTMLButtonElement | null
label: HTMLLabelElement | null label: HTMLLabelElement | null
@@ -15,13 +27,13 @@ type StateDefinition = {
setLabel(element: HTMLLabelElement): void setLabel(element: HTMLLabelElement): void
} }
const GroupContext = React.createContext<StateDefinition | null>(null) let GroupContext = createContext<StateDefinition | null>(null)
GroupContext.displayName = 'GroupContext' GroupContext.displayName = 'GroupContext'
function useGroupContext(component: string) { function useGroupContext(component: string) {
const context = React.useContext(GroupContext) let context = useContext(GroupContext)
if (context === null) { if (context === null) {
const err = new Error(`<${component} /> is missing a parent <Switch.Group /> component.`) let err = new Error(`<${component} /> is missing a parent <Switch.Group /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useGroupContext) if (Error.captureStackTrace) Error.captureStackTrace(err, useGroupContext)
throw err throw err
} }
@@ -30,13 +42,13 @@ function useGroupContext(component: string) {
// --- // ---
const DEFAULT_GROUP_TAG = React.Fragment let DEFAULT_GROUP_TAG = Fragment
function Group<TTag extends React.ElementType = typeof DEFAULT_GROUP_TAG>(props: Props<TTag>) { function Group<TTag extends ElementType = typeof DEFAULT_GROUP_TAG>(props: Props<TTag>) {
const [switchElement, setSwitchElement] = React.useState<HTMLButtonElement | null>(null) let [switchElement, setSwitchElement] = useState<HTMLButtonElement | null>(null)
const [labelElement, setLabelElement] = React.useState<HTMLLabelElement | null>(null) let [labelElement, setLabelElement] = useState<HTMLLabelElement | null>(null)
const context = React.useMemo<StateDefinition>( let context = useMemo<StateDefinition>(
() => ({ () => ({
switch: switchElement, switch: switchElement,
label: labelElement, label: labelElement,
@@ -45,6 +57,7 @@ function Group<TTag extends React.ElementType = typeof DEFAULT_GROUP_TAG>(props:
}), }),
[switchElement, setSwitchElement, labelElement, setLabelElement] [switchElement, setSwitchElement, labelElement, setLabelElement]
) )
return ( return (
<GroupContext.Provider value={context}> <GroupContext.Provider value={context}>
{render(props, {}, DEFAULT_GROUP_TAG)} {render(props, {}, DEFAULT_GROUP_TAG)}
@@ -54,8 +67,10 @@ function Group<TTag extends React.ElementType = typeof DEFAULT_GROUP_TAG>(props:
// --- // ---
const DEFAULT_SWITCH_TAG = 'button' let DEFAULT_SWITCH_TAG = 'button' as const
type SwitchRenderPropArg = { checked: boolean } interface SwitchRenderPropArg {
checked: boolean
}
type SwitchPropsWeControl = type SwitchPropsWeControl =
| 'id' | 'id'
| 'role' | 'role'
@@ -65,7 +80,7 @@ type SwitchPropsWeControl =
| 'onKeyUp' | 'onKeyUp'
| 'onKeyPress' | 'onKeyPress'
export function Switch<TTag extends React.ElementType = typeof DEFAULT_SWITCH_TAG>( export function Switch<TTag extends ElementType = typeof DEFAULT_SWITCH_TAG>(
props: Props< props: Props<
TTag, TTag,
SwitchRenderPropArg, SwitchRenderPropArg,
@@ -78,21 +93,21 @@ export function Switch<TTag extends React.ElementType = typeof DEFAULT_SWITCH_TA
className?: ((bag: SwitchRenderPropArg) => string) | string className?: ((bag: SwitchRenderPropArg) => string) | string
} }
) { ) {
const { checked, onChange, className, ...passThroughProps } = props let { checked, onChange, className, ...passThroughProps } = props
const id = `headlessui-switch-${useId()}` let id = `headlessui-switch-${useId()}`
const groupContext = React.useContext(GroupContext) let groupContext = useContext(GroupContext)
const toggle = React.useCallback(() => onChange(!checked), [onChange, checked]) let toggle = useCallback(() => onChange(!checked), [onChange, checked])
const handleClick = React.useCallback( let handleClick = useCallback(
(event: React.MouseEvent) => { (event: ReactMouseEvent) => {
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
event.preventDefault() event.preventDefault()
toggle() toggle()
}, },
[toggle] [toggle]
) )
const handleKeyUp = React.useCallback( let handleKeyUp = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => { (event: ReactKeyboardEvent<HTMLElement>) => {
if (event.key !== Keys.Tab) event.preventDefault() if (event.key !== Keys.Tab) event.preventDefault()
if (event.key === Keys.Space) toggle() if (event.key === Keys.Space) toggle()
}, },
@@ -100,13 +115,13 @@ export function Switch<TTag extends React.ElementType = typeof DEFAULT_SWITCH_TA
) )
// This is needed so that we can "cancel" the click event when we use the `Enter` key on a button. // This is needed so that we can "cancel" the click event when we use the `Enter` key on a button.
const handleKeyPress = React.useCallback( let handleKeyPress = useCallback(
(event: React.KeyboardEvent<HTMLElement>) => event.preventDefault(), (event: ReactKeyboardEvent<HTMLElement>) => event.preventDefault(),
[] []
) )
const propsBag = React.useMemo<SwitchRenderPropArg>(() => ({ checked }), [checked]) let propsBag = useMemo<SwitchRenderPropArg>(() => ({ checked }), [checked])
const propsWeControl = { let propsWeControl = {
id, id,
ref: groupContext === null ? undefined : groupContext.setSwitch, ref: groupContext === null ? undefined : groupContext.setSwitch,
role: 'switch', role: 'switch',
@@ -128,23 +143,23 @@ export function Switch<TTag extends React.ElementType = typeof DEFAULT_SWITCH_TA
// --- // ---
const DEFAULT_LABEL_TAG = 'label' let DEFAULT_LABEL_TAG = 'label' as const
type LabelRenderPropArg = {} interface LabelRenderPropArg {}
type LabelPropsWeControl = 'id' | 'ref' | 'onClick' type LabelPropsWeControl = 'id' | 'ref' | 'onClick'
function Label<TTag extends React.ElementType = typeof DEFAULT_LABEL_TAG>( function Label<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl> props: Props<TTag, LabelRenderPropArg, LabelPropsWeControl>
) { ) {
const state = useGroupContext([Switch.name, Label.name].join('.')) let state = useGroupContext([Switch.name, Label.name].join('.'))
const id = `headlessui-switch-label-${useId()}` let id = `headlessui-switch-label-${useId()}`
const handleClick = React.useCallback(() => { let handleClick = useCallback(() => {
if (!state.switch) return if (!state.switch) return
state.switch.click() state.switch.click()
state.switch.focus({ preventScroll: true }) state.switch.focus({ preventScroll: true })
}, [state.switch]) }, [state.switch])
const propsWeControl = { ref: state.setLabel, id, onClick: handleClick } let propsWeControl = { ref: state.setLabel, id, onClick: handleClick }
return render({ ...props, ...propsWeControl }, {}, DEFAULT_LABEL_TAG) return render({ ...props, ...propsWeControl }, {}, DEFAULT_LABEL_TAG)
} }
@@ -1,4 +1,4 @@
import * as React from 'react' import React, { Fragment, useState, useRef, useLayoutEffect } from 'react'
import { render, fireEvent } from '@testing-library/react' import { render, fireEvent } from '@testing-library/react'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
@@ -41,7 +41,7 @@ it(
describe('Setup API', () => { describe('Setup API', () => {
describe('shallow', () => { describe('shallow', () => {
it('should render a div and its children by default', () => { it('should render a div and its children by default', () => {
const { container } = render(<Transition show={true}>Children</Transition>) let { container } = render(<Transition show={true}>Children</Transition>)
expect(container.firstChild).toMatchInlineSnapshot(` expect(container.firstChild).toMatchInlineSnapshot(`
<div> <div>
@@ -51,7 +51,7 @@ describe('Setup API', () => {
}) })
it('should passthrough all the props (that we do not use internally)', () => { it('should passthrough all the props (that we do not use internally)', () => {
const { container } = render( let { container } = render(
<Transition show={true} id="root" className="text-blue-400"> <Transition show={true} id="root" className="text-blue-400">
Children Children
</Transition> </Transition>
@@ -68,7 +68,7 @@ describe('Setup API', () => {
}) })
it('should render another component if the `as` prop is used and its children by default', () => { it('should render another component if the `as` prop is used and its children by default', () => {
const { container } = render( let { container } = render(
<Transition show={true} as="a"> <Transition show={true} as="a">
Children Children
</Transition> </Transition>
@@ -82,7 +82,7 @@ describe('Setup API', () => {
}) })
it('should passthrough all the props (that we do not use internally) even when using an `as` prop', () => { it('should passthrough all the props (that we do not use internally) even when using an `as` prop', () => {
const { container } = render( let { container } = render(
<Transition show={true} as="a" href="/" className="text-blue-400"> <Transition show={true} as="a" href="/" className="text-blue-400">
Children Children
</Transition> </Transition>
@@ -99,13 +99,13 @@ describe('Setup API', () => {
}) })
it('should render nothing when the show prop is false', () => { it('should render nothing when the show prop is false', () => {
const { container } = render(<Transition show={false}>Children</Transition>) let { container } = render(<Transition show={false}>Children</Transition>)
expect(container.firstChild).toMatchInlineSnapshot(`null`) expect(container.firstChild).toMatchInlineSnapshot(`null`)
}) })
it('should be possible to change the underlying DOM tag', () => { it('should be possible to change the underlying DOM tag', () => {
const { container } = render( let { container } = render(
<Transition show={true} as="a"> <Transition show={true} as="a">
Children Children
</Transition> </Transition>
@@ -119,8 +119,8 @@ describe('Setup API', () => {
}) })
it('should be possible to use a render prop', () => { it('should be possible to use a render prop', () => {
const { container } = render( let { container } = render(
<Transition show={true} as={React.Fragment}> <Transition show={true} as={Fragment}>
{() => <span>Children</span>} {() => <span>Children</span>}
</Transition> </Transition>
) )
@@ -143,7 +143,7 @@ describe('Setup API', () => {
expect(() => { expect(() => {
render( render(
<Transition show={true} as={React.Fragment}> <Transition show={true} as={Fragment}>
{() => <Dummy />} {() => <Dummy />}
</Transition> </Transition>
) )
@@ -182,7 +182,7 @@ describe('Setup API', () => {
}) })
it('should be possible to nest transition components', () => { it('should be possible to nest transition components', () => {
const { container } = render( let { container } = render(
<div className="My Page"> <div className="My Page">
<Transition show={true}> <Transition show={true}>
<Transition.Child>Sidebar</Transition.Child> <Transition.Child>Sidebar</Transition.Child>
@@ -208,7 +208,7 @@ describe('Setup API', () => {
}) })
it('should be possible to change the underlying DOM tag of the Transition.Child components', () => { it('should be possible to change the underlying DOM tag of the Transition.Child components', () => {
const { container } = render( let { container } = render(
<div className="My Page"> <div className="My Page">
<Transition show={true}> <Transition show={true}>
<Transition.Child as="aside">Sidebar</Transition.Child> <Transition.Child as="aside">Sidebar</Transition.Child>
@@ -234,7 +234,7 @@ describe('Setup API', () => {
}) })
it('should be possible to change the underlying DOM tag of the Transition component and Transition.Child components', () => { it('should be possible to change the underlying DOM tag of the Transition component and Transition.Child components', () => {
const { container } = render( let { container } = render(
<div className="My Page"> <div className="My Page">
<Transition show={true} as="article"> <Transition show={true} as="article">
<Transition.Child as="aside">Sidebar</Transition.Child> <Transition.Child as="aside">Sidebar</Transition.Child>
@@ -260,13 +260,11 @@ describe('Setup API', () => {
}) })
it('should be possible to use render props on the Transition.Child components', () => { it('should be possible to use render props on the Transition.Child components', () => {
const { container } = render( let { container } = render(
<div className="My Page"> <div className="My Page">
<Transition show={true}> <Transition show={true}>
<Transition.Child as={React.Fragment}>{() => <aside>Sidebar</aside>}</Transition.Child> <Transition.Child as={Fragment}>{() => <aside>Sidebar</aside>}</Transition.Child>
<Transition.Child as={React.Fragment}> <Transition.Child as={Fragment}>{() => <section>Content</section>}</Transition.Child>
{() => <section>Content</section>}
</Transition.Child>
</Transition> </Transition>
</div> </div>
) )
@@ -288,15 +286,13 @@ describe('Setup API', () => {
}) })
it('should be possible to use render props on the Transition and Transition.Child components', () => { it('should be possible to use render props on the Transition and Transition.Child components', () => {
const { container } = render( let { container } = render(
<div className="My Page"> <div className="My Page">
<Transition show={true} as={React.Fragment}> <Transition show={true} as={Fragment}>
{() => ( {() => (
<article> <article>
<Transition.Child as={React.Fragment}> <Transition.Child as={Fragment}>{() => <aside>Sidebar</aside>}</Transition.Child>
{() => <aside>Sidebar</aside>} <Transition.Child as={Fragment}>
</Transition.Child>
<Transition.Child as={React.Fragment}>
{() => <section>Content</section>} {() => <section>Content</section>}
</Transition.Child> </Transition.Child>
</article> </article>
@@ -334,12 +330,8 @@ describe('Setup API', () => {
render( render(
<div className="My Page"> <div className="My Page">
<Transition show={true}> <Transition show={true}>
<Transition.Child as={React.Fragment}> <Transition.Child as={Fragment}>{() => <Dummy>Sidebar</Dummy>}</Transition.Child>
{() => <Dummy>Sidebar</Dummy>} <Transition.Child as={Fragment}>{() => <Dummy>Content</Dummy>}</Transition.Child>
</Transition.Child>
<Transition.Child as={React.Fragment}>
{() => <Dummy>Content</Dummy>}
</Transition.Child>
</Transition> </Transition>
</div> </div>
) )
@@ -361,7 +353,7 @@ describe('Setup API', () => {
expect(() => { expect(() => {
render( render(
<div className="My Page"> <div className="My Page">
<Transition show={true} as={React.Fragment}> <Transition show={true} as={Fragment}>
{() => ( {() => (
<Dummy> <Dummy>
<Transition.Child>{() => <aside>Sidebar</aside>}</Transition.Child> <Transition.Child>{() => <aside>Sidebar</aside>}</Transition.Child>
@@ -380,7 +372,7 @@ describe('Setup API', () => {
describe('transition classes', () => { describe('transition classes', () => {
it('should be possible to passthrough the transition classes', () => { it('should be possible to passthrough the transition classes', () => {
const { container } = render( let { container } = render(
<Transition <Transition
show={true} show={true}
enter="enter" enter="enter"
@@ -402,7 +394,7 @@ describe('Setup API', () => {
}) })
it('should be possible to passthrough the transition classes and immediately apply the enter transitions when appear is set to true', () => { it('should be possible to passthrough the transition classes and immediately apply the enter transitions when appear is set to true', () => {
const { container } = render( let { container } = render(
<Transition <Transition
show={true} show={true}
appear={true} appear={true}
@@ -431,10 +423,10 @@ describe('Setup API', () => {
describe('Transitions', () => { describe('Transitions', () => {
describe('shallow transitions', () => { describe('shallow transitions', () => {
it('should transition in completely (duration defined in milliseconds)', async () => { it('should transition in completely (duration defined in milliseconds)', async () => {
const enterDuration = 50 let enterDuration = 50
function Example() { function Example() {
const [show, setShow] = React.useState(false) let [show, setShow] = useState(false)
return ( return (
<> <>
@@ -451,7 +443,7 @@ describe('Transitions', () => {
) )
} }
const timeline = await executeTimeline(<Example />, [ let timeline = await executeTimeline(<Example />, [
// Toggle to show // Toggle to show
({ getByTestId }) => { ({ getByTestId }) => {
fireEvent.click(getByTestId('toggle')) fireEvent.click(getByTestId('toggle'))
@@ -480,10 +472,10 @@ describe('Transitions', () => {
}) })
it('should transition in completely (duration defined in seconds)', async () => { it('should transition in completely (duration defined in seconds)', async () => {
const enterDuration = 50 let enterDuration = 50
function Example() { function Example() {
const [show, setShow] = React.useState(false) let [show, setShow] = useState(false)
return ( return (
<> <>
@@ -501,7 +493,7 @@ describe('Transitions', () => {
) )
} }
const timeline = await executeTimeline(<Example />, [ let timeline = await executeTimeline(<Example />, [
// Toggle to show // Toggle to show
({ getByTestId }) => { ({ getByTestId }) => {
fireEvent.click(getByTestId('toggle')) fireEvent.click(getByTestId('toggle'))
@@ -530,10 +522,10 @@ describe('Transitions', () => {
}) })
it('should transition in completely (duration defined in seconds) in (render strategy = hidden)', async () => { it('should transition in completely (duration defined in seconds) in (render strategy = hidden)', async () => {
const enterDuration = 50 let enterDuration = 50
function Example() { function Example() {
const [show, setShow] = React.useState(false) let [show, setShow] = useState(false)
return ( return (
<> <>
@@ -551,7 +543,7 @@ describe('Transitions', () => {
) )
} }
const timeline = await executeTimeline(<Example />, [ let timeline = await executeTimeline(<Example />, [
// Toggle to show // Toggle to show
({ getByTestId }) => { ({ getByTestId }) => {
fireEvent.click(getByTestId('toggle')) fireEvent.click(getByTestId('toggle'))
@@ -577,10 +569,10 @@ describe('Transitions', () => {
}) })
it('should transition in completely', async () => { it('should transition in completely', async () => {
const enterDuration = 50 let enterDuration = 50
function Example() { function Example() {
const [show, setShow] = React.useState(false) let [show, setShow] = useState(false)
return ( return (
<> <>
@@ -597,7 +589,7 @@ describe('Transitions', () => {
) )
} }
const timeline = await executeTimeline(<Example />, [ let timeline = await executeTimeline(<Example />, [
// Toggle to show // Toggle to show
({ getByTestId }) => { ({ getByTestId }) => {
fireEvent.click(getByTestId('toggle')) fireEvent.click(getByTestId('toggle'))
@@ -628,10 +620,10 @@ describe('Transitions', () => {
it( it(
'should transition out completely', 'should transition out completely',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const leaveDuration = 50 let leaveDuration = 50
function Example() { function Example() {
const [show, setShow] = React.useState(true) let [show, setShow] = useState(true)
return ( return (
<> <>
@@ -648,7 +640,7 @@ describe('Transitions', () => {
) )
} }
const timeline = await executeTimeline(<Example />, [ let timeline = await executeTimeline(<Example />, [
// Toggle to hide // Toggle to hide
({ getByTestId }) => { ({ getByTestId }) => {
fireEvent.click(getByTestId('toggle')) fireEvent.click(getByTestId('toggle'))
@@ -682,10 +674,10 @@ describe('Transitions', () => {
it( it(
'should transition out completely (render strategy = hidden)', 'should transition out completely (render strategy = hidden)',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const leaveDuration = 50 let leaveDuration = 50
function Example() { function Example() {
const [show, setShow] = React.useState(true) let [show, setShow] = useState(true)
return ( return (
<> <>
@@ -702,7 +694,7 @@ describe('Transitions', () => {
) )
} }
const timeline = await executeTimeline(<Example />, [ let timeline = await executeTimeline(<Example />, [
// Toggle to hide // Toggle to hide
({ getByTestId }) => { ({ getByTestId }) => {
fireEvent.click(getByTestId('toggle')) fireEvent.click(getByTestId('toggle'))
@@ -733,11 +725,11 @@ describe('Transitions', () => {
it( it(
'should transition in and out completely', 'should transition in and out completely',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const enterDuration = 50 let enterDuration = 50
const leaveDuration = 75 let leaveDuration = 75
function Example() { function Example() {
const [show, setShow] = React.useState(false) let [show, setShow] = useState(false)
return ( return (
<> <>
@@ -763,7 +755,7 @@ describe('Transitions', () => {
) )
} }
const timeline = await executeTimeline(<Example />, [ let timeline = await executeTimeline(<Example />, [
// Toggle to show // Toggle to show
({ getByTestId }) => { ({ getByTestId }) => {
fireEvent.click(getByTestId('toggle')) fireEvent.click(getByTestId('toggle'))
@@ -818,11 +810,11 @@ describe('Transitions', () => {
it( it(
'should transition in and out completely (render strategy = hidden)', 'should transition in and out completely (render strategy = hidden)',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const enterDuration = 50 let enterDuration = 50
const leaveDuration = 75 let leaveDuration = 75
function Example() { function Example() {
const [show, setShow] = React.useState(false) let [show, setShow] = useState(false)
return ( return (
<> <>
@@ -849,7 +841,7 @@ describe('Transitions', () => {
) )
} }
const timeline = await executeTimeline(<Example />, [ let timeline = await executeTimeline(<Example />, [
// Toggle to show // Toggle to show
({ getByTestId }) => { ({ getByTestId }) => {
fireEvent.click(getByTestId('toggle')) fireEvent.click(getByTestId('toggle'))
@@ -922,11 +914,11 @@ describe('Transitions', () => {
it( it(
'should not unmount the whole tree when some children are still transitioning', 'should not unmount the whole tree when some children are still transitioning',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const slowLeaveDuration = 150 let slowLeaveDuration = 150
const fastLeaveDuration = 50 let fastLeaveDuration = 50
function Example() { function Example() {
const [show, setShow] = React.useState(true) let [show, setShow] = useState(true)
return ( return (
<> <>
@@ -949,7 +941,7 @@ describe('Transitions', () => {
) )
} }
const timeline = await executeTimeline(<Example />, [ let timeline = await executeTimeline(<Example />, [
// Toggle to hide // Toggle to hide
({ getByTestId }) => { ({ getByTestId }) => {
fireEvent.click(getByTestId('toggle')) fireEvent.click(getByTestId('toggle'))
@@ -1003,11 +995,11 @@ describe('Transitions', () => {
it( it(
'should not unmount the whole tree when some children are still transitioning', 'should not unmount the whole tree when some children are still transitioning',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const slowLeaveDuration = 150 let slowLeaveDuration = 150
const fastLeaveDuration = 50 let fastLeaveDuration = 50
function Example() { function Example() {
const [show, setShow] = React.useState(true) let [show, setShow] = useState(true)
return ( return (
<> <>
@@ -1033,7 +1025,7 @@ describe('Transitions', () => {
) )
} }
const timeline = await executeTimeline(<Example />, [ let timeline = await executeTimeline(<Example />, [
// Toggle to hide // Toggle to hide
({ getByTestId }) => { ({ getByTestId }) => {
fireEvent.click(getByTestId('toggle')) fireEvent.click(getByTestId('toggle'))
@@ -1102,15 +1094,15 @@ describe('Events', () => {
it( it(
'should fire events for all the stages', 'should fire events for all the stages',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const eventHandler = jest.fn() let eventHandler = jest.fn()
const enterDuration = 50 let enterDuration = 50
const leaveDuration = 75 let leaveDuration = 75
function Example() { function Example() {
const [show, setShow] = React.useState(false) let [show, setShow] = useState(false)
const start = React.useRef(Date.now()) let start = useRef(Date.now())
React.useLayoutEffect(() => { useLayoutEffect(() => {
start.current = Date.now() start.current = Date.now()
}, []) }, [])
@@ -1144,7 +1136,7 @@ describe('Events', () => {
) )
} }
const timeline = await executeTimeline(<Example />, [ let timeline = await executeTimeline(<Example />, [
// Toggle to show // Toggle to show
({ getByTestId }) => { ({ getByTestId }) => {
fireEvent.click(getByTestId('toggle')) fireEvent.click(getByTestId('toggle'))
@@ -1202,11 +1194,11 @@ describe('Events', () => {
'afterLeave', 'afterLeave',
]) ])
const enterHookDiff = eventHandler.mock.calls[1][1] - eventHandler.mock.calls[0][1] let enterHookDiff = eventHandler.mock.calls[1][1] - eventHandler.mock.calls[0][1]
expect(enterHookDiff).toBeGreaterThanOrEqual(enterDuration) expect(enterHookDiff).toBeGreaterThanOrEqual(enterDuration)
expect(enterHookDiff).toBeLessThanOrEqual(enterDuration * 2) expect(enterHookDiff).toBeLessThanOrEqual(enterDuration * 2)
const leaveHookDiff = eventHandler.mock.calls[3][1] - eventHandler.mock.calls[2][1] let leaveHookDiff = eventHandler.mock.calls[3][1] - eventHandler.mock.calls[2][1]
expect(leaveHookDiff).toBeGreaterThanOrEqual(leaveDuration) expect(leaveHookDiff).toBeGreaterThanOrEqual(leaveDuration)
expect(leaveHookDiff).toBeLessThanOrEqual(leaveDuration * 2) expect(leaveHookDiff).toBeLessThanOrEqual(leaveDuration * 2)
}) })
@@ -1,5 +1,18 @@
import * as React from 'react' import React, {
import { Props } from 'types' useMemo,
createContext,
useContext,
useRef,
useEffect,
useCallback,
useState,
Fragment,
// Types
ElementType,
MutableRefObject,
} from 'react'
import { Props, Expand } from 'types'
import { useId } from '../../hooks/use-id' import { useId } from '../../hooks/use-id'
import { useIsInitialRender } from '../../hooks/use-is-initial-render' import { useIsInitialRender } from '../../hooks/use-is-initial-render'
@@ -13,16 +26,16 @@ import { Reason, transition } from './utils/transition'
type ID = ReturnType<typeof useId> type ID = ReturnType<typeof useId>
function useSplitClasses(classes: string = '') { function useSplitClasses(classes: string = '') {
return React.useMemo(() => classes.split(' ').filter(className => className.trim().length > 1), [ return useMemo(() => classes.split(' ').filter(className => className.trim().length > 1), [
classes, classes,
]) ])
} }
type TransitionContextValues = { interface TransitionContextValues {
show: boolean show: boolean
appear: boolean appear: boolean
} | null }
const TransitionContext = React.createContext<TransitionContextValues>(null) let TransitionContext = createContext<TransitionContextValues | null>(null)
TransitionContext.displayName = 'TransitionContext' TransitionContext.displayName = 'TransitionContext'
enum TreeStates { enum TreeStates {
@@ -30,28 +43,29 @@ enum TreeStates {
Hidden = 'hidden', Hidden = 'hidden',
} }
export type TransitionClasses = Partial<{ export interface TransitionClasses {
enter: string enter?: string
enterFrom: string enterFrom?: string
enterTo: string enterTo?: string
leave: string leave?: string
leaveFrom: string leaveFrom?: string
leaveTo: string leaveTo?: string
}> }
export type TransitionEvents = Partial<{ export interface TransitionEvents {
beforeEnter(): void beforeEnter?: () => void
afterEnter(): void afterEnter?: () => void
beforeLeave(): void beforeLeave?: () => void
afterLeave(): void afterLeave?: () => void
}> }
type TransitionChildProps<TTag> = Props<TTag, TransitionChildRenderPropArg> & type TransitionChildProps<TTag> = Props<TTag, TransitionChildRenderPropArg> &
PropsForFeatures<typeof TransitionChildRenderFeatures> & PropsForFeatures<typeof TransitionChildRenderFeatures> &
Partial<{ appear: boolean } & TransitionClasses & TransitionEvents> TransitionClasses &
TransitionEvents & { appear?: boolean }
function useTransitionContext() { function useTransitionContext() {
const context = React.useContext(TransitionContext) let context = useContext(TransitionContext)
if (context === null) { if (context === null) {
throw new Error('A <Transition.Child /> is used but it is missing a parent <Transition />.') throw new Error('A <Transition.Child /> is used but it is missing a parent <Transition />.')
@@ -61,7 +75,7 @@ function useTransitionContext() {
} }
function useParentNesting() { function useParentNesting() {
const context = React.useContext(NestingContext) let context = useContext(NestingContext)
if (context === null) { if (context === null) {
throw new Error('A <Transition.Child /> is used but it is missing a parent <Transition />.') throw new Error('A <Transition.Child /> is used but it is missing a parent <Transition />.')
@@ -70,13 +84,13 @@ function useParentNesting() {
return context return context
} }
type NestingContextValues = { interface NestingContextValues {
children: React.MutableRefObject<{ id: ID; state: TreeStates }[]> children: MutableRefObject<{ id: ID; state: TreeStates }[]>
register: (id: ID) => () => void register: (id: ID) => () => void
unregister: (id: ID, strategy?: RenderStrategy) => void unregister: (id: ID, strategy?: RenderStrategy) => void
} }
const NestingContext = React.createContext<NestingContextValues | null>(null) let NestingContext = createContext<NestingContextValues | null>(null)
NestingContext.displayName = 'NestingContext' NestingContext.displayName = 'NestingContext'
function hasChildren( function hasChildren(
@@ -87,17 +101,17 @@ function hasChildren(
} }
function useNesting(done?: () => void) { function useNesting(done?: () => void) {
const doneRef = React.useRef(done) let doneRef = useRef(done)
const transitionableChildren = React.useRef<NestingContextValues['children']['current']>([]) let transitionableChildren = useRef<NestingContextValues['children']['current']>([])
const mounted = useIsMounted() let mounted = useIsMounted()
React.useEffect(() => { useEffect(() => {
doneRef.current = done doneRef.current = done
}, [done]) }, [done])
const unregister = React.useCallback( let unregister = useCallback(
(childId: ID, strategy = RenderStrategy.Hidden) => { (childId: ID, strategy = RenderStrategy.Hidden) => {
const idx = transitionableChildren.current.findIndex(({ id }) => id === childId) let idx = transitionableChildren.current.findIndex(({ id }) => id === childId)
if (idx === -1) return if (idx === -1) return
match(strategy, { match(strategy, {
@@ -116,9 +130,9 @@ function useNesting(done?: () => void) {
[doneRef, mounted, transitionableChildren] [doneRef, mounted, transitionableChildren]
) )
const register = React.useCallback( let register = useCallback(
(childId: ID) => { (childId: ID) => {
const child = transitionableChildren.current.find(({ id }) => id === childId) let child = transitionableChildren.current.find(({ id }) => id === childId)
if (!child) { if (!child) {
transitionableChildren.current.push({ id: childId, state: TreeStates.Visible }) transitionableChildren.current.push({ id: childId, state: TreeStates.Visible })
} else if (child.state !== TreeStates.Visible) { } else if (child.state !== TreeStates.Visible) {
@@ -130,7 +144,7 @@ function useNesting(done?: () => void) {
[transitionableChildren, unregister] [transitionableChildren, unregister]
) )
return React.useMemo( return useMemo(
() => ({ () => ({
children: transitionableChildren, children: transitionableChildren,
register, register,
@@ -141,7 +155,7 @@ function useNesting(done?: () => void) {
} }
function noop() {} function noop() {}
const eventNames: (keyof TransitionEvents)[] = [ let eventNames: (keyof TransitionEvents)[] = [
'beforeEnter', 'beforeEnter',
'afterEnter', 'afterEnter',
'beforeLeave', 'beforeLeave',
@@ -155,9 +169,9 @@ function ensureEventHooksExist(events: TransitionEvents) {
} }
function useEvents(events: TransitionEvents) { function useEvents(events: TransitionEvents) {
const eventsRef = React.useRef(ensureEventHooksExist(events)) let eventsRef = useRef(ensureEventHooksExist(events))
React.useEffect(() => { useEffect(() => {
eventsRef.current = ensureEventHooksExist(events) eventsRef.current = ensureEventHooksExist(events)
}, [events]) }, [events])
@@ -166,14 +180,14 @@ function useEvents(events: TransitionEvents) {
// --- // ---
const DEFAULT_TRANSITION_CHILD_TAG = 'div' let DEFAULT_TRANSITION_CHILD_TAG = 'div' as const
type TransitionChildRenderPropArg = React.MutableRefObject<HTMLDivElement> type TransitionChildRenderPropArg = MutableRefObject<HTMLDivElement>
const TransitionChildRenderFeatures = Features.RenderStrategy let TransitionChildRenderFeatures = Features.RenderStrategy
function TransitionChild<TTag extends React.ElementType = typeof DEFAULT_TRANSITION_CHILD_TAG>( function TransitionChild<TTag extends ElementType = typeof DEFAULT_TRANSITION_CHILD_TAG>(
props: TransitionChildProps<TTag> props: TransitionChildProps<TTag>
) { ) {
const { let {
// Event "handlers" // Event "handlers"
beforeEnter, beforeEnter,
afterEnter, afterEnter,
@@ -188,20 +202,20 @@ function TransitionChild<TTag extends React.ElementType = typeof DEFAULT_TRANSIT
leaveFrom, leaveFrom,
leaveTo, leaveTo,
...rest ...rest
} = props } = props as Expand<typeof props>
const container = React.useRef<HTMLElement | null>(null) let container = useRef<HTMLElement | null>(null)
const [state, setState] = React.useState(TreeStates.Visible) let [state, setState] = useState(TreeStates.Visible)
const strategy = rest.unmount ? RenderStrategy.Unmount : RenderStrategy.Hidden let strategy = rest.unmount ? RenderStrategy.Unmount : RenderStrategy.Hidden
const { show, appear } = useTransitionContext() let { show, appear } = useTransitionContext()
const { register, unregister } = useParentNesting() let { register, unregister } = useParentNesting()
const initial = useIsInitialRender() let initial = useIsInitialRender()
const id = useId() let id = useId()
const isTransitioning = React.useRef(false) let isTransitioning = useRef(false)
const nesting = useNesting(() => { let nesting = useNesting(() => {
// When all children have been unmounted we can only hide ourselves if and only if we are not // When all children have been unmounted we can only hide ourselves if and only if we are not
// transitioning ourserlves. Otherwise we would unmount before the transitions are finished. // transitioning ourserlves. Otherwise we would unmount before the transitions are finished.
if (!isTransitioning.current) { if (!isTransitioning.current) {
@@ -233,27 +247,27 @@ function TransitionChild<TTag extends React.ElementType = typeof DEFAULT_TRANSIT
}) })
}, [state, id, register, unregister, show, strategy]) }, [state, id, register, unregister, show, strategy])
const enterClasses = useSplitClasses(enter) let enterClasses = useSplitClasses(enter)
const enterFromClasses = useSplitClasses(enterFrom) let enterFromClasses = useSplitClasses(enterFrom)
const enterToClasses = useSplitClasses(enterTo) let enterToClasses = useSplitClasses(enterTo)
const leaveClasses = useSplitClasses(leave) let leaveClasses = useSplitClasses(leave)
const leaveFromClasses = useSplitClasses(leaveFrom) let leaveFromClasses = useSplitClasses(leaveFrom)
const leaveToClasses = useSplitClasses(leaveTo) let leaveToClasses = useSplitClasses(leaveTo)
const events = useEvents({ beforeEnter, afterEnter, beforeLeave, afterLeave }) let events = useEvents({ beforeEnter, afterEnter, beforeLeave, afterLeave })
React.useEffect(() => { useEffect(() => {
if (state === TreeStates.Visible && container.current === null) { if (state === TreeStates.Visible && container.current === null) {
throw new Error('Did you forget to passthrough the `ref` to the actual DOM node?') throw new Error('Did you forget to passthrough the `ref` to the actual DOM node?')
} }
}, [container, state]) }, [container, state])
// Skipping initial transition // Skipping initial transition
const skip = initial && !appear let skip = initial && !appear
useIsoMorphicEffect(() => { useIsoMorphicEffect(() => {
const node = container.current let node = container.current
if (!node) return if (!node) return
if (skip) return if (skip) return
@@ -297,9 +311,9 @@ function TransitionChild<TTag extends React.ElementType = typeof DEFAULT_TRANSIT
leaveToClasses, leaveToClasses,
]) ])
const propsBag = {} let propsBag = {}
const propsWeControl = { ref: container } let propsWeControl = { ref: container }
const passthroughProps = rest let passthroughProps = rest
return ( return (
<NestingContext.Provider value={nesting}> <NestingContext.Provider value={nesting}>
@@ -314,28 +328,28 @@ function TransitionChild<TTag extends React.ElementType = typeof DEFAULT_TRANSIT
) )
} }
export function Transition<TTag extends React.ElementType = typeof DEFAULT_TRANSITION_CHILD_TAG>( export function Transition<TTag extends ElementType = typeof DEFAULT_TRANSITION_CHILD_TAG>(
props: TransitionChildProps<TTag> & { show: boolean; appear?: boolean } props: TransitionChildProps<TTag> & { show: boolean; appear?: boolean }
) { ) {
const { show, appear = false, unmount, ...passthroughProps } = props let { show, appear = false, unmount, ...passthroughProps } = props as Expand<typeof props>
if (![true, false].includes(show)) { if (![true, false].includes(show)) {
throw new Error('A <Transition /> is used but it is missing a `show={true | false}` prop.') throw new Error('A <Transition /> is used but it is missing a `show={true | false}` prop.')
} }
const [state, setState] = React.useState(show ? TreeStates.Visible : TreeStates.Hidden) let [state, setState] = useState(show ? TreeStates.Visible : TreeStates.Hidden)
const nestingBag = useNesting(() => { let nestingBag = useNesting(() => {
setState(TreeStates.Hidden) setState(TreeStates.Hidden)
}) })
const initial = useIsInitialRender() let initial = useIsInitialRender()
const transitionBag = React.useMemo<TransitionContextValues>( let transitionBag = useMemo<TransitionContextValues>(
() => ({ show, appear: appear || !initial }), () => ({ show, appear: appear || !initial }),
[show, appear, initial] [show, appear, initial]
) )
React.useEffect(() => { useEffect(() => {
if (show) { if (show) {
setState(TreeStates.Visible) setState(TreeStates.Visible)
} else if (!hasChildren(nestingBag)) { } else if (!hasChildren(nestingBag)) {
@@ -343,8 +357,8 @@ export function Transition<TTag extends React.ElementType = typeof DEFAULT_TRANS
} }
}, [show, nestingBag]) }, [show, nestingBag])
const sharedProps = { unmount } let sharedProps = { unmount }
const propsBag = {} let propsBag = {}
return ( return (
<NestingContext.Provider value={nestingBag}> <NestingContext.Provider value={nestingBag}>
@@ -352,11 +366,11 @@ export function Transition<TTag extends React.ElementType = typeof DEFAULT_TRANS
{render( {render(
{ {
...sharedProps, ...sharedProps,
as: React.Fragment, as: Fragment,
children: <TransitionChild {...sharedProps} {...passthroughProps} />, children: <TransitionChild {...sharedProps} {...passthroughProps} />,
}, },
propsBag, propsBag,
React.Fragment, Fragment,
TransitionChildRenderFeatures, TransitionChildRenderFeatures,
state === TreeStates.Visible state === TreeStates.Visible
)} )}
@@ -8,10 +8,10 @@ beforeEach(() => {
}) })
it('should be possible to transition', async () => { it('should be possible to transition', async () => {
const d = disposables() let d = disposables()
const snapshots: { content: string; recordedAt: bigint }[] = [] let snapshots: { content: string; recordedAt: bigint }[] = []
const element = document.createElement('div') let element = document.createElement('div')
document.body.appendChild(element) document.body.appendChild(element)
d.add( d.add(
@@ -44,17 +44,17 @@ it('should be possible to transition', async () => {
// Cleanup phase // Cleanup phase
expect(snapshots[2].content).toEqual('<div class=""></div>') expect(snapshots[2].content).toEqual('<div class=""></div>')
await d.dispose() d.dispose()
}) })
it('should wait the correct amount of time to finish a transition', async () => { it('should wait the correct amount of time to finish a transition', async () => {
const d = disposables() let d = disposables()
const snapshots: { content: string; recordedAt: bigint }[] = [] let snapshots: { content: string; recordedAt: bigint }[] = []
const element = document.createElement('div') let element = document.createElement('div')
document.body.appendChild(element) document.body.appendChild(element)
const duration = 20 let duration = 20
element.style.transitionDuration = `${duration}ms` element.style.transitionDuration = `${duration}ms`
@@ -70,7 +70,7 @@ it('should wait the correct amount of time to finish a transition', async () =>
) )
) )
const reason = await new Promise(resolve => { let reason = await new Promise(resolve => {
transition(element, ['enter'], ['enterFrom'], ['enterTo'], resolve) transition(element, ['enter'], ['enterFrom'], ['enterTo'], resolve)
}) })
@@ -89,7 +89,7 @@ it('should wait the correct amount of time to finish a transition', async () =>
`<div style="transition-duration: ${duration}ms;" class="enter enterTo"></div>` `<div style="transition-duration: ${duration}ms;" class="enter enterTo"></div>`
) )
const estimatedDuration = Number( let estimatedDuration = Number(
(snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) / (snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) /
BigInt(1e6) BigInt(1e6)
) )
@@ -103,14 +103,14 @@ it('should wait the correct amount of time to finish a transition', async () =>
}) })
it('should keep the delay time into account', async () => { it('should keep the delay time into account', async () => {
const d = disposables() let d = disposables()
const snapshots: { content: string; recordedAt: bigint }[] = [] let snapshots: { content: string; recordedAt: bigint }[] = []
const element = document.createElement('div') let element = document.createElement('div')
document.body.appendChild(element) document.body.appendChild(element)
const duration = 20 let duration = 20
const delayDuration = 100 let delayDuration = 100
element.style.transitionDuration = `${duration}ms` element.style.transitionDuration = `${duration}ms`
element.style.transitionDelay = `${delayDuration}ms` element.style.transitionDelay = `${delayDuration}ms`
@@ -127,14 +127,14 @@ it('should keep the delay time into account', async () => {
) )
) )
const reason = await new Promise(resolve => { let reason = await new Promise(resolve => {
transition(element, ['enter'], ['enterFrom'], ['enterTo'], resolve) transition(element, ['enter'], ['enterFrom'], ['enterTo'], resolve)
}) })
await new Promise(resolve => d.nextFrame(resolve)) await new Promise(resolve => d.nextFrame(resolve))
expect(reason).toBe(Reason.Finished) expect(reason).toBe(Reason.Finished)
const estimatedDuration = Number( let estimatedDuration = Number(
(snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) / (snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) /
BigInt(1e6) BigInt(1e6)
) )
@@ -143,18 +143,18 @@ it('should keep the delay time into account', async () => {
}) })
it('should be possible to cancel a transition at any time', async () => { it('should be possible to cancel a transition at any time', async () => {
const d = disposables() let d = disposables()
const snapshots: { let snapshots: {
content: string content: string
recordedAt: bigint recordedAt: bigint
relativeTime: number relativeTime: number
}[] = [] }[] = []
const element = document.createElement('div') let element = document.createElement('div')
document.body.appendChild(element) document.body.appendChild(element)
// This duration is so overkill, however it will demonstrate that we can cancel transitions. // This duration is so overkill, however it will demonstrate that we can cancel transitions.
const duration = 5000 let duration = 5000
element.style.transitionDuration = `${duration}ms` element.style.transitionDuration = `${duration}ms`
@@ -162,8 +162,8 @@ it('should be possible to cancel a transition at any time', async () => {
reportChanges( reportChanges(
() => document.body.innerHTML, () => document.body.innerHTML,
content => { content => {
const recordedAt = process.hrtime.bigint() let recordedAt = process.hrtime.bigint()
const total = snapshots.length let total = snapshots.length
snapshots.push({ snapshots.push({
content, content,
@@ -178,7 +178,7 @@ it('should be possible to cancel a transition at any time', async () => {
expect.assertions(2) expect.assertions(2)
// Setup the transition // Setup the transition
const cancel = transition(element, ['enter'], ['enterFrom'], ['enterTo'], reason => { let cancel = transition(element, ['enter'], ['enterFrom'], ['enterTo'], reason => {
expect(reason).toBe(Reason.Cancelled) expect(reason).toBe(Reason.Cancelled)
}) })
@@ -15,15 +15,15 @@ export enum Reason {
} }
function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) { function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) {
const d = disposables() let d = disposables()
if (!node) return d.dispose if (!node) return d.dispose
// Safari returns a comma separated list of values, so let's sort them and take the highest value. // Safari returns a comma separated list of values, so let's sort them and take the highest value.
const { transitionDuration, transitionDelay } = getComputedStyle(node) let { transitionDuration, transitionDelay } = getComputedStyle(node)
const [durationMs, delaysMs] = [transitionDuration, transitionDelay].map(value => { let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map(value => {
const [resolvedValue = 0] = value let [resolvedValue = 0] = value
.split(',') .split(',')
// Remove falseys we can't work with // Remove falseys we can't work with
.filter(Boolean) .filter(Boolean)
@@ -62,8 +62,8 @@ export function transition(
to: string[], to: string[],
done?: (reason: Reason) => void done?: (reason: Reason) => void
) { ) {
const d = disposables() let d = disposables()
const _done = done !== undefined ? once(done) : () => {} let _done = done !== undefined ? once(done) : () => {}
addClasses(node, ...base, ...from) addClasses(node, ...base, ...from)
@@ -1,4 +1,4 @@
import * as React from 'react' import { useState } from 'react'
beforeEach(() => { beforeEach(() => {
id = 0 id = 0
@@ -10,6 +10,6 @@ function generateId() {
} }
export function useId() { export function useId() {
const [id] = React.useState(generateId) const [id] = useState(generateId)
return id return id
} }
@@ -1,9 +1,9 @@
import * as React from 'react' import { useState, useRef } from 'react'
import { useIsoMorphicEffect } from './use-iso-morphic-effect' import { useIsoMorphicEffect } from './use-iso-morphic-effect'
export function useComputed<T>(cb: () => T, dependencies: React.DependencyList) { export function useComputed<T>(cb: () => T, dependencies: React.DependencyList) {
const [value, setValue] = React.useState(cb) let [value, setValue] = useState(cb)
const cbRef = React.useRef(cb) let cbRef = useRef(cb)
useIsoMorphicEffect(() => { useIsoMorphicEffect(() => {
cbRef.current = cb cbRef.current = cb
}, [cb]) }, [cb])
@@ -1,10 +1,10 @@
import * as React from 'react' import { useState, useEffect } from 'react'
import { disposables } from '../utils/disposables' import { disposables } from '../utils/disposables'
export function useDisposables() { export function useDisposables() {
// Using useState instead of useRef so that we can use the initializer function. // Using useState instead of useRef so that we can use the initializer function.
const [d] = React.useState(disposables) let [d] = useState(disposables)
React.useEffect(() => () => d.dispose(), [d]) useEffect(() => () => d.dispose(), [d])
return d return d
} }
@@ -1,4 +1,4 @@
import * as React from 'react' import { useState, useEffect } from 'react'
import { useIsoMorphicEffect } from './use-iso-morphic-effect' import { useIsoMorphicEffect } from './use-iso-morphic-effect'
// We used a "simple" approach first which worked for SSR and rehydration on the client. However we // We used a "simple" approach first which worked for SSR and rehydration on the client. However we
@@ -14,13 +14,13 @@ function generateId() {
} }
export function useId() { export function useId() {
const [id, setId] = React.useState(state.serverHandoffComplete ? generateId : null) let [id, setId] = useState(state.serverHandoffComplete ? generateId : null)
useIsoMorphicEffect(() => { useIsoMorphicEffect(() => {
if (id === null) setId(generateId()) if (id === null) setId(generateId())
}, [id]) }, [id])
React.useEffect(() => { useEffect(() => {
if (state.serverHandoffComplete === false) state.serverHandoffComplete = true if (state.serverHandoffComplete === false) state.serverHandoffComplete = true
}, []) }, [])
@@ -1,9 +1,9 @@
import * as React from 'react' import { useRef, useEffect } from 'react'
export function useIsInitialRender() { export function useIsInitialRender() {
const initial = React.useRef(true) let initial = useRef(true)
React.useEffect(() => { useEffect(() => {
initial.current = false initial.current = false
}, []) }, [])
@@ -1,9 +1,9 @@
import * as React from 'react' import { useRef, useEffect } from 'react'
export function useIsMounted() { export function useIsMounted() {
const mounted = React.useRef(true) let mounted = useRef(true)
React.useEffect(() => { useEffect(() => {
return () => { return () => {
mounted.current = false mounted.current = false
} }
@@ -1,4 +1,3 @@
import * as React from 'react' import { useLayoutEffect, useEffect } from 'react'
export const useIsoMorphicEffect = export const useIsoMorphicEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
@@ -1,9 +1,9 @@
import * as React from 'react' import { useCallback } from 'react'
export function useSyncRefs<TType>( export function useSyncRefs<TType>(
...refs: (React.MutableRefObject<TType> | ((instance: TType) => void) | null)[] ...refs: (React.MutableRefObject<TType> | ((instance: TType) => void) | null)[]
) { ) {
return React.useCallback( return useCallback(
(value: TType) => { (value: TType) => {
refs.forEach(ref => { refs.forEach(ref => {
if (ref === null) return if (ref === null) return
+2 -2
View File
@@ -1,9 +1,9 @@
import * as TailwindUI from './index' import * as HeadlessUI from './index'
/** /**
* Looks a bit of a silly test, however this ensures that we don't accidentally expose something to * Looks a bit of a silly test, however this ensures that we don't accidentally expose something to
* the outside world that we didn't want! * the outside world that we didn't want!
*/ */
it('should expose the correct components', () => { it('should expose the correct components', () => {
expect(Object.keys(TailwindUI)).toEqual(['Transition', 'Menu', 'Listbox', 'Switch']) expect(Object.keys(HeadlessUI)).toEqual(['Transition', 'Menu', 'Listbox', 'Switch'])
}) })
@@ -580,7 +580,7 @@ export function assertLabelValue(element: HTMLElement | null, value: string) {
if (element === null) return expect(element).not.toBe(null) if (element === null) return expect(element).not.toBe(null)
if (element.hasAttribute('aria-labelledby')) { if (element.hasAttribute('aria-labelledby')) {
const ids = element.getAttribute('aria-labelledby')!.split(' ') let ids = element.getAttribute('aria-labelledby')!.split(' ')
expect(ids.map(id => document.getElementById(id)?.textContent).join(' ')).toEqual(value) expect(ids.map(id => document.getElementById(id)?.textContent).join(' ')).toEqual(value)
return return
} }
@@ -8,11 +8,11 @@ export async function executeTimeline(
element: JSX.Element, element: JSX.Element,
steps: ((tools: ReturnType<typeof render>) => (null | number)[])[] steps: ((tools: ReturnType<typeof render>) => (null | number)[])[]
) { ) {
const d = disposables() let d = disposables()
const snapshots: { content: DocumentFragment; recordedAt: bigint }[] = [] let snapshots: { content: DocumentFragment; recordedAt: bigint }[] = []
// //
const tools = render(element) let tools = render(element)
// Start listening for changes // Start listening for changes
d.add( d.add(
@@ -30,13 +30,13 @@ export async function executeTimeline(
// We start with a `null` value because we will start with a snapshot even _before_ things start // We start with a `null` value because we will start with a snapshot even _before_ things start
// happening. // happening.
const timestamps: (null | number)[] = [null] let timestamps: (null | number)[] = [null]
// //
await steps.reduce(async (chain, step) => { await steps.reduce(async (chain, step) => {
await chain await chain
const durations = step(tools) let durations = step(tools)
// Note: The following calls are just in place to ensure that **we** waited long enough for the // Note: The following calls are just in place to ensure that **we** waited long enough for the
// transitions to take place. This has no impact on the actual transitions. Above where the // transitions to take place. This has no impact on the actual transitions. Above where the
@@ -45,7 +45,7 @@ export async function executeTimeline(
timestamps.push(...durations) timestamps.push(...durations)
const totalDuration = durations let totalDuration = durations
.filter((duration): duration is number => duration !== null) .filter((duration): duration is number => duration !== null)
.reduce((total, current) => total + current, 0) .reduce((total, current) => total + current, 0)
@@ -63,7 +63,7 @@ export async function executeTimeline(
throw new Error('We could not record any changes') throw new Error('We could not record any changes')
} }
const uniqueSnapshots = snapshots let uniqueSnapshots = snapshots
// Only keep the snapshots that are unique. Multiple snapshots of the same // Only keep the snapshots that are unique. Multiple snapshots of the same
// content are a bit useless for us. // content are a bit useless for us.
.filter((snapshot, i) => { .filter((snapshot, i) => {
@@ -79,7 +79,7 @@ export async function executeTimeline(
i === 0 ? 0 : Number((snapshot.recordedAt - all[i - 1].recordedAt) / BigInt(1e6)), i === 0 ? 0 : Number((snapshot.recordedAt - all[i - 1].recordedAt) / BigInt(1e6)),
})) }))
const diffed = uniqueSnapshots let diffed = uniqueSnapshots
.map((call, i) => { .map((call, i) => {
// Skip initial render, because there is nothing to compare with // Skip initial render, because there is nothing to compare with
if (i === 0) return false if (i === 0) return false
@@ -112,7 +112,7 @@ export async function executeTimeline(
.filter(Boolean) .filter(Boolean)
.join('\n\n') .join('\n\n')
await d.dispose() d.dispose()
return diffed return diffed
} }
@@ -131,13 +131,13 @@ executeTimeline.fullTransition = (duration: number) => {
} }
// Assuming that we run at 60 frames per second // Assuming that we run at 60 frames per second
const frame = 1000 / 60 let frame = 1000 / 60
function isWithinFrame(actual: number, expected: number, frames = 2) { function isWithinFrame(actual: number, expected: number, frames = 2) {
const buffer = frame * frames let buffer = frame * frames
const min = expected - buffer let min = expected - buffer
const max = expected + buffer let max = expected + buffer
return actual >= min && actual <= max return actual >= min && actual <= max
} }
@@ -8,7 +8,7 @@ function nextFrame(cb: Function): void {
) )
} }
export const Keys: Record<string, Partial<KeyboardEvent>> = { export let Keys: Record<string, Partial<KeyboardEvent>> = {
Space: { key: ' ', keyCode: 32, charCode: 32 }, Space: { key: ' ', keyCode: 32, charCode: 32 },
Enter: { key: 'Enter', keyCode: 13, charCode: 13 }, Enter: { key: 'Enter', keyCode: 13, charCode: 13 },
Escape: { key: 'Escape', keyCode: 27, charCode: 27 }, Escape: { key: 'Escape', keyCode: 27, charCode: 27 },
@@ -276,13 +276,13 @@ export async function mouseLeave(element: Document | Element | Window | null) {
// --- // ---
function focusNext(event: Partial<KeyboardEvent>) { function focusNext(event: Partial<KeyboardEvent>) {
const direction = event.shiftKey ? -1 : +1 let direction = event.shiftKey ? -1 : +1
const focusableElements = getFocusableElements() let focusableElements = getFocusableElements()
const total = focusableElements.length let total = focusableElements.length
function innerFocusNext(offset = 0): Element { function innerFocusNext(offset = 0): Element {
const currentIdx = focusableElements.indexOf(document.activeElement as HTMLElement) let currentIdx = focusableElements.indexOf(document.activeElement as HTMLElement)
const next = focusableElements[(currentIdx + total + direction + offset) % total] as HTMLElement let next = focusableElements[(currentIdx + total + direction + offset) % total] as HTMLElement
if (next) next?.focus({ preventScroll: true }) if (next) next?.focus({ preventScroll: true })
@@ -295,7 +295,7 @@ function focusNext(event: Partial<KeyboardEvent>) {
// Credit: // Credit:
// - https://stackoverflow.com/a/30753870 // - https://stackoverflow.com/a/30753870
const focusableSelector = [ let focusableSelector = [
'[contentEditable=true]', '[contentEditable=true]',
'[tabindex]', '[tabindex]',
'a[href]', 'a[href]',
@@ -1,12 +1,12 @@
import { disposables } from '../utils/disposables' import { disposables } from '../utils/disposables'
export function reportChanges<TType>(key: () => TType, onChange: (value: TType) => void) { export function reportChanges<TType>(key: () => TType, onChange: (value: TType) => void) {
const d = disposables() let d = disposables()
let previous: TType let previous: TType
function track() { function track() {
const next = key() let next = key()
if (previous !== next) { if (previous !== next) {
previous = next previous = next
onChange(next) onChange(next)
@@ -8,7 +8,7 @@ export function suppressConsoleLogs<T extends unknown[]>(
type: FunctionPropertyNames<typeof global.console> = 'error' type: FunctionPropertyNames<typeof global.console> = 'error'
) { ) {
return (...args: T) => { return (...args: T) => {
const spy = jest.spyOn(global.console, type).mockImplementation(jest.fn()) let spy = jest.spyOn(global.console, type).mockImplementation(jest.fn())
return new Promise<unknown>((resolve, reject) => { return new Promise<unknown>((resolve, reject) => {
Promise.resolve(cb(...args)).then(resolve, reject) Promise.resolve(cb(...args)).then(resolve, reject)
+2
View File
@@ -3,6 +3,8 @@
const __: unique symbol = Symbol('__placeholder__') const __: unique symbol = Symbol('__placeholder__')
export type __ = typeof __ export type __ = typeof __
export type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never
export type PropsOf<TTag = any> = TTag extends React.ElementType export type PropsOf<TTag = any> = TTag extends React.ElementType
? React.ComponentProps<TTag> ? React.ComponentProps<TTag>
: never : never
@@ -31,19 +31,19 @@ export function calculateActiveIndex<TItem>(
resolveDisabled(item: TItem): boolean resolveDisabled(item: TItem): boolean
} }
) { ) {
const items = resolvers.resolveItems() let items = resolvers.resolveItems()
if (items.length <= 0) return null if (items.length <= 0) return null
const currentActiveIndex = resolvers.resolveActiveIndex() let currentActiveIndex = resolvers.resolveActiveIndex()
const activeIndex = currentActiveIndex ?? -1 let activeIndex = currentActiveIndex ?? -1
const nextActiveIndex = (() => { let nextActiveIndex = (() => {
switch (action.focus) { switch (action.focus) {
case Focus.First: case Focus.First:
return items.findIndex(item => !resolvers.resolveDisabled(item)) return items.findIndex(item => !resolvers.resolveDisabled(item))
case Focus.Previous: { case Focus.Previous: {
const idx = items let idx = items
.slice() .slice()
.reverse() .reverse()
.findIndex((item, idx, all) => { .findIndex((item, idx, all) => {
@@ -61,7 +61,7 @@ export function calculateActiveIndex<TItem>(
}) })
case Focus.Last: { case Focus.Last: {
const idx = items let idx = items
.slice() .slice()
.reverse() .reverse()
.findIndex(item => !resolvers.resolveDisabled(item)) .findIndex(item => !resolvers.resolveDisabled(item))
@@ -1,9 +1,9 @@
export function disposables() { export function disposables() {
const disposables: Function[] = [] let disposables: Function[] = []
const api = { let api = {
requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) { requestAnimationFrame(...args: Parameters<typeof requestAnimationFrame>) {
const raf = requestAnimationFrame(...args) let raf = requestAnimationFrame(...args)
api.add(() => cancelAnimationFrame(raf)) api.add(() => cancelAnimationFrame(raf))
}, },
@@ -14,7 +14,7 @@ export function disposables() {
}, },
setTimeout(...args: Parameters<typeof setTimeout>) { setTimeout(...args: Parameters<typeof setTimeout>) {
const timer = setTimeout(...args) let timer = setTimeout(...args)
api.add(() => clearTimeout(timer)) api.add(() => clearTimeout(timer))
}, },
@@ -23,7 +23,9 @@ export function disposables() {
}, },
dispose() { dispose() {
disposables.splice(0).forEach(dispose => dispose()) for (let dispose of disposables.splice(0)) {
dispose()
}
}, },
} }
@@ -4,11 +4,11 @@ export function match<TValue extends string | number = string, TReturnValue = un
...args: any[] ...args: any[]
): TReturnValue { ): TReturnValue {
if (value in lookup) { if (value in lookup) {
const returnValue = lookup[value] let returnValue = lookup[value]
return typeof returnValue === 'function' ? returnValue(...args) : returnValue return typeof returnValue === 'function' ? returnValue(...args) : returnValue
} }
const error = new Error( let error = new Error(
`Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys( `Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys(
lookup lookup
) )
+2 -4
View File
@@ -1,10 +1,8 @@
export function once<T>(cb: (...args: T[]) => void) { export function once<T>(cb: (...args: T[]) => void) {
const state = { called: false } let state = { called: false }
return (...args: T[]) => { return (...args: T[]) => {
if (state.called) { if (state.called) return
return
}
state.called = true state.called = true
return cb(...args) return cb(...args)
} }
@@ -1,4 +1,4 @@
import React from 'react' import React, { ElementType, createRef, Ref, Fragment } from 'react'
import { render as testRender, prettyDOM, getByTestId } from '@testing-library/react' import { render as testRender, prettyDOM, getByTestId } from '@testing-library/react'
import { suppressConsoleLogs } from '../test-utils/suppress-console-logs' import { suppressConsoleLogs } from '../test-utils/suppress-console-logs'
@@ -12,8 +12,8 @@ function contents() {
} }
describe('Default functionality', () => { describe('Default functionality', () => {
const bag = {} let bag = {}
function Dummy<TTag extends React.ElementType = 'div'>( function Dummy<TTag extends ElementType = 'div'>(
props: Props<TTag> & Partial<{ a: any; b: any; c: any }> props: Props<TTag> & Partial<{ a: any; b: any; c: any }>
) { ) {
return <div data-testid="wrapper">{render(props, bag, 'div')}</div> return <div data-testid="wrapper">{render(props, bag, 'div')}</div>
@@ -58,16 +58,16 @@ describe('Default functionality', () => {
}) })
it('should be possible to add a ref with a different name', () => { it('should be possible to add a ref with a different name', () => {
const ref = React.createRef() let ref = createRef()
function MyComponent<T extends React.ElementType = 'div'>({ function MyComponent<T extends ElementType = 'div'>({
innerRef, innerRef,
...props ...props
}: Props<T> & { innerRef: React.Ref<HTMLDivElement> }) { }: Props<T> & { innerRef: Ref<HTMLDivElement> }) {
return <div ref={innerRef} {...props} /> return <div ref={innerRef} {...props} />
} }
function OtherDummy<TTag extends React.ElementType = 'div'>(props: Props<TTag>) { function OtherDummy<TTag extends ElementType = 'div'>(props: Props<TTag>) {
return <div data-testid="wrapper">{render({ ...props, ref }, bag, 'div')}</div> return <div data-testid="wrapper">{render({ ...props, ref }, bag, 'div')}</div>
} }
@@ -134,8 +134,8 @@ describe('Default functionality', () => {
`) `)
}) })
it('should be possible to render the children only when the `as` prop is set to React.Fragment', () => { it('should be possible to render the children only when the `as` prop is set to Fragment', () => {
testRender(<Dummy as={React.Fragment}>Contents</Dummy>) testRender(<Dummy as={Fragment}>Contents</Dummy>)
expect(contents()).toMatchInlineSnapshot(` expect(contents()).toMatchInlineSnapshot(`
"<div "<div
@@ -146,9 +146,9 @@ describe('Default functionality', () => {
`) `)
}) })
it('should forward all the props to the first child when using an as={React.Fragment}', () => { it('should forward all the props to the first child when using an as={Fragment}', () => {
testRender( testRender(
<Dummy as={React.Fragment} a={1} b={1}> <Dummy as={Fragment} a={1} b={1}>
{() => <span>Contents</span>} {() => <span>Contents</span>}
</Dummy> </Dummy>
) )
@@ -168,14 +168,14 @@ describe('Default functionality', () => {
}) })
it( it(
'should error when we are rendering a React.Fragment with multiple children', 'should error when we are rendering a Fragment with multiple children',
suppressConsoleLogs(() => { suppressConsoleLogs(() => {
expect.assertions(1) expect.assertions(1)
return expect(() => { return expect(() => {
testRender( testRender(
// @ts-expect-error className cannot be applied to a React.Fragment // @ts-expect-error className cannot be applied to a Fragment
<Dummy as={React.Fragment} className="p-12"> <Dummy as={Fragment} className="p-12">
<span>Contents A</span> <span>Contents A</span>
<span>Contents B</span> <span>Contents B</span>
</Dummy> </Dummy>
@@ -184,9 +184,9 @@ describe('Default functionality', () => {
}) })
) )
it("should not error when we are rendering a React.Fragment with multiple children when we don't passthrough additional props", () => { it("should not error when we are rendering a Fragment with multiple children when we don't passthrough additional props", () => {
testRender( testRender(
<Dummy as={React.Fragment}> <Dummy as={Fragment}>
<span>Contents A</span> <span>Contents A</span>
<span>Contents B</span> <span>Contents B</span>
</Dummy> </Dummy>
@@ -207,14 +207,14 @@ describe('Default functionality', () => {
}) })
it( it(
'should error when we are applying props to a React.Fragment when we do not have a dedicated element', 'should error when we are applying props to a Fragment when we do not have a dedicated element',
suppressConsoleLogs(() => { suppressConsoleLogs(() => {
expect.assertions(1) expect.assertions(1)
return expect(() => { return expect(() => {
testRender( testRender(
// @ts-expect-error className cannot be applied to a React.Fragment // @ts-expect-error className cannot be applied to a Fragment
<Dummy as={React.Fragment} className="p-12"> <Dummy as={Fragment} className="p-12">
Contents Contents
</Dummy> </Dummy>
) )
@@ -270,12 +270,12 @@ function testStaticFeature(Dummy) {
// component in a Transition for example so that the Transition component can control the // component in a Transition for example so that the Transition component can control the
// showing/hiding based on the `show` prop AND the state of the transition. // showing/hiding based on the `show` prop AND the state of the transition.
describe('Features.Static', () => { describe('Features.Static', () => {
const bag = {} let bag = {}
const EnabledFeatures = Features.Static let EnabledFeatures = Features.Static
function Dummy<TTag extends React.ElementType = 'div'>( function Dummy<TTag extends ElementType = 'div'>(
props: Props<TTag> & { show: boolean } & PropsForFeatures<typeof EnabledFeatures> props: Props<TTag> & { show: boolean } & PropsForFeatures<typeof EnabledFeatures>
) { ) {
const { show, ...rest } = props let { show, ...rest } = props
return <div data-testid="wrapper">{render(rest, bag, 'div', EnabledFeatures, show)}</div> return <div data-testid="wrapper">{render(rest, bag, 'div', EnabledFeatures, show)}</div>
} }
@@ -364,12 +364,12 @@ function testRenderStrategyFeature(Dummy) {
} }
describe('Features.RenderStrategy', () => { describe('Features.RenderStrategy', () => {
const bag = {} let bag = {}
const EnabledFeatures = Features.RenderStrategy let EnabledFeatures = Features.RenderStrategy
function Dummy<TTag extends React.ElementType = 'div'>( function Dummy<TTag extends ElementType = 'div'>(
props: Props<TTag> & { show: boolean } & PropsForFeatures<typeof EnabledFeatures> props: Props<TTag> & { show: boolean } & PropsForFeatures<typeof EnabledFeatures>
) { ) {
const { show, ...rest } = props let { show, ...rest } = props
return <div data-testid="wrapper">{render(rest, bag, 'div', EnabledFeatures, show)}</div> return <div data-testid="wrapper">{render(rest, bag, 'div', EnabledFeatures, show)}</div>
} }
@@ -380,12 +380,12 @@ describe('Features.RenderStrategy', () => {
// This should enable the `static` and `unmount` features. However they can't be used together! // This should enable the `static` and `unmount` features. However they can't be used together!
describe('Features.Static | Features.RenderStrategy', () => { describe('Features.Static | Features.RenderStrategy', () => {
const bag = {} let bag = {}
const EnabledFeatures = Features.Static | Features.RenderStrategy let EnabledFeatures = Features.Static | Features.RenderStrategy
function Dummy<TTag extends React.ElementType = 'div'>( function Dummy<TTag extends ElementType = 'div'>(
props: Props<TTag> & { show: boolean } & PropsForFeatures<typeof EnabledFeatures> props: Props<TTag> & { show: boolean } & PropsForFeatures<typeof EnabledFeatures>
) { ) {
const { show, ...rest } = props let { show, ...rest } = props
return <div data-testid="wrapper">{render(rest, bag, 'div', EnabledFeatures, show)}</div> return <div data-testid="wrapper">{render(rest, bag, 'div', EnabledFeatures, show)}</div>
} }
+35 -29
View File
@@ -1,5 +1,15 @@
import * as React from 'react' import {
import { Props, XOR, __ } from '../types' Fragment,
cloneElement,
createElement,
forwardRef,
isValidElement,
// Types
ElementType,
ReactElement,
} from 'react'
import { Props, XOR, __, Expand } from '../types'
import { match } from './match' import { match } from './match'
export enum Features { export enum Features {
@@ -36,28 +46,28 @@ export type PropsForFeatures<T extends Features> = XOR<
PropsForFeature<T, Features.RenderStrategy, { unmount?: boolean }> PropsForFeature<T, Features.RenderStrategy, { unmount?: boolean }>
> >
export function render<TFeature extends Features, TTag extends React.ElementType, TBag>( export function render<TFeature extends Features, TTag extends ElementType, TBag>(
props: Props<TTag, TBag, any> & PropsForFeatures<TFeature>, props: Expand<Props<TTag, TBag, any> & PropsForFeatures<TFeature>>,
propsBag: TBag, propsBag: TBag,
defaultTag: React.ElementType, defaultTag: ElementType,
features?: TFeature, features?: TFeature,
visible: boolean = true visible: boolean = true
) { ) {
// Visible always render // Visible always render
if (visible) return _render(props, propsBag, defaultTag) if (visible) return _render(props, propsBag, defaultTag)
const featureFlags = features ?? Features.None let featureFlags = features ?? Features.None
if (featureFlags & Features.Static) { if (featureFlags & Features.Static) {
const { static: isStatic = false, ...rest } = props as PropsForFeatures<Features.Static> let { static: isStatic = false, ...rest } = props as PropsForFeatures<Features.Static>
// When the `static` prop is passed as `true`, then the user is in control, thus we don't care about anything else // When the `static` prop is passed as `true`, then the user is in control, thus we don't care about anything else
if (isStatic) return _render(rest, propsBag, defaultTag) if (isStatic) return _render(rest, propsBag, defaultTag)
} }
if (featureFlags & Features.RenderStrategy) { if (featureFlags & Features.RenderStrategy) {
const { unmount = true, ...rest } = props as PropsForFeatures<Features.RenderStrategy> let { unmount = true, ...rest } = props as PropsForFeatures<Features.RenderStrategy>
const strategy = unmount ? RenderStrategy.Unmount : RenderStrategy.Hidden let strategy = unmount ? RenderStrategy.Unmount : RenderStrategy.Hidden
return match(strategy, { return match(strategy, {
[RenderStrategy.Unmount]() { [RenderStrategy.Unmount]() {
@@ -77,40 +87,40 @@ export function render<TFeature extends Features, TTag extends React.ElementType
return _render(props, propsBag, defaultTag) return _render(props, propsBag, defaultTag)
} }
function _render<TTag extends React.ElementType, TBag>( function _render<TTag extends ElementType, TBag>(
props: Props<TTag, TBag> & { ref?: unknown }, props: Expand<Props<TTag, TBag> & { ref?: unknown }>,
bag: TBag, bag: TBag,
tag: React.ElementType tag: ElementType
) { ) {
const { as: Component = tag, children, refName = 'ref', ...passThroughProps } = omit(props, [ let { as: Component = tag, children, refName = 'ref', ...passThroughProps } = omit(props, [
'unmount', 'unmount',
'static', 'static',
]) ])
// This allows us to use `<HeadlessUIComponent as={MyComopnent} refName="innerRef" />` // This allows us to use `<HeadlessUIComponent as={MyComopnent} refName="innerRef" />`
const refRelatedProps = props.ref !== undefined ? { [refName]: props.ref } : {} let refRelatedProps = props.ref !== undefined ? { [refName]: props.ref } : {}
const resolvedChildren = (typeof children === 'function' ? children(bag) : children) as let resolvedChildren = (typeof children === 'function' ? children(bag) : children) as
| React.ReactElement | ReactElement
| React.ReactElement[] | ReactElement[]
if (Component === React.Fragment) { if (Component === Fragment) {
if (Object.keys(passThroughProps).length > 0) { if (Object.keys(passThroughProps).length > 0) {
if (Array.isArray(resolvedChildren) && resolvedChildren.length > 1) { if (Array.isArray(resolvedChildren) && resolvedChildren.length > 1) {
const err = new Error('You should only render 1 child') let err = new Error('You should only render 1 child')
if (Error.captureStackTrace) Error.captureStackTrace(err, _render) if (Error.captureStackTrace) Error.captureStackTrace(err, _render)
throw err throw err
} }
if (!React.isValidElement(resolvedChildren)) { if (!isValidElement(resolvedChildren)) {
const err = new Error( let err = new Error(
`You should render an element as a child. Did you forget the as="..." prop?` `You should render an element as a child. Did you forget the as="..." prop?`
) )
if (Error.captureStackTrace) Error.captureStackTrace(err, _render) if (Error.captureStackTrace) Error.captureStackTrace(err, _render)
throw err throw err
} }
return React.cloneElement( return cloneElement(
resolvedChildren, resolvedChildren,
Object.assign( Object.assign(
{}, {},
@@ -124,13 +134,9 @@ function _render<TTag extends React.ElementType, TBag>(
} }
} }
return React.createElement( return createElement(
Component, Component,
Object.assign( Object.assign({}, omit(passThroughProps, ['ref']), Component !== Fragment && refRelatedProps),
{},
omit(passThroughProps, ['ref']),
Component !== React.Fragment && refRelatedProps
),
resolvedChildren resolvedChildren
) )
} }
@@ -177,7 +183,7 @@ function mergeEventFunctions(
* wrap it in a forwardRef so that we _can_ passthrough the ref * wrap it in a forwardRef so that we _can_ passthrough the ref
*/ */
export function forwardRefWithAs<T>(component: T): T { export function forwardRefWithAs<T>(component: T): T {
return React.forwardRef((component as unknown) as any) as any return forwardRef((component as unknown) as any) as any
} }
function compact<T extends Record<any, any>>(object: T) { function compact<T extends Record<any, any>>(object: T) {
@@ -87,8 +87,8 @@ export default {
KeyCaster, KeyCaster,
}, },
setup() { setup() {
const route = useRoute() let route = useRoute()
const sourceCode = computed(() => source[route.path]) let sourceCode = computed(() => source[route.path])
return { sourceCode } return { sourceCode }
}, },
@@ -15,9 +15,9 @@
<script> <script>
import { defineComponent, ref } from 'vue' import { defineComponent, ref } from 'vue'
const isMac = navigator.userAgent.indexOf('Mac OS X') !== -1 let isMac = navigator.userAgent.indexOf('Mac OS X') !== -1
const KeyDisplay = isMac let KeyDisplay = isMac
? { ? {
ArrowUp: '↑', ArrowUp: '↑',
ArrowDown: '↓', ArrowDown: '↓',
@@ -57,7 +57,7 @@ const KeyDisplay = isMac
export default defineComponent({ export default defineComponent({
setup() { setup() {
const keys = ref([]) let keys = ref([])
window.addEventListener('keydown', event => { window.addEventListener('keydown', event => {
keys.value.unshift( keys.value.unshift(
@@ -12,7 +12,7 @@ import { defineComponent, h } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import routes from '../routes.json' import routes from '../routes.json'
const Examples = defineComponent({ let Examples = defineComponent({
props: ['examples'], props: ['examples'],
setup(props) { setup(props) {
return () => { return () => {
@@ -93,7 +93,7 @@ function classNames(...classes) {
export default { export default {
components: { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption }, components: { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption },
setup(props, context) { setup(props, context) {
const people = [ let people = [
{ id: 1, name: 'Wade Cooper' }, { id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' }, { id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' }, { id: 3, name: 'Devon Webb' },
@@ -106,7 +106,7 @@ export default {
{ id: 10, name: 'Emil Schaefer' }, { id: 10, name: 'Emil Schaefer' },
] ]
const active = ref(people[Math.floor(Math.random() * people.length)]) let active = ref(people[Math.floor(Math.random() * people.length)])
return { return {
people, people,
@@ -179,7 +179,7 @@ function classNames(...classes) {
export default { export default {
components: { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption }, components: { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption },
setup(props, context) { setup(props, context) {
const people = [ let people = [
{ id: 1, name: 'Wade Cooper' }, { id: 1, name: 'Wade Cooper' },
{ id: 2, name: 'Arlene Mccoy' }, { id: 2, name: 'Arlene Mccoy' },
{ id: 3, name: 'Devon Webb' }, { id: 3, name: 'Devon Webb' },
@@ -192,7 +192,7 @@ export default {
{ id: 10, name: 'Emil Schaefer' }, { id: 10, name: 'Emil Schaefer' },
] ]
const active = ref(people[Math.floor(Math.random() * people.length)]) let active = ref(people[Math.floor(Math.random() * people.length)])
return { return {
people, people,
@@ -62,7 +62,7 @@ function classNames(...classes) {
export default { export default {
components: { Menu, MenuButton, MenuItems, MenuItem }, components: { Menu, MenuButton, MenuItems, MenuItem },
setup(props, context) { setup(props, context) {
const [trigger, container] = usePopper({ let [trigger, container] = usePopper({
placement: 'bottom-end', placement: 'bottom-end',
strategy: 'fixed', strategy: 'fixed',
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }], modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
@@ -70,7 +70,7 @@ function classNames(...classes) {
export default { export default {
components: { Menu, MenuButton, MenuItems, MenuItem }, components: { Menu, MenuButton, MenuItems, MenuItem },
setup(props, context) { setup(props, context) {
const [trigger, container] = usePopper({ let [trigger, container] = usePopper({
placement: 'bottom-end', placement: 'bottom-end',
strategy: 'fixed', strategy: 'fixed',
modifiers: [{ name: 'offset', options: { offset: [0, 10] } }], modifiers: [{ name: 'offset', options: { offset: [0, 10] } }],
@@ -48,7 +48,7 @@ function classNames(...classes) {
return classes.filter(Boolean).join(' ') return classes.filter(Boolean).join(' ')
} }
const CustomMenuItem = defineComponent({ let CustomMenuItem = defineComponent({
components: { Menu, MenuButton, MenuItems, MenuItem }, components: { Menu, MenuButton, MenuItems, MenuItem },
setup(props, { slots }) { setup(props, { slots }) {
return () => { return () => {
@@ -24,7 +24,7 @@ function classNames(...classes) {
export default { export default {
components: { SwitchGroup, Switch, SwitchLabel }, components: { SwitchGroup, Switch, SwitchLabel },
setup(props, context) { setup(props, context) {
const state = ref(false) let state = ref(false)
return { return {
state, state,
@@ -2,18 +2,18 @@ import { ref, onMounted, watchEffect } from 'vue'
import { createPopper } from '@popperjs/core' import { createPopper } from '@popperjs/core'
export function usePopper(options) { export function usePopper(options) {
const reference = ref(null) let reference = ref(null)
const popper = ref(null) let popper = ref(null)
onMounted(() => { onMounted(() => {
watchEffect(onInvalidate => { watchEffect(onInvalidate => {
const popperEl = popper.value.el || popper.value let popperEl = popper.value.el || popper.value
const referenceEl = reference.value.el || reference.value let referenceEl = reference.value.el || reference.value
if (!(referenceEl instanceof HTMLElement)) return if (!(referenceEl instanceof HTMLElement)) return
if (!(popperEl instanceof HTMLElement)) return if (!(popperEl instanceof HTMLElement)) return
const { destroy } = createPopper(referenceEl, popperEl, options) let { destroy } = createPopper(referenceEl, popperEl, options)
onInvalidate(destroy) onInvalidate(destroy)
}) })
@@ -4,7 +4,7 @@ import routes from './routes.json'
function buildRoutes(routes) { function buildRoutes(routes) {
return routes.map(route => { return routes.map(route => {
const definition = { let definition = {
path: route.path, path: route.path,
component: route.component ? lookup[route.component] : RouterView, component: route.component ? lookup[route.component] : RouterView,
} }
@@ -45,7 +45,7 @@ beforeAll(() => {
afterAll(() => jest.restoreAllMocks()) afterAll(() => jest.restoreAllMocks())
function renderTemplate(input: string | Partial<Parameters<typeof defineComponent>[0]>) { function renderTemplate(input: string | Partial<Parameters<typeof defineComponent>[0]>) {
const defaultComponents = { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption } let defaultComponents = { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption }
if (typeof input === 'string') { if (typeof input === 'string') {
return render(defineComponent({ template: input, components: defaultComponents })) return render(defineComponent({ template: input, components: defaultComponents }))
@@ -472,7 +472,7 @@ describe('Rendering composition', () => {
// Open Listbox // Open Listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// Verify correct classNames // Verify correct classNames
expect('' + options[0].classList).toEqual( expect('' + options[0].classList).toEqual(
@@ -597,7 +597,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option, { selected: false })) options.forEach(option => assertListboxOption(option, { selected: false }))
@@ -684,7 +684,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -733,7 +733,7 @@ describe('Keyboard interactions', () => {
assertActiveElement(getListbox()) assertActiveElement(getListbox())
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
const options = getListboxOptions() let options = getListboxOptions()
// Hover over Option A // Hover over Option A
await mouseMove(options[0]) await mouseMove(options[0])
@@ -772,12 +772,12 @@ describe('Keyboard interactions', () => {
</Listbox> </Listbox>
`, `,
setup: () => { setup: () => {
const options = [ let options = [
{ id: 'a', name: 'Option A' }, { id: 'a', name: 'Option A' },
{ id: 'b', name: 'Option B' }, { id: 'b', name: 'Option B' },
{ id: 'c', name: 'Option C' }, { id: 'c', name: 'Option C' },
] ]
const value = ref(options[1]) let value = ref(options[1])
return { value, options } return { value, options }
}, },
@@ -805,7 +805,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -872,7 +872,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// Verify that the first non-disabled listbox option is active // Verify that the first non-disabled listbox option is active
assertActiveListboxOption(options[1]) assertActiveListboxOption(options[1])
@@ -912,7 +912,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// Verify that the first non-disabled listbox option is active // Verify that the first non-disabled listbox option is active
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -1002,7 +1002,7 @@ describe('Keyboard interactions', () => {
it( it(
'should be possible to close the listbox with Enter and choose the active listbox option', 'should be possible to close the listbox with Enter and choose the active listbox option',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
renderTemplate({ renderTemplate({
template: ` template: `
<Listbox v-model="value"> <Listbox v-model="value">
@@ -1015,7 +1015,7 @@ describe('Keyboard interactions', () => {
</Listbox> </Listbox>
`, `,
setup() { setup() {
const value = ref(null) let value = ref(null)
watch([value], () => handleChange(value.value)) watch([value], () => handleChange(value.value))
return { value } return { value }
}, },
@@ -1034,7 +1034,7 @@ describe('Keyboard interactions', () => {
assertListboxButton({ state: ListboxState.Visible }) assertListboxButton({ state: ListboxState.Visible })
// Activate the first listbox option // Activate the first listbox option
const options = getListboxOptions() let options = getListboxOptions()
await mouseMove(options[0]) await mouseMove(options[0])
// Choose option, and close listbox // Choose option, and close listbox
@@ -1100,7 +1100,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -1184,7 +1184,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -1251,7 +1251,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Space) await press(Keys.Space)
const options = getListboxOptions() let options = getListboxOptions()
// Verify that the first non-disabled listbox option is active // Verify that the first non-disabled listbox option is active
assertActiveListboxOption(options[1]) assertActiveListboxOption(options[1])
@@ -1291,7 +1291,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Space) await press(Keys.Space)
const options = getListboxOptions() let options = getListboxOptions()
// Verify that the first non-disabled listbox option is active // Verify that the first non-disabled listbox option is active
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -1340,7 +1340,7 @@ describe('Keyboard interactions', () => {
it( it(
'should be possible to close the listbox with Space and choose the active listbox option', 'should be possible to close the listbox with Space and choose the active listbox option',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
renderTemplate({ renderTemplate({
template: ` template: `
<Listbox v-model="value"> <Listbox v-model="value">
@@ -1353,7 +1353,7 @@ describe('Keyboard interactions', () => {
</Listbox> </Listbox>
`, `,
setup() { setup() {
const value = ref(null) let value = ref(null)
watch([value], () => handleChange(value.value)) watch([value], () => handleChange(value.value))
return { value } return { value }
}, },
@@ -1372,7 +1372,7 @@ describe('Keyboard interactions', () => {
assertListboxButton({ state: ListboxState.Visible }) assertListboxButton({ state: ListboxState.Visible })
// Activate the first listbox option // Activate the first listbox option
const options = getListboxOptions() let options = getListboxOptions()
await mouseMove(options[0]) await mouseMove(options[0])
// Choose option, and close listbox // Choose option, and close listbox
@@ -1484,7 +1484,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -1538,7 +1538,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -1594,7 +1594,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
@@ -1680,7 +1680,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -1746,7 +1746,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -1797,7 +1797,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[1]) assertActiveListboxOption(options[1])
@@ -1842,7 +1842,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -1890,7 +1890,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
@@ -1976,7 +1976,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -2046,7 +2046,7 @@ describe('Keyboard interactions', () => {
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -2087,7 +2087,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2141,7 +2141,7 @@ describe('Keyboard interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2185,7 +2185,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first option // We should be on the first option
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -2224,7 +2224,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first option // We should be on the first option
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -2268,7 +2268,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.End) await press(Keys.End)
const options = getListboxOptions() let options = getListboxOptions()
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
}) })
) )
@@ -2337,7 +2337,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first option // We should be on the first option
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -2376,7 +2376,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.Enter) await press(Keys.Enter)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first option // We should be on the first option
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
@@ -2420,7 +2420,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageDown) await press(Keys.PageDown)
const options = getListboxOptions() let options = getListboxOptions()
assertActiveListboxOption(options[0]) assertActiveListboxOption(options[0])
}) })
) )
@@ -2489,7 +2489,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the last option // We should be on the last option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2531,7 +2531,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.Home) await press(Keys.Home)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first non-disabled option // We should be on the first non-disabled option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2571,7 +2571,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.Home) await press(Keys.Home)
const options = getListboxOptions() let options = getListboxOptions()
assertActiveListboxOption(options[3]) assertActiveListboxOption(options[3])
}) })
) )
@@ -2640,7 +2640,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the last option // We should be on the last option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2682,7 +2682,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageUp) await press(Keys.PageUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the first non-disabled option // We should be on the first non-disabled option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2722,7 +2722,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageUp) await press(Keys.PageUp)
const options = getListboxOptions() let options = getListboxOptions()
assertActiveListboxOption(options[3]) assertActiveListboxOption(options[3])
}) })
) )
@@ -2788,7 +2788,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to go to the second option // We should be able to go to the second option
await type(word('bob')) await type(word('bob'))
@@ -2827,7 +2827,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the last option // We should be on the last option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2869,7 +2869,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the last option // We should be on the last option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -2913,7 +2913,7 @@ describe('Keyboard interactions', () => {
// Open listbox // Open listbox
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const options = getListboxOptions() let options = getListboxOptions()
// We should be on the last option // We should be on the last option
assertActiveListboxOption(options[2]) assertActiveListboxOption(options[2])
@@ -3023,7 +3023,7 @@ describe('Mouse interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach(option => assertListboxOption(option)) options.forEach(option => assertListboxOption(option))
}) })
@@ -3131,7 +3131,7 @@ describe('Mouse interactions', () => {
assertListboxButtonLinkedWithListbox() assertListboxButtonLinkedWithListbox()
// Verify we have listbox options // Verify we have listbox options
const options = getListboxOptions() let options = getListboxOptions()
expect(options).toHaveLength(3) expect(options).toHaveLength(3)
options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 }))
@@ -3261,7 +3261,7 @@ describe('Mouse interactions', () => {
setup: () => ({ value: ref(null) }), setup: () => ({ value: ref(null) }),
}) })
const [button1, button2] = getListboxButtons() let [button1, button2] = getListboxButtons()
// Click the first menu button // Click the first menu button
await click(button1) await click(button1)
@@ -3333,7 +3333,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to go to the second option // We should be able to go to the second option
await mouseMove(options[1]) await mouseMove(options[1])
assertActiveListboxOption(options[1]) assertActiveListboxOption(options[1])
@@ -3368,7 +3368,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to go to the second option // We should be able to go to the second option
await mouseMove(options[1]) await mouseMove(options[1])
assertActiveListboxOption(options[1]) assertActiveListboxOption(options[1])
@@ -3395,7 +3395,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to go to the second option // We should be able to go to the second option
await mouseMove(options[1]) await mouseMove(options[1])
@@ -3430,7 +3430,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
await mouseMove(options[1]) await mouseMove(options[1])
assertNoActiveListboxOption() assertNoActiveListboxOption()
@@ -3459,7 +3459,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// Try to hover over option 1, which is disabled // Try to hover over option 1, which is disabled
await mouseMove(options[1]) await mouseMove(options[1])
@@ -3489,7 +3489,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to go to the second option // We should be able to go to the second option
await mouseMove(options[1]) await mouseMove(options[1])
@@ -3536,7 +3536,7 @@ describe('Mouse interactions', () => {
// Open listbox // Open listbox
await click(getListboxButton()) await click(getListboxButton())
const options = getListboxOptions() let options = getListboxOptions()
// Try to hover over option 1, which is disabled // Try to hover over option 1, which is disabled
await mouseMove(options[1]) await mouseMove(options[1])
@@ -3550,7 +3550,7 @@ describe('Mouse interactions', () => {
it( it(
'should be possible to click a listbox option, which closes the listbox', 'should be possible to click a listbox option, which closes the listbox',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
renderTemplate({ renderTemplate({
template: ` template: `
<Listbox v-model="value"> <Listbox v-model="value">
@@ -3563,7 +3563,7 @@ describe('Mouse interactions', () => {
</Listbox> </Listbox>
`, `,
setup() { setup() {
const value = ref(null) let value = ref(null)
watch([value], () => handleChange(value.value)) watch([value], () => handleChange(value.value))
return { value } return { value }
}, },
@@ -3574,7 +3574,7 @@ describe('Mouse interactions', () => {
assertListbox({ state: ListboxState.Visible }) assertListbox({ state: ListboxState.Visible })
assertActiveElement(getListbox()) assertActiveElement(getListbox())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to click the first option // We should be able to click the first option
await click(options[1]) await click(options[1])
@@ -3596,7 +3596,7 @@ describe('Mouse interactions', () => {
it( it(
'should be possible to click a disabled listbox option, which is a no-op', 'should be possible to click a disabled listbox option, which is a no-op',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
renderTemplate({ renderTemplate({
template: ` template: `
<Listbox v-model="value"> <Listbox v-model="value">
@@ -3611,7 +3611,7 @@ describe('Mouse interactions', () => {
</Listbox> </Listbox>
`, `,
setup() { setup() {
const value = ref(null) let value = ref(null)
watch([value], () => handleChange(value.value)) watch([value], () => handleChange(value.value))
return { value } return { value }
}, },
@@ -3622,7 +3622,7 @@ describe('Mouse interactions', () => {
assertListbox({ state: ListboxState.Visible }) assertListbox({ state: ListboxState.Visible })
assertActiveElement(getListbox()) assertActiveElement(getListbox())
const options = getListboxOptions() let options = getListboxOptions()
// We should be able to click the first option // We should be able to click the first option
await click(options[1]) await click(options[1])
@@ -3663,7 +3663,7 @@ describe('Mouse interactions', () => {
assertListbox({ state: ListboxState.Visible }) assertListbox({ state: ListboxState.Visible })
assertActiveElement(getListbox()) assertActiveElement(getListbox())
const options = getListboxOptions() let options = getListboxOptions()
// Verify that nothing is active yet // Verify that nothing is active yet
assertNoActiveListboxOption() assertNoActiveListboxOption()
@@ -3698,7 +3698,7 @@ describe('Mouse interactions', () => {
assertListbox({ state: ListboxState.Visible }) assertListbox({ state: ListboxState.Visible })
assertActiveElement(getListbox()) assertActiveElement(getListbox())
const options = getListboxOptions() let options = getListboxOptions()
// We should not be able to focus the first option // We should not be able to focus the first option
await focus(options[1]) await focus(options[1])
@@ -53,13 +53,13 @@ type StateDefinition = {
select(value: unknown): void select(value: unknown): void
} }
const ListboxContext = Symbol('ListboxContext') as InjectionKey<StateDefinition> let ListboxContext = Symbol('ListboxContext') as InjectionKey<StateDefinition>
function useListboxContext(component: string) { function useListboxContext(component: string) {
const context = inject(ListboxContext, null) let context = inject(ListboxContext, null)
if (context === null) { if (context === null) {
const err = new Error(`<${component} /> is missing a parent <Listbox /> component.`) let err = new Error(`<${component} /> is missing a parent <Listbox /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useListboxContext) if (Error.captureStackTrace) Error.captureStackTrace(err, useListboxContext)
throw err throw err
} }
@@ -69,7 +69,7 @@ function useListboxContext(component: string) {
// --- // ---
export const Listbox = defineComponent({ export let Listbox = defineComponent({
name: 'Listbox', name: 'Listbox',
emits: ['update:modelValue'], emits: ['update:modelValue'],
props: { props: {
@@ -77,18 +77,18 @@ export const Listbox = defineComponent({
modelValue: { type: [Object, String, Number, Boolean], default: null }, modelValue: { type: [Object, String, Number, Boolean], default: null },
}, },
setup(props, { slots, attrs, emit }) { setup(props, { slots, attrs, emit }) {
const { modelValue, ...passThroughProps } = props let { modelValue, ...passThroughProps } = props
const listboxState = ref<StateDefinition['listboxState']['value']>(ListboxStates.Closed) let listboxState = ref<StateDefinition['listboxState']['value']>(ListboxStates.Closed)
const labelRef = ref<StateDefinition['labelRef']['value']>(null) let labelRef = ref<StateDefinition['labelRef']['value']>(null)
const buttonRef = ref<StateDefinition['buttonRef']['value']>(null) let buttonRef = ref<StateDefinition['buttonRef']['value']>(null)
const optionsRef = ref<StateDefinition['optionsRef']['value']>(null) let optionsRef = ref<StateDefinition['optionsRef']['value']>(null)
const options = ref<StateDefinition['options']['value']>([]) let options = ref<StateDefinition['options']['value']>([])
const searchQuery = ref<StateDefinition['searchQuery']['value']>('') let searchQuery = ref<StateDefinition['searchQuery']['value']>('')
const activeOptionIndex = ref<StateDefinition['activeOptionIndex']['value']>(null) let activeOptionIndex = ref<StateDefinition['activeOptionIndex']['value']>(null)
const value = computed(() => props.modelValue) let value = computed(() => props.modelValue)
const api = { let api = {
listboxState, listboxState,
value, value,
labelRef, labelRef,
@@ -103,7 +103,7 @@ export const Listbox = defineComponent({
}, },
openListbox: () => (listboxState.value = ListboxStates.Open), openListbox: () => (listboxState.value = ListboxStates.Open),
goToOption(focus: Focus, id?: string) { goToOption(focus: Focus, id?: string) {
const nextActiveOptionIndex = calculateActiveIndex( let nextActiveOptionIndex = calculateActiveIndex(
focus === Focus.Specific focus === Focus.Specific
? { focus: Focus.Specific, id: id! } ? { focus: Focus.Specific, id: id! }
: { focus: focus as Exclude<Focus, Focus.Specific> }, : { focus: focus as Exclude<Focus, Focus.Specific> },
@@ -122,7 +122,7 @@ export const Listbox = defineComponent({
search(value: string) { search(value: string) {
searchQuery.value += value searchQuery.value += value
const match = options.value.findIndex( let match = options.value.findIndex(
option => option =>
!option.dataRef.disabled && option.dataRef.textValue.startsWith(searchQuery.value) !option.dataRef.disabled && option.dataRef.textValue.startsWith(searchQuery.value)
) )
@@ -138,10 +138,10 @@ export const Listbox = defineComponent({
options.value.push({ id, dataRef }) options.value.push({ id, dataRef })
}, },
unregisterOption(id: string) { unregisterOption(id: string) {
const nextOptions = options.value.slice() let nextOptions = options.value.slice()
const currentActiveOption = let currentActiveOption =
activeOptionIndex.value !== null ? nextOptions[activeOptionIndex.value] : null activeOptionIndex.value !== null ? nextOptions[activeOptionIndex.value] : null
const idx = nextOptions.findIndex(a => a.id === id) let idx = nextOptions.findIndex(a => a.id === id)
if (idx !== -1) nextOptions.splice(idx, 1) if (idx !== -1) nextOptions.splice(idx, 1)
options.value = nextOptions options.value = nextOptions
activeOptionIndex.value = (() => { activeOptionIndex.value = (() => {
@@ -160,8 +160,8 @@ export const Listbox = defineComponent({
onMounted(() => { onMounted(() => {
function handler(event: MouseEvent) { function handler(event: MouseEvent) {
const target = event.target as HTMLElement let target = event.target as HTMLElement
const active = document.activeElement let active = document.activeElement
if (listboxState.value !== ListboxStates.Open) return if (listboxState.value !== ListboxStates.Open) return
if (buttonRef.value?.contains(target)) return if (buttonRef.value?.contains(target)) return
@@ -179,7 +179,7 @@ export const Listbox = defineComponent({
provide(ListboxContext, api) provide(ListboxContext, api)
return () => { return () => {
const slot = { open: listboxState.value === ListboxStates.Open } let slot = { open: listboxState.value === ListboxStates.Open }
return render({ props: passThroughProps, slot, slots, attrs }) return render({ props: passThroughProps, slot, slots, attrs })
} }
}, },
@@ -187,14 +187,14 @@ export const Listbox = defineComponent({
// --- // ---
export const ListboxLabel = defineComponent({ export let ListboxLabel = defineComponent({
name: 'ListboxLabel', name: 'ListboxLabel',
props: { as: { type: [Object, String], default: 'label' } }, props: { as: { type: [Object, String], default: 'label' } },
render() { render() {
const api = useListboxContext('ListboxLabel') let api = useListboxContext('ListboxLabel')
const slot = { open: api.listboxState.value === ListboxStates.Open } let slot = { open: api.listboxState.value === ListboxStates.Open }
const propsWeControl = { id: this.id, ref: 'el', onClick: this.handleClick } let propsWeControl = { id: this.id, ref: 'el', onClick: this.handleClick }
return render({ return render({
props: { ...this.$props, ...propsWeControl }, props: { ...this.$props, ...propsWeControl },
@@ -204,8 +204,8 @@ export const ListboxLabel = defineComponent({
}) })
}, },
setup() { setup() {
const api = useListboxContext('ListboxLabel') let api = useListboxContext('ListboxLabel')
const id = `headlessui-listbox-label-${useId()}` let id = `headlessui-listbox-label-${useId()}`
return { return {
id, id,
@@ -219,17 +219,17 @@ export const ListboxLabel = defineComponent({
// --- // ---
export const ListboxButton = defineComponent({ export let ListboxButton = defineComponent({
name: 'ListboxButton', name: 'ListboxButton',
props: { props: {
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
as: { type: [Object, String], default: 'button' }, as: { type: [Object, String], default: 'button' },
}, },
render() { render() {
const api = useListboxContext('ListboxButton') let api = useListboxContext('ListboxButton')
const slot = { open: api.listboxState.value === ListboxStates.Open } let slot = { open: api.listboxState.value === ListboxStates.Open }
const propsWeControl = { let propsWeControl = {
ref: 'el', ref: 'el',
id: this.id, id: this.id,
type: 'button', type: 'button',
@@ -251,8 +251,8 @@ export const ListboxButton = defineComponent({
}) })
}, },
setup(props) { setup(props) {
const api = useListboxContext('ListboxButton') let api = useListboxContext('ListboxButton')
const id = `headlessui-listbox-button-${useId()}` let id = `headlessui-listbox-button-${useId()}`
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
switch (event.key) { switch (event.key) {
@@ -298,7 +298,7 @@ export const ListboxButton = defineComponent({
// --- // ---
export const ListboxOptions = defineComponent({ export let ListboxOptions = defineComponent({
name: 'ListboxOptions', name: 'ListboxOptions',
props: { props: {
as: { type: [Object, String], default: 'ul' }, as: { type: [Object, String], default: 'ul' },
@@ -306,10 +306,10 @@ export const ListboxOptions = defineComponent({
unmount: { type: Boolean, default: true }, unmount: { type: Boolean, default: true },
}, },
render() { render() {
const api = useListboxContext('ListboxOptions') let api = useListboxContext('ListboxOptions')
const slot = { open: api.listboxState.value === ListboxStates.Open } let slot = { open: api.listboxState.value === ListboxStates.Open }
const propsWeControl = { let propsWeControl = {
'aria-activedescendant': 'aria-activedescendant':
api.activeOptionIndex.value === null api.activeOptionIndex.value === null
? undefined ? undefined
@@ -321,7 +321,7 @@ export const ListboxOptions = defineComponent({
tabIndex: 0, tabIndex: 0,
ref: 'el', ref: 'el',
} }
const passThroughProps = this.$props let passThroughProps = this.$props
return render({ return render({
props: { ...passThroughProps, ...propsWeControl }, props: { ...passThroughProps, ...propsWeControl },
@@ -333,9 +333,9 @@ export const ListboxOptions = defineComponent({
}) })
}, },
setup() { setup() {
const api = useListboxContext('ListboxOptions') let api = useListboxContext('ListboxOptions')
const id = `headlessui-listbox-options-${useId()}` let id = `headlessui-listbox-options-${useId()}`
const searchDebounce = ref<ReturnType<typeof setTimeout> | null>(null) let searchDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (searchDebounce.value) clearTimeout(searchDebounce.value) if (searchDebounce.value) clearTimeout(searchDebounce.value)
@@ -353,7 +353,7 @@ export const ListboxOptions = defineComponent({
case Keys.Enter: case Keys.Enter:
event.preventDefault() event.preventDefault()
if (api.activeOptionIndex.value !== null) { if (api.activeOptionIndex.value !== null) {
const { dataRef } = api.options.value[api.activeOptionIndex.value] let { dataRef } = api.options.value[api.activeOptionIndex.value]
api.select(dataRef.value) api.select(dataRef.value)
} }
api.closeListbox() api.closeListbox()
@@ -400,7 +400,7 @@ export const ListboxOptions = defineComponent({
}, },
}) })
export const ListboxOption = defineComponent({ export let ListboxOption = defineComponent({
name: 'ListboxOption', name: 'ListboxOption',
props: { props: {
as: { type: [Object, String], default: 'li' }, as: { type: [Object, String], default: 'li' },
@@ -410,21 +410,21 @@ export const ListboxOption = defineComponent({
className: { type: [String, Function], required: false }, className: { type: [String, Function], required: false },
}, },
setup(props, { slots, attrs }) { setup(props, { slots, attrs }) {
const api = useListboxContext('ListboxOption') let api = useListboxContext('ListboxOption')
const id = `headlessui-listbox-option-${useId()}` let id = `headlessui-listbox-option-${useId()}`
const { disabled, class: defaultClass, className = defaultClass, value } = props let { disabled, class: defaultClass, className = defaultClass, value } = props
const active = computed(() => { let active = computed(() => {
return api.activeOptionIndex.value !== null return api.activeOptionIndex.value !== null
? api.options.value[api.activeOptionIndex.value].id === id ? api.options.value[api.activeOptionIndex.value].id === id
: false : false
}) })
const selected = computed(() => toRaw(api.value.value) === toRaw(value)) let selected = computed(() => toRaw(api.value.value) === toRaw(value))
const dataRef = ref<ListboxOptionDataRef['value']>({ disabled, value, textValue: '' }) let dataRef = ref<ListboxOptionDataRef['value']>({ disabled, value, textValue: '' })
onMounted(() => { onMounted(() => {
const textValue = document let textValue = document
.getElementById(id) .getElementById(id)
?.textContent?.toLowerCase() ?.textContent?.toLowerCase()
.trim() .trim()
@@ -478,8 +478,8 @@ export const ListboxOption = defineComponent({
} }
return () => { return () => {
const slot = { active: active.value, selected: selected.value, disabled } let slot = { active: active.value, selected: selected.value, disabled }
const propsWeControl = { let propsWeControl = {
id, id,
role: 'option', role: 'option',
tabIndex: -1, tabIndex: -1,
@@ -40,7 +40,7 @@ beforeAll(() => {
afterAll(() => jest.restoreAllMocks()) afterAll(() => jest.restoreAllMocks())
function renderTemplate(input: string | Partial<Parameters<typeof defineComponent>[0]>) { function renderTemplate(input: string | Partial<Parameters<typeof defineComponent>[0]>) {
const defaultComponents = { Menu, MenuButton, MenuItems, MenuItem } let defaultComponents = { Menu, MenuButton, MenuItems, MenuItem }
if (typeof input === 'string') { if (typeof input === 'string') {
return render(defineComponent({ template: input, components: defaultComponents })) return render(defineComponent({ template: input, components: defaultComponents }))
@@ -315,7 +315,7 @@ describe('Rendering', () => {
}) })
it('should yell when we render MenuItems using a template `as` prop that contains multiple children', async () => { it('should yell when we render MenuItems using a template `as` prop that contains multiple children', async () => {
const state = { let state = {
resolve(_value: Error | PromiseLike<Error>) {}, resolve(_value: Error | PromiseLike<Error>) {},
done(error: unknown) { done(error: unknown) {
state.resolve(error as Error) state.resolve(error as Error)
@@ -343,7 +343,7 @@ describe('Rendering', () => {
}) })
await click(getMenuButton()) await click(getMenuButton())
const error = await state.promise let error = await state.promise
expect(error.message).toMatchInlineSnapshot( expect(error.message).toMatchInlineSnapshot(
`"You should only render 1 child or use the \`as=\\"...\\"\` prop"` `"You should only render 1 child or use the \`as=\\"...\\"\` prop"`
) )
@@ -469,7 +469,7 @@ describe('Rendering', () => {
}) })
it('should yell when we render a MenuItem using a template `as` prop that contains multiple children', async () => { it('should yell when we render a MenuItem using a template `as` prop that contains multiple children', async () => {
const state = { let state = {
resolve(_value: Error | PromiseLike<Error>) {}, resolve(_value: Error | PromiseLike<Error>) {},
done(error: unknown) { done(error: unknown) {
state.resolve(error as Error) state.resolve(error as Error)
@@ -500,7 +500,7 @@ describe('Rendering', () => {
}) })
await click(getMenuButton()) await click(getMenuButton())
const error = await state.promise let error = await state.promise
expect(error.message).toMatchInlineSnapshot( expect(error.message).toMatchInlineSnapshot(
`"You should only render 1 child or use the \`as=\\"...\\"\` prop"` `"You should only render 1 child or use the \`as=\\"...\\"\` prop"`
) )
@@ -530,7 +530,7 @@ describe('Rendering composition', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// Verify correct classNames // Verify correct classNames
expect('' + items[0].classList).toEqual(JSON.stringify({ active: false, disabled: false })) expect('' + items[0].classList).toEqual(JSON.stringify({ active: false, disabled: false }))
@@ -566,7 +566,7 @@ describe('Rendering composition', () => {
it( it(
'should be possible to swap the menu item with a button for example', 'should be possible to swap the menu item with a button for example',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const MyButton = defineComponent({ let MyButton = defineComponent({
setup(props) { setup(props) {
return () => h('button', { 'data-my-custom-button': true, ...props }) return () => h('button', { 'data-my-custom-button': true, ...props })
}, },
@@ -596,7 +596,7 @@ describe('Rendering composition', () => {
await click(getMenuButton()) await click(getMenuButton())
// Verify items are buttons now // Verify items are buttons now
const items = getMenuItems() let items = getMenuItems()
items.forEach(item => items.forEach(item =>
assertMenuItem(item, { tag: 'button', attributes: { 'data-my-custom-button': 'true' } }) assertMenuItem(item, { tag: 'button', attributes: { 'data-my-custom-button': 'true' } })
) )
@@ -639,7 +639,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
@@ -723,7 +723,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// Verify that the first non-disabled menu item is active // Verify that the first non-disabled menu item is active
assertMenuLinkedWithMenuItem(items[1]) assertMenuLinkedWithMenuItem(items[1])
@@ -753,7 +753,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// Verify that the first non-disabled menu item is active // Verify that the first non-disabled menu item is active
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -822,7 +822,7 @@ describe('Keyboard interactions', () => {
}) })
it('should be possible to close the menu with Enter and invoke the active menu item', async () => { it('should be possible to close the menu with Enter and invoke the active menu item', async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
renderTemplate({ renderTemplate({
template: ` template: `
<Menu> <Menu>
@@ -850,7 +850,7 @@ describe('Keyboard interactions', () => {
assertMenuButton({ state: MenuState.Visible }) assertMenuButton({ state: MenuState.Visible })
// Activate the first menu item // Activate the first menu item
const items = getMenuItems() let items = getMenuItems()
await mouseMove(items[0]) await mouseMove(items[0])
// Close menu, and invoke the item // Close menu, and invoke the item
@@ -869,7 +869,7 @@ describe('Keyboard interactions', () => {
}) })
it('should be possible to use a button as a menu item and invoke it upon Enter', async () => { it('should be possible to use a button as a menu item and invoke it upon Enter', async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
renderTemplate({ renderTemplate({
template: ` template: `
@@ -902,7 +902,7 @@ describe('Keyboard interactions', () => {
assertMenuButton({ state: MenuState.Visible }) assertMenuButton({ state: MenuState.Visible })
// Activate the second menu item // Activate the second menu item
const items = getMenuItems() let items = getMenuItems()
await mouseMove(items[1]) await mouseMove(items[1])
// Close menu, and invoke the item // Close menu, and invoke the item
@@ -968,7 +968,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1050,7 +1050,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Space) await press(Keys.Space)
const items = getMenuItems() let items = getMenuItems()
// Verify that the first non-disabled menu item is active // Verify that the first non-disabled menu item is active
assertMenuLinkedWithMenuItem(items[1]) assertMenuLinkedWithMenuItem(items[1])
@@ -1080,7 +1080,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Space) await press(Keys.Space)
const items = getMenuItems() let items = getMenuItems()
// Verify that the first non-disabled menu item is active // Verify that the first non-disabled menu item is active
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -1154,7 +1154,7 @@ describe('Keyboard interactions', () => {
it( it(
'should be possible to close the menu with Space and invoke the active menu item', 'should be possible to close the menu with Space and invoke the active menu item',
suppressConsoleLogs(async () => { suppressConsoleLogs(async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
renderTemplate({ renderTemplate({
template: ` template: `
<Menu> <Menu>
@@ -1182,7 +1182,7 @@ describe('Keyboard interactions', () => {
assertMenuButton({ state: MenuState.Visible }) assertMenuButton({ state: MenuState.Visible })
// Activate the first menu item // Activate the first menu item
const items = getMenuItems() let items = getMenuItems()
await mouseMove(items[0]) await mouseMove(items[0])
// Close menu, and invoke the item // Close menu, and invoke the item
@@ -1274,7 +1274,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1320,7 +1320,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1368,7 +1368,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
@@ -1453,7 +1453,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1496,7 +1496,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[1]) assertMenuLinkedWithMenuItem(items[1])
@@ -1531,7 +1531,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -1572,7 +1572,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
@@ -1625,7 +1625,7 @@ describe('Keyboard interactions', () => {
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1656,7 +1656,7 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter) await press(Keys.Enter)
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -1703,7 +1703,7 @@ describe('Keyboard interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -1741,7 +1741,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first item // We should be on the first item
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1770,7 +1770,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first item // We should be on the first item
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1802,7 +1802,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.End) await press(Keys.End)
const items = getMenuItems() let items = getMenuItems()
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
}) })
@@ -1851,7 +1851,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first item // We should be on the first item
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1880,7 +1880,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.Enter) await press(Keys.Enter)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first item // We should be on the first item
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
@@ -1912,7 +1912,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageDown) await press(Keys.PageDown)
const items = getMenuItems() let items = getMenuItems()
assertMenuLinkedWithMenuItem(items[0]) assertMenuLinkedWithMenuItem(items[0])
}) })
@@ -1961,7 +1961,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the last item // We should be on the last item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -1993,7 +1993,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.Home) await press(Keys.Home)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first non-disabled item // We should be on the first non-disabled item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2021,7 +2021,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.Home) await press(Keys.Home)
const items = getMenuItems() let items = getMenuItems()
assertMenuLinkedWithMenuItem(items[3]) assertMenuLinkedWithMenuItem(items[3])
}) })
@@ -2070,7 +2070,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the last item // We should be on the last item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2102,7 +2102,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageUp) await press(Keys.PageUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the first non-disabled item // We should be on the first non-disabled item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2130,7 +2130,7 @@ describe('Keyboard interactions', () => {
// We should not be able to go to the end // We should not be able to go to the end
await press(Keys.PageUp) await press(Keys.PageUp)
const items = getMenuItems() let items = getMenuItems()
assertMenuLinkedWithMenuItem(items[3]) assertMenuLinkedWithMenuItem(items[3])
}) })
@@ -2176,7 +2176,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// We should be able to go to the second item // We should be able to go to the second item
await type(word('bob')) await type(word('bob'))
@@ -2209,7 +2209,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the last item // We should be on the last item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2247,7 +2247,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the last item // We should be on the last item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2284,7 +2284,7 @@ describe('Keyboard interactions', () => {
// Open menu // Open menu
await press(Keys.ArrowUp) await press(Keys.ArrowUp)
const items = getMenuItems() let items = getMenuItems()
// We should be on the last item // We should be on the last item
assertMenuLinkedWithMenuItem(items[2]) assertMenuLinkedWithMenuItem(items[2])
@@ -2329,7 +2329,7 @@ describe('Mouse interactions', () => {
assertMenuButtonLinkedWithMenu() assertMenuButtonLinkedWithMenu()
// Verify we have menu items // Verify we have menu items
const items = getMenuItems() let items = getMenuItems()
expect(items).toHaveLength(3) expect(items).toHaveLength(3)
items.forEach(item => assertMenuItem(item)) items.forEach(item => assertMenuItem(item))
}) })
@@ -2516,7 +2516,7 @@ describe('Mouse interactions', () => {
</div> </div>
`) `)
const [button1, button2] = getMenuButtons() let [button1, button2] = getMenuButtons()
// Click the first menu button // Click the first menu button
await click(button1) await click(button1)
@@ -2550,7 +2550,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// We should be able to go to the second item // We should be able to go to the second item
await mouseMove(items[1]) await mouseMove(items[1])
assertMenuLinkedWithMenuItem(items[1]) assertMenuLinkedWithMenuItem(items[1])
@@ -2579,7 +2579,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// We should be able to go to the second item // We should be able to go to the second item
await mouseMove(items[1]) await mouseMove(items[1])
assertMenuLinkedWithMenuItem(items[1]) assertMenuLinkedWithMenuItem(items[1])
@@ -2600,7 +2600,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// We should be able to go to the second item // We should be able to go to the second item
await mouseMove(items[1]) await mouseMove(items[1])
@@ -2627,7 +2627,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
await mouseMove(items[1]) await mouseMove(items[1])
assertNoActiveMenuItem() assertNoActiveMenuItem()
@@ -2648,7 +2648,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// Try to hover over item 1, which is disabled // Try to hover over item 1, which is disabled
await mouseMove(items[1]) await mouseMove(items[1])
@@ -2672,7 +2672,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// We should be able to go to the second item // We should be able to go to the second item
await mouseMove(items[1]) await mouseMove(items[1])
@@ -2711,7 +2711,7 @@ describe('Mouse interactions', () => {
// Open menu // Open menu
await click(getMenuButton()) await click(getMenuButton())
const items = getMenuItems() let items = getMenuItems()
// Try to hover over item 1, which is disabled // Try to hover over item 1, which is disabled
await mouseMove(items[1]) await mouseMove(items[1])
@@ -2722,7 +2722,7 @@ describe('Mouse interactions', () => {
}) })
it('should be possible to click a menu item, which closes the menu', async () => { it('should be possible to click a menu item, which closes the menu', async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
renderTemplate({ renderTemplate({
template: ` template: `
<Menu> <Menu>
@@ -2741,7 +2741,7 @@ describe('Mouse interactions', () => {
await click(getMenuButton()) await click(getMenuButton())
assertMenu({ state: MenuState.Visible }) assertMenu({ state: MenuState.Visible })
const items = getMenuItems() let items = getMenuItems()
// We should be able to click the first item // We should be able to click the first item
await click(items[1]) await click(items[1])
@@ -2751,7 +2751,7 @@ describe('Mouse interactions', () => {
}) })
it('should be possible to click a menu item, which closes the menu and invokes the @click handler', async () => { it('should be possible to click a menu item, which closes the menu and invokes the @click handler', async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
renderTemplate({ renderTemplate({
template: ` template: `
<Menu> <Menu>
@@ -2806,7 +2806,7 @@ describe('Mouse interactions', () => {
await click(getMenuButton()) await click(getMenuButton())
assertMenu({ state: MenuState.Visible }) assertMenu({ state: MenuState.Visible })
const items = getMenuItems() let items = getMenuItems()
// We should be able to click the first item // We should be able to click the first item
await click(items[1]) await click(items[1])
@@ -2829,7 +2829,7 @@ describe('Mouse interactions', () => {
await click(getMenuButton()) await click(getMenuButton())
assertMenu({ state: MenuState.Visible }) assertMenu({ state: MenuState.Visible })
const items = getMenuItems() let items = getMenuItems()
// Verify that nothing is active yet // Verify that nothing is active yet
assertNoActiveMenuItem() assertNoActiveMenuItem()
@@ -2855,7 +2855,7 @@ describe('Mouse interactions', () => {
await click(getMenuButton()) await click(getMenuButton())
assertMenu({ state: MenuState.Visible }) assertMenu({ state: MenuState.Visible })
const items = getMenuItems() let items = getMenuItems()
// We should not be able to focus the first item // We should not be able to focus the first item
await focus(items[1]) await focus(items[1])
@@ -2863,7 +2863,7 @@ describe('Mouse interactions', () => {
}) })
it('should not be possible to activate a disabled item', async () => { it('should not be possible to activate a disabled item', async () => {
const clickHandler = jest.fn() let clickHandler = jest.fn()
renderTemplate({ renderTemplate({
template: ` template: `
@@ -2887,7 +2887,7 @@ describe('Mouse interactions', () => {
await click(getMenuButton()) await click(getMenuButton())
assertMenu({ state: MenuState.Visible }) assertMenu({ state: MenuState.Visible })
const items = getMenuItems() let items = getMenuItems()
await focus(items[0]) await focus(items[0])
await focus(items[1]) await focus(items[1])
@@ -45,13 +45,13 @@ type StateDefinition = {
unregisterItem(id: string): void unregisterItem(id: string): void
} }
const MenuContext = Symbol('MenuContext') as InjectionKey<StateDefinition> let MenuContext = Symbol('MenuContext') as InjectionKey<StateDefinition>
function useMenuContext(component: string) { function useMenuContext(component: string) {
const context = inject(MenuContext, null) let context = inject(MenuContext, null)
if (context === null) { if (context === null) {
const err = new Error(`<${component} /> is missing a parent <Menu /> component.`) let err = new Error(`<${component} /> is missing a parent <Menu /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useMenuContext) if (Error.captureStackTrace) Error.captureStackTrace(err, useMenuContext)
throw err throw err
} }
@@ -59,17 +59,17 @@ function useMenuContext(component: string) {
return context return context
} }
export const Menu = defineComponent({ export let Menu = defineComponent({
props: { as: { type: [Object, String], default: 'template' } }, props: { as: { type: [Object, String], default: 'template' } },
setup(props, { slots, attrs }) { setup(props, { slots, attrs }) {
const menuState = ref<StateDefinition['menuState']['value']>(MenuStates.Closed) let menuState = ref<StateDefinition['menuState']['value']>(MenuStates.Closed)
const buttonRef = ref<StateDefinition['buttonRef']['value']>(null) let buttonRef = ref<StateDefinition['buttonRef']['value']>(null)
const itemsRef = ref<StateDefinition['itemsRef']['value']>(null) let itemsRef = ref<StateDefinition['itemsRef']['value']>(null)
const items = ref<StateDefinition['items']['value']>([]) let items = ref<StateDefinition['items']['value']>([])
const searchQuery = ref<StateDefinition['searchQuery']['value']>('') let searchQuery = ref<StateDefinition['searchQuery']['value']>('')
const activeItemIndex = ref<StateDefinition['activeItemIndex']['value']>(null) let activeItemIndex = ref<StateDefinition['activeItemIndex']['value']>(null)
const api = { let api = {
menuState, menuState,
buttonRef, buttonRef,
itemsRef, itemsRef,
@@ -82,7 +82,7 @@ export const Menu = defineComponent({
}, },
openMenu: () => (menuState.value = MenuStates.Open), openMenu: () => (menuState.value = MenuStates.Open),
goToItem(focus: Focus, id?: string) { goToItem(focus: Focus, id?: string) {
const nextActiveItemIndex = calculateActiveIndex( let nextActiveItemIndex = calculateActiveIndex(
focus === Focus.Specific focus === Focus.Specific
? { focus: Focus.Specific, id: id! } ? { focus: Focus.Specific, id: id! }
: { focus: focus as Exclude<Focus, Focus.Specific> }, : { focus: focus as Exclude<Focus, Focus.Specific> },
@@ -101,7 +101,7 @@ export const Menu = defineComponent({
search(value: string) { search(value: string) {
searchQuery.value += value searchQuery.value += value
const match = items.value.findIndex( let match = items.value.findIndex(
item => item.dataRef.textValue.startsWith(searchQuery.value) && !item.dataRef.disabled item => item.dataRef.textValue.startsWith(searchQuery.value) && !item.dataRef.disabled
) )
@@ -117,10 +117,10 @@ export const Menu = defineComponent({
items.value.push({ id, dataRef }) items.value.push({ id, dataRef })
}, },
unregisterItem(id: string) { unregisterItem(id: string) {
const nextItems = items.value.slice() let nextItems = items.value.slice()
const currentActiveItem = let currentActiveItem =
activeItemIndex.value !== null ? nextItems[activeItemIndex.value] : null activeItemIndex.value !== null ? nextItems[activeItemIndex.value] : null
const idx = nextItems.findIndex(a => a.id === id) let idx = nextItems.findIndex(a => a.id === id)
if (idx !== -1) nextItems.splice(idx, 1) if (idx !== -1) nextItems.splice(idx, 1)
items.value = nextItems items.value = nextItems
activeItemIndex.value = (() => { activeItemIndex.value = (() => {
@@ -136,8 +136,8 @@ export const Menu = defineComponent({
onMounted(() => { onMounted(() => {
function handler(event: MouseEvent) { function handler(event: MouseEvent) {
const target = event.target as HTMLElement let target = event.target as HTMLElement
const active = document.activeElement let active = document.activeElement
if (menuState.value !== MenuStates.Open) return if (menuState.value !== MenuStates.Open) return
if (buttonRef.value?.contains(target)) return if (buttonRef.value?.contains(target)) return
@@ -155,22 +155,22 @@ export const Menu = defineComponent({
provide(MenuContext, api) provide(MenuContext, api)
return () => { return () => {
const slot = { open: menuState.value === MenuStates.Open } let slot = { open: menuState.value === MenuStates.Open }
return render({ props, slot, slots, attrs }) return render({ props, slot, slots, attrs })
} }
}, },
}) })
export const MenuButton = defineComponent({ export let MenuButton = defineComponent({
props: { props: {
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
as: { type: [Object, String], default: 'button' }, as: { type: [Object, String], default: 'button' },
}, },
render() { render() {
const api = useMenuContext('MenuButton') let api = useMenuContext('MenuButton')
const slot = { open: api.menuState.value === MenuStates.Open } let slot = { open: api.menuState.value === MenuStates.Open }
const propsWeControl = { let propsWeControl = {
ref: 'el', ref: 'el',
id: this.id, id: this.id,
type: 'button', type: 'button',
@@ -189,8 +189,8 @@ export const MenuButton = defineComponent({
}) })
}, },
setup(props) { setup(props) {
const api = useMenuContext('MenuButton') let api = useMenuContext('MenuButton')
const id = `headlessui-menu-button-${useId()}` let id = `headlessui-menu-button-${useId()}`
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
switch (event.key) { switch (event.key) {
@@ -239,17 +239,17 @@ export const MenuButton = defineComponent({
}, },
}) })
export const MenuItems = defineComponent({ export let MenuItems = defineComponent({
props: { props: {
as: { type: [Object, String], default: 'div' }, as: { type: [Object, String], default: 'div' },
static: { type: Boolean, default: false }, static: { type: Boolean, default: false },
unmount: { type: Boolean, default: true }, unmount: { type: Boolean, default: true },
}, },
render() { render() {
const api = useMenuContext('MenuItems') let api = useMenuContext('MenuItems')
const slot = { open: api.menuState.value === MenuStates.Open } let slot = { open: api.menuState.value === MenuStates.Open }
const propsWeControl = { let propsWeControl = {
'aria-activedescendant': 'aria-activedescendant':
api.activeItemIndex.value === null api.activeItemIndex.value === null
? undefined ? undefined
@@ -261,7 +261,7 @@ export const MenuItems = defineComponent({
tabIndex: 0, tabIndex: 0,
ref: 'el', ref: 'el',
} }
const passThroughProps = this.$props let passThroughProps = this.$props
return render({ return render({
props: { ...passThroughProps, ...propsWeControl }, props: { ...passThroughProps, ...propsWeControl },
@@ -273,9 +273,9 @@ export const MenuItems = defineComponent({
}) })
}, },
setup() { setup() {
const api = useMenuContext('MenuItems') let api = useMenuContext('MenuItems')
const id = `headlessui-menu-items-${useId()}` let id = `headlessui-menu-items-${useId()}`
const searchDebounce = ref<ReturnType<typeof setTimeout> | null>(null) let searchDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (searchDebounce.value) clearTimeout(searchDebounce.value) if (searchDebounce.value) clearTimeout(searchDebounce.value)
@@ -293,7 +293,7 @@ export const MenuItems = defineComponent({
case Keys.Enter: case Keys.Enter:
event.preventDefault() event.preventDefault()
if (api.activeItemIndex.value !== null) { if (api.activeItemIndex.value !== null) {
const { id } = api.items.value[api.activeItemIndex.value] let { id } = api.items.value[api.activeItemIndex.value]
document.getElementById(id)?.click() document.getElementById(id)?.click()
} }
api.closeMenu() api.closeMenu()
@@ -340,7 +340,7 @@ export const MenuItems = defineComponent({
}, },
}) })
export const MenuItem = defineComponent({ export let MenuItem = defineComponent({
props: { props: {
as: { type: [Object, String], default: 'template' }, as: { type: [Object, String], default: 'template' },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false },
@@ -348,19 +348,19 @@ export const MenuItem = defineComponent({
className: { type: [String, Function], required: false }, className: { type: [String, Function], required: false },
}, },
setup(props, { slots, attrs }) { setup(props, { slots, attrs }) {
const api = useMenuContext('MenuItem') let api = useMenuContext('MenuItem')
const id = `headlessui-menu-item-${useId()}` let id = `headlessui-menu-item-${useId()}`
const { disabled, class: defaultClass, className = defaultClass } = props let { disabled, class: defaultClass, className = defaultClass } = props
const active = computed(() => { let active = computed(() => {
return api.activeItemIndex.value !== null return api.activeItemIndex.value !== null
? api.items.value[api.activeItemIndex.value].id === id ? api.items.value[api.activeItemIndex.value].id === id
: false : false
}) })
const dataRef = ref<MenuItemDataRef['value']>({ disabled, textValue: '' }) let dataRef = ref<MenuItemDataRef['value']>({ disabled, textValue: '' })
onMounted(() => { onMounted(() => {
const textValue = document let textValue = document
.getElementById(id) .getElementById(id)
?.textContent?.toLowerCase() ?.textContent?.toLowerCase()
.trim() .trim()
@@ -394,8 +394,8 @@ export const MenuItem = defineComponent({
} }
return () => { return () => {
const slot = { active: active.value, disabled } let slot = { active: active.value, disabled }
const propsWeControl = { let propsWeControl = {
id, id,
role: 'menuitem', role: 'menuitem',
tabIndex: -1, tabIndex: -1,
@@ -15,7 +15,7 @@ import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
jest.mock('../../hooks/use-id') jest.mock('../../hooks/use-id')
function renderTemplate(input: string | Partial<Parameters<typeof defineComponent>[0]>) { function renderTemplate(input: string | Partial<Parameters<typeof defineComponent>[0]>) {
const defaultComponents = { Switch, SwitchLabel, SwitchGroup } let defaultComponents = { Switch, SwitchLabel, SwitchGroup }
if (typeof input === 'string') { if (typeof input === 'string') {
return render(defineComponent({ template: input, components: defaultComponents })) return render(defineComponent({ template: input, components: defaultComponents }))
@@ -170,11 +170,11 @@ describe('Render composition', () => {
describe('Keyboard interactions', () => { describe('Keyboard interactions', () => {
describe('`Space` key', () => { describe('`Space` key', () => {
it('should be possible to toggle the Switch with Space', async () => { it('should be possible to toggle the Switch with Space', async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
renderTemplate({ renderTemplate({
template: `<Switch v-model="checked" />`, template: `<Switch v-model="checked" />`,
setup() { setup() {
const checked = ref(false) let checked = ref(false)
watch([checked], () => handleChange(checked.value)) watch([checked], () => handleChange(checked.value))
return { checked } return { checked }
}, },
@@ -202,11 +202,11 @@ describe('Keyboard interactions', () => {
describe('`Enter` key', () => { describe('`Enter` key', () => {
it('should not be possible to use Enter to toggle the Switch', async () => { it('should not be possible to use Enter to toggle the Switch', async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
renderTemplate({ renderTemplate({
template: `<Switch v-model="checked" />`, template: `<Switch v-model="checked" />`,
setup() { setup() {
const checked = ref(false) let checked = ref(false)
watch([checked], () => handleChange(checked.value)) watch([checked], () => handleChange(checked.value))
return { checked } return { checked }
}, },
@@ -257,11 +257,11 @@ describe('Keyboard interactions', () => {
describe('Mouse interactions', () => { describe('Mouse interactions', () => {
it('should be possible to toggle the Switch with a click', async () => { it('should be possible to toggle the Switch with a click', async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
renderTemplate({ renderTemplate({
template: `<Switch v-model="checked" />`, template: `<Switch v-model="checked" />`,
setup() { setup() {
const checked = ref(false) let checked = ref(false)
watch([checked], () => handleChange(checked.value)) watch([checked], () => handleChange(checked.value))
return { checked } return { checked }
}, },
@@ -284,7 +284,7 @@ describe('Mouse interactions', () => {
}) })
it('should be possible to toggle the Switch with a click on the Label', async () => { it('should be possible to toggle the Switch with a click on the Label', async () => {
const handleChange = jest.fn() let handleChange = jest.fn()
renderTemplate({ renderTemplate({
template: ` template: `
<SwitchGroup> <SwitchGroup>
@@ -293,7 +293,7 @@ describe('Mouse interactions', () => {
</SwitchGroup> </SwitchGroup>
`, `,
setup() { setup() {
const checked = ref(false) let checked = ref(false)
watch([checked], () => handleChange(checked.value)) watch([checked], () => handleChange(checked.value))
return { checked } return { checked }
}, },
@@ -11,13 +11,13 @@ type StateDefinition = {
labelRef: Ref<HTMLLabelElement | null> labelRef: Ref<HTMLLabelElement | null>
} }
const GroupContext = Symbol('GroupContext') as InjectionKey<StateDefinition> let GroupContext = Symbol('GroupContext') as InjectionKey<StateDefinition>
function useGroupContext(component: string) { function useGroupContext(component: string) {
const context = inject(GroupContext, null) let context = inject(GroupContext, null)
if (context === null) { if (context === null) {
const err = new Error(`<${component} /> is missing a parent <SwitchGroup /> component.`) let err = new Error(`<${component} /> is missing a parent <SwitchGroup /> component.`)
if (Error.captureStackTrace) Error.captureStackTrace(err, useGroupContext) if (Error.captureStackTrace) Error.captureStackTrace(err, useGroupContext)
throw err throw err
} }
@@ -27,16 +27,16 @@ function useGroupContext(component: string) {
// --- // ---
export const SwitchGroup = defineComponent({ export let SwitchGroup = defineComponent({
name: 'SwitchGroup', name: 'SwitchGroup',
props: { props: {
as: { type: [Object, String], default: 'template' }, as: { type: [Object, String], default: 'template' },
}, },
setup(props, { slots, attrs }) { setup(props, { slots, attrs }) {
const switchRef = ref<StateDefinition['switchRef']['value']>(null) let switchRef = ref<StateDefinition['switchRef']['value']>(null)
const labelRef = ref<StateDefinition['labelRef']['value']>(null) let labelRef = ref<StateDefinition['labelRef']['value']>(null)
const api = { switchRef, labelRef } let api = { switchRef, labelRef }
provide(GroupContext, api) provide(GroupContext, api)
@@ -46,7 +46,7 @@ export const SwitchGroup = defineComponent({
// --- // ---
export const Switch = defineComponent({ export let Switch = defineComponent({
name: 'Switch', name: 'Switch',
emits: ['update:modelValue'], emits: ['update:modelValue'],
props: { props: {
@@ -56,13 +56,13 @@ export const Switch = defineComponent({
className: { type: [String, Function], required: false }, className: { type: [String, Function], required: false },
}, },
render() { render() {
const api = inject(GroupContext, null) let api = inject(GroupContext, null)
const { class: defaultClass, className = defaultClass } = this.$props let { class: defaultClass, className = defaultClass } = this.$props
const labelledby = computed(() => api?.labelRef.value?.id) let labelledby = computed(() => api?.labelRef.value?.id)
const slot = { checked: this.$props.modelValue } let slot = { checked: this.$props.modelValue }
const propsWeControl = { let propsWeControl = {
id: this.id, id: this.id,
ref: api === null ? undefined : api.switchRef, ref: api === null ? undefined : api.switchRef,
role: 'switch', role: 'switch',
@@ -87,8 +87,8 @@ export const Switch = defineComponent({
}) })
}, },
setup(props, { emit }) { setup(props, { emit }) {
const api = inject(GroupContext, null) let api = inject(GroupContext, null)
const id = `headlessui-switch-${useId()}` let id = `headlessui-switch-${useId()}`
function toggle() { function toggle() {
emit('update:modelValue', !props.modelValue) emit('update:modelValue', !props.modelValue)
@@ -115,11 +115,11 @@ export const Switch = defineComponent({
// --- // ---
export const SwitchLabel = defineComponent({ export let SwitchLabel = defineComponent({
name: 'SwitchLabel', name: 'SwitchLabel',
props: { as: { type: [Object, String], default: 'label' } }, props: { as: { type: [Object, String], default: 'label' } },
render() { render() {
const propsWeControl = { let propsWeControl = {
id: this.id, id: this.id,
ref: 'el', ref: 'el',
onClick: this.handleClick, onClick: this.handleClick,
@@ -133,8 +133,8 @@ export const SwitchLabel = defineComponent({
}) })
}, },
setup() { setup() {
const api = useGroupContext('SwitchLabel') let api = useGroupContext('SwitchLabel')
const id = `headlessui-switch-label-${useId()}` let id = `headlessui-switch-label-${useId()}`
return { return {
id, id,
@@ -580,7 +580,7 @@ export function assertLabelValue(element: HTMLElement | null, value: string) {
if (element === null) return expect(element).not.toBe(null) if (element === null) return expect(element).not.toBe(null)
if (element.hasAttribute('aria-labelledby')) { if (element.hasAttribute('aria-labelledby')) {
const ids = element.getAttribute('aria-labelledby')!.split(' ') let ids = element.getAttribute('aria-labelledby')!.split(' ')
expect(ids.map(id => document.getElementById(id)?.textContent).join(' ')).toEqual(value) expect(ids.map(id => document.getElementById(id)?.textContent).join(' ')).toEqual(value)
return return
} }
@@ -7,7 +7,7 @@ type Events = 'onKeyDown' | 'onKeyUp' | 'onKeyPress' | 'onClick' | 'onBlur' | 'o
let events: Events[] = ['onKeyDown', 'onKeyUp', 'onKeyPress', 'onClick', 'onBlur', 'onFocus'] let events: Events[] = ['onKeyDown', 'onKeyUp', 'onKeyPress', 'onClick', 'onBlur', 'onFocus']
function renderTemplate(input: string | Partial<Parameters<typeof defineComponent>[0]>) { function renderTemplate(input: string | Partial<Parameters<typeof defineComponent>[0]>) {
const defaultComponents = {} let defaultComponents = {}
if (typeof input === 'string') { if (typeof input === 'string') {
return render(defineComponent({ template: input, components: defaultComponents })) return render(defineComponent({ template: input, components: defaultComponents }))
@@ -8,7 +8,7 @@ function nextFrame(cb: Function): void {
) )
} }
export const Keys: Record<string, Partial<KeyboardEvent>> = { export let Keys: Record<string, Partial<KeyboardEvent>> = {
Space: { key: ' ', keyCode: 32, charCode: 32 }, Space: { key: ' ', keyCode: 32, charCode: 32 },
Enter: { key: 'Enter', keyCode: 13, charCode: 13 }, Enter: { key: 'Enter', keyCode: 13, charCode: 13 },
Escape: { key: 'Escape', keyCode: 27, charCode: 27 }, Escape: { key: 'Escape', keyCode: 27, charCode: 27 },
@@ -277,13 +277,13 @@ export async function mouseLeave(element: Document | Element | Window | null) {
// --- // ---
function focusNext(event: Partial<KeyboardEvent>) { function focusNext(event: Partial<KeyboardEvent>) {
const direction = event.shiftKey ? -1 : +1 let direction = event.shiftKey ? -1 : +1
const focusableElements = getFocusableElements() let focusableElements = getFocusableElements()
const total = focusableElements.length let total = focusableElements.length
function innerFocusNext(offset = 0): Element { function innerFocusNext(offset = 0): Element {
const currentIdx = focusableElements.indexOf(document.activeElement as HTMLElement) let currentIdx = focusableElements.indexOf(document.activeElement as HTMLElement)
const next = focusableElements[(currentIdx + total + direction + offset) % total] as HTMLElement let next = focusableElements[(currentIdx + total + direction + offset) % total] as HTMLElement
if (next) next?.focus({ preventScroll: true }) if (next) next?.focus({ preventScroll: true })
@@ -296,7 +296,7 @@ function focusNext(event: Partial<KeyboardEvent>) {
// Credit: // Credit:
// - https://stackoverflow.com/a/30753870 // - https://stackoverflow.com/a/30753870
const focusableSelector = [ let focusableSelector = [
'[contentEditable=true]', '[contentEditable=true]',
'[tabindex]', '[tabindex]',
'a[href]', 'a[href]',
@@ -8,7 +8,7 @@ export function suppressConsoleLogs<T extends unknown[]>(
type: FunctionPropertyNames<typeof global.console> = 'warn' type: FunctionPropertyNames<typeof global.console> = 'warn'
) { ) {
return (...args: T) => { return (...args: T) => {
const spy = jest.spyOn(global.console, type).mockImplementation(jest.fn()) let spy = jest.spyOn(global.console, type).mockImplementation(jest.fn())
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
Promise.resolve(cb(...args)).then(resolve, reject) Promise.resolve(cb(...args)).then(resolve, reject)
@@ -1,20 +1,20 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { logDOM, fireEvent } from '@testing-library/dom' import { logDOM, fireEvent } from '@testing-library/dom'
const mountedWrappers = new Set() let mountedWrappers = new Set()
export function render( export function render(
TestComponent: any, TestComponent: any,
options?: Omit<Parameters<typeof mount>[1], 'attachTo'> options?: Omit<Parameters<typeof mount>[1], 'attachTo'>
) { ) {
const div = document.createElement('div') let div = document.createElement('div')
const baseElement = document.body let baseElement = document.body
const container = baseElement.appendChild(div) let container = baseElement.appendChild(div)
const attachTo = document.createElement('div') let attachTo = document.createElement('div')
container.appendChild(attachTo) container.appendChild(attachTo)
const wrapper = mount(TestComponent, { let wrapper = mount(TestComponent, {
...options, ...options,
attachTo, attachTo,
}) })
@@ -31,19 +31,19 @@ export function calculateActiveIndex<TItem>(
resolveDisabled(item: TItem): boolean resolveDisabled(item: TItem): boolean
} }
) { ) {
const items = resolvers.resolveItems() let items = resolvers.resolveItems()
if (items.length <= 0) return null if (items.length <= 0) return null
const currentActiveIndex = resolvers.resolveActiveIndex() let currentActiveIndex = resolvers.resolveActiveIndex()
const activeIndex = currentActiveIndex ?? -1 let activeIndex = currentActiveIndex ?? -1
const nextActiveIndex = (() => { let nextActiveIndex = (() => {
switch (action.focus) { switch (action.focus) {
case Focus.First: case Focus.First:
return items.findIndex(item => !resolvers.resolveDisabled(item)) return items.findIndex(item => !resolvers.resolveDisabled(item))
case Focus.Previous: { case Focus.Previous: {
const idx = items let idx = items
.slice() .slice()
.reverse() .reverse()
.findIndex((item, idx, all) => { .findIndex((item, idx, all) => {
@@ -61,7 +61,7 @@ export function calculateActiveIndex<TItem>(
}) })
case Focus.Last: { case Focus.Last: {
const idx = items let idx = items
.slice() .slice()
.reverse() .reverse()
.findIndex(item => !resolvers.resolveDisabled(item)) .findIndex(item => !resolvers.resolveDisabled(item))
+2 -2
View File
@@ -4,11 +4,11 @@ export function match<TValue extends string | number = string, TReturnValue = un
...args: any[] ...args: any[]
): TReturnValue { ): TReturnValue {
if (value in lookup) { if (value in lookup) {
const returnValue = lookup[value] let returnValue = lookup[value]
return typeof returnValue === 'function' ? returnValue(...args) : returnValue return typeof returnValue === 'function' ? returnValue(...args) : returnValue
} }
const error = new Error( let error = new Error(
`Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys( `Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys(
lookup lookup
) )
+4 -4
View File
@@ -48,7 +48,7 @@ export function render({
} }
if (features & Features.RenderStrategy) { if (features & Features.RenderStrategy) {
const strategy = main.props.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden let strategy = main.props.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden
return match(strategy, { return match(strategy, {
[RenderStrategy.Unmount]() { [RenderStrategy.Unmount]() {
@@ -78,13 +78,13 @@ function _render({
attrs: Record<string, any> attrs: Record<string, any>
slots: Slots slots: Slots
}) { }) {
const { as, ...passThroughProps } = omit(props, ['unmount', 'static']) let { as, ...passThroughProps } = omit(props, ['unmount', 'static'])
const children = slots.default?.(slot) let children = slots.default?.(slot)
if (as === 'template') { if (as === 'template') {
if (Object.keys(passThroughProps).length > 0 || 'class' in attrs) { if (Object.keys(passThroughProps).length > 0 || 'class' in attrs) {
const [firstChild, ...other] = children ?? [] let [firstChild, ...other] = children ?? []
if (other.length > 0) if (other.length > 0)
throw new Error('You should only render 1 child or use the `as="..."` prop') throw new Error('You should only render 1 child or use the `as="..."` prop')
+14 -14
View File
@@ -1,11 +1,11 @@
const fs = require('fs') let fs = require('fs')
const path = require('path') let path = require('path')
const prettier = require('prettier') let prettier = require('prettier')
const Prism = require('prismjs') let Prism = require('prismjs')
require('prismjs/plugins/custom-class/prism-custom-class') require('prismjs/plugins/custom-class/prism-custom-class')
const routes = require('./examples/src/routes') let routes = require('./examples/src/routes')
function flatten(routes, resolver) { function flatten(routes, resolver) {
return routes return routes
@@ -17,10 +17,10 @@ function flatten(routes, resolver) {
// file. However just doing dynamic imports() doesn't work well at build time. Therefore we will // file. However just doing dynamic imports() doesn't work well at build time. Therefore we will
// generate a fake file that contains them all. // generate a fake file that contains them all.
let i = 0 let i = 0
const map = {} let map = {}
const contents = flatten(routes, route => route.component) let contents = flatten(routes, route => route.component)
.map(path => { .map(path => {
const name = `Component$${++i}` let name = `Component$${++i}`
map[path] = name map[path] = name
return `import ${name} from ".${path}";` return `import ${name} from ".${path}";`
}) })
@@ -53,7 +53,7 @@ Prism.plugins.customClass.map({
comment: 'text-gray-400 italic', comment: 'text-gray-400 italic',
}) })
const sourcePipeline = pipe( let sourcePipeline = pipe(
path => fs.readFileSync(path, 'utf8'), path => fs.readFileSync(path, 'utf8'),
contents => contents =>
prettier.format(contents, { prettier.format(contents, {
@@ -74,8 +74,8 @@ const sourcePipeline = pipe(
].join('') ].join('')
) )
const skipRoutes = ['/'] let skipRoutes = ['/']
const source = Object.assign( let source = Object.assign(
{}, {},
...flatten(routes, route => ({ ...flatten(routes, route => ({
urlPath: route.path, urlPath: route.path,
@@ -93,14 +93,14 @@ fs.writeFileSync(
) )
// --- // ---
const TailwindUIPlugin = ({ let HeadlessUIPlugin = ({
root, // project root directory, absolute path root, // project root directory, absolute path
app, // Koa app instance app, // Koa app instance
server, // raw http server instance server, // raw http server instance
watcher, // chokidar file watcher instance watcher, // chokidar file watcher instance
resolver, // chokidar file watcher instance resolver, // chokidar file watcher instance
}) => { }) => {
const routePaths = flatten(routes, route => route.path) let routePaths = flatten(routes, route => route.path)
app.use(async (ctx, next) => { app.use(async (ctx, next) => {
if (routePaths.includes(ctx.path)) { if (routePaths.includes(ctx.path)) {
@@ -118,5 +118,5 @@ module.exports = {
'./src/index.ts' './src/index.ts'
), ),
}, },
configureServer: [TailwindUIPlugin], configureServer: [HeadlessUIPlugin],
} }