@crisp-ui-kit/crisp 0.56.0 → 0.57.1

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 (139) hide show
  1. package/dist/accentpicker.css +5 -0
  2. package/dist/accentpicker.d.ts +7 -0
  3. package/dist/accentpicker.layered.css +7 -0
  4. package/dist/avatar.css +1 -1
  5. package/dist/avatar.layered.css +1 -1
  6. package/dist/badge.css +2 -2
  7. package/dist/badge.layered.css +2 -2
  8. package/dist/button.css +3 -3
  9. package/dist/button.layered.css +3 -3
  10. package/dist/callout.css +6 -0
  11. package/dist/callout.d.ts +2 -2
  12. package/dist/callout.layered.css +6 -0
  13. package/dist/cjs/components/accentpicker/accentpicker.js +66 -0
  14. package/dist/cjs/components/accentpicker/accentpicker.recipe.js +25 -0
  15. package/dist/cjs/components/accentpicker/index.js +7 -0
  16. package/dist/cjs/components/avatar/avatar.recipe.js +4 -1
  17. package/dist/cjs/components/badge/badge.recipe.js +16 -2
  18. package/dist/cjs/components/button/button.recipe.js +16 -6
  19. package/dist/cjs/components/callout/callout.js +2 -2
  20. package/dist/cjs/components/callout/callout.recipe.js +15 -2
  21. package/dist/cjs/components/input/input.js +2 -2
  22. package/dist/cjs/components/input/input.recipe.js +14 -0
  23. package/dist/cjs/components/menu/menu.js +2 -2
  24. package/dist/cjs/components/menu/menu.recipe.js +8 -0
  25. package/dist/cjs/components/multiselect/multiselect.js +23 -10
  26. package/dist/cjs/components/multiselect/multiselect.recipe.js +104 -65
  27. package/dist/cjs/components/progress/progress.js +21 -4
  28. package/dist/cjs/components/progress/progress.recipe.js +30 -0
  29. package/dist/cjs/components/select/listbox-keyboard.js +7 -1
  30. package/dist/cjs/components/select/select.js +24 -11
  31. package/dist/cjs/components/select/select.recipe.js +177 -78
  32. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +3 -3
  33. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +26 -1
  34. package/dist/cjs/components/tabs/tabs.js +63 -4
  35. package/dist/cjs/components/tabs/tabs.recipe.js +13 -0
  36. package/dist/cjs/components/text/text.recipe.js +8 -7
  37. package/dist/cjs/components/textarea/textarea.js +3 -2
  38. package/dist/cjs/components/textarea/textarea.recipe.js +2 -1
  39. package/dist/cjs/components/textlink/textlink.js +2 -2
  40. package/dist/cjs/components/textlink/textlink.recipe.js +24 -3
  41. package/dist/cjs/index.js +1 -0
  42. package/dist/cjs/product/recordpage/recordpage.js +61 -7
  43. package/dist/cjs/tokens/elevation.js +7 -0
  44. package/dist/cjs/tokens/palette.js +120 -25
  45. package/dist/cjs/tokens/scale.js +9 -0
  46. package/dist/cjs/tokens/semantic.js +42 -0
  47. package/dist/esm/components/accentpicker/accentpicker.js +30 -0
  48. package/dist/esm/components/accentpicker/accentpicker.recipe.js +22 -0
  49. package/dist/esm/components/accentpicker/index.js +2 -0
  50. package/dist/esm/components/avatar/avatar.recipe.js +4 -1
  51. package/dist/esm/components/badge/badge.recipe.js +16 -2
  52. package/dist/esm/components/button/button.recipe.js +16 -6
  53. package/dist/esm/components/callout/callout.js +2 -2
  54. package/dist/esm/components/callout/callout.recipe.js +15 -2
  55. package/dist/esm/components/input/input.js +3 -3
  56. package/dist/esm/components/input/input.recipe.js +14 -0
  57. package/dist/esm/components/menu/menu.js +2 -2
  58. package/dist/esm/components/menu/menu.recipe.js +8 -0
  59. package/dist/esm/components/multiselect/multiselect.js +23 -10
  60. package/dist/esm/components/multiselect/multiselect.recipe.js +104 -65
  61. package/dist/esm/components/progress/progress.js +22 -5
  62. package/dist/esm/components/progress/progress.recipe.js +30 -0
  63. package/dist/esm/components/select/listbox-keyboard.js +7 -1
  64. package/dist/esm/components/select/select.js +24 -11
  65. package/dist/esm/components/select/select.recipe.js +177 -78
  66. package/dist/esm/components/tabletoolbar/tabletoolbar.js +3 -3
  67. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +26 -1
  68. package/dist/esm/components/tabs/tabs.js +63 -4
  69. package/dist/esm/components/tabs/tabs.recipe.js +13 -0
  70. package/dist/esm/components/text/text.recipe.js +8 -7
  71. package/dist/esm/components/textarea/textarea.js +3 -2
  72. package/dist/esm/components/textarea/textarea.recipe.js +2 -1
  73. package/dist/esm/components/textlink/textlink.js +2 -2
  74. package/dist/esm/components/textlink/textlink.recipe.js +24 -3
  75. package/dist/esm/index.js +1 -0
  76. package/dist/esm/product/recordpage/recordpage.js +62 -8
  77. package/dist/esm/tokens/elevation.js +7 -0
  78. package/dist/esm/tokens/palette.js +120 -25
  79. package/dist/esm/tokens/scale.js +9 -0
  80. package/dist/esm/tokens/semantic.js +42 -0
  81. package/dist/input.css +11 -0
  82. package/dist/input.d.ts +2 -2
  83. package/dist/input.layered.css +11 -0
  84. package/dist/menu.css +8 -0
  85. package/dist/menu.layered.css +8 -0
  86. package/dist/multiselect.css +57 -29
  87. package/dist/multiselect.d.ts +3 -1
  88. package/dist/multiselect.layered.css +57 -29
  89. package/dist/native/tokens.d.ts +1 -1
  90. package/dist/native/tokens.js +82 -28
  91. package/dist/progress.css +3 -0
  92. package/dist/progress.layered.css +3 -0
  93. package/dist/select.css +100 -42
  94. package/dist/select.d.ts +3 -2
  95. package/dist/select.layered.css +100 -42
  96. package/dist/styles.css +268 -93
  97. package/dist/styles.layered.css +268 -93
  98. package/dist/tabletoolbar.css +8 -1
  99. package/dist/tabletoolbar.layered.css +8 -1
  100. package/dist/tabs.css +6 -0
  101. package/dist/tabs.layered.css +6 -0
  102. package/dist/text.css +1 -1
  103. package/dist/text.layered.css +1 -1
  104. package/dist/textarea.css +2 -1
  105. package/dist/textarea.layered.css +2 -1
  106. package/dist/textlink.css +11 -2
  107. package/dist/textlink.d.ts +2 -1
  108. package/dist/textlink.layered.css +11 -2
  109. package/dist/tokens.css +43 -11
  110. package/dist/tokens.json +82 -28
  111. package/dist/types/components/accentpicker/accentpicker.d.ts +23 -0
  112. package/dist/types/components/accentpicker/accentpicker.recipe.d.ts +3 -0
  113. package/dist/types/components/accentpicker/index.d.ts +2 -0
  114. package/dist/types/components/button/button.recipe.d.ts +2 -2
  115. package/dist/types/components/callout/callout.d.ts +9 -1
  116. package/dist/types/components/callout/callout.recipe.d.ts +12 -2
  117. package/dist/types/components/input/input.d.ts +14 -0
  118. package/dist/types/components/input/input.recipe.d.ts +3 -0
  119. package/dist/types/components/menu/menu.d.ts +7 -1
  120. package/dist/types/components/multiselect/multiselect.d.ts +25 -6
  121. package/dist/types/components/multiselect/multiselect.recipe.d.ts +27 -0
  122. package/dist/types/components/progress/progress.d.ts +16 -4
  123. package/dist/types/components/progress/progress.recipe.d.ts +22 -0
  124. package/dist/types/components/select/listbox-keyboard.d.ts +10 -1
  125. package/dist/types/components/select/select.d.ts +29 -6
  126. package/dist/types/components/select/select.recipe.d.ts +25 -10
  127. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +14 -0
  128. package/dist/types/components/tabs/tabs.d.ts +9 -1
  129. package/dist/types/components/textarea/textarea.d.ts +16 -0
  130. package/dist/types/components/textlink/textlink.d.ts +8 -1
  131. package/dist/types/components/textlink/textlink.recipe.d.ts +10 -0
  132. package/dist/types/index.d.ts +1 -0
  133. package/dist/types/product/recordpage/recordpage.d.ts +61 -1
  134. package/dist/types/tokens/contract.d.ts +16 -3
  135. package/dist/types/tokens/elevation.d.ts +7 -0
  136. package/dist/types/tokens/palette.d.ts +55 -11
  137. package/dist/types/tokens/scale.d.ts +6 -0
  138. package/dist/types/tokens/semantic.d.ts +33 -0
  139. package/package.json +6 -1
@@ -10,13 +10,16 @@ function optionText(o) {
10
10
  return typeof o.label === "string" ? o.label : o.value;
11
11
  }
12
12
  /**
13
- * A select / dropdown — the settings control, measured live, with an
14
- * optional Linear-style search. A `role="combobox"` trigger opens a
15
- * `role="listbox"`; pass `searchable` to add a filter field at the top (with a
16
- * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
17
- * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
13
+ * A select / dropdown, built to seed-design's Select (crisp/#867) rather than
14
+ * measured from Attio — a deliberate one-component exception, the same way
15
+ * `DatePicker` follows seed's calendar structure — with an optional
16
+ * Linear-style search seed itself has no equivalent of. A `role="combobox"`
17
+ * trigger opens a `role="listbox"`; pass `searchable` to add a filter field
18
+ * at the top (with a "No results" empty state). Full keyboard model — ↑/↓
19
+ * move, Enter/Space pick, Esc close, Home/End jump, opens upward when tight.
20
+ * Controlled or uncontrolled.
18
21
  */
19
- export 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 }) {
22
+ export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder: placeholderProp, variant = "default", width = "full", size = "small", searchable, searchPlaceholder: searchPlaceholderProp, onSearchChange, emptyLabel: emptyLabelProp, disabled, readOnly, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
20
23
  // crisp/#471: an explicit prop always wins; missing, it reads the nearest
21
24
  // CrispMessagesProvider's translation of the English default, or that
22
25
  // default itself with no provider — unchanged from before this existed.
@@ -63,8 +66,12 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
63
66
  const activeId = open && highlight >= 0 && highlight < filtered.length
64
67
  ? optionId(highlight)
65
68
  : undefined;
69
+ // seed-design's `interactive` (crisp/#867): disabled AND readOnly both
70
+ // refuse to open, but only `disabled` removes the button from the tab
71
+ // order and greys its text — readOnly stays focusable, full-strength ink.
72
+ const interactive = !disabled && !readOnly;
66
73
  const openMenu = () => {
67
- if (disabled)
74
+ if (!interactive)
68
75
  return;
69
76
  const rect = triggerRef.current?.getBoundingClientRect();
70
77
  if (rect) {
@@ -73,7 +80,7 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
73
80
  // direction that is (#210)
74
81
  const { style, up: flipUp } = placeFloating({
75
82
  rect,
76
- gap: 4,
83
+ gap: 8, // seed-design's select.yaml `gutter` (crisp/#867; was 4)
77
84
  flipBelow: 260,
78
85
  anchor: triggerRef.current,
79
86
  });
@@ -107,8 +114,14 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
107
114
  commit: () => pick(filtered[highlight]),
108
115
  close,
109
116
  dismiss: () => setOpen(false),
117
+ // Space also commits when the trigger itself owns navigation (crisp/#867,
118
+ // matching seed-design). Never on the search input's own wiring below —
119
+ // that one omits this option, so Space stays a normal character there.
120
+ commitOnSpace: !searchable,
110
121
  });
111
122
  const onTriggerKeyDown = (e) => {
123
+ if (!interactive)
124
+ return;
112
125
  if (!open) {
113
126
  if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
114
127
  e.preventDefault();
@@ -165,10 +178,10 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
165
178
  document.addEventListener("keydown", onKey);
166
179
  return () => document.removeEventListener("keydown", onKey);
167
180
  }, [open, close]);
168
- return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
181
+ return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, size, invalid, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
169
182
  // controlled by the listbox above; `readOnly` would bar it from
170
183
  // validation, which is the only reason this input is not hidden
171
- onChange: () => { } })), _jsxs("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.triggerLabel ?? selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
184
+ onChange: () => { } })), _jsxs("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, "aria-readonly": readOnly ? "true" : undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.triggerLabel ?? selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 16, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
172
185
  setQuery(e.target.value);
173
186
  setHighlight(0);
174
187
  onSearchChange?.(e.target.value);
@@ -179,7 +192,7 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
179
192
  // heading, not one each.
180
193
  const showGroupLabel = opt.group !== undefined &&
181
194
  opt.group !== filtered[i - 1]?.group;
182
- return (_jsxs(React.Fragment, { children: [showGroupLabel && (_jsx("div", { className: "crisp-select-label", children: opt.group })), _jsxs("button", { type: "button", id: optionId(i), role: "option", "aria-selected": isSel, tabIndex: -1, disabled: opt.disabled, "data-disabled": opt.disabled ? "" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-select-option", onClick: () => pick(opt), onMouseEnter: () => setHighlight(i), children: [opt.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: opt.color } })), _jsx("span", { className: "crisp-select-option-label", children: opt.label }), isSel && (_jsx(Check, { className: "crisp-select-option-check", size: 15, strokeWidth: 2 }))] })] }, opt.value));
195
+ return (_jsxs(React.Fragment, { children: [showGroupLabel && (_jsx("div", { className: "crisp-select-label", children: opt.group })), _jsxs("button", { type: "button", id: optionId(i), role: "option", "aria-selected": isSel, tabIndex: -1, disabled: opt.disabled, "data-disabled": opt.disabled ? "" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-select-option", onClick: () => pick(opt), onMouseEnter: () => setHighlight(i), children: [opt.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: opt.color } })), opt.description !== undefined ? (_jsxs("span", { className: "crisp-select-option-body", children: [_jsx("span", { className: "crisp-select-option-label", children: opt.label }), _jsx("span", { className: "crisp-select-option-description", children: opt.description })] })) : (_jsx("span", { className: "crisp-select-option-label", children: opt.label })), isSel && (_jsx(Check, { className: "crisp-select-option-check", size: 15, strokeWidth: 2 }))] })] }, opt.value));
183
196
  }))] }) }))] }));
184
197
  }
185
198
  Select.displayName = "Select";
@@ -1,24 +1,36 @@
1
1
  import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
2
  import { contract as c, elevation as e } from "../../tokens/index.js";
3
- // A select / dropdown, measured from settings: a 32px combobox trigger
4
- // (radius 9, value + a 16px chevron) over a listbox — radius 12, 4px padding, a
5
- // layered soft shadow — of 32px options (radius 8, 6/8 padding) that show a
6
- // check on the selected one and highlight on hover / keyboard. The listbox is
7
- // portaled to document.body (#339) so no ancestor's transform/backdrop-filter
8
- // (Dialog's own measured surface sets both) can capture its position: fixed
9
- // box; z-index 70 keeps it above Dialog/Drawer's z-index 60/61 layers, since
10
- // it's no longer nested inside whichever one opened it.
11
- // The listbox and everything inside it live in `globalCss` below, not
12
- // nested under `&` (#357): nesting compiles to a real descendant combinator
13
- // scoped to the recipe's root class, which needs actual DOM containment —
14
- // exactly what the portal to `document.body` breaks. Nested, these rules
15
- // matched nothing once the listbox moved and it rendered completely
16
- // unstyled. The trigger and its own parts stay nested under `&` — the
17
- // trigger is NOT portaled, it stays where it was authored. `font-family`/
18
- // smoothing are restated on the listbox's own rule (its children pick font
19
- // up via their own `font-family: inherit`, which still works — that
20
- // inheritance is intact WITHIN the portaled subtree, only the link back to
21
- // the trigger-holding root broke).
3
+ // seed-design's Select, resolved value-for-value from
4
+ // `@seed-design/rootage`'s select.yaml / select-trigger.yaml / select-item.yaml
5
+ // (crisp/#867) — a deliberate, owner-requested exception to "measure Attio
6
+ // first": this ONE component follows seed-design, not Attio, the same way
7
+ // DatePicker already follows seed's calendar structure. Every px/colour below
8
+ // traces to a resolved seed token (see the new entries in tokens/scale.ts,
9
+ // tokens/palette.ts, tokens/semantic.ts and tokens/elevation.ts, each
10
+ // commented with the seed token it came from) — nothing here is measured
11
+ // live, because there is no live Attio reference for this shape any more.
12
+ //
13
+ // crisp keeps its own `searchable` filter field and `triggerLabel` (#846):
14
+ // seed has no search of its own, and the owner asked to keep both. The
15
+ // listbox stays portaled to `document.body` for the same reason #339
16
+ // established (no ancestor's transform/backdrop-filter can capture its
17
+ // position) — see the sibling comment on `globalCss` below for why its rules
18
+ // live there rather than nested under `&`.
19
+ //
20
+ // Two things seed has that this recipe deliberately does NOT reproduce:
21
+ // - A JS-driven exit animation (seed's `exitDuration`/`exitScale`/
22
+ // `exitOpacity`, played before unmount). Every other crisp overlay
23
+ // (Dialog, Menu, ContextMenu, Popover) unmounts synchronously on close and
24
+ // only animates the ENTER transition via `@starting-style` — adding a
25
+ // delayed-unmount mechanism just for Select would be a new architectural
26
+ // pattern with no precedent anywhere else in the repo, for one component.
27
+ // The enter animation (scale 0.95→1, opacity 0→1, seed's own curve) IS
28
+ // reproduced below.
29
+ // - A visible `divider` slot: `select.yaml` defines one, but nothing in
30
+ // crisp or GRC groups options with an explicit separator today, and
31
+ // AUTHORING's rule against building what nothing consumes applies to CSS
32
+ // too. The resolved value is in `~/grc-parity/seed-select-spec.md` for
33
+ // whoever adds it.
22
34
  const config = defineRecipe({
23
35
  name: "select",
24
36
  base: `
@@ -26,87 +38,174 @@ const config = defineRecipe({
26
38
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
27
39
 
28
40
  & .crisp-select-trigger {
29
- box-sizing: border-box; display: inline-flex; align-items: center; gap: ${c.space["1.5"]};
30
- height: ${c.size.menuRow}; width: 100%; min-width: 0; margin: 0; padding: 0 ${c.space["1.5"]} 0 ${c.space["2"]};
31
- border: none; border-radius: ${c.radius.md}; background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 7%, transparent);
32
- font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; cursor: pointer;
33
- transition: background-color .12s ease, box-shadow .12s ease;
41
+ box-sizing: border-box; display: inline-flex; align-items: center; width: 100%; min-width: 0; margin: 0;
42
+ border: 1px solid ${c.strokeSelectTrigger}; background: transparent; cursor: pointer;
43
+ font-family: inherit; color: ${c.fgSelectNeutral};
44
+ 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);
34
45
  }
35
- & .crisp-select-trigger:hover { background: ${c.bgRaised}; }
36
- & .crisp-select-trigger[data-open], & .crisp-select-trigger:focus-visible { outline: none; box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; background: ${c.bgSurface}; }
37
- & .crisp-select-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
38
- & .crisp-select-value { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start; }
39
- & .crisp-select-placeholder { color: ${c.fgMuted}; }
40
- & .crisp-select-chevron { flex-shrink: 0; color: ${c.fgMuted}; transition: transform .15s ease; }
41
- & .crisp-select-trigger[data-open] .crisp-select-chevron { transform: rotate(180deg); }
46
+ /* seed defines no hover state for this trigger -- only pressed, invalid,
47
+ disabled and readonly. The scale-down is scoped to the CONTENT
48
+ (seed's scaleScope: content), not the button itself, so the border
49
+ never moves. */
50
+ /* Only the value scales, not the chevron: the chevron's own transform is
51
+ its open/close rotation, and a scale would fight it for one shared
52
+ CSS property when both are true at once (pressing while open). */
53
+ & .crisp-select-trigger:active:not(:disabled):not([aria-readonly="true"]) { background: ${c.bgSelectPressed}; }
54
+ & .crisp-select-trigger:active:not(:disabled):not([aria-readonly="true"]) .crisp-select-value { transform: scale(0.98); }
55
+ & .crisp-select-trigger:focus-visible { outline: 2px solid ${c.strokeFocusRing}; outline-offset: -2px; }
56
+ & .crisp-select-trigger:disabled { background: ${c.bgSelectDisabled}; cursor: not-allowed; }
57
+ & .crisp-select-trigger:disabled .crisp-select-value,
58
+ & .crisp-select-trigger:disabled .crisp-select-placeholder,
59
+ & .crisp-select-trigger:disabled .crisp-select-chevron { color: ${c.fgDisabled}; }
60
+ /* readonly: same fill as disabled, but the value/placeholder text stays
61
+ normal ink -- it reads as "answered and locked", not "answered and
62
+ greyed out" (select-trigger.yaml's readonly state only overrides
63
+ root.color; value/placeholder keep their enabled colours). */
64
+ & .crisp-select-trigger[aria-readonly="true"] { background: ${c.bgSelectDisabled}; cursor: default; }
65
+ & .crisp-select-value { flex: 1; min-width: 0; display: inline-flex; align-items: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start; transition: transform .15s cubic-bezier(0,0,.15,1); }
66
+ & .crisp-select-placeholder { color: ${c.fgSelectPlaceholder}; }
67
+ /* closeRotateDuration/closeRotateTimingFunction: 100ms, seed's "easing"
68
+ curve. Overridden to 150ms on open (openRotateDuration) below --
69
+ same curve both ways, only the duration differs. */
70
+ & .crisp-select-chevron { flex-shrink: 0; color: ${c.fgNeutralMuted}; transition: transform .1s cubic-bezier(.35,0,.35,1); }
71
+ & .crisp-select-trigger[data-open] .crisp-select-chevron { transform: rotate(180deg); transition-duration: .15s; }
42
72
  `,
43
73
  globalCss: `
44
74
  /* pointer-events: auto (#375): a modal Dialog sets pointer-events: none
45
- on document.body while open (Zag re-enables it only on layers IT
46
- tracks), and the portaled listbox is outside Zag's knowledge -- it
47
- inherited the lock, so every option was unclickable, hit-testing
48
- right through to whatever real element sat behind it. */
75
+ on document.body while open, and the portaled listbox is outside its
76
+ knowledge -- it inherited the lock without this. */
49
77
  .crisp-select-listbox {
50
78
  font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
51
- position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
52
- max-height: 280px; overflow-y: auto; padding: ${c.space["1"]};
53
- border-radius: ${c.radius.xl}; background: ${c.bgPopover};
54
- box-shadow: ${e.floating};
55
- transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
79
+ position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space["2"]};
80
+ max-height: 480px; overflow-y: auto; padding: ${c.space["2"]};
81
+ border-radius: ${c.radius["3xl"]}; background: ${c.bgSelectListbox};
82
+ box-shadow: ${e.selectFloating};
83
+ transform-origin: top left; opacity: 1; transform: none;
84
+ transition: opacity .15s cubic-bezier(0,0,.15,1), transform .15s cubic-bezier(0,0,.15,1);
56
85
  }
57
- .crisp-select-listbox[data-up] { transform-origin: bottom; }
58
- .crisp-select-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
59
- .crisp-select-search { display: flex; align-items: center; gap: ${c.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${c.space["1"]}) calc(-1 * ${c.space["1"]}) ${c.space["1"]}; padding: 0 ${c.space["2.5"]}; border-bottom: 1px solid ${c.borderSubtle}; color: ${c.fgMuted}; }
86
+ .crisp-select-listbox[data-up] { transform-origin: bottom left; }
87
+ .crisp-select-listbox { @starting-style { opacity: 0; transform: scale(0.95); } }
88
+ /* Composed from @seed-design/rootage's text-input.yaml
89
+ (variant=outline,size=medium) -- seed's Select has no search field of
90
+ its own to resolve this from (see the file header), so this reuses
91
+ the SAME tokens #867 already resolved for the trigger at this size:
92
+ minHeight x10 (${c.size.selectTriggerMd}), paddingX x3_5
93
+ (${c.space["3.5"]}), gap x2 (${c.space["2"]}), cornerRadius r2
94
+ (${c.radius.md}), strokeColor stroke.neutral-weak
95
+ (${c.strokeSelectTrigger}) -- a real bordered field sitting flush
96
+ inside the listbox's own 8px padding, replacing the old bare
97
+ 34px/no-border/bleeds-to-the-edge strip (crisp/#904). */
98
+ .crisp-select-search { display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.selectTriggerMd}; flex-shrink: 0; padding: 0 ${c.space["3.5"]}; border: 1px solid ${c.strokeSelectTrigger}; border-radius: ${c.radius.md}; color: ${c.fgNeutralMuted}; }
60
99
  .crisp-select-search svg { flex-shrink: 0; }
61
- .crisp-select-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; }
62
- .crisp-select-search input::placeholder { color: ${c.fgMuted}; }
63
- .crisp-select-empty { padding: ${c.space["3"]} ${c.space["2"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgMuted}; }
64
- /* #343: same measured values as Menu's own group heading
65
- (crisp-menu-label) -- reused rather than re-measured, since it's the
66
- same "small muted heading above a run of rows" treatment. */
67
- .crisp-select-label { padding: ${c.space["1.5"]} ${c.space["2"]} ${c.space["0.5"]}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
100
+ .crisp-select-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; line-height: 19px; color: ${c.fgSelectNeutral}; }
101
+ .crisp-select-search input::placeholder { color: ${c.fgSelectPlaceholder}; }
102
+ .crisp-select-empty { padding: ${c.space["3"]} ${c.space["4"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgNeutralSubtle}; }
103
+ /* select.yaml's groupLabel -- font-size/line-height/weight are the same
104
+ across sizes, only paddingY differs (medium 8px, large 10px). */
105
+ .crisp-select-label { padding: ${c.space["2"]} ${c.space["4"]} 0; font-size: ${c.text.md}; line-height: 18px; font-weight: ${c.weight.regular}; color: ${c.fgNeutralSubtle}; }
68
106
  .crisp-select-option {
69
- box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.menuRow}; flex-shrink: 0;
70
- padding: 0 ${c.space["2"]}; margin: 0; border: none; background: none; border-radius: ${c.radius.md};
71
- font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; text-align: start; cursor: pointer; white-space: nowrap;
107
+ box-sizing: border-box; display: flex; align-items: center; flex-shrink: 0;
108
+ padding: ${c.space["2.5"]} ${c.space["4"]}; margin-inline: 0; border: none; background: none; border-radius: ${c.radius.xl};
109
+ font-family: inherit; color: ${c.fgSelectNeutral}; text-align: start; cursor: pointer; white-space: nowrap;
110
+ transition: background-color .15s cubic-bezier(.35,0,.35,1), margin-inline .15s cubic-bezier(.35,0,.35,1);
72
111
  }
112
+ /* Keyboard/hover highlight keeps crisp's own visible neutral fill --
113
+ seed's spec has no "highlighted" state at all (only pressed/selected/
114
+ disabled), and its actual pressed tint (below) is a ~3% black hint,
115
+ too faint to serve as a keyboard-navigation indicator. */
73
116
  .crisp-select-option[data-highlighted] { background: ${c.bgRaised}; }
74
- .crisp-select-option[data-disabled] { opacity: 0.5; cursor: not-allowed; }
75
- .crisp-select-option-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${c.radius.full}; }
117
+ .crisp-select-option:active:not([data-disabled]) { background: ${c.bgSelectPressed}; margin-inline: ${c.space["2"]}; }
118
+ .crisp-select-option[data-disabled] { cursor: not-allowed; }
119
+ .crisp-select-option[data-disabled] .crisp-select-option-label,
120
+ .crisp-select-option[data-disabled] .crisp-select-option-description,
121
+ .crisp-select-option[data-disabled] .crisp-select-option-check { color: ${c.fgDisabled}; }
122
+ .crisp-select-option-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${c.radius.full}; margin-inline-end: ${c.space["2"]}; }
123
+ .crisp-select-option-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: ${c.space["0.5"]}; }
76
124
  .crisp-select-option-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
77
- .crisp-select-option-check { flex-shrink: 0; color: ${c.bgBrandSolid}; }
125
+ .crisp-select-option-description { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: ${c.fgNeutralSubtle}; }
126
+ .crisp-select-option-check { flex-shrink: 0; color: ${c.fgSelectNeutral}; margin-inline-start: ${c.space["2"]}; }
78
127
  `,
79
128
  variants: {
80
- // ghost: borderless at rest (transparent), reveals a subtle fill on hover —
81
- // inline selects (e.g. the sort configurator).
82
- variant: {
83
- default: "",
84
- ghost: `
85
- & .crisp-select-trigger { background: transparent; box-shadow: none; }
86
- & .crisp-select-trigger:hover { background: ${c.bgRaised}; box-shadow: none; }
87
- & .crisp-select-trigger[data-open], & .crisp-select-trigger:focus-visible { background: transparent; box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; }
88
- `,
89
- },
90
- // full (default): fills its container, as before. auto: sizes to content.
129
+ // full (default): fills its container. auto: sizes to content.
91
130
  width: {
92
131
  full: `width: 100%;`,
93
132
  auto: `width: auto;`,
94
133
  },
95
- // Same ring Input's `invalid` draws, adapted to Select's box-shadow-ring
96
- // trigger (a real <button>, so it needs no ::after overlay the way
97
- // Input/Textarea do). Outranks hover/open/focus, same reasoning as
98
- // Input: a field being corrected stays visibly wrong while it is.
99
- invalid: {
100
- true: `
101
- & .crisp-select-trigger,
102
- & .crisp-select-trigger:hover,
103
- & .crisp-select-trigger[data-open],
104
- & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${c.bgDanger}; }
134
+ // Owner's follow-up after #867/#879 (crisp/#889): the LOOK stays seed's
135
+ // (border, listbox, radius, shadow, items, states, all unchanged below),
136
+ // but the SIZE is Attio's again -- a 40px trigger next to a 32px `Input`
137
+ // in the same form visibly mismatched (measured live in GRC's own "New
138
+ // employee" dialog). `small` is the new default: Attio's original
139
+ // 32px `c.size.menuRow`, `Input`'s own radius/padding/line-height so the
140
+ // two line up exactly, not just approximately. `medium`/`large` (seed's
141
+ // own 40/52px) stay, opt-in. Item rows are IDENTICAL across all three --
142
+ // only the trigger differs; only it was ever the mismatch.
143
+ size: {
144
+ small: `
145
+ & .crisp-select-trigger { height: ${c.size.menuRow}; border-radius: ${c.radius.md}; gap: ${c.space["1.5"]}; padding-inline: ${c.space["2.5"]}; }
146
+ & .crisp-select-value, & .crisp-select-placeholder { font-size: ${c.text.lg}; line-height: 20px; }
147
+ & .crisp-select-chevron { width: ${c.size.icon}; height: ${c.size.icon}; }
148
+ .crisp-select-option { gap: ${c.space["2"]}; }
149
+ .crisp-select-option-label { font-size: ${c.text.lg}; line-height: 19px; }
150
+ .crisp-select-option-description { font-size: ${c.text.sm}; line-height: 16px; }
151
+ .crisp-select-option-check { width: ${c.space["3"]}; height: ${c.space["3"]}; }
152
+ .crisp-select-label { padding-top: ${c.space["2"]}; }
153
+ `,
154
+ medium: `
155
+ & .crisp-select-trigger { height: ${c.size.selectTriggerMd}; border-radius: ${c.radius.md}; gap: ${c.space["2"]}; padding-inline: ${c.space["3.5"]}; }
156
+ & .crisp-select-value, & .crisp-select-placeholder { font-size: ${c.text.lg}; line-height: 19px; }
157
+ & .crisp-select-chevron { width: ${c.size.icon}; height: ${c.size.icon}; }
158
+ .crisp-select-option { gap: ${c.space["2"]}; }
159
+ .crisp-select-option-label { font-size: ${c.text.lg}; line-height: 19px; }
160
+ .crisp-select-option-description { font-size: ${c.text.sm}; line-height: 16px; }
161
+ .crisp-select-option-check { width: ${c.space["3"]}; height: ${c.space["3"]}; }
162
+ .crisp-select-label { padding-top: ${c.space["2"]}; }
163
+ `,
164
+ large: `
165
+ & .crisp-select-trigger { height: ${c.size.selectTriggerLg}; border-radius: ${c.radius.xl}; gap: ${c.space["2.5"]}; padding-inline: ${c.space["4"]}; }
166
+ & .crisp-select-value, & .crisp-select-placeholder { font-size: ${c.text.xl}; line-height: 22px; }
167
+ & .crisp-select-chevron { width: ${c.size.selectIconLg}; height: ${c.size.selectIconLg}; }
168
+ .crisp-select-option { gap: ${c.space["3"]}; }
169
+ .crisp-select-option-label { font-size: ${c.text.xl}; line-height: 22px; }
170
+ .crisp-select-option-description { font-size: ${c.text.md}; line-height: 18px; }
171
+ .crisp-select-option-check { width: ${c.space["3.5"]}; height: ${c.space["3.5"]}; }
172
+ .crisp-select-label { padding-top: ${c.space["2.5"]}; }
105
173
  `,
174
+ },
175
+ // Real border width+colour change (seed: strokeWidth 1px→2px,
176
+ // strokeColor neutral-weak→critical-solid), not a box-shadow ring --
177
+ // crisp's own field/switch invalid rings don't apply here since this
178
+ // component's whole border model changed to match seed's real stroke.
179
+ invalid: {
180
+ true: `& .crisp-select-trigger { border-width: 2px; border-color: ${c.strokeSelectInvalid}; }`,
106
181
  false: ``,
107
182
  },
183
+ // ghost: borderless at rest, a subtle fill on hover -- inline selects
184
+ // (e.g. the sort configurator, and datatable's own cell editors, which
185
+ // embed at whatever height their row already is, not seed's fixed
186
+ // trigger height). Not a seed state; kept from crisp's existing API
187
+ // surface, layered on top of the seed base.
188
+ //
189
+ // Declared LAST on purpose: `resolveRecipe` emits one CSS rule per
190
+ // variant, in the order this object lists them, and same-specificity
191
+ // rules resolve by source order -- ghost's `height: 100%` below must
192
+ // come after `size`'s fixed height to win the cascade. Move `variant`
193
+ // earlier and every ghost consumer's trigger silently grows to 40/52px
194
+ // inside its 36px row again.
195
+ variant: {
196
+ default: "",
197
+ ghost: `
198
+ & .crisp-select-trigger { height: 100%; border-color: transparent; }
199
+ & .crisp-select-trigger:hover:not(:disabled) { background: ${c.bgSelectPressed}; }
200
+ `,
201
+ },
202
+ },
203
+ defaultVariants: {
204
+ width: "full",
205
+ size: "small",
206
+ invalid: false,
207
+ variant: "default",
108
208
  },
109
- defaultVariants: { variant: "default", width: "full", invalid: false },
110
209
  compoundVariants: [],
111
210
  });
112
211
  export const selectRecipe = Object.assign(resolveRecipe(config), { config });
@@ -733,7 +733,7 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
733
733
  : sorts.length
734
734
  ? `${crispMessages["Sorted by"] ?? "Sorted by"} ${sortField}${sorts.length > 1 ? ` +${sorts.length - 1}` : ""}`
735
735
  : sortLabel;
736
- return (_jsx("div", { ref: rootRef, className: tabletoolbarRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-tabletoolbar-toolbar", "data-inline": inline ? "" : undefined, children: [showSort && (_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `crisp-tabletoolbar-tool${sorts.length ? " crisp-tabletoolbar-tool--active" : " crisp-tabletoolbar-tool--dashed"}`, onClick: () => setOpenMenu(openMenu === "sort" ? null : "sort"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "sort", "data-menu-trigger": true, children: [_jsx(ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortTriggerLabel || !sorts.length ? (sortedLabel) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-tabletoolbar-sorted-prefix", children: crispMessages["Sorted by"] ?? "Sorted by" }), " ", _jsx("span", { className: "crisp-tabletoolbar-sorted-field", children: sortField }), sorts.length > 1 && (_jsxs("span", { className: "crisp-tabletoolbar-sorted-count", children: [" ", "+", sorts.length - 1] }))] }))] }), openMenu === "sort" && (_jsxs("div", { ref: sortListRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? (_jsx("div", { className: "crisp-tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => (_jsxs("div", { className: "crisp-tabletoolbar-sort-row", onDragOver: (e) => {
736
+ return (_jsx("div", { ref: rootRef, className: tabletoolbarRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-tabletoolbar-toolbar", "data-inline": inline ? "" : undefined, children: [showSort && (_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `crisp-tabletoolbar-tool${sorts.length ? " crisp-tabletoolbar-tool--active" : " crisp-tabletoolbar-tool--dashed"}`, onClick: () => setOpenMenu(openMenu === "sort" ? null : "sort"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "sort", "data-menu-trigger": true, children: [_jsx(ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortTriggerLabel || !sorts.length ? (sortedLabel) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-tabletoolbar-sorted-prefix", children: crispMessages["Sorted by"] ?? "Sorted by" }), " ", _jsx("span", { className: "crisp-tabletoolbar-sorted-field", children: sortField }), sorts.length > 1 && (_jsxs("span", { className: "crisp-tabletoolbar-sorted-count", children: ["+", sorts.length - 1] }))] }))] }), openMenu === "sort" && (_jsxs("div", { ref: sortListRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? (_jsx("div", { className: "crisp-tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => (_jsxs("div", { className: "crisp-tabletoolbar-sort-row", onDragOver: (e) => {
737
737
  if (dragSort !== null)
738
738
  e.preventDefault();
739
739
  }, onDrop: () => {
@@ -928,12 +928,12 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
928
928
  * contents` so it contributes no box of its own — the same technique
929
929
  * `TableToolbar`'s own root already uses to disappear from layout.
930
930
  */
931
- export const ToolbarTool = React.forwardRef(({ active, ghost, className, ...rest }, ref) => (_jsx("span", { className: tabletoolbarRecipe(), children: _jsx("button", { ref: ref, type: "button", className: [
931
+ export const ToolbarTool = React.forwardRef(({ active, ghost, count, className, children, ...rest }, ref) => (_jsx("span", { className: tabletoolbarRecipe(), children: _jsxs("button", { ref: ref, type: "button", className: [
932
932
  "crisp-tabletoolbar-tool",
933
933
  active && "crisp-tabletoolbar-tool--active",
934
934
  ghost && "crisp-tabletoolbar-tool--ghost",
935
935
  className,
936
936
  ]
937
937
  .filter(Boolean)
938
- .join(" "), ...rest }) })));
938
+ .join(" "), ...rest, children: [children, count != null && (_jsx("span", { className: "crisp-tabletoolbar-tool-count", children: count }))] }) })));
939
939
  ToolbarTool.displayName = "ToolbarTool";
@@ -104,9 +104,33 @@ const importExportChrome = `
104
104
  // vs 14px) and its own mid-grey, distinct from the "Sorted by" muted tone.
105
105
  // TableToolbar-only, same reasoning as `filterChipChrome` above (RecordsTable
106
106
  // keeps its own older Sort label markup, not touched here).
107
+ // crisp#876: re-measured live -- the "+N" isn't just smaller/greyer text,
108
+ // it's a real chip: `background: rgba(0,0,0,.04)`, `box-shadow: inset 0 0 0
109
+ // 1px rgb(238,239,241)` (borderSubtle), `border-radius: 6px`, `padding: 0
110
+ // 4px`, 16px tall, colour `rgb(80,81,85)` -- bit-for-bit crisp's own Badge
111
+ // `tone="neutral" size="small"` shape, so this reuses those exact tokens
112
+ // (`fgNeutralSoft`, `borderSubtle`, `radius.sm`, `space["1"]`) rather than
113
+ // re-deriving new ones or leaving the old 73%-mix colour approximation.
107
114
  const sortedLabelChrome = `
108
115
  & .crisp-tabletoolbar-sorted-prefix { color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
109
- & .crisp-tabletoolbar-sorted-count { font-size: ${c.text.sm}; color: color-mix(in srgb, ${c.fgPrimary} 73%, transparent); }`;
116
+ & .crisp-tabletoolbar-sorted-count { display: inline-flex; align-items: center; height: 16px; box-sizing: border-box;
117
+ padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent);
118
+ box-shadow: inset 0 0 0 1px ${c.borderSubtle}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgNeutralSoft}; }`;
119
+ // crisp#875: a general count-badge slot for `ToolbarTool` (a screen's own
120
+ // quick filter, e.g. "My tasks •5") -- the SAME measured shape as
121
+ // `sorted-count` above, independently re-confirmed on a second live Attio
122
+ // surface this round (a Kanban board's own stage-column count, "Idea 0":
123
+ // identical `data-variant="tertiary" data-size="16"` chip, same bg/ring/
124
+ // radius/padding/colour, plus `font-variant-numeric: tabular-nums` for
125
+ // digit alignment past a single character). Same reasoning as
126
+ // search/menu-input's own duplicated ring formula elsewhere in this file:
127
+ // a distinct class, not a shared one, so `sorted-count`'s own meaning
128
+ // doesn't get diluted by an unrelated consumer.
129
+ const toolCountChrome = `
130
+ & .crisp-tabletoolbar-tool-count { display: inline-flex; align-items: center; height: 16px; box-sizing: border-box;
131
+ padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent);
132
+ box-shadow: inset 0 0 0 1px ${c.borderSubtle}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgNeutralSoft};
133
+ font-variant-numeric: tabular-nums; }`;
110
134
  // The table toolbar, measured: 28px controls, the Sort/Filter buttons and
111
135
  // the menus they open, sort-condition rows, and the filter chips. The chrome
112
136
  // itself lives in ./tabletoolbar.chrome.ts because RecordsTable renders the
@@ -126,6 +150,7 @@ ${toolbarChromeBody("tabletoolbar")}
126
150
  ${filterChipChrome}
127
151
  ${importExportChrome}
128
152
  ${sortedLabelChrome}
153
+ ${toolCountChrome}
129
154
  ${rtlFlipCss}
130
155
  `,
131
156
  });
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ChevronDown } from "lucide-react";
2
3
  import * as React from "react";
4
+ import { Menu, MenuItem } from "../menu/index.js";
3
5
  import { tabsRecipe } from "./tabs.recipe.js";
4
6
  import { useTablistKeyboard } from "./tabs-keyboard.js";
5
7
  /**
@@ -8,7 +10,7 @@ import { useTablistKeyboard } from "./tabs-keyboard.js";
8
10
  * Home/End jump); activation follows focus. Controlled via
9
11
  * `value`/`onValueChange`, or uncontrolled from `defaultValue`.
10
12
  */
11
- export function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, className, fill, "aria-label": ariaLabel, ...rest }) {
13
+ export function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, moreLabel = (n) => `+${n} more`, moreMenuLabel = "More tabs", className, fill, "aria-label": ariaLabel, ...rest }) {
12
14
  // Scoped to this instance: `tab-${value}` and `panel-${value}` are global,
13
15
  // so two tab bars sharing a value mint the same id twice and every
14
16
  // aria-labelledby resolves to the first one (#227).
@@ -41,16 +43,73 @@ export function Tabs({ tabs, value, defaultValue, onValueChange, leading, childr
41
43
  ro.observe(list);
42
44
  return () => ro.disconnect();
43
45
  }, [active, tabs]);
46
+ // crisp#863 -- responsive "+N more" overflow, measured live: Attio
47
+ // collapses the TRAILING tabs into a single trigger once the strip runs
48
+ // out of room, rather than letting the row wrap or overrun. Every tab
49
+ // (and the "+N more" trigger itself) stays permanently mounted -- an
50
+ // overflowed one gets `data-tabs-overflow-hidden` (position: absolute;
51
+ // visibility: hidden in the recipe), which removes it from the flex row
52
+ // WITHOUT ever making its `offsetWidth` unmeasurable the way `display:
53
+ // none` would. That's what lets this recompute purely from cached
54
+ // per-tab widths on every resize, with no flash: a naive
55
+ // measure-everything-visible-first pass would show the full unclipped
56
+ // row for one frame before collapsing it.
57
+ const [hiddenFrom, setHiddenFrom] = React.useState(null);
58
+ const moreRef = React.useRef(null);
59
+ React.useLayoutEffect(() => {
60
+ const list = listRef.current;
61
+ if (!list || tabs.length === 0)
62
+ return;
63
+ const recompute = () => {
64
+ const containerWidth = list.clientWidth;
65
+ // Not yet laid out (e.g. this render, or the test environment, which
66
+ // never computes real box metrics) -- never collapse everything on a
67
+ // 0-width read, that's a false "nothing fits" rather than "not
68
+ // measurable yet".
69
+ if (containerWidth <= 0)
70
+ return;
71
+ const els = tabs.map((t) => list.querySelector(`[data-value="${CSS.escape(t.value)}"]`));
72
+ if (els.some((el) => !el))
73
+ return;
74
+ const gap = 4; // ${c.space["1"]}, the list's own gap (recipe.ts)
75
+ const moreWidth = moreRef.current?.offsetWidth ?? 0;
76
+ let used = 0;
77
+ let cutoff = tabs.length;
78
+ for (let i = 0; i < els.length; i++) {
79
+ const w = (els[i]?.offsetWidth ?? 0) + (i > 0 ? gap : 0);
80
+ const reserve = i < tabs.length - 1 ? moreWidth + gap : 0;
81
+ if (used + w + reserve > containerWidth) {
82
+ cutoff = i;
83
+ break;
84
+ }
85
+ used += w;
86
+ }
87
+ // The active tab is never allowed to fall into the overflow menu --
88
+ // not itself measured live (Attio's own fixture never exercised
89
+ // navigating INTO an overflowed tab first), but "the tab you're on
90
+ // stays visible" is the safe, unsurprising default for any tab UI.
91
+ const activeIndex = tabs.findIndex((t) => t.value === active);
92
+ if (activeIndex >= 0 && activeIndex >= cutoff)
93
+ cutoff = activeIndex + 1;
94
+ setHiddenFrom(cutoff >= tabs.length ? null : cutoff);
95
+ };
96
+ recompute();
97
+ const ro = new ResizeObserver(recompute);
98
+ ro.observe(list);
99
+ return () => ro.disconnect();
100
+ }, [tabs, active]);
101
+ const hiddenTabs = hiddenFrom == null ? [] : tabs.slice(hiddenFrom);
44
102
  const { onKeyDown, tabIndexFor } = useTablistKeyboard({
45
103
  tabs,
46
104
  active,
47
105
  select,
48
106
  listRef,
49
107
  });
50
- return (_jsxs("div", { className: tabsRecipe({ fill, className }), ...rest, children: [_jsxs("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && _jsx("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t) => {
108
+ return (_jsxs("div", { className: tabsRecipe({ fill, className }), ...rest, children: [_jsxs("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && _jsx("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t, i) => {
51
109
  const selected = t.value === active;
52
- return (_jsxs("button", { type: "button", role: "tab", id: tabId(t.value), "aria-selected": selected, "aria-controls": children && selected ? panelId(t.value) : undefined, tabIndex: tabIndexFor(t.value), "data-value": t.value, "data-selected": selected ? "" : undefined, disabled: t.disabled, className: "crisp-tabs-tab", onClick: () => select(t.value), children: [t.icon, _jsx("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && (_jsx("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value));
53
- }), indicator && (_jsx("span", { className: "crisp-tabs-indicator", style: {
110
+ const overflowHidden = hiddenFrom != null && i >= hiddenFrom;
111
+ return (_jsxs("button", { type: "button", role: "tab", id: tabId(t.value), "aria-selected": selected, "aria-controls": children && selected ? panelId(t.value) : undefined, tabIndex: overflowHidden ? -1 : tabIndexFor(t.value), "data-value": t.value, "data-selected": selected ? "" : undefined, "data-tabs-overflow-hidden": overflowHidden ? "" : undefined, disabled: t.disabled, className: "crisp-tabs-tab", onClick: () => select(t.value), children: [t.icon, _jsx("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && (_jsx("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value));
112
+ }), _jsx("span", { ref: moreRef, className: "crisp-tabs-more", "data-tabs-overflow-hidden": hiddenTabs.length === 0 ? "" : undefined, children: _jsx(Menu, { "aria-label": moreMenuLabel, trigger: _jsxs("button", { type: "button", className: "crisp-tabs-tab", children: [_jsx("span", { className: "tabs-tab-label", children: moreLabel(hiddenTabs.length) }), _jsx(ChevronDown, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" })] }), children: hiddenTabs.map((t) => (_jsxs(MenuItem, { icon: t.icon, onSelect: () => select(t.value), children: [t.label, t.badge != null && (_jsx("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value))) }) }), indicator && (_jsx("span", { className: "crisp-tabs-indicator", style: {
54
113
  width: indicator.width,
55
114
  transform: `translateX(${indicator.left}px)`,
56
115
  } }))] }), children && (_jsx("div", { className: "crisp-tabs-panel", role: "tabpanel", id: panelId(active), "aria-labelledby": tabId(active),
@@ -54,6 +54,19 @@ const config = defineRecipe({
54
54
  & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 14px; height: 14px; padding: 0 ${c.space["1"]}; border-radius: 4px; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); box-shadow: inset 0 0 0 1px ${c.borderSubtle}; font-size: 10px; line-height: 14px; font-weight: ${c.weight.medium}; color: ${c.fgNeutralSoft}; }
55
55
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: ${c.fgPrimary}; }
56
56
  & .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: ${c.fgPrimary}; border-radius: 2px 2px 0 0; transition: transform .2s cubic-bezier(.4,0,.2,1), width .2s cubic-bezier(.4,0,.2,1); pointer-events: none; }
57
+ /* crisp#863: the "+N more" overflow trigger, measured live -- its
58
+ computed style is identical to a plain .crisp-tabs-tab (same pill,
59
+ same 6px icon/label gap the tab class already gives via
60
+ space["1.5"]), and the dropdown it opens is crisp's own Menu/
61
+ MenuItem chrome (already matches Attio's popover, nothing new to
62
+ style there). Only the wrapper needs a rule: an overflowed tab (or
63
+ the trigger itself, when nothing is hidden) is taken out of the
64
+ flex row and out of the a11y tree without ever making its own
65
+ offsetWidth unmeasurable the way display:none would -- that's what
66
+ lets the tabs.tsx effect recompute the cutoff from cached widths on
67
+ every resize with no flash of an unclipped row. */
68
+ & .crisp-tabs-more { display: inline-flex; }
69
+ & [data-tabs-overflow-hidden] { position: absolute; visibility: hidden; pointer-events: none; }
57
70
  & .crisp-tabs-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; padding-top: ${c.space["8"]}; }
58
71
  & .crisp-tabs-panel:focus-visible { outline: none; }
59
72
  `,