diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b2bc576d..dc92b06bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Mimic browser select on focus when navigating via `Tab` ([#1272](https://github.com/tailwindlabs/headlessui/pull/1272)) - Ensure that there is always an active option in the `Combobox` ([#1279](https://github.com/tailwindlabs/headlessui/pull/1279), [#1281](https://github.com/tailwindlabs/headlessui/pull/1281)) - Allow `Enter` for form submit in `RadioGroup`, `Switch` and `Combobox` improvements ([#1285](https://github.com/tailwindlabs/headlessui/pull/1285)) +- add React 18 compatibility ([#1326](https://github.com/tailwindlabs/headlessui/pull/1326)) ### Added diff --git a/jest/create-jest-config.cjs b/jest/create-jest-config.cjs index bbeb0b88a0..6700197700 100644 --- a/jest/create-jest-config.cjs +++ b/jest/create-jest-config.cjs @@ -1,12 +1,14 @@ module.exports = function createJestConfig(root, options) { + let { setupFilesAfterEnv = [], transform = {}, ...rest } = options return Object.assign( { rootDir: root, - setupFilesAfterEnv: ['../../jest/custom-matchers.ts'], + setupFilesAfterEnv: ['../../jest/custom-matchers.ts', ...setupFilesAfterEnv], transform: { '^.+\\.(t|j)sx?$': '@swc/jest', + ...transform, }, }, - options + rest ) } diff --git a/package.json b/package.json index 7280f0ba57..b249e00a1c 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@swc/core": "^1.2.131", "@swc/jest": "^0.2.17", - "@testing-library/jest-dom": "^5.11.9", + "@testing-library/jest-dom": "^5.16.4", "@types/node": "^14.14.22", "esbuild": "^0.14.11", "fast-glob": "^3.2.11", diff --git a/packages/@headlessui-react/jest.config.cjs b/packages/@headlessui-react/jest.config.cjs index 8dc6875268..3f5bc9c3f1 100644 --- a/packages/@headlessui-react/jest.config.cjs +++ b/packages/@headlessui-react/jest.config.cjs @@ -1,2 +1,5 @@ let create = require('../../jest/create-jest-config.cjs') -module.exports = create(__dirname, { displayName: 'React' }) +module.exports = create(__dirname, { + displayName: 'React', + setupFilesAfterEnv: ['./jest.setup.js'], +}) diff --git a/packages/@headlessui-react/jest.setup.js b/packages/@headlessui-react/jest.setup.js new file mode 100644 index 0000000000..ef4af5454b --- /dev/null +++ b/packages/@headlessui-react/jest.setup.js @@ -0,0 +1 @@ +globalThis.IS_REACT_ACT_ENVIRONMENT = true diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index 9b8fad7fd8..140f31d9f8 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -42,12 +42,12 @@ "react-dom": "^16 || ^17 || ^18" }, "devDependencies": { - "@testing-library/react": "^11.2.3", - "@types/react": "16.14.21", - "@types/react-dom": "^16.9.0", + "@testing-library/react": "^13.0.0", + "@types/react": "^17.0.43", + "@types/react-dom": "^17.0.14", "esbuild": "^0.11.18", - "react": "^16.14.0", - "react-dom": "^16.14.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", "snapshot-diff": "^0.8.1" } } diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 82c1a578bd..f376666145 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -866,7 +866,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) @@ -915,7 +915,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Try to focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Try to open the combobox await press(Keys.Enter) @@ -951,7 +951,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) @@ -1000,7 +1000,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleHidden }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) @@ -1073,7 +1073,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) @@ -1114,7 +1114,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.Enter) @@ -1153,7 +1153,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.Space) @@ -1200,7 +1200,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Try to open the combobox await press(Keys.Space) @@ -1238,7 +1238,7 @@ describe('Keyboard interactions', () => { }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.Space) @@ -1278,7 +1278,7 @@ describe('Keyboard interactions', () => { }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.Space) @@ -1319,7 +1319,7 @@ describe('Keyboard interactions', () => { }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.Space) @@ -1358,7 +1358,7 @@ describe('Keyboard interactions', () => { assertComboboxButtonLinkedWithCombobox() // Re-focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) assertActiveElement(getComboboxButton()) // Close combobox @@ -1397,7 +1397,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowDown) @@ -1443,7 +1443,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Try to open the combobox await press(Keys.ArrowDown) @@ -1479,7 +1479,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowDown) @@ -1517,7 +1517,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowDown) @@ -1552,7 +1552,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) @@ -1598,7 +1598,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Try to open the combobox await press(Keys.ArrowUp) @@ -1634,7 +1634,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) @@ -1672,7 +1672,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) @@ -1709,7 +1709,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the button - getComboboxButton()?.focus() + await focus(getComboboxButton()) // Open combobox await press(Keys.ArrowUp) @@ -1899,7 +1899,7 @@ describe('Keyboard interactions', () => { render() // Focus the input field - getComboboxInput()?.focus() + await focus(getComboboxInput()) assertActiveElement(getComboboxInput()) // Press enter (which should submit the form) @@ -2212,7 +2212,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowDown) @@ -2258,7 +2258,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Try to open the combobox await press(Keys.ArrowDown) @@ -2294,7 +2294,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowDown) @@ -2332,7 +2332,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowDown) @@ -2527,7 +2527,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) @@ -2573,7 +2573,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Try to open the combobox await press(Keys.ArrowUp) @@ -2609,7 +2609,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) @@ -2647,7 +2647,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) @@ -2684,7 +2684,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) @@ -2766,7 +2766,7 @@ describe('Keyboard interactions', () => { assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) @@ -3099,7 +3099,7 @@ describe('Keyboard interactions', () => { ) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) @@ -3243,7 +3243,7 @@ describe('Keyboard interactions', () => { ) // Focus the input - getComboboxInput()?.focus() + await focus(getComboboxInput()) // Open combobox await press(Keys.ArrowUp) diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx index 1d93d260b5..6c0864f7eb 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx @@ -12,7 +12,7 @@ import { assertActiveElement, getByText, } from '../../test-utils/accessibility-assertions' -import { click, press, Keys, MouseButton } from '../../test-utils/interactions' +import { click, press, focus, Keys, MouseButton } from '../../test-utils/interactions' import { Transition } from '../transitions/transition' jest.mock('../../hooks/use-id') @@ -133,7 +133,7 @@ describe('Rendering', () => { ) // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Ensure the button is focused assertActiveElement(getDisclosureButton()) @@ -174,7 +174,7 @@ describe('Rendering', () => { ) // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Ensure the button is focused assertActiveElement(getDisclosureButton()) @@ -218,7 +218,7 @@ describe('Rendering', () => { render() // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Ensure the button is focused assertActiveElement(getDisclosureButton()) @@ -437,7 +437,7 @@ describe('Rendering', () => { ) // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Ensure the button is focused assertActiveElement(getDisclosureButton()) @@ -474,7 +474,7 @@ describe('Rendering', () => { ) // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Ensure the button is focused assertActiveElement(getDisclosureButton()) @@ -514,7 +514,7 @@ describe('Rendering', () => { render() // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Ensure the button is focused assertActiveElement(getDisclosureButton()) @@ -579,14 +579,15 @@ describe('Composition', () => { // Wait for all transitions to finish await nextFrame() + await nextFrame() // Verify that we tracked the `mounts` and `unmounts` in the correct order expect(orderFn.mock.calls).toEqual([ ['Mounting - Disclosure'], ['Mounting - Transition'], ['Mounting - Transition.Child'], - ['Unmounting - Transition.Child'], ['Unmounting - Transition'], + ['Unmounting - Transition.Child'], ]) }) ) @@ -611,7 +612,7 @@ describe('Keyboard interactions', () => { assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Open disclosure await press(Keys.Enter) @@ -646,7 +647,7 @@ describe('Keyboard interactions', () => { assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Try to open the disclosure await press(Keys.Enter) @@ -677,7 +678,7 @@ describe('Keyboard interactions', () => { assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Open disclosure await press(Keys.Enter) @@ -717,7 +718,7 @@ describe('Keyboard interactions', () => { assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Open disclosure await press(Keys.Space) @@ -748,7 +749,7 @@ describe('Keyboard interactions', () => { assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Try to open the disclosure await press(Keys.Space) @@ -779,7 +780,7 @@ describe('Keyboard interactions', () => { assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) // Focus the button - getDisclosureButton()?.focus() + await focus(getDisclosureButton()) // Open disclosure await press(Keys.Space) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx index 9bf22112c2..30a445c5d1 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx @@ -741,7 +741,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -787,7 +787,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Try to open the listbox await press(Keys.Enter) @@ -822,7 +822,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -867,7 +867,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleHidden }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -936,7 +936,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -973,7 +973,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -1007,7 +1007,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -1044,7 +1044,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -1083,7 +1083,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -1219,7 +1219,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Space) @@ -1262,7 +1262,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Try to open the listbox await press(Keys.Space) @@ -1297,7 +1297,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Space) @@ -1334,7 +1334,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Space) @@ -1368,7 +1368,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Space) @@ -1405,7 +1405,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Space) @@ -1444,7 +1444,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Space) @@ -1536,7 +1536,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Space) @@ -1585,7 +1585,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -1636,7 +1636,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -1689,7 +1689,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowDown) @@ -1734,7 +1734,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Try to open the listbox await press(Keys.ArrowDown) @@ -1769,7 +1769,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowDown) @@ -1806,7 +1806,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowDown) @@ -1838,7 +1838,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -1886,7 +1886,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -1928,7 +1928,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -1964,7 +1964,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -2012,7 +2012,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) @@ -2057,7 +2057,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Try to open the listbox await press(Keys.ArrowUp) @@ -2092,7 +2092,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) @@ -2129,7 +2129,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) @@ -2165,7 +2165,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) @@ -2203,7 +2203,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -2245,7 +2245,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) @@ -2302,7 +2302,7 @@ describe('Keyboard interactions', () => { assertListbox({ state: ListboxState.InvisibleUnmounted }) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) @@ -2354,7 +2354,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -2390,7 +2390,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -2494,7 +2494,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -2530,7 +2530,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.Enter) @@ -2634,7 +2634,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) @@ -2773,7 +2773,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) @@ -2945,7 +2945,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) @@ -2984,7 +2984,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) @@ -3025,7 +3025,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) @@ -3058,7 +3058,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getListboxButton()?.focus() + await focus(getListboxButton()) // Open listbox await press(Keys.ArrowUp) diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx index bf087b26f6..46b450ad5d 100644 --- a/packages/@headlessui-react/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx @@ -677,7 +677,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -721,7 +721,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Try to open the menu await press(Keys.Enter) @@ -748,7 +748,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -781,7 +781,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -818,7 +818,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -857,7 +857,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -1041,7 +1041,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Space) @@ -1083,7 +1083,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Try to open the menu await press(Keys.Space) @@ -1110,7 +1110,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Space) @@ -1143,7 +1143,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Space) @@ -1180,7 +1180,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Space) @@ -1219,7 +1219,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Space) @@ -1331,7 +1331,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Space) @@ -1379,7 +1379,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -1428,7 +1428,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -1479,7 +1479,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowDown) @@ -1523,7 +1523,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Try to open the menu await press(Keys.ArrowDown) @@ -1550,7 +1550,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowDown) @@ -1581,7 +1581,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -1629,7 +1629,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -1671,7 +1671,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -1707,7 +1707,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowUp) @@ -1751,7 +1751,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Try to open the menu await press(Keys.ArrowUp) @@ -1778,7 +1778,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowUp) @@ -1813,7 +1813,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowUp) @@ -1851,7 +1851,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -1893,7 +1893,7 @@ describe('Keyboard interactions', () => { assertMenu({ state: MenuState.InvisibleUnmounted }) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowUp) @@ -1943,7 +1943,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -1979,7 +1979,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -2083,7 +2083,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -2119,7 +2119,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.Enter) @@ -2223,7 +2223,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowUp) @@ -2362,7 +2362,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowUp) @@ -2534,7 +2534,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowUp) @@ -2573,7 +2573,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowUp) @@ -2614,7 +2614,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowUp) @@ -2647,7 +2647,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getMenuButton()?.focus() + await focus(getMenuButton()) // Open menu await press(Keys.ArrowUp) diff --git a/packages/@headlessui-react/src/components/popover/popover.test.tsx b/packages/@headlessui-react/src/components/popover/popover.test.tsx index 94cc9ed21e..e22db759d1 100644 --- a/packages/@headlessui-react/src/components/popover/popover.test.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.test.tsx @@ -14,7 +14,7 @@ import { assertContainsActiveElement, getPopoverOverlay, } from '../../test-utils/accessibility-assertions' -import { click, press, Keys, MouseButton, shift } from '../../test-utils/interactions' +import { click, press, focus, Keys, MouseButton, shift } from '../../test-utils/interactions' import { Portal } from '../portal/portal' import { Transition } from '../transitions/transition' @@ -156,7 +156,7 @@ describe('Rendering', () => { ) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Ensure the button is focused assertActiveElement(getPopoverButton()) @@ -197,7 +197,7 @@ describe('Rendering', () => { ) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Ensure the button is focused assertActiveElement(getPopoverButton()) @@ -241,7 +241,7 @@ describe('Rendering', () => { render() // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Ensure the button is focused assertActiveElement(getPopoverButton()) @@ -431,7 +431,7 @@ describe('Rendering', () => { ) - getPopoverButton()?.focus() + await focus(getPopoverButton()) assertPopoverButton({ state: PopoverState.InvisibleHidden }) assertPopoverPanel({ state: PopoverState.InvisibleHidden }) @@ -462,7 +462,7 @@ describe('Rendering', () => { ) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Ensure the button is focused assertActiveElement(getPopoverButton()) @@ -489,7 +489,7 @@ describe('Rendering', () => { ) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Ensure the button is focused assertActiveElement(getPopoverButton()) @@ -502,7 +502,7 @@ describe('Rendering', () => { assertActiveElement(getByText('Link 1')) // Focus the button again - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Ensure the Popover is closed again assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) @@ -525,7 +525,7 @@ describe('Rendering', () => { ) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Ensure the button is focused assertActiveElement(getPopoverButton()) @@ -552,7 +552,7 @@ describe('Rendering', () => { ) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Ensure the button is focused assertActiveElement(getPopoverButton()) @@ -579,7 +579,7 @@ describe('Rendering', () => { ) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Ensure the button is focused assertActiveElement(getPopoverButton()) @@ -616,7 +616,7 @@ describe('Rendering', () => { ) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Ensure the button is focused assertActiveElement(getPopoverButton()) @@ -656,7 +656,7 @@ describe('Rendering', () => { render() // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Ensure the button is focused assertActiveElement(getPopoverButton()) @@ -715,14 +715,15 @@ describe('Composition', () => { // Wait for all transitions to finish await nextFrame() + await nextFrame() // Verify that we tracked the `mounts` and `unmounts` in the correct order expect(orderFn.mock.calls).toEqual([ ['Mounting - Popover'], ['Mounting - Transition'], ['Mounting - Transition.Child'], - ['Unmounting - Transition.Child'], ['Unmounting - Transition'], + ['Unmounting - Transition.Child'], ]) }) ) @@ -747,7 +748,7 @@ describe('Keyboard interactions', () => { assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Open popover await press(Keys.Enter) @@ -782,7 +783,7 @@ describe('Keyboard interactions', () => { assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Try to open the popover await press(Keys.Enter) @@ -813,7 +814,7 @@ describe('Keyboard interactions', () => { assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Open popover await press(Keys.Enter) @@ -918,7 +919,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Verify popover is closed assertPopoverButton({ state: PopoverState.InvisibleUnmounted }) @@ -953,7 +954,7 @@ describe('Keyboard interactions', () => { ) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Verify popover is closed assertPopoverButton({ state: PopoverState.InvisibleUnmounted }) @@ -1425,7 +1426,7 @@ describe('Keyboard interactions', () => { ) // Focus the button of the Popover - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Open popover await click(getPopoverButton()) @@ -1675,7 +1676,7 @@ describe('Keyboard interactions', () => { assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Open popover await press(Keys.Space) @@ -1706,7 +1707,7 @@ describe('Keyboard interactions', () => { assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Try to open the popover await press(Keys.Space) @@ -1737,7 +1738,7 @@ describe('Keyboard interactions', () => { assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) // Focus the button - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Open popover await press(Keys.Space) @@ -1963,7 +1964,7 @@ describe('Mouse interactions', () => { ) - getPopoverButton()?.focus() + await focus(getPopoverButton()) // Open popover await click(getPopoverButton()) diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx index 0aee104afc..47816492db 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx @@ -1,10 +1,11 @@ import React, { createElement, useState } from 'react' + import { render } from '@testing-library/react' import { RadioGroup } from './radio-group' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' -import { press, Keys, shift, click } from '../../test-utils/interactions' +import { press, focus, Keys, shift, click } from '../../test-utils/interactions' import { getByText, assertRadioGroupLabel, @@ -55,657 +56,853 @@ describe('Safe guards', () => { }) describe('Rendering', () => { - it('should be possible to render a RadioGroup, where the first element is tabbable (value is undefined)', async () => { - render( - - Pizza Delivery - Pickup - Home delivery - Dine in - - ) - - expect(getRadioGroupOptions()).toHaveLength(3) - - assertFocusable(getByText('Pickup')) - assertNotFocusable(getByText('Home delivery')) - assertNotFocusable(getByText('Dine in')) - }) - - it('should be possible to render a RadioGroup, where the first element is tabbable (value is null)', async () => { - render( - - Pizza Delivery - Pickup - Home delivery - Dine in - - ) - - expect(getRadioGroupOptions()).toHaveLength(3) - - assertFocusable(getByText('Pickup')) - assertNotFocusable(getByText('Home delivery')) - assertNotFocusable(getByText('Dine in')) - }) - - it('should be possible to render a RadioGroup with an active value', async () => { - render( - - Pizza Delivery - Pickup - Home delivery - Dine in - - ) - - expect(getRadioGroupOptions()).toHaveLength(3) + it( + 'should be possible to render a RadioGroup, where the first element is tabbable (value is undefined)', + suppressConsoleLogs(async () => { + render( + + Pizza Delivery + Pickup + Home delivery + Dine in + + ) - assertNotFocusable(getByText('Pickup')) - assertFocusable(getByText('Home delivery')) - assertNotFocusable(getByText('Dine in')) - }) + expect(getRadioGroupOptions()).toHaveLength(3) - it('should guarantee the radio option order after a few unmounts', async () => { - function Example() { - let [showFirst, setShowFirst] = useState(false) - let [active, setActive] = useState() + assertFocusable(getByText('Pickup')) + assertNotFocusable(getByText('Home delivery')) + assertNotFocusable(getByText('Dine in')) + }) + ) - return ( - <> - - - Pizza Delivery - {showFirst && Pickup} - Home delivery - Dine in - - + it( + 'should be possible to render a RadioGroup, where the first element is tabbable (value is null)', + suppressConsoleLogs(async () => { + render( + + Pizza Delivery + Pickup + Home delivery + Dine in + ) - } - render() + expect(getRadioGroupOptions()).toHaveLength(3) - await click(getByText('Toggle')) // Render the pickup again + assertFocusable(getByText('Pickup')) + assertNotFocusable(getByText('Home delivery')) + assertNotFocusable(getByText('Dine in')) + }) + ) - await press(Keys.Tab) // Focus first element - assertActiveElement(getByText('Pickup')) + it( + 'should be possible to render a RadioGroup with an active value', + suppressConsoleLogs(async () => { + render( + + Pizza Delivery + Pickup + Home delivery + Dine in + + ) - await press(Keys.ArrowUp) // Loop around - assertActiveElement(getByText('Dine in')) + expect(getRadioGroupOptions()).toHaveLength(3) - await press(Keys.ArrowUp) // Up again - assertActiveElement(getByText('Home delivery')) - }) + assertNotFocusable(getByText('Pickup')) + assertFocusable(getByText('Home delivery')) + assertNotFocusable(getByText('Dine in')) + }) + ) - it('should be possible to disable a RadioGroup', async () => { - let changeFn = jest.fn() + it( + 'should guarantee the radio option order after a few unmounts', + suppressConsoleLogs(async () => { + function Example() { + let [showFirst, setShowFirst] = useState(false) + let [active, setActive] = useState() - function Example() { - let [disabled, setDisabled] = useState(true) - return ( - <> - - - Pizza Delivery - Pickup - Home delivery - Dine in - - {JSON.stringify} - - - - ) - } + return ( + <> + + + Pizza Delivery + {showFirst && Pickup} + Home delivery + Dine in + + + ) + } - render() + render() - // Try to click one a few options - await click(getByText('Pickup')) - await click(getByText('Dine in')) + await click(getByText('Toggle')) // Render the pickup again - // Verify that the RadioGroup.Option gets the disabled state - expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent( - JSON.stringify({ - checked: false, - disabled: true, - active: false, - }) - ) + await press(Keys.Tab) // Focus first element + assertActiveElement(getByText('Pickup')) - // Make sure that the onChange handler never got called - expect(changeFn).toHaveBeenCalledTimes(0) + await press(Keys.ArrowUp) // Loop around + assertActiveElement(getByText('Dine in')) - // Make sure that all the options get an `aria-disabled` - let options = getRadioGroupOptions() - expect(options).toHaveLength(4) - for (let option of options) expect(option).toHaveAttribute('aria-disabled', 'true') + await press(Keys.ArrowUp) // Up again + assertActiveElement(getByText('Home delivery')) + }) + ) - // Toggle the disabled state - await click(getByText('Toggle')) + it( + 'should be possible to disable a RadioGroup', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() - // Verify that the RadioGroup.Option gets the disabled state - expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent( - JSON.stringify({ - checked: false, - disabled: false, - active: false, - }) - ) + function Example() { + let [disabled, setDisabled] = useState(true) + return ( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + {JSON.stringify} + + + + ) + } - // Try to click one a few options - await click(getByText('Pickup')) + render() - // Make sure that the onChange handler got called - expect(changeFn).toHaveBeenCalledTimes(1) - }) + // Try to click one a few options + await click(getByText('Pickup')) + await click(getByText('Dine in')) + + // Verify that the RadioGroup.Option gets the disabled state + expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent( + JSON.stringify({ + checked: false, + disabled: true, + active: false, + }) + ) - it('should be possible to disable a RadioGroup.Option', async () => { - let changeFn = jest.fn() + // Make sure that the onChange handler never got called + expect(changeFn).toHaveBeenCalledTimes(0) - function Example() { - let [disabled, setDisabled] = useState(true) - return ( - <> - - - Pizza Delivery - Pickup - Home delivery - Dine in - - {JSON.stringify} - - - + // Make sure that all the options get an `aria-disabled` + let options = getRadioGroupOptions() + expect(options).toHaveLength(4) + for (let option of options) expect(option).toHaveAttribute('aria-disabled', 'true') + + // Toggle the disabled state + await click(getByText('Toggle')) + + // Verify that the RadioGroup.Option gets the disabled state + expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent( + JSON.stringify({ + checked: false, + disabled: false, + active: false, + }) ) - } - render() + // Try to click one a few options + await click(getByText('Pickup')) - // Try to click the disabled option - await click(document.querySelector('[data-value="render-prop"]')) + // Make sure that the onChange handler got called + expect(changeFn).toHaveBeenCalledTimes(1) + }) + ) - // Verify that the RadioGroup.Option gets the disabled state - expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent( - JSON.stringify({ - checked: false, - disabled: true, - active: false, - }) - ) + it( + 'should be possible to disable a RadioGroup.Option', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() - // Make sure that the onChange handler never got called - expect(changeFn).toHaveBeenCalledTimes(0) - - // Make sure that the option with value "render-prop" gets an `aria-disabled` - let options = getRadioGroupOptions() - expect(options).toHaveLength(4) - for (let option of options) { - if (option.dataset.value) { - expect(option).toHaveAttribute('aria-disabled', 'true') - } else { - expect(option).not.toHaveAttribute('aria-disabled') + function Example() { + let [disabled, setDisabled] = useState(true) + return ( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + {JSON.stringify} + + + + ) } - } - - // Toggle the disabled state - await click(getByText('Toggle')) - - // Verify that the RadioGroup.Option gets the disabled state - expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent( - JSON.stringify({ - checked: false, - disabled: false, - active: false, - }) - ) - // Try to click one a few options - await click(document.querySelector('[data-value="render-prop"]')) + render() - // Make sure that the onChange handler got called - expect(changeFn).toHaveBeenCalledTimes(1) - }) + // Try to click the disabled option + await click(document.querySelector('[data-value="render-prop"]')) - it('should guarantee the order of DOM nodes when performing actions', async () => { - function Example({ hide = false }) { - return ( - {}}> - Option 1 - {!hide && Option 2} - Option 3 - + // Verify that the RadioGroup.Option gets the disabled state + expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent( + JSON.stringify({ + checked: false, + disabled: true, + active: false, + }) ) - } - - let { rerender } = render() - // Focus the RadioGroup - await press(Keys.Tab) - - rerender() // Remove RadioGroup.Option 2 - rerender() // Re-add RadioGroup.Option 2 - - // Verify that the first radio group option is active - assertActiveElement(getByText('Option 1')) + // Make sure that the onChange handler never got called + expect(changeFn).toHaveBeenCalledTimes(0) - await press(Keys.ArrowDown) - // Verify that the second radio group option is active - assertActiveElement(getByText('Option 2')) + // Make sure that the option with value "render-prop" gets an `aria-disabled` + let options = getRadioGroupOptions() + expect(options).toHaveLength(4) + for (let option of options) { + if (option.dataset.value) { + expect(option).toHaveAttribute('aria-disabled', 'true') + } else { + expect(option).not.toHaveAttribute('aria-disabled') + } + } - await press(Keys.ArrowDown) - // Verify that the third radio group option is active - assertActiveElement(getByText('Option 3')) - }) -}) + // Toggle the disabled state + await click(getByText('Toggle')) -describe('Keyboard interactions', () => { - describe('`Tab` key', () => { - it('should be possible to tab to the first item', async () => { - render( - - Pizza Delivery - Pickup - Home delivery - Dine in - + // Verify that the RadioGroup.Option gets the disabled state + expect(document.querySelector('[data-value="render-prop"]')).toHaveTextContent( + JSON.stringify({ + checked: false, + disabled: false, + active: false, + }) ) - await press(Keys.Tab) + // Try to click one a few options + await click(document.querySelector('[data-value="render-prop"]')) - assertActiveElement(getByText('Pickup')) + // Make sure that the onChange handler got called + expect(changeFn).toHaveBeenCalledTimes(1) }) + ) - it('should not change the selected element on focus', async () => { - let changeFn = jest.fn() - render( - - Pizza Delivery - Pickup - Home delivery - Dine in - - ) - - await press(Keys.Tab) - - assertActiveElement(getByText('Pickup')) - - expect(changeFn).toHaveBeenCalledTimes(0) - }) + it( + 'should guarantee the order of DOM nodes when performing actions', + suppressConsoleLogs(async () => { + function Example({ hide = false }) { + return ( + {}}> + Option 1 + {!hide && Option 2} + Option 3 + + ) + } - it('should be possible to tab to the active item', async () => { - render( - - Pizza Delivery - Pickup - Home delivery - Dine in - - ) + let { rerender } = render() + // Focus the RadioGroup await press(Keys.Tab) - assertActiveElement(getByText('Home delivery')) - }) - - it('should not change the selected element on focus (when selecting the active item)', async () => { - let changeFn = jest.fn() - render( - - Pizza Delivery - Pickup - Home delivery - Dine in - - ) + rerender() // Remove RadioGroup.Option 2 + rerender() // Re-add RadioGroup.Option 2 - await press(Keys.Tab) + // Verify that the first radio group option is active + assertActiveElement(getByText('Option 1')) - assertActiveElement(getByText('Home delivery')) + await press(Keys.ArrowDown) + // Verify that the second radio group option is active + assertActiveElement(getByText('Option 2')) - expect(changeFn).toHaveBeenCalledTimes(0) + await press(Keys.ArrowDown) + // Verify that the third radio group option is active + assertActiveElement(getByText('Option 3')) }) + ) +}) - it('should be possible to tab out of the radio group (no selected value)', async () => { - render( - <> - +describe('Keyboard interactions', () => { + describe('`Tab` key', () => { + it( + 'should be possible to tab to the first item', + suppressConsoleLogs(async () => { + render( Pizza Delivery Pickup Home delivery Dine in - - - ) - - await press(Keys.Tab) - assertActiveElement(getByText('Before')) + ) - await press(Keys.Tab) - assertActiveElement(getByText('Pickup')) + await press(Keys.Tab) - await press(Keys.Tab) - assertActiveElement(getByText('After')) - }) + assertActiveElement(getByText('Pickup')) + }) + ) - it('should be possible to tab out of the radio group (selected value)', async () => { - render( - <> - - + it( + 'should not change the selected element on focus', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() + render( + Pizza Delivery Pickup Home delivery Dine in - - - ) + ) - await press(Keys.Tab) - assertActiveElement(getByText('Before')) + await press(Keys.Tab) - await press(Keys.Tab) - assertActiveElement(getByText('Home delivery')) + assertActiveElement(getByText('Pickup')) - await press(Keys.Tab) - assertActiveElement(getByText('After')) - }) - }) + expect(changeFn).toHaveBeenCalledTimes(0) + }) + ) - describe('`Shift+Tab` key', () => { - it('should be possible to tab to the first item', async () => { - render( - <> - + it( + 'should be possible to tab to the active item', + suppressConsoleLogs(async () => { + render( + Pizza Delivery Pickup Home delivery Dine in - - - ) - - getByText('After')?.focus() + ) - await press(shift(Keys.Tab)) + await press(Keys.Tab) - assertActiveElement(getByText('Pickup')) - }) + assertActiveElement(getByText('Home delivery')) + }) + ) - it('should not change the selected element on focus', async () => { - let changeFn = jest.fn() - render( - <> - + it( + 'should not change the selected element on focus (when selecting the active item)', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() + render( + Pizza Delivery Pickup Home delivery Dine in - - - ) + ) - getByText('After')?.focus() + await press(Keys.Tab) - await press(shift(Keys.Tab)) + assertActiveElement(getByText('Home delivery')) - assertActiveElement(getByText('Pickup')) + expect(changeFn).toHaveBeenCalledTimes(0) + }) + ) - expect(changeFn).toHaveBeenCalledTimes(0) - }) + it( + 'should be possible to tab out of the radio group (no selected value)', + suppressConsoleLogs(async () => { + render( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) - it('should be possible to tab to the active item', async () => { - render( - <> - - Pizza Delivery - Pickup - Home delivery - Dine in - - - - ) + await press(Keys.Tab) + assertActiveElement(getByText('Before')) - getByText('After')?.focus() + await press(Keys.Tab) + assertActiveElement(getByText('Pickup')) - await press(shift(Keys.Tab)) + await press(Keys.Tab) + assertActiveElement(getByText('After')) + }) + ) - assertActiveElement(getByText('Home delivery')) - }) + it( + 'should be possible to tab out of the radio group (selected value)', + suppressConsoleLogs(async () => { + render( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) - it('should not change the selected element on focus (when selecting the active item)', async () => { - let changeFn = jest.fn() - render( - <> - - Pizza Delivery - Pickup - Home delivery - Dine in - - - - ) + await press(Keys.Tab) + assertActiveElement(getByText('Before')) - getByText('After')?.focus() + await press(Keys.Tab) + assertActiveElement(getByText('Home delivery')) - await press(shift(Keys.Tab)) + await press(Keys.Tab) + assertActiveElement(getByText('After')) + }) + ) + }) - assertActiveElement(getByText('Home delivery')) + describe('`Shift+Tab` key', () => { + it( + 'should be possible to tab to the first item', + suppressConsoleLogs(async () => { + render( + <> + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) - expect(changeFn).toHaveBeenCalledTimes(0) - }) + await focus(getByText('After')) - it('should be possible to tab out of the radio group (no selected value)', async () => { - render( - <> - - - Pizza Delivery - Pickup - Home delivery - Dine in - - - - ) + await press(shift(Keys.Tab)) - getByText('After')?.focus() + assertActiveElement(getByText('Pickup')) + }) + ) - await press(shift(Keys.Tab)) - assertActiveElement(getByText('Pickup')) + it( + 'should not change the selected element on focus', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() + render( + <> + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) - await press(shift(Keys.Tab)) - assertActiveElement(getByText('Before')) - }) + await focus(getByText('After')) - it('should be possible to tab out of the radio group (selected value)', async () => { - render( - <> - - - Pizza Delivery - Pickup - Home delivery - Dine in - - - - ) + await press(shift(Keys.Tab)) - getByText('After')?.focus() + assertActiveElement(getByText('Pickup')) - await press(shift(Keys.Tab)) - assertActiveElement(getByText('Home delivery')) + expect(changeFn).toHaveBeenCalledTimes(0) + }) + ) - await press(shift(Keys.Tab)) - assertActiveElement(getByText('Before')) - }) + it( + 'should be possible to tab to the active item', + suppressConsoleLogs(async () => { + render( + <> + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) + + await focus(getByText('After')) + + await press(shift(Keys.Tab)) + + assertActiveElement(getByText('Home delivery')) + }) + ) + + it( + 'should not change the selected element on focus (when selecting the active item)', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() + render( + <> + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) + + await focus(getByText('After')) + + await press(shift(Keys.Tab)) + + assertActiveElement(getByText('Home delivery')) + + expect(changeFn).toHaveBeenCalledTimes(0) + }) + ) + + it( + 'should be possible to tab out of the radio group (no selected value)', + suppressConsoleLogs(async () => { + render( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) + + await focus(getByText('After')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Pickup')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Before')) + }) + ) + + it( + 'should be possible to tab out of the radio group (selected value)', + suppressConsoleLogs(async () => { + render( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) + + await focus(getByText('After')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Home delivery')) + + await press(shift(Keys.Tab)) + assertActiveElement(getByText('Before')) + }) + ) }) describe('`ArrowLeft` key', () => { - it('should go to the previous item when pressing the ArrowLeft key', async () => { - let changeFn = jest.fn() - render( - <> - - - Pizza Delivery - Pickup - Home delivery - Dine in - - - - ) + it( + 'should go to the previous item when pressing the ArrowLeft key', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() + render( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) - // Focus the "Before" button - await press(Keys.Tab) + // Focus the "Before" button + await press(Keys.Tab) - // Focus the RadioGroup - await press(Keys.Tab) + // Focus the RadioGroup + await press(Keys.Tab) - assertActiveElement(getByText('Pickup')) + assertActiveElement(getByText('Pickup')) - await press(Keys.ArrowLeft) // Loop around - assertActiveElement(getByText('Dine in')) + await press(Keys.ArrowLeft) // Loop around + assertActiveElement(getByText('Dine in')) - await press(Keys.ArrowLeft) - assertActiveElement(getByText('Home delivery')) + await press(Keys.ArrowLeft) + assertActiveElement(getByText('Home delivery')) - expect(changeFn).toHaveBeenCalledTimes(2) - expect(changeFn).toHaveBeenNthCalledWith(1, 'dine-in') - expect(changeFn).toHaveBeenNthCalledWith(2, 'home-delivery') - }) + expect(changeFn).toHaveBeenCalledTimes(2) + expect(changeFn).toHaveBeenNthCalledWith(1, 'dine-in') + expect(changeFn).toHaveBeenNthCalledWith(2, 'home-delivery') + }) + ) }) describe('`ArrowUp` key', () => { - it('should go to the previous item when pressing the ArrowUp key', async () => { - let changeFn = jest.fn() - render( - <> - - - Pizza Delivery - Pickup - Home delivery - Dine in - - - - ) + it( + 'should go to the previous item when pressing the ArrowUp key', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() + render( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) - // Focus the "Before" button - await press(Keys.Tab) + // Focus the "Before" button + await press(Keys.Tab) - // Focus the RadioGroup - await press(Keys.Tab) + // Focus the RadioGroup + await press(Keys.Tab) - assertActiveElement(getByText('Pickup')) + assertActiveElement(getByText('Pickup')) - await press(Keys.ArrowUp) // Loop around - assertActiveElement(getByText('Dine in')) + await press(Keys.ArrowUp) // Loop around + assertActiveElement(getByText('Dine in')) - await press(Keys.ArrowUp) - assertActiveElement(getByText('Home delivery')) + await press(Keys.ArrowUp) + assertActiveElement(getByText('Home delivery')) - expect(changeFn).toHaveBeenCalledTimes(2) - expect(changeFn).toHaveBeenNthCalledWith(1, 'dine-in') - expect(changeFn).toHaveBeenNthCalledWith(2, 'home-delivery') - }) + expect(changeFn).toHaveBeenCalledTimes(2) + expect(changeFn).toHaveBeenNthCalledWith(1, 'dine-in') + expect(changeFn).toHaveBeenNthCalledWith(2, 'home-delivery') + }) + ) }) describe('`ArrowRight` key', () => { - it('should go to the next item when pressing the ArrowRight key', async () => { - let changeFn = jest.fn() - render( - <> - - - Pizza Delivery - Pickup - Home delivery - Dine in - - - - ) + it( + 'should go to the next item when pressing the ArrowRight key', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() + render( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) - // Focus the "Before" button - await press(Keys.Tab) + // Focus the "Before" button + await press(Keys.Tab) - // Focus the RadioGroup - await press(Keys.Tab) + // Focus the RadioGroup + await press(Keys.Tab) - assertActiveElement(getByText('Pickup')) + assertActiveElement(getByText('Pickup')) - await press(Keys.ArrowRight) - assertActiveElement(getByText('Home delivery')) + await press(Keys.ArrowRight) + assertActiveElement(getByText('Home delivery')) - await press(Keys.ArrowRight) - assertActiveElement(getByText('Dine in')) + await press(Keys.ArrowRight) + assertActiveElement(getByText('Dine in')) - await press(Keys.ArrowRight) // Loop around - assertActiveElement(getByText('Pickup')) + await press(Keys.ArrowRight) // Loop around + assertActiveElement(getByText('Pickup')) - expect(changeFn).toHaveBeenCalledTimes(3) - expect(changeFn).toHaveBeenNthCalledWith(1, 'home-delivery') - expect(changeFn).toHaveBeenNthCalledWith(2, 'dine-in') - expect(changeFn).toHaveBeenNthCalledWith(3, 'pickup') - }) + expect(changeFn).toHaveBeenCalledTimes(3) + expect(changeFn).toHaveBeenNthCalledWith(1, 'home-delivery') + expect(changeFn).toHaveBeenNthCalledWith(2, 'dine-in') + expect(changeFn).toHaveBeenNthCalledWith(3, 'pickup') + }) + ) }) describe('`ArrowDown` key', () => { - it('should go to the next item when pressing the ArrowDown key', async () => { - let changeFn = jest.fn() - render( - <> - - - Pizza Delivery - Pickup - Home delivery - Dine in - - - - ) + it( + 'should go to the next item when pressing the ArrowDown key', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() + render( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) - // Focus the "Before" button - await press(Keys.Tab) + // Focus the "Before" button + await press(Keys.Tab) - // Focus the RadioGroup - await press(Keys.Tab) + // Focus the RadioGroup + await press(Keys.Tab) - assertActiveElement(getByText('Pickup')) + assertActiveElement(getByText('Pickup')) - await press(Keys.ArrowDown) - assertActiveElement(getByText('Home delivery')) + await press(Keys.ArrowDown) + assertActiveElement(getByText('Home delivery')) - await press(Keys.ArrowDown) - assertActiveElement(getByText('Dine in')) + await press(Keys.ArrowDown) + assertActiveElement(getByText('Dine in')) - await press(Keys.ArrowDown) // Loop around - assertActiveElement(getByText('Pickup')) + await press(Keys.ArrowDown) // Loop around + assertActiveElement(getByText('Pickup')) - expect(changeFn).toHaveBeenCalledTimes(3) - expect(changeFn).toHaveBeenNthCalledWith(1, 'home-delivery') - expect(changeFn).toHaveBeenNthCalledWith(2, 'dine-in') - expect(changeFn).toHaveBeenNthCalledWith(3, 'pickup') - }) + expect(changeFn).toHaveBeenCalledTimes(3) + expect(changeFn).toHaveBeenNthCalledWith(1, 'home-delivery') + expect(changeFn).toHaveBeenNthCalledWith(2, 'dine-in') + expect(changeFn).toHaveBeenNthCalledWith(3, 'pickup') + }) + ) }) describe('`Space` key', () => { - it('should select the current option when pressing space', async () => { + it( + 'should select the current option when pressing space', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() + render( + <> + + + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) + + // Focus the "Before" button + await press(Keys.Tab) + + // Focus the RadioGroup + await press(Keys.Tab) + + assertActiveElement(getByText('Pickup')) + + await press(Keys.Space) + assertActiveElement(getByText('Pickup')) + + expect(changeFn).toHaveBeenCalledTimes(1) + expect(changeFn).toHaveBeenNthCalledWith(1, 'pickup') + }) + ) + + it( + 'should select the current option only once when pressing space', + suppressConsoleLogs(async () => { + let changeFn = jest.fn() + function Example() { + let [value, setValue] = useState(undefined) + + return ( + <> + + { + setValue(v) + changeFn(v) + }} + > + Pizza Delivery + Pickup + Home delivery + Dine in + + + + ) + } + render() + + // Focus the "Before" button + await press(Keys.Tab) + + // Focus the RadioGroup + await press(Keys.Tab) + + assertActiveElement(getByText('Pickup')) + + await press(Keys.Space) + await press(Keys.Space) + await press(Keys.Space) + await press(Keys.Space) + await press(Keys.Space) + assertActiveElement(getByText('Pickup')) + + expect(changeFn).toHaveBeenCalledTimes(1) + expect(changeFn).toHaveBeenNthCalledWith(1, 'pickup') + }) + ) + }) + + describe('`Enter`', () => { + it( + 'should submit the form on `Enter`', + suppressConsoleLogs(async () => { + let submits = jest.fn() + + function Example() { + let [value, setValue] = useState('bob') + + return ( +
{ + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + Alice + Bob + Charlie + + +
+ ) + } + + render() + + // Focus the RadioGroup + await press(Keys.Tab) + + // Press enter (which should submit the form) + await press(Keys.Enter) + + // Verify the form was submitted + expect(submits).toHaveBeenCalledTimes(1) + expect(submits).toHaveBeenCalledWith([['option', 'bob']]) + }) + ) + }) +}) + +describe('Mouse interactions', () => { + it( + 'should be possible to change the current radio group value when clicking on a radio option', + suppressConsoleLogs(async () => { let changeFn = jest.fn() render( <> @@ -720,22 +917,17 @@ describe('Keyboard interactions', () => { ) - // Focus the "Before" button - await press(Keys.Tab) - - // Focus the RadioGroup - await press(Keys.Tab) + await click(getByText('Home delivery')) - assertActiveElement(getByText('Pickup')) - - await press(Keys.Space) - assertActiveElement(getByText('Pickup')) + assertActiveElement(getByText('Home delivery')) - expect(changeFn).toHaveBeenCalledTimes(1) - expect(changeFn).toHaveBeenNthCalledWith(1, 'pickup') + expect(changeFn).toHaveBeenNthCalledWith(1, 'home-delivery') }) + ) - it('should select the current option only once when pressing space', async () => { + it( + 'should be a no-op when clicking on the same item', + suppressConsoleLogs(async () => { let changeFn = jest.fn() function Example() { let [value, setValue] = useState(undefined) @@ -761,33 +953,26 @@ describe('Keyboard interactions', () => { } render() - // Focus the "Before" button - await press(Keys.Tab) - - // Focus the RadioGroup - await press(Keys.Tab) + await click(getByText('Home delivery')) + await click(getByText('Home delivery')) + await click(getByText('Home delivery')) + await click(getByText('Home delivery')) - assertActiveElement(getByText('Pickup')) - - await press(Keys.Space) - await press(Keys.Space) - await press(Keys.Space) - await press(Keys.Space) - await press(Keys.Space) - assertActiveElement(getByText('Pickup')) + assertActiveElement(getByText('Home delivery')) expect(changeFn).toHaveBeenCalledTimes(1) - expect(changeFn).toHaveBeenNthCalledWith(1, 'pickup') }) - }) + ) +}) - describe('`Enter`', () => { - it('should submit the form on `Enter`', async () => { +describe('Form compatibility', () => { + it( + 'should be possible to submit a form with a value', + suppressConsoleLogs(async () => { let submits = jest.fn() function Example() { - let [value, setValue] = useState('bob') - + let [value, setValue] = useState(null) return (
{ @@ -795,10 +980,11 @@ describe('Keyboard interactions', () => { submits([...new FormData(event.currentTarget).entries()]) }} > - - Alice - Bob - Charlie + + Pizza Delivery + Pickup + Home delivery + Dine in @@ -807,215 +993,120 @@ describe('Keyboard interactions', () => { render() - // Focus the RadioGroup - await press(Keys.Tab) - - // Press enter (which should submit the form) - await press(Keys.Enter) + // Submit the form + await click(getByText('Submit')) - // Verify the form was submitted - expect(submits).toHaveBeenCalledTimes(1) - expect(submits).toHaveBeenCalledWith([['option', 'bob']]) - }) - }) -}) + // Verify that the form has been submitted + expect(submits).lastCalledWith([]) // no data -describe('Mouse interactions', () => { - it('should be possible to change the current radio group value when clicking on a radio option', async () => { - let changeFn = jest.fn() - render( - <> - - - Pizza Delivery - Pickup - Home delivery - Dine in - - - - ) + // Choose home delivery + await click(getByText('Home delivery')) - await click(getByText('Home delivery')) + // Submit the form again + await click(getByText('Submit')) - assertActiveElement(getByText('Home delivery')) + // Verify that the form has been submitted + expect(submits).lastCalledWith([['delivery', 'home-delivery']]) - expect(changeFn).toHaveBeenNthCalledWith(1, 'home-delivery') - }) + // Choose pickup + await click(getByText('Pickup')) - it('should be a no-op when clicking on the same item', async () => { - let changeFn = jest.fn() - function Example() { - let [value, setValue] = useState(undefined) + // Submit the form again + await click(getByText('Submit')) - return ( - <> - - { - setValue(v) - changeFn(v) - }} - > - Pizza Delivery - Pickup - Home delivery - Dine in - - - - ) - } - render() - - await click(getByText('Home delivery')) - await click(getByText('Home delivery')) - await click(getByText('Home delivery')) - await click(getByText('Home delivery')) - - assertActiveElement(getByText('Home delivery')) - - expect(changeFn).toHaveBeenCalledTimes(1) - }) -}) - -describe('Form compatibility', () => { - it('should be possible to submit a form with a value', async () => { - let submits = jest.fn() - - function Example() { - let [value, setValue] = useState(null) - return ( -
{ - event.preventDefault() - submits([...new FormData(event.currentTarget).entries()]) - }} - > - - Pizza Delivery - Pickup - Home delivery - Dine in - - -
- ) - } - - render() - - // Submit the form - await click(getByText('Submit')) - - // Verify that the form has been submitted - expect(submits).lastCalledWith([]) // no data - - // Choose home delivery - await click(getByText('Home delivery')) - - // Submit the form again - await click(getByText('Submit')) + // Verify that the form has been submitted + expect(submits).lastCalledWith([['delivery', 'pickup']]) + }) + ) - // Verify that the form has been submitted - expect(submits).lastCalledWith([['delivery', 'home-delivery']]) + it( + 'should be possible to submit a form with a complex value object', + suppressConsoleLogs(async () => { + let submits = jest.fn() + let options = [ + { + id: 1, + value: 'pickup', + label: 'Pickup', + extra: { info: 'Some extra info' }, + }, + { + id: 2, + value: 'home-delivery', + label: 'Home delivery', + extra: { info: 'Some extra info' }, + }, + { + id: 3, + value: 'dine-in', + label: 'Dine in', + extra: { info: 'Some extra info' }, + }, + ] - // Choose pickup - await click(getByText('Pickup')) + function Example() { + let [value, setValue] = useState(options[0]) - // Submit the form again - await click(getByText('Submit')) + return ( +
{ + event.preventDefault() + submits([...new FormData(event.currentTarget).entries()]) + }} + > + + Pizza Delivery + {options.map((option) => ( + + {option.label} + + ))} + + +
+ ) + } - // Verify that the form has been submitted - expect(submits).lastCalledWith([['delivery', 'pickup']]) - }) + render() - it('should be possible to submit a form with a complex value object', async () => { - let submits = jest.fn() - let options = [ - { - id: 1, - value: 'pickup', - label: 'Pickup', - extra: { info: 'Some extra info' }, - }, - { - id: 2, - value: 'home-delivery', - label: 'Home delivery', - extra: { info: 'Some extra info' }, - }, - { - id: 3, - value: 'dine-in', - label: 'Dine in', - extra: { info: 'Some extra info' }, - }, - ] - - function Example() { - let [value, setValue] = useState(options[0]) - - return ( -
{ - event.preventDefault() - submits([...new FormData(event.currentTarget).entries()]) - }} - > - - Pizza Delivery - {options.map((option) => ( - - {option.label} - - ))} - - -
- ) - } - - render() - - // Submit the form - await click(getByText('Submit')) - - // Verify that the form has been submitted - expect(submits).lastCalledWith([ - ['delivery[id]', '1'], - ['delivery[value]', 'pickup'], - ['delivery[label]', 'Pickup'], - ['delivery[extra][info]', 'Some extra info'], - ]) - - // Choose home delivery - await click(getByText('Home delivery')) - - // Submit the form again - await click(getByText('Submit')) - - // Verify that the form has been submitted - expect(submits).lastCalledWith([ - ['delivery[id]', '2'], - ['delivery[value]', 'home-delivery'], - ['delivery[label]', 'Home delivery'], - ['delivery[extra][info]', 'Some extra info'], - ]) - - // Choose pickup - await click(getByText('Pickup')) - - // Submit the form again - await click(getByText('Submit')) - - // Verify that the form has been submitted - expect(submits).lastCalledWith([ - ['delivery[id]', '1'], - ['delivery[value]', 'pickup'], - ['delivery[label]', 'Pickup'], - ['delivery[extra][info]', 'Some extra info'], - ]) - }) + // Submit the form + await click(getByText('Submit')) + + // Verify that the form has been submitted + expect(submits).lastCalledWith([ + ['delivery[id]', '1'], + ['delivery[value]', 'pickup'], + ['delivery[label]', 'Pickup'], + ['delivery[extra][info]', 'Some extra info'], + ]) + + // Choose home delivery + await click(getByText('Home delivery')) + + // Submit the form again + await click(getByText('Submit')) + + // Verify that the form has been submitted + expect(submits).lastCalledWith([ + ['delivery[id]', '2'], + ['delivery[value]', 'home-delivery'], + ['delivery[label]', 'Home delivery'], + ['delivery[extra][info]', 'Some extra info'], + ]) + + // Choose pickup + await click(getByText('Pickup')) + + // Submit the form again + await click(getByText('Submit')) + + // Verify that the form has been submitted + expect(submits).lastCalledWith([ + ['delivery[id]', '1'], + ['delivery[value]', 'pickup'], + ['delivery[label]', 'Pickup'], + ['delivery[extra][info]', 'Some extra info'], + ]) + }) + ) }) diff --git a/packages/@headlessui-react/src/components/switch/switch.test.tsx b/packages/@headlessui-react/src/components/switch/switch.test.tsx index 28cd20b8fd..7b66e9175a 100644 --- a/packages/@headlessui-react/src/components/switch/switch.test.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.test.tsx @@ -10,7 +10,7 @@ import { getSwitchLabel, getByText, } from '../../test-utils/accessibility-assertions' -import { press, click, Keys } from '../../test-utils/interactions' +import { press, click, focus, Keys } from '../../test-utils/interactions' jest.mock('../../hooks/use-id') @@ -229,7 +229,7 @@ describe('Keyboard interactions', () => { assertSwitch({ state: SwitchState.Off }) // Focus the switch - getSwitch()?.focus() + await focus(getSwitch()) // Toggle await press(Keys.Space) @@ -254,7 +254,7 @@ describe('Keyboard interactions', () => { assertSwitch({ state: SwitchState.Off }) // Focus the switch - getSwitch()?.focus() + await focus(getSwitch()) // Try to toggle await press(Keys.Enter) @@ -284,7 +284,7 @@ describe('Keyboard interactions', () => { render() // Focus the input field - getSwitch()?.focus() + await focus(getSwitch()) assertActiveElement(getSwitch()) // Press enter (which should submit the form) @@ -309,7 +309,7 @@ describe('Keyboard interactions', () => { assertSwitch({ state: SwitchState.Off }) // Focus the switch - getSwitch()?.focus() + await focus(getSwitch()) // Expect the switch to be active assertActiveElement(getSwitch()) diff --git a/packages/@headlessui-react/src/components/transitions/transition.test.tsx b/packages/@headlessui-react/src/components/transitions/transition.test.tsx index 8dfcc004c4..da9ff9225f 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.test.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.test.tsx @@ -474,7 +474,7 @@ describe('Transitions', () => { Render 3: Transition took at least 50ms (yes) - class=\\"enter to\\" - + class=\\"\\"" + + class=\\"to\\"" `) }) @@ -525,7 +525,7 @@ describe('Transitions', () => { Render 3: Transition took at least 50ms (yes) - class=\\"enter to\\" - + class=\\"\\"" + + class=\\"to\\"" `) }) @@ -573,7 +573,7 @@ describe('Transitions', () => { Render 3: Transition took at least 50ms (yes) - class=\\"enter to\\" - + class=\\"\\"" + + class=\\"to\\"" `) }) @@ -622,7 +622,7 @@ describe('Transitions', () => { Render 3: Transition took at least 50ms (yes) - class=\\"enter to\\" - + class=\\"\\"" + + class=\\"to\\"" `) }) @@ -724,7 +724,7 @@ describe('Transitions', () => { Render 3: Transition took at least 50ms (yes) - class=\\"leave to\\" - + class=\\"\\" + + class=\\"to\\" + hidden=\\"\\" + style=\\"display: none;\\"" `) @@ -794,10 +794,10 @@ describe('Transitions', () => { Render 3: Transition took at least 50ms (yes) - class=\\"enter enter-to\\" - + class=\\"\\" + + class=\\"enter-to\\" Render 4: - - class=\\"\\" + - class=\\"enter-to\\" + class=\\"leave leave-from\\" Render 5: @@ -883,10 +883,10 @@ describe('Transitions', () => { Render 3: Transition took at least 50ms (yes) - class=\\"enter enter-to\\" - + class=\\"\\" + + class=\\"enter-to\\" Render 4: - - class=\\"\\" + - class=\\"enter-to\\" + class=\\"leave leave-from\\" Render 5: @@ -896,12 +896,12 @@ describe('Transitions', () => { Render 6: Transition took at least 75ms (yes) - class=\\"leave leave-to\\" - style=\\"\\" - + class=\\"\\" + + class=\\"leave-to\\" + hidden=\\"\\" + style=\\"display: none;\\" Render 7: - - class=\\"\\" + - class=\\"leave-to\\" - hidden=\\"\\" - style=\\"display: none;\\" + class=\\"enter enter-from\\" @@ -913,7 +913,7 @@ describe('Transitions', () => { Render 9: Transition took at least 75ms (yes) - class=\\"enter enter-to\\" - + class=\\"\\"" + + class=\\"enter-to\\"" `) }) ) @@ -1174,10 +1174,10 @@ describe('Events', () => { Render 3: Transition took at least 50ms (yes) - class=\\"enter enter-to\\" - + class=\\"\\" + + class=\\"enter-to\\" Render 4: - - class=\\"\\" + - class=\\"enter-to\\" + class=\\"leave leave-from\\" Render 5: diff --git a/packages/@headlessui-react/src/components/transitions/transition.tsx b/packages/@headlessui-react/src/components/transitions/transition.tsx index ce5d5b0b91..1fd22cbf1c 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.tsx @@ -13,13 +13,6 @@ import React, { Ref, } 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 { Features, forwardRefWithAs, @@ -27,19 +20,21 @@ import { render, RenderStrategy, } from '../../utils/render' -import { Reason, transition } from './utils/transition' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' +import { match } from '../../utils/match' +import { microTask } from '../../utils/micro-task' +import { useId } from '../../hooks/use-id' +import { useIsMounted } from '../../hooks/use-is-mounted' +import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' +import { useLatestValue } from '../../hooks/use-latest-value' import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete' import { useSyncRefs } from '../../hooks/use-sync-refs' -import { useLatestValue } from '../../hooks/use-latest-value' +import { useTransition } from '../../hooks/use-transition' type ID = ReturnType -function useSplitClasses(classes: string = '') { - return useMemo( - () => classes.split(' ').filter((className) => className.trim().length > 1), - [classes] - ) +function splitClasses(classes: string = '') { + return classes.split(' ').filter((className) => className.trim().length > 1) } interface TransitionContextValues { @@ -135,9 +130,11 @@ function useNesting(done?: () => void) { }, }) - if (!hasChildren(transitionableChildren) && mounted.current) { - doneRef.current?.() - } + microTask(() => { + if (!hasChildren(transitionableChildren) && mounted.current) { + doneRef.current?.() + } + }) }) let register = useLatestValue((childId: ID) => { @@ -220,23 +217,23 @@ let TransitionChild = forwardRefWithAs(function TransitionChild< let id = useId() - let isTransitioning = useRef(false) + let transitionInFlight = useRef(false) let nesting = useNesting(() => { // When all children have been unmounted we can only hide ourselves if and only if we are not // transitioning ourselves. Otherwise we would unmount before the transitions are finished. - if (!isTransitioning.current) { + if (!transitionInFlight.current) { setState(TreeStates.Hidden) unregister.current(id) } }) - useIsoMorphicEffect(() => { + useEffect(() => { if (!id) return return register.current(id) }, [register, id]) - useIsoMorphicEffect(() => { + useEffect(() => { // If we are in another mode than the Hidden mode then ignore if (strategy !== RenderStrategy.Hidden) return if (!id) return @@ -253,16 +250,15 @@ let TransitionChild = forwardRefWithAs(function TransitionChild< }) }, [state, id, register, unregister, show, strategy]) - let enterClasses = useSplitClasses(enter) - let enterFromClasses = useSplitClasses(enterFrom) - let enterToClasses = useSplitClasses(enterTo) - - let enteredClasses = useSplitClasses(entered) - - let leaveClasses = useSplitClasses(leave) - let leaveFromClasses = useSplitClasses(leaveFrom) - let leaveToClasses = useSplitClasses(leaveTo) - + let classes = useLatestValue({ + enter: splitClasses(enter), + enterFrom: splitClasses(enterFrom), + enterTo: splitClasses(enterTo), + entered: splitClasses(entered), + leave: splitClasses(leave), + leaveFrom: splitClasses(leaveFrom), + leaveTo: splitClasses(leaveTo), + }) let events = useEvents({ beforeEnter, afterEnter, beforeLeave, afterLeave }) let ready = useServerHandoffComplete() @@ -276,69 +272,30 @@ let TransitionChild = forwardRefWithAs(function TransitionChild< // Skipping initial transition let skip = initial && !appear - useIsoMorphicEffect(() => { - let node = container.current - if (!node) return - if (!ready) return - if (skip) return - if (show === prevShow.current) return - - isTransitioning.current = true - - if (show) events.current.beforeEnter() - if (!show) events.current.beforeLeave() - - return show - ? transition( - node, - enterClasses, - enterFromClasses, - enterToClasses, - enteredClasses, - (reason) => { - isTransitioning.current = false - if (reason === Reason.Finished) events.current.afterEnter() - } - ) - : transition( - node, - leaveClasses, - leaveFromClasses, - leaveToClasses, - enteredClasses, - (reason) => { - isTransitioning.current = false - - if (reason !== Reason.Finished) return - - // When we don't have children anymore we can safely unregister from the parent and hide - // ourselves. - if (!hasChildren(nesting)) { - setState(TreeStates.Hidden) - unregister.current(id) - events.current.afterLeave() - } - } - ) - }, [ - events, - id, - isTransitioning, - unregister, - nesting, + let transitionDirection = (() => { + if (!ready) return 'idle' + if (skip) return 'idle' + if (prevShow.current === show) return 'idle' + return show ? 'enter' : 'leave' + })() as 'enter' | 'leave' | 'idle' + + useTransition({ container, - skip, - show, - ready, - enterClasses, - enterFromClasses, - enterToClasses, - leaveClasses, - leaveFromClasses, - leaveToClasses, - ]) + classes, + events, + direction: transitionDirection, + onStart: useLatestValue(() => {}), + onStop: useLatestValue((direction) => { + if (direction === 'leave' && !hasChildren(nesting)) { + // When we don't have children anymore we can safely unregister from the parent and hide + // ourselves. + setState(TreeStates.Hidden) + unregister.current(id) + } + }), + }) - useIsoMorphicEffect(() => { + useEffect(() => { if (!skip) return if (strategy === RenderStrategy.Hidden) { @@ -379,6 +336,9 @@ let TransitionRoot = forwardRefWithAs(function Transition< let { show, appear = false, unmount, ...theirProps } = props as typeof props let transitionRef = useSyncRefs(ref) + // The TransitionChild will also call this hook, and we have to make sure that we are ready. + useServerHandoffComplete() + let usesOpenClosedState = useOpenClosed() if (show === undefined && usesOpenClosedState !== null) { @@ -398,7 +358,23 @@ let TransitionRoot = forwardRefWithAs(function Transition< setState(TreeStates.Hidden) }) - let initial = useIsInitialRender() + let [initial, setInitial] = useState(true) + + // Change the `initial` value + let changes = useRef([show]) + useIsoMorphicEffect(() => { + // We can skip this effect + if (initial === false) { + return + } + + // Track the changes + if (changes.current.at(-1) !== show) { + changes.current.push(show) + setInitial(false) + } + }, [changes, show]) + let transitionBag = useMemo( () => ({ show: show as boolean, appear, initial }), [show, appear, initial] @@ -440,10 +416,14 @@ function Child( let hasTransitionContext = useContext(TransitionContext) !== null let hasOpenClosedContext = useOpenClosed() !== null - return !hasTransitionContext && hasOpenClosedContext ? ( - - ) : ( - + return ( + <> + {!hasTransitionContext && hasOpenClosedContext ? ( + + ) : ( + + )} + ) } diff --git a/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts b/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts index 58cd91a0aa..65230b0621 100644 --- a/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts +++ b/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts @@ -27,7 +27,20 @@ it('should be possible to transition', async () => { ) await new Promise((resolve) => { - transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], resolve) + transition( + element, + { + enter: ['enter'], + enterFrom: ['enterFrom'], + enterTo: ['enterTo'], + leave: [], + leaveFrom: [], + leaveTo: [], + entered: ['entered'], + }, + true, // Show + resolve + ) }) await new Promise((resolve) => d.nextFrame(resolve)) @@ -42,7 +55,7 @@ it('should be possible to transition', async () => { // necessary to put the classes on the element and immediately remove them. // Cleanup phase - expect(snapshots[2].content).toEqual('
') + expect(snapshots[2].content).toEqual('
') d.dispose() }) @@ -71,11 +84,24 @@ it('should wait the correct amount of time to finish a transition', async () => ) let reason = await new Promise((resolve) => { - transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], resolve) + transition( + element, + { + enter: ['enter'], + enterFrom: ['enterFrom'], + enterTo: ['enterTo'], + leave: [], + leaveFrom: [], + leaveTo: [], + entered: ['entered'], + }, + true, // Show + resolve + ) }) await new Promise((resolve) => d.nextFrame(resolve)) - expect(reason).toBe(Reason.Finished) + expect(reason).toBe(Reason.Ended) // Initial render: expect(snapshots[0].content).toEqual(`
`) @@ -98,7 +124,7 @@ it('should wait the correct amount of time to finish a transition', async () => // Cleanup phase expect(snapshots[3].content).toEqual( - `
` + `
` ) }) @@ -128,11 +154,24 @@ it('should keep the delay time into account', async () => { ) let reason = await new Promise((resolve) => { - transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], resolve) + transition( + element, + { + enter: ['enter'], + enterFrom: ['enterFrom'], + enterTo: ['enterTo'], + leave: [], + leaveFrom: [], + leaveTo: [], + entered: ['entered'], + }, + true, // Show + resolve + ) }) await new Promise((resolve) => d.nextFrame(resolve)) - expect(reason).toBe(Reason.Finished) + expect(reason).toBe(Reason.Ended) let estimatedDuration = Number( (snapshots[snapshots.length - 1].recordedAt - snapshots[snapshots.length - 2].recordedAt) / @@ -141,53 +180,3 @@ it('should keep the delay time into account', async () => { expect(estimatedDuration).toBeWithinRenderFrame(duration + delayDuration) }) - -it('should be possible to cancel a transition at any time', async () => { - let d = disposables() - - let snapshots: { - content: string - recordedAt: bigint - relativeTime: number - }[] = [] - let element = document.createElement('div') - document.body.appendChild(element) - - // This duration is so overkill, however it will demonstrate that we can cancel transitions. - let duration = 5000 - - element.style.transitionDuration = `${duration}ms` - - d.add( - reportChanges( - () => document.body.innerHTML, - (content) => { - let recordedAt = process.hrtime.bigint() - let total = snapshots.length - - snapshots.push({ - content, - recordedAt, - relativeTime: - total === 0 ? 0 : Number((recordedAt - snapshots[total - 1].recordedAt) / BigInt(1e6)), - }) - } - ) - ) - - expect.assertions(2) - - // Setup the transition - let cancel = transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], (reason) => { - expect(reason).toBe(Reason.Cancelled) - }) - - // Wait for a bit - await new Promise((resolve) => setTimeout(resolve, 20)) - - // Cancel the transition - cancel() - await new Promise((resolve) => d.nextFrame(resolve)) - - expect(snapshots.map((snapshot) => snapshot.content).join('\n')).not.toContain('enterTo') -}) diff --git a/packages/@headlessui-react/src/components/transitions/utils/transition.ts b/packages/@headlessui-react/src/components/transitions/utils/transition.ts index 01d657c2c7..5e75d75ec2 100644 --- a/packages/@headlessui-react/src/components/transitions/utils/transition.ts +++ b/packages/@headlessui-react/src/components/transitions/utils/transition.ts @@ -1,5 +1,6 @@ import { once } from '../../../utils/once' import { disposables } from '../../../utils/disposables' +import { match } from '../../../utils/match' function addClasses(node: HTMLElement, ...classes: string[]) { node && classes.length > 0 && node.classList.add(...classes) @@ -10,7 +11,10 @@ function removeClasses(node: HTMLElement, ...classes: string[]) { } export enum Reason { - Finished = 'finished', + // Transition succesfully ended + Ended = 'ended', + + // Transition was cancelled Cancelled = 'cancelled', } @@ -22,7 +26,7 @@ function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) { // Safari returns a comma separated list of values, so let's sort them and take the highest value. let { transitionDuration, transitionDelay } = getComputedStyle(node) - let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map((value) => { + let [durationMs, delayMs] = [transitionDuration, transitionDelay].map((value) => { let [resolvedValue = 0] = value .split(',') // Remove falsy we can't work with @@ -34,22 +38,60 @@ function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) { return resolvedValue }) - // Waiting for the transition to end. We could use the `transitionend` event, however when no - // actual transition/duration is defined then the `transitionend` event is not fired. - // - // TODO: Downside is, when you slow down transitions via devtools this timeout is still using the - // full 100% speed instead of the 25% or 10%. - if (durationMs !== 0) { - d.setTimeout(() => { - done(Reason.Finished) - }, durationMs + delaysMs) + let totalDuration = durationMs + delayMs + + if (totalDuration !== 0) { + let listeners: (() => void)[] = [] + + if (process.env.NODE_ENV === 'test') { + listeners.push( + d.setTimeout(() => { + done(Reason.Ended) + listeners.splice(0).forEach((dispose) => dispose()) + }, totalDuration) + ) + } else { + listeners.push( + d.addEventListener( + node, + 'transitionrun', + () => { + // Cleanup "old" listeners + listeners.splice(0).forEach((dispose) => dispose()) + + // Register new listeners + listeners.push( + d.addEventListener( + node, + 'transitionend', + () => { + done(Reason.Ended) + listeners.splice(0).forEach((dispose) => dispose()) + }, + { once: true } + ), + d.addEventListener( + node, + 'transitioncancel', + () => { + done(Reason.Cancelled) + listeners.splice(0).forEach((dispose) => dispose()) + }, + { once: true } + ) + ) + }, + { once: true } + ) + ) + } } else { // No transition is happening, so we should cleanup already. Otherwise we have to wait until we // get disposed. - done(Reason.Finished) + done(Reason.Ended) } - // If we get disposed before the timeout runs we should cleanup anyway + // If we get disposed before the transition finishes, we should cleanup anyway. d.add(() => done(Reason.Cancelled)) return d.dispose @@ -57,39 +99,60 @@ function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) { export function transition( node: HTMLElement, - base: string[], - from: string[], - to: string[], - entered: string[], + classes: { + enter: string[] + enterFrom: string[] + enterTo: string[] + leave: string[] + leaveFrom: string[] + leaveTo: string[] + entered: string[] + }, + show: boolean, done?: (reason: Reason) => void ) { + let direction = show ? 'enter' : 'leave' let d = disposables() let _done = done !== undefined ? once(done) : () => {} - removeClasses(node, ...entered) + let base = match(direction, { + enter: () => classes.enter, + leave: () => classes.leave, + }) + let to = match(direction, { + enter: () => classes.enterTo, + leave: () => classes.leaveTo, + }) + let from = match(direction, { + enter: () => classes.enterFrom, + leave: () => classes.leaveFrom, + }) + + removeClasses( + node, + ...classes.enter, + ...classes.enterTo, + ...classes.enterFrom, + ...classes.leave, + ...classes.leaveFrom, + ...classes.leaveTo, + ...classes.entered + ) addClasses(node, ...base, ...from) d.nextFrame(() => { removeClasses(node, ...from) addClasses(node, ...to) - d.add( - waitForTransition(node, (reason) => { - removeClasses(node, ...to, ...base) - addClasses(node, ...entered) - return _done(reason) - }) - ) - }) - - // Once we get disposed, we should ensure that we cleanup after ourselves. In case of an unmount, - // the node itself will be nullified and will be a no-op. In case of a full transition the classes - // are already removed which is also a no-op. However if you go from enter -> leave mid-transition - // then we have some leftovers that should be cleaned. - d.add(() => removeClasses(node, ...base, ...from, ...to, ...entered)) + waitForTransition(node, (reason) => { + if (reason === Reason.Ended) { + removeClasses(node, ...base) + addClasses(node, ...classes.entered) + } - // When we get disposed early, than we should also call the done method but switch the reason. - d.add(() => _done(Reason.Cancelled)) + return _done(reason) + }) + }) return d.dispose } diff --git a/packages/@headlessui-react/src/hooks/use-id.ts b/packages/@headlessui-react/src/hooks/use-id.ts index b692971612..772d6512a3 100644 --- a/packages/@headlessui-react/src/hooks/use-id.ts +++ b/packages/@headlessui-react/src/hooks/use-id.ts @@ -1,4 +1,4 @@ -import { useState } from 'react' +import React from 'react' import { useIsoMorphicEffect } from './use-iso-morphic-effect' import { useServerHandoffComplete } from './use-server-handoff-complete' @@ -13,13 +13,17 @@ function generateId() { return ++id } -export function useId() { - let ready = useServerHandoffComplete() - let [id, setId] = useState(ready ? generateId : null) +export let useId = + // Prefer React's `useId` if it's available. + // @ts-expect-error - `useId` doesn't exist in React < 18. + React.useId ?? + function useId() { + let ready = useServerHandoffComplete() + let [id, setId] = React.useState(ready ? generateId : null) - useIsoMorphicEffect(() => { - if (id === null) setId(generateId()) - }, [id]) + useIsoMorphicEffect(() => { + if (id === null) setId(generateId()) + }, [id]) - return id != null ? '' + id : undefined -} + return id != null ? '' + id : undefined + } diff --git a/packages/@headlessui-react/src/hooks/use-is-initial-render.ts b/packages/@headlessui-react/src/hooks/use-is-initial-render.ts index a61ca7facd..442a80fdd6 100644 --- a/packages/@headlessui-react/src/hooks/use-is-initial-render.ts +++ b/packages/@headlessui-react/src/hooks/use-is-initial-render.ts @@ -5,6 +5,10 @@ export function useIsInitialRender() { useEffect(() => { initial.current = false + + return () => { + initial.current = true + } }, []) return initial.current diff --git a/packages/@headlessui-react/src/hooks/use-is-mounted.ts b/packages/@headlessui-react/src/hooks/use-is-mounted.ts index 8f890a6665..a73a7d845c 100644 --- a/packages/@headlessui-react/src/hooks/use-is-mounted.ts +++ b/packages/@headlessui-react/src/hooks/use-is-mounted.ts @@ -1,9 +1,10 @@ -import { useRef, useEffect } from 'react' +import { useRef } from 'react' +import { useIsoMorphicEffect } from './use-iso-morphic-effect' export function useIsMounted() { let mounted = useRef(false) - useEffect(() => { + useIsoMorphicEffect(() => { mounted.current = true return () => { diff --git a/packages/@headlessui-react/src/hooks/use-latest-value.ts b/packages/@headlessui-react/src/hooks/use-latest-value.ts index 6795e7e74b..b578c4f120 100644 --- a/packages/@headlessui-react/src/hooks/use-latest-value.ts +++ b/packages/@headlessui-react/src/hooks/use-latest-value.ts @@ -1,9 +1,10 @@ -import { useRef, useEffect } from 'react' +import { useRef } from 'react' +import { useIsoMorphicEffect } from './use-iso-morphic-effect' export function useLatestValue(value: T) { let cache = useRef(value) - useEffect(() => { + useIsoMorphicEffect(() => { cache.current = value }, [value]) diff --git a/packages/@headlessui-react/src/hooks/use-outside-click.ts b/packages/@headlessui-react/src/hooks/use-outside-click.ts index 9f15fc990e..1990e698f3 100644 --- a/packages/@headlessui-react/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-react/src/hooks/use-outside-click.ts @@ -1,22 +1,8 @@ import { MutableRefObject, useMemo, useRef } from 'react' +import { microTask } from '../utils/micro-task' import { useLatestValue } from './use-latest-value' import { useWindowEvent } from './use-window-event' -// Polyfill -function microTask(cb: () => void) { - if (typeof queueMicrotask === 'function') { - queueMicrotask(cb) - } else { - Promise.resolve() - .then(cb) - .catch((e) => - setTimeout(() => { - throw e - }) - ) - } -} - type Container = MutableRefObject | HTMLElement | null type ContainerCollection = Container[] | Set type ContainerInput = Container | ContainerCollection diff --git a/packages/@headlessui-react/src/hooks/use-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts new file mode 100644 index 0000000000..424d6058f4 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-transition.ts @@ -0,0 +1,96 @@ +import { MutableRefObject, useRef } from 'react' + +import { Reason, transition } from '../components/transitions/utils/transition' +import { disposables } from '../utils/disposables' +import { match } from '../utils/match' + +import { useDisposables } from './use-disposables' +import { useIsMounted } from './use-is-mounted' +import { useIsoMorphicEffect } from './use-iso-morphic-effect' +import { useLatestValue } from './use-latest-value' + +interface TransitionArgs { + container: MutableRefObject + classes: MutableRefObject<{ + enter: string[] + enterFrom: string[] + enterTo: string[] + + leave: string[] + leaveFrom: string[] + leaveTo: string[] + + entered: string[] + }> + events: MutableRefObject<{ + beforeEnter: () => void + afterEnter: () => void + beforeLeave: () => void + afterLeave: () => void + }> + direction: 'enter' | 'leave' | 'idle' + onStart: MutableRefObject<(direction: TransitionArgs['direction']) => void> + onStop: MutableRefObject<(direction: TransitionArgs['direction']) => void> +} + +export function useTransition({ + container, + direction, + classes, + events, + onStart, + onStop, +}: TransitionArgs) { + let mounted = useIsMounted() + let d = useDisposables() + + let latestDirection = useLatestValue(direction) + + let beforeEvent = useLatestValue(() => { + return match(latestDirection.current, { + enter: () => events.current.beforeEnter(), + leave: () => events.current.beforeLeave(), + idle: () => {}, + }) + }) + + let afterEvent = useLatestValue(() => { + return match(latestDirection.current, { + enter: () => events.current.afterEnter(), + leave: () => events.current.afterLeave(), + idle: () => {}, + }) + }) + + useIsoMorphicEffect(() => { + let dd = disposables() + d.add(dd.dispose) + + let node = container.current + if (!node) return // We don't have a DOM node (yet) + if (latestDirection.current === 'idle') return // We don't need to transition + if (!mounted.current) return + + dd.dispose() + + beforeEvent.current() + + onStart.current(latestDirection.current) + + dd.add( + transition(node, classes.current, latestDirection.current === 'enter', (reason) => { + dd.dispose() + + match(reason, { + [Reason.Ended]() { + afterEvent.current() + onStop.current(latestDirection.current) + }, + [Reason.Cancelled]: () => {}, + }) + }) + ) + + return dd.dispose + }, [direction]) +} diff --git a/packages/@headlessui-react/src/test-utils/interactions.test.tsx b/packages/@headlessui-react/src/test-utils/interactions.test.tsx index e4701f1556..75e0e9558a 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.test.tsx +++ b/packages/@headlessui-react/src/test-utils/interactions.test.tsx @@ -47,8 +47,8 @@ describe('Keyboard', () => { Keys.Tab, [ event('keydown', 'trigger'), - event('blur', 'trigger'), - event('focus', 'after'), + event('focusout', 'trigger'), + event('focusin', 'after'), event('keyup', 'after'), ], new Set(), @@ -57,8 +57,8 @@ describe('Keyboard', () => { shift(Keys.Tab), [ event('keydown', 'trigger'), - event('blur', 'trigger'), - event('focus', 'before'), + event('focusout', 'trigger'), + event('focusin', 'before'), event('keyup', 'before'), ], new Set(), @@ -79,8 +79,8 @@ describe('Keyboard', () => { Keys.Tab, [ event('keydown', 'trigger'), - event('blur', 'trigger'), - event('focus', 'after'), + event('focusout', 'trigger'), + event('focusin', 'after'), event('keyup', 'after'), ], new Set(['onKeyPress']), @@ -89,8 +89,8 @@ describe('Keyboard', () => { shift(Keys.Tab), [ event('keydown', 'trigger'), - event('blur', 'trigger'), - event('focus', 'before'), + event('focusout', 'trigger'), + event('focusin', 'before'), event('keyup', 'before'), ], new Set(['onKeyPress']), @@ -104,8 +104,8 @@ describe('Keyboard', () => { Keys.Tab, [ event('keydown', 'trigger'), - event('blur', 'trigger'), - event('focus', 'after'), + event('focusout', 'trigger'), + event('focusin', 'after'), event('keyup', 'after'), ], new Set(['onKeyUp']), @@ -114,8 +114,8 @@ describe('Keyboard', () => { shift(Keys.Tab), [ event('keydown', 'trigger'), - event('blur', 'trigger'), - event('focus', 'before'), + event('focusout', 'trigger'), + event('focusin', 'before'), event('keyup', 'before'), ], new Set(['onKeyUp']), @@ -126,8 +126,8 @@ describe('Keyboard', () => { Keys.Tab, [ event('keydown', 'trigger'), - event('blur', 'trigger'), - event('focus', 'after'), + event('focusout', 'trigger'), + event('focusin', 'after'), event('keyup', 'after'), ], new Set(['onBlur']), @@ -136,8 +136,8 @@ describe('Keyboard', () => { shift(Keys.Tab), [ event('keydown', 'trigger'), - event('blur', 'trigger'), - event('focus', 'before'), + event('focusout', 'trigger'), + event('focusin', 'before'), event('keyup', 'before'), ], new Set(['onBlur']), diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index 9e18c36a2e..f243fac493 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/packages/@headlessui-react/src/test-utils/interactions.ts @@ -4,11 +4,13 @@ import { disposables } from '../utils/disposables' let d = disposables() function nextFrame(cb: Function): void { - setImmediate(() => + setImmediate(() => { setImmediate(() => { - cb() + setImmediate(() => { + cb() + }) }) - ) + }) } export let Keys: Record> = { @@ -282,7 +284,11 @@ export async function focus(element: Document | Element | Window | Node | null) try { if (element === null) return expect(element).not.toBe(null) - fireEvent.focus(element) + if (element instanceof HTMLElement) { + element.focus() + } else { + fireEvent.focus(element) + } await new Promise(nextFrame) } catch (err) { diff --git a/packages/@headlessui-react/src/utils/disposables.ts b/packages/@headlessui-react/src/utils/disposables.ts index 4c0f89c0f8..972c266187 100644 --- a/packages/@headlessui-react/src/utils/disposables.ts +++ b/packages/@headlessui-react/src/utils/disposables.ts @@ -7,24 +7,41 @@ export function disposables() { queue.push(fn) }, + addEventListener( + element: HTMLElement, + name: TEventName, + listener: (event: WindowEventMap[TEventName]) => any, + options?: boolean | AddEventListenerOptions + ) { + element.addEventListener(name, listener as any, options) + return api.add(() => element.removeEventListener(name, listener as any, options)) + }, + requestAnimationFrame(...args: Parameters) { let raf = requestAnimationFrame(...args) - api.add(() => cancelAnimationFrame(raf)) + return api.add(() => cancelAnimationFrame(raf)) }, nextFrame(...args: Parameters) { - api.requestAnimationFrame(() => { - api.requestAnimationFrame(...args) + return api.requestAnimationFrame(() => { + return api.requestAnimationFrame(...args) }) }, setTimeout(...args: Parameters) { let timer = setTimeout(...args) - api.add(() => clearTimeout(timer)) + return api.add(() => clearTimeout(timer)) }, add(cb: () => void) { disposables.push(cb) + return () => { + let idx = disposables.indexOf(cb) + if (idx >= 0) { + let [dispose] = disposables.splice(idx, 1) + dispose() + } + } }, dispose() { diff --git a/packages/@headlessui-react/src/utils/micro-task.ts b/packages/@headlessui-react/src/utils/micro-task.ts new file mode 100644 index 0000000000..3098563a55 --- /dev/null +++ b/packages/@headlessui-react/src/utils/micro-task.ts @@ -0,0 +1,14 @@ +// Polyfill +export function microTask(cb: () => void) { + if (typeof queueMicrotask === 'function') { + queueMicrotask(cb) + } else { + Promise.resolve() + .then(cb) + .catch((e) => + setTimeout(() => { + throw e + }) + ) + } +} diff --git a/packages/playground-react/next.config.js b/packages/playground-react/next.config.js index 5b8efdfbd8..cff704b710 100644 --- a/packages/playground-react/next.config.js +++ b/packages/playground-react/next.config.js @@ -1,4 +1,5 @@ module.exports = { + reactStrictMode: true, devIndicators: { autoPrerender: false, }, diff --git a/packages/playground-react/package.json b/packages/playground-react/package.json index d2cfaeeb8b..211fdf2a9e 100644 --- a/packages/playground-react/package.json +++ b/packages/playground-react/package.json @@ -15,9 +15,9 @@ "@headlessui/react": "*", "@popperjs/core": "^2.6.0", "framer-motion": "^6.0.0", - "next": "^12.0.8", - "react": "16.14.0", - "react-dom": "16.14.0", + "next": "^12.1.4", + "react": "^18.0.0", + "react-dom": "^18.0.0", "react-flatpickr": "^3.10.9" } } diff --git a/packages/playground-react/pages/dialog/dialog.tsx b/packages/playground-react/pages/dialog/dialog.tsx index b2c423edb1..9e4f25f321 100644 --- a/packages/playground-react/pages/dialog/dialog.tsx +++ b/packages/playground-react/pages/dialog/dialog.tsx @@ -71,7 +71,20 @@ export default function Home() { {nested && setNested(false)} />} - console.log('done')}> +
+ + console.log('[Transition] Before enter')} + afterEnter={() => console.log('[Transition] After enter')} + beforeLeave={() => console.log('[Transition] Before leave')} + afterLeave={() => console.log('[Transition] After leave')} + >
@@ -84,6 +97,10 @@ export default function Home() { leaveFrom="opacity-75" leaveTo="opacity-0" entered="opacity-75" + beforeEnter={() => console.log('[Transition.Child] [Overlay] Before enter')} + afterEnter={() => console.log('[Transition.Child] [Overlay] After enter')} + beforeLeave={() => console.log('[Transition.Child] [Overlay] Before leave')} + afterLeave={() => console.log('[Transition.Child] [Overlay] After leave')} > @@ -95,6 +112,10 @@ export default function Home() { leave="ease-in transform duration-200" leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" + beforeEnter={() => console.log('[Transition.Child] [Panel] Before enter')} + afterEnter={() => console.log('[Transition.Child] [Panel] After enter')} + beforeLeave={() => console.log('[Transition.Child] [Panel] Before leave')} + afterLeave={() => console.log('[Transition.Child] [Panel] After leave')} > {/* This element is to trick the browser into centering the modal contents. */}
+
@@ -35,6 +39,10 @@ export default function Home() { leave="transition duration-1000 ease-out" leaveFrom="transform scale-100 opacity-100" leaveTo="transform scale-95 opacity-0" + beforeEnter={() => console.log('Before enter')} + afterEnter={() => console.log('After enter')} + beforeLeave={() => console.log('Before leave')} + afterLeave={() => console.log('After leave')} >
diff --git a/packages/playground-react/pages/transitions/component-examples/modal.tsx b/packages/playground-react/pages/transitions/component-examples/modal.tsx index caa21f7ec1..70aea639e3 100644 --- a/packages/playground-react/pages/transitions/component-examples/modal.tsx +++ b/packages/playground-react/pages/transitions/component-examples/modal.tsx @@ -17,6 +17,10 @@ export default function Home() { return (
+
@@ -44,21 +48,17 @@ export default function Home() { show={isOpen} className="fixed inset-0 z-10 overflow-y-auto" beforeEnter={() => { - addEvent('Before enter') + addEvent('[Root] Before enter') }} afterEnter={() => { - inputRef.current.focus() - addEvent('After enter') + inputRef.current?.focus() + addEvent('[Root] After enter') }} beforeLeave={() => { - addEvent('Before leave (before confirm)') - window.confirm('Are you sure?') - addEvent('Before leave (after confirm)') + addEvent('[Root] Before leave') }} afterLeave={() => { - addEvent('After leave (before alert)') - window.alert('Consider it done!') - addEvent('After leave (after alert)') + addEvent('[Root] After leave') setEmail('') }} > @@ -70,6 +70,10 @@ export default function Home() { leave="ease-in duration-200" leaveFrom="opacity-100" leaveTo="opacity-0" + beforeEnter={() => addEvent('[Overlay] Before enter')} + afterEnter={() => addEvent('[Overlay] After enter')} + beforeLeave={() => addEvent('[Overlay] Before leave')} + afterLeave={() => addEvent('[Overlay] After leave')} >
@@ -88,6 +92,10 @@ export default function Home() { leave="ease-in duration-200" leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" + beforeEnter={() => addEvent('[Panel] Before enter')} + afterEnter={() => addEvent('[Panel] After enter')} + beforeLeave={() => addEvent('[Panel] Before leave')} + afterLeave={() => addEvent('[Panel] After leave')} >
@@ -131,6 +139,7 @@ export default function Home() { ref={inputRef} value={email} onChange={(event) => setEmail(event.target.value)} + type="email" id="email" className="form-input block w-full px-3 sm:text-sm sm:leading-5" placeholder="name@example.com" diff --git a/packages/playground-react/pages/transitions/component-examples/peek-a-boo.tsx b/packages/playground-react/pages/transitions/component-examples/peek-a-boo.tsx index 5cfba5bd8a..1deeee541f 100644 --- a/packages/playground-react/pages/transitions/component-examples/peek-a-boo.tsx +++ b/packages/playground-react/pages/transitions/component-examples/peek-a-boo.tsx @@ -18,16 +18,27 @@ export default function Home() { +