@primitiv-ui/react 0.1.7 → 0.1.9
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primitiv-ui/react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Headless, accessible React components built on the WAI-ARIA authoring patterns. Zero styles ship with this package — bring your own (CSS, Tailwind, CSS-in-JS, design tokens).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
package/src/Tabs/README.md
CHANGED
|
@@ -107,4 +107,5 @@ pattern (child handler runs first, then the trigger's):
|
|
|
107
107
|
|
|
108
108
|
Both `data-state` (`"active"` | `"inactive"`) and
|
|
109
109
|
`data-orientation` (`"horizontal"` | `"vertical"`) are available on
|
|
110
|
-
every rendered element.
|
|
110
|
+
every rendered element. `Tabs.Trigger` also sets `data-disabled=""`
|
|
111
|
+
when disabled (omitted otherwise), matching the rest of the library.
|
package/src/Tabs/Tabs.tsx
CHANGED
|
@@ -238,6 +238,8 @@ TabsList.displayName = "TabsList";
|
|
|
238
238
|
* **Styling hooks.**
|
|
239
239
|
* - `data-state="active" | "inactive"` on the rendered element.
|
|
240
240
|
* - `data-orientation="horizontal" | "vertical"`.
|
|
241
|
+
* - `data-disabled=""` when disabled (omitted otherwise), so CSS can target
|
|
242
|
+
* `[data-disabled]` without the `:disabled` pseudo-class.
|
|
241
243
|
*
|
|
242
244
|
* @example Basic usage
|
|
243
245
|
* ```tsx
|
|
@@ -297,7 +299,7 @@ export function TabsTrigger<T extends HTMLElement = HTMLButtonElement>({
|
|
|
297
299
|
"aria-controls": panelId,
|
|
298
300
|
"aria-selected": isActive,
|
|
299
301
|
"aria-disabled": disabled,
|
|
300
|
-
"data-disabled": disabled,
|
|
302
|
+
"data-disabled": disabled ? "" : undefined,
|
|
301
303
|
"data-orientation": orientation,
|
|
302
304
|
"data-state": state,
|
|
303
305
|
tabIndex,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { render, screen } from "@testing-library/react";
|
|
6
|
+
|
|
7
|
+
import { Tabs } from "../Tabs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Auto-verification of the Tabs styling contract (RFC 0004 §3.4 / D15). The
|
|
11
|
+
* `data-*` half of `registry/components/tabs/contract.json` is *derived from and
|
|
12
|
+
* asserted against the rendered headless component* so it can never drift from
|
|
13
|
+
* what the component actually emits. Tabs is the first **structural** compound:
|
|
14
|
+
* its data-* surface is spread across the root container and the `list` /
|
|
15
|
+
* `trigger` / `content` subcomponents, so the guard walks each part by its ARIA
|
|
16
|
+
* role. The authored half (BEM classes, modifiers, custom properties) is a
|
|
17
|
+
* styling convention the headless layer does not emit and is checked by the
|
|
18
|
+
* generator drift-guards instead.
|
|
19
|
+
*/
|
|
20
|
+
const contractPath = resolve(
|
|
21
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
22
|
+
"../../../../../registry/components/tabs/contract.json",
|
|
23
|
+
);
|
|
24
|
+
const contract = JSON.parse(readFileSync(contractPath, "utf8"));
|
|
25
|
+
|
|
26
|
+
type DataAttribute = { name: string; value: string; when: string; source: string };
|
|
27
|
+
|
|
28
|
+
/** Every `data-*` attribute name present on an element, sorted and de-duped. */
|
|
29
|
+
function dataAttributeNames(element: Element): string[] {
|
|
30
|
+
return [
|
|
31
|
+
...new Set(
|
|
32
|
+
[...element.attributes]
|
|
33
|
+
.map((attribute) => attribute.name)
|
|
34
|
+
.filter((name) => name.startsWith("data-")),
|
|
35
|
+
),
|
|
36
|
+
].sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The distinct auto-verified data-* names a part's contract declares. */
|
|
40
|
+
function contractedNames(dataAttributes: DataAttribute[]): string[] {
|
|
41
|
+
return [
|
|
42
|
+
...new Set(
|
|
43
|
+
dataAttributes
|
|
44
|
+
.filter((attribute) => attribute.source === "auto")
|
|
45
|
+
.map((attribute) => attribute.name),
|
|
46
|
+
),
|
|
47
|
+
].sort();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const subcontract = (name: string): { dataAttributes: DataAttribute[] } =>
|
|
51
|
+
contract.subcomponents.find((sub: { name: string }) => sub.name === name);
|
|
52
|
+
|
|
53
|
+
function renderTabs(orientation: "horizontal" | "vertical" = "horizontal") {
|
|
54
|
+
return render(
|
|
55
|
+
<Tabs.Root defaultValue="a" orientation={orientation}>
|
|
56
|
+
<Tabs.List label="Sections">
|
|
57
|
+
<Tabs.Trigger value="a">A</Tabs.Trigger>
|
|
58
|
+
<Tabs.Trigger value="b" disabled>
|
|
59
|
+
B
|
|
60
|
+
</Tabs.Trigger>
|
|
61
|
+
</Tabs.List>
|
|
62
|
+
<Tabs.Content value="a">Panel A</Tabs.Content>
|
|
63
|
+
<Tabs.Content value="b">Panel B</Tabs.Content>
|
|
64
|
+
</Tabs.Root>,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("Tabs styling contract", () => {
|
|
69
|
+
it("declares every data-* attribute on every part as auto-verified", () => {
|
|
70
|
+
const all: DataAttribute[] = [
|
|
71
|
+
...contract.dataAttributes,
|
|
72
|
+
...contract.subcomponents.flatMap(
|
|
73
|
+
(sub: { dataAttributes: DataAttribute[] }) => sub.dataAttributes,
|
|
74
|
+
),
|
|
75
|
+
];
|
|
76
|
+
expect(all.every((attribute) => attribute.source === "auto")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("root emits exactly its contracted data-* names", () => {
|
|
80
|
+
const { container } = renderTabs();
|
|
81
|
+
const root = container.firstElementChild as HTMLElement;
|
|
82
|
+
expect(dataAttributeNames(root)).toEqual(contractedNames(contract.dataAttributes));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("list emits exactly its contracted data-* names", () => {
|
|
86
|
+
renderTabs();
|
|
87
|
+
expect(dataAttributeNames(screen.getByRole("tablist"))).toEqual(
|
|
88
|
+
contractedNames(subcontract("list").dataAttributes),
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("trigger emits exactly its contracted data-* names across its states", () => {
|
|
93
|
+
renderTabs();
|
|
94
|
+
const emitted = new Set<string>();
|
|
95
|
+
for (const trigger of screen.getAllByRole("tab")) {
|
|
96
|
+
for (const name of dataAttributeNames(trigger)) emitted.add(name);
|
|
97
|
+
}
|
|
98
|
+
expect([...emitted].sort()).toEqual(
|
|
99
|
+
contractedNames(subcontract("trigger").dataAttributes),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("content emits exactly its contracted data-* names across its states", () => {
|
|
104
|
+
renderTabs();
|
|
105
|
+
const emitted = new Set<string>();
|
|
106
|
+
for (const panel of screen.getAllByRole("tabpanel", { hidden: true })) {
|
|
107
|
+
for (const name of dataAttributeNames(panel)) emitted.add(name);
|
|
108
|
+
}
|
|
109
|
+
expect([...emitted].sort()).toEqual(
|
|
110
|
+
contractedNames(subcontract("content").dataAttributes),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("emits data-state with the documented value on trigger and content", () => {
|
|
115
|
+
renderTabs();
|
|
116
|
+
const [activeTrigger, inactiveTrigger] = screen.getAllByRole("tab");
|
|
117
|
+
expect(activeTrigger).toHaveAttribute("data-state", "active");
|
|
118
|
+
expect(inactiveTrigger).toHaveAttribute("data-state", "inactive");
|
|
119
|
+
|
|
120
|
+
const panels = screen.getAllByRole("tabpanel", { hidden: true });
|
|
121
|
+
expect(panels[0]).toHaveAttribute("data-state", "active");
|
|
122
|
+
expect(panels[1]).toHaveAttribute("data-state", "inactive");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("emits data-orientation with the documented value on every part", () => {
|
|
126
|
+
const { container, unmount } = renderTabs("vertical");
|
|
127
|
+
expect(container.firstElementChild).toHaveAttribute("data-orientation", "vertical");
|
|
128
|
+
expect(screen.getByRole("tablist")).toHaveAttribute("data-orientation", "vertical");
|
|
129
|
+
expect(screen.getAllByRole("tab")[0]).toHaveAttribute("data-orientation", "vertical");
|
|
130
|
+
expect(screen.getAllByRole("tabpanel", { hidden: true })[0]).toHaveAttribute(
|
|
131
|
+
"data-orientation",
|
|
132
|
+
"vertical",
|
|
133
|
+
);
|
|
134
|
+
unmount();
|
|
135
|
+
|
|
136
|
+
renderTabs("horizontal");
|
|
137
|
+
expect(screen.getAllByRole("tab")[0]).toHaveAttribute(
|
|
138
|
+
"data-orientation",
|
|
139
|
+
"horizontal",
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("emits data-disabled on the trigger exactly as the contract documents it", () => {
|
|
144
|
+
const entry = subcontract("trigger").dataAttributes.find(
|
|
145
|
+
(attribute) => attribute.name === "data-disabled",
|
|
146
|
+
)!;
|
|
147
|
+
renderTabs();
|
|
148
|
+
const [enabledTrigger, disabledTrigger] = screen.getAllByRole("tab");
|
|
149
|
+
// Button/Switch convention: present as "" when disabled, omitted otherwise.
|
|
150
|
+
expect(disabledTrigger).toHaveAttribute("data-disabled", entry.value);
|
|
151
|
+
expect(enabledTrigger).not.toHaveAttribute("data-disabled");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -61,7 +61,7 @@ describe("Tabs disabled tabs tests", () => {
|
|
|
61
61
|
expect(firstTab).toHaveAttribute("aria-disabled", "true");
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
it("should
|
|
64
|
+
it("should not set the data-disabled attribute by default", () => {
|
|
65
65
|
// Arrange
|
|
66
66
|
render(
|
|
67
67
|
<Tabs.Root defaultValue="tab1">
|
|
@@ -75,11 +75,11 @@ describe("Tabs disabled tabs tests", () => {
|
|
|
75
75
|
);
|
|
76
76
|
const firstTab = screen.getByRole("tab", { name: "Tab 1" });
|
|
77
77
|
|
|
78
|
-
// Assert
|
|
79
|
-
expect(firstTab).toHaveAttribute("data-disabled"
|
|
78
|
+
// Assert — matches the Button/Switch convention: omitted when enabled.
|
|
79
|
+
expect(firstTab).not.toHaveAttribute("data-disabled");
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it("should
|
|
82
|
+
it("should set data-disabled to an empty string when disabled prop is provided", () => {
|
|
83
83
|
// Arrange
|
|
84
84
|
render(
|
|
85
85
|
<Tabs.Root defaultValue="tab1">
|
|
@@ -95,8 +95,8 @@ describe("Tabs disabled tabs tests", () => {
|
|
|
95
95
|
);
|
|
96
96
|
const firstTab = screen.getByRole("tab", { name: "Tab 1" });
|
|
97
97
|
|
|
98
|
-
// Assert
|
|
99
|
-
expect(firstTab).toHaveAttribute("data-disabled", "
|
|
98
|
+
// Assert — matches the Button/Switch convention: "" when disabled.
|
|
99
|
+
expect(firstTab).toHaveAttribute("data-disabled", "");
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
it("should not change the currently active panel when clicking on a disabled tab", async () => {
|