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
@@ -48,7 +48,7 @@ const trackStyles = cva(
48
48
  // transparent and lighten on hover.
49
49
  const optionStyles = cva(
50
50
  [
51
- "flex items-center justify-center gap-[6px]",
51
+ "flex items-center justify-center gap-[4px]",
52
52
  "rounded-[8px]",
53
53
  "font-[510] leading-none",
54
54
  "transition-all duration-200 ease-in-out",
@@ -74,8 +74,11 @@ const optionStyles = cva(
74
74
  true: [
75
75
  "bg-white",
76
76
  "text-[#1C1D1F]",
77
+ // The border is not in the design. It stays because its transparent twin below is what
78
+ // keeps both states the same width — without it, selecting a tab resizes the pill and
79
+ // shoves its neighbours. The design's own 4px gap absorbs the 1px.
77
80
  "border border-black/5",
78
- "shadow-[0_1px_3px_0_rgba(0,0,0,0.18)]",
81
+ "drop-shadow-[0px_0px_5px_rgba(0,0,0,0.25)]",
79
82
  ],
80
83
  false: [
81
84
  "border border-transparent",
@@ -121,8 +124,17 @@ function TabSwitchInner<T extends string = string>(
121
124
 
122
125
  return (
123
126
  <div key={option.value} className="flex items-center">
124
- {showDivider && (
125
- <div className="mx-[3px] h-3 w-px bg-border-presentation-action-disabled" />
127
+ {idx > 0 && (
128
+ <div
129
+ aria-hidden
130
+ className={cn(
131
+ // The slot is always here, and only its ink changes. Unmounting it instead cost
132
+ // the track 7px every time the rule below hid one, so picking a tab resized the
133
+ // track and shoved its neighbours sideways.
134
+ "mx-[3px] h-3 w-px",
135
+ showDivider ? "bg-border-presentation-action-disabled" : "bg-transparent",
136
+ )}
137
+ />
126
138
  )}
127
139
  <button
128
140
  type="button"
@@ -32,7 +32,14 @@ const TableHeader = React.forwardRef<
32
32
  >(({ className, ...props }, ref) => (
33
33
  <thead
34
34
  ref={ref}
35
- className={cn("shadow-[0px_4px_8px_0px_rgba(0,0,0,0.15)]", className)}
35
+ // The header band is one continuous bar, not a per-cell background — a background on each
36
+ // `<th>` would paint over the half of the previous column's resize handle that overhangs
37
+ // the boundary.
38
+ className={cn(
39
+ "bg-background-presentation-form-header backdrop-blur-[8px]",
40
+ "shadow-[0px_4px_8px_0px_rgba(0,0,0,0.15)]",
41
+ className,
42
+ )}
36
43
  {...props}
37
44
  />
38
45
  ));
@@ -95,6 +102,9 @@ const TableRow = React.forwardRef<
95
102
  ));
96
103
  TableRow.displayName = "TableRow";
97
104
 
105
+ /** Floor for a drag-resized column, so dragging past the column's own left edge can't invert it. */
106
+ const MIN_COLUMN_WIDTH = 40;
107
+
98
108
  const TableHead = React.forwardRef<
99
109
  HTMLTableCellElement,
100
110
  React.ThHTMLAttributes<HTMLTableCellElement> &
@@ -102,57 +112,133 @@ const TableHead = React.forwardRef<
102
112
  React.ButtonHTMLAttributes<HTMLButtonElement> & {
103
113
  sortType?: "asc" | "desc" | undefined;
104
114
  onSort?: () => void;
115
+ /** Column name for the sort button's accessible label. */
116
+ sortLabel?: string;
117
+ /**
118
+ * Called with the new width (px) while the column is being drag-resized. Pass it to take
119
+ * **control** of the width — the header then renders `style.width` and expects you to
120
+ * feed the new value back. Required whenever the table needs a definite width
121
+ * (`table-layout: fixed`), because only the owner of every column width can total them.
122
+ * Omit for uncontrolled resizing, where the header keeps the width itself.
123
+ */
124
+ onResize?: (width: number) => void;
125
+ /**
126
+ * Classes for the inner layout box (the flex row holding the label and sort toggle).
127
+ * `className` and every other prop go to the `<th>` — reach for this only when you need
128
+ * to restyle the content box itself, e.g. its typography or colour.
129
+ */
130
+ contentClassName?: string;
105
131
  isDummy?: boolean;
106
132
  }
107
- >(({ className, size = "M", disabled, sortType, onSort, isDummy, ...props }, forwardedRef) => {
108
- const headRef = useRef<HTMLTableCellElement>(null);
109
- const { width, handleStartResize } = useResize(headRef as React.RefObject<HTMLElement>);
133
+ >(
134
+ (
135
+ {
136
+ className,
137
+ contentClassName,
138
+ children,
139
+ style,
140
+ size = "M",
141
+ disabled,
142
+ sortType,
143
+ onSort,
144
+ sortLabel,
145
+ onResize,
146
+ isDummy,
147
+ ...props
148
+ },
149
+ forwardedRef,
150
+ ) => {
151
+ const headRef = useRef<HTMLTableCellElement>(null);
152
+ const { width, handleStartResize } = useResize(headRef as React.RefObject<HTMLElement>);
110
153
 
111
- // Combine refs using useEffect
112
- React.useEffect(() => {
113
- if (!forwardedRef) return;
114
- if (typeof forwardedRef === "function") forwardedRef(headRef.current);
115
- else forwardedRef.current = headRef.current;
116
- }, [forwardedRef]);
154
+ const clampedWidth = width === undefined ? undefined : Math.max(width, MIN_COLUMN_WIDTH);
117
155
 
118
- return (
119
- <th
120
- ref={headRef}
121
- className={cn(
122
- "relative py-[6px] px-[4px] border-b-[2px] border-border-presentation-table-header",
123
- )}
124
- >
125
- <div
156
+ React.useEffect(() => {
157
+ if (clampedWidth !== undefined) onResize?.(clampedWidth);
158
+ // `onResize` is intentionally not a dep — callers pass an inline closure, and re-firing
159
+ // on every render would loop.
160
+ // eslint-disable-next-line react-hooks/exhaustive-deps
161
+ }, [clampedWidth]);
162
+
163
+ // Combine refs using useEffect
164
+ React.useEffect(() => {
165
+ if (!forwardedRef) return;
166
+ if (typeof forwardedRef === "function") forwardedRef(headRef.current);
167
+ else forwardedRef.current = headRef.current;
168
+ }, [forwardedRef]);
169
+
170
+ // The width has to land on the `<th>` — that is the element the browser sizes the column
171
+ // from. Written to an inner div (as it once was) it did nothing at all. When `onResize` is
172
+ // given the caller owns the value and feeds it back via `style.width`; otherwise the drag
173
+ // result is applied here directly.
174
+ const resolvedWidth = onResize ? style?.width : (clampedWidth ?? style?.width);
175
+
176
+ return (
177
+ <th
178
+ ref={headRef}
179
+ // EVERY caller prop lands here: `className`, `style`, `aria-*` and event handlers
180
+ // alike. They have to share one element — dnd-kit's `useSortable` returns `attributes`
181
+ // (role/tabIndex/aria-*) and `listeners` (onKeyDown/onPointerDown) as a matched pair,
182
+ // and splitting them across the <th> and the inner div left the focusable node with no
183
+ // key handler. Use `contentClassName` to reach the inner box.
126
184
  {...props}
185
+ style={{ ...style, width: resolvedWidth }}
127
186
  className={cn(
128
- tableHeadVariants({ size, disabled, isDummy }),
129
- { "min-w-[100px]": !isDummy },
187
+ "relative h-[44px] py-[6px] px-[4px] border-b-[2px] border-border-presentation-table-header",
130
188
  className,
131
189
  )}
132
190
  >
133
191
  <div
134
- style={{ width: `${width}px` }}
135
- className={cn("flex items-center justify-between flex-1", {
136
- "justify-center": isDummy,
137
- })}
192
+ className={cn(
193
+ tableHeadVariants({ size, disabled, isDummy }),
194
+ {
195
+ // Only when nothing has sized the column — otherwise this floor would overflow a
196
+ // column narrower than 100px.
197
+ "min-w-[100px]": !isDummy && resolvedWidth === undefined,
198
+ },
199
+ contentClassName,
200
+ )}
138
201
  >
139
- {props.children}
140
- {isDummy || !onSort ? null : <SortButton onSort={onSort} sortType={sortType} />}
202
+ <div
203
+ className={cn("flex min-w-0 items-center justify-between flex-1", {
204
+ "justify-center": isDummy,
205
+ })}
206
+ >
207
+ {children}
208
+ {isDummy || !onSort ? null : (
209
+ <SortButton onSort={onSort} sortType={sortType} label={sortLabel} />
210
+ )}
211
+ </div>
141
212
  </div>
142
- </div>
143
- <button
144
- disabled={isDummy}
145
- className="absolute top-[50%] translate-y-[-50%] right-[-1px] rtl:left-[-1px] rtl:right-[unset] h-[20px] w-[2px] rounded-full bg-border-presentation-action-primary"
146
- >
147
- <ResizeIcon
148
- className={cn({ "!opacity-0 cursor-default": isDummy })}
213
+ {/* The grab target, straddling the column boundary. `z-10` keeps it above the next
214
+ column's content — it deliberately overhangs by half its width. The drag handlers
215
+ live here, not on the icon: the icon is `opacity-0` until hover, which made the
216
+ 8px-wide SVG a near-impossible thing to grab. */}
217
+ <button
218
+ type="button"
219
+ disabled={isDummy}
220
+ aria-label="Resize column"
221
+ // Lets consumers exclude the grip from blanket `[&_button]` rules on a header row.
222
+ data-slot="resize-handle"
149
223
  onMouseDown={handleStartResize}
150
224
  onTouchStart={handleStartResize}
151
- />
152
- </button>
153
- </th>
154
- );
155
- });
225
+ className={cn(
226
+ "group/resize absolute top-[50%] translate-y-[-50%] z-10",
227
+ "right-[-4px] rtl:left-[-4px] rtl:right-[unset]",
228
+ // `absolute` already makes this the containing block for the grip icon.
229
+ "flex h-[24px] w-[8px] items-center justify-center cursor-col-resize",
230
+ "disabled:cursor-default",
231
+ )}
232
+ >
233
+ <span className="h-[20px] w-[2px] rounded-full bg-border-presentation-action-primary" />
234
+ <ResizeIcon
235
+ className={cn("group-hover/resize:opacity-100", { "!opacity-0": isDummy })}
236
+ />
237
+ </button>
238
+ </th>
239
+ );
240
+ },
241
+ );
156
242
  TableHead.displayName = "TableHead";
157
243
 
158
244
  const TableCell = React.forwardRef<
@@ -161,15 +247,29 @@ const TableCell = React.forwardRef<
161
247
  isDummy?: boolean;
162
248
  childrenClassName?: string;
163
249
  className?: string;
250
+ /**
251
+ * Minimum width of the cell's content box, in px. Defaults to 200 — pass `0` when the
252
+ * column width is driven by the caller (e.g. `FormBuilder.Table`'s `column.width`),
253
+ * otherwise this floor silently overrides any narrower column.
254
+ */
255
+ minWidth?: number;
256
+ /**
257
+ * Fade the last 25% of the cell's content, to signal text clipped by the column width.
258
+ * Defaults to `true`. Set `false` for cells holding a **control** rather than text — the
259
+ * fade washes out whatever sits at the right edge (a Select's chevron, a date button).
260
+ * The built-in `:has(input)` escape hatch can't catch those, since a Radix trigger is a
261
+ * `<button>`, not an `<input>`.
262
+ */
263
+ fade?: boolean;
164
264
  }
165
- >(({ className, childrenClassName, isDummy, ...props }, ref) => (
265
+ >(({ className, childrenClassName, isDummy, minWidth = 200, fade = true, ...props }, ref) => (
166
266
  <td
167
267
  ref={ref}
168
268
  className={cn(
169
269
  [
170
- "h-[40px] text-content-presentation-action-light-primary",
270
+ "h-[50px] text-content-presentation-action-light-primary",
171
271
  "typography-body-small-regular relative",
172
- "border-r border-b border-border-presentation-table-header px-1 rtl:border-l rtl:border-r-0",
272
+ "border-r border-b border-border-presentation-table-header px-[8px] rtl:border-l rtl:border-r-0",
173
273
  "break-all",
174
274
  ],
175
275
  className,
@@ -177,11 +277,21 @@ const TableCell = React.forwardRef<
177
277
  {...props}
178
278
  >
179
279
  <div
280
+ style={isDummy ? undefined : { minWidth }}
180
281
  className={cn(
181
- "flex justify-start items-center gap-1 min-w-[200px] overflow-hidden has-input:bg-blue-200",
182
- "[mask-image:linear-gradient(to_right,black_0%,black_0%,black_75%,transparent_100%)]",
183
- "rtl:[mask-image:linear-gradient(to_left,black_0%,black_0%,black_75%,transparent_100%)]",
184
- "[&:has(input)]:[mask-image:none]",
282
+ "flex justify-start items-center gap-1",
283
+ // This div hugs the control exactly — same height, and the control is `w-full` — so
284
+ // `overflow-hidden` clips every side of a drop shadow drawn on it. Text cells still
285
+ // need the crop to feed the fade gradient; control cells need the shadow to escape.
286
+ fade ? "overflow-hidden" : "overflow-visible",
287
+ // Never fade a dummy cell — its content is a centred checkbox or drag handle, not
288
+ // clippable text.
289
+ fade &&
290
+ !isDummy && [
291
+ "[mask-image:linear-gradient(to_right,black_0%,black_0%,black_75%,transparent_100%)]",
292
+ "rtl:[mask-image:linear-gradient(to_left,black_0%,black_0%,black_75%,transparent_100%)]",
293
+ "[&:has(input)]:[mask-image:none]",
294
+ ],
185
295
  { "min-w-fit justify-center": isDummy },
186
296
  childrenClassName,
187
297
  )}
@@ -207,6 +317,75 @@ const TableCheckbox = React.forwardRef<
207
317
  });
208
318
  TableCheckbox.displayName = "TableCheckbox";
209
319
 
320
+ /**
321
+ * The full-width action bar that sits **below** a table — e.g. "+ Add New".
322
+ *
323
+ * Deliberately not a `<tr>`: it renders as a sibling of the table's horizontal scroll
324
+ * container, so it stays put while the columns scroll sideways under it. Use
325
+ * `TableFooterButton` instead when the action must scroll with the grid.
326
+ */
327
+ const TableEndAction = React.forwardRef<
328
+ HTMLButtonElement,
329
+ React.ButtonHTMLAttributes<HTMLButtonElement>
330
+ >(({ className, children, ...props }, ref) => (
331
+ <button
332
+ ref={ref}
333
+ type="button"
334
+ className={cn(
335
+ "flex h-[40px] w-full items-center justify-start gap-[8px] px-[8px]",
336
+ // No border. Figma's Table-End-Action-1.0 has none — it rounds its own bottom corners
337
+ // so it can sit flush on the card edge. A rule here is invisible against the card until
338
+ // the hover fill lands behind it, at which point it reads as a hard line cutting across
339
+ // the bar.
340
+ "rounded-bl-[16px] rounded-br-[16px]",
341
+ "typography-body-medium-semibold text-content-presentation-action-light-primary",
342
+ // NB: the "acton" spelling is the token that actually exists; the correctly-spelled
343
+ // `…table-action-hover` is defined nowhere and would be a dead class.
344
+ "transition-[background-color,box-shadow] hover:bg-background-presentation-table-acton-hover",
345
+ // Hover draws a ring rather than a border: it renders inset, so it costs no layout and
346
+ // can't push the bar's 40px box around the way a border would.
347
+ "hover:ring-2 hover:ring-inset hover:ring-border-presentation-table-action-hover",
348
+ "outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-presentation-state-focus",
349
+ "disabled:cursor-not-allowed disabled:opacity-50",
350
+ "[&_i]:text-[20px]",
351
+ className,
352
+ )}
353
+ {...props}
354
+ >
355
+ {children}
356
+ </button>
357
+ ));
358
+ TableEndAction.displayName = "TableEndAction";
359
+
360
+ /**
361
+ * Horizontal scroll container for a table — the 14px scroller row from the design,
362
+ * with the thin track that thickens and turns blue on hover.
363
+ */
364
+ const TableScroller = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
365
+ ({ className, children, ...props }, ref) => (
366
+ <div
367
+ ref={ref}
368
+ className={cn(
369
+ "w-full overflow-x-auto overflow-y-hidden",
370
+ "[&::-webkit-scrollbar]:h-[14px]",
371
+ "[&::-webkit-scrollbar-track]:bg-transparent",
372
+ "[&::-webkit-scrollbar-thumb]:rounded-[7px]",
373
+ "[&::-webkit-scrollbar-thumb]:border-[5px] [&::-webkit-scrollbar-thumb]:border-solid",
374
+ "[&::-webkit-scrollbar-thumb]:border-transparent",
375
+ "[&::-webkit-scrollbar-thumb]:bg-clip-content",
376
+ "[&::-webkit-scrollbar-thumb]:bg-background-presentation-body-scroller-default",
377
+ "[&::-webkit-scrollbar-thumb:hover]:border-[3px]",
378
+ "[&::-webkit-scrollbar-thumb:hover]:bg-background-presentation-body-scroller-hover",
379
+ className,
380
+ )}
381
+ {...props}
382
+ >
383
+ {children}
384
+ </div>
385
+ ),
386
+ );
387
+ TableScroller.displayName = "TableScroller";
388
+
210
389
  const TableCaption = React.forwardRef<
211
390
  HTMLTableCaptionElement,
212
391
  React.HTMLAttributes<HTMLTableCaptionElement>
@@ -225,7 +404,9 @@ const TableFooterButton = React.forwardRef<
225
404
  <TableRow className={cn("h-[40px] overflow-hidden", className)}>
226
405
  <TableCell
227
406
  className={
228
- "border-t-2 border-b-2 border-transparent hover:border-border-presentation-table-action-hover hover:bg-background-presentation-table-acton-hover"
407
+ // `h-[40px]` because the row asks for 40 and body cells default to 50 — the two sit
408
+ // on different elements, so CSS would otherwise take the larger and grow the footer.
409
+ "h-[40px] border-t-2 border-b-2 border-transparent hover:border-border-presentation-table-action-hover hover:bg-background-presentation-table-acton-hover"
229
410
  }
230
411
  colSpan={100}
231
412
  >
@@ -291,37 +472,41 @@ export {
291
472
  TableCheckbox,
292
473
  SubTableButton,
293
474
  TableFooterButton,
475
+ TableEndAction,
476
+ TableScroller,
294
477
  };
295
478
 
296
479
  interface ResizeIconProps {
297
480
  className?: string;
298
- onMouseDown?: React.MouseEventHandler<SVGElement>;
299
- onTouchStart?: React.TouchEventHandler<SVGElement>;
300
481
  }
301
482
 
302
- const ResizeIcon = (props: ResizeIconProps) => {
483
+ /**
484
+ * The grip that appears on hover. Sized to its parent button (8px wide), so it can't spill
485
+ * past the grab target; the button is what deliberately straddles the column boundary.
486
+ */
487
+ const ResizeIcon = ({ className }: ResizeIconProps) => {
303
488
  return (
304
489
  <svg
305
- {...props}
306
490
  className={cn(
307
- "z-50 cursor-col-resize absolute top-[50%] right-[50%] translate-x-[50%] translate-y-[-50%] opacity-0 hover:opacity-100 transition-opacity duration-200",
308
- props.className,
491
+ "pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
492
+ "opacity-0 transition-opacity duration-200",
493
+ className,
309
494
  )}
310
495
  width="8"
311
- height="32"
312
- viewBox="0 0 8 40"
496
+ height="30"
497
+ viewBox="0 1 8 38"
313
498
  fill="none"
314
499
  xmlns="http://www.w3.org/2000/svg"
315
500
  >
316
- <rect {...props} y="5" width="8" height="30" rx="3" fill="#3391FF" />
317
- <circle {...props} cx="2.75" cy="15.5" r="0.75" fill="#F9F9F9" />
318
- <circle {...props} cx="5.25" cy="15.5" r="0.75" fill="#F9F9F9" />
319
- <circle {...props} cx="2.75" cy="18.5" r="0.75" fill="#F9F9F9" />
320
- <circle {...props} cx="5.25" cy="18.5" r="0.75" fill="#F9F9F9" />
321
- <circle {...props} cx="2.75" cy="21.5" r="0.75" fill="#F9F9F9" />
322
- <circle {...props} cx="5.25" cy="21.5" r="0.75" fill="#F9F9F9" />
323
- <circle {...props} cx="2.75" cy="24.5" r="0.75" fill="#F9F9F9" />
324
- <circle {...props} cx="5.25" cy="24.5" r="0.75" fill="#F9F9F9" />
501
+ <rect y="5" width="8" height="30" rx="3" fill="#3391FF" />
502
+ <circle cx="2.75" cy="15.5" r="0.75" fill="#F9F9F9" />
503
+ <circle cx="5.25" cy="15.5" r="0.75" fill="#F9F9F9" />
504
+ <circle cx="2.75" cy="18.5" r="0.75" fill="#F9F9F9" />
505
+ <circle cx="5.25" cy="18.5" r="0.75" fill="#F9F9F9" />
506
+ <circle cx="2.75" cy="21.5" r="0.75" fill="#F9F9F9" />
507
+ <circle cx="5.25" cy="21.5" r="0.75" fill="#F9F9F9" />
508
+ <circle cx="2.75" cy="24.5" r="0.75" fill="#F9F9F9" />
509
+ <circle cx="5.25" cy="24.5" r="0.75" fill="#F9F9F9" />
325
510
  </svg>
326
511
  );
327
512
  };
@@ -329,12 +514,25 @@ const ResizeIcon = (props: ResizeIconProps) => {
329
514
  const SortButton = ({
330
515
  onSort,
331
516
  sortType,
517
+ label,
332
518
  }: {
333
519
  onSort?: () => void;
334
520
  sortType?: "asc" | "desc" | undefined;
521
+ /** What this button sorts — without it the control is an unnamed icon. */
522
+ label?: string;
335
523
  }) => {
524
+ const next = sortType === "asc" ? "descending" : "ascending";
336
525
  return (
337
- <button className={cn("cursor-pointer text-[16px] z-10")} onPointerDown={onSort}>
526
+ // `type="button"` matters: this table renders inside a <form> (FormBuilder.Table),
527
+ // where an untyped button defaults to submit.
528
+ <button
529
+ type="button"
530
+ // `onClick`, not `onPointerDown`: a button activated from the keyboard fires
531
+ // `click` only, so pointer-down made this mouse-only.
532
+ onClick={onSort}
533
+ aria-label={label ? `Sort by ${label} ${next}` : `Sort ${next}`}
534
+ className={cn("cursor-pointer text-[16px] z-10")}
535
+ >
338
536
  {sortType === "asc" ? (
339
537
  <i className="ri-arrow-up-line text-border-presentation-state-focus" />
340
538
  ) : sortType === "desc" ? (
@@ -379,9 +577,9 @@ const tableHeadVariants = cva(
379
577
  isDummy: {
380
578
  true: ["hover:bg-transparent", "hover:text-content-presentation-global-primary"],
381
579
  },
382
- defaultVariants: {
383
- size: "M",
384
- },
580
+ },
581
+ defaultVariants: {
582
+ size: "M",
385
583
  },
386
584
  },
387
585
  );
@@ -22,6 +22,7 @@ import type {
22
22
  TreeFolderVisibleRow,
23
23
  } from "./types";
24
24
  import { useTreeFolderDnD } from "./useTreeFolderDnD";
25
+ import { DndContext, DragList } from "../../hooks/useDragDrop";
25
26
 
26
27
  export type TreeFolderProps = {
27
28
  data: TreeFolderNode[];
@@ -230,10 +231,9 @@ export const TreeFolder = forwardRef<TreeFolderHandle, TreeFolderProps>(
230
231
  }, []);
231
232
 
232
233
  // ---- DnD ----
233
- const { dragIds, dropTarget, getRowDragHandlers } = useTreeFolderDnD({
234
+ const { dragIds, dropTarget, contextProps, ids } = useTreeFolderDnD({
234
235
  data,
235
236
  rowsById,
236
- scrollContainerRef: scrollRef,
237
237
  enabled: dndEnabled,
238
238
  onMove: handleMoveInternal,
239
239
  canDrop: ({ parentId }) => {
@@ -310,6 +310,7 @@ export const TreeFolder = forwardRef<TreeFolderHandle, TreeFolderProps>(
310
310
  )}
311
311
 
312
312
  <TreeFolderStyles />
313
+ <DndContext {...contextProps}>
313
314
  <div ref={scrollRef} role="tree" className="tf-scroll flex-1 min-h-0 overflow-auto">
314
315
  {isEmpty ? (
315
316
  (emptyState ?? (
@@ -319,6 +320,7 @@ export const TreeFolder = forwardRef<TreeFolderHandle, TreeFolderProps>(
319
320
  ))
320
321
  ) : (
321
322
  <div className="min-w-max" style={stripStyle}>
323
+ <DragList ids={ids} makeRoom={false}>
322
324
  {visibleRows.map((row, idx) => {
323
325
  const prevRow = visibleRows[idx - 1];
324
326
  const nextRow = visibleRows[idx + 1];
@@ -364,13 +366,14 @@ export const TreeFolder = forwardRef<TreeFolderHandle, TreeFolderProps>(
364
366
  dndEnabled={dndEnabled}
365
367
  onSelect={handleSelect}
366
368
  onToggle={handleToggle}
367
- dragHandlers={getRowDragHandlers(row.node.id)}
368
369
  />
369
370
  );
370
371
  })}
372
+ </DragList>
371
373
  </div>
372
374
  )}
373
375
  </div>
376
+ </DndContext>
374
377
  </div>
375
378
  );
376
379
  },
@@ -1,20 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { ChevronRight, ChevronDown, GripVertical } from "lucide-react";
4
- import type { DragEvent } from "react";
4
+ import { useDragItem } from "../../hooks/useDragDrop";
5
5
  import { cn } from "../../utils/cn";
6
6
  import { resolveIcon } from "./icons";
7
7
  import type { TreeFolderIconResolver, TreeFolderNode, TreeFolderVisibleRow } from "./types";
8
8
 
9
- export type TreeFolderRowDragHandlers = {
10
- draggable: boolean;
11
- onDragStart: (e: DragEvent<HTMLElement>) => void;
12
- onDragEnd: (e: DragEvent<HTMLElement>) => void;
13
- onDragOver: (e: DragEvent<HTMLElement>) => void;
14
- onDragLeave: (e: DragEvent<HTMLElement>) => void;
15
- onDrop: (e: DragEvent<HTMLElement>) => void;
16
- };
17
-
18
9
  export type TreeFolderRowProps = {
19
10
  row: TreeFolderVisibleRow;
20
11
  rowHeight: number;
@@ -37,7 +28,6 @@ export type TreeFolderRowProps = {
37
28
  dndEnabled: boolean;
38
29
  onSelect: (id: string | null) => void;
39
30
  onToggle: (id: string) => void;
40
- dragHandlers: TreeFolderRowDragHandlers;
41
31
  };
42
32
 
43
33
  export function TreeFolderRow({
@@ -57,9 +47,14 @@ export function TreeFolderRow({
57
47
  dndEnabled,
58
48
  onSelect,
59
49
  onToggle,
60
- dragHandlers,
61
50
  }: TreeFolderRowProps) {
62
51
  const { node, level, isOpen, isInternal, ancestorHasMoreSiblings } = row;
52
+
53
+ const {
54
+ ref: dragRef,
55
+ handleProps: dragProps,
56
+ style: dragStyle,
57
+ } = useDragItem(node.id, !dndEnabled || Boolean(node.disabled), { follow: false });
63
58
  const data = node;
64
59
  const hasChildren = isInternal;
65
60
 
@@ -134,10 +129,13 @@ export function TreeFolderRow({
134
129
 
135
130
  return (
136
131
  <div
132
+ ref={dragRef}
137
133
  data-row-id={node.id}
138
134
  className={cn("select-none group/row", outerClassName)}
139
- style={{ height: rowHeight }}
140
- {...dragHandlers}
135
+ // The row is the drag surface, not just the grip: that is how this tree has always behaved,
136
+ // and the 8px threshold is what keeps a click on it still a click.
137
+ style={{ ...dragStyle, height: rowHeight }}
138
+ {...dragProps}
141
139
  >
142
140
  <span aria-hidden className={bandClassName} />
143
141
 
@@ -234,6 +232,10 @@ export function TreeFolderRow({
234
232
  {data.name}
235
233
  </span>
236
234
 
235
+ {/* `meta` has always been part of `TreeFolderNode` but was never painted. It sits after
236
+ the name, before the collapsed-child count, and only appears when a node supplies it. */}
237
+ {data.meta != null && <span className="shrink-0 pr-2">{data.meta}</span>}
238
+
237
239
  {hasChildren && !isOpen && (
238
240
  <span
239
241
  className={cn(
@@ -1,7 +1,7 @@
1
1
  export { TreeFolder } from "./TreeFolder";
2
2
  export type { TreeFolderHandle, TreeFolderProps } from "./TreeFolder";
3
3
  export { TreeFolderRow } from "./TreeFolderRow";
4
- export type { TreeFolderRowProps, TreeFolderRowDragHandlers } from "./TreeFolderRow";
4
+ export type { TreeFolderRowProps } from "./TreeFolderRow";
5
5
  export { TreeFolderBreadcrumb } from "./TreeFolderBreadcrumb";
6
6
  export { defaultIconRegistry, defaultIconFor } from "./icons";
7
7
  export {