@kinetixui/ui 0.23.3 → 0.24.0

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.
Files changed (80) hide show
  1. package/README.md +134 -0
  2. package/dist/index.d.ts +212 -31
  3. package/dist/index.js +1466 -1118
  4. package/dist/kx-src-hash.json +3 -0
  5. package/package.json +7 -7
  6. package/src/components/accordion.tsx +1 -1
  7. package/src/components/alert-dialog.tsx +7 -5
  8. package/src/components/avatar.tsx +4 -1
  9. package/src/components/banner.tsx +9 -3
  10. package/src/components/breadcrumb.tsx +1 -1
  11. package/src/components/button.tsx +1 -1
  12. package/src/components/card.tsx +77 -6
  13. package/src/components/checkbox.tsx +42 -8
  14. package/src/components/collapsible.tsx +41 -1
  15. package/src/components/command.tsx +2 -2
  16. package/src/components/context-menu.tsx +21 -13
  17. package/src/components/dialog.tsx +5 -4
  18. package/src/components/direction-provider.tsx +85 -1
  19. package/src/components/drawer.tsx +62 -18
  20. package/src/components/dropdown-menu.tsx +12 -4
  21. package/src/components/file-upload.tsx +1 -1
  22. package/src/components/hover-card.tsx +1 -1
  23. package/src/components/inform.tsx +9 -3
  24. package/src/components/input-group.tsx +27 -5
  25. package/src/components/input-otp.tsx +48 -5
  26. package/src/components/input.tsx +20 -3
  27. package/src/components/json-viewer.tsx +1 -1
  28. package/src/components/menubar.tsx +21 -15
  29. package/src/components/modal.tsx +3 -2
  30. package/src/components/multi-select.tsx +14 -5
  31. package/src/components/native-select.tsx +10 -2
  32. package/src/components/navigation-bar.tsx +1 -1
  33. package/src/components/navigation-menu.tsx +6 -6
  34. package/src/components/number-input.tsx +28 -10
  35. package/src/components/pagination.tsx +2 -2
  36. package/src/components/popover.tsx +29 -5
  37. package/src/components/radio-group.tsx +23 -8
  38. package/src/components/segmented-control.tsx +20 -4
  39. package/src/components/select.tsx +25 -8
  40. package/src/components/sheet.tsx +4 -3
  41. package/src/components/spinner.tsx +1 -1
  42. package/src/components/switch.tsx +31 -8
  43. package/src/components/table.tsx +121 -8
  44. package/src/components/tabs.tsx +29 -4
  45. package/src/components/tag.tsx +1 -1
  46. package/src/components/textarea.tsx +20 -3
  47. package/src/components/tooltip.tsx +14 -4
  48. package/src/components/tour.tsx +22 -7
  49. package/src/components/tree-view.tsx +1 -1
  50. package/src/components-behavior.test.tsx +74 -0
  51. package/src/components-direction-portal.test.tsx +482 -0
  52. package/src/components-form.test.tsx +435 -0
  53. package/src/components-icons.test.tsx +197 -0
  54. package/src/components-keyboard.test.tsx +542 -2
  55. package/src/components-overlay.test.tsx +381 -0
  56. package/src/components-rtl.test.tsx +258 -0
  57. package/src/components-selection.test.tsx +207 -0
  58. package/src/components-table-scroll.test.tsx +363 -0
  59. package/src/index.ts +2 -0
  60. package/src/navigation-menu-focus.test.tsx +35 -0
  61. package/src/stories/Card.stories.tsx +81 -1
  62. package/src/stories/Checkbox.stories.tsx +23 -1
  63. package/src/stories/ContextMenu.stories.tsx +1 -1
  64. package/src/stories/Input.stories.tsx +34 -0
  65. package/src/stories/InputGroup.stories.tsx +96 -2
  66. package/src/stories/InputOTP.stories.tsx +24 -1
  67. package/src/stories/MultiSelect.stories.tsx +25 -1
  68. package/src/stories/NativeSelect.stories.tsx +24 -1
  69. package/src/stories/NumberInput.stories.tsx +20 -1
  70. package/src/stories/Progress.stories.tsx +1 -1
  71. package/src/stories/RadioGroup.stories.tsx +33 -1
  72. package/src/stories/SegmentedControl.stories.tsx +21 -1
  73. package/src/stories/Select.stories.tsx +28 -1
  74. package/src/stories/Slider.stories.tsx +1 -1
  75. package/src/stories/Switch.stories.tsx +37 -1
  76. package/src/stories/Table.stories.tsx +35 -1
  77. package/src/stories/Tabs.stories.tsx +27 -2
  78. package/src/stories/Textarea.stories.tsx +34 -0
  79. package/src/usage/icons.tsx +60 -0
  80. package/tailwind.config.ts +118 -22
@@ -0,0 +1,207 @@
1
+ import * as React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { Checkbox } from "./components/checkbox";
6
+ import { KinetixDirectionProvider } from "./components/direction-provider";
7
+ import { RadioGroup, RadioGroupItem } from "./components/radio-group";
8
+ import { Switch } from "./components/switch";
9
+ import { Toggle } from "./components/toggle";
10
+ import { ToggleGroup, ToggleGroupItem } from "./components/toggle-group";
11
+
12
+ /**
13
+ * components-selection.test.tsx — the selection-control family's state contract.
14
+ *
15
+ * Checkbox, RadioGroup, Switch, Toggle and ToggleGroup are five thin wrappers over five different Radix
16
+ * primitives, and their prop vocabularies differ accordingly: `checked`, `value` and `pressed` are not an
17
+ * inconsistency to be tidied away, they are the primitives saying three different true things. What they
18
+ * DO share is a state contract — controlled stays controlled, uncontrolled keeps its own state, a change
19
+ * handler fires once, and disabled means disabled — and nothing was asserting it.
20
+ *
21
+ * Geometry is not here. jsdom has no layout, so the large-text behaviour this slice fixed is measured in a
22
+ * browser by `scripts/large-text.mjs`; what a unit test can hold is the class contract that makes the
23
+ * measurement come out right, which is why the RTL passage below asserts the mirrored class rather than a
24
+ * pixel offset.
25
+ */
26
+
27
+ // kx-verify: interaction
28
+
29
+ describe("controlled and uncontrolled", () => {
30
+ it("Checkbox: a controlled checkbox does not move on its own, and reports once", async () => {
31
+ const user = userEvent.setup();
32
+ const onCheckedChange = vi.fn();
33
+ render(<Checkbox checked={false} onCheckedChange={onCheckedChange} aria-label="Subscribe" />);
34
+ const box = screen.getByRole("checkbox", { name: "Subscribe" });
35
+
36
+ await user.click(box);
37
+ // The owner did not change `checked`, so the checkbox must still read false — a component that
38
+ // tracked its own state as well would flip here and then fight the next render.
39
+ expect(box).toHaveAttribute("aria-checked", "false");
40
+ expect(onCheckedChange).toHaveBeenCalledTimes(1);
41
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
42
+ });
43
+
44
+ it("Checkbox: an uncontrolled checkbox keeps its own state from defaultChecked", async () => {
45
+ const user = userEvent.setup();
46
+ render(<Checkbox defaultChecked aria-label="Subscribe" />);
47
+ const box = screen.getByRole("checkbox", { name: "Subscribe" });
48
+ expect(box).toHaveAttribute("aria-checked", "true");
49
+ await user.click(box);
50
+ expect(box).toHaveAttribute("aria-checked", "false");
51
+ });
52
+
53
+ it("Checkbox: indeterminate is a third state, not a styled false", () => {
54
+ render(<Checkbox checked="indeterminate" aria-label="All" />);
55
+ expect(screen.getByRole("checkbox", { name: "All" })).toHaveAttribute("aria-checked", "mixed");
56
+ });
57
+
58
+ it("Switch: controlled stays put and reports the requested value", async () => {
59
+ const user = userEvent.setup();
60
+ const onCheckedChange = vi.fn();
61
+ render(<Switch checked={false} onCheckedChange={onCheckedChange} aria-label="Airplane mode" />);
62
+ const sw = screen.getByRole("switch", { name: "Airplane mode" });
63
+ await user.click(sw);
64
+ expect(sw).toHaveAttribute("aria-checked", "false");
65
+ expect(onCheckedChange).toHaveBeenCalledTimes(1);
66
+ expect(onCheckedChange).toHaveBeenCalledWith(true);
67
+ });
68
+
69
+ it("Switch: an external update is reflected without an interaction", () => {
70
+ const { rerender } = render(<Switch checked={false} aria-label="Airplane mode" />);
71
+ expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "false");
72
+ rerender(<Switch checked aria-label="Airplane mode" />);
73
+ expect(screen.getByRole("switch")).toHaveAttribute("aria-checked", "true");
74
+ });
75
+
76
+ it("RadioGroup: controlled selection does not move, and reports the value asked for", async () => {
77
+ const user = userEvent.setup();
78
+ const onValueChange = vi.fn();
79
+ render(
80
+ <RadioGroup value="free" onValueChange={onValueChange} aria-label="Plan">
81
+ <RadioGroupItem value="free" aria-label="Free" />
82
+ <RadioGroupItem value="pro" aria-label="Pro" />
83
+ </RadioGroup>,
84
+ );
85
+ await user.click(screen.getByRole("radio", { name: "Pro" }));
86
+ expect(screen.getByRole("radio", { name: "Free" })).toBeChecked();
87
+ expect(onValueChange).toHaveBeenCalledTimes(1);
88
+ expect(onValueChange).toHaveBeenCalledWith("pro");
89
+ });
90
+
91
+ it("ToggleGroup: uncontrolled multiple keeps a set, and reports the whole set", async () => {
92
+ const user = userEvent.setup();
93
+ const onValueChange = vi.fn();
94
+ render(
95
+ <ToggleGroup type="multiple" defaultValue={["bold"]} onValueChange={onValueChange} aria-label="Formatting">
96
+ <ToggleGroupItem value="bold" aria-label="Bold" />
97
+ <ToggleGroupItem value="italic" aria-label="Italic" />
98
+ </ToggleGroup>,
99
+ );
100
+ expect(screen.getByRole("button", { name: "Bold" })).toHaveAttribute("aria-pressed", "true");
101
+ await user.click(screen.getByRole("button", { name: "Italic" }));
102
+ expect(onValueChange).toHaveBeenCalledWith(["bold", "italic"]);
103
+ });
104
+ });
105
+
106
+ describe("disabled", () => {
107
+ it("every control in the family refuses pointer interaction when disabled", async () => {
108
+ const user = userEvent.setup();
109
+ const spies = { checkbox: vi.fn(), switch: vi.fn(), radio: vi.fn(), toggle: vi.fn() };
110
+ render(
111
+ <>
112
+ <Checkbox disabled onCheckedChange={spies.checkbox} aria-label="Subscribe" />
113
+ <Switch disabled onCheckedChange={spies.switch} aria-label="Airplane mode" />
114
+ <RadioGroup disabled onValueChange={spies.radio} aria-label="Plan">
115
+ <RadioGroupItem value="free" aria-label="Free" />
116
+ </RadioGroup>
117
+ <Toggle disabled onPressedChange={spies.toggle} aria-label="Bold" />
118
+ </>,
119
+ );
120
+
121
+ for (const el of [
122
+ screen.getByRole("checkbox", { name: "Subscribe" }),
123
+ screen.getByRole("switch", { name: "Airplane mode" }),
124
+ screen.getByRole("radio", { name: "Free" }),
125
+ screen.getByRole("button", { name: "Bold" }),
126
+ ]) {
127
+ expect(el).toBeDisabled();
128
+ await user.click(el).catch(() => {
129
+ /* pointer-events: none on a disabled control is itself correct behaviour */
130
+ });
131
+ }
132
+ for (const [name, spy] of Object.entries(spies)) {
133
+ expect(spy, `${name} fired a change while disabled`).not.toHaveBeenCalled();
134
+ }
135
+ });
136
+ });
137
+
138
+ // kx-verify: interaction, rtl
139
+
140
+ /**
141
+ * The Switch was the one control in the family whose movement depends on direction, and it was wrong.
142
+ *
143
+ * `translate-x` is physical. Under `dir="rtl"` the thumb starts against the right edge (correct — that is
144
+ * the start) and then `data-[state=checked]:translate-x-6` moved it further right: measured in Chromium,
145
+ * the thumb's left edge went from 26px to 50px on a 48px track, so a switch turned on in an RTL locale
146
+ * rendered as a filled pill with no thumb visible in it at all.
147
+ *
148
+ * The first fix flipped the translate with Tailwind's `rtl:`, which matches ANY rtl ancestor — so a switch
149
+ * in an LTR section of an RTL page was flipped as well, and its thumb landed 22px outside the track. The
150
+ * travel is now `inset-inline-start`, a logical offset the browser resolves against the switch's own
151
+ * direction.
152
+ *
153
+ * jsdom cannot see that, so what is held here is the class that makes the geometry right. The geometry
154
+ * itself — all four page/subtree direction pairs, checked and unchecked — is measured in Chromium by
155
+ * check:selection-visual; this is what fails first if the logical offset is dropped.
156
+ */
157
+ describe("Switch under RTL", () => {
158
+ const thumbOf = (container: HTMLElement) =>
159
+ container.querySelector("[role=switch] > span") ?? container.querySelector("[role=switch] > *");
160
+
161
+ it("mirrors the thumb's travel instead of pushing it off the track", () => {
162
+ const { container } = render(
163
+ <KinetixDirectionProvider dir="rtl">
164
+ <Switch checked aria-label="Airplane mode" />
165
+ </KinetixDirectionProvider>,
166
+ );
167
+ const cls = thumbOf(container)?.getAttribute("class") ?? "";
168
+ // The travel is a logical offset, so the browser mirrors it against the switch's OWN direction — no
169
+ // ancestor selector decides it. A physical translate flipped by `rtl:` matched any rtl ancestor and pushed
170
+ // the thumb off the track in an LTR section of an RTL page (check:selection-visual, "direction").
171
+ expect(cls, "the thumb must travel along the inline axis").toContain("data-[state=checked]:start-6");
172
+ expect(cls).toContain("relative");
173
+ expect(cls, "no physical travel that rtl: would have to flip").not.toMatch(/translate-x|rtl:/);
174
+ });
175
+
176
+ it("still toggles from the keyboard under RTL", async () => {
177
+ const user = userEvent.setup();
178
+ render(
179
+ <KinetixDirectionProvider dir="rtl">
180
+ <Switch aria-label="Airplane mode" />
181
+ </KinetixDirectionProvider>,
182
+ );
183
+ const sw = screen.getByRole("switch", { name: "Airplane mode" });
184
+ await user.tab();
185
+ expect(sw).toHaveFocus();
186
+ await user.keyboard(" ");
187
+ expect(sw).toHaveAttribute("aria-checked", "true");
188
+ });
189
+
190
+ it("RadioGroup arrow keys follow reading order under RTL", async () => {
191
+ const user = userEvent.setup();
192
+ render(
193
+ <KinetixDirectionProvider dir="rtl">
194
+ <RadioGroup aria-label="Plan">
195
+ <RadioGroupItem value="free" aria-label="Free" />
196
+ <RadioGroupItem value="pro" aria-label="Pro" />
197
+ </RadioGroup>
198
+ </KinetixDirectionProvider>,
199
+ );
200
+ await user.tab();
201
+ expect(screen.getByRole("radio", { name: "Free" })).toHaveFocus();
202
+ // In RTL the next item is to the LEFT, so ArrowLeft advances.
203
+ await user.keyboard("{ArrowLeft>}");
204
+ await user.keyboard("{/ArrowLeft}");
205
+ expect(screen.getByRole("radio", { name: "Pro" })).toHaveFocus();
206
+ });
207
+ });
@@ -0,0 +1,363 @@
1
+ import * as React from "react";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "./components/table";
6
+
7
+ /**
8
+ * components-table-scroll.test.tsx — Table's scroll container becomes keyboard reachable exactly when it
9
+ * can actually scroll, and stays out of the tab order when it cannot.
10
+ *
11
+ * WHAT IS MOCKED, AND WHY IT HAS TO BE
12
+ *
13
+ * jsdom does no layout: every element reports `scrollWidth === clientWidth === 0`, so the real condition
14
+ * this component branches on cannot occur here. Two things are therefore faked, and nothing else:
15
+ *
16
+ * geometry `scrollWidth` / `clientWidth` are defined per element, so a table can be declared
17
+ * wider than its wrapper. This is the input to the decision, not the decision.
18
+ * ResizeObserver the global stub in test/setup.ts silently does nothing, so it can never deliver a
19
+ * second measurement. It is replaced with one that records its callbacks and lets a
20
+ * test fire them, which is how the "dimensions changed" case is exercised at all.
21
+ *
22
+ * So what these tests prove is the COMPONENT'S CONTRACT: given an overflowing measurement, the wrapper
23
+ * becomes focusable and named; given a fitting one, it does not; and a later measurement is acted on.
24
+ * They do not and cannot prove that a real browser decides a real table overflows — that is what the
25
+ * `check:a11y-site` and `check:a11y-browser` gates measure in Chromium, at 320px and at 200% text, and
26
+ * the axe rule `scrollable-region-focusable` is the assertion there.
27
+ */
28
+
29
+ type ROCallback = (entries: ResizeObserverEntry[], observer: ResizeObserver) => void;
30
+ const callbacks: ROCallback[] = [];
31
+ let realRO: typeof ResizeObserver;
32
+
33
+ /** Re-measure everything, the way a real resize or a text-size change would. */
34
+ const resize = () => {
35
+ for (const cb of callbacks) cb([], {} as ResizeObserver);
36
+ };
37
+
38
+ /** Declare a layout jsdom will never compute for us. */
39
+ function setGeometry(el: Element, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) {
40
+ Object.defineProperty(el, "scrollWidth", { value: scrollWidth, configurable: true });
41
+ Object.defineProperty(el, "clientWidth", { value: clientWidth, configurable: true });
42
+ }
43
+
44
+ beforeEach(() => {
45
+ callbacks.length = 0;
46
+ realRO = window.ResizeObserver;
47
+ window.ResizeObserver = class {
48
+ constructor(cb: ROCallback) {
49
+ callbacks.push(cb);
50
+ }
51
+ observe() {}
52
+ unobserve() {}
53
+ disconnect() {
54
+ const i = callbacks.indexOf(this.cb);
55
+ if (i >= 0) callbacks.splice(i, 1);
56
+ }
57
+ declare cb: ROCallback;
58
+ } as unknown as typeof ResizeObserver;
59
+ });
60
+
61
+ afterEach(() => {
62
+ window.ResizeObserver = realRO;
63
+ });
64
+
65
+ /** The wrapper is the scroll container: the element that owns `overflow-auto`. */
66
+ const wrapperOf = (table: HTMLElement) => table.closest("div.overflow-auto") as HTMLElement;
67
+
68
+ function Fixture({ caption }: { caption?: string } = {}) {
69
+ return (
70
+ <Table>
71
+ {caption ? <TableCaption>{caption}</TableCaption> : null}
72
+ <TableHeader>
73
+ <TableRow>
74
+ <TableHead>Invoice</TableHead>
75
+ <TableHead>Status</TableHead>
76
+ </TableRow>
77
+ </TableHeader>
78
+ <TableBody>
79
+ <TableRow>
80
+ <TableCell>INV-001</TableCell>
81
+ <TableCell>Paid</TableCell>
82
+ </TableRow>
83
+ </TableBody>
84
+ </Table>
85
+ );
86
+ }
87
+
88
+ describe("Table keeps its semantics", () => {
89
+ it("still renders a real table, not a div with a role", () => {
90
+ render(<Fixture />);
91
+ const table = screen.getByRole("table");
92
+ expect(table.tagName).toBe("TABLE");
93
+ // The semantics a screen reader navigates by are the element's own, not ARIA stand-ins.
94
+ expect(table).not.toHaveAttribute("role");
95
+ expect(screen.getByRole("columnheader", { name: "Invoice" }).tagName).toBe("TH");
96
+ expect(screen.getByRole("cell", { name: "INV-001" }).tagName).toBe("TD");
97
+ expect(screen.getAllByRole("row")).toHaveLength(2);
98
+ });
99
+
100
+ it("puts a caption inside the table, where the table owns it", () => {
101
+ render(<Fixture caption="Q3 invoices" />);
102
+ const caption = screen.getByText("Q3 invoices");
103
+ expect(caption.tagName).toBe("CAPTION");
104
+ expect(caption.parentElement?.tagName).toBe("TABLE");
105
+ });
106
+ });
107
+
108
+ describe("the scroll container is reachable only when it scrolls", () => {
109
+ it("adds no tab stop when the table fits", async () => {
110
+ render(<Fixture />);
111
+ const wrapper = wrapperOf(screen.getByRole("table"));
112
+ setGeometry(wrapper, { scrollWidth: 400, clientWidth: 400 });
113
+ resize();
114
+ await waitFor(() => expect(wrapper).not.toHaveAttribute("tabindex"));
115
+ expect(wrapper).not.toHaveAttribute("role");
116
+ expect(wrapper).not.toHaveAttribute("aria-labelledby");
117
+ });
118
+
119
+ it("ignores a sub-pixel difference rather than minting a useless focus stop", async () => {
120
+ render(<Fixture />);
121
+ const wrapper = wrapperOf(screen.getByRole("table"));
122
+ // Rounding routinely produces a fraction of a pixel on a table that cannot actually scroll.
123
+ setGeometry(wrapper, { scrollWidth: 400.6, clientWidth: 400 });
124
+ resize();
125
+ await waitFor(() => expect(wrapper).not.toHaveAttribute("tabindex"));
126
+ });
127
+
128
+ it("becomes focusable when the table is wider than the container", async () => {
129
+ render(<Fixture />);
130
+ const wrapper = wrapperOf(screen.getByRole("table"));
131
+ setGeometry(wrapper, { scrollWidth: 900, clientWidth: 400 });
132
+ resize();
133
+ await waitFor(() => expect(wrapper).toHaveAttribute("tabindex", "0"));
134
+ });
135
+
136
+ it("acts on a later measurement, so a resize or a text-size change is not missed", async () => {
137
+ render(<Fixture />);
138
+ const wrapper = wrapperOf(screen.getByRole("table"));
139
+ setGeometry(wrapper, { scrollWidth: 400, clientWidth: 400 });
140
+ resize();
141
+ await waitFor(() => expect(wrapper).not.toHaveAttribute("tabindex"));
142
+
143
+ // The reader doubles their text size: the table grows, the wrapper does not.
144
+ setGeometry(wrapper, { scrollWidth: 820, clientWidth: 400 });
145
+ resize();
146
+ await waitFor(() => expect(wrapper).toHaveAttribute("tabindex", "0"));
147
+
148
+ // …and back again, so the state is not one-way.
149
+ setGeometry(wrapper, { scrollWidth: 400, clientWidth: 400 });
150
+ resize();
151
+ await waitFor(() => expect(wrapper).not.toHaveAttribute("tabindex"));
152
+ });
153
+
154
+ it("is reachable by Tab once scrollable, and does not trap focus", async () => {
155
+ const user = userEvent.setup();
156
+ render(
157
+ <>
158
+ <button type="button">before</button>
159
+ <Fixture />
160
+ <button type="button">after</button>
161
+ </>,
162
+ );
163
+ const wrapper = wrapperOf(screen.getByRole("table"));
164
+ setGeometry(wrapper, { scrollWidth: 900, clientWidth: 400 });
165
+ resize();
166
+ await waitFor(() => expect(wrapper).toHaveAttribute("tabindex", "0"));
167
+
168
+ await user.tab();
169
+ expect(screen.getByRole("button", { name: "before" })).toHaveFocus();
170
+ await user.tab();
171
+ expect(wrapper).toHaveFocus();
172
+ // Focus order continues past it rather than being captured.
173
+ await user.tab();
174
+ expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
175
+ });
176
+
177
+ it("stops being a tab stop again when it no longer scrolls", async () => {
178
+ const user = userEvent.setup();
179
+ render(
180
+ <>
181
+ <button type="button">before</button>
182
+ <Fixture />
183
+ <button type="button">after</button>
184
+ </>,
185
+ );
186
+ const wrapper = wrapperOf(screen.getByRole("table"));
187
+ setGeometry(wrapper, { scrollWidth: 400, clientWidth: 400 });
188
+ resize();
189
+ await waitFor(() => expect(wrapper).not.toHaveAttribute("tabindex"));
190
+
191
+ await user.tab();
192
+ await user.tab();
193
+ expect(screen.getByRole("button", { name: "after" })).toHaveFocus();
194
+ });
195
+ });
196
+
197
+ describe("the scroll container is named by the table's own caption", () => {
198
+ it("points aria-labelledby at the caption when there is one", async () => {
199
+ render(<Fixture caption="Q3 invoices" />);
200
+ const wrapper = wrapperOf(screen.getByRole("table"));
201
+ setGeometry(wrapper, { scrollWidth: 900, clientWidth: 400 });
202
+ resize();
203
+ await waitFor(() => expect(wrapper).toHaveAttribute("tabindex", "0"));
204
+
205
+ const caption = screen.getByText("Q3 invoices");
206
+ expect(caption.id).toBeTruthy();
207
+ expect(wrapper).toHaveAttribute("aria-labelledby", caption.id);
208
+ // `group`, not `region`: a landmark per table would clutter the landmark list.
209
+ expect(wrapper).toHaveAttribute("role", "group");
210
+ expect(wrapper).toHaveAccessibleName("Q3 invoices");
211
+ });
212
+
213
+ it("invents no name when the table has no caption", async () => {
214
+ render(<Fixture />);
215
+ const wrapper = wrapperOf(screen.getByRole("table"));
216
+ setGeometry(wrapper, { scrollWidth: 900, clientWidth: 400 });
217
+ resize();
218
+ await waitFor(() => expect(wrapper).toHaveAttribute("tabindex", "0"));
219
+ // Reachable, but not announced as a generic "scrollable table" region it cannot describe.
220
+ expect(wrapper).not.toHaveAttribute("role");
221
+ expect(wrapper).not.toHaveAttribute("aria-labelledby");
222
+ });
223
+
224
+ it("lets a caller's own caption id win", async () => {
225
+ render(
226
+ <Table>
227
+ <TableCaption id="mine">Q3 invoices</TableCaption>
228
+ <TableBody>
229
+ <TableRow>
230
+ <TableCell>INV-001</TableCell>
231
+ </TableRow>
232
+ </TableBody>
233
+ </Table>,
234
+ );
235
+ const wrapper = wrapperOf(screen.getByRole("table"));
236
+ setGeometry(wrapper, { scrollWidth: 900, clientWidth: 400 });
237
+ resize();
238
+ await waitFor(() => expect(wrapper).toHaveAttribute("aria-labelledby", "mine"));
239
+ expect(screen.getByText("Q3 invoices").id).toBe("mine");
240
+ });
241
+
242
+ it("gives two tables on one page distinct caption ids", () => {
243
+ render(
244
+ <>
245
+ <Fixture caption="First" />
246
+ <Fixture caption="Second" />
247
+ </>,
248
+ );
249
+ const a = screen.getByText("First").id;
250
+ const b = screen.getByText("Second").id;
251
+ expect(a).toBeTruthy();
252
+ expect(b).toBeTruthy();
253
+ expect(a).not.toBe(b);
254
+ });
255
+ });
256
+
257
+ describe("the existing public contract is unchanged", () => {
258
+ it("still forwards className to the table, not the wrapper", () => {
259
+ render(
260
+ <Table className="my-table">
261
+ <TableBody>
262
+ <TableRow>
263
+ <TableCell>x</TableCell>
264
+ </TableRow>
265
+ </TableBody>
266
+ </Table>,
267
+ );
268
+ const table = screen.getByRole("table");
269
+ expect(table).toHaveClass("my-table");
270
+ // The wrapper keeps owning the scrolling, and does not absorb the caller's class.
271
+ expect(wrapperOf(table)).not.toHaveClass("my-table");
272
+ expect(wrapperOf(table)).toHaveClass("overflow-auto");
273
+ });
274
+
275
+ it("still forwards the ref to the table element", () => {
276
+ const ref = React.createRef<HTMLTableElement>();
277
+ render(
278
+ <Table ref={ref}>
279
+ <TableBody>
280
+ <TableRow>
281
+ <TableCell>x</TableCell>
282
+ </TableRow>
283
+ </TableBody>
284
+ </Table>,
285
+ );
286
+ expect(ref.current).toBeInstanceOf(HTMLTableElement);
287
+ expect(ref.current).toBe(screen.getByRole("table"));
288
+ });
289
+
290
+ it("still spreads arbitrary props onto the table", () => {
291
+ render(
292
+ <Table aria-label="Invoices" data-testid="t">
293
+ <TableBody>
294
+ <TableRow>
295
+ <TableCell>x</TableCell>
296
+ </TableRow>
297
+ </TableBody>
298
+ </Table>,
299
+ );
300
+ const table = screen.getByRole("table", { name: "Invoices" });
301
+ expect(table).toHaveAttribute("data-testid", "t");
302
+ });
303
+
304
+ it("carries the library's focus ring rather than a new one", () => {
305
+ render(<Fixture />);
306
+ expect(wrapperOf(screen.getByRole("table")).className).toContain("focus-visible:ring-ring");
307
+ });
308
+ });
309
+
310
+ describe("the label reference survives a caption id change", () => {
311
+ it("follows a caller's id when it changes without the table resizing", async () => {
312
+ function Fixture2({ captionId }: { captionId: string }) {
313
+ return (
314
+ <Table>
315
+ <TableCaption id={captionId}>Q3 invoices</TableCaption>
316
+ <TableBody>
317
+ <TableRow>
318
+ <TableCell>INV-001</TableCell>
319
+ </TableRow>
320
+ </TableBody>
321
+ </Table>
322
+ );
323
+ }
324
+ const { rerender } = render(<Fixture2 captionId="first" />);
325
+ const wrapper = wrapperOf(screen.getByRole("table"));
326
+ setGeometry(wrapper, { scrollWidth: 900, clientWidth: 400 });
327
+ resize();
328
+ await waitFor(() => expect(wrapper).toHaveAttribute("aria-labelledby", "first"));
329
+
330
+ // The id changes but nothing moves — no resize, so no ResizeObserver callback. A cached id read
331
+ // only during measurement would be left pointing at an element that no longer exists.
332
+ rerender(<Fixture2 captionId="second" />);
333
+ await waitFor(() => expect(wrapper).toHaveAttribute("aria-labelledby", "second"));
334
+ expect(screen.getByText("Q3 invoices").id).toBe("second");
335
+ expect(wrapper).toHaveAccessibleName("Q3 invoices");
336
+ });
337
+
338
+ it("drops the name when the caption goes away without a resize", async () => {
339
+ function Fixture3({ withCaption }: { withCaption: boolean }) {
340
+ return (
341
+ <Table>
342
+ {withCaption ? <TableCaption>Q3 invoices</TableCaption> : null}
343
+ <TableBody>
344
+ <TableRow>
345
+ <TableCell>INV-001</TableCell>
346
+ </TableRow>
347
+ </TableBody>
348
+ </Table>
349
+ );
350
+ }
351
+ const { rerender } = render(<Fixture3 withCaption />);
352
+ const wrapper = wrapperOf(screen.getByRole("table"));
353
+ setGeometry(wrapper, { scrollWidth: 900, clientWidth: 400 });
354
+ resize();
355
+ await waitFor(() => expect(wrapper).toHaveAttribute("role", "group"));
356
+
357
+ rerender(<Fixture3 withCaption={false} />);
358
+ // Still reachable — it still scrolls — but no longer claiming a name that is not in the document.
359
+ await waitFor(() => expect(wrapper).not.toHaveAttribute("aria-labelledby"));
360
+ expect(wrapper).not.toHaveAttribute("role");
361
+ expect(wrapper).toHaveAttribute("tabindex", "0");
362
+ });
363
+ });
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  export { AspectRatio } from "./components/aspect-ratio";
27
27
  export {
28
28
  KinetixDirectionProvider,
29
+ useKinetixPortalContainer,
29
30
  type KinetixDirectionProviderProps,
30
31
  } from "./components/direction-provider";
31
32
  export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, type AvatarGroupProps } from "./components/avatar";
@@ -46,6 +47,7 @@ export {
46
47
  CardTitle,
47
48
  CardDescription,
48
49
  CardContent,
50
+ type CardProps,
49
51
  } from "./components/card";
50
52
  export { Checkbox } from "./components/checkbox";
51
53
  export { Collapsible, CollapsibleTrigger, CollapsibleContent } from "./components/collapsible";
@@ -0,0 +1,35 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ NavigationMenu,
5
+ NavigationMenuItem,
6
+ NavigationMenuList,
7
+ NavigationMenuTrigger,
8
+ navigationMenuTriggerStyle,
9
+ } from "./components/navigation-menu";
10
+
11
+ /**
12
+ * Audit P1-5. The trigger's only keyboard-focus cue was `focus:bg-accent`: measured in Chromium, accent is
13
+ * 1.08:1 on the light page and 1.24:1 on the dark one, and the text colour only moved in light. It now draws
14
+ * the `ring` role like every other ring-ring control. jsdom cannot compute a box-shadow, so this pins the
15
+ * class; the measurement itself is `check:theme-isolation` ("navigation menu: …").
16
+ */
17
+ describe("NavigationMenu keyboard focus", () => {
18
+ it("draws a ring from the `ring` role on a trigger", () => {
19
+ render(
20
+ <NavigationMenu>
21
+ <NavigationMenuList>
22
+ <NavigationMenuItem>
23
+ <NavigationMenuTrigger>Getting started</NavigationMenuTrigger>
24
+ </NavigationMenuItem>
25
+ </NavigationMenuList>
26
+ </NavigationMenu>,
27
+ );
28
+ expect(screen.getByRole("button", { name: /getting started/i }).className).toMatch(/focus-visible:ring-2/);
29
+ expect(screen.getByRole("button", { name: /getting started/i }).className).toMatch(/focus-visible:ring-ring/);
30
+ });
31
+
32
+ it("draws it on a link too, which shares the trigger style", () => {
33
+ expect(navigationMenuTriggerStyle()).toMatch(/focus-visible:ring-ring/);
34
+ });
35
+ });