feat: add render features + render strategy (#106)

* add unmount strategy to README (React)

* add unmount strategy to README (Vue)

* add different render features (React)

* use render features in Menu and Listbox (React)

* add different render features (Vue)

* use render features in Menu and Listbox (Vue)

* bump dependencies

* add ability to change the ref property using `refName`

Example use case:

```tsx
// Some components have this API with an `innerRef`. The suggested approach is to use
// `React.forwardRef` so that you get the actual `ref` value. However if you already have this
// `innerRef` API than we can use the `refName="innerRef"` to give the `ref` prop a good name. It
// defaults to `ref` so that it still works everywhere else.

function MyButton({ innerRef, ...props }) {
  return <button ref={innerRef} {...props} />
}

<Menu.Button as={MyButton} refName="innerRef" />
```

* small cleanup, move refs to props we control

* add tests for the render abstraction (Render)

+ use the unique __ symbol as a default value in the Props type for the
  omitable props.

* use render features in Transition (React)

* add/update Transition examples to also showcase the `unmount={false}` render strategy

* bump dependencies

* add example with nested unmount/hide transitions

* add unmount to Transition documentation
This commit is contained in:
Robin Malfait
2020-10-18 15:34:05 +02:00
committed by GitHub
parent 7d4af1ad3a
commit aab23c9077
26 changed files with 2471 additions and 985 deletions
+3 -3
View File
@@ -34,11 +34,11 @@
"devDependencies": {
"@tailwindcss/ui": "^0.6.2",
"@testing-library/jest-dom": "^5.11.4",
"@types/node": "^14.11.8",
"@types/node": "^14.11.10",
"husky": "^4.3.0",
"lint-staged": "^10.4.0",
"lint-staged": "^10.4.2",
"prismjs": "^1.22.0",
"tailwindcss": "^1.9.1",
"tailwindcss": "^1.9.4",
"tsdx": "^0.14.1",
"tslib": "^2.0.3",
"typescript": "^3.9.7"
+16 -8
View File
@@ -317,6 +317,7 @@ function MyComponent({ isShowing }) {
| `show` | Boolean | Whether the children should be shown or hidden. |
| `as` | String Component _(Default: `'div'`)_ | The element or component to render in place of the `Transition` itself. |
| `appear` | Boolean _(Default: `false`)_ | Whether the transition should run on initial mount. |
| `unmount` | Boolean _(Default: `true`)_ | Whether the element should be unmounted or hidden based on the show state. |
| `enter` | String _(Default: '')_ | Classes to add to the transitioning element during the entire enter phase. |
| `enterFrom` | String _(Default: '')_ | Classes to add to the transitioning element before the enter phase starts. |
| `enterTo` | String _(Default: '')_ | Classes to add to the transitioning element immediately after the enter phase starts. |
@@ -358,6 +359,7 @@ function MyComponent({ isShowing }) {
| ----------- | ------------------------------------- | ------------------------------------------------------------------------------------- |
| `as` | String Component _(Default: `'div'`)_ | The element or component to render in place of the `Transition.Child` itself. |
| `appear` | Boolean _(Default: `false`)_ | Whether the transition should run on initial mount. |
| `unmount` | Boolean _(Default: `true`)_ | Whether the element should be unmounted or hidden based on the show state. |
| `enter` | String _(Default: '')_ | Classes to add to the transitioning element during the entire enter phase. |
| `enterFrom` | String _(Default: '')_ | Classes to add to the transitioning element before the enter phase starts. |
| `enterTo` | String _(Default: '')_ | Classes to add to the transitioning element immediately after the enter phase starts. |
@@ -726,10 +728,13 @@ function MyDropdown() {
##### Props
| Prop | Type | Default | Description |
| -------- | ------------------- | ------- | --------------------------------------------------------------------------- |
| `as` | String \| Component | `div` | The element or component the `Menu.Items` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| Prop | Type | Default | Description |
| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- |
| `as` | String \| Component | `div` | The element or component the `Menu.Items` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. |
> **note**: `static` and `unmount` can not be used at the same time. You will get a TypeScript error if you try to do it.
##### Render prop object
@@ -1231,10 +1236,13 @@ function MyListbox() {
##### Props
| Prop | Type | Default | Description |
| -------- | ------------------- | ------- | --------------------------------------------------------------------------- |
| `as` | String \| Component | `ul` | The element or component the `Listbox.Options` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| Prop | Type | Default | Description |
| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- |
| `as` | String \| Component | `ul` | The element or component the `Listbox.Options` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. |
> **note**: `static` and `unmount` can not be used at the same time. You will get a TypeScript error if you try to do it.
##### Render prop object
+4 -4
View File
@@ -31,14 +31,14 @@
"react": ">=16"
},
"devDependencies": {
"@types/react": "^16.9.52",
"@types/react": "^16.9.53",
"@types/react-dom": "^16.9.8",
"@popperjs/core": "^2.5.3",
"@testing-library/react": "^11.0.4",
"@testing-library/react": "^11.1.0",
"framer-motion": "^2.9.1",
"next": "9.5.5",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"snapshot-diff": "^0.8.1"
}
}
@@ -0,0 +1,60 @@
import React, { useState } from 'react'
import { Transition } from '@headlessui/react'
export default function Home() {
const [isOpen, setIsOpen] = useState(true)
return (
<>
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="space-y-2 w-96">
<span className="inline-flex rounded-md shadow-sm">
<button
type="button"
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-gray-700 transition bg-white border border-gray-300 rounded-md duration-150-out hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:text-gray-800 active:bg-gray-50"
>
{isOpen ? 'Hide' : 'Show'}
</button>
</span>
<Transition show={isOpen} unmount={false}>
<Box>
<Box>
<Box>
<Box />
</Box>
<Box>
<Box>
<Box>
<Box />
</Box>
</Box>
</Box>
</Box>
</Box>
</Transition>
</div>
</div>
</>
)
}
function Box({ children }: { children?: React.ReactNode }) {
return (
<Transition.Child
unmount={false}
enter="transition translate duration-300"
enterFrom="transform -translate-x-full"
enterTo="transform translate-x-0"
leave="transition translate duration-300"
leaveFrom="transform translate-x-0"
leaveTo="transform translate-x-full"
>
<div className="p-4 space-y-2 text-sm font-semibold tracking-wide text-gray-700 uppercase bg-white rounded-md shadow">
<span>This is a box</span>
{children}
</div>
</Transition.Child>
)
}
@@ -0,0 +1,60 @@
import React, { useState } from 'react'
import { Transition } from '@headlessui/react'
export default function Home() {
const [isOpen, setIsOpen] = useState(true)
return (
<>
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="space-y-2 w-96">
<span className="inline-flex rounded-md shadow-sm">
<button
type="button"
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-gray-700 transition bg-white border border-gray-300 rounded-md duration-150-out hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:text-gray-800 active:bg-gray-50"
>
{isOpen ? 'Hide' : 'Show'}
</button>
</span>
<Transition show={isOpen} unmount={true}>
<Box>
<Box>
<Box>
<Box />
</Box>
<Box>
<Box>
<Box>
<Box />
</Box>
</Box>
</Box>
</Box>
</Box>
</Transition>
</div>
</div>
</>
)
}
function Box({ children }: { children?: React.ReactNode }) {
return (
<Transition.Child
unmount={true}
enter="transition translate duration-300"
enterFrom="transform -translate-x-full"
enterTo="transform translate-x-0"
leave="transition translate duration-300"
leaveFrom="transform translate-x-0"
leaveTo="transform translate-x-full"
>
<div className="p-4 space-y-2 text-sm font-semibold tracking-wide text-gray-700 uppercase bg-white rounded-md shadow">
<span>This is a box</span>
{children}
</div>
</Transition.Child>
)
}
@@ -0,0 +1,38 @@
import React, { useState } from 'react'
import { Transition } from '@headlessui/react'
export default function Home() {
const [isOpen, setIsOpen] = useState(true)
return (
<>
<div className="flex justify-center w-screen h-full p-12 bg-gray-50">
<div className="space-y-2 w-96">
<span className="inline-flex rounded-md shadow-sm">
<button
type="button"
onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center px-3 py-2 text-sm font-medium leading-4 text-gray-700 transition duration-150 ease-in-out bg-white border border-gray-300 rounded-md hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:text-gray-800 active:bg-gray-50"
>
{isOpen ? 'Hide' : 'Show'}
</button>
</span>
<Transition
show={isOpen}
unmount={false}
enter="transition ease-out duration-300"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in duration-300"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
className="p-4 bg-white rounded-md shadow"
>
Contents to show and hide
</Transition>
</div>
</div>
</>
)
}
@@ -26,9 +26,10 @@ export default function App() {
<div className="flex h-screen overflow-hidden bg-cool-gray-100">
{/* Off-canvas menu for mobile */}
<Transition show={mobileOpen} className="fixed inset-0 z-40 flex">
<Transition show={mobileOpen} unmount={false} className="fixed inset-0 z-40 flex">
{/* Off-canvas menu overlay, show/hide based on off-canvas menu state. */}
<Transition.Child
unmount={false}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
@@ -36,8 +37,8 @@ export default function App() {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
{ref => (
<div ref={ref} className="fixed inset-0">
{() => (
<div className="fixed inset-0">
<div
onClick={() => setMobileOpen(false)}
className="absolute inset-0 opacity-75 bg-cool-gray-600"
@@ -48,6 +49,7 @@ export default function App() {
{/* Off-canvas menu, show/hide based on off-canvas menu state. */}
<Transition.Child
unmount={false}
enter="transition ease-in-out duration-300 transform"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
@@ -58,6 +60,7 @@ export default function App() {
>
<div className="absolute top-0 right-0 p-1 -mr-14">
<Transition.Child
unmount={false}
className="flex items-center justify-center w-12 h-12 rounded-full focus:outline-none focus:bg-cool-gray-600"
aria-label="Close sidebar"
as="button"
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { useComputed } from '../../hooks/use-computed'
import { useSyncRefs } from '../../hooks/use-sync-refs'
import { Props } from '../../types'
import { forwardRefWithAs, render } from '../../utils/render'
import { Features, forwardRefWithAs, PropsForFeatures, render } from '../../utils/render'
import { match } from '../../utils/match'
import { disposables } from '../../utils/disposables'
import { Keys } from '../keyboard'
@@ -114,7 +114,11 @@ const reducers: {
action: Extract<Actions, { type: P }>
) => StateDefinition
} = {
[ActionTypes.CloseListbox]: state => ({ ...state, listboxState: ListboxStates.Closed }),
[ActionTypes.CloseListbox]: state => ({
...state,
activeOptionIndex: null,
listboxState: ListboxStates.Closed,
}),
[ActionTypes.OpenListbox]: state => ({ ...state, listboxState: ListboxStates.Open }),
[ActionTypes.GoToOption]: (state, action) => {
const activeOptionIndex = calculateActiveOptionIndex(state, action.focus, action.id)
@@ -375,11 +379,7 @@ function Label<TTag extends React.ElementType = typeof DEFAULT_LABEL_TAG>(
() => ({ open: state.listboxState === ListboxStates.Open }),
[state]
)
const propsWeControl = {
ref: state.labelRef,
id,
onPointerUp: handlePointerUp,
}
const propsWeControl = { ref: state.labelRef, id, onPointerUp: handlePointerUp }
return render({ ...props, ...propsWeControl }, propsBag, DEFAULT_LABEL_TAG)
}
@@ -397,24 +397,15 @@ type OptionsPropsWeControl =
const DEFAULT_OPTIONS_TAG = 'ul'
type OptionsRenderPropArg = { open: boolean }
type ListboxOptionsProp<TTag> = Props<TTag, OptionsRenderPropArg, OptionsPropsWeControl> & {
static?: boolean
}
const OptionsRenderFeatures = Features.RenderStrategy | Features.Static
const Options = forwardRefWithAs(function Options<
TTag extends React.ElementType = typeof DEFAULT_OPTIONS_TAG
>(props: ListboxOptionsProp<TTag>, ref: React.Ref<HTMLUListElement>) {
const {
enter,
enterFrom,
enterTo,
leave,
leaveFrom,
leaveTo,
static: isStatic = false,
...passthroughProps
} = props
>(
props: Props<TTag, OptionsRenderPropArg, OptionsPropsWeControl> &
PropsForFeatures<typeof OptionsRenderFeatures>,
ref: React.Ref<HTMLUListElement>
) {
const [state, dispatch] = useListboxContext([Listbox.name, Options.name].join('.'))
const optionsRef = useSyncRefs(state.optionsRef, ref)
@@ -500,14 +491,16 @@ const Options = forwardRefWithAs(function Options<
onKeyDown: handleKeyDown,
role: 'listbox',
tabIndex: 0,
ref: optionsRef,
}
if (!isStatic && state.listboxState === ListboxStates.Closed) return null
const passthroughProps = props
return render(
{ ...passthroughProps, ...propsWeControl, ...{ ref: optionsRef } },
{ ...passthroughProps, ...propsWeControl },
propsBag,
DEFAULT_OPTIONS_TAG
DEFAULT_OPTIONS_TAG,
OptionsRenderFeatures,
state.listboxState === ListboxStates.Open
)
})
@@ -570,17 +563,19 @@ function Option<
}, [bag, id])
useIsoMorphicEffect(() => {
if (state.listboxState !== ListboxStates.Open) return
if (!selected) return
dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id })
document.getElementById(id)?.focus?.()
}, [])
}, [state.listboxState])
useIsoMorphicEffect(() => {
if (state.listboxState !== ListboxStates.Open) return
if (!active) return
const d = disposables()
d.nextFrame(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }))
return d.dispose
}, [active])
}, [active, state.listboxState])
const handleClick = React.useCallback(
(event: { preventDefault: Function }) => {
@@ -627,11 +622,7 @@ function Option<
onPointerLeave: handlePointerLeave,
}
return render<TTag, OptionRenderPropArg>(
{ ...passthroughProps, ...propsWeControl },
propsBag,
DEFAULT_OPTION_TAG
)
return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_OPTION_TAG)
}
function resolvePropValue<TProperty, TBag>(property: TProperty, bag: TBag) {
@@ -68,10 +68,10 @@ describe('Safe guards', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
})
)
})
@@ -99,18 +99,18 @@ describe('Rendering', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
await click(getMenuButton())
assertMenuButton({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
})
)
})
@@ -131,20 +131,20 @@ describe('Rendering', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
textContent: JSON.stringify({ open: false, focused: false }),
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
await click(getMenuButton())
assertMenuButton({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-button-1' },
textContent: JSON.stringify({ open: true, focused: false }),
})
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
})
)
@@ -165,20 +165,20 @@ describe('Rendering', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
textContent: JSON.stringify({ open: false, focused: false }),
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
await click(getMenuButton())
assertMenuButton({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-button-1' },
textContent: JSON.stringify({ open: true, focused: false }),
})
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
})
)
})
@@ -201,19 +201,19 @@ describe('Rendering', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
await click(getMenuButton())
assertMenuButton({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({
state: MenuState.Open,
state: MenuState.Visible,
textContent: JSON.stringify({ open: true }),
})
})
@@ -234,6 +234,26 @@ describe('Rendering', () => {
// Let's verify that the Menu is already there
expect(getMenu()).not.toBe(null)
})
it('should be possible to use a different render strategy for the Menu.Items', async () => {
render(
<Menu>
<Menu.Button>Trigger</Menu.Button>
<Menu.Items unmount={false}>
<Menu.Item as="a">Item A</Menu.Item>
<Menu.Item as="a">Item B</Menu.Item>
<Menu.Item as="a">Item C</Menu.Item>
</Menu.Items>
</Menu>
)
assertMenu({ state: MenuState.InvisibleHidden })
// Let's open the Menu, to see if it is not hidden anymore
await click(getMenuButton())
assertMenu({ state: MenuState.Visible })
})
})
describe('Menu.Item', () => {
@@ -250,19 +270,19 @@ describe('Rendering', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
await click(getMenuButton())
assertMenuButton({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({
state: MenuState.Open,
state: MenuState.Visible,
textContent: JSON.stringify({ active: false, disabled: false }),
})
})
@@ -292,10 +312,10 @@ describe('Rendering composition', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Open menu
await click(getMenuButton())
@@ -349,10 +369,10 @@ describe('Rendering composition', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Open menu
await click(getMenuButton())
@@ -381,10 +401,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -393,9 +413,9 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter)
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
assertMenu({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-items-2' },
})
assertMenuButtonLinkedWithMenu()
@@ -425,10 +445,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -438,10 +458,10 @@ describe('Keyboard interactions', () => {
// Verify it is still closed
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
})
)
@@ -455,14 +475,14 @@ describe('Keyboard interactions', () => {
</Menu>
)
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
// Open menu
await press(Keys.Enter)
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
assertNoActiveMenuItem()
})
@@ -485,10 +505,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -522,10 +542,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -561,10 +581,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -591,23 +611,23 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Open menu
await click(getMenuButton())
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
// Close menu
await press(Keys.Enter)
// Verify it is closed
assertMenuButton({ state: MenuState.Closed })
assertMenu({ state: MenuState.Closed })
assertMenuButton({ state: MenuState.InvisibleUnmounted })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Verify the button is focused again
assertActiveElement(getMenuButton())
@@ -632,16 +652,16 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Open menu
await click(getMenuButton())
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
// Activate the first menu item
const items = getMenuItems()
@@ -651,8 +671,8 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter)
// Verify it is closed
assertMenuButton({ state: MenuState.Closed })
assertMenu({ state: MenuState.Closed })
assertMenuButton({ state: MenuState.InvisibleUnmounted })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Verify the button is focused again
assertActiveElement(getMenuButton())
@@ -684,16 +704,16 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Open menu
await click(getMenuButton())
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
// Activate the second menu item
const items = getMenuItems()
@@ -703,8 +723,8 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter)
// Verify it is closed
assertMenuButton({ state: MenuState.Closed })
assertMenu({ state: MenuState.Closed })
assertMenuButton({ state: MenuState.InvisibleUnmounted })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Verify the button got "clicked"
expect(clickHandler).toHaveBeenCalledTimes(1)
@@ -745,10 +765,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -757,9 +777,9 @@ describe('Keyboard interactions', () => {
await press(Keys.Space)
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
assertMenu({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-items-2' },
})
assertMenuButtonLinkedWithMenu()
@@ -787,10 +807,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -800,10 +820,10 @@ describe('Keyboard interactions', () => {
// Verify it is still closed
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
})
)
@@ -817,14 +837,14 @@ describe('Keyboard interactions', () => {
</Menu>
)
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
// Open menu
await press(Keys.Space)
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
assertNoActiveMenuItem()
})
@@ -847,10 +867,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -884,10 +904,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -923,10 +943,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -953,23 +973,23 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Open menu
await click(getMenuButton())
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
// Close menu
await press(Keys.Space)
// Verify it is closed
assertMenuButton({ state: MenuState.Closed })
assertMenu({ state: MenuState.Closed })
assertMenuButton({ state: MenuState.InvisibleUnmounted })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Verify the button is focused again
assertActiveElement(getMenuButton())
@@ -994,16 +1014,16 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Open menu
await click(getMenuButton())
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
// Activate the first menu item
const items = getMenuItems()
@@ -1013,8 +1033,8 @@ describe('Keyboard interactions', () => {
await press(Keys.Space)
// Verify it is closed
assertMenuButton({ state: MenuState.Closed })
assertMenu({ state: MenuState.Closed })
assertMenuButton({ state: MenuState.InvisibleUnmounted })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Verify the "click" went through on the `a` tag
expect(clickHandler).toHaveBeenCalled()
@@ -1047,9 +1067,9 @@ describe('Keyboard interactions', () => {
await press(Keys.Space)
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
assertMenu({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-items-2' },
})
assertMenuButtonLinkedWithMenu()
@@ -1058,8 +1078,8 @@ describe('Keyboard interactions', () => {
await press(Keys.Escape)
// Verify it is closed
assertMenuButton({ state: MenuState.Closed })
assertMenu({ state: MenuState.Closed })
assertMenuButton({ state: MenuState.InvisibleUnmounted })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Verify the button is focused again
assertActiveElement(getMenuButton())
@@ -1083,10 +1103,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1095,9 +1115,9 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter)
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
assertMenu({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-items-2' },
})
assertMenuButtonLinkedWithMenu()
@@ -1112,8 +1132,8 @@ describe('Keyboard interactions', () => {
await press(Keys.Tab)
// Verify it is still open
assertMenuButton({ state: MenuState.Open })
assertMenu({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
assertMenu({ state: MenuState.Visible })
})
)
@@ -1132,10 +1152,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1144,9 +1164,9 @@ describe('Keyboard interactions', () => {
await press(Keys.Enter)
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
assertMenu({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-items-2' },
})
assertMenuButtonLinkedWithMenu()
@@ -1161,8 +1181,8 @@ describe('Keyboard interactions', () => {
await press(shift(Keys.Tab))
// Verify it is still open
assertMenuButton({ state: MenuState.Open })
assertMenu({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
assertMenu({ state: MenuState.Visible })
})
)
})
@@ -1183,10 +1203,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1195,9 +1215,9 @@ describe('Keyboard interactions', () => {
await press(Keys.ArrowDown)
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
assertMenu({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-items-2' },
})
assertMenuButtonLinkedWithMenu()
@@ -1227,10 +1247,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1240,10 +1260,10 @@ describe('Keyboard interactions', () => {
// Verify it is still closed
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
})
)
@@ -1257,14 +1277,14 @@ describe('Keyboard interactions', () => {
</Menu>
)
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
// Open menu
await press(Keys.ArrowDown)
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
assertNoActiveMenuItem()
})
@@ -1285,10 +1305,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1333,10 +1353,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1375,10 +1395,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1411,10 +1431,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1423,9 +1443,9 @@ describe('Keyboard interactions', () => {
await press(Keys.ArrowUp)
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
assertMenu({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-items-2' },
})
assertMenuButtonLinkedWithMenu()
@@ -1455,10 +1475,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1468,10 +1488,10 @@ describe('Keyboard interactions', () => {
// Verify it is still closed
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
})
)
@@ -1485,14 +1505,14 @@ describe('Keyboard interactions', () => {
</Menu>
)
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
// Open menu
await press(Keys.ArrowUp)
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
assertNoActiveMenuItem()
})
@@ -1517,10 +1537,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1555,10 +1575,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1597,10 +1617,10 @@ describe('Keyboard interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Focus the button
getMenuButton()?.focus()
@@ -1609,9 +1629,9 @@ describe('Keyboard interactions', () => {
await press(Keys.ArrowUp)
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
assertMenu({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-items-2' },
})
assertMenuButtonLinkedWithMenu()
@@ -2360,18 +2380,18 @@ describe('Mouse interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Open menu
await click(getMenuButton())
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
assertMenu({
state: MenuState.Open,
state: MenuState.Visible,
attributes: { id: 'headlessui-menu-items-2' },
})
assertMenuButtonLinkedWithMenu()
@@ -2398,20 +2418,20 @@ describe('Mouse interactions', () => {
)
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Try to open the menu
await click(getMenuButton())
// Verify it is still closed
assertMenuButton({
state: MenuState.Closed,
state: MenuState.InvisibleUnmounted,
attributes: { id: 'headlessui-menu-button-1' },
})
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
})
)
@@ -2433,14 +2453,14 @@ describe('Mouse interactions', () => {
await click(getMenuButton())
// Verify it is open
assertMenuButton({ state: MenuState.Open })
assertMenuButton({ state: MenuState.Visible })
// Click to close
await click(getMenuButton())
// Verify it is closed
assertMenuButton({ state: MenuState.Closed })
assertMenu({ state: MenuState.Closed })
assertMenuButton({ state: MenuState.InvisibleUnmounted })
assertMenu({ state: MenuState.InvisibleUnmounted })
})
)
@@ -2484,13 +2504,13 @@ describe('Mouse interactions', () => {
)
// Verify that the window is closed
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Click something that is not related to the menu
await click(document.body)
// Should still be closed
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
})
)
@@ -2510,13 +2530,13 @@ describe('Mouse interactions', () => {
// Open menu
await click(getMenuButton())
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
// Click something that is not related to the menu
await click(document.body)
// Should be closed now
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Verify the button is focused again
assertActiveElement(getMenuButton())
@@ -2539,13 +2559,13 @@ describe('Mouse interactions', () => {
// Open menu
await click(getMenuButton())
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
// Click the menu button again
await click(getMenuButton())
// Should be closed now
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Verify the button is focused again
assertActiveElement(getMenuButton())
@@ -2828,14 +2848,14 @@ describe('Mouse interactions', () => {
// Open menu
await click(getMenuButton())
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
const items = getMenuItems()
// We should be able to click the first item
await click(items[1])
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
expect(clickHandler).toHaveBeenCalled()
})
)
@@ -2861,11 +2881,11 @@ describe('Mouse interactions', () => {
// Open menu
await click(getMenuButton())
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
// We should be able to click the first item
await click(getMenuItems()[1])
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Verify the callback has been called
expect(clickHandler).toHaveBeenCalledTimes(1)
@@ -2875,7 +2895,7 @@ describe('Mouse interactions', () => {
// Click the last item, which should close and invoke the handler
await click(getMenuItems()[2])
assertMenu({ state: MenuState.Closed })
assertMenu({ state: MenuState.InvisibleUnmounted })
// Verify the callback has been called
expect(clickHandler).toHaveBeenCalledTimes(2)
@@ -2900,13 +2920,13 @@ describe('Mouse interactions', () => {
// Open menu
await click(getMenuButton())
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
const items = getMenuItems()
// We should be able to click the first item
await click(items[1])
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
})
)
@@ -2926,7 +2946,7 @@ describe('Mouse interactions', () => {
// Open menu
await click(getMenuButton())
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
const items = getMenuItems()
@@ -2957,7 +2977,7 @@ describe('Mouse interactions', () => {
// Open menu
await click(getMenuButton())
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
const items = getMenuItems()
@@ -2991,7 +3011,7 @@ describe('Mouse interactions', () => {
// Open menu
await click(getMenuButton())
assertMenu({ state: MenuState.Open })
assertMenu({ state: MenuState.Visible })
const items = getMenuItems()
@@ -3,7 +3,7 @@ import * as React from 'react'
import { Props } from '../../types'
import { match } from '../../utils/match'
import { forwardRefWithAs, render } from '../../utils/render'
import { forwardRefWithAs, render, Features, PropsForFeatures } from '../../utils/render'
import { disposables } from '../../utils/disposables'
import { useDisposables } from '../../hooks/use-disposables'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
@@ -108,7 +108,11 @@ const reducers: {
action: Extract<Actions, { type: P }>
) => StateDefinition
} = {
[ActionTypes.CloseMenu]: state => ({ ...state, menuState: MenuStates.Closed }),
[ActionTypes.CloseMenu]: state => ({
...state,
activeItemIndex: null,
menuState: MenuStates.Closed,
}),
[ActionTypes.OpenMenu]: state => ({ ...state, menuState: MenuStates.Open }),
[ActionTypes.GoToItem]: (state, action) => {
const activeItemIndex = calculateActiveItemIndex(state, action.focus, action.id)
@@ -348,14 +352,15 @@ type ItemsPropsWeControl =
const DEFAULT_ITEMS_TAG = 'div'
type ItemsRenderPropArg = { open: boolean }
const ItemsRenderFeatures = Features.RenderStrategy | Features.Static
const Items = forwardRefWithAs(function Items<
TTag extends React.ElementType = typeof DEFAULT_ITEMS_TAG
>(
props: Props<TTag, ItemsRenderPropArg, ItemsPropsWeControl> & { static?: boolean },
props: Props<TTag, ItemsRenderPropArg, ItemsPropsWeControl> &
PropsForFeatures<typeof ItemsRenderFeatures>,
ref: React.Ref<HTMLDivElement>
) {
const { static: isStatic = false, ...passthroughProps } = props
const [state, dispatch] = useMenuContext([Menu.name, Items.name].join('.'))
const itemsRef = useSyncRefs(state.itemsRef, ref)
@@ -433,14 +438,16 @@ const Items = forwardRefWithAs(function Items<
onKeyDown: handleKeyDown,
role: 'menu',
tabIndex: 0,
ref: itemsRef,
}
if (!isStatic && state.menuState === MenuStates.Closed) return null
const passthroughProps = props
return render(
{ ...passthroughProps, ...propsWeControl, ...{ ref: itemsRef } },
{ ...passthroughProps, ...propsWeControl },
propsBag,
DEFAULT_ITEMS_TAG
DEFAULT_ITEMS_TAG,
ItemsRenderFeatures,
state.menuState === MenuStates.Open
)
})
@@ -529,11 +536,7 @@ function Item<TTag extends React.ElementType = typeof DEFAULT_ITEM_TAG>(
onPointerLeave: handlePointerLeave,
}
return render<TTag, ItemRenderPropArg>(
{ ...passthroughProps, ...propsWeControl },
propsBag,
DEFAULT_ITEM_TAG
)
return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_ITEM_TAG)
}
function resolvePropValue<TProperty, TBag>(property: TProperty, bag: TBag) {
@@ -120,7 +120,9 @@ describe('Setup API', () => {
it('should be possible to use a render prop', () => {
const { container } = render(
<Transition show={true}>{ref => <span ref={ref}>Children</span>}</Transition>
<Transition show={true} as={React.Fragment}>
{() => <span>Children</span>}
</Transition>
)
expect(container.firstChild).toMatchInlineSnapshot(`
@@ -131,12 +133,20 @@ describe('Setup API', () => {
})
it(
'should yell at us when we forget to apply the ref when using a render prop',
'should yell at us when we forget to forward the ref when using a render prop',
suppressConsoleLogs(() => {
expect.assertions(1)
function Dummy(props: any) {
return <span {...props}>Children</span>
}
expect(() => {
render(<Transition show={true}>{() => <span>Children</span>}</Transition>)
render(
<Transition show={true} as={React.Fragment}>
{() => <Dummy />}
</Transition>
)
}).toThrowErrorMatchingInlineSnapshot(
`"Did you forget to passthrough the \`ref\` to the actual DOM node?"`
)
@@ -253,8 +263,10 @@ describe('Setup API', () => {
const { container } = render(
<div className="My Page">
<Transition show={true}>
<Transition.Child>{ref => <aside ref={ref}>Sidebar</aside>}</Transition.Child>
<Transition.Child>{ref => <section ref={ref}>Content</section>}</Transition.Child>
<Transition.Child as={React.Fragment}>{() => <aside>Sidebar</aside>}</Transition.Child>
<Transition.Child as={React.Fragment}>
{() => <section>Content</section>}
</Transition.Child>
</Transition>
</div>
)
@@ -278,11 +290,15 @@ describe('Setup API', () => {
it('should be possible to use render props on the Transition and Transition.Child components', () => {
const { container } = render(
<div className="My Page">
<Transition show={true}>
{ref => (
<article ref={ref}>
<Transition.Child>{ref => <aside ref={ref}>Sidebar</aside>}</Transition.Child>
<Transition.Child>{ref => <section ref={ref}>Content</section>}</Transition.Child>
<Transition show={true} as={React.Fragment}>
{() => (
<article>
<Transition.Child as={React.Fragment}>
{() => <aside>Sidebar</aside>}
</Transition.Child>
<Transition.Child as={React.Fragment}>
{() => <section>Content</section>}
</Transition.Child>
</article>
)}
</Transition>
@@ -306,16 +322,24 @@ describe('Setup API', () => {
})
it(
'should yell at us when we forgot to apply the ref on one of the Transition.Child components',
'should yell at us when we forgot to forward the ref on one of the Transition.Child components',
suppressConsoleLogs(() => {
expect.assertions(1)
function Dummy(props: any) {
return <div {...props} />
}
expect(() => {
render(
<div className="My Page">
<Transition show={true}>
<Transition.Child>{ref => <aside ref={ref}>Sidebar</aside>}</Transition.Child>
<Transition.Child>{() => <section>Content</section>}</Transition.Child>
<Transition.Child as={React.Fragment}>
{() => <Dummy>Sidebar</Dummy>}
</Transition.Child>
<Transition.Child as={React.Fragment}>
{() => <Dummy>Content</Dummy>}
</Transition.Child>
</Transition>
</div>
)
@@ -326,21 +350,23 @@ describe('Setup API', () => {
)
it(
'should yell at us when we forgot to apply a ref on the Transition component',
'should yell at us when we forgot to forward a ref on the Transition component',
suppressConsoleLogs(() => {
expect.assertions(1)
function Dummy(props: any) {
return <div {...props} />
}
expect(() => {
render(
<div className="My Page">
<Transition show={true}>
<Transition show={true} as={React.Fragment}>
{() => (
<article>
<Transition.Child>{ref => <aside ref={ref}>Sidebar</aside>}</Transition.Child>
<Transition.Child>
{ref => <section ref={ref}>Content</section>}
</Transition.Child>
</article>
<Dummy>
<Transition.Child>{() => <aside>Sidebar</aside>}</Transition.Child>
<Transition.Child>{() => <section>Content</section>}</Transition.Child>
</Dummy>
)}
</Transition>
</div>
@@ -503,6 +529,53 @@ describe('Transitions', () => {
`)
})
it('should transition in completely (duration defined in seconds) in (render strategy = hidden)', async () => {
const enterDuration = 50
function Example() {
const [show, setShow] = React.useState(false)
return (
<>
<style>{`.enter { transition-duration: ${enterDuration /
1000}s; } .from { opacity: 0%; } .to { opacity: 100%; }`}</style>
<Transition show={show} unmount={false} enter="enter" enterFrom="from" enterTo="to">
<span>Hello!</span>
</Transition>
<button data-testid="toggle" onClick={() => setShow(v => !v)}>
Toggle
</button>
</>
)
}
const timeline = await executeTimeline(<Example />, [
// Toggle to show
({ getByTestId }) => {
fireEvent.click(getByTestId('toggle'))
return executeTimeline.fullTransition(enterDuration)
},
])
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
- hidden=\\"\\"
- style=\\"display: none;\\"
+ class=\\"enter from\\"
+ style=\\"\\"
Render 2:
- class=\\"enter from\\"
+ class=\\"enter to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"enter to\\"
+ class=\\"\\""
`)
})
it('should transition in completely', async () => {
const enterDuration = 50
@@ -606,6 +679,57 @@ describe('Transitions', () => {
})
)
it(
'should transition out completely (render strategy = hidden)',
suppressConsoleLogs(async () => {
const leaveDuration = 50
function Example() {
const [show, setShow] = React.useState(true)
return (
<>
<style>{`.leave { transition-duration: ${leaveDuration}ms; } .from { opacity: 0%; } .to { opacity: 100%; }`}</style>
<Transition show={show} unmount={false} leave="leave" leaveFrom="from" leaveTo="to">
<span>Hello!</span>
</Transition>
<button data-testid="toggle" onClick={() => setShow(v => !v)}>
Toggle
</button>
</>
)
}
const timeline = await executeTimeline(<Example />, [
// Toggle to hide
({ getByTestId }) => {
fireEvent.click(getByTestId('toggle'))
return executeTimeline.fullTransition(leaveDuration)
},
])
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
- <div>
+ <div
+ class=\\"leave from\\"
+ >
Render 2:
- class=\\"leave from\\"
+ class=\\"leave to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"leave to\\"
+ class=\\"\\"
+ hidden=\\"\\"
+ style=\\"display: none;\\""
`)
})
)
it(
'should transition in and out completely',
suppressConsoleLogs(async () => {
@@ -690,6 +814,108 @@ describe('Transitions', () => {
`)
})
)
it(
'should transition in and out completely (render strategy = hidden)',
suppressConsoleLogs(async () => {
const enterDuration = 50
const leaveDuration = 75
function Example() {
const [show, setShow] = React.useState(false)
return (
<>
<style>{`.enter { transition-duration: ${enterDuration}ms; } .enter-from { opacity: 0%; } .enter-to { opacity: 100%; }`}</style>
<style>{`.leave { transition-duration: ${leaveDuration}ms; } .leave-from { opacity: 100%; } .leave-to { opacity: 0%; }`}</style>
<Transition
show={show}
unmount={false}
enter="enter"
enterFrom="enter-from"
enterTo="enter-to"
leave="leave"
leaveFrom="leave-from"
leaveTo="leave-to"
>
<span>Hello!</span>
</Transition>
<button data-testid="toggle" onClick={() => setShow(v => !v)}>
Toggle
</button>
</>
)
}
const timeline = await executeTimeline(<Example />, [
// Toggle to show
({ getByTestId }) => {
fireEvent.click(getByTestId('toggle'))
return executeTimeline.fullTransition(enterDuration)
},
// Toggle to hide
({ getByTestId }) => {
fireEvent.click(getByTestId('toggle'))
return executeTimeline.fullTransition(leaveDuration)
},
// Toggle to show
({ getByTestId }) => {
fireEvent.click(getByTestId('toggle'))
return executeTimeline.fullTransition(leaveDuration)
},
])
expect(timeline).toMatchInlineSnapshot(`
"Render 1:
- hidden=\\"\\"
- style=\\"display: none;\\"
+ class=\\"enter enter-from\\"
+ style=\\"\\"
Render 2:
- class=\\"enter enter-from\\"
+ class=\\"enter enter-to\\"
Render 3: Transition took at least 50ms (yes)
- class=\\"enter enter-to\\"
+ class=\\"\\"
Render 4:
- class=\\"\\"
+ class=\\"leave leave-from\\"
Render 5:
- class=\\"leave leave-from\\"
+ class=\\"leave leave-to\\"
Render 6: Transition took at least 75ms (yes)
- class=\\"leave leave-to\\"
- style=\\"\\"
+ class=\\"\\"
+ hidden=\\"\\"
+ style=\\"display: none;\\"
Render 7:
- class=\\"\\"
- hidden=\\"\\"
- style=\\"display: none;\\"
+ class=\\"enter enter-from\\"
+ style=\\"\\"
Render 8:
- class=\\"enter enter-from\\"
+ class=\\"enter enter-to\\"
Render 9: Transition took at least 75ms (yes)
- class=\\"enter enter-to\\"
+ class=\\"\\""
`)
})
)
})
describe('nested transitions', () => {
@@ -1,11 +1,13 @@
import * as React from 'react'
import { Props } from 'types'
import { useId } from '../../hooks/use-id'
import { useIsInitialRender } from '../../hooks/use-is-initial-render'
import { match } from '../../utils/match'
import { useIsMounted } from '../../hooks/use-is-mounted'
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
import { match } from '../../utils/match'
import { Features, PropsForFeatures, render, RenderStrategy } from '../../utils/render'
import { Reason, transition } from './utils/transition'
type ID = ReturnType<typeof useId>
@@ -43,24 +45,9 @@ export type TransitionEvents = Partial<{
afterLeave(): void
}>
type HTMLTags = keyof JSX.IntrinsicElements
type HTMLTagProps<TTag extends HTMLTags> = JSX.IntrinsicElements[TTag]
type AsShortcut<TTag extends HTMLTags> = {
children?: React.ReactNode
as?: TTag
} & Omit<HTMLTagProps<TTag>, 'ref'>
type AsRenderPropFunction = {
children: (ref: React.MutableRefObject<any>) => JSX.Element
}
type BaseConfig = Partial<{ appear: boolean }>
type TransitionChildProps<TTag extends HTMLTags> = BaseConfig &
(AsShortcut<TTag> | AsRenderPropFunction) &
TransitionClasses &
TransitionEvents
type TransitionChildProps<TTag> = Props<TTag, TransitionChildRenderPropArg> &
PropsForFeatures<typeof TransitionChildRenderFeatures> &
Partial<{ appear: boolean } & TransitionClasses & TransitionEvents>
function useTransitionContext() {
const context = React.useContext(TransitionContext)
@@ -83,16 +70,23 @@ function useParentNesting() {
}
type NestingContextValues = {
children: React.MutableRefObject<ID[]>
children: React.MutableRefObject<{ id: ID; state: TreeStates }[]>
register: (id: ID) => () => void
unregister: (id: ID) => void
unregister: (id: ID, strategy?: RenderStrategy) => void
}
const NestingContext = React.createContext<NestingContextValues | null>(null)
function hasChildren(
bag: NestingContextValues['children'] | { children: NestingContextValues['children'] }
): boolean {
if ('children' in bag) return hasChildren(bag.children)
return bag.current.filter(({ state }) => state === TreeStates.Visible).length > 0
}
function useNesting(done?: () => void) {
const doneRef = React.useRef(done)
const transitionableChildren = React.useRef<ID[]>([])
const transitionableChildren = React.useRef<NestingContextValues['children']['current']>([])
const mounted = useIsMounted()
React.useEffect(() => {
@@ -100,14 +94,20 @@ function useNesting(done?: () => void) {
}, [done])
const unregister = React.useCallback(
(childId: ID) => {
const idx = transitionableChildren.current.indexOf(childId)
(childId: ID, strategy = RenderStrategy.Hidden) => {
const idx = transitionableChildren.current.findIndex(({ id }) => id === childId)
if (idx === -1) return
transitionableChildren.current.splice(idx, 1)
match(strategy, {
[RenderStrategy.Unmount]() {
transitionableChildren.current.splice(idx, 1)
},
[RenderStrategy.Hidden]() {
transitionableChildren.current[idx].state = TreeStates.Hidden
},
})
if (transitionableChildren.current.length <= 0 && mounted.current) {
if (!hasChildren(transitionableChildren) && mounted.current) {
doneRef.current?.()
}
},
@@ -116,8 +116,14 @@ function useNesting(done?: () => void) {
const register = React.useCallback(
(childId: ID) => {
transitionableChildren.current.push(childId)
return () => unregister(childId)
const child = transitionableChildren.current.find(({ id }) => id === childId)
if (!child) {
transitionableChildren.current.push({ id: childId, state: TreeStates.Visible })
} else if (child.state !== TreeStates.Visible) {
child.state = TreeStates.Visible
}
return () => unregister(childId, RenderStrategy.Unmount)
},
[transitionableChildren, unregister]
)
@@ -156,7 +162,15 @@ function useEvents(events: TransitionEvents) {
return eventsRef
}
function TransitionChild<TTag extends HTMLTags = 'div'>(props: TransitionChildProps<TTag>) {
// ---
const DEFAULT_TRANSITION_CHILD_TAG = 'div'
type TransitionChildRenderPropArg = React.MutableRefObject<HTMLDivElement>
const TransitionChildRenderFeatures = Features.RenderStrategy
function TransitionChild<TTag extends React.ElementType = typeof DEFAULT_TRANSITION_CHILD_TAG>(
props: TransitionChildProps<TTag>
) {
const {
// Event "handlers"
beforeEnter,
@@ -171,13 +185,11 @@ function TransitionChild<TTag extends HTMLTags = 'div'>(props: TransitionChildPr
leave,
leaveFrom,
leaveTo,
// ..
children,
...rest
} = props
const container = React.useRef<HTMLElement | null>(null)
const [state, setState] = React.useState(TreeStates.Visible)
const strategy = rest.unmount ? RenderStrategy.Unmount : RenderStrategy.Hidden
const { show, appear } = useTransitionContext()
const { register, unregister } = useParentNesting()
@@ -202,6 +214,23 @@ function TransitionChild<TTag extends HTMLTags = 'div'>(props: TransitionChildPr
return register(id)
}, [register, id])
useIsoMorphicEffect(() => {
// If we are in another mode than the Hidden mode then ignore
if (strategy !== RenderStrategy.Hidden) return
if (!id) return
// Make sure that we are visible
if (show && state !== TreeStates.Visible) {
setState(TreeStates.Visible)
return
}
match(state, {
[TreeStates.Hidden]: () => unregister(id),
[TreeStates.Visible]: () => register(id),
})
}, [state, id, register, unregister, show, strategy])
const enterClasses = useSplitClasses(enter)
const enterFromClasses = useSplitClasses(enterFrom)
const enterToClasses = useSplitClasses(enterTo)
@@ -243,7 +272,7 @@ function TransitionChild<TTag extends HTMLTags = 'div'>(props: TransitionChildPr
// When we don't have children anymore we can safely unregister from the parent and hide
// ourselves.
if (nesting.children.current.length <= 0) {
if (!hasChildren(nesting)) {
setState(TreeStates.Hidden)
unregister(id)
events.current.afterLeave()
@@ -266,32 +295,27 @@ function TransitionChild<TTag extends HTMLTags = 'div'>(props: TransitionChildPr
leaveToClasses,
])
// Unmount the whole tree
if (state === TreeStates.Hidden) return null
const propsBag = {}
const propsWeControl = { ref: container }
const passthroughProps = rest
if (typeof children === 'function') {
return (
<NestingContext.Provider value={nesting}>
{(children as AsRenderPropFunction['children'])(container)}
</NestingContext.Provider>
)
}
const { as: Component = 'div', ...passthroughProps } = rest as AsShortcut<TTag>
return (
<NestingContext.Provider value={nesting}>
{/* @ts-expect-error Expression produces a union type that is too complex to represent. */}
<Component {...passthroughProps} ref={container}>
{children}
</Component>
{render(
{ ...passthroughProps, ...propsWeControl },
propsBag,
DEFAULT_TRANSITION_CHILD_TAG,
TransitionChildRenderFeatures,
state === TreeStates.Visible
)}
</NestingContext.Provider>
)
}
export function Transition<TTag extends HTMLTags = 'div'>(
export function Transition<TTag extends React.ElementType = typeof DEFAULT_TRANSITION_CHILD_TAG>(
props: TransitionChildProps<TTag> & { show: boolean; appear?: boolean }
) {
const { show, appear = false, ...rest } = props
const { show, appear = false, unmount, ...passthroughProps } = props
if (![true, false].includes(show)) {
throw new Error('A <Transition /> is used but it is missing a `show={true | false}` prop.')
@@ -312,18 +336,28 @@ export function Transition<TTag extends HTMLTags = 'div'>(
React.useEffect(() => {
if (show) {
setState(TreeStates.Visible)
} else if (nestingBag.children.current.length <= 0) {
} else if (!hasChildren(nestingBag)) {
setState(TreeStates.Hidden)
}
}, [show, nestingBag])
const sharedProps = { unmount }
const propsBag = {}
return (
<NestingContext.Provider value={nestingBag}>
<TransitionContext.Provider value={transitionBag}>
{match(state, {
[TreeStates.Visible]: () => <TransitionChild {...rest} />,
[TreeStates.Hidden]: null,
})}
{render(
{
...sharedProps,
as: React.Fragment,
children: <TransitionChild {...sharedProps} {...passthroughProps} />,
},
propsBag,
React.Fragment,
TransitionChildRenderFeatures,
state === TreeStates.Visible
)}
</TransitionContext.Provider>
</NestingContext.Provider>
)
@@ -27,8 +27,14 @@ export function getMenuItems(): HTMLElement[] {
// ---
export enum MenuState {
Open,
Closed,
/** The menu is visible to the user. */
Visible,
/** The menu is **not** visible to the user. It's still in the DOM, but it is hidden. */
InvisibleHidden,
/** The menu is **not** visible to the user. It's not in the DOM, it is unmounted. */
InvisibleUnmounted,
}
export function assertMenuButton(
@@ -47,12 +53,17 @@ export function assertMenuButton(
expect(button).toHaveAttribute('aria-haspopup')
switch (options.state) {
case MenuState.Open:
case MenuState.Visible:
expect(button).toHaveAttribute('aria-controls')
expect(button).toHaveAttribute('aria-expanded', 'true')
break
case MenuState.Closed:
case MenuState.InvisibleHidden:
expect(button).toHaveAttribute('aria-controls')
expect(button).not.toHaveAttribute('aria-expanded')
break
case MenuState.InvisibleUnmounted:
expect(button).not.toHaveAttribute('aria-controls')
expect(button).not.toHaveAttribute('aria-expanded')
break
@@ -124,27 +135,37 @@ export function assertMenu(
) {
try {
switch (options.state) {
case MenuState.Open:
case MenuState.InvisibleHidden:
if (menu === null) return expect(menu).not.toBe(null)
// Check that some attributes exists, doesn't really matter what the values are at this point in
// time, we just require them.
expect(menu).toHaveAttribute('aria-labelledby')
assertHidden(menu)
// Check that we have the correct values for certain attributes
expect(menu).toHaveAttribute('aria-labelledby')
expect(menu).toHaveAttribute('role', 'menu')
if (options.textContent) {
expect(menu).toHaveTextContent(options.textContent)
}
if (options.textContent) expect(menu).toHaveTextContent(options.textContent)
// Ensure menu button has the following attributes
for (let attributeName in options.attributes) {
expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case MenuState.Closed:
case MenuState.Visible:
if (menu === null) return expect(menu).not.toBe(null)
assertVisible(menu)
expect(menu).toHaveAttribute('aria-labelledby')
expect(menu).toHaveAttribute('role', 'menu')
if (options.textContent) expect(menu).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case MenuState.InvisibleUnmounted:
expect(menu).toBe(null)
break
@@ -217,8 +238,14 @@ export function getListboxOptions(): HTMLElement[] {
// ---
export enum ListboxState {
Open,
Closed,
/** The listbox is visible to the user. */
Visible,
/** The listbox is **not** visible to the user. It's still in the DOM, but it is hidden. */
InvisibleHidden,
/** The listbox is **not** visible to the user. It's not in the DOM, it is unmounted. */
InvisibleUnmounted,
}
export function assertListbox(
@@ -231,27 +258,37 @@ export function assertListbox(
) {
try {
switch (options.state) {
case ListboxState.Open:
case ListboxState.InvisibleHidden:
if (listbox === null) return expect(listbox).not.toBe(null)
// Check that some attributes exists, doesn't really matter what the values are at this point in
// time, we just require them.
expect(listbox).toHaveAttribute('aria-labelledby')
assertHidden(listbox)
// Check that we have the correct values for certain attributes
expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) {
expect(listbox).toHaveTextContent(options.textContent)
}
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
// Ensure listbox button has the following attributes
for (let attributeName in options.attributes) {
expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ListboxState.Closed:
case ListboxState.Visible:
if (listbox === null) return expect(listbox).not.toBe(null)
assertVisible(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ListboxState.InvisibleUnmounted:
expect(listbox).toBe(null)
break
@@ -280,12 +317,17 @@ export function assertListboxButton(
expect(button).toHaveAttribute('aria-haspopup')
switch (options.state) {
case ListboxState.Open:
case ListboxState.Visible:
expect(button).toHaveAttribute('aria-controls')
expect(button).toHaveAttribute('aria-expanded', 'true')
break
case ListboxState.Closed:
case ListboxState.InvisibleHidden:
expect(button).toHaveAttribute('aria-controls')
expect(button).not.toHaveAttribute('aria-expanded')
break
case ListboxState.InvisibleUnmounted:
expect(button).not.toHaveAttribute('aria-controls')
expect(button).not.toHaveAttribute('aria-expanded')
break
@@ -567,3 +609,29 @@ export function assertActiveElement(element: HTMLElement | null) {
throw err
}
}
// ---
export function assertHidden(element: HTMLElement | null) {
try {
if (element === null) return expect(element).not.toBe(null)
expect(element).toHaveAttribute('hidden')
expect(element).toHaveStyle({ display: 'none' })
} catch (err) {
Error.captureStackTrace(err, assertHidden)
throw err
}
}
export function assertVisible(element: HTMLElement | null) {
try {
if (element === null) return expect(element).not.toBe(null)
expect(element).not.toHaveAttribute('hidden')
expect(element).not.toHaveStyle({ display: 'none' })
} catch (err) {
Error.captureStackTrace(err, assertVisible)
throw err
}
}
+19 -2
View File
@@ -1,8 +1,25 @@
// A unique placeholder we can use as some defaults. This is nice because we can use this instead of
// defaulting to null / never / ... and possibly collide with actual data.
const __: unique symbol = Symbol('__placeholder__')
export type __ = typeof __
export type PropsOf<TTag = any> = TTag extends React.ElementType
? React.ComponentProps<TTag>
: never
export type Props<TTag, TSlot = {}, TOmitableProps extends keyof any = ''> = {
export type Props<TTag, TSlot = {}, TOmitableProps extends keyof any = __> = {
as?: TTag
children?: React.ReactNode | ((bag: TSlot) => React.ReactElement)
} & Omit<PropsOf<TTag>, TOmitableProps>
refName?: string
} & (TOmitableProps extends __ ? PropsOf<TTag> : Omit<PropsOf<TTag>, TOmitableProps>)
type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }
export type XOR<T, U> = T | U extends __
? never
: T extends __
? U
: U extends __
? T
: T | U extends object
? (Without<T, U> & U) | (Without<U, T> & T)
: T | U
@@ -0,0 +1,406 @@
import React from 'react'
import { render as testRender, prettyDOM, getByTestId } from '@testing-library/react'
import { suppressConsoleLogs } from '../test-utils/suppress-console-logs'
import { render, Features, PropsForFeatures } from './render'
import { Props } from '../types'
function contents() {
return prettyDOM(getByTestId(document.body, 'wrapper'), undefined, {
highlight: false,
})
}
describe('Default functionality', () => {
const bag = {}
function Dummy<TTag extends React.ElementType = 'div'>(
props: Props<TTag> & Partial<{ a: any; b: any; c: any }>
) {
return <div data-testid="wrapper">{render(props, bag, 'div')}</div>
}
it('should be possible to render a dummy component', () => {
testRender(<Dummy />)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<div />
</div>"
`)
})
it('should be possible to render a dummy component with some children as a callback', () => {
expect.assertions(2)
testRender(
<Dummy>
{data => {
expect(data).toBe(bag)
return <span>Contents</span>
}}
</Dummy>
)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<div>
<span>
Contents
</span>
</div>
</div>"
`)
})
it('should be possible to add a ref with a different name', () => {
const ref = React.createRef()
function MyComponent<T extends React.ElementType = 'div'>({
innerRef,
...props
}: Props<T> & { innerRef: React.Ref<HTMLDivElement> }) {
return <div ref={innerRef} {...props} />
}
function OtherDummy<TTag extends React.ElementType = 'div'>(props: Props<TTag>) {
return <div data-testid="wrapper">{render({ ...props, ref }, bag, 'div')}</div>
}
testRender(
<OtherDummy as={MyComponent} refName="potato">
Contents
</OtherDummy>
)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<div
potato=\\"[object Object]\\"
>
Contents
</div>
</div>"
`)
})
it('should be possible to passthrough props to a dummy component', () => {
testRender(<Dummy a={1} b={2} c={3} />)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<div
a=\\"1\\"
b=\\"2\\"
c=\\"3\\"
/>
</div>"
`)
})
it('should be possible to change the underlying DOM node using the `as` prop', () => {
testRender(<Dummy as="button" />)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<button />
</div>"
`)
})
it('should be possible to change the underlying DOM node using the `as` prop and still have a function as children', () => {
testRender(<Dummy as="button">{() => <span>Contents</span>}</Dummy>)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<button>
<span>
Contents
</span>
</button>
</div>"
`)
})
it('should be possible to render the children only when the `as` prop is set to React.Fragment', () => {
testRender(<Dummy as={React.Fragment}>Contents</Dummy>)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
Contents
</div>"
`)
})
it('should forward all the props to the first child when using an as={React.Fragment}', () => {
testRender(
<Dummy as={React.Fragment} a={1} b={1}>
{() => <span>Contents</span>}
</Dummy>
)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<span
a=\\"1\\"
b=\\"1\\"
>
Contents
</span>
</div>"
`)
})
it(
'should error when we are rendering a React.Fragment with multiple children',
suppressConsoleLogs(() => {
expect.assertions(1)
return expect(() => {
testRender(
// @ts-expect-error className cannot be applied to a React.Fragment
<Dummy as={React.Fragment} className="p-12">
<span>Contents A</span>
<span>Contents B</span>
</Dummy>
)
}).toThrowErrorMatchingInlineSnapshot(`"You should only render 1 child"`)
})
)
it("should not error when we are rendering a React.Fragment with multiple children when we don't passthrough additional props", () => {
testRender(
<Dummy as={React.Fragment}>
<span>Contents A</span>
<span>Contents B</span>
</Dummy>
)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<span>
Contents A
</span>
<span>
Contents B
</span>
</div>"
`)
})
it(
'should error when we are applying props to a React.Fragment when we do not have a dedicated element',
suppressConsoleLogs(() => {
expect.assertions(1)
return expect(() => {
testRender(
// @ts-expect-error className cannot be applied to a React.Fragment
<Dummy as={React.Fragment} className="p-12">
Contents
</Dummy>
)
}).toThrowErrorMatchingInlineSnapshot(
`"You should render an element as a child. Did you forget the as=\\"...\\" prop?"`
)
})
)
})
// ---
function testStaticFeature(Dummy) {
it('should be possible to render a `static` dummy component (show = true)', () => {
testRender(
<Dummy show={true} static>
Contents
</Dummy>
)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<div>
Contents
</div>
</div>"
`)
})
it('should be possible to render a `static` dummy component (show = false)', () => {
testRender(
<Dummy show={false} static>
Contents
</Dummy>
)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<div>
Contents
</div>
</div>"
`)
})
}
// With the `static` keyword, the user is always in control. When we internally decide to show the
// component or hide it then it won't have any effect. This is useful for when you want to wrap your
// 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.
describe('Features.Static', () => {
const bag = {}
const EnabledFeatures = Features.Static
function Dummy<TTag extends React.ElementType = 'div'>(
props: Props<TTag> & { show: boolean } & PropsForFeatures<typeof EnabledFeatures>
) {
const { show, ...rest } = props
return <div data-testid="wrapper">{render(rest, bag, 'div', EnabledFeatures, show)}</div>
}
testStaticFeature(Dummy)
})
// ---
function testRenderStrategyFeature(Dummy) {
describe('Unmount render strategy', () => {
it('should be possible to render an `unmount` dummy component (show = true)', () => {
testRender(
<Dummy show={true} unmount>
Contents
</Dummy>
)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<div>
Contents
</div>
</div>"
`)
})
it('should be possible to render an `unmount` dummy component (show = false)', () => {
testRender(
<Dummy show={false} unmount>
Contents
</Dummy>
)
// No contents, because we unmounted!
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
/>"
`)
})
})
describe('Hidden render strategy', () => {
it('should be possible to render an `unmount={false}` dummy component (show = true)', () => {
testRender(
<Dummy show={true} unmount={false}>
Contents
</Dummy>
)
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<div>
Contents
</div>
</div>"
`)
})
it('should be possible to render an `unmount={false}` dummy component (show = false)', () => {
testRender(
<Dummy show={false} unmount={false}>
Contents
</Dummy>
)
// We do have contents, but it is marked as hidden!
expect(contents()).toMatchInlineSnapshot(`
"<div
data-testid=\\"wrapper\\"
>
<div
hidden=\\"\\"
style=\\"display: none;\\"
>
Contents
</div>
</div>"
`)
})
})
}
describe('Features.RenderStrategy', () => {
const bag = {}
const EnabledFeatures = Features.RenderStrategy
function Dummy<TTag extends React.ElementType = 'div'>(
props: Props<TTag> & { show: boolean } & PropsForFeatures<typeof EnabledFeatures>
) {
const { show, ...rest } = props
return <div data-testid="wrapper">{render(rest, bag, 'div', EnabledFeatures, show)}</div>
}
testRenderStrategyFeature(Dummy)
})
// ---
// This should enable the `static` and `unmount` features. However they can't be used together!
describe('Features.Static | Features.RenderStrategy', () => {
const bag = {}
const EnabledFeatures = Features.Static | Features.RenderStrategy
function Dummy<TTag extends React.ElementType = 'div'>(
props: Props<TTag> & { show: boolean } & PropsForFeatures<typeof EnabledFeatures>
) {
const { show, ...rest } = props
return <div data-testid="wrapper">{render(rest, bag, 'div', EnabledFeatures, show)}</div>
}
// TODO: Can we "legit" test this? 🤔
it('should result in a typescript error', () => {
testRender(
// @ts-expect-error static & unmount together are incompatible
<Dummy show={false} static unmount>
Contents
</Dummy>
)
})
// To avoid duplication, and to make sure that the features tested in isolation can also be
// re-used when they are combined.
testStaticFeature(Dummy)
testRenderStrategyFeature(Dummy)
})
+113 -10
View File
@@ -1,12 +1,94 @@
import * as React from 'react'
import { Props } from '../types'
import { Props, XOR, __ } from '../types'
import { match } from './match'
export function render<TTag extends React.ElementType, TBag>(
props: Props<TTag, TBag, any>,
export enum Features {
/** No features at all */
None = 0,
/**
* When used, this will allow us to use one of the render strategies.
*
* **The render strategies are:**
* - **Unmount** _(Will unmount the component.)_
* - **Hidden** _(Will hide the component using the [hidden] attribute.)_
*/
RenderStrategy = 1,
/**
* When used, this will allow the user of our component to be in control. This can be used when
* you want to transition based on some state.
*/
Static = 2,
}
export enum RenderStrategy {
Unmount,
Hidden,
}
type PropsForFeature<TPassedInFeatures extends Features, TForFeature extends Features, TProps> = {
[P in TPassedInFeatures]: P extends TForFeature ? TProps : __
}[TPassedInFeatures]
export type PropsForFeatures<T extends Features> = XOR<
PropsForFeature<T, Features.Static, { static?: boolean }>,
PropsForFeature<T, Features.RenderStrategy, { unmount?: boolean }>
>
export function render<TFeature extends Features, TTag extends React.ElementType, TBag>(
props: Props<TTag, TBag, any> & PropsForFeatures<TFeature>,
propsBag: TBag,
defaultTag: React.ElementType,
features?: TFeature,
visible: boolean = true
) {
// Visible always render
if (visible) return _render(props, propsBag, defaultTag)
const featureFlags = features ?? Features.None
if (featureFlags & Features.Static) {
const { 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
if (isStatic) return _render(rest, propsBag, defaultTag)
}
if (featureFlags & Features.RenderStrategy) {
const { unmount = true, ...rest } = props as PropsForFeatures<Features.RenderStrategy>
const strategy = unmount ? RenderStrategy.Unmount : RenderStrategy.Hidden
return match(strategy, {
[RenderStrategy.Unmount]() {
return null
},
[RenderStrategy.Hidden]() {
return _render(
{ ...rest, ...{ hidden: true, style: { display: 'none' } } },
propsBag,
defaultTag
)
},
})
}
// No features enabled, just render
return _render(props, propsBag, defaultTag)
}
function _render<TTag extends React.ElementType, TBag>(
props: Props<TTag, TBag> & { ref?: unknown },
bag: TBag,
tag: React.ElementType
) {
const { as: Component = tag, children, ...passThroughProps } = props
const { as: Component = tag, children, refName = 'ref', ...passThroughProps } = omit(props, [
'unmount',
'static',
])
// This allows us to use `<HeadlessUIComponent as={MyComopnent} refName="innerRef" />`
const refRelatedProps = props.ref !== undefined ? { [refName]: props.ref } : {}
const resolvedChildren = (typeof children === 'function' ? children(bag) : children) as
| React.ReactElement
@@ -16,7 +98,7 @@ export function render<TTag extends React.ElementType, TBag>(
if (Object.keys(passThroughProps).length > 0) {
if (Array.isArray(resolvedChildren) && resolvedChildren.length > 1) {
const 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
}
@@ -24,20 +106,33 @@ export function render<TTag extends React.ElementType, TBag>(
const err = new Error(
`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
}
return React.cloneElement(
resolvedChildren,
// Filter out undefined values so that they don't override the existing values
mergeEventFunctions(compact(passThroughProps), resolvedChildren.props, ['onClick'])
Object.assign(
{},
// Filter out undefined values so that they don't override the existing values
mergeEventFunctions(compact(omit(passThroughProps, ['ref'])), resolvedChildren.props, [
'onClick',
]),
refRelatedProps
)
)
}
}
return React.createElement(Component, passThroughProps, resolvedChildren)
return React.createElement(
Component,
Object.assign(
{},
omit(passThroughProps, ['ref']),
Component !== React.Fragment && refRelatedProps
),
resolvedChildren
)
}
/**
@@ -92,3 +187,11 @@ function compact<T extends Record<any, any>>(object: T) {
}
return clone
}
function omit<T extends Record<any, any>>(object: T, keysToOmit: string[] = []) {
let clone = Object.assign({}, object)
for (let key of keysToOmit) {
if (key in clone) delete clone[key]
}
return clone
}
+10 -8
View File
@@ -338,10 +338,11 @@ To tell an element to render its children directly with no wrapper element, use
##### Props
| Prop | Type | Default | Description |
| -------- | ------------------- | ------- | --------------------------------------------------------------------------- |
| `as` | String \| Component | `div` | The element or component the `MenuItems` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| Prop | Type | Default | Description |
| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- |
| `as` | String \| Component | `div` | The element or component the `MenuItems` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. |
##### Slot props
@@ -1029,10 +1030,11 @@ export default {
##### Props
| Prop | Type | Default | Description |
| -------- | ------------------- | ------- | --------------------------------------------------------------------------- |
| `as` | String \| Component | `ul` | The element or component the `ListboxOptions` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| Prop | Type | Default | Description |
| --------- | ------------------- | ------- | --------------------------------------------------------------------------------- |
| `as` | String \| Component | `ul` | The element or component the `ListboxOptions` should render as. |
| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. |
| `unmount` | Boolean | `true` | Whether the element should be unmounted or hidden based on the open/closed state. |
##### Slot props
+2 -2
View File
@@ -34,8 +34,8 @@
"@popperjs/core": "^2.5.3",
"@testing-library/vue": "^5.1.0",
"@types/debounce": "^1.2.0",
"@vue/compiler-sfc": "3.0.0",
"@vue/test-utils": "^2.0.0-beta.6",
"@vue/compiler-sfc": "3.0.1",
"@vue/test-utils": "^2.0.0-beta.7",
"husky": "^4.3.0",
"vite": "^1.0.0-rc.4",
"vue": "^3.0.0-rc.13",
File diff suppressed because it is too large Load Diff
@@ -12,9 +12,10 @@ import {
ComputedRef,
watchEffect,
toRaw,
watch,
} from 'vue'
import { match } from '../../utils/match'
import { render } from '../../utils/render'
import { Features, render } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
@@ -142,7 +143,10 @@ export const Listbox = defineComponent({
options,
searchQuery,
activeOptionIndex,
closeListbox: () => (listboxState.value = ListboxStates.Closed),
closeListbox: () => {
listboxState.value = ListboxStates.Closed
activeOptionIndex.value = null
},
openListbox: () => (listboxState.value = ListboxStates.Open),
goToOption(focus: Focus, id?: string) {
const nextActiveOptionIndex = calculateActiveOptionIndex(focus, id)
@@ -359,15 +363,11 @@ export const ListboxOptions = defineComponent({
props: {
as: { type: [Object, String], default: 'ul' },
static: { type: Boolean, default: false },
unmount: { type: Boolean, default: true },
},
render() {
const api = useListboxContext('ListboxOptions')
// `static` is a reserved keyword, therefore aliasing it...
const { static: isStatic, ...passThroughProps } = this.$props
if (!isStatic && api.listboxState.value === ListboxStates.Closed) return null
const slot = { open: api.listboxState.value === ListboxStates.Open }
const propsWeControl = {
'aria-activedescendant':
@@ -381,12 +381,15 @@ export const ListboxOptions = defineComponent({
tabIndex: 0,
ref: 'el',
}
const passThroughProps = this.$props
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
features: Features.RenderStrategy | Features.Static,
visible: slot.open,
})
},
setup() {
@@ -409,11 +412,11 @@ export const ListboxOptions = defineComponent({
// When in type ahead mode, fallthrough
case Keys.Enter:
event.preventDefault()
api.closeListbox()
if (api.activeOptionIndex.value !== null) {
const { dataRef } = api.options.value[api.activeOptionIndex.value]
api.select(dataRef.value)
}
api.closeListbox()
nextTick(() => api.buttonRef.value?.focus())
break
@@ -496,12 +499,20 @@ export const ListboxOption = defineComponent({
onUnmounted(() => api.unregisterOption(id))
onMounted(() => {
if (!selected.value) return
api.goToOption(Focus.Specific, id)
document.getElementById(id)?.focus?.()
watch(
[api.listboxState, selected],
() => {
if (api.listboxState.value !== ListboxStates.Open) return
if (!selected.value) return
api.goToOption(Focus.Specific, id)
document.getElementById(id)?.focus?.()
},
{ immediate: true }
)
})
watchEffect(() => {
if (api.listboxState.value !== ListboxStates.Open) return
if (!active.value) return
nextTick(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }))
})
File diff suppressed because it is too large Load Diff
@@ -11,7 +11,7 @@ import {
Ref,
} from 'vue'
import { match } from '../../utils/match'
import { render } from '../../utils/render'
import { Features, render } from '../../utils/render'
import { useId } from '../../hooks/use-id'
import { Keys } from '../../keyboard'
@@ -121,7 +121,10 @@ export const Menu = defineComponent({
items,
searchQuery,
activeItemIndex,
closeMenu: () => (menuState.value = MenuStates.Closed),
closeMenu: () => {
menuState.value = MenuStates.Closed
activeItemIndex.value = null
},
openMenu: () => (menuState.value = MenuStates.Open),
goToItem(focus: Focus, id?: string) {
const nextActiveItemIndex = calculateActiveItemIndex(focus, id)
@@ -280,15 +283,11 @@ export const MenuItems = defineComponent({
props: {
as: { type: [Object, String], default: 'div' },
static: { type: Boolean, default: false },
unmount: { type: Boolean, default: true },
},
render() {
const api = useMenuContext('MenuItems')
// `static` is a reserved keyword, therefore aliasing it...
const { static: isStatic, ...passThroughProps } = this.$props
if (!isStatic && api.menuState.value === MenuStates.Closed) return null
const slot = { open: api.menuState.value === MenuStates.Open }
const propsWeControl = {
'aria-activedescendant':
@@ -302,12 +301,15 @@ export const MenuItems = defineComponent({
tabIndex: 0,
ref: 'el',
}
const passThroughProps = this.$props
return render({
props: { ...passThroughProps, ...propsWeControl },
slot,
attrs: this.$attrs,
slots: this.$slots,
features: Features.RenderStrategy | Features.Static,
visible: slot.open,
})
},
setup() {
@@ -330,11 +332,11 @@ export const MenuItems = defineComponent({
// When in type ahead mode, fallthrough
case Keys.Enter:
event.preventDefault()
api.closeMenu()
if (api.activeItemIndex.value !== null) {
const { id } = api.items.value[api.activeItemIndex.value]
document.getElementById(id)?.click()
}
api.closeMenu()
nextTick(() => api.buttonRef.value?.focus())
break
@@ -27,8 +27,14 @@ export function getMenuItems(): HTMLElement[] {
// ---
export enum MenuState {
Open,
Closed,
/** The menu is visible to the user. */
Visible,
/** The menu is **not** visible to the user. It's still in the DOM, but it is hidden. */
InvisibleHidden,
/** The menu is **not** visible to the user. It's not in the DOM, it is unmounted. */
InvisibleUnmounted,
}
export function assertMenuButton(
@@ -47,12 +53,17 @@ export function assertMenuButton(
expect(button).toHaveAttribute('aria-haspopup')
switch (options.state) {
case MenuState.Open:
case MenuState.Visible:
expect(button).toHaveAttribute('aria-controls')
expect(button).toHaveAttribute('aria-expanded', 'true')
break
case MenuState.Closed:
case MenuState.InvisibleHidden:
expect(button).toHaveAttribute('aria-controls')
expect(button).not.toHaveAttribute('aria-expanded')
break
case MenuState.InvisibleUnmounted:
expect(button).not.toHaveAttribute('aria-controls')
expect(button).not.toHaveAttribute('aria-expanded')
break
@@ -124,27 +135,37 @@ export function assertMenu(
) {
try {
switch (options.state) {
case MenuState.Open:
case MenuState.InvisibleHidden:
if (menu === null) return expect(menu).not.toBe(null)
// Check that some attributes exists, doesn't really matter what the values are at this point in
// time, we just require them.
expect(menu).toHaveAttribute('aria-labelledby')
assertHidden(menu)
// Check that we have the correct values for certain attributes
expect(menu).toHaveAttribute('aria-labelledby')
expect(menu).toHaveAttribute('role', 'menu')
if (options.textContent) {
expect(menu).toHaveTextContent(options.textContent)
}
if (options.textContent) expect(menu).toHaveTextContent(options.textContent)
// Ensure menu button has the following attributes
for (let attributeName in options.attributes) {
expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case MenuState.Closed:
case MenuState.Visible:
if (menu === null) return expect(menu).not.toBe(null)
assertVisible(menu)
expect(menu).toHaveAttribute('aria-labelledby')
expect(menu).toHaveAttribute('role', 'menu')
if (options.textContent) expect(menu).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case MenuState.InvisibleUnmounted:
expect(menu).toBe(null)
break
@@ -217,8 +238,14 @@ export function getListboxOptions(): HTMLElement[] {
// ---
export enum ListboxState {
Open,
Closed,
/** The listbox is visible to the user. */
Visible,
/** The listbox is **not** visible to the user. It's still in the DOM, but it is hidden. */
InvisibleHidden,
/** The listbox is **not** visible to the user. It's not in the DOM, it is unmounted. */
InvisibleUnmounted,
}
export function assertListbox(
@@ -231,27 +258,37 @@ export function assertListbox(
) {
try {
switch (options.state) {
case ListboxState.Open:
case ListboxState.InvisibleHidden:
if (listbox === null) return expect(listbox).not.toBe(null)
// Check that some attributes exists, doesn't really matter what the values are at this point in
// time, we just require them.
expect(listbox).toHaveAttribute('aria-labelledby')
assertHidden(listbox)
// Check that we have the correct values for certain attributes
expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) {
expect(listbox).toHaveTextContent(options.textContent)
}
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
// Ensure listbox button has the following attributes
for (let attributeName in options.attributes) {
expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ListboxState.Closed:
case ListboxState.Visible:
if (listbox === null) return expect(listbox).not.toBe(null)
assertVisible(listbox)
expect(listbox).toHaveAttribute('aria-labelledby')
expect(listbox).toHaveAttribute('role', 'listbox')
if (options.textContent) expect(listbox).toHaveTextContent(options.textContent)
for (let attributeName in options.attributes) {
expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName])
}
break
case ListboxState.InvisibleUnmounted:
expect(listbox).toBe(null)
break
@@ -280,12 +317,17 @@ export function assertListboxButton(
expect(button).toHaveAttribute('aria-haspopup')
switch (options.state) {
case ListboxState.Open:
case ListboxState.Visible:
expect(button).toHaveAttribute('aria-controls')
expect(button).toHaveAttribute('aria-expanded', 'true')
break
case ListboxState.Closed:
case ListboxState.InvisibleHidden:
expect(button).toHaveAttribute('aria-controls')
expect(button).not.toHaveAttribute('aria-expanded')
break
case ListboxState.InvisibleUnmounted:
expect(button).not.toHaveAttribute('aria-controls')
expect(button).not.toHaveAttribute('aria-expanded')
break
@@ -567,3 +609,29 @@ export function assertActiveElement(element: HTMLElement | null) {
throw err
}
}
// ---
export function assertHidden(element: HTMLElement | null) {
try {
if (element === null) return expect(element).not.toBe(null)
expect(element).toHaveAttribute('hidden')
expect(element).toHaveStyle({ display: 'none' })
} catch (err) {
Error.captureStackTrace(err, assertHidden)
throw err
}
}
export function assertVisible(element: HTMLElement | null) {
try {
if (element === null) return expect(element).not.toBe(null)
expect(element).not.toHaveAttribute('hidden')
expect(element).not.toHaveStyle({ display: 'none' })
} catch (err) {
Error.captureStackTrace(err, assertVisible)
throw err
}
}
+76 -1
View File
@@ -1,6 +1,73 @@
import { h, cloneVNode, Slots } from 'vue'
import { match } from './match'
export enum Features {
/** No features at all */
None = 0,
/**
* When used, this will allow us to use one of the render strategies.
*
* **The render strategies are:**
* - **Unmount** _(Will unmount the component.)_
* - **Hidden** _(Will hide the component using the [hidden] attribute.)_
*/
RenderStrategy = 1,
/**
* When used, this will allow the user of our component to be in control. This can be used when
* you want to transition based on some state.
*/
Static = 2,
}
enum RenderStrategy {
Unmount,
Hidden,
}
export function render({
visible = true,
features = Features.None,
...main
}: {
props: Record<string, any>
slot: Record<string, any>
attrs: Record<string, any>
slots: Slots
} & {
features?: Features
visible?: boolean
}) {
// Visible always render
if (visible) return _render(main)
if (features & Features.Static) {
// When the `static` prop is passed as `true`, then the user is in control, thus we don't care about anything else
if (main.props.static) return _render(main)
}
if (features & Features.RenderStrategy) {
const strategy = main.props.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden
return match(strategy, {
[RenderStrategy.Unmount]() {
return null
},
[RenderStrategy.Hidden]() {
return _render({
...main,
props: { ...main.props, hidden: true, style: { display: 'none' } },
})
},
})
}
// No features enabled, just render
return _render(main)
}
function _render({
props,
attrs,
slots,
@@ -11,7 +78,7 @@ export function render({
attrs: Record<string, any>
slots: Slots
}) {
const { as, ...passThroughProps } = props
const { as, ...passThroughProps } = omit(props, ['unmount', 'static'])
const children = slots.default?.(slot)
@@ -30,3 +97,11 @@ export function render({
return h(as, passThroughProps, children)
}
function omit<T extends Record<any, any>>(object: T, keysToOmit: string[] = []) {
let clone = Object.assign({}, object)
for (let key of keysToOmit) {
if (key in clone) delete clone[key]
}
return clone
}
+140 -43
View File
@@ -363,6 +363,11 @@
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037"
integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q==
"@babel/parser@^7.12.0":
version "7.12.3"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.3.tgz#a305415ebe7a6c7023b40b5122a0662d928334cd"
integrity sha512-kFsOS0IbsuhO5ojF8Hc8z/8vEIOkylVBrjiZUbLTE3XFe0Qi+uu6HjzQixkFaqr0ZPAMZcBVxEwmsnsLPZ2Xsw==
"@babel/plugin-proposal-async-generator-functions@^7.10.4":
version "7.10.5"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.5.tgz#3491cabf2f7c179ab820606cec27fed15e0e8558"
@@ -1065,6 +1070,15 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"
"@babel/types@^7.12.0":
version "7.12.1"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.1.tgz#e109d9ab99a8de735be287ee3d6a9947a190c4ae"
integrity sha512-BzSY3NJBKM4kyatSOWh3D/JJ2O3CVzBybHWxtgxnggaxEuaSTTDqeiSb/xk9lrkw2Tbqyivw5ZU4rT+EfznQsA==
dependencies:
"@babel/helper-validator-identifier" "^7.10.4"
lodash "^4.17.19"
to-fast-properties "^2.0.0"
"@bcoe/v8-coverage@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
@@ -1491,10 +1505,10 @@
hex-rgb "^4.1.0"
postcss-selector-parser "^6.0.2"
"@testing-library/dom@^7.24.2", "@testing-library/dom@^7.5.7":
version "7.24.2"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.2.tgz#6d2b7dd21efbd5358b98c2777fc47c252f3ae55e"
integrity sha512-ERxcZSoHx0EcN4HfshySEWmEf5Kkmgi+J7O79yCJ3xggzVlBJ2w/QjJUC+EBkJJ2OeSw48i3IoePN4w8JlVUIA==
"@testing-library/dom@^7.24.3":
version "7.24.3"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.3.tgz#dae3071463cf28dc7755b43d9cf2202e34cbb85d"
integrity sha512-6eW9fUhEbR423FZvoHRwbWm9RUUByLWGayYFNVvqTnQLYvsNpBS4uEuKH9aqr3trhxFwGVneJUonehL3B1sHJw==
dependencies:
"@babel/code-frame" "^7.10.4"
"@babel/runtime" "^7.10.3"
@@ -1504,10 +1518,24 @@
dom-accessibility-api "^0.5.1"
pretty-format "^26.4.2"
"@testing-library/dom@^7.24.3":
version "7.24.3"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.3.tgz#dae3071463cf28dc7755b43d9cf2202e34cbb85d"
integrity sha512-6eW9fUhEbR423FZvoHRwbWm9RUUByLWGayYFNVvqTnQLYvsNpBS4uEuKH9aqr3trhxFwGVneJUonehL3B1sHJw==
"@testing-library/dom@^7.26.0":
version "7.26.0"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.26.0.tgz#da4d052dc426a4ccc916303369c6e7552126f680"
integrity sha512-fyKFrBbS1IigaE3FV21LyeC7kSGF84lqTlSYdKmGaHuK2eYQ/bXVPM5vAa2wx/AU1iPD6oQHsxy2QQ17q9AMCg==
dependencies:
"@babel/code-frame" "^7.10.4"
"@babel/runtime" "^7.10.3"
"@types/aria-query" "^4.2.0"
aria-query "^4.2.2"
chalk "^4.1.0"
dom-accessibility-api "^0.5.1"
lz-string "^1.4.4"
pretty-format "^26.4.2"
"@testing-library/dom@^7.5.7":
version "7.24.2"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.2.tgz#6d2b7dd21efbd5358b98c2777fc47c252f3ae55e"
integrity sha512-ERxcZSoHx0EcN4HfshySEWmEf5Kkmgi+J7O79yCJ3xggzVlBJ2w/QjJUC+EBkJJ2OeSw48i3IoePN4w8JlVUIA==
dependencies:
"@babel/code-frame" "^7.10.4"
"@babel/runtime" "^7.10.3"
@@ -1531,13 +1559,13 @@
lodash "^4.17.15"
redent "^3.0.0"
"@testing-library/react@^11.0.4":
version "11.0.4"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.0.4.tgz#c84082bfe1593d8fcd475d46baee024452f31dee"
integrity sha512-U0fZO2zxm7M0CB5h1+lh31lbAwMSmDMEMGpMT3BUPJwIjDEKYWOV4dx7lb3x2Ue0Pyt77gmz/VropuJnSz/Iew==
"@testing-library/react@^11.1.0":
version "11.1.0"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.1.0.tgz#dfb4b3177d05a8ccf156b5fd14a5550e91d7ebe4"
integrity sha512-Nfz58jGzW0tgg3irmTB7sa02JLkLnCk+QN3XG6WiaGQYb0Qc4Ok00aujgjdxlIQWZHbb4Zj5ZOIeE9yKFSs4sA==
dependencies:
"@babel/runtime" "^7.11.2"
"@testing-library/dom" "^7.24.2"
"@testing-library/dom" "^7.26.0"
"@testing-library/vue@^5.1.0":
version "5.1.0"
@@ -1779,10 +1807,10 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.1.tgz#56af902ad157e763f9ba63d671c39cda3193c835"
integrity sha512-oTQgnd0hblfLsJ6BvJzzSL+Inogp3lq9fGgqRkMB/ziKMgEUaFl801OncOzUmalfzt14N0oPHMK47ipl+wbTIw==
"@types/node@^14.11.8":
version "14.11.8"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.8.tgz#fe2012f2355e4ce08bca44aeb3abbb21cf88d33f"
integrity sha512-KPcKqKm5UKDkaYPTuXSx8wEP7vE9GnuaXIZKijwRYcePpZFDVuy2a57LarFKiORbHOuTOOwYzxVxcUzsh2P2Pw==
"@types/node@^14.11.10":
version "14.11.10"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.10.tgz#8c102aba13bf5253f35146affbf8b26275069bef"
integrity sha512-yV1nWZPlMFpoXyoknm4S56y2nlTAuFYaJuQtYRAOU7xA/FJ9RY0Xm7QOkaYMMmr8ESdHIuUb6oQgR/0+2NqlyA==
"@types/normalize-package-data@^2.4.0":
version "2.4.0"
@@ -1834,10 +1862,10 @@
"@types/prop-types" "*"
csstype "^3.0.2"
"@types/react@^16.9.52":
version "16.9.52"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.52.tgz#c46c72d1a1d8d9d666f4dd2066c0e22600ccfde1"
integrity sha512-EHRjmnxiNivwhGdMh9sz1Yw9AUxTSZFxKqdBWAAzyZx3sufWwx6ogqHYh/WB1m/I4ZpjkoZLExF5QTy2ekVi/Q==
"@types/react@^16.9.53":
version "16.9.53"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.53.tgz#40cd4f8b8d6b9528aedd1fff8fcffe7a112a3d23"
integrity sha512-4nW60Sd4L7+WMXH1D6jCdVftuW7j4Za6zdp6tJ33Rqv0nk1ZAmQKML9ZLD4H0dehA3FZxXR/GM8gXplf82oNGw==
dependencies:
"@types/prop-types" "*"
csstype "^3.0.2"
@@ -1947,6 +1975,17 @@
estree-walker "^2.0.1"
source-map "^0.6.1"
"@vue/compiler-core@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.0.1.tgz#3ce57531078c6220be7ea458e41e4bab3522015b"
integrity sha512-BbQQj9YVNaNWEPnP4PiFKgW8QSGB3dcPSKCtekx1586m4VA1z8hHNLQnzeygtV8BM4oU6yriiWmOIYghbJHwFw==
dependencies:
"@babel/parser" "^7.12.0"
"@babel/types" "^7.12.0"
"@vue/shared" "3.0.1"
estree-walker "^2.0.1"
source-map "^0.6.1"
"@vue/compiler-dom@3.0.0", "@vue/compiler-dom@^3.0.0-rc.5":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.0.0.tgz#4cbb48fcf1f852daef2babcf9953b681ac463526"
@@ -1955,7 +1994,37 @@
"@vue/compiler-core" "3.0.0"
"@vue/shared" "3.0.0"
"@vue/compiler-sfc@3.0.0", "@vue/compiler-sfc@^3.0.0-rc.5":
"@vue/compiler-dom@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.0.1.tgz#00b12f2e4aa55e624e2a5257e4bed93cf7555f0b"
integrity sha512-8cjgswVU2YmV35H9ARZmSlDr1P9VZxUihRwefkrk6Vrsb7kui5C3d/WQ2/su34FSDpyMU1aacUOiL2CV/vdX6w==
dependencies:
"@vue/compiler-core" "3.0.1"
"@vue/shared" "3.0.1"
"@vue/compiler-sfc@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.0.1.tgz#f340f8f75b5c1c4509e0f3a12c79d1544899b663"
integrity sha512-VO5gJ7SyHw0hf1rkKXRlxjXI9+Q4ngcuUWYnyjOSDch7Wtt2IdOEiC82KFWIkfWMpHqA5HPzL2nDmys3y9d19w==
dependencies:
"@babel/parser" "^7.12.0"
"@babel/types" "^7.12.0"
"@vue/compiler-core" "3.0.1"
"@vue/compiler-dom" "3.0.1"
"@vue/compiler-ssr" "3.0.1"
"@vue/shared" "3.0.1"
consolidate "^0.16.0"
estree-walker "^2.0.1"
hash-sum "^2.0.0"
lru-cache "^5.1.1"
magic-string "^0.25.7"
merge-source-map "^1.1.0"
postcss "^7.0.32"
postcss-modules "^3.2.2"
postcss-selector-parser "^6.0.4"
source-map "^0.6.1"
"@vue/compiler-sfc@^3.0.0-rc.5":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.0.0.tgz#efa38037984bd64aae315828aa5c1248c6eadca9"
integrity sha512-1Bn4L5jNRm6tlb79YwqYUGGe+Yc9PRoRSJi67NJX6icdhf84+tRMtESbx1zCLL9QixQXu2+7aLkXHxvh4RpqAA==
@@ -1985,6 +2054,14 @@
"@vue/compiler-dom" "3.0.0"
"@vue/shared" "3.0.0"
"@vue/compiler-ssr@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.0.1.tgz#0455b011d72d4ed02faa93610f14981c3d44a079"
integrity sha512-U0Vb7BOniw9rY0/YvXNw5EuIuO0dCoZd3XhnDjAKL9A5pSBxTlx6fPJeQ53gV0XH40M5z8q4yXukFqSVTXC6hQ==
dependencies:
"@vue/compiler-dom" "3.0.1"
"@vue/shared" "3.0.1"
"@vue/reactivity@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.0.0.tgz#fd15632a608650ce2a969c721787e27e2c80aa6b"
@@ -2014,6 +2091,11 @@
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.0.0.tgz#ec089236629ecc0f10346b92f101ff4339169f1a"
integrity sha512-4XWL/avABGxU2E2ZF1eZq3Tj7fvksCMssDZUHOykBIMmh5d+KcAnQMC5XHMhtnA0NAvktYsA2YpdsVwVmhWzvA==
"@vue/shared@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.0.1.tgz#48196c056726aa7466d0182698524c84f203006b"
integrity sha512-/X6AUbTFCyD2BcJnBoacUct8qcv1A5uk1+N+3tbzDVuhGPRmoYrTSnNUuF53C/GIsTkChrEiXaJh2kyo/0tRvw==
"@vue/test-utils@^1.0.3", "@vue/test-utils@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.1.0.tgz#76305e73a786c921ede1352849614e26c7113f94"
@@ -2023,10 +2105,10 @@
lodash "^4.17.15"
pretty "^2.0.0"
"@vue/test-utils@^2.0.0-beta.6":
version "2.0.0-beta.6"
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.0-beta.6.tgz#2f7a653b0025cd4236968269c5972e807fa1fb2c"
integrity sha512-nBj5HHoTD+2xg0OQ93p/Hil5SkFUcNJ5BA2RUnHlOH6a4PVskgMK8dOLyVcZ1ZJif7knjt7yQVJ6K6YwIzeR1A==
"@vue/test-utils@^2.0.0-beta.7":
version "2.0.0-beta.7"
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.0-beta.7.tgz#27751991e0b013ee4af487e51e16a58d477e5857"
integrity sha512-cAe7VqoxxkxTr/2N93UpW/LQbcUVKC+QRA3ZBq5ZWImtAf/8jtcdC2mQ9g4AKmSvyaKQtqxrRn4i/y5z7yrrKA==
"@webassemblyjs/ast@1.9.0":
version "1.9.0"
@@ -6748,10 +6830,10 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
lint-staged@^10.4.0:
version "10.4.0"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.4.0.tgz#d18628f737328e0bbbf87d183f4020930e9a984e"
integrity sha512-uaiX4U5yERUSiIEQc329vhCTDDwUcSvKdRLsNomkYLRzijk3v8V9GWm2Nz0RMVB87VcuzLvtgy6OsjoH++QHIg==
lint-staged@^10.4.2:
version "10.4.2"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.4.2.tgz#9fee4635c4b5ddb845746f237c6d43494ccd21c1"
integrity sha512-OLCA9K1hS+Sl179SO6kX0JtnsaKj/MZalEhUj5yAgXsb63qPI/Gfn6Ua1KuZdbfkZNEu3/n5C/obYCu70IMt9g==
dependencies:
chalk "^4.1.0"
cli-truncate "^2.1.0"
@@ -6959,6 +7041,11 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
lz-string@^1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=
magic-string@^0.25.2, magic-string@^0.25.5, magic-string@^0.25.7:
version "0.25.7"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051"
@@ -8147,6 +8234,16 @@ postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2:
indexes-of "^1.0.1"
uniq "^1.0.1"
postcss-selector-parser@^6.0.4:
version "6.0.4"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.4.tgz#56075a1380a04604c38b063ea7767a129af5c2b3"
integrity sha512-gjMeXBempyInaBqpp8gODmwZ52WaYsVOsfr4L4lDQ7n3ncD6mEyySiDtgzCT+NYC0mmeOLvtsF8iaEf0YT6dBw==
dependencies:
cssesc "^3.0.0"
indexes-of "^1.0.1"
uniq "^1.0.1"
util-deprecate "^1.0.2"
postcss-value-parser@^3.2.3, postcss-value-parser@^3.3.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
@@ -8410,10 +8507,10 @@ randomfill@^1.0.3:
randombytes "^2.0.5"
safe-buffer "^5.1.0"
react-dom@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f"
integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag==
react-dom@^16.14.0:
version "16.14.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89"
integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@@ -8430,10 +8527,10 @@ react-refresh@0.8.3:
resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f"
integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg==
react@^16.13.1:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e"
integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w==
react@^16.14.0:
version "16.14.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d"
integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
@@ -9700,10 +9797,10 @@ table@^5.2.3:
slice-ansi "^2.1.0"
string-width "^3.0.0"
tailwindcss@^1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.9.1.tgz#5cd83b7962c0e22d7608bc502daf4185962995fc"
integrity sha512-3faxlyPlcWN8AoNEIVQFNsDcrdXS/D9nOGtdknrXvZp4D4E3AGPO2KRPiGG69B2ZUO0V6RvYiW91L2/n9QnBxg==
tailwindcss@^1.9.4:
version "1.9.4"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.9.4.tgz#5ae8ff84bc8234df22ba5f2c7feafb64bb14da55"
integrity sha512-CVeP4J1pDluBM/AF11JPku9Cx+VwQ6MbOcnlobnWVVZnq+xku8sa+XXmYzy/GvE08qD8w+OmpSdN21ZFPoVDRg==
dependencies:
"@fullhuman/postcss-purgecss" "^2.1.2"
autoprefixer "^9.4.5"
@@ -10242,7 +10339,7 @@ use@^3.1.0:
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
util-deprecate@^1.0.1, util-deprecate@~1.0.1:
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=