@zentauri-ui/zentauri-components 1.8.1 → 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 (156) hide show
  1. package/README.md +21 -10
  2. package/cli/registry.json +10 -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-F3V4POW3.mjs +8 -0
  62. package/dist/chunk-F3V4POW3.mjs.map +1 -0
  63. package/dist/{chunk-G2WARVAM.mjs → chunk-HZIRD3SR.mjs} +35 -15
  64. package/dist/chunk-HZIRD3SR.mjs.map +1 -0
  65. package/dist/{chunk-G66SXATZ.js → chunk-IL4LH2XX.js} +50 -4
  66. package/dist/chunk-IL4LH2XX.js.map +1 -0
  67. package/dist/chunk-LREMK2XR.js +97 -0
  68. package/dist/chunk-LREMK2XR.js.map +1 -0
  69. package/dist/chunk-O2KM3ETC.mjs +95 -0
  70. package/dist/chunk-O2KM3ETC.mjs.map +1 -0
  71. package/dist/{chunk-ZIFMIS7D.mjs → chunk-OL3BJSRC.mjs} +51 -5
  72. package/dist/chunk-OL3BJSRC.mjs.map +1 -0
  73. package/dist/{chunk-QNUDODDX.js → chunk-PWPMKXEG.js} +36 -14
  74. package/dist/chunk-PWPMKXEG.js.map +1 -0
  75. package/dist/chunk-XRM7GOIE.js +10 -0
  76. package/dist/chunk-XRM7GOIE.js.map +1 -0
  77. package/dist/hooks/index.d.ts +2 -0
  78. package/dist/hooks/index.d.ts.map +1 -1
  79. package/dist/hooks/useIsomorphicLayoutEffect.js +6 -4
  80. package/dist/hooks/useIsomorphicLayoutEffect.js.map +1 -1
  81. package/dist/hooks/useIsomorphicLayoutEffect.mjs +1 -6
  82. package/dist/hooks/useIsomorphicLayoutEffect.mjs.map +1 -1
  83. package/dist/hooks/useTableFilter/index.d.ts +3 -0
  84. package/dist/hooks/useTableFilter/index.d.ts.map +1 -0
  85. package/dist/hooks/useTableFilter/types.d.ts +20 -0
  86. package/dist/hooks/useTableFilter/types.d.ts.map +1 -0
  87. package/dist/hooks/useTableFilter/useTableFilter.d.ts +3 -0
  88. package/dist/hooks/useTableFilter/useTableFilter.d.ts.map +1 -0
  89. package/dist/hooks/useTableFilter.js +124 -0
  90. package/dist/hooks/useTableFilter.js.map +1 -0
  91. package/dist/hooks/useTableFilter.mjs +122 -0
  92. package/dist/hooks/useTableFilter.mjs.map +1 -0
  93. package/dist/hooks/useTableSort/index.d.ts +3 -0
  94. package/dist/hooks/useTableSort/index.d.ts.map +1 -0
  95. package/dist/hooks/useTableSort/types.d.ts +15 -0
  96. package/dist/hooks/useTableSort/types.d.ts.map +1 -0
  97. package/dist/hooks/useTableSort/useTableSort.d.ts +3 -0
  98. package/dist/hooks/useTableSort/useTableSort.d.ts.map +1 -0
  99. package/dist/hooks/useTableSort.js +99 -0
  100. package/dist/hooks/useTableSort.js.map +1 -0
  101. package/dist/hooks/useTableSort.mjs +97 -0
  102. package/dist/hooks/useTableSort.mjs.map +1 -0
  103. package/dist/ui/marquee/marquee.d.ts.map +1 -1
  104. package/dist/ui/marquee.js +82 -21
  105. package/dist/ui/marquee.js.map +1 -1
  106. package/dist/ui/marquee.mjs +83 -22
  107. package/dist/ui/marquee.mjs.map +1 -1
  108. package/dist/ui/table/animated.js +8 -8
  109. package/dist/ui/table/animated.mjs +2 -2
  110. package/dist/ui/table/index.d.ts +1 -1
  111. package/dist/ui/table/index.d.ts.map +1 -1
  112. package/dist/ui/table/table-base.d.ts +2 -2
  113. package/dist/ui/table/table-base.d.ts.map +1 -1
  114. package/dist/ui/table/types.d.ts +9 -1
  115. package/dist/ui/table/types.d.ts.map +1 -1
  116. package/dist/ui/table.js +14 -14
  117. package/dist/ui/table.mjs +1 -1
  118. package/package.json +1 -1
  119. package/src/charts/charts.test.tsx +80 -0
  120. package/src/charts/funnel/Funnel.tsx +105 -0
  121. package/src/charts/funnel/index.ts +14 -0
  122. package/src/charts/pie/Pie.tsx +28 -1
  123. package/src/charts/radar/Radar.tsx +84 -0
  124. package/src/charts/radar/index.ts +16 -0
  125. package/src/charts/scatter/Scatter.tsx +104 -0
  126. package/src/charts/scatter/index.ts +16 -0
  127. package/src/charts/shared/chart-frame.tsx +4 -2
  128. package/src/charts/shared/types.ts +42 -2
  129. package/src/charts/stacked-bar/StackedBar.tsx +12 -0
  130. package/src/charts/stacked-bar/index.ts +16 -0
  131. package/src/hooks/index.ts +12 -0
  132. package/src/hooks/useTableFilter/index.ts +7 -0
  133. package/src/hooks/useTableFilter/types.ts +28 -0
  134. package/src/hooks/useTableFilter/useTableFilter.test.ts +141 -0
  135. package/src/hooks/useTableFilter/useTableFilter.ts +153 -0
  136. package/src/hooks/useTableSort/index.ts +5 -0
  137. package/src/hooks/useTableSort/types.ts +23 -0
  138. package/src/hooks/useTableSort/useTableSort.test.ts +150 -0
  139. package/src/hooks/useTableSort/useTableSort.ts +121 -0
  140. package/src/ui/divider/divider.test.tsx +55 -0
  141. package/src/ui/empty-state/empty-state.test.tsx +88 -0
  142. package/src/ui/marquee/marquee.test.tsx +45 -4
  143. package/src/ui/marquee/marquee.tsx +100 -18
  144. package/src/ui/skeleton/skeleton.test.tsx +85 -0
  145. package/src/ui/table/index.ts +3 -0
  146. package/src/ui/table/table-base.tsx +69 -4
  147. package/src/ui/table/table.test.tsx +207 -0
  148. package/src/ui/table/types.ts +13 -1
  149. package/dist/chunk-G2WARVAM.mjs.map +0 -1
  150. package/dist/chunk-G66SXATZ.js.map +0 -1
  151. package/dist/chunk-OULU7OC4.mjs +0 -21
  152. package/dist/chunk-OULU7OC4.mjs.map +0 -1
  153. package/dist/chunk-QNUDODDX.js.map +0 -1
  154. package/dist/chunk-Z6S36PDD.js +0 -24
  155. package/dist/chunk-Z6S36PDD.js.map +0 -1
  156. package/dist/chunk-ZIFMIS7D.mjs.map +0 -1
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useState } from "react";
4
+
5
+ import type {
6
+ TableFilterState,
7
+ UseTableFilterParams,
8
+ UseTableFilterResult,
9
+ } from "./types";
10
+
11
+ function normalizeFilters<TKey extends string>(
12
+ filters: TableFilterState<TKey> | null | undefined,
13
+ ): TableFilterState<TKey> {
14
+ if (!filters || typeof filters !== "object") {
15
+ return {};
16
+ }
17
+
18
+ return Object.fromEntries(
19
+ Object.entries(filters).filter(
20
+ (entry): entry is [TKey, string] =>
21
+ typeof entry[1] === "string" && entry[1].trim().length > 0,
22
+ ),
23
+ ) as TableFilterState<TKey>;
24
+ }
25
+
26
+ function defaultColumnValue<TData, TKey extends string>(
27
+ row: TData,
28
+ filterKey: TKey,
29
+ ): unknown {
30
+ if (row && typeof row === "object" && filterKey in row) {
31
+ return (row as Record<TKey, unknown>)[filterKey];
32
+ }
33
+ return undefined;
34
+ }
35
+
36
+ export function useTableFilter<TData, TKey extends string = string>({
37
+ data,
38
+ filters,
39
+ defaultFilters = {},
40
+ onFiltersChange,
41
+ getColumnValue = defaultColumnValue,
42
+ filterPredicate,
43
+ }: UseTableFilterParams<TData, TKey>): UseTableFilterResult<TData, TKey> {
44
+ const [internalFilters, setInternalFilters] = useState<
45
+ TableFilterState<TKey>
46
+ >(() => normalizeFilters(defaultFilters));
47
+
48
+ const isControlled = filters !== undefined;
49
+ const currentFilters = useMemo(
50
+ () => normalizeFilters(isControlled ? filters : internalFilters),
51
+ [filters, internalFilters, isControlled],
52
+ );
53
+
54
+ const setFilters = useCallback(
55
+ (nextFilters: TableFilterState<TKey>) => {
56
+ const normalized = normalizeFilters(nextFilters);
57
+ if (!isControlled) {
58
+ setInternalFilters(normalized);
59
+ }
60
+ onFiltersChange?.(normalized);
61
+ },
62
+ [isControlled, onFiltersChange],
63
+ );
64
+
65
+ const updateFilters = useCallback(
66
+ (
67
+ updater: (
68
+ previousFilters: TableFilterState<TKey>,
69
+ ) => TableFilterState<TKey>,
70
+ ) => {
71
+ if (isControlled) {
72
+ const normalized = normalizeFilters(updater(currentFilters));
73
+ onFiltersChange?.(normalized);
74
+ return;
75
+ }
76
+
77
+ setInternalFilters((previousFilters) => {
78
+ const normalized = normalizeFilters(updater(previousFilters));
79
+ onFiltersChange?.(normalized);
80
+ return normalized;
81
+ });
82
+ },
83
+ [currentFilters, isControlled, onFiltersChange],
84
+ );
85
+
86
+ const setFilter = useCallback(
87
+ (filterKey: TKey, value: string) => {
88
+ updateFilters((previousFilters) => ({
89
+ ...previousFilters,
90
+ [filterKey]: value,
91
+ }));
92
+ },
93
+ [updateFilters],
94
+ );
95
+
96
+ const clearFilter = useCallback(
97
+ (filterKey: TKey) => {
98
+ updateFilters((previousFilters) => {
99
+ const nextFilters = { ...previousFilters };
100
+ delete nextFilters[filterKey];
101
+ return nextFilters;
102
+ });
103
+ },
104
+ [updateFilters],
105
+ );
106
+
107
+ const clearFilters = useCallback(() => {
108
+ setFilters({});
109
+ }, [setFilters]);
110
+
111
+ const activeFilters = useMemo(
112
+ () =>
113
+ (Object.entries(currentFilters) as [TKey, string][]).map(
114
+ ([filterKey, filterValue]) => ({
115
+ filterKey,
116
+ filterValue,
117
+ lowerFilterValue: filterValue.toLowerCase(),
118
+ }),
119
+ ),
120
+ [currentFilters],
121
+ );
122
+
123
+ const filteredData = useMemo(() => {
124
+ if (activeFilters.length === 0) {
125
+ return [...data];
126
+ }
127
+
128
+ return data.filter((row) =>
129
+ activeFilters.every(({ filterKey, filterValue, lowerFilterValue }) => {
130
+ if (filterPredicate) {
131
+ return filterPredicate(row, filterValue, filterKey);
132
+ }
133
+
134
+ const columnValue = getColumnValue(row, filterKey);
135
+ if (columnValue == null) {
136
+ return false;
137
+ }
138
+
139
+ return String(columnValue).toLowerCase().includes(lowerFilterValue);
140
+ }),
141
+ );
142
+ }, [activeFilters, data, filterPredicate, getColumnValue]);
143
+
144
+ return {
145
+ filters: currentFilters,
146
+ filteredData,
147
+ hasActiveFilters: activeFilters.length > 0,
148
+ setFilter,
149
+ setFilters,
150
+ clearFilter,
151
+ clearFilters,
152
+ };
153
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ type UseTableSortParams,
3
+ type UseTableSortResult,
4
+ } from "./types";
5
+ export { useTableSort } from "./useTableSort";
@@ -0,0 +1,23 @@
1
+ import type {
2
+ TableHeadCellProps,
3
+ TableSortDirection,
4
+ TableSortState,
5
+ } from "../../ui/table/types";
6
+
7
+ export type UseTableSortParams<TKey extends string = string> = {
8
+ sortKey?: TKey;
9
+ defaultSortKey?: TKey;
10
+ sortDirection?: TableSortDirection;
11
+ defaultSortDirection?: TableSortDirection;
12
+ onSortChange?: (nextSort: TableSortState<TKey>) => void;
13
+ };
14
+
15
+ export type UseTableSortResult<TKey extends string = string> =
16
+ TableSortState<TKey> & {
17
+ setSort: (nextSort: TableSortState<TKey>) => void;
18
+ clearSort: () => void;
19
+ toggleSort: (sortKey: TKey) => void;
20
+ getSortProps: (
21
+ sortKey: TKey,
22
+ ) => Pick<TableHeadCellProps, "sortKey" | "sortDirection" | "onSortChange">;
23
+ };
@@ -0,0 +1,150 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import type { TableSortDirection } from "../../ui/table/types";
5
+ import { useTableSort } from "./useTableSort";
6
+
7
+ describe("useTableSort", () => {
8
+ it("should default to no active sort", () => {
9
+ const { result } = renderHook(() => useTableSort());
10
+ expect(result.current.sortKey).toBeUndefined();
11
+ expect(result.current.sortDirection).toBe("none");
12
+ });
13
+
14
+ it("should seed state from default sort params", () => {
15
+ const { result } = renderHook(() =>
16
+ useTableSort({
17
+ defaultSortKey: "name",
18
+ defaultSortDirection: "ascending",
19
+ }),
20
+ );
21
+ expect(result.current.sortKey).toBe("name");
22
+ expect(result.current.sortDirection).toBe("ascending");
23
+ });
24
+
25
+ it("should cycle a column through ascending, descending, and none", () => {
26
+ const { result } = renderHook(() => useTableSort<"name">());
27
+
28
+ act(() => {
29
+ result.current.toggleSort("name");
30
+ });
31
+ expect(result.current).toMatchObject({
32
+ sortKey: "name",
33
+ sortDirection: "ascending",
34
+ });
35
+
36
+ act(() => {
37
+ result.current.toggleSort("name");
38
+ });
39
+ expect(result.current).toMatchObject({
40
+ sortKey: "name",
41
+ sortDirection: "descending",
42
+ });
43
+
44
+ act(() => {
45
+ result.current.toggleSort("name");
46
+ });
47
+ expect(result.current).toMatchObject({
48
+ sortKey: undefined,
49
+ sortDirection: "none",
50
+ });
51
+ });
52
+
53
+ it("should support controlled sort state", () => {
54
+ const handleSortChange = vi.fn();
55
+ const { result, rerender } = renderHook(
56
+ ({
57
+ sortKey,
58
+ sortDirection,
59
+ }: {
60
+ sortKey?: string;
61
+ sortDirection?: TableSortDirection;
62
+ }) =>
63
+ useTableSort({
64
+ sortKey,
65
+ sortDirection,
66
+ onSortChange: handleSortChange,
67
+ }),
68
+ {
69
+ initialProps: {
70
+ sortKey: "createdAt" as string,
71
+ sortDirection: "ascending" as TableSortDirection,
72
+ },
73
+ },
74
+ );
75
+
76
+ act(() => {
77
+ result.current.toggleSort("createdAt");
78
+ });
79
+ expect(handleSortChange).toHaveBeenCalledWith({
80
+ sortKey: "createdAt",
81
+ sortDirection: "descending",
82
+ });
83
+ expect(result.current.sortDirection).toBe("ascending");
84
+
85
+ rerender({ sortKey: "createdAt", sortDirection: "descending" });
86
+ expect(result.current.sortDirection).toBe("descending");
87
+ });
88
+
89
+ it("should allow only sortKey to be controlled", () => {
90
+ const handleSortChange = vi.fn();
91
+ const { result } = renderHook(() =>
92
+ useTableSort({
93
+ sortKey: "name",
94
+ onSortChange: handleSortChange,
95
+ }),
96
+ );
97
+
98
+ act(() => {
99
+ result.current.toggleSort("name");
100
+ });
101
+
102
+ expect(handleSortChange).toHaveBeenCalledWith({
103
+ sortKey: "name",
104
+ sortDirection: "ascending",
105
+ });
106
+ expect(result.current.sortKey).toBe("name");
107
+ expect(result.current.sortDirection).toBe("ascending");
108
+ });
109
+
110
+ it("should allow only sortDirection to be controlled", () => {
111
+ const handleSortChange = vi.fn();
112
+ const { result } = renderHook(() =>
113
+ useTableSort<"name">({
114
+ sortDirection: "ascending",
115
+ onSortChange: handleSortChange,
116
+ }),
117
+ );
118
+
119
+ act(() => {
120
+ result.current.toggleSort("name");
121
+ });
122
+
123
+ expect(handleSortChange).toHaveBeenCalledWith({
124
+ sortKey: "name",
125
+ sortDirection: "ascending",
126
+ });
127
+ expect(result.current.sortKey).toBe("name");
128
+ expect(result.current.sortDirection).toBe("ascending");
129
+ });
130
+
131
+ it("should return TableHead-compatible sort props", () => {
132
+ const { result } = renderHook(() =>
133
+ useTableSort({
134
+ defaultSortKey: "status",
135
+ defaultSortDirection: "descending",
136
+ }),
137
+ );
138
+ const props = result.current.getSortProps("status");
139
+ expect(props).toMatchObject({
140
+ sortKey: "status",
141
+ sortDirection: "descending",
142
+ });
143
+
144
+ act(() => {
145
+ props.onSortChange?.({ sortKey: "status", sortDirection: "none" });
146
+ });
147
+ expect(result.current.sortKey).toBeUndefined();
148
+ expect(result.current.sortDirection).toBe("none");
149
+ });
150
+ });
@@ -0,0 +1,121 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useState } from "react";
4
+
5
+ import type { TableSortDirection, TableSortState } from "../../ui/table/types";
6
+ import type { UseTableSortParams, UseTableSortResult } from "./types";
7
+
8
+ function nextSortDirection(
9
+ currentDirection: TableSortDirection,
10
+ ): TableSortDirection {
11
+ if (currentDirection === "ascending") {
12
+ return "descending";
13
+ }
14
+ if (currentDirection === "descending") {
15
+ return "none";
16
+ }
17
+ return "ascending";
18
+ }
19
+
20
+ function normalizeSortState<TKey extends string>(
21
+ nextSort: TableSortState<TKey>,
22
+ ): TableSortState<TKey> {
23
+ if (!nextSort.sortKey || nextSort.sortDirection === "none") {
24
+ return { sortKey: undefined, sortDirection: "none" };
25
+ }
26
+ return nextSort;
27
+ }
28
+
29
+ export function useTableSort<TKey extends string = string>({
30
+ sortKey,
31
+ defaultSortKey,
32
+ sortDirection,
33
+ defaultSortDirection = "none",
34
+ onSortChange,
35
+ }: UseTableSortParams<TKey> = {}): UseTableSortResult<TKey> {
36
+ const [internalSort, setInternalSort] = useState<TableSortState<TKey>>(() =>
37
+ normalizeSortState({
38
+ sortKey: defaultSortKey,
39
+ sortDirection: defaultSortDirection,
40
+ }),
41
+ );
42
+
43
+ const isSortKeyControlled = sortKey !== undefined;
44
+ const isSortDirectionControlled = sortDirection !== undefined;
45
+ const currentSort = normalizeSortState({
46
+ sortKey: isSortKeyControlled ? sortKey : internalSort.sortKey,
47
+ sortDirection: isSortDirectionControlled
48
+ ? sortDirection
49
+ : internalSort.sortDirection,
50
+ });
51
+
52
+ const setSort = useCallback(
53
+ (nextSort: TableSortState<TKey>) => {
54
+ const normalized = normalizeSortState(nextSort);
55
+ if (!isSortKeyControlled || !isSortDirectionControlled) {
56
+ setInternalSort((previousSort) => ({
57
+ sortKey: isSortKeyControlled
58
+ ? previousSort.sortKey
59
+ : normalized.sortKey,
60
+ sortDirection: isSortDirectionControlled
61
+ ? previousSort.sortDirection
62
+ : normalized.sortDirection,
63
+ }));
64
+ }
65
+ onSortChange?.(normalized);
66
+ },
67
+ [isSortDirectionControlled, isSortKeyControlled, onSortChange],
68
+ );
69
+
70
+ const clearSort = useCallback(() => {
71
+ setSort({ sortKey: undefined, sortDirection: "none" });
72
+ }, [setSort]);
73
+
74
+ const toggleSort = useCallback(
75
+ (nextSortKey: TKey) => {
76
+ const direction =
77
+ currentSort.sortKey === nextSortKey
78
+ ? nextSortDirection(currentSort.sortDirection)
79
+ : "ascending";
80
+
81
+ setSort({
82
+ sortKey: nextSortKey,
83
+ sortDirection: direction,
84
+ });
85
+ },
86
+ [currentSort.sortDirection, currentSort.sortKey, setSort],
87
+ );
88
+
89
+ const getSortProps = useCallback(
90
+ (nextSortKey: TKey) => ({
91
+ sortKey: nextSortKey,
92
+ sortDirection:
93
+ currentSort.sortKey === nextSortKey
94
+ ? currentSort.sortDirection
95
+ : "none",
96
+ onSortChange: (nextSort: TableSortState) => {
97
+ setSort(nextSort as TableSortState<TKey>);
98
+ },
99
+ }),
100
+ [currentSort.sortDirection, currentSort.sortKey, setSort],
101
+ );
102
+
103
+ return useMemo(
104
+ () => ({
105
+ sortKey: currentSort.sortKey,
106
+ sortDirection: currentSort.sortDirection,
107
+ setSort,
108
+ clearSort,
109
+ toggleSort,
110
+ getSortProps,
111
+ }),
112
+ [
113
+ clearSort,
114
+ currentSort.sortDirection,
115
+ currentSort.sortKey,
116
+ getSortProps,
117
+ setSort,
118
+ toggleSort,
119
+ ],
120
+ );
121
+ }
@@ -0,0 +1,55 @@
1
+ import { createRef } from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { Divider } from "./divider";
6
+
7
+ const DIVIDER_SLOT = '[data-slot="divider"]';
8
+
9
+ function getDividerRoot(container: HTMLElement = document.body) {
10
+ const elements = container.querySelectorAll(DIVIDER_SLOT);
11
+ expect(elements.length).toBe(1);
12
+ return elements[0] as HTMLElement;
13
+ }
14
+
15
+ describe("Divider", () => {
16
+ it("should expose a stable displayName", () => {
17
+ expect(Divider.displayName).toBe("Divider");
18
+ });
19
+
20
+ it("should render a horizontal separator by default", () => {
21
+ render(<Divider />);
22
+ const root = screen.getByRole("separator");
23
+ expect(root).toHaveAttribute("data-slot", "divider");
24
+ expect(root).toHaveAttribute("aria-orientation", "horizontal");
25
+ });
26
+
27
+ it("should render a vertical separator when requested", () => {
28
+ render(<Divider orientation="vertical" />);
29
+ expect(screen.getByRole("separator")).toHaveAttribute(
30
+ "aria-orientation",
31
+ "vertical",
32
+ );
33
+ });
34
+
35
+ it("should render label content between two divider lines", () => {
36
+ render(<Divider label="Or continue with" />);
37
+ expect(screen.getByText("Or continue with")).toHaveAttribute(
38
+ "data-slot",
39
+ "divider-label",
40
+ );
41
+ expect(document.querySelectorAll("[aria-hidden]").length).toBe(2);
42
+ });
43
+
44
+ it("should apply appearance token classes", () => {
45
+ render(<Divider appearance="emerald" />);
46
+ expect(getDividerRoot().className).toMatch(/--zui-divider-emerald-fg/);
47
+ });
48
+
49
+ it("should forward refs to the separator element", () => {
50
+ const ref = createRef<HTMLDivElement>();
51
+ render(<Divider ref={ref} />);
52
+ expect(ref.current).toBeInstanceOf(HTMLElement);
53
+ expect(ref.current?.getAttribute("data-slot")).toBe("divider");
54
+ });
55
+ });
@@ -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">