@zentauri-ui/zentauri-components 1.8.1 → 1.8.3

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 (195) hide show
  1. package/README.md +123 -25
  2. package/cli/cli.integration.test.ts +77 -2
  3. package/cli/index.mjs +53 -0
  4. package/cli/registry.json +136 -0
  5. package/cli/rewrite-imports.mjs +4 -1
  6. package/dist/charts/area.js +9 -10
  7. package/dist/charts/area.js.map +1 -1
  8. package/dist/charts/area.mjs +2 -3
  9. package/dist/charts/area.mjs.map +1 -1
  10. package/dist/charts/bar.js +10 -95
  11. package/dist/charts/bar.js.map +1 -1
  12. package/dist/charts/bar.mjs +2 -95
  13. package/dist/charts/bar.mjs.map +1 -1
  14. package/dist/charts/bubble.js +8 -9
  15. package/dist/charts/bubble.js.map +1 -1
  16. package/dist/charts/bubble.mjs +2 -3
  17. package/dist/charts/bubble.mjs.map +1 -1
  18. package/dist/charts/funnel/Funnel.d.ts +6 -0
  19. package/dist/charts/funnel/Funnel.d.ts.map +1 -0
  20. package/dist/charts/funnel/index.d.ts +4 -0
  21. package/dist/charts/funnel/index.d.ts.map +1 -0
  22. package/dist/charts/funnel.js +102 -0
  23. package/dist/charts/funnel.js.map +1 -0
  24. package/dist/charts/funnel.mjs +89 -0
  25. package/dist/charts/funnel.mjs.map +1 -0
  26. package/dist/charts/line.js +8 -9
  27. package/dist/charts/line.js.map +1 -1
  28. package/dist/charts/line.mjs +2 -3
  29. package/dist/charts/line.mjs.map +1 -1
  30. package/dist/charts/pie/Pie.d.ts +1 -1
  31. package/dist/charts/pie/Pie.d.ts.map +1 -1
  32. package/dist/charts/pie.js +19 -6
  33. package/dist/charts/pie.js.map +1 -1
  34. package/dist/charts/pie.mjs +17 -4
  35. package/dist/charts/pie.mjs.map +1 -1
  36. package/dist/charts/radar/Radar.d.ts +6 -0
  37. package/dist/charts/radar/Radar.d.ts.map +1 -0
  38. package/dist/charts/radar/index.d.ts +4 -0
  39. package/dist/charts/radar/index.d.ts.map +1 -0
  40. package/dist/charts/radar.js +94 -0
  41. package/dist/charts/radar.js.map +1 -0
  42. package/dist/charts/radar.mjs +81 -0
  43. package/dist/charts/radar.mjs.map +1 -0
  44. package/dist/charts/scatter/Scatter.d.ts +6 -0
  45. package/dist/charts/scatter/Scatter.d.ts.map +1 -0
  46. package/dist/charts/scatter/index.d.ts +4 -0
  47. package/dist/charts/scatter/index.d.ts.map +1 -0
  48. package/dist/charts/scatter.js +116 -0
  49. package/dist/charts/scatter.js.map +1 -0
  50. package/dist/charts/scatter.mjs +103 -0
  51. package/dist/charts/scatter.mjs.map +1 -0
  52. package/dist/charts/shared/chart-frame.d.ts +2 -1
  53. package/dist/charts/shared/chart-frame.d.ts.map +1 -1
  54. package/dist/charts/shared/types.d.ts +22 -2
  55. package/dist/charts/shared/types.d.ts.map +1 -1
  56. package/dist/charts/stacked-bar/StackedBar.d.ts +6 -0
  57. package/dist/charts/stacked-bar/StackedBar.d.ts.map +1 -0
  58. package/dist/charts/stacked-bar/index.d.ts +4 -0
  59. package/dist/charts/stacked-bar/index.d.ts.map +1 -0
  60. package/dist/charts/stacked-bar.js +29 -0
  61. package/dist/charts/stacked-bar.js.map +1 -0
  62. package/dist/charts/stacked-bar.mjs +15 -0
  63. package/dist/charts/stacked-bar.mjs.map +1 -0
  64. package/dist/{chunk-ABOZ5QIX.js → chunk-466QDL44.js} +5 -12
  65. package/dist/chunk-466QDL44.js.map +1 -0
  66. package/dist/chunk-4ZP444GA.mjs +19 -0
  67. package/dist/chunk-4ZP444GA.mjs.map +1 -0
  68. package/dist/{chunk-HDO5ZM2S.mjs → chunk-CIEZFHCO.mjs} +3 -10
  69. package/dist/chunk-CIEZFHCO.mjs.map +1 -0
  70. package/dist/chunk-F3V4POW3.mjs +8 -0
  71. package/dist/chunk-F3V4POW3.mjs.map +1 -0
  72. package/dist/{chunk-G2WARVAM.mjs → chunk-HZIRD3SR.mjs} +35 -15
  73. package/dist/chunk-HZIRD3SR.mjs.map +1 -0
  74. package/dist/{chunk-G66SXATZ.js → chunk-IL4LH2XX.js} +50 -4
  75. package/dist/chunk-IL4LH2XX.js.map +1 -0
  76. package/dist/{chunk-QQ6F4LZK.js → chunk-JFS5PJSH.js} +5 -5
  77. package/dist/{chunk-QQ6F4LZK.js.map → chunk-JFS5PJSH.js.map} +1 -1
  78. package/dist/chunk-LREMK2XR.js +97 -0
  79. package/dist/chunk-LREMK2XR.js.map +1 -0
  80. package/dist/chunk-MUP7DVQR.js +26 -0
  81. package/dist/chunk-MUP7DVQR.js.map +1 -0
  82. package/dist/chunk-O2KM3ETC.mjs +95 -0
  83. package/dist/chunk-O2KM3ETC.mjs.map +1 -0
  84. package/dist/{chunk-ZIFMIS7D.mjs → chunk-OL3BJSRC.mjs} +51 -5
  85. package/dist/chunk-OL3BJSRC.mjs.map +1 -0
  86. package/dist/{chunk-QNUDODDX.js → chunk-PWPMKXEG.js} +36 -14
  87. package/dist/chunk-PWPMKXEG.js.map +1 -0
  88. package/dist/{chunk-ASJQP53L.mjs → chunk-VARQ7W4G.mjs} +3 -3
  89. package/dist/{chunk-ASJQP53L.mjs.map → chunk-VARQ7W4G.mjs.map} +1 -1
  90. package/dist/chunk-XRM7GOIE.js +10 -0
  91. package/dist/chunk-XRM7GOIE.js.map +1 -0
  92. package/dist/design-system/tokens.js +32 -0
  93. package/dist/design-system/tokens.js.map +1 -0
  94. package/dist/design-system/tokens.mjs +3 -0
  95. package/dist/design-system/tokens.mjs.map +1 -0
  96. package/dist/hooks/index.d.ts +2 -0
  97. package/dist/hooks/index.d.ts.map +1 -1
  98. package/dist/hooks/useIsomorphicLayoutEffect.js +6 -4
  99. package/dist/hooks/useIsomorphicLayoutEffect.js.map +1 -1
  100. package/dist/hooks/useIsomorphicLayoutEffect.mjs +1 -6
  101. package/dist/hooks/useIsomorphicLayoutEffect.mjs.map +1 -1
  102. package/dist/hooks/useTableFilter/index.d.ts +3 -0
  103. package/dist/hooks/useTableFilter/index.d.ts.map +1 -0
  104. package/dist/hooks/useTableFilter/types.d.ts +20 -0
  105. package/dist/hooks/useTableFilter/types.d.ts.map +1 -0
  106. package/dist/hooks/useTableFilter/useTableFilter.d.ts +3 -0
  107. package/dist/hooks/useTableFilter/useTableFilter.d.ts.map +1 -0
  108. package/dist/hooks/useTableFilter.js +124 -0
  109. package/dist/hooks/useTableFilter.js.map +1 -0
  110. package/dist/hooks/useTableFilter.mjs +122 -0
  111. package/dist/hooks/useTableFilter.mjs.map +1 -0
  112. package/dist/hooks/useTableSort/index.d.ts +3 -0
  113. package/dist/hooks/useTableSort/index.d.ts.map +1 -0
  114. package/dist/hooks/useTableSort/types.d.ts +15 -0
  115. package/dist/hooks/useTableSort/types.d.ts.map +1 -0
  116. package/dist/hooks/useTableSort/useTableSort.d.ts +3 -0
  117. package/dist/hooks/useTableSort/useTableSort.d.ts.map +1 -0
  118. package/dist/hooks/useTableSort.js +99 -0
  119. package/dist/hooks/useTableSort.js.map +1 -0
  120. package/dist/hooks/useTableSort.mjs +97 -0
  121. package/dist/hooks/useTableSort.mjs.map +1 -0
  122. package/dist/ui/buttons/animated.js +4 -3
  123. package/dist/ui/buttons/animated.js.map +1 -1
  124. package/dist/ui/buttons/animated.mjs +2 -1
  125. package/dist/ui/buttons/animated.mjs.map +1 -1
  126. package/dist/ui/buttons.js +5 -4
  127. package/dist/ui/buttons.mjs +3 -2
  128. package/dist/ui/dynamic-stepper.js +5 -4
  129. package/dist/ui/dynamic-stepper.js.map +1 -1
  130. package/dist/ui/dynamic-stepper.mjs +3 -2
  131. package/dist/ui/dynamic-stepper.mjs.map +1 -1
  132. package/dist/ui/marquee/marquee.d.ts.map +1 -1
  133. package/dist/ui/marquee.js +82 -21
  134. package/dist/ui/marquee.js.map +1 -1
  135. package/dist/ui/marquee.mjs +83 -22
  136. package/dist/ui/marquee.mjs.map +1 -1
  137. package/dist/ui/pagination.js +5 -4
  138. package/dist/ui/pagination.js.map +1 -1
  139. package/dist/ui/pagination.mjs +2 -1
  140. package/dist/ui/pagination.mjs.map +1 -1
  141. package/dist/ui/table/animated.js +8 -8
  142. package/dist/ui/table/animated.mjs +2 -2
  143. package/dist/ui/table/index.d.ts +1 -1
  144. package/dist/ui/table/index.d.ts.map +1 -1
  145. package/dist/ui/table/table-base.d.ts +2 -2
  146. package/dist/ui/table/table-base.d.ts.map +1 -1
  147. package/dist/ui/table/types.d.ts +9 -1
  148. package/dist/ui/table/types.d.ts.map +1 -1
  149. package/dist/ui/table.js +14 -14
  150. package/dist/ui/table.mjs +1 -1
  151. package/package.json +8 -2
  152. package/src/charts/charts.test.tsx +80 -0
  153. package/src/charts/funnel/Funnel.tsx +105 -0
  154. package/src/charts/funnel/index.ts +14 -0
  155. package/src/charts/pie/Pie.tsx +28 -1
  156. package/src/charts/radar/Radar.tsx +84 -0
  157. package/src/charts/radar/index.ts +16 -0
  158. package/src/charts/scatter/Scatter.tsx +104 -0
  159. package/src/charts/scatter/index.ts +16 -0
  160. package/src/charts/shared/chart-frame.tsx +4 -2
  161. package/src/charts/shared/types.ts +42 -2
  162. package/src/charts/stacked-bar/StackedBar.tsx +12 -0
  163. package/src/charts/stacked-bar/index.ts +16 -0
  164. package/src/hooks/index.ts +12 -0
  165. package/src/hooks/useTableFilter/index.ts +7 -0
  166. package/src/hooks/useTableFilter/types.ts +28 -0
  167. package/src/hooks/useTableFilter/useTableFilter.test.ts +141 -0
  168. package/src/hooks/useTableFilter/useTableFilter.ts +153 -0
  169. package/src/hooks/useTableSort/index.ts +5 -0
  170. package/src/hooks/useTableSort/types.ts +23 -0
  171. package/src/hooks/useTableSort/useTableSort.test.ts +150 -0
  172. package/src/hooks/useTableSort/useTableSort.ts +121 -0
  173. package/src/ui/context-menu/context-menu.test.tsx +30 -0
  174. package/src/ui/divider/divider.test.tsx +55 -0
  175. package/src/ui/empty-state/empty-state.test.tsx +88 -0
  176. package/src/ui/marquee/marquee.test.tsx +45 -4
  177. package/src/ui/marquee/marquee.tsx +100 -18
  178. package/src/ui/modal/modal.test.tsx +24 -0
  179. package/src/ui/peer-isolation.test.ts +81 -0
  180. package/src/ui/select/select.test.tsx +98 -2
  181. package/src/ui/skeleton/skeleton.test.tsx +85 -0
  182. package/src/ui/table/index.ts +3 -0
  183. package/src/ui/table/table-base.tsx +69 -4
  184. package/src/ui/table/table.test.tsx +207 -0
  185. package/src/ui/table/types.ts +13 -1
  186. package/dist/chunk-ABOZ5QIX.js.map +0 -1
  187. package/dist/chunk-G2WARVAM.mjs.map +0 -1
  188. package/dist/chunk-G66SXATZ.js.map +0 -1
  189. package/dist/chunk-HDO5ZM2S.mjs.map +0 -1
  190. package/dist/chunk-OULU7OC4.mjs +0 -21
  191. package/dist/chunk-OULU7OC4.mjs.map +0 -1
  192. package/dist/chunk-QNUDODDX.js.map +0 -1
  193. package/dist/chunk-Z6S36PDD.js +0 -24
  194. package/dist/chunk-Z6S36PDD.js.map +0 -1
  195. package/dist/chunk-ZIFMIS7D.mjs.map +0 -1
@@ -0,0 +1,88 @@
1
+ import { createRef } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { EmptyState } from "./empty-state";
6
+ import {
7
+ EmptyStateAction,
8
+ EmptyStateDescription,
9
+ EmptyStateIcon,
10
+ EmptyStateTitle,
11
+ } from "./empty-state-base";
12
+
13
+ const EMPTY_STATE_SLOT = '[data-slot="empty-state"]';
14
+
15
+ function getEmptyStateRoot(container: HTMLElement = document.body) {
16
+ const elements = container.querySelectorAll(EMPTY_STATE_SLOT);
17
+ expect(elements.length).toBe(1);
18
+ return elements[0] as HTMLElement;
19
+ }
20
+
21
+ describe("EmptyState", () => {
22
+ it("should set displayName on compound parts", () => {
23
+ expect(EmptyState.displayName).toBe("EmptyState");
24
+ expect(EmptyStateIcon.displayName).toBe("EmptyStateIcon");
25
+ expect(EmptyStateTitle.displayName).toBe("EmptyStateTitle");
26
+ expect(EmptyStateDescription.displayName).toBe("EmptyStateDescription");
27
+ expect(EmptyStateAction.displayName).toBe("EmptyStateAction");
28
+ });
29
+
30
+ it("should stamp data-slot on the root section", () => {
31
+ render(<EmptyState>Nothing here</EmptyState>);
32
+ const root = getEmptyStateRoot();
33
+ expect(root.tagName).toBe("SECTION");
34
+ expect(root).toHaveAttribute("data-slot", "empty-state");
35
+ });
36
+
37
+ it("should render title, description, icon, and action slots", () => {
38
+ render(
39
+ <EmptyState>
40
+ <EmptyStateIcon>!</EmptyStateIcon>
41
+ <EmptyStateTitle>No results</EmptyStateTitle>
42
+ <EmptyStateDescription>Try another filter.</EmptyStateDescription>
43
+ <EmptyStateAction>
44
+ <button type="button">Reset</button>
45
+ </EmptyStateAction>
46
+ </EmptyState>,
47
+ );
48
+
49
+ expect(
50
+ screen.getByRole("heading", { level: 2, name: "No results" }),
51
+ ).toHaveAttribute("data-slot", "empty-state-title");
52
+ expect(screen.getByText("Try another filter.")).toHaveAttribute(
53
+ "data-slot",
54
+ "empty-state-description",
55
+ );
56
+ expect(screen.getByText("!")).toHaveAttribute(
57
+ "data-slot",
58
+ "empty-state-icon",
59
+ );
60
+ expect(screen.getByRole("button", { name: "Reset" })).toBeVisible();
61
+ });
62
+
63
+ it("should apply live region state when requested", () => {
64
+ render(<EmptyState liveRegion="assertive">Updated</EmptyState>);
65
+ expect(getEmptyStateRoot()).toHaveAttribute("aria-live", "assertive");
66
+ });
67
+
68
+ it("should apply size classes to nested text slots through context", () => {
69
+ render(
70
+ <EmptyState size="lg">
71
+ <EmptyStateTitle>Large title</EmptyStateTitle>
72
+ <EmptyStateDescription>Large description</EmptyStateDescription>
73
+ </EmptyState>,
74
+ );
75
+
76
+ expect(screen.getByText("Large title").className).toMatch(/text-xl/);
77
+ expect(screen.getByText("Large description").className).toMatch(
78
+ /text-base/,
79
+ );
80
+ });
81
+
82
+ it("should forward refs to the root element", () => {
83
+ const ref = createRef<HTMLElement>();
84
+ render(<EmptyState ref={ref}>Empty</EmptyState>);
85
+ expect(ref.current).toBeInstanceOf(HTMLElement);
86
+ expect(ref.current?.getAttribute("data-slot")).toBe("empty-state");
87
+ });
88
+ });
@@ -1,9 +1,13 @@
1
- import { render, screen } from "@testing-library/react";
2
- import { describe, expect, it } from "vitest";
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
3
 
4
4
  import { Marquee } from "./marquee";
5
5
 
6
6
  describe("Marquee", () => {
7
+ afterEach(() => {
8
+ vi.restoreAllMocks();
9
+ });
10
+
7
11
  it("exposes a display name", () => {
8
12
  expect(Marquee.displayName).toBe("Marquee");
9
13
  });
@@ -17,13 +21,14 @@ describe("Marquee", () => {
17
21
 
18
22
  expect(document.querySelector('[data-slot="marquee"]')).toBeTruthy();
19
23
  expect(document.querySelector('[data-slot="marquee-track"]')).toBeTruthy();
20
- expect(screen.getAllByText("Acme")).toHaveLength(2);
21
-
22
24
  const groups = document.querySelectorAll(
23
25
  '[data-slot="marquee-item-group"]',
24
26
  );
27
+ expect(groups).toHaveLength(2);
28
+ expect(groups[0]?.textContent).toContain("Acme");
25
29
  expect(groups[1]).toHaveAttribute("aria-hidden", "true");
26
30
  expect(groups[1]).toHaveAttribute("inert");
31
+ expect(groups[1]?.textContent).toContain("Acme");
27
32
  });
28
33
 
29
34
  it("applies horizontal metadata by default", () => {
@@ -84,6 +89,42 @@ describe("Marquee", () => {
84
89
  });
85
90
  });
86
91
 
92
+ it("hides filler copies from assistive technology", async () => {
93
+ vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(
94
+ function (this: HTMLElement) {
95
+ return this.getAttribute("data-slot") === "marquee" ? 300 : 0;
96
+ },
97
+ );
98
+ vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockImplementation(
99
+ function (this: HTMLElement) {
100
+ return this.getAttribute("data-slot") === "marquee-measure" ? 100 : 0;
101
+ },
102
+ );
103
+
104
+ render(
105
+ <Marquee>
106
+ <button type="button">Focusable item</button>
107
+ </Marquee>,
108
+ );
109
+
110
+ const firstGroup = document.querySelector(
111
+ '[data-slot="marquee-item-group"]',
112
+ );
113
+
114
+ await waitFor(() => {
115
+ expect(firstGroup?.textContent).toBe(
116
+ "Focusable itemFocusable itemFocusable item",
117
+ );
118
+ });
119
+
120
+ expect(
121
+ screen.getAllByRole("button", { name: "Focusable item" }),
122
+ ).toHaveLength(1);
123
+ expect(
124
+ firstGroup?.querySelectorAll('[aria-hidden="true"][inert]'),
125
+ ).toHaveLength(2);
126
+ });
127
+
87
128
  it("accepts string gap values", () => {
88
129
  render(
89
130
  <Marquee gap="2rem" data-testid="marquee">
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
 
3
- import type { CSSProperties } from "react";
3
+ import { Children, useCallback, useMemo, useRef, useState } from "react";
4
+ import type { CSSProperties, Ref } from "react";
4
5
 
6
+ import { useIsomorphicLayoutEffect } from "../../hooks/useIsomorphicLayoutEffect";
5
7
  import { cn } from "../../lib/utils";
6
8
 
7
9
  import type { MarqueeProps } from "./types";
@@ -16,6 +18,17 @@ function toCssLength(value: number | string | undefined) {
16
18
  return typeof value === "number" ? `${value}px` : value;
17
19
  }
18
20
 
21
+ function assignRef<TElement>(
22
+ ref: Ref<TElement> | undefined,
23
+ value: TElement | null,
24
+ ) {
25
+ if (typeof ref === "function") {
26
+ ref(value);
27
+ } else if (ref) {
28
+ (ref as { current: TElement | null }).current = value;
29
+ }
30
+ }
31
+
19
32
  export function Marquee(props: MarqueeProps) {
20
33
  const {
21
34
  appearance,
@@ -44,14 +57,81 @@ export function Marquee(props: MarqueeProps) {
44
57
  resolvedDirection === "right" || resolvedDirection === "down";
45
58
  const animationName =
46
59
  resolvedOrientation === "vertical" ? "zui-marquee-y" : "zui-marquee-x";
60
+ const rootRef = useRef<HTMLDivElement | null>(null);
61
+ const measureRef = useRef<HTMLDivElement | null>(null);
62
+ const [copyCount, setCopyCount] = useState(1);
63
+ const childArray = useMemo(() => Children.toArray(children), [children]);
64
+ const setRootRef = useCallback(
65
+ (node: HTMLDivElement | null) => {
66
+ rootRef.current = node;
67
+ assignRef(ref, node);
68
+ },
69
+ [ref],
70
+ );
47
71
  const marqueeStyle = {
48
72
  ...(gap !== undefined ? { "--zui-marquee-gap": toCssLength(gap) } : null),
49
73
  ...style,
50
74
  } as CSSProperties;
75
+ const groupClassName = cn(
76
+ "flex shrink-0 items-center justify-around gap-(--zui-marquee-gap)",
77
+ resolvedOrientation === "vertical" ? "flex-col" : "flex-row",
78
+ itemClassName,
79
+ );
80
+ const fillerChildren = Array.from(
81
+ { length: Math.max(0, copyCount - 1) },
82
+ (_, index) => (
83
+ <div key={index} aria-hidden="true" inert className="contents">
84
+ {childArray}
85
+ </div>
86
+ ),
87
+ );
88
+
89
+ useIsomorphicLayoutEffect(() => {
90
+ const updateCopyCount = () => {
91
+ const root = rootRef.current;
92
+ const measure = measureRef.current;
93
+
94
+ if (!root || !measure) {
95
+ return;
96
+ }
97
+
98
+ const rootSize =
99
+ resolvedOrientation === "vertical"
100
+ ? root.offsetHeight
101
+ : root.offsetWidth;
102
+ const contentSize =
103
+ resolvedOrientation === "vertical"
104
+ ? measure.scrollHeight
105
+ : measure.scrollWidth;
106
+
107
+ if (!rootSize || !contentSize) {
108
+ setCopyCount(1);
109
+ return;
110
+ }
111
+
112
+ setCopyCount(Math.max(1, Math.ceil(rootSize / contentSize)));
113
+ };
114
+
115
+ updateCopyCount();
116
+
117
+ if (typeof ResizeObserver === "undefined") {
118
+ return;
119
+ }
120
+
121
+ const observer = new ResizeObserver(updateCopyCount);
122
+ if (rootRef.current) {
123
+ observer.observe(rootRef.current);
124
+ }
125
+ if (measureRef.current) {
126
+ observer.observe(measureRef.current);
127
+ }
128
+
129
+ return () => observer.disconnect();
130
+ }, [childArray, gap, resolvedOrientation]);
51
131
 
52
132
  return (
53
133
  <div
54
- ref={ref}
134
+ ref={setRootRef}
55
135
  data-direction={resolvedDirection}
56
136
  data-orientation={resolvedOrientation}
57
137
  data-slot="marquee"
@@ -68,10 +148,21 @@ export function Marquee(props: MarqueeProps) {
68
148
  {...rest}
69
149
  >
70
150
  <style>{marqueeKeyframes}</style>
151
+ <div
152
+ aria-hidden="true"
153
+ data-slot="marquee-measure"
154
+ ref={measureRef}
155
+ className={cn(
156
+ "pointer-events-none invisible absolute -z-10",
157
+ groupClassName,
158
+ )}
159
+ >
160
+ {childArray}
161
+ </div>
71
162
  <div
72
163
  data-slot="marquee-track"
73
164
  className={cn(
74
- "flex shrink-0 gap-[var(--zui-marquee-gap)] will-change-transform [animation-iteration-count:infinite] [animation-timing-function:linear] motion-reduce:[animation-play-state:paused]",
165
+ "flex shrink-0 gap-(--zui-marquee-gap) will-change-transform [animation-iteration-count:infinite] [animation-timing-function:linear] motion-reduce:[animation-play-state:paused]",
75
166
  resolvedOrientation === "vertical" ? "flex-col" : "w-max flex-row",
76
167
  pauseOnHover && "group-hover/marquee:[animation-play-state:paused]",
77
168
  isReverse && "[animation-direction:reverse]",
@@ -84,27 +175,18 @@ export function Marquee(props: MarqueeProps) {
84
175
  } as CSSProperties
85
176
  }
86
177
  >
87
- <div
88
- data-slot="marquee-item-group"
89
- className={cn(
90
- "flex shrink-0 items-center justify-around gap-[var(--zui-marquee-gap)]",
91
- resolvedOrientation === "vertical" ? "flex-col" : "flex-row",
92
- itemClassName,
93
- )}
94
- >
95
- {children}
178
+ <div data-slot="marquee-item-group" className={groupClassName}>
179
+ {childArray}
180
+ {fillerChildren}
96
181
  </div>
97
182
  <div
98
183
  aria-hidden="true"
99
184
  inert
100
185
  data-slot="marquee-item-group"
101
- className={cn(
102
- "flex shrink-0 items-center justify-around gap-[var(--zui-marquee-gap)]",
103
- resolvedOrientation === "vertical" ? "flex-col" : "flex-row",
104
- itemClassName,
105
- )}
186
+ className={groupClassName}
106
187
  >
107
- {children}
188
+ {childArray}
189
+ {fillerChildren}
108
190
  </div>
109
191
  </div>
110
192
  </div>
@@ -166,4 +166,28 @@ describe("Modal", () => {
166
166
  );
167
167
  expect(trigger).toHaveFocus();
168
168
  });
169
+
170
+ it("should expose aria-modal and wire labelledby/describedby to its title and description", async () => {
171
+ render(
172
+ <Modal defaultOpen>
173
+ <ModalContent>
174
+ <ModalTitle>Confirm</ModalTitle>
175
+ <ModalDescription>Please review</ModalDescription>
176
+ </ModalContent>
177
+ </Modal>,
178
+ );
179
+ const dialog = await screen.findByRole("dialog");
180
+ expect(dialog).toHaveAttribute("aria-modal", "true");
181
+
182
+ const labelledby = dialog.getAttribute("aria-labelledby");
183
+ const describedby = dialog.getAttribute("aria-describedby");
184
+ expect(labelledby).toBeTruthy();
185
+ expect(describedby).toBeTruthy();
186
+ expect(document.getElementById(labelledby as string)).toHaveTextContent(
187
+ "Confirm",
188
+ );
189
+ expect(document.getElementById(describedby as string)).toHaveTextContent(
190
+ "Please review",
191
+ );
192
+ });
169
193
  });
@@ -0,0 +1,81 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ /**
7
+ * Guards the package's optional-peer isolation contract:
8
+ *
9
+ * - A component that ships BOTH a static entry and an `animated/` entry must keep
10
+ * `framer-motion` out of its static files, so importing the static entry never
11
+ * forces the optional `framer-motion` peer onto the consumer. (Components that
12
+ * are themselves motion primitives — e.g. `animated-number`, `marquee` — have no
13
+ * `animated/` split, so this rule does not apply to them.)
14
+ * - No file under `src/ui` may import `recharts`; charts live only in `src/charts/*`.
15
+ */
16
+
17
+ const uiRoot = dirname(fileURLToPath(import.meta.url));
18
+
19
+ const componentDirs = readdirSync(uiRoot, { withFileTypes: true })
20
+ .filter((e) => e.isDirectory())
21
+ .map((e) => e.name);
22
+
23
+ /** `.ts`/`.tsx` files directly involved in the static entry (excludes `animated/` and tests). */
24
+ function staticFiles(dir: string): string[] {
25
+ const out: string[] = [];
26
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
27
+ const full = join(dir, entry.name);
28
+ if (entry.isDirectory()) {
29
+ if (entry.name === "animated") continue;
30
+ out.push(...staticFiles(full));
31
+ } else if (
32
+ /\.(tsx?|jsx?)$/.test(entry.name) &&
33
+ !entry.name.includes(".test.")
34
+ ) {
35
+ out.push(full);
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ const FRAMER = /from\s+["']framer-motion/;
42
+ const RECHARTS = /from\s+["']recharts/;
43
+
44
+ describe("static UI entries — optional peer isolation", () => {
45
+ it("scans every UI component directory", () => {
46
+ expect(componentDirs.length).toBeGreaterThan(40);
47
+ });
48
+
49
+ for (const name of componentDirs) {
50
+ const dir = join(uiRoot, name);
51
+ const hasAnimatedEntry = existsSync(join(dir, "animated"));
52
+ if (!hasAnimatedEntry) continue;
53
+
54
+ it(`${name}: static files do not import framer-motion`, () => {
55
+ const offenders = staticFiles(dir).filter((file) =>
56
+ FRAMER.test(readFileSync(file, "utf8")),
57
+ );
58
+ expect(offenders).toEqual([]);
59
+ });
60
+ }
61
+
62
+ it("no UI file imports recharts (charts live in src/charts)", () => {
63
+ const offenders = componentDirs
64
+ .flatMap((name) => {
65
+ const all: string[] = [];
66
+ const dir = join(uiRoot, name);
67
+ // include animated files here too — recharts must never appear in ui/.
68
+ const walk = (d: string) => {
69
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
70
+ const full = join(d, entry.name);
71
+ if (entry.isDirectory()) walk(full);
72
+ else if (/\.(tsx?|jsx?)$/.test(entry.name)) all.push(full);
73
+ }
74
+ };
75
+ walk(dir);
76
+ return all;
77
+ })
78
+ .filter((file) => RECHARTS.test(readFileSync(file, "utf8")));
79
+ expect(offenders).toEqual([]);
80
+ });
81
+ });
@@ -1,6 +1,6 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { render, screen, waitFor } from "@testing-library/react";
2
2
  import userEvent from "@testing-library/user-event";
3
- import { describe, expect, it } from "vitest";
3
+ import { describe, expect, it, vi } from "vitest";
4
4
 
5
5
  import {
6
6
  Select,
@@ -126,3 +126,99 @@ describe("Select", () => {
126
126
  expect(beta).toHaveAttribute("aria-selected", "false");
127
127
  });
128
128
  });
129
+
130
+ describe("Select — keyboard and a11y", () => {
131
+ it("wires trigger aria attributes to the listbox", async () => {
132
+ const user = userEvent.setup();
133
+ render(
134
+ <Select multiple defaultValue={[]}>
135
+ <SelectTrigger>
136
+ <SelectValue placeholder="Pick" />
137
+ </SelectTrigger>
138
+ <SelectContent>
139
+ <SelectItem value="a">Alpha</SelectItem>
140
+ </SelectContent>
141
+ </Select>,
142
+ );
143
+ const trigger = screen.getByRole("button");
144
+ expect(trigger).toHaveAttribute("aria-haspopup", "listbox");
145
+ expect(trigger).toHaveAttribute("aria-expanded", "false");
146
+ const controls = trigger.getAttribute("aria-controls");
147
+
148
+ await user.click(trigger);
149
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
150
+ const listbox = screen.getByRole("listbox");
151
+ expect(listbox).toHaveAttribute("id", controls);
152
+ expect(listbox).toHaveAttribute("aria-multiselectable", "true");
153
+ });
154
+
155
+ it("moves focus across enabled options with arrow keys", async () => {
156
+ const user = userEvent.setup();
157
+ render(
158
+ <Select defaultValue={[]}>
159
+ <SelectTrigger>
160
+ <SelectValue placeholder="Pick" />
161
+ </SelectTrigger>
162
+ <SelectContent>
163
+ <SelectItem value="a">Alpha</SelectItem>
164
+ <SelectItem value="b">Beta</SelectItem>
165
+ </SelectContent>
166
+ </Select>,
167
+ );
168
+ await user.click(screen.getByRole("button"));
169
+ const alpha = await screen.findByRole("option", { name: /alpha/i });
170
+ const beta = screen.getByRole("option", { name: /beta/i });
171
+ await waitFor(() => expect(alpha).toHaveFocus());
172
+
173
+ await user.keyboard("{ArrowDown}");
174
+ expect(beta).toHaveFocus();
175
+ await user.keyboard("{ArrowUp}");
176
+ expect(alpha).toHaveFocus();
177
+ });
178
+
179
+ it("selects the focused option with the Enter key", async () => {
180
+ const user = userEvent.setup();
181
+ const onChange = vi.fn();
182
+ render(
183
+ <Select multiple defaultValue={[]} onChange={onChange}>
184
+ <SelectTrigger>
185
+ <SelectValue placeholder="Pick" />
186
+ </SelectTrigger>
187
+ <SelectContent>
188
+ <SelectItem value="a">Alpha</SelectItem>
189
+ <SelectItem value="b">Beta</SelectItem>
190
+ </SelectContent>
191
+ </Select>,
192
+ );
193
+ await user.click(screen.getByRole("button"));
194
+ const alpha = await screen.findByRole("option", { name: /alpha/i });
195
+ await waitFor(() => expect(alpha).toHaveFocus());
196
+
197
+ await user.keyboard("{Enter}");
198
+ expect(onChange).toHaveBeenCalledWith(["a"]);
199
+ });
200
+
201
+ it("ignores a disabled option for pointer and marks it aria-disabled", async () => {
202
+ const user = userEvent.setup();
203
+ const onChange = vi.fn();
204
+ render(
205
+ <Select multiple defaultValue={[]} onChange={onChange}>
206
+ <SelectTrigger>
207
+ <SelectValue placeholder="Pick" />
208
+ </SelectTrigger>
209
+ <SelectContent>
210
+ <SelectItem value="a" disabled>
211
+ Alpha
212
+ </SelectItem>
213
+ <SelectItem value="b">Beta</SelectItem>
214
+ </SelectContent>
215
+ </Select>,
216
+ );
217
+ await user.click(screen.getByRole("button"));
218
+ const alpha = await screen.findByRole("option", { name: /alpha/i });
219
+ expect(alpha).toHaveAttribute("aria-disabled", "true");
220
+
221
+ await user.click(alpha);
222
+ expect(onChange).not.toHaveBeenCalled();
223
+ });
224
+ });
@@ -0,0 +1,85 @@
1
+ import { createRef } from "react";
2
+ import { render } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import {
6
+ Skeleton,
7
+ SkeletonAvatar,
8
+ SkeletonButton,
9
+ SkeletonCard,
10
+ SkeletonText,
11
+ } from "./skeleton";
12
+
13
+ describe("Skeleton", () => {
14
+ it("should set displayName on skeleton primitives", () => {
15
+ expect(Skeleton.displayName).toBe("Skeleton");
16
+ expect(SkeletonText.displayName).toBe("SkeletonText");
17
+ expect(SkeletonAvatar.displayName).toBe("SkeletonAvatar");
18
+ expect(SkeletonCard.displayName).toBe("SkeletonCard");
19
+ expect(SkeletonButton.displayName).toBe("SkeletonButton");
20
+ });
21
+
22
+ it("should stamp data-slot and hide the placeholder from assistive tech", () => {
23
+ const { container } = render(<Skeleton busy />);
24
+ const root = container.querySelector('[data-slot="skeleton"]');
25
+ expect(root).toHaveAttribute("aria-hidden", "true");
26
+ expect(root).toHaveAttribute("aria-busy", "true");
27
+ });
28
+
29
+ it("should apply pulse motion when animation is not none", () => {
30
+ const { container } = render(<Skeleton animation="pulse" />);
31
+ expect(
32
+ container.querySelector('[data-slot="skeleton"]')?.className,
33
+ ).toMatch(/animate-pulse/);
34
+ });
35
+
36
+ it("should omit motion classes when animation is none", () => {
37
+ const { container } = render(<Skeleton animation="none" />);
38
+ expect(
39
+ container.querySelector('[data-slot="skeleton"]')?.className,
40
+ ).not.toMatch(/animate-pulse/);
41
+ });
42
+
43
+ it("should render the requested number of text lines", () => {
44
+ const { container } = render(<SkeletonText lines={4} />);
45
+ const root = container.querySelector('[data-slot="skeleton-text"]');
46
+ expect(root).toHaveAttribute("aria-hidden", "true");
47
+ expect(root?.children.length).toBe(4);
48
+ expect(root?.lastElementChild?.className).toMatch(/w-3\/5/);
49
+ });
50
+
51
+ it("should share parent animation with nested text placeholders", () => {
52
+ const { container } = render(
53
+ <Skeleton animation="none">
54
+ <SkeletonText lines={2} />
55
+ </Skeleton>,
56
+ );
57
+ const text = container.querySelector('[data-slot="skeleton-text"]');
58
+ expect(text?.firstElementChild?.className).not.toMatch(/animate-pulse/);
59
+ });
60
+
61
+ it("should render avatar, button, and card slots", () => {
62
+ const { container } = render(
63
+ <>
64
+ <SkeletonAvatar avatarSize="xl" />
65
+ <SkeletonButton buttonSize="lg" />
66
+ <SkeletonCard />
67
+ </>,
68
+ );
69
+
70
+ expect(
71
+ container.querySelector('[data-slot="skeleton-avatar"]'),
72
+ ).toHaveClass("size-14");
73
+ expect(
74
+ container.querySelector('[data-slot="skeleton-button"]'),
75
+ ).toHaveClass("h-12");
76
+ expect(container.querySelector('[data-slot="skeleton-card"]')).toBeTruthy();
77
+ });
78
+
79
+ it("should forward refs to the base skeleton element", () => {
80
+ const ref = createRef<HTMLDivElement>();
81
+ render(<Skeleton ref={ref} />);
82
+ expect(ref.current).toBeInstanceOf(HTMLElement);
83
+ expect(ref.current?.getAttribute("data-slot")).toBe("skeleton");
84
+ });
85
+ });
@@ -18,5 +18,8 @@ export type {
18
18
  TableHeadCellProps,
19
19
  TableProps,
20
20
  TableSectionProps,
21
+ TableSortChangeHandler,
22
+ TableSortDirection,
23
+ TableSortState,
21
24
  } from "./types";
22
25
  export { tableVariants, tableRowVariants, tableCellVariants } from "./variants";