torch-glare 2.5.4 → 2.5.5
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/BadgeField.tsx +70 -66
- package/apps/lib/components/Card.tsx +2 -1
- package/apps/lib/components/ContextMenu.tsx +65 -22
- package/apps/lib/components/DataViews/context.ts +2 -2
- package/apps/lib/components/DataViews/data-views.tsx +3 -3
- package/apps/lib/components/DataViews/filters/filters.tsx +0 -2
- package/apps/lib/components/DataViews/views/table-view.tsx +184 -176
- package/apps/lib/components/DropdownMenu.tsx +65 -22
- package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +19 -5
- package/apps/lib/components/FormBuilder/types.ts +16 -0
- package/apps/lib/components/FormRenderer/form-renderer.tsx +16 -5
- package/apps/lib/components/HeaderBar.tsx +51 -53
- package/apps/lib/components/InputField.tsx +46 -47
- package/apps/lib/components/Popover.tsx +23 -9
- package/apps/lib/components/SearchableSelect.tsx +10 -6
- package/apps/lib/components/SearchableTree.tsx +23 -6
- package/apps/lib/components/SearchableTreeDialog.tsx +11 -1
- package/apps/lib/components/Select.tsx +56 -48
- package/apps/lib/components/SlideDatePicker.tsx +5 -7
- package/apps/lib/components/TabSwitch.tsx +18 -12
- package/apps/lib/layouts/FieldSection.tsx +28 -2
- package/apps/lib/registry.json +1 -2
- package/docs/components/badge-field.md +4 -4
- package/docs/components/context-menu.md +3 -1
- package/docs/components/data-views/examples/filters.md +0 -1
- package/docs/components/data-views/index.md +1 -17
- package/docs/components/dropdown-menu.md +3 -0
- package/docs/components/form-builder.md +27 -1
- package/docs/components/header-bar.md +3 -2
- package/docs/components/input-field.md +3 -3
- package/docs/components/select.md +1 -1
- package/docs/migration/changelog.md +13 -0
- package/package.json +1 -1
- package/apps/lib/components/DataViews/filters/summary.tsx +0 -65
|
@@ -125,192 +125,200 @@ function TableViewImpl({
|
|
|
125
125
|
)}
|
|
126
126
|
>
|
|
127
127
|
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
128
|
+
{/* The scroller and the end action are stacked here with NO gap, so the bar sits flush
|
|
129
|
+
under the table the way it does on the card. `min-h-0` lets the scroller actually
|
|
130
|
+
shrink — a flex item will not go below its content without it. */}
|
|
131
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
132
|
+
<div
|
|
133
|
+
ref={scrollRef}
|
|
134
|
+
// `min-w-0` so the scroller can be narrower than the table inside it. Without it a flex
|
|
135
|
+
// item refuses to shrink below its content, so pinning the table to its natural width
|
|
136
|
+
// pushes the whole component wide instead of scrolling within it.
|
|
137
|
+
className="min-w-0 flex-1 overflow-auto rounded-lg"
|
|
138
|
+
>
|
|
139
|
+
{/* Sizes the table, not the footer — the end action now lives outside this scroller.
|
|
140
|
+
Inside an `overflow-auto` scroller a child's `w-full` resolves against the *visible*
|
|
141
|
+
width, so the table needs `w-max` to reach its natural width and scroll; `min-w-full`
|
|
142
|
+
keeps it filling the scroller when the table is narrower. */}
|
|
143
|
+
<div className="w-max min-w-full">
|
|
144
|
+
{/* `overflow-visible` opts out of the primitive's `overflow-hidden` (tailwind-merge
|
|
145
|
+
lets ours win). It has to: a clipping table is its own scrollport, and the sticky
|
|
146
|
+
header would then resolve against a box that never scrolls. Safe here because the
|
|
147
|
+
scroller above is `min-w-0 overflow-auto`, so a wide table scrolls inside it rather
|
|
148
|
+
than pushing the layout. */}
|
|
149
|
+
<Table ref={tableRef} className="w-full overflow-visible">
|
|
150
|
+
{/* The header sticks to the scroller above by default — that lives on `TableHeader`
|
|
151
|
+
in the primitive. Two things this view adds:
|
|
149
152
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
Opacity. The primitive's header token is translucent, so scrolling rows read
|
|
154
|
+
straight through it; the primitive can't fix that without naming a surface colour
|
|
155
|
+
it doesn't know. Here the surface *is* known — the view root above sets
|
|
156
|
+
`form-base` — so paint that as the background-color (tailwind-merge drops the
|
|
157
|
+
primitive's translucent one, same `bg-color` group) and re-apply the tint as a
|
|
158
|
+
background-image, which stacks above background-color. Composited that is the
|
|
159
|
+
exact colour the header already had, just no longer see-through.
|
|
157
160
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
161
|
+
And `shadow-none`, because the drop shadow reads as a seam now that the view
|
|
162
|
+
carries its own border. */}
|
|
163
|
+
<TableHeader className="bg-background-presentation-form-base bg-[image:linear-gradient(var(--background-presentation-form-header),var(--background-presentation-form-header))] shadow-none">
|
|
164
|
+
<TableRow>
|
|
165
|
+
{onRowMove && <TableHead isDummy className="w-8" />}
|
|
166
|
+
{selectable && (
|
|
167
|
+
<TableHead isDummy className="w-12">
|
|
168
|
+
{/* Size is stated on both this and the per-row checkbox rather than left to
|
|
169
|
+
`Checkbox`'s default — they have to agree, and relying on the default on
|
|
170
|
+
one side only is what previously made the select-all bigger than the
|
|
171
|
+
column it heads. */}
|
|
172
|
+
<Checkbox
|
|
173
|
+
size="M"
|
|
174
|
+
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
|
175
|
+
onCheckedChange={toggleAll}
|
|
176
|
+
aria-label="Select all rows"
|
|
177
|
+
/>
|
|
178
|
+
</TableHead>
|
|
179
|
+
)}
|
|
180
|
+
{/* Keyed by position: two fields may share a `path` — the same value shown two
|
|
181
|
+
ways — and keying on it would collide. */}
|
|
182
|
+
{visibleFields.map((field, i) => (
|
|
183
|
+
<TableHead
|
|
184
|
+
key={`${field.path}-${i}`}
|
|
185
|
+
size="M"
|
|
186
|
+
sortType={sort?.path === field.path ? sort.direction : undefined}
|
|
187
|
+
onSort={() => setSort(cycle(field.path))}
|
|
188
|
+
// Without this every sort control announces itself as a bare "Sort ascending",
|
|
189
|
+
// so a screen reader hears one identical button per column.
|
|
190
|
+
sortLabel={field.label ?? formatPathLabel(field.path)}
|
|
191
|
+
>
|
|
192
|
+
{/* Wrapped rather than handed to `Table` as bare text: `truncate` needs a box
|
|
193
|
+
to clip, and the label's parent in the primitive is already `flex min-w-0`
|
|
194
|
+
so this span shrinks and ellipsises instead of wrapping the header row
|
|
195
|
+
onto a second line. */}
|
|
196
|
+
<span className="truncate">{field.label ?? field.path}</span>
|
|
197
|
+
</TableHead>
|
|
198
|
+
))}
|
|
199
|
+
</TableRow>
|
|
200
|
+
</TableHeader>
|
|
198
201
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
202
|
+
<TableBody>
|
|
203
|
+
{loading && <SkeletonRows fields={visibleFields} grip={Boolean(onRowMove)} selectable={selectable} />}
|
|
204
|
+
{virtualize && padTop > 0 && (
|
|
205
|
+
<tr aria-hidden style={{ height: padTop }} />
|
|
206
|
+
)}
|
|
207
|
+
<DragList ids={ids}>
|
|
208
|
+
{renderIndexes.map((index) => {
|
|
209
|
+
const row = rows[index];
|
|
210
|
+
const id = ids[index];
|
|
211
|
+
const selected = selection.includes(id);
|
|
212
|
+
return (
|
|
213
|
+
<DraggableRow
|
|
214
|
+
key={id}
|
|
215
|
+
id={id}
|
|
216
|
+
draggable={Boolean(onRowMove)}
|
|
217
|
+
state={selected ? "selected" : undefined}
|
|
218
|
+
onClick={onRowClick ? () => onRowClick(row, id) : undefined}
|
|
219
|
+
// A clickable row has to be reachable without a mouse. `<tr>` carries no
|
|
220
|
+
// implicit role, so a bare `onClick` is invisible to the keyboard and to a
|
|
221
|
+
// screen reader; these only appear when the row is actually interactive.
|
|
222
|
+
tabIndex={onRowClick ? 0 : undefined}
|
|
223
|
+
role={onRowClick ? "button" : undefined}
|
|
224
|
+
onKeyDown={
|
|
225
|
+
onRowClick
|
|
226
|
+
? (e) => {
|
|
227
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
228
|
+
if (e.target !== e.currentTarget) return;
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
onRowClick(row, id);
|
|
231
|
+
}
|
|
232
|
+
: undefined
|
|
228
233
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
</
|
|
306
|
-
</
|
|
234
|
+
className={cn(
|
|
235
|
+
// 40px per Figma's `Table-RowBackgroand-1.1`. `Table`'s body cells default
|
|
236
|
+
// to 50, and the two live on different elements, so the cells below must be
|
|
237
|
+
// told as well or CSS keeps the larger.
|
|
238
|
+
"h-[40px]",
|
|
239
|
+
"focus-visible:ring-border-presentation-state-focus outline-none focus-visible:ring-2",
|
|
240
|
+
onRowClick && "cursor-pointer",
|
|
241
|
+
)}
|
|
242
|
+
>
|
|
243
|
+
{onRowMove && <GripCell />}
|
|
244
|
+
{selectable && (
|
|
245
|
+
<TableCell isDummy className="min-h-[40px] w-12" onClick={(e) => e.stopPropagation()}>
|
|
246
|
+
{/* What `TableCheckbox` renders, inlined: its props are typed as button
|
|
247
|
+
attributes, so it cannot express a controlled checkbox. */}
|
|
248
|
+
<div className="flex items-center justify-center">
|
|
249
|
+
<Checkbox
|
|
250
|
+
size="M"
|
|
251
|
+
checked={selected}
|
|
252
|
+
onCheckedChange={() => toggleRow(id)}
|
|
253
|
+
aria-label="Select row"
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
</TableCell>
|
|
257
|
+
)}
|
|
258
|
+
{visibleFields.map((field, i) => {
|
|
259
|
+
// `undefined` means "you paint it" — distinct from `null`, which is a
|
|
260
|
+
// deliberate blank.
|
|
261
|
+
const custom = renderCell?.({ field, row, id, index, fields: visibleFields });
|
|
262
|
+
// `fade={false}` and clip instead. The fade is a `mask-image` on every
|
|
263
|
+
// cell, close to free on its own but very expensive once a sticky header
|
|
264
|
+
// repaints the region each scroll frame: measured on this page,
|
|
265
|
+
// mask+sticky costs ~61ms/frame against ~29ms with the mask off and
|
|
266
|
+
// ~17ms with neither. `overflow-hidden` is passed back because
|
|
267
|
+
// `fade={false}` otherwise switches the cell to `overflow-visible`, and
|
|
268
|
+
// the text would spill into the next column.
|
|
269
|
+
return (
|
|
270
|
+
<TableCell
|
|
271
|
+
key={`${field.path}-${i}`}
|
|
272
|
+
className="min-h-[40px]"
|
|
273
|
+
fade={false}
|
|
274
|
+
childrenClassName="overflow-hidden"
|
|
275
|
+
>
|
|
276
|
+
{/* `isolate` confines the Badge's mix-blend-luminosity to a local
|
|
277
|
+
stacking context and `transform-gpu` promotes it to its own layer, so
|
|
278
|
+
the table's post-mount column reflow repaints cleanly instead of
|
|
279
|
+
leaving a ghosted badge frame. */}
|
|
280
|
+
<span className="isolate inline-flex transform-gpu">
|
|
281
|
+
{custom === undefined ? <Cell field={field} row={row} /> : custom}
|
|
282
|
+
</span>
|
|
283
|
+
</TableCell>
|
|
284
|
+
);
|
|
285
|
+
})}
|
|
286
|
+
</DraggableRow>
|
|
287
|
+
);
|
|
288
|
+
})}
|
|
289
|
+
</DragList>
|
|
290
|
+
{virtualize && padBottom > 0 && (
|
|
291
|
+
<tr aria-hidden style={{ height: padBottom }} />
|
|
292
|
+
)}
|
|
293
|
+
{/* The trigger. A row of its own rather than an element after the table, so it sits
|
|
294
|
+
inside the same scroller the rows do. */}
|
|
295
|
+
{hasMore && !loading && (
|
|
296
|
+
<tr ref={sentinelRef as React.Ref<HTMLTableRowElement>} aria-hidden>
|
|
297
|
+
<td style={{ height: 1, padding: 0, border: 0 }} colSpan={100} />
|
|
298
|
+
</tr>
|
|
299
|
+
)}
|
|
300
|
+
{loadingMore && (
|
|
301
|
+
<SkeletonRows
|
|
302
|
+
rows={2}
|
|
303
|
+
fields={visibleFields}
|
|
304
|
+
grip={Boolean(onRowMove)}
|
|
305
|
+
selectable={selectable}
|
|
306
|
+
/>
|
|
307
|
+
)}
|
|
308
|
+
</TableBody>
|
|
309
|
+
</Table>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
{/* Outside the scroller, deliberately: as a sibling its width is the component's visible
|
|
313
|
+
width, so it spans the full outer container and stays put while the columns scroll
|
|
314
|
+
sideways under it — instead of stretching to the table's scrollable width and sliding
|
|
315
|
+
away like a normal row. Same placement the form Table field uses. */}
|
|
307
316
|
{onAddRow && (
|
|
308
317
|
<TableEndAction onClick={onAddRow}>
|
|
309
318
|
<Plus className="h-4 w-4" aria-hidden />
|
|
310
319
|
{addRowLabel}
|
|
311
320
|
</TableEndAction>
|
|
312
321
|
)}
|
|
313
|
-
</div>
|
|
314
322
|
</div>
|
|
315
323
|
</div>
|
|
316
324
|
</div>
|
|
@@ -70,16 +70,27 @@ const DropdownMenuContent = React.forwardRef<
|
|
|
70
70
|
ref={ref}
|
|
71
71
|
sideOffset={sideOffset}
|
|
72
72
|
collisionPadding={collisionPadding}
|
|
73
|
-
// Cap at maxHeight, but never exceed the space Radix has after collision
|
|
74
|
-
//
|
|
73
|
+
// Cap at maxHeight, but never exceed the space Radix has after collision handling. The
|
|
74
|
+
// `100vh` fallback is load-bearing: an undefined var invalidates the whole `min()`, so
|
|
75
|
+
// `max-height` would resolve to `none` and the panel — which no longer scrolls itself —
|
|
76
|
+
// would grow unbounded with its rows clipped and unreachable.
|
|
75
77
|
style={{
|
|
76
|
-
maxHeight: `min(${maxHeight}px, var(--radix-dropdown-menu-content-available-height))`,
|
|
78
|
+
maxHeight: `min(${maxHeight}px, var(--radix-dropdown-menu-content-available-height, 100vh))`,
|
|
77
79
|
...style,
|
|
78
80
|
}}
|
|
79
81
|
className={cn(menuContentStyles({ variant }), className)}
|
|
80
82
|
{...props}
|
|
81
83
|
>
|
|
82
|
-
{
|
|
84
|
+
{/* Dedicated scroll viewport, matching Select's: the cap lives on the panel above, this
|
|
85
|
+
fills what is left and scrolls. `min-h-0` is what makes it work — a flex item will not
|
|
86
|
+
shrink below its content, so without it the list grows past the panel and the panel's
|
|
87
|
+
`overflow-hidden` just clips the rows with no scrollbar.
|
|
88
|
+
|
|
89
|
+
`gap-1` is re-declared here because `autoGroupChildren` emits several siblings (a group,
|
|
90
|
+
a label, a separator…) and this is now the element they are siblings within. */}
|
|
91
|
+
<div className="flex flex-col gap-1 flex-1 min-h-0 overflow-y-auto overflow-x-hidden rounded-[10px] scrollbar-hide">
|
|
92
|
+
{autoGroup ? autoGroupChildren(children) : children}
|
|
93
|
+
</div>
|
|
83
94
|
</DropdownMenuPrimitive.Content>
|
|
84
95
|
</DropdownMenuPrimitive.Portal>
|
|
85
96
|
),
|
|
@@ -123,18 +134,45 @@ const DropdownMenuSubContent = React.forwardRef<
|
|
|
123
134
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {
|
|
124
135
|
variant?: "PresentationStyle";
|
|
125
136
|
autoGroup?: boolean;
|
|
137
|
+
maxHeight?: number;
|
|
126
138
|
}
|
|
127
|
-
>(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
>(
|
|
140
|
+
(
|
|
141
|
+
{
|
|
142
|
+
className,
|
|
143
|
+
variant = "PresentationStyle",
|
|
144
|
+
autoGroup = true,
|
|
145
|
+
collisionPadding = 8,
|
|
146
|
+
maxHeight = 320,
|
|
147
|
+
// Destructured out of `{...props}` so the spread below cannot clobber the cap. Radix
|
|
148
|
+
// re-publishes the namespaced available-height var on SubContent, so the same expression
|
|
149
|
+
// Content uses works here unchanged.
|
|
150
|
+
style,
|
|
151
|
+
children,
|
|
152
|
+
...props
|
|
153
|
+
},
|
|
154
|
+
ref,
|
|
155
|
+
) => (
|
|
156
|
+
<DropdownMenuPrimitive.Portal>
|
|
157
|
+
<DropdownMenuPrimitive.SubContent
|
|
158
|
+
ref={ref}
|
|
159
|
+
collisionPadding={collisionPadding}
|
|
160
|
+
style={{
|
|
161
|
+
maxHeight: `min(${maxHeight}px, var(--radix-dropdown-menu-content-available-height, 100vh))`,
|
|
162
|
+
...style,
|
|
163
|
+
}}
|
|
164
|
+
className={cn(menuContentStyles({ variant }), className)}
|
|
165
|
+
{...props}
|
|
166
|
+
>
|
|
167
|
+
{/* Same panel-clips / viewport-scrolls split as Content. A submenu is a peer surface, so it
|
|
168
|
+
shares the 320px default rather than getting a smaller one of its own. */}
|
|
169
|
+
<div className="flex flex-col gap-1 flex-1 min-h-0 overflow-y-auto overflow-x-hidden rounded-[10px] scrollbar-hide">
|
|
170
|
+
{autoGroup ? autoGroupChildren(children) : children}
|
|
171
|
+
</div>
|
|
172
|
+
</DropdownMenuPrimitive.SubContent>
|
|
173
|
+
</DropdownMenuPrimitive.Portal>
|
|
174
|
+
),
|
|
175
|
+
);
|
|
138
176
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
|
139
177
|
|
|
140
178
|
const DropdownMenuItem = React.forwardRef<
|
|
@@ -456,16 +494,19 @@ export const menuContentStyles = cva(
|
|
|
456
494
|
"rounded-[14px]",
|
|
457
495
|
"min-w-[240px]",
|
|
458
496
|
"outline-none",
|
|
459
|
-
|
|
460
|
-
|
|
497
|
+
// The panel clips; the inner viewport below owns scrolling. Height is capped inline on the
|
|
498
|
+
// Content element from `min(maxHeight, available-height)` — no `max-h-*` class here on
|
|
499
|
+
// purpose, so the inline value governs.
|
|
500
|
+
"overflow-hidden",
|
|
461
501
|
// Only animate the OPEN (enter) state. An exit animation on [data-state=closed]
|
|
462
502
|
// holds the old DOM node during close, which breaks the context menu's
|
|
463
503
|
// close/reposition on a second right-click (Radix issue #2572).
|
|
464
504
|
"data-[state=open]:animate-in",
|
|
465
505
|
"data-[state=open]:fade-in-0",
|
|
466
|
-
"scrollbar-hide",
|
|
467
506
|
"backdrop-blur-[21px]",
|
|
468
|
-
|
|
507
|
+
// No `gap` here: the panel has exactly one child (the scroll viewport), so a gap between
|
|
508
|
+
// siblings has nothing to act on. The 4px between groups/labels lives on that viewport.
|
|
509
|
+
"flex flex-col",
|
|
469
510
|
],
|
|
470
511
|
{
|
|
471
512
|
variants: {
|
|
@@ -475,9 +516,11 @@ export const menuContentStyles = cva(
|
|
|
475
516
|
"shadow-[0_0_32px_2px_rgba(0,0,0,0.20),0_0_48px_2px_rgba(0,0,0,0.05)]",
|
|
476
517
|
],
|
|
477
518
|
},
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
519
|
+
},
|
|
520
|
+
// Was nested inside `variants`, where cva reads it as a variant group named
|
|
521
|
+
// "defaultVariants" and no default is ever applied. `menuGroupStyles` below has it right.
|
|
522
|
+
defaultVariants: {
|
|
523
|
+
variant: "PresentationStyle",
|
|
481
524
|
},
|
|
482
525
|
},
|
|
483
526
|
);
|
|
@@ -16,6 +16,7 @@ import { FormField, FormItem, FormControl } from "../../Form";
|
|
|
16
16
|
import { FieldHint } from "../../FieldHint";
|
|
17
17
|
import { Tooltip } from "../../Tooltip";
|
|
18
18
|
import { useDirection, useStepRegistry, useBare } from "../context";
|
|
19
|
+
import type { FieldHintSpec } from "../types";
|
|
19
20
|
|
|
20
21
|
export interface FieldShellProps {
|
|
21
22
|
name: string;
|
|
@@ -26,6 +27,8 @@ export interface FieldShellProps {
|
|
|
26
27
|
hidden?: boolean;
|
|
27
28
|
/** Force the field's layout direction, overriding the form's `useDirection()` context. */
|
|
28
29
|
direction?: "horizontal" | "vertical" | "flexible";
|
|
30
|
+
/** Alerts stacked under the validation error. See `FieldHintSpec`. */
|
|
31
|
+
hints?: FieldHintSpec[];
|
|
29
32
|
/** The input, wired to the react-hook-form field. */
|
|
30
33
|
children: (
|
|
31
34
|
field: ControllerRenderProps<FieldValues, string>,
|
|
@@ -47,6 +50,7 @@ export function FieldShell({
|
|
|
47
50
|
fullWidth,
|
|
48
51
|
hidden,
|
|
49
52
|
direction: directionProp,
|
|
53
|
+
hints,
|
|
50
54
|
children,
|
|
51
55
|
}: FieldShellProps) {
|
|
52
56
|
const form = useFormContext();
|
|
@@ -108,7 +112,7 @@ export function FieldShell({
|
|
|
108
112
|
secondaryLabel={description}
|
|
109
113
|
direction={direction}
|
|
110
114
|
className={fullWidth ? "max-w-full" : undefined}
|
|
111
|
-
childrenUnderLabel={<
|
|
115
|
+
childrenUnderLabel={<FieldMessages message={fieldError} hints={hints} />}
|
|
112
116
|
>
|
|
113
117
|
<FormField
|
|
114
118
|
control={form.control}
|
|
@@ -125,10 +129,20 @@ export function FieldShell({
|
|
|
125
129
|
);
|
|
126
130
|
}
|
|
127
131
|
|
|
128
|
-
/**
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
/**
|
|
133
|
+
* The stack under a field: the validation error first (it is the actionable one), then any author
|
|
134
|
+
* `hints` in order. Renders nothing when there is neither, so a field without hints is unchanged.
|
|
135
|
+
*/
|
|
136
|
+
function FieldMessages({ message, hints }: { message?: string; hints?: FieldHintSpec[] }) {
|
|
137
|
+
if (!message && !hints?.length) return null;
|
|
138
|
+
return (
|
|
139
|
+
<div className="flex flex-col items-start gap-[4px]">
|
|
140
|
+
{message && <FieldHint state="error" label={message} />}
|
|
141
|
+
{hints?.map((hint, i) => (
|
|
142
|
+
<FieldHint key={i} state={hint.state ?? "info"} label={hint.label} icon={hint.icon} />
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
/**
|
|
@@ -41,6 +41,16 @@ export type FieldKind =
|
|
|
41
41
|
| "custom";
|
|
42
42
|
|
|
43
43
|
/** Props shared by every `FormBuilder.*` field. `name` is the RHF path. */
|
|
44
|
+
/**
|
|
45
|
+
* One alert under a field. Mirrors `FieldHint`'s own props so the design system stays the single
|
|
46
|
+
* source of truth for how each state looks.
|
|
47
|
+
*/
|
|
48
|
+
export interface FieldHintSpec {
|
|
49
|
+
label: ReactNode;
|
|
50
|
+
state?: "info" | "warning" | "error" | "success";
|
|
51
|
+
icon?: ReactNode;
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
export interface BaseFieldProps {
|
|
45
55
|
name: string;
|
|
46
56
|
label?: ReactNode;
|
|
@@ -51,6 +61,12 @@ export interface BaseFieldProps {
|
|
|
51
61
|
hidden?: boolean;
|
|
52
62
|
/** Span the full section width. */
|
|
53
63
|
fullWidth?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Alerts stacked under the field. The validation error, when there is one, always renders first —
|
|
66
|
+
* it is the actionable message — and these follow in order. Ignored in `bare` mode (a
|
|
67
|
+
* `FormBuilder.Table` cell), where errors surface as a tooltip to keep the row one line tall.
|
|
68
|
+
*/
|
|
69
|
+
hints?: FieldHintSpec[];
|
|
54
70
|
}
|
|
55
71
|
|
|
56
72
|
export interface OptionItem {
|