@crisp-ui-kit/crisp 0.36.0 → 0.37.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 (53) hide show
  1. package/dist/cjs/components/contextmenu/contextmenu.js +25 -8
  2. package/dist/cjs/components/contextmenu/contextmenu.recipe.js +5 -2
  3. package/dist/cjs/components/dialog/dialog.js +1 -1
  4. package/dist/cjs/components/dialog/dialog.recipe.js +1 -1
  5. package/dist/cjs/components/menu/menu.js +20 -3
  6. package/dist/cjs/components/menu/menu.recipe.js +4 -3
  7. package/dist/cjs/components/multiselect/multiselect.js +26 -9
  8. package/dist/cjs/components/multiselect/multiselect.recipe.js +4 -3
  9. package/dist/cjs/components/popover/popover.js +25 -8
  10. package/dist/cjs/components/popover/popover.recipe.js +5 -3
  11. package/dist/cjs/components/select/select.js +30 -9
  12. package/dist/cjs/components/select/select.recipe.js +5 -3
  13. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +50 -6
  14. package/dist/cjs/core/floating-portal.js +28 -0
  15. package/dist/cjs/core/floating-stack.js +54 -0
  16. package/dist/cjs/core/index.js +6 -1
  17. package/dist/cjs/product/recordstable/recordstable.js +15 -4
  18. package/dist/contextmenu.css +1 -1
  19. package/dist/contextmenu.layered.css +1 -1
  20. package/dist/dialog.css +1 -1
  21. package/dist/dialog.layered.css +1 -1
  22. package/dist/esm/components/contextmenu/contextmenu.js +25 -8
  23. package/dist/esm/components/contextmenu/contextmenu.recipe.js +5 -2
  24. package/dist/esm/components/dialog/dialog.js +1 -1
  25. package/dist/esm/components/dialog/dialog.recipe.js +1 -1
  26. package/dist/esm/components/menu/menu.js +21 -4
  27. package/dist/esm/components/menu/menu.recipe.js +4 -3
  28. package/dist/esm/components/multiselect/multiselect.js +27 -10
  29. package/dist/esm/components/multiselect/multiselect.recipe.js +4 -3
  30. package/dist/esm/components/popover/popover.js +26 -9
  31. package/dist/esm/components/popover/popover.recipe.js +5 -3
  32. package/dist/esm/components/select/select.js +31 -10
  33. package/dist/esm/components/select/select.recipe.js +5 -3
  34. package/dist/esm/components/tabletoolbar/tabletoolbar.js +51 -7
  35. package/dist/esm/core/floating-portal.js +25 -0
  36. package/dist/esm/core/floating-stack.js +50 -0
  37. package/dist/esm/core/index.js +2 -0
  38. package/dist/esm/product/recordstable/recordstable.js +16 -5
  39. package/dist/menu.css +1 -1
  40. package/dist/menu.layered.css +1 -1
  41. package/dist/multiselect.css +1 -1
  42. package/dist/multiselect.layered.css +1 -1
  43. package/dist/popover.css +1 -1
  44. package/dist/popover.layered.css +1 -1
  45. package/dist/select.css +1 -1
  46. package/dist/select.layered.css +1 -1
  47. package/dist/styles.css +6 -6
  48. package/dist/styles.layered.css +6 -6
  49. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +10 -1
  50. package/dist/types/core/floating-portal.d.ts +25 -0
  51. package/dist/types/core/floating-stack.d.ts +13 -0
  52. package/dist/types/core/index.d.ts +2 -0
  53. package/package.json +1 -1
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerFloatingLayer = registerFloatingLayer;
4
+ exports.isInsideLaterFloatingLayer = isInsideLaterFloatingLayer;
5
+ /**
6
+ * Tracks every currently-open `FloatingPortal` layer, in open order, so a
7
+ * layer's outside-click check can tell "outside me" from "inside a layer I
8
+ * spawned" (#339).
9
+ *
10
+ * Portaling `Select`/`MultiSelect`/`Menu`/`Popover`/`ContextMenu` to
11
+ * `document.body` fixed their own mis-positioning inside a `Dialog`, but it
12
+ * broke a composition each of them supports: one of these opened INSIDE
13
+ * another (TableToolbar's sort popover is a `Menu` holding a per-row
14
+ * `Select`). Before portaling, the inner layer was a DOM descendant of the
15
+ * outer one, so the outer's `rootRef.contains(target)` check saw a click
16
+ * inside the inner layer as "inside". Once both portal independently to
17
+ * `document.body`, they become DOM SIBLINGS — nested in the React tree, not
18
+ * the DOM tree — so that same check reads the inner layer's own pointerdown
19
+ * as "outside", and the outer layer closes itself, unmounting the inner one
20
+ * mid-interaction.
21
+ *
22
+ * The fix: each layer registers its content node here on open (removed on
23
+ * close/unmount), and a layer's outside-click check also accepts a target
24
+ * inside any layer registered AFTER it — i.e. any layer that could only have
25
+ * opened from inside it.
26
+ */
27
+ const stack = [];
28
+ /** Call when a layer opens; call the returned cleanup when it closes. */
29
+ function registerFloatingLayer(el) {
30
+ stack.push(el);
31
+ return () => {
32
+ const i = stack.indexOf(el);
33
+ if (i >= 0)
34
+ stack.splice(i, 1);
35
+ };
36
+ }
37
+ /**
38
+ * True when `target` sits inside a layer that was registered strictly after
39
+ * `self` — a layer that could only have been spawned from inside `self`,
40
+ * directly or through further nesting.
41
+ *
42
+ * Pass `null` for `self` when the caller is a hand-rolled dropdown that
43
+ * doesn't register itself on this stack (RecordsTable's and TableToolbar's
44
+ * own popovers, e.g.) — every registered layer then counts, which is the
45
+ * right query for "was this click inside some floating child I opened".
46
+ */
47
+ function isInsideLaterFloatingLayer(target, self) {
48
+ const selfIndex = self ? stack.indexOf(self) : -1;
49
+ for (let i = selfIndex + 1; i < stack.length; i++) {
50
+ if (stack[i]?.contains(target))
51
+ return true;
52
+ }
53
+ return false;
54
+ }
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.usePrefersReducedMotion = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.formValueStyle = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = exports.parseCellDate = exports.formatCellDate = void 0;
17
+ exports.usePrefersReducedMotion = exports.useLabelAssociation = exports.useFormReset = exports.useFocusAfterRemoval = exports.specToRecipe = exports.defineSpec = exports.resolveRecipe = exports.Primitive = exports.placeFloating = exports.formValueStyle = exports.registerFloatingLayer = exports.isInsideLaterFloatingLayer = exports.FloatingPortal = exports.isRtlElement = exports.withDescribedBy = exports.defineRecipe = exports.cx = exports.parseCellDate = exports.formatCellDate = void 0;
18
18
  var cell_date_1 = require("./cell-date");
19
19
  Object.defineProperty(exports, "formatCellDate", { enumerable: true, get: function () { return cell_date_1.formatCellDate; } });
20
20
  Object.defineProperty(exports, "parseCellDate", { enumerable: true, get: function () { return cell_date_1.parseCellDate; } });
@@ -26,6 +26,11 @@ var dialog_description_1 = require("./dialog-description");
26
26
  Object.defineProperty(exports, "withDescribedBy", { enumerable: true, get: function () { return dialog_description_1.withDescribedBy; } });
27
27
  var direction_1 = require("./direction");
28
28
  Object.defineProperty(exports, "isRtlElement", { enumerable: true, get: function () { return direction_1.isRtlElement; } });
29
+ var floating_portal_1 = require("./floating-portal");
30
+ Object.defineProperty(exports, "FloatingPortal", { enumerable: true, get: function () { return floating_portal_1.FloatingPortal; } });
31
+ var floating_stack_1 = require("./floating-stack");
32
+ Object.defineProperty(exports, "isInsideLaterFloatingLayer", { enumerable: true, get: function () { return floating_stack_1.isInsideLaterFloatingLayer; } });
33
+ Object.defineProperty(exports, "registerFloatingLayer", { enumerable: true, get: function () { return floating_stack_1.registerFloatingLayer; } });
29
34
  var form_value_1 = require("./form-value");
30
35
  Object.defineProperty(exports, "formValueStyle", { enumerable: true, get: function () { return form_value_1.formValueStyle; } });
31
36
  var place_floating_1 = require("./place-floating");
@@ -499,8 +499,15 @@ function RecordsTable({ labels, columns: columnsProp = exports.defaultColumns, r
499
499
  return;
500
500
  const onDown = (e) => {
501
501
  const t = e.target;
502
- if (!t.closest(".crisp-recordstable-menu, [data-menu-trigger]"))
503
- setOpenMenu(null);
502
+ if (t.closest(".crisp-recordstable-menu, [data-menu-trigger]"))
503
+ return;
504
+ // #339: a Select/MultiSelect/Menu opened from inside this popover now
505
+ // portals to document.body, so it's no longer inside
506
+ // .crisp-recordstable-menu by DOM containment -- check the shared
507
+ // floating-layer stack too.
508
+ if ((0, core_1.isInsideLaterFloatingLayer)(t, null))
509
+ return;
510
+ setOpenMenu(null);
504
511
  };
505
512
  const onKey = (e) => e.key === "Escape" && setOpenMenu(null);
506
513
  document.addEventListener("mousedown", onDown);
@@ -515,8 +522,12 @@ function RecordsTable({ labels, columns: columnsProp = exports.defaultColumns, r
515
522
  if (!ctxMenu)
516
523
  return;
517
524
  const onDown = (e) => {
518
- if (!e.target.closest(".crisp-recordstable-ctx"))
519
- setCtxMenu(null);
525
+ const t = e.target;
526
+ if (t.closest(".crisp-recordstable-ctx"))
527
+ return;
528
+ if ((0, core_1.isInsideLaterFloatingLayer)(t, null))
529
+ return;
530
+ setCtxMenu(null);
520
531
  };
521
532
  const onKey = (e) => e.key === "Escape" && setCtxMenu(null);
522
533
  document.addEventListener("mousedown", onDown);
@@ -1,6 +1,6 @@
1
1
  .crisp-contextmenu {
2
2
  display: contents; font-family: var(--crisp-font-sans);
3
- & .crisp-contextmenu-content { position: fixed; z-index: 60; 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-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
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
5
  & .crisp-contextmenu-item svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
6
6
  & .crisp-contextmenu-item:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); }
@@ -1,7 +1,7 @@
1
1
  @layer crisp-components {
2
2
  .crisp-contextmenu {
3
3
  display: contents; font-family: var(--crisp-font-sans);
4
- & .crisp-contextmenu-content { position: fixed; z-index: 60; 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-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
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
6
  & .crisp-contextmenu-item svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
7
7
  & .crisp-contextmenu-item:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); }
package/dist/dialog.css CHANGED
@@ -6,7 +6,7 @@
6
6
  & .crisp-dialog-card { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-surface); box-shadow: 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); overflow: hidden; }
7
7
  & .crisp-dialog-header { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; height: 52px; padding: 0 var(--crisp-space-2) 0 var(--crisp-space-4); border-bottom: 1px solid var(--crisp-border-subtle); }
8
8
  & .crisp-dialog-header svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
9
- & .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); }
9
+ & .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); margin: 0; }
10
10
  & .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: var(--crisp-radius-md); background: none; color: var(--crisp-fg-muted); font-family: inherit; cursor: pointer; }
11
11
  & .crisp-dialog-close:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); color: var(--crisp-fg-primary); }
12
12
  & .crisp-dialog-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: var(--crisp-space-4); padding: var(--crisp-space-5) var(--crisp-space-4); }
@@ -7,7 +7,7 @@
7
7
  & .crisp-dialog-card { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-surface); box-shadow: 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); overflow: hidden; }
8
8
  & .crisp-dialog-header { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; height: 52px; padding: 0 var(--crisp-space-2) 0 var(--crisp-space-4); border-bottom: 1px solid var(--crisp-border-subtle); }
9
9
  & .crisp-dialog-header svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
10
- & .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); }
10
+ & .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); margin: 0; }
11
11
  & .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: var(--crisp-radius-md); background: none; color: var(--crisp-fg-muted); font-family: inherit; cursor: pointer; }
12
12
  & .crisp-dialog-close:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 6%, transparent); color: var(--crisp-fg-primary); }
13
13
  & .crisp-dialog-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: var(--crisp-space-4); padding: var(--crisp-space-5) var(--crisp-space-4); }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
+ import { FloatingPortal, isInsideLaterFloatingLayer, registerFloatingLayer, } from "../../core/index.js";
3
4
  import { useMenuKeyboard } from "../menu/index.js";
4
5
  import { contextmenuRecipe } from "./contextmenu.recipe.js";
5
6
  /**
@@ -41,12 +42,28 @@ export function ContextMenu({ children, items, menuLabel = "Context menu", class
41
42
  dismiss: () => close(),
42
43
  contentRef: menuRef,
43
44
  });
45
+ // Registers the (now-portaled) menu on the shared floating-layer stack
46
+ // (#339) so this doesn't get closed out from under itself when it holds
47
+ // another floating layer -- see core/floating-stack.ts.
48
+ React.useEffect(() => {
49
+ if (!pos || !menuRef.current)
50
+ return;
51
+ return registerFloatingLayer(menuRef.current);
52
+ }, [pos]);
44
53
  React.useEffect(() => {
45
54
  if (!pos)
46
55
  return;
47
56
  const onDown = (e) => {
48
- if (!rootRef.current?.contains(e.target))
49
- close();
57
+ // menuRef too (#339): the menu content portals to document.body, so
58
+ // it's no longer a descendant of rootRef.
59
+ const target = e.target;
60
+ if (rootRef.current?.contains(target))
61
+ return;
62
+ if (menuRef.current?.contains(target))
63
+ return;
64
+ if (isInsideLaterFloatingLayer(target, menuRef.current))
65
+ return;
66
+ close();
50
67
  };
51
68
  const onKey = (e) => e.key === "Escape" && close(true);
52
69
  document.addEventListener("pointerdown", onDown);
@@ -56,11 +73,11 @@ export function ContextMenu({ children, items, menuLabel = "Context menu", class
56
73
  document.removeEventListener("keydown", onKey);
57
74
  };
58
75
  }, [pos, close]);
59
- return (_jsxs("div", { ref: rootRef, className: contextmenuRecipe({ className }), children: [_jsx("div", { onContextMenu: open, children: children }), pos && (_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
60
- ? "crisp-contextmenu-item crisp-contextmenu-item--danger"
61
- : "crisp-contextmenu-item", tabIndex: -1, onClick: () => {
62
- item.onSelect?.();
63
- close(true);
64
- }, children: [item.icon, item.label, item.shortcut && (_jsx("span", { className: "crisp-contextmenu-shortcut", children: item.shortcut }))] }, i))) }))] }));
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
77
+ ? "crisp-contextmenu-item crisp-contextmenu-item--danger"
78
+ : "crisp-contextmenu-item", tabIndex: -1, onClick: () => {
79
+ item.onSelect?.();
80
+ close(true);
81
+ }, children: [item.icon, item.label, item.shortcut && (_jsx("span", { className: "crisp-contextmenu-shortcut", children: item.shortcut }))] }, i))) }) }))] }));
65
82
  }
66
83
  ContextMenu.displayName = "ContextMenu";
@@ -1,10 +1,13 @@
1
1
  import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
2
  import { contract as c, elevation as e } from "../../tokens/index.js";
3
- // A right-click menu on the measured popover surface, positioned at the cursor.
3
+ // A right-click menu on the measured popover surface, positioned at the
4
+ // cursor. The content portals to document.body (#339) so no ancestor's
5
+ // transform/backdrop-filter can capture its position: fixed box; z-index 70
6
+ // keeps it above Dialog/Drawer's 60/61.
4
7
  const config = defineRecipe({
5
8
  name: "contextmenu",
6
9
  base: `display: contents; font-family: ${c.font.sans};
7
- & .crisp-contextmenu-content { position: fixed; z-index: 60; 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); } }
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); } }
8
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; }
9
12
  & .crisp-contextmenu-item svg { flex-shrink: 0; color: ${c.fgMuted}; }
10
13
  & .crisp-contextmenu-item:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
@@ -41,7 +41,7 @@ export function Dialog({ open, onOpenChange, title, icon, width = 640, "aria-lab
41
41
  return (_jsx(Portal, { children: _jsxs("div", { className: dialogRecipe(), children: [_jsx("div", { ...api.getBackdropProps(), className: "crisp-dialog-backdrop" }), _jsx("div", { ...api.getPositionerProps(), className: "crisp-dialog-positioner", children: _jsx("div", { ...rest, ...withDescribedBy(api.getContentProps(), describedBy), className: cx("crisp-dialog-content", className), "data-crisp-dialog": id,
42
42
  // `width` is the component's own knob, so it wins over a passed
43
43
  // maxWidth; everything else a consumer sets survives
44
- style: { ...style, maxWidth: `min(${width}px, 90vw)` }, children: _jsxs("div", { className: "crisp-dialog-card", children: [title != null && (_jsxs("div", { className: "crisp-dialog-header", children: [icon, _jsx("span", { ...api.getTitleProps(), className: "crisp-dialog-title", children: title }), _jsx("button", { type: "button", ...api.getCloseTriggerProps(), className: "crisp-dialog-close", "aria-label": closeLabel, children: _jsx(X, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] })), children] }) }) })] }) }));
44
+ style: { ...style, maxWidth: `min(${width}px, 90vw)` }, children: _jsxs("div", { className: "crisp-dialog-card", children: [title != null && (_jsxs("div", { className: "crisp-dialog-header", children: [icon, _jsx("h2", { ...api.getTitleProps(), className: "crisp-dialog-title", children: title }), _jsx("button", { type: "button", ...api.getCloseTriggerProps(), className: "crisp-dialog-close", "aria-label": closeLabel, children: _jsx(X, { size: 16, strokeWidth: 2, "aria-hidden": "true" }) })] })), children] }) }) })] }) }));
45
45
  }
46
46
  Dialog.displayName = "Dialog";
47
47
  export function DialogBody({ children, className, }) {
@@ -14,7 +14,7 @@ const config = defineRecipe({
14
14
  & .crisp-dialog-card { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; border-radius: ${c.radius.xl}; background: ${c.bgSurface}; box-shadow: 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 8%, transparent); overflow: hidden; }
15
15
  & .crisp-dialog-header { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; height: 52px; padding: 0 ${c.space["2"]} 0 ${c.space["4"]}; border-bottom: 1px solid ${c.borderSubtle}; }
16
16
  & .crisp-dialog-header svg { flex-shrink: 0; color: ${c.fgMuted}; }
17
- & .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; }
17
+ & .crisp-dialog-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; margin: 0; }
18
18
  & .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: ${c.radius.md}; background: none; color: ${c.fgMuted}; font-family: inherit; cursor: pointer; }
19
19
  & .crisp-dialog-close:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: ${c.fgPrimary}; }
20
20
  & .crisp-dialog-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: ${c.space["4"]}; padding: ${c.space["5"]} ${c.space["4"]}; }
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check } from "lucide-react";
3
3
  import * as React from "react";
4
- import { placeFloating } from "../../core/index.js";
4
+ import { FloatingPortal, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, } from "../../core/index.js";
5
5
  import { menuRecipe } from "./menu.recipe.js";
6
6
  import { useMenuKeyboard } from "./menu-keyboard.js";
7
7
  const MenuContext = React.createContext(null);
@@ -58,13 +58,30 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
58
58
  setOpen(false);
59
59
  triggerRef.current?.focus?.();
60
60
  }, [setOpen]);
61
+ // Registers the (now-portaled) content on the shared floating-layer stack
62
+ // (#339) so this doesn't get closed out from under itself when it holds
63
+ // another floating layer (a Select in a sort popover, e.g.) -- see
64
+ // core/floating-stack.ts.
65
+ React.useEffect(() => {
66
+ if (!open || !contentRef.current)
67
+ return;
68
+ return registerFloatingLayer(contentRef.current);
69
+ }, [open]);
61
70
  // close on outside pointer-down
62
71
  React.useEffect(() => {
63
72
  if (!open)
64
73
  return;
65
74
  const onDown = (e) => {
66
- if (!rootRef.current?.contains(e.target))
67
- setOpen(false);
75
+ // contentRef too (#339): the menu content portals to document.body, so
76
+ // it's no longer a descendant of rootRef.
77
+ const target = e.target;
78
+ if (rootRef.current?.contains(target))
79
+ return;
80
+ if (contentRef.current?.contains(target))
81
+ return;
82
+ if (isInsideLaterFloatingLayer(target, contentRef.current))
83
+ return;
84
+ setOpen(false);
68
85
  };
69
86
  document.addEventListener("pointerdown", onDown);
70
87
  return () => document.removeEventListener("pointerdown", onDown);
@@ -88,7 +105,7 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
88
105
  setOpen(!open);
89
106
  },
90
107
  });
91
- return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_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, { 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 }) }) }))] }));
92
109
  }
93
110
  Menu.displayName = "Menu";
94
111
  export function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
@@ -3,8 +3,9 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
3
3
  // A dropdown menu — same popover surface used for ⋯ / sort / filter
4
4
  // menus (measured from listboxes): radius 12, 4px padding, a layered
5
5
  // soft shadow, over 32px items (radius 8, icon + label + optional shortcut /
6
- // check). The content is rendered inside this relative root so it keeps these
7
- // nested styles while escaping the trigger's own box.
6
+ // check). The content portals to document.body (#339) so no ancestor's
7
+ // transform/backdrop-filter can capture its position: fixed box; z-index 70
8
+ // keeps it above Dialog/Drawer's 60/61.
8
9
  const config = defineRecipe({
9
10
  name: "menu",
10
11
  base: `
@@ -12,7 +13,7 @@ const config = defineRecipe({
12
13
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
13
14
 
14
15
  & .crisp-menu-content {
15
- position: fixed; z-index: 60; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
16
+ position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
16
17
  min-width: 200px; max-height: 360px; overflow-y: auto; padding: ${c.space["1"]};
17
18
  border-radius: ${c.radius.xl}; background: ${c.bgPopover};
18
19
  box-shadow: ${e.floating};
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check, ChevronDown, Search } from "lucide-react";
3
3
  import * as React from "react";
4
- import { formValueStyle, placeFloating, useFormReset } from "../../core/index.js";
4
+ import { FloatingPortal, formValueStyle, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, useFormReset, } from "../../core/index.js";
5
5
  import { useListboxKeyboard } from "../select/index.js";
6
6
  import { multiselectRecipe } from "./multiselect.recipe.js";
7
7
  const TONES = [
@@ -46,6 +46,9 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
46
46
  const [query, setQuery] = React.useState("");
47
47
  const [highlight, setHighlight] = React.useState(0);
48
48
  const rootRef = React.useRef(null);
49
+ // The listbox portals to document.body (#339), so it's no longer a
50
+ // descendant of rootRef -- outside-click detection needs this ref too.
51
+ const listboxRef = React.useRef(null);
49
52
  // `form.reset()` restores native controls; a button + listbox holds its value
50
53
  // in React, so it has to listen (#245). Controlled multiselects are left
51
54
  // alone.
@@ -119,12 +122,26 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
119
122
  // Tab is already moving focus out; `close` would drag it back to the trigger
120
123
  dismiss: () => setOpen(false),
121
124
  });
125
+ // Registers the (now-portaled) listbox on the shared floating-layer stack
126
+ // (#339) so this doesn't get closed out from under itself when nested
127
+ // inside another floating layer -- see Select's matching comment.
128
+ React.useEffect(() => {
129
+ if (!open || !listboxRef.current)
130
+ return;
131
+ return registerFloatingLayer(listboxRef.current);
132
+ }, [open]);
122
133
  React.useEffect(() => {
123
134
  if (!open)
124
135
  return;
125
136
  const onDown = (e) => {
126
- if (!rootRef.current?.contains(e.target))
127
- setOpen(false);
137
+ const target = e.target;
138
+ if (rootRef.current?.contains(target))
139
+ return;
140
+ if (listboxRef.current?.contains(target))
141
+ return;
142
+ if (isInsideLaterFloatingLayer(target, listboxRef.current))
143
+ return;
144
+ setOpen(false);
128
145
  };
129
146
  document.addEventListener("pointerdown", onDown);
130
147
  return () => document.removeEventListener("pointerdown", onDown);
@@ -137,12 +154,12 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
137
154
  e.preventDefault();
138
155
  openMenu();
139
156
  }
140
- }, 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 && (_jsxs("div", { 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) => {
141
- setQuery(e.target.value);
142
- setHighlight(0);
143
- }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-multiselect-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
144
- const sel = selectedSet.has(opt.value);
145
- return (_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: [_jsx("span", { className: "crisp-multiselect-option-chip", children: _jsx(Chip, { opt: opt }) }), _jsx("span", { className: "crisp-multiselect-check", children: sel && _jsx(Check, { size: 12, strokeWidth: 3 }) })] }, opt.value));
146
- }))] }))] }));
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) => {
158
+ setQuery(e.target.value);
159
+ setHighlight(0);
160
+ }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-multiselect-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
161
+ const sel = selectedSet.has(opt.value);
162
+ return (_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: [_jsx("span", { className: "crisp-multiselect-option-chip", children: _jsx(Chip, { opt: opt }) }), _jsx("span", { className: "crisp-multiselect-check", children: sel && _jsx(Check, { size: 12, strokeWidth: 3 }) })] }, opt.value));
163
+ }))] }) }))] }));
147
164
  }
148
165
  MultiSelect.displayName = "MultiSelect";
@@ -3,8 +3,9 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
3
3
  // A multi-select / tag editor — measured from a cell tag picker: an
4
4
  // outlined trigger showing the selected values as coloured chips, opening a
5
5
  // popover with a search field over rows of "[coloured chip] … [checkbox]" that
6
- // toggle on click. The popover keeps these nested styles by rendering inside
7
- // this relative root.
6
+ // toggle on click. The popover portals to document.body (#339) so no
7
+ // ancestor's transform/backdrop-filter can capture its position: fixed box;
8
+ // z-index 70 keeps it above Dialog/Drawer's 60/61.
8
9
  const config = defineRecipe({
9
10
  name: "multiselect",
10
11
  base: `
@@ -27,7 +28,7 @@ const config = defineRecipe({
27
28
  & .crisp-multiselect-trigger[data-open] .crisp-multiselect-chevron { transform: rotate(180deg); }
28
29
 
29
30
  & .crisp-multiselect-listbox {
30
- position: fixed; z-index: 60; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
31
+ position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
31
32
  max-height: 300px; overflow-y: auto; padding: ${c.space["1"]};
32
33
  border-radius: ${c.radius.xl}; background: ${c.bgPopover};
33
34
  box-shadow: ${e.floating};
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
- import { placeFloating } from "../../core/index.js";
3
+ import { FloatingPortal, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, } from "../../core/index.js";
4
4
  import { popoverRecipe } from "./popover.recipe.js";
5
5
  /**
6
6
  * A generic anchored popover — the Menu/Select surface holding arbitrary
@@ -19,6 +19,9 @@ export function Popover({ trigger, children, align = "start", open: openProp, de
19
19
  const open = openProp ?? internalOpen;
20
20
  const rootRef = React.useRef(null);
21
21
  const triggerRef = React.useRef(null);
22
+ // The content portals to document.body (#339), so it's no longer a
23
+ // descendant of rootRef -- outside-click detection needs this ref too.
24
+ const contentRef = React.useRef(null);
22
25
  const [pos, setPos] = React.useState({});
23
26
  const [up, setUp] = React.useState(false);
24
27
  const setOpen = React.useCallback((next) => {
@@ -46,12 +49,26 @@ export function Popover({ trigger, children, align = "start", open: openProp, de
46
49
  if (open)
47
50
  place();
48
51
  }, [open]);
52
+ // Registers the (now-portaled) content on the shared floating-layer stack
53
+ // (#339) so this doesn't get closed out from under itself when it holds
54
+ // another floating layer -- see core/floating-stack.ts.
55
+ React.useEffect(() => {
56
+ if (!open || !contentRef.current)
57
+ return;
58
+ return registerFloatingLayer(contentRef.current);
59
+ }, [open]);
49
60
  React.useEffect(() => {
50
61
  if (!open)
51
62
  return;
52
63
  const onDown = (e) => {
53
- if (!rootRef.current?.contains(e.target))
54
- setOpen(false);
64
+ const target = e.target;
65
+ if (rootRef.current?.contains(target))
66
+ return;
67
+ if (contentRef.current?.contains(target))
68
+ return;
69
+ if (isInsideLaterFloatingLayer(target, contentRef.current))
70
+ return;
71
+ setOpen(false);
55
72
  };
56
73
  document.addEventListener("pointerdown", onDown);
57
74
  return () => document.removeEventListener("pointerdown", onDown);
@@ -69,11 +86,11 @@ export function Popover({ trigger, children, align = "start", open: openProp, de
69
86
  setOpen(!open);
70
87
  },
71
88
  });
72
- return (_jsxs("div", { ref: rootRef, className: popoverRecipe({ className }), children: [triggerEl, open && (_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) => {
73
- if (e.key === "Escape") {
74
- e.preventDefault();
75
- setOpen(false);
76
- }
77
- }, children: children }))] }));
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) => {
90
+ if (e.key === "Escape") {
91
+ e.preventDefault();
92
+ setOpen(false);
93
+ }
94
+ }, children: children }) }))] }));
78
95
  }
79
96
  Popover.displayName = "Popover";
@@ -2,12 +2,14 @@ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
2
  import { contract as c, elevation as e } from "../../tokens/index.js";
3
3
  // A generic anchored popover — the same measured surface as Menu / Select
4
4
  // (radius 12, 4px padding, layered soft shadow, inset border), but holding
5
- // arbitrary content. Rendered inside a display:contents root and lifted out of
6
- // flow with position:fixed, so its styles survive while it floats.
5
+ // arbitrary content. The content portals to document.body (#339) so no
6
+ // ancestor's transform/backdrop-filter can capture its position: fixed box;
7
+ // z-index 70 keeps it above Dialog/Drawer's 60/61. The display:contents root
8
+ // is what's left in place, so a consumer's own styling of it survives.
7
9
  const config = defineRecipe({
8
10
  name: "popover",
9
11
  base: `display: contents; font-family: ${c.font.sans};
10
- & .crisp-popover-content { position: fixed; z-index: 60; 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); } }
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); } }
11
13
  & .crisp-popover-content[data-align="end"] { transform-origin: top right; }
12
14
  & .crisp-popover-content[data-up] { transform-origin: bottom left; }`,
13
15
  variants: {},
@@ -1,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check, ChevronDown, Search } from "lucide-react";
3
3
  import * as React from "react";
4
- import { formValueStyle, placeFloating, useFormReset } from "../../core/index.js";
4
+ import { FloatingPortal, formValueStyle, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, useFormReset, } from "../../core/index.js";
5
5
  import { useListboxKeyboard } from "./listbox-keyboard.js";
6
6
  import { selectRecipe } from "./select.recipe.js";
7
7
  function optionText(o) {
@@ -33,6 +33,10 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
33
33
  const [highlight, setHighlight] = React.useState(0);
34
34
  const [query, setQuery] = React.useState("");
35
35
  const rootRef = React.useRef(null);
36
+ // The listbox portals to document.body (#339), so it's no longer a
37
+ // descendant of rootRef -- outside-click detection needs this ref too, or
38
+ // every click inside the listbox reads as "outside" and closes it.
39
+ const listboxRef = React.useRef(null);
36
40
  // `form.reset()` restores native controls; a button + listbox holds its value
37
41
  // in React, so it has to listen (#245). Controlled selects are left alone.
38
42
  useFormReset(rootRef, () => {
@@ -110,12 +114,29 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
110
114
  if (!searchable)
111
115
  onNavKeys(e);
112
116
  };
117
+ // Registers the (now-portaled) listbox on the shared floating-layer stack
118
+ // (#339) so a Select nested inside another floating layer (a Menu's sort
119
+ // popover, e.g.) doesn't get closed out from under itself: once both
120
+ // portal independently to document.body they're DOM siblings, not
121
+ // ancestor/descendant, so the outer layer's own outside-click check can no
122
+ // longer see "inside me" by DOM containment alone.
123
+ React.useEffect(() => {
124
+ if (!open || !listboxRef.current)
125
+ return;
126
+ return registerFloatingLayer(listboxRef.current);
127
+ }, [open]);
113
128
  React.useEffect(() => {
114
129
  if (!open)
115
130
  return;
116
131
  const onDown = (e) => {
117
- if (!rootRef.current?.contains(e.target))
118
- setOpen(false);
132
+ const target = e.target;
133
+ if (rootRef.current?.contains(target))
134
+ return;
135
+ if (listboxRef.current?.contains(target))
136
+ return;
137
+ if (isInsideLaterFloatingLayer(target, listboxRef.current))
138
+ return;
139
+ setOpen(false);
119
140
  };
120
141
  document.addEventListener("pointerdown", onDown);
121
142
  return () => document.removeEventListener("pointerdown", onDown);
@@ -123,12 +144,12 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
123
144
  return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
124
145
  // controlled by the listbox above; `readOnly` would bar it from
125
146
  // validation, which is the only reason this input is not hidden
126
- onChange: () => { } })), _jsxs("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsxs("div", { id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
127
- setQuery(e.target.value);
128
- setHighlight(0);
129
- }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
130
- const isSel = opt.value === selected;
131
- return (_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 && (_jsx("span", { className: "crisp-select-option-dot", style: { background: opt.color } })), _jsx("span", { className: "crisp-select-option-label", children: opt.label }), isSel && (_jsx(Check, { className: "crisp-select-option-check", size: 15, strokeWidth: 2 }))] }, opt.value));
132
- }))] }))] }));
147
+ onChange: () => { } })), _jsxs("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
148
+ setQuery(e.target.value);
149
+ setHighlight(0);
150
+ }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
151
+ const isSel = opt.value === selected;
152
+ return (_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 && (_jsx("span", { className: "crisp-select-option-dot", style: { background: opt.color } })), _jsx("span", { className: "crisp-select-option-label", children: opt.label }), isSel && (_jsx(Check, { className: "crisp-select-option-check", size: 15, strokeWidth: 2 }))] }, opt.value));
153
+ }))] }) }))] }));
133
154
  }
134
155
  Select.displayName = "Select";