@crisp-ui-kit/crisp 0.38.0 → 0.40.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/cjs/components/contextmenu/contextmenu.js +1 -1
- package/dist/cjs/components/contextmenu/contextmenu.recipe.js +22 -10
- package/dist/cjs/components/dialog/dialog.js +12 -0
- package/dist/cjs/components/drawer/drawer.js +7 -0
- package/dist/cjs/components/menu/menu.js +1 -1
- package/dist/cjs/components/menu/menu.recipe.js +32 -20
- package/dist/cjs/components/multiselect/multiselect.js +1 -1
- package/dist/cjs/components/multiselect/multiselect.recipe.js +37 -22
- package/dist/cjs/components/popover/popover.js +1 -1
- package/dist/cjs/components/popover/popover.recipe.js +19 -4
- package/dist/cjs/components/select/select.js +1 -1
- package/dist/cjs/components/select/select.recipe.js +28 -15
- package/dist/cjs/core/floating-portal.js +55 -2
- package/dist/cjs/core/floating-stack.js +21 -0
- package/dist/cjs/core/index.js +2 -1
- package/dist/cjs/product/recordpanel/recordpanel.js +13 -2
- package/dist/contextmenu.css +11 -9
- package/dist/contextmenu.layered.css +11 -9
- package/dist/esm/components/contextmenu/contextmenu.js +1 -1
- package/dist/esm/components/contextmenu/contextmenu.recipe.js +22 -10
- package/dist/esm/components/dialog/dialog.js +13 -1
- package/dist/esm/components/drawer/drawer.js +8 -1
- package/dist/esm/components/menu/menu.js +1 -1
- package/dist/esm/components/menu/menu.recipe.js +32 -20
- package/dist/esm/components/multiselect/multiselect.js +1 -1
- package/dist/esm/components/multiselect/multiselect.recipe.js +37 -22
- package/dist/esm/components/popover/popover.js +1 -1
- package/dist/esm/components/popover/popover.recipe.js +19 -4
- package/dist/esm/components/select/select.js +1 -1
- package/dist/esm/components/select/select.recipe.js +28 -15
- package/dist/esm/core/floating-portal.js +22 -2
- package/dist/esm/core/floating-stack.js +20 -0
- package/dist/esm/core/index.js +1 -1
- package/dist/esm/product/recordpanel/recordpanel.js +13 -2
- package/dist/menu.css +34 -19
- package/dist/menu.layered.css +34 -19
- package/dist/multiselect.css +34 -21
- package/dist/multiselect.layered.css +34 -21
- package/dist/popover.css +5 -3
- package/dist/popover.layered.css +5 -3
- package/dist/select.css +27 -14
- package/dist/select.layered.css +27 -14
- package/dist/styles.css +111 -66
- package/dist/styles.layered.css +111 -66
- package/dist/types/core/floating-portal.d.ts +17 -3
- package/dist/types/core/floating-stack.d.ts +18 -0
- package/dist/types/core/index.d.ts +1 -1
- package/package.json +3 -3
package/dist/contextmenu.css
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
+
|
|
2
|
+
.crisp-contextmenu-content { font-family: var(--crisp-font-sans); position: fixed; z-index: 70; min-width: 200px; box-sizing: border-box; padding: var(--crisp-space-1); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-popover); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); 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); } }
|
|
3
|
+
.crisp-contextmenu-item { display: flex; align-items: center; gap: var(--crisp-space-2); width: 100%; height: var(--crisp-size-menuRow); box-sizing: border-box; padding: 0 var(--crisp-space-2); margin: 0; border: none; border-radius: var(--crisp-radius-md); background: none; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); text-align: start; white-space: nowrap; cursor: pointer; }
|
|
4
|
+
.crisp-contextmenu-item svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
|
|
5
|
+
.crisp-contextmenu-item:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); }
|
|
6
|
+
.crisp-contextmenu-item--danger { color: var(--crisp-bg-danger); }
|
|
7
|
+
.crisp-contextmenu-item--danger svg { color: var(--crisp-bg-danger); }
|
|
8
|
+
.crisp-contextmenu-item--danger:hover { background: color-mix(in srgb, var(--crisp-bg-danger) 10%, transparent); }
|
|
9
|
+
.crisp-contextmenu-shortcut { margin-inline-start: auto; padding-inline-start: var(--crisp-space-3); color: var(--crisp-fg-muted); font-size: var(--crisp-text-sm); }
|
|
10
|
+
.crisp-contextmenu-sep { height: 1px; margin: var(--crisp-space-1) 0; border: none; background: var(--crisp-border-subtle); }
|
|
11
|
+
|
|
1
12
|
.crisp-contextmenu {
|
|
2
13
|
display: contents; font-family: var(--crisp-font-sans);
|
|
3
|
-
& .crisp-contextmenu-content { position: fixed; z-index: 70; min-width: 200px; box-sizing: border-box; padding: var(--crisp-space-1); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-popover); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); 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); } }
|
|
4
|
-
& .crisp-contextmenu-item { display: flex; align-items: center; gap: var(--crisp-space-2); width: 100%; height: var(--crisp-size-menuRow); box-sizing: border-box; padding: 0 var(--crisp-space-2); margin: 0; border: none; border-radius: var(--crisp-radius-md); background: none; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); text-align: start; white-space: nowrap; cursor: pointer; }
|
|
5
|
-
& .crisp-contextmenu-item svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
|
|
6
|
-
& .crisp-contextmenu-item:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); }
|
|
7
|
-
& .crisp-contextmenu-item--danger { color: var(--crisp-bg-danger); }
|
|
8
|
-
& .crisp-contextmenu-item--danger svg { color: var(--crisp-bg-danger); }
|
|
9
|
-
& .crisp-contextmenu-item--danger:hover { background: color-mix(in srgb, var(--crisp-bg-danger) 10%, transparent); }
|
|
10
|
-
& .crisp-contextmenu-shortcut { margin-inline-start: auto; padding-inline-start: var(--crisp-space-3); color: var(--crisp-fg-muted); font-size: var(--crisp-text-sm); }
|
|
11
|
-
& .crisp-contextmenu-sep { height: 1px; margin: var(--crisp-space-1) 0; border: none; background: var(--crisp-border-subtle); }
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
@layer crisp-components {
|
|
2
|
+
|
|
3
|
+
.crisp-contextmenu-content { font-family: var(--crisp-font-sans); position: fixed; z-index: 70; min-width: 200px; box-sizing: border-box; padding: var(--crisp-space-1); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-popover); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); 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); } }
|
|
4
|
+
.crisp-contextmenu-item { display: flex; align-items: center; gap: var(--crisp-space-2); width: 100%; height: var(--crisp-size-menuRow); box-sizing: border-box; padding: 0 var(--crisp-space-2); margin: 0; border: none; border-radius: var(--crisp-radius-md); background: none; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); text-align: start; white-space: nowrap; cursor: pointer; }
|
|
5
|
+
.crisp-contextmenu-item svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
|
|
6
|
+
.crisp-contextmenu-item:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); }
|
|
7
|
+
.crisp-contextmenu-item--danger { color: var(--crisp-bg-danger); }
|
|
8
|
+
.crisp-contextmenu-item--danger svg { color: var(--crisp-bg-danger); }
|
|
9
|
+
.crisp-contextmenu-item--danger:hover { background: color-mix(in srgb, var(--crisp-bg-danger) 10%, transparent); }
|
|
10
|
+
.crisp-contextmenu-shortcut { margin-inline-start: auto; padding-inline-start: var(--crisp-space-3); color: var(--crisp-fg-muted); font-size: var(--crisp-text-sm); }
|
|
11
|
+
.crisp-contextmenu-sep { height: 1px; margin: var(--crisp-space-1) 0; border: none; background: var(--crisp-border-subtle); }
|
|
12
|
+
|
|
2
13
|
.crisp-contextmenu {
|
|
3
14
|
display: contents; font-family: var(--crisp-font-sans);
|
|
4
|
-
& .crisp-contextmenu-content { position: fixed; z-index: 70; min-width: 200px; box-sizing: border-box; padding: var(--crisp-space-1); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-popover); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); 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); } }
|
|
5
|
-
& .crisp-contextmenu-item { display: flex; align-items: center; gap: var(--crisp-space-2); width: 100%; height: var(--crisp-size-menuRow); box-sizing: border-box; padding: 0 var(--crisp-space-2); margin: 0; border: none; border-radius: var(--crisp-radius-md); background: none; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); text-align: start; white-space: nowrap; cursor: pointer; }
|
|
6
|
-
& .crisp-contextmenu-item svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
|
|
7
|
-
& .crisp-contextmenu-item:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); }
|
|
8
|
-
& .crisp-contextmenu-item--danger { color: var(--crisp-bg-danger); }
|
|
9
|
-
& .crisp-contextmenu-item--danger svg { color: var(--crisp-bg-danger); }
|
|
10
|
-
& .crisp-contextmenu-item--danger:hover { background: color-mix(in srgb, var(--crisp-bg-danger) 10%, transparent); }
|
|
11
|
-
& .crisp-contextmenu-shortcut { margin-inline-start: auto; padding-inline-start: var(--crisp-space-3); color: var(--crisp-fg-muted); font-size: var(--crisp-text-sm); }
|
|
12
|
-
& .crisp-contextmenu-sep { height: 1px; margin: var(--crisp-space-1) 0; border: none; background: var(--crisp-border-subtle); }
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -73,7 +73,7 @@ export function ContextMenu({ children, items, menuLabel = "Context menu", class
|
|
|
73
73
|
document.removeEventListener("keydown", onKey);
|
|
74
74
|
};
|
|
75
75
|
}, [pos, close]);
|
|
76
|
-
return (_jsxs("div", { ref: rootRef, className: contextmenuRecipe({ className }), children: [_jsx("div", { onContextMenu: open, children: children }), pos && (_jsx(FloatingPortal, { children: _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 ? (_jsx("hr", { className: "crisp-contextmenu-sep" }, i)) : (_jsxs("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
|
|
76
|
+
return (_jsxs("div", { ref: rootRef, className: contextmenuRecipe({ className }), children: [_jsx("div", { onContextMenu: open, children: children }), pos && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _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 ? (_jsx("hr", { className: "crisp-contextmenu-sep" }, i)) : (_jsxs("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
|
|
77
77
|
? "crisp-contextmenu-item crisp-contextmenu-item--danger"
|
|
78
78
|
: "crisp-contextmenu-item", tabIndex: -1, onClick: () => {
|
|
79
79
|
item.onSelect?.();
|
|
@@ -4,18 +4,30 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
|
|
|
4
4
|
// cursor. The content portals to document.body (#339) so no ancestor's
|
|
5
5
|
// transform/backdrop-filter can capture its position: fixed box; z-index 70
|
|
6
6
|
// keeps it above Dialog/Drawer's 60/61.
|
|
7
|
+
//
|
|
8
|
+
// Every content-related selector below lives in `globalCss`, not nested
|
|
9
|
+
// under `&` (#357): nesting compiles to a real descendant combinator scoped
|
|
10
|
+
// to the recipe's root class, which needs actual DOM containment — exactly
|
|
11
|
+
// what the portal to `document.body` breaks. Nested, these rules silently
|
|
12
|
+
// matched nothing and the menu rendered completely unstyled. `font-family`
|
|
13
|
+
// is restated on the content's own rule (items then pick it up via their
|
|
14
|
+
// own `font-family: inherit`, which still works — that inheritance chain is
|
|
15
|
+
// intact WITHIN the portaled subtree, only the link back to the root
|
|
16
|
+
// broke).
|
|
7
17
|
const config = defineRecipe({
|
|
8
18
|
name: "contextmenu",
|
|
9
|
-
base: `display: contents; font-family: ${c.font.sans}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
base: `display: contents; font-family: ${c.font.sans};`,
|
|
20
|
+
globalCss: `
|
|
21
|
+
.crisp-contextmenu-content { font-family: ${c.font.sans}; position: fixed; z-index: 70; min-width: 200px; box-sizing: border-box; padding: ${c.space["1"]}; border-radius: ${c.radius.xl}; background: ${c.bgPopover}; box-shadow: ${e.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); } }
|
|
22
|
+
.crisp-contextmenu-item { display: flex; align-items: center; gap: ${c.space["2"]}; width: 100%; height: ${c.size.menuRow}; box-sizing: border-box; padding: 0 ${c.space["2"]}; margin: 0; border: none; border-radius: ${c.radius.md}; background: none; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.lg}; text-align: start; white-space: nowrap; cursor: pointer; }
|
|
23
|
+
.crisp-contextmenu-item svg { flex-shrink: 0; color: ${c.fgMuted}; }
|
|
24
|
+
.crisp-contextmenu-item:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
|
|
25
|
+
.crisp-contextmenu-item--danger { color: ${c.bgDanger}; }
|
|
26
|
+
.crisp-contextmenu-item--danger svg { color: ${c.bgDanger}; }
|
|
27
|
+
.crisp-contextmenu-item--danger:hover { background: color-mix(in srgb, ${c.bgDanger} 10%, transparent); }
|
|
28
|
+
.crisp-contextmenu-shortcut { margin-inline-start: auto; padding-inline-start: ${c.space["3"]}; color: ${c.fgMuted}; font-size: ${c.text.sm}; }
|
|
29
|
+
.crisp-contextmenu-sep { height: 1px; margin: ${c.space["1"]} 0; border: none; background: ${c.borderSubtle}; }
|
|
30
|
+
`,
|
|
19
31
|
variants: {},
|
|
20
32
|
defaultVariants: {},
|
|
21
33
|
compoundVariants: [],
|
|
@@ -3,7 +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
|
+
import { cx, hasOpenFloatingLayer, withDescribedBy } from "../../core/index.js";
|
|
7
7
|
import { dialogRecipe } from "./dialog.recipe.js";
|
|
8
8
|
// A globally unique id per mounted instance (see command.tsx for the rationale —
|
|
9
9
|
// the machine defers its dismissal listeners a frame, so tree-relative useId
|
|
@@ -34,6 +34,18 @@ export function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-lab
|
|
|
34
34
|
// search), rather than the close button; fall back to the machine default.
|
|
35
35
|
initialFocusEl: () => document.querySelector(`[data-crisp-dialog="${id}"] .crisp-dialog-body :where(input, textarea, select, [contenteditable]):not([disabled])`),
|
|
36
36
|
"aria-label": typeof title === "string" ? title : ariaLabel,
|
|
37
|
+
// Defer to a nested Menu/Select/Popover/ContextMenu's own Escape
|
|
38
|
+
// handling (#361): none of those five are Zag machines, so this
|
|
39
|
+
// machine's own dismissable-layer stack has no visibility into them --
|
|
40
|
+
// pressing Escape to close one opened from inside this Dialog closed
|
|
41
|
+
// the Dialog too, since both listen for the same keypress independently.
|
|
42
|
+
// Zag only calls its own dismiss when the event isn't already
|
|
43
|
+
// prevented (`@zag-js/dismissable`), so this cleanly defers instead of
|
|
44
|
+
// racing it.
|
|
45
|
+
onEscapeKeyDown(event) {
|
|
46
|
+
if (hasOpenFloatingLayer())
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
},
|
|
37
49
|
});
|
|
38
50
|
const api = dialog.connect(service, normalizeProps);
|
|
39
51
|
if (!api.open)
|
|
@@ -3,7 +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
|
+
import { cx, hasOpenFloatingLayer, withDescribedBy } from "../../core/index.js";
|
|
7
7
|
import { drawerRecipe } from "./drawer.recipe.js";
|
|
8
8
|
let instanceCounter = 0;
|
|
9
9
|
function useDrawerInstanceId() {
|
|
@@ -28,6 +28,13 @@ export function Drawer({ open, onOpenChange, side = "right", title, icon, "aria-
|
|
|
28
28
|
onOpenChange(details.open);
|
|
29
29
|
},
|
|
30
30
|
"aria-label": typeof title === "string" ? title : ariaLabel,
|
|
31
|
+
// Defer to a nested Menu/Select/Popover/ContextMenu's own Escape
|
|
32
|
+
// handling (#361) -- see Dialog's identical comment; this composes the
|
|
33
|
+
// same Zag machine.
|
|
34
|
+
onEscapeKeyDown(event) {
|
|
35
|
+
if (hasOpenFloatingLayer())
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
},
|
|
31
38
|
});
|
|
32
39
|
const api = dialog.connect(service, normalizeProps);
|
|
33
40
|
if (!api.open)
|
|
@@ -105,7 +105,7 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
|
|
|
105
105
|
setOpen(!open);
|
|
106
106
|
},
|
|
107
107
|
});
|
|
108
|
-
return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(FloatingPortal, {
|
|
108
|
+
return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _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: _jsx(MenuContext.Provider, { value: { close }, children: children }) }) }))] }));
|
|
109
109
|
}
|
|
110
110
|
Menu.displayName = "Menu";
|
|
111
111
|
export function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
|
|
@@ -6,43 +6,55 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
|
|
|
6
6
|
// check). The content portals to document.body (#339) so no ancestor's
|
|
7
7
|
// transform/backdrop-filter can capture its position: fixed box; z-index 70
|
|
8
8
|
// keeps it above Dialog/Drawer's 60/61.
|
|
9
|
+
//
|
|
10
|
+
// The menu's floating content and everything inside it live in `globalCss`
|
|
11
|
+
// below, not nested under `&` (#357): nesting compiles to a real descendant
|
|
12
|
+
// combinator scoped to the recipe's root class, which needs actual DOM
|
|
13
|
+
// containment — exactly what the portal to `document.body` breaks. Nested,
|
|
14
|
+
// these rules matched nothing once the content moved and the menu rendered
|
|
15
|
+
// completely unstyled. `font-family`/smoothing are restated on the content's
|
|
16
|
+
// own rule (items then pick font up via their own `font-family: inherit`,
|
|
17
|
+
// which still works — that inheritance is intact WITHIN the portaled
|
|
18
|
+
// subtree, only the link back to the trigger-holding root broke).
|
|
9
19
|
const config = defineRecipe({
|
|
10
20
|
name: "menu",
|
|
11
21
|
base: `
|
|
12
22
|
position: relative; display: inline-flex; font-family: ${c.font.sans};
|
|
13
23
|
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
14
|
-
|
|
15
|
-
|
|
24
|
+
`,
|
|
25
|
+
globalCss: `
|
|
26
|
+
.crisp-menu-content {
|
|
27
|
+
font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
16
28
|
position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
|
|
17
29
|
min-width: 200px; max-height: 360px; overflow-y: auto; padding: ${c.space["1"]};
|
|
18
30
|
border-radius: ${c.radius.xl}; background: ${c.bgPopover};
|
|
19
31
|
box-shadow: ${e.floating};
|
|
20
32
|
transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
|
|
21
33
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
34
|
+
.crisp-menu-content[data-align="end"] { transform-origin: top right; }
|
|
35
|
+
.crisp-menu-content[data-up] { transform-origin: bottom left; }
|
|
36
|
+
.crisp-menu-content[data-up][data-align="end"] { transform-origin: bottom right; }
|
|
37
|
+
.crisp-menu-content { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
|
|
26
38
|
|
|
27
|
-
|
|
39
|
+
.crisp-menu-item {
|
|
28
40
|
box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["1.5"]}; width: 100%; height: ${c.size.menuRow}; flex-shrink: 0;
|
|
29
41
|
margin: 0; padding: 0 ${c.space["2"]}; border: none; background: none; border-radius: ${c.radius.md};
|
|
30
42
|
font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; text-align: start; cursor: pointer; white-space: nowrap;
|
|
31
43
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
.crisp-menu-item svg { flex-shrink: 0; color: ${c.fgMuted}; }
|
|
45
|
+
.crisp-menu-item:focus { outline: none; }
|
|
46
|
+
.crisp-menu-item:hover:not([data-disabled]), .crisp-menu-item:focus-visible:not([data-disabled]), .crisp-menu-item[data-highlighted]:not([data-disabled]) { background: ${c.bgRaised}; }
|
|
47
|
+
.crisp-menu-item[data-disabled] { opacity: 0.5; cursor: not-allowed; }
|
|
48
|
+
.crisp-menu-item--danger { color: ${c.bgDanger}; }
|
|
49
|
+
.crisp-menu-item--danger svg { color: ${c.bgDanger}; }
|
|
50
|
+
.crisp-menu-item--danger:hover, .crisp-menu-item--danger:focus-visible, .crisp-menu-item--danger[data-highlighted] { background: color-mix(in srgb, ${c.bgDanger} 10%, transparent); }
|
|
51
|
+
.crisp-menu-item-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
52
|
+
.crisp-menu-item-shortcut { flex-shrink: 0; margin-inline-start: ${c.space["3"]}; font-size: ${c.text.sm}; color: ${c.fgMuted}; letter-spacing: 0.02em; }
|
|
53
|
+
.crisp-menu-item-check { flex-shrink: 0; color: ${c.bgBrandSolid}; }
|
|
54
|
+
.crisp-menu-item-check--placeholder { width: 15px; }
|
|
43
55
|
|
|
44
|
-
|
|
45
|
-
|
|
56
|
+
.crisp-menu-sep { height: 1px; flex-shrink: 0; border: none; margin: ${c.space["1"]} calc(-1 * ${c.space["1"]}); background: ${c.borderSubtle}; }
|
|
57
|
+
.crisp-menu-label { padding: ${c.space["1.5"]} ${c.space["2"]} ${c.space["0.5"]}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
|
|
46
58
|
`,
|
|
47
59
|
variants: {},
|
|
48
60
|
defaultVariants: {},
|
|
@@ -154,7 +154,7 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
|
|
|
154
154
|
e.preventDefault();
|
|
155
155
|
openMenu();
|
|
156
156
|
}
|
|
157
|
-
}, children: [_jsx("span", { className: "crisp-multiselect-chips", children: selectedOptions.length === 0 ? (_jsx("span", { className: "crisp-multiselect-placeholder", children: placeholder })) : (selectedOptions.map((o) => _jsx(Chip, { opt: o }, o.value))) }), _jsx(ChevronDown, { className: "crisp-multiselect-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { children: _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 && (_jsxs("div", { className: "crisp-multiselect-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
|
|
157
|
+
}, children: [_jsx("span", { className: "crisp-multiselect-chips", children: selectedOptions.length === 0 ? (_jsx("span", { className: "crisp-multiselect-placeholder", children: placeholder })) : (selectedOptions.map((o) => _jsx(Chip, { opt: o }, o.value))) }), _jsx(ChevronDown, { className: "crisp-multiselect-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _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 && (_jsxs("div", { className: "crisp-multiselect-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
|
|
158
158
|
setQuery(e.target.value);
|
|
159
159
|
setHighlight(0);
|
|
160
160
|
}, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-multiselect-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
|
|
@@ -6,6 +6,19 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
|
|
|
6
6
|
// toggle on click. The popover portals to document.body (#339) so no
|
|
7
7
|
// ancestor's transform/backdrop-filter can capture its position: fixed box;
|
|
8
8
|
// z-index 70 keeps it above Dialog/Drawer's 60/61.
|
|
9
|
+
// The listbox and everything inside it (including the recordstable-tag
|
|
10
|
+
// chips, which render inside listbox option rows as well as the trigger)
|
|
11
|
+
// live in `globalCss` below, not nested under `&` (#357): nesting compiles
|
|
12
|
+
// to a real descendant combinator scoped to the recipe's root class, which
|
|
13
|
+
// needs actual DOM containment — exactly what the portal to `document.body`
|
|
14
|
+
// breaks. Nested, these rules matched nothing once the listbox moved and it
|
|
15
|
+
// rendered completely unstyled. The trigger and its own parts stay nested
|
|
16
|
+
// under `&` — the trigger is NOT portaled. `font-family`/smoothing are
|
|
17
|
+
// restated on the listbox's own rule (its children pick font up via their
|
|
18
|
+
// own `font-family: inherit`, which still works — that inheritance is
|
|
19
|
+
// intact WITHIN the portaled subtree, only the link back to the
|
|
20
|
+
// trigger-holding root broke). A flat recordstable-tag selector also works
|
|
21
|
+
// unchanged wherever it's rendered, portaled or not, so it needs no split.
|
|
9
22
|
const config = defineRecipe({
|
|
10
23
|
name: "multiselect",
|
|
11
24
|
base: `
|
|
@@ -26,38 +39,40 @@ const config = defineRecipe({
|
|
|
26
39
|
& .crisp-multiselect-placeholder { color: ${c.fgMuted}; }
|
|
27
40
|
& .crisp-multiselect-chevron { flex-shrink: 0; color: ${c.fgMuted}; transition: transform .15s ease; }
|
|
28
41
|
& .crisp-multiselect-trigger[data-open] .crisp-multiselect-chevron { transform: rotate(180deg); }
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
`,
|
|
43
|
+
globalCss: `
|
|
44
|
+
.crisp-multiselect-listbox {
|
|
45
|
+
font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
31
46
|
position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
|
|
32
47
|
max-height: 300px; overflow-y: auto; padding: ${c.space["1"]};
|
|
33
48
|
border-radius: ${c.radius.xl}; background: ${c.bgPopover};
|
|
34
49
|
box-shadow: ${e.floating};
|
|
35
50
|
transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
|
|
36
51
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
.crisp-multiselect-listbox[data-up] { transform-origin: bottom left; }
|
|
53
|
+
.crisp-multiselect-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
|
|
54
|
+
.crisp-multiselect-search { display: flex; align-items: center; gap: ${c.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${c.space["1"]}) calc(-1 * ${c.space["1"]}) ${c.space["1"]}; padding: 0 ${c.space["2.5"]}; border-bottom: 1px solid ${c.borderSubtle}; color: ${c.fgMuted}; }
|
|
55
|
+
.crisp-multiselect-search svg { flex-shrink: 0; }
|
|
56
|
+
.crisp-multiselect-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; }
|
|
57
|
+
.crisp-multiselect-search input::placeholder { color: ${c.fgMuted}; }
|
|
58
|
+
.crisp-multiselect-option {
|
|
44
59
|
box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.menuRow}; flex-shrink: 0;
|
|
45
60
|
padding: 0 ${c.space["2"]}; margin: 0; border: none; background: none; border-radius: ${c.radius.md}; font-family: inherit; cursor: pointer; text-align: start;
|
|
46
61
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
.crisp-multiselect-option[data-highlighted] { background: ${c.bgRaised}; }
|
|
63
|
+
.crisp-multiselect-option-chip { flex: 0 1 auto; min-width: 0; overflow: hidden; }
|
|
64
|
+
.crisp-multiselect-check { flex-shrink: 0; margin-inline-start: auto; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: ${c.radius.sm}; box-shadow: inset 0 0 0 1.5px ${c.borderDefault}; color: transparent; }
|
|
65
|
+
.crisp-multiselect-option[aria-selected="true"] .crisp-multiselect-check { background: ${c.bgBrandSolid}; box-shadow: none; color: #fff; }
|
|
66
|
+
.crisp-multiselect-empty { padding: ${c.space["3"]} ${c.space["2"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgMuted}; }
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
.crisp-recordstable-tag { display: inline-flex; align-items: center; height: ${c.size.chip}; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.chip}; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: color-mix(in srgb, ${c.fgPrimary} 82%, transparent); }
|
|
69
|
+
.crisp-recordstable-tag[data-tone="brand"] { background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent); color: color-mix(in srgb, ${c.bgBrandSolid} 82%, ${c.fgPrimary}); }
|
|
70
|
+
.crisp-recordstable-tag[data-tone="success"] { background: color-mix(in srgb, ${c.bgSuccess} 16%, transparent); color: color-mix(in srgb, ${c.bgSuccess} 78%, ${c.fgPrimary}); }
|
|
71
|
+
.crisp-recordstable-tag[data-tone="warning"] { background: color-mix(in srgb, ${c.bgWarning} 18%, transparent); color: color-mix(in srgb, ${c.bgWarning} 82%, ${c.fgPrimary}); }
|
|
72
|
+
.crisp-recordstable-tag[data-tone="danger"] { background: color-mix(in srgb, ${c.bgDanger} 15%, transparent); color: color-mix(in srgb, ${c.bgDanger} 80%, ${c.fgPrimary}); }
|
|
73
|
+
.crisp-recordstable-tag[data-tone="purple"] { background: color-mix(in srgb, ${c.bgPurple} 16%, transparent); color: color-mix(in srgb, ${c.bgPurple} 80%, ${c.fgPrimary}); }
|
|
74
|
+
.crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, ${c.bgCyan} 18%, transparent); color: color-mix(in srgb, ${c.bgCyan} 82%, ${c.fgPrimary}); }
|
|
75
|
+
.crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, ${c.bgOrange} 16%, transparent); color: color-mix(in srgb, ${c.bgOrange} 82%, ${c.fgPrimary}); }
|
|
61
76
|
`,
|
|
62
77
|
variants: {
|
|
63
78
|
// Same ring Select's `invalid` draws — see that recipe's comment.
|
|
@@ -86,7 +86,7 @@ export function Popover({ trigger, children, align = "start", open: openProp, de
|
|
|
86
86
|
setOpen(!open);
|
|
87
87
|
},
|
|
88
88
|
});
|
|
89
|
-
return (_jsxs("div", { ref: rootRef, className: popoverRecipe({ className }), children: [triggerEl, open && (_jsx(FloatingPortal, { children: _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) => {
|
|
89
|
+
return (_jsxs("div", { ref: rootRef, className: popoverRecipe({ className }), children: [triggerEl, open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _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) => {
|
|
90
90
|
if (e.key === "Escape") {
|
|
91
91
|
e.preventDefault();
|
|
92
92
|
setOpen(false);
|
|
@@ -6,12 +6,27 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
|
|
|
6
6
|
// ancestor's transform/backdrop-filter can capture its position: fixed box;
|
|
7
7
|
// z-index 70 keeps it above Dialog/Drawer's 60/61. The display:contents root
|
|
8
8
|
// is what's left in place, so a consumer's own styling of it survives.
|
|
9
|
+
//
|
|
10
|
+
// The popover's floating content is declared via `globalCss` below, not
|
|
11
|
+
// nested under `&` (#357): nesting compiles to a genuine descendant
|
|
12
|
+
// combinator scoped to the recipe's root class, and the portal moves the
|
|
13
|
+
// content to a DOM sibling of `document.body`, not a descendant of the root
|
|
14
|
+
// — so a nested rule silently matched nothing and the popover rendered
|
|
15
|
+
// completely unstyled (`position: static`, no background, no shadow).
|
|
16
|
+
// `globalCss` is already the escape hatch for CSS a property can't express
|
|
17
|
+
// (`@keyframes`); a portaled part is the same problem, one selector at a
|
|
18
|
+
// time. Every declaration a portaled selector needs — including ones
|
|
19
|
+
// normally inherited from the root, like `font-family` — has to be restated
|
|
20
|
+
// here, since DOM inheritance from the root is exactly what the portal
|
|
21
|
+
// breaks.
|
|
9
22
|
const config = defineRecipe({
|
|
10
23
|
name: "popover",
|
|
11
|
-
base: `display: contents; font-family: ${c.font.sans}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
24
|
+
base: `display: contents; font-family: ${c.font.sans};`,
|
|
25
|
+
globalCss: `
|
|
26
|
+
.crisp-popover-content { font-family: ${c.font.sans}; position: fixed; z-index: 70; box-sizing: border-box; min-width: 200px; max-width: min(360px, 92vw); padding: ${c.space["1"]}; border-radius: ${c.radius.xl}; background: ${c.bgPopover}; color: ${c.fgPrimary}; box-shadow: ${e.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); } }
|
|
27
|
+
.crisp-popover-content[data-align="end"] { transform-origin: top right; }
|
|
28
|
+
.crisp-popover-content[data-up] { transform-origin: bottom left; }
|
|
29
|
+
`,
|
|
15
30
|
variants: {},
|
|
16
31
|
defaultVariants: {},
|
|
17
32
|
compoundVariants: [],
|
|
@@ -144,7 +144,7 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
|
|
|
144
144
|
return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
|
|
145
145
|
// controlled by the listbox above; `readOnly` would bar it from
|
|
146
146
|
// validation, which is the only reason this input is not hidden
|
|
147
|
-
onChange: () => { } })), _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: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
|
|
147
|
+
onChange: () => { } })), _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: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
|
|
148
148
|
setQuery(e.target.value);
|
|
149
149
|
setHighlight(0);
|
|
150
150
|
}, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
|
|
@@ -8,6 +8,17 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
|
|
|
8
8
|
// (Dialog's own measured surface sets both) can capture its position: fixed
|
|
9
9
|
// box; z-index 70 keeps it above Dialog/Drawer's z-index 60/61 layers, since
|
|
10
10
|
// it's no longer nested inside whichever one opened it.
|
|
11
|
+
// The listbox and everything inside it live in `globalCss` below, not
|
|
12
|
+
// nested under `&` (#357): nesting compiles to a real descendant combinator
|
|
13
|
+
// scoped to the recipe's root class, which needs actual DOM containment —
|
|
14
|
+
// exactly what the portal to `document.body` breaks. Nested, these rules
|
|
15
|
+
// matched nothing once the listbox moved and it rendered completely
|
|
16
|
+
// unstyled. The trigger and its own parts stay nested under `&` — the
|
|
17
|
+
// trigger is NOT portaled, it stays where it was authored. `font-family`/
|
|
18
|
+
// smoothing are restated on the listbox's own rule (its children pick font
|
|
19
|
+
// up via their own `font-family: inherit`, which still works — that
|
|
20
|
+
// inheritance is intact WITHIN the portaled subtree, only the link back to
|
|
21
|
+
// the trigger-holding root broke).
|
|
11
22
|
const config = defineRecipe({
|
|
12
23
|
name: "select",
|
|
13
24
|
base: `
|
|
@@ -28,31 +39,33 @@ const config = defineRecipe({
|
|
|
28
39
|
& .crisp-select-placeholder { color: ${c.fgMuted}; }
|
|
29
40
|
& .crisp-select-chevron { flex-shrink: 0; color: ${c.fgMuted}; transition: transform .15s ease; }
|
|
30
41
|
& .crisp-select-trigger[data-open] .crisp-select-chevron { transform: rotate(180deg); }
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
`,
|
|
43
|
+
globalCss: `
|
|
44
|
+
.crisp-select-listbox {
|
|
45
|
+
font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
33
46
|
position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
|
|
34
47
|
max-height: 280px; overflow-y: auto; padding: ${c.space["1"]};
|
|
35
48
|
border-radius: ${c.radius.xl}; background: ${c.bgPopover};
|
|
36
49
|
box-shadow: ${e.floating};
|
|
37
50
|
transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
|
|
38
51
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
.crisp-select-listbox[data-up] { transform-origin: bottom; }
|
|
53
|
+
.crisp-select-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
|
|
54
|
+
.crisp-select-search { display: flex; align-items: center; gap: ${c.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${c.space["1"]}) calc(-1 * ${c.space["1"]}) ${c.space["1"]}; padding: 0 ${c.space["2.5"]}; border-bottom: 1px solid ${c.borderSubtle}; color: ${c.fgMuted}; }
|
|
55
|
+
.crisp-select-search svg { flex-shrink: 0; }
|
|
56
|
+
.crisp-select-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; }
|
|
57
|
+
.crisp-select-search input::placeholder { color: ${c.fgMuted}; }
|
|
58
|
+
.crisp-select-empty { padding: ${c.space["3"]} ${c.space["2"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgMuted}; }
|
|
59
|
+
.crisp-select-option {
|
|
47
60
|
box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.menuRow}; flex-shrink: 0;
|
|
48
61
|
padding: 0 ${c.space["2"]}; margin: 0; border: none; background: none; border-radius: ${c.radius.md};
|
|
49
62
|
font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; text-align: start; cursor: pointer; white-space: nowrap;
|
|
50
63
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
.crisp-select-option[data-highlighted] { background: ${c.bgRaised}; }
|
|
65
|
+
.crisp-select-option[data-disabled] { opacity: 0.5; cursor: not-allowed; }
|
|
66
|
+
.crisp-select-option-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${c.radius.full}; }
|
|
67
|
+
.crisp-select-option-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
68
|
+
.crisp-select-option-check { flex-shrink: 0; color: ${c.bgBrandSolid}; }
|
|
56
69
|
`,
|
|
57
70
|
variants: {
|
|
58
71
|
// ghost: borderless at rest (transparent), reveals a subtle fill on hover —
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as React from "react";
|
|
1
2
|
import { createPortal } from "react-dom";
|
|
2
3
|
/**
|
|
3
4
|
* Portals a floating layer (a `position: fixed` listbox/menu/popover
|
|
@@ -19,7 +20,26 @@ import { createPortal } from "react-dom";
|
|
|
19
20
|
* starts `false` and is driven only by client interaction — never a prop a
|
|
20
21
|
* fixture sets true at mount (unlike Dialog/Drawer/Command) — so this never
|
|
21
22
|
* touches `document` during SSR, the same safety Tooltip already had.
|
|
23
|
+
*
|
|
24
|
+
* `sourceRef` is the caller's own root — still in its original DOM position,
|
|
25
|
+
* never portaled — used to carry a `data-theme` set on some ancestor of it
|
|
26
|
+
* onto the portaled content (#357). `[data-theme="dark"]` is a normal
|
|
27
|
+
* attribute selector: it needs the themed element to be an actual DOM
|
|
28
|
+
* ancestor, which a consumer that scopes theming below `<html>`/`<body>`
|
|
29
|
+
* (crisp's own visual-test harness does exactly this, per case) no longer
|
|
30
|
+
* is once the content moves to `document.body` — the popover rendered in
|
|
31
|
+
* the PAGE's theme instead of the SUBTREE's. Re-declaring the attribute
|
|
32
|
+
* directly on the portaled root re-establishes it for CSS attribute
|
|
33
|
+
* selectors AND for `var()` resolution, since a custom property set under
|
|
34
|
+
* `[data-theme="dark"] { --crisp-x: ... }` only reaches descendants of an
|
|
35
|
+
* element carrying that attribute.
|
|
22
36
|
*/
|
|
23
|
-
export function FloatingPortal({ children, }) {
|
|
24
|
-
|
|
37
|
+
export function FloatingPortal({ children, sourceRef, }) {
|
|
38
|
+
const theme = sourceRef.current
|
|
39
|
+
?.closest("[data-theme]")
|
|
40
|
+
?.getAttribute("data-theme");
|
|
41
|
+
const content = theme
|
|
42
|
+
? React.cloneElement(children, { "data-theme": theme })
|
|
43
|
+
: children;
|
|
44
|
+
return createPortal(content, document.body);
|
|
25
45
|
}
|
|
@@ -48,3 +48,23 @@ export function isInsideLaterFloatingLayer(target, self) {
|
|
|
48
48
|
}
|
|
49
49
|
return false;
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* True when at least one `Select`/`MultiSelect`/`Menu`/`Popover`/
|
|
53
|
+
* `ContextMenu` is currently open, anywhere (#361).
|
|
54
|
+
*
|
|
55
|
+
* A dismissable container that closes on Escape via its OWN raw keydown
|
|
56
|
+
* listener or a library's dismissable-layer machine (`RecordPanel`,
|
|
57
|
+
* `Dialog`, `Drawer`) has no visibility into these five: none of them are
|
|
58
|
+
* built on the same machine, so a Menu opened from inside a `Dialog`
|
|
59
|
+
* registers here, but the Dialog's own Escape handling doesn't know to
|
|
60
|
+
* check for it. Both listeners fire on the same Escape keydown, so both
|
|
61
|
+
* close — the outer container closing was never the point of that
|
|
62
|
+
* keypress, which was meant for the nested layer alone. Call this before
|
|
63
|
+
* closing on Escape (or in a dismissable-layer's `onEscapeKeyDown`, calling
|
|
64
|
+
* `event.preventDefault()` when it's true) to defer instead: the nested
|
|
65
|
+
* layer's own listener still runs and closes it, and the outer container
|
|
66
|
+
* gets its own, uncontested Escape on the next press.
|
|
67
|
+
*/
|
|
68
|
+
export function hasOpenFloatingLayer() {
|
|
69
|
+
return stack.length > 0;
|
|
70
|
+
}
|
package/dist/esm/core/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { defineRecipe } from "./define.js";
|
|
|
4
4
|
export { withDescribedBy } from "./dialog-description.js";
|
|
5
5
|
export { isRtlElement } from "./direction.js";
|
|
6
6
|
export { FloatingPortal } from "./floating-portal.js";
|
|
7
|
-
export { isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack.js";
|
|
7
|
+
export { hasOpenFloatingLayer, isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack.js";
|
|
8
8
|
export { formValueStyle } from "./form-value.js";
|
|
9
9
|
export { placeFloating, } from "./place-floating.js";
|
|
10
10
|
export { Primitive } from "./primitive.js";
|
|
@@ -2,6 +2,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { Avatar } from "../../components/avatar/index.js";
|
|
5
|
+
import { hasOpenFloatingLayer } from "../../core/index.js";
|
|
5
6
|
import { tagTone, } from "../recordstable/index.js";
|
|
6
7
|
import { recordpanelRecipe } from "./recordpanel.recipe.js";
|
|
7
8
|
function FieldValue({ col, row, emptyLabel, }) {
|
|
@@ -28,11 +29,21 @@ function FieldValue({ col, row, emptyLabel, }) {
|
|
|
28
29
|
*/
|
|
29
30
|
export function RecordPanel({ record, columns, onClose, className, closeLabel = "Close panel", title, detailsLabel = "Details", emptyLabel = "Empty", footer, ...rest }) {
|
|
30
31
|
const titleId = React.useId();
|
|
31
|
-
// close on Escape while open
|
|
32
|
+
// close on Escape while open -- but not when a Menu/Select/Popover/
|
|
33
|
+
// ContextMenu opened from inside the panel (e.g. a footer "More actions"
|
|
34
|
+
// menu) is itself open (#361): that Escape belongs to the nested layer,
|
|
35
|
+
// which has its own listener and will close itself. Without this check
|
|
36
|
+
// both close on the same keypress, since neither knows about the other --
|
|
37
|
+
// none of the five are built on this listener or registered anywhere it
|
|
38
|
+
// can see, only on the shared floating-stack this checks instead.
|
|
32
39
|
React.useEffect(() => {
|
|
33
40
|
if (!record)
|
|
34
41
|
return;
|
|
35
|
-
const onKey = (e) =>
|
|
42
|
+
const onKey = (e) => {
|
|
43
|
+
if (e.key !== "Escape" || hasOpenFloatingLayer())
|
|
44
|
+
return;
|
|
45
|
+
onClose();
|
|
46
|
+
};
|
|
36
47
|
document.addEventListener("keydown", onKey);
|
|
37
48
|
return () => document.removeEventListener("keydown", onKey);
|
|
38
49
|
}, [record, onClose]);
|