@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
@@ -126,8 +126,14 @@ const spec = defineSpec({
126
126
  {
127
127
  variant: "solid",
128
128
  tone: "brand",
129
+ // your-moon/crisp#897: `bgBrandContrast`, not `bgBrandSolid` — white
130
+ // text on the exact accent blue (#0298d9) only clears 3.23:1;
131
+ // `bgBrandContrast` is the darker sibling built to clear 4.5:1+ with
132
+ // white, so `fgOnBrand` (white) stays right rather than `fgOnTone`
133
+ // (ink read off-brand next to solid `success`/`warning`/`danger`,
134
+ // which really do need it).
129
135
  decls: {
130
- background: c.bgBrandSolid,
136
+ background: c.bgBrandContrast,
131
137
  color: c.fgOnBrand,
132
138
  boxShadow: "none",
133
139
  },
@@ -140,7 +146,15 @@ const spec = defineSpec({
140
146
  {
141
147
  variant: "solid",
142
148
  tone: "warning",
143
- decls: { background: c.bgWarning, color: c.fgOnTone, boxShadow: "none" },
149
+ // seed-identity migration (your-moon/crisp#890): `bgWarningSolid`, not
150
+ // `bgWarning` — seed's own solid warning fill is a distinct, paler
151
+ // yellow from the accent every other tone's solid fill reuses
152
+ // directly (see tokens/palette.ts's `amberSolid` comment).
153
+ decls: {
154
+ background: c.bgWarningSolid,
155
+ color: c.fgOnTone,
156
+ boxShadow: "none",
157
+ },
144
158
  },
145
159
  {
146
160
  variant: "solid",
@@ -27,7 +27,13 @@ const spec = defineSpec({
27
27
  intent: {
28
28
  primary: {
29
29
  decls: {
30
- background: c.bgBrandSolid,
30
+ // your-moon/crisp#897: `bgBrandContrast`, not `bgBrandSolid` —
31
+ // white text on the exact accent blue (#0298d9) only clears
32
+ // 3.23:1; `bgBrandContrast` is the darker sibling built to clear
33
+ // white at 4.5:1+, keeping `fgOnBrand` (white) rather than
34
+ // reaching for `fgOnTone` (ink read off-brand — seed's own solid
35
+ // buttons use white).
36
+ background: c.bgBrandContrast,
31
37
  color: c.fgOnBrand,
32
38
  // crisp#494: the inset ring is a black tint, not white -- live-
33
39
  // measured on Attio's own "New Company" button as
@@ -36,12 +42,16 @@ const spec = defineSpec({
36
42
  // exactly.
37
43
  boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(38,109,240,0.12), 0 3px 6px -2px rgba(38,109,240,0.08)",
38
44
  },
45
+ // your-moon/crisp#897: a brightness filter, not a second background
46
+ // swap — matches `danger`'s own pattern below. `bgBrandSolidPressed`
47
+ // remains the accent's own hover-darken for the many OTHER
48
+ // consumers that pair `bgBrandSolid` with white already (out of
49
+ // scope for this fix; tracked separately), so reusing it here would
50
+ // have made primary's rest-to-hover jump inconsistent with its own
51
+ // rest fill's new, darker starting point.
39
52
  parts: [
40
- { selector: "&:hover", decls: { background: c.bgBrandSolidPressed } },
41
- {
42
- selector: "&:active",
43
- decls: { background: c.bgBrandSolidPressed },
44
- },
53
+ { selector: "&:hover", decls: { filter: "brightness(0.96)" } },
54
+ { selector: "&:active", decls: { filter: "brightness(0.92)" } },
45
55
  ],
46
56
  },
47
57
  neutral: {
@@ -12,11 +12,11 @@ const DEFAULT_ICONS = {
12
12
  * An inline callout / banner — measured from info notices. `tone` tints
13
13
  * the background and icon; pass a `title` and/or body `children`.
14
14
  */
15
- export function Callout({ tone = "neutral", icon, title, children, action, className, ...rest }) {
15
+ export function Callout({ tone = "neutral", icon, title, children, action, bordered, className, ...rest }) {
16
16
  const Fallback = DEFAULT_ICONS[tone];
17
17
  const iconNode = icon === undefined
18
18
  ? Fallback && _jsx(Fallback, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" })
19
19
  : icon;
20
- return (_jsxs("div", { className: calloutRecipe({ className }), ...rest, "data-tone": tone, role: "note", children: [iconNode && _jsx("span", { className: "crisp-callout-icon", children: iconNode }), _jsxs("div", { className: "crisp-callout-body", children: [title != null && _jsx("div", { className: "crisp-callout-title", children: title }), children != null && (_jsx("div", { className: "crisp-callout-desc", children: children }))] }), action && _jsx("div", { className: "crisp-callout-action", children: action })] }));
20
+ return (_jsxs("div", { className: calloutRecipe({ bordered, className }), ...rest, "data-tone": tone, role: "note", children: [iconNode && _jsx("span", { className: "crisp-callout-icon", children: iconNode }), _jsxs("div", { className: "crisp-callout-body", children: [title != null && _jsx("div", { className: "crisp-callout-title", children: title }), children != null && (_jsx("div", { className: "crisp-callout-desc", children: children }))] }), action && _jsx("div", { className: "crisp-callout-action", children: action })] }));
21
21
  }
22
22
  Callout.displayName = "Callout";
@@ -18,8 +18,21 @@ const config = defineRecipe({
18
18
  &[data-tone="warning"] .crisp-callout-icon { color: ${c.bgWarning}; }
19
19
  &[data-tone="danger"] { background: color-mix(in srgb, ${c.bgDanger} 10%, transparent); }
20
20
  &[data-tone="danger"] .crisp-callout-icon { color: ${c.bgDanger}; }`,
21
- variants: {},
22
- defaultVariants: {},
21
+ variants: {
22
+ // crisp#869: a page-level banner look, measured live on Attio's Objects
23
+ // settings page (the "Create custom objects... Upgrade" Pro-plan
24
+ // nudge) — a real 1px opaque border, 12px radius, 8px/12px padding, and
25
+ // a 14%-brand-tinted background (the same `color-mix` ratio Badge's own
26
+ // soft-brand variant already uses). Only this one brand/info-toned
27
+ // instance was found live; `bordered` doesn't attempt a per-tone border
28
+ // colour for success/warning/danger — no such Attio banner was found to
29
+ // measure one against.
30
+ bordered: {
31
+ true: `border: 1px solid ${c.ringBrandSoft}; border-radius: ${c.radius.xl}; padding: ${c.space["2"]} ${c.space["3"]}; background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent);`,
32
+ false: "",
33
+ },
34
+ },
35
+ defaultVariants: { bordered: false },
23
36
  compoundVariants: [],
24
37
  });
25
38
  export const calloutRecipe = resolveRecipe(config);
@@ -1,11 +1,11 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { inputRecipe } from "./input.recipe.js";
4
- export const Input = React.forwardRef(({ size, invalid, className, style, ...props }, ref) => {
4
+ export const Input = React.forwardRef(({ size, invalid, className, style, suffix, ...props }, ref) => {
5
5
  // The wrapper exists only to host the ::after focus ring — an <input> is a
6
6
  // replaced element and cannot have one. Everything the consumer passes
7
7
  // still lands on the <input> itself, so `id`, `name`, `onChange`, form
8
8
  // association and every native attribute behave exactly as before.
9
- return (_jsx("span", { className: inputRecipe({ size, invalid, className }), style: style, children: _jsx("input", { ref: ref, className: "crisp-input-value", "aria-invalid": invalid === true || undefined, ...props }) }));
9
+ return (_jsxs("span", { className: inputRecipe({ size, invalid, className }), style: style, "data-readonly": props.readOnly ? "" : undefined, children: [_jsx("input", { ref: ref, className: "crisp-input-value", "aria-invalid": invalid === true || undefined, ...props }), suffix != null && _jsx("span", { className: "crisp-input-suffix", children: suffix })] }));
10
10
  });
11
11
  Input.displayName = "Input";
@@ -38,9 +38,23 @@ const config = defineRecipe({
38
38
  & .crisp-input-value::placeholder { color: ${c.fgMuted}; }
39
39
  & .crisp-input-value:disabled { opacity: 0.5; cursor: not-allowed; }
40
40
  &:has(.crisp-input-value:disabled) { opacity: 1; }
41
+
42
+ /* crisp#634: measured live on Attio's own Settings > Profile read-only
43
+ primary-email field -- a tinted field, not just muted text, driven off
44
+ the native \`readOnly\` attribute. \`ringField\` (rgba(0,0,0,.06)) is an
45
+ exact match for the measured ring; the 2%-fgPrimary tint reuses the
46
+ same color-mix idiom every other soft crisp surface already uses. */
47
+ &[data-readonly] { background: color-mix(in srgb, ${c.fgPrimary} 2%, transparent); box-shadow: inset 0 0 0 1px ${c.ringField}; }
48
+ & .crisp-input-suffix { flex-shrink: 0; margin-inline-start: ${c.space["1"]}; }
41
49
  `,
42
50
  variants: {
43
51
  size: {
52
+ // crisp#878: not a fresh click-measurement -- reuses `TableToolbar`'s
53
+ // own compact search box shape (`tabletoolbar.chrome.ts`'s `-search`
54
+ // rule), already corroborated against Attio, at the same height as
55
+ // `c.size.row` ("nav item · search · control height"). Same radius and
56
+ // padding `medium` already uses, just 4px shorter.
57
+ sm: `height: ${c.size.row}; border-radius: ${c.radius.md}; & .crisp-input-value { padding-inline: ${c.space["2.5"]}; }`,
44
58
  medium: `height: 32px; border-radius: ${c.radius.md}; & .crisp-input-value { padding-inline: ${c.space["2.5"]}; }`,
45
59
  large: `height: 36px; border-radius: 10px; & .crisp-input-value { padding-inline: ${c.space["3"]}; }`,
46
60
  },
@@ -128,13 +128,13 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
128
128
  : "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: _jsx(MenuContext.Provider, { value: { close }, children: children }) }) }))] }));
129
129
  }
130
130
  Menu.displayName = "Menu";
131
- export function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
131
+ export function MenuItem({ children, icon, shortcut, count, danger, disabled, onSelect, closeOnSelect = true, }) {
132
132
  const ctx = React.useContext(MenuContext);
133
133
  return (_jsxs("button", { type: "button", role: "menuitem", tabIndex: -1, disabled: disabled, "data-disabled": disabled ? "" : undefined, className: `crisp-menu-item${danger ? " crisp-menu-item--danger" : ""}`, onClick: () => {
134
134
  onSelect?.();
135
135
  if (closeOnSelect)
136
136
  ctx?.close();
137
- }, children: [icon, _jsx("span", { className: "crisp-menu-item-label", children: children }), shortcut && _jsx("span", { className: "crisp-menu-item-shortcut", children: shortcut })] }));
137
+ }, children: [icon, _jsx("span", { className: "crisp-menu-item-label", children: children }), count != null && _jsx("span", { className: "crisp-menu-item-count", children: count }), shortcut && _jsx("span", { className: "crisp-menu-item-shortcut", children: shortcut })] }));
138
138
  }
139
139
  MenuItem.displayName = "MenuItem";
140
140
  export function MenuCheckboxItem({ children, checked, onCheckedChange, disabled, }) {
@@ -54,6 +54,14 @@ const config = defineRecipe({
54
54
  .crisp-menu-item--danger svg { color: ${c.bgDanger}; }
55
55
  .crisp-menu-item--danger:hover, .crisp-menu-item--danger:focus-visible, .crisp-menu-item--danger[data-highlighted] { background: color-mix(in srgb, ${c.bgDanger} 10%, transparent); }
56
56
  .crisp-menu-item-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
57
+ /* crisp#875: same measured neutral chip TableToolbar's own ToolbarTool
58
+ count prop uses (re-confirmed on two live Attio surfaces this round:
59
+ the Sort tool's own "+N" overflow, a Kanban board's stage-column
60
+ count) -- a small badge, not bare text. */
61
+ .crisp-menu-item-count { flex-shrink: 0; margin-inline-start: ${c.space["1.5"]}; display: inline-flex; align-items: center; height: 16px; box-sizing: border-box;
62
+ padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent);
63
+ box-shadow: inset 0 0 0 1px ${c.borderSubtle}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgNeutralSoft};
64
+ font-variant-numeric: tabular-nums; }
57
65
  .crisp-menu-item-shortcut { flex-shrink: 0; margin-inline-start: ${c.space["3"]}; font-size: ${c.text.sm}; color: ${c.fgMuted}; letter-spacing: 0.02em; }
58
66
  .crisp-menu-item-check { flex-shrink: 0; color: ${c.bgBrandSolid}; }
59
67
  .crisp-menu-item-check--placeholder { width: 15px; }
@@ -24,16 +24,18 @@ function Chip({ opt }) {
24
24
  return (_jsx("span", { className: "crisp-recordstable-tag", "data-tone": opt.tone ?? toneFor(opt.value), children: opt.label }));
25
25
  }
26
26
  /**
27
- * A multi-select / tag editor — measured from a cell tag picker. The
28
- * outlined trigger shows the selected values as coloured chips; it opens a
29
- * popover with a search field over rows of "[chip] … [checkbox]" that toggle on
30
- * click (the menu stays open). Keyboard: ↑/↓ move, Enter toggles, Esc closes.
27
+ * A multi-select / tag editor, its trigger/listbox/item chrome built to the
28
+ * same seed-design parity as `Select` (crisp/#882, following #867/#879) —
29
+ * its own chip/checkbox picker behaviour is unchanged. The outlined trigger
30
+ * shows the selected values as coloured chips; it opens a popover with a
31
+ * search field over rows of "[chip] … [checkbox]" that toggle on click (the
32
+ * menu stays open). Keyboard: ↑/↓ move, Enter/Space toggle, Esc closes.
31
33
  */
32
34
  export function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, required,
33
35
  // The id belongs on the trigger, not the wrapper: `<label for>` only names a
34
36
  // LABELABLE element, so an id left on the root div meant a FormField label
35
37
  // above this control named nothing (#247). Select already did this.
36
- id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPlaceholderProp, emptyLabel: emptyLabelProp, disabled, invalid, className, "aria-label": ariaLabel, ...rest }) {
38
+ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPlaceholderProp, emptyLabel: emptyLabelProp, disabled, readOnly, invalid, size = "small", variant = "default", className, "aria-label": ariaLabel, ...rest }) {
37
39
  // crisp/#471: same resolution order as Select's own.
38
40
  const placeholder = useCrispMessage(placeholderProp, "Select…");
39
41
  const searchPlaceholder = useCrispMessage(searchPlaceholderProp, "Search…");
@@ -70,8 +72,12 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
70
72
  const filtered = searchable && q
71
73
  ? options.filter((o) => o.label.toLowerCase().includes(q))
72
74
  : options;
75
+ // seed-design's `interactive` (crisp/#882, matching Select): disabled AND
76
+ // readOnly both refuse to open, but only `disabled` greys the text and
77
+ // removes the button from the tab order.
78
+ const interactive = !disabled && !readOnly;
73
79
  const openMenu = () => {
74
- if (disabled)
80
+ if (!interactive)
75
81
  return;
76
82
  const rect = triggerRef.current?.getBoundingClientRect();
77
83
  if (rect) {
@@ -80,7 +86,7 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
80
86
  // direction that is (#210)
81
87
  const { style, up: flipUp } = placeFloating({
82
88
  rect,
83
- gap: 4,
89
+ gap: 8, // seed-design's gutter (crisp/#882, matching Select; was 4)
84
90
  flipBelow: 300,
85
91
  anchor: triggerRef.current,
86
92
  });
@@ -125,6 +131,10 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
125
131
  close,
126
132
  // Tab is already moving focus out; `close` would drag it back to the trigger
127
133
  dismiss: () => setOpen(false),
134
+ // Space also toggles the highlighted option (crisp/#882, matching
135
+ // Select's Enter/Space parity) when the trigger itself owns navigation.
136
+ // Never when searchable -- see Select's own comment on this option.
137
+ commitOnSpace: !searchable,
128
138
  });
129
139
  // Registers the (now-portaled) listbox on the shared floating-layer stack
130
140
  // (#339) so this doesn't get closed out from under itself when nested
@@ -162,11 +172,14 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
162
172
  document.addEventListener("keydown", onKey);
163
173
  return () => document.removeEventListener("keydown", onKey);
164
174
  }, [open, close]);
165
- return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ invalid, className }), ...rest, children: [name &&
166
- (value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
175
+ return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ size, variant, invalid, className }), ...rest, children: [name &&
176
+ (value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, "aria-readonly": readOnly ? "true" : undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
177
+ if (!interactive)
178
+ return;
167
179
  if (open && !searchable)
168
180
  onNavKeys(e);
169
- else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
181
+ else if (!open &&
182
+ (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ")) {
170
183
  e.preventDefault();
171
184
  openMenu();
172
185
  }
@@ -1,30 +1,26 @@
1
1
  import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
2
  import { contract as c, elevation as e } from "../../tokens/index.js";
3
- // A multi-select / tag editor — measured from a cell tag picker: an
4
- // outlined trigger showing the selected values as coloured chips, opening a
5
- // popover with a search field over rows of "[coloured chip] … [checkbox]" that
6
- // toggle on click. The popover portals to document.body (#339) so no
7
- // ancestor's transform/backdrop-filter can capture its position: fixed box;
8
- // z-index 70 keeps it above Dialog/Drawer's 60/61.
9
- // The listbox and everything inside it (including the recordstable-tag
10
- // chips, which render inside listbox option rows as well as the trigger)
11
- // live in `globalCss` below, not nested under `&` (#357): nesting compiles
12
- // to a real descendant combinator scoped to the recipe's root class, which
13
- // needs actual DOM containment — exactly what the portal to `document.body`
14
- // breaks. Nested, these rules matched nothing once the listbox moved and it
15
- // rendered completely unstyled. The trigger and its own parts stay nested
16
- // under `&` — the trigger is NOT portaled. `font-family`/smoothing are
17
- // restated on the listbox's own rule (its children pick font up via their
18
- // own `font-family: inherit`, which still works — that inheritance is
19
- // intact WITHIN the portaled subtree, only the link back to the
20
- // trigger-holding root broke). A flat recordstable-tag selector also works
21
- // unchanged wherever it's rendered, portaled or not, so it needs no split.
22
- // This is a third, hand-pasted copy of `recordTagCss`
23
- // (product/recordstable/recordstable.tag.ts) rather than a splice of it:
24
- // that helper is written `& .crisp-recordstable-tag { ... }` for nesting
25
- // under a recipe's `base`, which doesn't compile to anything inside a flat
26
- // `globalCss` block. Keep the two in sync by hand (#380 added the measured
27
- // `box-shadow: inset 0 0 0 1px <tone ring colour>` here to match).
3
+ // MultiSelect reads as the same family as Select (crisp/#882, following
4
+ // #867/#879): the trigger/listbox/item chrome below reuses Select's
5
+ // seed-design tokens verbatim (`strokeSelectTrigger`, `bgSelectPressed`,
6
+ // `bgSelectDisabled`, `strokeSelectInvalid`, `bgSelectListbox`,
7
+ // `selectFloating`, `fgSelectNeutral`, `fgSelectPlaceholder`, `radius["3xl"]`,
8
+ // `size.selectTriggerMd/Lg`) rather than resolving a second seed spec --
9
+ // seed-design has no multi-value combobox of its own (`packages/rootage/
10
+ // components/` has no `combobox`/`multiselect`; `chip.yaml`/`tag-group*.yaml`
11
+ // are a different, standalone pill pattern, not a field's multi-value
12
+ // trigger). What stays untouched: the `.crisp-recordstable-tag` chip
13
+ // styling (shared with RecordsTable/DataTable elsewhere), the checkbox-style
14
+ // selected indicator, and the toggle-without-closing interaction.
15
+ //
16
+ // The search row's geometry (36px height, 6px gap, no divider) is an
17
+ // Attio measurement (#481) `build/spec-check.mjs` guards directly -- it is
18
+ // NOT part of the seed-parity change and must not move. Only its text/icon
19
+ // colours follow the rest of this file onto the shared tokens.
20
+ //
21
+ // The listbox portals to `document.body` (#339), so its rules live in
22
+ // `globalCss`, not nested under `&` -- see Select's own recipe for the full
23
+ // reasoning (identical here).
28
24
  const config = defineRecipe({
29
25
  name: "multiselect",
30
26
  base: `
@@ -32,53 +28,63 @@ const config = defineRecipe({
32
28
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
33
29
 
34
30
  & .crisp-multiselect-trigger {
35
- box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["1"]}; width: 100%; min-width: 0; min-height: ${c.size.menuRow};
36
- margin: 0; padding: ${c.space["0.5"]} ${c.space["1.5"]} ${c.space["0.5"]} ${c.space["2"]};
37
- border: none; border-radius: ${c.radius.md}; background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 7%, transparent);
38
- font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; cursor: pointer; text-align: start;
39
- transition: background-color .12s ease, box-shadow .12s ease;
31
+ box-sizing: border-box; display: flex; align-items: center; width: 100%; min-width: 0; margin: 0;
32
+ border: 1px solid ${c.strokeSelectTrigger}; background: transparent; cursor: pointer; text-align: start;
33
+ font-family: inherit; color: ${c.fgSelectNeutral};
34
+ 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);
40
35
  }
41
- & .crisp-multiselect-trigger:hover { background: ${c.bgRaised}; }
42
- & .crisp-multiselect-trigger[data-open], & .crisp-multiselect-trigger:focus-visible { outline: none; box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; background: ${c.bgSurface}; }
43
- & .crisp-multiselect-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
36
+ /* Same pressed/disabled/readonly/focus model as Select's trigger -- see
37
+ that recipe's comment for why hover and an open/focus ring are gone. */
38
+ & .crisp-multiselect-trigger:active:not(:disabled):not([aria-readonly="true"]) { background: ${c.bgSelectPressed}; }
39
+ & .crisp-multiselect-trigger:focus-visible { outline: 2px solid ${c.strokeFocusRing}; outline-offset: -2px; }
40
+ & .crisp-multiselect-trigger:disabled { background: ${c.bgSelectDisabled}; cursor: not-allowed; }
41
+ & .crisp-multiselect-trigger:disabled .crisp-multiselect-placeholder,
42
+ & .crisp-multiselect-trigger:disabled .crisp-multiselect-chevron { color: ${c.fgDisabled}; }
43
+ & .crisp-multiselect-trigger[aria-readonly="true"] { background: ${c.bgSelectDisabled}; cursor: default; }
44
44
  & .crisp-multiselect-chips { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: ${c.space["1"]}; }
45
- & .crisp-multiselect-placeholder { color: ${c.fgMuted}; }
46
- & .crisp-multiselect-chevron { flex-shrink: 0; color: ${c.fgMuted}; transition: transform .15s ease; }
47
- & .crisp-multiselect-trigger[data-open] .crisp-multiselect-chevron { transform: rotate(180deg); }
45
+ & .crisp-multiselect-placeholder { color: ${c.fgSelectPlaceholder}; }
46
+ /* closeRotateDuration/openRotateDuration, same asymmetric timing as
47
+ Select's own chevron (see that recipe's comment). */
48
+ & .crisp-multiselect-chevron { flex-shrink: 0; color: ${c.fgNeutralMuted}; transition: transform .1s cubic-bezier(.35,0,.35,1); }
49
+ & .crisp-multiselect-trigger[data-open] .crisp-multiselect-chevron { transform: rotate(180deg); transition-duration: .15s; }
48
50
  `,
49
51
  globalCss: `
50
- /* pointer-events: auto (#375): a modal Dialog sets pointer-events: none
51
- on document.body while open (Zag re-enables it only on layers IT
52
- tracks), and the portaled listbox is outside Zag's knowledge -- it
53
- inherited the lock, so every option was unclickable, hit-testing
54
- right through to whatever real element sat behind it. */
52
+ /* pointer-events: auto (#375) -- see Select's matching comment. */
55
53
  .crisp-multiselect-listbox {
56
54
  font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
57
- position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
58
- max-height: 300px; overflow-y: auto; padding: ${c.space["1"]};
59
- border-radius: ${c.radius.xl}; background: ${c.bgPopover};
60
- box-shadow: ${e.floating};
61
- transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
55
+ position: fixed; z-index: 70; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space["2"]};
56
+ max-height: 480px; overflow-y: auto; padding: ${c.space["2"]};
57
+ border-radius: ${c.radius["3xl"]}; background: ${c.bgSelectListbox};
58
+ box-shadow: ${e.selectFloating};
59
+ transform-origin: top left; opacity: 1; transform: none;
60
+ transition: opacity .15s cubic-bezier(0,0,.15,1), transform .15s cubic-bezier(0,0,.15,1);
62
61
  }
63
62
  .crisp-multiselect-listbox[data-up] { transform-origin: bottom left; }
64
- .crisp-multiselect-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
65
- .crisp-multiselect-search { display: flex; align-items: center; gap: ${c.space["1.5"]}; height: 36px; flex-shrink: 0; margin: calc(-1 * ${c.space["1"]}) calc(-1 * ${c.space["1"]}) ${c.space["1"]}; padding: ${c.space["2"]} ${c.space["3"]}; color: ${c.fgMuted}; }
63
+ .crisp-multiselect-listbox { @starting-style { opacity: 0; transform: scale(0.95); } }
64
+ /* LOCKED geometry (#481, guarded by build/spec-check.mjs): height 36px,
65
+ gap space["1.5"] (6px), no border-bottom -- an Attio measurement, not
66
+ part of this seed-parity pass. Only colours below follow Select. */
67
+ .crisp-multiselect-search { display: flex; align-items: center; gap: ${c.space["1.5"]}; height: 36px; flex-shrink: 0; margin: calc(-1 * ${c.space["2"]}) calc(-1 * ${c.space["2"]}) ${c.space["2"]}; padding: ${c.space["2"]} ${c.space["3"]}; color: ${c.fgNeutralMuted}; }
66
68
  .crisp-multiselect-search svg { flex-shrink: 0; }
67
- .crisp-multiselect-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; }
68
- .crisp-multiselect-search input::placeholder { color: ${c.fgMuted}; }
69
- /* #343: same measured values as Menu's own group heading
70
- (crisp-menu-label) -- reused rather than re-measured, since it's the
71
- same "small muted heading above a run of rows" treatment. */
72
- .crisp-multiselect-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}; }
69
+ .crisp-multiselect-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgSelectNeutral}; }
70
+ .crisp-multiselect-search input::placeholder { color: ${c.fgSelectPlaceholder}; }
71
+ /* Same groupLabel treatment as Select's (13px/regular/neutral-subtle). */
72
+ .crisp-multiselect-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}; }
73
73
  .crisp-multiselect-option {
74
- box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.menuRow}; flex-shrink: 0;
75
- padding: 0 ${c.space["2"]}; margin: 0; border: none; background: none; border-radius: ${c.radius.md}; font-family: inherit; cursor: pointer; text-align: start;
74
+ box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0;
75
+ padding: ${c.space["2.5"]} ${c.space["4"]}; margin: 0; border: none; background: none; border-radius: ${c.radius.xl};
76
+ font-family: inherit; text-align: start; cursor: pointer;
77
+ transition: background-color .15s cubic-bezier(.35,0,.35,1);
76
78
  }
79
+ /* Keyboard/hover highlight keeps crisp's own visible neutral fill, same
80
+ reasoning as Select's matching rule (seed's own pressed tint is too
81
+ faint for a keyboard-navigation indicator). */
77
82
  .crisp-multiselect-option[data-highlighted] { background: ${c.bgRaised}; }
83
+ .crisp-multiselect-option:active { background: ${c.bgSelectPressed}; }
78
84
  .crisp-multiselect-option-chip { flex: 0 1 auto; min-width: 0; overflow: hidden; }
79
85
  .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: ${c.radius.sm}; box-shadow: inset 0 0 0 1.5px ${c.borderDefault}; color: transparent; }
80
86
  .crisp-multiselect-option[aria-selected="true"] .crisp-multiselect-check { background: ${c.bgBrandSolid}; box-shadow: none; color: #fff; }
81
- .crisp-multiselect-empty { padding: ${c.space["3"]} ${c.space["2"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgMuted}; }
87
+ .crisp-multiselect-empty { padding: ${c.space["3"]} ${c.space["4"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgNeutralSubtle}; }
82
88
 
83
89
  .crisp-recordstable-tag { display: inline-flex; align-items: center; height: ${c.size.chip}; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.chip}; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: color-mix(in srgb, ${c.fgPrimary} 82%, transparent); box-shadow: inset 0 0 0 1px ${c.borderSubtle}; }
84
90
  .crisp-recordstable-tag[data-tone="brand"] { background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent); color: color-mix(in srgb, ${c.bgBrandSolid} 82%, ${c.fgPrimary}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgBrandSolid} 30%, transparent); }
@@ -90,18 +96,51 @@ const config = defineRecipe({
90
96
  .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, ${c.bgOrange} 16%, transparent); color: color-mix(in srgb, ${c.bgOrange} 82%, ${c.fgPrimary}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgOrange} 30%, transparent); }
91
97
  `,
92
98
  variants: {
93
- // Same ring Select's `invalid` draws — see that recipe's comment.
94
- invalid: {
95
- true: `
96
- & .crisp-multiselect-trigger,
97
- & .crisp-multiselect-trigger:hover,
98
- & .crisp-multiselect-trigger[data-open],
99
- & .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${c.bgDanger}; }
99
+ // Owner's follow-up after #867/#879/#882 (crisp/#889): SIZE is Attio's
100
+ // again, LOOK stays seed's -- see Select's own recipe comment for the
101
+ // full reasoning (measured live in GRC's "New employee" dialog: a 40px
102
+ // trigger next to a 32px `Input` visibly mismatched). `small` is the new
103
+ // default: `c.size.menuRow` (32px) and the original, pre-#882
104
+ // Attio-measured asymmetric padding for a chip-wrapping trigger.
105
+ // `medium`/`large` (seed's own 40/52px) stay, opt-in.
106
+ size: {
107
+ small: `
108
+ & .crisp-multiselect-trigger { min-height: ${c.size.menuRow}; border-radius: ${c.radius.md}; gap: ${c.space["1"]}; padding: ${c.space["0.5"]} ${c.space["1.5"]} ${c.space["0.5"]} ${c.space["2"]}; }
109
+ & .crisp-multiselect-chevron { width: ${c.size.icon}; height: ${c.size.icon}; }
110
+ `,
111
+ medium: `
112
+ & .crisp-multiselect-trigger { min-height: ${c.size.selectTriggerMd}; border-radius: ${c.radius.md}; gap: ${c.space["2"]}; padding: ${c.space["1"]} ${c.space["3.5"]}; }
113
+ & .crisp-multiselect-chevron { width: ${c.size.icon}; height: ${c.size.icon}; }
100
114
  `,
115
+ large: `
116
+ & .crisp-multiselect-trigger { min-height: ${c.size.selectTriggerLg}; border-radius: ${c.radius.xl}; gap: ${c.space["2.5"]}; padding: ${c.space["1.5"]} ${c.space["4"]}; }
117
+ & .crisp-multiselect-chevron { width: ${c.size.selectIconLg}; height: ${c.size.selectIconLg}; }
118
+ `,
119
+ },
120
+ // Real border width+colour change, same as Select's -- see that
121
+ // recipe's comment.
122
+ invalid: {
123
+ true: `& .crisp-multiselect-trigger { border-width: 2px; border-color: ${c.strokeSelectInvalid}; }`,
101
124
  false: ``,
102
125
  },
126
+ // ghost: fills whatever row it's embedded in instead of seed's fixed
127
+ // trigger height -- RecordsTable's TagsCell embeds a MultiSelect inline
128
+ // in a 36px table row, the exact regression #879 caught for Select's
129
+ // own datatable consumers. Not a seed state; crisp's own escape hatch.
130
+ //
131
+ // Declared LAST on purpose, same reasoning as Select's recipe: same-
132
+ // specificity CSS rules resolve by source order, and ghost's
133
+ // `min-height: 0; height: 100%` below must come after `size`'s fixed
134
+ // min-height to win the cascade.
135
+ variant: {
136
+ default: "",
137
+ ghost: `
138
+ & .crisp-multiselect-trigger { min-height: 0; height: 100%; border-color: transparent; }
139
+ & .crisp-multiselect-trigger:hover:not(:disabled) { background: ${c.bgSelectPressed}; }
140
+ `,
141
+ },
103
142
  },
104
- defaultVariants: { invalid: false },
143
+ defaultVariants: { size: "small", invalid: false, variant: "default" },
105
144
  compoundVariants: [],
106
145
  });
107
146
  export const multiselectRecipe = Object.assign(resolveRecipe(config), {
@@ -1,11 +1,12 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { progressRecipe } from "./progress.recipe.js";
3
3
  /**
4
- * A progress bar — a continuous brand fill, or a segmented row of pills when
5
- * `segments` is set (the "Getting started 3/6" pattern). Reports its state via
6
- * `role="progressbar"` and the ARIA value attributes.
4
+ * A progress bar — a continuous brand fill, a segmented row of pills when
5
+ * `segments` is set (the "Getting started 3/6" pattern), or a small ring
6
+ * (`variant="circular"`, a character-counter's own progress). Reports its
7
+ * state via `role="progressbar"` and the ARIA value attributes.
7
8
  */
8
- export function Progress({ value, max = 100, segments, "aria-label": ariaLabel, className, ...rest }) {
9
+ export function Progress({ value, max = 100, segments, variant = "linear", size = 18, "aria-label": ariaLabel, className, ...rest }) {
9
10
  const pct = Math.max(0, Math.min(100, (value / max) * 100));
10
11
  const aria = {
11
12
  role: "progressbar",
@@ -14,6 +15,22 @@ export function Progress({ value, max = 100, segments, "aria-label": ariaLabel,
14
15
  "aria-valuemax": max,
15
16
  "aria-label": ariaLabel,
16
17
  };
18
+ if (variant === "circular") {
19
+ // Measured live: an 18x18 SVG, r=7.4, stroke-width 1.6 (a 0.6px margin on
20
+ // each side) — a two-circle ring (a soft-tinted track + a brand arc),
21
+ // the same track tint Badge/Callout's own `soft(bgBrandSolid, 14%)`
22
+ // formula already gives.
23
+ const strokeWidth = size * (1.6 / 18);
24
+ const r = size / 2 - strokeWidth;
25
+ const circumference = 2 * Math.PI * r;
26
+ const offset = circumference * (1 - pct / 100);
27
+ // The measured sample was at value=0 (a full, invisible offset either
28
+ // way), so the arc's start point couldn't be read off that one DOM
29
+ // snapshot -- 12 o'clock is the conventional start for this shape and
30
+ // isn't itself in tension with anything measured, but isn't independently
31
+ // confirmed at a nonzero value either.
32
+ return (_jsx("div", { ...aria, className: progressRecipe({ className }), ...rest, "data-variant": "circular", children: _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, "aria-hidden": "true", children: [_jsx("circle", { className: "crisp-progress-circle-track", r: r, cx: size / 2, cy: size / 2, strokeWidth: strokeWidth, fill: "transparent" }), _jsx("circle", { className: "crisp-progress-circle-fill", r: r, cx: size / 2, cy: size / 2, strokeWidth: strokeWidth, fill: "transparent", strokeDasharray: circumference, strokeDashoffset: offset, strokeLinecap: "round", transform: `rotate(-90 ${size / 2} ${size / 2})` })] }) }));
33
+ }
17
34
  if (segments && segments > 0) {
18
35
  const filled = Math.round((pct / 100) * segments);
19
36
  return (_jsx("div", { ...aria, className: progressRecipe({ className }), ...rest, "data-segmented": "", children: Array.from({ length: segments }, (_, i) => (_jsx("span", { className: "crisp-progress-seg", "data-filled": i < filled ? "" : undefined }, i))) }));
@@ -49,6 +49,36 @@ const spec = defineSpec({
49
49
  selector: "&[data-segmented] .crisp-progress-seg[data-filled]",
50
50
  decls: { background: c.bgBrandSolid },
51
51
  },
52
+ // crisp#634: measured live on Attio's own Settings > Profile
53
+ // character-counter ring — the track tint is the same
54
+ // `soft(bgBrandSolid, 14%)` formula Badge/Callout already use (measured
55
+ // rgb(229,238,255), within the same noise this codebase already treats
56
+ // as equivalent), the arc is the plain brand fill.
57
+ {
58
+ selector: '&[data-variant="circular"]',
59
+ decls: {
60
+ display: "inline-flex",
61
+ height: "auto",
62
+ borderRadius: "0",
63
+ background: "transparent",
64
+ overflow: "visible",
65
+ },
66
+ },
67
+ {
68
+ slot: "circleTrack",
69
+ selector: "& .crisp-progress-circle-track",
70
+ decls: {
71
+ stroke: `color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent)`,
72
+ },
73
+ },
74
+ {
75
+ slot: "circleFill",
76
+ selector: "& .crisp-progress-circle-fill",
77
+ decls: {
78
+ stroke: c.bgBrandSolid,
79
+ transition: "stroke-dashoffset 0.25s ease",
80
+ },
81
+ },
52
82
  ],
53
83
  });
54
84
  const config = specToRecipe(spec);
@@ -11,7 +11,7 @@
11
11
  * NOT for `WheelPicker`: that is an always-visible multi-column wheel that
12
12
  * commits on move, so Enter, Escape and Tab have no meaning in it.
13
13
  */
14
- export function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, commit, close, dismiss = close, }) {
14
+ export function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, commit, close, dismiss = close, commitOnSpace = false, }) {
15
15
  const move = (dir) => {
16
16
  if (enabledIndexes.length === 0)
17
17
  return;
@@ -50,6 +50,12 @@ export function useListboxKeyboard({ enabledIndexes, highlight, setHighlight, co
50
50
  e.preventDefault();
51
51
  commit();
52
52
  break;
53
+ case " ":
54
+ if (!commitOnSpace)
55
+ break;
56
+ e.preventDefault();
57
+ commit();
58
+ break;
53
59
  case "Escape":
54
60
  e.preventDefault();
55
61
  close();