torch-glare 2.5.4 → 2.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/lib/components/BadgeField.tsx +138 -69
- package/apps/lib/components/Button.tsx +10 -2
- package/apps/lib/components/Card.tsx +2 -1
- package/apps/lib/components/ContextMenu.tsx +65 -22
- package/apps/lib/components/DataViews/context.ts +2 -2
- package/apps/lib/components/DataViews/data-views.tsx +20 -8
- package/apps/lib/components/DataViews/filters/filters.tsx +0 -2
- 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/DataViews/views/table-view.tsx +184 -176
- package/apps/lib/components/Drawer.tsx +70 -39
- package/apps/lib/components/DropdownMenu.tsx +79 -22
- package/apps/lib/components/FormBuilder/context.ts +12 -0
- package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +38 -19
- 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 +21 -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 +82 -10
- 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/HeaderBar.tsx +51 -53
- package/apps/lib/components/InputField.tsx +46 -47
- package/apps/lib/components/Popover.tsx +23 -9
- package/apps/lib/components/SearchableSelect.tsx +10 -6
- package/apps/lib/components/SearchableTree.tsx +23 -6
- package/apps/lib/components/SearchableTreeDialog.tsx +11 -1
- package/apps/lib/components/SectionBlock.tsx +24 -3
- package/apps/lib/components/Select.tsx +64 -56
- package/apps/lib/components/SlideDatePicker.tsx +5 -5
- package/apps/lib/components/TabSwitch.tsx +18 -12
- 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/layouts/FieldSection.tsx +28 -2
- package/apps/lib/registry.json +20 -5
- package/apps/lib/utils/scroller.ts +26 -0
- package/docs/components/badge-field.md +30 -4
- package/docs/components/context-menu.md +3 -1
- package/docs/components/data-views/examples/filters.md +0 -1
- package/docs/components/data-views/index.md +32 -22
- package/docs/components/data-views/migration.md +7 -5
- package/docs/components/drawer.md +5 -5
- package/docs/components/dropdown-menu.md +3 -0
- package/docs/components/form-builder.md +36 -2
- package/docs/components/form-renderer.md +71 -1
- package/docs/components/header-bar.md +3 -2
- package/docs/components/input-field.md +3 -3
- package/docs/components/section-block.md +6 -0
- package/docs/components/select.md +1 -1
- package/docs/migration/changelog.md +19 -0
- package/docs/reference/hooks.md +23 -0
- package/docs/reference/utilities.md +22 -0
- package/package.json +1 -1
- package/apps/lib/components/DataViews/filters/summary.tsx +0 -65
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
FocusEvent,
|
|
10
10
|
} from "react";
|
|
11
11
|
import { cn } from "../utils/cn";
|
|
12
|
-
import {
|
|
12
|
+
import { ToolTipSide } from "./Tooltip";
|
|
13
13
|
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
|
|
14
14
|
import { Themes } from "../utils/types";
|
|
15
15
|
import { Icon, Input, Group, Trilling } from "./Input";
|
|
@@ -22,8 +22,13 @@ interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "va
|
|
|
22
22
|
size?: "XS" | "S" | "M"; // this is used to change the size style of the component
|
|
23
23
|
variant?: "SystemStyle" | "PresentationStyle";
|
|
24
24
|
icon?: ReactNode; // to add left side icon if you pass it
|
|
25
|
-
|
|
25
|
+
/** Marks the field invalid: any non-undefined value turns on the negative border. */
|
|
26
|
+
errorMessage?: string;
|
|
26
27
|
onTable?: boolean; // to change the border style of the component when it is on table
|
|
28
|
+
/**
|
|
29
|
+
* @deprecated Ignored. The error tooltip was removed — an invalid field is shown by its negative
|
|
30
|
+
* border alone. Kept so existing call sites keep compiling; it will go in a future major.
|
|
31
|
+
*/
|
|
27
32
|
toolTipSide?: ToolTipSide;
|
|
28
33
|
label?: string;
|
|
29
34
|
required?: boolean;
|
|
@@ -32,6 +37,15 @@ interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "va
|
|
|
32
37
|
tags: Tag[];
|
|
33
38
|
onValueChange?: (tags: Tag[]) => void;
|
|
34
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;
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
@@ -45,12 +59,15 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
45
59
|
errorMessage,
|
|
46
60
|
onTable,
|
|
47
61
|
variant = "PresentationStyle",
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- deprecated no-op, destructured to keep it out of the {...props} spread
|
|
48
63
|
toolTipSide,
|
|
49
64
|
className,
|
|
50
65
|
actionButton,
|
|
51
66
|
theme,
|
|
52
67
|
tags,
|
|
53
68
|
addLabel = "add",
|
|
69
|
+
creatable = false,
|
|
70
|
+
createLabel = (value: string) => `Create "${value}"`,
|
|
54
71
|
dir,
|
|
55
72
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- excluded from {...props} spread
|
|
56
73
|
children,
|
|
@@ -75,6 +92,7 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
75
92
|
selectedTagsStack,
|
|
76
93
|
handleSelectTag,
|
|
77
94
|
handleUnselectTag,
|
|
95
|
+
handleCreateTag,
|
|
78
96
|
handleKeyDown,
|
|
79
97
|
setFocusedTagIndex,
|
|
80
98
|
filterTagsBySearch,
|
|
@@ -86,6 +104,7 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
86
104
|
searchTags,
|
|
87
105
|
} = useTagSelection({
|
|
88
106
|
Tags: tags,
|
|
107
|
+
creatable,
|
|
89
108
|
onTagsChange: (e) => {
|
|
90
109
|
// Native onChange keeps the event-shaped API (Tag[] in target.value)
|
|
91
110
|
// for react-hook-form / Controller; onValueChange is the typed, direct
|
|
@@ -100,77 +119,103 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
100
119
|
inputRef,
|
|
101
120
|
});
|
|
102
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
|
+
|
|
103
130
|
return (
|
|
104
131
|
<Popover open={isPopoverOpen}>
|
|
105
|
-
<
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
<PopoverTrigger asChild>
|
|
133
|
+
<Group
|
|
134
|
+
dir={dir}
|
|
135
|
+
error={errorMessage !== undefined}
|
|
136
|
+
onTable={onTable}
|
|
137
|
+
data-theme={theme}
|
|
138
|
+
variant={variant}
|
|
139
|
+
tabIndex={isPopoverOpen ? 0 : -1}
|
|
140
|
+
onKeyDown={handleKeyDown}
|
|
141
|
+
size={size === "XS" ? "S" : size}
|
|
142
|
+
ref={inputGroupRef}
|
|
143
|
+
onFocus={(e: FocusEvent<HTMLDivElement>) => {
|
|
144
|
+
setDropDownListWidth(e.currentTarget.offsetWidth);
|
|
145
|
+
}}
|
|
146
|
+
className={cn(
|
|
147
|
+
"flex gap-1 flex-row w-full relative p-1 flex-nowrap overflow-hidden justify-end items-center",
|
|
148
|
+
{
|
|
149
|
+
"flex-wrap justify-start": isPopoverOpen,
|
|
150
|
+
"h-fit": isPopoverOpen,
|
|
151
|
+
},
|
|
152
|
+
className,
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
{icon && <Icon>{icon}</Icon>}
|
|
156
|
+
|
|
157
|
+
{selectedTagsStack.map((tag, index) => (
|
|
158
|
+
<Badge
|
|
159
|
+
key={tag.id}
|
|
160
|
+
size={size}
|
|
161
|
+
color={tag.variant as VariantProps<typeof badgeStyles>["color"]}
|
|
162
|
+
label={tag.name}
|
|
163
|
+
isClosable={true}
|
|
164
|
+
onClose={() => handleUnselectTag(tag.id)}
|
|
165
|
+
className={focusedTagIndex === index ? "ring-2 ring-blue-500" : ""}
|
|
166
|
+
tabIndex={focusedTagIndex === index ? 0 : -1}
|
|
167
|
+
/>
|
|
168
|
+
))}
|
|
169
|
+
|
|
170
|
+
<Input
|
|
171
|
+
{...props}
|
|
172
|
+
value={searchTags}
|
|
173
|
+
onChange={(e) => {
|
|
174
|
+
filterTagsBySearch(e.target.value);
|
|
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
|
+
}
|
|
119
195
|
}}
|
|
196
|
+
onFocus={(e) => {
|
|
197
|
+
props.onFocus?.(e);
|
|
198
|
+
setFocusedTagIndex(null);
|
|
199
|
+
setIsPopoverOpen(true);
|
|
200
|
+
}}
|
|
201
|
+
ref={inputRef}
|
|
120
202
|
className={cn(
|
|
121
|
-
"
|
|
203
|
+
"min-w-[100px] w-full", // Added w-full to Input
|
|
122
204
|
{
|
|
123
|
-
"
|
|
124
|
-
"h-
|
|
205
|
+
"!h-[18px]": size === "XS",
|
|
206
|
+
"!h-[22px]": size === "S",
|
|
207
|
+
"!h-[24px]": size === "M",
|
|
125
208
|
},
|
|
126
|
-
className,
|
|
127
|
-
)}
|
|
128
|
-
>
|
|
129
|
-
{icon && <Icon>{icon}</Icon>}
|
|
130
|
-
|
|
131
|
-
{selectedTagsStack.map((tag, index) => (
|
|
132
|
-
<Badge
|
|
133
|
-
key={tag.id}
|
|
134
|
-
size={size}
|
|
135
|
-
color={tag.variant as VariantProps<typeof badgeStyles>["color"]}
|
|
136
|
-
label={tag.name}
|
|
137
|
-
isClosable={true}
|
|
138
|
-
onClose={() => handleUnselectTag(tag.id)}
|
|
139
|
-
className={focusedTagIndex === index ? "ring-2 ring-blue-500" : ""}
|
|
140
|
-
tabIndex={focusedTagIndex === index ? 0 : -1}
|
|
141
|
-
/>
|
|
142
|
-
))}
|
|
143
|
-
|
|
144
|
-
<Input
|
|
145
|
-
{...props}
|
|
146
|
-
value={searchTags}
|
|
147
|
-
onChange={(e) => {
|
|
148
|
-
filterTagsBySearch(e.target.value);
|
|
149
|
-
}}
|
|
150
|
-
onFocus={(e) => {
|
|
151
|
-
props.onFocus?.(e);
|
|
152
|
-
setFocusedTagIndex(null);
|
|
153
|
-
setIsPopoverOpen(true);
|
|
154
|
-
}}
|
|
155
|
-
ref={inputRef}
|
|
156
|
-
className={cn(
|
|
157
|
-
"min-w-[100px] w-full", // Added w-full to Input
|
|
158
|
-
{
|
|
159
|
-
"!h-[18px]": size === "XS",
|
|
160
|
-
"!h-[22px]": size === "S",
|
|
161
|
-
"!h-[24px]": size === "M",
|
|
162
|
-
},
|
|
163
|
-
)}
|
|
164
|
-
/>
|
|
165
|
-
{actionButton && (
|
|
166
|
-
<Trilling className="py-0">
|
|
167
|
-
{/* Keep the ActionButton right aligned */}
|
|
168
|
-
{actionButton}
|
|
169
|
-
</Trilling>
|
|
170
209
|
)}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
210
|
+
/>
|
|
211
|
+
{actionButton && (
|
|
212
|
+
<Trilling className="py-0">
|
|
213
|
+
{/* Keep the ActionButton right aligned */}
|
|
214
|
+
{actionButton}
|
|
215
|
+
</Trilling>
|
|
216
|
+
)}
|
|
217
|
+
</Group>
|
|
218
|
+
</PopoverTrigger>
|
|
174
219
|
|
|
175
220
|
<PopoverContent
|
|
176
221
|
dir={dir}
|
|
@@ -187,6 +232,22 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
187
232
|
// Reuse the DropdownMenu surface so the list matches the menu design.
|
|
188
233
|
>
|
|
189
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
|
+
)}
|
|
190
251
|
{filteredTags.length > 0 ? (
|
|
191
252
|
filteredTags.map((tag, index) => (
|
|
192
253
|
<button
|
|
@@ -230,9 +291,17 @@ export const BadgeField = forwardRef<HTMLInputElement, Props>(
|
|
|
230
291
|
</button>
|
|
231
292
|
))
|
|
232
293
|
) : (
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
+
)
|
|
236
305
|
)}{" "}
|
|
237
306
|
</div>
|
|
238
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
|
>
|
|
@@ -6,7 +6,8 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|
|
6
6
|
export const cardStyles = cva(
|
|
7
7
|
[
|
|
8
8
|
"flex flex-col justify-start",
|
|
9
|
-
|
|
9
|
+
// `radius/xl` is 12px — the same value this literal had, now taken from the shared scale.
|
|
10
|
+
"gap-2 rounded-radius-xl border",
|
|
10
11
|
"transition-all ease-in-out duration-200",
|
|
11
12
|
"p-[16px]",
|
|
12
13
|
"border-border-presentation-global-primary",
|
|
@@ -139,16 +139,27 @@ const ContextMenuContent = React.forwardRef<
|
|
|
139
139
|
data-theme={theme}
|
|
140
140
|
ref={ref}
|
|
141
141
|
collisionPadding={collisionPadding}
|
|
142
|
-
// Cap at maxHeight, but never exceed the space Radix has after collision
|
|
143
|
-
//
|
|
142
|
+
// Cap at maxHeight, but never exceed the space Radix has after collision handling. The
|
|
143
|
+
// `100vh` fallback is load-bearing: an undefined var invalidates the whole `min()`, so
|
|
144
|
+
// `max-height` would resolve to `none` and the panel — which no longer scrolls itself —
|
|
145
|
+
// would grow unbounded with its rows clipped and unreachable.
|
|
144
146
|
style={{
|
|
145
|
-
maxHeight: `min(${maxHeight}px, var(--radix-context-menu-content-available-height))`,
|
|
147
|
+
maxHeight: `min(${maxHeight}px, var(--radix-context-menu-content-available-height, 100vh))`,
|
|
146
148
|
...style,
|
|
147
149
|
}}
|
|
148
150
|
className={cn(menuContentStyles({ variant }), className)}
|
|
149
151
|
{...props}
|
|
150
152
|
>
|
|
151
|
-
{
|
|
153
|
+
{/* Dedicated scroll viewport, matching Select's: the cap lives on the panel above, this
|
|
154
|
+
fills what is left and scrolls. `min-h-0` is what makes it work — a flex item will not
|
|
155
|
+
shrink below its content, so without it the list grows past the panel and the panel's
|
|
156
|
+
`overflow-hidden` just clips the rows with no scrollbar.
|
|
157
|
+
|
|
158
|
+
`gap-1` is re-declared here because `autoGroupChildren` emits several siblings (a group,
|
|
159
|
+
a label, a separator…) and this is now the element they are siblings within. */}
|
|
160
|
+
<div className="flex flex-col gap-1 flex-1 min-h-0 overflow-y-auto overflow-x-hidden rounded-[10px] scrollbar-hide">
|
|
161
|
+
{autoGroup ? autoGroupChildren(children) : children}
|
|
162
|
+
</div>
|
|
152
163
|
</ContextMenuPrimitive.Content>
|
|
153
164
|
</ContextMenuPrimitive.Portal>
|
|
154
165
|
),
|
|
@@ -180,18 +191,45 @@ const ContextMenuSubContent = React.forwardRef<
|
|
|
180
191
|
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> & {
|
|
181
192
|
variant?: "PresentationStyle";
|
|
182
193
|
autoGroup?: boolean;
|
|
194
|
+
maxHeight?: number;
|
|
183
195
|
}
|
|
184
|
-
>(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
>(
|
|
197
|
+
(
|
|
198
|
+
{
|
|
199
|
+
className,
|
|
200
|
+
variant = "PresentationStyle",
|
|
201
|
+
autoGroup = true,
|
|
202
|
+
collisionPadding = 8,
|
|
203
|
+
maxHeight = 320,
|
|
204
|
+
// Destructured out of `{...props}` so the spread below cannot clobber the cap. Radix
|
|
205
|
+
// re-publishes the namespaced available-height var on SubContent, so the same expression
|
|
206
|
+
// Content uses works here unchanged.
|
|
207
|
+
style,
|
|
208
|
+
children,
|
|
209
|
+
...props
|
|
210
|
+
},
|
|
211
|
+
ref,
|
|
212
|
+
) => (
|
|
213
|
+
<ContextMenuPrimitive.Portal>
|
|
214
|
+
<ContextMenuPrimitive.SubContent
|
|
215
|
+
ref={ref}
|
|
216
|
+
collisionPadding={collisionPadding}
|
|
217
|
+
style={{
|
|
218
|
+
maxHeight: `min(${maxHeight}px, var(--radix-context-menu-content-available-height, 100vh))`,
|
|
219
|
+
...style,
|
|
220
|
+
}}
|
|
221
|
+
className={cn(menuContentStyles({ variant }), className)}
|
|
222
|
+
{...props}
|
|
223
|
+
>
|
|
224
|
+
{/* Same panel-clips / viewport-scrolls split as Content. A submenu is a peer surface, so it
|
|
225
|
+
shares the 320px default rather than getting a smaller one of its own. */}
|
|
226
|
+
<div className="flex flex-col gap-1 flex-1 min-h-0 overflow-y-auto overflow-x-hidden rounded-[10px] scrollbar-hide">
|
|
227
|
+
{autoGroup ? autoGroupChildren(children) : children}
|
|
228
|
+
</div>
|
|
229
|
+
</ContextMenuPrimitive.SubContent>
|
|
230
|
+
</ContextMenuPrimitive.Portal>
|
|
231
|
+
),
|
|
232
|
+
);
|
|
195
233
|
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
|
196
234
|
|
|
197
235
|
const ContextMenuItem = React.forwardRef<
|
|
@@ -487,16 +525,19 @@ const menuContentStyles = cva(
|
|
|
487
525
|
"rounded-[14px]",
|
|
488
526
|
"min-w-[240px]",
|
|
489
527
|
"outline-none",
|
|
490
|
-
|
|
491
|
-
|
|
528
|
+
// The panel clips; the inner viewport below owns scrolling. Height is capped inline on the
|
|
529
|
+
// Content element from `min(maxHeight, available-height)` — no `max-h-*` class here on
|
|
530
|
+
// purpose, so the inline value governs.
|
|
531
|
+
"overflow-hidden",
|
|
492
532
|
// Only animate the OPEN (enter) state. An exit animation on [data-state=closed]
|
|
493
533
|
// holds the old DOM node during close, which breaks close/reposition on a
|
|
494
534
|
// second right-click (Radix issue #2572).
|
|
495
535
|
"data-[state=open]:animate-in",
|
|
496
536
|
"data-[state=open]:fade-in-0",
|
|
497
|
-
"scrollbar-hide",
|
|
498
537
|
"backdrop-blur-[21px]",
|
|
499
|
-
|
|
538
|
+
// No `gap` here: the panel has exactly one child (the scroll viewport), so a gap between
|
|
539
|
+
// siblings has nothing to act on. The 4px between groups/labels lives on that viewport.
|
|
540
|
+
"flex flex-col",
|
|
500
541
|
],
|
|
501
542
|
{
|
|
502
543
|
variants: {
|
|
@@ -506,9 +547,11 @@ const menuContentStyles = cva(
|
|
|
506
547
|
"shadow-[0_0_32px_2px_rgba(0,0,0,0.20),0_0_48px_2px_rgba(0,0,0,0.05)]",
|
|
507
548
|
],
|
|
508
549
|
},
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
550
|
+
},
|
|
551
|
+
// Was nested inside `variants`, where cva reads it as a variant group named
|
|
552
|
+
// "defaultVariants" and no default is ever applied. `menuGroupStyles` below has it right.
|
|
553
|
+
defaultVariants: {
|
|
554
|
+
variant: "PresentationStyle",
|
|
512
555
|
},
|
|
513
556
|
},
|
|
514
557
|
);
|
|
@@ -129,8 +129,8 @@ export interface FiltersContextValue {
|
|
|
129
129
|
setFilters: (filters: FilterState) => void;
|
|
130
130
|
/**
|
|
131
131
|
* What each filter control is, read off the `FormBuilder` children of `DataViews.Filters` —
|
|
132
|
-
* never derived from the rows. The root collects them as well, so
|
|
133
|
-
*
|
|
132
|
+
* never derived from the rows. The root collects them as well, so a consumer such as
|
|
133
|
+
* `Filters.Presets` can resolve a field by path even when it is rendered outside `Filters`.
|
|
134
134
|
*/
|
|
135
135
|
filterFields: readonly FilterFieldDescriptor[];
|
|
136
136
|
}
|
|
@@ -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
|
|
@@ -264,9 +270,9 @@ function DataViewsRoot({
|
|
|
264
270
|
[isPanelOpen, setPanelOpen],
|
|
265
271
|
);
|
|
266
272
|
|
|
267
|
-
// The descriptors as well as the value: `Filters.
|
|
268
|
-
//
|
|
269
|
-
// the context `Filters` provides to its own children.
|
|
273
|
+
// The descriptors as well as the value: a consumer such as `Filters.Presets` resolves a field by
|
|
274
|
+
// path, and may be rendered outside `Filters` — above the table, in a toolbar — where it cannot
|
|
275
|
+
// reach the context `Filters` provides to its own children.
|
|
270
276
|
const filterFields = useMemo(() => collectFilterFields(children), [children]);
|
|
271
277
|
|
|
272
278
|
const setFilters = useCallback(
|
|
@@ -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
|
});
|
|
@@ -10,7 +10,6 @@ import { FiltersContext, useDataViewsFilters } from "../context";
|
|
|
10
10
|
import { collectFilterFields, renderFields } from "./children";
|
|
11
11
|
import { Custom } from "./custom";
|
|
12
12
|
import { Presets } from "./presets";
|
|
13
|
-
import { Summary } from "./summary";
|
|
14
13
|
import { Sync } from "./sync";
|
|
15
14
|
import { toFormValues } from "./values";
|
|
16
15
|
import type { FiltersProps } from "../types";
|
|
@@ -155,5 +154,4 @@ function FiltersRoot({
|
|
|
155
154
|
export const Filters = Object.assign(FiltersRoot, {
|
|
156
155
|
Presets,
|
|
157
156
|
Custom,
|
|
158
|
-
Summary,
|
|
159
157
|
});
|
|
@@ -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);
|