@neasg/design-system 0.4.21 → 0.4.22
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/dist/filter-popover.js +4 -2
- package/dist/multi-select.js +22 -34
- package/dist/popover-focus.d.ts +14 -0
- package/dist/popover-focus.js +64 -0
- package/dist/popover-menu.js +26 -4
- package/dist/popover.d.ts +4 -1
- package/dist/popover.js +31 -3
- package/dist/select.js +28 -11
- package/package.json +1 -1
package/dist/filter-popover.js
CHANGED
|
@@ -7,7 +7,7 @@ import { Button } from "./button";
|
|
|
7
7
|
import { CountBadge } from "./count-badge";
|
|
8
8
|
import { cn } from "./lib/utils";
|
|
9
9
|
import { Popover } from "./popover";
|
|
10
|
-
import { PopoverMenuFooter, PopoverMenuFooterAction, PopoverMenuGroup, PopoverMenuGroupLabel, PopoverMenuItem, PopoverMenuScrollArea, PopoverMenuSearch, PopoverMenuSection, PopoverMenuView, popoverMenuItemFocusClassName, } from "./popover-menu";
|
|
10
|
+
import { PopoverMenuFooter, PopoverMenuFooterAction, PopoverMenuCheckboxItem, PopoverMenuGroup, PopoverMenuGroupLabel, PopoverMenuItem, PopoverMenuScrollArea, PopoverMenuSearch, PopoverMenuSection, PopoverMenuView, popoverMenuItemFocusClassName, } from "./popover-menu";
|
|
11
11
|
import { Typography } from "./typography";
|
|
12
12
|
function getText(value) {
|
|
13
13
|
if (typeof value === "string" || typeof value === "number") {
|
|
@@ -72,7 +72,9 @@ function FilterPopover({ align = "start", ariaLabel, contentClassName, defaultOp
|
|
|
72
72
|
onOpenChange === null || onOpenChange === void 0 ? void 0 : onOpenChange(nextOpen);
|
|
73
73
|
};
|
|
74
74
|
const resolvedTitle = (_c = title !== null && title !== void 0 ? title : directGroup === null || directGroup === void 0 ? void 0 : directGroup.label) !== null && _c !== void 0 ? _c : (!activeGroup ? triggerLabel : undefined);
|
|
75
|
-
return (_jsxs(Popover, { align: align, contentClassName: cn("w-[360px]", contentClassName), defaultOpen: defaultOpen, modal: modal, onOpenChange: handleOpenChange, open: open, side: side, surface: "menu", trigger: trigger !== null && trigger !== void 0 ? trigger : defaultTrigger, title: resolvedTitle, "aria-label": (_e = (_d = ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : getText(resolvedTitle)) !== null && _d !== void 0 ? _d : getText(triggerLabel)) !== null && _e !== void 0 ? _e : "Filters", children: [_jsx(PopoverMenuView, { className: cn("-mx-1 -mt-1 px-1 pt-1", viewClassName), direction: transitionDirection, viewKey: activeGroup ? `group-${activeGroup.id}` : "filter-groups", children: activeGroup ? (_jsxs(_Fragment, { children: [!directGroup ? (_jsx("div", { className: "-mx-1 -mt-1 border-b p-1", children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: cn("w-full justify-start", popoverMenuItemFocusClassName), onClick: showFilterGroups, children: [_jsx(ChevronLeft, { className: "h-4 w-4" }), activeGroup.label] }) })) : null, activeGroup.searchable ? (_jsx(PopoverMenuSearch, { value: query, onChange: (event) => setQuery(event.target.value), placeholder: (_f = activeGroup.searchPlaceholder) !== null && _f !== void 0 ? _f : `Search ${getGroupLabel(activeGroup).toLowerCase()}...`, "aria-label": (_g = activeGroup.searchPlaceholder) !== null && _g !== void 0 ? _g : `Search ${getGroupLabel(activeGroup).toLowerCase()}` })) : null, _jsx(PopoverMenuScrollArea, { maxHeight: maxHeight, children: _jsx(PopoverMenuSection, { children: filteredOptions.length ? (filteredOptions.map((option) => (_jsx(
|
|
75
|
+
return (_jsxs(Popover, { align: align, contentClassName: cn("w-[360px]", contentClassName), defaultOpen: defaultOpen, modal: modal, onOpenChange: handleOpenChange, open: open, side: side, surface: "menu", tabBehavior: "close-at-boundary", trigger: trigger !== null && trigger !== void 0 ? trigger : defaultTrigger, title: resolvedTitle, "aria-label": (_e = (_d = ariaLabel !== null && ariaLabel !== void 0 ? ariaLabel : getText(resolvedTitle)) !== null && _d !== void 0 ? _d : getText(triggerLabel)) !== null && _e !== void 0 ? _e : "Filters", children: [_jsx(PopoverMenuView, { className: cn("-mx-1 -mt-1 px-1 pt-1", viewClassName), direction: transitionDirection, viewKey: activeGroup ? `group-${activeGroup.id}` : "filter-groups", children: activeGroup ? (_jsxs(_Fragment, { children: [!directGroup ? (_jsx("div", { className: "-mx-1 -mt-1 border-b p-1", children: _jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: cn("w-full justify-start", popoverMenuItemFocusClassName), onClick: showFilterGroups, children: [_jsx(ChevronLeft, { className: "h-4 w-4" }), activeGroup.label] }) })) : null, activeGroup.searchable ? (_jsx(PopoverMenuSearch, { value: query, onChange: (event) => setQuery(event.target.value), placeholder: (_f = activeGroup.searchPlaceholder) !== null && _f !== void 0 ? _f : `Search ${getGroupLabel(activeGroup).toLowerCase()}...`, "aria-label": (_g = activeGroup.searchPlaceholder) !== null && _g !== void 0 ? _g : `Search ${getGroupLabel(activeGroup).toLowerCase()}` })) : null, _jsx(PopoverMenuScrollArea, { maxHeight: maxHeight, children: _jsx(PopoverMenuSection, { children: filteredOptions.length ? (filteredOptions.map((option) => (_jsx(PopoverMenuCheckboxItem, { "aria-label": option.ariaLabel, checked: Boolean(option.selected), disabled: option.disabled, onChange: option.onSelect
|
|
76
|
+
? () => { var _a; return (_a = option.onSelect) === null || _a === void 0 ? void 0 : _a.call(option); }
|
|
77
|
+
: undefined, children: option.label }, option.id)))) : (_jsx(Typography, { as: "p", variant: "caption", className: "px-2 py-2", children: (_h = activeGroup.emptyLabel) !== null && _h !== void 0 ? _h : emptyLabel })) }) })] })) : (_jsx(PopoverMenuScrollArea, { maxHeight: maxHeight, children: _jsxs(PopoverMenuGroup, { children: [!resolvedTitle ? (_jsx(PopoverMenuGroupLabel, { children: groupListLabel })) : null, _jsx(PopoverMenuSection, { children: groups.map((group) => {
|
|
76
78
|
var _a;
|
|
77
79
|
const groupSelectedCount = getGroupSelectedCount(group);
|
|
78
80
|
const selectionLabel = (_a = group.selectionLabel) !== null && _a !== void 0 ? _a : getFilterSelectionLabel(groupSelectedCount);
|
package/dist/multi-select.js
CHANGED
|
@@ -6,9 +6,11 @@ import { XIcon } from "./animated-icons/x";
|
|
|
6
6
|
import { Badge } from "./badge";
|
|
7
7
|
import { Field, FieldDescription, FieldError, FieldLabel } from "./field";
|
|
8
8
|
import { Checkbox } from "./checkbox";
|
|
9
|
-
import { InputControl } from "./input-control";
|
|
10
9
|
import { cn } from "./lib/utils";
|
|
10
|
+
import { Popover } from "./popover";
|
|
11
|
+
import { PopoverMenuSearch } from "./popover-menu";
|
|
11
12
|
import { Typography } from "./typography";
|
|
13
|
+
import { SearchHighlight } from "./use-search-highlight";
|
|
12
14
|
function MultiSelect({ label, options, value, onValueChange, placeholder = "Select options", description, error, invalid = false, disabled = false, hideLabel = false, searchable = false, searchPlaceholder = "Search...", maxVisibleTags = 2, className, triggerClassName, labelClassName, descriptionClassName, errorClassName, id, }) {
|
|
13
15
|
const generatedId = React.useId();
|
|
14
16
|
const selectId = id !== null && id !== void 0 ? id : `multi-select-${generatedId}`;
|
|
@@ -20,7 +22,6 @@ function MultiSelect({ label, options, value, onValueChange, placeholder = "Sele
|
|
|
20
22
|
const isInvalid = invalid || Boolean(error);
|
|
21
23
|
const [open, setOpen] = React.useState(false);
|
|
22
24
|
const [searchQuery, setSearchQuery] = React.useState("");
|
|
23
|
-
const containerRef = React.useRef(null);
|
|
24
25
|
const triggerRef = React.useRef(null);
|
|
25
26
|
const searchInputRef = React.useRef(null);
|
|
26
27
|
const listboxRef = React.useRef(null);
|
|
@@ -67,6 +68,14 @@ function MultiSelect({ label, options, value, onValueChange, placeholder = "Sele
|
|
|
67
68
|
const removeOption = React.useCallback((optionValue) => {
|
|
68
69
|
onValueChange(value.filter((v) => v !== optionValue));
|
|
69
70
|
}, [value, onValueChange]);
|
|
71
|
+
const handleOpenChange = React.useCallback((nextOpen) => {
|
|
72
|
+
if (disabled)
|
|
73
|
+
return;
|
|
74
|
+
if (nextOpen) {
|
|
75
|
+
openFocusTargetRef.current = searchable ? "search" : "first";
|
|
76
|
+
}
|
|
77
|
+
setOpen(nextOpen);
|
|
78
|
+
}, [disabled, searchable]);
|
|
70
79
|
React.useEffect(() => {
|
|
71
80
|
if (!open) {
|
|
72
81
|
setSearchQuery("");
|
|
@@ -82,18 +91,6 @@ function MultiSelect({ label, options, value, onValueChange, placeholder = "Sele
|
|
|
82
91
|
});
|
|
83
92
|
return () => window.cancelAnimationFrame(frame);
|
|
84
93
|
}, [focusBoundaryOption, open, searchable]);
|
|
85
|
-
React.useEffect(() => {
|
|
86
|
-
const handleClickOutside = (event) => {
|
|
87
|
-
if (containerRef.current &&
|
|
88
|
-
!containerRef.current.contains(event.target)) {
|
|
89
|
-
setOpen(false);
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
if (open) {
|
|
93
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
94
|
-
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
95
|
-
}
|
|
96
|
-
}, [open]);
|
|
97
94
|
const handleTriggerKeyDown = React.useCallback((event) => {
|
|
98
95
|
if (disabled)
|
|
99
96
|
return;
|
|
@@ -165,25 +162,16 @@ function MultiSelect({ label, options, value, onValueChange, placeholder = "Sele
|
|
|
165
162
|
var _a;
|
|
166
163
|
event.preventDefault();
|
|
167
164
|
(_a = triggerRef.current) === null || _a === void 0 ? void 0 : _a.focus();
|
|
168
|
-
}, children: label }), _jsxs("
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
removeOption(opt.value);
|
|
180
|
-
}, "aria-label": `Remove ${opt.label}`, children: _jsx(XIcon, { size: 12 }) })] }, opt.value))), selectedLabels.length > maxVisibleTags ? (_jsxs(Badge, { variant: "secondary", className: "shrink-0 text-sm font-medium", title: selectedLabels
|
|
181
|
-
.slice(maxVisibleTags)
|
|
182
|
-
.map((o) => o.label)
|
|
183
|
-
.join(", "), children: ["+", selectedLabels.length - maxVisibleTags] })) : null] })) : (_jsx("span", { className: "text-muted-foreground", children: placeholder })), _jsx(ChevronDownIcon, { "aria-hidden": "true", size: 16, className: cn("pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 shrink-0 text-muted-foreground transition-transform duration-150", open && "rotate-180") })] }), open ? (_jsxs("div", { className: "absolute z-50 mt-1 w-full rounded-overlay border bg-popover shadow-md", children: [searchable ? (_jsx("div", { className: "border-b p-1", children: _jsx(InputControl, { ref: searchInputRef, value: searchQuery, onChange: (e) => setSearchQuery(e.target.value), placeholder: searchPlaceholder, className: "h-control border-0 bg-transparent px-2 py-0 text-sm shadow-none", onKeyDown: handleSearchKeyDown }) })) : null, _jsx("div", { ref: listboxRef, id: listboxId, role: "listbox", "aria-label": typeof label === "string" ? label : "Options", "aria-multiselectable": "true", className: "max-h-64 overflow-y-auto p-1", onKeyDown: handleListboxKeyDown, children: filteredOptions.length > 0 ? (filteredOptions.map((option) => {
|
|
184
|
-
const isSelected = value.includes(option.value);
|
|
185
|
-
return (_jsxs("button", { type: "button", role: "option", "aria-selected": isSelected, disabled: option.disabled, tabIndex: -1, onClick: () => toggleOption(option.value), className: cn("flex min-h-8 w-full cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50", isSelected &&
|
|
186
|
-
"bg-primary/10 text-primary hover:bg-primary/20 hover:text-primary focus:bg-primary/20 focus:text-primary"), children: [_jsx(Checkbox, { checked: isSelected, disabled: option.disabled, tabIndex: -1, readOnly: true, "aria-hidden": "true", className: "mt-0.5" }), _jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx("span", { children: option.label }), option.description ? (_jsx(Typography, { as: "span", variant: "caption", children: option.description })) : null] })] }, option.value));
|
|
187
|
-
})) : (_jsx("div", { className: "px-2.5 py-1.5 text-center text-sm text-muted-foreground", children: "No options found." })) })] })) : null] }), showDescription ? (_jsx(FieldDescription, { id: descriptionId, className: descriptionClassName, children: description })) : null, _jsx(FieldError, { id: errorId, className: errorClassName, children: error })] }));
|
|
165
|
+
}, children: label }), _jsxs(Popover, { open: disabled ? false : open, onOpenChange: handleOpenChange, align: "start", sideOffset: 4, collisionPadding: 16, surface: "menu", tabBehavior: "close", contentClassName: "flex max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] min-w-[var(--radix-popover-trigger-width)] flex-col overflow-hidden", onOpenAutoFocus: (event) => event.preventDefault(), trigger: _jsxs("div", { ref: triggerRef, role: "combobox", id: selectId, tabIndex: disabled ? -1 : 0, onKeyDown: handleTriggerKeyDown, "aria-expanded": open, "aria-controls": listboxId, "aria-haspopup": "listbox", "aria-invalid": isInvalid ? "true" : undefined, "aria-disabled": disabled ? "true" : undefined, "aria-labelledby": labelId, "aria-describedby": [descriptionId, errorId].filter(Boolean).join(" ") || undefined, className: cn("relative flex h-control w-full cursor-pointer items-center gap-1.5 overflow-hidden rounded-control border border-input bg-background px-2.5 py-1.5 pr-8 text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", isInvalid ? "border-destructive" : "border-input", disabled && "cursor-not-allowed opacity-50", triggerClassName), children: [selectedLabels.length > 0 ? (_jsxs(_Fragment, { children: [selectedLabels.slice(0, maxVisibleTags).map((opt) => (_jsxs(Badge, { variant: "secondary", className: "max-w-[10rem] shrink-0 justify-start gap-1 overflow-visible text-sm font-medium", children: [_jsx("span", { className: "min-w-0 truncate", children: opt.label }), _jsx("button", { type: "button", className: "inline-flex cursor-pointer items-center justify-center rounded-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", onClick: (event) => {
|
|
166
|
+
event.stopPropagation();
|
|
167
|
+
removeOption(opt.value);
|
|
168
|
+
}, "aria-label": `Remove ${opt.label}`, children: _jsx(XIcon, { size: 12 }) })] }, opt.value))), selectedLabels.length > maxVisibleTags ? (_jsxs(Badge, { variant: "secondary", className: "shrink-0 text-sm font-medium", title: selectedLabels
|
|
169
|
+
.slice(maxVisibleTags)
|
|
170
|
+
.map((option) => option.label)
|
|
171
|
+
.join(", "), children: ["+", selectedLabels.length - maxVisibleTags] })) : null] })) : (_jsx("span", { className: "text-muted-foreground", children: placeholder })), _jsx(ChevronDownIcon, { "aria-hidden": "true", size: 16, className: cn("pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 shrink-0 text-muted-foreground transition-transform duration-150", open && "rotate-180") })] }), children: [searchable ? (_jsx(PopoverMenuSearch, { ref: searchInputRef, value: searchQuery, onChange: (event) => setSearchQuery(event.target.value), onKeyDown: handleSearchKeyDown, placeholder: searchPlaceholder, "aria-label": searchPlaceholder, className: "h-control border-0 bg-transparent px-2 py-0 text-sm shadow-none" })) : null, _jsx("div", { ref: listboxRef, id: listboxId, role: "listbox", "aria-label": typeof label === "string" ? label : "Options", "aria-multiselectable": "true", className: "max-h-64 overflow-y-auto", onKeyDown: handleListboxKeyDown, children: filteredOptions.length > 0 ? (filteredOptions.map((option) => {
|
|
172
|
+
const isSelected = value.includes(option.value);
|
|
173
|
+
return (_jsxs("button", { type: "button", role: "option", "aria-selected": isSelected, disabled: option.disabled, tabIndex: -1, onClick: () => toggleOption(option.value), className: cn("flex min-h-8 w-full cursor-pointer items-center gap-1.5 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50", isSelected &&
|
|
174
|
+
"bg-primary/10 text-primary hover:bg-primary/20 hover:text-primary focus:bg-primary/20 focus:text-primary"), children: [_jsx(Checkbox, { checked: isSelected, disabled: option.disabled, tabIndex: -1, readOnly: true, "aria-hidden": "true", className: "mt-0.5" }), _jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(SearchHighlight, { text: option.label, query: searchQuery }), option.description ? (_jsx(Typography, { as: "span", variant: "caption", children: _jsx(SearchHighlight, { text: option.description, query: searchQuery }) })) : null] })] }, option.value));
|
|
175
|
+
})) : (_jsx("div", { className: "px-2.5 py-1.5 text-center text-sm text-muted-foreground", children: "No options found." })) })] }), showDescription ? (_jsx(FieldDescription, { id: descriptionId, className: descriptionClassName, children: description })) : null, _jsx(FieldError, { id: errorId, className: errorClassName, children: error })] }));
|
|
188
176
|
}
|
|
189
177
|
export { MultiSelect };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
type PopoverTabBehavior = "contain" | "close" | "close-at-boundary";
|
|
3
|
+
interface UsePopoverTabExitOptions<TTrigger extends HTMLElement, TContent extends HTMLElement> {
|
|
4
|
+
behavior: PopoverTabBehavior;
|
|
5
|
+
triggerRef: React.RefObject<TTrigger>;
|
|
6
|
+
contentRef: React.RefObject<TContent>;
|
|
7
|
+
onExit: () => void;
|
|
8
|
+
}
|
|
9
|
+
declare function usePopoverTabExit<TTrigger extends HTMLElement, TContent extends HTMLElement>({ behavior, triggerRef, contentRef, onExit, }: UsePopoverTabExitOptions<TTrigger, TContent>): {
|
|
10
|
+
handleCloseAutoFocus: (event: Event) => void;
|
|
11
|
+
handleTabKeyDown: (event: React.KeyboardEvent<HTMLElement>) => boolean;
|
|
12
|
+
};
|
|
13
|
+
export { usePopoverTabExit };
|
|
14
|
+
export type { PopoverTabBehavior };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
const tabbableSelector = 'a[href], button:not(:disabled), input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';
|
|
4
|
+
function usePopoverTabExit({ behavior, triggerRef, contentRef, onExit, }) {
|
|
5
|
+
const tabExitPendingRef = React.useRef(false);
|
|
6
|
+
const focusFrameRef = React.useRef(null);
|
|
7
|
+
React.useEffect(() => () => {
|
|
8
|
+
if (focusFrameRef.current !== null) {
|
|
9
|
+
window.cancelAnimationFrame(focusFrameRef.current);
|
|
10
|
+
}
|
|
11
|
+
}, []);
|
|
12
|
+
const handleTabKeyDown = React.useCallback((event) => {
|
|
13
|
+
if (behavior === "contain" ||
|
|
14
|
+
event.defaultPrevented ||
|
|
15
|
+
event.key !== "Tab") {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const trigger = triggerRef.current;
|
|
19
|
+
const content = contentRef.current;
|
|
20
|
+
if (!trigger) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if (behavior === "close-at-boundary" && content) {
|
|
24
|
+
const contentControls = Array.from(content.querySelectorAll(tabbableSelector)).filter((candidate) => !candidate.closest('[hidden], [aria-hidden="true"], [inert]'));
|
|
25
|
+
const boundaryControl = event.shiftKey
|
|
26
|
+
? contentControls[0]
|
|
27
|
+
: contentControls[contentControls.length - 1];
|
|
28
|
+
if (document.activeElement !== boundaryControl) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const candidates = Array.from(document.querySelectorAll(tabbableSelector)).filter((candidate) => {
|
|
33
|
+
const hiddenAncestor = candidate.closest('[hidden], [aria-hidden="true"], [inert]');
|
|
34
|
+
const isHidden = hiddenAncestor &&
|
|
35
|
+
!hiddenAncestor.hasAttribute("data-aria-hidden");
|
|
36
|
+
return (!candidate.hasAttribute("data-radix-focus-guard") &&
|
|
37
|
+
!isHidden &&
|
|
38
|
+
(candidate === trigger ||
|
|
39
|
+
(!trigger.contains(candidate) && !(content === null || content === void 0 ? void 0 : content.contains(candidate)))));
|
|
40
|
+
});
|
|
41
|
+
const triggerIndex = candidates.indexOf(trigger);
|
|
42
|
+
const target = triggerIndex >= 0
|
|
43
|
+
? candidates[triggerIndex + (event.shiftKey ? -1 : 1)]
|
|
44
|
+
: undefined;
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
event.stopPropagation();
|
|
47
|
+
tabExitPendingRef.current = true;
|
|
48
|
+
onExit();
|
|
49
|
+
focusFrameRef.current = window.requestAnimationFrame(() => {
|
|
50
|
+
(target !== null && target !== void 0 ? target : trigger).focus({ preventScroll: true });
|
|
51
|
+
focusFrameRef.current = null;
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
}, [behavior, contentRef, onExit, triggerRef]);
|
|
55
|
+
const handleCloseAutoFocus = React.useCallback((event) => {
|
|
56
|
+
if (!tabExitPendingRef.current) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
tabExitPendingRef.current = false;
|
|
61
|
+
}, []);
|
|
62
|
+
return { handleCloseAutoFocus, handleTabKeyDown };
|
|
63
|
+
}
|
|
64
|
+
export { usePopoverTabExit };
|
package/dist/popover-menu.js
CHANGED
|
@@ -27,7 +27,7 @@ function PopoverMenuItem({ className, children, active, type = "button", ...prop
|
|
|
27
27
|
function PopoverMenuCheckboxItem({ className, children, checked = false, description, disabled, onChange, readOnly, ...props }) {
|
|
28
28
|
return (_jsxs("label", { className: cn("flex h-auto min-h-8 w-full cursor-pointer items-start gap-1.5 rounded-sm px-2 py-2 text-left text-popover-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-within:bg-accent focus-within:text-accent-foreground", popoverMenuItemFocusWithinClassName, checked &&
|
|
29
29
|
"bg-primary/10 text-primary hover:bg-primary/20 hover:text-primary focus-within:bg-primary/20 focus-within:text-primary", disabled &&
|
|
30
|
-
"cursor-not-allowed opacity-50 hover:bg-transparent hover:text-inherit", className), children: [_jsx(Checkbox, { ...props, checked: checked, disabled: disabled, readOnly: readOnly !== null && readOnly !== void 0 ? readOnly : !onChange, onChange: onChange, className: "mt-0.5" }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx(Typography, { as: "span", variant: "bodySm", className: "block", children: children }), description ? (_jsx(Typography, { as: "span", variant: "caption", className: "mt-0.5 block", children: description })) : null] })] }));
|
|
30
|
+
"cursor-not-allowed opacity-50 hover:bg-transparent hover:text-inherit", className), children: [_jsx(Checkbox, { ...props, "data-slot": "popover-menu-item", checked: checked, disabled: disabled, readOnly: readOnly !== null && readOnly !== void 0 ? readOnly : !onChange, onChange: onChange, className: "mt-0.5" }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx(Typography, { as: "span", variant: "bodySm", className: "block", children: children }), description ? (_jsx(Typography, { as: "span", variant: "caption", className: "mt-0.5 block", children: description })) : null] })] }));
|
|
31
31
|
}
|
|
32
32
|
function PopoverMenuDivider({ className, ...props }) {
|
|
33
33
|
return _jsx("div", { className: cn("-mx-1 my-1 h-px bg-border", className), ...props });
|
|
@@ -49,7 +49,7 @@ function PopoverMenuGroupLabel({ className, children, ...props }) {
|
|
|
49
49
|
}
|
|
50
50
|
const PopoverMenuSearch = React.forwardRef(({ "aria-label": ariaLabel, className, containerClassName, label, placeholder, ...props }, ref) => {
|
|
51
51
|
var _a, _b;
|
|
52
|
-
return (_jsx("div", { className: cn("relative -mx-1 -mt-1 mb-1 px-
|
|
52
|
+
return (_jsx("div", { className: cn("relative -mx-1 -mt-1 mb-1 px-1 py-1 after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-border after:content-['']", containerClassName), children: _jsx(Input, { ref: ref, "data-popover-menu-search": "true", label: (_b = (_a = label !== null && label !== void 0 ? label : ariaLabel) !== null && _a !== void 0 ? _a : placeholder) !== null && _b !== void 0 ? _b : "Search", hideLabel: true, className: "gap-0", inputClassName: className, "aria-label": ariaLabel, placeholder: placeholder, ...props }) }));
|
|
53
53
|
});
|
|
54
54
|
PopoverMenuSearch.displayName = "PopoverMenuSearch";
|
|
55
55
|
function PopoverMenuScrollArea({ className, children, maxHeight = "max-h-64", ...props }) {
|
|
@@ -62,6 +62,8 @@ function PopoverMenuView({ children, className, direction = "forward", viewKey =
|
|
|
62
62
|
key: viewKey,
|
|
63
63
|
children,
|
|
64
64
|
});
|
|
65
|
+
const activeViewElementRef = React.useRef(null);
|
|
66
|
+
const previousViewKeyRef = React.useRef(viewKey);
|
|
65
67
|
const exitTimeoutRef = React.useRef(null);
|
|
66
68
|
if (Object.is(viewKey, activeViewKey)) {
|
|
67
69
|
activeViewRef.current = { key: viewKey, children };
|
|
@@ -99,12 +101,32 @@ function PopoverMenuView({ children, className, direction = "forward", viewKey =
|
|
|
99
101
|
}
|
|
100
102
|
};
|
|
101
103
|
}, [direction, viewKey]);
|
|
104
|
+
useIsomorphicLayoutEffect(() => {
|
|
105
|
+
var _a;
|
|
106
|
+
if (Object.is(previousViewKeyRef.current, viewKey)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
previousViewKeyRef.current = viewKey;
|
|
110
|
+
const firstControl = (_a = activeViewElementRef.current) === null || _a === void 0 ? void 0 : _a.querySelector('button:not(:disabled), input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])');
|
|
111
|
+
firstControl === null || firstControl === void 0 ? void 0 : firstControl.focus({ preventScroll: true });
|
|
112
|
+
}, [viewKey]);
|
|
102
113
|
const activeDirection = exitingView === null || exitingView === void 0 ? void 0 : exitingView.direction;
|
|
103
114
|
const shouldAnimateActive = Boolean(activeDirection);
|
|
104
|
-
|
|
115
|
+
const setExitingViewElement = React.useCallback((element) => {
|
|
116
|
+
if (!element) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
element.inert = true;
|
|
120
|
+
element
|
|
121
|
+
.querySelectorAll('a[href], button, input, select, textarea, [contenteditable="true"], [tabindex]')
|
|
122
|
+
.forEach((control) => {
|
|
123
|
+
control.tabIndex = -1;
|
|
124
|
+
});
|
|
125
|
+
}, []);
|
|
126
|
+
return (_jsxs("div", { className: cn("grid min-w-0 overflow-hidden", className), ...props, children: [exitingView ? (_jsx("div", { ref: setExitingViewElement, "aria-hidden": "true", inert: "", "data-popover-menu-view": "exiting", className: cn("pointer-events-none col-start-1 row-start-1 min-w-0", exitingView.direction !== "none" &&
|
|
105
127
|
"motion-safe:animate-out motion-safe:fade-out-0 motion-safe:duration-300 motion-reduce:animate-none", exitingView.direction === "forward" &&
|
|
106
128
|
"motion-safe:slide-out-to-left-full", exitingView.direction === "backward" &&
|
|
107
|
-
"motion-safe:slide-out-to-right-full"), children: exitingView.children }, `leaving-${exitingView.key}`)) : null, _jsx("div", { className: cn("col-start-1 row-start-1 min-w-0", shouldAnimateActive &&
|
|
129
|
+
"motion-safe:slide-out-to-right-full"), children: exitingView.children }, `leaving-${exitingView.key}`)) : null, _jsx("div", { ref: activeViewElementRef, "data-popover-menu-view": "active", className: cn("col-start-1 row-start-1 min-w-0", shouldAnimateActive &&
|
|
108
130
|
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300 motion-reduce:animate-none", activeDirection === "forward" &&
|
|
109
131
|
"motion-safe:slide-in-from-right-full", activeDirection === "backward" &&
|
|
110
132
|
"motion-safe:slide-in-from-left-full"), children: children }, viewKey)] }));
|
package/dist/popover.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
3
|
+
import { type PopoverTabBehavior } from "./popover-focus";
|
|
3
4
|
import { type TooltipProps } from "./tooltip";
|
|
4
5
|
import { Typography } from "./typography";
|
|
5
6
|
type PopoverRootProps = React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Root>;
|
|
@@ -10,13 +11,15 @@ export interface PopoverProps extends Pick<PopoverRootProps, "defaultOpen" | "mo
|
|
|
10
11
|
/** Visible popover heading. Also labels the popover for assistive technology. */
|
|
11
12
|
title?: React.ReactNode;
|
|
12
13
|
surface?: "panel" | "menu";
|
|
14
|
+
/** Controls whether Tab stays within the popover or closes it and continues in the page Tab order. */
|
|
15
|
+
tabBehavior?: PopoverTabBehavior;
|
|
13
16
|
contentClassName?: string;
|
|
14
17
|
triggerTooltip?: React.ReactNode;
|
|
15
18
|
triggerTooltipSide?: TooltipProps["side"];
|
|
16
19
|
triggerTooltipSideOffset?: TooltipProps["sideOffset"];
|
|
17
20
|
triggerTooltipClassName?: TooltipProps["contentClassName"];
|
|
18
21
|
}
|
|
19
|
-
declare function Popover({ defaultOpen, modal, onOpenChange, open, trigger, children, title, surface, contentClassName, triggerTooltip, triggerTooltipSide, triggerTooltipSideOffset, triggerTooltipClassName, align, sideOffset, ...contentProps }: PopoverProps): import("react/jsx-runtime").JSX.Element;
|
|
22
|
+
declare function Popover({ defaultOpen, modal, onOpenChange, open, trigger, children, title, surface, tabBehavior, contentClassName, triggerTooltip, triggerTooltipSide, triggerTooltipSideOffset, triggerTooltipClassName, align, sideOffset, ...contentProps }: PopoverProps): import("react/jsx-runtime").JSX.Element;
|
|
20
23
|
export interface PopoverTitleProps extends Omit<React.ComponentPropsWithoutRef<typeof Typography>, "as" | "asChild" | "variant" | "color"> {
|
|
21
24
|
}
|
|
22
25
|
declare function PopoverTitle({ className, children, ...props }: PopoverTitleProps): import("react/jsx-runtime").JSX.Element;
|
package/dist/popover.js
CHANGED
|
@@ -3,13 +3,33 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { cn } from "./lib/utils";
|
|
5
5
|
import { PopoverContent, PopoverRoot, PopoverTrigger, } from "./popover-primitive";
|
|
6
|
+
import { usePopoverTabExit, } from "./popover-focus";
|
|
6
7
|
import { Tooltip } from "./tooltip";
|
|
7
8
|
import { Typography } from "./typography";
|
|
8
|
-
function Popover({ defaultOpen, modal, onOpenChange, open, trigger, children, title, surface = "panel", contentClassName, triggerTooltip, triggerTooltipSide, triggerTooltipSideOffset, triggerTooltipClassName, align = "center", sideOffset = 4, ...contentProps }) {
|
|
9
|
+
function Popover({ defaultOpen, modal, onOpenChange, open, trigger, children, title, surface = "panel", tabBehavior = "contain", contentClassName, triggerTooltip, triggerTooltipSide, triggerTooltipSideOffset, triggerTooltipClassName, align = "center", sideOffset = 4, ...contentProps }) {
|
|
9
10
|
const generatedTitleId = React.useId();
|
|
10
11
|
const titleId = title ? `popover-title-${generatedTitleId}` : undefined;
|
|
11
|
-
const
|
|
12
|
+
const [internalOpen, setInternalOpen] = React.useState(defaultOpen !== null && defaultOpen !== void 0 ? defaultOpen : false);
|
|
13
|
+
const isControlled = open !== undefined;
|
|
14
|
+
const currentOpen = isControlled ? open : internalOpen;
|
|
15
|
+
const triggerRef = React.useRef(null);
|
|
16
|
+
const contentRef = React.useRef(null);
|
|
17
|
+
const setOpen = React.useCallback((nextOpen) => {
|
|
18
|
+
if (!isControlled) {
|
|
19
|
+
setInternalOpen(nextOpen);
|
|
20
|
+
}
|
|
21
|
+
onOpenChange === null || onOpenChange === void 0 ? void 0 : onOpenChange(nextOpen);
|
|
22
|
+
}, [isControlled, onOpenChange]);
|
|
23
|
+
const { handleCloseAutoFocus, handleTabKeyDown } = usePopoverTabExit({
|
|
24
|
+
behavior: tabBehavior,
|
|
25
|
+
triggerRef,
|
|
26
|
+
contentRef,
|
|
27
|
+
onExit: () => setOpen(false),
|
|
28
|
+
});
|
|
29
|
+
const triggerElement = (_jsx(PopoverTrigger, { ref: triggerRef, asChild: true, children: trigger }));
|
|
12
30
|
const contentOnKeyDown = contentProps.onKeyDown;
|
|
31
|
+
const contentOnKeyDownCapture = contentProps.onKeyDownCapture;
|
|
32
|
+
const contentOnCloseAutoFocus = contentProps.onCloseAutoFocus;
|
|
13
33
|
const handleContentKeyDown = React.useCallback((event) => {
|
|
14
34
|
contentOnKeyDown === null || contentOnKeyDown === void 0 ? void 0 : contentOnKeyDown(event);
|
|
15
35
|
if (event.defaultPrevented ||
|
|
@@ -51,7 +71,15 @@ function Popover({ defaultOpen, modal, onOpenChange, open, trigger, children, ti
|
|
|
51
71
|
event.preventDefault();
|
|
52
72
|
focusTarget.focus({ preventScroll: true });
|
|
53
73
|
}, [contentOnKeyDown]);
|
|
54
|
-
|
|
74
|
+
const handleContentKeyDownCapture = React.useCallback((event) => {
|
|
75
|
+
contentOnKeyDownCapture === null || contentOnKeyDownCapture === void 0 ? void 0 : contentOnKeyDownCapture(event);
|
|
76
|
+
handleTabKeyDown(event);
|
|
77
|
+
}, [contentOnKeyDownCapture, handleTabKeyDown]);
|
|
78
|
+
const handleContentCloseAutoFocus = React.useCallback((event) => {
|
|
79
|
+
contentOnCloseAutoFocus === null || contentOnCloseAutoFocus === void 0 ? void 0 : contentOnCloseAutoFocus(event);
|
|
80
|
+
handleCloseAutoFocus(event);
|
|
81
|
+
}, [contentOnCloseAutoFocus, handleCloseAutoFocus]);
|
|
82
|
+
return (_jsxs(PopoverRoot, { modal: modal, onOpenChange: setOpen, open: currentOpen, children: [triggerTooltip ? (_jsx(Tooltip, { content: triggerTooltip, side: triggerTooltipSide, sideOffset: triggerTooltipSideOffset, contentClassName: triggerTooltipClassName, children: triggerElement })) : (triggerElement), _jsxs(PopoverContent, { ref: contentRef, align: align, sideOffset: sideOffset, "aria-labelledby": titleId !== null && titleId !== void 0 ? titleId : contentProps["aria-labelledby"], className: cn(surface === "menu" && "overflow-hidden p-1", contentClassName), ...contentProps, onCloseAutoFocus: handleContentCloseAutoFocus, onKeyDown: handleContentKeyDown, onKeyDownCapture: handleContentKeyDownCapture, children: [title ? (_jsx(PopoverTitle, { id: titleId, className: cn(surface === "menu"
|
|
55
83
|
? "px-2 py-1.5"
|
|
56
84
|
: "-mx-4 -mt-4 mb-4 border-b px-4 py-3"), children: title })) : null, children] })] }));
|
|
57
85
|
}
|
package/dist/select.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as React from "react";
|
|
|
4
4
|
import { Field, FieldDescription, FieldError, FieldLabel } from "./field";
|
|
5
5
|
import { InputControl } from "./input-control";
|
|
6
6
|
import { cn } from "./lib/utils";
|
|
7
|
+
import { usePopoverTabExit } from "./popover-focus";
|
|
7
8
|
import { SearchHighlight } from "./use-search-highlight";
|
|
8
9
|
import { SelectContent, SelectGroup, SelectItem, SelectLabel, SelectRoot, SelectSeparator, SelectTrigger, SelectValue, } from "./select-primitive";
|
|
9
10
|
function groupOptions(options) {
|
|
@@ -42,6 +43,7 @@ function renderHighlightedLabel(label, query) {
|
|
|
42
43
|
function Select({ label, options, placeholder = "Select an option", description, error, invalid = false, hideLabel = false, searchable = false, searchPlaceholder = "Search options...", searchEmptyMessage = "No options found.", searchInputAriaLabel = "Search options", className, triggerClassName, contentClassName, labelClassName, descriptionClassName, errorClassName, id, disabled, open, onOpenChange, ...props }) {
|
|
43
44
|
const generatedId = React.useId();
|
|
44
45
|
const selectId = id !== null && id !== void 0 ? id : `select-${generatedId}`;
|
|
46
|
+
const triggerRef = React.useRef(null);
|
|
45
47
|
const searchInputRef = React.useRef(null);
|
|
46
48
|
const contentRef = React.useRef(null);
|
|
47
49
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
|
|
@@ -63,6 +65,22 @@ function Select({ label, options, placeholder = "Select an option", description,
|
|
|
63
65
|
: options;
|
|
64
66
|
const optionGroups = groupOptions(filteredOptions);
|
|
65
67
|
const hasVisibleOptions = filteredOptions.length > 0;
|
|
68
|
+
const handleOpenChange = React.useCallback((nextOpen) => {
|
|
69
|
+
if (open === undefined) {
|
|
70
|
+
setUncontrolledOpen(nextOpen);
|
|
71
|
+
}
|
|
72
|
+
if (!nextOpen) {
|
|
73
|
+
setSearchQuery("");
|
|
74
|
+
}
|
|
75
|
+
onOpenChange === null || onOpenChange === void 0 ? void 0 : onOpenChange(nextOpen);
|
|
76
|
+
}, [onOpenChange, open]);
|
|
77
|
+
const closeForTab = React.useCallback(() => handleOpenChange(false), [handleOpenChange]);
|
|
78
|
+
const { handleCloseAutoFocus, handleTabKeyDown } = usePopoverTabExit({
|
|
79
|
+
behavior: "close",
|
|
80
|
+
triggerRef,
|
|
81
|
+
contentRef,
|
|
82
|
+
onExit: closeForTab,
|
|
83
|
+
});
|
|
66
84
|
const focusSearchInput = React.useCallback(() => {
|
|
67
85
|
const input = searchInputRef.current;
|
|
68
86
|
if (!input || document.activeElement === input) {
|
|
@@ -82,6 +100,9 @@ function Select({ label, options, placeholder = "Select an option", description,
|
|
|
82
100
|
option === null || option === void 0 ? void 0 : option.focus({ preventScroll: true });
|
|
83
101
|
}, [getEnabledOptionElements]);
|
|
84
102
|
const handleContentKeyDownCapture = React.useCallback((event) => {
|
|
103
|
+
if (handleTabKeyDown(event)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
85
106
|
if (event.key !== "ArrowUp") {
|
|
86
107
|
return;
|
|
87
108
|
}
|
|
@@ -94,7 +115,12 @@ function Select({ label, options, placeholder = "Select an option", description,
|
|
|
94
115
|
if (searchable) {
|
|
95
116
|
focusSearchInput();
|
|
96
117
|
}
|
|
97
|
-
}, [
|
|
118
|
+
}, [
|
|
119
|
+
focusSearchInput,
|
|
120
|
+
getEnabledOptionElements,
|
|
121
|
+
handleTabKeyDown,
|
|
122
|
+
searchable,
|
|
123
|
+
]);
|
|
98
124
|
React.useEffect(() => {
|
|
99
125
|
if (!isOpen) {
|
|
100
126
|
setSearchQuery("");
|
|
@@ -127,16 +153,7 @@ function Select({ label, options, placeholder = "Select an option", description,
|
|
|
127
153
|
searchable,
|
|
128
154
|
searchQuery,
|
|
129
155
|
]);
|
|
130
|
-
|
|
131
|
-
if (open === undefined) {
|
|
132
|
-
setUncontrolledOpen(nextOpen);
|
|
133
|
-
}
|
|
134
|
-
if (!nextOpen) {
|
|
135
|
-
setSearchQuery("");
|
|
136
|
-
}
|
|
137
|
-
onOpenChange === null || onOpenChange === void 0 ? void 0 : onOpenChange(nextOpen);
|
|
138
|
-
}, [onOpenChange, open]);
|
|
139
|
-
return (_jsxs(Field, { className: className, "data-disabled": disabled ? "true" : undefined, "data-invalid": isInvalid ? "true" : undefined, children: [_jsx(FieldLabel, { htmlFor: selectId, className: cn(hideLabel && "sr-only", labelClassName), children: label }), _jsxs(SelectRoot, { disabled: disabled, open: open, onOpenChange: handleOpenChange, ...props, children: [_jsx(SelectTrigger, { id: selectId, className: triggerClassName, "aria-invalid": isInvalid ? "true" : undefined, "aria-describedby": [descriptionId, errorId].filter(Boolean).join(" ") || undefined, children: _jsx(SelectValue, { placeholder: placeholder }) }), _jsx(SelectContent, { ref: contentRef, className: contentClassName, onKeyDownCapture: handleContentKeyDownCapture, header: searchable ? (_jsx(InputControl, { ref: searchInputRef, value: searchQuery, onChange: (event) => setSearchQuery(event.target.value), placeholder: searchPlaceholder, "aria-label": searchInputAriaLabel, autoComplete: "off", className: "h-control border-0 bg-transparent px-2 py-0 text-sm shadow-none", onKeyDown: (event) => {
|
|
156
|
+
return (_jsxs(Field, { className: className, "data-disabled": disabled ? "true" : undefined, "data-invalid": isInvalid ? "true" : undefined, children: [_jsx(FieldLabel, { htmlFor: selectId, className: cn(hideLabel && "sr-only", labelClassName), children: label }), _jsxs(SelectRoot, { disabled: disabled, open: isOpen, onOpenChange: handleOpenChange, ...props, children: [_jsx(SelectTrigger, { ref: triggerRef, id: selectId, className: triggerClassName, "aria-invalid": isInvalid ? "true" : undefined, "aria-describedby": [descriptionId, errorId].filter(Boolean).join(" ") || undefined, children: _jsx(SelectValue, { placeholder: placeholder }) }), _jsx(SelectContent, { ref: contentRef, className: contentClassName, onCloseAutoFocus: handleCloseAutoFocus, onKeyDownCapture: handleContentKeyDownCapture, header: searchable ? (_jsx(InputControl, { ref: searchInputRef, value: searchQuery, onChange: (event) => setSearchQuery(event.target.value), placeholder: searchPlaceholder, "aria-label": searchInputAriaLabel, autoComplete: "off", className: "h-control border-0 bg-transparent px-2 py-0 text-sm shadow-none", onKeyDown: (event) => {
|
|
140
157
|
if (event.key === "ArrowDown" ||
|
|
141
158
|
event.key === "ArrowUp") {
|
|
142
159
|
event.preventDefault();
|