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.
- package/apps/lib/components/BadgeField.tsx +68 -3
- package/apps/lib/components/Button.tsx +10 -2
- package/apps/lib/components/DataViews/data-views.tsx +17 -5
- package/apps/lib/components/DataViews/index.ts +8 -4
- package/apps/lib/components/DataViews/slots.ts +9 -0
- package/apps/lib/components/DataViews/states.tsx +43 -8
- package/apps/lib/components/Drawer.tsx +70 -39
- package/apps/lib/components/DropdownMenu.tsx +14 -0
- package/apps/lib/components/FormBuilder/context.ts +12 -0
- package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +19 -14
- package/apps/lib/components/FormBuilder/fields/SelectField.tsx +31 -8
- package/apps/lib/components/FormBuilder/submit.tsx +21 -1
- package/apps/lib/components/FormBuilder/types.ts +5 -0
- package/apps/lib/components/FormRenderer/FormDrawer.tsx +139 -17
- package/apps/lib/components/FormRenderer/detail.tsx +57 -8
- package/apps/lib/components/FormRenderer/form-renderer.tsx +66 -5
- package/apps/lib/components/FormRenderer/index.ts +2 -0
- package/apps/lib/components/FormRenderer/notch-action.tsx +64 -0
- package/apps/lib/components/FormRenderer/stepper.tsx +56 -2
- package/apps/lib/components/FormRenderer/types.ts +37 -0
- package/apps/lib/components/SectionBlock.tsx +24 -3
- package/apps/lib/components/Select.tsx +9 -9
- package/apps/lib/components/SlideDatePicker.tsx +2 -0
- package/apps/lib/components/Table.tsx +15 -28
- package/apps/lib/hooks/useActiveTreeItem.ts +4 -1
- package/apps/lib/hooks/useHtmlDir.ts +31 -0
- package/apps/lib/hooks/useTagSelection.ts +95 -9
- package/apps/lib/registry.json +20 -4
- package/apps/lib/utils/scroller.ts +26 -0
- package/docs/components/badge-field.md +26 -0
- package/docs/components/data-views/index.md +31 -5
- package/docs/components/data-views/migration.md +7 -5
- package/docs/components/drawer.md +5 -5
- package/docs/components/form-builder.md +9 -1
- package/docs/components/form-renderer.md +71 -1
- package/docs/components/section-block.md +6 -0
- package/docs/migration/changelog.md +6 -0
- package/docs/reference/hooks.md +23 -0
- package/docs/reference/utilities.md +22 -0
- 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
|
-
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
128
|
-
|
|
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-
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 (
|
|
49
|
-
|
|
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,
|
package/apps/lib/registry.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.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
|
|