@crisp-ui-kit/crisp 0.22.0 → 0.23.1
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/cjs/components/dialog/dialog.js +6 -2
- package/dist/cjs/components/drawer/drawer.js +3 -2
- package/dist/cjs/components/menu/menu.js +12 -1
- package/dist/cjs/components/popover/popover.js +12 -1
- package/dist/cjs/components/tooltip/tooltip.js +20 -2
- package/dist/cjs/core/dialog-description.js +22 -0
- package/dist/cjs/core/index.js +3 -1
- package/dist/cjs/product/command/command.js +3 -2
- package/dist/cjs/product/command/command.recipe.js +6 -0
- package/dist/command.css +6 -0
- package/dist/esm/components/dialog/dialog.js +6 -2
- package/dist/esm/components/drawer/drawer.js +3 -2
- package/dist/esm/components/menu/menu.js +12 -1
- package/dist/esm/components/popover/popover.js +12 -1
- package/dist/esm/components/tooltip/tooltip.js +20 -2
- package/dist/esm/core/dialog-description.js +19 -0
- package/dist/esm/core/index.js +1 -0
- package/dist/esm/product/command/command.js +3 -2
- package/dist/esm/product/command/command.recipe.js +6 -0
- package/dist/styles.css +6 -0
- package/dist/types/components/dialog/dialog.d.ts +12 -2
- package/dist/types/components/drawer/drawer.d.ts +6 -2
- package/dist/types/components/tooltip/tooltip.d.ts +6 -1
- package/dist/types/core/dialog-description.d.ts +18 -0
- package/dist/types/core/index.d.ts +1 -0
- package/dist/types/product/command/command.d.ts +6 -2
- package/package.json +1 -1
|
@@ -41,6 +41,7 @@ const dialog = __importStar(require("@zag-js/dialog"));
|
|
|
41
41
|
const react_1 = require("@zag-js/react");
|
|
42
42
|
const lucide_react_1 = require("lucide-react");
|
|
43
43
|
const React = __importStar(require("react"));
|
|
44
|
+
const core_1 = require("../../core");
|
|
44
45
|
const dialog_recipe_1 = require("./dialog.recipe");
|
|
45
46
|
// A globally unique id per mounted instance (see command.tsx for the rationale —
|
|
46
47
|
// the machine defers its dismissal listeners a frame, so tree-relative useId
|
|
@@ -59,7 +60,7 @@ function useDialogInstanceId() {
|
|
|
59
60
|
* Escape / outside-click dismissal) with the measured Attio modal surface.
|
|
60
61
|
* Compose the body with `DialogBody` and actions with `DialogFooter`.
|
|
61
62
|
*/
|
|
62
|
-
function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-label": ariaLabel, closeLabel = "Close", children, }) {
|
|
63
|
+
function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-label": ariaLabel, closeLabel = "Close", children, className, style, "aria-describedby": describedBy, ...rest }) {
|
|
63
64
|
const id = useDialogInstanceId();
|
|
64
65
|
const service = (0, react_1.useMachine)(dialog.machine, {
|
|
65
66
|
id,
|
|
@@ -75,7 +76,10 @@ function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-label": ar
|
|
|
75
76
|
const api = dialog.connect(service, react_1.normalizeProps);
|
|
76
77
|
if (!api.open)
|
|
77
78
|
return null;
|
|
78
|
-
return ((0, jsx_runtime_1.jsx)(react_1.Portal, { children: (0, jsx_runtime_1.jsxs)("div", { className: (0, dialog_recipe_1.dialogRecipe)(), children: [(0, jsx_runtime_1.jsx)("div", { ...api.getBackdropProps(), className: "dialog-backdrop" }), (0, jsx_runtime_1.jsx)("div", { ...api.getPositionerProps(), className: "dialog-positioner", children: (0, jsx_runtime_1.jsx)("div", { ...
|
|
79
|
+
return ((0, jsx_runtime_1.jsx)(react_1.Portal, { children: (0, jsx_runtime_1.jsxs)("div", { className: (0, dialog_recipe_1.dialogRecipe)(), children: [(0, jsx_runtime_1.jsx)("div", { ...api.getBackdropProps(), className: "dialog-backdrop" }), (0, jsx_runtime_1.jsx)("div", { ...api.getPositionerProps(), className: "dialog-positioner", children: (0, jsx_runtime_1.jsx)("div", { ...rest, ...(0, core_1.withDescribedBy)(api.getContentProps(), describedBy), className: (0, core_1.cx)("dialog-content", className), "data-crisp-dialog": id,
|
|
80
|
+
// `width` is the component's own knob, so it wins over a passed
|
|
81
|
+
// maxWidth; everything else a consumer sets survives
|
|
82
|
+
style: { ...style, maxWidth: `min(${width}px, 90vw)` }, children: (0, jsx_runtime_1.jsxs)("div", { className: "dialog-card", children: [title != null && ((0, jsx_runtime_1.jsxs)("div", { className: "dialog-header", children: [icon, (0, jsx_runtime_1.jsx)("span", { ...api.getTitleProps(), className: "dialog-title", children: title }), (0, jsx_runtime_1.jsx)("button", { type: "button", ...api.getCloseTriggerProps(), className: "dialog-close", "aria-label": closeLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] })), children] }) }) })] }) }));
|
|
79
83
|
}
|
|
80
84
|
Dialog.displayName = "Dialog";
|
|
81
85
|
function DialogBody({ children, className, }) {
|
|
@@ -41,6 +41,7 @@ const dialog = __importStar(require("@zag-js/dialog"));
|
|
|
41
41
|
const react_1 = require("@zag-js/react");
|
|
42
42
|
const lucide_react_1 = require("lucide-react");
|
|
43
43
|
const React = __importStar(require("react"));
|
|
44
|
+
const core_1 = require("../../core");
|
|
44
45
|
const drawer_recipe_1 = require("./drawer.recipe");
|
|
45
46
|
let instanceCounter = 0;
|
|
46
47
|
function useDrawerInstanceId() {
|
|
@@ -56,7 +57,7 @@ function useDrawerInstanceId() {
|
|
|
56
57
|
* Escape / outside-click dismissal) with a panel that slides in from the edge.
|
|
57
58
|
* Compose the body with `DrawerBody` and actions with `DrawerFooter`.
|
|
58
59
|
*/
|
|
59
|
-
function Drawer({ open, onOpenChange, side = "right", title, icon, "aria-label": ariaLabel, closeLabel = "Close", children, }) {
|
|
60
|
+
function Drawer({ open, onOpenChange, side = "right", title, icon, "aria-label": ariaLabel, closeLabel = "Close", children, className, "aria-describedby": describedBy, ...rest }) {
|
|
60
61
|
const id = useDrawerInstanceId();
|
|
61
62
|
const service = (0, react_1.useMachine)(dialog.machine, {
|
|
62
63
|
id,
|
|
@@ -69,7 +70,7 @@ function Drawer({ open, onOpenChange, side = "right", title, icon, "aria-label":
|
|
|
69
70
|
const api = dialog.connect(service, react_1.normalizeProps);
|
|
70
71
|
if (!api.open)
|
|
71
72
|
return null;
|
|
72
|
-
return ((0, jsx_runtime_1.jsx)(react_1.Portal, { children: (0, jsx_runtime_1.jsxs)("div", { className: (0, drawer_recipe_1.drawerRecipe)(), children: [(0, jsx_runtime_1.jsx)("div", { ...api.getBackdropProps(), className: "drawer-backdrop" }), (0, jsx_runtime_1.jsx)("div", { ...api.getPositionerProps(), className: "drawer-positioner", "data-side": side, children: (0, jsx_runtime_1.jsxs)("div", { ...api.getContentProps(), className: "drawer-content", children: [title != null && ((0, jsx_runtime_1.jsxs)("div", { className: "drawer-header", children: [icon, (0, jsx_runtime_1.jsx)("span", { ...api.getTitleProps(), className: "drawer-title", children: title }), (0, jsx_runtime_1.jsx)("button", { type: "button", ...api.getCloseTriggerProps(), className: "drawer-close", "aria-label": closeLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] })), children] }) })] }) }));
|
|
73
|
+
return ((0, jsx_runtime_1.jsx)(react_1.Portal, { children: (0, jsx_runtime_1.jsxs)("div", { className: (0, drawer_recipe_1.drawerRecipe)(), children: [(0, jsx_runtime_1.jsx)("div", { ...api.getBackdropProps(), className: "drawer-backdrop" }), (0, jsx_runtime_1.jsx)("div", { ...api.getPositionerProps(), className: "drawer-positioner", "data-side": side, children: (0, jsx_runtime_1.jsxs)("div", { ...rest, ...(0, core_1.withDescribedBy)(api.getContentProps(), describedBy), className: (0, core_1.cx)("drawer-content", className), children: [title != null && ((0, jsx_runtime_1.jsxs)("div", { className: "drawer-header", children: [icon, (0, jsx_runtime_1.jsx)("span", { ...api.getTitleProps(), className: "drawer-title", children: title }), (0, jsx_runtime_1.jsx)("button", { type: "button", ...api.getCloseTriggerProps(), className: "drawer-close", "aria-label": closeLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] })), children] }) })] }) }));
|
|
73
74
|
}
|
|
74
75
|
Drawer.displayName = "Drawer";
|
|
75
76
|
function DrawerBody({ children }) {
|
|
@@ -53,6 +53,13 @@ const MenuContext = React.createContext(null);
|
|
|
53
53
|
* closes on outside click. Flips up / right-aligns when space is tight.
|
|
54
54
|
*/
|
|
55
55
|
function Menu({ trigger, children, align = "start", open: openProp, defaultOpen, onOpenChange, className, "aria-label": ariaLabel, }) {
|
|
56
|
+
// The surface and the button that opens it were two unconnected nodes: the
|
|
57
|
+
// menu/dialog had no id, so nothing could point at it, and no accessible
|
|
58
|
+
// name, so a screen reader announced a bare "menu"/"dialog". Labelling the
|
|
59
|
+
// surface by its own trigger is the WAI menu-button recommendation, and it
|
|
60
|
+
// needs no invented copy (#225).
|
|
61
|
+
const surfaceId = React.useId();
|
|
62
|
+
const triggerId = trigger.props.id ?? `${surfaceId}-trigger`;
|
|
56
63
|
const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false);
|
|
57
64
|
const open = openProp ?? internalOpen;
|
|
58
65
|
const setOpen = React.useCallback((next) => {
|
|
@@ -110,14 +117,18 @@ function Menu({ trigger, children, align = "start", open: openProp, defaultOpen,
|
|
|
110
117
|
});
|
|
111
118
|
const triggerEl = React.cloneElement(trigger, {
|
|
112
119
|
ref: triggerRef,
|
|
120
|
+
id: triggerId,
|
|
113
121
|
"aria-haspopup": "menu",
|
|
114
122
|
"aria-expanded": open,
|
|
123
|
+
// only while the surface exists — a reference to a node that is not
|
|
124
|
+
// there is not a relationship
|
|
125
|
+
"aria-controls": open ? surfaceId : undefined,
|
|
115
126
|
onClick: (e) => {
|
|
116
127
|
trigger.props.onClick?.(e);
|
|
117
128
|
setOpen(!open);
|
|
118
129
|
},
|
|
119
130
|
});
|
|
120
|
-
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, menu_recipe_1.menuRecipe)({ className }), children: [triggerEl, open && ((0, jsx_runtime_1.jsx)(MenuContext.Provider, { value: { close }, children: (0, jsx_runtime_1.jsx)("div", { ref: contentRef, className: "menu-content", role: "menu", "aria-label": ariaLabel, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: children }) }))] }));
|
|
131
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, menu_recipe_1.menuRecipe)({ className }), children: [triggerEl, open && ((0, jsx_runtime_1.jsx)(MenuContext.Provider, { value: { close }, children: (0, jsx_runtime_1.jsx)("div", { ref: contentRef, className: "menu-content", role: "menu", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: children }) }))] }));
|
|
121
132
|
}
|
|
122
133
|
Menu.displayName = "Menu";
|
|
123
134
|
function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
|
|
@@ -44,6 +44,13 @@ const popover_recipe_1 = require("./popover.recipe");
|
|
|
44
44
|
* closes on outside pointer-down and Escape. Controlled or self-managed.
|
|
45
45
|
*/
|
|
46
46
|
function Popover({ trigger, children, align = "start", open: openProp, defaultOpen, onOpenChange, "aria-label": ariaLabel, className, }) {
|
|
47
|
+
// The surface and the button that opens it were two unconnected nodes: the
|
|
48
|
+
// menu/dialog had no id, so nothing could point at it, and no accessible
|
|
49
|
+
// name, so a screen reader announced a bare "menu"/"dialog". Labelling the
|
|
50
|
+
// surface by its own trigger is the WAI menu-button recommendation, and it
|
|
51
|
+
// needs no invented copy (#225).
|
|
52
|
+
const surfaceId = React.useId();
|
|
53
|
+
const triggerId = trigger.props.id ?? `${surfaceId}-trigger`;
|
|
47
54
|
const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false);
|
|
48
55
|
const open = openProp ?? internalOpen;
|
|
49
56
|
const rootRef = React.useRef(null);
|
|
@@ -87,14 +94,18 @@ function Popover({ trigger, children, align = "start", open: openProp, defaultOp
|
|
|
87
94
|
}, [open, setOpen]);
|
|
88
95
|
const triggerEl = React.cloneElement(trigger, {
|
|
89
96
|
ref: triggerRef,
|
|
97
|
+
id: triggerId,
|
|
90
98
|
"aria-haspopup": "dialog",
|
|
91
99
|
"aria-expanded": open,
|
|
100
|
+
// only while the surface exists — a reference to a node that is not
|
|
101
|
+
// there is not a relationship
|
|
102
|
+
"aria-controls": open ? surfaceId : undefined,
|
|
92
103
|
onClick: (e) => {
|
|
93
104
|
trigger.props.onClick?.(e);
|
|
94
105
|
setOpen(!open);
|
|
95
106
|
},
|
|
96
107
|
});
|
|
97
|
-
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, popover_recipe_1.popoverRecipe)({ className }), children: [triggerEl, open && ((0, jsx_runtime_1.jsx)("div", { className: "popover-content", role: "dialog", "aria-label": ariaLabel, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: (e) => {
|
|
108
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, popover_recipe_1.popoverRecipe)({ className }), children: [triggerEl, open && ((0, jsx_runtime_1.jsx)("div", { className: "popover-content", role: "dialog", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: (e) => {
|
|
98
109
|
if (e.key === "Escape") {
|
|
99
110
|
e.preventDefault();
|
|
100
111
|
setOpen(false);
|
|
@@ -45,6 +45,7 @@ const tooltip_recipe_1 = require("./tooltip.recipe");
|
|
|
45
45
|
* above the trigger (flipping below near the top edge).
|
|
46
46
|
*/
|
|
47
47
|
function Tooltip({ content, children, side = "top", delay = 350, open: openProp, defaultOpen, onOpenChange, }) {
|
|
48
|
+
const tipId = React.useId();
|
|
48
49
|
const triggerRef = React.useRef(null);
|
|
49
50
|
const timer = React.useRef(undefined);
|
|
50
51
|
const [pos, setPos] = React.useState(null);
|
|
@@ -82,9 +83,26 @@ function Tooltip({ content, children, side = "top", delay = 350, open: openProp,
|
|
|
82
83
|
setOpen(false);
|
|
83
84
|
};
|
|
84
85
|
React.useEffect(() => () => clearTimeout(timer.current), []);
|
|
85
|
-
|
|
86
|
+
// The whole point of a tooltip is the description it gives its trigger, and
|
|
87
|
+
// the only way a screen reader learns of one is `aria-describedby` on the
|
|
88
|
+
// element that gets focus. It cannot go on the wrapper: a description on a
|
|
89
|
+
// non-focusable span around a button is never announced. So the trigger is
|
|
90
|
+
// cloned, keeping any aria-describedby it already had, and the reference is
|
|
91
|
+
// only present while the bubble is (a reference to a removed node is dead
|
|
92
|
+
// wiring, #225).
|
|
93
|
+
const described = React.isValidElement(children) && open
|
|
94
|
+
? React.cloneElement(children, {
|
|
95
|
+
"aria-describedby": [
|
|
96
|
+
children.props["aria-describedby"],
|
|
97
|
+
tipId,
|
|
98
|
+
]
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.join(" "),
|
|
101
|
+
})
|
|
102
|
+
: children;
|
|
103
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { ref: triggerRef, style: { display: "inline-flex" }, onPointerEnter: openAfterDelay, onPointerLeave: close, onFocusCapture: openAfterDelay, onBlurCapture: close, onKeyDown: (e) => e.key === "Escape" && close(), children: described }), open &&
|
|
86
104
|
pos != null &&
|
|
87
|
-
(0, react_dom_1.createPortal)((0, jsx_runtime_1.jsx)("div", { role: "tooltip", className: (0, tooltip_recipe_1.tooltipRecipe)(), style: {
|
|
105
|
+
(0, react_dom_1.createPortal)((0, jsx_runtime_1.jsx)("div", { id: tipId, role: "tooltip", className: (0, tooltip_recipe_1.tooltipRecipe)(), style: {
|
|
88
106
|
left: pos.left,
|
|
89
107
|
top: pos.top,
|
|
90
108
|
transform: pos.below
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.withDescribedBy = withDescribedBy;
|
|
4
|
+
/**
|
|
5
|
+
* Zag's dialog machine always advertises a description: `getContentProps()`
|
|
6
|
+
* returns an `aria-describedby` pointing at the id of a description element.
|
|
7
|
+
* crisp renders no such element — Attio's modals have a title and a body, no
|
|
8
|
+
* description line — so the reference dangles, and a dangling id is not a
|
|
9
|
+
* relationship a screen reader can follow.
|
|
10
|
+
*
|
|
11
|
+
* It also silenced the consumer's own: the machine's props are spread after
|
|
12
|
+
* anything passed in, so a real `aria-describedby` was overwritten by the
|
|
13
|
+
* missing one every time (#223).
|
|
14
|
+
*
|
|
15
|
+
* Drop the machine's value, and let the caller's stand when there is one. A
|
|
16
|
+
* consumer who wants a description points at a node inside their own children,
|
|
17
|
+
* which is the only place one exists.
|
|
18
|
+
*/
|
|
19
|
+
function withDescribedBy(contentProps, describedBy) {
|
|
20
|
+
const { "aria-describedby": _machine, ...rest } = contentProps;
|
|
21
|
+
return describedBy ? { ...rest, "aria-describedby": describedBy } : rest;
|
|
22
|
+
}
|
package/dist/cjs/core/index.js
CHANGED
|
@@ -14,11 +14,13 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.usePrefersReducedMotion = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.isRtlElement = exports.defineRecipe = exports.cx = void 0;
|
|
17
|
+
exports.usePrefersReducedMotion = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = void 0;
|
|
18
18
|
var cx_1 = require("./cx");
|
|
19
19
|
Object.defineProperty(exports, "cx", { enumerable: true, get: function () { return cx_1.cx; } });
|
|
20
20
|
var define_1 = require("./define");
|
|
21
21
|
Object.defineProperty(exports, "defineRecipe", { enumerable: true, get: function () { return define_1.defineRecipe; } });
|
|
22
|
+
var dialog_description_1 = require("./dialog-description");
|
|
23
|
+
Object.defineProperty(exports, "withDescribedBy", { enumerable: true, get: function () { return dialog_description_1.withDescribedBy; } });
|
|
22
24
|
var direction_1 = require("./direction");
|
|
23
25
|
Object.defineProperty(exports, "isRtlElement", { enumerable: true, get: function () { return direction_1.isRtlElement; } });
|
|
24
26
|
var place_floating_1 = require("./place-floating");
|
|
@@ -42,6 +42,7 @@ const react_1 = require("@zag-js/react");
|
|
|
42
42
|
const lucide_react_1 = require("lucide-react");
|
|
43
43
|
const React = __importStar(require("react"));
|
|
44
44
|
const kbd_1 = require("../../components/kbd");
|
|
45
|
+
const core_1 = require("../../core");
|
|
45
46
|
const command_recipe_1 = require("./command.recipe");
|
|
46
47
|
const IconHome = () => ((0, jsx_runtime_1.jsx)(lucide_react_1.House, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }));
|
|
47
48
|
const IconTasks = () => ((0, jsx_runtime_1.jsx)(lucide_react_1.SquareCheck, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }));
|
|
@@ -146,7 +147,7 @@ function useCommandInstanceId() {
|
|
|
146
147
|
* option list) so keyboard/focus correctness comes from the machines, not
|
|
147
148
|
* hand-rolled event handling.
|
|
148
149
|
*/
|
|
149
|
-
function Command({ open, onOpenChange, items = exports.defaultCommandItems, placeholder = "Search actions and records…", "aria-label": ariaLabel = "Command palette", navigateLabel = "Navigate", selectLabel = "Select", emptyLabel = "No matching commands", query: queryProp, onQueryChange, filterItems = true, }) {
|
|
150
|
+
function Command({ open, onOpenChange, items = exports.defaultCommandItems, placeholder = "Search actions and records…", "aria-label": ariaLabel = "Command palette", navigateLabel = "Navigate", selectLabel = "Select", emptyLabel = "No matching commands", query: queryProp, onQueryChange, filterItems = true, className, "aria-describedby": describedBy, ...rest }) {
|
|
150
151
|
const instanceId = useCommandInstanceId();
|
|
151
152
|
const dialogId = `${instanceId}-dialog`;
|
|
152
153
|
const comboId = `${instanceId}-combobox`;
|
|
@@ -230,7 +231,7 @@ function Command({ open, onOpenChange, items = exports.defaultCommandItems, plac
|
|
|
230
231
|
};
|
|
231
232
|
if (!dlg.open)
|
|
232
233
|
return null;
|
|
233
|
-
return ((0, jsx_runtime_1.jsx)(react_1.Portal, { children: (0, jsx_runtime_1.jsxs)("div", { className: (0, command_recipe_1.commandRecipe)(), children: [(0, jsx_runtime_1.jsx)("div", { ...dlg.getBackdropProps(), className: "command-backdrop" }), (0, jsx_runtime_1.jsx)("div", { ...dlg.getPositionerProps(), className: "command-positioner", children: (0, jsx_runtime_1.jsx)("div", { ...dlg.getContentProps(), className: "command-content", children: (0, jsx_runtime_1.jsxs)("div", { className: "command-card", children: [(0, jsx_runtime_1.jsxs)("div", { className: "command-control", children: [(0, jsx_runtime_1.jsx)(IconSearch, {}), (0, jsx_runtime_1.jsx)("input", { ref: inputRef, ...combo.getInputProps(), className: "command-input", placeholder: placeholder })] }), (0, jsx_runtime_1.jsx)("div", { ...combo.getContentProps(), className: "command-list", children: filteredItems.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "command-empty", children: emptyLabel })) : (filteredItems.map((item, i) => {
|
|
234
|
+
return ((0, jsx_runtime_1.jsx)(react_1.Portal, { children: (0, jsx_runtime_1.jsxs)("div", { className: (0, command_recipe_1.commandRecipe)(), children: [(0, jsx_runtime_1.jsx)("div", { ...dlg.getBackdropProps(), className: "command-backdrop" }), (0, jsx_runtime_1.jsx)("div", { ...dlg.getPositionerProps(), className: "command-positioner", children: (0, jsx_runtime_1.jsx)("div", { ...rest, ...(0, core_1.withDescribedBy)(dlg.getContentProps(), describedBy), className: (0, core_1.cx)("command-content", className), children: (0, jsx_runtime_1.jsxs)("div", { className: "command-card", children: [(0, jsx_runtime_1.jsxs)("div", { className: "command-control", children: [(0, jsx_runtime_1.jsx)("label", { ...combo.getLabelProps(), className: "command-srlabel", children: ariaLabel }), (0, jsx_runtime_1.jsx)(IconSearch, {}), (0, jsx_runtime_1.jsx)("input", { ref: inputRef, ...combo.getInputProps(), className: "command-input", placeholder: placeholder })] }), (0, jsx_runtime_1.jsx)("div", { ...combo.getContentProps(), className: "command-list", children: filteredItems.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "command-empty", children: emptyLabel })) : (filteredItems.map((item, i) => {
|
|
234
235
|
const Icon = item.icon;
|
|
235
236
|
const showGroup = item.group && item.group !== filteredItems[i - 1]?.group;
|
|
236
237
|
return ((0, jsx_runtime_1.jsxs)(React.Fragment, { children: [showGroup && ((0, jsx_runtime_1.jsx)("div", { className: "command-group-label", "aria-hidden": "true", children: item.group })), (0, jsx_runtime_1.jsxs)("div", { ...combo.getItemProps({ item }), className: "command-item", children: [(0, jsx_runtime_1.jsx)(Icon, {}), (0, jsx_runtime_1.jsx)("span", { className: "command-item-label", children: item.label }), item.shortcut && ((0, jsx_runtime_1.jsx)("span", { className: "command-item-shortcut", children: item.shortcut.map((key) => ((0, jsx_runtime_1.jsx)(kbd_1.Kbd, { children: key }, key))) }))] })] }, item.id));
|
|
@@ -13,6 +13,12 @@ const config = (0, core_1.defineRecipe)({
|
|
|
13
13
|
& .command-card { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgSurface}; box-shadow: 0 0 0 1px color-mix(in srgb, ${tokens_1.contract.fgPrimary} 8%, transparent); overflow: hidden; }
|
|
14
14
|
& .command-control { display: flex; align-items: center; gap: ${tokens_1.contract.space["2.5"]}; height: 52px; flex-shrink: 0; padding: 0 ${tokens_1.contract.space["4"]}; border-bottom: 1px solid ${tokens_1.contract.borderSubtle}; color: ${tokens_1.contract.fgMuted}; }
|
|
15
15
|
& .command-control svg { flex-shrink: 0; }
|
|
16
|
+
/* The palette's label element. It exists for the assistive tree only: zag's
|
|
17
|
+
combobox names the input through it and points the listbox's
|
|
18
|
+
aria-labelledby at it, and without it both were unnamed and the reference
|
|
19
|
+
resolved to nothing (#225). Clipped rather than display:none, which would
|
|
20
|
+
take it out of the tree it exists for. */
|
|
21
|
+
& .command-srlabel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
16
22
|
& .command-input { flex: 1; min-width: 0; border: none; outline: none; background: transparent; font-family: ${tokens_1.contract.font.sans}; font-size: ${tokens_1.contract.text.xl}; color: ${tokens_1.contract.fgPrimary}; }
|
|
17
23
|
& .command-input::placeholder { color: ${tokens_1.contract.fgMuted}; }
|
|
18
24
|
& .command-list { flex: 1; min-height: 0; overflow-y: auto; padding: ${tokens_1.contract.space["1"]} ${tokens_1.contract.space["2.5"]}; display: flex; flex-direction: column; gap: ${tokens_1.contract.space.px}; }
|
package/dist/command.css
CHANGED
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
& .command-card { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; border-radius: var(--radius-xl); background: var(--bg-surface); box-shadow: 0 0 0 1px color-mix(in srgb, var(--fg-primary) 8%, transparent); overflow: hidden; }
|
|
8
8
|
& .command-control { display: flex; align-items: center; gap: var(--space-2-5); height: 52px; flex-shrink: 0; padding: 0 var(--space-4); border-bottom: 1px solid var(--border-subtle); color: var(--fg-muted); }
|
|
9
9
|
& .command-control svg { flex-shrink: 0; }
|
|
10
|
+
/* The palette's label element. It exists for the assistive tree only: zag's
|
|
11
|
+
combobox names the input through it and points the listbox's
|
|
12
|
+
aria-labelledby at it, and without it both were unnamed and the reference
|
|
13
|
+
resolved to nothing (#225). Clipped rather than display:none, which would
|
|
14
|
+
take it out of the tree it exists for. */
|
|
15
|
+
& .command-srlabel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
10
16
|
& .command-input { flex: 1; min-width: 0; border: none; outline: none; background: transparent; font-family: var(--font-sans); font-size: var(--text-xl); color: var(--fg-primary); }
|
|
11
17
|
& .command-input::placeholder { color: var(--fg-muted); }
|
|
12
18
|
& .command-list { flex: 1; min-height: 0; overflow-y: auto; padding: var(--space-1) var(--space-2-5); display: flex; flex-direction: column; gap: var(--space-px); }
|
|
@@ -3,6 +3,7 @@ import * as dialog from "@zag-js/dialog";
|
|
|
3
3
|
import { normalizeProps, Portal, useMachine } from "@zag-js/react";
|
|
4
4
|
import { X } from "lucide-react";
|
|
5
5
|
import * as React from "react";
|
|
6
|
+
import { cx, withDescribedBy } from "../../core/index.js";
|
|
6
7
|
import { dialogRecipe } from "./dialog.recipe.js";
|
|
7
8
|
// A globally unique id per mounted instance (see command.tsx for the rationale —
|
|
8
9
|
// the machine defers its dismissal listeners a frame, so tree-relative useId
|
|
@@ -21,7 +22,7 @@ function useDialogInstanceId() {
|
|
|
21
22
|
* Escape / outside-click dismissal) with the measured Attio modal surface.
|
|
22
23
|
* Compose the body with `DialogBody` and actions with `DialogFooter`.
|
|
23
24
|
*/
|
|
24
|
-
export function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-label": ariaLabel, closeLabel = "Close", children, }) {
|
|
25
|
+
export function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-label": ariaLabel, closeLabel = "Close", children, className, style, "aria-describedby": describedBy, ...rest }) {
|
|
25
26
|
const id = useDialogInstanceId();
|
|
26
27
|
const service = useMachine(dialog.machine, {
|
|
27
28
|
id,
|
|
@@ -37,7 +38,10 @@ export function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-lab
|
|
|
37
38
|
const api = dialog.connect(service, normalizeProps);
|
|
38
39
|
if (!api.open)
|
|
39
40
|
return null;
|
|
40
|
-
return (_jsx(Portal, { children: _jsxs("div", { className: dialogRecipe(), children: [_jsx("div", { ...api.getBackdropProps(), className: "dialog-backdrop" }), _jsx("div", { ...api.getPositionerProps(), className: "dialog-positioner", children: _jsx("div", { ...api.getContentProps(),
|
|
41
|
+
return (_jsx(Portal, { children: _jsxs("div", { className: dialogRecipe(), children: [_jsx("div", { ...api.getBackdropProps(), className: "dialog-backdrop" }), _jsx("div", { ...api.getPositionerProps(), className: "dialog-positioner", children: _jsx("div", { ...rest, ...withDescribedBy(api.getContentProps(), describedBy), className: cx("dialog-content", className), "data-crisp-dialog": id,
|
|
42
|
+
// `width` is the component's own knob, so it wins over a passed
|
|
43
|
+
// maxWidth; everything else a consumer sets survives
|
|
44
|
+
style: { ...style, maxWidth: `min(${width}px, 90vw)` }, children: _jsxs("div", { className: "dialog-card", children: [title != null && (_jsxs("div", { className: "dialog-header", children: [icon, _jsx("span", { ...api.getTitleProps(), className: "dialog-title", children: title }), _jsx("button", { type: "button", ...api.getCloseTriggerProps(), className: "dialog-close", "aria-label": closeLabel, children: _jsx(X, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] })), children] }) }) })] }) }));
|
|
41
45
|
}
|
|
42
46
|
Dialog.displayName = "Dialog";
|
|
43
47
|
export function DialogBody({ children, className, }) {
|
|
@@ -3,6 +3,7 @@ import * as dialog from "@zag-js/dialog";
|
|
|
3
3
|
import { normalizeProps, Portal, useMachine } from "@zag-js/react";
|
|
4
4
|
import { X } from "lucide-react";
|
|
5
5
|
import * as React from "react";
|
|
6
|
+
import { cx, withDescribedBy } from "../../core/index.js";
|
|
6
7
|
import { drawerRecipe } from "./drawer.recipe.js";
|
|
7
8
|
let instanceCounter = 0;
|
|
8
9
|
function useDrawerInstanceId() {
|
|
@@ -18,7 +19,7 @@ function useDrawerInstanceId() {
|
|
|
18
19
|
* Escape / outside-click dismissal) with a panel that slides in from the edge.
|
|
19
20
|
* Compose the body with `DrawerBody` and actions with `DrawerFooter`.
|
|
20
21
|
*/
|
|
21
|
-
export function Drawer({ open, onOpenChange, side = "right", title, icon, "aria-label": ariaLabel, closeLabel = "Close", children, }) {
|
|
22
|
+
export function Drawer({ open, onOpenChange, side = "right", title, icon, "aria-label": ariaLabel, closeLabel = "Close", children, className, "aria-describedby": describedBy, ...rest }) {
|
|
22
23
|
const id = useDrawerInstanceId();
|
|
23
24
|
const service = useMachine(dialog.machine, {
|
|
24
25
|
id,
|
|
@@ -31,7 +32,7 @@ export function Drawer({ open, onOpenChange, side = "right", title, icon, "aria-
|
|
|
31
32
|
const api = dialog.connect(service, normalizeProps);
|
|
32
33
|
if (!api.open)
|
|
33
34
|
return null;
|
|
34
|
-
return (_jsx(Portal, { children: _jsxs("div", { className: drawerRecipe(), children: [_jsx("div", { ...api.getBackdropProps(), className: "drawer-backdrop" }), _jsx("div", { ...api.getPositionerProps(), className: "drawer-positioner", "data-side": side, children: _jsxs("div", { ...api.getContentProps(), className: "drawer-content", children: [title != null && (_jsxs("div", { className: "drawer-header", children: [icon, _jsx("span", { ...api.getTitleProps(), className: "drawer-title", children: title }), _jsx("button", { type: "button", ...api.getCloseTriggerProps(), className: "drawer-close", "aria-label": closeLabel, children: _jsx(X, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] })), children] }) })] }) }));
|
|
35
|
+
return (_jsx(Portal, { children: _jsxs("div", { className: drawerRecipe(), children: [_jsx("div", { ...api.getBackdropProps(), className: "drawer-backdrop" }), _jsx("div", { ...api.getPositionerProps(), className: "drawer-positioner", "data-side": side, children: _jsxs("div", { ...rest, ...withDescribedBy(api.getContentProps(), describedBy), className: cx("drawer-content", className), children: [title != null && (_jsxs("div", { className: "drawer-header", children: [icon, _jsx("span", { ...api.getTitleProps(), className: "drawer-title", children: title }), _jsx("button", { type: "button", ...api.getCloseTriggerProps(), className: "drawer-close", "aria-label": closeLabel, children: _jsx(X, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] })), children] }) })] }) }));
|
|
35
36
|
}
|
|
36
37
|
Drawer.displayName = "Drawer";
|
|
37
38
|
export function DrawerBody({ children }) {
|
|
@@ -13,6 +13,13 @@ const MenuContext = React.createContext(null);
|
|
|
13
13
|
* closes on outside click. Flips up / right-aligns when space is tight.
|
|
14
14
|
*/
|
|
15
15
|
export function Menu({ trigger, children, align = "start", open: openProp, defaultOpen, onOpenChange, className, "aria-label": ariaLabel, }) {
|
|
16
|
+
// The surface and the button that opens it were two unconnected nodes: the
|
|
17
|
+
// menu/dialog had no id, so nothing could point at it, and no accessible
|
|
18
|
+
// name, so a screen reader announced a bare "menu"/"dialog". Labelling the
|
|
19
|
+
// surface by its own trigger is the WAI menu-button recommendation, and it
|
|
20
|
+
// needs no invented copy (#225).
|
|
21
|
+
const surfaceId = React.useId();
|
|
22
|
+
const triggerId = trigger.props.id ?? `${surfaceId}-trigger`;
|
|
16
23
|
const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false);
|
|
17
24
|
const open = openProp ?? internalOpen;
|
|
18
25
|
const setOpen = React.useCallback((next) => {
|
|
@@ -70,14 +77,18 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
|
|
|
70
77
|
});
|
|
71
78
|
const triggerEl = React.cloneElement(trigger, {
|
|
72
79
|
ref: triggerRef,
|
|
80
|
+
id: triggerId,
|
|
73
81
|
"aria-haspopup": "menu",
|
|
74
82
|
"aria-expanded": open,
|
|
83
|
+
// only while the surface exists — a reference to a node that is not
|
|
84
|
+
// there is not a relationship
|
|
85
|
+
"aria-controls": open ? surfaceId : undefined,
|
|
75
86
|
onClick: (e) => {
|
|
76
87
|
trigger.props.onClick?.(e);
|
|
77
88
|
setOpen(!open);
|
|
78
89
|
},
|
|
79
90
|
});
|
|
80
|
-
return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(MenuContext.Provider, { value: { close }, children: _jsx("div", { ref: contentRef, className: "menu-content", role: "menu", "aria-label": ariaLabel, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: children }) }))] }));
|
|
91
|
+
return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(MenuContext.Provider, { value: { close }, children: _jsx("div", { ref: contentRef, className: "menu-content", role: "menu", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: children }) }))] }));
|
|
81
92
|
}
|
|
82
93
|
Menu.displayName = "Menu";
|
|
83
94
|
export function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
|
|
@@ -8,6 +8,13 @@ import { popoverRecipe } from "./popover.recipe.js";
|
|
|
8
8
|
* closes on outside pointer-down and Escape. Controlled or self-managed.
|
|
9
9
|
*/
|
|
10
10
|
export function Popover({ trigger, children, align = "start", open: openProp, defaultOpen, onOpenChange, "aria-label": ariaLabel, className, }) {
|
|
11
|
+
// The surface and the button that opens it were two unconnected nodes: the
|
|
12
|
+
// menu/dialog had no id, so nothing could point at it, and no accessible
|
|
13
|
+
// name, so a screen reader announced a bare "menu"/"dialog". Labelling the
|
|
14
|
+
// surface by its own trigger is the WAI menu-button recommendation, and it
|
|
15
|
+
// needs no invented copy (#225).
|
|
16
|
+
const surfaceId = React.useId();
|
|
17
|
+
const triggerId = trigger.props.id ?? `${surfaceId}-trigger`;
|
|
11
18
|
const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false);
|
|
12
19
|
const open = openProp ?? internalOpen;
|
|
13
20
|
const rootRef = React.useRef(null);
|
|
@@ -51,14 +58,18 @@ export function Popover({ trigger, children, align = "start", open: openProp, de
|
|
|
51
58
|
}, [open, setOpen]);
|
|
52
59
|
const triggerEl = React.cloneElement(trigger, {
|
|
53
60
|
ref: triggerRef,
|
|
61
|
+
id: triggerId,
|
|
54
62
|
"aria-haspopup": "dialog",
|
|
55
63
|
"aria-expanded": open,
|
|
64
|
+
// only while the surface exists — a reference to a node that is not
|
|
65
|
+
// there is not a relationship
|
|
66
|
+
"aria-controls": open ? surfaceId : undefined,
|
|
56
67
|
onClick: (e) => {
|
|
57
68
|
trigger.props.onClick?.(e);
|
|
58
69
|
setOpen(!open);
|
|
59
70
|
},
|
|
60
71
|
});
|
|
61
|
-
return (_jsxs("div", { ref: rootRef, className: popoverRecipe({ className }), children: [triggerEl, open && (_jsx("div", { className: "popover-content", role: "dialog", "aria-label": ariaLabel, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: (e) => {
|
|
72
|
+
return (_jsxs("div", { ref: rootRef, className: popoverRecipe({ className }), children: [triggerEl, open && (_jsx("div", { className: "popover-content", role: "dialog", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: (e) => {
|
|
62
73
|
if (e.key === "Escape") {
|
|
63
74
|
e.preventDefault();
|
|
64
75
|
setOpen(false);
|
|
@@ -9,6 +9,7 @@ import { tooltipRecipe } from "./tooltip.recipe.js";
|
|
|
9
9
|
* above the trigger (flipping below near the top edge).
|
|
10
10
|
*/
|
|
11
11
|
export function Tooltip({ content, children, side = "top", delay = 350, open: openProp, defaultOpen, onOpenChange, }) {
|
|
12
|
+
const tipId = React.useId();
|
|
12
13
|
const triggerRef = React.useRef(null);
|
|
13
14
|
const timer = React.useRef(undefined);
|
|
14
15
|
const [pos, setPos] = React.useState(null);
|
|
@@ -46,9 +47,26 @@ export function Tooltip({ content, children, side = "top", delay = 350, open: op
|
|
|
46
47
|
setOpen(false);
|
|
47
48
|
};
|
|
48
49
|
React.useEffect(() => () => clearTimeout(timer.current), []);
|
|
49
|
-
|
|
50
|
+
// The whole point of a tooltip is the description it gives its trigger, and
|
|
51
|
+
// the only way a screen reader learns of one is `aria-describedby` on the
|
|
52
|
+
// element that gets focus. It cannot go on the wrapper: a description on a
|
|
53
|
+
// non-focusable span around a button is never announced. So the trigger is
|
|
54
|
+
// cloned, keeping any aria-describedby it already had, and the reference is
|
|
55
|
+
// only present while the bubble is (a reference to a removed node is dead
|
|
56
|
+
// wiring, #225).
|
|
57
|
+
const described = React.isValidElement(children) && open
|
|
58
|
+
? React.cloneElement(children, {
|
|
59
|
+
"aria-describedby": [
|
|
60
|
+
children.props["aria-describedby"],
|
|
61
|
+
tipId,
|
|
62
|
+
]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join(" "),
|
|
65
|
+
})
|
|
66
|
+
: children;
|
|
67
|
+
return (_jsxs(_Fragment, { children: [_jsx("span", { ref: triggerRef, style: { display: "inline-flex" }, onPointerEnter: openAfterDelay, onPointerLeave: close, onFocusCapture: openAfterDelay, onBlurCapture: close, onKeyDown: (e) => e.key === "Escape" && close(), children: described }), open &&
|
|
50
68
|
pos != null &&
|
|
51
|
-
createPortal(_jsx("div", { role: "tooltip", className: tooltipRecipe(), style: {
|
|
69
|
+
createPortal(_jsx("div", { id: tipId, role: "tooltip", className: tooltipRecipe(), style: {
|
|
52
70
|
left: pos.left,
|
|
53
71
|
top: pos.top,
|
|
54
72
|
transform: pos.below
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zag's dialog machine always advertises a description: `getContentProps()`
|
|
3
|
+
* returns an `aria-describedby` pointing at the id of a description element.
|
|
4
|
+
* crisp renders no such element — Attio's modals have a title and a body, no
|
|
5
|
+
* description line — so the reference dangles, and a dangling id is not a
|
|
6
|
+
* relationship a screen reader can follow.
|
|
7
|
+
*
|
|
8
|
+
* It also silenced the consumer's own: the machine's props are spread after
|
|
9
|
+
* anything passed in, so a real `aria-describedby` was overwritten by the
|
|
10
|
+
* missing one every time (#223).
|
|
11
|
+
*
|
|
12
|
+
* Drop the machine's value, and let the caller's stand when there is one. A
|
|
13
|
+
* consumer who wants a description points at a node inside their own children,
|
|
14
|
+
* which is the only place one exists.
|
|
15
|
+
*/
|
|
16
|
+
export function withDescribedBy(contentProps, describedBy) {
|
|
17
|
+
const { "aria-describedby": _machine, ...rest } = contentProps;
|
|
18
|
+
return describedBy ? { ...rest, "aria-describedby": describedBy } : rest;
|
|
19
|
+
}
|
package/dist/esm/core/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { cx } from "./cx.js";
|
|
2
2
|
export { defineRecipe } from "./define.js";
|
|
3
|
+
export { withDescribedBy } from "./dialog-description.js";
|
|
3
4
|
export { isRtlElement } from "./direction.js";
|
|
4
5
|
export { placeFloating, } from "./place-floating.js";
|
|
5
6
|
export { Primitive } from "./primitive.js";
|
|
@@ -5,6 +5,7 @@ import { normalizeProps, Portal, useMachine } from "@zag-js/react";
|
|
|
5
5
|
import { Building2, CircleDollarSign, House, ListTodo, Search, Settings, SquareCheck, User, UserPlus, } from "lucide-react";
|
|
6
6
|
import * as React from "react";
|
|
7
7
|
import { Kbd } from "../../components/kbd/index.js";
|
|
8
|
+
import { cx, withDescribedBy } from "../../core/index.js";
|
|
8
9
|
import { commandRecipe } from "./command.recipe.js";
|
|
9
10
|
const IconHome = () => (_jsx(House, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }));
|
|
10
11
|
const IconTasks = () => (_jsx(SquareCheck, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }));
|
|
@@ -109,7 +110,7 @@ function useCommandInstanceId() {
|
|
|
109
110
|
* option list) so keyboard/focus correctness comes from the machines, not
|
|
110
111
|
* hand-rolled event handling.
|
|
111
112
|
*/
|
|
112
|
-
export function Command({ open, onOpenChange, items = defaultCommandItems, placeholder = "Search actions and records…", "aria-label": ariaLabel = "Command palette", navigateLabel = "Navigate", selectLabel = "Select", emptyLabel = "No matching commands", query: queryProp, onQueryChange, filterItems = true, }) {
|
|
113
|
+
export function Command({ open, onOpenChange, items = defaultCommandItems, placeholder = "Search actions and records…", "aria-label": ariaLabel = "Command palette", navigateLabel = "Navigate", selectLabel = "Select", emptyLabel = "No matching commands", query: queryProp, onQueryChange, filterItems = true, className, "aria-describedby": describedBy, ...rest }) {
|
|
113
114
|
const instanceId = useCommandInstanceId();
|
|
114
115
|
const dialogId = `${instanceId}-dialog`;
|
|
115
116
|
const comboId = `${instanceId}-combobox`;
|
|
@@ -193,7 +194,7 @@ export function Command({ open, onOpenChange, items = defaultCommandItems, place
|
|
|
193
194
|
};
|
|
194
195
|
if (!dlg.open)
|
|
195
196
|
return null;
|
|
196
|
-
return (_jsx(Portal, { children: _jsxs("div", { className: commandRecipe(), children: [_jsx("div", { ...dlg.getBackdropProps(), className: "command-backdrop" }), _jsx("div", { ...dlg.getPositionerProps(), className: "command-positioner", children: _jsx("div", { ...dlg.getContentProps(), className: "command-content", children: _jsxs("div", { className: "command-card", children: [_jsxs("div", { className: "command-control", children: [_jsx(IconSearch, {}), _jsx("input", { ref: inputRef, ...combo.getInputProps(), className: "command-input", placeholder: placeholder })] }), _jsx("div", { ...combo.getContentProps(), className: "command-list", children: filteredItems.length === 0 ? (_jsx("div", { className: "command-empty", children: emptyLabel })) : (filteredItems.map((item, i) => {
|
|
197
|
+
return (_jsx(Portal, { children: _jsxs("div", { className: commandRecipe(), children: [_jsx("div", { ...dlg.getBackdropProps(), className: "command-backdrop" }), _jsx("div", { ...dlg.getPositionerProps(), className: "command-positioner", children: _jsx("div", { ...rest, ...withDescribedBy(dlg.getContentProps(), describedBy), className: cx("command-content", className), children: _jsxs("div", { className: "command-card", children: [_jsxs("div", { className: "command-control", children: [_jsx("label", { ...combo.getLabelProps(), className: "command-srlabel", children: ariaLabel }), _jsx(IconSearch, {}), _jsx("input", { ref: inputRef, ...combo.getInputProps(), className: "command-input", placeholder: placeholder })] }), _jsx("div", { ...combo.getContentProps(), className: "command-list", children: filteredItems.length === 0 ? (_jsx("div", { className: "command-empty", children: emptyLabel })) : (filteredItems.map((item, i) => {
|
|
197
198
|
const Icon = item.icon;
|
|
198
199
|
const showGroup = item.group && item.group !== filteredItems[i - 1]?.group;
|
|
199
200
|
return (_jsxs(React.Fragment, { children: [showGroup && (_jsx("div", { className: "command-group-label", "aria-hidden": "true", children: item.group })), _jsxs("div", { ...combo.getItemProps({ item }), className: "command-item", children: [_jsx(Icon, {}), _jsx("span", { className: "command-item-label", children: item.label }), item.shortcut && (_jsx("span", { className: "command-item-shortcut", children: item.shortcut.map((key) => (_jsx(Kbd, { children: key }, key))) }))] })] }, item.id));
|
|
@@ -10,6 +10,12 @@ const config = defineRecipe({
|
|
|
10
10
|
& .command-card { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; border-radius: ${c.radius.xl}; background: ${c.bgSurface}; box-shadow: 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 8%, transparent); overflow: hidden; }
|
|
11
11
|
& .command-control { display: flex; align-items: center; gap: ${c.space["2.5"]}; height: 52px; flex-shrink: 0; padding: 0 ${c.space["4"]}; border-bottom: 1px solid ${c.borderSubtle}; color: ${c.fgMuted}; }
|
|
12
12
|
& .command-control svg { flex-shrink: 0; }
|
|
13
|
+
/* The palette's label element. It exists for the assistive tree only: zag's
|
|
14
|
+
combobox names the input through it and points the listbox's
|
|
15
|
+
aria-labelledby at it, and without it both were unnamed and the reference
|
|
16
|
+
resolved to nothing (#225). Clipped rather than display:none, which would
|
|
17
|
+
take it out of the tree it exists for. */
|
|
18
|
+
& .command-srlabel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
13
19
|
& .command-input { flex: 1; min-width: 0; border: none; outline: none; background: transparent; font-family: ${c.font.sans}; font-size: ${c.text.xl}; color: ${c.fgPrimary}; }
|
|
14
20
|
& .command-input::placeholder { color: ${c.fgMuted}; }
|
|
15
21
|
& .command-list { flex: 1; min-height: 0; overflow-y: auto; padding: ${c.space["1"]} ${c.space["2.5"]}; display: flex; flex-direction: column; gap: ${c.space.px}; }
|
package/dist/styles.css
CHANGED
|
@@ -1976,6 +1976,12 @@
|
|
|
1976
1976
|
& .command-card { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; border-radius: var(--radius-xl); background: var(--bg-surface); box-shadow: 0 0 0 1px color-mix(in srgb, var(--fg-primary) 8%, transparent); overflow: hidden; }
|
|
1977
1977
|
& .command-control { display: flex; align-items: center; gap: var(--space-2-5); height: 52px; flex-shrink: 0; padding: 0 var(--space-4); border-bottom: 1px solid var(--border-subtle); color: var(--fg-muted); }
|
|
1978
1978
|
& .command-control svg { flex-shrink: 0; }
|
|
1979
|
+
/* The palette's label element. It exists for the assistive tree only: zag's
|
|
1980
|
+
combobox names the input through it and points the listbox's
|
|
1981
|
+
aria-labelledby at it, and without it both were unnamed and the reference
|
|
1982
|
+
resolved to nothing (#225). Clipped rather than display:none, which would
|
|
1983
|
+
take it out of the tree it exists for. */
|
|
1984
|
+
& .command-srlabel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
|
1979
1985
|
& .command-input { flex: 1; min-width: 0; border: none; outline: none; background: transparent; font-family: var(--font-sans); font-size: var(--text-xl); color: var(--fg-primary); }
|
|
1980
1986
|
& .command-input::placeholder { color: var(--fg-muted); }
|
|
1981
1987
|
& .command-list { flex: 1; min-height: 0; overflow-y: auto; padding: var(--space-1) var(--space-2-5); display: flex; flex-direction: column; gap: var(--space-px); }
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Root props land on the DIALOG SURFACE — the element that carries
|
|
4
|
+
* `role="dialog"` — not on the portal wrapper. That is what a consumer needs
|
|
5
|
+
* to reach: `aria-describedby` has to sit beside `aria-modal` to associate the
|
|
6
|
+
* description, and a `data-testid` or a `style` is wanted on the panel, not on
|
|
7
|
+
* the full-viewport layer behind it (#223).
|
|
8
|
+
*
|
|
9
|
+
* `id` and the aria wiring are the machine's: it mints the ids that link the
|
|
10
|
+
* title and the description, so those are spread after anything passed in.
|
|
11
|
+
*/
|
|
12
|
+
export type DialogProps = Omit<React.ComponentPropsWithoutRef<"div">, "title" | "children"> & {
|
|
3
13
|
/** Controlled open state. */
|
|
4
14
|
open: boolean;
|
|
5
15
|
onOpenChange: (open: boolean) => void;
|
|
@@ -19,7 +29,7 @@ export type DialogProps = {
|
|
|
19
29
|
* Escape / outside-click dismissal) with the measured Attio modal surface.
|
|
20
30
|
* Compose the body with `DialogBody` and actions with `DialogFooter`.
|
|
21
31
|
*/
|
|
22
|
-
export declare function Dialog({ open, onOpenChange, title, icon, width, "aria-label": ariaLabel, closeLabel, children, }: DialogProps): React.JSX.Element | null;
|
|
32
|
+
export declare function Dialog({ open, onOpenChange, title, icon, width, "aria-label": ariaLabel, closeLabel, children, className, style, "aria-describedby": describedBy, ...rest }: DialogProps): React.JSX.Element | null;
|
|
23
33
|
export declare namespace Dialog {
|
|
24
34
|
var displayName: string;
|
|
25
35
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Root props land on the DRAWER SURFACE — the element carrying `role="dialog"`
|
|
4
|
+
* — not on the portal wrapper, for the same reason as Dialog (#223).
|
|
5
|
+
*/
|
|
6
|
+
export type DrawerProps = Omit<React.ComponentPropsWithoutRef<"div">, "title" | "children"> & {
|
|
3
7
|
open: boolean;
|
|
4
8
|
onOpenChange: (open: boolean) => void;
|
|
5
9
|
/** Which edge the sheet slides in from (default right). */
|
|
@@ -16,7 +20,7 @@ export type DrawerProps = {
|
|
|
16
20
|
* Escape / outside-click dismissal) with a panel that slides in from the edge.
|
|
17
21
|
* Compose the body with `DrawerBody` and actions with `DrawerFooter`.
|
|
18
22
|
*/
|
|
19
|
-
export declare function Drawer({ open, onOpenChange, side, title, icon, "aria-label": ariaLabel, closeLabel, children, }: DrawerProps): React.JSX.Element | null;
|
|
23
|
+
export declare function Drawer({ open, onOpenChange, side, title, icon, "aria-label": ariaLabel, closeLabel, children, className, "aria-describedby": describedBy, ...rest }: DrawerProps): React.JSX.Element | null;
|
|
20
24
|
export declare namespace Drawer {
|
|
21
25
|
var displayName: string;
|
|
22
26
|
}
|
|
@@ -2,7 +2,12 @@ import * as React from "react";
|
|
|
2
2
|
export type TooltipProps = {
|
|
3
3
|
/** The hint text/content shown on hover or focus. */
|
|
4
4
|
content: React.ReactNode;
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* The trigger element. A SINGLE element: it is cloned to carry the
|
|
7
|
+
* `aria-describedby` that makes the hint reachable, so anything else — a
|
|
8
|
+
* string, a fragment, several children — renders and hovers correctly but
|
|
9
|
+
* cannot be described to a screen reader.
|
|
10
|
+
*/
|
|
6
11
|
children: React.ReactNode;
|
|
7
12
|
/** Preferred side; flips to the other axis end when there's no room. */
|
|
8
13
|
side?: "top" | "bottom";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zag's dialog machine always advertises a description: `getContentProps()`
|
|
3
|
+
* returns an `aria-describedby` pointing at the id of a description element.
|
|
4
|
+
* crisp renders no such element — Attio's modals have a title and a body, no
|
|
5
|
+
* description line — so the reference dangles, and a dangling id is not a
|
|
6
|
+
* relationship a screen reader can follow.
|
|
7
|
+
*
|
|
8
|
+
* It also silenced the consumer's own: the machine's props are spread after
|
|
9
|
+
* anything passed in, so a real `aria-describedby` was overwritten by the
|
|
10
|
+
* missing one every time (#223).
|
|
11
|
+
*
|
|
12
|
+
* Drop the machine's value, and let the caller's stand when there is one. A
|
|
13
|
+
* consumer who wants a description points at a node inside their own children,
|
|
14
|
+
* which is the only place one exists.
|
|
15
|
+
*/
|
|
16
|
+
export declare function withDescribedBy<T extends Record<string, unknown>>(contentProps: T, describedBy: string | undefined): Omit<T, "aria-describedby"> & {
|
|
17
|
+
"aria-describedby"?: string;
|
|
18
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { cx } from "./cx";
|
|
2
2
|
export { defineRecipe } from "./define";
|
|
3
|
+
export { withDescribedBy } from "./dialog-description";
|
|
3
4
|
export { isRtlElement } from "./direction";
|
|
4
5
|
export { type FloatingAlign, type PlaceFloatingOptions, placeFloating, } from "./place-floating";
|
|
5
6
|
export { Primitive } from "./primitive";
|
|
@@ -13,7 +13,11 @@ export type CommandItem = {
|
|
|
13
13
|
};
|
|
14
14
|
/** crisp's own generic command set (not copied from any product). */
|
|
15
15
|
export declare const defaultCommandItems: CommandItem[];
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Root props land on the PALETTE SURFACE — the element carrying
|
|
18
|
+
* `role="dialog"` — not on the portal wrapper (#223).
|
|
19
|
+
*/
|
|
20
|
+
export type CommandProps = Omit<React.ComponentPropsWithoutRef<"div">, "children" | "onSelect"> & {
|
|
17
21
|
/** Controlled open state — the caller owns when the palette is shown. */
|
|
18
22
|
open: boolean;
|
|
19
23
|
onOpenChange: (open: boolean) => void;
|
|
@@ -50,4 +54,4 @@ export type CommandProps = {
|
|
|
50
54
|
* option list) so keyboard/focus correctness comes from the machines, not
|
|
51
55
|
* hand-rolled event handling.
|
|
52
56
|
*/
|
|
53
|
-
export declare function Command({ open, onOpenChange, items, placeholder, "aria-label": ariaLabel, navigateLabel, selectLabel, emptyLabel, query: queryProp, onQueryChange, filterItems, }: CommandProps): React.JSX.Element | null;
|
|
57
|
+
export declare function Command({ open, onOpenChange, items, placeholder, "aria-label": ariaLabel, navigateLabel, selectLabel, emptyLabel, query: queryProp, onQueryChange, filterItems, className, "aria-describedby": describedBy, ...rest }: CommandProps): React.JSX.Element | null;
|