@primitiv-ui/react 0.1.15 → 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/Checkbox/Checkbox.tsx +96 -120
- package/src/Checkbox/CheckboxContext.ts +8 -0
- package/src/Checkbox/README.md +69 -88
- package/src/Checkbox/__tests__/Checkbox.basic-rendering.test.tsx +48 -10
- package/src/Checkbox/__tests__/Checkbox.contract.test.tsx +29 -40
- package/src/Checkbox/__tests__/Checkbox.controlled-state.test.tsx +16 -24
- package/src/Checkbox/__tests__/Checkbox.disabled.test.tsx +33 -3
- package/src/Checkbox/__tests__/Checkbox.form.test.tsx +53 -0
- package/src/Checkbox/__tests__/Checkbox.indeterminate.test.tsx +28 -34
- package/src/Checkbox/__tests__/Checkbox.indicator.test.tsx +27 -52
- package/src/Checkbox/__tests__/Checkbox.uncontrolled-state.test.tsx +33 -17
- package/src/Checkbox/hooks/index.ts +1 -0
- package/src/Checkbox/hooks/useCheckboxInput.ts +49 -0
- package/src/Checkbox/types.ts +24 -17
- package/src/Radio/README.md +89 -86
- package/src/Radio/Radio.tsx +87 -98
- package/src/Radio/RadioContext.ts +9 -0
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +34 -9
- package/src/Radio/__tests__/Radio.contract.test.tsx +30 -36
- package/src/Radio/__tests__/Radio.controlled-state.test.tsx +21 -11
- package/src/Radio/__tests__/Radio.disabled.test.tsx +31 -6
- package/src/Radio/__tests__/Radio.native-grouping.test.tsx +77 -0
- package/src/Radio/__tests__/Radio.parts.test.tsx +76 -0
- package/src/Radio/__tests__/Radio.uncontrolled-state.test.tsx +39 -32
- package/src/Radio/hooks/useRadioRoot.ts +32 -16
- package/src/Radio/types.ts +30 -16
- package/src/Switch/README.md +45 -57
- package/src/Switch/Switch.tsx +78 -65
- package/src/Switch/SwitchContext.ts +3 -1
- package/src/Switch/__tests__/Switch.asChild.test.tsx +16 -34
- package/src/Switch/__tests__/Switch.basic-rendering.test.tsx +47 -28
- package/src/Switch/__tests__/Switch.contract.test.tsx +21 -16
- package/src/Switch/__tests__/Switch.controlled-state.test.tsx +10 -12
- package/src/Switch/__tests__/Switch.disabled.test.tsx +12 -17
- package/src/Switch/__tests__/Switch.form.test.tsx +55 -0
- package/src/Switch/__tests__/Switch.keyboard-interaction.test.tsx +4 -4
- package/src/Switch/__tests__/Switch.uncontrolled-state.test.tsx +55 -20
- package/src/Switch/hooks/useSwitchRoot.ts +29 -11
- package/src/Switch/types.ts +20 -10
- package/src/Checkbox/__tests__/Checkbox.asChild.test.tsx +0 -69
- package/src/Radio/__tests__/Radio.asChild.test.tsx +0 -63
- package/src/Radio/__tests__/Radio.indicator.test.tsx +0 -103
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
|
|
4
|
+
import { Radio } from "../Radio";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The behaviour this whole redesign exists to fix: three standalone radios
|
|
8
|
+
* sharing a `name` must form a *native* radio group, where the browser — not
|
|
9
|
+
* React — enforces single-selection and silently deselects the others. The
|
|
10
|
+
* old `<button role="radio">` could never do this; a real `<input
|
|
11
|
+
* type="radio">` does it for free.
|
|
12
|
+
*/
|
|
13
|
+
describe("Radio native grouping", () => {
|
|
14
|
+
function DensityGroup() {
|
|
15
|
+
return (
|
|
16
|
+
<fieldset>
|
|
17
|
+
{["compact", "comfortable", "spacious"].map((value) => (
|
|
18
|
+
<Radio.Root key={value} name="density" value={value} aria-label={value} />
|
|
19
|
+
))}
|
|
20
|
+
</fieldset>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
it("enforces single-selection across siblings sharing a name", async () => {
|
|
25
|
+
// Arrange
|
|
26
|
+
const user = userEvent.setup();
|
|
27
|
+
render(<DensityGroup />);
|
|
28
|
+
const compact = screen.getByRole("radio", { name: "compact" });
|
|
29
|
+
const comfortable = screen.getByRole("radio", { name: "comfortable" });
|
|
30
|
+
const spacious = screen.getByRole("radio", { name: "spacious" });
|
|
31
|
+
|
|
32
|
+
// Act: select the first
|
|
33
|
+
await user.click(compact);
|
|
34
|
+
|
|
35
|
+
// Assert: only it is checked
|
|
36
|
+
expect(compact).toBeChecked();
|
|
37
|
+
expect(comfortable).not.toBeChecked();
|
|
38
|
+
expect(spacious).not.toBeChecked();
|
|
39
|
+
|
|
40
|
+
// Act: select a sibling — the browser deselects the first
|
|
41
|
+
await user.click(comfortable);
|
|
42
|
+
|
|
43
|
+
// Assert: selection moved; no two radios are checked at once
|
|
44
|
+
expect(compact).not.toBeChecked();
|
|
45
|
+
expect(comfortable).toBeChecked();
|
|
46
|
+
expect(spacious).not.toBeChecked();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("submits the selected radio's value with an enclosing form", async () => {
|
|
50
|
+
// Arrange
|
|
51
|
+
const user = userEvent.setup();
|
|
52
|
+
const submitted: string[] = [];
|
|
53
|
+
function Form() {
|
|
54
|
+
return (
|
|
55
|
+
<form
|
|
56
|
+
onSubmit={(event) => {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
const data = new FormData(event.currentTarget);
|
|
59
|
+
submitted.push(String(data.get("density")));
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<Radio.Root name="density" value="compact" aria-label="compact" />
|
|
63
|
+
<Radio.Root name="density" value="spacious" aria-label="spacious" />
|
|
64
|
+
<button type="submit">Save</button>
|
|
65
|
+
</form>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
render(<Form />);
|
|
69
|
+
|
|
70
|
+
// Act
|
|
71
|
+
await user.click(screen.getByRole("radio", { name: "spacious" }));
|
|
72
|
+
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
73
|
+
|
|
74
|
+
// Assert
|
|
75
|
+
expect(submitted).toEqual(["spacious"]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
|
|
4
|
+
import { Radio } from "../Radio";
|
|
5
|
+
|
|
6
|
+
describe("Radio.Indicator", () => {
|
|
7
|
+
it("is always mounted, regardless of checked state", () => {
|
|
8
|
+
// Arrange & Act — unchecked, yet the dot is in the DOM (CSS hides it).
|
|
9
|
+
render(
|
|
10
|
+
<Radio.Root aria-label="Compact">
|
|
11
|
+
<Radio.Indicator data-testid="dot" />
|
|
12
|
+
</Radio.Root>,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
// Assert
|
|
16
|
+
expect(screen.getByTestId("dot")).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("is decorative, carrying aria-hidden", () => {
|
|
20
|
+
// Arrange & Act
|
|
21
|
+
render(
|
|
22
|
+
<Radio.Root aria-label="Compact">
|
|
23
|
+
<Radio.Indicator data-testid="dot" />
|
|
24
|
+
</Radio.Root>,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Assert
|
|
28
|
+
expect(screen.getByTestId("dot")).toHaveAttribute("aria-hidden", "true");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("mirrors the radio's data-state for CSS to key off", async () => {
|
|
32
|
+
// Arrange
|
|
33
|
+
const user = userEvent.setup();
|
|
34
|
+
render(
|
|
35
|
+
<Radio.Root aria-label="Compact">
|
|
36
|
+
<Radio.Indicator data-testid="dot" />
|
|
37
|
+
</Radio.Root>,
|
|
38
|
+
);
|
|
39
|
+
const dot = screen.getByTestId("dot");
|
|
40
|
+
expect(dot).toHaveAttribute("data-state", "unchecked");
|
|
41
|
+
|
|
42
|
+
// Act
|
|
43
|
+
await user.click(screen.getByRole("radio", { name: "Compact" }));
|
|
44
|
+
|
|
45
|
+
// Assert
|
|
46
|
+
expect(dot).toHaveAttribute("data-state", "checked");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("delegates to the consumer's element (e.g. an svg dot) via asChild", () => {
|
|
50
|
+
// Arrange & Act
|
|
51
|
+
render(
|
|
52
|
+
<Radio.Root defaultChecked aria-label="Compact">
|
|
53
|
+
<Radio.Indicator asChild>
|
|
54
|
+
<svg data-testid="dot" viewBox="0 0 10 10">
|
|
55
|
+
<circle cx="5" cy="5" r="3" />
|
|
56
|
+
</svg>
|
|
57
|
+
</Radio.Indicator>
|
|
58
|
+
</Radio.Root>,
|
|
59
|
+
);
|
|
60
|
+
const dot = screen.getByTestId("dot");
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
63
|
+
expect(dot.tagName.toLowerCase()).toBe("svg");
|
|
64
|
+
expect(dot).toHaveAttribute("data-state", "checked");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("throws when rendered outside Radio.Root", () => {
|
|
68
|
+
// Arrange
|
|
69
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
72
|
+
expect(() => render(<Radio.Indicator />)).toThrow();
|
|
73
|
+
|
|
74
|
+
error.mockRestore();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -7,14 +7,12 @@ describe("Radio uncontrolled state", () => {
|
|
|
7
7
|
it("starts checked when defaultChecked is true", () => {
|
|
8
8
|
// Arrange & Act
|
|
9
9
|
render(<Radio.Root defaultChecked aria-label="Compact" />);
|
|
10
|
-
const radio = screen.getByRole("radio", { name: "Compact" });
|
|
11
10
|
|
|
12
11
|
// Assert
|
|
13
|
-
expect(
|
|
14
|
-
expect(radio).toHaveAttribute("data-state", "checked");
|
|
12
|
+
expect(screen.getByRole("radio", { name: "Compact" })).toBeChecked();
|
|
15
13
|
});
|
|
16
14
|
|
|
17
|
-
it("
|
|
15
|
+
it("checks on click", async () => {
|
|
18
16
|
// Arrange
|
|
19
17
|
const user = userEvent.setup();
|
|
20
18
|
render(<Radio.Root aria-label="Compact" />);
|
|
@@ -24,57 +22,47 @@ describe("Radio uncontrolled state", () => {
|
|
|
24
22
|
await user.click(radio);
|
|
25
23
|
|
|
26
24
|
// Assert
|
|
27
|
-
expect(radio).
|
|
28
|
-
expect(radio).toHaveAttribute("data-state", "checked");
|
|
25
|
+
expect(radio).toBeChecked();
|
|
29
26
|
});
|
|
30
27
|
|
|
31
|
-
it("
|
|
28
|
+
it("calls onCheckedChange with true when it becomes selected", async () => {
|
|
32
29
|
// Arrange
|
|
33
30
|
const user = userEvent.setup();
|
|
34
31
|
const onCheckedChange = vi.fn();
|
|
35
|
-
render(
|
|
36
|
-
<Radio.Root
|
|
37
|
-
defaultChecked
|
|
38
|
-
onCheckedChange={onCheckedChange}
|
|
39
|
-
aria-label="Compact"
|
|
40
|
-
/>,
|
|
41
|
-
);
|
|
42
|
-
const radio = screen.getByRole("radio", { name: "Compact" });
|
|
32
|
+
render(<Radio.Root onCheckedChange={onCheckedChange} aria-label="Compact" />);
|
|
43
33
|
|
|
44
34
|
// Act
|
|
45
|
-
await user.click(radio);
|
|
35
|
+
await user.click(screen.getByRole("radio", { name: "Compact" }));
|
|
46
36
|
|
|
47
|
-
// Assert
|
|
48
|
-
expect(
|
|
49
|
-
expect(
|
|
50
|
-
expect(onCheckedChange).not.toHaveBeenCalled();
|
|
37
|
+
// Assert
|
|
38
|
+
expect(onCheckedChange).toHaveBeenCalledTimes(1);
|
|
39
|
+
expect(onCheckedChange).toHaveBeenCalledWith(true);
|
|
51
40
|
});
|
|
52
41
|
|
|
53
|
-
it("
|
|
42
|
+
it("updates the data-state mirror on the wrapper when selected", async () => {
|
|
54
43
|
// Arrange
|
|
55
44
|
const user = userEvent.setup();
|
|
56
|
-
const
|
|
57
|
-
render(<Radio.Root onCheckedChange={onCheckedChange} aria-label="Compact" />);
|
|
58
|
-
const radio = screen.getByRole("radio", { name: "Compact" });
|
|
45
|
+
const { container } = render(<Radio.Root aria-label="Compact" />);
|
|
59
46
|
|
|
60
47
|
// Act
|
|
61
|
-
await user.click(radio);
|
|
62
|
-
await user.click(radio);
|
|
48
|
+
await user.click(screen.getByRole("radio", { name: "Compact" }));
|
|
63
49
|
|
|
64
|
-
// Assert
|
|
65
|
-
expect(
|
|
66
|
-
|
|
50
|
+
// Assert
|
|
51
|
+
expect(container.querySelector("label")).toHaveAttribute(
|
|
52
|
+
"data-state",
|
|
53
|
+
"checked",
|
|
54
|
+
);
|
|
67
55
|
});
|
|
68
56
|
|
|
69
|
-
it("composes
|
|
57
|
+
it("composes a consumer onChange, running it before the internal handler", async () => {
|
|
70
58
|
// Arrange
|
|
71
59
|
const user = userEvent.setup();
|
|
72
60
|
const order: string[] = [];
|
|
73
|
-
const
|
|
61
|
+
const onChange = vi.fn(() => order.push("consumer"));
|
|
74
62
|
const onCheckedChange = vi.fn(() => order.push("internal"));
|
|
75
63
|
render(
|
|
76
64
|
<Radio.Root
|
|
77
|
-
|
|
65
|
+
onChange={onChange}
|
|
78
66
|
onCheckedChange={onCheckedChange}
|
|
79
67
|
aria-label="Compact"
|
|
80
68
|
/>,
|
|
@@ -86,4 +74,23 @@ describe("Radio uncontrolled state", () => {
|
|
|
86
74
|
// Assert
|
|
87
75
|
expect(order).toEqual(["consumer", "internal"]);
|
|
88
76
|
});
|
|
77
|
+
|
|
78
|
+
it("lets a consumer onChange veto the internal handler via preventDefault", async () => {
|
|
79
|
+
// Arrange
|
|
80
|
+
const user = userEvent.setup();
|
|
81
|
+
const onCheckedChange = vi.fn();
|
|
82
|
+
render(
|
|
83
|
+
<Radio.Root
|
|
84
|
+
onChange={(event) => event.preventDefault()}
|
|
85
|
+
onCheckedChange={onCheckedChange}
|
|
86
|
+
aria-label="Compact"
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Act
|
|
91
|
+
await user.click(screen.getByRole("radio", { name: "Compact" }));
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
94
|
+
expect(onCheckedChange).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
89
96
|
});
|
|
@@ -1,32 +1,48 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { ChangeEvent } from "react";
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
+
import type { RadioChangeHandler } from "../types";
|
|
4
5
|
|
|
5
6
|
type UseRadioRootArgs = {
|
|
6
7
|
defaultChecked?: boolean;
|
|
7
8
|
checked?: boolean;
|
|
8
9
|
onCheckedChange?: (checked: boolean) => void;
|
|
10
|
+
onChange?: RadioChangeHandler;
|
|
9
11
|
};
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Resolves the controlled / uncontrolled state for a {@link Radio.Root} and
|
|
15
|
+
* produces the props to spread onto its native `<input type="radio">`.
|
|
16
|
+
*
|
|
17
|
+
* The key correctness move: in **uncontrolled** mode the input is rendered
|
|
18
|
+
* with `defaultChecked` and *no* `checked` prop, so the browser owns the
|
|
19
|
+
* value and native `name`-grouping (including the silent deselection of
|
|
20
|
+
* siblings) works. The returned `checked` is only a best-effort mirror used
|
|
21
|
+
* for the `data-state` styling hook — it is updated from the input's own
|
|
22
|
+
* `change` events, and may lag behind a sibling-driven deselect, which the
|
|
23
|
+
* CSS `:checked` selector handles instead.
|
|
24
|
+
*/
|
|
11
25
|
export function useRadioRoot({
|
|
12
26
|
defaultChecked,
|
|
13
27
|
checked: controlledChecked,
|
|
14
28
|
onCheckedChange,
|
|
29
|
+
onChange,
|
|
15
30
|
}: UseRadioRootArgs) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
);
|
|
31
|
+
const isControlled = controlledChecked !== undefined;
|
|
32
|
+
const [mirror, setMirror] = useState(defaultChecked ?? false);
|
|
33
|
+
const checked = isControlled ? controlledChecked : mirror;
|
|
20
34
|
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
onCheckedChange?.(true);
|
|
29
|
-
}, [checked, setChecked, onCheckedChange]);
|
|
35
|
+
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
|
36
|
+
onChange?.(event);
|
|
37
|
+
if (event.defaultPrevented) return;
|
|
38
|
+
const next = event.target.checked;
|
|
39
|
+
if (!isControlled) setMirror(next);
|
|
40
|
+
onCheckedChange?.(next);
|
|
41
|
+
};
|
|
30
42
|
|
|
31
|
-
|
|
43
|
+
const inputStateProps = isControlled
|
|
44
|
+
? { checked: controlledChecked }
|
|
45
|
+
: { defaultChecked: defaultChecked ?? false };
|
|
46
|
+
|
|
47
|
+
return { checked, handleChange, inputStateProps };
|
|
32
48
|
}
|
package/src/Radio/types.ts
CHANGED
|
@@ -1,44 +1,55 @@
|
|
|
1
|
-
import { ComponentProps, ReactNode, Ref } from "react";
|
|
1
|
+
import { ChangeEventHandler, ComponentProps, ReactNode, Ref } from "react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Props for {@link Radio.Indicator} —
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
4
|
+
* Props for {@link Radio.Indicator} — the decorative dot. All `<span>`
|
|
5
|
+
* attributes plus the `asChild` escape hatch. The indicator is always mounted;
|
|
6
|
+
* its visibility is a CSS concern, driven off the input's native `:checked`
|
|
7
|
+
* state, so it stays correct even when the browser silently deselects a
|
|
8
|
+
* grouped sibling (which fires no React event).
|
|
7
9
|
*/
|
|
8
10
|
export type RadioIndicatorProps = ComponentProps<"span"> & {
|
|
9
11
|
children?: ReactNode;
|
|
10
|
-
forceMount?: boolean;
|
|
11
12
|
asChild?: boolean;
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Shared base for both {@link RadioRootProps} variants — the native
|
|
16
|
-
* `<
|
|
17
|
-
* `
|
|
17
|
+
* `<input type="radio">` attributes (minus the ones the component owns) plus
|
|
18
|
+
* the `onCheckedChange` convenience callback. `className` / `style` here style
|
|
19
|
+
* the **wrapper** (the visible control), not the hidden input; everything
|
|
20
|
+
* else spreads onto the input, because semantically the Root *is* the radio.
|
|
18
21
|
*/
|
|
19
22
|
export type RadioRootBaseProps = Omit<
|
|
20
|
-
ComponentProps<"
|
|
21
|
-
"type" | "
|
|
23
|
+
ComponentProps<"input">,
|
|
24
|
+
"type" | "checked" | "defaultChecked"
|
|
22
25
|
> & {
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Fired with the new checked value whenever this radio becomes selected.
|
|
28
|
+
* A native radio only ever fires `change` when it moves *into* the checked
|
|
29
|
+
* state, so this is always called with `true`.
|
|
30
|
+
*/
|
|
31
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
32
|
+
children?: ReactNode;
|
|
33
|
+
/** Forwarded to the underlying native `<input type="radio">`. */
|
|
34
|
+
ref?: Ref<HTMLInputElement>;
|
|
25
35
|
};
|
|
26
36
|
|
|
27
37
|
/**
|
|
28
|
-
* Uncontrolled variant of {@link RadioRootProps}: the
|
|
29
|
-
* checked value
|
|
30
|
-
*
|
|
38
|
+
* Uncontrolled variant of {@link RadioRootProps}: the **browser** owns the
|
|
39
|
+
* checked value (the input is rendered with `defaultChecked` and no `checked`
|
|
40
|
+
* prop), so native `name`-grouping — including the silent deselection of
|
|
41
|
+
* siblings — works for free. Pass `defaultChecked` (or omit it); `checked` is
|
|
42
|
+
* forbidden.
|
|
31
43
|
*/
|
|
32
44
|
export type RadioRootUncontrolledProps = RadioRootBaseProps & {
|
|
33
45
|
defaultChecked?: boolean;
|
|
34
46
|
checked?: never;
|
|
35
|
-
onCheckedChange?: (checked: boolean) => void;
|
|
36
47
|
};
|
|
37
48
|
|
|
38
49
|
/**
|
|
39
50
|
* Controlled variant of {@link RadioRootProps}: the parent owns the checked
|
|
40
51
|
* value. Pass `checked` and `onCheckedChange` together; `defaultChecked` is
|
|
41
|
-
* forbidden.
|
|
52
|
+
* forbidden. The consumer owns grouping in this mode.
|
|
42
53
|
*/
|
|
43
54
|
export type RadioRootControlledProps = RadioRootBaseProps & {
|
|
44
55
|
defaultChecked?: never;
|
|
@@ -54,3 +65,6 @@ export type RadioRootControlledProps = RadioRootBaseProps & {
|
|
|
54
65
|
export type RadioRootProps =
|
|
55
66
|
| RadioRootUncontrolledProps
|
|
56
67
|
| RadioRootControlledProps;
|
|
68
|
+
|
|
69
|
+
/** Internal: the native `onChange` shape the Root composes with its own. */
|
|
70
|
+
export type RadioChangeHandler = ChangeEventHandler<HTMLInputElement>;
|
package/src/Switch/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# Switch
|
|
2
2
|
|
|
3
|
-
Headless, accessible **Switch** — a compound component
|
|
4
|
-
|
|
5
|
-
on
|
|
6
|
-
|
|
3
|
+
Headless, accessible **Switch** — a compound component built on a **real,
|
|
4
|
+
visually-hidden native `<input type="checkbox" role="switch">`**. Semantically
|
|
5
|
+
represents an immediate on/off action (as opposed to a selection choice), and —
|
|
6
|
+
being a genuine native input — participates in forms. Zero styles ship.
|
|
7
7
|
|
|
8
8
|
```tsx
|
|
9
9
|
import { Switch } from "@primitiv-ui/react";
|
|
10
10
|
|
|
11
|
-
<Switch.Root defaultChecked aria-label="Enable notifications">
|
|
11
|
+
<Switch.Root name="notify" value="on" defaultChecked aria-label="Enable notifications">
|
|
12
12
|
<Switch.Thumb />
|
|
13
13
|
</Switch.Root>
|
|
14
14
|
```
|
|
@@ -17,18 +17,23 @@ import { Switch } from "@primitiv-ui/react";
|
|
|
17
17
|
|
|
18
18
|
| Export | Element | ARIA / data hooks | `asChild` |
|
|
19
19
|
|--------|---------|------------------|-----------|
|
|
20
|
-
| `Switch.Root` | `<
|
|
20
|
+
| `Switch.Root` | `<label>` + hidden `<input>` | input is `role="switch"`; `data-state`, `data-disabled` on the label | — |
|
|
21
21
|
| `Switch.Thumb` | `<span>` | `aria-hidden="true"`, `data-state` | yes |
|
|
22
22
|
|
|
23
|
+
`Switch.Root`'s `className` / `style` style the **track** (the `<label>` you
|
|
24
|
+
see). Every other prop spreads onto the hidden `<input>`, because semantically
|
|
25
|
+
the Root *is* the switch: pass `name`, `value`, `id`, `required`, `aria-*`,
|
|
26
|
+
`ref`, etc. straight to `Switch.Root`.
|
|
27
|
+
|
|
23
28
|
## State modes
|
|
24
29
|
|
|
25
30
|
### Uncontrolled
|
|
26
31
|
|
|
27
|
-
Pass `defaultChecked` (or omit for
|
|
28
|
-
the
|
|
32
|
+
Pass `defaultChecked` (or omit for off on mount). The **browser** owns the
|
|
33
|
+
value, so the switch participates in forms and resets.
|
|
29
34
|
|
|
30
35
|
```tsx
|
|
31
|
-
<Switch.Root defaultChecked aria-label="Enable dark mode">
|
|
36
|
+
<Switch.Root name="dark" value="on" defaultChecked aria-label="Enable dark mode">
|
|
32
37
|
<Switch.Thumb />
|
|
33
38
|
</Switch.Root>
|
|
34
39
|
```
|
|
@@ -45,18 +50,24 @@ const [enabled, setEnabled] = useState(false);
|
|
|
45
50
|
</Switch.Root>
|
|
46
51
|
```
|
|
47
52
|
|
|
53
|
+
> **`data-state` is a best-effort mirror.** Drive the *visual* on/off look off
|
|
54
|
+
> the input's native `:checked` (and `:has(> input:checked)` on the track), as
|
|
55
|
+
> the shipped registry stylesheet does — it stays correct through a form reset,
|
|
56
|
+
> which fires no React event.
|
|
57
|
+
|
|
48
58
|
## Keyboard interaction
|
|
49
59
|
|
|
50
60
|
| Key | Behaviour |
|
|
51
61
|
|-----|-----------|
|
|
52
|
-
| `Space` | Toggle the switch (native
|
|
53
|
-
| `Enter` | Toggle the switch (native `<button>` behaviour) |
|
|
62
|
+
| `Space` | Toggle the switch (native checkbox behaviour) |
|
|
54
63
|
| `Tab` | Move focus to or from the switch |
|
|
55
64
|
|
|
65
|
+
`Enter` does **not** toggle — a checkbox-based control responds to `Space` only.
|
|
66
|
+
|
|
56
67
|
## Disabled
|
|
57
68
|
|
|
58
69
|
Pass `disabled` on the Root. The native attribute suppresses clicks and removes
|
|
59
|
-
the switch from the focus ring. `data-disabled=""` is set for CSS
|
|
70
|
+
the switch from the focus ring. `data-disabled=""` is set on the track for CSS.
|
|
60
71
|
|
|
61
72
|
```tsx
|
|
62
73
|
<Switch.Root aria-label="Enable feature" disabled>
|
|
@@ -67,15 +78,7 @@ the switch from the focus ring. `data-disabled=""` is set for CSS targeting.
|
|
|
67
78
|
## Size
|
|
68
79
|
|
|
69
80
|
The styled surface installed by `primitiv add switch` exposes a `size` prop.
|
|
70
|
-
The default is `md
|
|
71
|
-
|
|
72
|
-
| Value | Description |
|
|
73
|
-
|-------|-------------|
|
|
74
|
-
| `xs` | Extra small |
|
|
75
|
-
| `sm` | Small |
|
|
76
|
-
| `md` | Medium (default) |
|
|
77
|
-
| `lg` | Large |
|
|
78
|
-
| `xl` | Extra large |
|
|
81
|
+
The default is `md` (`xs` · `sm` · `md` · `lg` · `xl`).
|
|
79
82
|
|
|
80
83
|
```tsx
|
|
81
84
|
<Switch size="sm" aria-label="Enable notifications" />
|
|
@@ -83,56 +86,41 @@ The default is `md`.
|
|
|
83
86
|
|
|
84
87
|
## The Thumb
|
|
85
88
|
|
|
86
|
-
`Switch.Thumb` is **always mounted
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
constrained to pseudo-elements.
|
|
89
|
+
`Switch.Thumb` is **always mounted**. Its visual position (off → on) should be
|
|
90
|
+
driven by CSS keyed off the input's native `:checked` (a sibling selector), so
|
|
91
|
+
it stays correct through a form reset. It gives consumers a real DOM node to
|
|
92
|
+
animate with `transition` or Web Animations.
|
|
91
93
|
|
|
92
|
-
```
|
|
93
|
-
.switch
|
|
94
|
+
```css
|
|
95
|
+
.switch {
|
|
94
96
|
position: relative;
|
|
95
|
-
width: 2.75rem;
|
|
96
|
-
height: 1.5rem;
|
|
97
|
-
border-radius: 9999px;
|
|
98
|
-
background: #d1d5db;
|
|
99
|
-
transition: background 120ms ease;
|
|
100
|
-
|
|
101
|
-
&[data-state="checked"] {
|
|
102
|
-
background: #6366f1;
|
|
103
|
-
}
|
|
104
97
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
left: 0.125rem;
|
|
110
|
-
width: 1.25rem;
|
|
111
|
-
height: 1.25rem;
|
|
112
|
-
border-radius: 50%;
|
|
113
|
-
background: white;
|
|
98
|
+
.switch:has(> input:checked) {
|
|
99
|
+
background: #6366f1;
|
|
100
|
+
}
|
|
101
|
+
.switch__thumb {
|
|
114
102
|
transition: translate 120ms ease;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
103
|
+
}
|
|
104
|
+
.switch > input:checked ~ .switch__thumb {
|
|
105
|
+
translate: 1.25rem 0;
|
|
119
106
|
}
|
|
120
107
|
```
|
|
121
108
|
|
|
122
109
|
## `asChild` composition
|
|
123
110
|
|
|
124
|
-
|
|
125
|
-
|
|
111
|
+
`Switch.Thumb` accepts `asChild`: the library's `aria-hidden`, `data-state`, and
|
|
112
|
+
ref are merged onto the consumer's element.
|
|
126
113
|
|
|
127
114
|
```tsx
|
|
128
|
-
<Switch.
|
|
129
|
-
<
|
|
130
|
-
</Switch.
|
|
115
|
+
<Switch.Thumb asChild>
|
|
116
|
+
<span className="my-thumb" />
|
|
117
|
+
</Switch.Thumb>
|
|
131
118
|
```
|
|
132
119
|
|
|
133
120
|
## Styling hooks
|
|
134
121
|
|
|
135
122
|
| Attribute | Values | Set on |
|
|
136
123
|
|-----------|--------|--------|
|
|
137
|
-
| `data-state` | `"checked"` \| `"unchecked"` | `Switch.Root
|
|
138
|
-
| `data-disabled` | `""` (present when disabled) | `Switch.Root` |
|
|
124
|
+
| `data-state` | `"checked"` \| `"unchecked"` | `Switch.Root` (track), `Switch.Thumb` |
|
|
125
|
+
| `data-disabled` | `""` (present when disabled) | `Switch.Root` (track) |
|
|
126
|
+
| `:checked` / `:focus-visible` / `:disabled` | native | the hidden `<input>` |
|