torch-glare 2.5.5 → 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 (40) hide show
  1. package/apps/lib/components/BadgeField.tsx +68 -3
  2. package/apps/lib/components/Button.tsx +10 -2
  3. package/apps/lib/components/DataViews/data-views.tsx +17 -5
  4. package/apps/lib/components/DataViews/index.ts +8 -4
  5. package/apps/lib/components/DataViews/slots.ts +9 -0
  6. package/apps/lib/components/DataViews/states.tsx +43 -8
  7. package/apps/lib/components/Drawer.tsx +70 -39
  8. package/apps/lib/components/DropdownMenu.tsx +14 -0
  9. package/apps/lib/components/FormBuilder/context.ts +12 -0
  10. package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +19 -14
  11. package/apps/lib/components/FormBuilder/fields/SelectField.tsx +31 -8
  12. package/apps/lib/components/FormBuilder/submit.tsx +21 -1
  13. package/apps/lib/components/FormBuilder/types.ts +5 -0
  14. package/apps/lib/components/FormRenderer/FormDrawer.tsx +139 -17
  15. package/apps/lib/components/FormRenderer/detail.tsx +57 -8
  16. package/apps/lib/components/FormRenderer/form-renderer.tsx +66 -5
  17. package/apps/lib/components/FormRenderer/index.ts +2 -0
  18. package/apps/lib/components/FormRenderer/notch-action.tsx +64 -0
  19. package/apps/lib/components/FormRenderer/stepper.tsx +56 -2
  20. package/apps/lib/components/FormRenderer/types.ts +37 -0
  21. package/apps/lib/components/SectionBlock.tsx +24 -3
  22. package/apps/lib/components/Select.tsx +9 -9
  23. package/apps/lib/components/SlideDatePicker.tsx +2 -0
  24. package/apps/lib/components/Table.tsx +15 -28
  25. package/apps/lib/hooks/useActiveTreeItem.ts +4 -1
  26. package/apps/lib/hooks/useHtmlDir.ts +31 -0
  27. package/apps/lib/hooks/useTagSelection.ts +95 -9
  28. package/apps/lib/registry.json +20 -4
  29. package/apps/lib/utils/scroller.ts +26 -0
  30. package/docs/components/badge-field.md +26 -0
  31. package/docs/components/data-views/index.md +31 -5
  32. package/docs/components/data-views/migration.md +7 -5
  33. package/docs/components/drawer.md +5 -5
  34. package/docs/components/form-builder.md +9 -1
  35. package/docs/components/form-renderer.md +71 -1
  36. package/docs/components/section-block.md +6 -0
  37. package/docs/migration/changelog.md +6 -0
  38. package/docs/reference/hooks.md +23 -0
  39. package/docs/reference/utilities.md +22 -0
  40. package/package.json +1 -1
@@ -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;
@@ -1,9 +1,13 @@
1
1
  import { forwardRef, HTMLAttributes, ReactNode } from "react";
2
2
  import { cva, type VariantProps } from "class-variance-authority";
3
3
  import { cn } from "../utils/cn";
4
+ import { horizontalScrollerStyles } from "../utils/scroller";
4
5
 
6
+ // LOCAL PATCH (Contact Center): the pill's side padding is asymmetric (16 leading / 22 trailing),
7
+ // so it must be logical — `ps`/`pe` rather than `pl`/`pr`. This pill heads every section card, so
8
+ // mirrored the wrong way it reads as a systematic misalignment across the whole page.
5
9
  const titleBadge = cva(
6
- "flex pt-2 pb-2 pl-[16px] pr-[22px] justify-center items-center gap-[6px] rounded-[10px] self-start typography-headers-medium-medium text-[#F4F4F4]",
10
+ "flex pt-2 pb-2 ps-[16px] pe-[22px] justify-center items-center gap-[6px] rounded-[10px] self-start typography-headers-medium-medium text-[#F4F4F4]",
7
11
  {
8
12
  variants: {
9
13
  color: {
@@ -56,12 +60,29 @@ const header = cva("flex px-[6px] justify-between gap-3", {
56
60
  defaultVariants: { variant: "Default" },
57
61
  });
58
62
 
59
- const body = cva("flex w-full flex-col", {
63
+ // LOCAL PATCH (Contact Center): the body is the section's horizontal scrollport.
64
+ //
65
+ // `Table` renders `overflow-visible w-auto` (so its sticky header can reach a real scrollport), so
66
+ // a table wider than its card does not clip or scroll itself — it widens the card, and then the
67
+ // page. Only one call site in the app wraps its table in `TableScroller`; the rest drop a bare
68
+ // `<Table>` straight into a section. Owning the scroll here contains all of them at once, and
69
+ // replaces the `Table` variant's old `overflow-hidden`, which truncated instead of scrolling.
70
+ //
71
+ // `overflow-y-hidden` is required, not decorative: CSS computes `overflow-y: visible` to `auto`
72
+ // whenever `overflow-x` is not `visible`, so without it every section grows a spurious vertical
73
+ // scrollbar. The body is content-height, so nothing is clipped vertically. Same reasoning, and the
74
+ // same scrollbar styling, as `TableScroller`.
75
+ //
76
+ // The cost: a scrollport is the containing block for `position: sticky`, so a `<Table>`'s sticky
77
+ // header inside a section now pins to a box that never scrolls vertically — i.e. it stops
78
+ // sticking. Sections hold short tables, and in `variant="Table"` those headers were already inert
79
+ // under the old `overflow-hidden`.
80
+ const body = cva(`flex w-full flex-col ${horizontalScrollerStyles}`, {
60
81
  variants: {
61
82
  variant: {
62
83
  Default: "px-[42px] gap-[2px]",
63
84
  // Full bleed, with the rule that separates the header from the table.
64
- Table: "mt-[6px] border-t border-border-presentation-global-primary overflow-hidden",
85
+ Table: "mt-[6px] border-t border-border-presentation-global-primary",
65
86
  },
66
87
  },
67
88
  defaultVariants: { variant: "Default" },
@@ -16,12 +16,12 @@ const SelectValue = SelectPrimitive.Value;
16
16
  const SelectTrigger = React.forwardRef<
17
17
  React.ElementRef<typeof SelectPrimitive.Trigger>,
18
18
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
19
- VariantProps<typeof PopoverTriggerStyles> & {
20
- /** Marks the trigger invalid: any non-undefined value turns on the negative border. */
21
- errors?: string;
22
- icon?: string;
23
- theme?: Themes;
24
- }
19
+ VariantProps<typeof PopoverTriggerStyles> & {
20
+ /** Marks the trigger invalid: any non-undefined value turns on the negative border. */
21
+ errors?: string;
22
+ icon?: string;
23
+ theme?: Themes;
24
+ }
25
25
  >(
26
26
  (
27
27
  {
@@ -124,9 +124,9 @@ SelectScrollDownButton.displayName = "SelectScrollDownButton";
124
124
  const SelectContent = React.forwardRef<
125
125
  React.ElementRef<typeof SelectPrimitive.Content>,
126
126
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> &
127
- VariantProps<typeof SelectContentStyles> & {
128
- theme?: Themes;
129
- }
127
+ VariantProps<typeof SelectContentStyles> & {
128
+ theme?: Themes;
129
+ }
130
130
  >(
131
131
  (
132
132
  { className, children, variant = "PresentationStyle", position = "popper", theme, ...props },
@@ -161,11 +161,13 @@ export const SlideDatePicker = forwardRef<HTMLInputElement, SlideDatePickerProps
161
161
  (children as React.ReactElement<HTMLInputElement>).props.value ?? formattedValue,
162
162
  type: "input",
163
163
  readOnly: true,
164
+ theme,
164
165
  })
165
166
  ) : (
166
167
  /* If the children is not a valid element, Show the default input */
167
168
  <InputField
168
169
  readOnly
170
+ theme={theme}
169
171
  type="input"
170
172
  {...props}
171
173
  childrenSide={
@@ -6,6 +6,7 @@ import { useRef } from "react";
6
6
  import { Button } from "./Button";
7
7
  import { Checkbox } from "./Checkbox";
8
8
  import { useResize } from "../hooks/useResize";
9
+ import { horizontalScrollerStyles } from "../utils/scroller";
9
10
 
10
11
  type TableHeadVariantsProps = VariantProps<typeof tableHeadVariants>;
11
12
 
@@ -18,18 +19,20 @@ const Table = React.forwardRef<
18
19
  <table
19
20
  data-theme={theme}
20
21
  ref={ref}
21
- // `overflow-hidden` is the default, and it is load-bearing twice over: the table is `w-auto`,
22
- // so one wider than its container would otherwise push the whole page wide and put a horizontal
23
- // scrollbar on the layout; and callers that give the table a radius rely on it to clip the
24
- // square header band out of the rounded corners.
22
+ // `overflow-visible` is the default, so the table does NOT clip or scroll itself. That is what
23
+ // lets `TableHeader`'s `sticky` reach past the table to the nearest real scrollport and
24
+ // actually pin — which is the whole point of it being sticky.
25
25
  //
26
- // The cost is that it makes the table its own scroll container, and `TableHeader`'s `sticky`
27
- // resolves against the *nearest* scrollport — so inside a clipping table the header pins to a
28
- // box that never scrolls, i.e. does nothing. That is the right default: a header only benefits
29
- // from sticking when the table sits in a scroller, and such a caller passes `overflow-visible`
30
- // (tailwind-merge lets theirs win) to bind it to that scroller instead. Only do that where the
31
- // scroller is `min-w-0`, or the width problem above comes back — `DataViews`' table view is the
32
- // worked example.
26
+ // The consequence is that the table is `w-auto` and unclipped, so **a wider-than-its-container
27
+ // table is the container's problem**. Every table therefore needs a scrolling ancestor, or it
28
+ // pushes its container — and eventually the page — wide. Two provide one:
29
+ // • `TableScroller` (below), wrapping the table directly. `FormBuilder.Table` uses it.
30
+ // • `SectionBlock`'s body, which is `overflow-x-auto` — so a bare `<Table>` dropped into any
31
+ // section card scrolls inside the card. That covers the app's detail tabs.
32
+ // `DataViews`' table view supplies its own `min-w-0 overflow-auto` scroller instead.
33
+ //
34
+ // A caller that wants the old self-clipping behaviour passes `overflow-hidden`
35
+ // (tailwind-merge lets theirs win) and gives up the sticky header in exchange.
33
36
  //
34
37
  // `[border-collapse:separate]` is what lets the header cells keep their borders while stuck.
35
38
  className={cn("overflow-visible w-auto [border-collapse:separate] border-spacing-0", className)}
@@ -388,23 +391,7 @@ TableEndAction.displayName = "TableEndAction";
388
391
  */
389
392
  const TableScroller = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
390
393
  ({ className, children, ...props }, ref) => (
391
- <div
392
- ref={ref}
393
- className={cn(
394
- "w-full overflow-x-auto overflow-y-hidden",
395
- "[&::-webkit-scrollbar]:h-[14px]",
396
- "[&::-webkit-scrollbar-track]:bg-transparent",
397
- "[&::-webkit-scrollbar-thumb]:rounded-[7px]",
398
- "[&::-webkit-scrollbar-thumb]:border-[5px] [&::-webkit-scrollbar-thumb]:border-solid",
399
- "[&::-webkit-scrollbar-thumb]:border-transparent",
400
- "[&::-webkit-scrollbar-thumb]:bg-clip-content",
401
- "[&::-webkit-scrollbar-thumb]:bg-background-presentation-body-scroller-default",
402
- "[&::-webkit-scrollbar-thumb:hover]:border-[3px]",
403
- "[&::-webkit-scrollbar-thumb:hover]:bg-background-presentation-body-scroller-hover",
404
- className,
405
- )}
406
- {...props}
407
- >
394
+ <div ref={ref} className={cn("w-full", horizontalScrollerStyles, className)} {...props}>
408
395
  {children}
409
396
  </div>
410
397
  ),
@@ -5,10 +5,13 @@ export function useActiveTreeItem(itemIds: string[]) {
5
5
  const [activeId, setActiveId] = useState<string | null>(null);
6
6
 
7
7
  useEffect(() => {
8
- if (!itemIds || itemIds.length === 0) {
8
+ // An empty list is a legitimate answer — a page with no Quick Nav has nothing to track. Only a
9
+ // missing list is a caller mistake worth a warning.
10
+ if (!itemIds) {
9
11
  console.warn("No itemIds provided to useActiveTreeItem.");
10
12
  return;
11
13
  }
14
+ if (itemIds.length === 0) return;
12
15
 
13
16
  const observer = new IntersectionObserver(
14
17
  (entries) => {
@@ -0,0 +1,31 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Track the document's text direction from `<html dir>`, updating when it
5
+ * changes (e.g. on a language switch).
6
+ *
7
+ * Several Radix primitives (Tabs, etc.) default to `"ltr"` when no `dir` prop
8
+ * or `DirectionProvider` is supplied, which leaves them rendered left-to-right
9
+ * even on an RTL page. Forwarding this value as their `dir` makes them mirror
10
+ * correctly in Arabic.
11
+ */
12
+ export function useHtmlDir(): "ltr" | "rtl" {
13
+ const read = () =>
14
+ typeof document !== "undefined" && document.documentElement.dir === "rtl"
15
+ ? "rtl"
16
+ : "ltr";
17
+
18
+ const [dir, setDir] = React.useState<"ltr" | "rtl">(read);
19
+
20
+ React.useEffect(() => {
21
+ if (typeof document === "undefined") return;
22
+ const html = document.documentElement;
23
+ const sync = () => setDir(read());
24
+ sync();
25
+ const observer = new MutationObserver(sync);
26
+ observer.observe(html, { attributes: true, attributeFilter: ["dir"] });
27
+ return () => observer.disconnect();
28
+ }, []);
29
+
30
+ return dir;
31
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useEffect, useRef } from "react";
2
2
 
3
3
  export interface Tag {
4
4
  id: string;
@@ -9,16 +9,22 @@ export interface Tag {
9
9
  [key: string]: unknown;
10
10
  }
11
11
 
12
+ /** Stable key for a selection, used to tell an external change from one we just made. */
13
+ const signatureOf = (tags: Tag[]) => tags.map((t) => t.id).join("\0");
14
+
12
15
  export const useTagSelection = ({
13
16
  Tags,
14
17
  onTagsChange,
15
18
  inputRef,
16
19
  singleSelect = false,
20
+ creatable = false,
17
21
  }: {
18
22
  Tags: Tag[];
19
23
  onTagsChange?: (selectedTags: Tag[]) => void;
20
24
  inputRef?: React.RefObject<HTMLInputElement | null>;
21
25
  singleSelect?: boolean;
26
+ /** Allow typed text to become a selected tag that was never in `Tags`. */
27
+ creatable?: boolean;
22
28
  }) => {
23
29
  // Split initial tags into selected and unselected
24
30
  const initialSelectedTags = Tags.filter((tag) => tag.isSelected);
@@ -36,18 +42,63 @@ export const useTagSelection = ({
36
42
  const [focusedPopoverIndex, setFocusedPopoverIndex] = useState<number | null>(null);
37
43
  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
38
44
 
39
- // Update internal state when Tags prop changes
45
+ // LOCAL PATCH (Contact Center): key both effects below off a SIGNATURE of `Tags`, not the array
46
+ // reference. A caller that builds its tag list inline — `MultiSelectField` does, from the form
47
+ // value — hands over a fresh array every render, so the upstream `[Tags]` dependency changed on
48
+ // every pass: effect → setTags → re-render → new array → effect, an infinite render loop.
49
+ const tagsSignature = Tags.map((tag) => `${tag.id}:${tag.isSelected ? 1 : 0}`).join(" ");
50
+
51
+ // Update internal state when Tags actually changes.
52
+ //
53
+ // Filter against the INCOMING selection as well as the one we hold: on an external sync (the
54
+ // hydration case below) `selectedTagsStack` is still the pre-sync value at this point, so
55
+ // filtering by it alone left the freshly selected values sitting in the dropdown as if they
56
+ // were still available to add.
40
57
  useEffect(() => {
41
- // Only update if the Tags array reference has changed
42
- const selectedIds = selectedTagsStack.map((tag) => tag.id);
43
- setTags(Tags.filter((tag) => !selectedIds.includes(tag.id)));
44
- }, [Tags]);
58
+ const selectedIds = new Set([
59
+ ...selectedTagsStack.map((tag) => tag.id),
60
+ ...Tags.filter((tag) => tag.isSelected).map((tag) => tag.id),
61
+ ]);
62
+ setTags(Tags.filter((tag) => !selectedIds.has(tag.id)));
63
+ }, [tagsSignature]);
64
+
65
+ // LOCAL PATCH (Contact Center): follow the incoming selection.
66
+ //
67
+ // Upstream seeded `selectedTagsStack` from `Tags` ONCE, and the effect above refreshed only the
68
+ // AVAILABLE list — never the selection. That made the component effectively uncontrolled: on an
69
+ // edit form, react-hook-form's `reset()` hydration lands after mount, so a person's saved emails
70
+ // and tags rendered as an empty field. Re-sync whenever the incoming selected set differs from
71
+ // what we hold, and mark the change as external so it isn't echoed straight back to the parent.
72
+ const incomingSelected = Tags.filter((tag) => tag.isSelected);
73
+ const incomingSignature = signatureOf(incomingSelected);
74
+ const lastSyncedRef = useRef<string | null>(null);
75
+ // Starts true so the mount pass below is swallowed — see the notify effect.
76
+ const skipNotifyRef = useRef(true);
45
77
 
46
- // Notify parent component when tags change
47
78
  useEffect(() => {
48
- if (onTagsChange) {
49
- onTagsChange(selectedTagsStack);
79
+ if (lastSyncedRef.current === incomingSignature) return;
80
+ lastSyncedRef.current = incomingSignature;
81
+ // Already matches (we made this change ourselves) — don't re-set state, so the notify flag
82
+ // stays untouched and the user's next real edit still reaches the parent.
83
+ if (signatureOf(selectedTagsStack) === incomingSignature) return;
84
+ skipNotifyRef.current = true;
85
+ setSelectedTagsStack(
86
+ singleSelect && incomingSelected.length > 0 ? [incomingSelected[0]] : incomingSelected,
87
+ );
88
+ }, [incomingSignature]);
89
+
90
+ // Notify parent component when tags change.
91
+ //
92
+ // LOCAL PATCH (Contact Center): upstream fired this on MOUNT too, so an untouched field wrote
93
+ // `[]` into the form — marking it dirty and adding an empty-array key to the request payload for
94
+ // a value nobody entered. It also fires for a sync from the parent, which would echo the value
95
+ // straight back. Both are skipped via the flag.
96
+ useEffect(() => {
97
+ if (skipNotifyRef.current) {
98
+ skipNotifyRef.current = false;
99
+ return;
50
100
  }
101
+ onTagsChange?.(selectedTagsStack);
51
102
  }, [selectedTagsStack]);
52
103
 
53
104
  // Filter tags based on search input
@@ -92,6 +143,38 @@ export const useTagSelection = ({
92
143
  setFocusedTagIndex(null);
93
144
  };
94
145
 
146
+ /**
147
+ * LOCAL PATCH (Contact Center): turn typed text into a selected tag.
148
+ *
149
+ * Upstream had no way in but `handleSelectTag(id)` against the fixed `Tags` list, so a free-text
150
+ * list (a person's emails, an organization's aliases) could not be expressed as a badge field at
151
+ * all — which is why those lists were built as one-column tables instead. Matching an existing
152
+ * tag by name selects it rather than creating a duplicate; the id IS the text, so a value the
153
+ * caller round-trips keeps a stable identity.
154
+ */
155
+ const handleCreateTag = (rawName: string) => {
156
+ const name = rawName.trim();
157
+ if (!name) return;
158
+ const sameName = (tag: Tag) => tag.name.toLowerCase() === name.toLowerCase();
159
+
160
+ // Already chosen — just clear the box so the user sees their text was accepted.
161
+ if (selectedTagsStack.some(sameName)) {
162
+ filterTagsBySearch("");
163
+ return;
164
+ }
165
+ // Offered in the list — select it instead of creating a look-alike.
166
+ const existing = tags.find(sameName);
167
+ if (existing) {
168
+ handleSelectTag(existing.id);
169
+ return;
170
+ }
171
+
172
+ const created: Tag = { id: name, name, value: name, isSelected: true };
173
+ setSelectedTagsStack((prev) => (singleSelect ? [created] : [...prev, created]));
174
+ filterTagsBySearch("");
175
+ setFocusedPopoverIndex(null);
176
+ };
177
+
95
178
  // Reset the hook state with new data
96
179
  const reset = (newTags: Tag[] = [], newSelectedTags: Tag[] = []) => {
97
180
  // In single select mode, ensure we only have at most one selected tag
@@ -198,6 +281,9 @@ export const useTagSelection = ({
198
281
  searchTags,
199
282
  handleSelectTag,
200
283
  handleUnselectTag,
284
+ // LOCAL PATCH (Contact Center) — see above.
285
+ handleCreateTag,
286
+ creatable,
201
287
  handleKeyDown,
202
288
  setFocusedTagIndex,
203
289
  setFocusedPopoverIndex,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.5.5",
2
+ "version": "2.5.6",
3
3
  "generatedBy": "scripts/bin/generateRegistry",
4
4
  "npmVersions": {
5
5
  "@dnd-kit/core": "^6.3.1",
@@ -439,7 +439,6 @@
439
439
  "components/Table",
440
440
  "components/TextEditor",
441
441
  "components/Textarea",
442
- "components/Tooltip",
443
442
  "hooks/useTagSelection",
444
443
  "layouts/FieldSection",
445
444
  "utils/cn"
@@ -461,6 +460,7 @@
461
460
  "components/SectionBlock",
462
461
  "components/Stepper",
463
462
  "components/TabFormItem",
463
+ "hooks/useHtmlDir",
464
464
  "utils/cn"
465
465
  ]
466
466
  },
@@ -772,7 +772,8 @@
772
772
  "class-variance-authority"
773
773
  ],
774
774
  "registryDependencies": [
775
- "utils/cn"
775
+ "utils/cn",
776
+ "utils/scroller"
776
777
  ]
777
778
  },
778
779
  {
@@ -883,7 +884,8 @@
883
884
  "components/Button",
884
885
  "components/Checkbox",
885
886
  "hooks/useResize",
886
- "utils/cn"
887
+ "utils/cn",
888
+ "utils/scroller"
887
889
  ]
888
890
  },
889
891
  {
@@ -1062,6 +1064,13 @@
1062
1064
  ],
1063
1065
  "registryDependencies": []
1064
1066
  },
1067
+ {
1068
+ "name": "useHtmlDir",
1069
+ "type": "hooks",
1070
+ "path": "hooks/useHtmlDir.ts",
1071
+ "npmDependencies": [],
1072
+ "registryDependencies": []
1073
+ },
1065
1074
  {
1066
1075
  "name": "useInfiniteScroll",
1067
1076
  "type": "hooks",
@@ -1190,6 +1199,13 @@
1190
1199
  "npmDependencies": [],
1191
1200
  "registryDependencies": []
1192
1201
  },
1202
+ {
1203
+ "name": "scroller",
1204
+ "type": "utils",
1205
+ "path": "utils/scroller.ts",
1206
+ "npmDependencies": [],
1207
+ "registryDependencies": []
1208
+ },
1193
1209
  {
1194
1210
  "name": "types",
1195
1211
  "type": "utils",
@@ -0,0 +1,26 @@
1
+ /**
2
+ * LOCAL PATCH (Contact Center): the design's 14px horizontal scroller — a thin track that thickens
3
+ * and turns blue on hover.
4
+ *
5
+ * Lives here, not on `Table`, because two unrelated components wear it: `TableScroller` (the
6
+ * wrapper `FormBuilder.Table` puts around its grid) and `SectionBlock`'s body (which scrolls any
7
+ * wide content dropped into a section card). A section card should not have to import the table
8
+ * component — and its consumers should not pull in the table's dependencies — just to share a
9
+ * scrollbar.
10
+ *
11
+ * `overflow-y-hidden` is part of the set on purpose: CSS computes `overflow-y: visible` to `auto`
12
+ * whenever `overflow-x` is not `visible`, so omitting it gives every consumer a spurious vertical
13
+ * scrollbar.
14
+ */
15
+ export const horizontalScrollerStyles = [
16
+ "overflow-x-auto overflow-y-hidden",
17
+ "[&::-webkit-scrollbar]:h-[14px]",
18
+ "[&::-webkit-scrollbar-track]:bg-transparent",
19
+ "[&::-webkit-scrollbar-thumb]:rounded-[7px]",
20
+ "[&::-webkit-scrollbar-thumb]:border-[5px] [&::-webkit-scrollbar-thumb]:border-solid",
21
+ "[&::-webkit-scrollbar-thumb]:border-transparent",
22
+ "[&::-webkit-scrollbar-thumb]:bg-clip-content",
23
+ "[&::-webkit-scrollbar-thumb]:bg-background-presentation-body-scroller-default",
24
+ "[&::-webkit-scrollbar-thumb:hover]:border-[3px]",
25
+ "[&::-webkit-scrollbar-thumb:hover]:bg-background-presentation-body-scroller-hover",
26
+ ].join(" ");
@@ -477,6 +477,32 @@ Extends all Input element props (except size and variant).
477
477
  | addLabel | `string` | `'add'` | Label for the add action shown in the field |
478
478
  | dir | `string` | `'ltr'` | Reading direction (`'rtl'` for right-to-left) |
479
479
  | placeholder | `string` | - | Input placeholder text |
480
+ | creatable | `boolean` | `false` | Let the user type a value that is not in `tags` and commit it as a badge |
481
+ | createLabel | `(value: string) => string` | ``value => `Create "${value}"` `` | Label for the create row; receives the typed text |
482
+
483
+ ### Creatable tags
484
+
485
+ With `creatable`, the field stops being a picker over a fixed list: whatever the user types can
486
+ become a badge. Enter or comma commits it; Backspace on an empty box removes the last badge. Pass
487
+ `tags={[]}` for a pure free-text list — emails, aliases, arbitrary labels — which otherwise has to be
488
+ modelled as a one-column table.
489
+
490
+ ```tsx
491
+ <BadgeField
492
+ creatable
493
+ tags={recipients}
494
+ onValueChange={setRecipients}
495
+ placeholder="Add an email…"
496
+ createLabel={(value) => `Invite ${value}`}
497
+ />
498
+ ```
499
+
500
+ The create row is suppressed when the typed text already matches a selected or listed tag
501
+ (case-insensitive), so you cannot produce duplicates. With `creatable` set, the empty-list message
502
+ becomes "Type a value and press Enter" rather than "All tags selected".
503
+
504
+ > `FormBuilder.MultiSelect` / `.Tags` forward `creatable`, but **not** `createLabel` — inside a form
505
+ > the create row keeps the default label.
480
506
 
481
507
  ### Tag Type
482
508