@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,141 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { useTableFilter } from "./useTableFilter";
5
+
6
+ const rows = [
7
+ { name: "Atlas", status: "active", seats: 12 },
8
+ { name: "Beacon", status: "paused", seats: 4 },
9
+ { name: "Comet", status: "active", seats: 8 },
10
+ ] as const;
11
+
12
+ describe("useTableFilter", () => {
13
+ it("should return all rows when no filters are active", () => {
14
+ const { result } = renderHook(() => useTableFilter({ data: rows }));
15
+ expect(result.current.filteredData).toEqual(rows);
16
+ expect(result.current.hasActiveFilters).toBe(false);
17
+ });
18
+
19
+ it("should filter rows by a string column value", () => {
20
+ const { result } = renderHook(() =>
21
+ useTableFilter({
22
+ data: rows,
23
+ defaultFilters: { status: "active" },
24
+ }),
25
+ );
26
+ expect(result.current.filteredData.map((row) => row.name)).toEqual([
27
+ "Atlas",
28
+ "Comet",
29
+ ]);
30
+ expect(result.current.hasActiveFilters).toBe(true);
31
+ });
32
+
33
+ it("should combine multiple column filters", () => {
34
+ const { result } = renderHook(() =>
35
+ useTableFilter({
36
+ data: rows,
37
+ defaultFilters: { status: "active", name: "com" },
38
+ }),
39
+ );
40
+ expect(result.current.filteredData).toEqual([rows[2]]);
41
+ });
42
+
43
+ it("should update and clear filters in uncontrolled mode", () => {
44
+ const { result } = renderHook(() => useTableFilter({ data: rows }));
45
+
46
+ act(() => {
47
+ result.current.setFilter("name", "bea");
48
+ });
49
+ expect(result.current.filteredData).toEqual([rows[1]]);
50
+
51
+ act(() => {
52
+ result.current.clearFilter("name");
53
+ });
54
+ expect(result.current.filteredData).toEqual(rows);
55
+
56
+ act(() => {
57
+ result.current.setFilters({ status: "paused" });
58
+ });
59
+ expect(result.current.filteredData).toEqual([rows[1]]);
60
+
61
+ act(() => {
62
+ result.current.clearFilters();
63
+ });
64
+ expect(result.current.filteredData).toEqual(rows);
65
+ });
66
+
67
+ it("should preserve batched uncontrolled filter updates", () => {
68
+ const { result } = renderHook(() => useTableFilter({ data: rows }));
69
+
70
+ act(() => {
71
+ result.current.setFilter("status", "active");
72
+ result.current.setFilter("name", "atlas");
73
+ });
74
+
75
+ expect(result.current.filters).toEqual({
76
+ status: "active",
77
+ name: "atlas",
78
+ });
79
+ expect(result.current.filteredData).toEqual([rows[0]]);
80
+ });
81
+
82
+ it("should support controlled filters", () => {
83
+ const handleFiltersChange = vi.fn();
84
+ const { result, rerender } = renderHook(
85
+ ({ filters }: { filters: Record<string, string> }) =>
86
+ useTableFilter({
87
+ data: rows,
88
+ filters,
89
+ onFiltersChange: handleFiltersChange,
90
+ }),
91
+ { initialProps: { filters: { status: "active" } } },
92
+ );
93
+
94
+ expect(result.current.filteredData.length).toBe(2);
95
+ act(() => {
96
+ result.current.setFilter("name", "atlas");
97
+ });
98
+ expect(handleFiltersChange).toHaveBeenCalledWith({
99
+ status: "active",
100
+ name: "atlas",
101
+ });
102
+ expect(result.current.filters).toEqual({ status: "active" });
103
+
104
+ rerender({ filters: { status: "active", name: "atlas" } });
105
+ expect(result.current.filteredData).toEqual([rows[0]]);
106
+ });
107
+
108
+ it("should support custom value accessors and predicates", () => {
109
+ const { result } = renderHook(() =>
110
+ useTableFilter({
111
+ data: rows,
112
+ defaultFilters: { seats: "10" },
113
+ getColumnValue: (row, key) => row[key],
114
+ filterPredicate: (row, value, key) => Number(row[key]) >= Number(value),
115
+ }),
116
+ );
117
+ expect(result.current.filteredData).toEqual([rows[0]]);
118
+ });
119
+
120
+ it("should remove blank filter values", () => {
121
+ const { result } = renderHook(() =>
122
+ useTableFilter({
123
+ data: rows,
124
+ defaultFilters: { name: " " },
125
+ }),
126
+ );
127
+ expect(result.current.filters).toEqual({});
128
+ expect(result.current.hasActiveFilters).toBe(false);
129
+ });
130
+
131
+ it("should guard against null filters at runtime", () => {
132
+ const { result } = renderHook(() =>
133
+ useTableFilter({
134
+ data: rows,
135
+ filters: null as unknown as Record<string, string>,
136
+ }),
137
+ );
138
+ expect(result.current.filters).toEqual({});
139
+ expect(result.current.filteredData).toEqual(rows);
140
+ });
141
+ });
@@ -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
+ }
@@ -173,4 +173,34 @@ describe("ContextMenu", () => {
173
173
 
174
174
  expect(screen.getByRole("menuitem", { name: "Archive" })).toBeVisible();
175
175
  });
176
+
177
+ it("should not invoke onSelect for a disabled item and keep the menu open", async () => {
178
+ const user = userEvent.setup();
179
+ const onSelect = vi.fn();
180
+ render(
181
+ <ContextMenu defaultOpen>
182
+ <ContextMenuTrigger>Right-click row</ContextMenuTrigger>
183
+ <ContextMenuContent>
184
+ <ContextMenuItem disabled onSelect={onSelect}>
185
+ Copy
186
+ </ContextMenuItem>
187
+ </ContextMenuContent>
188
+ </ContextMenu>,
189
+ );
190
+
191
+ const item = screen.getByRole("menuitem", { name: "Copy" });
192
+ expect(item).toHaveAttribute("aria-disabled", "true");
193
+
194
+ await user.click(item);
195
+ expect(onSelect).not.toHaveBeenCalled();
196
+ expect(screen.getByRole("menu")).toBeInTheDocument();
197
+ });
198
+
199
+ it("should mark the submenu trigger with aria-haspopup menu", () => {
200
+ renderContextMenu();
201
+ fireEvent.contextMenu(screen.getByTestId("surface"));
202
+ expect(
203
+ screen.getByRole("menuitem", { name: "More actions" }),
204
+ ).toHaveAttribute("aria-haspopup", "menu");
205
+ });
176
206
  });
@@ -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
+ });