diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afe6cafdd8..268a53c928 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,8 +258,8 @@ catalogs: specifier: 0.27.20 version: 0.27.20 '@headlessui/react': - specifier: 1.7.19 - version: 1.7.19 + specifier: 2.2.10 + version: 2.2.10 '@hot-updater/bsdiff': specifier: 0.30.6 version: 0.30.6 @@ -4174,7 +4174,7 @@ importers: version: 0.27.20(react-dom@18.3.1)(react@18.3.1) '@headlessui/react': specifier: 'catalog:' - version: 1.7.19(react-dom@18.3.1)(react@18.3.1) + version: 2.2.10(react-dom@18.3.1)(react@18.3.1) '@hot-updater/bsdiff': specifier: 'catalog:' version: 0.30.6 @@ -4704,7 +4704,7 @@ importers: version: 0.27.20(react-dom@18.3.1)(react@18.3.1) '@headlessui/react': specifier: 'catalog:' - version: 1.7.19(react-dom@18.3.1)(react@18.3.1) + version: 2.2.10(react-dom@18.3.1)(react@18.3.1) '@hot-updater/bsdiff': specifier: 'catalog:' version: 0.30.6 @@ -6233,6 +6233,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@floating-ui/react@0.26.28': + resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@floating-ui/react@0.27.20': resolution: {integrity: sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==} peerDependencies: @@ -6242,12 +6248,12 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} - '@headlessui/react@1.7.19': - resolution: {integrity: sha512-Ll+8q3OlMJfJbAKM/+/Y2q6PPYbryqNTXDbryx7SXLIDamkF6iQFbriYHga0dY44PvDhvvBWCx1Xj4U5+G4hOw==} + '@headlessui/react@2.2.10': + resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==} engines: {node: '>=10'} peerDependencies: - react: ^16 || ^17 || ^18 - react-dom: ^16 || ^17 || ^18 + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc '@hot-updater/bsdiff@0.30.6': resolution: {integrity: sha512-Ux8WyYsYXlU13TahGCHHa3EQpfDQukGm0REFQEz7IxRXYFJf0+oOB0k8e+j+V9j53WXbUFdoRq+FIB+gMC2iLQ==} @@ -6282,6 +6288,15 @@ packages: peerDependencies: react: '*' + '@internationalized/date@3.12.3': + resolution: {integrity: sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==} + + '@internationalized/number@3.6.7': + resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} + + '@internationalized/string@3.2.10': + resolution: {integrity: sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -7208,6 +7223,18 @@ packages: '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@react-aria/focus@3.22.1': + resolution: {integrity: sha512-CPxtkyrBi/HYY5P3lE/57sQ6qfa0lN8E55TOm89H0kNGv0lKt+/0zP7lWERzBjRr5IxBVrQX4gFEowBN52LPaA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/interactions@3.28.1': + resolution: {integrity: sha512-Bqb+HrD5I5MHS2SKBhISYqo2SW8Y2dfzgF/Y1lIJq7xqLxheo9vzxPGEHhz+XzkgGfoqEJx8A6a3C7uiqS3HWA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@react-dnd/asap@4.0.1': resolution: {integrity: sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==} @@ -7217,6 +7244,11 @@ packages: '@react-dnd/shallowequal@2.0.0': resolution: {integrity: sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==} + '@react-types/shared@3.36.1': + resolution: {integrity: sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@redocly/ajv@8.11.2': resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} @@ -7371,6 +7403,9 @@ packages: peerDependencies: eslint: ^9.0.0 || ^10.0.0 + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@tailwindcss/cli@4.3.2': resolution: {integrity: sha512-Fzt+HrIZHDlkRYKdLMBeufaroaPvwCBG70sMLdmurdeadNMO/LxbmT8Sbb+P83ep0iAlAImettb7Y+rO+37rXw==} hasBin: true @@ -8387,6 +8422,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -8743,9 +8782,6 @@ packages: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -11537,6 +11573,12 @@ packages: react: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-aria@3.51.0: + resolution: {integrity: sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-bootstrap@0.33.1: resolution: {integrity: sha512-qWTRravSds87P8WC82tETy2yIso8qDqlIm0czsrduCaYAFtHuyLu0XDbUlfLXeRzqgwm5sRk2wRaTNoiVkk/YQ==} peerDependencies: @@ -11681,6 +11723,11 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-stately@3.49.0: + resolution: {integrity: sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-transition-group@2.9.0: resolution: {integrity: sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==} peerDependencies: @@ -12722,6 +12769,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} @@ -14266,6 +14318,14 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@floating-ui/react@0.26.28(react-dom@18.3.1)(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@18.3.1)(react@18.3.1) + '@floating-ui/utils': 0.2.12 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tabbable: 6.5.0 + '@floating-ui/react@0.27.20(react-dom@18.3.1)(react@18.3.1)': dependencies: '@floating-ui/react-dom': 2.1.9(react-dom@18.3.1)(react@18.3.1) @@ -14276,12 +14336,15 @@ snapshots: '@floating-ui/utils@0.2.12': {} - '@headlessui/react@1.7.19(react-dom@18.3.1)(react@18.3.1)': + '@headlessui/react@2.2.10(react-dom@18.3.1)(react@18.3.1)': dependencies: + '@floating-ui/react': 0.26.28(react-dom@18.3.1)(react@18.3.1) + '@react-aria/focus': 3.22.1(react-dom@18.3.1)(react@18.3.1) + '@react-aria/interactions': 3.28.1(react-dom@18.3.1)(react@18.3.1) '@tanstack/react-virtual': 3.13.23(react-dom@18.3.1)(react@18.3.1) - client-only: 0.0.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) '@hot-updater/bsdiff@0.30.6': {} @@ -14309,6 +14372,18 @@ snapshots: dependencies: react: 18.3.1 + '@internationalized/date@3.12.3': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.7': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/string@3.2.10': + dependencies: + '@swc/helpers': 0.5.23 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -15102,12 +15177,31 @@ snapshots: dependencies: quansync: 1.0.0 + '@react-aria/focus@3.22.1(react-dom@18.3.1)(react@18.3.1)': + dependencies: + '@swc/helpers': 0.5.23 + react: 18.3.1 + react-aria: 3.51.0(react-dom@18.3.1)(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) + + '@react-aria/interactions@3.28.1(react-dom@18.3.1)(react@18.3.1)': + dependencies: + '@react-types/shared': 3.36.1(react@18.3.1) + '@swc/helpers': 0.5.23 + react: 18.3.1 + react-aria: 3.51.0(react-dom@18.3.1)(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) + '@react-dnd/asap@4.0.1': {} '@react-dnd/invariant@2.0.0': {} '@react-dnd/shallowequal@2.0.0': {} + '@react-types/shared@3.36.1(react@18.3.1)': + dependencies: + react: 18.3.1 + '@redocly/ajv@8.11.2': dependencies: fast-deep-equal: 3.1.3 @@ -15237,6 +15331,10 @@ snapshots: estraverse: 5.3.0 picomatch: 4.0.4 + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@tailwindcss/cli@4.3.2': dependencies: '@parcel/watcher': 2.5.1 @@ -16325,6 +16423,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -16693,8 +16795,6 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.1 - client-only@0.0.1: {} - cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -19610,6 +19710,20 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + react-aria@3.51.0(react-dom@18.3.1)(react@18.3.1): + dependencies: + '@internationalized/date': 3.12.3 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@18.3.1) + '@swc/helpers': 0.5.23 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-stately: 3.49.0(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) + react-bootstrap@0.33.1(react-dom@18.3.1)(react@18.3.1): dependencies: '@babel/runtime-corejs2': 7.29.2 @@ -19799,6 +19913,16 @@ snapshots: react-dom: 18.3.1(react@18.3.1) react-transition-group: 4.4.5(react-dom@18.3.1)(react@18.3.1) + react-stately@3.49.0(react@18.3.1): + dependencies: + '@internationalized/date': 3.12.3 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.10 + '@react-types/shared': 3.36.1(react@18.3.1) + '@swc/helpers': 0.5.23 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + react-transition-group@2.9.0(react-dom@18.3.1)(react@18.3.1): dependencies: dom-helpers: 3.4.0 @@ -20857,6 +20981,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + utf8-byte-length@1.0.5: {} util-deprecate@1.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index baf3c4ee21..4d6f7d1436 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -31,7 +31,7 @@ catalog: "@eslint-react/eslint-plugin": 3.0.0 "@eslint/js": 10.0.1 "@floating-ui/react": 0.27.20 - "@headlessui/react": 1.7.19 + "@headlessui/react": 2.2.10 "@hot-updater/bsdiff": 0.30.6 "@iarna/toml": 2.2.5 "@mdi/js": 7.4.47 diff --git a/src/renderer/src/renderer.tsx b/src/renderer/src/renderer.tsx index 75b7b0a1b2..16fe74d814 100644 --- a/src/renderer/src/renderer.tsx +++ b/src/renderer/src/renderer.tsx @@ -286,8 +286,13 @@ function errorHandler(evt: any) { // it's almost certainly some callback invoked from a native library log("error", "script error"); return; - } else if (error === "ResizeObserver loop limit exceeded") { + } else if (error.startsWith("ResizeObserver loop")) { // this error was called "benign" by one of the spec authors. I'll take their word for it. + // Matched by prefix because chromium has used two wordings: "loop limit exceeded" and, + // since chromium 92, "loop completed with undelivered notifications". preventDefault + // stops chromium printing it to the console on its own account, which is the only way + // it would still reach the log now that we're swallowing it here. + evt.preventDefault?.(); return; } } @@ -349,13 +354,20 @@ function errorHandler(evt: any) { return; } - if (error.stack.includes("packery")) { + // `error` isn't necessarily an Error: the fallback chain above ends at `evt.message`, so a + // browser-level ErrorEvent that carries no error object (a cross-origin "Script error.", a + // ResizeObserver loop warning) arrives here as a plain string with no `stack` at all. Read it + // once, as a string, so the sniffing below can't take the handler down with it — an exception + // thrown in here is itself uncaught, and reports as a bug in Vortex rather than the original. + const stack: string = typeof error.stack === "string" ? error.stack : ""; + + if (stack.includes("packery")) { // seems to be caused by an event triggered inside packery after cleanup so I don't see // a way to catch this cleanly return; } - if (error.stack.includes("react-sortable-tree")) { + if (stack.includes("react-sortable-tree")) { // matches the @nosferatu500/react-sortable-tree fork too (the scoped package // path still contains "react-sortable-tree"). // bug in external library. I know where the bug is but fixing that causes a new problem and @@ -371,10 +383,10 @@ function errorHandler(evt: any) { const dynPaths = ExtensionManager.getExtensionPaths().filter((extPath) => !extPath.bundled); if (dynPaths.length > 0) { - if (error.stack.includes(`at ${dynPaths[0].path}`)) { + if (stack.includes(`at ${dynPaths[0].path}`)) { const extPath = (dynPaths[0].path + path.sep).replace(/[\\]/g, "\\\\"); const re = new RegExp(`at ${extPath}(Vortex Extension Update - )?([^/\\\\]*)`); - const reMatch = error.stack.match(re); + const reMatch = stack.match(re); const extName = reMatch?.[2] ?? "unknown"; error["extension"] = extName; diff --git a/src/renderer/src/ui/README.md b/src/renderer/src/ui/README.md index c0f35cf494..377a131b23 100644 --- a/src/renderer/src/ui/README.md +++ b/src/renderer/src/ui/README.md @@ -355,26 +355,28 @@ import { FormFieldWrap } from "../../ui/components/form/formfield/FormField"; ### Switch -A tri-state toggle switch (xs) — `off`, `on`, and a programmatic `semi-on` ("mixed") state. Built on a visually-hidden native ``; setting `indeterminate` renders `semi-on` and reports `aria-checked="mixed"`. Clicking only ever flips on/off — `semi-on` is set by the consumer (e.g. a master control whose children are partially on). It's controlled-visual (appearance follows the `checked`/`indeterminate` props, like `Checkbox`). +A tri-state toggle switch (xs) — `off`, `on`, and a programmatic `semi-on` ("mixed") state. Setting `indeterminate` renders `semi-on` and reports `aria-checked="mixed"`. Clicking only ever flips on/off — `semi-on` is set by the consumer (e.g. a master control whose children are partially on). + +Built on Headless UI's **`Checkbox`**, not its `Switch`: ARIA only allows `aria-checked` to be true/false on `role="switch"`, and Headless UI controls that attribute, so `mixed` can't be forced onto a `Switch`. A tri-state master control is the checkbox pattern, which is what `Checkbox` implements. ```tsx import { Switch } from "../../ui/components/form/switch/Switch"; -// Controlled on/off - setEnabled(e.target.checked)} aria-label="Enable" /> +// Controlled on/off — onChange receives the new checked value, not an event + // Semi-on (mixed) — e.g. a "select all" with some children on setAll(e.target.checked)} + onChange={setAll} aria-label="All settings" /> ``` -**Props:** native `` attributes (minus `type`) plus `indeterminate?: boolean`. +**Props:** Headless UI `Checkbox` props — `checked`, `onChange(checked: boolean)`, `disabled`, `indeterminate`, `name`/`value`/`form` for form submission, `defaultChecked` for uncontrolled use — plus `className`. -> **Porting note:** when `@headlessui/react` reaches v2 (after the React upgrade), reimplement on top of HeadlessUI's ``. The `nxm-switch` classes live on the track/thumb so they can move straight across. HeadlessUI's Switch is binary, so the tri-state stays the wrapper's responsibility (`data-state` + `indeterminate`/`aria-checked="mixed"`). +The track and thumb style themselves off the attributes Headless UI sets (`data-checked`, `data-indeterminate`, `data-disabled`, `data-hover`, `data-active`, `data-focus`) rather than any state we derive ourselves. It renders a ``, so pass `name` if the value needs to take part in form submission. ### Dropdown @@ -406,7 +408,7 @@ import { mdiTune } from "@mdi/js"; ; ``` -> **Positioning note:** the panel is positioned manually (absolute) until `@headlessui/react` reaches v2, which brings dynamic anchor positioning and proper z-index handling. +> **Positioning note:** the panel is placed by Headless UI's `anchor` prop, which uses Floating UI to flip and shift it into view and portals it out of any clipping ancestor. It defaults to `bottom end` with a 4px gap; pass `anchor` to place it elsewhere. ### DisplayOptions @@ -670,7 +672,7 @@ import { mdiViewGrid } from "@mdi/js"; /> ``` -**Props:** `options` (`{ label, value, iconPath?/icon? }[]`), `value`, `onChange` (required); `button` (props forwarded to the trigger `ListboxButton` — Button props + `showChevron`; any `children` is ignored, the label is always the selected option), `placement` (`"left"`/`"right"`, default `"right"` — temporary until Headless UI v2), `className`. +**Props:** `options` (`{ label, value, iconPath?/icon? }[]`), `value`, `onChange` (required); `button` (props forwarded to the trigger `ListboxButton` — Button props + `showChevron`; any `children` is ignored, the label is always the selected option), `placement` (`"left"`/`"right"`, default `"right"` — which edge of the trigger the panel aligns to), `className`. ### Pill diff --git a/src/renderer/src/ui/components/dropdown/Dropdown.demo.tsx b/src/renderer/src/ui/components/dropdown/Dropdown.demo.tsx index c2d1e76089..5776c85744 100644 --- a/src/renderer/src/ui/components/dropdown/Dropdown.demo.tsx +++ b/src/renderer/src/ui/components/dropdown/Dropdown.demo.tsx @@ -3,7 +3,7 @@ * Demonstrates the Dropdown component variants and features */ -import { Menu } from "@headlessui/react"; +import { MenuButton } from "@headlessui/react"; import { mdiContentCopy, mdiDelete, mdiDotsVertical, mdiDownload, mdiPencil } from "@mdi/js"; import React, { useCallback } from "react"; @@ -41,9 +41,9 @@ export const DropdownDemo = () => {
- + Options - + handleClick("Option 1")}>Option 1 @@ -63,9 +63,9 @@ export const DropdownDemo = () => {
- + Actions - + handleClick("Edit")}> @@ -101,7 +101,7 @@ export const DropdownDemo = () => {
- {
- + With Disabled - + handleClick("Available")}>Available action diff --git a/src/renderer/src/ui/components/dropdown/Dropdown.test.tsx b/src/renderer/src/ui/components/dropdown/Dropdown.test.tsx index d61f378682..8387b058dc 100644 --- a/src/renderer/src/ui/components/dropdown/Dropdown.test.tsx +++ b/src/renderer/src/ui/components/dropdown/Dropdown.test.tsx @@ -1,4 +1,4 @@ -import { Menu } from "@headlessui/react"; +import { MenuButton } from "@headlessui/react"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import React from "react"; @@ -16,7 +16,7 @@ const renderComponent = () => { render( - Options + Options Edit diff --git a/src/renderer/src/ui/components/dropdown/Dropdown.tsx b/src/renderer/src/ui/components/dropdown/Dropdown.tsx index 95f5d24af8..c2f05d2393 100644 --- a/src/renderer/src/ui/components/dropdown/Dropdown.tsx +++ b/src/renderer/src/ui/components/dropdown/Dropdown.tsx @@ -3,8 +3,6 @@ import React, { type ComponentProps } from "react"; import { joinClasses } from "@/ui/utils/joinClasses"; -// needs updating to headless v2 so we can have dynamic positioning and proper z-index usage - export const Dropdown = ({ className, ...props }: ComponentProps) => ( ); diff --git a/src/renderer/src/ui/components/dropdown/DropdownButton.tsx b/src/renderer/src/ui/components/dropdown/DropdownButton.tsx index 2b9b032a33..e7315f3849 100644 --- a/src/renderer/src/ui/components/dropdown/DropdownButton.tsx +++ b/src/renderer/src/ui/components/dropdown/DropdownButton.tsx @@ -1,4 +1,4 @@ -import { Menu } from "@headlessui/react"; +import { MenuButton } from "@headlessui/react"; import React from "react"; import { Button, type IButtonProps } from "@/ui/components/button/Button"; @@ -6,5 +6,5 @@ import { Button, type IButtonProps } from "@/ui/components/button/Button"; export type IDropdownButtonProps = IButtonProps; export const DropdownButton = (props: IDropdownButtonProps) => ( - + ); diff --git a/src/renderer/src/ui/components/dropdown/DropdownItem.tsx b/src/renderer/src/ui/components/dropdown/DropdownItem.tsx index 10d6270e25..2c91e4aecf 100644 --- a/src/renderer/src/ui/components/dropdown/DropdownItem.tsx +++ b/src/renderer/src/ui/components/dropdown/DropdownItem.tsx @@ -1,4 +1,4 @@ -import { Menu } from "@headlessui/react"; +import { MenuItem } from "@headlessui/react"; import React, { type ComponentProps, type ReactNode } from "react"; import { Icon } from "@/ui/components/icon/Icon"; @@ -33,8 +33,8 @@ export const DropdownItem = ({ rightIconPath, onClick, ...props -}: ComponentProps & IDropdownItemProps & { onClick?: () => void }) => ( - +}: ComponentProps & IDropdownItemProps & { onClick?: () => void }) => ( + {({ active, disabled }) => (
@@ -77,7 +85,7 @@ export const SwitchDemo = () => {
- setEnabled(e.target.checked)} /> + {enabled ? "Enabled" : "Disabled"} @@ -99,7 +107,7 @@ export const SwitchDemo = () => { setChildren(children.map(() => e.target.checked))} + onChange={(checked) => setChildren(children.map(() => checked))} /> @@ -110,10 +118,7 @@ export const SwitchDemo = () => {
{CHILD_LABELS.map((label, index) => (
- setChild(index, e.target.checked)} - /> + setChild(index, checked)} /> {label} diff --git a/src/renderer/src/ui/components/form/switch/Switch.test.tsx b/src/renderer/src/ui/components/form/switch/Switch.test.tsx index cd8d340f28..28784c70ef 100644 --- a/src/renderer/src/ui/components/form/switch/Switch.test.tsx +++ b/src/renderer/src/ui/components/form/switch/Switch.test.tsx @@ -23,15 +23,42 @@ describe("Switch", () => { expect(getSwitch()).toBePartiallyChecked(); }); - it("marks the track with the matching data-state", () => { + // The reason this is built on Headless UI's Checkbox rather than its Switch: + // ARIA only allows true/false on `role="switch"`, so semi-on needs a checkbox. + it('exposes semi-on as role="checkbox" with aria-checked="mixed"', () => { + render( undefined} />); + + expect(getSwitch()).toHaveAttribute("role", "checkbox"); + expect(getSwitch()).toHaveAttribute("aria-checked", "mixed"); + }); + + it("keeps semi-on even when checked is false, so a master control can show it", () => { + render( + undefined} + />, + ); + expect(getSwitch()).toBePartiallyChecked(); + }); + + // The track styles itself off the attributes Headless UI sets, so those are + // what the appearance of each state actually depends on. + it("marks the track with the state attributes for off, on and semi-on", () => { + const track = () => document.querySelector(".nxm-switch"); + const { rerender } = render(); - expect(document.querySelector(".nxm-switch")).toHaveAttribute("data-state", "off"); + expect(track()).not.toHaveAttribute("data-checked"); + expect(track()).not.toHaveAttribute("data-indeterminate"); rerender(); - expect(document.querySelector(".nxm-switch")).toHaveAttribute("data-state", "on"); + expect(track()).toHaveAttribute("data-checked"); + expect(track()).not.toHaveAttribute("data-indeterminate"); rerender(); - expect(document.querySelector(".nxm-switch")).toHaveAttribute("data-state", "semi-on"); + expect(track()).toHaveAttribute("data-indeterminate"); }); it("calls onChange when clicked", async () => { diff --git a/src/renderer/src/ui/components/form/switch/Switch.tsx b/src/renderer/src/ui/components/form/switch/Switch.tsx index 6707366495..682e6f7d14 100644 --- a/src/renderer/src/ui/components/form/switch/Switch.tsx +++ b/src/renderer/src/ui/components/form/switch/Switch.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useRef, type InputHTMLAttributes } from "react"; +import { Checkbox } from "@headlessui/react"; +import React, { type ComponentProps } from "react"; import { joinClasses } from "@/ui/utils/joinClasses"; @@ -8,58 +9,24 @@ import { joinClasses } from "@/ui/utils/joinClasses"; * Clicking only ever flips between on and off; semi-on is never reached by user * interaction. * - * Implementation: a visually-hidden native `` drives the - * state and accessibility. Setting `indeterminate` makes the browser report - * `aria-checked="mixed"` — that is the `semi-on` state. The visible track/thumb - * are styled spans whose appearance is keyed off `data-state` on the track. + * Built on Headless UI's `Checkbox` rather than its `Switch`, because ARIA only + * allows `aria-checked` to be true/false on `role="switch"` — a switch is binary + * by definition, and Headless UI controls that attribute so `mixed` can't be + * forced onto it. A tri-state master control is the checkbox pattern, and + * `Checkbox` reports `indeterminate` as `aria-checked="mixed"`. * - * PORTING NOTE: once `@headlessui/react` is upgraded to v2 (after the React - * upgrade), reimplement this on top of HeadlessUI's ``. The `nxm-switch` - * classes live on the track/thumb specifically so they can move straight onto - * the Switch's elements. HeadlessUI's Switch is binary, so the tri-state must be - * preserved here in the wrapper — keep driving `data-state` (and the native - * `indeterminate`/`aria-checked="mixed"`) for the `semi-on` state. + * The visible track and thumb are styled off the `data-checked`, + * `data-indeterminate`, `data-disabled`, `data-hover`, `data-active` and + * `data-focus` attributes Headless UI sets from its own state. */ -export type ISwitchProps = Omit, "type"> & { +export type ISwitchProps = Omit, "className"> & { + className?: string; /** Renders the "semi-on" state and reports `aria-checked="mixed"`. */ indeterminate?: boolean; }; -export const Switch = ({ - checked, - className, - disabled, - indeterminate, - ...inputProps -}: ISwitchProps) => { - const inputRef = useRef(null); - - // `indeterminate` is a DOM property, not an attribute, so it must be set - // imperatively. This also drives the `:indeterminate` pseudo-class and makes - // the accessibility tree report `aria-checked="mixed"`. - useEffect(() => { - if (inputRef.current) { - inputRef.current.indeterminate = !!indeterminate; - } - }, [indeterminate]); - - return ( - - ); -}; +export const Switch = ({ className, ...props }: ISwitchProps) => ( + + + +); diff --git a/src/renderer/src/ui/components/listbox/ListboxButton.tsx b/src/renderer/src/ui/components/listbox/ListboxButton.tsx index 0594c8a975..681966576d 100644 --- a/src/renderer/src/ui/components/listbox/ListboxButton.tsx +++ b/src/renderer/src/ui/components/listbox/ListboxButton.tsx @@ -1,4 +1,4 @@ -import { Listbox as HeadlessListbox } from "@headlessui/react"; +import { ListboxButton as HeadlessListboxButton } from "@headlessui/react"; import { mdiUnfoldMoreHorizontal } from "@mdi/js"; import React from "react"; @@ -9,7 +9,7 @@ export type IListboxButtonProps = IButtonProps & { }; export const ListboxButton = ({ showChevron = true, ...props }: IListboxButtonProps) => ( - = ComponentProps & { +export type IListboxOption = ComponentProps & { label: string; value: T; } & XOr<{ iconPath?: string }, { icon?: ReactNode }>; export const ListboxOption = ({ className, icon, iconPath, label, ...props }: IListboxOption) => ( - + {({ active, selected }) => (
}
)} -
+ ); diff --git a/src/renderer/src/ui/components/listbox/ListboxOptions.tsx b/src/renderer/src/ui/components/listbox/ListboxOptions.tsx index 14498616fe..f9fcc14ae8 100644 --- a/src/renderer/src/ui/components/listbox/ListboxOptions.tsx +++ b/src/renderer/src/ui/components/listbox/ListboxOptions.tsx @@ -1,4 +1,4 @@ -import { Listbox as HeadlessListbox } from "@headlessui/react"; +import { ListboxOptions as HeadlessListboxOptions } from "@headlessui/react"; import React, { type ComponentProps } from "react"; import { joinClasses } from "@/ui/utils/joinClasses"; @@ -6,8 +6,9 @@ import { joinClasses } from "@/ui/utils/joinClasses"; export const ListboxOptions = ({ className, ...props -}: ComponentProps) => ( - ) => ( + - + {children} @@ -49,16 +49,16 @@ export const ModalPanel = ({ title, onClose, }: PropsWithChildren) => ( - + {!!title && ( - {title} - + )} {showCloseButton && ( @@ -68,7 +68,7 @@ export const ModalPanel = ({ )} {children} - + ); export const Modal = ({ diff --git a/src/renderer/src/ui/components/picker/Picker.tsx b/src/renderer/src/ui/components/picker/Picker.tsx index 6517162686..bbe51427fd 100644 --- a/src/renderer/src/ui/components/picker/Picker.tsx +++ b/src/renderer/src/ui/components/picker/Picker.tsx @@ -5,8 +5,6 @@ import { ListboxButton, type IListboxButtonProps } from "@/ui/components/listbox import { ListboxOption, type IListboxOption } from "@/ui/components/listbox/ListboxOption"; import { ListboxOptions } from "@/ui/components/listbox/ListboxOptions"; -// todo placement prop should be removed when you use headless ui v2 - interface IPickerProps { button?: IListboxButtonProps; className?: string; @@ -30,7 +28,7 @@ export function Picker({ {selectedOption?.label} - + {options.map(({ ...option }) => ( ))} diff --git a/src/renderer/src/ui/components/popover/Popover.demo.tsx b/src/renderer/src/ui/components/popover/Popover.demo.tsx index 75c87860c1..4df8b0ae86 100644 --- a/src/renderer/src/ui/components/popover/Popover.demo.tsx +++ b/src/renderer/src/ui/components/popover/Popover.demo.tsx @@ -79,7 +79,7 @@ export const PopoverDemo = () => { setShowHidden(e.target.checked)} + onChange={setShowHidden} />
diff --git a/src/renderer/src/ui/components/popover/Popover.tsx b/src/renderer/src/ui/components/popover/Popover.tsx index be4060099c..fad0dd5296 100644 --- a/src/renderer/src/ui/components/popover/Popover.tsx +++ b/src/renderer/src/ui/components/popover/Popover.tsx @@ -3,8 +3,6 @@ import React, { type ComponentProps } from "react"; import { joinClasses } from "@/ui/utils/joinClasses"; -// needs updating to headless v2 so we can have dynamic positioning and proper z-index usage - export const Popover = ({ className, ...props }: ComponentProps) => ( ); diff --git a/src/renderer/src/ui/components/popover/PopoverButton.tsx b/src/renderer/src/ui/components/popover/PopoverButton.tsx index f71303308e..810e5fd880 100644 --- a/src/renderer/src/ui/components/popover/PopoverButton.tsx +++ b/src/renderer/src/ui/components/popover/PopoverButton.tsx @@ -1,4 +1,4 @@ -import { Popover as HeadlessPopover } from "@headlessui/react"; +import { PopoverButton as HeadlessPopoverButton } from "@headlessui/react"; import React, { forwardRef } from "react"; import { Button, type IButtonProps } from "@/ui/components/button/Button"; @@ -6,7 +6,7 @@ import { Button, type IButtonProps } from "@/ui/components/button/Button"; export type IPopoverButtonProps = IButtonProps; /** - * Popover trigger button. Renders a Button as the Headless UI `Popover.Button`, + * Popover trigger button. Renders a Button as the Headless UI `PopoverButton`, * so it takes all the same props as Button. Place it inside a `Popover` * alongside a `PopoverPanel`. * @@ -14,7 +14,7 @@ export type IPopoverButtonProps = IButtonProps; * directly. */ export const PopoverButton = forwardRef((props, ref) => ( - + )); PopoverButton.displayName = "PopoverButton"; diff --git a/src/renderer/src/ui/components/popover/PopoverPanel.tsx b/src/renderer/src/ui/components/popover/PopoverPanel.tsx index 512c49f11a..dc284e1638 100644 --- a/src/renderer/src/ui/components/popover/PopoverPanel.tsx +++ b/src/renderer/src/ui/components/popover/PopoverPanel.tsx @@ -1,4 +1,4 @@ -import { Popover as HeadlessPopover } from "@headlessui/react"; +import { PopoverPanel as HeadlessPopoverPanel } from "@headlessui/react"; import React, { type ComponentProps } from "react"; import { joinClasses } from "@/ui/utils/joinClasses"; @@ -6,12 +6,19 @@ import { joinClasses } from "@/ui/utils/joinClasses"; /** * Floating panel for a Popover. Unlike a Dropdown's menu, this holds arbitrary * interactive content (pickers, switches, buttons) and stays open until an - * outside click or Escape. Positioned manually (absolute) until Headless UI v2 - * gives us dynamic anchor positioning. + * outside click or Escape. + * + * `anchor` hands positioning to Headless UI's Floating UI integration, which also + * portals the panel — so it escapes any clipping ancestor and flips itself when + * there's no room below. Pass `anchor` to place it elsewhere. */ export const PopoverPanel = ({ className, ...props -}: ComponentProps) => ( - +}: ComponentProps) => ( + ); diff --git a/src/renderer/src/ui/components/table/Table.demo.tsx b/src/renderer/src/ui/components/table/Table.demo.tsx index e1d2292187..3cacec9da4 100644 --- a/src/renderer/src/ui/components/table/Table.demo.tsx +++ b/src/renderer/src/ui/components/table/Table.demo.tsx @@ -79,7 +79,7 @@ const ActionsCell = ({ mod }: { mod: IDemoMod }) => { setEnabled(event.target.checked)} + onChange={setEnabled} /> ) : (