@crisp-ui-kit/crisp 0.56.0 → 0.57.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 (90) hide show
  1. package/dist/callout.css +6 -0
  2. package/dist/callout.d.ts +2 -2
  3. package/dist/callout.layered.css +6 -0
  4. package/dist/cjs/components/callout/callout.js +2 -2
  5. package/dist/cjs/components/callout/callout.recipe.js +15 -2
  6. package/dist/cjs/components/input/input.recipe.js +6 -0
  7. package/dist/cjs/components/menu/menu.js +2 -2
  8. package/dist/cjs/components/menu/menu.recipe.js +8 -0
  9. package/dist/cjs/components/multiselect/multiselect.js +23 -10
  10. package/dist/cjs/components/multiselect/multiselect.recipe.js +95 -65
  11. package/dist/cjs/components/select/listbox-keyboard.js +7 -1
  12. package/dist/cjs/components/select/select.js +24 -11
  13. package/dist/cjs/components/select/select.recipe.js +150 -78
  14. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +3 -3
  15. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +26 -1
  16. package/dist/cjs/components/tabs/tabs.js +63 -4
  17. package/dist/cjs/components/tabs/tabs.recipe.js +13 -0
  18. package/dist/cjs/components/textlink/textlink.js +2 -2
  19. package/dist/cjs/components/textlink/textlink.recipe.js +18 -0
  20. package/dist/cjs/product/recordpage/recordpage.js +17 -4
  21. package/dist/cjs/tokens/elevation.js +7 -0
  22. package/dist/cjs/tokens/palette.js +41 -0
  23. package/dist/cjs/tokens/scale.js +8 -0
  24. package/dist/cjs/tokens/semantic.js +18 -0
  25. package/dist/esm/components/callout/callout.js +2 -2
  26. package/dist/esm/components/callout/callout.recipe.js +15 -2
  27. package/dist/esm/components/input/input.recipe.js +6 -0
  28. package/dist/esm/components/menu/menu.js +2 -2
  29. package/dist/esm/components/menu/menu.recipe.js +8 -0
  30. package/dist/esm/components/multiselect/multiselect.js +23 -10
  31. package/dist/esm/components/multiselect/multiselect.recipe.js +95 -65
  32. package/dist/esm/components/select/listbox-keyboard.js +7 -1
  33. package/dist/esm/components/select/select.js +24 -11
  34. package/dist/esm/components/select/select.recipe.js +150 -78
  35. package/dist/esm/components/tabletoolbar/tabletoolbar.js +3 -3
  36. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +26 -1
  37. package/dist/esm/components/tabs/tabs.js +63 -4
  38. package/dist/esm/components/tabs/tabs.recipe.js +13 -0
  39. package/dist/esm/components/textlink/textlink.js +2 -2
  40. package/dist/esm/components/textlink/textlink.recipe.js +18 -0
  41. package/dist/esm/product/recordpage/recordpage.js +18 -5
  42. package/dist/esm/tokens/elevation.js +7 -0
  43. package/dist/esm/tokens/palette.js +41 -0
  44. package/dist/esm/tokens/scale.js +8 -0
  45. package/dist/esm/tokens/semantic.js +18 -0
  46. package/dist/input.css +4 -0
  47. package/dist/input.d.ts +2 -2
  48. package/dist/input.layered.css +4 -0
  49. package/dist/menu.css +8 -0
  50. package/dist/menu.layered.css +8 -0
  51. package/dist/multiselect.css +53 -29
  52. package/dist/multiselect.d.ts +3 -1
  53. package/dist/multiselect.layered.css +53 -29
  54. package/dist/native/tokens.d.ts +1 -1
  55. package/dist/native/tokens.js +49 -6
  56. package/dist/select.css +80 -42
  57. package/dist/select.d.ts +3 -2
  58. package/dist/select.layered.css +80 -42
  59. package/dist/styles.css +199 -72
  60. package/dist/styles.layered.css +199 -72
  61. package/dist/tabletoolbar.css +8 -1
  62. package/dist/tabletoolbar.layered.css +8 -1
  63. package/dist/tabs.css +6 -0
  64. package/dist/tabs.layered.css +6 -0
  65. package/dist/textlink.css +9 -0
  66. package/dist/textlink.d.ts +2 -1
  67. package/dist/textlink.layered.css +9 -0
  68. package/dist/tokens.css +25 -0
  69. package/dist/tokens.json +49 -6
  70. package/dist/types/components/callout/callout.d.ts +9 -1
  71. package/dist/types/components/callout/callout.recipe.d.ts +12 -2
  72. package/dist/types/components/input/input.d.ts +2 -0
  73. package/dist/types/components/input/input.recipe.d.ts +3 -0
  74. package/dist/types/components/menu/menu.d.ts +7 -1
  75. package/dist/types/components/multiselect/multiselect.d.ts +24 -6
  76. package/dist/types/components/multiselect/multiselect.recipe.d.ts +24 -0
  77. package/dist/types/components/select/listbox-keyboard.d.ts +10 -1
  78. package/dist/types/components/select/select.d.ts +28 -6
  79. package/dist/types/components/select/select.recipe.d.ts +22 -10
  80. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +14 -0
  81. package/dist/types/components/tabs/tabs.d.ts +9 -1
  82. package/dist/types/components/textlink/textlink.d.ts +8 -1
  83. package/dist/types/components/textlink/textlink.recipe.d.ts +10 -0
  84. package/dist/types/product/recordpage/recordpage.d.ts +25 -1
  85. package/dist/types/tokens/contract.d.ts +13 -3
  86. package/dist/types/tokens/elevation.d.ts +7 -0
  87. package/dist/types/tokens/palette.d.ts +36 -0
  88. package/dist/types/tokens/scale.d.ts +5 -0
  89. package/dist/types/tokens/semantic.d.ts +18 -0
  90. package/package.json +1 -1
package/dist/callout.css CHANGED
@@ -14,3 +14,9 @@
14
14
  &[data-tone="danger"] { background: color-mix(in srgb, var(--crisp-bg-danger) 10%, transparent); }
15
15
  &[data-tone="danger"] .crisp-callout-icon { color: var(--crisp-bg-danger); }
16
16
  }
17
+ .crisp-callout--bordered_true {
18
+ border: 1px solid var(--crisp-ring-brand-soft); border-radius: var(--crisp-radius-xl); padding: var(--crisp-space-2) var(--crisp-space-3); background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent);
19
+ }
20
+ .crisp-callout--bordered_false {
21
+
22
+ }
package/dist/callout.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { RecipeFn } from "../core/types";
2
2
 
3
3
  export interface CalloutVariants {
4
-
4
+ bordered?: boolean;
5
5
  }
6
6
 
7
- export declare const calloutRecipe: RecipeFn<{ }>;
7
+ export declare const calloutRecipe: RecipeFn<{ bordered: { true: string; false: string } }>;
@@ -14,5 +14,11 @@
14
14
  &[data-tone="warning"] .crisp-callout-icon { color: var(--crisp-bg-warning); }
15
15
  &[data-tone="danger"] { background: color-mix(in srgb, var(--crisp-bg-danger) 10%, transparent); }
16
16
  &[data-tone="danger"] .crisp-callout-icon { color: var(--crisp-bg-danger); }
17
+ }
18
+ .crisp-callout--bordered_true {
19
+ border: 1px solid var(--crisp-ring-brand-soft); border-radius: var(--crisp-radius-xl); padding: var(--crisp-space-2) var(--crisp-space-3); background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent);
20
+ }
21
+ .crisp-callout--bordered_false {
22
+
17
23
  }
18
24
  }
@@ -15,11 +15,11 @@ const DEFAULT_ICONS = {
15
15
  * An inline callout / banner — measured from info notices. `tone` tints
16
16
  * the background and icon; pass a `title` and/or body `children`.
17
17
  */
18
- function Callout({ tone = "neutral", icon, title, children, action, className, ...rest }) {
18
+ function Callout({ tone = "neutral", icon, title, children, action, bordered, className, ...rest }) {
19
19
  const Fallback = DEFAULT_ICONS[tone];
20
20
  const iconNode = icon === undefined
21
21
  ? Fallback && (0, jsx_runtime_1.jsx)(Fallback, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" })
22
22
  : icon;
23
- return ((0, jsx_runtime_1.jsxs)("div", { className: (0, callout_recipe_1.calloutRecipe)({ className }), ...rest, "data-tone": tone, role: "note", children: [iconNode && (0, jsx_runtime_1.jsx)("span", { className: "crisp-callout-icon", children: iconNode }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-callout-body", children: [title != null && (0, jsx_runtime_1.jsx)("div", { className: "crisp-callout-title", children: title }), children != null && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-callout-desc", children: children }))] }), action && (0, jsx_runtime_1.jsx)("div", { className: "crisp-callout-action", children: action })] }));
23
+ return ((0, jsx_runtime_1.jsxs)("div", { className: (0, callout_recipe_1.calloutRecipe)({ bordered, className }), ...rest, "data-tone": tone, role: "note", children: [iconNode && (0, jsx_runtime_1.jsx)("span", { className: "crisp-callout-icon", children: iconNode }), (0, jsx_runtime_1.jsxs)("div", { className: "crisp-callout-body", children: [title != null && (0, jsx_runtime_1.jsx)("div", { className: "crisp-callout-title", children: title }), children != null && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-callout-desc", children: children }))] }), action && (0, jsx_runtime_1.jsx)("div", { className: "crisp-callout-action", children: action })] }));
24
24
  }
25
25
  Callout.displayName = "Callout";
@@ -21,8 +21,21 @@ const config = (0, core_1.defineRecipe)({
21
21
  &[data-tone="warning"] .crisp-callout-icon { color: ${tokens_1.contract.bgWarning}; }
22
22
  &[data-tone="danger"] { background: color-mix(in srgb, ${tokens_1.contract.bgDanger} 10%, transparent); }
23
23
  &[data-tone="danger"] .crisp-callout-icon { color: ${tokens_1.contract.bgDanger}; }`,
24
- variants: {},
25
- defaultVariants: {},
24
+ variants: {
25
+ // crisp#869: a page-level banner look, measured live on Attio's Objects
26
+ // settings page (the "Create custom objects... Upgrade" Pro-plan
27
+ // nudge) — a real 1px opaque border, 12px radius, 8px/12px padding, and
28
+ // a 14%-brand-tinted background (the same `color-mix` ratio Badge's own
29
+ // soft-brand variant already uses). Only this one brand/info-toned
30
+ // instance was found live; `bordered` doesn't attempt a per-tone border
31
+ // colour for success/warning/danger — no such Attio banner was found to
32
+ // measure one against.
33
+ bordered: {
34
+ true: `border: 1px solid ${tokens_1.contract.ringBrandSoft}; border-radius: ${tokens_1.contract.radius.xl}; padding: ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["3"]}; background: color-mix(in srgb, ${tokens_1.contract.bgBrandSolid} 14%, transparent);`,
35
+ false: "",
36
+ },
37
+ },
38
+ defaultVariants: { bordered: false },
26
39
  compoundVariants: [],
27
40
  });
28
41
  exports.calloutRecipe = (0, core_1.resolveRecipe)(config);
@@ -44,6 +44,12 @@ const config = (0, core_1.defineRecipe)({
44
44
  `,
45
45
  variants: {
46
46
  size: {
47
+ // crisp#878: not a fresh click-measurement -- reuses `TableToolbar`'s
48
+ // own compact search box shape (`tabletoolbar.chrome.ts`'s `-search`
49
+ // rule), already corroborated against Attio, at the same height as
50
+ // `c.size.row` ("nav item · search · control height"). Same radius and
51
+ // padding `medium` already uses, just 4px shorter.
52
+ sm: `height: ${tokens_1.contract.size.row}; border-radius: ${tokens_1.contract.radius.md}; & .crisp-input-value { padding-inline: ${tokens_1.contract.space["2.5"]}; }`,
47
53
  medium: `height: 32px; border-radius: ${tokens_1.contract.radius.md}; & .crisp-input-value { padding-inline: ${tokens_1.contract.space["2.5"]}; }`,
48
54
  large: `height: 36px; border-radius: 10px; & .crisp-input-value { padding-inline: ${tokens_1.contract.space["3"]}; }`,
49
55
  },
@@ -168,13 +168,13 @@ function Menu({ trigger, children, align = "start", open: openProp, defaultOpen,
168
168
  : "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 }) }) }))] }));
169
169
  }
170
170
  Menu.displayName = "Menu";
171
- function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
171
+ function MenuItem({ children, icon, shortcut, count, danger, disabled, onSelect, closeOnSelect = true, }) {
172
172
  const ctx = React.useContext(MenuContext);
173
173
  return ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "menuitem", tabIndex: -1, disabled: disabled, "data-disabled": disabled ? "" : undefined, className: `crisp-menu-item${danger ? " crisp-menu-item--danger" : ""}`, onClick: () => {
174
174
  onSelect?.();
175
175
  if (closeOnSelect)
176
176
  ctx?.close();
177
- }, children: [icon, (0, jsx_runtime_1.jsx)("span", { className: "crisp-menu-item-label", children: children }), shortcut && (0, jsx_runtime_1.jsx)("span", { className: "crisp-menu-item-shortcut", children: shortcut })] }));
177
+ }, children: [icon, (0, jsx_runtime_1.jsx)("span", { className: "crisp-menu-item-label", children: children }), count != null && (0, jsx_runtime_1.jsx)("span", { className: "crisp-menu-item-count", children: count }), shortcut && (0, jsx_runtime_1.jsx)("span", { className: "crisp-menu-item-shortcut", children: shortcut })] }));
178
178
  }
179
179
  MenuItem.displayName = "MenuItem";
180
180
  function MenuCheckboxItem({ children, checked, onCheckedChange, disabled, }) {
@@ -57,6 +57,14 @@ const config = (0, core_1.defineRecipe)({
57
57
  .crisp-menu-item--danger svg { color: ${tokens_1.contract.bgDanger}; }
58
58
  .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); }
59
59
  .crisp-menu-item-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
60
+ /* crisp#875: same measured neutral chip TableToolbar's own ToolbarTool
61
+ count prop uses (re-confirmed on two live Attio surfaces this round:
62
+ the Sort tool's own "+N" overflow, a Kanban board's stage-column
63
+ count) -- a small badge, not bare text. */
64
+ .crisp-menu-item-count { flex-shrink: 0; margin-inline-start: ${tokens_1.contract.space["1.5"]}; display: inline-flex; align-items: center; height: 16px; box-sizing: border-box;
65
+ padding: 0 ${tokens_1.contract.space["1"]}; border-radius: ${tokens_1.contract.radius.sm}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent);
66
+ box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderSubtle}; font-size: ${tokens_1.contract.text.sm}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgNeutralSoft};
67
+ font-variant-numeric: tabular-nums; }
60
68
  .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; }
61
69
  .crisp-menu-item-check { flex-shrink: 0; color: ${tokens_1.contract.bgBrandSolid}; }
62
70
  .crisp-menu-item-check--placeholder { width: 15px; }
@@ -60,16 +60,18 @@ function Chip({ opt }) {
60
60
  return ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordstable-tag", "data-tone": opt.tone ?? toneFor(opt.value), children: opt.label }));
61
61
  }
62
62
  /**
63
- * A multi-select / tag editor — measured from a cell tag picker. The
64
- * outlined trigger shows the selected values as coloured chips; it opens a
65
- * popover with a search field over rows of "[chip] … [checkbox]" that toggle on
66
- * click (the menu stays open). Keyboard: ↑/↓ move, Enter toggles, Esc closes.
63
+ * A multi-select / tag editor, its trigger/listbox/item chrome built to the
64
+ * same seed-design parity as `Select` (crisp/#882, following #867/#879) —
65
+ * its own chip/checkbox picker behaviour is unchanged. The outlined trigger
66
+ * shows the selected values as coloured chips; it opens a popover with a
67
+ * search field over rows of "[chip] … [checkbox]" that toggle on click (the
68
+ * menu stays open). Keyboard: ↑/↓ move, Enter/Space toggle, Esc closes.
67
69
  */
68
70
  function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, required,
69
71
  // The id belongs on the trigger, not the wrapper: `<label for>` only names a
70
72
  // LABELABLE element, so an id left on the root div meant a FormField label
71
73
  // above this control named nothing (#247). Select already did this.
72
- id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPlaceholderProp, emptyLabel: emptyLabelProp, disabled, invalid, className, "aria-label": ariaLabel, ...rest }) {
74
+ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPlaceholderProp, emptyLabel: emptyLabelProp, disabled, readOnly, invalid, size = "medium", variant = "default", className, "aria-label": ariaLabel, ...rest }) {
73
75
  // crisp/#471: same resolution order as Select's own.
74
76
  const placeholder = (0, core_1.useCrispMessage)(placeholderProp, "Select…");
75
77
  const searchPlaceholder = (0, core_1.useCrispMessage)(searchPlaceholderProp, "Search…");
@@ -106,8 +108,12 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
106
108
  const filtered = searchable && q
107
109
  ? options.filter((o) => o.label.toLowerCase().includes(q))
108
110
  : options;
111
+ // seed-design's `interactive` (crisp/#882, matching Select): disabled AND
112
+ // readOnly both refuse to open, but only `disabled` greys the text and
113
+ // removes the button from the tab order.
114
+ const interactive = !disabled && !readOnly;
109
115
  const openMenu = () => {
110
- if (disabled)
116
+ if (!interactive)
111
117
  return;
112
118
  const rect = triggerRef.current?.getBoundingClientRect();
113
119
  if (rect) {
@@ -116,7 +122,7 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
116
122
  // direction that is (#210)
117
123
  const { style, up: flipUp } = (0, core_1.placeFloating)({
118
124
  rect,
119
- gap: 4,
125
+ gap: 8, // seed-design's gutter (crisp/#882, matching Select; was 4)
120
126
  flipBelow: 300,
121
127
  anchor: triggerRef.current,
122
128
  });
@@ -161,6 +167,10 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
161
167
  close,
162
168
  // Tab is already moving focus out; `close` would drag it back to the trigger
163
169
  dismiss: () => setOpen(false),
170
+ // Space also toggles the highlighted option (crisp/#882, matching
171
+ // Select's Enter/Space parity) when the trigger itself owns navigation.
172
+ // Never when searchable -- see Select's own comment on this option.
173
+ commitOnSpace: !searchable,
164
174
  });
165
175
  // Registers the (now-portaled) listbox on the shared floating-layer stack
166
176
  // (#339) so this doesn't get closed out from under itself when nested
@@ -198,11 +208,14 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
198
208
  document.addEventListener("keydown", onKey);
199
209
  return () => document.removeEventListener("keydown", onKey);
200
210
  }, [open, close]);
201
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ invalid, className }), ...rest, children: [name &&
202
- (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) => {
211
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ size, variant, invalid, className }), ...rest, children: [name &&
212
+ (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, "aria-readonly": readOnly ? "true" : undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
213
+ if (!interactive)
214
+ return;
203
215
  if (open && !searchable)
204
216
  onNavKeys(e);
205
- else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
217
+ else if (!open &&
218
+ (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")) {
206
219
  e.preventDefault();
207
220
  openMenu();
208
221
  }
@@ -3,31 +3,27 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.multiselectRecipe = void 0;
4
4
  const core_1 = require("../../core");
5
5
  const tokens_1 = require("../../tokens");
6
- // A multi-select / tag editor — measured from a cell tag picker: an
7
- // outlined trigger showing the selected values as coloured chips, opening a
8
- // popover with a search field over rows of "[coloured chip] … [checkbox]" that
9
- // toggle on click. The popover portals to document.body (#339) so no
10
- // ancestor's transform/backdrop-filter can capture its position: fixed box;
11
- // z-index 70 keeps it above Dialog/Drawer's 60/61.
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.
25
- // This is a third, hand-pasted copy of `recordTagCss`
26
- // (product/recordstable/recordstable.tag.ts) rather than a splice of it:
27
- // that helper is written `& .crisp-recordstable-tag { ... }` for nesting
28
- // under a recipe's `base`, which doesn't compile to anything inside a flat
29
- // `globalCss` block. Keep the two in sync by hand (#380 added the measured
30
- // `box-shadow: inset 0 0 0 1px <tone ring colour>` here to match).
6
+ // MultiSelect reads as the same family as Select (crisp/#882, following
7
+ // #867/#879): the trigger/listbox/item chrome below reuses Select's
8
+ // seed-design tokens verbatim (`strokeSelectTrigger`, `bgSelectPressed`,
9
+ // `bgSelectDisabled`, `strokeSelectInvalid`, `bgSelectListbox`,
10
+ // `selectFloating`, `fgSelectNeutral`, `fgSelectPlaceholder`, `radius["3xl"]`,
11
+ // `size.selectTriggerMd/Lg`) rather than resolving a second seed spec --
12
+ // seed-design has no multi-value combobox of its own (`packages/rootage/
13
+ // components/` has no `combobox`/`multiselect`; `chip.yaml`/`tag-group*.yaml`
14
+ // are a different, standalone pill pattern, not a field's multi-value
15
+ // trigger). What stays untouched: the `.crisp-recordstable-tag` chip
16
+ // styling (shared with RecordsTable/DataTable elsewhere), the checkbox-style
17
+ // selected indicator, and the toggle-without-closing interaction.
18
+ //
19
+ // The search row's geometry (36px height, 6px gap, no divider) is an
20
+ // Attio measurement (#481) `build/spec-check.mjs` guards directly -- it is
21
+ // NOT part of the seed-parity change and must not move. Only its text/icon
22
+ // colours follow the rest of this file onto the shared tokens.
23
+ //
24
+ // The listbox portals to `document.body` (#339), so its rules live in
25
+ // `globalCss`, not nested under `&` -- see Select's own recipe for the full
26
+ // reasoning (identical here).
31
27
  const config = (0, core_1.defineRecipe)({
32
28
  name: "multiselect",
33
29
  base: `
@@ -35,53 +31,63 @@ const config = (0, core_1.defineRecipe)({
35
31
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
36
32
 
37
33
  & .crisp-multiselect-trigger {
38
- box-sizing: border-box; display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; width: 100%; min-width: 0; min-height: ${tokens_1.contract.size.menuRow};
39
- margin: 0; padding: ${tokens_1.contract.space["0.5"]} ${tokens_1.contract.space["1.5"]} ${tokens_1.contract.space["0.5"]} ${tokens_1.contract.space["2"]};
40
- border: none; border-radius: ${tokens_1.contract.radius.md}; background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${tokens_1.contract.fgPrimary} 7%, transparent);
41
- font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; cursor: pointer; text-align: start;
42
- transition: background-color .12s ease, box-shadow .12s ease;
34
+ box-sizing: border-box; display: flex; align-items: center; width: 100%; min-width: 0; margin: 0;
35
+ border: 1px solid ${tokens_1.contract.strokeSelectTrigger}; background: transparent; cursor: pointer; text-align: start;
36
+ font-family: inherit; color: ${tokens_1.contract.fgSelectNeutral};
37
+ transition: background-color .15s cubic-bezier(.35,0,.35,1), border-color .1s cubic-bezier(.35,0,.35,1), border-width .1s cubic-bezier(.35,0,.35,1);
43
38
  }
44
- & .crisp-multiselect-trigger:hover { background: ${tokens_1.contract.bgRaised}; }
45
- & .crisp-multiselect-trigger[data-open], & .crisp-multiselect-trigger:focus-visible { outline: none; box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgBrandSolid}; background: ${tokens_1.contract.bgSurface}; }
46
- & .crisp-multiselect-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
39
+ /* Same pressed/disabled/readonly/focus model as Select's trigger -- see
40
+ that recipe's comment for why hover and an open/focus ring are gone. */
41
+ & .crisp-multiselect-trigger:active:not(:disabled):not([aria-readonly="true"]) { background: ${tokens_1.contract.bgSelectPressed}; }
42
+ & .crisp-multiselect-trigger:focus-visible { outline: 2px solid ${tokens_1.contract.strokeFocusRing}; outline-offset: -2px; }
43
+ & .crisp-multiselect-trigger:disabled { background: ${tokens_1.contract.bgSelectDisabled}; cursor: not-allowed; }
44
+ & .crisp-multiselect-trigger:disabled .crisp-multiselect-placeholder,
45
+ & .crisp-multiselect-trigger:disabled .crisp-multiselect-chevron { color: ${tokens_1.contract.fgDisabled}; }
46
+ & .crisp-multiselect-trigger[aria-readonly="true"] { background: ${tokens_1.contract.bgSelectDisabled}; cursor: default; }
47
47
  & .crisp-multiselect-chips { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: ${tokens_1.contract.space["1"]}; }
48
- & .crisp-multiselect-placeholder { color: ${tokens_1.contract.fgMuted}; }
49
- & .crisp-multiselect-chevron { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; transition: transform .15s ease; }
50
- & .crisp-multiselect-trigger[data-open] .crisp-multiselect-chevron { transform: rotate(180deg); }
48
+ & .crisp-multiselect-placeholder { color: ${tokens_1.contract.fgSelectPlaceholder}; }
49
+ /* closeRotateDuration/openRotateDuration, same asymmetric timing as
50
+ Select's own chevron (see that recipe's comment). */
51
+ & .crisp-multiselect-chevron { flex-shrink: 0; color: ${tokens_1.contract.fgNeutralMuted}; transition: transform .1s cubic-bezier(.35,0,.35,1); }
52
+ & .crisp-multiselect-trigger[data-open] .crisp-multiselect-chevron { transform: rotate(180deg); transition-duration: .15s; }
51
53
  `,
52
54
  globalCss: `
53
- /* pointer-events: auto (#375): a modal Dialog sets pointer-events: none
54
- on document.body while open (Zag re-enables it only on layers IT
55
- tracks), and the portaled listbox is outside Zag's knowledge -- it
56
- inherited the lock, so every option was unclickable, hit-testing
57
- right through to whatever real element sat behind it. */
55
+ /* pointer-events: auto (#375) -- see Select's matching comment. */
58
56
  .crisp-multiselect-listbox {
59
57
  font-family: ${tokens_1.contract.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
60
- position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space.px};
61
- max-height: 300px; overflow-y: auto; padding: ${tokens_1.contract.space["1"]};
62
- border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgPopover};
63
- box-shadow: ${tokens_1.elevation.floating};
64
- transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
58
+ position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${tokens_1.contract.space["2"]};
59
+ max-height: 480px; overflow-y: auto; padding: ${tokens_1.contract.space["2"]};
60
+ border-radius: ${tokens_1.contract.radius["3xl"]}; background: ${tokens_1.contract.bgSelectListbox};
61
+ box-shadow: ${tokens_1.elevation.selectFloating};
62
+ transform-origin: top left; opacity: 1; transform: none;
63
+ transition: opacity .15s cubic-bezier(0,0,.15,1), transform .15s cubic-bezier(0,0,.15,1);
65
64
  }
66
65
  .crisp-multiselect-listbox[data-up] { transform-origin: bottom left; }
67
- .crisp-multiselect-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
68
- .crisp-multiselect-search { display: flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; height: 36px; flex-shrink: 0; margin: calc(-1 * ${tokens_1.contract.space["1"]}) calc(-1 * ${tokens_1.contract.space["1"]}) ${tokens_1.contract.space["1"]}; padding: ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["3"]}; color: ${tokens_1.contract.fgMuted}; }
66
+ .crisp-multiselect-listbox { @starting-style { opacity: 0; transform: scale(0.95); } }
67
+ /* LOCKED geometry (#481, guarded by build/spec-check.mjs): height 36px,
68
+ gap space["1.5"] (6px), no border-bottom -- an Attio measurement, not
69
+ part of this seed-parity pass. Only colours below follow Select. */
70
+ .crisp-multiselect-search { display: flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]}; height: 36px; flex-shrink: 0; margin: calc(-1 * ${tokens_1.contract.space["2"]}) calc(-1 * ${tokens_1.contract.space["2"]}) ${tokens_1.contract.space["2"]}; padding: ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["3"]}; color: ${tokens_1.contract.fgNeutralMuted}; }
69
71
  .crisp-multiselect-search svg { flex-shrink: 0; }
70
- .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}; }
71
- .crisp-multiselect-search input::placeholder { color: ${tokens_1.contract.fgMuted}; }
72
- /* #343: same measured values as Menu's own group heading
73
- (crisp-menu-label) -- reused rather than re-measured, since it's the
74
- same "small muted heading above a run of rows" treatment. */
75
- .crisp-multiselect-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}; }
72
+ .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.fgSelectNeutral}; }
73
+ .crisp-multiselect-search input::placeholder { color: ${tokens_1.contract.fgSelectPlaceholder}; }
74
+ /* Same groupLabel treatment as Select's (13px/regular/neutral-subtle). */
75
+ .crisp-multiselect-label { padding: ${tokens_1.contract.space["2"]} ${tokens_1.contract.space["4"]} 0; font-size: ${tokens_1.contract.text.md}; line-height: 18px; font-weight: ${tokens_1.contract.weight.regular}; color: ${tokens_1.contract.fgNeutralSubtle}; }
76
76
  .crisp-multiselect-option {
77
- box-sizing: border-box; display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: ${tokens_1.contract.size.menuRow}; flex-shrink: 0;
78
- 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;
77
+ box-sizing: border-box; display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; flex-shrink: 0;
78
+ padding: ${tokens_1.contract.space["2.5"]} ${tokens_1.contract.space["4"]}; margin: 0; border: none; background: none; border-radius: ${tokens_1.contract.radius.xl};
79
+ font-family: inherit; text-align: start; cursor: pointer;
80
+ transition: background-color .15s cubic-bezier(.35,0,.35,1);
79
81
  }
82
+ /* Keyboard/hover highlight keeps crisp's own visible neutral fill, same
83
+ reasoning as Select's matching rule (seed's own pressed tint is too
84
+ faint for a keyboard-navigation indicator). */
80
85
  .crisp-multiselect-option[data-highlighted] { background: ${tokens_1.contract.bgRaised}; }
86
+ .crisp-multiselect-option:active { background: ${tokens_1.contract.bgSelectPressed}; }
81
87
  .crisp-multiselect-option-chip { flex: 0 1 auto; min-width: 0; overflow: hidden; }
82
88
  .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; }
83
89
  .crisp-multiselect-option[aria-selected="true"] .crisp-multiselect-check { background: ${tokens_1.contract.bgBrandSolid}; box-shadow: none; color: #fff; }
84
- .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}; }
90
+ .crisp-multiselect-empty { padding: ${tokens_1.contract.space["3"]} ${tokens_1.contract.space["4"]}; text-align: center; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgNeutralSubtle}; }
85
91
 
86
92
  .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); box-shadow: inset 0 0 0 1px ${tokens_1.contract.borderSubtle}; }
87
93
  .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}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${tokens_1.contract.bgBrandSolid} 30%, transparent); }
@@ -93,18 +99,42 @@ const config = (0, core_1.defineRecipe)({
93
99
  .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}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${tokens_1.contract.bgOrange} 30%, transparent); }
94
100
  `,
95
101
  variants: {
96
- // Same ring Select's `invalid` draws — see that recipe's comment.
97
- invalid: {
98
- true: `
99
- & .crisp-multiselect-trigger,
100
- & .crisp-multiselect-trigger:hover,
101
- & .crisp-multiselect-trigger[data-open],
102
- & .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgDanger}; }
102
+ // seed's two trigger sizes, same tokens as Select's. medium is the
103
+ // default -- closest to crisp's previous single size.
104
+ size: {
105
+ medium: `
106
+ & .crisp-multiselect-trigger { min-height: ${tokens_1.contract.size.selectTriggerMd}; border-radius: ${tokens_1.contract.radius.md}; gap: ${tokens_1.contract.space["2"]}; padding: ${tokens_1.contract.space["1"]} ${tokens_1.contract.space["3.5"]}; }
107
+ & .crisp-multiselect-chevron { width: ${tokens_1.contract.size.icon}; height: ${tokens_1.contract.size.icon}; }
108
+ `,
109
+ large: `
110
+ & .crisp-multiselect-trigger { min-height: ${tokens_1.contract.size.selectTriggerLg}; border-radius: ${tokens_1.contract.radius.xl}; gap: ${tokens_1.contract.space["2.5"]}; padding: ${tokens_1.contract.space["1.5"]} ${tokens_1.contract.space["4"]}; }
111
+ & .crisp-multiselect-chevron { width: ${tokens_1.contract.size.selectIconLg}; height: ${tokens_1.contract.size.selectIconLg}; }
103
112
  `,
113
+ },
114
+ // Real border width+colour change, same as Select's -- see that
115
+ // recipe's comment.
116
+ invalid: {
117
+ true: `& .crisp-multiselect-trigger { border-width: 2px; border-color: ${tokens_1.contract.strokeSelectInvalid}; }`,
104
118
  false: ``,
105
119
  },
120
+ // ghost: fills whatever row it's embedded in instead of seed's fixed
121
+ // trigger height -- RecordsTable's TagsCell embeds a MultiSelect inline
122
+ // in a 36px table row, the exact regression #879 caught for Select's
123
+ // own datatable consumers. Not a seed state; crisp's own escape hatch.
124
+ //
125
+ // Declared LAST on purpose, same reasoning as Select's recipe: same-
126
+ // specificity CSS rules resolve by source order, and ghost's
127
+ // `min-height: 0; height: 100%` below must come after `size`'s fixed
128
+ // min-height to win the cascade.
129
+ variant: {
130
+ default: "",
131
+ ghost: `
132
+ & .crisp-multiselect-trigger { min-height: 0; height: 100%; border-color: transparent; }
133
+ & .crisp-multiselect-trigger:hover:not(:disabled) { background: ${tokens_1.contract.bgSelectPressed}; }
134
+ `,
135
+ },
106
136
  },
107
- defaultVariants: { invalid: false },
137
+ defaultVariants: { size: "medium", invalid: false, variant: "default" },
108
138
  compoundVariants: [],
109
139
  });
110
140
  exports.multiselectRecipe = Object.assign((0, core_1.resolveRecipe)(config), {
@@ -14,7 +14,7 @@ exports.useListboxKeyboard = useListboxKeyboard;
14
14
  * NOT for `WheelPicker`: that is an always-visible multi-column wheel that
15
15
  * commits on move, so Enter, Escape and Tab have no meaning in it.
16
16
  */
17
- function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, commit, close, dismiss = close, }) {
17
+ function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, commit, close, dismiss = close, commitOnSpace = false, }) {
18
18
  const move = (dir) => {
19
19
  if (enabledIndexes.length === 0)
20
20
  return;
@@ -53,6 +53,12 @@ function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, commit, c
53
53
  e.preventDefault();
54
54
  commit();
55
55
  break;
56
+ case " ":
57
+ if (!commitOnSpace)
58
+ break;
59
+ e.preventDefault();
60
+ commit();
61
+ break;
56
62
  case "Escape":
57
63
  e.preventDefault();
58
64
  close();
@@ -46,13 +46,16 @@ function optionText(o) {
46
46
  return typeof o.label === "string" ? o.label : o.value;
47
47
  }
48
48
  /**
49
- * A select / dropdown — the settings control, measured live, with an
50
- * optional Linear-style search. A `role="combobox"` trigger opens a
51
- * `role="listbox"`; pass `searchable` to add a filter field at the top (with a
52
- * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
53
- * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
49
+ * A select / dropdown, built to seed-design's Select (crisp/#867) rather than
50
+ * measured from Attio — a deliberate one-component exception, the same way
51
+ * `DatePicker` follows seed's calendar structure — with an optional
52
+ * Linear-style search seed itself has no equivalent of. A `role="combobox"`
53
+ * trigger opens a `role="listbox"`; pass `searchable` to add a filter field
54
+ * at the top (with a "No results" empty state). Full keyboard model — ↑/↓
55
+ * move, Enter/Space pick, Esc close, Home/End jump, opens upward when tight.
56
+ * Controlled or uncontrolled.
54
57
  */
55
- function Select({ options, value, defaultValue, onValueChange, name, required, placeholder: placeholderProp, variant = "default", width = "full", searchable, searchPlaceholder: searchPlaceholderProp, onSearchChange, emptyLabel: emptyLabelProp, disabled, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
58
+ function Select({ options, value, defaultValue, onValueChange, name, required, placeholder: placeholderProp, variant = "default", width = "full", size = "medium", searchable, searchPlaceholder: searchPlaceholderProp, onSearchChange, emptyLabel: emptyLabelProp, disabled, readOnly, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
56
59
  // crisp/#471: an explicit prop always wins; missing, it reads the nearest
57
60
  // CrispMessagesProvider's translation of the English default, or that
58
61
  // default itself with no provider — unchanged from before this existed.
@@ -99,8 +102,12 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
99
102
  const activeId = open && highlight >= 0 && highlight < filtered.length
100
103
  ? optionId(highlight)
101
104
  : undefined;
105
+ // seed-design's `interactive` (crisp/#867): disabled AND readOnly both
106
+ // refuse to open, but only `disabled` removes the button from the tab
107
+ // order and greys its text — readOnly stays focusable, full-strength ink.
108
+ const interactive = !disabled && !readOnly;
102
109
  const openMenu = () => {
103
- if (disabled)
110
+ if (!interactive)
104
111
  return;
105
112
  const rect = triggerRef.current?.getBoundingClientRect();
106
113
  if (rect) {
@@ -109,7 +116,7 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
109
116
  // direction that is (#210)
110
117
  const { style, up: flipUp } = (0, core_1.placeFloating)({
111
118
  rect,
112
- gap: 4,
119
+ gap: 8, // seed-design's select.yaml `gutter` (crisp/#867; was 4)
113
120
  flipBelow: 260,
114
121
  anchor: triggerRef.current,
115
122
  });
@@ -143,8 +150,14 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
143
150
  commit: () => pick(filtered[highlight]),
144
151
  close,
145
152
  dismiss: () => setOpen(false),
153
+ // Space also commits when the trigger itself owns navigation (crisp/#867,
154
+ // matching seed-design). Never on the search input's own wiring below —
155
+ // that one omits this option, so Space stays a normal character there.
156
+ commitOnSpace: !searchable,
146
157
  });
147
158
  const onTriggerKeyDown = (e) => {
159
+ if (!interactive)
160
+ return;
148
161
  if (!open) {
149
162
  if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
150
163
  e.preventDefault();
@@ -201,10 +214,10 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
201
214
  document.addEventListener("keydown", onKey);
202
215
  return () => document.removeEventListener("keydown", onKey);
203
216
  }, [open, close]);
204
- 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",
217
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, size, 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",
205
218
  // controlled by the listbox above; `readOnly` would bar it from
206
219
  // validation, which is the only reason this input is not hidden
207
- 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.triggerLabel ?? 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) => {
220
+ 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, "aria-readonly": readOnly ? "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.triggerLabel ?? 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) => {
208
221
  setQuery(e.target.value);
209
222
  setHighlight(0);
210
223
  onSearchChange?.(e.target.value);
@@ -215,7 +228,7 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
215
228
  // heading, not one each.
216
229
  const showGroupLabel = opt.group !== undefined &&
217
230
  opt.group !== filtered[i - 1]?.group;
218
- return ((0, jsx_runtime_1.jsxs)(React.Fragment, { children: [showGroupLabel && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-select-label", children: opt.group })), (0, jsx_runtime_1.jsxs)("button", { type: "button", id: optionId(i), role: "option", "aria-selected": isSel, tabIndex: -1, disabled: opt.disabled, "data-disabled": opt.disabled ? "" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-select-option", onClick: () => pick(opt), onMouseEnter: () => setHighlight(i), children: [opt.color && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-dot", style: { background: opt.color } })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-label", children: opt.label }), isSel && ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "crisp-select-option-check", size: 15, strokeWidth: 2 }))] })] }, opt.value));
231
+ return ((0, jsx_runtime_1.jsxs)(React.Fragment, { children: [showGroupLabel && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-select-label", children: opt.group })), (0, jsx_runtime_1.jsxs)("button", { type: "button", id: optionId(i), role: "option", "aria-selected": isSel, tabIndex: -1, disabled: opt.disabled, "data-disabled": opt.disabled ? "" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-select-option", onClick: () => pick(opt), onMouseEnter: () => setHighlight(i), children: [opt.color && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-dot", style: { background: opt.color } })), opt.description !== undefined ? ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-select-option-body", children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-label", children: opt.label }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-description", children: opt.description })] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-label", children: opt.label })), isSel && ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "crisp-select-option-check", size: 15, strokeWidth: 2 }))] })] }, opt.value));
219
232
  }))] }) }))] }));
220
233
  }
221
234
  Select.displayName = "Select";