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
|
@@ -37,6 +37,15 @@ interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "va
|
|
|
37
37
|
tags: Tag[];
|
|
38
38
|
onValueChange?: (tags: Tag[]) => void;
|
|
39
39
|
addLabel?: string;
|
|
40
|
+
/**
|
|
41
|
+
* LOCAL PATCH (Contact Center): let the user TYPE a value and have it become a selected badge,
|
|
42
|
+
* rather than only picking from `tags`. Enter or comma commits what is in the box; Backspace on
|
|
43
|
+
* an empty box removes the last badge. This is what makes a free-text list (emails, aliases,
|
|
44
|
+
* tags) expressible as a badge field instead of a one-column table.
|
|
45
|
+
*/
|
|
46
|
+
creatable?: boolean;
|
|
47
|
+
/** Label for the "create this text" row. Receives the typed text. */
|
|
48
|
+
createLabel?: (value: string) => string;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
@@ -57,6 +66,8 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
57
66
|
theme,
|
|
58
67
|
tags,
|
|
59
68
|
addLabel = "add",
|
|
69
|
+
creatable = false,
|
|
70
|
+
createLabel = (value: string) => `Create "${value}"`,
|
|
60
71
|
dir,
|
|
61
72
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- excluded from {...props} spread
|
|
62
73
|
children,
|
|
@@ -81,6 +92,7 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
81
92
|
selectedTagsStack,
|
|
82
93
|
handleSelectTag,
|
|
83
94
|
handleUnselectTag,
|
|
95
|
+
handleCreateTag,
|
|
84
96
|
handleKeyDown,
|
|
85
97
|
setFocusedTagIndex,
|
|
86
98
|
filterTagsBySearch,
|
|
@@ -92,6 +104,7 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
92
104
|
searchTags,
|
|
93
105
|
} = useTagSelection({
|
|
94
106
|
Tags: tags,
|
|
107
|
+
creatable,
|
|
95
108
|
onTagsChange: (e) => {
|
|
96
109
|
// Native onChange keeps the event-shaped API (Tag[] in target.value)
|
|
97
110
|
// for react-hook-form / Controller; onValueChange is the typed, direct
|
|
@@ -106,6 +119,14 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
106
119
|
inputRef,
|
|
107
120
|
});
|
|
108
121
|
|
|
122
|
+
// LOCAL PATCH (Contact Center): offer the typed text as a new badge, unless it already exists
|
|
123
|
+
// (selected or listed) — in which case the normal rows already cover it.
|
|
124
|
+
const typedValue = searchTags.trim();
|
|
125
|
+
const alreadyExists = [...selectedTagsStack, ...filteredTags].some(
|
|
126
|
+
(tag) => tag.name.toLowerCase() === typedValue.toLowerCase(),
|
|
127
|
+
);
|
|
128
|
+
const showCreateRow = creatable && typedValue !== "" && !alreadyExists;
|
|
129
|
+
|
|
109
130
|
return (
|
|
110
131
|
<Popover open={isPopoverOpen}>
|
|
111
132
|
<PopoverTrigger asChild>
|
|
@@ -152,6 +173,26 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
152
173
|
onChange={(e) => {
|
|
153
174
|
filterTagsBySearch(e.target.value);
|
|
154
175
|
}}
|
|
176
|
+
// LOCAL PATCH (Contact Center): commit typed text as a badge. Handled here rather
|
|
177
|
+
// than in the Group's `handleKeyDown` because that one only ever navigates the
|
|
178
|
+
// existing list — and because `preventDefault` on Enter has to stop the surrounding
|
|
179
|
+
// `<form>` submitting before the badge is added.
|
|
180
|
+
onKeyDown={(e) => {
|
|
181
|
+
if (!creatable) return;
|
|
182
|
+
if (e.key === "Enter" || e.key === ",") {
|
|
183
|
+
// Let Enter pick the highlighted row when the user is arrowing the list.
|
|
184
|
+
if (e.key === "Enter" && focusedPopoverIndex !== null) return;
|
|
185
|
+
if (!searchTags.trim()) return;
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
e.stopPropagation();
|
|
188
|
+
handleCreateTag(searchTags);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (e.key === "Backspace" && searchTags === "" && selectedTagsStack.length > 0) {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
handleUnselectTag(selectedTagsStack[selectedTagsStack.length - 1].id);
|
|
194
|
+
}
|
|
195
|
+
}}
|
|
155
196
|
onFocus={(e) => {
|
|
156
197
|
props.onFocus?.(e);
|
|
157
198
|
setFocusedTagIndex(null);
|
|
@@ -191,6 +232,22 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
191
232
|
// Reuse the DropdownMenu surface so the list matches the menu design.
|
|
192
233
|
>
|
|
193
234
|
<div className={cn(menuContentStyles({ variant: "PresentationStyle" }), "p-0")}>
|
|
235
|
+
{/* LOCAL PATCH (Contact Center): the create-from-typed-text row. */}
|
|
236
|
+
{showCreateRow && (
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
onClick={() => handleCreateTag(searchTags)}
|
|
240
|
+
className={cn(
|
|
241
|
+
MenuItemStyles({ variant: "Default", size: "M" }),
|
|
242
|
+
"w-full p-1 shrink-0 h-fit",
|
|
243
|
+
)}
|
|
244
|
+
>
|
|
245
|
+
<div className="flex items-center gap-1 w-full">
|
|
246
|
+
<i className="ri-add-line text-[14px]" />
|
|
247
|
+
<span className="truncate">{createLabel(typedValue)}</span>
|
|
248
|
+
</div>
|
|
249
|
+
</button>
|
|
250
|
+
)}
|
|
194
251
|
{filteredTags.length > 0 ? (
|
|
195
252
|
filteredTags.map((tag, index) => (
|
|
196
253
|
<button
|
|
@@ -234,9 +291,17 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
234
291
|
</button>
|
|
235
292
|
))
|
|
236
293
|
) : (
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
294
|
+
// The create row already tells the user what will happen, so don't also say
|
|
295
|
+
// "no matching tags found" underneath it.
|
|
296
|
+
!showCreateRow && (
|
|
297
|
+
<div className="px-3 py-2 typography-body-small-regular text-white-alpha-75">
|
|
298
|
+
{creatable
|
|
299
|
+
? "Type a value and press Enter"
|
|
300
|
+
: tags.length === 0
|
|
301
|
+
? "All tags selected"
|
|
302
|
+
: "No matching tags found"}
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
240
305
|
)}{" "}
|
|
241
306
|
</div>
|
|
242
307
|
</PopoverContent>
|
|
@@ -60,7 +60,11 @@ export const Button = forwardRef<HTMLButtonElement, Props>(
|
|
|
60
60
|
{},
|
|
61
61
|
<div
|
|
62
62
|
className={cn(
|
|
63
|
-
|
|
63
|
+
// LOCAL PATCH (Contact Center): logical, not physical. These were `mr`/`ml`/`pl`/`pr`,
|
|
64
|
+
// so under `dir="rtl"` a leading icon rendered on the right but kept its gap on
|
|
65
|
+
// its OUTER side — colliding with the label and drifting off the button edge.
|
|
66
|
+
// `me`/`ms`/`ps`/`pe` compile to the same values in LTR, so English is unchanged.
|
|
67
|
+
"flex items-center justify-center [&>:is(i,svg):first-child]:me-[3px] [&>:is(i,svg):last-child]:ms-[3px] [&>:is(i,svg):only-child]:m-0 [&:has(>:is(i,svg):last-child):not(:has(>:is(i,svg):first-child))]:ps-[6px] [&:has(>:is(i,svg):first-child):not(:has(>:is(i,svg):last-child))]:pe-[6px]",
|
|
64
68
|
containerClassName,
|
|
65
69
|
)}
|
|
66
70
|
>
|
|
@@ -71,7 +75,11 @@ export const Button = forwardRef<HTMLButtonElement, Props>(
|
|
|
71
75
|
) : (
|
|
72
76
|
<div
|
|
73
77
|
className={cn(
|
|
74
|
-
|
|
78
|
+
// LOCAL PATCH (Contact Center): logical, not physical. These were `mr`/`ml`/`pl`/`pr`,
|
|
79
|
+
// so under `dir="rtl"` a leading icon rendered on the right but kept its gap on
|
|
80
|
+
// its OUTER side — colliding with the label and drifting off the button edge.
|
|
81
|
+
// `me`/`ms`/`ps`/`pe` compile to the same values in LTR, so English is unchanged.
|
|
82
|
+
"flex items-center justify-center [&>:is(i,svg):first-child]:me-[3px] [&>:is(i,svg):last-child]:ms-[3px] [&>:is(i,svg):only-child]:m-0 [&:has(>:is(i,svg):last-child):not(:has(>:is(i,svg):first-child))]:ps-[6px] [&:has(>:is(i,svg):first-child):not(:has(>:is(i,svg):last-child))]:pe-[6px]",
|
|
75
83
|
containerClassName,
|
|
76
84
|
)}
|
|
77
85
|
>
|
|
@@ -19,7 +19,9 @@ import {
|
|
|
19
19
|
type RegisteredView,
|
|
20
20
|
} from "./context";
|
|
21
21
|
import { Actions, Header, PanelToggle, Search, ViewSwitch } from "./header";
|
|
22
|
+
import { Empty } from "./states";
|
|
22
23
|
import {
|
|
24
|
+
isEmptyElement,
|
|
23
25
|
isHeaderElement,
|
|
24
26
|
isPanelElement,
|
|
25
27
|
isViewElement,
|
|
@@ -103,9 +105,13 @@ function DataViewsRoot({
|
|
|
103
105
|
const panelEl = childArray.find(isPanelElement);
|
|
104
106
|
// Anything the root does not position itself — a `Filters` bar, a toolbar of your own — sits
|
|
105
107
|
// between the header and the views, in the order you wrote it.
|
|
108
|
+
// LOCAL PATCH (Contact Center): the empty slot. It must be excluded from `extras` too --
|
|
109
|
+
// `extras` is the negative-space bucket, so without this the element would render twice:
|
|
110
|
+
// once above the view and once as the body.
|
|
111
|
+
const emptyEl = childArray.find(isEmptyElement);
|
|
106
112
|
const extras = childArray.filter(
|
|
107
113
|
(n) =>
|
|
108
|
-
!isViewElement(n) && !isHeaderElement(n) && !isPanelElement(n),
|
|
114
|
+
!isViewElement(n) && !isHeaderElement(n) && !isPanelElement(n) && !isEmptyElement(n),
|
|
109
115
|
);
|
|
110
116
|
|
|
111
117
|
// `viewElements` is a fresh array on every render, so memoising on its identity would never
|
|
@@ -279,10 +285,15 @@ function DataViewsRoot({
|
|
|
279
285
|
[currentQuery.filters, setFilters, filterFields],
|
|
280
286
|
);
|
|
281
287
|
|
|
282
|
-
// The active view is
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
288
|
+
// The active view is what renders, unless a `DataViews.Empty` was supplied and the query has
|
|
289
|
+
// SETTLED on nothing — then the caller's empty state takes the view's place entirely.
|
|
290
|
+
//
|
|
291
|
+
// LOCAL PATCH (Contact Center). `!loading` is the whole reason this is safe: rows are empty
|
|
292
|
+
// while the first page is still in flight, so without it the empty state would replace the
|
|
293
|
+
// view's skeleton and announce "nothing matched" before anything had been asked for — the
|
|
294
|
+
// exact failure that made upstream ship no `Empty` at all (see `states.tsx`). Loading is still
|
|
295
|
+
// answered by the view's own skeleton; only a settled empty result swaps the body.
|
|
296
|
+
const body = emptyEl && !loading && rows.length === 0 ? emptyEl : activeElement;
|
|
286
297
|
|
|
287
298
|
return (
|
|
288
299
|
<DataContext.Provider value={dataValue}>
|
|
@@ -379,5 +390,6 @@ export const DataViews = Object.assign(DataViewsRoot, {
|
|
|
379
390
|
Tree: TreeView,
|
|
380
391
|
Detail,
|
|
381
392
|
// states
|
|
393
|
+
Empty,
|
|
382
394
|
// paging
|
|
383
395
|
});
|
|
@@ -26,9 +26,13 @@ export { useActiveRow } from "./hooks";
|
|
|
26
26
|
export { Cell } from "./cell";
|
|
27
27
|
|
|
28
28
|
// A view of your own gets the loading state the built-in four get: read `loading` from
|
|
29
|
-
// `useDataViewsData()` and lay these out in your own shape.
|
|
30
|
-
//
|
|
31
|
-
|
|
29
|
+
// `useDataViewsData()` and lay these out in your own shape.
|
|
30
|
+
//
|
|
31
|
+
// `DataViews.Empty` (LOCAL PATCH, Contact Center) is the counterpart upstream omits: a slot that
|
|
32
|
+
// renders IN PLACE OF the view once a query has settled on no rows. It is opt-in and carries no
|
|
33
|
+
// design of its own — see `states.tsx` for why it does not reintroduce the "announced nothing
|
|
34
|
+
// matched before anything was asked for" bug that kept it out.
|
|
35
|
+
export { SkeletonBar, skeletonKeys, Empty } from "./states";
|
|
32
36
|
|
|
33
37
|
// Wrapping a part in a component of your own — a preset panel, a project-standard header — hides
|
|
34
38
|
// the marker the root recognises it by, so the wrapper has to carry the marker itself:
|
|
@@ -36,7 +40,7 @@ export { SkeletonBar, skeletonKeys } from "./states";
|
|
|
36
40
|
// ```tsx
|
|
37
41
|
// const AppPanel = markPanel(function AppPanel() { return <DataViews.Panel>…</DataViews.Panel>; });
|
|
38
42
|
// ```
|
|
39
|
-
export { markHeader, markPanel, markView } from "./slots";
|
|
43
|
+
export { markEmpty, markHeader, markPanel, markView } from "./slots";
|
|
40
44
|
export { resolveBadgeVariant } from "./badge";
|
|
41
45
|
|
|
42
46
|
export type { ResolvedBadgeProps } from "./badge";
|
|
@@ -59,5 +59,14 @@ export const isHeaderElement = (n: React.ReactNode) => isMarked(n, "__dvHeader")
|
|
|
59
59
|
export const markPanel = <P extends object>(c: React.ComponentType<P>) => mark(c, "__dvPanel");
|
|
60
60
|
export const isPanelElement = (n: React.ReactNode) => isMarked(n, "__dvPanel");
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* LOCAL PATCH (Contact Center): the empty slot — what renders *in place of* the view when a
|
|
64
|
+
* settled query returns no rows. Without a marker the caller's empty state is just an "extra"
|
|
65
|
+
* and stacks ABOVE the view, so an empty list shows the message on top of an empty table.
|
|
66
|
+
* See `states.tsx` for why this does not reintroduce the bug upstream avoided.
|
|
67
|
+
*/
|
|
68
|
+
export const markEmpty = <P extends object>(c: React.ComponentType<P>) => mark(c, "__dvEmpty");
|
|
69
|
+
export const isEmptyElement = (n: React.ReactNode) => isMarked(n, "__dvEmpty");
|
|
70
|
+
|
|
62
71
|
|
|
63
72
|
|
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
4
5
|
import { cn } from "../../utils/cn";
|
|
5
6
|
import { Skeleton } from "../Skeleton";
|
|
7
|
+
import { markEmpty } from "./slots";
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
|
-
* The shared parts of a loading state.
|
|
10
|
+
* The shared parts of a loading state, and the empty slot.
|
|
11
|
+
*
|
|
12
|
+
* Upstream ships no `Empty`, for reasons that are mostly still right: a centred sentence in place
|
|
13
|
+
* of the view throws the chrome away and makes the layout jump twice on every query, and — the
|
|
14
|
+
* real bug — it could not tell "no results" apart from "not fetched yet", so the first load of
|
|
15
|
+
* every page announced that nothing matched before anything had been asked for.
|
|
9
16
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
17
|
+
* `Empty` below (LOCAL PATCH, Contact Center) keeps that objection answered rather than ignoring
|
|
18
|
+
* it. It is a SLOT, not a design: the caller supplies the whole UI, and the root fills it only
|
|
19
|
+
* when the query has settled (`!loading`) and still returned nothing. "Not fetched yet" is still
|
|
20
|
+
* the view's own skeleton, so the two states stay distinguishable. What it fixes is the shape the
|
|
21
|
+
* app was forced into without it — an unrecognised child renders as an "extra" ABOVE the view, so
|
|
22
|
+
* an empty list showed the message stacked on top of an empty table, headers and all.
|
|
15
23
|
*
|
|
16
|
-
* Loading is answered per view
|
|
24
|
+
* Loading is still answered per view, because a skeleton is only useful if it is the shape of
|
|
17
25
|
* the thing that is coming. These are the pieces the four views share so they cannot drift; the
|
|
18
26
|
* shapes themselves live with the view that owns them.
|
|
19
27
|
*/
|
|
@@ -36,3 +44,30 @@ export function SkeletonBar({ className }: { className?: string }) {
|
|
|
36
44
|
* decides that, and it has not answered yet.
|
|
37
45
|
*/
|
|
38
46
|
export const skeletonKeys = (n: number) => Array.from({ length: n }, (_, i) => i);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `DataViews.Empty` — what renders **in place of** the view when a settled query returns no rows.
|
|
50
|
+
*
|
|
51
|
+
* LOCAL PATCH (Contact Center). A passthrough with a marker: it holds no opinion about what an
|
|
52
|
+
* empty state looks like, it only tells the root "put this where the view goes". The root fills
|
|
53
|
+
* it on `!loading && rows.length === 0`; write it anywhere among the children.
|
|
54
|
+
*
|
|
55
|
+
* Note the caller's content is what gets the body slot's height, so give it `flex-1` if it should
|
|
56
|
+
* centre rather than sit at the top of a tall surface — the app's shared `EmptyState` sizes
|
|
57
|
+
* itself intrinsically.
|
|
58
|
+
*
|
|
59
|
+
* ```tsx
|
|
60
|
+
* <DataViews rows={rows} fields={fields}>
|
|
61
|
+
* <DataViews.Header title="Fields">…</DataViews.Header>
|
|
62
|
+
* <DataViews.Empty>
|
|
63
|
+
* <EmptyState className="flex-1" title={search ? "No matches" : "No fields yet"} />
|
|
64
|
+
* </DataViews.Empty>
|
|
65
|
+
* <DataViews.Table />
|
|
66
|
+
* </DataViews>
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function Empty({ children, className }: { children?: ReactNode; className?: string }) {
|
|
70
|
+
return <div className={cn("flex min-h-0 flex-1 flex-col", className)}>{children}</div>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
markEmpty(Empty);
|
|
@@ -40,7 +40,17 @@ interface DrawerContentProps extends React.ComponentPropsWithoutRef<
|
|
|
40
40
|
/** @deprecated No effect here — the drag handle is `DrawerPanel`'s `showHandle`. */
|
|
41
41
|
showHandle?: boolean;
|
|
42
42
|
notch?: React.ReactNode;
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Which INLINE edge the notch attaches to — `"start"` follows the document direction
|
|
45
|
+
* (left under LTR, right under RTL), so callers never compute `dir` themselves.
|
|
46
|
+
*
|
|
47
|
+
* LOCAL PATCH (Contact Center): these were physical `"left" | "right"` and every consumer
|
|
48
|
+
* passed `isRtl ? "right" : "left"`, duplicating a decision CSS already knows. The
|
|
49
|
+
* alignment (`self-start`) and the corner radii below are logical properties, so the
|
|
50
|
+
* browser mirrors them; only the wedge's SVG path needs a flip, which it does itself with
|
|
51
|
+
* `rtl:-scale-x-100`.
|
|
52
|
+
*/
|
|
53
|
+
notchSide?: "start" | "end";
|
|
44
54
|
/**
|
|
45
55
|
* Show the dark "tray" frame (and panel border + inset shadow) around the
|
|
46
56
|
* drawer panel. Defaults to `true`. Set to `false` for bottom-anchored
|
|
@@ -64,7 +74,8 @@ interface DrawerPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
/**
|
|
67
|
-
* The
|
|
77
|
+
* The body surface inside a `DrawerContent` — the same surface a page draws, anchored to an
|
|
78
|
+
* edge, so it follows the app theme.
|
|
68
79
|
*
|
|
69
80
|
* It is an ordinary child, not something the tray paints — so a drawer can hold a panel
|
|
70
81
|
* and something else beside it (e.g. a `FormSummary`), each bringing its own background.
|
|
@@ -81,12 +92,6 @@ const DrawerPanel = React.forwardRef<HTMLDivElement, DrawerPanelProps>(
|
|
|
81
92
|
({ className, framed = true, showHandle = false, children, ...props }, ref) => (
|
|
82
93
|
<div
|
|
83
94
|
ref={ref}
|
|
84
|
-
// The content surface is always light (#F0F0F0) regardless of the page
|
|
85
|
-
// theme, so pin a light theme here. This makes theme-aware content tokens
|
|
86
|
-
// (DrawerTitle/Description and any consumer content) resolve to their
|
|
87
|
-
// dark-on-light values instead of following a dark page theme (which
|
|
88
|
-
// would render white-on-light = invisible).
|
|
89
|
-
data-theme="light"
|
|
90
95
|
className={cn(
|
|
91
96
|
// `flex-col` is load-bearing: this panel stacks header / body / footer, and its own
|
|
92
97
|
// `showHandle` centres the drag handle with `mx-auto`, which only centres in a column.
|
|
@@ -98,13 +103,26 @@ const DrawerPanel = React.forwardRef<HTMLDivElement, DrawerPanelProps>(
|
|
|
98
103
|
// main axis, and a flex item's default `min-width: auto` pins it to its content — a wide
|
|
99
104
|
// form pushed the panel straight out past the tray. `min-h-0` alone covered the old
|
|
100
105
|
// column tray; the row needs both.
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
//
|
|
107
|
+
// LOCAL PATCH (Contact Center): the surface was `bg-[#F0F0F0]` with a `#D4D4D4` border —
|
|
108
|
+
// frozen copies of the LIGHT values of the two tokens below — and the panel pinned
|
|
109
|
+
// `data-theme="light"` so its content didn't render white-on-light against them.
|
|
110
|
+
// mapping-color-system-v4 selects on a BARE `[data-theme="light"]`, so that attribute
|
|
111
|
+
// re-declared every colour variable on this element and inherited to the whole subtree:
|
|
112
|
+
// a drawer was immune to the app's theme, and its content was correctly themed to the
|
|
113
|
+
// WRONG theme. A drawer is the same surface as a page, only anchored to an edge, so it
|
|
114
|
+
// reads the same tokens and follows `<html>` like everything else. Light and default
|
|
115
|
+
// resolve to the exact literals removed here (#F0F0F0 / #D4D4D4), so only dark changes.
|
|
116
|
+
"flex flex-1 flex-col gap-2 rounded-t-[16px] p-1 bg-background-presentation-body-primary min-h-0 min-w-0",
|
|
117
|
+
framed &&
|
|
118
|
+
"border border-border-presentation-global-primary shadow-[inset_0_-4px_16px_rgba(0,0,0,0.1)]",
|
|
103
119
|
className,
|
|
104
120
|
)}
|
|
105
121
|
{...props}
|
|
106
122
|
>
|
|
107
|
-
{showHandle &&
|
|
123
|
+
{showHandle && (
|
|
124
|
+
<div className="mx-auto h-2 w-[100px] rounded-full bg-border-presentation-global-primary" />
|
|
125
|
+
)}
|
|
108
126
|
{children}
|
|
109
127
|
</div>
|
|
110
128
|
),
|
|
@@ -120,7 +138,7 @@ const DrawerContent = React.forwardRef<
|
|
|
120
138
|
className,
|
|
121
139
|
children,
|
|
122
140
|
notch,
|
|
123
|
-
notchSide = "
|
|
141
|
+
notchSide = "start",
|
|
124
142
|
framed: framedProp,
|
|
125
143
|
wrapperClassName,
|
|
126
144
|
trayClassName,
|
|
@@ -146,10 +164,12 @@ const DrawerContent = React.forwardRef<
|
|
|
146
164
|
)}
|
|
147
165
|
{...props}
|
|
148
166
|
>
|
|
167
|
+
{/* `self-start` / `self-end` are LOGICAL — the browser flips them under
|
|
168
|
+
`dir="rtl"`, so this needs no direction check. */}
|
|
149
169
|
{notch && (
|
|
150
|
-
<div className={notchSide === "
|
|
170
|
+
<div className={notchSide === "end" ? "self-end" : "self-start"}>
|
|
151
171
|
{React.isValidElement(notch)
|
|
152
|
-
? React.cloneElement(notch as React.ReactElement<{ side?: "
|
|
172
|
+
? React.cloneElement(notch as React.ReactElement<{ side?: "start" | "end" }>, {
|
|
153
173
|
side: notchSide,
|
|
154
174
|
})
|
|
155
175
|
: notch}
|
|
@@ -164,10 +184,12 @@ const DrawerContent = React.forwardRef<
|
|
|
164
184
|
framed
|
|
165
185
|
? "p-1.5 bg-black-400 shadow-[0_0_4px_rgba(0,0,0,0.2),0_0_30px_rgba(0,0,0,0.4)]"
|
|
166
186
|
: "p-0",
|
|
187
|
+
// Logical corner radii (`ss` = start-start, `se` = start-end): the squared
|
|
188
|
+
// corner follows the notch under either direction with no JS.
|
|
167
189
|
framed && notch
|
|
168
|
-
? notchSide === "
|
|
169
|
-
? "rounded-
|
|
170
|
-
: "rounded-
|
|
190
|
+
? notchSide === "end"
|
|
191
|
+
? "rounded-se-none rounded-ss-[22px] rounded-b-[22px]"
|
|
192
|
+
: "rounded-ss-none rounded-se-[22px] rounded-b-[22px]"
|
|
171
193
|
: framed
|
|
172
194
|
? "rounded-t-[22px]"
|
|
173
195
|
: "",
|
|
@@ -193,14 +215,15 @@ const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
|
|
|
193
215
|
DrawerHeader.displayName = "DrawerHeader";
|
|
194
216
|
|
|
195
217
|
const drawerHeaderPane = cva(
|
|
196
|
-
//
|
|
197
|
-
// (
|
|
218
|
+
// A deliberately dark pill in every theme — the same slab the page-mode FormHeaderBar draws
|
|
219
|
+
// (FormRenderer/header.tsx). Its fill is a literal on purpose, not a frozen token: it must not
|
|
220
|
+
// follow the panel. So the title/description are forced to light text against it.
|
|
198
221
|
"flex items-center gap-2 rounded-[14px] border p-2 bg-[#131415] border-[#2C2D2E] shadow-[0_0_32px_2px_rgba(0,0,0,0.05)] [&_[data-slot=drawer-title]]:text-white [&_[data-slot=drawer-description]]:text-[#9FA0A1]",
|
|
199
222
|
);
|
|
200
223
|
|
|
201
224
|
const DrawerHeaderTitle = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
202
|
-
//
|
|
203
|
-
//
|
|
225
|
+
// The pane is dark in every theme, so pin dark here: the Buttons and content inside must
|
|
226
|
+
// resolve against THIS slab, not against the panel (which now follows the app theme).
|
|
204
227
|
<div data-theme="dark" className={cn(drawerHeaderPane(), className)} {...props} />
|
|
205
228
|
);
|
|
206
229
|
DrawerHeaderTitle.displayName = "DrawerHeaderTitle";
|
|
@@ -243,40 +266,48 @@ const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
|
|
|
243
266
|
DrawerFooter.displayName = "DrawerFooter";
|
|
244
267
|
|
|
245
268
|
interface DrawerNotchProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
246
|
-
|
|
269
|
+
/** Logical edge, mirrored by the browser under `dir="rtl"`. See `notchSide`. */
|
|
270
|
+
side?: "start" | "end";
|
|
247
271
|
}
|
|
248
272
|
|
|
249
|
-
const DrawerNotch = ({ className, children, side = "
|
|
250
|
-
// Wedge bridges the notch's bottom-
|
|
251
|
-
//
|
|
252
|
-
//
|
|
273
|
+
const DrawerNotch = ({ className, children, side = "start", ...props }: DrawerNotchProps) => {
|
|
274
|
+
// Wedge bridges the notch's bottom inline-end corner into the tray's top edge.
|
|
275
|
+
//
|
|
276
|
+
// LOCAL PATCH (Contact Center): both the DOM order and the SVG path used to be picked from a
|
|
277
|
+
// physical left/right. Neither needs to be: the row is `flex-row`, which is direction-aware, so
|
|
278
|
+
// writing {pill}{wedge} already renders wedge-on-the-left under RTL. Only the path's curve is
|
|
279
|
+
// physical, and `rtl:-scale-x-100` mirrors it.
|
|
280
|
+
//
|
|
281
|
+
// That class, not a rule in some stylesheet: an earlier draft of this comment pointed at an
|
|
282
|
+
// `rtl.css` that does not exist in this repo — so under `dir="rtl"` the wedge pointed the wrong
|
|
283
|
+
// way and nothing failed loudly. Keeping the mirror on the element means it travels with the
|
|
284
|
+
// component when a consumer copies it in, which a global stylesheet would not.
|
|
253
285
|
const wedge = (
|
|
254
|
-
<svg
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
/>
|
|
286
|
+
<svg
|
|
287
|
+
aria-hidden
|
|
288
|
+
data-drawer-wedge
|
|
289
|
+
width="12"
|
|
290
|
+
height="12"
|
|
291
|
+
viewBox="0 0 12 12"
|
|
292
|
+
className="block shrink-0 self-end rtl:-scale-x-100"
|
|
293
|
+
>
|
|
294
|
+
<path d="M 0 0 L 0 12 L 12 12 A 12 12 0 0 1 0 0 Z" fill="#434446" />
|
|
263
295
|
</svg>
|
|
264
296
|
);
|
|
265
297
|
|
|
266
298
|
return (
|
|
267
299
|
<div className="relative flex flex-row items-end">
|
|
268
|
-
{side === "
|
|
300
|
+
{side === "end" && wedge}
|
|
269
301
|
<div
|
|
270
302
|
className={cn(
|
|
271
|
-
"flex items-center gap-1 rounded-t-[18px] bg-black-400 px-1.5 pt-1.5 pb-1.5",
|
|
272
|
-
side === "right" ? "flex-row-reverse" : "flex-row",
|
|
303
|
+
"flex flex-row items-center gap-1 rounded-t-[18px] bg-black-400 px-1.5 pt-1.5 pb-1.5",
|
|
273
304
|
className,
|
|
274
305
|
)}
|
|
275
306
|
{...props}
|
|
276
307
|
>
|
|
277
308
|
{children}
|
|
278
309
|
</div>
|
|
279
|
-
{side === "
|
|
310
|
+
{side === "start" && wedge}
|
|
280
311
|
</div>
|
|
281
312
|
);
|
|
282
313
|
};
|
|
@@ -507,6 +507,20 @@ export const menuContentStyles = cva(
|
|
|
507
507
|
// No `gap` here: the panel has exactly one child (the scroll viewport), so a gap between
|
|
508
508
|
// siblings has nothing to act on. The 4px between groups/labels lives on that viewport.
|
|
509
509
|
"flex flex-col",
|
|
510
|
+
// LOCAL PATCH (Contact Center): this was the ONE portalled surface in the library with no
|
|
511
|
+
// z-index. Radix portals the panel to <body> and positions it `fixed`, but `z-index: auto`
|
|
512
|
+
// paints in a LOWER layer than any positive z-index in the same stacking context, whatever
|
|
513
|
+
// the DOM order — so the library's own `Table` sticky header (`sticky top-0 z-20`,
|
|
514
|
+
// Table.tsx) painted straight over it. On a DataViews list that header is opaque and sits
|
|
515
|
+
// exactly where a topbar action menu opens, so the menu vanished entirely.
|
|
516
|
+
//
|
|
517
|
+
// The class goes on Content, not on Radix's positioner: Popper reads the content's COMPUTED
|
|
518
|
+
// z-index and copies it to the wrapper (@radix-ui/react-popper 1.3.7), which is the same
|
|
519
|
+
// mechanism `Select`'s identical panel already rides on.
|
|
520
|
+
//
|
|
521
|
+
// 1000 matches `Select` and `Popover` rather than clearing `z-20` by one, so a menu opened
|
|
522
|
+
// inside a Drawer or Dialog (both `z-50`) clears those too — the same half of this bug.
|
|
523
|
+
"z-[1000]",
|
|
510
524
|
],
|
|
511
525
|
{
|
|
512
526
|
variants: {
|
|
@@ -55,6 +55,18 @@ export const useBare = () => useContext(CellContext) !== false;
|
|
|
55
55
|
/** True only inside a `FormBuilder.Table` cell — drives the control's `onTable` border style. */
|
|
56
56
|
export const useOnTable = () => useContext(CellContext) === "table";
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The "(Required)" tag `FieldShell` prints beside a `required` field's label.
|
|
60
|
+
*
|
|
61
|
+
* LOCAL PATCH (Contact Center): upstream hardcodes the English literal, so a
|
|
62
|
+
* localized app cannot translate it. Defaulting to that literal keeps every
|
|
63
|
+
* existing caller identical; provide the context once near the app root to
|
|
64
|
+
* localize every field at once. Logged in TORCH-GLARE-FEEDBACK.md — re-apply
|
|
65
|
+
* after any `npx torch-glare update`.
|
|
66
|
+
*/
|
|
67
|
+
export const RequiredLabelContext = createContext<string>("(Required)");
|
|
68
|
+
export const useRequiredLabel = () => useContext(RequiredLabelContext);
|
|
69
|
+
|
|
58
70
|
/**
|
|
59
71
|
* Step registry — a `FormRenderer.Step` provides this so the fields rendered inside it can
|
|
60
72
|
* register their `name`, and the stepper validates just those names before advancing. `null`
|
|
@@ -14,8 +14,7 @@ import {
|
|
|
14
14
|
import { FieldSection } from "../../../layouts/FieldSection";
|
|
15
15
|
import { FormField, FormItem, FormControl } from "../../Form";
|
|
16
16
|
import { FieldHint } from "../../FieldHint";
|
|
17
|
-
import {
|
|
18
|
-
import { useDirection, useStepRegistry, useBare } from "../context";
|
|
17
|
+
import { useDirection, useStepRegistry, useBare, useRequiredLabel } from "../context";
|
|
19
18
|
import type { FieldHintSpec } from "../types";
|
|
20
19
|
|
|
21
20
|
export interface FieldShellProps {
|
|
@@ -56,6 +55,8 @@ export function FieldShell({
|
|
|
56
55
|
const form = useFormContext();
|
|
57
56
|
const bare = useBare();
|
|
58
57
|
const ctxDirection = useDirection();
|
|
58
|
+
// LOCAL PATCH (Contact Center): upstream hardcodes "(Required)". See context.ts.
|
|
59
|
+
const requiredLabel = useRequiredLabel();
|
|
59
60
|
// A field may pin its own direction (e.g. RichText forces vertical), else the form's. When
|
|
60
61
|
// neither is set this stays `undefined` — FieldSection then falls back to its responsive
|
|
61
62
|
// `flexible` layout rather than a fixed two-column one.
|
|
@@ -79,9 +80,20 @@ export function FieldShell({
|
|
|
79
80
|
if (hidden) return null;
|
|
80
81
|
|
|
81
82
|
// Chrome-less mode — a `FormBuilder.Table` cell (`"table"`) or a panel field (`"bare"`).
|
|
82
|
-
// Render just the control: no FieldSection label/row,
|
|
83
|
-
//
|
|
84
|
-
//
|
|
83
|
+
// Render just the control: no FieldSection label/row, so a row stays one line tall. Step
|
|
84
|
+
// registration above still applies. The control's border treatment is the caller's call,
|
|
85
|
+
// via `useOnTable`.
|
|
86
|
+
//
|
|
87
|
+
// LOCAL PATCH (Contact Center): the error Tooltip that used to wrap the control is gone.
|
|
88
|
+
// Upstream bound it to `open={Boolean(fieldError)}`, so it was not a hover tooltip at all — it
|
|
89
|
+
// was a bubble pinned open for as long as the field was invalid, anchored `top`, which in a
|
|
90
|
+
// table's first row sat directly on top of the column header. Several invalid cells meant
|
|
91
|
+
// several permanent bubbles over the header row. Its `TooltipTrigger` also stamped
|
|
92
|
+
// `aria-label="Open tooltip"` onto EVERY cell wrapper, error or not, so a screen reader
|
|
93
|
+
// announced every table input that way.
|
|
94
|
+
//
|
|
95
|
+
// The controls already signal invalidity themselves (`state="negative"` / `invalid`), so the
|
|
96
|
+
// red border remains; only the bubble is dropped. Re-apply after any `npx torch-glare update`.
|
|
85
97
|
if (bare) {
|
|
86
98
|
return (
|
|
87
99
|
<FormField
|
|
@@ -90,14 +102,7 @@ export function FieldShell({
|
|
|
90
102
|
render={({ field, fieldState }) => (
|
|
91
103
|
<FormItem className="w-full">
|
|
92
104
|
<FormControl>
|
|
93
|
-
<
|
|
94
|
-
open={Boolean(fieldError)}
|
|
95
|
-
text={fieldError ?? ""}
|
|
96
|
-
toolTipSide="top"
|
|
97
|
-
variant="highlight"
|
|
98
|
-
>
|
|
99
|
-
<div className="w-full">{children(field, fieldState)}</div>
|
|
100
|
-
</Tooltip>
|
|
105
|
+
<div className="w-full">{children(field, fieldState)}</div>
|
|
101
106
|
</FormControl>
|
|
102
107
|
</FormItem>
|
|
103
108
|
)}
|
|
@@ -108,7 +113,7 @@ export function FieldShell({
|
|
|
108
113
|
return (
|
|
109
114
|
<FieldSection
|
|
110
115
|
label={label}
|
|
111
|
-
requiredLabel={required ?
|
|
116
|
+
requiredLabel={required ? requiredLabel : undefined}
|
|
112
117
|
secondaryLabel={description}
|
|
113
118
|
direction={direction}
|
|
114
119
|
className={fullWidth ? "max-w-full" : undefined}
|