@payglocal_ui/flux-ui 0.2.6 → 0.3.0
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/dist/index.cjs +3145 -592
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1206 -26
- package/dist/index.d.ts +1206 -26
- package/dist/index.js +3094 -546
- package/dist/index.js.map +1 -1
- package/package.json +14 -4
- package/src/__tests__/data-card-list.test.tsx +74 -0
- package/src/__tests__/data-table-row-click.test.tsx +113 -0
- package/src/__tests__/filter-chips.test.tsx +237 -0
- package/src/calendar-date-chip.tsx +262 -0
- package/src/column-manager.tsx +590 -0
- package/src/copyable-cell.tsx +253 -0
- package/src/data-card-list.tsx +188 -0
- package/src/data-table-card.tsx +205 -0
- package/src/data-table.tsx +876 -144
- package/src/date-picker.tsx +15 -3
- package/src/filter-chips.tsx +1874 -0
- package/src/format-datetime.ts +170 -0
- package/src/index.ts +95 -2
- package/src/popover.tsx +24 -15
- package/src/rotating-search-input.tsx +164 -0
- package/src/tab-presets.tsx +189 -0
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
DndContext,
|
|
6
|
+
KeyboardSensor,
|
|
7
|
+
PointerSensor,
|
|
8
|
+
closestCenter,
|
|
9
|
+
useSensor,
|
|
10
|
+
useSensors,
|
|
11
|
+
type DragEndEvent,
|
|
12
|
+
} from "@dnd-kit/core";
|
|
13
|
+
import {
|
|
14
|
+
SortableContext,
|
|
15
|
+
arrayMove,
|
|
16
|
+
sortableKeyboardCoordinates,
|
|
17
|
+
useSortable,
|
|
18
|
+
verticalListSortingStrategy,
|
|
19
|
+
} from "@dnd-kit/sortable";
|
|
20
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
21
|
+
import { GripVertical, RotateCcw } from "lucide-react";
|
|
22
|
+
import { Button } from "./button";
|
|
23
|
+
import { Checkbox } from "./checkbox";
|
|
24
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
25
|
+
import { Separator } from "./separator";
|
|
26
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
|
|
27
|
+
import { cn } from "./utils";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A column as the manager sees it: a key and something to call it in the list.
|
|
31
|
+
* `label` is separate from the table's `header` because a header can be a node
|
|
32
|
+
* (an icon, a tooltip, a two-line stack) and this list needs plain text.
|
|
33
|
+
*/
|
|
34
|
+
export interface ManagedColumn {
|
|
35
|
+
key: string;
|
|
36
|
+
label: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ColumnManagerProps {
|
|
40
|
+
/** Every manageable column, in the table's *declared* order. */
|
|
41
|
+
columns: ManagedColumn[];
|
|
42
|
+
/** Current arrangement, as column keys. */
|
|
43
|
+
order: string[];
|
|
44
|
+
onOrderChange: (order: string[]) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Column keys currently hidden. Omit — along with `onHiddenKeysChange` — to
|
|
47
|
+
* drop the tick boxes entirely and keep this a reorder-only popover.
|
|
48
|
+
*/
|
|
49
|
+
hiddenKeys?: string[];
|
|
50
|
+
onHiddenKeysChange?: (hidden: string[]) => void;
|
|
51
|
+
/**
|
|
52
|
+
* Columns that cannot be **hidden**. Says nothing about where they sit — a
|
|
53
|
+
* column the table cannot do without is still one the user may want to move.
|
|
54
|
+
*
|
|
55
|
+
* They keep a tick box rather than losing it, so the list reads as one set of
|
|
56
|
+
* columns with some locked rather than as two lists — but the box is grey,
|
|
57
|
+
* not primary, because nobody chose it.
|
|
58
|
+
*/
|
|
59
|
+
fixedKeys?: string[];
|
|
60
|
+
/**
|
|
61
|
+
* Columns that cannot be **reordered**. Says nothing about whether they can
|
|
62
|
+
* be hidden.
|
|
63
|
+
*
|
|
64
|
+
* The usual case is a frozen (sticky) column: its left offset is the running
|
|
65
|
+
* total of the widths of the frozen columns before it, so the block only
|
|
66
|
+
* works while they stay first and contiguous — drag one into the middle and
|
|
67
|
+
* it keeps `left-0`, leaving a pinned column floating over the scrolling
|
|
68
|
+
* ones. Hiding it is fine; that just shortens the block.
|
|
69
|
+
*
|
|
70
|
+
* A key can appear in both lists, and then neither control is offered.
|
|
71
|
+
*/
|
|
72
|
+
pinnedKeys?: string[];
|
|
73
|
+
/**
|
|
74
|
+
* Why a fixed column cannot be hidden, shown on hover and focus. A disabled
|
|
75
|
+
* control that stays silent leaves the user to guess whether they are doing
|
|
76
|
+
* something wrong, so every caller should say something; the default is
|
|
77
|
+
* deliberately generic so a missing one is still an answer.
|
|
78
|
+
*/
|
|
79
|
+
fixedReason?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Why a pinned column cannot be moved, shown on hover. Same reasoning as
|
|
82
|
+
* `fixedReason`: a dead affordance should say why it is dead.
|
|
83
|
+
*/
|
|
84
|
+
pinnedReason?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Discards the saved arrangement so the table falls back to `columns`' own
|
|
87
|
+
* order with nothing hidden. Separate from `onOrderChange` rather than
|
|
88
|
+
* passing the default order through it, since "no saved preference" is its
|
|
89
|
+
* own state in the caller, not just another arrangement.
|
|
90
|
+
*/
|
|
91
|
+
onReset: () => void;
|
|
92
|
+
/** Trigger label. Default "Columns". */
|
|
93
|
+
label?: string;
|
|
94
|
+
/** Render the trigger as an icon-only button — for a crowded toolbar. */
|
|
95
|
+
iconOnly?: boolean;
|
|
96
|
+
/** Extra classes on the trigger button. */
|
|
97
|
+
className?: string;
|
|
98
|
+
/** Popover alignment against the trigger. Default "end". */
|
|
99
|
+
align?: "start" | "center" | "end";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface ColumnVisibility {
|
|
103
|
+
checked: boolean;
|
|
104
|
+
/** Locked on, with a reason. Renders grey rather than as an active choice. */
|
|
105
|
+
lockedReason?: string;
|
|
106
|
+
onToggle: () => void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One row of the list.
|
|
111
|
+
*
|
|
112
|
+
* `useSortable` hands back a transform for *every* row, not just the one under
|
|
113
|
+
* the pointer — which is what makes the others slide out of the way as the
|
|
114
|
+
* dragged row passes them, instead of the list snapping to a new order. The
|
|
115
|
+
* transition it supplies drives that animation, so both go straight onto the
|
|
116
|
+
* element's style.
|
|
117
|
+
*/
|
|
118
|
+
function SortableColumnRow({
|
|
119
|
+
id,
|
|
120
|
+
label,
|
|
121
|
+
visibility,
|
|
122
|
+
pinned,
|
|
123
|
+
pinnedReason,
|
|
124
|
+
}: {
|
|
125
|
+
id: string;
|
|
126
|
+
label: string;
|
|
127
|
+
visibility?: ColumnVisibility;
|
|
128
|
+
/** A fixed column: it cannot be dragged, and nothing can be dropped on it. */
|
|
129
|
+
pinned?: boolean;
|
|
130
|
+
/** Why, for the cursor's tooltip and the accessible name. */
|
|
131
|
+
pinnedReason?: string;
|
|
132
|
+
}) {
|
|
133
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
134
|
+
id,
|
|
135
|
+
// Both halves matter. `draggable` is the request — a fixed column cannot be
|
|
136
|
+
// picked up. `droppable` is what makes it actually stay put: without it the
|
|
137
|
+
// row is still a valid drop target, so dragging any other row onto it
|
|
138
|
+
// pushes it down and the "fixed" column has moved after all.
|
|
139
|
+
disabled: pinned ? { draggable: true, droppable: true } : undefined,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const style = {
|
|
143
|
+
// A pinned row takes no transform at all, so it holds its place while the
|
|
144
|
+
// movable rows animate around it.
|
|
145
|
+
transform: pinned ? undefined : CSS.Transform.toString(transform),
|
|
146
|
+
transition: pinned ? undefined : transition,
|
|
147
|
+
// Above its neighbours while moving, so it slides over them rather than
|
|
148
|
+
// disappearing behind the next row's background.
|
|
149
|
+
zIndex: isDragging ? 10 : undefined,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
ref={setNodeRef}
|
|
155
|
+
style={style}
|
|
156
|
+
className={cn(
|
|
157
|
+
"flex items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] text-foreground",
|
|
158
|
+
isDragging && "bg-muted opacity-90",
|
|
159
|
+
// No hover affordance on a pinned row: there is nothing to pick up.
|
|
160
|
+
!isDragging && !pinned && "hover:bg-muted/50"
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
{/* The drag listeners live on the grip and the label, not the row, so the
|
|
164
|
+
tick box stays clickable instead of being swallowed by a drag.
|
|
165
|
+
|
|
166
|
+
A pinned row gets neither listeners nor the grab cursor. The grip
|
|
167
|
+
still renders, dimmed: dropping it would reflow the labels out of
|
|
168
|
+
line with every other row, and a row with no grip at all reads as a
|
|
169
|
+
different kind of thing rather than as this one, locked. */}
|
|
170
|
+
{pinned ? (
|
|
171
|
+
<span
|
|
172
|
+
title={pinnedReason}
|
|
173
|
+
aria-label={`${label} column is fixed in place`}
|
|
174
|
+
className="flex min-w-0 flex-1 cursor-not-allowed items-center gap-2"
|
|
175
|
+
>
|
|
176
|
+
<GripVertical className="h-3.5 w-3.5 shrink-0 text-muted-foreground/30" />
|
|
177
|
+
{/* The label keeps its normal colour even here. Only the grip dims:
|
|
178
|
+
greying the text would read as "this row is disabled", which is
|
|
179
|
+
wrong when its tick box is live. */}
|
|
180
|
+
<span className="truncate">{label}</span>
|
|
181
|
+
</span>
|
|
182
|
+
) : (
|
|
183
|
+
<span
|
|
184
|
+
className="flex min-w-0 flex-1 cursor-grab items-center gap-2 active:cursor-grabbing"
|
|
185
|
+
{...attributes}
|
|
186
|
+
{...listeners}
|
|
187
|
+
>
|
|
188
|
+
<GripVertical className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
189
|
+
<span className="truncate">{label}</span>
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{visibility &&
|
|
194
|
+
(visibility.lockedReason ? (
|
|
195
|
+
/**
|
|
196
|
+
* A locked column renders grey, not the primary blue a live tick
|
|
197
|
+
* uses: blue says "you chose this", and nobody chose this. The
|
|
198
|
+
* tooltip hangs off a wrapping span rather than the Checkbox, because
|
|
199
|
+
* a disabled control receives no pointer events and a trigger on the
|
|
200
|
+
* box itself would never fire. The span also carries the not-allowed
|
|
201
|
+
* cursor and is focusable, so the reason is reachable by keyboard as
|
|
202
|
+
* well as hover.
|
|
203
|
+
*/
|
|
204
|
+
<TooltipProvider delayDuration={150}>
|
|
205
|
+
<Tooltip>
|
|
206
|
+
<TooltipTrigger asChild>
|
|
207
|
+
<span tabIndex={0} className="shrink-0 cursor-not-allowed rounded-sm">
|
|
208
|
+
<Checkbox
|
|
209
|
+
checked
|
|
210
|
+
disabled
|
|
211
|
+
aria-label={`${label} column is always shown`}
|
|
212
|
+
className="pointer-events-none border-border opacity-100 data-[state=checked]:border-border data-[state=checked]:bg-muted-foreground/40 data-[state=checked]:text-foreground/70"
|
|
213
|
+
/>
|
|
214
|
+
</span>
|
|
215
|
+
</TooltipTrigger>
|
|
216
|
+
<TooltipContent className="max-w-[15rem]">{visibility.lockedReason}</TooltipContent>
|
|
217
|
+
</Tooltip>
|
|
218
|
+
</TooltipProvider>
|
|
219
|
+
) : (
|
|
220
|
+
<Checkbox
|
|
221
|
+
checked={visibility.checked}
|
|
222
|
+
onCheckedChange={visibility.onToggle}
|
|
223
|
+
aria-label={`Show ${label} column`}
|
|
224
|
+
className="shrink-0"
|
|
225
|
+
/>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Column manager: drag to reorder, tick to show or hide, with locked columns
|
|
233
|
+
* and a reset. One implementation for every grid in every app, so a merchant
|
|
234
|
+
* and an internal operator arrange their columns the same way.
|
|
235
|
+
*
|
|
236
|
+
* Reordering is @dnd-kit, the same stack the dashboard's widget grid uses, so
|
|
237
|
+
* the rows animate out of each other's way as one is dragged past them. A
|
|
238
|
+
* hand-rolled pointer drag can reorder the list correctly and still feel wrong:
|
|
239
|
+
* without a per-row transform the rows simply teleport into their new slots,
|
|
240
|
+
* and there is nothing to follow.
|
|
241
|
+
*
|
|
242
|
+
* dnd-kit is `external` in the build rather than bundled, because both
|
|
243
|
+
* consuming apps already depend on it — two copies of `DndContext` in one app
|
|
244
|
+
* is the kind of thing that breaks only in the app, never in the library.
|
|
245
|
+
*
|
|
246
|
+
* Keyboard users reorder without a mouse at all: Space picks a row up, the
|
|
247
|
+
* arrows move it, Space drops it, Escape abandons it, and dnd-kit announces
|
|
248
|
+
* each step as it goes.
|
|
249
|
+
*/
|
|
250
|
+
export function ColumnManager({
|
|
251
|
+
columns,
|
|
252
|
+
order,
|
|
253
|
+
onOrderChange,
|
|
254
|
+
hiddenKeys,
|
|
255
|
+
onHiddenKeysChange,
|
|
256
|
+
fixedKeys = [],
|
|
257
|
+
pinnedKeys = [],
|
|
258
|
+
fixedReason = "Always shown. The table needs this column to make sense.",
|
|
259
|
+
pinnedReason = "Fixed in place. This column stays frozen at the left of the table.",
|
|
260
|
+
onReset,
|
|
261
|
+
label = "Columns",
|
|
262
|
+
iconOnly = false,
|
|
263
|
+
className,
|
|
264
|
+
align = "end",
|
|
265
|
+
}: ColumnManagerProps) {
|
|
266
|
+
const canToggleVisibility = !!hiddenKeys && !!onHiddenKeysChange;
|
|
267
|
+
const hidden = useMemo(() => hiddenKeys ?? [], [hiddenKeys]);
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Every key that cannot move — `pinnedKeys` and nothing else.
|
|
271
|
+
*
|
|
272
|
+
* Visibility and position are independent, and deliberately not inferred from
|
|
273
|
+
* one another: a column the table cannot do without is very often one the
|
|
274
|
+
* user is still free to put wherever they like (a transaction id), and a
|
|
275
|
+
* column frozen to the left edge is very often one they are free to hide
|
|
276
|
+
* (a merchant id on a single-merchant view). Folding either into the other
|
|
277
|
+
* takes away a control for no reason the user can see.
|
|
278
|
+
*/
|
|
279
|
+
const immovable = pinnedKeys;
|
|
280
|
+
|
|
281
|
+
const byKey = useMemo(() => new Map(columns.map((c) => [c.key, c])), [columns]);
|
|
282
|
+
/** The arrangement to draw: saved order, minus keys the table no longer has. */
|
|
283
|
+
const ordered = useMemo(
|
|
284
|
+
() => order.map((k) => byKey.get(k)).filter((c): c is ManagedColumn => !!c),
|
|
285
|
+
[order, byKey]
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* `columns` arrives in the caller's declared order with nothing hidden, which
|
|
290
|
+
* is exactly what resetting falls back to — so comparing against it tells us
|
|
291
|
+
* whether there is a custom arrangement to reset at all.
|
|
292
|
+
*/
|
|
293
|
+
const isCustomised =
|
|
294
|
+
order.join("|") !== columns.map((c) => c.key).join("|") || hidden.length > 0;
|
|
295
|
+
|
|
296
|
+
const toggleVisibility = (key: string) => {
|
|
297
|
+
if (!onHiddenKeysChange || fixedKeys.includes(key)) return;
|
|
298
|
+
onHiddenKeysChange(
|
|
299
|
+
hidden.includes(key) ? hidden.filter((k) => k !== key) : [...hidden, key]
|
|
300
|
+
);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Drag sensors. The 4px activation distance is what keeps a *click* on the
|
|
305
|
+
* grip from registering as a drag, so tapping a row does nothing rather than
|
|
306
|
+
* nudging the order by a pixel.
|
|
307
|
+
*
|
|
308
|
+
* The keyboard sensor is the reason reordering is reachable without a mouse
|
|
309
|
+
* at all: Space picks a row up, the arrows move it, Space drops it, Escape
|
|
310
|
+
* abandons it — and dnd-kit announces each step to screen readers as it goes.
|
|
311
|
+
*/
|
|
312
|
+
const sensors = useSensors(
|
|
313
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
|
314
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
/** The keys that can actually move, in their current order. */
|
|
318
|
+
const movableKeys = useMemo(
|
|
319
|
+
() => order.filter((k) => !immovable.includes(k)),
|
|
320
|
+
[order, immovable]
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Reorders within the movable keys only, then puts them back around the fixed
|
|
325
|
+
* ones, which keep their exact indices.
|
|
326
|
+
*
|
|
327
|
+
* A plain `arrayMove` over the whole list would not do: moving the last row to
|
|
328
|
+
* the top shifts every key below it by one, fixed keys included — so a column
|
|
329
|
+
* nobody is allowed to drag ends up somewhere new anyway. This moves the
|
|
330
|
+
* dragged key through the movable slots and leaves the fixed slots alone.
|
|
331
|
+
*/
|
|
332
|
+
const onDragEnd = (e: DragEndEvent) => {
|
|
333
|
+
const { active, over } = e;
|
|
334
|
+
if (!over || active.id === over.id) return;
|
|
335
|
+
|
|
336
|
+
const from = movableKeys.indexOf(active.id as string);
|
|
337
|
+
const to = movableKeys.indexOf(over.id as string);
|
|
338
|
+
if (from === -1 || to === -1) return;
|
|
339
|
+
|
|
340
|
+
const moved = arrayMove(movableKeys, from, to);
|
|
341
|
+
let next = 0;
|
|
342
|
+
onOrderChange(order.map((key) => (immovable.includes(key) ? key : moved[next++])));
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<Popover>
|
|
347
|
+
<PopoverTrigger asChild>
|
|
348
|
+
<Button
|
|
349
|
+
variant="outline"
|
|
350
|
+
size="sm"
|
|
351
|
+
aria-label={iconOnly ? label : undefined}
|
|
352
|
+
// The same grip glyph the draggable rows inside use, so the button
|
|
353
|
+
// names the gesture it opens.
|
|
354
|
+
leftIcon={<GripVertical className="h-3.5 w-3.5" />}
|
|
355
|
+
className={cn(
|
|
356
|
+
// Compact by default so it sits level with filter chips rather than
|
|
357
|
+
// towering over them at Button's own `sm` height.
|
|
358
|
+
"h-auto min-h-0 shrink-0 py-1 text-muted-foreground hover:text-foreground",
|
|
359
|
+
className
|
|
360
|
+
)}
|
|
361
|
+
>
|
|
362
|
+
{iconOnly ? null : label}
|
|
363
|
+
</Button>
|
|
364
|
+
</PopoverTrigger>
|
|
365
|
+
|
|
366
|
+
{/* Bounded by the room Radix measures between the trigger and the
|
|
367
|
+
viewport edge, not by a pixel cap: a grid with thirty columns used to
|
|
368
|
+
render thirty rows in one tall popover, and the last of them — along
|
|
369
|
+
with Reset — fell below the fold with no way to reach them. The hint
|
|
370
|
+
line and the Reset footer stay put; only the list scrolls. */}
|
|
371
|
+
<PopoverContent
|
|
372
|
+
align={align}
|
|
373
|
+
className="flex max-h-[var(--radix-popover-content-available-height)] w-56 flex-col overflow-hidden p-2"
|
|
374
|
+
>
|
|
375
|
+
<p className="shrink-0 px-2 pb-1.5 text-[11px] font-medium text-muted-foreground">
|
|
376
|
+
{canToggleVisibility ? "Drag to reorder · tick to show" : "Drag to reorder"}
|
|
377
|
+
</p>
|
|
378
|
+
|
|
379
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
380
|
+
{/* Only the movable keys are sortable; a fixed one is neither a
|
|
381
|
+
drag source nor a drop target. */}
|
|
382
|
+
<SortableContext items={movableKeys} strategy={verticalListSortingStrategy}>
|
|
383
|
+
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto">
|
|
384
|
+
{ordered.map((col) => (
|
|
385
|
+
<SortableColumnRow
|
|
386
|
+
key={col.key}
|
|
387
|
+
id={col.key}
|
|
388
|
+
label={col.label}
|
|
389
|
+
pinned={immovable.includes(col.key)}
|
|
390
|
+
pinnedReason={pinnedReason}
|
|
391
|
+
visibility={
|
|
392
|
+
canToggleVisibility
|
|
393
|
+
? {
|
|
394
|
+
checked: !hidden.includes(col.key),
|
|
395
|
+
// A fixed column keeps a box rather than having none,
|
|
396
|
+
// so the list reads as one set of columns with some
|
|
397
|
+
// locked, not two lists.
|
|
398
|
+
lockedReason: fixedKeys.includes(col.key) ? fixedReason : undefined,
|
|
399
|
+
onToggle: () => toggleVisibility(col.key),
|
|
400
|
+
}
|
|
401
|
+
: undefined
|
|
402
|
+
}
|
|
403
|
+
/>
|
|
404
|
+
))}
|
|
405
|
+
</div>
|
|
406
|
+
</SortableContext>
|
|
407
|
+
</DndContext>
|
|
408
|
+
|
|
409
|
+
{/* Secondary action, divided off from the list so it reads as an escape
|
|
410
|
+
hatch rather than another draggable row. */}
|
|
411
|
+
<Separator className="my-2 shrink-0" />
|
|
412
|
+
{isCustomised ? (
|
|
413
|
+
<Button
|
|
414
|
+
type="button"
|
|
415
|
+
variant="ghost"
|
|
416
|
+
size="sm"
|
|
417
|
+
leftIcon={<RotateCcw className="h-3 w-3" />}
|
|
418
|
+
onClick={onReset}
|
|
419
|
+
className="w-full justify-start text-muted-foreground hover:text-foreground"
|
|
420
|
+
>
|
|
421
|
+
Reset to defaults
|
|
422
|
+
</Button>
|
|
423
|
+
) : (
|
|
424
|
+
/* Nothing to undo. A disabled button here would offer an action and
|
|
425
|
+
then refuse it; saying the columns already are the default answers
|
|
426
|
+
the question the button was raising. */
|
|
427
|
+
<p className="px-2 py-1.5 text-[12px] text-muted-foreground">
|
|
428
|
+
Columns are in their default order.
|
|
429
|
+
</p>
|
|
430
|
+
)}
|
|
431
|
+
</PopoverContent>
|
|
432
|
+
</Popover>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── Preferences ─────────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
export interface ColumnPreferences {
|
|
439
|
+
order: string[];
|
|
440
|
+
hidden: string[];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export interface UseColumnPreferencesOptions {
|
|
444
|
+
/**
|
|
445
|
+
* `localStorage` key. Omit and the arrangement lives only for the session —
|
|
446
|
+
* which is what a grid whose columns depend on the signed-in user's role
|
|
447
|
+
* wants, since a saved order from another role would resurrect columns that
|
|
448
|
+
* no longer exist.
|
|
449
|
+
*/
|
|
450
|
+
storageKey?: string;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export interface UseColumnPreferencesResult extends ColumnPreferences {
|
|
454
|
+
setOrder: (order: string[]) => void;
|
|
455
|
+
setHidden: (hidden: string[]) => void;
|
|
456
|
+
reset: () => void;
|
|
457
|
+
/** Spread straight onto `<ColumnManager>`. */
|
|
458
|
+
managerProps: Pick<
|
|
459
|
+
ColumnManagerProps,
|
|
460
|
+
"order" | "onOrderChange" | "hiddenKeys" | "onHiddenKeysChange" | "onReset"
|
|
461
|
+
>;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Owns a grid's column arrangement, optionally persisted.
|
|
466
|
+
*
|
|
467
|
+
* `defaultOrder` is the source of truth for which columns exist: a stored order
|
|
468
|
+
* is reconciled against it on every read, so a column added in a release shows
|
|
469
|
+
* up for someone who saved an arrangement before it existed, and a removed one
|
|
470
|
+
* disappears instead of leaving a hole.
|
|
471
|
+
*/
|
|
472
|
+
export function useColumnPreferences(
|
|
473
|
+
defaultOrder: string[],
|
|
474
|
+
{ storageKey }: UseColumnPreferencesOptions = {}
|
|
475
|
+
): UseColumnPreferencesResult {
|
|
476
|
+
const defaultKey = defaultOrder.join("|");
|
|
477
|
+
|
|
478
|
+
const [prefs, setPrefs] = useState<ColumnPreferences>({ order: defaultOrder, hidden: [] });
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Read in an effect rather than a lazy initialiser: this package renders on
|
|
482
|
+
* the server too, and reading `localStorage` during the first render would
|
|
483
|
+
* make the server and client trees disagree.
|
|
484
|
+
*/
|
|
485
|
+
useEffect(() => {
|
|
486
|
+
if (!storageKey || typeof window === "undefined") return;
|
|
487
|
+
try {
|
|
488
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
489
|
+
if (!raw) return;
|
|
490
|
+
const saved = JSON.parse(raw) as Partial<ColumnPreferences>;
|
|
491
|
+
const savedOrder = Array.isArray(saved.order) ? saved.order : [];
|
|
492
|
+
const savedHidden = Array.isArray(saved.hidden) ? saved.hidden : [];
|
|
493
|
+
const known = new Set(defaultOrder);
|
|
494
|
+
setPrefs({
|
|
495
|
+
// Keep the saved arrangement for columns that still exist, then append
|
|
496
|
+
// anything new in its declared position at the end.
|
|
497
|
+
order: [
|
|
498
|
+
...savedOrder.filter((k) => known.has(k)),
|
|
499
|
+
...defaultOrder.filter((k) => !savedOrder.includes(k)),
|
|
500
|
+
],
|
|
501
|
+
hidden: savedHidden.filter((k) => known.has(k)),
|
|
502
|
+
});
|
|
503
|
+
} catch {
|
|
504
|
+
// Corrupt or unreadable storage (private mode, a bad hand-edit) is not
|
|
505
|
+
// worth breaking a table over — the default arrangement is a fine answer.
|
|
506
|
+
}
|
|
507
|
+
// `defaultKey` rather than `defaultOrder`: a column list rebuilt on every
|
|
508
|
+
// render is a new array each time, which would re-read storage forever.
|
|
509
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
510
|
+
}, [storageKey, defaultKey]);
|
|
511
|
+
|
|
512
|
+
const persist = useCallback(
|
|
513
|
+
(next: ColumnPreferences) => {
|
|
514
|
+
setPrefs(next);
|
|
515
|
+
if (!storageKey || typeof window === "undefined") return;
|
|
516
|
+
try {
|
|
517
|
+
window.localStorage.setItem(storageKey, JSON.stringify(next));
|
|
518
|
+
} catch {
|
|
519
|
+
// Storage full or blocked. The arrangement still applies this session.
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
[storageKey]
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
const setOrder = useCallback(
|
|
526
|
+
(order: string[]) => persist({ order, hidden: prefs.hidden }),
|
|
527
|
+
[persist, prefs.hidden]
|
|
528
|
+
);
|
|
529
|
+
const setHidden = useCallback(
|
|
530
|
+
(hidden: string[]) => persist({ order: prefs.order, hidden }),
|
|
531
|
+
[persist, prefs.order]
|
|
532
|
+
);
|
|
533
|
+
const reset = useCallback(() => {
|
|
534
|
+
setPrefs({ order: defaultOrder, hidden: [] });
|
|
535
|
+
if (!storageKey || typeof window === "undefined") return;
|
|
536
|
+
try {
|
|
537
|
+
window.localStorage.removeItem(storageKey);
|
|
538
|
+
} catch {
|
|
539
|
+
// See persist().
|
|
540
|
+
}
|
|
541
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
542
|
+
}, [storageKey, defaultKey]);
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
order: prefs.order,
|
|
546
|
+
hidden: prefs.hidden,
|
|
547
|
+
setOrder,
|
|
548
|
+
setHidden,
|
|
549
|
+
reset,
|
|
550
|
+
managerProps: {
|
|
551
|
+
order: prefs.order,
|
|
552
|
+
onOrderChange: setOrder,
|
|
553
|
+
hiddenKeys: prefs.hidden,
|
|
554
|
+
onHiddenKeysChange: setHidden,
|
|
555
|
+
onReset: reset,
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Applies a saved arrangement to a built column list.
|
|
562
|
+
*
|
|
563
|
+
* `pinnedKeys` stay where they are declared regardless of the saved order —
|
|
564
|
+
* for the trailing "action" column, which is a utility, not a data field
|
|
565
|
+
* anybody wants to move. Columns missing from `order` (a field that only
|
|
566
|
+
* exists for some roles, say) are appended before them rather than dropped.
|
|
567
|
+
*/
|
|
568
|
+
export function applyColumnPreferences<T extends { key: string }>(
|
|
569
|
+
columns: T[],
|
|
570
|
+
{ order, hidden }: Partial<ColumnPreferences> = {},
|
|
571
|
+
pinnedKeys: string[] = ["action"]
|
|
572
|
+
): T[] {
|
|
573
|
+
const pinned = columns.filter((c) => pinnedKeys.includes(c.key));
|
|
574
|
+
const movable = columns.filter((c) => !pinnedKeys.includes(c.key));
|
|
575
|
+
|
|
576
|
+
const arranged = order?.length
|
|
577
|
+
? (() => {
|
|
578
|
+
const byKey = new Map(movable.map((c) => [c.key, c]));
|
|
579
|
+
const seen = order.map((k) => byKey.get(k)).filter((c): c is T => !!c);
|
|
580
|
+
const missing = movable.filter((c) => !order.includes(c.key));
|
|
581
|
+
return [...seen, ...missing];
|
|
582
|
+
})()
|
|
583
|
+
: movable;
|
|
584
|
+
|
|
585
|
+
const visible = hidden?.length
|
|
586
|
+
? arranged.filter((c) => !hidden.includes(c.key))
|
|
587
|
+
: arranged;
|
|
588
|
+
|
|
589
|
+
return [...visible, ...pinned];
|
|
590
|
+
}
|