torch-glare 2.4.4 → 2.5.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.
Files changed (147) hide show
  1. package/apps/lib/components/DataViews/{badgeAdapter.ts → badge.ts} +2 -2
  2. package/apps/lib/components/DataViews/cell.tsx +324 -0
  3. package/apps/lib/components/DataViews/context.ts +144 -0
  4. package/apps/lib/components/DataViews/data-views.tsx +383 -0
  5. package/apps/lib/components/DataViews/filters/children.tsx +98 -0
  6. package/apps/lib/components/DataViews/filters/custom.tsx +34 -0
  7. package/apps/lib/components/DataViews/filters/filters.tsx +163 -0
  8. package/apps/lib/components/DataViews/filters/index.ts +4 -0
  9. package/apps/lib/components/DataViews/filters/labelled.tsx +20 -0
  10. package/apps/lib/components/DataViews/filters/presets.tsx +65 -0
  11. package/apps/lib/components/DataViews/filters/summary.tsx +65 -0
  12. package/apps/lib/components/DataViews/filters/sync.tsx +35 -0
  13. package/apps/lib/components/DataViews/filters/values.ts +173 -0
  14. package/apps/lib/components/DataViews/header.tsx +217 -0
  15. package/apps/lib/components/DataViews/hooks/index.ts +5 -0
  16. package/apps/lib/components/DataViews/hooks/useActiveRow.ts +22 -0
  17. package/apps/lib/components/DataViews/hooks/useControllable.ts +52 -0
  18. package/apps/lib/components/DataViews/index.ts +74 -26
  19. package/apps/lib/components/DataViews/panel/columns.tsx +153 -0
  20. package/apps/lib/components/DataViews/panel/controls.tsx +106 -0
  21. package/apps/lib/components/DataViews/panel/index.ts +3 -0
  22. package/apps/lib/components/DataViews/panel/panel.tsx +164 -0
  23. package/apps/lib/components/DataViews/panel/saved-views.tsx +67 -0
  24. package/apps/lib/components/DataViews/panel/section.tsx +79 -0
  25. package/apps/lib/components/DataViews/panel/sort.tsx +42 -0
  26. package/apps/lib/components/DataViews/panel/tab.tsx +31 -0
  27. package/apps/lib/components/DataViews/slots.ts +63 -0
  28. package/apps/lib/components/DataViews/states.tsx +38 -0
  29. package/apps/lib/components/DataViews/types.ts +485 -178
  30. package/apps/lib/components/DataViews/views/board-view.tsx +379 -0
  31. package/apps/lib/components/DataViews/views/card-rows.tsx +36 -0
  32. package/apps/lib/components/DataViews/views/inbox-view.tsx +257 -0
  33. package/apps/lib/components/DataViews/views/pane-views.tsx +192 -0
  34. package/apps/lib/components/DataViews/views/table-view.tsx +426 -0
  35. package/apps/lib/components/DataViews/views/tree-view.tsx +365 -0
  36. package/apps/lib/components/FormBuilder/context.ts +20 -6
  37. package/apps/lib/components/FormBuilder/field-kind.ts +28 -0
  38. package/apps/lib/components/FormBuilder/fields/DateField.tsx +3 -3
  39. package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +7 -6
  40. package/apps/lib/components/FormBuilder/fields/PhoneField.tsx +30 -4
  41. package/apps/lib/components/FormBuilder/fields/SelectField.tsx +7 -7
  42. package/apps/lib/components/FormBuilder/fields/TableField.tsx +80 -52
  43. package/apps/lib/components/FormBuilder/fields/TextField.tsx +9 -9
  44. package/apps/lib/components/FormBuilder/form-builder.tsx +66 -6
  45. package/apps/lib/components/FormBuilder/index.ts +3 -1
  46. package/apps/lib/components/FormBuilder/types.ts +40 -0
  47. package/apps/lib/components/Input.tsx +3 -0
  48. package/apps/lib/components/SearchableTable.tsx +5 -4
  49. package/apps/lib/components/SectionBlock.tsx +58 -11
  50. package/apps/lib/components/Select.tsx +3 -1
  51. package/apps/lib/components/TabSwitch.tsx +16 -4
  52. package/apps/lib/components/Table.tsx +265 -67
  53. package/apps/lib/components/TreeFolder/TreeFolder.tsx +6 -3
  54. package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +16 -14
  55. package/apps/lib/components/TreeFolder/index.ts +1 -1
  56. package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +70 -207
  57. package/apps/lib/hooks/useDragDrop.tsx +365 -0
  58. package/apps/lib/hooks/useInfiniteScroll.ts +108 -0
  59. package/apps/lib/registry.json +159 -4
  60. package/apps/lib/tsconfig.tsbuildinfo +1 -1
  61. package/apps/lib/utils/dataViews/path.ts +67 -0
  62. package/apps/lib/utils/dataViews/query.ts +73 -0
  63. package/apps/lib/utils/dataViews/types.ts +187 -0
  64. package/docs/components/breadcrumb.md +1 -1
  65. package/docs/components/button-group.md +1 -1
  66. package/docs/components/button.md +1 -1
  67. package/docs/components/card.md +1 -1
  68. package/docs/components/checkbox.md +1 -1
  69. package/docs/components/data-views/backend-response.md +324 -0
  70. package/docs/components/data-views/examples/a11y-rtl.md +250 -0
  71. package/docs/components/data-views/examples/api-orders-route.md +130 -0
  72. package/docs/components/data-views/examples/fields.md +362 -0
  73. package/docs/components/data-views/examples/filters.md +308 -0
  74. package/docs/components/data-views/examples/inbox-routing.md +218 -0
  75. package/docs/components/data-views/examples/index.md +29 -0
  76. package/docs/components/data-views/examples/overview.md +244 -0
  77. package/docs/components/data-views/examples/panel.md +212 -0
  78. package/docs/components/data-views/examples/scale.md +231 -0
  79. package/docs/components/data-views/examples/server-side.md +210 -0
  80. package/docs/components/data-views/examples/state.md +250 -0
  81. package/docs/components/data-views/examples/tree-custom.md +388 -0
  82. package/docs/components/data-views/examples/view-registry.md +313 -0
  83. package/docs/components/data-views/examples/views.md +534 -0
  84. package/docs/components/data-views/guide.md +405 -0
  85. package/docs/components/data-views/index.md +1504 -0
  86. package/docs/components/data-views/migration.md +79 -0
  87. package/docs/components/date-picker.md +0 -1
  88. package/docs/components/form-builder.md +19 -8
  89. package/docs/components/form-renderer.md +2 -1
  90. package/docs/components/form.md +1 -1
  91. package/docs/components/input-field.md +1 -1
  92. package/docs/components/input-otp.md +1 -1
  93. package/docs/components/input.md +1 -1
  94. package/docs/components/labeled-check-box.md +1 -1
  95. package/docs/components/labeled-radio.md +1 -1
  96. package/docs/components/radio-card.md +1 -1
  97. package/docs/components/radio.md +1 -1
  98. package/docs/components/search-field.md +1 -1
  99. package/docs/components/section-block.md +79 -3
  100. package/docs/components/select.md +1 -1
  101. package/docs/components/simple-select.md +1 -1
  102. package/docs/components/switch.md +1 -1
  103. package/docs/components/tab-switch.md +1 -1
  104. package/docs/components/table.md +45 -8
  105. package/docs/components/text-editor.md +1 -1
  106. package/docs/components/textarea.md +1 -1
  107. package/docs/components/toggle-button.md +1 -1
  108. package/docs/components/toggle.md +1 -1
  109. package/docs/components/tree-folder.md +110 -0
  110. package/docs/how-to/forms-with-form-builder.md +6 -4
  111. package/docs/reference/components.md +16 -6
  112. package/docs/tutorials/component-composition.md +11 -13
  113. package/package.json +3 -2
  114. package/apps/lib/components/DataViews/DataViewRadio.tsx +0 -49
  115. package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +0 -393
  116. package/apps/lib/components/DataViews/DataViewsHeader.tsx +0 -207
  117. package/apps/lib/components/DataViews/DataViewsLayout.tsx +0 -332
  118. package/apps/lib/components/DataViews/FilterPanel.tsx +0 -493
  119. package/apps/lib/components/DataViews/HeaderSearch.tsx +0 -93
  120. package/apps/lib/components/DataViews/InboxView.tsx +0 -463
  121. package/apps/lib/components/DataViews/InboxViewCard.tsx +0 -127
  122. package/apps/lib/components/DataViews/KanbanView.tsx +0 -336
  123. package/apps/lib/components/DataViews/PanelControls.tsx +0 -39
  124. package/apps/lib/components/DataViews/SettingsPanel.tsx +0 -279
  125. package/apps/lib/components/DataViews/TableView.tsx +0 -212
  126. package/apps/lib/components/DataViews/TreeView.tsx +0 -364
  127. package/apps/lib/components/DataViews/fieldRenderers.tsx +0 -299
  128. package/apps/lib/components/DataViews/filters/DatePickerRangeFilter.tsx +0 -87
  129. package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +0 -120
  130. package/apps/lib/components/DataViews/filters/PresetChips.tsx +0 -45
  131. package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +0 -165
  132. package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +0 -50
  133. package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +0 -74
  134. package/apps/lib/hooks/useDataViewsState.ts +0 -175
  135. package/apps/lib/utils/dataViews/columnUtils.ts +0 -132
  136. package/apps/lib/utils/dataViews/fieldUtils.ts +0 -197
  137. package/apps/lib/utils/dataViews/nestedDataUtils.tsx +0 -371
  138. package/apps/lib/utils/dataViews/pathUtils.ts +0 -139
  139. package/apps/lib/utils/dataViews/rangeUtils.ts +0 -234
  140. package/apps/lib/utils/dataViews/treeUtils.ts +0 -396
  141. package/docs/components/data-views-config-panel.md +0 -208
  142. package/docs/components/data-views-layout.md +0 -291
  143. package/docs/components/inbox-view.md +0 -170
  144. package/docs/components/kanban-view.md +0 -135
  145. package/docs/components/table-view.md +0 -141
  146. package/docs/components/tree-view.md +0 -147
  147. package/docs/how-to/data-views-from-backend-response.md +0 -194
@@ -0,0 +1,192 @@
1
+ "use client";
2
+
3
+ import React, { createContext, useContext } from "react";
4
+ import { LayoutGrid, Table2 } from "lucide-react";
5
+ import { DataViewCard } from "../../../layouts/DataViewCard";
6
+ import type { FieldConfig } from "../../../utils/dataViews/types";
7
+ import { useDataViewsData } from "../context";
8
+ import { Cell } from "../cell";
9
+ import { buildCardRows } from "./card-rows";
10
+ import { TableView } from "./table-view";
11
+ import type {
12
+ TreePaneCardsProps,
13
+ TreePaneTabProps,
14
+ TreePaneTableProps,
15
+ TreePaneViewBase,
16
+ } from "../types";
17
+
18
+ /**
19
+ * The tree pane's own views, written the way every other part of this component is: **you render
20
+ * them, and that is what makes them exist**.
21
+ *
22
+ * ```tsx
23
+ * <DataViews.Tree nodes={nodes} labelPath="name">
24
+ * <DataViews.Tree.Table selectable renderCell={cell} />
25
+ * <DataViews.Tree.Cards renderCard={card} />
26
+ * <DataViews.Tree.Tab value="timeline" label="Timeline" icon={<Clock />}>
27
+ * <Timeline />
28
+ * </DataViews.Tree.Tab>
29
+ * </DataViews.Tree>
30
+ * ```
31
+ *
32
+ * Render one and the switch disappears, exactly as the header's `ViewSwitch` does with a single
33
+ * view — a switch with one option is a label. Render none and there is no pane at all: the tree
34
+ * is then a hierarchy and nothing else, and it takes the whole width.
35
+ *
36
+ * All three sit inside the pane's data scope, so `useDataViewsData()` in any of them returns the
37
+ * **selected node's** rows rather than the whole set.
38
+ */
39
+
40
+ const PANE_VIEW = "__dvTreePaneView";
41
+
42
+ interface PaneViewMeta {
43
+ defaultValue: string;
44
+ defaultLabel: string;
45
+ defaultIcon?: React.ReactNode;
46
+ }
47
+
48
+ /**
49
+ * The tab's own props, stripped off before the rest reaches the view underneath.
50
+ *
51
+ * `label` and `icon` name the *tab*; `DataViews.Table` reads the same two to name itself in the
52
+ * root's switcher, so leaving them on would register the pane's table up in the header.
53
+ */
54
+ function withoutTabProps<T extends TreePaneViewBase>(props: T) {
55
+ const rest = { ...props };
56
+ delete rest.value;
57
+ delete rest.label;
58
+ delete rest.icon;
59
+ return rest;
60
+ }
61
+
62
+ interface PaneContextValue {
63
+ mode: string;
64
+ /** Which field labels a row — the tree's `labelPath`, already resolved. */
65
+ labelField?: FieldConfig;
66
+ }
67
+
68
+ const PaneContext = createContext<PaneContextValue>({ mode: "table" });
69
+
70
+ export const TreePaneProvider = PaneContext.Provider;
71
+
72
+ function markPaneView<P extends object>(component: React.ComponentType<P>, meta: PaneViewMeta) {
73
+ (component as unknown as Record<string, PaneViewMeta>)[PANE_VIEW] = meta;
74
+ return component;
75
+ }
76
+
77
+ export type PaneViewElement = React.ReactElement<{
78
+ value?: string;
79
+ label?: string;
80
+ icon?: React.ReactNode;
81
+ }>;
82
+
83
+ export function paneViewMetaOf(node: React.ReactNode): PaneViewMeta | undefined {
84
+ if (!React.isValidElement(node)) return undefined;
85
+ return (node.type as unknown as Record<string, PaneViewMeta | undefined>)[PANE_VIEW];
86
+ }
87
+
88
+ export function isPaneViewElement(node: React.ReactNode): node is PaneViewElement {
89
+ return paneViewMetaOf(node) !== undefined;
90
+ }
91
+
92
+ /**
93
+ * Children, with fragments opened out.
94
+ *
95
+ * `React.Children.toArray` stops at a `<>…</>`, so tabs grouped in one — which is what writing
96
+ * them behind a condition produces — would arrive as a single unrecognised child and be read as a
97
+ * whole-pane override. Flattening first is what lets `{cond ? <><Table/><Cards/></> : <Mine/>}`
98
+ * mean what it looks like.
99
+ */
100
+ export function flattenChildren(children: React.ReactNode): React.ReactNode[] {
101
+ return React.Children.toArray(children).flatMap((child) =>
102
+ React.isValidElement(child) && child.type === React.Fragment
103
+ ? flattenChildren((child.props as { children?: React.ReactNode }).children)
104
+ : [child],
105
+ );
106
+ }
107
+
108
+ /** `value`, `label` and `icon` as the switch will show them — defaults filled in. */
109
+ export function paneViewOption(element: PaneViewElement) {
110
+ const meta = paneViewMetaOf(element)!;
111
+ return {
112
+ value: element.props.value ?? meta.defaultValue,
113
+ label: element.props.label ?? meta.defaultLabel,
114
+ icon: element.props.icon ?? meta.defaultIcon,
115
+ };
116
+ }
117
+
118
+ /** True while this element is the pane's selected mode. */
119
+ function useIsActive(element: { value?: string }, defaultValue: string) {
120
+ const { mode } = useContext(PaneContext);
121
+ return mode === (element.value ?? defaultValue);
122
+ }
123
+
124
+ // ─── The three ────────────────────────────────────────────────────────────────
125
+
126
+ /**
127
+ * The pane as a table — the **real `DataViews.Table`**, not a copy, so it keeps sortable headers,
128
+ * selection, `renderCell`, the drag grip, `+ Add New` and virtualization past 300 rows. It reads
129
+ * its rows from the pane's scope, which is what points it at the selected node.
130
+ *
131
+ * One consequence worth knowing: its headers sort by writing to the **query**, which is how the
132
+ * root fetches. Sort the pane's rows in `paneRows` if you want the order self-contained.
133
+ */
134
+ export const PaneTable = markPaneView(function PaneTable(props: TreePaneTableProps) {
135
+ const active = useIsActive({ value: props.value }, "table");
136
+ if (!active) return null;
137
+ return <TableView {...withoutTabProps(props)} />;
138
+ }, { defaultValue: "table", defaultLabel: "List", defaultIcon: <Table2 /> });
139
+
140
+ /**
141
+ * The pane as cards — the same `DataViewCard` the board paints, so a row looks the same in both.
142
+ * `renderCard` replaces it outright with markup of your own.
143
+ */
144
+ export const PaneCards = markPaneView(function PaneCards({
145
+ value,
146
+ renderCard,
147
+ className,
148
+ }: TreePaneCardsProps) {
149
+ const { labelField } = useContext(PaneContext);
150
+ const { rows, visibleFields, getRowId } = useDataViewsData();
151
+ const active = useIsActive({ value }, "cards");
152
+ if (!active) return null;
153
+
154
+ return (
155
+ <div
156
+ className={
157
+ className ??
158
+ "grid grid-cols-1 content-start gap-3 p-4 sm:grid-cols-2 lg:grid-cols-3"
159
+ }
160
+ >
161
+ {rows.map((row, index) => {
162
+ const id = getRowId(row, index);
163
+ return (
164
+ <React.Fragment key={id}>
165
+ {renderCard ? (
166
+ renderCard({ row, id, index, fields: visibleFields })
167
+ ) : (
168
+ <DataViewCard
169
+ title={labelField ? <Cell field={labelField} row={row} /> : undefined}
170
+ rows={buildCardRows(
171
+ visibleFields.filter((f) => f.path !== labelField?.path),
172
+ row,
173
+ )}
174
+ />
175
+ )}
176
+ </React.Fragment>
177
+ );
178
+ })}
179
+ </div>
180
+ );
181
+ }, { defaultValue: "cards", defaultLabel: "Cards", defaultIcon: <LayoutGrid /> });
182
+
183
+ /**
184
+ * A mode of your own. Unlike the two above it has no default content — its `children` are the
185
+ * pane while it is selected, and nothing while it is not.
186
+ *
187
+ * `value` is required here: there is no sensible default name for a view only you know about.
188
+ */
189
+ export const PaneTab = markPaneView(function PaneTab({ value, children }: TreePaneTabProps) {
190
+ if (!useIsActive({ value }, value)) return null;
191
+ return <>{children}</>;
192
+ }, { defaultValue: "", defaultLabel: "Tab" });
@@ -0,0 +1,426 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { Plus } from "lucide-react";
5
+ import { useVirtualizer } from "@tanstack/react-virtual";
6
+ import { cn } from "../../../utils/cn";
7
+ import { formatPathLabel } from "../../../utils/dataViews/path";
8
+ import type { Sort } from "../../../utils/dataViews/types";
9
+ import { Checkbox } from "../../Checkbox";
10
+ import {
11
+ Table,
12
+ TableBody,
13
+ TableCell,
14
+ TableEndAction,
15
+ TableHead,
16
+ TableHeader,
17
+ TableRow,
18
+ } from "../../Table";
19
+ import { DragList, useDragDrop, useDragItem } from "../../../hooks/useDragDrop";
20
+ import { Cell } from "../cell";
21
+ import { SkeletonBar, skeletonKeys } from "../states";
22
+ import { useInfiniteScroll } from "../../../hooks/useInfiniteScroll";
23
+ import { useDataViewsData, useDataViewsView } from "../context";
24
+ import { markView } from "../slots";
25
+ import type { TableViewProps } from "../types";
26
+
27
+ /**
28
+ * `DataViews.Table` — rows and columns.
29
+ *
30
+ * Built on the shared `Table` component rather than a bare `<table>`, which is where the 44px
31
+ * header band, the 50px rows, the cell grid, the right-edge fade and the drag-resize handles all
32
+ * come from. Rebuilding those by hand is how this view drifts away from every other table in the
33
+ * product.
34
+ *
35
+ * Columns come from `visibleFields`, which the root resolved once from `fields` + `columns`. The
36
+ * header cells are sort *buttons*: clicking one puts a `sort` in the query and nothing else. The table
37
+ * paints `rows` in the order you handed them over, always — if the arrow points down and the rows
38
+ * are not descending, that is your query's business, not the table's.
39
+ */
40
+ function TableViewImpl({
41
+ selectable = false,
42
+ onRowClick,
43
+ onRowMove,
44
+ onAddRow,
45
+ addRowLabel = "Add New",
46
+ renderCell,
47
+ className,
48
+ }: TableViewProps) {
49
+ const { rows, visibleFields, getRowId, loading, loadingMore, hasMore, onLoadMore } =
50
+ useDataViewsData();
51
+ const { sort, setSort, selection, setSelection } = useDataViewsView();
52
+
53
+ const ids = rows.map((row, i) => getRowId(row, i));
54
+
55
+ const { DndContext, contextProps, activeId: activeDragId } = useDragDrop({
56
+ mode: "list",
57
+ disabled: !onRowMove,
58
+ onMove: ({ id, to }) => {
59
+ const index = ids.indexOf(String(to));
60
+ if (index < 0) return;
61
+ onRowMove?.({ id, from: null, to: null, index });
62
+ },
63
+ });
64
+ const allSelected = ids.length > 0 && ids.every((id) => selection.includes(id));
65
+ const someSelected = !allSelected && ids.some((id) => selection.includes(id));
66
+
67
+ const toggleRow = (id: string) =>
68
+ setSelection(selection.includes(id) ? selection.filter((s) => s !== id) : [...selection, id]);
69
+
70
+ const toggleAll = () => setSelection(allSelected ? [] : ids);
71
+
72
+ // asc → desc → unsorted, so a third click gets you back to the server's natural order.
73
+ const cycle = (path: string): Sort => {
74
+ if (sort?.path !== path) return { path, direction: "asc" };
75
+ return sort.direction === "asc" ? { path, direction: "desc" } : null;
76
+ };
77
+
78
+ // ── Virtualization ────────────────────────────────────────────────────────
79
+ //
80
+ // A mode the table *enters*, not how it always renders. Below the threshold this whole block is
81
+ // inert and the markup below is exactly what it has always been — which is what keeps the row
82
+ // drag, the column resize and every existing example untouched.
83
+ const scrollRef = React.useRef<HTMLDivElement | null>(null);
84
+ const tableRef = React.useRef<HTMLTableElement | null>(null);
85
+ const virtualize = rows.length > VIRTUALIZE_AFTER;
86
+
87
+ const virtualizer = useVirtualizer({
88
+ count: rows.length,
89
+ getScrollElement: () => scrollRef.current,
90
+ // Exact, not an estimate: rows are 40px by design (Figma's `Table-RowBackgroand-1.1`), so
91
+ // there is nothing to measure and no correction pass.
92
+ estimateSize: () => ROW_HEIGHT,
93
+ overscan: 8,
94
+ enabled: virtualize,
95
+ });
96
+
97
+ usePinnedColumnWidths(tableRef, virtualize);
98
+
99
+ const virtualItems = virtualizer.getVirtualItems();
100
+ const padTop = virtualItems.length ? virtualItems[0].start : 0;
101
+ const padBottom = virtualItems.length
102
+ ? virtualizer.getTotalSize() - virtualItems[virtualItems.length - 1].end
103
+ : 0;
104
+
105
+ // Which rows to actually render. The dragged row is always included even when it has scrolled
106
+ // out of the window: dnd-kit tracks a live node, and unmounting it mid-drag drops the drag.
107
+ const windowIndexes = virtualize ? virtualItems.map((v) => v.index) : rows.map((_, i) => i);
108
+ const activeIndex = activeDragId ? ids.indexOf(activeDragId) : -1;
109
+ const renderIndexes =
110
+ activeIndex >= 0 && !windowIndexes.includes(activeIndex)
111
+ ? [...windowIndexes, activeIndex].sort((a, b) => a - b)
112
+ : windowIndexes;
113
+
114
+ const { sentinelRef } = useInfiniteScroll({ onLoadMore, hasMore, loading: loading || loadingMore });
115
+
116
+ return (
117
+ <DndContext {...contextProps}>
118
+ <div className={cn("bg-background-presentation-form-base flex h-full", className)}>
119
+ <div className="flex flex-1 flex-col gap-4 overflow-hidden">
120
+ <div
121
+ ref={scrollRef}
122
+ // `min-w-0` so the scroller can be narrower than the table inside it. Without it a flex
123
+ // item refuses to shrink below its content, so pinning the table to its natural width
124
+ // pushes the whole component wide instead of scrolling within it.
125
+ className="min-w-0 flex-1 overflow-auto rounded-lg"
126
+ >
127
+ <Table ref={tableRef} className="w-full">
128
+ <TableHeader>
129
+ <TableRow>
130
+ {onRowMove && <TableHead isDummy className="w-8" />}
131
+ {selectable && (
132
+ <TableHead isDummy className="w-12">
133
+ <Checkbox
134
+ checked={allSelected ? true : someSelected ? "indeterminate" : false}
135
+ onCheckedChange={toggleAll}
136
+ aria-label="Select all rows"
137
+ />
138
+ </TableHead>
139
+ )}
140
+ {/* Keyed by position: two fields may share a `path` — the same value shown two
141
+ ways — and keying on it would collide. */}
142
+ {visibleFields.map((field, i) => (
143
+ <TableHead
144
+ key={`${field.path}-${i}`}
145
+ size="M"
146
+ sortType={sort?.path === field.path ? sort.direction : undefined}
147
+ onSort={() => setSort(cycle(field.path))}
148
+ // Without this every sort control announces itself as a bare "Sort ascending",
149
+ // so a screen reader hears one identical button per column.
150
+ sortLabel={field.label ?? formatPathLabel(field.path)}
151
+ >
152
+ {field.label ?? field.path}
153
+ </TableHead>
154
+ ))}
155
+ </TableRow>
156
+ </TableHeader>
157
+
158
+ <TableBody>
159
+ {loading && <SkeletonRows fields={visibleFields} grip={Boolean(onRowMove)} selectable={selectable} />}
160
+ {virtualize && padTop > 0 && (
161
+ <tr aria-hidden style={{ height: padTop }} />
162
+ )}
163
+ <DragList ids={ids}>
164
+ {renderIndexes.map((index) => {
165
+ const row = rows[index];
166
+ const id = ids[index];
167
+ const selected = selection.includes(id);
168
+ return (
169
+ <DraggableRow
170
+ key={id}
171
+ id={id}
172
+ draggable={Boolean(onRowMove)}
173
+ state={selected ? "selected" : undefined}
174
+ onClick={onRowClick ? () => onRowClick(row, id) : undefined}
175
+ // A clickable row has to be reachable without a mouse. `<tr>` carries no
176
+ // implicit role, so a bare `onClick` is invisible to the keyboard and to a
177
+ // screen reader; these only appear when the row is actually interactive.
178
+ tabIndex={onRowClick ? 0 : undefined}
179
+ role={onRowClick ? "button" : undefined}
180
+ onKeyDown={
181
+ onRowClick
182
+ ? (e) => {
183
+ if (e.key !== "Enter" && e.key !== " ") return;
184
+ if (e.target !== e.currentTarget) return;
185
+ e.preventDefault();
186
+ onRowClick(row, id);
187
+ }
188
+ : undefined
189
+ }
190
+ className={cn(
191
+ // 40px per Figma's `Table-RowBackgroand-1.1`. `Table`'s body cells default
192
+ // to 50, and the two live on different elements, so the cells below must be
193
+ // told as well or CSS keeps the larger.
194
+ "h-[40px]",
195
+ "focus-visible:ring-border-presentation-state-focus outline-none focus-visible:ring-2",
196
+ onRowClick && "cursor-pointer",
197
+ )}
198
+ >
199
+ {onRowMove && <GripCell />}
200
+ {selectable && (
201
+ <TableCell isDummy className="h-[40px] w-12" onClick={(e) => e.stopPropagation()}>
202
+ {/* What `TableCheckbox` renders, inlined: its props are typed as button
203
+ attributes, so it cannot express a controlled checkbox. */}
204
+ <div className="flex items-center justify-center">
205
+ <Checkbox
206
+ size="S"
207
+ checked={selected}
208
+ onCheckedChange={() => toggleRow(id)}
209
+ aria-label="Select row"
210
+ />
211
+ </div>
212
+ </TableCell>
213
+ )}
214
+ {visibleFields.map((field, i) => {
215
+ // `undefined` means "you paint it" — distinct from `null`, which is a
216
+ // deliberate blank.
217
+ const custom = renderCell?.({ field, row, id, index, fields: visibleFields });
218
+ return (
219
+ <TableCell key={`${field.path}-${i}`} className="h-[40px]">
220
+ {/* `isolate` confines the Badge's mix-blend-luminosity to a local
221
+ stacking context and `transform-gpu` promotes it to its own layer, so
222
+ the table's post-mount column reflow repaints cleanly instead of
223
+ leaving a ghosted badge frame. */}
224
+ <span className="isolate inline-flex transform-gpu">
225
+ {custom === undefined ? <Cell field={field} row={row} /> : custom}
226
+ </span>
227
+ </TableCell>
228
+ );
229
+ })}
230
+ </DraggableRow>
231
+ );
232
+ })}
233
+ </DragList>
234
+ {virtualize && padBottom > 0 && (
235
+ <tr aria-hidden style={{ height: padBottom }} />
236
+ )}
237
+ {/* The trigger. A row of its own rather than an element after the table, so it sits
238
+ inside the same scroller the rows do. */}
239
+ {hasMore && !loading && (
240
+ <tr ref={sentinelRef as React.Ref<HTMLTableRowElement>} aria-hidden>
241
+ <td style={{ height: 1, padding: 0, border: 0 }} colSpan={100} />
242
+ </tr>
243
+ )}
244
+ {loadingMore && (
245
+ <SkeletonRows
246
+ rows={2}
247
+ fields={visibleFields}
248
+ grip={Boolean(onRowMove)}
249
+ selectable={selectable}
250
+ />
251
+ )}
252
+ </TableBody>
253
+ </Table>
254
+ {onAddRow && (
255
+ <TableEndAction onClick={onAddRow}>
256
+ <Plus className="h-4 w-4" aria-hidden />
257
+ {addRowLabel}
258
+ </TableEndAction>
259
+ )}
260
+ </div>
261
+ </div>
262
+ </div>
263
+ </DndContext>
264
+ );
265
+ }
266
+
267
+ /**
268
+ * A row that can be picked up.
269
+ *
270
+ * `TableRow` forwards its ref, so the `<tr>` itself is the draggable — no wrapper element, which
271
+ * a table would not accept between `<tbody>` and `<tr>` anyway.
272
+ *
273
+ * The drag listeners go on the grip, not the row: a row is clickable, and a row that is also a
274
+ * drag handle makes selecting text or hitting a button inside it a gamble.
275
+ */
276
+ function DraggableRow({
277
+ id,
278
+ draggable,
279
+ children,
280
+ ...props
281
+ }: React.ComponentProps<typeof TableRow> & { id: string; draggable: boolean }) {
282
+ const { ref, handleProps, style, isDragging } = useDragItem(id, !draggable);
283
+
284
+ return (
285
+ <DragHandleContext.Provider value={draggable ? handleProps : null}>
286
+ <TableRow
287
+ ref={ref}
288
+ style={style}
289
+ {...props}
290
+ className={cn(props.className, isDragging && "opacity-40")}
291
+ >
292
+ {children}
293
+ </TableRow>
294
+ </DragHandleContext.Provider>
295
+ );
296
+ }
297
+
298
+ /** The listeners the grip needs, handed down from the row that owns them. */
299
+ const DragHandleContext = React.createContext<Record<string, unknown> | null>(null);
300
+
301
+ /** How many shimmer rows to lay down. Enough to fill the fold — not a guess at what is coming. */
302
+ const SKELETON_ROWS = 8;
303
+
304
+ /** Figma's `Table-RowBackgroand-1.1`. Exact, which is what makes virtualization measurement-free. */
305
+ const ROW_HEIGHT = 50;
306
+
307
+ /**
308
+ * Below this many rows the table renders every one of them, exactly as it always has.
309
+ *
310
+ * Virtualization is not free here: the table is `table-layout: auto` with a 200px per-cell
311
+ * minimum, so column widths are computed from whatever rows are currently mounted. Rendering only
312
+ * a window of them makes the widths change as you scroll — unless they are pinned first, which is
313
+ * what `usePinnedColumnWidths` does. None of that is worth doing to a table of 40 rows.
314
+ */
315
+ const VIRTUALIZE_AFTER = 300;
316
+
317
+ /**
318
+ * Freeze the column widths before the rows start disappearing.
319
+ *
320
+ * The order matters and is the whole difficulty. `table-layout: auto` sizes columns from the rows
321
+ * that are *mounted*, so by the time virtualization has swapped thousands of rows for a window of
322
+ * thirty, the widths have already collapsed to fit that window — measuring then pins the collapsed
323
+ * numbers, and the table ends up about half its width with content spilling between columns. So
324
+ * the widths are remembered on every render while the table is still whole, and those remembered
325
+ * numbers are what get pinned at the moment it stops being whole.
326
+ *
327
+ * The table's own width is pinned too: under `fixed` the column widths are read as proportions of
328
+ * it, and the table is `w-full`, so leaving it alone squeezes every column into the container.
329
+ */
330
+ function usePinnedColumnWidths(
331
+ tableRef: React.RefObject<HTMLTableElement | null>,
332
+ virtualize: boolean,
333
+ ) {
334
+ const naturalWidths = React.useRef<number[] | null>(null);
335
+ const pinned = React.useRef(false);
336
+
337
+ // Deliberately no dependency array: the widths have to be re-read as rows accumulate, right up
338
+ // to the render before the threshold is crossed.
339
+ React.useLayoutEffect(() => {
340
+ const table = tableRef.current;
341
+ if (!table) return;
342
+ const heads = [...table.querySelectorAll("thead th")] as HTMLTableCellElement[];
343
+
344
+ if (!virtualize) {
345
+ naturalWidths.current = heads.map((th) => th.getBoundingClientRect().width);
346
+ if (pinned.current) {
347
+ table.style.tableLayout = "";
348
+ table.style.removeProperty("width");
349
+ heads.forEach((th) => th.style.removeProperty("width"));
350
+ pinned.current = false;
351
+ }
352
+ return;
353
+ }
354
+
355
+ if (pinned.current) return;
356
+ const widths = naturalWidths.current;
357
+ if (!widths || widths.length !== heads.length) return;
358
+ heads.forEach((th, i) => {
359
+ th.style.width = `${widths[i]}px`;
360
+ });
361
+ table.style.width = `${widths.reduce((sum, w) => sum + w, 0)}px`;
362
+ table.style.tableLayout = "fixed";
363
+ pinned.current = true;
364
+ });
365
+ }
366
+
367
+ /**
368
+ * The loading table.
369
+ *
370
+ * Built from the same `TableRow`/`TableCell` a real row uses, with one cell per visible field, so
371
+ * the columns sit at exactly the widths the data will land in and nothing shifts when it does.
372
+ * The header above is untouched — it comes from `visibleFields`, not from `rows`.
373
+ */
374
+ function SkeletonRows({
375
+ fields,
376
+ grip,
377
+ selectable,
378
+ rows = SKELETON_ROWS,
379
+ }: {
380
+ fields: readonly { path: string }[];
381
+ grip: boolean;
382
+ selectable: boolean;
383
+ rows?: number;
384
+ }) {
385
+ return (
386
+ <>
387
+ {skeletonKeys(rows).map((row) => (
388
+ <TableRow key={`skeleton-${row}`} className="h-[40px]">
389
+ {grip && <TableCell isDummy className="h-[40px] w-8" />}
390
+ {selectable && <TableCell isDummy className="h-[40px] w-12" />}
391
+ {fields.map((field, i) => (
392
+ <TableCell key={`${field.path}-${i}`} className="h-[40px]">
393
+ {/* Widths cycle rather than repeat, so a column of bars reads as text that has not
394
+ arrived instead of as a grid. */}
395
+ <SkeletonBar className={["w-[70%]", "w-[45%]", "w-[85%]", "w-[60%]"][(row + i) % 4]} />
396
+ </TableCell>
397
+ ))}
398
+ </TableRow>
399
+ ))}
400
+ </>
401
+ );
402
+ }
403
+
404
+ /** The 2×3 grip. Only rendered when the table is reorderable. */
405
+ function GripCell() {
406
+ const handleProps = React.useContext(DragHandleContext);
407
+ return (
408
+ <TableCell isDummy className="h-[40px] w-8" onClick={(e) => e.stopPropagation()}>
409
+ <span
410
+ {...handleProps}
411
+ aria-label="Reorder row"
412
+ className={cn(
413
+ "text-content-presentation-global-tertiary flex cursor-grab items-center justify-center",
414
+ "active:cursor-grabbing",
415
+ )}
416
+ >
417
+ <i className="ri-draggable text-[16px]" aria-hidden />
418
+ </span>
419
+ </TableCell>
420
+ );
421
+ }
422
+
423
+ export const TableView = markView(TableViewImpl, {
424
+ defaultId: "table",
425
+ defaultLabel: "List",
426
+ });