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
|
@@ -6,6 +6,7 @@ import { useRef } from "react";
|
|
|
6
6
|
import { Button } from "./Button";
|
|
7
7
|
import { Checkbox } from "./Checkbox";
|
|
8
8
|
import { useResize } from "../hooks/useResize";
|
|
9
|
+
import { horizontalScrollerStyles } from "../utils/scroller";
|
|
9
10
|
|
|
10
11
|
type TableHeadVariantsProps = VariantProps<typeof tableHeadVariants>;
|
|
11
12
|
|
|
@@ -18,18 +19,20 @@ const Table = React.forwardRef<
|
|
|
18
19
|
<table
|
|
19
20
|
data-theme={theme}
|
|
20
21
|
ref={ref}
|
|
21
|
-
// `overflow-
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// square header band out of the rounded corners.
|
|
22
|
+
// `overflow-visible` is the default, so the table does NOT clip or scroll itself. That is what
|
|
23
|
+
// lets `TableHeader`'s `sticky` reach past the table to the nearest real scrollport and
|
|
24
|
+
// actually pin — which is the whole point of it being sticky.
|
|
25
25
|
//
|
|
26
|
-
// The
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
26
|
+
// The consequence is that the table is `w-auto` and unclipped, so **a wider-than-its-container
|
|
27
|
+
// table is the container's problem**. Every table therefore needs a scrolling ancestor, or it
|
|
28
|
+
// pushes its container — and eventually the page — wide. Two provide one:
|
|
29
|
+
// • `TableScroller` (below), wrapping the table directly. `FormBuilder.Table` uses it.
|
|
30
|
+
// • `SectionBlock`'s body, which is `overflow-x-auto` — so a bare `<Table>` dropped into any
|
|
31
|
+
// section card scrolls inside the card. That covers the app's detail tabs.
|
|
32
|
+
// `DataViews`' table view supplies its own `min-w-0 overflow-auto` scroller instead.
|
|
33
|
+
//
|
|
34
|
+
// A caller that wants the old self-clipping behaviour passes `overflow-hidden`
|
|
35
|
+
// (tailwind-merge lets theirs win) and gives up the sticky header in exchange.
|
|
33
36
|
//
|
|
34
37
|
// `[border-collapse:separate]` is what lets the header cells keep their borders while stuck.
|
|
35
38
|
className={cn("overflow-visible w-auto [border-collapse:separate] border-spacing-0", className)}
|
|
@@ -388,23 +391,7 @@ TableEndAction.displayName = "TableEndAction";
|
|
|
388
391
|
*/
|
|
389
392
|
const TableScroller = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
390
393
|
({ className, children, ...props }, ref) => (
|
|
391
|
-
<div
|
|
392
|
-
ref={ref}
|
|
393
|
-
className={cn(
|
|
394
|
-
"w-full overflow-x-auto overflow-y-hidden",
|
|
395
|
-
"[&::-webkit-scrollbar]:h-[14px]",
|
|
396
|
-
"[&::-webkit-scrollbar-track]:bg-transparent",
|
|
397
|
-
"[&::-webkit-scrollbar-thumb]:rounded-[7px]",
|
|
398
|
-
"[&::-webkit-scrollbar-thumb]:border-[5px] [&::-webkit-scrollbar-thumb]:border-solid",
|
|
399
|
-
"[&::-webkit-scrollbar-thumb]:border-transparent",
|
|
400
|
-
"[&::-webkit-scrollbar-thumb]:bg-clip-content",
|
|
401
|
-
"[&::-webkit-scrollbar-thumb]:bg-background-presentation-body-scroller-default",
|
|
402
|
-
"[&::-webkit-scrollbar-thumb:hover]:border-[3px]",
|
|
403
|
-
"[&::-webkit-scrollbar-thumb:hover]:bg-background-presentation-body-scroller-hover",
|
|
404
|
-
className,
|
|
405
|
-
)}
|
|
406
|
-
{...props}
|
|
407
|
-
>
|
|
394
|
+
<div ref={ref} className={cn("w-full", horizontalScrollerStyles, className)} {...props}>
|
|
408
395
|
{children}
|
|
409
396
|
</div>
|
|
410
397
|
),
|
|
@@ -5,10 +5,13 @@ export function useActiveTreeItem(itemIds: string[]) {
|
|
|
5
5
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
6
6
|
|
|
7
7
|
useEffect(() => {
|
|
8
|
-
|
|
8
|
+
// An empty list is a legitimate answer — a page with no Quick Nav has nothing to track. Only a
|
|
9
|
+
// missing list is a caller mistake worth a warning.
|
|
10
|
+
if (!itemIds) {
|
|
9
11
|
console.warn("No itemIds provided to useActiveTreeItem.");
|
|
10
12
|
return;
|
|
11
13
|
}
|
|
14
|
+
if (itemIds.length === 0) return;
|
|
12
15
|
|
|
13
16
|
const observer = new IntersectionObserver(
|
|
14
17
|
(entries) => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Track the document's text direction from `<html dir>`, updating when it
|
|
5
|
+
* changes (e.g. on a language switch).
|
|
6
|
+
*
|
|
7
|
+
* Several Radix primitives (Tabs, etc.) default to `"ltr"` when no `dir` prop
|
|
8
|
+
* or `DirectionProvider` is supplied, which leaves them rendered left-to-right
|
|
9
|
+
* even on an RTL page. Forwarding this value as their `dir` makes them mirror
|
|
10
|
+
* correctly in Arabic.
|
|
11
|
+
*/
|
|
12
|
+
export function useHtmlDir(): "ltr" | "rtl" {
|
|
13
|
+
const read = () =>
|
|
14
|
+
typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
|
15
|
+
? "rtl"
|
|
16
|
+
: "ltr";
|
|
17
|
+
|
|
18
|
+
const [dir, setDir] = React.useState<"ltr" | "rtl">(read);
|
|
19
|
+
|
|
20
|
+
React.useEffect(() => {
|
|
21
|
+
if (typeof document === "undefined") return;
|
|
22
|
+
const html = document.documentElement;
|
|
23
|
+
const sync = () => setDir(read());
|
|
24
|
+
sync();
|
|
25
|
+
const observer = new MutationObserver(sync);
|
|
26
|
+
observer.observe(html, { attributes: true, attributeFilter: ["dir"] });
|
|
27
|
+
return () => observer.disconnect();
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect } from "react";
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
2
|
|
|
3
3
|
export interface Tag {
|
|
4
4
|
id: string;
|
|
@@ -9,16 +9,22 @@ export interface Tag {
|
|
|
9
9
|
[key: string]: unknown;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** Stable key for a selection, used to tell an external change from one we just made. */
|
|
13
|
+
const signatureOf = (tags: Tag[]) => tags.map((t) => t.id).join("\0");
|
|
14
|
+
|
|
12
15
|
export const useTagSelection = ({
|
|
13
16
|
Tags,
|
|
14
17
|
onTagsChange,
|
|
15
18
|
inputRef,
|
|
16
19
|
singleSelect = false,
|
|
20
|
+
creatable = false,
|
|
17
21
|
}: {
|
|
18
22
|
Tags: Tag[];
|
|
19
23
|
onTagsChange?: (selectedTags: Tag[]) => void;
|
|
20
24
|
inputRef?: React.RefObject<HTMLInputElement | null>;
|
|
21
25
|
singleSelect?: boolean;
|
|
26
|
+
/** Allow typed text to become a selected tag that was never in `Tags`. */
|
|
27
|
+
creatable?: boolean;
|
|
22
28
|
}) => {
|
|
23
29
|
// Split initial tags into selected and unselected
|
|
24
30
|
const initialSelectedTags = Tags.filter((tag) => tag.isSelected);
|
|
@@ -36,18 +42,63 @@ export const useTagSelection = ({
|
|
|
36
42
|
const [focusedPopoverIndex, setFocusedPopoverIndex] = useState<number | null>(null);
|
|
37
43
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
38
44
|
|
|
39
|
-
//
|
|
45
|
+
// LOCAL PATCH (Contact Center): key both effects below off a SIGNATURE of `Tags`, not the array
|
|
46
|
+
// reference. A caller that builds its tag list inline — `MultiSelectField` does, from the form
|
|
47
|
+
// value — hands over a fresh array every render, so the upstream `[Tags]` dependency changed on
|
|
48
|
+
// every pass: effect → setTags → re-render → new array → effect, an infinite render loop.
|
|
49
|
+
const tagsSignature = Tags.map((tag) => `${tag.id}:${tag.isSelected ? 1 : 0}`).join(" ");
|
|
50
|
+
|
|
51
|
+
// Update internal state when Tags actually changes.
|
|
52
|
+
//
|
|
53
|
+
// Filter against the INCOMING selection as well as the one we hold: on an external sync (the
|
|
54
|
+
// hydration case below) `selectedTagsStack` is still the pre-sync value at this point, so
|
|
55
|
+
// filtering by it alone left the freshly selected values sitting in the dropdown as if they
|
|
56
|
+
// were still available to add.
|
|
40
57
|
useEffect(() => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
const selectedIds = new Set([
|
|
59
|
+
...selectedTagsStack.map((tag) => tag.id),
|
|
60
|
+
...Tags.filter((tag) => tag.isSelected).map((tag) => tag.id),
|
|
61
|
+
]);
|
|
62
|
+
setTags(Tags.filter((tag) => !selectedIds.has(tag.id)));
|
|
63
|
+
}, [tagsSignature]);
|
|
64
|
+
|
|
65
|
+
// LOCAL PATCH (Contact Center): follow the incoming selection.
|
|
66
|
+
//
|
|
67
|
+
// Upstream seeded `selectedTagsStack` from `Tags` ONCE, and the effect above refreshed only the
|
|
68
|
+
// AVAILABLE list — never the selection. That made the component effectively uncontrolled: on an
|
|
69
|
+
// edit form, react-hook-form's `reset()` hydration lands after mount, so a person's saved emails
|
|
70
|
+
// and tags rendered as an empty field. Re-sync whenever the incoming selected set differs from
|
|
71
|
+
// what we hold, and mark the change as external so it isn't echoed straight back to the parent.
|
|
72
|
+
const incomingSelected = Tags.filter((tag) => tag.isSelected);
|
|
73
|
+
const incomingSignature = signatureOf(incomingSelected);
|
|
74
|
+
const lastSyncedRef = useRef<string | null>(null);
|
|
75
|
+
// Starts true so the mount pass below is swallowed — see the notify effect.
|
|
76
|
+
const skipNotifyRef = useRef(true);
|
|
45
77
|
|
|
46
|
-
// Notify parent component when tags change
|
|
47
78
|
useEffect(() => {
|
|
48
|
-
if (
|
|
49
|
-
|
|
79
|
+
if (lastSyncedRef.current === incomingSignature) return;
|
|
80
|
+
lastSyncedRef.current = incomingSignature;
|
|
81
|
+
// Already matches (we made this change ourselves) — don't re-set state, so the notify flag
|
|
82
|
+
// stays untouched and the user's next real edit still reaches the parent.
|
|
83
|
+
if (signatureOf(selectedTagsStack) === incomingSignature) return;
|
|
84
|
+
skipNotifyRef.current = true;
|
|
85
|
+
setSelectedTagsStack(
|
|
86
|
+
singleSelect && incomingSelected.length > 0 ? [incomingSelected[0]] : incomingSelected,
|
|
87
|
+
);
|
|
88
|
+
}, [incomingSignature]);
|
|
89
|
+
|
|
90
|
+
// Notify parent component when tags change.
|
|
91
|
+
//
|
|
92
|
+
// LOCAL PATCH (Contact Center): upstream fired this on MOUNT too, so an untouched field wrote
|
|
93
|
+
// `[]` into the form — marking it dirty and adding an empty-array key to the request payload for
|
|
94
|
+
// a value nobody entered. It also fires for a sync from the parent, which would echo the value
|
|
95
|
+
// straight back. Both are skipped via the flag.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (skipNotifyRef.current) {
|
|
98
|
+
skipNotifyRef.current = false;
|
|
99
|
+
return;
|
|
50
100
|
}
|
|
101
|
+
onTagsChange?.(selectedTagsStack);
|
|
51
102
|
}, [selectedTagsStack]);
|
|
52
103
|
|
|
53
104
|
// Filter tags based on search input
|
|
@@ -92,6 +143,38 @@ export const useTagSelection = ({
|
|
|
92
143
|
setFocusedTagIndex(null);
|
|
93
144
|
};
|
|
94
145
|
|
|
146
|
+
/**
|
|
147
|
+
* LOCAL PATCH (Contact Center): turn typed text into a selected tag.
|
|
148
|
+
*
|
|
149
|
+
* Upstream had no way in but `handleSelectTag(id)` against the fixed `Tags` list, so a free-text
|
|
150
|
+
* list (a person's emails, an organization's aliases) could not be expressed as a badge field at
|
|
151
|
+
* all — which is why those lists were built as one-column tables instead. Matching an existing
|
|
152
|
+
* tag by name selects it rather than creating a duplicate; the id IS the text, so a value the
|
|
153
|
+
* caller round-trips keeps a stable identity.
|
|
154
|
+
*/
|
|
155
|
+
const handleCreateTag = (rawName: string) => {
|
|
156
|
+
const name = rawName.trim();
|
|
157
|
+
if (!name) return;
|
|
158
|
+
const sameName = (tag: Tag) => tag.name.toLowerCase() === name.toLowerCase();
|
|
159
|
+
|
|
160
|
+
// Already chosen — just clear the box so the user sees their text was accepted.
|
|
161
|
+
if (selectedTagsStack.some(sameName)) {
|
|
162
|
+
filterTagsBySearch("");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// Offered in the list — select it instead of creating a look-alike.
|
|
166
|
+
const existing = tags.find(sameName);
|
|
167
|
+
if (existing) {
|
|
168
|
+
handleSelectTag(existing.id);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const created: Tag = { id: name, name, value: name, isSelected: true };
|
|
173
|
+
setSelectedTagsStack((prev) => (singleSelect ? [created] : [...prev, created]));
|
|
174
|
+
filterTagsBySearch("");
|
|
175
|
+
setFocusedPopoverIndex(null);
|
|
176
|
+
};
|
|
177
|
+
|
|
95
178
|
// Reset the hook state with new data
|
|
96
179
|
const reset = (newTags: Tag[] = [], newSelectedTags: Tag[] = []) => {
|
|
97
180
|
// In single select mode, ensure we only have at most one selected tag
|
|
@@ -198,6 +281,9 @@ export const useTagSelection = ({
|
|
|
198
281
|
searchTags,
|
|
199
282
|
handleSelectTag,
|
|
200
283
|
handleUnselectTag,
|
|
284
|
+
// LOCAL PATCH (Contact Center) — see above.
|
|
285
|
+
handleCreateTag,
|
|
286
|
+
creatable,
|
|
201
287
|
handleKeyDown,
|
|
202
288
|
setFocusedTagIndex,
|
|
203
289
|
setFocusedPopoverIndex,
|
|
@@ -27,6 +27,14 @@ export function FieldSection({
|
|
|
27
27
|
childrenUnderLabel,
|
|
28
28
|
...props
|
|
29
29
|
}: Props) {
|
|
30
|
+
// Which copy of `childrenUnderLabel` is visible, driven purely by the `@md` container
|
|
31
|
+
// breakpoint (this section's own width reaching 650px) — deliberately not by `direction`.
|
|
32
|
+
// Pinning it per-direction breaks the common case: `FormRenderer` forces `direction="vertical"`
|
|
33
|
+
// inside a drawer and FormBuilder puts the field's validation error in this slot, so a vertical
|
|
34
|
+
// form would strand every error under the label at any width.
|
|
35
|
+
const underLabelSlot = direction === "vertical" ? "hidden" : "hidden @md:block";
|
|
36
|
+
const underChildrenSlot = direction === "vertical" ? "block" : "block @md:hidden";
|
|
37
|
+
|
|
30
38
|
return (
|
|
31
39
|
<section
|
|
32
40
|
{...props}
|
|
@@ -54,11 +62,29 @@ export function FieldSection({
|
|
|
54
62
|
)}
|
|
55
63
|
|
|
56
64
|
{secondaryLabel && <Label size={size} secondaryLabel={secondaryLabel} />}
|
|
57
|
-
|
|
65
|
+
|
|
66
|
+
{/* Stacked (below `@md`): the label column IS the full width, so under the label is the
|
|
67
|
+
natural spot. Hidden once `@md` splits the row into two columns — the copy in the
|
|
68
|
+
children column takes over there. Exactly one of the two is ever displayed. */}
|
|
69
|
+
{childrenUnderLabel && (
|
|
70
|
+
<div data-slot="under-label" className={cn(underLabelSlot)}>
|
|
71
|
+
{childrenUnderLabel}
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
58
74
|
</div>
|
|
59
75
|
|
|
60
76
|
{/* Flexible section that takes up the remaining space */}
|
|
61
|
-
<div className="grid grid-cols-1 place-items-end gap-[12px]">
|
|
77
|
+
<div className="grid grid-cols-1 place-items-end gap-[12px]">
|
|
78
|
+
{children}
|
|
79
|
+
|
|
80
|
+
{/* Two-column (`@md` and up): the hint belongs under the control it describes, not
|
|
81
|
+
stranded at the bottom of the 350px label column. */}
|
|
82
|
+
{childrenUnderLabel && (
|
|
83
|
+
<div data-slot="under-children" className={cn("w-full", underChildrenSlot)}>
|
|
84
|
+
{childrenUnderLabel}
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
62
88
|
</div>
|
|
63
89
|
</section>
|
|
64
90
|
);
|
package/apps/lib/registry.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.5.
|
|
2
|
+
"version": "2.5.6",
|
|
3
3
|
"generatedBy": "scripts/bin/generateRegistry",
|
|
4
4
|
"npmVersions": {
|
|
5
5
|
"@dnd-kit/core": "^6.3.1",
|
|
@@ -439,7 +439,6 @@
|
|
|
439
439
|
"components/Table",
|
|
440
440
|
"components/TextEditor",
|
|
441
441
|
"components/Textarea",
|
|
442
|
-
"components/Tooltip",
|
|
443
442
|
"hooks/useTagSelection",
|
|
444
443
|
"layouts/FieldSection",
|
|
445
444
|
"utils/cn"
|
|
@@ -461,6 +460,7 @@
|
|
|
461
460
|
"components/SectionBlock",
|
|
462
461
|
"components/Stepper",
|
|
463
462
|
"components/TabFormItem",
|
|
463
|
+
"hooks/useHtmlDir",
|
|
464
464
|
"utils/cn"
|
|
465
465
|
]
|
|
466
466
|
},
|
|
@@ -772,7 +772,8 @@
|
|
|
772
772
|
"class-variance-authority"
|
|
773
773
|
],
|
|
774
774
|
"registryDependencies": [
|
|
775
|
-
"utils/cn"
|
|
775
|
+
"utils/cn",
|
|
776
|
+
"utils/scroller"
|
|
776
777
|
]
|
|
777
778
|
},
|
|
778
779
|
{
|
|
@@ -786,7 +787,6 @@
|
|
|
786
787
|
"registryDependencies": [
|
|
787
788
|
"components/ActionButton",
|
|
788
789
|
"components/DropdownMenu",
|
|
789
|
-
"components/Tooltip",
|
|
790
790
|
"utils/cn",
|
|
791
791
|
"utils/types"
|
|
792
792
|
]
|
|
@@ -884,7 +884,8 @@
|
|
|
884
884
|
"components/Button",
|
|
885
885
|
"components/Checkbox",
|
|
886
886
|
"hooks/useResize",
|
|
887
|
-
"utils/cn"
|
|
887
|
+
"utils/cn",
|
|
888
|
+
"utils/scroller"
|
|
888
889
|
]
|
|
889
890
|
},
|
|
890
891
|
{
|
|
@@ -1063,6 +1064,13 @@
|
|
|
1063
1064
|
],
|
|
1064
1065
|
"registryDependencies": []
|
|
1065
1066
|
},
|
|
1067
|
+
{
|
|
1068
|
+
"name": "useHtmlDir",
|
|
1069
|
+
"type": "hooks",
|
|
1070
|
+
"path": "hooks/useHtmlDir.ts",
|
|
1071
|
+
"npmDependencies": [],
|
|
1072
|
+
"registryDependencies": []
|
|
1073
|
+
},
|
|
1066
1074
|
{
|
|
1067
1075
|
"name": "useInfiniteScroll",
|
|
1068
1076
|
"type": "hooks",
|
|
@@ -1191,6 +1199,13 @@
|
|
|
1191
1199
|
"npmDependencies": [],
|
|
1192
1200
|
"registryDependencies": []
|
|
1193
1201
|
},
|
|
1202
|
+
{
|
|
1203
|
+
"name": "scroller",
|
|
1204
|
+
"type": "utils",
|
|
1205
|
+
"path": "utils/scroller.ts",
|
|
1206
|
+
"npmDependencies": [],
|
|
1207
|
+
"registryDependencies": []
|
|
1208
|
+
},
|
|
1194
1209
|
{
|
|
1195
1210
|
"name": "types",
|
|
1196
1211
|
"type": "utils",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LOCAL PATCH (Contact Center): the design's 14px horizontal scroller — a thin track that thickens
|
|
3
|
+
* and turns blue on hover.
|
|
4
|
+
*
|
|
5
|
+
* Lives here, not on `Table`, because two unrelated components wear it: `TableScroller` (the
|
|
6
|
+
* wrapper `FormBuilder.Table` puts around its grid) and `SectionBlock`'s body (which scrolls any
|
|
7
|
+
* wide content dropped into a section card). A section card should not have to import the table
|
|
8
|
+
* component — and its consumers should not pull in the table's dependencies — just to share a
|
|
9
|
+
* scrollbar.
|
|
10
|
+
*
|
|
11
|
+
* `overflow-y-hidden` is part of the set on purpose: CSS computes `overflow-y: visible` to `auto`
|
|
12
|
+
* whenever `overflow-x` is not `visible`, so omitting it gives every consumer a spurious vertical
|
|
13
|
+
* scrollbar.
|
|
14
|
+
*/
|
|
15
|
+
export const horizontalScrollerStyles = [
|
|
16
|
+
"overflow-x-auto overflow-y-hidden",
|
|
17
|
+
"[&::-webkit-scrollbar]:h-[14px]",
|
|
18
|
+
"[&::-webkit-scrollbar-track]:bg-transparent",
|
|
19
|
+
"[&::-webkit-scrollbar-thumb]:rounded-[7px]",
|
|
20
|
+
"[&::-webkit-scrollbar-thumb]:border-[5px] [&::-webkit-scrollbar-thumb]:border-solid",
|
|
21
|
+
"[&::-webkit-scrollbar-thumb]:border-transparent",
|
|
22
|
+
"[&::-webkit-scrollbar-thumb]:bg-clip-content",
|
|
23
|
+
"[&::-webkit-scrollbar-thumb]:bg-background-presentation-body-scroller-default",
|
|
24
|
+
"[&::-webkit-scrollbar-thumb:hover]:border-[3px]",
|
|
25
|
+
"[&::-webkit-scrollbar-thumb:hover]:bg-background-presentation-body-scroller-hover",
|
|
26
|
+
].join(" ");
|
|
@@ -335,7 +335,7 @@ export function BadgeFieldWithIcon() {
|
|
|
335
335
|
|
|
336
336
|
### With Error State
|
|
337
337
|
|
|
338
|
-
Display validation errors
|
|
338
|
+
Display validation errors. `errorMessage` turns on the field's negative border.
|
|
339
339
|
|
|
340
340
|
```tsx
|
|
341
341
|
export function BadgeFieldWithError() {
|
|
@@ -467,9 +467,9 @@ Extends all Input element props (except size and variant).
|
|
|
467
467
|
| size | `'XS' \| 'S' \| 'M'` | `'M'` | Field size |
|
|
468
468
|
| variant | `'PresentationStyle'` | `'PresentationStyle'` | Visual variant |
|
|
469
469
|
| icon | `ReactNode` | - | Leading icon |
|
|
470
|
-
| errorMessage | `string` | - |
|
|
470
|
+
| errorMessage | `string` | - | Marks the field invalid — any non-undefined value turns on the negative border |
|
|
471
471
|
| onTable | `boolean` | `false` | Table-specific styling |
|
|
472
|
-
| toolTipSide | `ToolTipSide` | - |
|
|
472
|
+
| toolTipSide | `ToolTipSide` | - | **Deprecated, ignored.** The error tooltip was removed; an invalid field is shown by its border alone |
|
|
473
473
|
| label | `string` | - | Field label |
|
|
474
474
|
| required | `boolean` | `false` | Required indicator |
|
|
475
475
|
| theme | `Themes` | - | Theme override |
|
|
@@ -477,6 +477,32 @@ Extends all Input element props (except size and variant).
|
|
|
477
477
|
| addLabel | `string` | `'add'` | Label for the add action shown in the field |
|
|
478
478
|
| dir | `string` | `'ltr'` | Reading direction (`'rtl'` for right-to-left) |
|
|
479
479
|
| placeholder | `string` | - | Input placeholder text |
|
|
480
|
+
| creatable | `boolean` | `false` | Let the user type a value that is not in `tags` and commit it as a badge |
|
|
481
|
+
| createLabel | `(value: string) => string` | ``value => `Create "${value}"` `` | Label for the create row; receives the typed text |
|
|
482
|
+
|
|
483
|
+
### Creatable tags
|
|
484
|
+
|
|
485
|
+
With `creatable`, the field stops being a picker over a fixed list: whatever the user types can
|
|
486
|
+
become a badge. Enter or comma commits it; Backspace on an empty box removes the last badge. Pass
|
|
487
|
+
`tags={[]}` for a pure free-text list — emails, aliases, arbitrary labels — which otherwise has to be
|
|
488
|
+
modelled as a one-column table.
|
|
489
|
+
|
|
490
|
+
```tsx
|
|
491
|
+
<BadgeField
|
|
492
|
+
creatable
|
|
493
|
+
tags={recipients}
|
|
494
|
+
onValueChange={setRecipients}
|
|
495
|
+
placeholder="Add an email…"
|
|
496
|
+
createLabel={(value) => `Invite ${value}`}
|
|
497
|
+
/>
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
The create row is suppressed when the typed text already matches a selected or listed tag
|
|
501
|
+
(case-insensitive), so you cannot produce duplicates. With `creatable` set, the empty-list message
|
|
502
|
+
becomes "Type a value and press Enter" rather than "All tags selected".
|
|
503
|
+
|
|
504
|
+
> `FormBuilder.MultiSelect` / `.Tags` forward `creatable`, but **not** `createLabel` — inside a form
|
|
505
|
+
> the create row keeps the default label.
|
|
480
506
|
|
|
481
507
|
### Tag Type
|
|
482
508
|
|
|
@@ -674,7 +700,7 @@ describe('BadgeField', () => {
|
|
|
674
700
|
- **ARIA Labels**: Proper labels for screen readers
|
|
675
701
|
- **Focus Management**: Clear focus indicators
|
|
676
702
|
- **Screen Reader**: Announces selected/removed tags
|
|
677
|
-
- **Error Messages**:
|
|
703
|
+
- **Error Messages**: Invalid fields are marked with the negative border token
|
|
678
704
|
- **Tab Order**: Logical tab navigation
|
|
679
705
|
|
|
680
706
|
## Performance
|
|
@@ -230,6 +230,8 @@ function RtlMenu() {
|
|
|
230
230
|
|
|
231
231
|
Tall menus scroll instead of overflowing off-screen. The surface caps at `maxHeight` (default `320`px) and never exceeds the space available after collision handling. Pass `maxHeight` to change the cap.
|
|
232
232
|
|
|
233
|
+
The panel itself does not scroll — it clips, and an inner viewport inside it does the scrolling. That keeps the panel's 4px frosted gutter fixed instead of scrolling away with the rows. Submenus behave identically and take their own `maxHeight` (same `320`px default).
|
|
234
|
+
|
|
233
235
|
```typescript
|
|
234
236
|
import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuLabel } from "@/components/ContextMenu";
|
|
235
237
|
|
|
@@ -425,7 +427,7 @@ export const ContextMenuRadioItem: React.ForwardRefExoticComponent<ContextMenuRa
|
|
|
425
427
|
- **Opens at the pointer**: the menu opens on right-click (`contextmenu`) at the exact cursor position, not anchored to a fixed trigger button.
|
|
426
428
|
- **Second right-click closes it**: the Root is made controlled and tracks `open` in context. The Trigger listens in the capture phase, and when the menu is already open it `preventDefault()` / `stopPropagation()` and closes — so a second right-click dismisses instead of re-anchoring (which Radix handles unreliably).
|
|
427
429
|
- **Auto-grouping**: by default (`autoGroup` on `ContextMenuContent`, default `true`) consecutive loose items (`ContextMenuItem`, `ContextMenuCheckboxItem`, `ContextMenuRadioItem`, and `ContextMenuSub`) are automatically wrapped in a `Boxed` `ContextMenuGroup`, so they render inside a boxed container like DropdownMenu even when you do not write a group. Labels and explicit groups act as boundaries and pass through unchanged. Set `autoGroup={false}` to render children verbatim.
|
|
428
|
-
- **Max height & scrolling**: the surface caps its height at `min(maxHeight, available-height)` (where `maxHeight` defaults to `320`px and `available-height` is the space Radix has after collision handling). A taller menu scrolls vertically instead of overflowing off-screen — items and groups keep their full height rather than squishing. Pass `maxHeight={N}` to change the cap.
|
|
430
|
+
- **Max height & scrolling**: the surface caps its height at `min(maxHeight, available-height)` (where `maxHeight` defaults to `320`px and `available-height` is the space Radix has after collision handling). A taller menu scrolls vertically instead of overflowing off-screen — items and groups keep their full height rather than squishing. The panel clips and an inner viewport scrolls, so the panel's frosted gutter stays put. Pass `maxHeight={N}` to change the cap; `ContextMenuSubContent` accepts it too.
|
|
429
431
|
- **Checkbox / radio keep the menu open**: `ContextMenuCheckboxItem` and `ContextMenuRadioItem` call `event.preventDefault()` inside `onSelect`, stopping Radix's default auto-close so users can toggle multiple options in one pass.
|
|
430
432
|
- **Open-only animation**: only the open (enter) state animates (`fade-in`). There is intentionally no exit animation — holding the old DOM node during close breaks close/reposition on a second right-click, so it is omitted to keep repositioning reliable.
|
|
431
433
|
- **Submenus and RTL**: nested `ContextMenuSub` / `ContextMenuSubTrigger` / `ContextMenuSubContent` are supported, and `dir="rtl"` on the Root mirrors the layout (including the submenu chevron).
|
|
@@ -231,7 +231,6 @@ export default function FiltersExample() {
|
|
|
231
231
|
|
|
232
232
|
{/* The active query, as removable chips — including the search term. */}
|
|
233
233
|
<div className="border-border-presentation-global-primary border-b px-4 py-2 empty:hidden">
|
|
234
|
-
<DataViews.Filters.Summary />
|
|
235
234
|
</div>
|
|
236
235
|
|
|
237
236
|
<DataViews.Table />
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
useDataViewsFilters, useDataViewsPanel, useDataViewsPanelTabs,
|
|
40
40
|
useActiveRow, // the row behind `activeId`
|
|
41
41
|
Cell, // paint one field the way the views paint it
|
|
42
|
-
markView, markHeader, markPanel,
|
|
42
|
+
markView, markHeader, markPanel, markEmpty, // register a part of your own
|
|
43
43
|
SkeletonBar, skeletonKeys, // the loading pieces every view is built from
|
|
44
44
|
getByPath, formatPathLabel, defaultGetRowId, // read a value by dotted path
|
|
45
45
|
buildCardRows, resolveBadgeVariant,
|
|
@@ -135,10 +135,35 @@ see the change, "new filter" and "new page" have already become one object.
|
|
|
135
135
|
|
|
136
136
|
## Empty and loading
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
**Loading is not a part you render.** While `loading` is set, each view paints a **skeleton in its
|
|
139
|
+
own shape**: the table shimmers rows at the real row height and column widths, the board shimmers
|
|
140
|
+
cards inside its columns.
|
|
141
|
+
|
|
142
|
+
**Empty is opt-in.** By default the view simply shows nothing — the table keeps its header band and
|
|
143
|
+
has no rows, the board keeps its columns and has no cards. That default is deliberate: a centred
|
|
144
|
+
message in place of the view throws away the chrome, and it cannot tell "no results" from "not
|
|
145
|
+
fetched yet".
|
|
146
|
+
|
|
147
|
+
When you do want something there, render `DataViews.Empty` anywhere among the children. It is a
|
|
148
|
+
passthrough with a marker — it holds no opinion about what an empty state looks like, it only tells
|
|
149
|
+
the root to put its content where the view goes. The root swaps it in when the query has settled and
|
|
150
|
+
returned nothing (`!loading && rows.length === 0`):
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
<DataViews rows={rows} fields={fields} loading={loading}>
|
|
154
|
+
<DataViews.Table />
|
|
155
|
+
<DataViews.Empty>
|
|
156
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-2">
|
|
157
|
+
<p>No invoices match these filters.</p>
|
|
158
|
+
<Button onClick={clearFilters}>Clear filters</Button>
|
|
159
|
+
</div>
|
|
160
|
+
</DataViews.Empty>
|
|
161
|
+
</DataViews>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Your content is what receives the body slot's height, so give it `flex-1` if it should centre.
|
|
165
|
+
Wrapping `DataViews.Empty` in a component of your own? Mark that wrapper with `markEmpty` so the
|
|
166
|
+
root still recognises it, the same way `markView` / `markHeader` / `markPanel` work.
|
|
142
167
|
|
|
143
168
|
## Large datasets
|
|
144
169
|
|
|
@@ -183,6 +208,7 @@ and that is not built.
|
|
|
183
208
|
| `DataViews.Board` | a kanban board | `groups` — it never groups rows itself | [`views`](./examples/views.md) |
|
|
184
209
|
| `DataViews.Inbox` | a master list beside a detail pane | the pane, as `children` | [`inbox-routing`](./examples/inbox-routing.md) |
|
|
185
210
|
| `DataViews.Tree` | a hierarchy, optionally beside a pane | `nodes` — it never builds one | [`tree-custom`](./examples/tree-custom.md) |
|
|
211
|
+
| `DataViews.Empty` | your content, in place of the view, once a settled query returns no rows | the content — it holds no opinion about what empty looks like | — |
|
|
186
212
|
|
|
187
213
|
Each takes `id`, `label` and `icon` to control how it appears in the switcher, so the same view can
|
|
188
214
|
be registered twice with different data. Full props are under
|
|
@@ -406,21 +432,13 @@ control at its neutral position emits no key at all, is under
|
|
|
406
432
|
</DataViews.Filters>
|
|
407
433
|
```
|
|
408
434
|
|
|
409
|
-
`Filters.Summary` paints whatever is active as removable chips. It reads the same context, so it
|
|
410
|
-
works anywhere — most usefully **outside** the rail, where it tells the user what is filtering the
|
|
411
|
-
rows they are looking at:
|
|
412
|
-
|
|
413
|
-
```tsx
|
|
414
|
-
<DataViews.Filters.Summary className="px-4 py-2" />
|
|
415
|
-
```
|
|
416
|
-
|
|
417
435
|
### Questions this design gets asked
|
|
418
436
|
|
|
419
437
|
| Question | Answer |
|
|
420
438
|
| --- | --- |
|
|
421
439
|
| Is there an in-view filter panel *and* a Filters tab — which is canonical? | **One surface.** `DataViews.Filters` is a single component. Render it inside a `Panel.Tab` or as a standalone bar; author against the component, not against a tab. |
|
|
422
440
|
| What orders the sections? | **The order you write the children.** There is no `order` prop for filters. |
|
|
423
|
-
|
|
|
441
|
+
| Is there an applied-count badge or a chip summary of active filters? | **No.** `PanelToggle` carries no count, and there is no summary component — the controls themselves show what is set. Render your own above the rows if you want one. |
|
|
424
442
|
| Do `BadgeField` chip colours and `FieldConfig.variants` share a token set? | **They never meet.** Chips come from the field's own `options`; `variants` (`BadgeVariant`) styles `enum-badge` **columns**. Filters and columns are independent. |
|
|
425
443
|
|
|
426
444
|
And the behaviours worth stating because they are easy to assume wrongly:
|
|
@@ -771,14 +789,6 @@ A filter no FormBuilder field covers.
|
|
|
771
789
|
| `render` | `(args: { value: FilterValue \| undefined; setValue: (v: FilterValue \| undefined) => void }) => ReactNode` | — | **yes** | |
|
|
772
790
|
| `label` | `ReactNode` | derived from `path` | no | |
|
|
773
791
|
|
|
774
|
-
### DataViews.Filters.Summary
|
|
775
|
-
|
|
776
|
-
| Prop | Type | Default | Required | Notes |
|
|
777
|
-
| --- | --- | --- | --- | --- |
|
|
778
|
-
| `className` | `string` | — | no | |
|
|
779
|
-
|
|
780
|
-
Active filters — and the search term — as removable chips. Renders `null` when there are none.
|
|
781
|
-
|
|
782
792
|
### Cell
|
|
783
793
|
|
|
784
794
|
Paint one field of one row exactly as the views paint it.
|
|
@@ -56,11 +56,13 @@ Three parts of `DataViews` itself went in the same release.
|
|
|
56
56
|
`rows`; whether there is more is derived from `rows.length < total`, so there is no `hasMore` prop.
|
|
57
57
|
See the *Large datasets* section of the [DataViews doc](./index.md).
|
|
58
58
|
|
|
59
|
-
**`DataViews.Empty`** —
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
**`DataViews.Empty`** — *removed, then re-added as opt-in.* It was dropped because rendering a
|
|
60
|
+
centred message in place of the view threw away the chrome, and it could not tell "no results" apart
|
|
61
|
+
from "not fetched yet" — so the first load of every page announced that nothing matched before
|
|
62
|
+
anything had been asked for. It is back on different terms: the default is still to show nothing,
|
|
63
|
+
and `DataViews.Empty` now renders **only** once the query has settled with no rows
|
|
64
|
+
(`!loading && rows.length === 0`). Nothing to migrate — omit it and behaviour is unchanged. See the
|
|
65
|
+
*Empty and loading* section of the [DataViews doc](./index.md).
|
|
64
66
|
|
|
65
67
|
**`DataViews.Loading`** — each view now paints its own skeleton, in its own shape, driven by the
|
|
66
68
|
`loading` prop. A custom view registered with `markView` gets the same thing: read `loading` from
|