@crisp-ui-kit/crisp 0.36.0 → 0.38.0

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