@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.
@@ -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
+ }