torch-glare 2.1.1 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/apps/lib/components/Avatar.tsx +1 -1
  2. package/apps/lib/components/BadgeField.tsx +2 -2
  3. package/apps/lib/components/Card.tsx +68 -54
  4. package/apps/lib/components/DataViews/ARCHITECTURE.md +439 -0
  5. package/apps/lib/components/DataViews/DataViewRadio.tsx +47 -0
  6. package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +427 -0
  7. package/apps/lib/components/DataViews/DataViewsHeader.tsx +228 -0
  8. package/apps/lib/components/DataViews/DataViewsLayout.tsx +330 -0
  9. package/apps/lib/components/DataViews/FilterPanel.tsx +469 -0
  10. package/apps/lib/components/DataViews/HeaderSearch.tsx +97 -0
  11. package/apps/lib/components/DataViews/InboxView.tsx +495 -0
  12. package/apps/lib/components/DataViews/InboxViewCard.tsx +136 -0
  13. package/apps/lib/components/DataViews/KanbanView.tsx +353 -0
  14. package/apps/lib/components/DataViews/PanelControls.tsx +49 -0
  15. package/apps/lib/components/DataViews/SettingsPanel.tsx +285 -0
  16. package/apps/lib/components/DataViews/TableView.tsx +232 -0
  17. package/apps/lib/components/DataViews/TreeView.tsx +392 -0
  18. package/apps/lib/components/DataViews/badgeAdapter.ts +45 -0
  19. package/apps/lib/components/DataViews/fieldRenderers.tsx +334 -0
  20. package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +113 -0
  21. package/apps/lib/components/DataViews/filters/PresetChips.tsx +45 -0
  22. package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +154 -0
  23. package/apps/lib/components/DataViews/index.ts +36 -0
  24. package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +54 -0
  25. package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +77 -0
  26. package/apps/lib/components/DataViews/types.ts +206 -0
  27. package/apps/lib/components/Radio.tsx +18 -21
  28. package/apps/lib/components/Switch.tsx +3 -1
  29. package/apps/lib/components/Table.tsx +1 -1
  30. package/apps/lib/components/TreeFolder/TreeFolder.tsx +410 -0
  31. package/apps/lib/components/TreeFolder/TreeFolderBreadcrumb.tsx +80 -0
  32. package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +363 -0
  33. package/apps/lib/components/TreeFolder/TreeFolderStyles.tsx +60 -0
  34. package/apps/lib/components/TreeFolder/icons.tsx +63 -0
  35. package/apps/lib/components/TreeFolder/index.ts +17 -0
  36. package/apps/lib/components/TreeFolder/treeFolderUtils.ts +114 -0
  37. package/apps/lib/components/TreeFolder/types.ts +77 -0
  38. package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +261 -0
  39. package/apps/lib/hooks/useDataViewsState.ts +169 -0
  40. package/apps/lib/hooks/useIsMobile.ts +21 -0
  41. package/apps/lib/layouts/DataViewCard.tsx +76 -0
  42. package/apps/lib/utils/dataViews/columnUtils.ts +130 -0
  43. package/apps/lib/utils/dataViews/fieldUtils.ts +198 -0
  44. package/apps/lib/utils/dataViews/nestedDataUtils.tsx +364 -0
  45. package/apps/lib/utils/dataViews/pathUtils.ts +132 -0
  46. package/apps/lib/utils/dataViews/rangeUtils.ts +225 -0
  47. package/apps/lib/utils/dataViews/treeUtils.ts +403 -0
  48. package/dist/bin/index.js +3 -3
  49. package/dist/bin/index.js.map +1 -1
  50. package/dist/src/commands/add.d.ts.map +1 -1
  51. package/dist/src/commands/add.js +29 -6
  52. package/dist/src/commands/add.js.map +1 -1
  53. package/dist/src/commands/utils.d.ts.map +1 -1
  54. package/dist/src/commands/utils.js +22 -2
  55. package/dist/src/commands/utils.js.map +1 -1
  56. package/dist/src/shared/copyComponentsRecursively.d.ts.map +1 -1
  57. package/dist/src/shared/copyComponentsRecursively.js +17 -2
  58. package/dist/src/shared/copyComponentsRecursively.js.map +1 -1
  59. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts +18 -4
  60. package/dist/src/shared/getDependenciesAndInstallNestedComponents.d.ts.map +1 -1
  61. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js +110 -40
  62. package/dist/src/shared/getDependenciesAndInstallNestedComponents.js.map +1 -1
  63. package/docs/components/data-views-config-panel.md +204 -0
  64. package/docs/components/data-views-layout.md +270 -0
  65. package/docs/components/form-stepper.md +244 -0
  66. package/docs/components/stepper.md +215 -0
  67. package/docs/components/timeline.md +248 -0
  68. package/package.json +6 -6
  69. package/apps/lib/components/Charts-dev.tsx +0 -365
  70. package/apps/lib/components/Command-dev.tsx +0 -151
  71. package/apps/lib/components/IosDatePicker-dev.tsx +0 -341
  72. /package/docs/components/{labeled-checkbox.md → labeled-check-box.md} +0 -0
  73. /package/docs/components/{tree-dropdown.md → tree-drop-down.md} +0 -0
@@ -0,0 +1,427 @@
1
+ "use client";
2
+
3
+ import { Fragment, useMemo, useState } from "react";
4
+ import {
5
+ X,
6
+ Settings as SettingsIcon,
7
+ Filter as FilterIcon,
8
+ Plus,
9
+ } from "lucide-react";
10
+ import type {
11
+ ViewConfig,
12
+ ViewType,
13
+ FieldConfig,
14
+ DynamicRecord,
15
+ DynamicFilterConfig,
16
+ FilterState,
17
+ FilterValue,
18
+ } from "./types";
19
+
20
+ import { RadioGroup } from "../Radio";
21
+ import { FilterPanel } from "./FilterPanel";
22
+ import { RadioRow, DataViewsSwitch } from "./PanelControls";
23
+ import { cn } from "../../utils/cn";
24
+
25
+ type ConfigTab = "config" | "filters";
26
+
27
+ type SavedView = { id: string; label: string };
28
+
29
+ export type DataViewsConfigPanelProps = {
30
+ config: ViewConfig;
31
+ onConfigChange: (config: Partial<ViewConfig>) => void;
32
+ onClose: () => void;
33
+ currentView: ViewType;
34
+ fields: FieldConfig[];
35
+
36
+ // Filters tab
37
+ data: DynamicRecord[];
38
+ filterState: FilterState;
39
+ onFilterChange: (filters: FilterState) => void;
40
+ filterConfig?: DynamicFilterConfig[];
41
+
42
+ // Saved views (presentational shell — wire to persistence when available)
43
+ savedViews?: SavedView[];
44
+ activeSavedView?: string;
45
+ onSavedViewChange?: (id: string) => void;
46
+ onSaveNewView?: () => void;
47
+
48
+ // Animation: drives slide-in/out. Parent keeps the panel mounted through
49
+ // the close animation, then unmounts.
50
+ state?: "open" | "closed";
51
+ };
52
+
53
+ const DEFAULT_SAVED_VIEWS: SavedView[] = [
54
+ { id: "default", label: "Default View" },
55
+ ];
56
+
57
+ function SectionHeader({
58
+ title,
59
+ action,
60
+ }: {
61
+ title: string;
62
+ action?: React.ReactNode;
63
+ }) {
64
+ return (
65
+ <div className="flex items-center justify-between">
66
+ <h3 className="text-[18px] font-[510] leading-[1.32] tracking-[-0.01em] text-white">
67
+ {title}
68
+ </h3>
69
+ {action}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ /** 2×3 dot drag handle, matching the Figma SB-Column-Item grip (16×16 box,
75
+ * compact ~1.5px dots, tight spacing — drawn as an SVG for pixel accuracy). */
76
+ function GripDots() {
77
+ return (
78
+ <svg
79
+ aria-hidden
80
+ width="16"
81
+ height="16"
82
+ viewBox="0 0 16 16"
83
+ // Panel is always-dark chrome (like the hardcoded white label text):
84
+ // the grip stays white-on-dark regardless of host theme.
85
+ className="text-white/60"
86
+ fill="currentColor"
87
+ >
88
+ {[5.33, 9.33].flatMap((cx) =>
89
+ [3.33, 8, 12.67].map((cy) => (
90
+ <circle key={`${cx}-${cy}`} cx={cx} cy={cy} r="1" />
91
+ )),
92
+ )}
93
+ </svg>
94
+ );
95
+ }
96
+
97
+ /** 2px blue insertion line shown between rows during a drag-reorder. */
98
+ function DropLine() {
99
+ return (
100
+ <div className="pointer-events-none relative h-0">
101
+ <div className="absolute -top-[1px] left-0 right-0 h-[2px] rounded-full bg-[#005ECC]" />
102
+ </div>
103
+ );
104
+ }
105
+
106
+ export function DataViewsConfigPanel(props: DataViewsConfigPanelProps) {
107
+ const {
108
+ config,
109
+ onConfigChange,
110
+ onClose,
111
+ fields,
112
+ data,
113
+ filterState,
114
+ onFilterChange,
115
+ filterConfig,
116
+ savedViews = DEFAULT_SAVED_VIEWS,
117
+ activeSavedView,
118
+ onSavedViewChange,
119
+ onSaveNewView,
120
+ state = "open",
121
+ } = props;
122
+
123
+ const [tab, setTab] = useState<ConfigTab>("config");
124
+
125
+ // Saved View is controlled by the parent when `activeSavedView` /
126
+ // `onSavedViewChange` are supplied; otherwise fall back to local state so the
127
+ // radios are still interactive (DataViewsLayout doesn't thread these props
128
+ // yet — without this the selection would snap back every click).
129
+ const [internalSavedView, setInternalSavedView] = useState(
130
+ () => savedViews[0]?.id,
131
+ );
132
+ const selectedSavedView = activeSavedView ?? internalSavedView;
133
+ const handleSavedViewChange = (id: string) => {
134
+ if (onSavedViewChange) onSavedViewChange(id);
135
+ else setInternalSavedView(id);
136
+ };
137
+
138
+ const visibleFields = useMemo(
139
+ () => fields.filter((f) => f.type !== "hidden"),
140
+ [fields],
141
+ );
142
+ const visiblePaths = useMemo(
143
+ () => new Set(visibleFields.map((f) => f.path)),
144
+ [visibleFields],
145
+ );
146
+ const fieldByPath = useMemo(
147
+ () => new Map(visibleFields.map((f) => [f.path, f])),
148
+ [visibleFields],
149
+ );
150
+ const orderedColumns = useMemo(
151
+ () =>
152
+ [...config.tableColumns]
153
+ .filter((c) => visiblePaths.has(c.id))
154
+ .sort((a, b) => a.order - b.order),
155
+ [config.tableColumns, visiblePaths],
156
+ );
157
+
158
+ const toggleColumnVisibility = (path: string) => {
159
+ const next = config.tableColumns.map((c) =>
160
+ c.id === path ? { ...c, visible: !c.visible } : c,
161
+ );
162
+ onConfigChange({ tableColumns: next });
163
+ };
164
+
165
+ const [dragPath, setDragPath] = useState<string | null>(null);
166
+ // Insertion slot in the ordered list: 0 means before the first row, N means
167
+ // after the last row (count). Single source of truth — there is exactly one
168
+ // indicator at a time, so no double-line ambiguity between adjacent rows.
169
+ const [dropSlot, setDropSlot] = useState<number | null>(null);
170
+
171
+ const reorderColumnToSlot = (sourcePath: string, slot: number) => {
172
+ const ids = orderedColumns.map((c) => c.id);
173
+ const from = ids.indexOf(sourcePath);
174
+ if (from === -1) return;
175
+ // Dropping into the same logical position (before or after itself) is a no-op.
176
+ if (slot === from || slot === from + 1) return;
177
+ const reordered = [...ids];
178
+ reordered.splice(from, 1);
179
+ // After removal, indices shift left by 1 for any slot beyond `from`.
180
+ const insertAt = slot > from ? slot - 1 : slot;
181
+ reordered.splice(insertAt, 0, sourcePath);
182
+ const orderByPath = new Map(reordered.map((id, i) => [id, i]));
183
+ const next = config.tableColumns.map((c) => {
184
+ const newOrder = orderByPath.get(c.id);
185
+ return newOrder == null ? c : { ...c, order: newOrder };
186
+ });
187
+ onConfigChange({ tableColumns: next });
188
+ };
189
+
190
+ const sortableColumns = orderedColumns;
191
+
192
+ const tabBtn = (id: ConfigTab, icon: React.ReactNode, label: string) => {
193
+ const active = tab === id;
194
+ return (
195
+ <button
196
+ type="button"
197
+ aria-pressed={active}
198
+ onClick={() => setTab(id)}
199
+ className={cn(
200
+ "flex h-6 flex-1 items-center justify-center gap-1 rounded-[8px] px-3 text-[14px] font-[510] leading-none transition-all duration-200 ease-in-out",
201
+ active
202
+ ? "bg-white text-black shadow-[0_0_10px_2px_rgba(0,0,0,0.25)]"
203
+ : "bg-transparent text-white hover:bg-white/5",
204
+ )}
205
+ >
206
+ <span className="flex h-[14px] w-[14px] items-center justify-center [&_svg]:h-[14px] [&_svg]:w-[14px]">
207
+ {icon}
208
+ </span>
209
+ {label}
210
+ </button>
211
+ );
212
+ };
213
+
214
+ return (
215
+ <div
216
+ data-state={state}
217
+ // Panel is always dark (Figma `Cun` = #000000). data-theme="dark" makes
218
+ // child themed components (Button, Switch, Radio, FilterPanel) resolve
219
+ // dark tokens even when the host app runs in default/light theme.
220
+ data-theme="dark"
221
+ className={cn(
222
+ "flex h-full w-[260px] flex-col overflow-hidden rounded-[16px] bg-black",
223
+ "transition-opacity duration-200 ease-in-out",
224
+ state === "open" ? "opacity-100" : "opacity-0",
225
+ )}
226
+ >
227
+ {/* Header: tab switcher + close */}
228
+ <div className="flex items-center gap-2 px-3 py-3">
229
+ <div className="flex flex-1 items-center gap-[2px] rounded-[10px] bg-[#252729] p-[2px] shadow-[inset_0_0_5px_0_rgba(0,0,0,0.16)]">
230
+ {tabBtn("config", <SettingsIcon />, "Config.")}
231
+ {tabBtn("filters", <FilterIcon />, "Filters")}
232
+ </div>
233
+ <button
234
+ type="button"
235
+ onClick={onClose}
236
+ aria-label="Close panel"
237
+ className="flex h-7 w-7 items-center justify-center rounded-[8px] bg-white/[0.15] text-white transition-colors hover:bg-white/25"
238
+ >
239
+ <X className="h-[18px] w-[18px]" />
240
+ </button>
241
+ </div>
242
+
243
+ <div className="h-px w-full bg-[#2C2D2E]" />
244
+
245
+ <div className="flex-1 overflow-y-auto">
246
+ {tab === "config" ? (
247
+ <div className="flex flex-col gap-6 px-3 py-4">
248
+ {/* Saved View */}
249
+ <div className="space-y-3">
250
+ <SectionHeader title="Saved View" />
251
+ <RadioGroup
252
+ value={selectedSavedView}
253
+ onValueChange={handleSavedViewChange}
254
+ className="flex flex-col gap-1 space-y-0 rounded-[12px] bg-[#1C1D1F] p-1"
255
+ >
256
+ {savedViews.map((sv, i) => (
257
+ <div key={sv.id}>
258
+ {/* Divider spans edge-to-edge (Figma: no horizontal
259
+ inset). */}
260
+ {i > 0 && <div className="h-px bg-[#2C2D2E]" />}
261
+ <RadioRow value={sv.id} label={sv.label} />
262
+ </div>
263
+ ))}
264
+ </RadioGroup>
265
+ <button
266
+ type="button"
267
+ onClick={onSaveNewView}
268
+ className="flex w-full items-center justify-center gap-1.5 rounded-[4px] bg-white/[0.15] px-1.5 py-0.5 text-[12px] font-[510] text-white transition-colors hover:bg-white/25"
269
+ >
270
+ <Plus className="h-3 w-3" />
271
+ Save a New View
272
+ </button>
273
+ </div>
274
+
275
+ <div className="h-px w-full bg-[#2C2D2E]" />
276
+
277
+ {/* Table Columns */}
278
+ <div className="space-y-3">
279
+ <SectionHeader title="Table Columns" />
280
+ <p className="text-[12px] leading-[1.475] text-content-presentation-global-tertiary">
281
+ Show or hide columns in table view
282
+ </p>
283
+ {orderedColumns.length === 0 ? (
284
+ <p className="text-xs text-content-presentation-global-tertiary">
285
+ No fields detected.
286
+ </p>
287
+ ) : (
288
+ <div data-theme="dark" className="flex flex-col gap-2">
289
+ {orderedColumns.map((col, index) => {
290
+ const field = fieldByPath.get(col.id);
291
+ const isDragging = dragPath === col.id;
292
+ // Slot for the cursor on this row: top half = insert at
293
+ // `index` (before this row); bottom half = `index + 1`
294
+ // (after this row, which is the SAME slot as "before next
295
+ // row" — the single source of truth avoids the old
296
+ // double-line problem in the gap between rows).
297
+ return (
298
+ <div key={col.id}>
299
+ {dropSlot === index && dragPath && <DropLine />}
300
+ <div
301
+ draggable
302
+ onDragStart={(e) => {
303
+ setDragPath(col.id);
304
+ e.dataTransfer.effectAllowed = "move";
305
+ e.dataTransfer.setData("text/plain", col.id);
306
+ }}
307
+ onDragOver={(e) => {
308
+ e.preventDefault();
309
+ e.dataTransfer.dropEffect = "move";
310
+ const rect =
311
+ e.currentTarget.getBoundingClientRect();
312
+ const before =
313
+ e.clientY < rect.top + rect.height / 2;
314
+ const slot = before ? index : index + 1;
315
+ if (dropSlot !== slot) setDropSlot(slot);
316
+ }}
317
+ onDrop={(e) => {
318
+ e.preventDefault();
319
+ if (dragPath && dropSlot != null)
320
+ reorderColumnToSlot(dragPath, dropSlot);
321
+ setDragPath(null);
322
+ setDropSlot(null);
323
+ }}
324
+ onDragEnd={() => {
325
+ setDragPath(null);
326
+ setDropSlot(null);
327
+ }}
328
+ className={cn(
329
+ // SB-Column-Item: standalone #1C1D1F pill, #252729
330
+ // border. Figma container spec: 8px radius, 8.8px
331
+ // padding, 8px gap between grip / label / switch.
332
+ "flex items-center gap-2 rounded-e-[99px] rounded-s-[60px] border border-[#252729] bg-[#1C1D1F] p-[8.8px] transition-colors cursor-grab active:cursor-grabbing",
333
+ isDragging ? "opacity-50" : "hover:bg-[#252729]",
334
+ )}
335
+ >
336
+ <span className="flex shrink-0 items-center justify-center">
337
+ <GripDots />
338
+ </span>
339
+ <span className="flex-1 text-[14px] text-white">
340
+ {col.label || field?.label || col.id}
341
+ </span>
342
+ <span className="flex shrink-0 items-center">
343
+ <DataViewsSwitch
344
+ checked={col.visible}
345
+ onCheckedChange={() =>
346
+ toggleColumnVisibility(col.id)
347
+ }
348
+ />
349
+ </span>
350
+ </div>
351
+ </div>
352
+ );
353
+ })}
354
+ {/* Drop-at-end indicator: only ever rendered when the slot
355
+ points past the last row, so still exactly one line. */}
356
+ {dropSlot === orderedColumns.length && dragPath && (
357
+ <DropLine />
358
+ )}
359
+ </div>
360
+ )}
361
+ </div>
362
+
363
+ <div className="h-px w-full bg-[#2C2D2E]" />
364
+
365
+ {/* Default Sort */}
366
+ <div className="space-y-3">
367
+ <SectionHeader title="Default Sort" />
368
+ {sortableColumns.length === 0 ? (
369
+ <p className="text-xs text-content-presentation-global-tertiary">
370
+ No sortable columns.
371
+ </p>
372
+ ) : (
373
+ // Single-choice radio list (Figma 1612:30016): selecting a
374
+ // column sets config.sortBy; direction keeps config.sortOrder.
375
+ // Rows + dividers are flat siblings so the `peer` pattern
376
+ // can hide the dividers immediately before AND after a
377
+ // hovered row.
378
+ <RadioGroup
379
+ value={config.sortBy || undefined}
380
+ onValueChange={(v) => onConfigChange({ sortBy: v })}
381
+ className={cn(
382
+ "flex flex-col space-y-0 rounded-[12px] bg-[#1C1D1F] p-1",
383
+ // Wrapper containing the hovered row: hide its OWN
384
+ // divider (sits above the row).
385
+ "[&>div:has(>[role=radio]:hover)>.dv-divider]:opacity-0",
386
+ // Wrapper that directly follows the one with the hovered
387
+ // row: hide its divider (sits below the hovered row).
388
+ "[&>div:has(>[role=radio]:hover)+div>.dv-divider]:opacity-0",
389
+ )}
390
+ >
391
+ {sortableColumns.map((col, i) => {
392
+ const field = fieldByPath.get(col.id);
393
+ return (
394
+ <div key={col.id}>
395
+ {/* Edge-to-edge divider (Figma: no horizontal
396
+ inset). */}
397
+ {i > 0 && (
398
+ <div className="dv-divider h-px bg-[#2C2D2E]" />
399
+ )}
400
+ <RadioRow
401
+ value={col.id}
402
+ label={col.label || field?.label || col.id}
403
+ />
404
+ </div>
405
+ );
406
+ })}
407
+ </RadioGroup>
408
+ )}
409
+ </div>
410
+ </div>
411
+ ) : (
412
+ <FilterPanel
413
+ variant="panel"
414
+ data={data}
415
+ fields={fields}
416
+ filters={filterState}
417
+ onFilterChange={(path: string, value: FilterValue) =>
418
+ onFilterChange({ ...filterState, [path]: value })
419
+ }
420
+ onClearAll={() => onFilterChange({})}
421
+ filterConfig={filterConfig}
422
+ />
423
+ )}
424
+ </div>
425
+ </div>
426
+ );
427
+ }
@@ -0,0 +1,228 @@
1
+ "use client";
2
+
3
+ import { Search, Settings } from "lucide-react";
4
+ import { useEffect, useRef, useState, type ReactNode } from "react";
5
+ import type { ViewType } from "./types";
6
+ import { Button } from "../Button";
7
+ import { cn } from "../../utils/cn";
8
+
9
+ export type DataViewsHeaderView = {
10
+ id: ViewType;
11
+ label: string;
12
+ icon: ReactNode;
13
+ };
14
+
15
+ type DataViewsHeaderProps = {
16
+ title: string;
17
+ views: DataViewsHeaderView[];
18
+ currentView: ViewType;
19
+ onViewChange: (view: ViewType) => void;
20
+ showSettings: boolean;
21
+ settingsOpen: boolean;
22
+ onToggleSettings: () => void;
23
+ onAddNew?: () => void;
24
+ addNewLabel?: string;
25
+ searchValue?: string;
26
+ onSearchChange?: (value: string) => void;
27
+ searchPlaceholder?: string;
28
+ className?: string;
29
+ };
30
+
31
+ export function DataViewsHeader({
32
+ title,
33
+ views,
34
+ currentView,
35
+ onViewChange,
36
+ showSettings,
37
+ settingsOpen,
38
+ onToggleSettings,
39
+ onAddNew,
40
+ addNewLabel = "Add New",
41
+ searchValue,
42
+ onSearchChange,
43
+ searchPlaceholder = "Search...",
44
+ className,
45
+ }: DataViewsHeaderProps) {
46
+ return (
47
+ <div
48
+ // Header is always dark. data-theme="dark" makes the child Button
49
+ // components resolve dark-theme tokens (correct against the black bar)
50
+ // even when the host app runs in default/light theme.
51
+ data-theme="dark"
52
+ className={cn(
53
+ "flex h-[52px] w-full items-center gap-2 rounded-[12px] bg-black px-2",
54
+ className,
55
+ )}
56
+ >
57
+ {/* Title pill */}
58
+ <div className="flex h-9 shrink-0 items-center gap-2 rounded-[12px] border border-[#434446] bg-[#252729] px-[10px]">
59
+ <span className="text-[28px] font-[510] uppercase leading-[1.19] text-white">
60
+ {title}
61
+ </span>
62
+ </div>
63
+
64
+ {/* Divider */}
65
+ <div className="h-5 w-px shrink-0 bg-[#434446]" />
66
+
67
+ {/* Segmented view switcher */}
68
+ <div className="flex flex-1 items-center gap-2">
69
+ <div className="flex items-center gap-[2px] rounded-[10px] bg-[#252729] p-[2px] shadow-[inset_0_0_4px_0_rgba(0,0,0,0.08)]">
70
+ {views.map((view, idx) => {
71
+ const active = view.id === currentView;
72
+ const prevActive = idx > 0 && views[idx - 1].id === currentView;
73
+ // Separator sits between two inactive tabs only; the active white
74
+ // pill never has a flanking divider (matches Figma).
75
+ const showDivider = idx > 0 && !active && !prevActive;
76
+ return (
77
+ <div key={view.id} className="flex items-center">
78
+ {showDivider && (
79
+ <div className="mx-[3px] h-3 w-px bg-[#434446]" />
80
+ )}
81
+ <button
82
+ type="button"
83
+ aria-pressed={active}
84
+ onClick={() => onViewChange(view.id)}
85
+ className={cn(
86
+ "flex h-6 items-center gap-[6px] rounded-[8px] px-3 text-[14px] font-[510] leading-none transition-all duration-200 ease-in-out",
87
+ active
88
+ ? "bg-white text-black shadow-[0_0_10px_2px_rgba(0,0,0,0.25)]"
89
+ : "bg-transparent text-white hover:bg-white/5",
90
+ )}
91
+ >
92
+ <span className="flex h-[14px] w-[14px] items-center justify-center [&_svg]:h-[14px] [&_svg]:w-[14px]">
93
+ {view.icon}
94
+ </span>
95
+ {view.label}
96
+ </button>
97
+ </div>
98
+ );
99
+ })}
100
+ </div>
101
+ </div>
102
+
103
+ {/* Action bar */}
104
+ <div className="flex shrink-0 items-center gap-2">
105
+ {onSearchChange && (
106
+ <HeaderSearch
107
+ value={searchValue ?? ""}
108
+ onChange={onSearchChange}
109
+ placeholder={searchPlaceholder}
110
+ />
111
+ )}
112
+ {onAddNew && (
113
+ <Button
114
+ variant="PrimeStyle"
115
+ size="M"
116
+ onClick={onAddNew}
117
+ className="rounded-[6px] bg-[#005ECC] px-[14px] text-[16px] font-[510] text-white hover:bg-[#005ECC]/90"
118
+ >
119
+ {addNewLabel}
120
+ </Button>
121
+ )}
122
+ {/* Hidden while the panel is open — the panel has its own close
123
+ control, so the header trigger would be redundant. */}
124
+ {showSettings && !settingsOpen && (
125
+ <Button
126
+ variant="BluContStyle"
127
+ size="M"
128
+ onClick={onToggleSettings}
129
+ aria-pressed={settingsOpen}
130
+ className="gap-[6px] rounded-[6px] px-[14px] text-[16px] font-[510]"
131
+ >
132
+ <Settings className="h-[18px] w-[18px]" />
133
+ Filter &amp; Config.
134
+ </Button>
135
+ )}
136
+ </div>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ function HeaderSearch({
142
+ value,
143
+ onChange,
144
+ placeholder,
145
+ }: {
146
+ value: string;
147
+ onChange: (value: string) => void;
148
+ placeholder: string;
149
+ }) {
150
+ const [open, setOpen] = useState(false);
151
+ const inputRef = useRef<HTMLInputElement>(null);
152
+ const wrapRef = useRef<HTMLDivElement>(null);
153
+
154
+ useEffect(() => {
155
+ if (open) inputRef.current?.focus();
156
+ }, [open]);
157
+
158
+ // Auto-collapse on outside click only when the input is empty — keeps the
159
+ // expanded state if the user has typed a query but clicks away.
160
+ useEffect(() => {
161
+ if (!open) return;
162
+ function onPointerDown(e: MouseEvent) {
163
+ if (!wrapRef.current) return;
164
+ if (wrapRef.current.contains(e.target as Node)) return;
165
+ if (!value) setOpen(false);
166
+ }
167
+ document.addEventListener("mousedown", onPointerDown);
168
+ return () => document.removeEventListener("mousedown", onPointerDown);
169
+ }, [open, value]);
170
+
171
+ function clearAndCollapse() {
172
+ onChange("");
173
+ setOpen(false);
174
+ }
175
+
176
+ if (!open) {
177
+ return (
178
+ <Button
179
+ variant="BluContStyle"
180
+ size="M"
181
+ buttonType="icon"
182
+ aria-label="Open search"
183
+ onClick={() => setOpen(true)}
184
+ className="shrink-0 rounded-[6px] border border-border-presentation-global-primary"
185
+ >
186
+ <Search className="h-[18px] w-[18px]" />
187
+ </Button>
188
+ );
189
+ }
190
+
191
+ return (
192
+ <div
193
+ ref={wrapRef}
194
+ className="relative flex h-[28px] w-[260px] shrink-0 items-center justify-center rounded-[6px] border border-border-presentation-state-focus bg-background-presentation-form-field-primary px-1 shadow-[0_1px_6px_0_rgba(0,0,0,0.30)] transition-all duration-150 ease-in-out"
195
+ >
196
+ <input
197
+ ref={inputRef}
198
+ type="text"
199
+ value={value}
200
+ placeholder={placeholder}
201
+ onChange={(e) => onChange(e.target.value)}
202
+ onKeyDown={(e) => {
203
+ if (e.key === "Escape") clearAndCollapse();
204
+ }}
205
+ className="flex-1 bg-transparent text-[14px] leading-none text-white caret-[#1E7AFE] placeholder:text-content-presentation-global-tertiary focus:outline-none"
206
+ />
207
+ <button
208
+ type="button"
209
+ aria-label="Clear search"
210
+ onClick={clearAndCollapse}
211
+ className="flex shrink-0 items-center justify-center self-stretch px-1"
212
+ >
213
+ <svg
214
+ xmlns="http://www.w3.org/2000/svg"
215
+ width="16"
216
+ height="16"
217
+ viewBox="0 0 16 16"
218
+ fill="none"
219
+ >
220
+ <path
221
+ d="M7.99992 14.6666C4.31802 14.6666 1.33325 11.6818 1.33325 7.99992C1.33325 4.31802 4.31802 1.33325 7.99992 1.33325C11.6818 1.33325 14.6666 4.31802 14.6666 7.99992C14.6666 11.6818 11.6818 14.6666 7.99992 14.6666ZM7.99992 7.05712L6.1143 5.17149L5.17149 6.1143L7.05712 7.99992L5.17149 9.88552L6.1143 10.8283L7.99992 8.94272L9.88552 10.8283L10.8283 9.88552L8.94272 7.99992L10.8283 6.1143L9.88552 5.17149L7.99992 7.05712Z"
222
+ fill="white"
223
+ />
224
+ </svg>
225
+ </button>
226
+ </div>
227
+ );
228
+ }