@primitiv-ui/react 0.1.12 → 0.1.14

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.
@@ -0,0 +1,89 @@
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 uncontrolled state", () => {
7
+ it("starts checked when defaultChecked is true", () => {
8
+ // Arrange & Act
9
+ render(<Radio.Root defaultChecked aria-label="Compact" />);
10
+ const radio = screen.getByRole("radio", { name: "Compact" });
11
+
12
+ // Assert
13
+ expect(radio).toHaveAttribute("aria-checked", "true");
14
+ expect(radio).toHaveAttribute("data-state", "checked");
15
+ });
16
+
17
+ it("selects on click from unchecked to checked", async () => {
18
+ // Arrange
19
+ const user = userEvent.setup();
20
+ render(<Radio.Root aria-label="Compact" />);
21
+ const radio = screen.getByRole("radio", { name: "Compact" });
22
+
23
+ // Act
24
+ await user.click(radio);
25
+
26
+ // Assert
27
+ expect(radio).toHaveAttribute("aria-checked", "true");
28
+ expect(radio).toHaveAttribute("data-state", "checked");
29
+ });
30
+
31
+ it("is a no-op when an already-selected radio is clicked (no toggle-off)", async () => {
32
+ // Arrange
33
+ const user = userEvent.setup();
34
+ 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" });
43
+
44
+ // Act
45
+ await user.click(radio);
46
+
47
+ // Assert: stays checked and the callback never fires for a re-select.
48
+ expect(radio).toHaveAttribute("aria-checked", "true");
49
+ expect(radio).toHaveAttribute("data-state", "checked");
50
+ expect(onCheckedChange).not.toHaveBeenCalled();
51
+ });
52
+
53
+ it("calls onCheckedChange with true the first time it is selected", async () => {
54
+ // Arrange
55
+ const user = userEvent.setup();
56
+ const onCheckedChange = vi.fn();
57
+ render(<Radio.Root onCheckedChange={onCheckedChange} aria-label="Compact" />);
58
+ const radio = screen.getByRole("radio", { name: "Compact" });
59
+
60
+ // Act
61
+ await user.click(radio);
62
+ await user.click(radio);
63
+
64
+ // Assert: selection is one-way, so the second click is swallowed.
65
+ expect(onCheckedChange).toHaveBeenCalledTimes(1);
66
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
67
+ });
68
+
69
+ it("composes the consumer's onClick with the internal select (consumer runs first)", async () => {
70
+ // Arrange
71
+ const user = userEvent.setup();
72
+ const order: string[] = [];
73
+ const onClick = vi.fn(() => order.push("consumer"));
74
+ const onCheckedChange = vi.fn(() => order.push("internal"));
75
+ render(
76
+ <Radio.Root
77
+ onClick={onClick}
78
+ onCheckedChange={onCheckedChange}
79
+ aria-label="Compact"
80
+ />,
81
+ );
82
+
83
+ // Act
84
+ await user.click(screen.getByRole("radio", { name: "Compact" }));
85
+
86
+ // Assert
87
+ expect(order).toEqual(["consumer", "internal"]);
88
+ });
89
+ });
@@ -0,0 +1,2 @@
1
+ export { useRadioRoot } from "./useRadioRoot";
2
+ export { useRadioContext } from "./useRadioContext";
@@ -0,0 +1 @@
1
+ export { useRadioContext } from "../RadioContext";
@@ -0,0 +1,32 @@
1
+ import { useCallback } from "react";
2
+
3
+ import { useControllableState } from "../../hooks/index.ts";
4
+
5
+ type UseRadioRootArgs = {
6
+ defaultChecked?: boolean;
7
+ checked?: boolean;
8
+ onCheckedChange?: (checked: boolean) => void;
9
+ };
10
+
11
+ export function useRadioRoot({
12
+ defaultChecked,
13
+ checked: controlledChecked,
14
+ onCheckedChange,
15
+ }: UseRadioRootArgs) {
16
+ const [checked, setChecked] = useControllableState<boolean>(
17
+ controlledChecked,
18
+ defaultChecked ?? false,
19
+ );
20
+
21
+ const select = useCallback(() => {
22
+ // A radio only ever moves *into* the selected state — re-clicking the
23
+ // already-selected radio is a no-op (no toggle-off), per the WAI-ARIA
24
+ // radio convention. De-selection happens when a sibling is chosen, which
25
+ // is the grouping consumer's concern, not this lone control's.
26
+ if (checked) return;
27
+ setChecked(true);
28
+ onCheckedChange?.(true);
29
+ }, [checked, setChecked, onCheckedChange]);
30
+
31
+ return { checked, select };
32
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./Radio";
2
+ export * from "./types";
@@ -0,0 +1,56 @@
1
+ import { ComponentProps, ReactNode, Ref } from "react";
2
+
3
+ /**
4
+ * Props for {@link Radio.Indicator} — all `<span>` attributes plus
5
+ * `forceMount` (keep mounted while unchecked for exit animations) and the
6
+ * `asChild` escape hatch.
7
+ */
8
+ export type RadioIndicatorProps = ComponentProps<"span"> & {
9
+ children?: ReactNode;
10
+ forceMount?: boolean;
11
+ asChild?: boolean;
12
+ };
13
+
14
+ /**
15
+ * Shared base for both {@link RadioRootProps} variants — the native
16
+ * `<button>` attributes (minus the ones the component owns) plus the
17
+ * `asChild` escape hatch and a typed `ref`.
18
+ */
19
+ export type RadioRootBaseProps = Omit<
20
+ ComponentProps<"button">,
21
+ "type" | "role" | "aria-checked" | "defaultChecked"
22
+ > & {
23
+ asChild?: boolean;
24
+ ref?: Ref<HTMLButtonElement>;
25
+ };
26
+
27
+ /**
28
+ * Uncontrolled variant of {@link RadioRootProps}: the component owns the
29
+ * checked value. Pass `defaultChecked` (or omit it); `onCheckedChange` is
30
+ * optional and `checked` is forbidden.
31
+ */
32
+ export type RadioRootUncontrolledProps = RadioRootBaseProps & {
33
+ defaultChecked?: boolean;
34
+ checked?: never;
35
+ onCheckedChange?: (checked: boolean) => void;
36
+ };
37
+
38
+ /**
39
+ * Controlled variant of {@link RadioRootProps}: the parent owns the checked
40
+ * value. Pass `checked` and `onCheckedChange` together; `defaultChecked` is
41
+ * forbidden.
42
+ */
43
+ export type RadioRootControlledProps = RadioRootBaseProps & {
44
+ defaultChecked?: never;
45
+ checked: boolean;
46
+ onCheckedChange: (checked: boolean) => void;
47
+ };
48
+
49
+ /**
50
+ * Props for {@link Radio.Root}. A discriminated union of
51
+ * {@link RadioRootUncontrolledProps} and {@link RadioRootControlledProps},
52
+ * so TypeScript accepts exactly one state mode.
53
+ */
54
+ export type RadioRootProps =
55
+ | RadioRootUncontrolledProps
56
+ | RadioRootControlledProps;
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export * from "./MillerColumns/index.ts";
21
21
  export * from "./Modal/index.ts";
22
22
  export * from "./Portal/index.ts";
23
23
  export * from "./Progress/index.ts";
24
+ export * from "./Radio/index.ts";
24
25
  export * from "./RadioCard/index.ts";
25
26
  export * from "./RadioGroup/index.ts";
26
27
  export * from "./Select/index.ts";