@crisp-ui-kit/crisp 0.37.0 → 0.39.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.
Files changed (61) hide show
  1. package/dist/badge.css +5 -5
  2. package/dist/badge.layered.css +5 -5
  3. package/dist/cjs/components/badge/badge.recipe.js +11 -5
  4. package/dist/cjs/components/contextmenu/contextmenu.js +1 -1
  5. package/dist/cjs/components/contextmenu/contextmenu.recipe.js +22 -10
  6. package/dist/cjs/components/fileupload/fileupload.js +2 -2
  7. package/dist/cjs/components/menu/menu.js +1 -1
  8. package/dist/cjs/components/menu/menu.recipe.js +32 -20
  9. package/dist/cjs/components/multiselect/multiselect.js +4 -4
  10. package/dist/cjs/components/multiselect/multiselect.recipe.js +50 -24
  11. package/dist/cjs/components/popover/popover.js +1 -1
  12. package/dist/cjs/components/popover/popover.recipe.js +19 -4
  13. package/dist/cjs/components/select/select.js +3 -3
  14. package/dist/cjs/components/select/select.recipe.js +42 -16
  15. package/dist/cjs/core/floating-portal.js +55 -2
  16. package/dist/cjs/tokens/palette.js +23 -0
  17. package/dist/cjs/tokens/semantic.js +7 -0
  18. package/dist/contextmenu.css +11 -9
  19. package/dist/contextmenu.layered.css +11 -9
  20. package/dist/esm/components/badge/badge.recipe.js +11 -5
  21. package/dist/esm/components/contextmenu/contextmenu.js +1 -1
  22. package/dist/esm/components/contextmenu/contextmenu.recipe.js +22 -10
  23. package/dist/esm/components/fileupload/fileupload.js +2 -2
  24. package/dist/esm/components/menu/menu.js +1 -1
  25. package/dist/esm/components/menu/menu.recipe.js +32 -20
  26. package/dist/esm/components/multiselect/multiselect.js +4 -4
  27. package/dist/esm/components/multiselect/multiselect.recipe.js +50 -24
  28. package/dist/esm/components/popover/popover.js +1 -1
  29. package/dist/esm/components/popover/popover.recipe.js +19 -4
  30. package/dist/esm/components/select/select.js +3 -3
  31. package/dist/esm/components/select/select.recipe.js +42 -16
  32. package/dist/esm/core/floating-portal.js +22 -2
  33. package/dist/esm/tokens/palette.js +23 -0
  34. package/dist/esm/tokens/semantic.js +7 -0
  35. package/dist/menu.css +34 -19
  36. package/dist/menu.layered.css +34 -19
  37. package/dist/multiselect.css +40 -21
  38. package/dist/multiselect.d.ts +2 -2
  39. package/dist/multiselect.layered.css +40 -21
  40. package/dist/native/tokens.d.ts +1 -1
  41. package/dist/native/tokens.js +24 -4
  42. package/dist/popover.css +5 -3
  43. package/dist/popover.layered.css +5 -3
  44. package/dist/select.css +33 -14
  45. package/dist/select.d.ts +2 -1
  46. package/dist/select.layered.css +33 -14
  47. package/dist/styles.css +138 -71
  48. package/dist/styles.layered.css +138 -71
  49. package/dist/tokens.css +10 -0
  50. package/dist/tokens.json +24 -4
  51. package/dist/types/components/fileupload/fileupload.d.ts +1 -1
  52. package/dist/types/components/multiselect/multiselect.d.ts +3 -1
  53. package/dist/types/components/multiselect/multiselect.recipe.d.ts +18 -3
  54. package/dist/types/components/select/select.d.ts +4 -1
  55. package/dist/types/components/select/select.recipe.d.ts +12 -0
  56. package/dist/types/core/floating-portal.d.ts +17 -3
  57. package/dist/types/product/sidebar/index.d.ts +1 -1
  58. package/dist/types/tokens/contract.d.ts +5 -0
  59. package/dist/types/tokens/palette.d.ts +20 -0
  60. package/dist/types/tokens/semantic.d.ts +7 -0
  61. package/package.json +3 -3
@@ -1,6 +1,40 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.FloatingPortal = FloatingPortal;
37
+ const React = __importStar(require("react"));
4
38
  const react_dom_1 = require("react-dom");
5
39
  /**
6
40
  * Portals a floating layer (a `position: fixed` listbox/menu/popover
@@ -22,7 +56,26 @@ const react_dom_1 = require("react-dom");
22
56
  * starts `false` and is driven only by client interaction — never a prop a
23
57
  * fixture sets true at mount (unlike Dialog/Drawer/Command) — so this never
24
58
  * touches `document` during SSR, the same safety Tooltip already had.
59
+ *
60
+ * `sourceRef` is the caller's own root — still in its original DOM position,
61
+ * never portaled — used to carry a `data-theme` set on some ancestor of it
62
+ * onto the portaled content (#357). `[data-theme="dark"]` is a normal
63
+ * attribute selector: it needs the themed element to be an actual DOM
64
+ * ancestor, which a consumer that scopes theming below `<html>`/`<body>`
65
+ * (crisp's own visual-test harness does exactly this, per case) no longer
66
+ * is once the content moves to `document.body` — the popover rendered in
67
+ * the PAGE's theme instead of the SUBTREE's. Re-declaring the attribute
68
+ * directly on the portaled root re-establishes it for CSS attribute
69
+ * selectors AND for `var()` resolution, since a custom property set under
70
+ * `[data-theme="dark"] { --crisp-x: ... }` only reaches descendants of an
71
+ * element carrying that attribute.
25
72
  */
26
- function FloatingPortal({ children, }) {
27
- return (0, react_dom_1.createPortal)(children, document.body);
73
+ function FloatingPortal({ children, sourceRef, }) {
74
+ const theme = sourceRef.current
75
+ ?.closest("[data-theme]")
76
+ ?.getAttribute("data-theme");
77
+ const content = theme
78
+ ? React.cloneElement(children, { "data-theme": theme })
79
+ : children;
80
+ return (0, react_dom_1.createPortal)(content, document.body);
28
81
  }
@@ -60,4 +60,27 @@ exports.palette = {
60
60
  cyan600: { light: "#00b9eb", dark: "#00b9eb" },
61
61
  purple600: { light: "#9b69ff", dark: "#9b69ff" },
62
62
  graySidebar: { light: "#fbfbfb", dark: "#0e0f10" },
63
+ // Soft Badge's text colour (#323): the *600 tones above, used directly as
64
+ // soft-badge text, fail AA on their own soft tint in light mode (2.97-3.87)
65
+ // and brand fails in dark too (3.83) -- everything else already brightens
66
+ // enough in dark to pass (measured: 5.60-6.77). Attio's own soft tag pills
67
+ // (a company's Categories field, app.attio.com, 2026-09-23) don't reuse the
68
+ // accent colour as text either: they shift it toward black (light) or white
69
+ // (dark) at roughly the same relative luminance regardless of hue --
70
+ // measured 5 different category colours in dark mode (L 0.61-0.75) and two
71
+ // in light (L 0.08-0.10), a tight enough cluster to generalise the SHIFT,
72
+ // not the exact colour, to crisp's own four accent hues. Computed to clear
73
+ // AA with the same margin Attio's pills measured at (5.5-6.5:1) against
74
+ // crisp's EXISTING soft-tint background -- the tint itself is unchanged.
75
+ // Dark values that already passed keep their *600 dark value unchanged.
76
+ softBrandText: { light: "#2057bd", dark: "#568df3" },
77
+ softSuccessText: { light: "#186b3d", dark: "#2ea968" },
78
+ softWarningText: { light: "#80520d", dark: "#e0982a" },
79
+ softDangerText: { light: "#a33539", dark: "#ff5a5f" },
80
+ // Same reasoning, for Badge's neutral tone: fg-muted on bg-raised measures
81
+ // 4.40 in light (just under AA) and 6.77 in dark (already fine). Unlike
82
+ // fg-muted/bg-raised themselves (system-wide, measured from Attio, kept
83
+ // as-is per build/spec-check.mjs's own note), this token exists only for
84
+ // Badge's soft-neutral text, so darkening it doesn't move any other surface.
85
+ softNeutralText: { light: "#686870", dark: "#9a9aa2" },
63
86
  };
@@ -54,4 +54,11 @@ exports.semantic = {
54
54
  // #fbfbfb — the subtle raised fill; the active tab pill uses the same
55
55
  // value as the sidebar, so it shares the palette entry under a neutral name.
56
56
  bgSubtle: "graySidebar",
57
+ /** Soft Badge's text colour (#323) — see palette.ts's comment on the
58
+ * soft*Text entries for the Attio measurement this is derived from. */
59
+ fgBrandSoft: "softBrandText",
60
+ fgSuccessSoft: "softSuccessText",
61
+ fgWarningSoft: "softWarningText",
62
+ fgDangerSoft: "softDangerText",
63
+ fgNeutralSoft: "softNeutralText",
57
64
  };
@@ -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) {
@@ -29,29 +29,35 @@ const spec = defineSpec({
29
29
  ],
30
30
  variants: {
31
31
  tone: {
32
+ // #323: the soft text colour is a dedicated fg*Soft token, not the
33
+ // tone's own accent (fgMuted/bgBrandSolid/bgSuccess/bgWarning/bgDanger)
34
+ // — reusing the accent as soft text is exactly what measured below AA
35
+ // (2.97-4.40 in light; brand also failed in dark, 3.83). Background and
36
+ // ring are unchanged: they were never the problem, and neither is a
37
+ // measured Attio value that moving would help — only the text is.
32
38
  neutral: {
33
39
  background: c.bgRaised,
34
- color: c.fgMuted,
40
+ color: c.fgNeutralSoft,
35
41
  boxShadow: `inset 0 0 0 1px ${c.borderDefault}`,
36
42
  },
37
43
  brand: {
38
44
  background: soft(c.bgBrandSolid, 14),
39
- color: c.bgBrandSolid,
45
+ color: c.fgBrandSoft,
40
46
  boxShadow: ring(c.bgBrandSolid, 30),
41
47
  },
42
48
  success: {
43
49
  background: soft(c.bgSuccess, 14),
44
- color: c.bgSuccess,
50
+ color: c.fgSuccessSoft,
45
51
  boxShadow: ring(c.bgSuccess, 30),
46
52
  },
47
53
  warning: {
48
54
  background: soft(c.bgWarning, 16),
49
- color: c.bgWarning,
55
+ color: c.fgWarningSoft,
50
56
  boxShadow: ring(c.bgWarning, 32),
51
57
  },
52
58
  danger: {
53
59
  background: soft(c.bgDanger, 14),
54
- color: c.bgDanger,
60
+ color: c.fgDangerSoft,
55
61
  boxShadow: ring(c.bgDanger, 30),
56
62
  },
57
63
  },
@@ -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
- & .crisp-contextmenu-content { 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); } }
11
- & .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; }
12
- & .crisp-contextmenu-item svg { flex-shrink: 0; color: ${c.fgMuted}; }
13
- & .crisp-contextmenu-item:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
14
- & .crisp-contextmenu-item--danger { color: ${c.bgDanger}; }
15
- & .crisp-contextmenu-item--danger svg { color: ${c.bgDanger}; }
16
- & .crisp-contextmenu-item--danger:hover { background: color-mix(in srgb, ${c.bgDanger} 10%, transparent); }
17
- & .crisp-contextmenu-shortcut { margin-inline-start: auto; padding-inline-start: ${c.space["3"]}; color: ${c.fgMuted}; font-size: ${c.text.sm}; }
18
- & .crisp-contextmenu-sep { height: 1px; margin: ${c.space["1"]} 0; border: none; background: ${c.borderSubtle}; }`,
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: [],
@@ -36,7 +36,7 @@ function formatSize(bytes) {
36
36
  * is theirs by construction. Controlled or uncontrolled, the same way every
37
37
  * other crisp component is.
38
38
  */
39
- export 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 }) {
39
+ export 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 }) {
40
40
  const props = {
41
41
  accept,
42
42
  disabled: disabledProp,
@@ -87,7 +87,7 @@ export function FileUpload({ className, labels, "aria-label": ariaLabel, accept,
87
87
  // focus should land next.
88
88
  const rootRef = React.useRef(null);
89
89
  const focusAfterRemoval = useFocusAfterRemoval(rootRef, ".crisp-fileupload-remove", dropzoneRef);
90
- return (_jsxs("div", { ref: rootRef, ...rest, className: fileuploadRecipe({ className }), children: [_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: [_jsx("span", { className: "crisp-fileupload-title", children: t.title }), _jsx("span", { className: "crisp-fileupload-hint", children: t.hint })] }), _jsx("input", { ...hiddenInputProps, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && (_jsx("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => (_jsxs("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [_jsx("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && (_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: _jsx("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), _jsx("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), _jsx("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
90
+ return (_jsxs("div", { ref: rootRef, ...rest, className: fileuploadRecipe({ className }), children: [_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: [_jsx("span", { className: "crisp-fileupload-title", children: t.title }), _jsx("span", { className: "crisp-fileupload-hint", children: t.hint })] }), _jsx("input", { ...hiddenInputProps, id: id ?? hiddenInputProps.id, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && (_jsx("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => (_jsxs("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [_jsx("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && (_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: _jsx("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), _jsx("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), _jsx("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
91
91
  focusAfterRemoval(i);
92
92
  removeFileEntry(entry.id);
93
93
  }, children: _jsx(X, { size: 13, strokeWidth: 2 }) })] }, entry.id))) })), _jsx("ul", { className: "crisp-fileupload-errors", "aria-live": "polite", children: rejections.map((r) => (_jsx("li", { className: "crisp-fileupload-error", children: `${r.file.name}: ${r.errors.map((e) => t.errors[e] ?? e).join(" ")}` }, r.file.name))) })] }));
@@ -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, { children: _jsx(MenuContext.Provider, { value: { close }, 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: children }) }) }))] }));
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
- & .crisp-menu-content {
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
- & .crisp-menu-content[data-align="end"] { transform-origin: top right; }
23
- & .crisp-menu-content[data-up] { transform-origin: bottom left; }
24
- & .crisp-menu-content[data-up][data-align="end"] { transform-origin: bottom right; }
25
- & .crisp-menu-content { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
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
- & .crisp-menu-item {
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
- & .crisp-menu-item svg { flex-shrink: 0; color: ${c.fgMuted}; }
33
- & .crisp-menu-item:focus { outline: none; }
34
- & .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}; }
35
- & .crisp-menu-item[data-disabled] { opacity: 0.5; cursor: not-allowed; }
36
- & .crisp-menu-item--danger { color: ${c.bgDanger}; }
37
- & .crisp-menu-item--danger svg { color: ${c.bgDanger}; }
38
- & .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); }
39
- & .crisp-menu-item-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
40
- & .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; }
41
- & .crisp-menu-item-check { flex-shrink: 0; color: ${c.bgBrandSolid}; }
42
- & .crisp-menu-item-check--placeholder { width: 15px; }
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
- & .crisp-menu-sep { height: 1px; flex-shrink: 0; border: none; margin: ${c.space["1"]} calc(-1 * ${c.space["1"]}); background: ${c.borderSubtle}; }
45
- & .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}; }
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: {},
@@ -33,7 +33,7 @@ export function MultiSelect({ options, value: valueProp, defaultValue, onValueCh
33
33
  // The id belongs on the trigger, not the wrapper: `<label for>` only names a
34
34
  // LABELABLE element, so an id left on the root div meant a FormField label
35
35
  // above this control named nothing (#247). Select already did this.
36
- id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
36
+ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, ...rest }) {
37
37
  // Virtual focus: DOM focus stays on the trigger (or the search field) and the
38
38
  // active option is named by `aria-activedescendant`. `data-highlighted` is a
39
39
  // styling hook only -- a screen reader cannot see it.
@@ -146,15 +146,15 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
146
146
  document.addEventListener("pointerdown", onDown);
147
147
  return () => document.removeEventListener("pointerdown", onDown);
148
148
  }, [open]);
149
- return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ className }), ...rest, children: [name &&
150
- (value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), _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) => {
149
+ return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ invalid, className }), ...rest, children: [name &&
150
+ (value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), _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) => {
151
151
  if (open && !searchable)
152
152
  onNavKeys(e);
153
153
  else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
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,41 +39,54 @@ 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
- & .crisp-multiselect-listbox {
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
- & .crisp-multiselect-listbox[data-up] { transform-origin: bottom left; }
38
- & .crisp-multiselect-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
39
- & .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}; }
40
- & .crisp-multiselect-search svg { flex-shrink: 0; }
41
- & .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}; }
42
- & .crisp-multiselect-search input::placeholder { color: ${c.fgMuted}; }
43
- & .crisp-multiselect-option {
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
- & .crisp-multiselect-option[data-highlighted] { background: ${c.bgRaised}; }
48
- & .crisp-multiselect-option-chip { flex: 0 1 auto; min-width: 0; overflow: hidden; }
49
- & .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; }
50
- & .crisp-multiselect-option[aria-selected="true"] .crisp-multiselect-check { background: ${c.bgBrandSolid}; box-shadow: none; color: #fff; }
51
- & .crisp-multiselect-empty { padding: ${c.space["3"]} ${c.space["2"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgMuted}; }
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
- & .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); }
54
- & .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}); }
55
- & .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}); }
56
- & .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}); }
57
- & .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}); }
58
- & .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}); }
59
- & .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}); }
60
- & .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}); }
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
- variants: {},
63
- defaultVariants: {},
77
+ variants: {
78
+ // Same ring Select's `invalid` draws — see that recipe's comment.
79
+ invalid: {
80
+ true: `
81
+ & .crisp-multiselect-trigger,
82
+ & .crisp-multiselect-trigger:hover,
83
+ & .crisp-multiselect-trigger[data-open],
84
+ & .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${c.bgDanger}; }
85
+ `,
86
+ false: ``,
87
+ },
88
+ },
89
+ defaultVariants: { invalid: false },
64
90
  compoundVariants: [],
65
91
  });
66
92
  export const multiselectRecipe = Object.assign(resolveRecipe(config), {
@@ -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
- & .crisp-popover-content { 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); } }
13
- & .crisp-popover-content[data-align="end"] { transform-origin: top right; }
14
- & .crisp-popover-content[data-up] { transform-origin: bottom left; }`,
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: [],