@crisp-ui-kit/crisp 0.37.0 → 0.39.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 (61) 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 +1 -1
  5. package/dist/cjs/components/contextmenu/contextmenu.recipe.js +22 -10
  6. package/dist/cjs/components/fileupload/fileupload.js +2 -2
  7. package/dist/cjs/components/menu/menu.js +1 -1
  8. package/dist/cjs/components/menu/menu.recipe.js +32 -20
  9. package/dist/cjs/components/multiselect/multiselect.js +4 -4
  10. package/dist/cjs/components/multiselect/multiselect.recipe.js +50 -24
  11. package/dist/cjs/components/popover/popover.js +1 -1
  12. package/dist/cjs/components/popover/popover.recipe.js +19 -4
  13. package/dist/cjs/components/select/select.js +3 -3
  14. package/dist/cjs/components/select/select.recipe.js +42 -16
  15. package/dist/cjs/core/floating-portal.js +55 -2
  16. package/dist/cjs/tokens/palette.js +23 -0
  17. package/dist/cjs/tokens/semantic.js +7 -0
  18. package/dist/contextmenu.css +11 -9
  19. package/dist/contextmenu.layered.css +11 -9
  20. package/dist/esm/components/badge/badge.recipe.js +11 -5
  21. package/dist/esm/components/contextmenu/contextmenu.js +1 -1
  22. package/dist/esm/components/contextmenu/contextmenu.recipe.js +22 -10
  23. package/dist/esm/components/fileupload/fileupload.js +2 -2
  24. package/dist/esm/components/menu/menu.js +1 -1
  25. package/dist/esm/components/menu/menu.recipe.js +32 -20
  26. package/dist/esm/components/multiselect/multiselect.js +4 -4
  27. package/dist/esm/components/multiselect/multiselect.recipe.js +50 -24
  28. package/dist/esm/components/popover/popover.js +1 -1
  29. package/dist/esm/components/popover/popover.recipe.js +19 -4
  30. package/dist/esm/components/select/select.js +3 -3
  31. package/dist/esm/components/select/select.recipe.js +42 -16
  32. package/dist/esm/core/floating-portal.js +22 -2
  33. package/dist/esm/tokens/palette.js +23 -0
  34. package/dist/esm/tokens/semantic.js +7 -0
  35. package/dist/menu.css +34 -19
  36. package/dist/menu.layered.css +34 -19
  37. package/dist/multiselect.css +40 -21
  38. package/dist/multiselect.d.ts +2 -2
  39. package/dist/multiselect.layered.css +40 -21
  40. package/dist/native/tokens.d.ts +1 -1
  41. package/dist/native/tokens.js +24 -4
  42. package/dist/popover.css +5 -3
  43. package/dist/popover.layered.css +5 -3
  44. package/dist/select.css +33 -14
  45. package/dist/select.d.ts +2 -1
  46. package/dist/select.layered.css +33 -14
  47. package/dist/styles.css +138 -71
  48. package/dist/styles.layered.css +138 -71
  49. package/dist/tokens.css +10 -0
  50. package/dist/tokens.json +24 -4
  51. package/dist/types/components/fileupload/fileupload.d.ts +1 -1
  52. package/dist/types/components/multiselect/multiselect.d.ts +3 -1
  53. package/dist/types/components/multiselect/multiselect.recipe.d.ts +18 -3
  54. package/dist/types/components/select/select.d.ts +4 -1
  55. package/dist/types/components/select/select.recipe.d.ts +12 -0
  56. package/dist/types/core/floating-portal.d.ts +17 -3
  57. package/dist/types/product/sidebar/index.d.ts +1 -1
  58. package/dist/types/tokens/contract.d.ts +5 -0
  59. package/dist/types/tokens/palette.d.ts +20 -0
  60. package/dist/types/tokens/semantic.d.ts +7 -0
  61. package/package.json +3 -3
package/dist/badge.css CHANGED
@@ -3,19 +3,19 @@
3
3
  & .crisp-badge-dot { width: 6px; height: 6px; flex-shrink: 0; border-radius: var(--crisp-radius-full); background: currentColor; }
4
4
  }
5
5
  .crisp-badge--tone_neutral {
6
- background: var(--crisp-bg-raised); color: var(--crisp-fg-muted); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
6
+ background: var(--crisp-bg-raised); color: var(--crisp-fg-neutral-soft); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
7
7
  }
8
8
  .crisp-badge--tone_brand {
9
- background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-bg-brand-solid); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
9
+ background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-fg-brand-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
10
10
  }
11
11
  .crisp-badge--tone_success {
12
- background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-bg-success); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
12
+ background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-fg-success-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
13
13
  }
14
14
  .crisp-badge--tone_warning {
15
- background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-bg-warning); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
15
+ background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-fg-warning-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
16
16
  }
17
17
  .crisp-badge--tone_danger {
18
- background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-bg-danger); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
18
+ background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-fg-danger-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
19
19
  }
20
20
  .crisp-badge--size_small {
21
21
  height: 16px; padding-inline: var(--crisp-space-1-5); font-size: var(--crisp-text-xs); border-radius: var(--crisp-radius-sm);
@@ -4,19 +4,19 @@
4
4
  & .crisp-badge-dot { width: 6px; height: 6px; flex-shrink: 0; border-radius: var(--crisp-radius-full); background: currentColor; }
5
5
  }
6
6
  .crisp-badge--tone_neutral {
7
- background: var(--crisp-bg-raised); color: var(--crisp-fg-muted); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
7
+ background: var(--crisp-bg-raised); color: var(--crisp-fg-neutral-soft); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
8
8
  }
9
9
  .crisp-badge--tone_brand {
10
- background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-bg-brand-solid); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
10
+ background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-fg-brand-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
11
11
  }
12
12
  .crisp-badge--tone_success {
13
- background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-bg-success); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
13
+ background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-fg-success-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
14
14
  }
15
15
  .crisp-badge--tone_warning {
16
- background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-bg-warning); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
16
+ background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-fg-warning-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
17
17
  }
18
18
  .crisp-badge--tone_danger {
19
- background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-bg-danger); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
19
+ background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-fg-danger-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
20
20
  }
21
21
  .crisp-badge--size_small {
22
22
  height: 16px; padding-inline: var(--crisp-space-1-5); font-size: var(--crisp-text-xs); border-radius: var(--crisp-radius-sm);
@@ -32,29 +32,35 @@ const spec = (0, core_1.defineSpec)({
32
32
  ],
33
33
  variants: {
34
34
  tone: {
35
+ // #323: the soft text colour is a dedicated fg*Soft token, not the
36
+ // tone's own accent (fgMuted/bgBrandSolid/bgSuccess/bgWarning/bgDanger)
37
+ // — reusing the accent as soft text is exactly what measured below AA
38
+ // (2.97-4.40 in light; brand also failed in dark, 3.83). Background and
39
+ // ring are unchanged: they were never the problem, and neither is a
40
+ // measured Attio value that moving would help — only the text is.
35
41
  neutral: {
36
42
  background: tokens_1.contract.bgRaised,
37
- color: tokens_1.contract.fgMuted,
43
+ color: tokens_1.contract.fgNeutralSoft,
38
44
  boxShadow: `inset 0 0 0 1px ${tokens_1.contract.borderDefault}`,
39
45
  },
40
46
  brand: {
41
47
  background: soft(tokens_1.contract.bgBrandSolid, 14),
42
- color: tokens_1.contract.bgBrandSolid,
48
+ color: tokens_1.contract.fgBrandSoft,
43
49
  boxShadow: ring(tokens_1.contract.bgBrandSolid, 30),
44
50
  },
45
51
  success: {
46
52
  background: soft(tokens_1.contract.bgSuccess, 14),
47
- color: tokens_1.contract.bgSuccess,
53
+ color: tokens_1.contract.fgSuccessSoft,
48
54
  boxShadow: ring(tokens_1.contract.bgSuccess, 30),
49
55
  },
50
56
  warning: {
51
57
  background: soft(tokens_1.contract.bgWarning, 16),
52
- color: tokens_1.contract.bgWarning,
58
+ color: tokens_1.contract.fgWarningSoft,
53
59
  boxShadow: ring(tokens_1.contract.bgWarning, 32),
54
60
  },
55
61
  danger: {
56
62
  background: soft(tokens_1.contract.bgDanger, 14),
57
- color: tokens_1.contract.bgDanger,
63
+ color: tokens_1.contract.fgDangerSoft,
58
64
  boxShadow: ring(tokens_1.contract.bgDanger, 30),
59
65
  },
60
66
  },
@@ -109,7 +109,7 @@ function ContextMenu({ children, items, menuLabel = "Context menu", className, }
109
109
  document.removeEventListener("keydown", onKey);
110
110
  };
111
111
  }, [pos, close]);
112
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, contextmenu_recipe_1.contextmenuRecipe)({ className }), children: [(0, jsx_runtime_1.jsx)("div", { onContextMenu: open, children: children }), pos && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsx)("div", { ref: menuRef, className: "crisp-contextmenu-content", role: "menu", "aria-label": menuLabel, style: { left: pos.x, top: pos.y }, onKeyDown: onMenuKeyDown, children: items.map((item, i) => item.separator ? ((0, jsx_runtime_1.jsx)("hr", { className: "crisp-contextmenu-sep" }, i)) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
112
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, contextmenu_recipe_1.contextmenuRecipe)({ className }), children: [(0, jsx_runtime_1.jsx)("div", { onContextMenu: open, children: children }), pos && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { sourceRef: rootRef, children: (0, jsx_runtime_1.jsx)("div", { ref: menuRef, className: "crisp-contextmenu-content", role: "menu", "aria-label": menuLabel, style: { left: pos.x, top: pos.y }, onKeyDown: onMenuKeyDown, children: items.map((item, i) => item.separator ? ((0, jsx_runtime_1.jsx)("hr", { className: "crisp-contextmenu-sep" }, i)) : ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "menuitem", disabled: item.disabled, className: item.danger
113
113
  ? "crisp-contextmenu-item crisp-contextmenu-item--danger"
114
114
  : "crisp-contextmenu-item", tabIndex: -1, onClick: () => {
115
115
  item.onSelect?.();
@@ -7,18 +7,30 @@ const tokens_1 = require("../../tokens");
7
7
  // cursor. The content portals to document.body (#339) so no ancestor's
8
8
  // transform/backdrop-filter can capture its position: fixed box; z-index 70
9
9
  // keeps it above Dialog/Drawer's 60/61.
10
+ //
11
+ // Every content-related selector below lives in `globalCss`, not nested
12
+ // under `&` (#357): nesting compiles to a real descendant combinator scoped
13
+ // to the recipe's root class, which needs actual DOM containment — exactly
14
+ // what the portal to `document.body` breaks. Nested, these rules silently
15
+ // matched nothing and the menu rendered completely unstyled. `font-family`
16
+ // is restated on the content's own rule (items then pick it up via their
17
+ // own `font-family: inherit`, which still works — that inheritance chain is
18
+ // intact WITHIN the portaled subtree, only the link back to the root
19
+ // broke).
10
20
  const config = (0, core_1.defineRecipe)({
11
21
  name: "contextmenu",
12
- base: `display: contents; font-family: ${tokens_1.contract.font.sans};
13
- & .crisp-contextmenu-content { position: fixed; z-index: 70; min-width: 200px; box-sizing: border-box; padding: ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover}; box-shadow: ${tokens_1.elevation.floating}; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
14
- & .crisp-contextmenu-item { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; width: 100%; height: ${tokens_1.contract.size.menuRow}; box-sizing: border-box; padding: 0 ${tokens_1.contract.space["2"]}; margin: 0; border: none; border-radius: ${tokens_1.contract.radius.md}; background: none; color: ${tokens_1.contract.fgPrimary}; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; text-align: start; white-space: nowrap; cursor: pointer; }
15
- & .crisp-contextmenu-item svg { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; }
16
- & .crisp-contextmenu-item:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 6%, transparent); }
17
- & .crisp-contextmenu-item--danger { color: ${tokens_1.contract.bgDanger}; }
18
- & .crisp-contextmenu-item--danger svg { color: ${tokens_1.contract.bgDanger}; }
19
- & .crisp-contextmenu-item--danger:hover { background: color-mix(in srgb, ${tokens_1.contract.bgDanger} 10%, transparent); }
20
- & .crisp-contextmenu-shortcut { margin-inline-start: auto; padding-inline-start: ${tokens_1.contract.space["3"]}; color: ${tokens_1.contract.fgMuted}; font-size: ${tokens_1.contract.text.sm}; }
21
- & .crisp-contextmenu-sep { height: 1px; margin: ${tokens_1.contract.space["1"]} 0; border: none; background: ${tokens_1.contract.borderSubtle}; }`,
22
+ base: `display: contents; font-family: ${tokens_1.contract.font.sans};`,
23
+ globalCss: `
24
+ .crisp-contextmenu-content { font-family: ${tokens_1.contract.font.sans}; position: fixed; z-index: 70; min-width: 200px; box-sizing: border-box; padding: ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover}; box-shadow: ${tokens_1.elevation.floating}; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
25
+ .crisp-contextmenu-item { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; width: 100%; height: ${tokens_1.contract.size.menuRow}; box-sizing: border-box; padding: 0 ${tokens_1.contract.space["2"]}; margin: 0; border: none; border-radius: ${tokens_1.contract.radius.md}; background: none; color: ${tokens_1.contract.fgPrimary}; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; text-align: start; white-space: nowrap; cursor: pointer; }
26
+ .crisp-contextmenu-item svg { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; }
27
+ .crisp-contextmenu-item:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 6%, transparent); }
28
+ .crisp-contextmenu-item--danger { color: ${tokens_1.contract.bgDanger}; }
29
+ .crisp-contextmenu-item--danger svg { color: ${tokens_1.contract.bgDanger}; }
30
+ .crisp-contextmenu-item--danger:hover { background: color-mix(in srgb, ${tokens_1.contract.bgDanger} 10%, transparent); }
31
+ .crisp-contextmenu-shortcut { margin-inline-start: auto; padding-inline-start: ${tokens_1.contract.space["3"]}; color: ${tokens_1.contract.fgMuted}; font-size: ${tokens_1.contract.text.sm}; }
32
+ .crisp-contextmenu-sep { height: 1px; margin: ${tokens_1.contract.space["1"]} 0; border: none; background: ${tokens_1.contract.borderSubtle}; }
33
+ `,
22
34
  variants: {},
23
35
  defaultVariants: {},
24
36
  compoundVariants: [],
@@ -73,7 +73,7 @@ function formatSize(bytes) {
73
73
  * is theirs by construction. Controlled or uncontrolled, the same way every
74
74
  * other crisp component is.
75
75
  */
76
- function FileUpload({ className, labels, "aria-label": ariaLabel, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
76
+ function FileUpload({ className, labels, "aria-label": ariaLabel, id, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
77
77
  const props = {
78
78
  accept,
79
79
  disabled: disabledProp,
@@ -124,7 +124,7 @@ function FileUpload({ className, labels, "aria-label": ariaLabel, accept, disabl
124
124
  // focus should land next.
125
125
  const rootRef = React.useRef(null);
126
126
  const focusAfterRemoval = (0, core_1.useFocusAfterRemoval)(rootRef, ".crisp-fileupload-remove", dropzoneRef);
127
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, ...rest, className: (0, fileupload_recipe_1.fileuploadRecipe)({ className }), children: [(0, jsx_runtime_1.jsxs)("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-title", children: t.title }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-hint", children: t.hint })] }), (0, jsx_runtime_1.jsx)("input", { ...hiddenInputProps, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => ((0, jsx_runtime_1.jsxs)("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
127
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, ...rest, className: (0, fileupload_recipe_1.fileuploadRecipe)({ className }), children: [(0, jsx_runtime_1.jsxs)("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-title", children: t.title }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-hint", children: t.hint })] }), (0, jsx_runtime_1.jsx)("input", { ...hiddenInputProps, id: id ?? hiddenInputProps.id, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => ((0, jsx_runtime_1.jsxs)("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
128
128
  focusAfterRemoval(i);
129
129
  removeFileEntry(entry.id);
130
130
  }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2 }) })] }, entry.id))) })), (0, jsx_runtime_1.jsx)("ul", { className: "crisp-fileupload-errors", "aria-live": "polite", children: rejections.map((r) => ((0, jsx_runtime_1.jsx)("li", { className: "crisp-fileupload-error", children: `${r.file.name}: ${r.errors.map((e) => t.errors[e] ?? e).join(" ")}` }, r.file.name))) })] }));
@@ -145,7 +145,7 @@ function Menu({ trigger, children, align = "start", open: openProp, defaultOpen,
145
145
  setOpen(!open);
146
146
  },
147
147
  });
148
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, menu_recipe_1.menuRecipe)({ className }), children: [triggerEl, open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsx)(MenuContext.Provider, { value: { close }, children: (0, jsx_runtime_1.jsx)("div", { ref: contentRef, className: "crisp-menu-content", role: "menu", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: children }) }) }))] }));
148
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, menu_recipe_1.menuRecipe)({ className }), children: [triggerEl, open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { sourceRef: rootRef, children: (0, jsx_runtime_1.jsx)("div", { ref: contentRef, className: "crisp-menu-content", role: "menu", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: (0, jsx_runtime_1.jsx)(MenuContext.Provider, { value: { close }, children: children }) }) }))] }));
149
149
  }
150
150
  Menu.displayName = "Menu";
151
151
  function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
@@ -9,43 +9,55 @@ const tokens_1 = require("../../tokens");
9
9
  // check). The content portals to document.body (#339) so no ancestor's
10
10
  // transform/backdrop-filter can capture its position: fixed box; z-index 70
11
11
  // keeps it above Dialog/Drawer's 60/61.
12
+ //
13
+ // The menu's floating content and everything inside it live in `globalCss`
14
+ // below, not nested under `&` (#357): nesting compiles to a real descendant
15
+ // combinator scoped to the recipe's root class, which needs actual DOM
16
+ // containment — exactly what the portal to `document.body` breaks. Nested,
17
+ // these rules matched nothing once the content moved and the menu rendered
18
+ // completely unstyled. `font-family`/smoothing are restated on the content's
19
+ // own rule (items then pick font up via their own `font-family: inherit`,
20
+ // which still works — that inheritance is intact WITHIN the portaled
21
+ // subtree, only the link back to the trigger-holding root broke).
12
22
  const config = (0, core_1.defineRecipe)({
13
23
  name: "menu",
14
24
  base: `
15
25
  position: relative; display: inline-flex; font-family: ${tokens_1.contract.font.sans};
16
26
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
17
-
18
- & .crisp-menu-content {
27
+ `,
28
+ globalCss: `
29
+ .crisp-menu-content {
30
+ font-family: ${tokens_1.contract.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
19
31
  position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space.px};
20
32
  min-width: 200px; max-height: 360px; overflow-y: auto; padding: ${tokens_1.contract.space["1"]};
21
33
  border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover};
22
34
  box-shadow: ${tokens_1.elevation.floating};
23
35
  transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
24
36
  }
25
- & .crisp-menu-content[data-align="end"] { transform-origin: top right; }
26
- & .crisp-menu-content[data-up] { transform-origin: bottom left; }
27
- & .crisp-menu-content[data-up][data-align="end"] { transform-origin: bottom right; }
28
- & .crisp-menu-content { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
37
+ .crisp-menu-content[data-align="end"] { transform-origin: top right; }
38
+ .crisp-menu-content[data-up] { transform-origin: bottom left; }
39
+ .crisp-menu-content[data-up][data-align="end"] { transform-origin: bottom right; }
40
+ .crisp-menu-content { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
29
41
 
30
- & .crisp-menu-item {
42
+ .crisp-menu-item {
31
43
  box-sizing: border-box; display: flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; width: 100%; height: ${tokens_1.contract.size.menuRow}; flex-shrink: 0;
32
44
  margin: 0; padding: 0 ${tokens_1.contract.space["2"]}; border: none; background: none; border-radius: ${tokens_1.contract.radius.md};
33
45
  font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; text-align: start; cursor: pointer; white-space: nowrap;
34
46
  }
35
- & .crisp-menu-item svg { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; }
36
- & .crisp-menu-item:focus { outline: none; }
37
- & .crisp-menu-item:hover:not([data-disabled]), & .crisp-menu-item:focus-visible:not([data-disabled]), & .crisp-menu-item[data-highlighted]:not([data-disabled]) { background: ${tokens_1.contract.bgRaised}; }
38
- & .crisp-menu-item[data-disabled] { opacity: 0.5; cursor: not-allowed; }
39
- & .crisp-menu-item--danger { color: ${tokens_1.contract.bgDanger}; }
40
- & .crisp-menu-item--danger svg { color: ${tokens_1.contract.bgDanger}; }
41
- & .crisp-menu-item--danger:hover, & .crisp-menu-item--danger:focus-visible, & .crisp-menu-item--danger[data-highlighted] { background: color-mix(in srgb, ${tokens_1.contract.bgDanger} 10%, transparent); }
42
- & .crisp-menu-item-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
43
- & .crisp-menu-item-shortcut { flex-shrink: 0; margin-inline-start: ${tokens_1.contract.space["3"]}; font-size: ${tokens_1.contract.text.sm}; color: ${tokens_1.contract.fgMuted}; letter-spacing: 0.02em; }
44
- & .crisp-menu-item-check { flex-shrink: 0; color: ${tokens_1.contract.bgBrandSolid}; }
45
- & .crisp-menu-item-check--placeholder { width: 15px; }
47
+ .crisp-menu-item svg { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; }
48
+ .crisp-menu-item:focus { outline: none; }
49
+ .crisp-menu-item:hover:not([data-disabled]), .crisp-menu-item:focus-visible:not([data-disabled]), .crisp-menu-item[data-highlighted]:not([data-disabled]) { background: ${tokens_1.contract.bgRaised}; }
50
+ .crisp-menu-item[data-disabled] { opacity: 0.5; cursor: not-allowed; }
51
+ .crisp-menu-item--danger { color: ${tokens_1.contract.bgDanger}; }
52
+ .crisp-menu-item--danger svg { color: ${tokens_1.contract.bgDanger}; }
53
+ .crisp-menu-item--danger:hover, .crisp-menu-item--danger:focus-visible, .crisp-menu-item--danger[data-highlighted] { background: color-mix(in srgb, ${tokens_1.contract.bgDanger} 10%, transparent); }
54
+ .crisp-menu-item-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
55
+ .crisp-menu-item-shortcut { flex-shrink: 0; margin-inline-start: ${tokens_1.contract.space["3"]}; font-size: ${tokens_1.contract.text.sm}; color: ${tokens_1.contract.fgMuted}; letter-spacing: 0.02em; }
56
+ .crisp-menu-item-check { flex-shrink: 0; color: ${tokens_1.contract.bgBrandSolid}; }
57
+ .crisp-menu-item-check--placeholder { width: 15px; }
46
58
 
47
- & .crisp-menu-sep { height: 1px; flex-shrink: 0; border: none; margin: ${tokens_1.contract.space["1"]} calc(-1 * ${tokens_1.contract.space["1"]}); background: ${tokens_1.contract.borderSubtle}; }
48
- & .crisp-menu-label { padding: ${tokens_1.contract.space["1.5"]} ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["0.5"]}; font-size: ${tokens_1.contract.text.sm}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgMuted}; }
59
+ .crisp-menu-sep { height: 1px; flex-shrink: 0; border: none; margin: ${tokens_1.contract.space["1"]} calc(-1 * ${tokens_1.contract.space["1"]}); background: ${tokens_1.contract.borderSubtle}; }
60
+ .crisp-menu-label { padding: ${tokens_1.contract.space["1.5"]} ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["0.5"]}; font-size: ${tokens_1.contract.text.sm}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgMuted}; }
49
61
  `,
50
62
  variants: {},
51
63
  defaultVariants: {},
@@ -69,7 +69,7 @@ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, n
69
69
  // The id belongs on the trigger, not the wrapper: `<label for>` only names a
70
70
  // LABELABLE element, so an id left on the root div meant a FormField label
71
71
  // above this control named nothing (#247). Select already did this.
72
- id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
72
+ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, ...rest }) {
73
73
  // Virtual focus: DOM focus stays on the trigger (or the search field) and the
74
74
  // active option is named by `aria-activedescendant`. `data-highlighted` is a
75
75
  // styling hook only -- a screen reader cannot see it.
@@ -182,15 +182,15 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
182
182
  document.addEventListener("pointerdown", onDown);
183
183
  return () => document.removeEventListener("pointerdown", onDown);
184
184
  }, [open]);
185
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ className }), ...rest, children: [name &&
186
- (value.length > 0 ? value : required ? [""] : []).map((v) => ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
185
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ invalid, className }), ...rest, children: [name &&
186
+ (value.length > 0 ? value : required ? [""] : []).map((v) => ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
187
187
  if (open && !searchable)
188
188
  onNavKeys(e);
189
189
  else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
190
190
  e.preventDefault();
191
191
  openMenu();
192
192
  }
193
- }, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-multiselect-chips", children: selectedOptions.length === 0 ? ((0, jsx_runtime_1.jsx)("span", { className: "crisp-multiselect-placeholder", children: placeholder })) : (selectedOptions.map((o) => (0, jsx_runtime_1.jsx)(Chip, { opt: o }, o.value))) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-multiselect-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsxs)("div", { ref: listboxRef, id: listboxId, className: "crisp-multiselect-listbox", role: "listbox", "aria-multiselectable": "true", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-multiselect-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
193
+ }, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-multiselect-chips", children: selectedOptions.length === 0 ? ((0, jsx_runtime_1.jsx)("span", { className: "crisp-multiselect-placeholder", children: placeholder })) : (selectedOptions.map((o) => (0, jsx_runtime_1.jsx)(Chip, { opt: o }, o.value))) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-multiselect-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { sourceRef: rootRef, children: (0, jsx_runtime_1.jsxs)("div", { ref: listboxRef, id: listboxId, className: "crisp-multiselect-listbox", role: "listbox", "aria-multiselectable": "true", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-multiselect-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
194
194
  setQuery(e.target.value);
195
195
  setHighlight(0);
196
196
  }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "crisp-multiselect-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
@@ -9,6 +9,19 @@ const tokens_1 = require("../../tokens");
9
9
  // toggle on click. The popover portals to document.body (#339) so no
10
10
  // ancestor's transform/backdrop-filter can capture its position: fixed box;
11
11
  // z-index 70 keeps it above Dialog/Drawer's 60/61.
12
+ // The listbox and everything inside it (including the recordstable-tag
13
+ // chips, which render inside listbox option rows as well as the trigger)
14
+ // live in `globalCss` below, not nested under `&` (#357): nesting compiles
15
+ // to a real descendant combinator scoped to the recipe's root class, which
16
+ // needs actual DOM containment — exactly what the portal to `document.body`
17
+ // breaks. Nested, these rules matched nothing once the listbox moved and it
18
+ // rendered completely unstyled. The trigger and its own parts stay nested
19
+ // under `&` — the trigger is NOT portaled. `font-family`/smoothing are
20
+ // restated on the listbox's own rule (its children pick font up via their
21
+ // own `font-family: inherit`, which still works — that inheritance is
22
+ // intact WITHIN the portaled subtree, only the link back to the
23
+ // trigger-holding root broke). A flat recordstable-tag selector also works
24
+ // unchanged wherever it's rendered, portaled or not, so it needs no split.
12
25
  const config = (0, core_1.defineRecipe)({
13
26
  name: "multiselect",
14
27
  base: `
@@ -29,41 +42,54 @@ const config = (0, core_1.defineRecipe)({
29
42
  & .crisp-multiselect-placeholder { color: ${tokens_1.contract.fgMuted}; }
30
43
  & .crisp-multiselect-chevron { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; transition: transform .15s ease; }
31
44
  & .crisp-multiselect-trigger[data-open] .crisp-multiselect-chevron { transform: rotate(180deg); }
32
-
33
- & .crisp-multiselect-listbox {
45
+ `,
46
+ globalCss: `
47
+ .crisp-multiselect-listbox {
48
+ font-family: ${tokens_1.contract.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
34
49
  position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space.px};
35
50
  max-height: 300px; overflow-y: auto; padding: ${tokens_1.contract.space["1"]};
36
51
  border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover};
37
52
  box-shadow: ${tokens_1.elevation.floating};
38
53
  transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
39
54
  }
40
- & .crisp-multiselect-listbox[data-up] { transform-origin: bottom left; }
41
- & .crisp-multiselect-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
42
- & .crisp-multiselect-search { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${tokens_1.contract.space["1"]}) calc(-1 * ${tokens_1.contract.space["1"]}) ${tokens_1.contract.space["1"]}; padding: 0 ${tokens_1.contract.space["2.5"]}; border-bottom: 1px solid ${tokens_1.contract.borderSubtle}; color: ${tokens_1.contract.fgMuted}; }
43
- & .crisp-multiselect-search svg { flex-shrink: 0; }
44
- & .crisp-multiselect-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; }
45
- & .crisp-multiselect-search input::placeholder { color: ${tokens_1.contract.fgMuted}; }
46
- & .crisp-multiselect-option {
55
+ .crisp-multiselect-listbox[data-up] { transform-origin: bottom left; }
56
+ .crisp-multiselect-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
57
+ .crisp-multiselect-search { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${tokens_1.contract.space["1"]}) calc(-1 * ${tokens_1.contract.space["1"]}) ${tokens_1.contract.space["1"]}; padding: 0 ${tokens_1.contract.space["2.5"]}; border-bottom: 1px solid ${tokens_1.contract.borderSubtle}; color: ${tokens_1.contract.fgMuted}; }
58
+ .crisp-multiselect-search svg { flex-shrink: 0; }
59
+ .crisp-multiselect-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; }
60
+ .crisp-multiselect-search input::placeholder { color: ${tokens_1.contract.fgMuted}; }
61
+ .crisp-multiselect-option {
47
62
  box-sizing: border-box; display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: ${tokens_1.contract.size.menuRow}; flex-shrink: 0;
48
63
  padding: 0 ${tokens_1.contract.space["2"]}; margin: 0; border: none; background: none; border-radius: ${tokens_1.contract.radius.md}; font-family: inherit; cursor: pointer; text-align: start;
49
64
  }
50
- & .crisp-multiselect-option[data-highlighted] { background: ${tokens_1.contract.bgRaised}; }
51
- & .crisp-multiselect-option-chip { flex: 0 1 auto; min-width: 0; overflow: hidden; }
52
- & .crisp-multiselect-check { flex-shrink: 0; margin-inline-start: auto; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: ${tokens_1.contract.radius.sm}; box-shadow: inset 0 0 0 1.5px ${tokens_1.contract.borderDefault}; color: transparent; }
53
- & .crisp-multiselect-option[aria-selected="true"] .crisp-multiselect-check { background: ${tokens_1.contract.bgBrandSolid}; box-shadow: none; color: #fff; }
54
- & .crisp-multiselect-empty { padding: ${tokens_1.contract.space["3"]} ${tokens_1.contract.space["2"]}; text-align: center; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgMuted}; }
65
+ .crisp-multiselect-option[data-highlighted] { background: ${tokens_1.contract.bgRaised}; }
66
+ .crisp-multiselect-option-chip { flex: 0 1 auto; min-width: 0; overflow: hidden; }
67
+ .crisp-multiselect-check { flex-shrink: 0; margin-inline-start: auto; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: ${tokens_1.contract.radius.sm}; box-shadow: inset 0 0 0 1.5px ${tokens_1.contract.borderDefault}; color: transparent; }
68
+ .crisp-multiselect-option[aria-selected="true"] .crisp-multiselect-check { background: ${tokens_1.contract.bgBrandSolid}; box-shadow: none; color: #fff; }
69
+ .crisp-multiselect-empty { padding: ${tokens_1.contract.space["3"]} ${tokens_1.contract.space["2"]}; text-align: center; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgMuted}; }
55
70
 
56
- & .crisp-recordstable-tag { display: inline-flex; align-items: center; height: ${tokens_1.contract.size.chip}; padding: 0 ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.chip}; font-size: ${tokens_1.contract.text.lg}; line-height: 20px; font-weight: ${tokens_1.contract.weight.medium}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 6%, transparent); color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 82%, transparent); }
57
- & .crisp-recordstable-tag[data-tone="brand"] { background: color-mix(in srgb, ${tokens_1.contract.bgBrandSolid} 14%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgBrandSolid} 82%, ${tokens_1.contract.fgPrimary}); }
58
- & .crisp-recordstable-tag[data-tone="success"] { background: color-mix(in srgb, ${tokens_1.contract.bgSuccess} 16%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgSuccess} 78%, ${tokens_1.contract.fgPrimary}); }
59
- & .crisp-recordstable-tag[data-tone="warning"] { background: color-mix(in srgb, ${tokens_1.contract.bgWarning} 18%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgWarning} 82%, ${tokens_1.contract.fgPrimary}); }
60
- & .crisp-recordstable-tag[data-tone="danger"] { background: color-mix(in srgb, ${tokens_1.contract.bgDanger} 15%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgDanger} 80%, ${tokens_1.contract.fgPrimary}); }
61
- & .crisp-recordstable-tag[data-tone="purple"] { background: color-mix(in srgb, ${tokens_1.contract.bgPurple} 16%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgPurple} 80%, ${tokens_1.contract.fgPrimary}); }
62
- & .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, ${tokens_1.contract.bgCyan} 18%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgCyan} 82%, ${tokens_1.contract.fgPrimary}); }
63
- & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, ${tokens_1.contract.bgOrange} 16%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgOrange} 82%, ${tokens_1.contract.fgPrimary}); }
71
+ .crisp-recordstable-tag { display: inline-flex; align-items: center; height: ${tokens_1.contract.size.chip}; padding: 0 ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.chip}; font-size: ${tokens_1.contract.text.lg}; line-height: 20px; font-weight: ${tokens_1.contract.weight.medium}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 6%, transparent); color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 82%, transparent); }
72
+ .crisp-recordstable-tag[data-tone="brand"] { background: color-mix(in srgb, ${tokens_1.contract.bgBrandSolid} 14%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgBrandSolid} 82%, ${tokens_1.contract.fgPrimary}); }
73
+ .crisp-recordstable-tag[data-tone="success"] { background: color-mix(in srgb, ${tokens_1.contract.bgSuccess} 16%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgSuccess} 78%, ${tokens_1.contract.fgPrimary}); }
74
+ .crisp-recordstable-tag[data-tone="warning"] { background: color-mix(in srgb, ${tokens_1.contract.bgWarning} 18%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgWarning} 82%, ${tokens_1.contract.fgPrimary}); }
75
+ .crisp-recordstable-tag[data-tone="danger"] { background: color-mix(in srgb, ${tokens_1.contract.bgDanger} 15%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgDanger} 80%, ${tokens_1.contract.fgPrimary}); }
76
+ .crisp-recordstable-tag[data-tone="purple"] { background: color-mix(in srgb, ${tokens_1.contract.bgPurple} 16%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgPurple} 80%, ${tokens_1.contract.fgPrimary}); }
77
+ .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, ${tokens_1.contract.bgCyan} 18%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgCyan} 82%, ${tokens_1.contract.fgPrimary}); }
78
+ .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, ${tokens_1.contract.bgOrange} 16%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgOrange} 82%, ${tokens_1.contract.fgPrimary}); }
64
79
  `,
65
- variants: {},
66
- defaultVariants: {},
80
+ variants: {
81
+ // Same ring Select's `invalid` draws — see that recipe's comment.
82
+ invalid: {
83
+ true: `
84
+ & .crisp-multiselect-trigger,
85
+ & .crisp-multiselect-trigger:hover,
86
+ & .crisp-multiselect-trigger[data-open],
87
+ & .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgDanger}; }
88
+ `,
89
+ false: ``,
90
+ },
91
+ },
92
+ defaultVariants: { invalid: false },
67
93
  compoundVariants: [],
68
94
  });
69
95
  exports.multiselectRecipe = Object.assign((0, core_1.resolveRecipe)(config), {
@@ -122,7 +122,7 @@ function Popover({ trigger, children, align = "start", open: openProp, defaultOp
122
122
  setOpen(!open);
123
123
  },
124
124
  });
125
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, popover_recipe_1.popoverRecipe)({ className }), children: [triggerEl, open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsx)("div", { ref: contentRef, className: "crisp-popover-content", role: "dialog", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: (e) => {
125
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, popover_recipe_1.popoverRecipe)({ className }), children: [triggerEl, open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { sourceRef: rootRef, children: (0, jsx_runtime_1.jsx)("div", { ref: contentRef, className: "crisp-popover-content", role: "dialog", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: (e) => {
126
126
  if (e.key === "Escape") {
127
127
  e.preventDefault();
128
128
  setOpen(false);
@@ -9,12 +9,27 @@ const tokens_1 = require("../../tokens");
9
9
  // ancestor's transform/backdrop-filter can capture its position: fixed box;
10
10
  // z-index 70 keeps it above Dialog/Drawer's 60/61. The display:contents root
11
11
  // is what's left in place, so a consumer's own styling of it survives.
12
+ //
13
+ // The popover's floating content is declared via `globalCss` below, not
14
+ // nested under `&` (#357): nesting compiles to a genuine descendant
15
+ // combinator scoped to the recipe's root class, and the portal moves the
16
+ // content to a DOM sibling of `document.body`, not a descendant of the root
17
+ // — so a nested rule silently matched nothing and the popover rendered
18
+ // completely unstyled (`position: static`, no background, no shadow).
19
+ // `globalCss` is already the escape hatch for CSS a property can't express
20
+ // (`@keyframes`); a portaled part is the same problem, one selector at a
21
+ // time. Every declaration a portaled selector needs — including ones
22
+ // normally inherited from the root, like `font-family` — has to be restated
23
+ // here, since DOM inheritance from the root is exactly what the portal
24
+ // breaks.
12
25
  const config = (0, core_1.defineRecipe)({
13
26
  name: "popover",
14
- base: `display: contents; font-family: ${tokens_1.contract.font.sans};
15
- & .crisp-popover-content { position: fixed; z-index: 70; box-sizing: border-box; min-width: 200px; max-width: min(360px, 92vw); padding: ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover}; color: ${tokens_1.contract.fgPrimary}; box-shadow: ${tokens_1.elevation.floating}; opacity: 1; transform: none; transition: opacity 0.08s ease, transform 0.08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
16
- & .crisp-popover-content[data-align="end"] { transform-origin: top right; }
17
- & .crisp-popover-content[data-up] { transform-origin: bottom left; }`,
27
+ base: `display: contents; font-family: ${tokens_1.contract.font.sans};`,
28
+ globalCss: `
29
+ .crisp-popover-content { font-family: ${tokens_1.contract.font.sans}; position: fixed; z-index: 70; box-sizing: border-box; min-width: 200px; max-width: min(360px, 92vw); padding: ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover}; color: ${tokens_1.contract.fgPrimary}; box-shadow: ${tokens_1.elevation.floating}; opacity: 1; transform: none; transition: opacity 0.08s ease, transform 0.08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
30
+ .crisp-popover-content[data-align="end"] { transform-origin: top right; }
31
+ .crisp-popover-content[data-up] { transform-origin: bottom left; }
32
+ `,
18
33
  variants: {},
19
34
  defaultVariants: {},
20
35
  compoundVariants: [],
@@ -52,7 +52,7 @@ function optionText(o) {
52
52
  * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
53
53
  * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
54
54
  */
55
- function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, id, ...rest }) {
55
+ function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
56
56
  // Virtual focus: DOM focus stays on the trigger (or the search field), and the
57
57
  // active option is named by `aria-activedescendant`. That is the only thing a
58
58
  // screen reader has to go on -- `data-highlighted` is invisible to it -- so the
@@ -177,10 +177,10 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
177
177
  document.addEventListener("pointerdown", onDown);
178
178
  return () => document.removeEventListener("pointerdown", onDown);
179
179
  }, [open]);
180
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, className }), ...rest, children: [name && (selected || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
180
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
181
181
  // controlled by the listbox above; `readOnly` would bar it from
182
182
  // validation, which is the only reason this input is not hidden
183
- onChange: () => { } })), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-select-value", children: selectedOption ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [selectedOption.color && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-placeholder", children: placeholder })) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsxs)("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-select-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
183
+ onChange: () => { } })), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-select-value", children: selectedOption ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [selectedOption.color && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-placeholder", children: placeholder })) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { sourceRef: rootRef, children: (0, jsx_runtime_1.jsxs)("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-select-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
184
184
  setQuery(e.target.value);
185
185
  setHighlight(0);
186
186
  }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
@@ -11,6 +11,17 @@ const tokens_1 = require("../../tokens");
11
11
  // (Dialog's own measured surface sets both) can capture its position: fixed
12
12
  // box; z-index 70 keeps it above Dialog/Drawer's z-index 60/61 layers, since
13
13
  // it's no longer nested inside whichever one opened it.
14
+ // The listbox and everything inside it live in `globalCss` below, not
15
+ // nested under `&` (#357): nesting compiles to a real descendant combinator
16
+ // scoped to the recipe's root class, which needs actual DOM containment —
17
+ // exactly what the portal to `document.body` breaks. Nested, these rules
18
+ // matched nothing once the listbox moved and it rendered completely
19
+ // unstyled. The trigger and its own parts stay nested under `&` — the
20
+ // trigger is NOT portaled, it stays where it was authored. `font-family`/
21
+ // smoothing are restated on the listbox's own rule (its children pick font
22
+ // up via their own `font-family: inherit`, which still works — that
23
+ // inheritance is intact WITHIN the portaled subtree, only the link back to
24
+ // the trigger-holding root broke).
14
25
  const config = (0, core_1.defineRecipe)({
15
26
  name: "select",
16
27
  base: `
@@ -31,31 +42,33 @@ const config = (0, core_1.defineRecipe)({
31
42
  & .crisp-select-placeholder { color: ${tokens_1.contract.fgMuted}; }
32
43
  & .crisp-select-chevron { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; transition: transform .15s ease; }
33
44
  & .crisp-select-trigger[data-open] .crisp-select-chevron { transform: rotate(180deg); }
34
-
35
- & .crisp-select-listbox {
45
+ `,
46
+ globalCss: `
47
+ .crisp-select-listbox {
48
+ font-family: ${tokens_1.contract.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
36
49
  position: fixed; z-index: 70; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space.px};
37
50
  max-height: 280px; overflow-y: auto; padding: ${tokens_1.contract.space["1"]};
38
51
  border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover};
39
52
  box-shadow: ${tokens_1.elevation.floating};
40
53
  transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
41
54
  }
42
- & .crisp-select-listbox[data-up] { transform-origin: bottom; }
43
- & .crisp-select-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
44
- & .crisp-select-search { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${tokens_1.contract.space["1"]}) calc(-1 * ${tokens_1.contract.space["1"]}) ${tokens_1.contract.space["1"]}; padding: 0 ${tokens_1.contract.space["2.5"]}; border-bottom: 1px solid ${tokens_1.contract.borderSubtle}; color: ${tokens_1.contract.fgMuted}; }
45
- & .crisp-select-search svg { flex-shrink: 0; }
46
- & .crisp-select-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; }
47
- & .crisp-select-search input::placeholder { color: ${tokens_1.contract.fgMuted}; }
48
- & .crisp-select-empty { padding: ${tokens_1.contract.space["3"]} ${tokens_1.contract.space["2"]}; text-align: center; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgMuted}; }
49
- & .crisp-select-option {
55
+ .crisp-select-listbox[data-up] { transform-origin: bottom; }
56
+ .crisp-select-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
57
+ .crisp-select-search { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${tokens_1.contract.space["1"]}) calc(-1 * ${tokens_1.contract.space["1"]}) ${tokens_1.contract.space["1"]}; padding: 0 ${tokens_1.contract.space["2.5"]}; border-bottom: 1px solid ${tokens_1.contract.borderSubtle}; color: ${tokens_1.contract.fgMuted}; }
58
+ .crisp-select-search svg { flex-shrink: 0; }
59
+ .crisp-select-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; }
60
+ .crisp-select-search input::placeholder { color: ${tokens_1.contract.fgMuted}; }
61
+ .crisp-select-empty { padding: ${tokens_1.contract.space["3"]} ${tokens_1.contract.space["2"]}; text-align: center; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgMuted}; }
62
+ .crisp-select-option {
50
63
  box-sizing: border-box; display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: ${tokens_1.contract.size.menuRow}; flex-shrink: 0;
51
64
  padding: 0 ${tokens_1.contract.space["2"]}; margin: 0; border: none; background: none; border-radius: ${tokens_1.contract.radius.md};
52
65
  font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; text-align: start; cursor: pointer; white-space: nowrap;
53
66
  }
54
- & .crisp-select-option[data-highlighted] { background: ${tokens_1.contract.bgRaised}; }
55
- & .crisp-select-option[data-disabled] { opacity: 0.5; cursor: not-allowed; }
56
- & .crisp-select-option-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${tokens_1.contract.radius.full}; }
57
- & .crisp-select-option-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
58
- & .crisp-select-option-check { flex-shrink: 0; color: ${tokens_1.contract.bgBrandSolid}; }
67
+ .crisp-select-option[data-highlighted] { background: ${tokens_1.contract.bgRaised}; }
68
+ .crisp-select-option[data-disabled] { opacity: 0.5; cursor: not-allowed; }
69
+ .crisp-select-option-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${tokens_1.contract.radius.full}; }
70
+ .crisp-select-option-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
71
+ .crisp-select-option-check { flex-shrink: 0; color: ${tokens_1.contract.bgBrandSolid}; }
59
72
  `,
60
73
  variants: {
61
74
  // ghost: borderless at rest (transparent), reveals a subtle fill on hover —
@@ -73,8 +86,21 @@ const config = (0, core_1.defineRecipe)({
73
86
  full: `width: 100%;`,
74
87
  auto: `width: auto;`,
75
88
  },
89
+ // Same ring Input's `invalid` draws, adapted to Select's box-shadow-ring
90
+ // trigger (a real <button>, so it needs no ::after overlay the way
91
+ // Input/Textarea do). Outranks hover/open/focus, same reasoning as
92
+ // Input: a field being corrected stays visibly wrong while it is.
93
+ invalid: {
94
+ true: `
95
+ & .crisp-select-trigger,
96
+ & .crisp-select-trigger:hover,
97
+ & .crisp-select-trigger[data-open],
98
+ & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgDanger}; }
99
+ `,
100
+ false: ``,
101
+ },
76
102
  },
77
- defaultVariants: { variant: "default", width: "full" },
103
+ defaultVariants: { variant: "default", width: "full", invalid: false },
78
104
  compoundVariants: [],
79
105
  });
80
106
  exports.selectRecipe = Object.assign((0, core_1.resolveRecipe)(config), { config });