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
|
@@ -64,22 +64,45 @@ export function SearchableSelectField(props: SearchableSelectFieldProps) {
|
|
|
64
64
|
);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/**
|
|
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
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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({
|
|
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
|
|
@@ -101,6 +101,11 @@ export interface SearchableSelectFieldProps extends SelectFieldProps {
|
|
|
101
101
|
/** `FormBuilder.MultiSelect` / `.Tags`, `.Radio`. */
|
|
102
102
|
export interface OptionsFieldProps extends BaseFieldProps {
|
|
103
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;
|
|
104
109
|
}
|
|
105
110
|
|
|
106
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
|
-
/**
|
|
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
|
-
<
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
{
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
</
|
|
248
|
+
</Root>
|
|
127
249
|
);
|
|
128
250
|
}
|
|
@@ -5,6 +5,7 @@ import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
|
5
5
|
|
|
6
6
|
import { cn } from "../../utils/cn";
|
|
7
7
|
import { formBarItemStyles } from "../TabFormItem";
|
|
8
|
+
import { useHtmlDir } from "../../hooks/useHtmlDir";
|
|
8
9
|
import { FormHeaderBar, type HeaderVariant } from "./header";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -55,7 +56,9 @@ function DetailSidebarItem({ value, icon, children }: DetailSidebarItemProps) {
|
|
|
55
56
|
)}
|
|
56
57
|
>
|
|
57
58
|
{icon}
|
|
58
|
-
|
|
59
|
+
{/* LOCAL PATCH (Contact Center): `text-start`, not `text-left` — otherwise an Arabic label
|
|
60
|
+
left-aligns inside a right-aligned rail, and `truncate` clips the wrong end. */}
|
|
61
|
+
<span className="flex-1 truncate text-start typography-body-medium-medium">{children}</span>
|
|
59
62
|
</TabsPrimitive.Trigger>
|
|
60
63
|
);
|
|
61
64
|
}
|
|
@@ -153,24 +156,65 @@ export interface DetailTabsViewProps {
|
|
|
153
156
|
/** The `FormRenderer.Tab` elements — the content panels. */
|
|
154
157
|
tabs: React.ReactElement<DetailTabProps>[];
|
|
155
158
|
className?: string;
|
|
159
|
+
/**
|
|
160
|
+
* LOCAL PATCH (Contact Center): the active tab, when the caller owns it. Omit both this and
|
|
161
|
+
* `onValueChange` to keep the original uncontrolled behaviour (defaults to the first tab).
|
|
162
|
+
*
|
|
163
|
+
* Upstream offered no way in, so a detail view could not put its tab in the URL — no
|
|
164
|
+
* `?tab=audit` deep link, no correct back-navigation, and a reload always bounced the user to
|
|
165
|
+
* the first tab. That is what this app's `useTabPersistence` provides.
|
|
166
|
+
*/
|
|
167
|
+
value?: string;
|
|
168
|
+
/** LOCAL PATCH (Contact Center): fires when a rail item is clicked. See `value`. */
|
|
169
|
+
onValueChange?: (value: string) => void;
|
|
170
|
+
/**
|
|
171
|
+
* LOCAL PATCH (Contact Center): rendered inside a surface that already draws its own card —
|
|
172
|
+
* a drawer, a panel. Drops the rounded body background so it doesn't double up, and lets the
|
|
173
|
+
* host own the height instead of filling the viewport.
|
|
174
|
+
*/
|
|
175
|
+
embedded?: boolean;
|
|
156
176
|
}
|
|
157
177
|
|
|
158
178
|
/**
|
|
159
179
|
* The detail-tabs surface: the floating header over a fixed left rail (the sidebar) + a scrolling
|
|
160
|
-
* content column showing the active tab. Radix `Tabs.Root` owns the state
|
|
161
|
-
* the first tab
|
|
180
|
+
* content column showing the active tab. Radix `Tabs.Root` owns the state — uncontrolled and
|
|
181
|
+
* defaulting to the first tab, unless the caller passes `value`/`onValueChange`. The rail matches
|
|
182
|
+
* the stepper's rail position; only the content column scrolls.
|
|
162
183
|
*/
|
|
163
|
-
export function DetailTabsView({
|
|
184
|
+
export function DetailTabsView({
|
|
185
|
+
header,
|
|
186
|
+
actions,
|
|
187
|
+
sidebar,
|
|
188
|
+
tabs,
|
|
189
|
+
className,
|
|
190
|
+
value,
|
|
191
|
+
onValueChange,
|
|
192
|
+
embedded,
|
|
193
|
+
}: DetailTabsViewProps) {
|
|
164
194
|
const defaultValue = tabs[0]?.props.value;
|
|
195
|
+
// Radix treats a defined `value` as controlled, so only pass one of the two — handing it both
|
|
196
|
+
// logs a warning and pins the tab.
|
|
197
|
+
const controlled = value !== undefined;
|
|
198
|
+
// LOCAL PATCH (Contact Center): Radix Tabs defaults to `dir="ltr"` when given neither a `dir`
|
|
199
|
+
// prop nor a `DirectionProvider`, and it stamps that onto the subtree — which left this whole
|
|
200
|
+
// detail surface rendering left-to-right on an Arabic page, no matter what `<html dir>` said.
|
|
201
|
+
// Same fix already applied to `Tabs.tsx` and `TabPage.tsx`.
|
|
202
|
+
const htmlDir = useHtmlDir();
|
|
165
203
|
|
|
166
204
|
return (
|
|
167
205
|
<TabsPrimitive.Root
|
|
168
206
|
orientation="vertical"
|
|
169
|
-
|
|
207
|
+
dir={htmlDir}
|
|
208
|
+
{...(controlled ? { value, onValueChange } : { defaultValue, onValueChange })}
|
|
170
209
|
className={cn("h-full w-full @container", className)}
|
|
171
210
|
>
|
|
172
211
|
{/* Scroll shell — mirrors FormBuilder's: the absolute header floats over the body. */}
|
|
173
|
-
<div
|
|
212
|
+
<div
|
|
213
|
+
className={cn(
|
|
214
|
+
"relative isolate flex h-full w-full flex-col overflow-hidden",
|
|
215
|
+
!embedded && "rounded-2xl bg-background-presentation-body-primary",
|
|
216
|
+
)}
|
|
217
|
+
>
|
|
174
218
|
{header && (
|
|
175
219
|
<FormHeaderBar title={header.title} label={header.label} variant={header.variant}>
|
|
176
220
|
{actions}
|
|
@@ -178,9 +222,14 @@ export function DetailTabsView({ header, actions, sidebar, tabs, className }: De
|
|
|
178
222
|
)}
|
|
179
223
|
|
|
180
224
|
<div className="relative z-[1] flex min-h-0 w-full flex-1 flex-row">
|
|
181
|
-
{/* The fixed rail — the tab list. `pt-[72px]` clears the floating header.
|
|
225
|
+
{/* The fixed rail — the tab list. `pt-[72px]` clears the floating header.
|
|
226
|
+
|
|
227
|
+
LOCAL PATCH (Contact Center): `border-e`, not `border-r`. The row is `flex-row`, which
|
|
228
|
+
is direction-aware, so under `dir="rtl"` the rail moves to the right — a physical
|
|
229
|
+
right border then lands on the outer screen edge instead of between rail and content,
|
|
230
|
+
leaving the rail visually detached. */}
|
|
182
231
|
<TabsPrimitive.List asChild>
|
|
183
|
-
<aside className="flex h-full w-[216px] shrink-0 flex-col gap-1 overflow-y-auto border-
|
|
232
|
+
<aside className="flex h-full w-[216px] shrink-0 flex-col gap-1 overflow-y-auto border-e border-border-presentation-global-primary bg-black-alpha-5 px-2 pb-6 pt-[72px] scrollbar-hide">
|
|
184
233
|
{sidebar.props.children}
|
|
185
234
|
</aside>
|
|
186
235
|
</TabsPrimitive.List>
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "./detail";
|
|
18
18
|
import { FormDrawer } from "./FormDrawer";
|
|
19
19
|
import { FormHeaderBar } from "./header";
|
|
20
|
+
import { NotchAction, isNotchActionElement } from "./notch-action";
|
|
20
21
|
import { Section } from "./section";
|
|
21
22
|
import {
|
|
22
23
|
Back,
|
|
@@ -72,6 +73,15 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
|
72
73
|
title,
|
|
73
74
|
badge,
|
|
74
75
|
onOpenInNewTab,
|
|
76
|
+
// LOCAL PATCH (Contact Center): detail-tabs control — see FormRendererProps.
|
|
77
|
+
activeTab,
|
|
78
|
+
onTabChange,
|
|
79
|
+
embedded,
|
|
80
|
+
// LOCAL PATCH (Contact Center): external stepper control — see FormRendererProps.
|
|
81
|
+
activeStep,
|
|
82
|
+
onStepChange,
|
|
83
|
+
// LOCAL PATCH (Contact Center): drawer layout — see FormRendererProps.
|
|
84
|
+
drawer,
|
|
75
85
|
}: FormRendererProps<T>) {
|
|
76
86
|
const isDrawer = display === "drawer";
|
|
77
87
|
|
|
@@ -101,6 +111,14 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
|
101
111
|
|
|
102
112
|
// Split out a `FormRenderer.Stepper`: its Steps become the visibility-toggled slots inside the
|
|
103
113
|
// `<form>`, its non-Step children a footer, and its state drives the rail beside them.
|
|
114
|
+
// LOCAL PATCH (Contact Center): lift `FormRenderer.NotchAction` children out of the form
|
|
115
|
+
// body and into the drawer's notch. Authored as children so the caller owns the label (and
|
|
116
|
+
// therefore its translation) instead of the component hardcoding English — see notch-action.tsx.
|
|
117
|
+
const notchActions = childArray.filter(isNotchActionElement);
|
|
118
|
+
const bodyChildren = notchActions.length
|
|
119
|
+
? childArray.filter((n) => !isNotchActionElement(n))
|
|
120
|
+
: children;
|
|
121
|
+
|
|
104
122
|
const stepperEl = childArray.find(isStepperElement);
|
|
105
123
|
const stepChildren = stepperEl ? Children.toArray(stepperEl.props.children) : [];
|
|
106
124
|
const steps = stepChildren.filter(isStepElement) as ReactElement<StepProps>[];
|
|
@@ -111,18 +129,55 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
|
111
129
|
const stepper = useStepperState(
|
|
112
130
|
steps,
|
|
113
131
|
formInstance.trigger as Parameters<typeof useStepperState>[1],
|
|
132
|
+
// LOCAL PATCH (Contact Center): undefined `activeStep` leaves the stepper uncontrolled.
|
|
133
|
+
{ activeStep, onStepChange },
|
|
114
134
|
);
|
|
115
135
|
|
|
116
136
|
if (detailSidebar && detailTabs.length > 0) {
|
|
117
|
-
|
|
137
|
+
const detailView = (
|
|
118
138
|
<DetailTabsView
|
|
119
|
-
header
|
|
120
|
-
|
|
139
|
+
// In a drawer the tray already paints the surface and the drawer header carries the
|
|
140
|
+
// title, so the view drops its own card and its own floating header.
|
|
141
|
+
header={isDrawer ? undefined : header}
|
|
142
|
+
actions={isDrawer ? undefined : actions}
|
|
121
143
|
sidebar={detailSidebar}
|
|
122
144
|
tabs={detailTabs}
|
|
123
145
|
className={className}
|
|
146
|
+
// LOCAL PATCH (Contact Center): lets the caller keep the tab in the URL, and render
|
|
147
|
+
// inside a surface that already draws its own card. Both optional.
|
|
148
|
+
value={activeTab}
|
|
149
|
+
onValueChange={onTabChange}
|
|
150
|
+
embedded={embedded ?? isDrawer}
|
|
124
151
|
/>
|
|
125
152
|
);
|
|
153
|
+
|
|
154
|
+
// LOCAL PATCH (Contact Center): a detail view can be a DRAWER too.
|
|
155
|
+
//
|
|
156
|
+
// Upstream returned the detail surface here unconditionally, *before* the `isDrawer` branch
|
|
157
|
+
// below — so `FormRenderer.Sidebar` + `display="drawer"` silently rendered a full page inside
|
|
158
|
+
// whatever the caller put it in, and a quick-view drawer had to be hand-rolled around the
|
|
159
|
+
// detail view instead. A record looks the same whether you opened it from a row or from its
|
|
160
|
+
// own route; only the surface differs, which is exactly what `display` is for.
|
|
161
|
+
if (!isDrawer) return detailView;
|
|
162
|
+
return (
|
|
163
|
+
<FormDrawer
|
|
164
|
+
open={open}
|
|
165
|
+
onOpenChange={onOpenChange ?? (() => {})}
|
|
166
|
+
title={title ?? header?.title}
|
|
167
|
+
badge={badge ?? header?.label}
|
|
168
|
+
variant={header?.variant}
|
|
169
|
+
actions={actions}
|
|
170
|
+
onOpenInNewTab={onOpenInNewTab}
|
|
171
|
+
notchActions={notchActions.length > 0 ? notchActions : undefined}
|
|
172
|
+
// `DetailTabsView` already owns its scroll container and already offsets for a header,
|
|
173
|
+
// so the drawer must not add a second of each — that is what `bareBody` is for. The
|
|
174
|
+
// drawer's own header still renders, carrying the title and actions.
|
|
175
|
+
bareBody
|
|
176
|
+
{...drawer}
|
|
177
|
+
>
|
|
178
|
+
{detailView}
|
|
179
|
+
</FormDrawer>
|
|
180
|
+
);
|
|
126
181
|
}
|
|
127
182
|
|
|
128
183
|
// The fields the `<form>` wraps: the stepper's steps (+ any custom footer extras), or the
|
|
@@ -137,7 +192,7 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
|
137
192
|
{stepExtras}
|
|
138
193
|
</>
|
|
139
194
|
) : (
|
|
140
|
-
|
|
195
|
+
bodyChildren
|
|
141
196
|
);
|
|
142
197
|
|
|
143
198
|
const formEl = (
|
|
@@ -219,7 +274,10 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
|
219
274
|
summary && !isDrawer ? (
|
|
220
275
|
<div className="flex h-full flex-row items-stretch">
|
|
221
276
|
<div className="min-h-0 min-w-0 flex-1">{surface}</div>
|
|
222
|
-
|
|
277
|
+
{/* LOCAL PATCH (Contact Center): `ms-`, not `ml-`. Under `dir="rtl"` the summary sits to
|
|
278
|
+
the LEFT of the surface, so a physical left margin puts the gutter on its far side and
|
|
279
|
+
the two panels butt together. */}
|
|
280
|
+
<div className="ms-[6px] flex min-h-0">{summary}</div>
|
|
223
281
|
</div>
|
|
224
282
|
) : (
|
|
225
283
|
surface
|
|
@@ -267,6 +325,8 @@ function FormRendererRoot<T extends FieldValues = FieldValues>({
|
|
|
267
325
|
) : undefined
|
|
268
326
|
}
|
|
269
327
|
onOpenInNewTab={onOpenInNewTab}
|
|
328
|
+
notchActions={notchActions.length > 0 ? notchActions : undefined}
|
|
329
|
+
{...drawer}
|
|
270
330
|
>
|
|
271
331
|
{tree}
|
|
272
332
|
</FormDrawer>
|
|
@@ -290,6 +350,7 @@ export const FormRenderer = Object.assign(FormRendererRoot, {
|
|
|
290
350
|
Step,
|
|
291
351
|
Back,
|
|
292
352
|
Next,
|
|
353
|
+
NotchAction,
|
|
293
354
|
Sidebar: DetailSidebar,
|
|
294
355
|
Tab: DetailTab,
|
|
295
356
|
Grid: DetailGrid,
|
|
@@ -2,6 +2,8 @@ export { FormRenderer } from "./form-renderer";
|
|
|
2
2
|
export { FormDrawer } from "./FormDrawer";
|
|
3
3
|
export type { FormDrawerProps } from "./FormDrawer";
|
|
4
4
|
export { FormHeaderBar } from "./header";
|
|
5
|
+
export { NotchAction } from "./notch-action";
|
|
6
|
+
export type { NotchActionProps } from "./notch-action";
|
|
5
7
|
export type { HeaderConfig, HeaderVariant, FormHeaderBarProps } from "./header";
|
|
6
8
|
export type { SectionProps } from "./section";
|
|
7
9
|
export type { StepProps, StepperProps } from "./stepper";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
import { DrawerNotchPill } from "../Drawer";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `FormRenderer.NotchAction` — a button in the drawer's notch, authored as a CHILD.
|
|
10
|
+
*
|
|
11
|
+
* LOCAL PATCH (Contact Center). Upstream models the one notch action it ships as a pair of
|
|
12
|
+
* props on `FormDrawer` / `FormRenderer`: `onOpenInNewTab` (a handler) plus a hardcoded
|
|
13
|
+
* English "Open in new tab" label. That shape has two problems:
|
|
14
|
+
*
|
|
15
|
+
* 1. The label is the component's, not the caller's, so a localized app cannot translate it
|
|
16
|
+
* without a second prop — which is exactly the `openInNewTabLabel` patch we kept having
|
|
17
|
+
* to re-apply after every `npx torch-glare update`.
|
|
18
|
+
* 2. It only ever admits ONE action, and only that action. Anything else in the notch means
|
|
19
|
+
* another prop pair.
|
|
20
|
+
*
|
|
21
|
+
* Passing the button as a child fixes both at once: the caller owns the text (so i18n is just
|
|
22
|
+
* `{t(...)}`, no prop), owns the icon, and can render more than one. Same bargain the rest of
|
|
23
|
+
* the library strikes — `DataViews` registers a view because you rendered it, `FormRenderer`
|
|
24
|
+
* renders a step because you wrote a `<Step>`.
|
|
25
|
+
*
|
|
26
|
+
* It renders nothing where it is written: `FormRenderer` lifts it out of the children and hands
|
|
27
|
+
* it to `FormDrawer`'s notch, the same way it lifts a `Stepper`. Ignored entirely on a page-display
|
|
28
|
+
* form, which has no notch.
|
|
29
|
+
*
|
|
30
|
+
* ```tsx
|
|
31
|
+
* <FormRenderer display="drawer" …>
|
|
32
|
+
* <FormRenderer.NotchAction onClick={openInTab}>
|
|
33
|
+
* {t("contacts.formShell.openInNewTab")}
|
|
34
|
+
* <i className="ri-arrow-right-up-line text-[12px]" />
|
|
35
|
+
* </FormRenderer.NotchAction>
|
|
36
|
+
* <FormRenderer.Section …>…</FormRenderer.Section>
|
|
37
|
+
* </FormRenderer>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export interface NotchActionProps
|
|
41
|
+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color"> {
|
|
42
|
+
/** Pill colour, matching `DrawerNotchPill`. Defaults to the yellow "open elsewhere" pill. */
|
|
43
|
+
color?: React.ComponentProps<typeof DrawerNotchPill>["color"];
|
|
44
|
+
children?: React.ReactNode;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function NotchAction({ color = "Yellow", className, children, ...props }: NotchActionProps) {
|
|
48
|
+
return (
|
|
49
|
+
<DrawerNotchPill color={color} className={cn(className)} {...props}>
|
|
50
|
+
{children}
|
|
51
|
+
</DrawerNotchPill>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
(NotchAction as unknown as { __isFormNotchAction: boolean }).__isFormNotchAction = true;
|
|
56
|
+
|
|
57
|
+
export function isNotchActionElement(
|
|
58
|
+
node: React.ReactNode,
|
|
59
|
+
): node is React.ReactElement<NotchActionProps> {
|
|
60
|
+
return (
|
|
61
|
+
React.isValidElement(node) &&
|
|
62
|
+
(node.type as { __isFormNotchAction?: boolean })?.__isFormNotchAction === true
|
|
63
|
+
);
|
|
64
|
+
}
|