@crisp-ui-kit/crisp 0.35.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 (69) hide show
  1. package/dist/bulkbar.css +3 -0
  2. package/dist/bulkbar.layered.css +3 -0
  3. package/dist/cjs/components/bulkbar/bulkbar.js +1 -1
  4. package/dist/cjs/components/bulkbar/bulkbar.recipe.js +6 -0
  5. package/dist/cjs/components/contextmenu/contextmenu.js +25 -8
  6. package/dist/cjs/components/contextmenu/contextmenu.recipe.js +5 -2
  7. package/dist/cjs/components/dialog/dialog.js +1 -1
  8. package/dist/cjs/components/dialog/dialog.recipe.js +1 -1
  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 +26 -9
  12. package/dist/cjs/components/multiselect/multiselect.recipe.js +4 -3
  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 +30 -9
  16. package/dist/cjs/components/select/select.recipe.js +5 -3
  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/recordpanel/recordpanel.js +4 -2
  22. package/dist/cjs/product/recordpanel/recordpanel.recipe.js +8 -0
  23. package/dist/cjs/product/recordstable/recordstable.js +15 -4
  24. package/dist/cjs/product/sidebar/sidebar.js +16 -3
  25. package/dist/contextmenu.css +1 -1
  26. package/dist/contextmenu.layered.css +1 -1
  27. package/dist/dialog.css +1 -1
  28. package/dist/dialog.layered.css +1 -1
  29. package/dist/esm/components/bulkbar/bulkbar.js +1 -1
  30. package/dist/esm/components/bulkbar/bulkbar.recipe.js +6 -0
  31. package/dist/esm/components/contextmenu/contextmenu.js +25 -8
  32. package/dist/esm/components/contextmenu/contextmenu.recipe.js +5 -2
  33. package/dist/esm/components/dialog/dialog.js +1 -1
  34. package/dist/esm/components/dialog/dialog.recipe.js +1 -1
  35. package/dist/esm/components/menu/menu.js +21 -4
  36. package/dist/esm/components/menu/menu.recipe.js +4 -3
  37. package/dist/esm/components/multiselect/multiselect.js +27 -10
  38. package/dist/esm/components/multiselect/multiselect.recipe.js +4 -3
  39. package/dist/esm/components/popover/popover.js +26 -9
  40. package/dist/esm/components/popover/popover.recipe.js +5 -3
  41. package/dist/esm/components/select/select.js +31 -10
  42. package/dist/esm/components/select/select.recipe.js +5 -3
  43. package/dist/esm/components/tabletoolbar/tabletoolbar.js +51 -7
  44. package/dist/esm/core/floating-portal.js +25 -0
  45. package/dist/esm/core/floating-stack.js +50 -0
  46. package/dist/esm/core/index.js +2 -0
  47. package/dist/esm/product/recordpanel/recordpanel.js +4 -2
  48. package/dist/esm/product/recordpanel/recordpanel.recipe.js +8 -0
  49. package/dist/esm/product/recordstable/recordstable.js +16 -5
  50. package/dist/esm/product/sidebar/sidebar.js +17 -4
  51. package/dist/menu.css +1 -1
  52. package/dist/menu.layered.css +1 -1
  53. package/dist/multiselect.css +1 -1
  54. package/dist/multiselect.layered.css +1 -1
  55. package/dist/popover.css +1 -1
  56. package/dist/popover.layered.css +1 -1
  57. package/dist/recordpanel.css +6 -0
  58. package/dist/recordpanel.layered.css +6 -0
  59. package/dist/select.css +1 -1
  60. package/dist/select.layered.css +1 -1
  61. package/dist/styles.css +15 -6
  62. package/dist/styles.layered.css +15 -6
  63. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +10 -1
  64. package/dist/types/core/floating-portal.d.ts +25 -0
  65. package/dist/types/core/floating-stack.d.ts +13 -0
  66. package/dist/types/core/index.d.ts +2 -0
  67. package/dist/types/product/recordpanel/recordpanel.d.ts +11 -1
  68. package/dist/types/product/sidebar/sidebar.d.ts +56 -0
  69. package/package.json +1 -1
package/dist/bulkbar.css CHANGED
@@ -5,6 +5,9 @@
5
5
  & .crisp-bulkbar-count { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); margin: 0; padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
6
6
  & .crisp-bulkbar-count:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); color: var(--crisp-fg-primary); }
7
7
  & .crisp-bulkbar-count svg { flex-shrink: 0; }
8
+ /* the ", Clear selection" ARIA concatenates onto the pill's name (#324)
9
+ -- read, never drawn. Same technique as Slider's thumblabel. */
10
+ & .crisp-bulkbar-clearlabel { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
8
11
  & .crisp-bulkbar-btn { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: transparent; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
9
12
  & .crisp-bulkbar-btn:hover { background: var(--crisp-bg-raised); }
10
13
  & .crisp-bulkbar-btn--danger { color: var(--crisp-bg-danger); }
@@ -6,6 +6,9 @@
6
6
  & .crisp-bulkbar-count { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); margin: 0; padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-muted); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
7
7
  & .crisp-bulkbar-count:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 8%, transparent); color: var(--crisp-fg-primary); }
8
8
  & .crisp-bulkbar-count svg { flex-shrink: 0; }
9
+ /* the ", Clear selection" ARIA concatenates onto the pill's name (#324)
10
+ -- read, never drawn. Same technique as Slider's thumblabel. */
11
+ & .crisp-bulkbar-clearlabel { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
9
12
  & .crisp-bulkbar-btn { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: var(--crisp-size-menuRow); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: transparent; color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); white-space: nowrap; cursor: pointer; transition: background-color .12s; }
10
13
  & .crisp-bulkbar-btn:hover { background: var(--crisp-bg-raised); }
11
14
  & .crisp-bulkbar-btn--danger { color: var(--crisp-bg-danger); }
@@ -45,7 +45,7 @@ const bulkbar_recipe_1 = require("./bulkbar.recipe");
45
45
  * (e.g. `count > 0 && <BulkBar …/>`). Measured from a records table.
46
46
  */
47
47
  function BulkBar({ count, onClear, countLabel = "selected", countText = (count, label) => ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [count, " ", label] })), children, className, clearLabel = "Clear selection", ...rest }) {
48
- return ((0, jsx_runtime_1.jsx)("div", { className: (0, bulkbar_recipe_1.bulkbarRecipe)({ className }), ...rest, children: (0, jsx_runtime_1.jsxs)("div", { className: "crisp-bulkbar-bar", children: [count != null && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-bulkbar-count", onClick: onClear, title: clearLabel, children: [countText(count, countLabel), (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2, "aria-hidden": "true" })] })), children] }) }));
48
+ return ((0, jsx_runtime_1.jsx)("div", { className: (0, bulkbar_recipe_1.bulkbarRecipe)({ className }), ...rest, children: (0, jsx_runtime_1.jsxs)("div", { className: "crisp-bulkbar-bar", children: [count != null && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-bulkbar-count", onClick: onClear, title: clearLabel, children: [countText(count, countLabel), (0, jsx_runtime_1.jsx)("span", { className: "crisp-bulkbar-clearlabel", children: clearLabel }), (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2, "aria-hidden": "true" })] })), children] }) }));
49
49
  }
50
50
  BulkBar.displayName = "BulkBar";
51
51
  /** A styled action button for use inside a `<BulkBar>` (or as a `<Menu>` trigger). */
@@ -18,6 +18,12 @@ const config = (0, core_1.defineRecipe)({
18
18
  & .crisp-bulkbar-count { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.menuRow}; margin: 0; padding: 0 ${tokens_1.contract.space["2"]}; border: none; border-radius: ${tokens_1.contract.radius.md}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); font-family: inherit; font-size: ${tokens_1.contract.text.md}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgMuted}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
19
19
  & .crisp-bulkbar-count:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 8%, transparent); color: ${tokens_1.contract.fgPrimary}; }
20
20
  & .crisp-bulkbar-count svg { flex-shrink: 0; }
21
+ /* the ", Clear selection" ARIA concatenates onto the pill's name (#324)
22
+ -- read, never drawn. Same technique as Slider's thumblabel. */
23
+ & .crisp-bulkbar-clearlabel {
24
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
25
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
26
+ }
21
27
  & .crisp-bulkbar-btn { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.menuRow}; padding: 0 ${tokens_1.contract.space["2"]}; border: none; border-radius: ${tokens_1.contract.radius.md}; background: transparent; color: ${tokens_1.contract.fgPrimary}; font-family: inherit; font-size: ${tokens_1.contract.text.md}; font-weight: ${tokens_1.contract.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
22
28
  & .crisp-bulkbar-btn:hover { background: ${tokens_1.contract.bgRaised}; }
23
29
  & .crisp-bulkbar-btn--danger { color: ${tokens_1.contract.bgDanger}; }
@@ -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"]}; }
@@ -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};
@@ -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,12 +158,26 @@ 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);
@@ -173,12 +190,12 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
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};
@@ -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: {},
@@ -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,12 +150,29 @@ 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);
@@ -159,12 +180,12 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
159
180
  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",
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, 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";
@@ -7,8 +7,10 @@ const tokens_1 = require("../../tokens");
7
7
  // (radius 9, value + a 16px chevron) over a listbox — radius 12, 4px padding, a
8
8
  // layered soft shadow — of 32px options (radius 8, 6/8 padding) that show a
9
9
  // check on the selected one and highlight on hover / keyboard. The listbox is
10
- // portaled into this root (which is position:relative) so it escapes overflow
11
- // while keeping these nested styles.
10
+ // portaled to document.body (#339) so no ancestor's transform/backdrop-filter
11
+ // (Dialog's own measured surface sets both) can capture its position: fixed
12
+ // box; z-index 70 keeps it above Dialog/Drawer's z-index 60/61 layers, since
13
+ // it's no longer nested inside whichever one opened it.
12
14
  const config = (0, core_1.defineRecipe)({
13
15
  name: "select",
14
16
  base: `
@@ -31,7 +33,7 @@ const config = (0, core_1.defineRecipe)({
31
33
  & .crisp-select-trigger[data-open] .crisp-select-chevron { transform: rotate(180deg); }
32
34
 
33
35
  & .crisp-select-listbox {
34
- position: fixed; z-index: 60; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space.px};
36
+ position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space.px};
35
37
  max-height: 280px; overflow-y: auto; padding: ${tokens_1.contract.space["1"]};
36
38
  border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover};
37
39
  box-shadow: ${tokens_1.elevation.floating};
@@ -40,12 +40,14 @@ const jsx_runtime_1 = require("react/jsx-runtime");
40
40
  const lucide_react_1 = require("lucide-react");
41
41
  const React = __importStar(require("react"));
42
42
  const core_1 = require("../../core");
43
+ const menu_1 = require("../menu");
43
44
  const select_1 = require("../select");
44
45
  const tabletoolbar_recipe_1 = require("./tabletoolbar.recipe");
45
46
  const OP_LABELS = {
46
47
  contains: "contains",
47
48
  is: "is",
48
49
  isnot: "is not",
50
+ isanyof: "is any of",
49
51
  empty: "is empty",
50
52
  notempty: "is not empty",
51
53
  };
@@ -56,7 +58,16 @@ const OP_ORDER = [
56
58
  "empty",
57
59
  "notempty",
58
60
  ];
61
+ // "isanyof" is appended only for a column that supplies `values` — there is
62
+ // nothing to check off otherwise.
63
+ const opsFor = (col) => col?.values?.length ? [...OP_ORDER, "isanyof"] : OP_ORDER;
59
64
  const opNeedsValue = (op) => op === "contains" || op === "is" || op === "isnot";
65
+ const opNeedsValues = (op) => op === "isanyof";
66
+ // "Department is any of IT, Finance" past two values becomes "… +N": the
67
+ // chip is one line, and a field can have far more than two facet values.
68
+ const isAnyOfSummary = (values) => values.length <= 2
69
+ ? values.join(", ")
70
+ : `${values.slice(0, 2).join(", ")} +${values.length - 2}`;
60
71
  const isEmptyValue = (v) => v == null || v === "" || (Array.isArray(v) && v.length === 0);
61
72
  const asText = (v) => {
62
73
  if (isEmptyValue(v))
@@ -82,7 +93,8 @@ const asText = (v) => {
82
93
  * display string, so a grouped-thousands "$1,200" still matches "1200".
83
94
  */
84
95
  function applyFilters(rows, filters, get = (row, key) => row[key]) {
85
- const active = filters.filter((f) => !opNeedsValue(f.op) || f.value.trim() !== "");
96
+ const active = filters.filter((f) => (!opNeedsValue(f.op) || f.value.trim() !== "") &&
97
+ (!opNeedsValues(f.op) || (f.values?.length ?? 0) > 0));
86
98
  if (!active.length)
87
99
  return rows;
88
100
  return rows.filter((row) => active.every((f) => {
@@ -92,6 +104,8 @@ function applyFilters(rows, filters, get = (row, key) => row[key]) {
92
104
  if (f.op === "notempty")
93
105
  return !isEmptyValue(raw);
94
106
  const t = asText(raw).toLowerCase();
107
+ if (f.op === "isanyof")
108
+ return (f.values ?? []).some((v) => v.toLowerCase() === t);
95
109
  const v = f.value.trim().toLowerCase();
96
110
  if (f.op === "contains")
97
111
  return t.includes(v);
@@ -153,6 +167,8 @@ function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsChange,
153
167
  const [openMenu, setOpenMenu] = React.useState(null);
154
168
  const [dragSort, setDragSort] = React.useState(null);
155
169
  const [filterQuery, setFilterQuery] = React.useState("");
170
+ // filters the "isanyof" checkbox list, same lifetime as filterQuery above
171
+ const [anyOfQuery, setAnyOfQuery] = React.useState("");
156
172
  // close any open menu on outside click (anything not inside a menu or its
157
173
  // trigger) or Escape
158
174
  React.useEffect(() => {
@@ -160,8 +176,15 @@ function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsChange,
160
176
  return;
161
177
  const onDown = (e) => {
162
178
  const t = e.target;
163
- if (!t.closest(".crisp-tabletoolbar-menu, [data-menu-trigger]"))
164
- setOpenMenu(null);
179
+ if (t.closest(".crisp-tabletoolbar-menu, [data-menu-trigger]"))
180
+ return;
181
+ // #339: a Select opened from inside this popover (the sort row's
182
+ // direction control) now portals to document.body, so it's no longer
183
+ // inside .crisp-tabletoolbar-menu by DOM containment -- check the
184
+ // shared floating-layer stack too.
185
+ if ((0, core_1.isInsideLaterFloatingLayer)(t, null))
186
+ return;
187
+ setOpenMenu(null);
165
188
  };
166
189
  const onKey = (e) => e.key === "Escape" && setOpenMenu(null);
167
190
  document.addEventListener("mousedown", onDown);
@@ -251,9 +274,30 @@ function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsChange,
251
274
  .map((col) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => addFilter(col.key), children: [col.icon ? ((0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)("span", { style: { width: 15 } })), col.label] }, col.key)))] }))] }), filters.map((cond) => {
252
275
  const col = columns.find((c) => c.key === cond.key);
253
276
  const open = openMenu === `fcond:${cond.id}`;
254
- const hasValue = !opNeedsValue(cond.op) || cond.value.trim();
255
- return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-fchip", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-fchip-main", "data-menu-trigger": true, "aria-haspopup": "dialog", onClick: () => setOpenMenu(open ? null : `fcond:${cond.id}`), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-field", children: col?.label }), hasValue ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-op", children: OP_LABELS[cond.op] }), opNeedsValue(cond.op) && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-val", children: cond.value }))] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-op", children: selectConditionLabel }))] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-tabletoolbar-fchip-x", "aria-label": `Remove ${col?.label} filter`, onClick: () => removeFilter(cond.id), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2 }) })] }), open && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": `${col?.label} condition`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-menu-head", children: [col?.label, " condition"] }), OP_ORDER.map((op) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", "data-on": cond.op === op ? "" : undefined, onClick: () => patchFilter(cond.id, { op }), children: [OP_LABELS[op], cond.op === op ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))), opNeedsValue(cond.op) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter value`,
277
+ const hasValue = (!opNeedsValue(cond.op) || cond.value.trim()) &&
278
+ (!opNeedsValues(cond.op) || (cond.values?.length ?? 0) > 0);
279
+ return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-fchip", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-fchip-main", "data-menu-trigger": true, "aria-haspopup": "dialog", onClick: () => setOpenMenu(open ? null : `fcond:${cond.id}`), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-field", children: col?.label }), hasValue ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-op", children: OP_LABELS[cond.op] }), opNeedsValue(cond.op) && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-val", children: cond.value })), cond.op === "isanyof" && cond.values && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-val", children: isAnyOfSummary(cond.values) }))] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-op", children: selectConditionLabel }))] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-tabletoolbar-fchip-x", "aria-label": `Remove ${col?.label} filter`, onClick: () => removeFilter(cond.id), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2 }) })] }), open && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": `${col?.label} condition`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-menu-head", children: [col?.label, " condition"] }), opsFor(col).map((op) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", "data-on": cond.op === op ? "" : undefined, onClick: () => patchFilter(cond.id, { op }), children: [OP_LABELS[op], cond.op === op ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))), opNeedsValue(cond.op) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter value`,
256
280
  // biome-ignore lint/a11y/noAutofocus: focus the value field
257
- autoFocus: true, value: cond.value, onChange: (e) => patchFilter(cond.id, { value: e.target.value }) }))] }))] }, cond.id));
281
+ autoFocus: true, value: cond.value, onChange: (e) => patchFilter(cond.id, { value: e.target.value }) })), cond.op === "isanyof" && col?.values && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter values`,
282
+ // biome-ignore lint/a11y/noAutofocus: focus the search field
283
+ autoFocus: true, value: anyOfQuery, onChange: (e) => setAnyOfQuery(e.target.value) }), col.values
284
+ .filter((v) => v
285
+ .toLowerCase()
286
+ .includes(anyOfQuery.trim().toLowerCase()))
287
+ .map((v) => {
288
+ const checked = (cond.values ?? []).includes(v);
289
+ return ((0, jsx_runtime_1.jsx)(menu_1.MenuCheckboxItem, { checked: checked, onCheckedChange: (next) => {
290
+ const set = new Set(cond.values ?? []);
291
+ if (next)
292
+ set.add(v);
293
+ else
294
+ set.delete(v);
295
+ // the column's own order, not click order —
296
+ // what the chip summary reads out
297
+ patchFilter(cond.id, {
298
+ values: col.values?.filter((x) => set.has(x)),
299
+ });
300
+ }, children: v }, v));
301
+ })] }))] }))] }, cond.id));
258
302
  }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-spacer" }), count != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-count", children: count })), children] }) }));
259
303
  }