@zentauri-ui/zentauri-components 1.8.0 → 1.8.2

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 (253) hide show
  1. package/README.md +25 -10
  2. package/cli/registry.json +12 -0
  3. package/dist/charts/area.js +9 -10
  4. package/dist/charts/area.js.map +1 -1
  5. package/dist/charts/area.mjs +2 -3
  6. package/dist/charts/area.mjs.map +1 -1
  7. package/dist/charts/bar.js +10 -95
  8. package/dist/charts/bar.js.map +1 -1
  9. package/dist/charts/bar.mjs +2 -95
  10. package/dist/charts/bar.mjs.map +1 -1
  11. package/dist/charts/bubble.js +8 -9
  12. package/dist/charts/bubble.js.map +1 -1
  13. package/dist/charts/bubble.mjs +2 -3
  14. package/dist/charts/bubble.mjs.map +1 -1
  15. package/dist/charts/funnel/Funnel.d.ts +6 -0
  16. package/dist/charts/funnel/Funnel.d.ts.map +1 -0
  17. package/dist/charts/funnel/index.d.ts +4 -0
  18. package/dist/charts/funnel/index.d.ts.map +1 -0
  19. package/dist/charts/funnel.js +102 -0
  20. package/dist/charts/funnel.js.map +1 -0
  21. package/dist/charts/funnel.mjs +89 -0
  22. package/dist/charts/funnel.mjs.map +1 -0
  23. package/dist/charts/line.js +8 -9
  24. package/dist/charts/line.js.map +1 -1
  25. package/dist/charts/line.mjs +2 -3
  26. package/dist/charts/line.mjs.map +1 -1
  27. package/dist/charts/pie/Pie.d.ts +1 -1
  28. package/dist/charts/pie/Pie.d.ts.map +1 -1
  29. package/dist/charts/pie.js +19 -6
  30. package/dist/charts/pie.js.map +1 -1
  31. package/dist/charts/pie.mjs +17 -4
  32. package/dist/charts/pie.mjs.map +1 -1
  33. package/dist/charts/radar/Radar.d.ts +6 -0
  34. package/dist/charts/radar/Radar.d.ts.map +1 -0
  35. package/dist/charts/radar/index.d.ts +4 -0
  36. package/dist/charts/radar/index.d.ts.map +1 -0
  37. package/dist/charts/radar.js +94 -0
  38. package/dist/charts/radar.js.map +1 -0
  39. package/dist/charts/radar.mjs +81 -0
  40. package/dist/charts/radar.mjs.map +1 -0
  41. package/dist/charts/scatter/Scatter.d.ts +6 -0
  42. package/dist/charts/scatter/Scatter.d.ts.map +1 -0
  43. package/dist/charts/scatter/index.d.ts +4 -0
  44. package/dist/charts/scatter/index.d.ts.map +1 -0
  45. package/dist/charts/scatter.js +116 -0
  46. package/dist/charts/scatter.js.map +1 -0
  47. package/dist/charts/scatter.mjs +103 -0
  48. package/dist/charts/scatter.mjs.map +1 -0
  49. package/dist/charts/shared/chart-frame.d.ts +2 -1
  50. package/dist/charts/shared/chart-frame.d.ts.map +1 -1
  51. package/dist/charts/shared/types.d.ts +22 -2
  52. package/dist/charts/shared/types.d.ts.map +1 -1
  53. package/dist/charts/stacked-bar/StackedBar.d.ts +6 -0
  54. package/dist/charts/stacked-bar/StackedBar.d.ts.map +1 -0
  55. package/dist/charts/stacked-bar/index.d.ts +4 -0
  56. package/dist/charts/stacked-bar/index.d.ts.map +1 -0
  57. package/dist/charts/stacked-bar.js +29 -0
  58. package/dist/charts/stacked-bar.js.map +1 -0
  59. package/dist/charts/stacked-bar.mjs +15 -0
  60. package/dist/charts/stacked-bar.mjs.map +1 -0
  61. package/dist/chunk-7TGUGTTQ.mjs +147 -0
  62. package/dist/chunk-7TGUGTTQ.mjs.map +1 -0
  63. package/dist/chunk-CQMV7BB6.js +50 -0
  64. package/dist/chunk-CQMV7BB6.js.map +1 -0
  65. package/dist/chunk-DN7TYUJ6.js +119 -0
  66. package/dist/chunk-DN7TYUJ6.js.map +1 -0
  67. package/dist/chunk-F3V4POW3.mjs +8 -0
  68. package/dist/chunk-F3V4POW3.mjs.map +1 -0
  69. package/dist/{chunk-G2WARVAM.mjs → chunk-HZIRD3SR.mjs} +35 -15
  70. package/dist/chunk-HZIRD3SR.mjs.map +1 -0
  71. package/dist/{chunk-G66SXATZ.js → chunk-IL4LH2XX.js} +50 -4
  72. package/dist/chunk-IL4LH2XX.js.map +1 -0
  73. package/dist/chunk-LREMK2XR.js +97 -0
  74. package/dist/chunk-LREMK2XR.js.map +1 -0
  75. package/dist/chunk-O2KM3ETC.mjs +95 -0
  76. package/dist/chunk-O2KM3ETC.mjs.map +1 -0
  77. package/dist/chunk-ODBG4Y6R.mjs +48 -0
  78. package/dist/chunk-ODBG4Y6R.mjs.map +1 -0
  79. package/dist/{chunk-ZIFMIS7D.mjs → chunk-OL3BJSRC.mjs} +51 -5
  80. package/dist/chunk-OL3BJSRC.mjs.map +1 -0
  81. package/dist/{chunk-QNUDODDX.js → chunk-PWPMKXEG.js} +36 -14
  82. package/dist/chunk-PWPMKXEG.js.map +1 -0
  83. package/dist/chunk-RKX5MERK.js +150 -0
  84. package/dist/chunk-RKX5MERK.js.map +1 -0
  85. package/dist/chunk-VYI3GS2C.mjs +115 -0
  86. package/dist/chunk-VYI3GS2C.mjs.map +1 -0
  87. package/dist/chunk-XRM7GOIE.js +10 -0
  88. package/dist/chunk-XRM7GOIE.js.map +1 -0
  89. package/dist/design-system/copy-button.d.ts +43 -0
  90. package/dist/design-system/copy-button.d.ts.map +1 -0
  91. package/dist/design-system/index.d.ts +2 -0
  92. package/dist/design-system/index.d.ts.map +1 -1
  93. package/dist/design-system/kbd.d.ts +44 -0
  94. package/dist/design-system/kbd.d.ts.map +1 -0
  95. package/dist/hooks/index.d.ts +2 -0
  96. package/dist/hooks/index.d.ts.map +1 -1
  97. package/dist/hooks/useClipboard.js +6 -44
  98. package/dist/hooks/useClipboard.js.map +1 -1
  99. package/dist/hooks/useClipboard.mjs +1 -46
  100. package/dist/hooks/useClipboard.mjs.map +1 -1
  101. package/dist/hooks/useIsomorphicLayoutEffect.js +6 -4
  102. package/dist/hooks/useIsomorphicLayoutEffect.js.map +1 -1
  103. package/dist/hooks/useIsomorphicLayoutEffect.mjs +1 -6
  104. package/dist/hooks/useIsomorphicLayoutEffect.mjs.map +1 -1
  105. package/dist/hooks/useTableFilter/index.d.ts +3 -0
  106. package/dist/hooks/useTableFilter/index.d.ts.map +1 -0
  107. package/dist/hooks/useTableFilter/types.d.ts +20 -0
  108. package/dist/hooks/useTableFilter/types.d.ts.map +1 -0
  109. package/dist/hooks/useTableFilter/useTableFilter.d.ts +3 -0
  110. package/dist/hooks/useTableFilter/useTableFilter.d.ts.map +1 -0
  111. package/dist/hooks/useTableFilter.js +124 -0
  112. package/dist/hooks/useTableFilter.js.map +1 -0
  113. package/dist/hooks/useTableFilter.mjs +122 -0
  114. package/dist/hooks/useTableFilter.mjs.map +1 -0
  115. package/dist/hooks/useTableSort/index.d.ts +3 -0
  116. package/dist/hooks/useTableSort/index.d.ts.map +1 -0
  117. package/dist/hooks/useTableSort/types.d.ts +15 -0
  118. package/dist/hooks/useTableSort/types.d.ts.map +1 -0
  119. package/dist/hooks/useTableSort/useTableSort.d.ts +3 -0
  120. package/dist/hooks/useTableSort/useTableSort.d.ts.map +1 -0
  121. package/dist/hooks/useTableSort.js +99 -0
  122. package/dist/hooks/useTableSort.js.map +1 -0
  123. package/dist/hooks/useTableSort.mjs +97 -0
  124. package/dist/hooks/useTableSort.mjs.map +1 -0
  125. package/dist/ui/copy-button/animated/animations.d.ts +3 -0
  126. package/dist/ui/copy-button/animated/animations.d.ts.map +1 -0
  127. package/dist/ui/copy-button/animated/copy-button-animated.d.ts +6 -0
  128. package/dist/ui/copy-button/animated/copy-button-animated.d.ts.map +1 -0
  129. package/dist/ui/copy-button/animated/index.d.ts +4 -0
  130. package/dist/ui/copy-button/animated/index.d.ts.map +1 -0
  131. package/dist/ui/copy-button/animated/types.d.ts +26 -0
  132. package/dist/ui/copy-button/animated/types.d.ts.map +1 -0
  133. package/dist/ui/copy-button/animated.js +59 -0
  134. package/dist/ui/copy-button/animated.js.map +1 -0
  135. package/dist/ui/copy-button/animated.mjs +56 -0
  136. package/dist/ui/copy-button/animated.mjs.map +1 -0
  137. package/dist/ui/copy-button/copy-button-base.d.ts +6 -0
  138. package/dist/ui/copy-button/copy-button-base.d.ts.map +1 -0
  139. package/dist/ui/copy-button/copy-button.d.ts +6 -0
  140. package/dist/ui/copy-button/copy-button.d.ts.map +1 -0
  141. package/dist/ui/copy-button/index.d.ts +4 -0
  142. package/dist/ui/copy-button/index.d.ts.map +1 -0
  143. package/dist/ui/copy-button/types.d.ts +32 -0
  144. package/dist/ui/copy-button/types.d.ts.map +1 -0
  145. package/dist/ui/copy-button/variants.d.ts +6 -0
  146. package/dist/ui/copy-button/variants.d.ts.map +1 -0
  147. package/dist/ui/copy-button.js +20 -0
  148. package/dist/ui/copy-button.js.map +1 -0
  149. package/dist/ui/copy-button.mjs +15 -0
  150. package/dist/ui/copy-button.mjs.map +1 -0
  151. package/dist/ui/kbd/animated/animations.d.ts +3 -0
  152. package/dist/ui/kbd/animated/animations.d.ts.map +1 -0
  153. package/dist/ui/kbd/animated/index.d.ts +4 -0
  154. package/dist/ui/kbd/animated/index.d.ts.map +1 -0
  155. package/dist/ui/kbd/animated/kbd-animated.d.ts +6 -0
  156. package/dist/ui/kbd/animated/kbd-animated.d.ts.map +1 -0
  157. package/dist/ui/kbd/animated/types.d.ts +10 -0
  158. package/dist/ui/kbd/animated/types.d.ts.map +1 -0
  159. package/dist/ui/kbd/animated.js +42 -0
  160. package/dist/ui/kbd/animated.js.map +1 -0
  161. package/dist/ui/kbd/animated.mjs +39 -0
  162. package/dist/ui/kbd/animated.mjs.map +1 -0
  163. package/dist/ui/kbd/index.d.ts +4 -0
  164. package/dist/ui/kbd/index.d.ts.map +1 -0
  165. package/dist/ui/kbd/kbd-base.d.ts +6 -0
  166. package/dist/ui/kbd/kbd-base.d.ts.map +1 -0
  167. package/dist/ui/kbd/kbd.d.ts +6 -0
  168. package/dist/ui/kbd/kbd.d.ts.map +1 -0
  169. package/dist/ui/kbd/types.d.ts +17 -0
  170. package/dist/ui/kbd/types.d.ts.map +1 -0
  171. package/dist/ui/kbd/variants.d.ts +8 -0
  172. package/dist/ui/kbd/variants.d.ts.map +1 -0
  173. package/dist/ui/kbd.js +23 -0
  174. package/dist/ui/kbd.js.map +1 -0
  175. package/dist/ui/kbd.mjs +14 -0
  176. package/dist/ui/kbd.mjs.map +1 -0
  177. package/dist/ui/marquee/marquee.d.ts.map +1 -1
  178. package/dist/ui/marquee.js +82 -21
  179. package/dist/ui/marquee.js.map +1 -1
  180. package/dist/ui/marquee.mjs +83 -22
  181. package/dist/ui/marquee.mjs.map +1 -1
  182. package/dist/ui/table/animated.js +8 -8
  183. package/dist/ui/table/animated.mjs +2 -2
  184. package/dist/ui/table/index.d.ts +1 -1
  185. package/dist/ui/table/index.d.ts.map +1 -1
  186. package/dist/ui/table/table-base.d.ts +2 -2
  187. package/dist/ui/table/table-base.d.ts.map +1 -1
  188. package/dist/ui/table/types.d.ts +9 -1
  189. package/dist/ui/table/types.d.ts.map +1 -1
  190. package/dist/ui/table.js +14 -14
  191. package/dist/ui/table.mjs +1 -1
  192. package/package.json +1 -1
  193. package/src/charts/charts.test.tsx +80 -0
  194. package/src/charts/funnel/Funnel.tsx +105 -0
  195. package/src/charts/funnel/index.ts +14 -0
  196. package/src/charts/pie/Pie.tsx +28 -1
  197. package/src/charts/radar/Radar.tsx +84 -0
  198. package/src/charts/radar/index.ts +16 -0
  199. package/src/charts/scatter/Scatter.tsx +104 -0
  200. package/src/charts/scatter/index.ts +16 -0
  201. package/src/charts/shared/chart-frame.tsx +4 -2
  202. package/src/charts/shared/types.ts +42 -2
  203. package/src/charts/stacked-bar/StackedBar.tsx +12 -0
  204. package/src/charts/stacked-bar/index.ts +16 -0
  205. package/src/design-system/copy-button.ts +81 -0
  206. package/src/design-system/index.ts +2 -0
  207. package/src/design-system/kbd.ts +83 -0
  208. package/src/hooks/index.ts +12 -0
  209. package/src/hooks/useTableFilter/index.ts +7 -0
  210. package/src/hooks/useTableFilter/types.ts +28 -0
  211. package/src/hooks/useTableFilter/useTableFilter.test.ts +141 -0
  212. package/src/hooks/useTableFilter/useTableFilter.ts +153 -0
  213. package/src/hooks/useTableSort/index.ts +5 -0
  214. package/src/hooks/useTableSort/types.ts +23 -0
  215. package/src/hooks/useTableSort/useTableSort.test.ts +150 -0
  216. package/src/hooks/useTableSort/useTableSort.ts +121 -0
  217. package/src/ui/copy-button/animated/animations.ts +22 -0
  218. package/src/ui/copy-button/animated/copy-button-animated.tsx +39 -0
  219. package/src/ui/copy-button/animated/index.ts +10 -0
  220. package/src/ui/copy-button/animated/types.ts +21 -0
  221. package/src/ui/copy-button/copy-button-base.tsx +88 -0
  222. package/src/ui/copy-button/copy-button.test.tsx +82 -0
  223. package/src/ui/copy-button/copy-button.tsx +9 -0
  224. package/src/ui/copy-button/index.ts +10 -0
  225. package/src/ui/copy-button/types.ts +37 -0
  226. package/src/ui/copy-button/variants.ts +29 -0
  227. package/src/ui/divider/divider.test.tsx +55 -0
  228. package/src/ui/empty-state/empty-state.test.tsx +88 -0
  229. package/src/ui/kbd/animated/animations.ts +15 -0
  230. package/src/ui/kbd/animated/index.ts +9 -0
  231. package/src/ui/kbd/animated/kbd-animated.tsx +26 -0
  232. package/src/ui/kbd/animated/types.ts +16 -0
  233. package/src/ui/kbd/index.ts +5 -0
  234. package/src/ui/kbd/kbd-base.tsx +50 -0
  235. package/src/ui/kbd/kbd.test.tsx +48 -0
  236. package/src/ui/kbd/kbd.tsx +9 -0
  237. package/src/ui/kbd/types.ts +21 -0
  238. package/src/ui/kbd/variants.ts +31 -0
  239. package/src/ui/marquee/marquee.test.tsx +45 -4
  240. package/src/ui/marquee/marquee.tsx +100 -18
  241. package/src/ui/skeleton/skeleton.test.tsx +85 -0
  242. package/src/ui/table/index.ts +3 -0
  243. package/src/ui/table/table-base.tsx +69 -4
  244. package/src/ui/table/table.test.tsx +207 -0
  245. package/src/ui/table/types.ts +13 -1
  246. package/dist/chunk-G2WARVAM.mjs.map +0 -1
  247. package/dist/chunk-G66SXATZ.js.map +0 -1
  248. package/dist/chunk-OULU7OC4.mjs +0 -21
  249. package/dist/chunk-OULU7OC4.mjs.map +0 -1
  250. package/dist/chunk-QNUDODDX.js.map +0 -1
  251. package/dist/chunk-Z6S36PDD.js +0 -24
  252. package/dist/chunk-Z6S36PDD.js.map +0 -1
  253. package/dist/chunk-ZIFMIS7D.mjs.map +0 -1
@@ -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>
@@ -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";
@@ -1,6 +1,12 @@
1
1
  "use client";
2
2
 
3
- import { createContext, useContext, useMemo } from "react";
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useMemo,
8
+ type KeyboardEvent,
9
+ } from "react";
4
10
 
5
11
  import { cn } from "../../lib/utils";
6
12
 
@@ -10,6 +16,7 @@ import type {
10
16
  TableHeadCellProps,
11
17
  TableProps,
12
18
  TableSectionProps,
19
+ TableSortDirection,
13
20
  } from "./types";
14
21
  import { tableCellVariants, tableRowVariants, tableVariants } from "./variants";
15
22
 
@@ -122,19 +129,20 @@ export function TableRow({
122
129
  children,
123
130
  ref,
124
131
  as: Wrapper = "tr",
132
+ rowAnimation: _rowAnimation,
125
133
  ...rest
126
134
  }: TableSectionProps & { ref?: React.Ref<HTMLTableRowElement> }) {
127
135
  const { appearance } = useTableContext("TableRow");
128
136
 
129
137
  return (
130
- <tr
138
+ <Wrapper
131
139
  ref={ref}
132
140
  data-slot="table-row"
133
141
  className={cn(tableRowVariants({ appearance }), className)}
134
142
  {...rest}
135
143
  >
136
144
  {children}
137
- </tr>
145
+ </Wrapper>
138
146
  );
139
147
  }
140
148
 
@@ -144,21 +152,78 @@ export function TableHead({
144
152
  className,
145
153
  children,
146
154
  scope = "col",
155
+ sortKey,
147
156
  sortDirection,
157
+ onSortChange,
158
+ onClick,
159
+ onKeyDown,
160
+ tabIndex,
148
161
  ref,
149
162
  ...rest
150
163
  }: TableHeadCellProps) {
151
164
  const { appearance, size, textAlign } = useTableContext("TableHead");
165
+ const isSortable = Boolean(sortKey && onSortChange);
166
+ const sortableDirection: TableSortDirection = sortDirection ?? "none";
167
+
168
+ const handleSort = useCallback(() => {
169
+ if (!sortKey || !onSortChange) {
170
+ return;
171
+ }
172
+
173
+ const nextDirection: TableSortDirection =
174
+ sortableDirection === "ascending"
175
+ ? "descending"
176
+ : sortableDirection === "descending"
177
+ ? "none"
178
+ : "ascending";
179
+
180
+ onSortChange({
181
+ sortKey,
182
+ sortDirection: nextDirection,
183
+ });
184
+ }, [onSortChange, sortKey, sortableDirection]);
185
+
186
+ const handleClick = useCallback<NonNullable<TableHeadCellProps["onClick"]>>(
187
+ (event) => {
188
+ onClick?.(event);
189
+ if (!event.defaultPrevented) {
190
+ handleSort();
191
+ }
192
+ },
193
+ [handleSort, onClick],
194
+ );
195
+
196
+ const handleKeyDown = useCallback(
197
+ (event: KeyboardEvent<HTMLTableCellElement>) => {
198
+ onKeyDown?.(event);
199
+ if (event.defaultPrevented || !isSortable) {
200
+ return;
201
+ }
202
+ if (event.key === "Enter" || event.key === " ") {
203
+ event.preventDefault();
204
+ handleSort();
205
+ }
206
+ },
207
+ [handleSort, isSortable, onKeyDown],
208
+ );
209
+
152
210
  return (
153
211
  <th
154
212
  ref={ref}
155
213
  data-slot="table-head"
156
214
  scope={scope}
157
- aria-sort={sortDirection}
215
+ aria-sort={isSortable ? sortableDirection : sortDirection}
216
+ data-sort-key={sortKey}
217
+ data-sort-direction={sortDirection}
218
+ tabIndex={isSortable ? (tabIndex ?? 0) : tabIndex}
158
219
  className={cn(
159
220
  tableCellVariants({ appearance, size, textAlign }),
221
+ isSortable &&
222
+ "cursor-pointer select-none outline-none focus-visible:ring-2 focus-visible:ring-slate-300 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950",
160
223
  className,
161
224
  )}
225
+ onClick={isSortable ? handleClick : onClick}
226
+ onKeyDown={isSortable ? handleKeyDown : onKeyDown}
162
227
  {...rest}
163
228
  >
164
229
  {children}
@@ -0,0 +1,207 @@
1
+ import { createRef } 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
+
6
+ import { Table } from "./table";
7
+ import {
8
+ TableBody,
9
+ TableCaption,
10
+ TableCell,
11
+ TableFooter,
12
+ TableHead,
13
+ TableHeader,
14
+ TableRow,
15
+ } from "./table-base";
16
+
17
+ function renderBasicTable() {
18
+ return render(
19
+ <Table>
20
+ <TableCaption>Quarterly revenue</TableCaption>
21
+ <TableHeader>
22
+ <TableRow>
23
+ <TableHead>Company</TableHead>
24
+ <TableHead>ARR</TableHead>
25
+ </TableRow>
26
+ </TableHeader>
27
+ <TableBody>
28
+ <TableRow>
29
+ <TableCell scope="row">Zentauri</TableCell>
30
+ <TableCell>$42k</TableCell>
31
+ </TableRow>
32
+ </TableBody>
33
+ <TableFooter>
34
+ <TableRow>
35
+ <TableCell>Total</TableCell>
36
+ <TableCell>$42k</TableCell>
37
+ </TableRow>
38
+ </TableFooter>
39
+ </Table>,
40
+ );
41
+ }
42
+
43
+ describe("Table", () => {
44
+ it("should set displayName on compound parts", () => {
45
+ expect(Table.displayName).toBe("Table");
46
+ expect(TableHeader.displayName).toBe("TableHeader");
47
+ expect(TableBody.displayName).toBe("TableBody");
48
+ expect(TableFooter.displayName).toBe("TableFooter");
49
+ expect(TableRow.displayName).toBe("TableRow");
50
+ expect(TableHead.displayName).toBe("TableHead");
51
+ expect(TableCell.displayName).toBe("TableCell");
52
+ expect(TableCaption.displayName).toBe("TableCaption");
53
+ });
54
+
55
+ it("should render semantic table structure inside a focusable scroll region", () => {
56
+ const { container } = renderBasicTable();
57
+ expect(
58
+ screen.getByRole("region", { name: "Scrollable table" }),
59
+ ).toHaveAttribute("data-slot", "table-scroll");
60
+ expect(screen.getByRole("table")).toHaveAttribute("data-slot", "table");
61
+ expect(
62
+ container.querySelector('[data-slot="table-caption"]'),
63
+ ).toHaveTextContent("Quarterly revenue");
64
+ });
65
+
66
+ it("should support a custom scroll region label", () => {
67
+ render(<Table scrollAreaAriaLabel="Usage table" />);
68
+ expect(screen.getByRole("region", { name: "Usage table" })).toBeVisible();
69
+ });
70
+
71
+ it("should apply sticky header classes when stickyHeader is enabled", () => {
72
+ const { container } = render(
73
+ <Table stickyHeader>
74
+ <TableHeader>
75
+ <TableRow>
76
+ <TableHead>Name</TableHead>
77
+ </TableRow>
78
+ </TableHeader>
79
+ </Table>,
80
+ );
81
+ expect(
82
+ container.querySelector('[data-slot="table-header"]')?.className,
83
+ ).toMatch(/sticky/);
84
+ expect(container.querySelector('[data-slot="table"]')?.className).toMatch(
85
+ /table-auto/,
86
+ );
87
+ });
88
+
89
+ it("should expose sort state on sortable header cells", () => {
90
+ render(
91
+ <Table>
92
+ <TableHeader>
93
+ <TableRow>
94
+ <TableHead sortKey="name" sortDirection="ascending">
95
+ Name
96
+ </TableHead>
97
+ </TableRow>
98
+ </TableHeader>
99
+ </Table>,
100
+ );
101
+ const header = screen.getByRole("columnheader", { name: "Name" });
102
+ expect(header).toHaveAttribute("aria-sort", "ascending");
103
+ expect(header).toHaveAttribute("data-sort-key", "name");
104
+ expect(header).toHaveAttribute("data-sort-direction", "ascending");
105
+ });
106
+
107
+ it("should mark sortable unsorted header cells with aria-sort none", () => {
108
+ render(
109
+ <Table>
110
+ <TableHeader>
111
+ <TableRow>
112
+ <TableHead sortKey="name" onSortChange={vi.fn()}>
113
+ Name
114
+ </TableHead>
115
+ </TableRow>
116
+ </TableHeader>
117
+ </Table>,
118
+ );
119
+ expect(screen.getByRole("columnheader", { name: "Name" })).toHaveAttribute(
120
+ "aria-sort",
121
+ "none",
122
+ );
123
+ });
124
+
125
+ it("should call onSortChange with the next direction on click", async () => {
126
+ const user = userEvent.setup();
127
+ const handleSortChange = vi.fn();
128
+ render(
129
+ <Table>
130
+ <TableHeader>
131
+ <TableRow>
132
+ <TableHead
133
+ sortKey="name"
134
+ sortDirection="ascending"
135
+ onSortChange={handleSortChange}
136
+ >
137
+ Name
138
+ </TableHead>
139
+ </TableRow>
140
+ </TableHeader>
141
+ </Table>,
142
+ );
143
+
144
+ await user.click(screen.getByRole("columnheader", { name: "Name" }));
145
+ expect(handleSortChange).toHaveBeenCalledWith({
146
+ sortKey: "name",
147
+ sortDirection: "descending",
148
+ });
149
+ });
150
+
151
+ it("should support keyboard sorting", async () => {
152
+ const user = userEvent.setup();
153
+ const handleSortChange = vi.fn();
154
+ render(
155
+ <Table>
156
+ <TableHeader>
157
+ <TableRow>
158
+ <TableHead
159
+ sortKey="createdAt"
160
+ sortDirection="none"
161
+ onSortChange={handleSortChange}
162
+ >
163
+ Created
164
+ </TableHead>
165
+ </TableRow>
166
+ </TableHeader>
167
+ </Table>,
168
+ );
169
+
170
+ screen.getByRole("columnheader", { name: "Created" }).focus();
171
+ await user.keyboard("{Enter}");
172
+ expect(handleSortChange).toHaveBeenCalledWith({
173
+ sortKey: "createdAt",
174
+ sortDirection: "ascending",
175
+ });
176
+ });
177
+
178
+ it("should render scoped body cells as row headers", () => {
179
+ renderBasicTable();
180
+ expect(screen.getByRole("rowheader", { name: "Zentauri" })).toHaveAttribute(
181
+ "data-slot",
182
+ "table-cell",
183
+ );
184
+ });
185
+
186
+ it("should not leak rowAnimation onto the rendered row", () => {
187
+ const { container } = render(
188
+ <Table>
189
+ <TableBody>
190
+ <TableRow rowAnimation="hover">
191
+ <TableCell>Animated elsewhere</TableCell>
192
+ </TableRow>
193
+ </TableBody>
194
+ </Table>,
195
+ );
196
+ expect(
197
+ container.querySelector('[data-slot="table-row"]'),
198
+ ).not.toHaveAttribute("rowAnimation");
199
+ });
200
+
201
+ it("should forward refs to the table element", () => {
202
+ const ref = createRef<HTMLTableElement>();
203
+ render(<Table ref={ref} />);
204
+ expect(ref.current).toBeInstanceOf(HTMLTableElement);
205
+ expect(ref.current?.getAttribute("data-slot")).toBe("table");
206
+ });
207
+ });
@@ -11,6 +11,16 @@ import type {
11
11
  import type { tableVariants } from "./variants";
12
12
 
13
13
  export type TableAnimation = "none" | "hover";
14
+ export type TableSortDirection = "ascending" | "descending" | "none";
15
+
16
+ export type TableSortState<TKey extends string = string> = {
17
+ sortKey?: TKey;
18
+ sortDirection: TableSortDirection;
19
+ };
20
+
21
+ export type TableSortChangeHandler<TKey extends string = string> = (
22
+ nextSort: TableSortState<TKey>,
23
+ ) => void;
14
24
 
15
25
  type TableVariantProps = VariantProps<typeof tableVariants>;
16
26
 
@@ -31,7 +41,9 @@ export type TableSectionProps = {
31
41
  };
32
42
 
33
43
  export type TableHeadCellProps = ThHTMLAttributes<HTMLTableCellElement> & {
34
- sortDirection?: "ascending" | "descending" | "none";
44
+ sortKey?: string;
45
+ sortDirection?: TableSortDirection;
46
+ onSortChange?: TableSortChangeHandler;
35
47
  ref?: Ref<HTMLTableCellElement>;
36
48
  };
37
49