- 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}
/>
) : (