@crisp-ui-kit/crisp 0.36.0 → 0.38.0
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/badge.css +5 -5
- package/dist/badge.layered.css +5 -5
- package/dist/cjs/components/badge/badge.recipe.js +11 -5
- package/dist/cjs/components/contextmenu/contextmenu.js +25 -8
- package/dist/cjs/components/contextmenu/contextmenu.recipe.js +5 -2
- package/dist/cjs/components/dialog/dialog.js +1 -1
- package/dist/cjs/components/dialog/dialog.recipe.js +1 -1
- package/dist/cjs/components/fileupload/fileupload.js +2 -2
- package/dist/cjs/components/menu/menu.js +20 -3
- package/dist/cjs/components/menu/menu.recipe.js +4 -3
- package/dist/cjs/components/multiselect/multiselect.js +29 -12
- package/dist/cjs/components/multiselect/multiselect.recipe.js +17 -5
- package/dist/cjs/components/popover/popover.js +25 -8
- package/dist/cjs/components/popover/popover.recipe.js +5 -3
- package/dist/cjs/components/select/select.js +32 -11
- package/dist/cjs/components/select/select.recipe.js +19 -4
- package/dist/cjs/components/tabletoolbar/tabletoolbar.js +50 -6
- package/dist/cjs/core/floating-portal.js +28 -0
- package/dist/cjs/core/floating-stack.js +54 -0
- package/dist/cjs/core/index.js +6 -1
- package/dist/cjs/product/recordstable/recordstable.js +15 -4
- package/dist/cjs/tokens/palette.js +23 -0
- package/dist/cjs/tokens/semantic.js +7 -0
- package/dist/contextmenu.css +1 -1
- package/dist/contextmenu.layered.css +1 -1
- package/dist/dialog.css +1 -1
- package/dist/dialog.layered.css +1 -1
- package/dist/esm/components/badge/badge.recipe.js +11 -5
- package/dist/esm/components/contextmenu/contextmenu.js +25 -8
- package/dist/esm/components/contextmenu/contextmenu.recipe.js +5 -2
- package/dist/esm/components/dialog/dialog.js +1 -1
- package/dist/esm/components/dialog/dialog.recipe.js +1 -1
- package/dist/esm/components/fileupload/fileupload.js +2 -2
- package/dist/esm/components/menu/menu.js +21 -4
- package/dist/esm/components/menu/menu.recipe.js +4 -3
- package/dist/esm/components/multiselect/multiselect.js +30 -13
- package/dist/esm/components/multiselect/multiselect.recipe.js +17 -5
- package/dist/esm/components/popover/popover.js +26 -9
- package/dist/esm/components/popover/popover.recipe.js +5 -3
- package/dist/esm/components/select/select.js +33 -12
- package/dist/esm/components/select/select.recipe.js +19 -4
- package/dist/esm/components/tabletoolbar/tabletoolbar.js +51 -7
- package/dist/esm/core/floating-portal.js +25 -0
- package/dist/esm/core/floating-stack.js +50 -0
- package/dist/esm/core/index.js +2 -0
- package/dist/esm/product/recordstable/recordstable.js +16 -5
- package/dist/esm/tokens/palette.js +23 -0
- package/dist/esm/tokens/semantic.js +7 -0
- package/dist/menu.css +1 -1
- package/dist/menu.layered.css +1 -1
- package/dist/multiselect.css +7 -1
- package/dist/multiselect.d.ts +2 -2
- package/dist/multiselect.layered.css +7 -1
- package/dist/native/tokens.d.ts +1 -1
- package/dist/native/tokens.js +24 -4
- package/dist/popover.css +1 -1
- package/dist/popover.layered.css +1 -1
- package/dist/select.css +7 -1
- package/dist/select.d.ts +2 -1
- package/dist/select.layered.css +7 -1
- package/dist/styles.css +33 -11
- package/dist/styles.layered.css +33 -11
- package/dist/tokens.css +10 -0
- package/dist/tokens.json +24 -4
- package/dist/types/components/fileupload/fileupload.d.ts +1 -1
- package/dist/types/components/multiselect/multiselect.d.ts +3 -1
- package/dist/types/components/multiselect/multiselect.recipe.d.ts +18 -3
- package/dist/types/components/select/select.d.ts +4 -1
- package/dist/types/components/select/select.recipe.d.ts +12 -0
- package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +10 -1
- package/dist/types/core/floating-portal.d.ts +25 -0
- package/dist/types/core/floating-stack.d.ts +13 -0
- package/dist/types/core/index.d.ts +2 -0
- package/dist/types/product/sidebar/index.d.ts +1 -1
- package/dist/types/tokens/contract.d.ts +5 -0
- package/dist/types/tokens/palette.d.ts +20 -0
- package/dist/types/tokens/semantic.d.ts +7 -0
- package/package.json +1 -1
package/dist/badge.css
CHANGED
|
@@ -3,19 +3,19 @@
|
|
|
3
3
|
& .crisp-badge-dot { width: 6px; height: 6px; flex-shrink: 0; border-radius: var(--crisp-radius-full); background: currentColor; }
|
|
4
4
|
}
|
|
5
5
|
.crisp-badge--tone_neutral {
|
|
6
|
-
background: var(--crisp-bg-raised); color: var(--crisp-fg-
|
|
6
|
+
background: var(--crisp-bg-raised); color: var(--crisp-fg-neutral-soft); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
|
|
7
7
|
}
|
|
8
8
|
.crisp-badge--tone_brand {
|
|
9
|
-
background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-
|
|
9
|
+
background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-fg-brand-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
|
|
10
10
|
}
|
|
11
11
|
.crisp-badge--tone_success {
|
|
12
|
-
background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-
|
|
12
|
+
background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-fg-success-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
|
|
13
13
|
}
|
|
14
14
|
.crisp-badge--tone_warning {
|
|
15
|
-
background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-
|
|
15
|
+
background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-fg-warning-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
|
|
16
16
|
}
|
|
17
17
|
.crisp-badge--tone_danger {
|
|
18
|
-
background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-
|
|
18
|
+
background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-fg-danger-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
|
|
19
19
|
}
|
|
20
20
|
.crisp-badge--size_small {
|
|
21
21
|
height: 16px; padding-inline: var(--crisp-space-1-5); font-size: var(--crisp-text-xs); border-radius: var(--crisp-radius-sm);
|
package/dist/badge.layered.css
CHANGED
|
@@ -4,19 +4,19 @@
|
|
|
4
4
|
& .crisp-badge-dot { width: 6px; height: 6px; flex-shrink: 0; border-radius: var(--crisp-radius-full); background: currentColor; }
|
|
5
5
|
}
|
|
6
6
|
.crisp-badge--tone_neutral {
|
|
7
|
-
background: var(--crisp-bg-raised); color: var(--crisp-fg-
|
|
7
|
+
background: var(--crisp-bg-raised); color: var(--crisp-fg-neutral-soft); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
|
|
8
8
|
}
|
|
9
9
|
.crisp-badge--tone_brand {
|
|
10
|
-
background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-
|
|
10
|
+
background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-fg-brand-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
|
|
11
11
|
}
|
|
12
12
|
.crisp-badge--tone_success {
|
|
13
|
-
background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-
|
|
13
|
+
background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-fg-success-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
|
|
14
14
|
}
|
|
15
15
|
.crisp-badge--tone_warning {
|
|
16
|
-
background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-
|
|
16
|
+
background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-fg-warning-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
|
|
17
17
|
}
|
|
18
18
|
.crisp-badge--tone_danger {
|
|
19
|
-
background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-
|
|
19
|
+
background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-fg-danger-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
|
|
20
20
|
}
|
|
21
21
|
.crisp-badge--size_small {
|
|
22
22
|
height: 16px; padding-inline: var(--crisp-space-1-5); font-size: var(--crisp-text-xs); border-radius: var(--crisp-radius-sm);
|
|
@@ -32,29 +32,35 @@ const spec = (0, core_1.defineSpec)({
|
|
|
32
32
|
],
|
|
33
33
|
variants: {
|
|
34
34
|
tone: {
|
|
35
|
+
// #323: the soft text colour is a dedicated fg*Soft token, not the
|
|
36
|
+
// tone's own accent (fgMuted/bgBrandSolid/bgSuccess/bgWarning/bgDanger)
|
|
37
|
+
// — reusing the accent as soft text is exactly what measured below AA
|
|
38
|
+
// (2.97-4.40 in light; brand also failed in dark, 3.83). Background and
|
|
39
|
+
// ring are unchanged: they were never the problem, and neither is a
|
|
40
|
+
// measured Attio value that moving would help — only the text is.
|
|
35
41
|
neutral: {
|
|
36
42
|
background: tokens_1.contract.bgRaised,
|
|
37
|
-
color: tokens_1.contract.
|
|
43
|
+
color: tokens_1.contract.fgNeutralSoft,
|
|
38
44
|
boxShadow: `inset 0 0 0 1px ${tokens_1.contract.borderDefault}`,
|
|
39
45
|
},
|
|
40
46
|
brand: {
|
|
41
47
|
background: soft(tokens_1.contract.bgBrandSolid, 14),
|
|
42
|
-
color: tokens_1.contract.
|
|
48
|
+
color: tokens_1.contract.fgBrandSoft,
|
|
43
49
|
boxShadow: ring(tokens_1.contract.bgBrandSolid, 30),
|
|
44
50
|
},
|
|
45
51
|
success: {
|
|
46
52
|
background: soft(tokens_1.contract.bgSuccess, 14),
|
|
47
|
-
color: tokens_1.contract.
|
|
53
|
+
color: tokens_1.contract.fgSuccessSoft,
|
|
48
54
|
boxShadow: ring(tokens_1.contract.bgSuccess, 30),
|
|
49
55
|
},
|
|
50
56
|
warning: {
|
|
51
57
|
background: soft(tokens_1.contract.bgWarning, 16),
|
|
52
|
-
color: tokens_1.contract.
|
|
58
|
+
color: tokens_1.contract.fgWarningSoft,
|
|
53
59
|
boxShadow: ring(tokens_1.contract.bgWarning, 32),
|
|
54
60
|
},
|
|
55
61
|
danger: {
|
|
56
62
|
background: soft(tokens_1.contract.bgDanger, 14),
|
|
57
|
-
color: tokens_1.contract.
|
|
63
|
+
color: tokens_1.contract.fgDangerSoft,
|
|
58
64
|
boxShadow: ring(tokens_1.contract.bgDanger, 30),
|
|
59
65
|
},
|
|
60
66
|
},
|
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.ContextMenu = ContextMenu;
|
|
37
37
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
38
38
|
const React = __importStar(require("react"));
|
|
39
|
+
const core_1 = require("../../core");
|
|
39
40
|
const menu_1 = require("../menu");
|
|
40
41
|
const contextmenu_recipe_1 = require("./contextmenu.recipe");
|
|
41
42
|
/**
|
|
@@ -77,12 +78,28 @@ function ContextMenu({ children, items, menuLabel = "Context menu", className, }
|
|
|
77
78
|
dismiss: () => close(),
|
|
78
79
|
contentRef: menuRef,
|
|
79
80
|
});
|
|
81
|
+
// Registers the (now-portaled) menu on the shared floating-layer stack
|
|
82
|
+
// (#339) so this doesn't get closed out from under itself when it holds
|
|
83
|
+
// another floating layer -- see core/floating-stack.ts.
|
|
84
|
+
React.useEffect(() => {
|
|
85
|
+
if (!pos || !menuRef.current)
|
|
86
|
+
return;
|
|
87
|
+
return (0, core_1.registerFloatingLayer)(menuRef.current);
|
|
88
|
+
}, [pos]);
|
|
80
89
|
React.useEffect(() => {
|
|
81
90
|
if (!pos)
|
|
82
91
|
return;
|
|
83
92
|
const onDown = (e) => {
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
// menuRef too (#339): the menu content portals to document.body, so
|
|
94
|
+
// it's no longer a descendant of rootRef.
|
|
95
|
+
const target = e.target;
|
|
96
|
+
if (rootRef.current?.contains(target))
|
|
97
|
+
return;
|
|
98
|
+
if (menuRef.current?.contains(target))
|
|
99
|
+
return;
|
|
100
|
+
if ((0, core_1.isInsideLaterFloatingLayer)(target, menuRef.current))
|
|
101
|
+
return;
|
|
102
|
+
close();
|
|
86
103
|
};
|
|
87
104
|
const onKey = (e) => e.key === "Escape" && close(true);
|
|
88
105
|
document.addEventListener("pointerdown", onDown);
|
|
@@ -92,11 +109,11 @@ function ContextMenu({ children, items, menuLabel = "Context menu", className, }
|
|
|
92
109
|
document.removeEventListener("keydown", onKey);
|
|
93
110
|
};
|
|
94
111
|
}, [pos, close]);
|
|
95
|
-
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, contextmenu_recipe_1.contextmenuRecipe)({ className }), children: [(0, jsx_runtime_1.jsx)("div", { onContextMenu: open, children: children }), pos && ((0, jsx_runtime_1.jsx)("div", { ref: menuRef, className: "crisp-contextmenu-content", role: "menu", "aria-label": menuLabel, style: { left: pos.x, top: pos.y }, onKeyDown: onMenuKeyDown, children: items.map((item, i) => item.separator ? ((0, jsx_runtime_1.jsx)("hr", { className: "crisp-contextmenu-sep" }, i)) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
112
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, contextmenu_recipe_1.contextmenuRecipe)({ className }), children: [(0, jsx_runtime_1.jsx)("div", { onContextMenu: open, children: children }), pos && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsx)("div", { ref: menuRef, className: "crisp-contextmenu-content", role: "menu", "aria-label": menuLabel, style: { left: pos.x, top: pos.y }, onKeyDown: onMenuKeyDown, children: items.map((item, i) => item.separator ? ((0, jsx_runtime_1.jsx)("hr", { className: "crisp-contextmenu-sep" }, i)) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
|
|
113
|
+
? "crisp-contextmenu-item crisp-contextmenu-item--danger"
|
|
114
|
+
: "crisp-contextmenu-item", tabIndex: -1, onClick: () => {
|
|
115
|
+
item.onSelect?.();
|
|
116
|
+
close(true);
|
|
117
|
+
}, children: [item.icon, item.label, item.shortcut && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-contextmenu-shortcut", children: item.shortcut }))] }, i))) }) }))] }));
|
|
101
118
|
}
|
|
102
119
|
ContextMenu.displayName = "ContextMenu";
|
|
@@ -3,11 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.contextmenuRecipe = void 0;
|
|
4
4
|
const core_1 = require("../../core");
|
|
5
5
|
const tokens_1 = require("../../tokens");
|
|
6
|
-
// A right-click menu on the measured popover surface, positioned at the
|
|
6
|
+
// A right-click menu on the measured popover surface, positioned at the
|
|
7
|
+
// cursor. The content portals to document.body (#339) so no ancestor's
|
|
8
|
+
// transform/backdrop-filter can capture its position: fixed box; z-index 70
|
|
9
|
+
// keeps it above Dialog/Drawer's 60/61.
|
|
7
10
|
const config = (0, core_1.defineRecipe)({
|
|
8
11
|
name: "contextmenu",
|
|
9
12
|
base: `display: contents; font-family: ${tokens_1.contract.font.sans};
|
|
10
|
-
& .crisp-contextmenu-content { position: fixed; z-index:
|
|
13
|
+
& .crisp-contextmenu-content { position: fixed; z-index: 70; min-width: 200px; box-sizing: border-box; padding: ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover}; box-shadow: ${tokens_1.elevation.floating}; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
|
|
11
14
|
& .crisp-contextmenu-item { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; width: 100%; height: ${tokens_1.contract.size.menuRow}; box-sizing: border-box; padding: 0 ${tokens_1.contract.space["2"]}; margin: 0; border: none; border-radius: ${tokens_1.contract.radius.md}; background: none; color: ${tokens_1.contract.fgPrimary}; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; text-align: start; white-space: nowrap; cursor: pointer; }
|
|
12
15
|
& .crisp-contextmenu-item svg { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; }
|
|
13
16
|
& .crisp-contextmenu-item:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 6%, transparent); }
|
|
@@ -79,7 +79,7 @@ function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-label": ar
|
|
|
79
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: "crisp-dialog-backdrop" }), (0, jsx_runtime_1.jsx)("div", { ...api.getPositionerProps(), className: "crisp-dialog-positioner", children: (0, jsx_runtime_1.jsx)("div", { ...rest, ...(0, core_1.withDescribedBy)(api.getContentProps(), describedBy), className: (0, core_1.cx)("crisp-dialog-content", className), "data-crisp-dialog": id,
|
|
80
80
|
// `width` is the component's own knob, so it wins over a passed
|
|
81
81
|
// maxWidth; everything else a consumer sets survives
|
|
82
|
-
style: { ...style, maxWidth: `min(${width}px, 90vw)` }, children: (0, jsx_runtime_1.jsxs)("div", { className: "crisp-dialog-card", children: [title != null && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-dialog-header", children: [icon, (0, jsx_runtime_1.jsx)("
|
|
82
|
+
style: { ...style, maxWidth: `min(${width}px, 90vw)` }, children: (0, jsx_runtime_1.jsxs)("div", { className: "crisp-dialog-card", children: [title != null && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-dialog-header", children: [icon, (0, jsx_runtime_1.jsx)("h2", { ...api.getTitleProps(), className: "crisp-dialog-title", children: title }), (0, jsx_runtime_1.jsx)("button", { type: "button", ...api.getCloseTriggerProps(), className: "crisp-dialog-close", "aria-label": closeLabel, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] })), children] }) }) })] }) }));
|
|
83
83
|
}
|
|
84
84
|
Dialog.displayName = "Dialog";
|
|
85
85
|
function DialogBody({ children, className, }) {
|
|
@@ -17,7 +17,7 @@ const config = (0, core_1.defineRecipe)({
|
|
|
17
17
|
& .crisp-dialog-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; }
|
|
18
18
|
& .crisp-dialog-header { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; flex-shrink: 0; height: 52px; padding: 0 ${tokens_1.contract.space["2"]} 0 ${tokens_1.contract.space["4"]}; border-bottom: 1px solid ${tokens_1.contract.borderSubtle}; }
|
|
19
19
|
& .crisp-dialog-header svg { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; }
|
|
20
|
-
& .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgPrimary}; }
|
|
20
|
+
& .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgPrimary}; margin: 0; }
|
|
21
21
|
& .crisp-dialog-close { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: ${tokens_1.contract.radius.md}; background: none; color: ${tokens_1.contract.fgMuted}; font-family: inherit; cursor: pointer; }
|
|
22
22
|
& .crisp-dialog-close:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 6%, transparent); color: ${tokens_1.contract.fgPrimary}; }
|
|
23
23
|
& .crisp-dialog-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: ${tokens_1.contract.space["4"]}; padding: ${tokens_1.contract.space["5"]} ${tokens_1.contract.space["4"]}; }
|
|
@@ -73,7 +73,7 @@ function formatSize(bytes) {
|
|
|
73
73
|
* is theirs by construction. Controlled or uncontrolled, the same way every
|
|
74
74
|
* other crisp component is.
|
|
75
75
|
*/
|
|
76
|
-
function FileUpload({ className, labels, "aria-label": ariaLabel, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
|
|
76
|
+
function FileUpload({ className, labels, "aria-label": ariaLabel, id, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
|
|
77
77
|
const props = {
|
|
78
78
|
accept,
|
|
79
79
|
disabled: disabledProp,
|
|
@@ -124,7 +124,7 @@ function FileUpload({ className, labels, "aria-label": ariaLabel, accept, disabl
|
|
|
124
124
|
// focus should land next.
|
|
125
125
|
const rootRef = React.useRef(null);
|
|
126
126
|
const focusAfterRemoval = (0, core_1.useFocusAfterRemoval)(rootRef, ".crisp-fileupload-remove", dropzoneRef);
|
|
127
|
-
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, ...rest, className: (0, fileupload_recipe_1.fileuploadRecipe)({ className }), children: [(0, jsx_runtime_1.jsxs)("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-title", children: t.title }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-hint", children: t.hint })] }), (0, jsx_runtime_1.jsx)("input", { ...hiddenInputProps, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => ((0, jsx_runtime_1.jsxs)("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
|
|
127
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, ...rest, className: (0, fileupload_recipe_1.fileuploadRecipe)({ className }), children: [(0, jsx_runtime_1.jsxs)("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-title", children: t.title }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-hint", children: t.hint })] }), (0, jsx_runtime_1.jsx)("input", { ...hiddenInputProps, id: id ?? hiddenInputProps.id, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => ((0, jsx_runtime_1.jsxs)("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
|
|
128
128
|
focusAfterRemoval(i);
|
|
129
129
|
removeFileEntry(entry.id);
|
|
130
130
|
}, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2 }) })] }, entry.id))) })), (0, jsx_runtime_1.jsx)("ul", { className: "crisp-fileupload-errors", "aria-live": "polite", children: rejections.map((r) => ((0, jsx_runtime_1.jsx)("li", { className: "crisp-fileupload-error", children: `${r.file.name}: ${r.errors.map((e) => t.errors[e] ?? e).join(" ")}` }, r.file.name))) })] }));
|
|
@@ -98,13 +98,30 @@ function Menu({ trigger, children, align = "start", open: openProp, defaultOpen,
|
|
|
98
98
|
setOpen(false);
|
|
99
99
|
triggerRef.current?.focus?.();
|
|
100
100
|
}, [setOpen]);
|
|
101
|
+
// Registers the (now-portaled) content on the shared floating-layer stack
|
|
102
|
+
// (#339) so this doesn't get closed out from under itself when it holds
|
|
103
|
+
// another floating layer (a Select in a sort popover, e.g.) -- see
|
|
104
|
+
// core/floating-stack.ts.
|
|
105
|
+
React.useEffect(() => {
|
|
106
|
+
if (!open || !contentRef.current)
|
|
107
|
+
return;
|
|
108
|
+
return (0, core_1.registerFloatingLayer)(contentRef.current);
|
|
109
|
+
}, [open]);
|
|
101
110
|
// close on outside pointer-down
|
|
102
111
|
React.useEffect(() => {
|
|
103
112
|
if (!open)
|
|
104
113
|
return;
|
|
105
114
|
const onDown = (e) => {
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
// contentRef too (#339): the menu content portals to document.body, so
|
|
116
|
+
// it's no longer a descendant of rootRef.
|
|
117
|
+
const target = e.target;
|
|
118
|
+
if (rootRef.current?.contains(target))
|
|
119
|
+
return;
|
|
120
|
+
if (contentRef.current?.contains(target))
|
|
121
|
+
return;
|
|
122
|
+
if ((0, core_1.isInsideLaterFloatingLayer)(target, contentRef.current))
|
|
123
|
+
return;
|
|
124
|
+
setOpen(false);
|
|
108
125
|
};
|
|
109
126
|
document.addEventListener("pointerdown", onDown);
|
|
110
127
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
@@ -128,7 +145,7 @@ function Menu({ trigger, children, align = "start", open: openProp, defaultOpen,
|
|
|
128
145
|
setOpen(!open);
|
|
129
146
|
},
|
|
130
147
|
});
|
|
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: "crisp-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 }) }))] }));
|
|
148
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, menu_recipe_1.menuRecipe)({ className }), children: [triggerEl, open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsx)(MenuContext.Provider, { value: { close }, children: (0, jsx_runtime_1.jsx)("div", { ref: contentRef, className: "crisp-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 }) }) }))] }));
|
|
132
149
|
}
|
|
133
150
|
Menu.displayName = "Menu";
|
|
134
151
|
function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
|
|
@@ -6,8 +6,9 @@ const tokens_1 = require("../../tokens");
|
|
|
6
6
|
// A dropdown menu — same popover surface used for ⋯ / sort / filter
|
|
7
7
|
// menus (measured from listboxes): radius 12, 4px padding, a layered
|
|
8
8
|
// soft shadow, over 32px items (radius 8, icon + label + optional shortcut /
|
|
9
|
-
// check). The content
|
|
10
|
-
//
|
|
9
|
+
// check). The content portals to document.body (#339) so no ancestor's
|
|
10
|
+
// transform/backdrop-filter can capture its position: fixed box; z-index 70
|
|
11
|
+
// keeps it above Dialog/Drawer's 60/61.
|
|
11
12
|
const config = (0, core_1.defineRecipe)({
|
|
12
13
|
name: "menu",
|
|
13
14
|
base: `
|
|
@@ -15,7 +16,7 @@ const config = (0, core_1.defineRecipe)({
|
|
|
15
16
|
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
16
17
|
|
|
17
18
|
& .crisp-menu-content {
|
|
18
|
-
position: fixed; z-index:
|
|
19
|
+
position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space.px};
|
|
19
20
|
min-width: 200px; max-height: 360px; overflow-y: auto; padding: ${tokens_1.contract.space["1"]};
|
|
20
21
|
border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover};
|
|
21
22
|
box-shadow: ${tokens_1.elevation.floating};
|
|
@@ -69,7 +69,7 @@ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, n
|
|
|
69
69
|
// The id belongs on the trigger, not the wrapper: `<label for>` only names a
|
|
70
70
|
// LABELABLE element, so an id left on the root div meant a FormField label
|
|
71
71
|
// above this control named nothing (#247). Select already did this.
|
|
72
|
-
id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
|
|
72
|
+
id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, ...rest }) {
|
|
73
73
|
// Virtual focus: DOM focus stays on the trigger (or the search field) and the
|
|
74
74
|
// active option is named by `aria-activedescendant`. `data-highlighted` is a
|
|
75
75
|
// styling hook only -- a screen reader cannot see it.
|
|
@@ -82,6 +82,9 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
|
|
|
82
82
|
const [query, setQuery] = React.useState("");
|
|
83
83
|
const [highlight, setHighlight] = React.useState(0);
|
|
84
84
|
const rootRef = React.useRef(null);
|
|
85
|
+
// The listbox portals to document.body (#339), so it's no longer a
|
|
86
|
+
// descendant of rootRef -- outside-click detection needs this ref too.
|
|
87
|
+
const listboxRef = React.useRef(null);
|
|
85
88
|
// `form.reset()` restores native controls; a button + listbox holds its value
|
|
86
89
|
// in React, so it has to listen (#245). Controlled multiselects are left
|
|
87
90
|
// alone.
|
|
@@ -155,30 +158,44 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
|
|
|
155
158
|
// Tab is already moving focus out; `close` would drag it back to the trigger
|
|
156
159
|
dismiss: () => setOpen(false),
|
|
157
160
|
});
|
|
161
|
+
// Registers the (now-portaled) listbox on the shared floating-layer stack
|
|
162
|
+
// (#339) so this doesn't get closed out from under itself when nested
|
|
163
|
+
// inside another floating layer -- see Select's matching comment.
|
|
164
|
+
React.useEffect(() => {
|
|
165
|
+
if (!open || !listboxRef.current)
|
|
166
|
+
return;
|
|
167
|
+
return (0, core_1.registerFloatingLayer)(listboxRef.current);
|
|
168
|
+
}, [open]);
|
|
158
169
|
React.useEffect(() => {
|
|
159
170
|
if (!open)
|
|
160
171
|
return;
|
|
161
172
|
const onDown = (e) => {
|
|
162
|
-
|
|
163
|
-
|
|
173
|
+
const target = e.target;
|
|
174
|
+
if (rootRef.current?.contains(target))
|
|
175
|
+
return;
|
|
176
|
+
if (listboxRef.current?.contains(target))
|
|
177
|
+
return;
|
|
178
|
+
if ((0, core_1.isInsideLaterFloatingLayer)(target, listboxRef.current))
|
|
179
|
+
return;
|
|
180
|
+
setOpen(false);
|
|
164
181
|
};
|
|
165
182
|
document.addEventListener("pointerdown", onDown);
|
|
166
183
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
167
184
|
}, [open]);
|
|
168
|
-
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ className }), ...rest, children: [name &&
|
|
169
|
-
(value.length > 0 ? value : required ? [""] : []).map((v) => ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
|
|
185
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ invalid, className }), ...rest, children: [name &&
|
|
186
|
+
(value.length > 0 ? value : required ? [""] : []).map((v) => ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
|
|
170
187
|
if (open && !searchable)
|
|
171
188
|
onNavKeys(e);
|
|
172
189
|
else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
|
|
173
190
|
e.preventDefault();
|
|
174
191
|
openMenu();
|
|
175
192
|
}
|
|
176
|
-
}, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-multiselect-chips", children: selectedOptions.length === 0 ? ((0, jsx_runtime_1.jsx)("span", { className: "crisp-multiselect-placeholder", children: placeholder })) : (selectedOptions.map((o) => (0, jsx_runtime_1.jsx)(Chip, { opt: o }, o.value))) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-multiselect-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsxs)("div", { id: listboxId, className: "crisp-multiselect-listbox", role: "listbox", "aria-multiselectable": "true", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-multiselect-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
193
|
+
}, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-multiselect-chips", children: selectedOptions.length === 0 ? ((0, jsx_runtime_1.jsx)("span", { className: "crisp-multiselect-placeholder", children: placeholder })) : (selectedOptions.map((o) => (0, jsx_runtime_1.jsx)(Chip, { opt: o }, o.value))) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-multiselect-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsxs)("div", { ref: listboxRef, id: listboxId, className: "crisp-multiselect-listbox", role: "listbox", "aria-multiselectable": "true", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-multiselect-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
|
|
194
|
+
setQuery(e.target.value);
|
|
195
|
+
setHighlight(0);
|
|
196
|
+
}, onKeyDown: onNavKeys })] })), filtered.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "crisp-multiselect-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
|
|
197
|
+
const sel = selectedSet.has(opt.value);
|
|
198
|
+
return ((0, jsx_runtime_1.jsxs)("button", { type: "button", id: optionId(i), role: "option", "aria-selected": sel, tabIndex: -1, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-multiselect-option", onClick: () => toggle(opt.value), onMouseEnter: () => setHighlight(i), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-multiselect-option-chip", children: (0, jsx_runtime_1.jsx)(Chip, { opt: opt }) }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-multiselect-check", children: sel && (0, jsx_runtime_1.jsx)(lucide_react_1.Check, { size: 12, strokeWidth: 3 }) })] }, opt.value));
|
|
199
|
+
}))] }) }))] }));
|
|
183
200
|
}
|
|
184
201
|
MultiSelect.displayName = "MultiSelect";
|
|
@@ -6,8 +6,9 @@ const tokens_1 = require("../../tokens");
|
|
|
6
6
|
// A multi-select / tag editor — measured from a cell tag picker: an
|
|
7
7
|
// outlined trigger showing the selected values as coloured chips, opening a
|
|
8
8
|
// popover with a search field over rows of "[coloured chip] … [checkbox]" that
|
|
9
|
-
// toggle on click. The popover
|
|
10
|
-
//
|
|
9
|
+
// toggle on click. The popover portals to document.body (#339) so no
|
|
10
|
+
// ancestor's transform/backdrop-filter can capture its position: fixed box;
|
|
11
|
+
// z-index 70 keeps it above Dialog/Drawer's 60/61.
|
|
11
12
|
const config = (0, core_1.defineRecipe)({
|
|
12
13
|
name: "multiselect",
|
|
13
14
|
base: `
|
|
@@ -30,7 +31,7 @@ const config = (0, core_1.defineRecipe)({
|
|
|
30
31
|
& .crisp-multiselect-trigger[data-open] .crisp-multiselect-chevron { transform: rotate(180deg); }
|
|
31
32
|
|
|
32
33
|
& .crisp-multiselect-listbox {
|
|
33
|
-
position: fixed; z-index:
|
|
34
|
+
position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space.px};
|
|
34
35
|
max-height: 300px; overflow-y: auto; padding: ${tokens_1.contract.space["1"]};
|
|
35
36
|
border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover};
|
|
36
37
|
box-shadow: ${tokens_1.elevation.floating};
|
|
@@ -61,8 +62,19 @@ const config = (0, core_1.defineRecipe)({
|
|
|
61
62
|
& .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, ${tokens_1.contract.bgCyan} 18%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgCyan} 82%, ${tokens_1.contract.fgPrimary}); }
|
|
62
63
|
& .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, ${tokens_1.contract.bgOrange} 16%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgOrange} 82%, ${tokens_1.contract.fgPrimary}); }
|
|
63
64
|
`,
|
|
64
|
-
variants: {
|
|
65
|
-
|
|
65
|
+
variants: {
|
|
66
|
+
// Same ring Select's `invalid` draws — see that recipe's comment.
|
|
67
|
+
invalid: {
|
|
68
|
+
true: `
|
|
69
|
+
& .crisp-multiselect-trigger,
|
|
70
|
+
& .crisp-multiselect-trigger:hover,
|
|
71
|
+
& .crisp-multiselect-trigger[data-open],
|
|
72
|
+
& .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgDanger}; }
|
|
73
|
+
`,
|
|
74
|
+
false: ``,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
defaultVariants: { invalid: false },
|
|
66
78
|
compoundVariants: [],
|
|
67
79
|
});
|
|
68
80
|
exports.multiselectRecipe = Object.assign((0, core_1.resolveRecipe)(config), {
|
|
@@ -55,6 +55,9 @@ function Popover({ trigger, children, align = "start", open: openProp, defaultOp
|
|
|
55
55
|
const open = openProp ?? internalOpen;
|
|
56
56
|
const rootRef = React.useRef(null);
|
|
57
57
|
const triggerRef = React.useRef(null);
|
|
58
|
+
// The content portals to document.body (#339), so it's no longer a
|
|
59
|
+
// descendant of rootRef -- outside-click detection needs this ref too.
|
|
60
|
+
const contentRef = React.useRef(null);
|
|
58
61
|
const [pos, setPos] = React.useState({});
|
|
59
62
|
const [up, setUp] = React.useState(false);
|
|
60
63
|
const setOpen = React.useCallback((next) => {
|
|
@@ -82,12 +85,26 @@ function Popover({ trigger, children, align = "start", open: openProp, defaultOp
|
|
|
82
85
|
if (open)
|
|
83
86
|
place();
|
|
84
87
|
}, [open]);
|
|
88
|
+
// Registers the (now-portaled) content on the shared floating-layer stack
|
|
89
|
+
// (#339) so this doesn't get closed out from under itself when it holds
|
|
90
|
+
// another floating layer -- see core/floating-stack.ts.
|
|
91
|
+
React.useEffect(() => {
|
|
92
|
+
if (!open || !contentRef.current)
|
|
93
|
+
return;
|
|
94
|
+
return (0, core_1.registerFloatingLayer)(contentRef.current);
|
|
95
|
+
}, [open]);
|
|
85
96
|
React.useEffect(() => {
|
|
86
97
|
if (!open)
|
|
87
98
|
return;
|
|
88
99
|
const onDown = (e) => {
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
const target = e.target;
|
|
101
|
+
if (rootRef.current?.contains(target))
|
|
102
|
+
return;
|
|
103
|
+
if (contentRef.current?.contains(target))
|
|
104
|
+
return;
|
|
105
|
+
if ((0, core_1.isInsideLaterFloatingLayer)(target, contentRef.current))
|
|
106
|
+
return;
|
|
107
|
+
setOpen(false);
|
|
91
108
|
};
|
|
92
109
|
document.addEventListener("pointerdown", onDown);
|
|
93
110
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
@@ -105,11 +122,11 @@ function Popover({ trigger, children, align = "start", open: openProp, defaultOp
|
|
|
105
122
|
setOpen(!open);
|
|
106
123
|
},
|
|
107
124
|
});
|
|
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: "crisp-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) => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
125
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, popover_recipe_1.popoverRecipe)({ className }), children: [triggerEl, open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsx)("div", { ref: contentRef, className: "crisp-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) => {
|
|
126
|
+
if (e.key === "Escape") {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
setOpen(false);
|
|
129
|
+
}
|
|
130
|
+
}, children: children }) }))] }));
|
|
114
131
|
}
|
|
115
132
|
Popover.displayName = "Popover";
|
|
@@ -5,12 +5,14 @@ const core_1 = require("../../core");
|
|
|
5
5
|
const tokens_1 = require("../../tokens");
|
|
6
6
|
// A generic anchored popover — the same measured surface as Menu / Select
|
|
7
7
|
// (radius 12, 4px padding, layered soft shadow, inset border), but holding
|
|
8
|
-
// arbitrary content.
|
|
9
|
-
//
|
|
8
|
+
// arbitrary content. The content portals to document.body (#339) so no
|
|
9
|
+
// ancestor's transform/backdrop-filter can capture its position: fixed box;
|
|
10
|
+
// z-index 70 keeps it above Dialog/Drawer's 60/61. The display:contents root
|
|
11
|
+
// is what's left in place, so a consumer's own styling of it survives.
|
|
10
12
|
const config = (0, core_1.defineRecipe)({
|
|
11
13
|
name: "popover",
|
|
12
14
|
base: `display: contents; font-family: ${tokens_1.contract.font.sans};
|
|
13
|
-
& .crisp-popover-content { position: fixed; z-index:
|
|
15
|
+
& .crisp-popover-content { position: fixed; z-index: 70; box-sizing: border-box; min-width: 200px; max-width: min(360px, 92vw); padding: ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover}; color: ${tokens_1.contract.fgPrimary}; box-shadow: ${tokens_1.elevation.floating}; opacity: 1; transform: none; transition: opacity 0.08s ease, transform 0.08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
|
|
14
16
|
& .crisp-popover-content[data-align="end"] { transform-origin: top right; }
|
|
15
17
|
& .crisp-popover-content[data-up] { transform-origin: bottom left; }`,
|
|
16
18
|
variants: {},
|
|
@@ -52,7 +52,7 @@ function optionText(o) {
|
|
|
52
52
|
* "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
|
|
53
53
|
* Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
|
|
54
54
|
*/
|
|
55
|
-
function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, id, ...rest }) {
|
|
55
|
+
function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
|
|
56
56
|
// Virtual focus: DOM focus stays on the trigger (or the search field), and the
|
|
57
57
|
// active option is named by `aria-activedescendant`. That is the only thing a
|
|
58
58
|
// screen reader has to go on -- `data-highlighted` is invisible to it -- so the
|
|
@@ -69,6 +69,10 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
|
|
|
69
69
|
const [highlight, setHighlight] = React.useState(0);
|
|
70
70
|
const [query, setQuery] = React.useState("");
|
|
71
71
|
const rootRef = React.useRef(null);
|
|
72
|
+
// The listbox portals to document.body (#339), so it's no longer a
|
|
73
|
+
// descendant of rootRef -- outside-click detection needs this ref too, or
|
|
74
|
+
// every click inside the listbox reads as "outside" and closes it.
|
|
75
|
+
const listboxRef = React.useRef(null);
|
|
72
76
|
// `form.reset()` restores native controls; a button + listbox holds its value
|
|
73
77
|
// in React, so it has to listen (#245). Controlled selects are left alone.
|
|
74
78
|
(0, core_1.useFormReset)(rootRef, () => {
|
|
@@ -146,25 +150,42 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
|
|
|
146
150
|
if (!searchable)
|
|
147
151
|
onNavKeys(e);
|
|
148
152
|
};
|
|
153
|
+
// Registers the (now-portaled) listbox on the shared floating-layer stack
|
|
154
|
+
// (#339) so a Select nested inside another floating layer (a Menu's sort
|
|
155
|
+
// popover, e.g.) doesn't get closed out from under itself: once both
|
|
156
|
+
// portal independently to document.body they're DOM siblings, not
|
|
157
|
+
// ancestor/descendant, so the outer layer's own outside-click check can no
|
|
158
|
+
// longer see "inside me" by DOM containment alone.
|
|
159
|
+
React.useEffect(() => {
|
|
160
|
+
if (!open || !listboxRef.current)
|
|
161
|
+
return;
|
|
162
|
+
return (0, core_1.registerFloatingLayer)(listboxRef.current);
|
|
163
|
+
}, [open]);
|
|
149
164
|
React.useEffect(() => {
|
|
150
165
|
if (!open)
|
|
151
166
|
return;
|
|
152
167
|
const onDown = (e) => {
|
|
153
|
-
|
|
154
|
-
|
|
168
|
+
const target = e.target;
|
|
169
|
+
if (rootRef.current?.contains(target))
|
|
170
|
+
return;
|
|
171
|
+
if (listboxRef.current?.contains(target))
|
|
172
|
+
return;
|
|
173
|
+
if ((0, core_1.isInsideLaterFloatingLayer)(target, listboxRef.current))
|
|
174
|
+
return;
|
|
175
|
+
setOpen(false);
|
|
155
176
|
};
|
|
156
177
|
document.addEventListener("pointerdown", onDown);
|
|
157
178
|
return () => document.removeEventListener("pointerdown", onDown);
|
|
158
179
|
}, [open]);
|
|
159
|
-
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, className }), ...rest, children: [name && (selected || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
|
|
180
|
+
return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
|
|
160
181
|
// controlled by the listbox above; `readOnly` would bar it from
|
|
161
182
|
// validation, which is the only reason this input is not hidden
|
|
162
|
-
onChange: () => { } })), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-select-value", children: selectedOption ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [selectedOption.color && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-placeholder", children: placeholder })) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsxs)("div", { id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-select-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
183
|
+
onChange: () => { } })), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-select-value", children: selectedOption ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [selectedOption.color && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-placeholder", children: placeholder })) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsxs)("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-select-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
|
|
184
|
+
setQuery(e.target.value);
|
|
185
|
+
setHighlight(0);
|
|
186
|
+
}, onKeyDown: onNavKeys })] })), filtered.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
|
|
187
|
+
const isSel = opt.value === selected;
|
|
188
|
+
return ((0, jsx_runtime_1.jsxs)("button", { type: "button", id: optionId(i), role: "option", "aria-selected": isSel, tabIndex: -1, disabled: opt.disabled, "data-disabled": opt.disabled ? "" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-select-option", onClick: () => pick(opt), onMouseEnter: () => setHighlight(i), children: [opt.color && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-dot", style: { background: opt.color } })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-label", children: opt.label }), isSel && ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "crisp-select-option-check", size: 15, strokeWidth: 2 }))] }, opt.value));
|
|
189
|
+
}))] }) }))] }));
|
|
169
190
|
}
|
|
170
191
|
Select.displayName = "Select";
|