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.
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/api.d.ts +175 -0
- package/dist/api.js +1954 -0
- package/dist/api.js.map +1 -0
- package/dist/frameworks/astro/astro.config.snippet.md +23 -0
- package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
- package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
- package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
- package/dist/frameworks/next/layout-import.snippet.md +19 -0
- package/dist/frameworks/next/next.config.snippet.md +15 -0
- package/dist/frameworks/next/postcss.config.js.template +16 -0
- package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
- package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
- package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
- package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
- package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
- package/dist/index.js +2047 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/registry/components/primitives/icon/icon.module.css +63 -0
- package/registry/components/primitives/icon/icon.tsx +53 -0
- package/registry/components/primitives/modal/modal-context.ts +19 -0
- package/registry/components/primitives/modal/modal.module.css +415 -0
- package/registry/components/primitives/modal/modal.tsx +550 -0
- package/registry/components/primitives/popover/popover-context.ts +28 -0
- package/registry/components/primitives/popover/popover.module.css +28 -0
- package/registry/components/primitives/popover/popover.tsx +465 -0
- package/registry/components/ui/accordion/accordion.module.css +208 -0
- package/registry/components/ui/accordion/accordion.tsx +327 -0
- package/registry/components/ui/avatar/avatar.module.css +32 -0
- package/registry/components/ui/avatar/avatar.tsx +37 -0
- package/registry/components/ui/badge/badge.module.css +103 -0
- package/registry/components/ui/badge/badge.tsx +29 -0
- package/registry/components/ui/button/button.module.css +216 -0
- package/registry/components/ui/button/button.tsx +133 -0
- package/registry/components/ui/chart/chart.module.css +168 -0
- package/registry/components/ui/chart/chart.tsx +1293 -0
- package/registry/components/ui/chart/types.ts +210 -0
- package/registry/components/ui/chart/utils.ts +59 -0
- package/registry/components/ui/copy-button/copy-button.module.css +21 -0
- package/registry/components/ui/copy-button/copy-button.tsx +123 -0
- package/registry/components/ui/countdown/countdown.module.css +5 -0
- package/registry/components/ui/countdown/countdown.tsx +152 -0
- package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
- package/registry/components/ui/data-table/data-table.module.css +594 -0
- package/registry/components/ui/data-table/data-table.tsx +1109 -0
- package/registry/components/ui/data-table/filter-bar.tsx +33 -0
- package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
- package/registry/components/ui/data-table/types.ts +594 -0
- package/registry/components/ui/data-table/useDataTable.tsx +573 -0
- package/registry/components/ui/data-table/utils.ts +445 -0
- package/registry/components/ui/empty-state/empty-state.module.css +26 -0
- package/registry/components/ui/empty-state/empty-state.tsx +32 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
- package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
- package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
- package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
- package/registry/components/ui/input-fields/common/types.ts +38 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
- package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
- package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
- package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
- package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
- package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
- package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
- package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
- package/registry/components/ui/input-fields/input/input.tsx +319 -0
- package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
- package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
- package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
- package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
- package/registry/components/ui/input-fields/media-input/types.ts +129 -0
- package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
- package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
- package/registry/components/ui/input-fields/select/select.tsx +118 -0
- package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
- package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
- package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
- package/registry/components/ui/loader/loader.module.css +156 -0
- package/registry/components/ui/loader/loader.tsx +102 -0
- package/registry/components/ui/stepper/stepper.module.css +294 -0
- package/registry/components/ui/stepper/stepper.tsx +197 -0
- package/registry/components/ui/tabs/tabs.module.css +150 -0
- package/registry/components/ui/tabs/tabs.tsx +134 -0
- package/registry/components/ui/toast/toast.module.css +159 -0
- package/registry/components/ui/toast/toast.tsx +104 -0
- package/registry/components/ui/tooltip/tooltip.module.css +82 -0
- package/registry/components/ui/tooltip/tooltip.tsx +72 -0
- package/registry/global.d.ts +4 -0
- package/registry/hooks/useFocusOutside.tsx +33 -0
- package/registry/hooks/useResizeObserver.tsx +32 -0
- package/registry/lib/dom.ts +25 -0
- package/registry/lib/string.ts +23 -0
- package/registry/registry.json +719 -0
- package/registry/styles/app.css +240 -0
- package/registry/styles/components.css +78 -0
- package/registry/styles/fonts/geist-regular.woff2 +0 -0
- package/registry/styles/globals.css +7 -0
- package/registry/styles/media.css +25 -0
- package/registry/styles/utilities.css +90 -0
- package/registry/themes/colors/blue.css +37 -0
- package/registry/themes/colors/green.css +37 -0
- package/registry/themes/colors/rose.css +37 -0
- package/registry/themes/colors/slate.css +37 -0
- package/registry/themes/colors/stone.css +37 -0
- package/registry/themes/colors/violet.css +37 -0
- package/registry/themes/colors/zinc.css +37 -0
- package/registry/themes/radius/pill.css +8 -0
- package/registry/themes/radius/rounded.css +8 -0
- package/registry/themes/radius/sharp.css +8 -0
- package/registry/themes/spacing/compact.css +17 -0
- package/registry/themes/spacing/spacious.css +17 -0
- 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
|
+
};
|