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
@@ -70,16 +70,27 @@ const DropdownMenuContent = React.forwardRef<
70
70
  ref={ref}
71
71
  sideOffset={sideOffset}
72
72
  collisionPadding={collisionPadding}
73
- // Cap at maxHeight, but never exceed the space Radix has after collision
74
- // handling. The menu scrolls (overflow on the surface) past this height.
73
+ // Cap at maxHeight, but never exceed the space Radix has after collision handling. The
74
+ // `100vh` fallback is load-bearing: an undefined var invalidates the whole `min()`, so
75
+ // `max-height` would resolve to `none` and the panel — which no longer scrolls itself —
76
+ // would grow unbounded with its rows clipped and unreachable.
75
77
  style={{
76
- maxHeight: `min(${maxHeight}px, var(--radix-dropdown-menu-content-available-height))`,
78
+ maxHeight: `min(${maxHeight}px, var(--radix-dropdown-menu-content-available-height, 100vh))`,
77
79
  ...style,
78
80
  }}
79
81
  className={cn(menuContentStyles({ variant }), className)}
80
82
  {...props}
81
83
  >
82
- {autoGroup ? autoGroupChildren(children) : children}
84
+ {/* Dedicated scroll viewport, matching Select's: the cap lives on the panel above, this
85
+ fills what is left and scrolls. `min-h-0` is what makes it work — a flex item will not
86
+ shrink below its content, so without it the list grows past the panel and the panel's
87
+ `overflow-hidden` just clips the rows with no scrollbar.
88
+
89
+ `gap-1` is re-declared here because `autoGroupChildren` emits several siblings (a group,
90
+ a label, a separator…) and this is now the element they are siblings within. */}
91
+ <div className="flex flex-col gap-1 flex-1 min-h-0 overflow-y-auto overflow-x-hidden rounded-[10px] scrollbar-hide">
92
+ {autoGroup ? autoGroupChildren(children) : children}
93
+ </div>
83
94
  </DropdownMenuPrimitive.Content>
84
95
  </DropdownMenuPrimitive.Portal>
85
96
  ),
@@ -123,18 +134,45 @@ const DropdownMenuSubContent = React.forwardRef<
123
134
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {
124
135
  variant?: "PresentationStyle";
125
136
  autoGroup?: boolean;
137
+ maxHeight?: number;
126
138
  }
127
- >(({ className, variant = "PresentationStyle", autoGroup = true, children, ...props }, ref) => (
128
- <DropdownMenuPrimitive.Portal>
129
- <DropdownMenuPrimitive.SubContent
130
- ref={ref}
131
- className={cn(menuContentStyles({ variant }), className)}
132
- {...props}
133
- >
134
- {autoGroup ? autoGroupChildren(children) : children}
135
- </DropdownMenuPrimitive.SubContent>
136
- </DropdownMenuPrimitive.Portal>
137
- ));
139
+ >(
140
+ (
141
+ {
142
+ className,
143
+ variant = "PresentationStyle",
144
+ autoGroup = true,
145
+ collisionPadding = 8,
146
+ maxHeight = 320,
147
+ // Destructured out of `{...props}` so the spread below cannot clobber the cap. Radix
148
+ // re-publishes the namespaced available-height var on SubContent, so the same expression
149
+ // Content uses works here unchanged.
150
+ style,
151
+ children,
152
+ ...props
153
+ },
154
+ ref,
155
+ ) => (
156
+ <DropdownMenuPrimitive.Portal>
157
+ <DropdownMenuPrimitive.SubContent
158
+ ref={ref}
159
+ collisionPadding={collisionPadding}
160
+ style={{
161
+ maxHeight: `min(${maxHeight}px, var(--radix-dropdown-menu-content-available-height, 100vh))`,
162
+ ...style,
163
+ }}
164
+ className={cn(menuContentStyles({ variant }), className)}
165
+ {...props}
166
+ >
167
+ {/* Same panel-clips / viewport-scrolls split as Content. A submenu is a peer surface, so it
168
+ shares the 320px default rather than getting a smaller one of its own. */}
169
+ <div className="flex flex-col gap-1 flex-1 min-h-0 overflow-y-auto overflow-x-hidden rounded-[10px] scrollbar-hide">
170
+ {autoGroup ? autoGroupChildren(children) : children}
171
+ </div>
172
+ </DropdownMenuPrimitive.SubContent>
173
+ </DropdownMenuPrimitive.Portal>
174
+ ),
175
+ );
138
176
  DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
139
177
 
140
178
  const DropdownMenuItem = React.forwardRef<
@@ -456,16 +494,33 @@ export const menuContentStyles = cva(
456
494
  "rounded-[14px]",
457
495
  "min-w-[240px]",
458
496
  "outline-none",
459
- "overflow-y-auto",
460
- "overflow-x-hidden",
497
+ // The panel clips; the inner viewport below owns scrolling. Height is capped inline on the
498
+ // Content element from `min(maxHeight, available-height)` — no `max-h-*` class here on
499
+ // purpose, so the inline value governs.
500
+ "overflow-hidden",
461
501
  // Only animate the OPEN (enter) state. An exit animation on [data-state=closed]
462
502
  // holds the old DOM node during close, which breaks the context menu's
463
503
  // close/reposition on a second right-click (Radix issue #2572).
464
504
  "data-[state=open]:animate-in",
465
505
  "data-[state=open]:fade-in-0",
466
- "scrollbar-hide",
467
506
  "backdrop-blur-[21px]",
468
- "flex gap-1 flex-col",
507
+ // No `gap` here: the panel has exactly one child (the scroll viewport), so a gap between
508
+ // siblings has nothing to act on. The 4px between groups/labels lives on that viewport.
509
+ "flex flex-col",
510
+ // LOCAL PATCH (Contact Center): this was the ONE portalled surface in the library with no
511
+ // z-index. Radix portals the panel to <body> and positions it `fixed`, but `z-index: auto`
512
+ // paints in a LOWER layer than any positive z-index in the same stacking context, whatever
513
+ // the DOM order — so the library's own `Table` sticky header (`sticky top-0 z-20`,
514
+ // Table.tsx) painted straight over it. On a DataViews list that header is opaque and sits
515
+ // exactly where a topbar action menu opens, so the menu vanished entirely.
516
+ //
517
+ // The class goes on Content, not on Radix's positioner: Popper reads the content's COMPUTED
518
+ // z-index and copies it to the wrapper (@radix-ui/react-popper 1.3.7), which is the same
519
+ // mechanism `Select`'s identical panel already rides on.
520
+ //
521
+ // 1000 matches `Select` and `Popover` rather than clearing `z-20` by one, so a menu opened
522
+ // inside a Drawer or Dialog (both `z-50`) clears those too — the same half of this bug.
523
+ "z-[1000]",
469
524
  ],
470
525
  {
471
526
  variants: {
@@ -475,9 +530,11 @@ export const menuContentStyles = cva(
475
530
  "shadow-[0_0_32px_2px_rgba(0,0,0,0.20),0_0_48px_2px_rgba(0,0,0,0.05)]",
476
531
  ],
477
532
  },
478
- defaultVariants: {
479
- variant: "PresentationStyle",
480
- },
533
+ },
534
+ // Was nested inside `variants`, where cva reads it as a variant group named
535
+ // "defaultVariants" and no default is ever applied. `menuGroupStyles` below has it right.
536
+ defaultVariants: {
537
+ variant: "PresentationStyle",
481
538
  },
482
539
  },
483
540
  );
@@ -55,6 +55,18 @@ export const useBare = () => useContext(CellContext) !== false;
55
55
  /** True only inside a `FormBuilder.Table` cell — drives the control's `onTable` border style. */
56
56
  export const useOnTable = () => useContext(CellContext) === "table";
57
57
 
58
+ /**
59
+ * The "(Required)" tag `FieldShell` prints beside a `required` field's label.
60
+ *
61
+ * LOCAL PATCH (Contact Center): upstream hardcodes the English literal, so a
62
+ * localized app cannot translate it. Defaulting to that literal keeps every
63
+ * existing caller identical; provide the context once near the app root to
64
+ * localize every field at once. Logged in TORCH-GLARE-FEEDBACK.md — re-apply
65
+ * after any `npx torch-glare update`.
66
+ */
67
+ export const RequiredLabelContext = createContext<string>("(Required)");
68
+ export const useRequiredLabel = () => useContext(RequiredLabelContext);
69
+
58
70
  /**
59
71
  * Step registry — a `FormRenderer.Step` provides this so the fields rendered inside it can
60
72
  * register their `name`, and the stepper validates just those names before advancing. `null`
@@ -14,8 +14,8 @@ import {
14
14
  import { FieldSection } from "../../../layouts/FieldSection";
15
15
  import { FormField, FormItem, FormControl } from "../../Form";
16
16
  import { FieldHint } from "../../FieldHint";
17
- import { Tooltip } from "../../Tooltip";
18
- import { useDirection, useStepRegistry, useBare } from "../context";
17
+ import { useDirection, useStepRegistry, useBare, useRequiredLabel } from "../context";
18
+ import type { FieldHintSpec } from "../types";
19
19
 
20
20
  export interface FieldShellProps {
21
21
  name: string;
@@ -26,6 +26,8 @@ export interface FieldShellProps {
26
26
  hidden?: boolean;
27
27
  /** Force the field's layout direction, overriding the form's `useDirection()` context. */
28
28
  direction?: "horizontal" | "vertical" | "flexible";
29
+ /** Alerts stacked under the validation error. See `FieldHintSpec`. */
30
+ hints?: FieldHintSpec[];
29
31
  /** The input, wired to the react-hook-form field. */
30
32
  children: (
31
33
  field: ControllerRenderProps<FieldValues, string>,
@@ -47,11 +49,14 @@ export function FieldShell({
47
49
  fullWidth,
48
50
  hidden,
49
51
  direction: directionProp,
52
+ hints,
50
53
  children,
51
54
  }: FieldShellProps) {
52
55
  const form = useFormContext();
53
56
  const bare = useBare();
54
57
  const ctxDirection = useDirection();
58
+ // LOCAL PATCH (Contact Center): upstream hardcodes "(Required)". See context.ts.
59
+ const requiredLabel = useRequiredLabel();
55
60
  // A field may pin its own direction (e.g. RichText forces vertical), else the form's. When
56
61
  // neither is set this stays `undefined` — FieldSection then falls back to its responsive
57
62
  // `flexible` layout rather than a fixed two-column one.
@@ -75,9 +80,20 @@ export function FieldShell({
75
80
  if (hidden) return null;
76
81
 
77
82
  // Chrome-less mode — a `FormBuilder.Table` cell (`"table"`) or a panel field (`"bare"`).
78
- // Render just the control: no FieldSection label/row, and errors surface as a tooltip on the
79
- // control rather than a stacked FieldHint, so a row stays one line tall. Step registration
80
- // above still applies. The control's border treatment is the caller's call, via `useOnTable`.
83
+ // Render just the control: no FieldSection label/row, so a row stays one line tall. Step
84
+ // registration above still applies. The control's border treatment is the caller's call,
85
+ // via `useOnTable`.
86
+ //
87
+ // LOCAL PATCH (Contact Center): the error Tooltip that used to wrap the control is gone.
88
+ // Upstream bound it to `open={Boolean(fieldError)}`, so it was not a hover tooltip at all — it
89
+ // was a bubble pinned open for as long as the field was invalid, anchored `top`, which in a
90
+ // table's first row sat directly on top of the column header. Several invalid cells meant
91
+ // several permanent bubbles over the header row. Its `TooltipTrigger` also stamped
92
+ // `aria-label="Open tooltip"` onto EVERY cell wrapper, error or not, so a screen reader
93
+ // announced every table input that way.
94
+ //
95
+ // The controls already signal invalidity themselves (`state="negative"` / `invalid`), so the
96
+ // red border remains; only the bubble is dropped. Re-apply after any `npx torch-glare update`.
81
97
  if (bare) {
82
98
  return (
83
99
  <FormField
@@ -86,14 +102,7 @@ export function FieldShell({
86
102
  render={({ field, fieldState }) => (
87
103
  <FormItem className="w-full">
88
104
  <FormControl>
89
- <Tooltip
90
- open={Boolean(fieldError)}
91
- text={fieldError ?? ""}
92
- toolTipSide="top"
93
- variant="highlight"
94
- >
95
- <div className="w-full">{children(field, fieldState)}</div>
96
- </Tooltip>
105
+ <div className="w-full">{children(field, fieldState)}</div>
97
106
  </FormControl>
98
107
  </FormItem>
99
108
  )}
@@ -104,11 +113,11 @@ export function FieldShell({
104
113
  return (
105
114
  <FieldSection
106
115
  label={label}
107
- requiredLabel={required ? "(Required)" : undefined}
116
+ requiredLabel={required ? requiredLabel : undefined}
108
117
  secondaryLabel={description}
109
118
  direction={direction}
110
119
  className={fullWidth ? "max-w-full" : undefined}
111
- childrenUnderLabel={<FieldError message={fieldError} />}
120
+ childrenUnderLabel={<FieldMessages message={fieldError} hints={hints} />}
112
121
  >
113
122
  <FormField
114
123
  control={form.control}
@@ -125,10 +134,20 @@ export function FieldShell({
125
134
  );
126
135
  }
127
136
 
128
- /** Validation error, shown as a `FieldHint` alert (no tooltip); null when there's none. */
129
- function FieldError({ message }: { message?: string }) {
130
- if (!message) return null;
131
- return <FieldHint state="error" label={message} />;
137
+ /**
138
+ * The stack under a field: the validation error first (it is the actionable one), then any author
139
+ * `hints` in order. Renders nothing when there is neither, so a field without hints is unchanged.
140
+ */
141
+ function FieldMessages({ message, hints }: { message?: string; hints?: FieldHintSpec[] }) {
142
+ if (!message && !hints?.length) return null;
143
+ return (
144
+ <div className="flex flex-col items-start gap-[4px]">
145
+ {message && <FieldHint state="error" label={message} />}
146
+ {hints?.map((hint, i) => (
147
+ <FieldHint key={i} state={hint.state ?? "info"} label={hint.label} icon={hint.icon} />
148
+ ))}
149
+ </div>
150
+ );
132
151
  }
133
152
 
134
153
  /**
@@ -64,22 +64,45 @@ export function SearchableSelectField(props: SearchableSelectFieldProps) {
64
64
  );
65
65
  }
66
66
 
67
- /** `FormBuilder.MultiSelect` / `.Tags` — BadgeField, value is `string[]`. */
67
+ /**
68
+ * `FormBuilder.MultiSelect` / `.Tags` — BadgeField, value is `string[]`.
69
+ *
70
+ * With `creatable`, the user can type a value that is not in `options` and it becomes a badge —
71
+ * so a free-text list (emails, aliases, tags) is one field rather than a one-column table. Pass
72
+ * `options={[]}` for a pure free-text list.
73
+ */
68
74
  export function MultiSelectField(props: OptionsFieldProps) {
69
75
  const onTable = useOnTable();
70
76
  return (
71
77
  <FieldShell {...props}>
72
78
  {(field) => {
73
- const selected = new Set<string>(Array.isArray(field.value) ? field.value : []);
74
- const tags: Tag[] = props.options.map((opt) => ({
75
- id: opt.value,
76
- name: opt.label,
77
- value: opt.value,
78
- isSelected: selected.has(opt.value),
79
- }));
79
+ const values: string[] = Array.isArray(field.value) ? field.value : [];
80
+ const byValue = new Map(props.options.map((opt) => [opt.value, opt]));
81
+ // Selected first, IN VALUE ORDER, then whatever is left to offer. Order matters: the
82
+ // hook re-syncs from this list, so building it in `options` order would reshuffle the
83
+ // user's badges on every keystroke — and drop any created value that is not an option.
84
+ const tags: Tag[] = [
85
+ ...values.map((value) => ({
86
+ id: value,
87
+ name: byValue.get(value)?.label ?? value,
88
+ value,
89
+ isSelected: true,
90
+ })),
91
+ ...props.options
92
+ .filter((opt) => !values.includes(opt.value))
93
+ .map((opt) => ({
94
+ id: opt.value,
95
+ name: opt.label,
96
+ value: opt.value,
97
+ isSelected: false,
98
+ })),
99
+ ];
80
100
  return (
81
101
  <BadgeField
82
102
  tags={tags}
103
+ creatable={props.creatable}
104
+ placeholder={props.placeholder}
105
+ disabled={props.disabled}
83
106
  onValueChange={(picked) => field.onChange(picked.map((t) => t.value ?? t.id))}
84
107
  onTable={onTable}
85
108
  className="w-full"
@@ -16,10 +16,28 @@ export interface SubmitButtonProps {
16
16
  * **outside** the `<form>` element. Set it explicitly only to target a different form.
17
17
  */
18
18
  form?: string;
19
+ /**
20
+ * LOCAL PATCH (Contact Center): upstream removed this in 2.5.5, leaving no way to
21
+ * disable a Save. Re-apply after any `npx torch-glare update`.
22
+ *
23
+ * Disable the button while leaving the form readable.
24
+ *
25
+ * Used by permission gating: someone with read access should still SEE a
26
+ * record, so a Save they may not use is disabled rather than removed --
27
+ * a missing button looks broken, a disabled one says "not yours to change".
28
+ * The server refuses the write either way.
29
+ */
30
+ disabled?: boolean;
19
31
  }
20
32
 
21
33
  /** `FormBuilder.Submit` — a loading-aware submit button, hidden in view mode. */
22
- export function SubmitButton({ children, className, loadingText, form }: SubmitButtonProps) {
34
+ export function SubmitButton({
35
+ children,
36
+ className,
37
+ loadingText,
38
+ form,
39
+ disabled,
40
+ }: SubmitButtonProps) {
23
41
  const loading = useLoading();
24
42
  const ctxFormId = useFormId();
25
43
 
@@ -34,6 +52,8 @@ export function SubmitButton({ children, className, loadingText, form }: SubmitB
34
52
  // variant is how the rule stops being a rule.
35
53
  variant="BluColStyle"
36
54
  is_loading={loading}
55
+ // LOCAL PATCH (Contact Center) -- see `disabled` in SubmitButtonProps.
56
+ disabled={disabled}
37
57
  // `w-fit` because the FormBuilder root is a flex COLUMN: a direct child with `width: auto`
38
58
  // inherits `align-items: stretch` and spans the whole form. Sections want that (SectionBlock
39
59
  // sets its own `w-full`); a Save button does not. `w-fit` rather than `self-start` so the
@@ -41,6 +41,16 @@ export type FieldKind =
41
41
  | "custom";
42
42
 
43
43
  /** Props shared by every `FormBuilder.*` field. `name` is the RHF path. */
44
+ /**
45
+ * One alert under a field. Mirrors `FieldHint`'s own props so the design system stays the single
46
+ * source of truth for how each state looks.
47
+ */
48
+ export interface FieldHintSpec {
49
+ label: ReactNode;
50
+ state?: "info" | "warning" | "error" | "success";
51
+ icon?: ReactNode;
52
+ }
53
+
44
54
  export interface BaseFieldProps {
45
55
  name: string;
46
56
  label?: ReactNode;
@@ -51,6 +61,12 @@ export interface BaseFieldProps {
51
61
  hidden?: boolean;
52
62
  /** Span the full section width. */
53
63
  fullWidth?: boolean;
64
+ /**
65
+ * Alerts stacked under the field. The validation error, when there is one, always renders first —
66
+ * it is the actionable message — and these follow in order. Ignored in `bare` mode (a
67
+ * `FormBuilder.Table` cell), where errors surface as a tooltip to keep the row one line tall.
68
+ */
69
+ hints?: FieldHintSpec[];
54
70
  }
55
71
 
56
72
  export interface OptionItem {
@@ -85,6 +101,11 @@ export interface SearchableSelectFieldProps extends SelectFieldProps {
85
101
  /** `FormBuilder.MultiSelect` / `.Tags`, `.Radio`. */
86
102
  export interface OptionsFieldProps extends BaseFieldProps {
87
103
  options: OptionItem[];
104
+ /**
105
+ * LOCAL PATCH (Contact Center): `MultiSelect`/`Tags` only — let the user type a value that is
106
+ * not in `options` and have it become a badge. Pass `options={[]}` for a pure free-text list.
107
+ */
108
+ creatable?: boolean;
88
109
  }
89
110
 
90
111
  /** `FormBuilder.Currency`. */
@@ -5,6 +5,8 @@ import { ReactNode } from "react";
5
5
  import {
6
6
  Drawer,
7
7
  DrawerContent,
8
+ DrawerDescription,
9
+ DrawerNested,
8
10
  DrawerPanel,
9
11
  DrawerTitle,
10
12
  DrawerNotch,
@@ -12,7 +14,10 @@ import {
12
14
  DrawerNotchDivider,
13
15
  DrawerNotchPill,
14
16
  } from "../Drawer";
17
+ import { cn } from "../../utils/cn";
15
18
  import { FormHeaderBar, type HeaderVariant } from "./header";
19
+ // Only for vaul's `direction` — every other mirror below is native CSS. See `slideFrom`.
20
+ import { useHtmlDir } from "../../hooks/useHtmlDir";
16
21
 
17
22
  export interface FormDrawerProps {
18
23
  open: boolean;
@@ -40,8 +45,52 @@ export interface FormDrawerProps {
40
45
  variant?: HeaderVariant;
41
46
  /** Action buttons shown on the right of the drawer header (e.g. a Save submit). */
42
47
  actions?: ReactNode;
43
- /** Shows an "Open in new tab" pill in the notch when provided. */
48
+ /**
49
+ * Shows an "Open in new tab" pill in the notch when provided.
50
+ *
51
+ * Prefer `notchActions` (a `FormRenderer.NotchAction` child): the caller owns the label
52
+ * there, so it needs no second prop to be translatable. Kept for callers that only want
53
+ * upstream's single built-in action.
54
+ */
44
55
  onOpenInNewTab?: () => void;
56
+ /**
57
+ * Buttons rendered in the notch, authored by the caller.
58
+ *
59
+ * LOCAL PATCH (Contact Center): upstream offers only `onOpenInNewTab` with a hardcoded
60
+ * English label, so a localized app cannot translate it and cannot add a second action.
61
+ * Passing the button itself solves both. Written as `FormRenderer.NotchAction` children
62
+ * and lifted here — see `notch-action.tsx`.
63
+ */
64
+ notchActions?: ReactNode;
65
+
66
+ /**
67
+ * LOCAL PATCH (Contact Center): the layout knobs `DrawerContent` already has.
68
+ *
69
+ * Upstream swallowed every one of them, which is why each non-default drawer in this app was
70
+ * hand-rolled instead: a two-field form does not want 1048px, a widget gallery is a bottom
71
+ * sheet, and a detail view brings its own header and scroll container. All optional and all
72
+ * defaulting to the original behaviour, so existing callers are untouched.
73
+ */
74
+
75
+ /** Which edge it slides from. `"bottom"` is a sheet; the default follows document direction. */
76
+ side?: "inline-end" | "bottom";
77
+ /**
78
+ * vaul `NestedRoot` — REQUIRED when this drawer opens inside another one, or the two Roots
79
+ * fight over the overlay and the scroll lock. Throws without a parent Drawer in the tree.
80
+ */
81
+ nested?: boolean;
82
+ /** The dark tray frame and the panel's border/inset shadow. Default `true`. */
83
+ framed?: boolean;
84
+ /** Skip the `FormHeaderBar` — for a child that draws its own header. */
85
+ hideHeader?: boolean;
86
+ /** Skip the padded scroll wrapper — for a child that owns its own padding and scrolling. */
87
+ bareBody?: boolean;
88
+ /** sr-only description. vaul warns when a drawer has none. */
89
+ description?: string;
90
+ /** Lands on the positioner: width, height, insets. Replaces the default sizing. */
91
+ wrapperClassName?: string;
92
+ /** Lands on the tray. */
93
+ className?: string;
45
94
  }
46
95
 
47
96
  /**
@@ -69,42 +118,107 @@ export function FormDrawer({
69
118
  variant,
70
119
  actions,
71
120
  onOpenInNewTab,
121
+ notchActions,
122
+ // LOCAL PATCH (Contact Center) — see the props above.
123
+ side = "inline-end",
124
+ nested = false,
125
+ framed = true,
126
+ hideHeader = false,
127
+ bareBody = false,
128
+ description,
129
+ wrapperClassName,
130
+ className,
72
131
  }: FormDrawerProps) {
73
132
  const conclusion = summary ?? childrenOutside;
133
+ const isBottom = side === "bottom";
134
+ // A drawer inside a drawer must be vaul's NestedRoot, which scales the parent behind it.
135
+ const Root = nested ? DrawerNested : Drawer;
136
+
137
+ // The ONLY thing that still has to know the direction in JS. Everything visual below is
138
+ // expressed in logical CSS and mirrors itself; but vaul (1.1.2) has no RTL support at all —
139
+ // it computes an inline `transform: translate3d(±Npx,0,0)` from `direction` and keys its drag
140
+ // physics off it. An inline transform cannot be overridden from a stylesheet mid-drag, so this
141
+ // one value must be passed, not styled. Read from <html dir>, so it still follows the document.
142
+ const slideFrom = useHtmlDir() === "rtl" ? "left" : "right";
74
143
 
75
144
  return (
76
- <Drawer open={open} onOpenChange={onOpenChange} direction="right">
145
+ <Root open={open} onOpenChange={onOpenChange} direction={isBottom ? "bottom" : slideFrom}>
77
146
  {/* The panel fills the available width (minus the 8px insets), capped at 1048px.
78
147
  `gap-[6px]` is the gutter between the form panel and the conclusion beside it. */}
79
148
  <DrawerContent
80
- wrapperClassName="top-2 right-2 bottom-2 left-auto mt-0 h-auto w-[calc(100vw-1rem)] max-w-[1048px]"
81
- className="gap-[6px]"
149
+ // Logical: the notch attaches to the inline-start edge, which the browser resolves
150
+ // to left under LTR and right under RTL. No direction check here.
151
+ notchSide="start"
152
+ framed={framed}
153
+ // `end-2` / `start-auto` are logical insets (inset-inline-*), so the panel parks
154
+ // against the inline-end edge in either direction — this used to be two hand-written
155
+ // physical class strings picked by JS.
156
+ //
157
+ // LOCAL PATCH (Contact Center): `inset-x-auto` is load-bearing. `DrawerContent` hardcodes
158
+ // `inset-x-0`, and tailwind-merge's `inset-x` conflict group covers `left`/`right` but NOT
159
+ // `start`/`end` — so `inset-x-0` survives into the class list and was only losing because
160
+ // Tailwind v4 happens to emit `start`/`end` later in the cascade. Every caller inherited
161
+ // that; this stops depending on emit order.
162
+ wrapperClassName={
163
+ wrapperClassName ??
164
+ (isBottom
165
+ ? "inset-x-0 bottom-0 top-auto mt-0 h-auto w-full"
166
+ : "top-2 end-2 bottom-2 inset-x-auto start-auto mt-0 h-auto w-[calc(100vw-1rem)] max-w-[1048px]")
167
+ }
168
+ className={cn("gap-[6px]", className)}
169
+ // LOCAL PATCH (Contact Center): a bottom sheet has no notch. The notch is a tab on the
170
+ // panel's inline-start edge — on a sheet that slides up from below there is no such edge
171
+ // to hang it from, and it renders as a stray pill floating above the corner.
82
172
  notch={
173
+ isBottom ? undefined : (
83
174
  <DrawerNotch>
84
175
  <DrawerNotchClose onClick={() => onOpenChange(false)} />
85
- {onOpenInNewTab && (
176
+ {/* Caller-authored notch buttons win; `onOpenInNewTab` is upstream's built-in
177
+ single action, kept as a fallback for callers that pass no children. */}
178
+ {notchActions ? (
86
179
  <>
87
180
  <DrawerNotchDivider />
88
- <DrawerNotchPill color="Yellow" onClick={onOpenInNewTab}>
89
- Open in new tab
90
- <i className="ri-arrow-right-up-line text-[12px]" />
91
- </DrawerNotchPill>
181
+ {notchActions}
92
182
  </>
183
+ ) : (
184
+ onOpenInNewTab && (
185
+ <>
186
+ <DrawerNotchDivider />
187
+ <DrawerNotchPill color="Yellow" onClick={onOpenInNewTab}>
188
+ Open in new tab
189
+ <i className="ri-arrow-right-up-line text-[12px]" />
190
+ </DrawerNotchPill>
191
+ </>
192
+ )
93
193
  )}
94
194
  </DrawerNotch>
195
+ )
95
196
  }
96
197
  >
97
- <DrawerPanel className="rounded-tr-[16px] rounded-b-[16px] p-0">
198
+ {/* `rounded-se-*` is the logical top-inline-end corner: rounded away from the notch,
199
+ square beneath it, mirrored by the browser rather than by a ternary. */}
200
+ <DrawerPanel
201
+ framed={framed}
202
+ className={cn(
203
+ "p-0",
204
+ isBottom ? "rounded-t-[16px]" : "rounded-se-[16px] rounded-b-[16px]",
205
+ )}
206
+ >
98
207
  <div className="relative flex min-h-0 flex-1 flex-col">
99
208
  {/* Vaul requires a Drawer.Title for the a11y name; the visible title is the
100
209
  HeaderBar below, so this one is for screen readers only. */}
101
210
  <DrawerTitle className="sr-only">{title}</DrawerTitle>
211
+ {description && (
212
+ <DrawerDescription className="sr-only">{description}</DrawerDescription>
213
+ )}
102
214
 
103
215
  {/* The SAME floating header the page form uses, so a form's title looks
104
216
  identical in either surface. */}
105
- <FormHeaderBar title={title} label={badge} variant={variant}>
106
- {actions}
107
- </FormHeaderBar>
217
+ {!hideHeader && (
218
+ <FormHeaderBar title={title} label={badge} variant={variant}>
219
+ {actions}
220
+ </FormHeaderBar>
221
+ )}
108
222
 
109
223
  {/* pt-[72px] clears the 44px header pill (inset 4px) — same as the page shell. The
110
224
  48px bottom breathing-room goes on an inner wrapper, not the scroll container:
@@ -112,9 +226,17 @@ export function FormDrawer({
112
226
  swallow the container's own `pb`. On a plain wrapper that `h-full` resolves to the
113
227
  content height, so the padding actually lengthens the scroll. The conclusion panel
114
228
  is a separate sibling, so it keeps its own spacing. */}
115
- <div className="h-full overflow-y-auto px-3 pt-[72px]">
116
- <div className="pb-[48px]">{children}</div>
117
- </div>
229
+ {/* LOCAL PATCH (Contact Center): `bareBody` hands the body to the child untouched.
230
+ Without it, a child that already scrolls and already offsets for its own header
231
+ (a detail view does both) gets a second scroll container and a second 72px of
232
+ dead space stacked on top of its own. */}
233
+ {bareBody ? (
234
+ children
235
+ ) : (
236
+ <div className={cn("h-full overflow-y-auto px-3", !hideHeader && "pt-[72px]")}>
237
+ <div className="pb-[48px]">{children}</div>
238
+ </div>
239
+ )}
118
240
  </div>
119
241
  </DrawerPanel>
120
242
 
@@ -123,6 +245,6 @@ export function FormDrawer({
123
245
  scrolling inside it. No `flex-1`: the panel sizes itself. */}
124
246
  {conclusion && <div className="flex min-h-0">{conclusion}</div>}
125
247
  </DrawerContent>
126
- </Drawer>
248
+ </Root>
127
249
  );
128
250
  }