torch-glare 2.5.2 → 2.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/lib/components/DataViews/data-views.tsx +1 -1
- package/apps/lib/components/DataViews/filters/filters.tsx +1 -1
- package/apps/lib/components/DataViews/header.tsx +5 -2
- package/apps/lib/components/DataViews/panel/section.tsx +3 -1
- package/apps/lib/components/DataViews/views/board-view.tsx +3 -1
- package/apps/lib/components/DataViews/views/inbox-view.tsx +4 -1
- package/apps/lib/components/DataViews/views/pane-views.tsx +5 -1
- package/apps/lib/components/DataViews/views/table-view.tsx +34 -4
- package/apps/lib/components/DataViews/views/tree-view.tsx +5 -3
- package/apps/lib/components/FormBuilder/context.ts +9 -1
- package/apps/lib/components/FormBuilder/submit.tsx +11 -2
- package/apps/lib/components/FormBuilder/types.ts +5 -1
- package/apps/lib/components/FormRenderer/form-renderer.tsx +15 -3
- package/apps/lib/components/FormRenderer/stepper.tsx +18 -3
- package/apps/lib/components/FormRenderer/types.ts +1 -1
- package/apps/lib/components/SectionBlock.tsx +11 -1
- package/apps/lib/components/Table.tsx +54 -29
- package/apps/lib/components/TreeFolder/TreeFolder.tsx +61 -61
- package/apps/lib/registry.json +1 -1
- package/apps/lib/tsconfig.tsbuildinfo +1 -1
- package/docs/components/data-views/index.md +7 -0
- package/docs/components/form-builder.md +4 -2
- package/docs/components/form-renderer.md +1 -1
- package/docs/components/table.md +33 -0
- package/docs/how-to/forms-with-form-builder.md +3 -1
- package/package.json +1 -1
|
@@ -311,7 +311,7 @@ function DataViewsRoot({
|
|
|
311
311
|
inherits it rather than landing on the black shell.
|
|
312
312
|
|
|
313
313
|
No border: Figma draws one, but every view already brings its own edge — the
|
|
314
|
-
table
|
|
314
|
+
table and the split views each draw their own panel border — so it only ever
|
|
315
315
|
read as a second outline around the first. */}
|
|
316
316
|
<div className="bg-background-presentation-form-base flex flex-1 overflow-hidden rounded-[16px]">
|
|
317
317
|
{/* Clip the scrollable surface to the parent radius minus its 1px border
|
|
@@ -95,7 +95,7 @@ function FiltersRoot({
|
|
|
95
95
|
) : (
|
|
96
96
|
// Reading the colour from a token rather than a `text-white` literal is what lets
|
|
97
97
|
// the same controls sit in the dark rail and in the light content area.
|
|
98
|
-
<h3 className="text-content-presentation-global-primary text-[18px] font-[510] leading-[1.32] tracking-[-0.01em]">
|
|
98
|
+
<h3 className="text-content-presentation-global-primary min-w-0 truncate text-[18px] font-[510] leading-[1.32] tracking-[-0.01em]">
|
|
99
99
|
{title}
|
|
100
100
|
</h3>
|
|
101
101
|
))}
|
|
@@ -44,8 +44,11 @@ export function Header({ title, children, className }: HeaderProps) {
|
|
|
44
44
|
>
|
|
45
45
|
{title !== undefined && (
|
|
46
46
|
<>
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
{/* `min-w-0`, not `shrink-0`: the pill has to be able to give up width, or a long title
|
|
48
|
+
pushes the view switch, actions and panel toggle off the end of the bar. It yields
|
|
49
|
+
first and the title ellipsises instead. */}
|
|
50
|
+
<div className="flex h-9 min-w-0 items-center gap-2 rounded-[12px] border border-[#434446] bg-[#252729] px-[10px]">
|
|
51
|
+
<span className="truncate text-[28px] font-[510] uppercase leading-[1.19] text-white">
|
|
49
52
|
{title}
|
|
50
53
|
</span>
|
|
51
54
|
</div>
|
|
@@ -40,7 +40,9 @@ export function Section({
|
|
|
40
40
|
aria-controls={bodyId}
|
|
41
41
|
/>
|
|
42
42
|
) : (
|
|
43
|
-
|
|
43
|
+
// No `min-w-0` needed here, unlike the other headings: the parent is `flex flex-col`,
|
|
44
|
+
// so this already stretches to the column's width rather than sizing to its text.
|
|
45
|
+
<h3 className="text-content-presentation-global-primary truncate text-[18px] font-[510] leading-[1.32] tracking-[-0.01em]">
|
|
44
46
|
{title}
|
|
45
47
|
</h3>
|
|
46
48
|
))}
|
|
@@ -356,7 +356,9 @@ function ColumnHeader({
|
|
|
356
356
|
COLUMN_BG[group.color ?? "gray"],
|
|
357
357
|
)}
|
|
358
358
|
>
|
|
359
|
-
|
|
359
|
+
{/* `min-w-0` because this is a flex item: without it the heading refuses to shrink below its
|
|
360
|
+
text and a long group name pushes the action button out of the column. */}
|
|
361
|
+
<h3 className="typography-headers-small-medium text-content-presentation-global-primary-light min-w-0 truncate">
|
|
360
362
|
{group.label}
|
|
361
363
|
</h3>
|
|
362
364
|
{onAction && (
|
|
@@ -67,9 +67,12 @@ function InboxViewImpl({
|
|
|
67
67
|
)}
|
|
68
68
|
>
|
|
69
69
|
<li className="border-border-presentation-global-primary border-b px-3 py-2">
|
|
70
|
+
{/* `block` before `truncate`: the parent is a plain `<li>`, so this span would otherwise
|
|
71
|
+
be inline — and `overflow` has no effect on an inline box, which would leave the
|
|
72
|
+
ellipsis silently doing nothing. */}
|
|
70
73
|
<span
|
|
71
74
|
style={{ fontFeatureSettings: "'cv05' on" }}
|
|
72
|
-
className="typography-display-medium-medium text-content-presentation-global-primary uppercase"
|
|
75
|
+
className="typography-display-medium-medium text-content-presentation-global-primary block truncate uppercase"
|
|
73
76
|
>
|
|
74
77
|
{titleField?.label ?? "inbox"}
|
|
75
78
|
</span>
|
|
@@ -134,7 +134,11 @@ function useIsActive(element: { value?: string }, defaultValue: string) {
|
|
|
134
134
|
export const PaneTable = markPaneView(function PaneTable(props: TreePaneTableProps) {
|
|
135
135
|
const active = useIsActive({ value: props.value }, "table");
|
|
136
136
|
if (!active) return null;
|
|
137
|
-
|
|
137
|
+
// Standalone, `TableView` draws its own border and radius so it reads as a separated surface.
|
|
138
|
+
// In here it is not standalone: the pane around it already draws exactly that border and radius
|
|
139
|
+
// (`tree-view.tsx`), so leaving the table's on nests one inside the other a pixel apart.
|
|
140
|
+
// `TreePaneTableProps` omits `className`, so there is no caller value to merge with.
|
|
141
|
+
return <TableView {...withoutTabProps(props)} className="rounded-none border-0" />;
|
|
138
142
|
}, { defaultValue: "table", defaultLabel: "List", defaultIcon: <Table2 /> });
|
|
139
143
|
|
|
140
144
|
/**
|
|
@@ -115,7 +115,15 @@ function TableViewImpl({
|
|
|
115
115
|
|
|
116
116
|
return (
|
|
117
117
|
<DndContext {...contextProps}>
|
|
118
|
-
|
|
118
|
+
{/* Its own edge, matching the inbox and tree panels: this view is a surface on the shell,
|
|
119
|
+
not a fill of it. `overflow-hidden` keeps the rows clipped to the radius. */}
|
|
120
|
+
<div
|
|
121
|
+
className={cn(
|
|
122
|
+
"bg-background-presentation-form-base flex h-full overflow-hidden",
|
|
123
|
+
"border-border-presentation-global-primary rounded-[16px] border",
|
|
124
|
+
className,
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
119
127
|
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
|
|
120
128
|
<div
|
|
121
129
|
ref={scrollRef}
|
|
@@ -125,12 +133,30 @@ function TableViewImpl({
|
|
|
125
133
|
className="min-w-0 flex-1 overflow-auto rounded-lg"
|
|
126
134
|
>
|
|
127
135
|
<Table ref={tableRef} className="w-full">
|
|
128
|
-
|
|
136
|
+
{/* The header sticks to the scroller above by default — that lives on `TableHeader`
|
|
137
|
+
in the primitive. Two things this view adds:
|
|
138
|
+
|
|
139
|
+
Opacity. The primitive's header token is translucent, so scrolling rows read
|
|
140
|
+
straight through it; the primitive can't fix that without naming a surface colour
|
|
141
|
+
it doesn't know. Here the surface *is* known — the view root above sets
|
|
142
|
+
`form-base` — so paint that as the background-color (tailwind-merge drops the
|
|
143
|
+
primitive's translucent one, same `bg-color` group) and re-apply the tint as a
|
|
144
|
+
background-image, which stacks above background-color. Composited that is the
|
|
145
|
+
exact colour the header already had, just no longer see-through.
|
|
146
|
+
|
|
147
|
+
And `shadow-none`, because the drop shadow reads as a seam now that the view
|
|
148
|
+
carries its own border. */}
|
|
149
|
+
<TableHeader className="bg-background-presentation-form-base bg-[image:linear-gradient(var(--background-presentation-form-header),var(--background-presentation-form-header))] shadow-none">
|
|
129
150
|
<TableRow>
|
|
130
151
|
{onRowMove && <TableHead isDummy className="w-8" />}
|
|
131
152
|
{selectable && (
|
|
132
153
|
<TableHead isDummy className="w-12">
|
|
154
|
+
{/* Size is stated on both this and the per-row checkbox rather than left to
|
|
155
|
+
`Checkbox`'s default — they have to agree, and relying on the default on
|
|
156
|
+
one side only is what previously made the select-all bigger than the
|
|
157
|
+
column it heads. */}
|
|
133
158
|
<Checkbox
|
|
159
|
+
size="M"
|
|
134
160
|
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
|
135
161
|
onCheckedChange={toggleAll}
|
|
136
162
|
aria-label="Select all rows"
|
|
@@ -149,7 +175,11 @@ function TableViewImpl({
|
|
|
149
175
|
// so a screen reader hears one identical button per column.
|
|
150
176
|
sortLabel={field.label ?? formatPathLabel(field.path)}
|
|
151
177
|
>
|
|
152
|
-
{
|
|
178
|
+
{/* Wrapped rather than handed to `Table` as bare text: `truncate` needs a box
|
|
179
|
+
to clip, and the label's parent in the primitive is already `flex min-w-0`
|
|
180
|
+
so this span shrinks and ellipsises instead of wrapping the header row
|
|
181
|
+
onto a second line. */}
|
|
182
|
+
<span className="truncate">{field.label ?? field.path}</span>
|
|
153
183
|
</TableHead>
|
|
154
184
|
))}
|
|
155
185
|
</TableRow>
|
|
@@ -203,7 +233,7 @@ function TableViewImpl({
|
|
|
203
233
|
attributes, so it cannot express a controlled checkbox. */}
|
|
204
234
|
<div className="flex items-center justify-center">
|
|
205
235
|
<Checkbox
|
|
206
|
-
size="
|
|
236
|
+
size="M"
|
|
207
237
|
checked={selected}
|
|
208
238
|
onCheckedChange={() => toggleRow(id)}
|
|
209
239
|
aria-label="Select row"
|
|
@@ -253,8 +253,8 @@ function TreeViewImpl({
|
|
|
253
253
|
|
|
254
254
|
return (
|
|
255
255
|
// A split view, so the gap between the rail and the pane is part of the design: it paints the
|
|
256
|
-
// shell's black over the Master Container's surface, and the two cards float on it. The
|
|
257
|
-
//
|
|
256
|
+
// shell's black over the Master Container's surface, and the two cards float on it. The board
|
|
257
|
+
// fills its container instead — the table now carries its own border, like these panels.
|
|
258
258
|
<div className={cn("flex h-full gap-2 bg-black", className)}>
|
|
259
259
|
<div
|
|
260
260
|
className={cn(
|
|
@@ -265,9 +265,11 @@ function TreeViewImpl({
|
|
|
265
265
|
)}
|
|
266
266
|
>
|
|
267
267
|
<div className="border-border-presentation-global-primary border-b px-3 py-2">
|
|
268
|
+
{/* `block` before `truncate` — the parent is a plain div, so an inline span would clip
|
|
269
|
+
nothing. Same reason as the inbox's panel header. */}
|
|
268
270
|
<span
|
|
269
271
|
style={{ fontFeatureSettings: "'cv05' on" }}
|
|
270
|
-
className="typography-display-medium-medium text-content-presentation-global-primary uppercase"
|
|
272
|
+
className="typography-display-medium-medium text-content-presentation-global-primary block truncate uppercase"
|
|
271
273
|
>
|
|
272
274
|
{labelField?.label ?? "categories"}
|
|
273
275
|
</span>
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { createContext, useContext } from "react";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* How a field row lays out its label against its control.
|
|
7
|
+
*
|
|
8
|
+
* `"flexible"` is the responsive one: stacked, then label-beside-control once the field row itself
|
|
9
|
+
* is past the container `md` breakpoint. It is what you get by leaving `fieldDirection` unset — and
|
|
10
|
+
* it is assignable so you can ask for it back where something else defaults you away from it, most
|
|
11
|
+
* notably a `FormRenderer` drawer, which pins `"vertical"`.
|
|
12
|
+
*/
|
|
13
|
+
export type FieldDirection = "horizontal" | "vertical" | "flexible";
|
|
6
14
|
|
|
7
15
|
/** Loading flag — drives the Submit spinner and disables inputs. */
|
|
8
16
|
export const LoadingContext = createContext<boolean>(false);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { ReactNode } from "react";
|
|
4
4
|
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
5
6
|
import { Button } from "../Button";
|
|
6
7
|
import { useFormId, useLoading } from "./context";
|
|
7
8
|
|
|
@@ -28,9 +29,17 @@ export function SubmitButton({ children, className, loadingText, form }: SubmitB
|
|
|
28
29
|
// Defaults to the enclosing form's id, so a Save in the header (outside the `<form>`)
|
|
29
30
|
// still submits it via native form-association.
|
|
30
31
|
form={form ?? ctxFormId}
|
|
31
|
-
|
|
32
|
+
// The blue fill, because Save is the form's primary action and a primary action is blue.
|
|
33
|
+
// Deliberately not a prop: a form has one primary action, and letting each caller pick a
|
|
34
|
+
// variant is how the rule stops being a rule.
|
|
35
|
+
variant="BluColStyle"
|
|
32
36
|
is_loading={loading}
|
|
33
|
-
|
|
37
|
+
// `w-fit` because the FormBuilder root is a flex COLUMN: a direct child with `width: auto`
|
|
38
|
+
// inherits `align-items: stretch` and spans the whole form. Sections want that (SectionBlock
|
|
39
|
+
// sets its own `w-full`); a Save button does not. `w-fit` rather than `self-start` so the
|
|
40
|
+
// header action bar — a row with `items-center` — is untouched at every button size.
|
|
41
|
+
// Merged through `cn` so a caller can still opt into `className="w-full"` in a narrow drawer.
|
|
42
|
+
className={cn("w-fit", className)}
|
|
34
43
|
>
|
|
35
44
|
{loading ? (loadingText ?? children ?? "Saving…") : (children ?? "Save")}
|
|
36
45
|
</Button>
|
|
@@ -276,7 +276,11 @@ export interface FormBuilderRootProps<T extends FieldValues = FieldValues> {
|
|
|
276
276
|
values?: T;
|
|
277
277
|
/** Loading flag — Submit shows a spinner and inputs disable. */
|
|
278
278
|
loading?: boolean;
|
|
279
|
-
/**
|
|
279
|
+
/**
|
|
280
|
+
* Field row direction. Unset means `"flexible"` — stacked, then label-beside-control once the
|
|
281
|
+
* field row passes the container `md` breakpoint. `FormRenderer` pins `"vertical"` in a drawer;
|
|
282
|
+
* pass `"flexible"` there to get the responsive layout back.
|
|
283
|
+
*/
|
|
280
284
|
fieldDirection?: FieldDirection;
|
|
281
285
|
/** Reset to defaults after a successful submit. */
|
|
282
286
|
resetOnSuccess?: boolean;
|
|
@@ -159,10 +159,22 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
|
159
159
|
// inside it: a form embedded in a 260px rail has no room to give up 96px to gutters.
|
|
160
160
|
const fieldsColumn = <div className="mx-auto w-full max-w-[1100px] px-[48px]">{formEl}</div>;
|
|
161
161
|
|
|
162
|
-
// Inside the form surface: the stepper rail beside the fields. Columns never wrap — the
|
|
163
|
-
//
|
|
162
|
+
// Inside the form surface: the stepper rail beside the fields. Columns never wrap — the layout
|
|
163
|
+
// stays side-by-side at every screen size; when space runs short the fields column shrinks and
|
|
164
|
+
// the rail's labels truncate.
|
|
165
|
+
//
|
|
166
|
+
// `minmax(180px,1fr)` and not a bare `1fr` on the side tracks. `1fr` means `minmax(auto,1fr)`,
|
|
167
|
+
// whose *auto* minimum stops a track shrinking below its content — so a long step label made the
|
|
168
|
+
// rail's track wider than the empty track opposite it, and the two side tracks being equal is the
|
|
169
|
+
// only thing centring the middle column. Hovering a step made it visibly worse: the label's
|
|
170
|
+
// padding grows 6px→9px on hover (FormStepper), so the centre jumped 3px every time.
|
|
171
|
+
//
|
|
172
|
+
// The minimum is a fixed 180px rather than `auto` precisely so it cannot depend on the labels:
|
|
173
|
+
// both side tracks size identically whatever the rail holds, so the middle column stays centred,
|
|
174
|
+
// and the rail still has room to be legible (a plain `minmax(0,…)` let it collapse to 0 in a
|
|
175
|
+
// narrow preview frame). Past that floor the labels truncate instead of pushing.
|
|
164
176
|
const bodyInner = isStepper ? (
|
|
165
|
-
<div className="grid w-full grid-cols-[
|
|
177
|
+
<div className="grid w-full grid-cols-[minmax(180px,1fr)_minmax(0,1100px)_minmax(180px,1fr)] gap-8">
|
|
166
178
|
<StepperNav control={formInstance.control as Control<FieldValues>} />
|
|
167
179
|
{fieldsColumn}
|
|
168
180
|
{/* Empty third column — balances the rail's gutter so the middle column is centred. */}
|
|
@@ -113,7 +113,10 @@ function StepperNav({ control }: { control: Control<FieldValues> }) {
|
|
|
113
113
|
[...(stepFields[index] ?? [])].some((name) => name in errors);
|
|
114
114
|
|
|
115
115
|
return (
|
|
116
|
-
|
|
116
|
+
// `min-w-0` so the rail can be squeezed: its grid track no longer grows to fit a long label
|
|
117
|
+
// (see form-renderer.tsx), so the column has to be allowed to shrink and let the labels
|
|
118
|
+
// truncate instead of spilling over the fields column.
|
|
119
|
+
<FormStepper activeStep={currentStep} className="min-w-0 shrink-0 flex-col items-start gap-[4px]">
|
|
117
120
|
{titles.map((title, index) => {
|
|
118
121
|
// The step buttons ARE the navigation: click to move. Backward is free;
|
|
119
122
|
// clicking forward validates the steps in between (goToStep) and stops at
|
|
@@ -126,9 +129,21 @@ function StepperNav({ control }: { control: Control<FieldValues> }) {
|
|
|
126
129
|
: "default";
|
|
127
130
|
return (
|
|
128
131
|
<React.Fragment key={title}>
|
|
129
|
-
|
|
132
|
+
{/* The shrink/truncate pair is passed in rather than changed in `FormStepper`, so its
|
|
133
|
+
other consumers keep sizing to their labels. `shrink` beats the pill's own
|
|
134
|
+
`shrink-0` and `truncate` beats the label's `whitespace-nowrap` — both via
|
|
135
|
+
tailwind-merge, which `FormStepper` runs the caller's className through. */}
|
|
136
|
+
<FormStep
|
|
137
|
+
index={index}
|
|
138
|
+
type={type}
|
|
139
|
+
onClick={() => void goToStep(index)}
|
|
140
|
+
// `max-w-full` is what actually makes the label truncate: the rail is
|
|
141
|
+
// `items-start`, so without a cap the pill shrink-wraps its label and grows straight
|
|
142
|
+
// past the track instead of clipping inside it.
|
|
143
|
+
className="min-w-0 max-w-full shrink"
|
|
144
|
+
>
|
|
130
145
|
<FormStepIndicator />
|
|
131
|
-
<FormStepLabel>{title}</FormStepLabel>
|
|
146
|
+
<FormStepLabel className="truncate">{title}</FormStepLabel>
|
|
132
147
|
</FormStep>
|
|
133
148
|
{/* Connector between steps — a 3×16 rounded bar centred under the badge. */}
|
|
134
149
|
{index < titles.length - 1 && (
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
export type FormRendererDisplay = "page" | "drawer";
|
|
19
|
-
export type FieldDirection = "horizontal" | "vertical";
|
|
19
|
+
export type FieldDirection = "horizontal" | "vertical" | "flexible";
|
|
20
20
|
|
|
21
21
|
export interface FormRendererProps<T extends FieldValues = FieldValues> {
|
|
22
22
|
/** The form body — `FormRenderer.Section` / field / `FormRenderer.Stepper` JSX. */
|
|
@@ -14,7 +14,17 @@ const titleBadge = cva(
|
|
|
14
14
|
Orange: "bg-red-orange-900",
|
|
15
15
|
Purple: "bg-violet-900",
|
|
16
16
|
Pink: "bg-medium-violet-red-900",
|
|
17
|
-
Gray
|
|
17
|
+
// Per Figma (`FormHeader10`, Color=Gray), which pairs two *variables*: the pill is
|
|
18
|
+
// `background/presentation/button/primary` and its label `content/presentation/global/
|
|
19
|
+
// primary-inverse`. They invert together — a light pill with dark text in the dark theme,
|
|
20
|
+
// dark pill with white text in the light one — so the text token has to ride along on the
|
|
21
|
+
// variant. It overrides the base `text-[#F4F4F4]` because `titleBadge` goes through `cn`
|
|
22
|
+
// (tailwind-merge) below; the other seven are dark palette chips that still want that base.
|
|
23
|
+
//
|
|
24
|
+
// This previously read `bg-background-presentation-badge-gray`, which is not a token at all:
|
|
25
|
+
// every badge colour has `-solid`/`-subtle` and none has a bare name, so the utility was
|
|
26
|
+
// never generated and a Gray badge painted no background whatsoever.
|
|
27
|
+
Gray: "bg-background-presentation-button-primary text-content-presentation-global-primary-inverse",
|
|
18
28
|
},
|
|
19
29
|
},
|
|
20
30
|
defaultVariants: { color: "Blue" },
|
|
@@ -18,6 +18,20 @@ const Table = React.forwardRef<
|
|
|
18
18
|
<table
|
|
19
19
|
data-theme={theme}
|
|
20
20
|
ref={ref}
|
|
21
|
+
// `overflow-hidden` is the default, and it is load-bearing twice over: the table is `w-auto`,
|
|
22
|
+
// so one wider than its container would otherwise push the whole page wide and put a horizontal
|
|
23
|
+
// scrollbar on the layout; and callers that give the table a radius rely on it to clip the
|
|
24
|
+
// square header band out of the rounded corners.
|
|
25
|
+
//
|
|
26
|
+
// The cost is that it makes the table its own scroll container, and `TableHeader`'s `sticky`
|
|
27
|
+
// resolves against the *nearest* scrollport — so inside a clipping table the header pins to a
|
|
28
|
+
// box that never scrolls, i.e. does nothing. That is the right default: a header only benefits
|
|
29
|
+
// from sticking when the table sits in a scroller, and such a caller passes `overflow-visible`
|
|
30
|
+
// (tailwind-merge lets theirs win) to bind it to that scroller instead. Only do that where the
|
|
31
|
+
// scroller is `min-w-0`, or the width problem above comes back — `DataViews`' table view is the
|
|
32
|
+
// worked example.
|
|
33
|
+
//
|
|
34
|
+
// `[border-collapse:separate]` is what lets the header cells keep their borders while stuck.
|
|
21
35
|
className={cn("overflow-hidden w-auto [border-collapse:separate] border-spacing-0", className)}
|
|
22
36
|
{...props}
|
|
23
37
|
>
|
|
@@ -34,9 +48,20 @@ const TableHeader = React.forwardRef<
|
|
|
34
48
|
ref={ref}
|
|
35
49
|
// The header band is one continuous bar, not a per-cell background — a background on each
|
|
36
50
|
// `<th>` would paint over the half of the previous column's resize handle that overhangs
|
|
37
|
-
// the boundary.
|
|
51
|
+
// the boundary. That is also why `sticky` sits here rather than on the `<th>`s: per-cell
|
|
52
|
+
// sticky would leave this background behind and let rows show through it.
|
|
53
|
+
//
|
|
54
|
+
// `z-20` clears the `z-10` on the resize handle and sort button inside the cells. In a table
|
|
55
|
+
// that never scrolls vertically, `sticky` keeps the element in flow and changes nothing.
|
|
56
|
+
//
|
|
57
|
+
// The band's token is translucent (`#0003`), so once rows do slide under it they read through.
|
|
58
|
+
// Making it opaque is deliberately NOT done here: it would mean naming one surface colour, and
|
|
59
|
+
// this header paints over whatever its caller sits on — forcing a surface would visibly recolour
|
|
60
|
+
// every table not on that one. A caller whose table actually scrolls supplies the opaque
|
|
61
|
+
// background itself, where the surface is known; `DataViews`' table view is the worked example.
|
|
38
62
|
className={cn(
|
|
39
63
|
"bg-background-presentation-form-header backdrop-blur-[8px]",
|
|
64
|
+
"sticky top-0 z-20",
|
|
40
65
|
"shadow-[0px_4px_8px_0px_rgba(0,0,0,0.15)]",
|
|
41
66
|
className,
|
|
42
67
|
)}
|
|
@@ -108,28 +133,28 @@ const MIN_COLUMN_WIDTH = 40;
|
|
|
108
133
|
const TableHead = React.forwardRef<
|
|
109
134
|
HTMLTableCellElement,
|
|
110
135
|
React.ThHTMLAttributes<HTMLTableCellElement> &
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
136
|
+
TableHeadVariantsProps &
|
|
137
|
+
React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
138
|
+
sortType?: "asc" | "desc" | undefined;
|
|
139
|
+
onSort?: () => void;
|
|
140
|
+
/** Column name for the sort button's accessible label. */
|
|
141
|
+
sortLabel?: string;
|
|
142
|
+
/**
|
|
143
|
+
* Called with the new width (px) while the column is being drag-resized. Pass it to take
|
|
144
|
+
* **control** of the width — the header then renders `style.width` and expects you to
|
|
145
|
+
* feed the new value back. Required whenever the table needs a definite width
|
|
146
|
+
* (`table-layout: fixed`), because only the owner of every column width can total them.
|
|
147
|
+
* Omit for uncontrolled resizing, where the header keeps the width itself.
|
|
148
|
+
*/
|
|
149
|
+
onResize?: (width: number) => void;
|
|
150
|
+
/**
|
|
151
|
+
* Classes for the inner layout box (the flex row holding the label and sort toggle).
|
|
152
|
+
* `className` and every other prop go to the `<th>` — reach for this only when you need
|
|
153
|
+
* to restyle the content box itself, e.g. its typography or colour.
|
|
154
|
+
*/
|
|
155
|
+
contentClassName?: string;
|
|
156
|
+
isDummy?: boolean;
|
|
157
|
+
}
|
|
133
158
|
>(
|
|
134
159
|
(
|
|
135
160
|
{
|
|
@@ -287,11 +312,11 @@ const TableCell = React.forwardRef<
|
|
|
287
312
|
// Never fade a dummy cell — its content is a centred checkbox or drag handle, not
|
|
288
313
|
// clippable text.
|
|
289
314
|
fade &&
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
315
|
+
!isDummy && [
|
|
316
|
+
"[mask-image:linear-gradient(to_right,black_0%,black_0%,black_75%,transparent_100%)]",
|
|
317
|
+
"rtl:[mask-image:linear-gradient(to_left,black_0%,black_0%,black_75%,transparent_100%)]",
|
|
318
|
+
"[&:has(input)]:[mask-image:none]",
|
|
319
|
+
],
|
|
295
320
|
{ "min-w-fit justify-center": isDummy },
|
|
296
321
|
childrenClassName,
|
|
297
322
|
)}
|
|
@@ -307,7 +332,7 @@ const TableCheckbox = React.forwardRef<
|
|
|
307
332
|
React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
308
333
|
id: string;
|
|
309
334
|
}
|
|
310
|
-
|
|
335
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- id destructured to exclude it from the props spread onto Checkbox
|
|
311
336
|
>(({ className, id, ...props }, ref) => {
|
|
312
337
|
return (
|
|
313
338
|
<div className={cn(["flex items-center justify-center"], className)}>
|
|
@@ -311,68 +311,68 @@ export const TreeFolder = forwardRef<TreeFolderHandle, TreeFolderProps>(
|
|
|
311
311
|
|
|
312
312
|
<TreeFolderStyles />
|
|
313
313
|
<DndContext {...contextProps}>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
314
|
+
<div ref={scrollRef} role="tree" className="tf-scroll flex-1 min-h-0 overflow-auto pt-[6px]">
|
|
315
|
+
{isEmpty ? (
|
|
316
|
+
(emptyState ?? (
|
|
317
|
+
<div className="text-xs text-content-presentation-global-tertiary p-3">
|
|
318
|
+
Nothing here yet.
|
|
319
|
+
</div>
|
|
320
|
+
))
|
|
321
|
+
) : (
|
|
322
|
+
<div className="min-w-max" style={stripStyle}>
|
|
323
|
+
<DragList ids={ids} makeRoom={false}>
|
|
324
|
+
{visibleRows.map((row, idx) => {
|
|
325
|
+
const prevRow = visibleRows[idx - 1];
|
|
326
|
+
const nextRow = visibleRows[idx + 1];
|
|
327
|
+
const isSelected = selectedId === row.node.id;
|
|
328
|
+
const isDescendant = isDescendantOfSelected(row.node.id) && !isSelected;
|
|
329
|
+
const inBand = isSelected || isDescendant;
|
|
330
|
+
|
|
331
|
+
// Neighbour-aware band rounding: a row is "in-band" if it's the selected
|
|
332
|
+
// node itself or one of its descendants. The Set lookup handles deep nesting.
|
|
333
|
+
const prevInBand =
|
|
334
|
+
inBand &&
|
|
335
|
+
!!prevRow &&
|
|
336
|
+
(prevRow.node.id === selectedId || descendantIdSet.has(prevRow.node.id));
|
|
337
|
+
const nextInBand =
|
|
338
|
+
inBand &&
|
|
339
|
+
!!nextRow &&
|
|
340
|
+
(nextRow.node.id === selectedId || descendantIdSet.has(nextRow.node.id));
|
|
341
|
+
|
|
342
|
+
const isDragging = dragIdSet.has(row.node.id);
|
|
343
|
+
const isDropTargetInside =
|
|
344
|
+
dropTarget?.rowId === row.node.id && dropTarget.position === "inside";
|
|
345
|
+
const isDropBefore =
|
|
346
|
+
dropTarget?.rowId === row.node.id && dropTarget.position === "before";
|
|
347
|
+
const isDropAfter =
|
|
348
|
+
dropTarget?.rowId === row.node.id && dropTarget.position === "after";
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
<TreeFolderRow
|
|
352
|
+
key={row.node.id}
|
|
353
|
+
row={row}
|
|
354
|
+
rowHeight={rowHeight}
|
|
355
|
+
indent={indent}
|
|
356
|
+
iconFor={iconFor}
|
|
357
|
+
isSelected={isSelected}
|
|
358
|
+
isAncestor={isAncestorFn(row.node.id) && !isSelected}
|
|
359
|
+
isDescendantOfSelected={isDescendant}
|
|
360
|
+
isPrevInBand={prevInBand}
|
|
361
|
+
isNextInBand={nextInBand}
|
|
362
|
+
isDragging={isDragging}
|
|
363
|
+
isDropTargetInside={isDropTargetInside}
|
|
364
|
+
isDropBefore={isDropBefore}
|
|
365
|
+
isDropAfter={isDropAfter}
|
|
366
|
+
dndEnabled={dndEnabled}
|
|
367
|
+
onSelect={handleSelect}
|
|
368
|
+
onToggle={handleToggle}
|
|
369
|
+
/>
|
|
370
|
+
);
|
|
371
|
+
})}
|
|
372
|
+
</DragList>
|
|
319
373
|
</div>
|
|
320
|
-
)
|
|
321
|
-
|
|
322
|
-
<div className="min-w-max" style={stripStyle}>
|
|
323
|
-
<DragList ids={ids} makeRoom={false}>
|
|
324
|
-
{visibleRows.map((row, idx) => {
|
|
325
|
-
const prevRow = visibleRows[idx - 1];
|
|
326
|
-
const nextRow = visibleRows[idx + 1];
|
|
327
|
-
const isSelected = selectedId === row.node.id;
|
|
328
|
-
const isDescendant = isDescendantOfSelected(row.node.id) && !isSelected;
|
|
329
|
-
const inBand = isSelected || isDescendant;
|
|
330
|
-
|
|
331
|
-
// Neighbour-aware band rounding: a row is "in-band" if it's the selected
|
|
332
|
-
// node itself or one of its descendants. The Set lookup handles deep nesting.
|
|
333
|
-
const prevInBand =
|
|
334
|
-
inBand &&
|
|
335
|
-
!!prevRow &&
|
|
336
|
-
(prevRow.node.id === selectedId || descendantIdSet.has(prevRow.node.id));
|
|
337
|
-
const nextInBand =
|
|
338
|
-
inBand &&
|
|
339
|
-
!!nextRow &&
|
|
340
|
-
(nextRow.node.id === selectedId || descendantIdSet.has(nextRow.node.id));
|
|
341
|
-
|
|
342
|
-
const isDragging = dragIdSet.has(row.node.id);
|
|
343
|
-
const isDropTargetInside =
|
|
344
|
-
dropTarget?.rowId === row.node.id && dropTarget.position === "inside";
|
|
345
|
-
const isDropBefore =
|
|
346
|
-
dropTarget?.rowId === row.node.id && dropTarget.position === "before";
|
|
347
|
-
const isDropAfter =
|
|
348
|
-
dropTarget?.rowId === row.node.id && dropTarget.position === "after";
|
|
349
|
-
|
|
350
|
-
return (
|
|
351
|
-
<TreeFolderRow
|
|
352
|
-
key={row.node.id}
|
|
353
|
-
row={row}
|
|
354
|
-
rowHeight={rowHeight}
|
|
355
|
-
indent={indent}
|
|
356
|
-
iconFor={iconFor}
|
|
357
|
-
isSelected={isSelected}
|
|
358
|
-
isAncestor={isAncestorFn(row.node.id) && !isSelected}
|
|
359
|
-
isDescendantOfSelected={isDescendant}
|
|
360
|
-
isPrevInBand={prevInBand}
|
|
361
|
-
isNextInBand={nextInBand}
|
|
362
|
-
isDragging={isDragging}
|
|
363
|
-
isDropTargetInside={isDropTargetInside}
|
|
364
|
-
isDropBefore={isDropBefore}
|
|
365
|
-
isDropAfter={isDropAfter}
|
|
366
|
-
dndEnabled={dndEnabled}
|
|
367
|
-
onSelect={handleSelect}
|
|
368
|
-
onToggle={handleToggle}
|
|
369
|
-
/>
|
|
370
|
-
);
|
|
371
|
-
})}
|
|
372
|
-
</DragList>
|
|
373
|
-
</div>
|
|
374
|
-
)}
|
|
375
|
-
</div>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
376
|
</DndContext>
|
|
377
377
|
</div>
|
|
378
378
|
);
|
package/apps/lib/registry.json
CHANGED