@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
@@ -3,8 +3,9 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
3
3
  // A dropdown menu — same popover surface used for ⋯ / sort / filter
4
4
  // menus (measured from listboxes): radius 12, 4px padding, a layered
5
5
  // soft shadow, over 32px items (radius 8, icon + label + optional shortcut /
6
- // check). The content is rendered inside this relative root so it keeps these
7
- // nested styles while escaping the trigger's own box.
6
+ // check). The content portals to document.body (#339) so no ancestor's
7
+ // transform/backdrop-filter can capture its position: fixed box; z-index 70
8
+ // keeps it above Dialog/Drawer's 60/61.
8
9
  const config = defineRecipe({
9
10
  name: "menu",
10
11
  base: `
@@ -12,7 +13,7 @@ const config = defineRecipe({
12
13
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
13
14
 
14
15
  & .crisp-menu-content {
15
- position: fixed; z-index: 60; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
16
+ position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
16
17
  min-width: 200px; max-height: 360px; overflow-y: auto; padding: ${c.space["1"]};
17
18
  border-radius: ${c.radius.xl}; background: ${c.bgPopover};
18
19
  box-shadow: ${e.floating};
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check, ChevronDown, Search } from "lucide-react";
3
3
  import * as React from "react";
4
- import { formValueStyle, placeFloating, useFormReset } from "../../core/index.js";
4
+ import { FloatingPortal, formValueStyle, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, useFormReset, } from "../../core/index.js";
5
5
  import { useListboxKeyboard } from "../select/index.js";
6
6
  import { multiselectRecipe } from "./multiselect.recipe.js";
7
7
  const TONES = [
@@ -33,7 +33,7 @@ export function MultiSelect({ options, value: valueProp, defaultValue, onValueCh
33
33
  // The id belongs on the trigger, not the wrapper: `<label for>` only names a
34
34
  // LABELABLE element, so an id left on the root div meant a FormField label
35
35
  // above this control named nothing (#247). Select already did this.
36
- id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
36
+ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, ...rest }) {
37
37
  // Virtual focus: DOM focus stays on the trigger (or the search field) and the
38
38
  // active option is named by `aria-activedescendant`. `data-highlighted` is a
39
39
  // styling hook only -- a screen reader cannot see it.
@@ -46,6 +46,9 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
46
46
  const [query, setQuery] = React.useState("");
47
47
  const [highlight, setHighlight] = React.useState(0);
48
48
  const rootRef = React.useRef(null);
49
+ // The listbox portals to document.body (#339), so it's no longer a
50
+ // descendant of rootRef -- outside-click detection needs this ref too.
51
+ const listboxRef = React.useRef(null);
49
52
  // `form.reset()` restores native controls; a button + listbox holds its value
50
53
  // in React, so it has to listen (#245). Controlled multiselects are left
51
54
  // alone.
@@ -119,30 +122,44 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
119
122
  // Tab is already moving focus out; `close` would drag it back to the trigger
120
123
  dismiss: () => setOpen(false),
121
124
  });
125
+ // Registers the (now-portaled) listbox on the shared floating-layer stack
126
+ // (#339) so this doesn't get closed out from under itself when nested
127
+ // inside another floating layer -- see Select's matching comment.
128
+ React.useEffect(() => {
129
+ if (!open || !listboxRef.current)
130
+ return;
131
+ return registerFloatingLayer(listboxRef.current);
132
+ }, [open]);
122
133
  React.useEffect(() => {
123
134
  if (!open)
124
135
  return;
125
136
  const onDown = (e) => {
126
- if (!rootRef.current?.contains(e.target))
127
- setOpen(false);
137
+ const target = e.target;
138
+ if (rootRef.current?.contains(target))
139
+ return;
140
+ if (listboxRef.current?.contains(target))
141
+ return;
142
+ if (isInsideLaterFloatingLayer(target, listboxRef.current))
143
+ return;
144
+ setOpen(false);
128
145
  };
129
146
  document.addEventListener("pointerdown", onDown);
130
147
  return () => document.removeEventListener("pointerdown", onDown);
131
148
  }, [open]);
132
- return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ className }), ...rest, children: [name &&
133
- (value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
149
+ return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ invalid, className }), ...rest, children: [name &&
150
+ (value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
134
151
  if (open && !searchable)
135
152
  onNavKeys(e);
136
153
  else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
137
154
  e.preventDefault();
138
155
  openMenu();
139
156
  }
140
- }, children: [_jsx("span", { className: "crisp-multiselect-chips", children: selectedOptions.length === 0 ? (_jsx("span", { className: "crisp-multiselect-placeholder", children: placeholder })) : (selectedOptions.map((o) => _jsx(Chip, { opt: o }, o.value))) }), _jsx(ChevronDown, { className: "crisp-multiselect-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsxs("div", { id: listboxId, className: "crisp-multiselect-listbox", role: "listbox", "aria-multiselectable": "true", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-multiselect-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
141
- setQuery(e.target.value);
142
- setHighlight(0);
143
- }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-multiselect-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
144
- const sel = selectedSet.has(opt.value);
145
- return (_jsxs("button", { type: "button", id: optionId(i), role: "option", "aria-selected": sel, tabIndex: -1, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-multiselect-option", onClick: () => toggle(opt.value), onMouseEnter: () => setHighlight(i), children: [_jsx("span", { className: "crisp-multiselect-option-chip", children: _jsx(Chip, { opt: opt }) }), _jsx("span", { className: "crisp-multiselect-check", children: sel && _jsx(Check, { size: 12, strokeWidth: 3 }) })] }, opt.value));
146
- }))] }))] }));
157
+ }, children: [_jsx("span", { className: "crisp-multiselect-chips", children: selectedOptions.length === 0 ? (_jsx("span", { className: "crisp-multiselect-placeholder", children: placeholder })) : (selectedOptions.map((o) => _jsx(Chip, { opt: o }, o.value))) }), _jsx(ChevronDown, { className: "crisp-multiselect-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-multiselect-listbox", role: "listbox", "aria-multiselectable": "true", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-multiselect-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
158
+ setQuery(e.target.value);
159
+ setHighlight(0);
160
+ }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-multiselect-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
161
+ const sel = selectedSet.has(opt.value);
162
+ return (_jsxs("button", { type: "button", id: optionId(i), role: "option", "aria-selected": sel, tabIndex: -1, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-multiselect-option", onClick: () => toggle(opt.value), onMouseEnter: () => setHighlight(i), children: [_jsx("span", { className: "crisp-multiselect-option-chip", children: _jsx(Chip, { opt: opt }) }), _jsx("span", { className: "crisp-multiselect-check", children: sel && _jsx(Check, { size: 12, strokeWidth: 3 }) })] }, opt.value));
163
+ }))] }) }))] }));
147
164
  }
148
165
  MultiSelect.displayName = "MultiSelect";
@@ -3,8 +3,9 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
3
3
  // A multi-select / tag editor — measured from a cell tag picker: an
4
4
  // outlined trigger showing the selected values as coloured chips, opening a
5
5
  // popover with a search field over rows of "[coloured chip] … [checkbox]" that
6
- // toggle on click. The popover keeps these nested styles by rendering inside
7
- // this relative root.
6
+ // toggle on click. The popover portals to document.body (#339) so no
7
+ // ancestor's transform/backdrop-filter can capture its position: fixed box;
8
+ // z-index 70 keeps it above Dialog/Drawer's 60/61.
8
9
  const config = defineRecipe({
9
10
  name: "multiselect",
10
11
  base: `
@@ -27,7 +28,7 @@ const config = defineRecipe({
27
28
  & .crisp-multiselect-trigger[data-open] .crisp-multiselect-chevron { transform: rotate(180deg); }
28
29
 
29
30
  & .crisp-multiselect-listbox {
30
- position: fixed; z-index: 60; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
31
+ position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
31
32
  max-height: 300px; overflow-y: auto; padding: ${c.space["1"]};
32
33
  border-radius: ${c.radius.xl}; background: ${c.bgPopover};
33
34
  box-shadow: ${e.floating};
@@ -58,8 +59,19 @@ const config = defineRecipe({
58
59
  & .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, ${c.bgCyan} 18%, transparent); color: color-mix(in srgb, ${c.bgCyan} 82%, ${c.fgPrimary}); }
59
60
  & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, ${c.bgOrange} 16%, transparent); color: color-mix(in srgb, ${c.bgOrange} 82%, ${c.fgPrimary}); }
60
61
  `,
61
- variants: {},
62
- defaultVariants: {},
62
+ variants: {
63
+ // Same ring Select's `invalid` draws — see that recipe's comment.
64
+ invalid: {
65
+ true: `
66
+ & .crisp-multiselect-trigger,
67
+ & .crisp-multiselect-trigger:hover,
68
+ & .crisp-multiselect-trigger[data-open],
69
+ & .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${c.bgDanger}; }
70
+ `,
71
+ false: ``,
72
+ },
73
+ },
74
+ defaultVariants: { invalid: false },
63
75
  compoundVariants: [],
64
76
  });
65
77
  export const multiselectRecipe = Object.assign(resolveRecipe(config), {
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
- import { placeFloating } from "../../core/index.js";
3
+ import { FloatingPortal, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, } from "../../core/index.js";
4
4
  import { popoverRecipe } from "./popover.recipe.js";
5
5
  /**
6
6
  * A generic anchored popover — the Menu/Select surface holding arbitrary
@@ -19,6 +19,9 @@ export function Popover({ trigger, children, align = "start", open: openProp, de
19
19
  const open = openProp ?? internalOpen;
20
20
  const rootRef = React.useRef(null);
21
21
  const triggerRef = React.useRef(null);
22
+ // The content portals to document.body (#339), so it's no longer a
23
+ // descendant of rootRef -- outside-click detection needs this ref too.
24
+ const contentRef = React.useRef(null);
22
25
  const [pos, setPos] = React.useState({});
23
26
  const [up, setUp] = React.useState(false);
24
27
  const setOpen = React.useCallback((next) => {
@@ -46,12 +49,26 @@ export function Popover({ trigger, children, align = "start", open: openProp, de
46
49
  if (open)
47
50
  place();
48
51
  }, [open]);
52
+ // Registers the (now-portaled) content on the shared floating-layer stack
53
+ // (#339) so this doesn't get closed out from under itself when it holds
54
+ // another floating layer -- see core/floating-stack.ts.
55
+ React.useEffect(() => {
56
+ if (!open || !contentRef.current)
57
+ return;
58
+ return registerFloatingLayer(contentRef.current);
59
+ }, [open]);
49
60
  React.useEffect(() => {
50
61
  if (!open)
51
62
  return;
52
63
  const onDown = (e) => {
53
- if (!rootRef.current?.contains(e.target))
54
- setOpen(false);
64
+ const target = e.target;
65
+ if (rootRef.current?.contains(target))
66
+ return;
67
+ if (contentRef.current?.contains(target))
68
+ return;
69
+ if (isInsideLaterFloatingLayer(target, contentRef.current))
70
+ return;
71
+ setOpen(false);
55
72
  };
56
73
  document.addEventListener("pointerdown", onDown);
57
74
  return () => document.removeEventListener("pointerdown", onDown);
@@ -69,11 +86,11 @@ export function Popover({ trigger, children, align = "start", open: openProp, de
69
86
  setOpen(!open);
70
87
  },
71
88
  });
72
- return (_jsxs("div", { ref: rootRef, className: popoverRecipe({ className }), children: [triggerEl, open && (_jsx("div", { className: "crisp-popover-content", role: "dialog", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: (e) => {
73
- if (e.key === "Escape") {
74
- e.preventDefault();
75
- setOpen(false);
76
- }
77
- }, children: children }))] }));
89
+ return (_jsxs("div", { ref: rootRef, className: popoverRecipe({ className }), children: [triggerEl, open && (_jsx(FloatingPortal, { children: _jsx("div", { ref: contentRef, className: "crisp-popover-content", role: "dialog", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: (e) => {
90
+ if (e.key === "Escape") {
91
+ e.preventDefault();
92
+ setOpen(false);
93
+ }
94
+ }, children: children }) }))] }));
78
95
  }
79
96
  Popover.displayName = "Popover";
@@ -2,12 +2,14 @@ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
2
  import { contract as c, elevation as e } from "../../tokens/index.js";
3
3
  // A generic anchored popover — the same measured surface as Menu / Select
4
4
  // (radius 12, 4px padding, layered soft shadow, inset border), but holding
5
- // arbitrary content. Rendered inside a display:contents root and lifted out of
6
- // flow with position:fixed, so its styles survive while it floats.
5
+ // arbitrary content. The content portals to document.body (#339) so no
6
+ // ancestor's transform/backdrop-filter can capture its position: fixed box;
7
+ // z-index 70 keeps it above Dialog/Drawer's 60/61. The display:contents root
8
+ // is what's left in place, so a consumer's own styling of it survives.
7
9
  const config = defineRecipe({
8
10
  name: "popover",
9
11
  base: `display: contents; font-family: ${c.font.sans};
10
- & .crisp-popover-content { position: fixed; z-index: 60; box-sizing: border-box; min-width: 200px; max-width: min(360px, 92vw); padding: ${c.space["1"]}; border-radius: ${c.radius.xl}; background: ${c.bgPopover}; color: ${c.fgPrimary}; box-shadow: ${e.floating}; opacity: 1; transform: none; transition: opacity 0.08s ease, transform 0.08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
12
+ & .crisp-popover-content { position: fixed; z-index: 70; box-sizing: border-box; min-width: 200px; max-width: min(360px, 92vw); padding: ${c.space["1"]}; border-radius: ${c.radius.xl}; background: ${c.bgPopover}; color: ${c.fgPrimary}; box-shadow: ${e.floating}; opacity: 1; transform: none; transition: opacity 0.08s ease, transform 0.08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
11
13
  & .crisp-popover-content[data-align="end"] { transform-origin: top right; }
12
14
  & .crisp-popover-content[data-up] { transform-origin: bottom left; }`,
13
15
  variants: {},
@@ -1,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check, ChevronDown, Search } from "lucide-react";
3
3
  import * as React from "react";
4
- import { formValueStyle, placeFloating, useFormReset } from "../../core/index.js";
4
+ import { FloatingPortal, formValueStyle, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, useFormReset, } from "../../core/index.js";
5
5
  import { useListboxKeyboard } from "./listbox-keyboard.js";
6
6
  import { selectRecipe } from "./select.recipe.js";
7
7
  function optionText(o) {
@@ -16,7 +16,7 @@ function optionText(o) {
16
16
  * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
17
17
  * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
18
18
  */
19
- export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, id, ...rest }) {
19
+ export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
20
20
  // Virtual focus: DOM focus stays on the trigger (or the search field), and the
21
21
  // active option is named by `aria-activedescendant`. That is the only thing a
22
22
  // screen reader has to go on -- `data-highlighted` is invisible to it -- so the
@@ -33,6 +33,10 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
33
33
  const [highlight, setHighlight] = React.useState(0);
34
34
  const [query, setQuery] = React.useState("");
35
35
  const rootRef = React.useRef(null);
36
+ // The listbox portals to document.body (#339), so it's no longer a
37
+ // descendant of rootRef -- outside-click detection needs this ref too, or
38
+ // every click inside the listbox reads as "outside" and closes it.
39
+ const listboxRef = React.useRef(null);
36
40
  // `form.reset()` restores native controls; a button + listbox holds its value
37
41
  // in React, so it has to listen (#245). Controlled selects are left alone.
38
42
  useFormReset(rootRef, () => {
@@ -110,25 +114,42 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
110
114
  if (!searchable)
111
115
  onNavKeys(e);
112
116
  };
117
+ // Registers the (now-portaled) listbox on the shared floating-layer stack
118
+ // (#339) so a Select nested inside another floating layer (a Menu's sort
119
+ // popover, e.g.) doesn't get closed out from under itself: once both
120
+ // portal independently to document.body they're DOM siblings, not
121
+ // ancestor/descendant, so the outer layer's own outside-click check can no
122
+ // longer see "inside me" by DOM containment alone.
123
+ React.useEffect(() => {
124
+ if (!open || !listboxRef.current)
125
+ return;
126
+ return registerFloatingLayer(listboxRef.current);
127
+ }, [open]);
113
128
  React.useEffect(() => {
114
129
  if (!open)
115
130
  return;
116
131
  const onDown = (e) => {
117
- if (!rootRef.current?.contains(e.target))
118
- setOpen(false);
132
+ const target = e.target;
133
+ if (rootRef.current?.contains(target))
134
+ return;
135
+ if (listboxRef.current?.contains(target))
136
+ return;
137
+ if (isInsideLaterFloatingLayer(target, listboxRef.current))
138
+ return;
139
+ setOpen(false);
119
140
  };
120
141
  document.addEventListener("pointerdown", onDown);
121
142
  return () => document.removeEventListener("pointerdown", onDown);
122
143
  }, [open]);
123
- return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
144
+ return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
124
145
  // controlled by the listbox above; `readOnly` would bar it from
125
146
  // validation, which is the only reason this input is not hidden
126
- onChange: () => { } })), _jsxs("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsxs("div", { id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
127
- setQuery(e.target.value);
128
- setHighlight(0);
129
- }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
130
- const isSel = opt.value === selected;
131
- return (_jsxs("button", { type: "button", id: optionId(i), role: "option", "aria-selected": isSel, tabIndex: -1, disabled: opt.disabled, "data-disabled": opt.disabled ? "" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-select-option", onClick: () => pick(opt), onMouseEnter: () => setHighlight(i), children: [opt.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: opt.color } })), _jsx("span", { className: "crisp-select-option-label", children: opt.label }), isSel && (_jsx(Check, { className: "crisp-select-option-check", size: 15, strokeWidth: 2 }))] }, opt.value));
132
- }))] }))] }));
147
+ onChange: () => { } })), _jsxs("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
148
+ setQuery(e.target.value);
149
+ setHighlight(0);
150
+ }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
151
+ const isSel = opt.value === selected;
152
+ return (_jsxs("button", { type: "button", id: optionId(i), role: "option", "aria-selected": isSel, tabIndex: -1, disabled: opt.disabled, "data-disabled": opt.disabled ? "" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-select-option", onClick: () => pick(opt), onMouseEnter: () => setHighlight(i), children: [opt.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: opt.color } })), _jsx("span", { className: "crisp-select-option-label", children: opt.label }), isSel && (_jsx(Check, { className: "crisp-select-option-check", size: 15, strokeWidth: 2 }))] }, opt.value));
153
+ }))] }) }))] }));
133
154
  }
134
155
  Select.displayName = "Select";
@@ -4,8 +4,10 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
4
4
  // (radius 9, value + a 16px chevron) over a listbox — radius 12, 4px padding, a
5
5
  // layered soft shadow — of 32px options (radius 8, 6/8 padding) that show a
6
6
  // check on the selected one and highlight on hover / keyboard. The listbox is
7
- // portaled into this root (which is position:relative) so it escapes overflow
8
- // while keeping these nested styles.
7
+ // portaled to document.body (#339) so no ancestor's transform/backdrop-filter
8
+ // (Dialog's own measured surface sets both) can capture its position: fixed
9
+ // box; z-index 70 keeps it above Dialog/Drawer's z-index 60/61 layers, since
10
+ // it's no longer nested inside whichever one opened it.
9
11
  const config = defineRecipe({
10
12
  name: "select",
11
13
  base: `
@@ -28,7 +30,7 @@ const config = defineRecipe({
28
30
  & .crisp-select-trigger[data-open] .crisp-select-chevron { transform: rotate(180deg); }
29
31
 
30
32
  & .crisp-select-listbox {
31
- position: fixed; z-index: 60; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
33
+ position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
32
34
  max-height: 280px; overflow-y: auto; padding: ${c.space["1"]};
33
35
  border-radius: ${c.radius.xl}; background: ${c.bgPopover};
34
36
  box-shadow: ${e.floating};
@@ -68,8 +70,21 @@ const config = defineRecipe({
68
70
  full: `width: 100%;`,
69
71
  auto: `width: auto;`,
70
72
  },
73
+ // Same ring Input's `invalid` draws, adapted to Select's box-shadow-ring
74
+ // trigger (a real <button>, so it needs no ::after overlay the way
75
+ // Input/Textarea do). Outranks hover/open/focus, same reasoning as
76
+ // Input: a field being corrected stays visibly wrong while it is.
77
+ invalid: {
78
+ true: `
79
+ & .crisp-select-trigger,
80
+ & .crisp-select-trigger:hover,
81
+ & .crisp-select-trigger[data-open],
82
+ & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${c.bgDanger}; }
83
+ `,
84
+ false: ``,
85
+ },
71
86
  },
72
- defaultVariants: { variant: "default", width: "full" },
87
+ defaultVariants: { variant: "default", width: "full", invalid: false },
73
88
  compoundVariants: [],
74
89
  });
75
90
  export const selectRecipe = Object.assign(resolveRecipe(config), { config });
@@ -1,13 +1,15 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide, Check, CircleHelp, GripVertical, ListFilter, Plus, X, } from "lucide-react";
3
3
  import * as React from "react";
4
- import { useFocusAfterRemoval } from "../../core/index.js";
4
+ import { isInsideLaterFloatingLayer, useFocusAfterRemoval } from "../../core/index.js";
5
+ import { MenuCheckboxItem } from "../menu/index.js";
5
6
  import { Select } from "../select/index.js";
6
7
  import { tabletoolbarRecipe } from "./tabletoolbar.recipe.js";
7
8
  const OP_LABELS = {
8
9
  contains: "contains",
9
10
  is: "is",
10
11
  isnot: "is not",
12
+ isanyof: "is any of",
11
13
  empty: "is empty",
12
14
  notempty: "is not empty",
13
15
  };
@@ -18,7 +20,16 @@ const OP_ORDER = [
18
20
  "empty",
19
21
  "notempty",
20
22
  ];
23
+ // "isanyof" is appended only for a column that supplies `values` — there is
24
+ // nothing to check off otherwise.
25
+ const opsFor = (col) => col?.values?.length ? [...OP_ORDER, "isanyof"] : OP_ORDER;
21
26
  const opNeedsValue = (op) => op === "contains" || op === "is" || op === "isnot";
27
+ const opNeedsValues = (op) => op === "isanyof";
28
+ // "Department is any of IT, Finance" past two values becomes "… +N": the
29
+ // chip is one line, and a field can have far more than two facet values.
30
+ const isAnyOfSummary = (values) => values.length <= 2
31
+ ? values.join(", ")
32
+ : `${values.slice(0, 2).join(", ")} +${values.length - 2}`;
22
33
  const isEmptyValue = (v) => v == null || v === "" || (Array.isArray(v) && v.length === 0);
23
34
  const asText = (v) => {
24
35
  if (isEmptyValue(v))
@@ -44,7 +55,8 @@ const asText = (v) => {
44
55
  * display string, so a grouped-thousands "$1,200" still matches "1200".
45
56
  */
46
57
  export function applyFilters(rows, filters, get = (row, key) => row[key]) {
47
- const active = filters.filter((f) => !opNeedsValue(f.op) || f.value.trim() !== "");
58
+ const active = filters.filter((f) => (!opNeedsValue(f.op) || f.value.trim() !== "") &&
59
+ (!opNeedsValues(f.op) || (f.values?.length ?? 0) > 0));
48
60
  if (!active.length)
49
61
  return rows;
50
62
  return rows.filter((row) => active.every((f) => {
@@ -54,6 +66,8 @@ export function applyFilters(rows, filters, get = (row, key) => row[key]) {
54
66
  if (f.op === "notempty")
55
67
  return !isEmptyValue(raw);
56
68
  const t = asText(raw).toLowerCase();
69
+ if (f.op === "isanyof")
70
+ return (f.values ?? []).some((v) => v.toLowerCase() === t);
57
71
  const v = f.value.trim().toLowerCase();
58
72
  if (f.op === "contains")
59
73
  return t.includes(v);
@@ -115,6 +129,8 @@ export function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsC
115
129
  const [openMenu, setOpenMenu] = React.useState(null);
116
130
  const [dragSort, setDragSort] = React.useState(null);
117
131
  const [filterQuery, setFilterQuery] = React.useState("");
132
+ // filters the "isanyof" checkbox list, same lifetime as filterQuery above
133
+ const [anyOfQuery, setAnyOfQuery] = React.useState("");
118
134
  // close any open menu on outside click (anything not inside a menu or its
119
135
  // trigger) or Escape
120
136
  React.useEffect(() => {
@@ -122,8 +138,15 @@ export function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsC
122
138
  return;
123
139
  const onDown = (e) => {
124
140
  const t = e.target;
125
- if (!t.closest(".crisp-tabletoolbar-menu, [data-menu-trigger]"))
126
- setOpenMenu(null);
141
+ if (t.closest(".crisp-tabletoolbar-menu, [data-menu-trigger]"))
142
+ return;
143
+ // #339: a Select opened from inside this popover (the sort row's
144
+ // direction control) now portals to document.body, so it's no longer
145
+ // inside .crisp-tabletoolbar-menu by DOM containment -- check the
146
+ // shared floating-layer stack too.
147
+ if (isInsideLaterFloatingLayer(t, null))
148
+ return;
149
+ setOpenMenu(null);
127
150
  };
128
151
  const onKey = (e) => e.key === "Escape" && setOpenMenu(null);
129
152
  document.addEventListener("mousedown", onDown);
@@ -213,9 +236,30 @@ export function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsC
213
236
  .map((col) => (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => addFilter(col.key), children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : (_jsx("span", { style: { width: 15 } })), col.label] }, col.key)))] }))] }), filters.map((cond) => {
214
237
  const col = columns.find((c) => c.key === cond.key);
215
238
  const open = openMenu === `fcond:${cond.id}`;
216
- const hasValue = !opNeedsValue(cond.op) || cond.value.trim();
217
- return (_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("span", { className: "crisp-tabletoolbar-fchip", children: [_jsxs("button", { type: "button", className: "crisp-tabletoolbar-fchip-main", "data-menu-trigger": true, "aria-haspopup": "dialog", onClick: () => setOpenMenu(open ? null : `fcond:${cond.id}`), children: [_jsx("span", { className: "crisp-tabletoolbar-fchip-field", children: col?.label }), hasValue ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-tabletoolbar-fchip-op", children: OP_LABELS[cond.op] }), opNeedsValue(cond.op) && (_jsx("span", { className: "crisp-tabletoolbar-fchip-val", children: cond.value }))] })) : (_jsx("span", { className: "crisp-tabletoolbar-fchip-op", children: selectConditionLabel }))] }), _jsx("button", { type: "button", className: "crisp-tabletoolbar-fchip-x", "aria-label": `Remove ${col?.label} filter`, onClick: () => removeFilter(cond.id), children: _jsx(X, { size: 13, strokeWidth: 2 }) })] }), open && (_jsxs("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": `${col?.label} condition`, children: [_jsxs("div", { className: "crisp-tabletoolbar-menu-head", children: [col?.label, " condition"] }), OP_ORDER.map((op) => (_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 ? (_jsx(Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))), opNeedsValue(cond.op) && (_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter value`,
239
+ const hasValue = (!opNeedsValue(cond.op) || cond.value.trim()) &&
240
+ (!opNeedsValues(cond.op) || (cond.values?.length ?? 0) > 0);
241
+ return (_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("span", { className: "crisp-tabletoolbar-fchip", children: [_jsxs("button", { type: "button", className: "crisp-tabletoolbar-fchip-main", "data-menu-trigger": true, "aria-haspopup": "dialog", onClick: () => setOpenMenu(open ? null : `fcond:${cond.id}`), children: [_jsx("span", { className: "crisp-tabletoolbar-fchip-field", children: col?.label }), hasValue ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-tabletoolbar-fchip-op", children: OP_LABELS[cond.op] }), opNeedsValue(cond.op) && (_jsx("span", { className: "crisp-tabletoolbar-fchip-val", children: cond.value })), cond.op === "isanyof" && cond.values && (_jsx("span", { className: "crisp-tabletoolbar-fchip-val", children: isAnyOfSummary(cond.values) }))] })) : (_jsx("span", { className: "crisp-tabletoolbar-fchip-op", children: selectConditionLabel }))] }), _jsx("button", { type: "button", className: "crisp-tabletoolbar-fchip-x", "aria-label": `Remove ${col?.label} filter`, onClick: () => removeFilter(cond.id), children: _jsx(X, { size: 13, strokeWidth: 2 }) })] }), open && (_jsxs("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": `${col?.label} condition`, children: [_jsxs("div", { className: "crisp-tabletoolbar-menu-head", children: [col?.label, " condition"] }), opsFor(col).map((op) => (_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 ? (_jsx(Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))), opNeedsValue(cond.op) && (_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter value`,
218
242
  // biome-ignore lint/a11y/noAutofocus: focus the value field
219
- autoFocus: true, value: cond.value, onChange: (e) => patchFilter(cond.id, { value: e.target.value }) }))] }))] }, cond.id));
243
+ autoFocus: true, value: cond.value, onChange: (e) => patchFilter(cond.id, { value: e.target.value }) })), cond.op === "isanyof" && col?.values && (_jsxs(_Fragment, { children: [_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter values`,
244
+ // biome-ignore lint/a11y/noAutofocus: focus the search field
245
+ autoFocus: true, value: anyOfQuery, onChange: (e) => setAnyOfQuery(e.target.value) }), col.values
246
+ .filter((v) => v
247
+ .toLowerCase()
248
+ .includes(anyOfQuery.trim().toLowerCase()))
249
+ .map((v) => {
250
+ const checked = (cond.values ?? []).includes(v);
251
+ return (_jsx(MenuCheckboxItem, { checked: checked, onCheckedChange: (next) => {
252
+ const set = new Set(cond.values ?? []);
253
+ if (next)
254
+ set.add(v);
255
+ else
256
+ set.delete(v);
257
+ // the column's own order, not click order —
258
+ // what the chip summary reads out
259
+ patchFilter(cond.id, {
260
+ values: col.values?.filter((x) => set.has(x)),
261
+ });
262
+ }, children: v }, v));
263
+ })] }))] }))] }, cond.id));
220
264
  }), _jsx("span", { className: "crisp-tabletoolbar-spacer" }), count != null && (_jsx("span", { className: "crisp-tabletoolbar-count", children: count })), children] }) }));
221
265
  }
@@ -0,0 +1,25 @@
1
+ import { createPortal } from "react-dom";
2
+ /**
3
+ * Portals a floating layer (a `position: fixed` listbox/menu/popover
4
+ * content) to `document.body` — exactly what Tooltip already did with a
5
+ * bare `createPortal` call, pulled out so `Select`, `MultiSelect`, `Menu`,
6
+ * `Popover` and `ContextMenu` share the one implementation (#339).
7
+ *
8
+ * Needed because a `position: fixed` box resolves against the nearest
9
+ * ancestor that establishes a containing block for it — not necessarily the
10
+ * viewport. `transform` (even the identity `scale(1)`), `filter` and
11
+ * `backdrop-filter` all qualify, and `Dialog`'s own measured surface sets
12
+ * both a resting `transform: scale(1)` (so its `@starting-style` entrance
13
+ * has something to animate from) and `backdrop-filter: blur(15px)` (Attio's
14
+ * frosted-glass modal). A floating layer opened from a trigger inside a
15
+ * `Dialog` was rendering offset by the dialog's own box instead of anchored
16
+ * to its trigger.
17
+ *
18
+ * Every caller renders this conditionally on its own `open` state, which
19
+ * starts `false` and is driven only by client interaction — never a prop a
20
+ * fixture sets true at mount (unlike Dialog/Drawer/Command) — so this never
21
+ * touches `document` during SSR, the same safety Tooltip already had.
22
+ */
23
+ export function FloatingPortal({ children, }) {
24
+ return createPortal(children, document.body);
25
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Tracks every currently-open `FloatingPortal` layer, in open order, so a
3
+ * layer's outside-click check can tell "outside me" from "inside a layer I
4
+ * spawned" (#339).
5
+ *
6
+ * Portaling `Select`/`MultiSelect`/`Menu`/`Popover`/`ContextMenu` to
7
+ * `document.body` fixed their own mis-positioning inside a `Dialog`, but it
8
+ * broke a composition each of them supports: one of these opened INSIDE
9
+ * another (TableToolbar's sort popover is a `Menu` holding a per-row
10
+ * `Select`). Before portaling, the inner layer was a DOM descendant of the
11
+ * outer one, so the outer's `rootRef.contains(target)` check saw a click
12
+ * inside the inner layer as "inside". Once both portal independently to
13
+ * `document.body`, they become DOM SIBLINGS — nested in the React tree, not
14
+ * the DOM tree — so that same check reads the inner layer's own pointerdown
15
+ * as "outside", and the outer layer closes itself, unmounting the inner one
16
+ * mid-interaction.
17
+ *
18
+ * The fix: each layer registers its content node here on open (removed on
19
+ * close/unmount), and a layer's outside-click check also accepts a target
20
+ * inside any layer registered AFTER it — i.e. any layer that could only have
21
+ * opened from inside it.
22
+ */
23
+ const stack = [];
24
+ /** Call when a layer opens; call the returned cleanup when it closes. */
25
+ export function registerFloatingLayer(el) {
26
+ stack.push(el);
27
+ return () => {
28
+ const i = stack.indexOf(el);
29
+ if (i >= 0)
30
+ stack.splice(i, 1);
31
+ };
32
+ }
33
+ /**
34
+ * True when `target` sits inside a layer that was registered strictly after
35
+ * `self` — a layer that could only have been spawned from inside `self`,
36
+ * directly or through further nesting.
37
+ *
38
+ * Pass `null` for `self` when the caller is a hand-rolled dropdown that
39
+ * doesn't register itself on this stack (RecordsTable's and TableToolbar's
40
+ * own popovers, e.g.) — every registered layer then counts, which is the
41
+ * right query for "was this click inside some floating child I opened".
42
+ */
43
+ export function isInsideLaterFloatingLayer(target, self) {
44
+ const selfIndex = self ? stack.indexOf(self) : -1;
45
+ for (let i = selfIndex + 1; i < stack.length; i++) {
46
+ if (stack[i]?.contains(target))
47
+ return true;
48
+ }
49
+ return false;
50
+ }
@@ -3,6 +3,8 @@ export { cx } from "./cx.js";
3
3
  export { defineRecipe } from "./define.js";
4
4
  export { withDescribedBy } from "./dialog-description.js";
5
5
  export { isRtlElement } from "./direction.js";
6
+ export { FloatingPortal } from "./floating-portal.js";
7
+ export { isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack.js";
6
8
  export { formValueStyle } from "./form-value.js";
7
9
  export { placeFloating, } from "./place-floating.js";
8
10
  export { Primitive } from "./primitive.js";