@crisp-ui-kit/crisp 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/badge.css +5 -5
  2. package/dist/badge.layered.css +5 -5
  3. package/dist/cjs/components/badge/badge.recipe.js +11 -5
  4. package/dist/cjs/components/contextmenu/contextmenu.js +25 -8
  5. package/dist/cjs/components/contextmenu/contextmenu.recipe.js +5 -2
  6. package/dist/cjs/components/dialog/dialog.js +1 -1
  7. package/dist/cjs/components/dialog/dialog.recipe.js +1 -1
  8. package/dist/cjs/components/fileupload/fileupload.js +2 -2
  9. package/dist/cjs/components/menu/menu.js +20 -3
  10. package/dist/cjs/components/menu/menu.recipe.js +4 -3
  11. package/dist/cjs/components/multiselect/multiselect.js +29 -12
  12. package/dist/cjs/components/multiselect/multiselect.recipe.js +17 -5
  13. package/dist/cjs/components/popover/popover.js +25 -8
  14. package/dist/cjs/components/popover/popover.recipe.js +5 -3
  15. package/dist/cjs/components/select/select.js +32 -11
  16. package/dist/cjs/components/select/select.recipe.js +19 -4
  17. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +50 -6
  18. package/dist/cjs/core/floating-portal.js +28 -0
  19. package/dist/cjs/core/floating-stack.js +54 -0
  20. package/dist/cjs/core/index.js +6 -1
  21. package/dist/cjs/product/recordstable/recordstable.js +15 -4
  22. package/dist/cjs/tokens/palette.js +23 -0
  23. package/dist/cjs/tokens/semantic.js +7 -0
  24. package/dist/contextmenu.css +1 -1
  25. package/dist/contextmenu.layered.css +1 -1
  26. package/dist/dialog.css +1 -1
  27. package/dist/dialog.layered.css +1 -1
  28. package/dist/esm/components/badge/badge.recipe.js +11 -5
  29. package/dist/esm/components/contextmenu/contextmenu.js +25 -8
  30. package/dist/esm/components/contextmenu/contextmenu.recipe.js +5 -2
  31. package/dist/esm/components/dialog/dialog.js +1 -1
  32. package/dist/esm/components/dialog/dialog.recipe.js +1 -1
  33. package/dist/esm/components/fileupload/fileupload.js +2 -2
  34. package/dist/esm/components/menu/menu.js +21 -4
  35. package/dist/esm/components/menu/menu.recipe.js +4 -3
  36. package/dist/esm/components/multiselect/multiselect.js +30 -13
  37. package/dist/esm/components/multiselect/multiselect.recipe.js +17 -5
  38. package/dist/esm/components/popover/popover.js +26 -9
  39. package/dist/esm/components/popover/popover.recipe.js +5 -3
  40. package/dist/esm/components/select/select.js +33 -12
  41. package/dist/esm/components/select/select.recipe.js +19 -4
  42. package/dist/esm/components/tabletoolbar/tabletoolbar.js +51 -7
  43. package/dist/esm/core/floating-portal.js +25 -0
  44. package/dist/esm/core/floating-stack.js +50 -0
  45. package/dist/esm/core/index.js +2 -0
  46. package/dist/esm/product/recordstable/recordstable.js +16 -5
  47. package/dist/esm/tokens/palette.js +23 -0
  48. package/dist/esm/tokens/semantic.js +7 -0
  49. package/dist/menu.css +1 -1
  50. package/dist/menu.layered.css +1 -1
  51. package/dist/multiselect.css +7 -1
  52. package/dist/multiselect.d.ts +2 -2
  53. package/dist/multiselect.layered.css +7 -1
  54. package/dist/native/tokens.d.ts +1 -1
  55. package/dist/native/tokens.js +24 -4
  56. package/dist/popover.css +1 -1
  57. package/dist/popover.layered.css +1 -1
  58. package/dist/select.css +7 -1
  59. package/dist/select.d.ts +2 -1
  60. package/dist/select.layered.css +7 -1
  61. package/dist/styles.css +33 -11
  62. package/dist/styles.layered.css +33 -11
  63. package/dist/tokens.css +10 -0
  64. package/dist/tokens.json +24 -4
  65. package/dist/types/components/fileupload/fileupload.d.ts +1 -1
  66. package/dist/types/components/multiselect/multiselect.d.ts +3 -1
  67. package/dist/types/components/multiselect/multiselect.recipe.d.ts +18 -3
  68. package/dist/types/components/select/select.d.ts +4 -1
  69. package/dist/types/components/select/select.recipe.d.ts +12 -0
  70. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +10 -1
  71. package/dist/types/core/floating-portal.d.ts +25 -0
  72. package/dist/types/core/floating-stack.d.ts +13 -0
  73. package/dist/types/core/index.d.ts +2 -0
  74. package/dist/types/product/sidebar/index.d.ts +1 -1
  75. package/dist/types/tokens/contract.d.ts +5 -0
  76. package/dist/types/tokens/palette.d.ts +20 -0
  77. package/dist/types/tokens/semantic.d.ts +7 -0
  78. package/package.json +1 -1
@@ -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};
@@ -71,8 +73,21 @@ const config = (0, core_1.defineRecipe)({
71
73
  full: `width: 100%;`,
72
74
  auto: `width: auto;`,
73
75
  },
76
+ // Same ring Input's `invalid` draws, adapted to Select's box-shadow-ring
77
+ // trigger (a real <button>, so it needs no ::after overlay the way
78
+ // Input/Textarea do). Outranks hover/open/focus, same reasoning as
79
+ // Input: a field being corrected stays visibly wrong while it is.
80
+ invalid: {
81
+ true: `
82
+ & .crisp-select-trigger,
83
+ & .crisp-select-trigger:hover,
84
+ & .crisp-select-trigger[data-open],
85
+ & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgDanger}; }
86
+ `,
87
+ false: ``,
88
+ },
74
89
  },
75
- defaultVariants: { variant: "default", width: "full" },
90
+ defaultVariants: { variant: "default", width: "full", invalid: false },
76
91
  compoundVariants: [],
77
92
  });
78
93
  exports.selectRecipe = Object.assign((0, core_1.resolveRecipe)(config), { config });
@@ -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
  }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FloatingPortal = FloatingPortal;
4
+ const react_dom_1 = require("react-dom");
5
+ /**
6
+ * Portals a floating layer (a `position: fixed` listbox/menu/popover
7
+ * content) to `document.body` — exactly what Tooltip already did with a
8
+ * bare `createPortal` call, pulled out so `Select`, `MultiSelect`, `Menu`,
9
+ * `Popover` and `ContextMenu` share the one implementation (#339).
10
+ *
11
+ * Needed because a `position: fixed` box resolves against the nearest
12
+ * ancestor that establishes a containing block for it — not necessarily the
13
+ * viewport. `transform` (even the identity `scale(1)`), `filter` and
14
+ * `backdrop-filter` all qualify, and `Dialog`'s own measured surface sets
15
+ * both a resting `transform: scale(1)` (so its `@starting-style` entrance
16
+ * has something to animate from) and `backdrop-filter: blur(15px)` (Attio's
17
+ * frosted-glass modal). A floating layer opened from a trigger inside a
18
+ * `Dialog` was rendering offset by the dialog's own box instead of anchored
19
+ * to its trigger.
20
+ *
21
+ * Every caller renders this conditionally on its own `open` state, which
22
+ * starts `false` and is driven only by client interaction — never a prop a
23
+ * fixture sets true at mount (unlike Dialog/Drawer/Command) — so this never
24
+ * touches `document` during SSR, the same safety Tooltip already had.
25
+ */
26
+ function FloatingPortal({ children, }) {
27
+ return (0, react_dom_1.createPortal)(children, document.body);
28
+ }
@@ -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);
@@ -60,4 +60,27 @@ exports.palette = {
60
60
  cyan600: { light: "#00b9eb", dark: "#00b9eb" },
61
61
  purple600: { light: "#9b69ff", dark: "#9b69ff" },
62
62
  graySidebar: { light: "#fbfbfb", dark: "#0e0f10" },
63
+ // Soft Badge's text colour (#323): the *600 tones above, used directly as
64
+ // soft-badge text, fail AA on their own soft tint in light mode (2.97-3.87)
65
+ // and brand fails in dark too (3.83) -- everything else already brightens
66
+ // enough in dark to pass (measured: 5.60-6.77). Attio's own soft tag pills
67
+ // (a company's Categories field, app.attio.com, 2026-09-23) don't reuse the
68
+ // accent colour as text either: they shift it toward black (light) or white
69
+ // (dark) at roughly the same relative luminance regardless of hue --
70
+ // measured 5 different category colours in dark mode (L 0.61-0.75) and two
71
+ // in light (L 0.08-0.10), a tight enough cluster to generalise the SHIFT,
72
+ // not the exact colour, to crisp's own four accent hues. Computed to clear
73
+ // AA with the same margin Attio's pills measured at (5.5-6.5:1) against
74
+ // crisp's EXISTING soft-tint background -- the tint itself is unchanged.
75
+ // Dark values that already passed keep their *600 dark value unchanged.
76
+ softBrandText: { light: "#2057bd", dark: "#568df3" },
77
+ softSuccessText: { light: "#186b3d", dark: "#2ea968" },
78
+ softWarningText: { light: "#80520d", dark: "#e0982a" },
79
+ softDangerText: { light: "#a33539", dark: "#ff5a5f" },
80
+ // Same reasoning, for Badge's neutral tone: fg-muted on bg-raised measures
81
+ // 4.40 in light (just under AA) and 6.77 in dark (already fine). Unlike
82
+ // fg-muted/bg-raised themselves (system-wide, measured from Attio, kept
83
+ // as-is per build/spec-check.mjs's own note), this token exists only for
84
+ // Badge's soft-neutral text, so darkening it doesn't move any other surface.
85
+ softNeutralText: { light: "#686870", dark: "#9a9aa2" },
63
86
  };
@@ -54,4 +54,11 @@ exports.semantic = {
54
54
  // #fbfbfb — the subtle raised fill; the active tab pill uses the same
55
55
  // value as the sidebar, so it shares the palette entry under a neutral name.
56
56
  bgSubtle: "graySidebar",
57
+ /** Soft Badge's text colour (#323) — see palette.ts's comment on the
58
+ * soft*Text entries for the Attio measurement this is derived from. */
59
+ fgBrandSoft: "softBrandText",
60
+ fgSuccessSoft: "softSuccessText",
61
+ fgWarningSoft: "softWarningText",
62
+ fgDangerSoft: "softDangerText",
63
+ fgNeutralSoft: "softNeutralText",
57
64
  };
@@ -1,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); }
@@ -29,29 +29,35 @@ const spec = defineSpec({
29
29
  ],
30
30
  variants: {
31
31
  tone: {
32
+ // #323: the soft text colour is a dedicated fg*Soft token, not the
33
+ // tone's own accent (fgMuted/bgBrandSolid/bgSuccess/bgWarning/bgDanger)
34
+ // — reusing the accent as soft text is exactly what measured below AA
35
+ // (2.97-4.40 in light; brand also failed in dark, 3.83). Background and
36
+ // ring are unchanged: they were never the problem, and neither is a
37
+ // measured Attio value that moving would help — only the text is.
32
38
  neutral: {
33
39
  background: c.bgRaised,
34
- color: c.fgMuted,
40
+ color: c.fgNeutralSoft,
35
41
  boxShadow: `inset 0 0 0 1px ${c.borderDefault}`,
36
42
  },
37
43
  brand: {
38
44
  background: soft(c.bgBrandSolid, 14),
39
- color: c.bgBrandSolid,
45
+ color: c.fgBrandSoft,
40
46
  boxShadow: ring(c.bgBrandSolid, 30),
41
47
  },
42
48
  success: {
43
49
  background: soft(c.bgSuccess, 14),
44
- color: c.bgSuccess,
50
+ color: c.fgSuccessSoft,
45
51
  boxShadow: ring(c.bgSuccess, 30),
46
52
  },
47
53
  warning: {
48
54
  background: soft(c.bgWarning, 16),
49
- color: c.bgWarning,
55
+ color: c.fgWarningSoft,
50
56
  boxShadow: ring(c.bgWarning, 32),
51
57
  },
52
58
  danger: {
53
59
  background: soft(c.bgDanger, 14),
54
- color: c.bgDanger,
60
+ color: c.fgDangerSoft,
55
61
  boxShadow: ring(c.bgDanger, 30),
56
62
  },
57
63
  },
@@ -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"]}; }
@@ -36,7 +36,7 @@ function formatSize(bytes) {
36
36
  * is theirs by construction. Controlled or uncontrolled, the same way every
37
37
  * other crisp component is.
38
38
  */
39
- export function FileUpload({ className, labels, "aria-label": ariaLabel, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
39
+ export function FileUpload({ className, labels, "aria-label": ariaLabel, id, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
40
40
  const props = {
41
41
  accept,
42
42
  disabled: disabledProp,
@@ -87,7 +87,7 @@ export function FileUpload({ className, labels, "aria-label": ariaLabel, accept,
87
87
  // focus should land next.
88
88
  const rootRef = React.useRef(null);
89
89
  const focusAfterRemoval = useFocusAfterRemoval(rootRef, ".crisp-fileupload-remove", dropzoneRef);
90
- return (_jsxs("div", { ref: rootRef, ...rest, className: fileuploadRecipe({ className }), children: [_jsxs("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [_jsx("span", { className: "crisp-fileupload-title", children: t.title }), _jsx("span", { className: "crisp-fileupload-hint", children: t.hint })] }), _jsx("input", { ...hiddenInputProps, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && (_jsx("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => (_jsxs("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [_jsx("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && (_jsx("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: _jsx("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), _jsx("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), _jsx("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
90
+ return (_jsxs("div", { ref: rootRef, ...rest, className: fileuploadRecipe({ className }), children: [_jsxs("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [_jsx("span", { className: "crisp-fileupload-title", children: t.title }), _jsx("span", { className: "crisp-fileupload-hint", children: t.hint })] }), _jsx("input", { ...hiddenInputProps, id: id ?? hiddenInputProps.id, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && (_jsx("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => (_jsxs("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [_jsx("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && (_jsx("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: _jsx("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), _jsx("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), _jsx("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
91
91
  focusAfterRemoval(i);
92
92
  removeFileEntry(entry.id);
93
93
  }, children: _jsx(X, { size: 13, strokeWidth: 2 }) })] }, entry.id))) })), _jsx("ul", { className: "crisp-fileupload-errors", "aria-live": "polite", children: rejections.map((r) => (_jsx("li", { className: "crisp-fileupload-error", children: `${r.file.name}: ${r.errors.map((e) => t.errors[e] ?? e).join(" ")}` }, r.file.name))) })] }));
@@ -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, }) {