aplance-ui 1.0.1

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 (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/dist/api.d.ts +175 -0
  4. package/dist/api.js +1954 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/frameworks/astro/astro.config.snippet.md +23 -0
  7. package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
  8. package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
  9. package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
  10. package/dist/frameworks/next/layout-import.snippet.md +19 -0
  11. package/dist/frameworks/next/next.config.snippet.md +15 -0
  12. package/dist/frameworks/next/postcss.config.js.template +16 -0
  13. package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
  14. package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
  15. package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
  16. package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
  17. package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
  18. package/dist/index.js +2047 -0
  19. package/dist/index.js.map +1 -0
  20. package/package.json +63 -0
  21. package/registry/components/primitives/icon/icon.module.css +63 -0
  22. package/registry/components/primitives/icon/icon.tsx +53 -0
  23. package/registry/components/primitives/modal/modal-context.ts +19 -0
  24. package/registry/components/primitives/modal/modal.module.css +415 -0
  25. package/registry/components/primitives/modal/modal.tsx +550 -0
  26. package/registry/components/primitives/popover/popover-context.ts +28 -0
  27. package/registry/components/primitives/popover/popover.module.css +28 -0
  28. package/registry/components/primitives/popover/popover.tsx +465 -0
  29. package/registry/components/ui/accordion/accordion.module.css +208 -0
  30. package/registry/components/ui/accordion/accordion.tsx +327 -0
  31. package/registry/components/ui/avatar/avatar.module.css +32 -0
  32. package/registry/components/ui/avatar/avatar.tsx +37 -0
  33. package/registry/components/ui/badge/badge.module.css +103 -0
  34. package/registry/components/ui/badge/badge.tsx +29 -0
  35. package/registry/components/ui/button/button.module.css +216 -0
  36. package/registry/components/ui/button/button.tsx +133 -0
  37. package/registry/components/ui/chart/chart.module.css +168 -0
  38. package/registry/components/ui/chart/chart.tsx +1293 -0
  39. package/registry/components/ui/chart/types.ts +210 -0
  40. package/registry/components/ui/chart/utils.ts +59 -0
  41. package/registry/components/ui/copy-button/copy-button.module.css +21 -0
  42. package/registry/components/ui/copy-button/copy-button.tsx +123 -0
  43. package/registry/components/ui/countdown/countdown.module.css +5 -0
  44. package/registry/components/ui/countdown/countdown.tsx +152 -0
  45. package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
  46. package/registry/components/ui/data-table/data-table.module.css +594 -0
  47. package/registry/components/ui/data-table/data-table.tsx +1109 -0
  48. package/registry/components/ui/data-table/filter-bar.tsx +33 -0
  49. package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
  50. package/registry/components/ui/data-table/types.ts +594 -0
  51. package/registry/components/ui/data-table/useDataTable.tsx +573 -0
  52. package/registry/components/ui/data-table/utils.ts +445 -0
  53. package/registry/components/ui/empty-state/empty-state.module.css +26 -0
  54. package/registry/components/ui/empty-state/empty-state.tsx +32 -0
  55. package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
  56. package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
  57. package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
  58. package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
  59. package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
  60. package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
  61. package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
  62. package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
  63. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
  64. package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
  65. package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
  66. package/registry/components/ui/input-fields/common/types.ts +38 -0
  67. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
  68. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
  69. package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
  70. package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
  71. package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
  72. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
  73. package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
  74. package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
  75. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
  76. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
  77. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
  78. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
  79. package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
  80. package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
  81. package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
  82. package/registry/components/ui/input-fields/input/input.tsx +319 -0
  83. package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
  84. package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
  85. package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
  86. package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
  87. package/registry/components/ui/input-fields/media-input/types.ts +129 -0
  88. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
  89. package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
  90. package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
  91. package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
  92. package/registry/components/ui/input-fields/select/select.tsx +118 -0
  93. package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
  94. package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
  95. package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
  96. package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
  97. package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
  98. package/registry/components/ui/loader/loader.module.css +156 -0
  99. package/registry/components/ui/loader/loader.tsx +102 -0
  100. package/registry/components/ui/stepper/stepper.module.css +294 -0
  101. package/registry/components/ui/stepper/stepper.tsx +197 -0
  102. package/registry/components/ui/tabs/tabs.module.css +150 -0
  103. package/registry/components/ui/tabs/tabs.tsx +134 -0
  104. package/registry/components/ui/toast/toast.module.css +159 -0
  105. package/registry/components/ui/toast/toast.tsx +104 -0
  106. package/registry/components/ui/tooltip/tooltip.module.css +82 -0
  107. package/registry/components/ui/tooltip/tooltip.tsx +72 -0
  108. package/registry/global.d.ts +4 -0
  109. package/registry/hooks/useFocusOutside.tsx +33 -0
  110. package/registry/hooks/useResizeObserver.tsx +32 -0
  111. package/registry/lib/dom.ts +25 -0
  112. package/registry/lib/string.ts +23 -0
  113. package/registry/registry.json +719 -0
  114. package/registry/styles/app.css +240 -0
  115. package/registry/styles/components.css +78 -0
  116. package/registry/styles/fonts/geist-regular.woff2 +0 -0
  117. package/registry/styles/globals.css +7 -0
  118. package/registry/styles/media.css +25 -0
  119. package/registry/styles/utilities.css +90 -0
  120. package/registry/themes/colors/blue.css +37 -0
  121. package/registry/themes/colors/green.css +37 -0
  122. package/registry/themes/colors/rose.css +37 -0
  123. package/registry/themes/colors/slate.css +37 -0
  124. package/registry/themes/colors/stone.css +37 -0
  125. package/registry/themes/colors/violet.css +37 -0
  126. package/registry/themes/colors/zinc.css +37 -0
  127. package/registry/themes/radius/pill.css +8 -0
  128. package/registry/themes/radius/rounded.css +8 -0
  129. package/registry/themes/radius/sharp.css +8 -0
  130. package/registry/themes/spacing/compact.css +17 -0
  131. package/registry/themes/spacing/spacious.css +17 -0
  132. package/registry/tsconfig.json +25 -0
@@ -0,0 +1,445 @@
1
+ import type {
2
+ Column,
3
+ ColumnOrderState,
4
+ ColumnPinningState,
5
+ ColumnSizingState,
6
+ ExpandedState,
7
+ Updater,
8
+ VisibilityState,
9
+ } from "@tanstack/react-table";
10
+ import type { CSSProperties } from "react";
11
+ import type {
12
+ ApplyPersistedTarget,
13
+ ColumnIdSource,
14
+ PersistedState,
15
+ SnapshotInput,
16
+ } from "./types";
17
+
18
+ /**
19
+ * Per-cell style map for one column. Returns the fixed width plus the
20
+ * sticky offset when the column is pinned. Reading offsets straight from
21
+ * tanstack (`getStart`/`getAfter`) keeps header and body cells in sync
22
+ * even after resizing or reordering - no manual bookkeeping needed.
23
+ */
24
+ export function getColumnStyle<TData>(
25
+ column: Column<TData, unknown>,
26
+ size: number,
27
+ extraWidth = 0,
28
+ ): CSSProperties {
29
+ const isPinned = column.getIsPinned();
30
+ const computedWidth = size + extraWidth;
31
+ const style: CSSProperties = {
32
+ width: `${computedWidth}px`,
33
+ minWidth: `${computedWidth}px`,
34
+ maxWidth: `${computedWidth}px`,
35
+ };
36
+ if (isPinned === "left") {
37
+ style.left = `${column.getStart("left")}px`;
38
+ } else if (isPinned === "right") {
39
+ style.right = `${column.getAfter("right")}px`;
40
+ }
41
+ return style;
42
+ }
43
+
44
+ /** Move `sourceId` to the position currently held by `targetId`. */
45
+ export function reorderColumnOrder(
46
+ order: ColumnOrderState,
47
+ sourceId: string,
48
+ targetId: string,
49
+ ): ColumnOrderState {
50
+ const next = order.slice();
51
+ const fromIdx = next.indexOf(sourceId);
52
+ const toIdx = next.indexOf(targetId);
53
+ if (fromIdx === -1 || toIdx === -1) return order;
54
+ next.splice(fromIdx, 1);
55
+ next.splice(toIdx, 0, sourceId);
56
+ return next;
57
+ }
58
+
59
+ /**
60
+ * Last visible pinned column on its side. Used by the divider style so
61
+ * we don't draw an inset line between adjacent pinned columns - only at
62
+ * the boundary between the pinned region and the scrolling center.
63
+ */
64
+ export function isLastPinned<TData>(column: Column<TData, unknown>): boolean {
65
+ const pinned = column.getIsPinned();
66
+ if (pinned !== "left" && pinned !== "right") return false;
67
+ return column.getIsLastColumn(pinned);
68
+ }
69
+
70
+ /**
71
+ * Default formatter for aggregated values when a column does not provide
72
+ * an `aggregatedCell`. Numbers fall back to `toLocaleString` and arrays
73
+ * to a count; everything else is rendered as-is.
74
+ */
75
+ export function formatAggregated(value: unknown): string {
76
+ if (value == null) return "";
77
+ if (typeof value === "number") return value.toLocaleString();
78
+ if (Array.isArray(value)) return `${value.length} items`;
79
+ return String(value);
80
+ }
81
+
82
+ /**
83
+ * Resolve the runtime column id from a raw `ColumnDef`. Mirrors how
84
+ * tanstack derives ids: explicit `id` first, then `accessorKey` for
85
+ * accessor columns. Useful when consumers need to build menus (visibility,
86
+ * grouping, etc.) from the same column array they hand to the table -
87
+ * tanstack only assigns the resolved id once the table is built, so
88
+ * reading `c.id` directly off the def returns `undefined` for accessor
89
+ * columns and breaks state maps keyed by id.
90
+ */
91
+ export function columnId(def: ColumnIdSource): string {
92
+ if (def.id) return def.id;
93
+ if (def.accessorKey != null) return String(def.accessorKey);
94
+ return "";
95
+ }
96
+
97
+ /**
98
+ * TanStack `Updater<T>` is `T | ((old: T) => T)`. When `T` itself extends
99
+ * `Function`, TypeScript widens the function branch to `((old: T) => T) |
100
+ * (T & Function)` after the `typeof` narrowing - and `T & Function` has no
101
+ * call signatures, so a cast is required to keep the compiler happy.
102
+ */
103
+ export function resolveUpdater<T>(updater: Updater<T>, prev: T): T {
104
+ return typeof updater === "function"
105
+ ? (updater as (old: T) => T)(prev)
106
+ : updater;
107
+ }
108
+
109
+ export const SELECT_COLUMN_ID = "select";
110
+
111
+ // ─────────────────────────────────────────────────────────────────────────────
112
+ // State persistence helpers (URL search params / localStorage / manual).
113
+ // Pure functions - no React. The hook-level orchestration in `useDataTable`
114
+ // supplies the current state + setters and these functions take care of the
115
+ // snapshot/apply/read/write mechanics.
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Single source-of-truth for every URL param key used by the persistence
120
+ * layer. Both `readSearchParams` and `writeSearchParams` reference these
121
+ * values - adding or renaming a key is one edit here, not scattered string
122
+ * literals across multiple functions.
123
+ */
124
+ export const PARAM_KEYS = {
125
+ pageIndex: "pageIndex",
126
+ pageSize: "pageSize",
127
+ sortBy: "sortBy",
128
+ sortDir: "sortDir",
129
+ columnOrder: "columnOrder",
130
+ columnVisibility: "columnVisibility",
131
+ columnPinning: "columnPinning",
132
+ columnSizing: "columnSizing",
133
+ grouping: "grouping",
134
+ expanded: "expanded",
135
+ } as const;
136
+
137
+ export type ParamKey = (typeof PARAM_KEYS)[keyof typeof PARAM_KEYS];
138
+
139
+ /**
140
+ * Reserved param keys used by the snapshot encoding itself. Filter keys must
141
+ * not collide with these - when iterating filter keys we skip any reserved
142
+ * names so a filter named `pageIndex` cannot stomp on the pagination param.
143
+ * Derived from `PARAM_KEYS` so the two never drift apart.
144
+ */
145
+ export const RESERVED_PARAM_KEYS = new Set<string>(Object.values(PARAM_KEYS));
146
+
147
+ const isNonEmptyExpanded = (e: ExpandedState | undefined): boolean =>
148
+ e !== undefined && (e === true || Object.keys(e).length > 0);
149
+
150
+ /**
151
+ * Build a `PersistedState` snapshot from current state, omitting empty/default
152
+ * slices so the persisted blob (URL or storage) stays minimal.
153
+ * `defaultPageSize` lives on `SnapshotInput` so callers don't need to thread
154
+ * it as a separate argument - it's part of the snapshot's own context.
155
+ */
156
+ export function snapshotPersistedState(input: SnapshotInput): PersistedState {
157
+ const out: PersistedState = {};
158
+
159
+ const activeFilters: Record<string, string> = {};
160
+ for (const [k, v] of Object.entries(input.filterValues)) {
161
+ if (v) activeFilters[k] = v;
162
+ }
163
+ if (Object.keys(activeFilters).length > 0) out.filterValues = activeFilters;
164
+
165
+ // Only persist pagination when it actually differs from defaults -
166
+ // pageSize is always a truthy number so `if (pageSize)` would always fire.
167
+ if (
168
+ input.pagination.pageIndex > 0 ||
169
+ input.pagination.pageSize !== input.defaultPageSize
170
+ ) {
171
+ out.pagination = input.pagination;
172
+ }
173
+
174
+ if (input.sorting.length > 0) out.sorting = input.sorting;
175
+ if (input.columnOrder.length > 0) out.columnOrder = input.columnOrder;
176
+ if (Object.keys(input.columnVisibility).length > 0) {
177
+ out.columnVisibility = input.columnVisibility;
178
+ }
179
+ if (input.columnPinning.left?.length || input.columnPinning.right?.length) {
180
+ out.columnPinning = input.columnPinning;
181
+ }
182
+ if (Object.keys(input.columnSizing).length > 0) {
183
+ out.columnSizing = input.columnSizing;
184
+ }
185
+ if (input.grouping.length > 0) out.grouping = input.grouping;
186
+ if (isNonEmptyExpanded(input.expanded)) out.expanded = input.expanded;
187
+
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * Apply a `PersistedState` blob back onto the controlled setters. Each slice
193
+ * is independently optional - partial blobs (e.g. only `sorting`) work fine.
194
+ * Unknown filter keys from a stale URL/blob are silently ignored.
195
+ */
196
+ export function applyPersistedState(
197
+ state: PersistedState,
198
+ target: ApplyPersistedTarget,
199
+ ): void {
200
+ if (state.filterValues) {
201
+ const next = { ...target.filterValues };
202
+ for (const key of target.filterKeys) {
203
+ const v = state.filterValues[key];
204
+ if (v !== undefined) next[key] = v;
205
+ }
206
+ target.setFilterValues(next);
207
+ }
208
+ if (state.pagination) target.setPagination(state.pagination);
209
+ if (state.sorting) target.setSorting(state.sorting);
210
+ if (state.columnOrder) target.setColumnOrder(state.columnOrder);
211
+ if (state.columnVisibility)
212
+ target.setColumnVisibility(state.columnVisibility);
213
+ if (state.columnPinning) target.setColumnPinning(state.columnPinning);
214
+ if (state.columnSizing) target.setColumnSizing(state.columnSizing);
215
+ if (state.grouping) target.setGrouping(state.grouping);
216
+ if (state.expanded !== undefined) target.setExpanded(state.expanded);
217
+ }
218
+
219
+ /**
220
+ * Decode a URL `?...` query string into a `PersistedState` blob. Flat
221
+ * encoding, hand-editable. `pageIndex` is stored 1-based for human
222
+ * readability; `pageSize` falls back to `defaultPageSize` when absent.
223
+ */
224
+ export function readSearchParams(
225
+ filterKeys: string[],
226
+ paramMap: Partial<Record<string, string>>,
227
+ defaultPageSize: number,
228
+ ): PersistedState {
229
+ const out: PersistedState = {};
230
+ const params = new URLSearchParams(window.location.search);
231
+ const p = (k: string) => paramMap[k] ?? k;
232
+
233
+ const filterValues: Record<string, string> = {};
234
+ for (const key of filterKeys) {
235
+ const raw = params.get(p(key));
236
+ if (raw !== null) filterValues[key] = raw;
237
+ }
238
+ if (Object.keys(filterValues).length > 0) out.filterValues = filterValues;
239
+
240
+ const rawPage = params.get(p(PARAM_KEYS.pageIndex));
241
+ const rawSize = params.get(p(PARAM_KEYS.pageSize));
242
+ if (rawPage !== null || rawSize !== null) {
243
+ const pageIndex = rawPage !== null ? Math.max(0, Number(rawPage) - 1) : 0;
244
+ const pageSize =
245
+ rawSize !== null && Number.isFinite(Number(rawSize))
246
+ ? Number(rawSize)
247
+ : defaultPageSize;
248
+ out.pagination = {
249
+ pageIndex: Number.isFinite(pageIndex) ? pageIndex : 0,
250
+ pageSize,
251
+ };
252
+ }
253
+
254
+ const rawSortBy = params.get(p(PARAM_KEYS.sortBy));
255
+ const rawSortDir = params.get(p(PARAM_KEYS.sortDir));
256
+ if (rawSortBy) {
257
+ out.sorting = [{ id: rawSortBy, desc: rawSortDir === "desc" }];
258
+ }
259
+
260
+ const rawColOrder = params.get(p(PARAM_KEYS.columnOrder));
261
+ if (rawColOrder) out.columnOrder = rawColOrder.split(",");
262
+
263
+ const tryJson = <T>(key: ParamKey): T | undefined => {
264
+ const raw = params.get(p(key));
265
+ if (!raw) return undefined;
266
+ try {
267
+ return JSON.parse(raw) as T;
268
+ } catch {
269
+ return undefined;
270
+ }
271
+ };
272
+
273
+ const colVis = tryJson<VisibilityState>(PARAM_KEYS.columnVisibility);
274
+ if (colVis) out.columnVisibility = colVis;
275
+ const colPin = tryJson<ColumnPinningState>(PARAM_KEYS.columnPinning);
276
+ if (colPin) out.columnPinning = colPin;
277
+ const colSize = tryJson<ColumnSizingState>(PARAM_KEYS.columnSizing);
278
+ if (colSize) out.columnSizing = colSize;
279
+
280
+ const rawGrouping = params.get(p(PARAM_KEYS.grouping));
281
+ if (rawGrouping) out.grouping = rawGrouping.split(",");
282
+
283
+ const expanded = tryJson<ExpandedState>(PARAM_KEYS.expanded);
284
+ if (expanded !== undefined) out.expanded = expanded;
285
+
286
+ return out;
287
+ }
288
+
289
+ /**
290
+ * Encode a `PersistedState` blob into the URL via `history.replaceState`.
291
+ * `pageSize` is omitted when it matches `defaultPageSize` so a fresh URL
292
+ * stays clean; reserved param keys are protected against filter-key collisions.
293
+ */
294
+ export function writeSearchParams(
295
+ state: PersistedState,
296
+ filterKeys: string[],
297
+ paramMap: Partial<Record<string, string>>,
298
+ defaultPageSize: number,
299
+ ): void {
300
+ const params = new URLSearchParams(window.location.search);
301
+ const p = (k: string) => paramMap[k] ?? k;
302
+
303
+ // Helper: set a JSON-serialised param or delete it when absent.
304
+ // Handles all truthy checks uniformly including `false` and `{}` for
305
+ // `expanded`, which are valid non-default states but would be dropped
306
+ // by a plain `if (value)` guard.
307
+ const setJson = (key: ParamKey, value: unknown): void => {
308
+ if (value !== undefined && value !== null) {
309
+ params.set(p(key), JSON.stringify(value));
310
+ } else {
311
+ params.delete(p(key));
312
+ }
313
+ };
314
+
315
+ // Filters - every declared filter key must be set or deleted (absent
316
+ // value === "cleared"). Keys outside the universe are left untouched.
317
+ const active = state.filterValues ?? {};
318
+ for (const key of filterKeys) {
319
+ if (RESERVED_PARAM_KEYS.has(key)) continue;
320
+ const value = active[key];
321
+ if (value) params.set(p(key), value);
322
+ else params.delete(p(key));
323
+ }
324
+
325
+ if (state.pagination && state.pagination.pageIndex > 0) {
326
+ params.set(p(PARAM_KEYS.pageIndex), String(state.pagination.pageIndex + 1));
327
+ } else {
328
+ params.delete(p(PARAM_KEYS.pageIndex));
329
+ }
330
+ if (state.pagination && state.pagination.pageSize !== defaultPageSize) {
331
+ params.set(p(PARAM_KEYS.pageSize), String(state.pagination.pageSize));
332
+ } else {
333
+ params.delete(p(PARAM_KEYS.pageSize));
334
+ }
335
+
336
+ if (state.sorting && state.sorting.length > 0) {
337
+ params.set(p(PARAM_KEYS.sortBy), state.sorting[0].id);
338
+ params.set(p(PARAM_KEYS.sortDir), state.sorting[0].desc ? "desc" : "asc");
339
+ } else {
340
+ params.delete(p(PARAM_KEYS.sortBy));
341
+ params.delete(p(PARAM_KEYS.sortDir));
342
+ }
343
+
344
+ // Guard against empty arrays serialising to "" which reads back as [''].
345
+ if (state.columnOrder?.length) {
346
+ params.set(p(PARAM_KEYS.columnOrder), state.columnOrder.join(","));
347
+ } else {
348
+ params.delete(p(PARAM_KEYS.columnOrder));
349
+ }
350
+
351
+ setJson(PARAM_KEYS.columnVisibility, state.columnVisibility ?? null);
352
+ setJson(PARAM_KEYS.columnPinning, state.columnPinning ?? null);
353
+ setJson(PARAM_KEYS.columnSizing, state.columnSizing ?? null);
354
+
355
+ // Same empty-array guard for grouping.
356
+ if (state.grouping?.length) {
357
+ params.set(p(PARAM_KEYS.grouping), state.grouping.join(","));
358
+ } else {
359
+ params.delete(p(PARAM_KEYS.grouping));
360
+ }
361
+
362
+ // `expanded` can be `false` or `{}` - both are valid and must be preserved.
363
+ setJson(
364
+ PARAM_KEYS.expanded,
365
+ state.expanded !== undefined ? state.expanded : null,
366
+ );
367
+
368
+ const qs = params.toString();
369
+ window.history.replaceState(
370
+ null,
371
+ "",
372
+ qs ? `${window.location.pathname}?${qs}` : window.location.pathname,
373
+ );
374
+ }
375
+
376
+ /** Read a persisted blob from `localStorage` under `storageKey`. */
377
+ export function readLocalStorage(storageKey: string): PersistedState {
378
+ try {
379
+ const raw = window.localStorage.getItem(storageKey);
380
+ if (!raw) return {};
381
+ return JSON.parse(raw) as PersistedState;
382
+ } catch {
383
+ return {};
384
+ }
385
+ }
386
+
387
+ /** Write (or remove, if empty) the persisted blob under `storageKey`. */
388
+ export function writeLocalStorage(
389
+ storageKey: string,
390
+ state: PersistedState,
391
+ ): void {
392
+ try {
393
+ if (Object.keys(state).length === 0) {
394
+ window.localStorage.removeItem(storageKey);
395
+ } else {
396
+ window.localStorage.setItem(storageKey, JSON.stringify(state));
397
+ }
398
+ } catch {
399
+ /* quota exceeded or storage disabled - silently no-op */
400
+ }
401
+ }
402
+
403
+ export function normalizeColumnPinningState(
404
+ pinning: ColumnPinningState | undefined,
405
+ pinSelectionColumn: boolean,
406
+ defaultColumnPinning?: ColumnPinningState,
407
+ ): ColumnPinningState | undefined {
408
+ const left = pinning?.left ?? [];
409
+ const right = pinning?.right ?? [];
410
+
411
+ const defaultLeft = defaultColumnPinning?.left ?? [];
412
+ const defaultRight = defaultColumnPinning?.right ?? [];
413
+
414
+ const actualDefaultLeft =
415
+ pinSelectionColumn && !defaultLeft.includes(SELECT_COLUMN_ID)
416
+ ? [SELECT_COLUMN_ID, ...defaultLeft]
417
+ : defaultLeft;
418
+
419
+ const finalLeft =
420
+ pinSelectionColumn && !left.includes(SELECT_COLUMN_ID)
421
+ ? [SELECT_COLUMN_ID, ...left]
422
+ : left;
423
+
424
+ // Use a Set for O(n) lookup instead of repeated Array.includes (O(n²)).
425
+ const defaultLeftSet = new Set(actualDefaultLeft);
426
+ const orderedLeft = [
427
+ ...actualDefaultLeft.filter((id) => finalLeft.includes(id)),
428
+ ...finalLeft.filter((id) => !defaultLeftSet.has(id)),
429
+ ];
430
+
431
+ const dynamicRight = right.filter((id) => !defaultRight.includes(id));
432
+ const orderedRight = [
433
+ ...dynamicRight,
434
+ ...defaultRight.filter((id) => right.includes(id)),
435
+ ];
436
+
437
+ if (!pinning && actualDefaultLeft.length === 0 && defaultRight.length === 0) {
438
+ return undefined;
439
+ }
440
+
441
+ return {
442
+ left: orderedLeft,
443
+ right: orderedRight,
444
+ };
445
+ }
@@ -0,0 +1,26 @@
1
+ .root {
2
+ display: flex;
3
+ flex-direction: column;
4
+ align-items: center;
5
+ justify-content: center;
6
+ padding: var(--spacing-14) var(--spacing-9);
7
+ text-align: center;
8
+ gap: var(--spacing-5);
9
+ }
10
+
11
+ .icon {
12
+ color: var(--color-muted-foreground);
13
+ opacity: 0.5;
14
+ }
15
+
16
+ .title {
17
+ font-size: var(--font-size-base);
18
+ font-weight: 500;
19
+ color: var(--color-foreground);
20
+ }
21
+
22
+ .description {
23
+ font-size: var(--font-size-sm);
24
+ color: var(--color-muted-foreground);
25
+ max-width: 300px;
26
+ }
@@ -0,0 +1,32 @@
1
+ import type { ElementType } from "react";
2
+ import { Icon } from "../../primitives/icon/icon";
3
+ import s from "./empty-state.module.css";
4
+
5
+ type EmptyStateProps = {
6
+ icon?: ElementType;
7
+ title: string;
8
+ description?: string;
9
+ children?: React.ReactNode;
10
+ };
11
+
12
+ export function EmptyState({
13
+ icon: IconComponent,
14
+ title,
15
+ description,
16
+ children,
17
+ }: EmptyStateProps) {
18
+ return (
19
+ <div className={s.root}>
20
+ {IconComponent && (
21
+ <div className={s.icon}>
22
+ <Icon size="lg">
23
+ <IconComponent />
24
+ </Icon>
25
+ </div>
26
+ )}
27
+ <p className={s.title}>{title}</p>
28
+ {description && <p className={s.description}>{description}</p>}
29
+ {children}
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,102 @@
1
+ /* Color palette - standalone wheel + hex input + swatch row.
2
+ Wraps react-colorful and overrides its sizing/radii via the .react-colorful
3
+ class names (escaped via :global since they live outside CSS Modules). */
4
+
5
+ .root {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--spacing-4);
9
+ padding: var(--spacing-4);
10
+ background-color: var(--color-background);
11
+ font-family: var(--font-family-1);
12
+ }
13
+
14
+ .picker {
15
+ width: 100% !important;
16
+ height: 12rem !important;
17
+
18
+ :global(.react-colorful__saturation) {
19
+ border-radius: var(--radius-md) var(--radius-md) 0 0;
20
+ }
21
+
22
+ :global(.react-colorful__last-control) {
23
+ border-radius: 0 0 var(--radius-md) var(--radius-md);
24
+ }
25
+
26
+ :global(.react-colorful__hue),
27
+ :global(.react-colorful__alpha) {
28
+ height: var(--spacing-10);
29
+ }
30
+
31
+ :global(.react-colorful__pointer) {
32
+ width: var(--spacing-10);
33
+ height: var(--spacing-10);
34
+ }
35
+ }
36
+
37
+ .controls {
38
+ display: flex;
39
+ align-items: center;
40
+ gap: var(--spacing-3);
41
+ height: var(--spacing-11);
42
+ padding-left: var(--spacing-4);
43
+ border: 1px solid var(--color-border);
44
+ border-radius: var(--radius-md);
45
+ background-color: var(--color-muted);
46
+ transition:
47
+ border-color var(--transition-fast),
48
+ box-shadow var(--transition-fast);
49
+
50
+ &:focus-within {
51
+ border-color: var(--color-ring);
52
+ box-shadow: var(--shadow-ring);
53
+ }
54
+ }
55
+
56
+ .hash {
57
+ color: var(--color-muted-foreground);
58
+ font-size: var(--font-size-sm);
59
+ }
60
+
61
+ .hex_input {
62
+ flex: 1;
63
+ min-width: 0;
64
+ border: 0;
65
+ outline: none;
66
+ background: transparent;
67
+ color: var(--color-foreground);
68
+ font-family: inherit;
69
+ font-size: var(--font-size-sm);
70
+ text-transform: uppercase;
71
+ letter-spacing: 0.05em;
72
+ }
73
+
74
+ .swatches {
75
+ display: grid;
76
+ grid-template-columns: repeat(10, 1fr);
77
+ gap: var(--spacing-2);
78
+ }
79
+
80
+ .swatch_button {
81
+ width: 100%;
82
+ aspect-ratio: 1;
83
+ padding: 0;
84
+ border: 1px solid var(--color-border);
85
+ border-radius: var(--radius-sm);
86
+ cursor: pointer;
87
+ transition: transform var(--transition-fast);
88
+
89
+ &:hover {
90
+ transform: scale(1.08);
91
+ }
92
+
93
+ &:focus-visible {
94
+ outline: none;
95
+ box-shadow: var(--shadow-ring);
96
+ }
97
+
98
+ &[aria-selected="true"] {
99
+ border-color: var(--color-foreground);
100
+ border-width: 2px;
101
+ }
102
+ }
@@ -0,0 +1,92 @@
1
+ import { useState, type CSSProperties } from "react";
2
+ import clsx from "clsx";
3
+ import { HexColorPicker, HexColorInput } from "react-colorful";
4
+ import s from "./color-palette.module.css";
5
+
6
+ export const DEFAULT_SWATCHES: readonly string[] = [
7
+ "#ef4444",
8
+ "#f97316",
9
+ "#eab308",
10
+ "#22c55e",
11
+ "#06b6d4",
12
+ "#3b82f6",
13
+ "#8b5cf6",
14
+ "#ec4899",
15
+ "#64748b",
16
+ "#000000",
17
+ ];
18
+
19
+ export type ColorPaletteProps = {
20
+ /** Selected color as hex (controlled). Empty string = unset. */
21
+ value?: string;
22
+ /** Initial color (uncontrolled). */
23
+ defaultValue?: string;
24
+ /** Fires with the new hex value (`#rrggbb`) whenever it changes. */
25
+ onChange?: (color: string) => void;
26
+ /** Optional swatches displayed below the picker. Pass `[]` to hide. */
27
+ swatches?: readonly string[];
28
+ className?: string;
29
+ style?: CSSProperties;
30
+ };
31
+
32
+ /**
33
+ * Standalone color palette - saturation/hue picker + hex input + swatch row.
34
+ * Renderable on its own (e.g. inline in a settings panel) or inside any popover.
35
+ */
36
+ export const ColorPalette = ({
37
+ value,
38
+ defaultValue,
39
+ onChange,
40
+ swatches = DEFAULT_SWATCHES,
41
+ className,
42
+ style,
43
+ }: ColorPaletteProps) => {
44
+ const isControlled = value !== undefined;
45
+ const [internal, setInternal] = useState(defaultValue ?? "#ffffff");
46
+ const current = isControlled ? value || "#ffffff" : internal;
47
+
48
+ const commit = (next: string) => {
49
+ if (!isControlled) setInternal(next);
50
+ onChange?.(next);
51
+ };
52
+
53
+ return (
54
+ <div className={clsx(s.root, className)} style={style}>
55
+ <HexColorPicker
56
+ color={current}
57
+ onChange={commit}
58
+ className={s.picker}
59
+ />
60
+
61
+ <div className={s.controls}>
62
+ <span className={s.hash} aria-hidden="true">
63
+ #
64
+ </span>
65
+ <HexColorInput
66
+ color={current}
67
+ onChange={commit}
68
+ className={s.hex_input}
69
+ placeholder="000000"
70
+ aria-label="Hex color"
71
+ />
72
+ </div>
73
+
74
+ {swatches.length > 0 && (
75
+ <div className={s.swatches} role="listbox" aria-label="Color presets">
76
+ {swatches.map((c) => (
77
+ <button
78
+ key={c}
79
+ type="button"
80
+ role="option"
81
+ aria-selected={current.toLowerCase() === c.toLowerCase()}
82
+ className={s.swatch_button}
83
+ style={{ backgroundColor: c }}
84
+ onClick={() => commit(c)}
85
+ aria-label={c}
86
+ />
87
+ ))}
88
+ </div>
89
+ )}
90
+ </div>
91
+ );
92
+ };