torch-glare 2.5.4 → 2.5.6

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 (60) hide show
  1. package/apps/lib/components/BadgeField.tsx +138 -69
  2. package/apps/lib/components/Button.tsx +10 -2
  3. package/apps/lib/components/Card.tsx +2 -1
  4. package/apps/lib/components/ContextMenu.tsx +65 -22
  5. package/apps/lib/components/DataViews/context.ts +2 -2
  6. package/apps/lib/components/DataViews/data-views.tsx +20 -8
  7. package/apps/lib/components/DataViews/filters/filters.tsx +0 -2
  8. package/apps/lib/components/DataViews/index.ts +8 -4
  9. package/apps/lib/components/DataViews/slots.ts +9 -0
  10. package/apps/lib/components/DataViews/states.tsx +43 -8
  11. package/apps/lib/components/DataViews/views/table-view.tsx +184 -176
  12. package/apps/lib/components/Drawer.tsx +70 -39
  13. package/apps/lib/components/DropdownMenu.tsx +79 -22
  14. package/apps/lib/components/FormBuilder/context.ts +12 -0
  15. package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +38 -19
  16. package/apps/lib/components/FormBuilder/fields/SelectField.tsx +31 -8
  17. package/apps/lib/components/FormBuilder/submit.tsx +21 -1
  18. package/apps/lib/components/FormBuilder/types.ts +21 -0
  19. package/apps/lib/components/FormRenderer/FormDrawer.tsx +139 -17
  20. package/apps/lib/components/FormRenderer/detail.tsx +57 -8
  21. package/apps/lib/components/FormRenderer/form-renderer.tsx +82 -10
  22. package/apps/lib/components/FormRenderer/index.ts +2 -0
  23. package/apps/lib/components/FormRenderer/notch-action.tsx +64 -0
  24. package/apps/lib/components/FormRenderer/stepper.tsx +56 -2
  25. package/apps/lib/components/FormRenderer/types.ts +37 -0
  26. package/apps/lib/components/HeaderBar.tsx +51 -53
  27. package/apps/lib/components/InputField.tsx +46 -47
  28. package/apps/lib/components/Popover.tsx +23 -9
  29. package/apps/lib/components/SearchableSelect.tsx +10 -6
  30. package/apps/lib/components/SearchableTree.tsx +23 -6
  31. package/apps/lib/components/SearchableTreeDialog.tsx +11 -1
  32. package/apps/lib/components/SectionBlock.tsx +24 -3
  33. package/apps/lib/components/Select.tsx +64 -56
  34. package/apps/lib/components/SlideDatePicker.tsx +5 -5
  35. package/apps/lib/components/TabSwitch.tsx +18 -12
  36. package/apps/lib/components/Table.tsx +15 -28
  37. package/apps/lib/hooks/useActiveTreeItem.ts +4 -1
  38. package/apps/lib/hooks/useHtmlDir.ts +31 -0
  39. package/apps/lib/hooks/useTagSelection.ts +95 -9
  40. package/apps/lib/layouts/FieldSection.tsx +28 -2
  41. package/apps/lib/registry.json +20 -5
  42. package/apps/lib/utils/scroller.ts +26 -0
  43. package/docs/components/badge-field.md +30 -4
  44. package/docs/components/context-menu.md +3 -1
  45. package/docs/components/data-views/examples/filters.md +0 -1
  46. package/docs/components/data-views/index.md +32 -22
  47. package/docs/components/data-views/migration.md +7 -5
  48. package/docs/components/drawer.md +5 -5
  49. package/docs/components/dropdown-menu.md +3 -0
  50. package/docs/components/form-builder.md +36 -2
  51. package/docs/components/form-renderer.md +71 -1
  52. package/docs/components/header-bar.md +3 -2
  53. package/docs/components/input-field.md +3 -3
  54. package/docs/components/section-block.md +6 -0
  55. package/docs/components/select.md +1 -1
  56. package/docs/migration/changelog.md +19 -0
  57. package/docs/reference/hooks.md +23 -0
  58. package/docs/reference/utilities.md +22 -0
  59. package/package.json +1 -1
  60. package/apps/lib/components/DataViews/filters/summary.tsx +0 -65
@@ -5,6 +5,7 @@ import * as TabsPrimitive from "@radix-ui/react-tabs";
5
5
 
6
6
  import { cn } from "../../utils/cn";
7
7
  import { formBarItemStyles } from "../TabFormItem";
8
+ import { useHtmlDir } from "../../hooks/useHtmlDir";
8
9
  import { FormHeaderBar, type HeaderVariant } from "./header";
9
10
 
10
11
  /**
@@ -55,7 +56,9 @@ function DetailSidebarItem({ value, icon, children }: DetailSidebarItemProps) {
55
56
  )}
56
57
  >
57
58
  {icon}
58
- <span className="flex-1 truncate text-left typography-body-medium-medium">{children}</span>
59
+ {/* LOCAL PATCH (Contact Center): `text-start`, not `text-left` — otherwise an Arabic label
60
+ left-aligns inside a right-aligned rail, and `truncate` clips the wrong end. */}
61
+ <span className="flex-1 truncate text-start typography-body-medium-medium">{children}</span>
59
62
  </TabsPrimitive.Trigger>
60
63
  );
61
64
  }
@@ -153,24 +156,65 @@ export interface DetailTabsViewProps {
153
156
  /** The `FormRenderer.Tab` elements — the content panels. */
154
157
  tabs: React.ReactElement<DetailTabProps>[];
155
158
  className?: string;
159
+ /**
160
+ * LOCAL PATCH (Contact Center): the active tab, when the caller owns it. Omit both this and
161
+ * `onValueChange` to keep the original uncontrolled behaviour (defaults to the first tab).
162
+ *
163
+ * Upstream offered no way in, so a detail view could not put its tab in the URL — no
164
+ * `?tab=audit` deep link, no correct back-navigation, and a reload always bounced the user to
165
+ * the first tab. That is what this app's `useTabPersistence` provides.
166
+ */
167
+ value?: string;
168
+ /** LOCAL PATCH (Contact Center): fires when a rail item is clicked. See `value`. */
169
+ onValueChange?: (value: string) => void;
170
+ /**
171
+ * LOCAL PATCH (Contact Center): rendered inside a surface that already draws its own card —
172
+ * a drawer, a panel. Drops the rounded body background so it doesn't double up, and lets the
173
+ * host own the height instead of filling the viewport.
174
+ */
175
+ embedded?: boolean;
156
176
  }
157
177
 
158
178
  /**
159
179
  * The detail-tabs surface: the floating header over a fixed left rail (the sidebar) + a scrolling
160
- * content column showing the active tab. Radix `Tabs.Root` owns the state (uncontrolled, defaults to
161
- * the first tab). The rail matches the stepper's rail position; only the content column scrolls.
180
+ * content column showing the active tab. Radix `Tabs.Root` owns the state — uncontrolled and
181
+ * defaulting to the first tab, unless the caller passes `value`/`onValueChange`. The rail matches
182
+ * the stepper's rail position; only the content column scrolls.
162
183
  */
163
- export function DetailTabsView({ header, actions, sidebar, tabs, className }: DetailTabsViewProps) {
184
+ export function DetailTabsView({
185
+ header,
186
+ actions,
187
+ sidebar,
188
+ tabs,
189
+ className,
190
+ value,
191
+ onValueChange,
192
+ embedded,
193
+ }: DetailTabsViewProps) {
164
194
  const defaultValue = tabs[0]?.props.value;
195
+ // Radix treats a defined `value` as controlled, so only pass one of the two — handing it both
196
+ // logs a warning and pins the tab.
197
+ const controlled = value !== undefined;
198
+ // LOCAL PATCH (Contact Center): Radix Tabs defaults to `dir="ltr"` when given neither a `dir`
199
+ // prop nor a `DirectionProvider`, and it stamps that onto the subtree — which left this whole
200
+ // detail surface rendering left-to-right on an Arabic page, no matter what `<html dir>` said.
201
+ // Same fix already applied to `Tabs.tsx` and `TabPage.tsx`.
202
+ const htmlDir = useHtmlDir();
165
203
 
166
204
  return (
167
205
  <TabsPrimitive.Root
168
206
  orientation="vertical"
169
- defaultValue={defaultValue}
207
+ dir={htmlDir}
208
+ {...(controlled ? { value, onValueChange } : { defaultValue, onValueChange })}
170
209
  className={cn("h-full w-full @container", className)}
171
210
  >
172
211
  {/* Scroll shell — mirrors FormBuilder's: the absolute header floats over the body. */}
173
- <div className="relative isolate flex h-full w-full flex-col overflow-hidden rounded-2xl bg-background-presentation-body-primary">
212
+ <div
213
+ className={cn(
214
+ "relative isolate flex h-full w-full flex-col overflow-hidden",
215
+ !embedded && "rounded-2xl bg-background-presentation-body-primary",
216
+ )}
217
+ >
174
218
  {header && (
175
219
  <FormHeaderBar title={header.title} label={header.label} variant={header.variant}>
176
220
  {actions}
@@ -178,9 +222,14 @@ export function DetailTabsView({ header, actions, sidebar, tabs, className }: De
178
222
  )}
179
223
 
180
224
  <div className="relative z-[1] flex min-h-0 w-full flex-1 flex-row">
181
- {/* The fixed rail — the tab list. `pt-[72px]` clears the floating header. */}
225
+ {/* The fixed rail — the tab list. `pt-[72px]` clears the floating header.
226
+
227
+ LOCAL PATCH (Contact Center): `border-e`, not `border-r`. The row is `flex-row`, which
228
+ is direction-aware, so under `dir="rtl"` the rail moves to the right — a physical
229
+ right border then lands on the outer screen edge instead of between rail and content,
230
+ leaving the rail visually detached. */}
182
231
  <TabsPrimitive.List asChild>
183
- <aside className="flex h-full w-[216px] shrink-0 flex-col gap-1 overflow-y-auto border-r border-border-presentation-global-primary bg-black-alpha-5 px-2 pb-6 pt-[72px] scrollbar-hide">
232
+ <aside className="flex h-full w-[216px] shrink-0 flex-col gap-1 overflow-y-auto border-e border-border-presentation-global-primary bg-black-alpha-5 px-2 pb-6 pt-[72px] scrollbar-hide">
184
233
  {sidebar.props.children}
185
234
  </aside>
186
235
  </TabsPrimitive.List>
@@ -17,6 +17,7 @@ import {
17
17
  } from "./detail";
18
18
  import { FormDrawer } from "./FormDrawer";
19
19
  import { FormHeaderBar } from "./header";
20
+ import { NotchAction, isNotchActionElement } from "./notch-action";
20
21
  import { Section } from "./section";
21
22
  import {
22
23
  Back,
@@ -72,6 +73,15 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
72
73
  title,
73
74
  badge,
74
75
  onOpenInNewTab,
76
+ // LOCAL PATCH (Contact Center): detail-tabs control — see FormRendererProps.
77
+ activeTab,
78
+ onTabChange,
79
+ embedded,
80
+ // LOCAL PATCH (Contact Center): external stepper control — see FormRendererProps.
81
+ activeStep,
82
+ onStepChange,
83
+ // LOCAL PATCH (Contact Center): drawer layout — see FormRendererProps.
84
+ drawer,
75
85
  }: FormRendererProps<T>) {
76
86
  const isDrawer = display === "drawer";
77
87
 
@@ -101,6 +111,14 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
101
111
 
102
112
  // Split out a `FormRenderer.Stepper`: its Steps become the visibility-toggled slots inside the
103
113
  // `<form>`, its non-Step children a footer, and its state drives the rail beside them.
114
+ // LOCAL PATCH (Contact Center): lift `FormRenderer.NotchAction` children out of the form
115
+ // body and into the drawer's notch. Authored as children so the caller owns the label (and
116
+ // therefore its translation) instead of the component hardcoding English — see notch-action.tsx.
117
+ const notchActions = childArray.filter(isNotchActionElement);
118
+ const bodyChildren = notchActions.length
119
+ ? childArray.filter((n) => !isNotchActionElement(n))
120
+ : children;
121
+
104
122
  const stepperEl = childArray.find(isStepperElement);
105
123
  const stepChildren = stepperEl ? Children.toArray(stepperEl.props.children) : [];
106
124
  const steps = stepChildren.filter(isStepElement) as ReactElement<StepProps>[];
@@ -111,18 +129,55 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
111
129
  const stepper = useStepperState(
112
130
  steps,
113
131
  formInstance.trigger as Parameters<typeof useStepperState>[1],
132
+ // LOCAL PATCH (Contact Center): undefined `activeStep` leaves the stepper uncontrolled.
133
+ { activeStep, onStepChange },
114
134
  );
115
135
 
116
136
  if (detailSidebar && detailTabs.length > 0) {
117
- return (
137
+ const detailView = (
118
138
  <DetailTabsView
119
- header={header}
120
- actions={actions}
139
+ // In a drawer the tray already paints the surface and the drawer header carries the
140
+ // title, so the view drops its own card and its own floating header.
141
+ header={isDrawer ? undefined : header}
142
+ actions={isDrawer ? undefined : actions}
121
143
  sidebar={detailSidebar}
122
144
  tabs={detailTabs}
123
145
  className={className}
146
+ // LOCAL PATCH (Contact Center): lets the caller keep the tab in the URL, and render
147
+ // inside a surface that already draws its own card. Both optional.
148
+ value={activeTab}
149
+ onValueChange={onTabChange}
150
+ embedded={embedded ?? isDrawer}
124
151
  />
125
152
  );
153
+
154
+ // LOCAL PATCH (Contact Center): a detail view can be a DRAWER too.
155
+ //
156
+ // Upstream returned the detail surface here unconditionally, *before* the `isDrawer` branch
157
+ // below — so `FormRenderer.Sidebar` + `display="drawer"` silently rendered a full page inside
158
+ // whatever the caller put it in, and a quick-view drawer had to be hand-rolled around the
159
+ // detail view instead. A record looks the same whether you opened it from a row or from its
160
+ // own route; only the surface differs, which is exactly what `display` is for.
161
+ if (!isDrawer) return detailView;
162
+ return (
163
+ <FormDrawer
164
+ open={open}
165
+ onOpenChange={onOpenChange ?? (() => {})}
166
+ title={title ?? header?.title}
167
+ badge={badge ?? header?.label}
168
+ variant={header?.variant}
169
+ actions={actions}
170
+ onOpenInNewTab={onOpenInNewTab}
171
+ notchActions={notchActions.length > 0 ? notchActions : undefined}
172
+ // `DetailTabsView` already owns its scroll container and already offsets for a header,
173
+ // so the drawer must not add a second of each — that is what `bareBody` is for. The
174
+ // drawer's own header still renders, carrying the title and actions.
175
+ bareBody
176
+ {...drawer}
177
+ >
178
+ {detailView}
179
+ </FormDrawer>
180
+ );
126
181
  }
127
182
 
128
183
  // The fields the `<form>` wraps: the stepper's steps (+ any custom footer extras), or the
@@ -137,7 +192,7 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
137
192
  {stepExtras}
138
193
  </>
139
194
  ) : (
140
- children
195
+ bodyChildren
141
196
  );
142
197
 
143
198
  const formEl = (
@@ -174,11 +229,22 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
174
229
  // and the rail still has room to be legible (a plain `minmax(0,…)` let it collapse to 0 in a
175
230
  // narrow preview frame). Past that floor the labels truncate instead of pushing.
176
231
  const bodyInner = isStepper ? (
177
- <div className="grid w-full grid-cols-[minmax(180px,1fr)_minmax(0,1100px)_minmax(180px,1fr)] gap-8">
178
- <StepperNav control={formInstance.control as Control<FieldValues>} />
179
- {fieldsColumn}
180
- {/* Empty third column — balances the rail's gutter so the middle column is centred. */}
181
- <div />
232
+ // `@container` on the wrapper, not on the grid: an element cannot query its own size, so the
233
+ // measured box has to be an ancestor. It sits here rather than on the scroll body because
234
+ // `bodyInner` is also used directly when there is no header, and this way both paths get a
235
+ // container. The form then reacts to the width it is actually given — a rail, a drawer, a
236
+ // narrow preview frame — instead of the viewport's.
237
+ <div className="@container w-full">
238
+ {/* Below `@lg` the balancing column is dropped: it is empty, and in a narrow container its
239
+ 180px floor plus a 32px gap is width the fields column needs more than the centring.
240
+ Both halves are required — a `display:none` child is not a grid item, but the third
241
+ track would still be reserved if the template kept describing it. */}
242
+ <div className="grid w-full grid-cols-[minmax(180px,1fr)_minmax(0,1100px)] gap-8 @lg:grid-cols-[minmax(180px,1fr)_minmax(0,1100px)_minmax(180px,1fr)]">
243
+ <StepperNav control={formInstance.control as Control<FieldValues>} />
244
+ {fieldsColumn}
245
+ {/* Empty third column — balances the rail's gutter so the middle column is centred. */}
246
+ <div className="hidden @lg:block" />
247
+ </div>
182
248
  </div>
183
249
  ) : (
184
250
  fieldsColumn
@@ -208,7 +274,10 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
208
274
  summary && !isDrawer ? (
209
275
  <div className="flex h-full flex-row items-stretch">
210
276
  <div className="min-h-0 min-w-0 flex-1">{surface}</div>
211
- <div className="ml-[6px] flex min-h-0">{summary}</div>
277
+ {/* LOCAL PATCH (Contact Center): `ms-`, not `ml-`. Under `dir="rtl"` the summary sits to
278
+ the LEFT of the surface, so a physical left margin puts the gutter on its far side and
279
+ the two panels butt together. */}
280
+ <div className="ms-[6px] flex min-h-0">{summary}</div>
212
281
  </div>
213
282
  ) : (
214
283
  surface
@@ -256,6 +325,8 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
256
325
  ) : undefined
257
326
  }
258
327
  onOpenInNewTab={onOpenInNewTab}
328
+ notchActions={notchActions.length > 0 ? notchActions : undefined}
329
+ {...drawer}
259
330
  >
260
331
  {tree}
261
332
  </FormDrawer>
@@ -279,6 +350,7 @@ export const FormRenderer = Object.assign(FormRendererRoot, {
279
350
  Step,
280
351
  Back,
281
352
  Next,
353
+ NotchAction,
282
354
  Sidebar: DetailSidebar,
283
355
  Tab: DetailTab,
284
356
  Grid: DetailGrid,
@@ -2,6 +2,8 @@ export { FormRenderer } from "./form-renderer";
2
2
  export { FormDrawer } from "./FormDrawer";
3
3
  export type { FormDrawerProps } from "./FormDrawer";
4
4
  export { FormHeaderBar } from "./header";
5
+ export { NotchAction } from "./notch-action";
6
+ export type { NotchActionProps } from "./notch-action";
5
7
  export type { HeaderConfig, HeaderVariant, FormHeaderBarProps } from "./header";
6
8
  export type { SectionProps } from "./section";
7
9
  export type { StepProps, StepperProps } from "./stepper";
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "../../utils/cn";
6
+ import { DrawerNotchPill } from "../Drawer";
7
+
8
+ /**
9
+ * `FormRenderer.NotchAction` — a button in the drawer's notch, authored as a CHILD.
10
+ *
11
+ * LOCAL PATCH (Contact Center). Upstream models the one notch action it ships as a pair of
12
+ * props on `FormDrawer` / `FormRenderer`: `onOpenInNewTab` (a handler) plus a hardcoded
13
+ * English "Open in new tab" label. That shape has two problems:
14
+ *
15
+ * 1. The label is the component's, not the caller's, so a localized app cannot translate it
16
+ * without a second prop — which is exactly the `openInNewTabLabel` patch we kept having
17
+ * to re-apply after every `npx torch-glare update`.
18
+ * 2. It only ever admits ONE action, and only that action. Anything else in the notch means
19
+ * another prop pair.
20
+ *
21
+ * Passing the button as a child fixes both at once: the caller owns the text (so i18n is just
22
+ * `{t(...)}`, no prop), owns the icon, and can render more than one. Same bargain the rest of
23
+ * the library strikes — `DataViews` registers a view because you rendered it, `FormRenderer`
24
+ * renders a step because you wrote a `<Step>`.
25
+ *
26
+ * It renders nothing where it is written: `FormRenderer` lifts it out of the children and hands
27
+ * it to `FormDrawer`'s notch, the same way it lifts a `Stepper`. Ignored entirely on a page-display
28
+ * form, which has no notch.
29
+ *
30
+ * ```tsx
31
+ * <FormRenderer display="drawer" …>
32
+ * <FormRenderer.NotchAction onClick={openInTab}>
33
+ * {t("contacts.formShell.openInNewTab")}
34
+ * <i className="ri-arrow-right-up-line text-[12px]" />
35
+ * </FormRenderer.NotchAction>
36
+ * <FormRenderer.Section …>…</FormRenderer.Section>
37
+ * </FormRenderer>
38
+ * ```
39
+ */
40
+ export interface NotchActionProps
41
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color"> {
42
+ /** Pill colour, matching `DrawerNotchPill`. Defaults to the yellow "open elsewhere" pill. */
43
+ color?: React.ComponentProps<typeof DrawerNotchPill>["color"];
44
+ children?: React.ReactNode;
45
+ }
46
+
47
+ export function NotchAction({ color = "Yellow", className, children, ...props }: NotchActionProps) {
48
+ return (
49
+ <DrawerNotchPill color={color} className={cn(className)} {...props}>
50
+ {children}
51
+ </DrawerNotchPill>
52
+ );
53
+ }
54
+
55
+ (NotchAction as unknown as { __isFormNotchAction: boolean }).__isFormNotchAction = true;
56
+
57
+ export function isNotchActionElement(
58
+ node: React.ReactNode,
59
+ ): node is React.ReactElement<NotchActionProps> {
60
+ return (
61
+ React.isValidElement(node) &&
62
+ (node.type as { __isFormNotchAction?: boolean })?.__isFormNotchAction === true
63
+ );
64
+ }
@@ -125,7 +125,28 @@ function StepperNav({ control }: { control: Control<FieldValues> }) {
125
125
  // `min-w-0` so the rail can be squeezed: its grid track no longer grows to fit a long label
126
126
  // (see form-renderer.tsx), so the column has to be allowed to shrink and let the labels
127
127
  // truncate instead of spilling over the fields column.
128
- <StepperRail activeStep={currentStep} orientation="vertical" className="min-w-0 shrink-0">
128
+ //
129
+ // The rail is navigation, so it stays put while the fields scroll past it. Two of these three
130
+ // classes are load-bearing in a way that is easy to get wrong:
131
+ //
132
+ // `self-start` is NOT optional. The rail is a grid item, and a grid item defaults to
133
+ // `align-self: stretch` — its box is already the full row height, so `sticky` alone has no room
134
+ // to move within and does exactly nothing. This is the silent-no-op version of this fix.
135
+ //
136
+ // `top-[72px]`, not `top-0`. `FormHeaderBar` is `absolute inset-x-0 top-0` over a 44px pill at a
137
+ // 4px inset, so it covers the scrollport's first 48px, and the body's own `pt-[72px]` is inside
138
+ // the scrollport and does not push sticky down. `top-0` parks the rail under the floating
139
+ // header; 72px clears it and matches the offset used across this component family.
140
+ //
141
+ // No height cap: the scrollport is the form body, not the viewport, so a `100dvh`-based
142
+ // `max-h` would be wrong in any bounded container (a drawer, the 640px docs frame). A rail
143
+ // taller than the body simply scrolls until its end is reached, which is standard sticky
144
+ // behaviour and correct here.
145
+ <StepperRail
146
+ activeStep={currentStep}
147
+ orientation="vertical"
148
+ className="min-w-0 shrink-0 sticky self-start top-[72px]"
149
+ >
129
150
  {titles.map((title, index) => {
130
151
  // The step buttons ARE the navigation: click to move. Backward is free;
131
152
  // clicking forward validates the steps in between (goToStep) and stops at
@@ -272,11 +293,38 @@ type TriggerFn = (names?: FieldPath<FieldValues>[]) => Promise<boolean>;
272
293
  * the form's `trigger` (passed in — no `useFormContext` needed). Inert when
273
294
  * `steps` is empty (a form without a stepper still calls this, for hooks order).
274
295
  */
296
+ /**
297
+ * LOCAL PATCH (Contact Center): optional external control of the active step.
298
+ *
299
+ * Upstream owns `currentStep` outright and advances it by clicking a step, gated on validating
300
+ * every step in between. That is right for a wizard whose steps are pages of one form — but not
301
+ * for one whose steps are owned by a SERVER: the import wizard goes upload → (job created) →
302
+ * mapping → (columns mapped, import started) → progress, and the user cannot click ahead to a
303
+ * step that does not exist yet.
304
+ *
305
+ * Passing `activeStep`/`onStepChange` makes the rail a display of someone else's state: internal
306
+ * advancement is suppressed and click-to-navigate is reported rather than applied. Omit both and
307
+ * every existing caller behaves exactly as before.
308
+ */
309
+ export interface StepperControl {
310
+ activeStep?: number;
311
+ onStepChange?: (index: number) => void;
312
+ }
313
+
275
314
  export function useStepperState(
276
315
  steps: React.ReactElement<StepProps>[],
277
316
  trigger: TriggerFn,
317
+ control?: StepperControl,
278
318
  ): StepperContextValue {
279
- const [currentStep, setCurrentStep] = React.useState(0);
319
+ const [internalStep, setInternalStep] = React.useState(0);
320
+ // LOCAL PATCH (Contact Center): controlled when `activeStep` is supplied — see `StepperControl`.
321
+ const isControlled = control?.activeStep !== undefined;
322
+ const currentStep = isControlled ? (control?.activeStep as number) : internalStep;
323
+ const setCurrentStep: React.Dispatch<React.SetStateAction<number>> = (value) => {
324
+ const next = typeof value === "function" ? (value as (p: number) => number)(currentStep) : value;
325
+ if (isControlled) control?.onStepChange?.(next);
326
+ else setInternalStep(next);
327
+ };
280
328
  // Steps that have passed their last validation — kept so their checkmark persists when the
281
329
  // user navigates back to an earlier step.
282
330
  const [completedSteps, setCompletedSteps] = React.useState<Set<number>>(new Set());
@@ -305,6 +353,12 @@ export function useStepperState(
305
353
  // the first step that has errors (so you can't skip past an invalid step).
306
354
  const goToStep = async (index: number) => {
307
355
  const target = Math.max(0, Math.min(index, Math.max(0, lastIndex)));
356
+ // LOCAL PATCH (Contact Center): controlled — the owner decides whether the move is allowed,
357
+ // so report it and do not run the forward-validation gauntlet.
358
+ if (isControlled) {
359
+ control?.onStepChange?.(target);
360
+ return;
361
+ }
308
362
  if (target <= currentStep) {
309
363
  setCurrentStep(target);
310
364
  return;
@@ -6,6 +6,7 @@ import type {
6
6
  Resolver,
7
7
  UseFormReturn,
8
8
  } from "react-hook-form";
9
+ import type { FormDrawerProps } from "./FormDrawer";
9
10
 
10
11
  /**
11
12
  * FormRenderer — the chrome around a `FormBuilder`. You author the fields as **JSX children**
@@ -67,6 +68,42 @@ export interface FormRendererProps<T extends FieldValues = FieldValues> {
67
68
  /** `id` on the underlying `<form>`. Optional — FormRenderer generates and wires one otherwise. */
68
69
  id?: string;
69
70
 
71
+ /**
72
+ * LOCAL PATCH (Contact Center): detail-tabs control (when the children are a
73
+ * `FormRenderer.Sidebar` + `FormRenderer.Tab`s). Inert in form mode.
74
+ *
75
+ * `activeTab`/`onTabChange` make the rail controlled, so the caller can keep the tab in the URL
76
+ * (`useTabPersistence` → `?tab=`); omit both for the library's uncontrolled default.
77
+ * `embedded` renders without the rounded body card, for a host that already draws one.
78
+ */
79
+ activeTab?: string;
80
+ onTabChange?: (tab: string) => void;
81
+ embedded?: boolean;
82
+
83
+ /**
84
+ * LOCAL PATCH (Contact Center): external control of a `FormRenderer.Stepper`'s active step,
85
+ * for a wizard whose steps are owned by something other than form validity — a server job,
86
+ * say. Omit both and the stepper owns its own step exactly as before. Inert without a Stepper.
87
+ */
88
+ activeStep?: number;
89
+ onStepChange?: (index: number) => void;
90
+
91
+ /**
92
+ * LOCAL PATCH (Contact Center): drawer layout, forwarded to `FormDrawer`. One object rather
93
+ * than eight flat props, since none of it means anything on a page. See `FormDrawerProps`.
94
+ */
95
+ drawer?: Pick<
96
+ FormDrawerProps,
97
+ | "side"
98
+ | "nested"
99
+ | "framed"
100
+ | "hideHeader"
101
+ | "bareBody"
102
+ | "description"
103
+ | "wrapperClassName"
104
+ | "className"
105
+ >;
106
+
70
107
  /** Drawer control (when `display === "drawer"`). */
71
108
  open?: boolean;
72
109
  onOpenChange?: (open: boolean) => void;
@@ -22,13 +22,18 @@ import { Themes } from "../utils/types";
22
22
  * (renders plain "sales iNVOICE" on the LEFT, colored "de-344" on the RIGHT)
23
23
  */
24
24
 
25
- // Inner row: order is reversed for "detail" so the badge ends up on the right.
26
- const rowStyles = cva(["flex", "items-center"], {
25
+ // 28px / weight 510 — the design's `Font/Size/Display/Medium`, which is exactly what this class
26
+ // carries. Shared by the badge and the plain title so the two pieces cannot drift apart.
27
+ const headerTextStyles = "typography-display-medium-medium uppercase [font-feature-settings:'cv05'_on]";
28
+
29
+ // Colored pill holding `label`. 32px tall, 4px side padding, 8px radius — Figma `Header.Badge`.
30
+ const badgeStyles = cva(["flex", "h-8", "items-center", "justify-center", "rounded-lg", "px-1"], {
27
31
  variants: {
28
32
  variant: {
29
- new: "flex-row",
30
- edit: "flex-row",
31
- detail: "flex-row-reverse",
33
+ new: "bg-blue-sparkle-alpha-50",
34
+ edit: "bg-orange-alpha-50",
35
+ // `White Alpha/15`, not /30 — the detail chip is the faintest of the three.
36
+ detail: "bg-white-alpha-15",
32
37
  },
33
38
  },
34
39
  defaultVariants: {
@@ -36,49 +41,22 @@ const rowStyles = cva(["flex", "items-center"], {
36
41
  },
37
42
  });
38
43
 
39
- // Colored pill holding `label`.
40
- const badgeStyles = cva(
41
- ["flex", "h-8", "items-center", "justify-center", "gap-2.5", "rounded-lg", "px-1"],
42
- {
43
- variants: {
44
- variant: {
45
- new: "bg-blue-sparkle-alpha-50",
46
- edit: "bg-orange-alpha-50",
47
- detail: "bg-white-alpha-30",
48
- },
49
- },
50
- defaultVariants: {
51
- variant: "new",
52
- },
53
- },
54
- );
55
-
56
44
  // Text inside the colored pill.
57
- const badgeTextStyles = cva(
58
- [
59
- "font-sans",
60
- "text-[28px]",
61
- "font-[510]",
62
- "leading-normal",
63
- "uppercase",
64
- "[font-feature-settings:'cv05'_on]",
65
- ],
66
- {
67
- variants: {
68
- variant: {
69
- new: "text-blue-sparkle-200",
70
- edit: "text-orange-200",
71
- detail: "text-white-00",
72
- },
73
- },
74
- defaultVariants: {
75
- variant: "new",
45
+ const badgeTextStyles = cva([headerTextStyles], {
46
+ variants: {
47
+ variant: {
48
+ new: "text-blue-sparkle-200",
49
+ edit: "text-orange-200",
50
+ detail: "text-white-00",
76
51
  },
77
52
  },
78
- );
53
+ defaultVariants: {
54
+ variant: "new",
55
+ },
56
+ });
79
57
 
80
58
  interface HeaderBarProps
81
- extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof rowStyles> {
59
+ extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeStyles> {
82
60
  theme?: Themes;
83
61
  /** The colored emphasis pill text. */
84
62
  label: string;
@@ -88,25 +66,45 @@ interface HeaderBarProps
88
66
 
89
67
  const HeaderBar = forwardRef<HTMLDivElement, HeaderBarProps>(
90
68
  ({ variant = "new", label, title, theme, className, ...props }, ref) => {
69
+ const badge = (
70
+ <div className={cn(badgeStyles({ variant }))}>
71
+ <p className={cn(badgeTextStyles({ variant }))}>{label}</p>
72
+ </div>
73
+ );
74
+
75
+ // The plain title sits in its own 32px box with 6px side padding — that padding, not a row
76
+ // `gap`, is what separates it from the badge (matching the design's `padding` frame).
77
+ const plain = (
78
+ <div className="flex h-8 items-center justify-center px-1.5">
79
+ <p className={cn(headerTextStyles, "text-white-00")}>{title}</p>
80
+ </div>
81
+ );
82
+
91
83
  return (
92
84
  <div
93
85
  ref={ref}
94
86
  data-theme={theme}
95
87
  className={cn(
96
- "inline-flex flex-col items-start rounded-[14px] border border-black-600 bg-black-1000 p-1.5 shadow-[0_0_32px_2px_rgba(0,0,0,0.05),0_0_32px_2px_rgba(0,0,0,0.05)]",
88
+ "inline-flex flex-col items-start overflow-hidden rounded-[14px] border border-black-600 bg-black-1000 p-1.5 shadow-[0_0_32px_2px_rgba(0,0,0,0.05),0_0_32px_2px_rgba(0,0,0,0.05)]",
97
89
  className,
98
90
  )}
99
91
  {...props}
100
92
  >
101
- <div className={cn(rowStyles({ variant }))}>
102
- <div className={cn(badgeStyles({ variant }))}>
103
- <p className={cn(badgeTextStyles({ variant }))}>{label}</p>
104
- </div>
105
- <div className="flex h-8 items-center justify-center px-1.5">
106
- <p className="font-sans text-[28px] font-[510] leading-normal uppercase text-white-00 [font-feature-settings:'cv05'_on]">
107
- {title}
108
- </p>
109
- </div>
93
+ {/* `detail` genuinely swaps the two children rather than using `flex-row-reverse`. The
94
+ visual result is the same in LTR, but reversing the row would flip the pair the wrong
95
+ way under `dir="rtl"` and would read out of order to a screen reader. */}
96
+ <div className="flex items-center">
97
+ {variant === "detail" ? (
98
+ <>
99
+ {plain}
100
+ {badge}
101
+ </>
102
+ ) : (
103
+ <>
104
+ {badge}
105
+ {plain}
106
+ </>
107
+ )}
110
108
  </div>
111
109
  </div>
112
110
  );