@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.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/Checkbox/__tests__/Checkbox.contract.test.tsx +117 -0
- package/src/Field/__tests__/Field.contract.test.tsx +87 -0
- package/src/Input/__tests__/Input.contract.test.tsx +77 -0
- package/src/InputGroup/__tests__/InputGroup.contract.test.tsx +60 -0
- package/src/Radio/README.md +157 -0
- package/src/Radio/Radio.tsx +213 -0
- package/src/Radio/RadioContext.ts +10 -0
- package/src/Radio/__tests__/Radio.asChild.test.tsx +63 -0
- package/src/Radio/__tests__/Radio.basic-rendering.test.tsx +41 -0
- package/src/Radio/__tests__/Radio.contract.test.tsx +112 -0
- package/src/Radio/__tests__/Radio.controlled-state.test.tsx +69 -0
- package/src/Radio/__tests__/Radio.disabled.test.tsx +23 -0
- package/src/Radio/__tests__/Radio.indicator.test.tsx +103 -0
- package/src/Radio/__tests__/Radio.uncontrolled-state.test.tsx +89 -0
- package/src/Radio/hooks/index.ts +2 -0
- package/src/Radio/hooks/useRadioContext.ts +1 -0
- package/src/Radio/hooks/useRadioRoot.ts +32 -0
- package/src/Radio/index.ts +2 -0
- package/src/Radio/types.ts +56 -0
- package/src/index.ts +1 -0
|
@@ -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 @@
|
|
|
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,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";
|