torch-glare 2.5.2 → 2.5.4

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 (51) hide show
  1. package/apps/lib/components/ActionButton.tsx +39 -6
  2. package/apps/lib/components/Badge.tsx +5 -2
  3. package/apps/lib/components/BadgeField.tsx +1 -1
  4. package/apps/lib/components/Button.tsx +4 -4
  5. package/apps/lib/components/Calendar.tsx +7 -17
  6. package/apps/lib/components/ColorPicker.tsx +1 -1
  7. package/apps/lib/components/DataViews/data-views.tsx +1 -1
  8. package/apps/lib/components/DataViews/filters/filters.tsx +1 -1
  9. package/apps/lib/components/DataViews/header.tsx +5 -2
  10. package/apps/lib/components/DataViews/panel/section.tsx +3 -1
  11. package/apps/lib/components/DataViews/views/board-view.tsx +3 -1
  12. package/apps/lib/components/DataViews/views/inbox-view.tsx +4 -1
  13. package/apps/lib/components/DataViews/views/pane-views.tsx +5 -1
  14. package/apps/lib/components/DataViews/views/table-view.tsx +67 -8
  15. package/apps/lib/components/DataViews/views/tree-view.tsx +5 -3
  16. package/apps/lib/components/Drawer.tsx +18 -1
  17. package/apps/lib/components/FormBuilder/context.ts +9 -1
  18. package/apps/lib/components/FormBuilder/submit.tsx +11 -2
  19. package/apps/lib/components/FormBuilder/types.ts +5 -1
  20. package/apps/lib/components/FormRenderer/form-renderer.tsx +15 -3
  21. package/apps/lib/components/FormRenderer/stepper.tsx +35 -14
  22. package/apps/lib/components/FormRenderer/types.ts +1 -1
  23. package/apps/lib/components/Input.tsx +19 -4
  24. package/apps/lib/components/Popover.tsx +93 -55
  25. package/apps/lib/components/SearchableSelect.tsx +12 -11
  26. package/apps/lib/components/SearchableTree.tsx +10 -11
  27. package/apps/lib/components/SearchableTreeDialog.tsx +10 -11
  28. package/apps/lib/components/SectionBlock.tsx +14 -2
  29. package/apps/lib/components/Select.tsx +35 -11
  30. package/apps/lib/components/SimpleSelect.tsx +2 -2
  31. package/apps/lib/components/SlideDatePicker.tsx +17 -4
  32. package/apps/lib/components/Stepper.tsx +329 -180
  33. package/apps/lib/components/Switch.tsx +2 -2
  34. package/apps/lib/components/Table.tsx +55 -30
  35. package/apps/lib/components/Textarea.tsx +29 -4
  36. package/apps/lib/components/TreeDropDown.tsx +18 -6
  37. package/apps/lib/components/TreeFolder/TreeFolder.tsx +61 -61
  38. package/apps/lib/registry.json +7 -16
  39. package/apps/lib/tsconfig.tsbuildinfo +1 -1
  40. package/docs/components/action-button.md +3 -1
  41. package/docs/components/data-views/index.md +7 -0
  42. package/docs/components/form-builder.md +4 -2
  43. package/docs/components/form-renderer.md +1 -1
  44. package/docs/components/stepper.md +119 -24
  45. package/docs/components/table.md +33 -0
  46. package/docs/how-to/forms-with-form-builder.md +3 -1
  47. package/docs/reference/tailwind-plugins.md +42 -0
  48. package/docs/tutorials/getting-started.md +32 -11
  49. package/package.json +1 -1
  50. package/apps/lib/components/FormStepper.tsx +0 -272
  51. package/docs/components/form-stepper.md +0 -250
@@ -4,12 +4,24 @@ import { Button } from "./Button";
4
4
  import { cn } from "../utils/cn";
5
5
  import { ButtonVariant, Themes } from "../utils/types";
6
6
 
7
- const buttonVariants = cva("!rounded-[4px]", {
7
+ /**
8
+ * The button that sits at the end of an input field — Figma `ActionButton` (Type=DropDown).
9
+ *
10
+ * Sizes are Figma's exactly: Xs 18, S 22, M 32, with `radius/sm` on the two small ones and
11
+ * `radius/lg` on M.
12
+ *
13
+ * The `!` on the radius is load-bearing. `twMerge` does not dedupe `rounded-radius-*` — they are
14
+ * custom scale keys, not values it recognises — so this radius and the one coming from `Button`'s
15
+ * own `size` variant both survive, and CSS source order picks the winner. Tailwind emits the scale
16
+ * in config order (sm before md before lg), so without `!` size S would render Button M's 6px
17
+ * instead of its own 4px.
18
+ */
19
+ const buttonVariants = cva("", {
8
20
  variants: {
9
21
  size: {
10
- XS: "h-[18px] w-[18px] text-[12px]",
11
- S: "h-[22px] w-[22px] text-[12px]",
12
- M: "h-[32px] w-[32px] text-[18px]",
22
+ XS: "h-[18px] w-[18px] text-[12px] !rounded-radius-sm",
23
+ S: "h-[22px] w-[22px] text-[12px] !rounded-radius-sm",
24
+ M: "h-[32px] w-[32px] text-[18px] !rounded-radius-lg",
13
25
  },
14
26
  },
15
27
  defaultVariants: {
@@ -17,6 +29,23 @@ const buttonVariants = cva("!rounded-[4px]", {
17
29
  },
18
30
  });
19
31
 
32
+ /**
33
+ * Default/hover/disabled colours as Figma binds them. Applied only when the caller passes no
34
+ * `variant`, so anything asking for a specific Button variant still gets it.
35
+ *
36
+ * `action-secondary` and `action-disabled` already match `Button`'s `PrimeStyle` equivalents; the
37
+ * real difference is hover, where `PrimeStyle` uses `button-hover` and the design uses
38
+ * `action-hover`.
39
+ */
40
+ const actionTokens = [
41
+ "bg-background-presentation-action-secondary",
42
+ "text-content-presentation-action-light-primary",
43
+ "hover:bg-background-presentation-action-hover",
44
+ "hover:text-content-presentation-global-hover",
45
+ "disabled:bg-background-presentation-action-disabled",
46
+ "disabled:text-content-presentation-state-disabled",
47
+ ];
48
+
20
49
  interface Props
21
50
  extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
22
51
  is_loading?: boolean;
@@ -29,7 +58,6 @@ interface Props
29
58
  export const ActionButton = function ({
30
59
  size,
31
60
  asChild,
32
- // eslint-disable-next-line @typescript-eslint/no-unused-vars -- excluded from {...props} spread
33
61
  as: Tag = "button",
34
62
  className,
35
63
  variant,
@@ -42,14 +70,19 @@ export const ActionButton = function ({
42
70
  <Button
43
71
  theme={theme}
44
72
  asChild={asChild}
73
+ // Forwarded, not swallowed: the chevron inside a field renders `as="span"` because it is
74
+ // nested in a clickable trigger, and a <button> cannot contain a <button>.
75
+ as={Tag}
45
76
  buttonType="icon"
46
- type={type}
77
+ // `type` is only meaningful on a real button; on a span it would be an invalid attribute.
78
+ type={Tag === "button" ? type : undefined}
47
79
  size={size == "XS" ? "S" : size == "S" ? "M" : size == "M" ? "L" : "S"}
48
80
  variant={variant}
49
81
  className={cn(
50
82
  buttonVariants({
51
83
  size,
52
84
  }),
85
+ !variant && actionTokens,
53
86
  className,
54
87
  )}
55
88
  {...props}
@@ -118,7 +118,10 @@ const subtleCompoundVariants = [
118
118
  export const badgeStyles = cva(
119
119
  [
120
120
  "inline-flex items-center justify-center w-fit",
121
- "rounded-[6px]",
121
+ // `radius/md` (6px), read off the Badge master component in Figma. Note the badge instances
122
+ // nested inside InnerLabelField render at `radius/lg` — that is an instance override, not the
123
+ // component's own value, and reading it as the spec is how this briefly shipped as 8px.
124
+ "rounded-radius-md",
122
125
  "transition-all duration-200 ease-in-out",
123
126
  "whitespace-nowrap",
124
127
  "[&_i]:!leading-none",
@@ -215,7 +218,7 @@ export const Badge: React.FC<BadgeProps> = ({
215
218
  }
216
219
  }}
217
220
  className={cn(
218
- "rounded-[4px]",
221
+ "rounded-radius-sm",
219
222
  "flex items-center justify-center cursor-pointer",
220
223
  "hover:bg-background-presentation-action-secondary",
221
224
  "transition-colors duration-150",
@@ -222,7 +222,7 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
222
222
  fill="black"
223
223
  />
224
224
  </svg>
225
- <p className="text-black-1000 text-right text-[12px] font-[510] leading-[148%]">
225
+ <p className="text-black-1000 text-end text-[12px] font-[510] leading-[148%]">
226
226
  {addLabel}
227
227
  </p>
228
228
  </div>
@@ -248,10 +248,10 @@ export const buttonVariants = cva(
248
248
  ],
249
249
  },
250
250
  size: {
251
- S: "h-[22px] py-[2px] px-[6px] typography-body-small-medium rounded-[4px] [&_i]:text-[12px]",
252
- M: "h-[28px] py-[2px] px-[14px] typography-body-large-medium rounded-[6px] [&_i]:text-[18px]",
253
- L: "h-[34px] py-[5px] px-[22px] typography-body-large-medium rounded-[8px] [&_i]:text-[20px]",
254
- XL: "h-[40px] py-[8px] px-[30px] typography-headers-medium-medium rounded-[8px] [&_i]:text-[22px]",
251
+ S: "h-[22px] py-[2px] px-[6px] typography-body-small-medium rounded-radius-sm [&_i]:text-[12px]",
252
+ M: "h-[28px] py-[2px] px-[14px] typography-body-large-medium rounded-radius-md [&_i]:text-[18px]",
253
+ L: "h-[34px] py-[5px] px-[22px] typography-body-large-medium rounded-radius-lg [&_i]:text-[20px]",
254
+ XL: "h-[40px] py-[8px] px-[30px] typography-headers-medium-medium rounded-radius-lg [&_i]:text-[22px]",
255
255
  },
256
256
  is_loading: {
257
257
  true: "[&_i]:hidden",
@@ -4,7 +4,7 @@ import * as React from "react";
4
4
  import { DayPicker } from "react-day-picker";
5
5
  import "react-day-picker/style.css";
6
6
  import { cn } from "../utils/cn";
7
- import { Button } from "./Button";
7
+ import { ActionButton } from "./ActionButton";
8
8
  import { SimpleSelectValue, SimpleSelectItem } from "./SimpleSelect";
9
9
 
10
10
  export type CalendarProps = React.ComponentProps<typeof DayPicker>;
@@ -30,24 +30,14 @@ const Calendar = ({
30
30
  Nav: (e) => {
31
31
  return (
32
32
  <div className="w-full flex items-center justify-between absolute top-0 left-0 p-[6px]">
33
- <Button
34
- onClick={e.onPreviousClick}
35
- buttonType="icon"
36
- variant="PrimeStyle"
37
- size="M"
38
- className="w-[26px] h-[26px]"
39
- >
33
+ {/* Popover navigation rather than an in-field button, so it keeps its own 26px box
34
+ and only adopts the shared action colours and radius. */}
35
+ <ActionButton onClick={e.onPreviousClick} size="S" className="w-[26px] h-[26px]">
40
36
  <i className="ri-arrow-left-s-line"></i>
41
- </Button>
42
- <Button
43
- onClick={e.onNextClick}
44
- buttonType="icon"
45
- variant="PrimeStyle"
46
- size="M"
47
- className="w-[26px] h-[26px]"
48
- >
37
+ </ActionButton>
38
+ <ActionButton onClick={e.onNextClick} size="S" className="w-[26px] h-[26px]">
49
39
  <i className="ri-arrow-right-s-line"></i>
50
- </Button>
40
+ </ActionButton>
51
41
  </div>
52
42
  );
53
43
  },
@@ -404,7 +404,7 @@ export const ColorPicker = forwardRef<HTMLElement, ColorPickerProps>(
404
404
  }}
405
405
  className={NUM_INPUT_CLS}
406
406
  />
407
- <span className="pr-1 typography-body-small-regular text-content-presentation-action-light-secondary">
407
+ <span className="pe-1 typography-body-small-regular text-content-presentation-action-light-secondary">
408
408
  %
409
409
  </span>
410
410
  </Group>
@@ -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 its header rule, the split views their panel borders — so it only ever
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
- <div className="flex h-9 shrink-0 items-center gap-2 rounded-[12px] border border-[#434446] bg-[#252729] px-[10px]">
48
- <span className="text-[28px] font-[510] uppercase leading-[1.19] text-white">
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
- <h3 className="text-content-presentation-global-primary text-[18px] font-[510] leading-[1.32] tracking-[-0.01em]">
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
- <h3 className="typography-headers-small-medium text-content-presentation-global-primary-light">
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
- return <TableView {...withoutTabProps(props)} />;
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
- <div className={cn("bg-background-presentation-form-base flex h-full", className)}>
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}
@@ -124,13 +132,42 @@ function TableViewImpl({
124
132
  // pushes the whole component wide instead of scrolling within it.
125
133
  className="min-w-0 flex-1 overflow-auto rounded-lg"
126
134
  >
127
- <Table ref={tableRef} className="w-full">
128
- <TableHeader>
135
+ {/* One width for the table and the footer bar below it. Inside an `overflow-auto`
136
+ scroller a child's `w-full` resolves against the *visible* width, not the scrollable
137
+ width — so without this box the footer stopped at the fold whenever the columns
138
+ overflowed. `w-max` sizes to the table (the widest child); `min-w-full` keeps it
139
+ filling the scroller when the table is narrower. */}
140
+ <div className="w-max min-w-full">
141
+ {/* `overflow-visible` opts out of the primitive's `overflow-hidden` (tailwind-merge
142
+ lets ours win). It has to: a clipping table is its own scrollport, and the sticky
143
+ header would then resolve against a box that never scrolls. Safe here because the
144
+ scroller above is `min-w-0 overflow-auto`, so a wide table scrolls inside it rather
145
+ than pushing the layout. */}
146
+ <Table ref={tableRef} className="w-full overflow-visible">
147
+ {/* The header sticks to the scroller above by default — that lives on `TableHeader`
148
+ in the primitive. Two things this view adds:
149
+
150
+ Opacity. The primitive's header token is translucent, so scrolling rows read
151
+ straight through it; the primitive can't fix that without naming a surface colour
152
+ it doesn't know. Here the surface *is* known — the view root above sets
153
+ `form-base` — so paint that as the background-color (tailwind-merge drops the
154
+ primitive's translucent one, same `bg-color` group) and re-apply the tint as a
155
+ background-image, which stacks above background-color. Composited that is the
156
+ exact colour the header already had, just no longer see-through.
157
+
158
+ And `shadow-none`, because the drop shadow reads as a seam now that the view
159
+ carries its own border. */}
160
+ <TableHeader className="bg-background-presentation-form-base bg-[image:linear-gradient(var(--background-presentation-form-header),var(--background-presentation-form-header))] shadow-none">
129
161
  <TableRow>
130
162
  {onRowMove && <TableHead isDummy className="w-8" />}
131
163
  {selectable && (
132
164
  <TableHead isDummy className="w-12">
165
+ {/* Size is stated on both this and the per-row checkbox rather than left to
166
+ `Checkbox`'s default — they have to agree, and relying on the default on
167
+ one side only is what previously made the select-all bigger than the
168
+ column it heads. */}
133
169
  <Checkbox
170
+ size="M"
134
171
  checked={allSelected ? true : someSelected ? "indeterminate" : false}
135
172
  onCheckedChange={toggleAll}
136
173
  aria-label="Select all rows"
@@ -149,7 +186,11 @@ function TableViewImpl({
149
186
  // so a screen reader hears one identical button per column.
150
187
  sortLabel={field.label ?? formatPathLabel(field.path)}
151
188
  >
152
- {field.label ?? field.path}
189
+ {/* Wrapped rather than handed to `Table` as bare text: `truncate` needs a box
190
+ to clip, and the label's parent in the primitive is already `flex min-w-0`
191
+ so this span shrinks and ellipsises instead of wrapping the header row
192
+ onto a second line. */}
193
+ <span className="truncate">{field.label ?? field.path}</span>
153
194
  </TableHead>
154
195
  ))}
155
196
  </TableRow>
@@ -198,12 +239,12 @@ function TableViewImpl({
198
239
  >
199
240
  {onRowMove && <GripCell />}
200
241
  {selectable && (
201
- <TableCell isDummy className="h-[40px] w-12" onClick={(e) => e.stopPropagation()}>
242
+ <TableCell isDummy className="min-h-[40px] w-12" onClick={(e) => e.stopPropagation()}>
202
243
  {/* What `TableCheckbox` renders, inlined: its props are typed as button
203
244
  attributes, so it cannot express a controlled checkbox. */}
204
245
  <div className="flex items-center justify-center">
205
246
  <Checkbox
206
- size="S"
247
+ size="M"
207
248
  checked={selected}
208
249
  onCheckedChange={() => toggleRow(id)}
209
250
  aria-label="Select row"
@@ -215,8 +256,20 @@ function TableViewImpl({
215
256
  // `undefined` means "you paint it" — distinct from `null`, which is a
216
257
  // deliberate blank.
217
258
  const custom = renderCell?.({ field, row, id, index, fields: visibleFields });
259
+ // `fade={false}` and clip instead. The fade is a `mask-image` on every
260
+ // cell, close to free on its own but very expensive once a sticky header
261
+ // repaints the region each scroll frame: measured on this page,
262
+ // mask+sticky costs ~61ms/frame against ~29ms with the mask off and
263
+ // ~17ms with neither. `overflow-hidden` is passed back because
264
+ // `fade={false}` otherwise switches the cell to `overflow-visible`, and
265
+ // the text would spill into the next column.
218
266
  return (
219
- <TableCell key={`${field.path}-${i}`} className="h-[40px]">
267
+ <TableCell
268
+ key={`${field.path}-${i}`}
269
+ className="min-h-[40px]"
270
+ fade={false}
271
+ childrenClassName="overflow-hidden"
272
+ >
220
273
  {/* `isolate` confines the Badge's mix-blend-luminosity to a local
221
274
  stacking context and `transform-gpu` promotes it to its own layer, so
222
275
  the table's post-mount column reflow repaints cleanly instead of
@@ -257,6 +310,7 @@ function TableViewImpl({
257
310
  {addRowLabel}
258
311
  </TableEndAction>
259
312
  )}
313
+ </div>
260
314
  </div>
261
315
  </div>
262
316
  </div>
@@ -389,7 +443,12 @@ function SkeletonRows({
389
443
  {grip && <TableCell isDummy className="h-[40px] w-8" />}
390
444
  {selectable && <TableCell isDummy className="h-[40px] w-12" />}
391
445
  {fields.map((field, i) => (
392
- <TableCell key={`${field.path}-${i}`} className="h-[40px]">
446
+ <TableCell
447
+ key={`${field.path}-${i}`}
448
+ className="min-h-[40px]"
449
+ fade={false}
450
+ childrenClassName="overflow-hidden"
451
+ >
393
452
  {/* Widths cycle rather than repeat, so a column of bars reads as text that has not
394
453
  arrived instead of as a grid. */}
395
454
  <SkeletonBar className={["w-[70%]", "w-[45%]", "w-[85%]", "w-[60%]"][(row + i) % 4]} />
@@ -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 table
257
- // and board fill their container instead, which is why only these two do this.
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>
@@ -37,6 +37,7 @@ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
37
37
  interface DrawerContentProps extends React.ComponentPropsWithoutRef<
38
38
  typeof DrawerPrimitive.Content
39
39
  > {
40
+ /** @deprecated No effect here — the drag handle is `DrawerPanel`'s `showHandle`. */
40
41
  showHandle?: boolean;
41
42
  notch?: React.ReactNode;
42
43
  notchSide?: "left" | "right";
@@ -87,7 +88,17 @@ const DrawerPanel = React.forwardRef<HTMLDivElement, DrawerPanelProps>(
87
88
  // would render white-on-light = invisible).
88
89
  data-theme="light"
89
90
  className={cn(
90
- "flex flex-1 gap-2 rounded-t-[16px] p-1 bg-[#F0F0F0] min-h-0",
91
+ // `flex-col` is load-bearing: this panel stacks header / body / footer, and its own
92
+ // `showHandle` centres the drag handle with `mx-auto`, which only centres in a column.
93
+ // It was dropped in 37fddd5 alongside the tray's — on the tray that was deliberate (FormDrawer
94
+ // puts the panel and its summary side by side), here it was not, and every consumer that puts
95
+ // a header/body/footer straight into a panel rendered them in a row.
96
+ //
97
+ // `min-w-0` for the same reason, one axis over: the tray is a row now, so width is its
98
+ // main axis, and a flex item's default `min-width: auto` pins it to its content — a wide
99
+ // form pushed the panel straight out past the tray. `min-h-0` alone covered the old
100
+ // column tray; the row needs both.
101
+ "flex flex-1 flex-col gap-2 rounded-t-[16px] p-1 bg-[#F0F0F0] min-h-0 min-w-0",
91
102
  framed && "border border-[#D4D4D4] shadow-[inset_0_-4px_16px_rgba(0,0,0,0.1)]",
92
103
  className,
93
104
  )}
@@ -113,6 +124,12 @@ const DrawerContent = React.forwardRef<
113
124
  framed: framedProp,
114
125
  wrapperClassName,
115
126
  trayClassName,
127
+ // Destructured only to keep it out of `...props`. The handle belongs to `DrawerPanel` now, so
128
+ // this does nothing here — but it was never pulled out either, so it was being spread onto the
129
+ // DOM node and React warned "does not recognize the `showHandle` prop on a DOM element" every
130
+ // time a drawer opened. Kept in the props type rather than removed: consumers still pass it.
131
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
132
+ showHandle,
116
133
  ...props
117
134
  },
118
135
  ref,
@@ -2,7 +2,15 @@
2
2
 
3
3
  import { createContext, useContext } from "react";
4
4
 
5
- export type FieldDirection = "horizontal" | "vertical";
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
- variant="PrimeStyle"
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
- className={className}
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
- /** Field row direction. Defaults to horizontal (vertical inside a drawer). */
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
- // layout stays side-by-side at every screen size (the fields column shrinks instead).
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-[1fr_minmax(0,1100px)_1fr] gap-8">
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. */}
@@ -10,7 +10,16 @@ import { Button } from "../Button";
10
10
  // stepper — the rail, the state, Back/Next — is chrome and lives here. The dependency only ever
11
11
  // points this way: FormRenderer → FormBuilder, never back.
12
12
  import { StepContext, type StepRegistry } from "../FormBuilder/context";
13
- import { FormStepper, FormStep, FormStepIndicator, FormStepLabel } from "../FormStepper";
13
+ // Aliased: this module exports its own `Stepper` and `Step` (the wizard behaviour —
14
+ // `FormRenderer.Stepper` / `.Step`), which would otherwise collide with the presentational
15
+ // component of the same name.
16
+ import {
17
+ Stepper as StepperRail,
18
+ Step as StepPill,
19
+ StepIndicator,
20
+ StepLabel,
21
+ StepConnector,
22
+ } from "../Stepper";
14
23
 
15
24
  // ─── Stepper state context ───────────────────────────────────────────────────
16
25
 
@@ -113,7 +122,10 @@ function StepperNav({ control }: { control: Control<FieldValues> }) {
113
122
  [...(stepFields[index] ?? [])].some((name) => name in errors);
114
123
 
115
124
  return (
116
- <FormStepper activeStep={currentStep} className="shrink-0 flex-col items-start gap-[4px]">
125
+ // `min-w-0` so the rail can be squeezed: its grid track no longer grows to fit a long label
126
+ // (see form-renderer.tsx), so the column has to be allowed to shrink and let the labels
127
+ // truncate instead of spilling over the fields column.
128
+ <StepperRail activeStep={currentStep} orientation="vertical" className="min-w-0 shrink-0">
117
129
  {titles.map((title, index) => {
118
130
  // The step buttons ARE the navigation: click to move. Backward is free;
119
131
  // clicking forward validates the steps in between (goToStep) and stops at
@@ -126,21 +138,30 @@ function StepperNav({ control }: { control: Control<FieldValues> }) {
126
138
  : "default";
127
139
  return (
128
140
  <React.Fragment key={title}>
129
- <FormStep index={index} type={type} onClick={() => void goToStep(index)}>
130
- <FormStepIndicator />
131
- <FormStepLabel>{title}</FormStepLabel>
132
- </FormStep>
133
- {/* Connector between steps — a 3×16 rounded bar centred under the badge. */}
134
- {index < titles.length - 1 && (
135
- <div
136
- aria-hidden
137
- className="ms-[12.5px] mt-[2px] h-[16px] w-[3px] rounded-full bg-[#A0A0A0]"
138
- />
139
- )}
141
+ {/* The shrink/truncate pair is passed in rather than changed in `Stepper`, so its
142
+ other consumers keep sizing to their labels. `shrink` beats the pill's own
143
+ `shrink-0` and `truncate` beats the label's `whitespace-nowrap` — both via
144
+ tailwind-merge, which `Stepper` runs the caller's className through. */}
145
+ <StepPill
146
+ index={index}
147
+ type={type}
148
+ onClick={() => void goToStep(index)}
149
+ // `max-w-full` is what actually makes the label truncate: the rail is
150
+ // `items-start`, so without a cap the pill shrink-wraps its label and grows straight
151
+ // past the track instead of clipping inside it.
152
+ className="min-w-0 max-w-full shrink"
153
+ >
154
+ <StepIndicator />
155
+ {/* `max-w-none` opts out of the component's 106px cap: the rail sizes to its own
156
+ grid track, so the label should truncate there rather than 106px early. */}
157
+ <StepLabel className="max-w-none truncate">{title}</StepLabel>
158
+ </StepPill>
159
+ {/* Connector between steps — the component centres it under the indicator per size. */}
160
+ {index < titles.length - 1 && <StepConnector />}
140
161
  </React.Fragment>
141
162
  );
142
163
  })}
143
- </FormStepper>
164
+ </StepperRail>
144
165
  );
145
166
  }
146
167