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.
- package/apps/lib/components/DataViews/{badgeAdapter.ts → badge.ts} +2 -2
- package/apps/lib/components/DataViews/cell.tsx +324 -0
- package/apps/lib/components/DataViews/context.ts +144 -0
- package/apps/lib/components/DataViews/data-views.tsx +383 -0
- package/apps/lib/components/DataViews/filters/children.tsx +98 -0
- package/apps/lib/components/DataViews/filters/custom.tsx +34 -0
- package/apps/lib/components/DataViews/filters/filters.tsx +163 -0
- package/apps/lib/components/DataViews/filters/index.ts +4 -0
- package/apps/lib/components/DataViews/filters/labelled.tsx +20 -0
- package/apps/lib/components/DataViews/filters/presets.tsx +65 -0
- package/apps/lib/components/DataViews/filters/summary.tsx +65 -0
- package/apps/lib/components/DataViews/filters/sync.tsx +35 -0
- package/apps/lib/components/DataViews/filters/values.ts +173 -0
- package/apps/lib/components/DataViews/header.tsx +217 -0
- package/apps/lib/components/DataViews/hooks/index.ts +5 -0
- package/apps/lib/components/DataViews/hooks/useActiveRow.ts +22 -0
- package/apps/lib/components/DataViews/hooks/useControllable.ts +52 -0
- package/apps/lib/components/DataViews/index.ts +74 -26
- package/apps/lib/components/DataViews/panel/columns.tsx +153 -0
- package/apps/lib/components/DataViews/panel/controls.tsx +106 -0
- package/apps/lib/components/DataViews/panel/index.ts +3 -0
- package/apps/lib/components/DataViews/panel/panel.tsx +164 -0
- package/apps/lib/components/DataViews/panel/saved-views.tsx +67 -0
- package/apps/lib/components/DataViews/panel/section.tsx +79 -0
- package/apps/lib/components/DataViews/panel/sort.tsx +42 -0
- package/apps/lib/components/DataViews/panel/tab.tsx +31 -0
- package/apps/lib/components/DataViews/slots.ts +63 -0
- package/apps/lib/components/DataViews/states.tsx +38 -0
- package/apps/lib/components/DataViews/types.ts +485 -178
- package/apps/lib/components/DataViews/views/board-view.tsx +379 -0
- package/apps/lib/components/DataViews/views/card-rows.tsx +36 -0
- package/apps/lib/components/DataViews/views/inbox-view.tsx +257 -0
- package/apps/lib/components/DataViews/views/pane-views.tsx +192 -0
- package/apps/lib/components/DataViews/views/table-view.tsx +426 -0
- package/apps/lib/components/DataViews/views/tree-view.tsx +365 -0
- package/apps/lib/components/FormBuilder/context.ts +20 -6
- package/apps/lib/components/FormBuilder/field-kind.ts +28 -0
- package/apps/lib/components/FormBuilder/fields/DateField.tsx +3 -3
- package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +7 -6
- package/apps/lib/components/FormBuilder/fields/PhoneField.tsx +30 -4
- package/apps/lib/components/FormBuilder/fields/SelectField.tsx +7 -7
- package/apps/lib/components/FormBuilder/fields/TableField.tsx +80 -52
- package/apps/lib/components/FormBuilder/fields/TextField.tsx +9 -9
- package/apps/lib/components/FormBuilder/form-builder.tsx +66 -6
- package/apps/lib/components/FormBuilder/index.ts +3 -1
- package/apps/lib/components/FormBuilder/types.ts +40 -0
- package/apps/lib/components/Input.tsx +3 -0
- package/apps/lib/components/SearchableTable.tsx +5 -4
- package/apps/lib/components/SectionBlock.tsx +58 -11
- package/apps/lib/components/Select.tsx +3 -1
- package/apps/lib/components/TabSwitch.tsx +16 -4
- package/apps/lib/components/Table.tsx +265 -67
- package/apps/lib/components/TreeFolder/TreeFolder.tsx +6 -3
- package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +16 -14
- package/apps/lib/components/TreeFolder/index.ts +1 -1
- package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +70 -207
- package/apps/lib/hooks/useDragDrop.tsx +365 -0
- package/apps/lib/hooks/useInfiniteScroll.ts +108 -0
- package/apps/lib/registry.json +159 -4
- package/apps/lib/tsconfig.tsbuildinfo +1 -1
- package/apps/lib/utils/dataViews/path.ts +67 -0
- package/apps/lib/utils/dataViews/query.ts +73 -0
- package/apps/lib/utils/dataViews/types.ts +187 -0
- package/docs/components/breadcrumb.md +1 -1
- package/docs/components/button-group.md +1 -1
- package/docs/components/button.md +1 -1
- package/docs/components/card.md +1 -1
- package/docs/components/checkbox.md +1 -1
- package/docs/components/data-views/backend-response.md +324 -0
- package/docs/components/data-views/examples/a11y-rtl.md +250 -0
- package/docs/components/data-views/examples/api-orders-route.md +130 -0
- package/docs/components/data-views/examples/fields.md +362 -0
- package/docs/components/data-views/examples/filters.md +308 -0
- package/docs/components/data-views/examples/inbox-routing.md +218 -0
- package/docs/components/data-views/examples/index.md +29 -0
- package/docs/components/data-views/examples/overview.md +244 -0
- package/docs/components/data-views/examples/panel.md +212 -0
- package/docs/components/data-views/examples/scale.md +231 -0
- package/docs/components/data-views/examples/server-side.md +210 -0
- package/docs/components/data-views/examples/state.md +250 -0
- package/docs/components/data-views/examples/tree-custom.md +388 -0
- package/docs/components/data-views/examples/view-registry.md +313 -0
- package/docs/components/data-views/examples/views.md +534 -0
- package/docs/components/data-views/guide.md +405 -0
- package/docs/components/data-views/index.md +1504 -0
- package/docs/components/data-views/migration.md +79 -0
- package/docs/components/date-picker.md +0 -1
- package/docs/components/form-builder.md +19 -8
- package/docs/components/form-renderer.md +2 -1
- package/docs/components/form.md +1 -1
- package/docs/components/input-field.md +1 -1
- package/docs/components/input-otp.md +1 -1
- package/docs/components/input.md +1 -1
- package/docs/components/labeled-check-box.md +1 -1
- package/docs/components/labeled-radio.md +1 -1
- package/docs/components/radio-card.md +1 -1
- package/docs/components/radio.md +1 -1
- package/docs/components/search-field.md +1 -1
- package/docs/components/section-block.md +79 -3
- package/docs/components/select.md +1 -1
- package/docs/components/simple-select.md +1 -1
- package/docs/components/switch.md +1 -1
- package/docs/components/tab-switch.md +1 -1
- package/docs/components/table.md +45 -8
- package/docs/components/text-editor.md +1 -1
- package/docs/components/textarea.md +1 -1
- package/docs/components/toggle-button.md +1 -1
- package/docs/components/toggle.md +1 -1
- package/docs/components/tree-folder.md +110 -0
- package/docs/how-to/forms-with-form-builder.md +6 -4
- package/docs/reference/components.md +16 -6
- package/docs/tutorials/component-composition.md +11 -13
- package/package.json +3 -2
- package/apps/lib/components/DataViews/DataViewRadio.tsx +0 -49
- package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +0 -393
- package/apps/lib/components/DataViews/DataViewsHeader.tsx +0 -207
- package/apps/lib/components/DataViews/DataViewsLayout.tsx +0 -332
- package/apps/lib/components/DataViews/FilterPanel.tsx +0 -493
- package/apps/lib/components/DataViews/HeaderSearch.tsx +0 -93
- package/apps/lib/components/DataViews/InboxView.tsx +0 -463
- package/apps/lib/components/DataViews/InboxViewCard.tsx +0 -127
- package/apps/lib/components/DataViews/KanbanView.tsx +0 -336
- package/apps/lib/components/DataViews/PanelControls.tsx +0 -39
- package/apps/lib/components/DataViews/SettingsPanel.tsx +0 -279
- package/apps/lib/components/DataViews/TableView.tsx +0 -212
- package/apps/lib/components/DataViews/TreeView.tsx +0 -364
- package/apps/lib/components/DataViews/fieldRenderers.tsx +0 -299
- package/apps/lib/components/DataViews/filters/DatePickerRangeFilter.tsx +0 -87
- package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +0 -120
- package/apps/lib/components/DataViews/filters/PresetChips.tsx +0 -45
- package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +0 -165
- package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +0 -50
- package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +0 -74
- package/apps/lib/hooks/useDataViewsState.ts +0 -175
- package/apps/lib/utils/dataViews/columnUtils.ts +0 -132
- package/apps/lib/utils/dataViews/fieldUtils.ts +0 -197
- package/apps/lib/utils/dataViews/nestedDataUtils.tsx +0 -371
- package/apps/lib/utils/dataViews/pathUtils.ts +0 -139
- package/apps/lib/utils/dataViews/rangeUtils.ts +0 -234
- package/apps/lib/utils/dataViews/treeUtils.ts +0 -396
- package/docs/components/data-views-config-panel.md +0 -208
- package/docs/components/data-views-layout.md +0 -291
- package/docs/components/inbox-view.md +0 -170
- package/docs/components/kanban-view.md +0 -135
- package/docs/components/table-view.md +0 -141
- package/docs/components/tree-view.md +0 -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
|
+
});
|