@crisp-ui-kit/crisp 0.57.0 → 0.57.2

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 (293) 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/authshell.css +9 -4
  5. package/dist/authshell.layered.css +9 -4
  6. package/dist/avatar.css +1 -1
  7. package/dist/avatar.layered.css +1 -1
  8. package/dist/badge.css +5 -5
  9. package/dist/badge.layered.css +5 -5
  10. package/dist/board.css +28 -8
  11. package/dist/board.layered.css +28 -8
  12. package/dist/bottomsheet.css +26 -0
  13. package/dist/bottomsheet.d.ts +7 -0
  14. package/dist/bottomsheet.layered.css +28 -0
  15. package/dist/button.css +9 -8
  16. package/dist/button.layered.css +9 -8
  17. package/dist/checkbox.css +7 -3
  18. package/dist/checkbox.layered.css +7 -3
  19. package/dist/chip.css +15 -0
  20. package/dist/chip.d.ts +3 -1
  21. package/dist/chip.layered.css +15 -0
  22. package/dist/chipgroup.css +5 -0
  23. package/dist/chipgroup.d.ts +7 -0
  24. package/dist/chipgroup.layered.css +7 -0
  25. package/dist/cjs/components/accentpicker/accentpicker.js +66 -0
  26. package/dist/cjs/components/accentpicker/accentpicker.recipe.js +25 -0
  27. package/dist/cjs/components/accentpicker/index.js +7 -0
  28. package/dist/cjs/components/avatar/avatar.recipe.js +4 -1
  29. package/dist/cjs/components/badge/badge.recipe.js +38 -10
  30. package/dist/cjs/components/bottomsheet/bottomsheet.js +90 -0
  31. package/dist/cjs/components/bottomsheet/bottomsheet.recipe.js +77 -0
  32. package/dist/cjs/components/bottomsheet/index.js +9 -0
  33. package/dist/cjs/components/button/button.recipe.js +55 -21
  34. package/dist/cjs/components/checkbox/checkbox.recipe.js +61 -9
  35. package/dist/cjs/components/chip/chip.js +14 -2
  36. package/dist/cjs/components/chip/chip.recipe.js +69 -1
  37. package/dist/cjs/components/chipgroup/chipgroup.js +51 -0
  38. package/dist/cjs/components/chipgroup/chipgroup.recipe.js +36 -0
  39. package/dist/cjs/components/chipgroup/index.js +7 -0
  40. package/dist/cjs/components/datatable/datatable.js +5 -1
  41. package/dist/cjs/components/datatable/datatable.recipe.js +11 -2
  42. package/dist/cjs/components/dialog/dialog.recipe.js +21 -10
  43. package/dist/cjs/components/emptystate/emptystate.recipe.js +7 -2
  44. package/dist/cjs/components/fileupload/fileupload.js +21 -4
  45. package/dist/cjs/components/input/index.js +2 -1
  46. package/dist/cjs/components/input/input.js +33 -3
  47. package/dist/cjs/components/input/input.recipe.js +22 -7
  48. package/dist/cjs/components/menu/menu.recipe.js +21 -7
  49. package/dist/cjs/components/menusheet/index.js +9 -0
  50. package/dist/cjs/components/menusheet/menusheet.js +133 -0
  51. package/dist/cjs/components/menusheet/menusheet.recipe.js +103 -0
  52. package/dist/cjs/components/multiselect/multiselect.js +2 -2
  53. package/dist/cjs/components/multiselect/multiselect.recipe.js +45 -12
  54. package/dist/cjs/components/progress/progress.js +21 -4
  55. package/dist/cjs/components/progress/progress.recipe.js +30 -0
  56. package/dist/cjs/components/select/select.js +3 -3
  57. package/dist/cjs/components/select/select.recipe.js +63 -10
  58. package/dist/cjs/components/snackbar/index.js +8 -0
  59. package/dist/cjs/components/snackbar/snackbar.js +121 -0
  60. package/dist/cjs/components/snackbar/snackbar.recipe.js +71 -0
  61. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +7 -2
  62. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +37 -12
  63. package/dist/cjs/components/tabs/tabs.js +10 -4
  64. package/dist/cjs/components/tabs/tabs.recipe.js +23 -6
  65. package/dist/cjs/components/text/text.recipe.js +8 -7
  66. package/dist/cjs/components/textarea/textarea.js +3 -2
  67. package/dist/cjs/components/textarea/textarea.recipe.js +13 -9
  68. package/dist/cjs/components/textlink/textlink.recipe.js +6 -3
  69. package/dist/cjs/index.js +8 -0
  70. package/dist/cjs/product/authshell/authshell.recipe.js +5 -1
  71. package/dist/cjs/product/command/command.recipe.js +4 -1
  72. package/dist/cjs/product/comments/comments.js +7 -2
  73. package/dist/cjs/product/comments/comments.recipe.js +25 -7
  74. package/dist/cjs/product/memberssettings/index.js +6 -0
  75. package/dist/cjs/product/memberssettings/memberssettings.js +99 -0
  76. package/dist/cjs/product/memberssettings/memberssettings.recipe.js +83 -0
  77. package/dist/cjs/product/notificationssettings/index.js +5 -0
  78. package/dist/cjs/product/notificationssettings/notificationssettings.js +19 -0
  79. package/dist/cjs/product/notificationssettings/notificationssettings.recipe.js +44 -0
  80. package/dist/cjs/product/recordpage/recordpage.js +101 -9
  81. package/dist/cjs/product/recordpage/recordpage.recipe.js +43 -6
  82. package/dist/cjs/product/workspacegeneralsettings/index.js +5 -0
  83. package/dist/cjs/product/workspacegeneralsettings/workspacegeneralsettings.js +21 -0
  84. package/dist/cjs/product/workspacegeneralsettings/workspacegeneralsettings.recipe.js +51 -0
  85. package/dist/cjs/tokens/contract.js +1 -0
  86. package/dist/cjs/tokens/elevation.js +8 -5
  87. package/dist/cjs/tokens/palette.js +107 -33
  88. package/dist/cjs/tokens/scale.js +45 -2
  89. package/dist/cjs/tokens/semantic.js +26 -0
  90. package/dist/columncalc.css +15 -7
  91. package/dist/columncalc.layered.css +15 -7
  92. package/dist/command.css +23 -19
  93. package/dist/command.layered.css +23 -19
  94. package/dist/comments.css +28 -19
  95. package/dist/comments.d.ts +2 -2
  96. package/dist/comments.layered.css +28 -19
  97. package/dist/datatable.css +161 -67
  98. package/dist/datatable.layered.css +161 -67
  99. package/dist/datepicker.css +13 -41
  100. package/dist/datepicker.layered.css +13 -41
  101. package/dist/datepickerfield.css +4 -1
  102. package/dist/datepickerfield.layered.css +4 -1
  103. package/dist/dialog.css +12 -7
  104. package/dist/dialog.layered.css +12 -7
  105. package/dist/emptystate.css +5 -3
  106. package/dist/emptystate.layered.css +5 -3
  107. package/dist/esm/components/accentpicker/accentpicker.js +30 -0
  108. package/dist/esm/components/accentpicker/accentpicker.recipe.js +22 -0
  109. package/dist/esm/components/accentpicker/index.js +2 -0
  110. package/dist/esm/components/avatar/avatar.recipe.js +4 -1
  111. package/dist/esm/components/badge/badge.recipe.js +38 -10
  112. package/dist/esm/components/bottomsheet/bottomsheet.js +52 -0
  113. package/dist/esm/components/bottomsheet/bottomsheet.recipe.js +74 -0
  114. package/dist/esm/components/bottomsheet/index.js +2 -0
  115. package/dist/esm/components/button/button.recipe.js +56 -22
  116. package/dist/esm/components/checkbox/checkbox.recipe.js +61 -9
  117. package/dist/esm/components/chip/chip.js +14 -2
  118. package/dist/esm/components/chip/chip.recipe.js +69 -1
  119. package/dist/esm/components/chipgroup/chipgroup.js +15 -0
  120. package/dist/esm/components/chipgroup/chipgroup.recipe.js +33 -0
  121. package/dist/esm/components/chipgroup/index.js +2 -0
  122. package/dist/esm/components/datatable/datatable.js +5 -1
  123. package/dist/esm/components/datatable/datatable.recipe.js +11 -2
  124. package/dist/esm/components/dialog/dialog.recipe.js +22 -11
  125. package/dist/esm/components/emptystate/emptystate.recipe.js +7 -2
  126. package/dist/esm/components/fileupload/fileupload.js +20 -5
  127. package/dist/esm/components/input/index.js +1 -1
  128. package/dist/esm/components/input/input.js +33 -3
  129. package/dist/esm/components/input/input.recipe.js +23 -8
  130. package/dist/esm/components/menu/menu.recipe.js +21 -7
  131. package/dist/esm/components/menusheet/index.js +2 -0
  132. package/dist/esm/components/menusheet/menusheet.js +95 -0
  133. package/dist/esm/components/menusheet/menusheet.recipe.js +100 -0
  134. package/dist/esm/components/multiselect/multiselect.js +2 -2
  135. package/dist/esm/components/multiselect/multiselect.recipe.js +45 -12
  136. package/dist/esm/components/progress/progress.js +22 -5
  137. package/dist/esm/components/progress/progress.recipe.js +30 -0
  138. package/dist/esm/components/select/select.js +3 -3
  139. package/dist/esm/components/select/select.recipe.js +63 -10
  140. package/dist/esm/components/snackbar/index.js +2 -0
  141. package/dist/esm/components/snackbar/snackbar.js +84 -0
  142. package/dist/esm/components/snackbar/snackbar.recipe.js +68 -0
  143. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +7 -2
  144. package/dist/esm/components/tabletoolbar/tabletoolbar.js +37 -12
  145. package/dist/esm/components/tabs/tabs.js +10 -4
  146. package/dist/esm/components/tabs/tabs.recipe.js +23 -6
  147. package/dist/esm/components/text/text.recipe.js +8 -7
  148. package/dist/esm/components/textarea/textarea.js +3 -2
  149. package/dist/esm/components/textarea/textarea.recipe.js +14 -10
  150. package/dist/esm/components/textlink/textlink.recipe.js +6 -3
  151. package/dist/esm/index.js +8 -0
  152. package/dist/esm/product/authshell/authshell.recipe.js +5 -1
  153. package/dist/esm/product/command/command.recipe.js +4 -1
  154. package/dist/esm/product/comments/comments.js +7 -2
  155. package/dist/esm/product/comments/comments.recipe.js +25 -7
  156. package/dist/esm/product/memberssettings/index.js +1 -0
  157. package/dist/esm/product/memberssettings/memberssettings.js +62 -0
  158. package/dist/esm/product/memberssettings/memberssettings.recipe.js +80 -0
  159. package/dist/esm/product/notificationssettings/index.js +1 -0
  160. package/dist/esm/product/notificationssettings/notificationssettings.js +16 -0
  161. package/dist/esm/product/notificationssettings/notificationssettings.recipe.js +41 -0
  162. package/dist/esm/product/recordpage/recordpage.js +102 -10
  163. package/dist/esm/product/recordpage/recordpage.recipe.js +43 -6
  164. package/dist/esm/product/workspacegeneralsettings/index.js +1 -0
  165. package/dist/esm/product/workspacegeneralsettings/workspacegeneralsettings.js +18 -0
  166. package/dist/esm/product/workspacegeneralsettings/workspacegeneralsettings.recipe.js +48 -0
  167. package/dist/esm/tokens/contract.js +2 -1
  168. package/dist/esm/tokens/elevation.js +8 -5
  169. package/dist/esm/tokens/palette.js +107 -33
  170. package/dist/esm/tokens/scale.js +44 -1
  171. package/dist/esm/tokens/semantic.js +26 -0
  172. package/dist/fileupload.css +3 -1
  173. package/dist/fileupload.layered.css +3 -1
  174. package/dist/input.css +11 -2
  175. package/dist/input.layered.css +11 -2
  176. package/dist/listpage.css +3 -1
  177. package/dist/listpage.layered.css +3 -1
  178. package/dist/memberssettings.css +67 -0
  179. package/dist/memberssettings.d.ts +7 -0
  180. package/dist/memberssettings.layered.css +69 -0
  181. package/dist/menu.css +2 -2
  182. package/dist/menu.layered.css +2 -2
  183. package/dist/menusheet.css +36 -0
  184. package/dist/menusheet.d.ts +7 -0
  185. package/dist/menusheet.layered.css +38 -0
  186. package/dist/multiselect.css +17 -6
  187. package/dist/multiselect.d.ts +2 -2
  188. package/dist/multiselect.layered.css +17 -6
  189. package/dist/native/tokens.d.ts +2 -1
  190. package/dist/native/tokens.js +53 -25
  191. package/dist/notificationssettings.css +22 -0
  192. package/dist/notificationssettings.d.ts +7 -0
  193. package/dist/notificationssettings.layered.css +24 -0
  194. package/dist/objectsettings.css +4 -4
  195. package/dist/objectsettings.layered.css +4 -4
  196. package/dist/progress.css +3 -0
  197. package/dist/progress.layered.css +3 -0
  198. package/dist/recordpage.css +67 -23
  199. package/dist/recordpage.layered.css +67 -23
  200. package/dist/recordpanel.css +35 -16
  201. package/dist/recordpanel.layered.css +35 -16
  202. package/dist/resizablepanel.css +8 -5
  203. package/dist/resizablepanel.layered.css +8 -5
  204. package/dist/richtext.css +24 -8
  205. package/dist/richtext.layered.css +24 -8
  206. package/dist/savedviews.css +9 -2
  207. package/dist/savedviews.layered.css +9 -2
  208. package/dist/select.css +47 -7
  209. package/dist/select.d.ts +2 -2
  210. package/dist/select.layered.css +47 -7
  211. package/dist/settingsshell.css +2 -3
  212. package/dist/settingsshell.layered.css +2 -3
  213. package/dist/slider.css +6 -27
  214. package/dist/slider.layered.css +6 -27
  215. package/dist/snackbar.css +24 -0
  216. package/dist/snackbar.d.ts +7 -0
  217. package/dist/snackbar.layered.css +26 -0
  218. package/dist/statcard.css +3 -1
  219. package/dist/statcard.layered.css +3 -1
  220. package/dist/styles.css +927 -365
  221. package/dist/styles.layered.css +927 -365
  222. package/dist/table.css +28 -10
  223. package/dist/table.layered.css +28 -10
  224. package/dist/tabletoolbar.css +35 -24
  225. package/dist/tabletoolbar.layered.css +35 -24
  226. package/dist/tabs.css +18 -4
  227. package/dist/tabs.layered.css +18 -4
  228. package/dist/text.css +1 -1
  229. package/dist/text.layered.css +1 -1
  230. package/dist/textarea.css +4 -7
  231. package/dist/textarea.layered.css +4 -7
  232. package/dist/textlink.css +2 -2
  233. package/dist/textlink.layered.css +2 -2
  234. package/dist/themepicker.css +2 -1
  235. package/dist/themepicker.layered.css +2 -1
  236. package/dist/tokens.css +31 -13
  237. package/dist/tokens.json +53 -25
  238. package/dist/types/components/accentpicker/accentpicker.d.ts +23 -0
  239. package/dist/types/components/accentpicker/accentpicker.recipe.d.ts +3 -0
  240. package/dist/types/components/accentpicker/index.d.ts +2 -0
  241. package/dist/types/components/bottomsheet/bottomsheet.d.ts +35 -0
  242. package/dist/types/components/bottomsheet/bottomsheet.recipe.d.ts +3 -0
  243. package/dist/types/components/bottomsheet/index.d.ts +2 -0
  244. package/dist/types/components/button/button.recipe.d.ts +13 -8
  245. package/dist/types/components/checkbox/checkbox.recipe.d.ts +28 -5
  246. package/dist/types/components/chip/chip.d.ts +22 -0
  247. package/dist/types/components/chip/chip.recipe.d.ts +48 -0
  248. package/dist/types/components/chipgroup/chipgroup.d.ts +14 -0
  249. package/dist/types/components/chipgroup/chipgroup.recipe.d.ts +5 -0
  250. package/dist/types/components/chipgroup/index.d.ts +2 -0
  251. package/dist/types/components/datatable/datatable.d.ts +19 -0
  252. package/dist/types/components/fileupload/fileupload.d.ts +26 -0
  253. package/dist/types/components/input/index.d.ts +1 -1
  254. package/dist/types/components/input/input.d.ts +84 -0
  255. package/dist/types/components/menusheet/index.d.ts +2 -0
  256. package/dist/types/components/menusheet/menusheet.d.ts +42 -0
  257. package/dist/types/components/menusheet/menusheet.recipe.d.ts +3 -0
  258. package/dist/types/components/multiselect/multiselect.d.ts +4 -3
  259. package/dist/types/components/multiselect/multiselect.recipe.d.ts +3 -0
  260. package/dist/types/components/progress/progress.d.ts +16 -4
  261. package/dist/types/components/progress/progress.recipe.d.ts +22 -0
  262. package/dist/types/components/select/select.d.ts +6 -5
  263. package/dist/types/components/select/select.recipe.d.ts +3 -0
  264. package/dist/types/components/snackbar/index.d.ts +2 -0
  265. package/dist/types/components/snackbar/snackbar.d.ts +36 -0
  266. package/dist/types/components/snackbar/snackbar.recipe.d.ts +5 -0
  267. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +28 -1
  268. package/dist/types/components/tabs/tabs.d.ts +11 -1
  269. package/dist/types/components/textarea/textarea.d.ts +16 -0
  270. package/dist/types/index.d.ts +8 -0
  271. package/dist/types/product/comments/comments.d.ts +9 -3
  272. package/dist/types/product/comments/comments.recipe.d.ts +12 -2
  273. package/dist/types/product/memberssettings/index.d.ts +1 -0
  274. package/dist/types/product/memberssettings/memberssettings.d.ts +85 -0
  275. package/dist/types/product/memberssettings/memberssettings.recipe.d.ts +3 -0
  276. package/dist/types/product/notificationssettings/index.d.ts +1 -0
  277. package/dist/types/product/notificationssettings/notificationssettings.d.ts +37 -0
  278. package/dist/types/product/notificationssettings/notificationssettings.recipe.d.ts +3 -0
  279. package/dist/types/product/recordpage/recordpage.d.ts +89 -2
  280. package/dist/types/product/workspacegeneralsettings/index.d.ts +1 -0
  281. package/dist/types/product/workspacegeneralsettings/workspacegeneralsettings.d.ts +48 -0
  282. package/dist/types/product/workspacegeneralsettings/workspacegeneralsettings.recipe.d.ts +3 -0
  283. package/dist/types/tokens/contract.d.ts +7 -1
  284. package/dist/types/tokens/elevation.d.ts +8 -5
  285. package/dist/types/tokens/palette.d.ts +28 -12
  286. package/dist/types/tokens/scale.d.ts +12 -1
  287. package/dist/types/tokens/semantic.d.ts +17 -0
  288. package/dist/wheelpicker.css +4 -2
  289. package/dist/wheelpicker.layered.css +4 -2
  290. package/dist/workspacegeneralsettings.css +30 -0
  291. package/dist/workspacegeneralsettings.d.ts +7 -0
  292. package/dist/workspacegeneralsettings.layered.css +32 -0
  293. package/package.json +41 -1
@@ -0,0 +1,95 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as dialog from "@zag-js/dialog";
3
+ import { normalizeProps, Portal, useMachine } from "@zag-js/react";
4
+ import * as React from "react";
5
+ import { hasOpenFloatingLayer, withDescribedBy } from "../../core/index.js";
6
+ import { MENU_ITEM_SELECTOR, useMenuKeyboard } from "../menu/menu-keyboard.js";
7
+ import { menusheetRecipe } from "./menusheet.recipe.js";
8
+ const MenuSheetContext = React.createContext(null);
9
+ let instanceCounter = 0;
10
+ function useMenuSheetInstanceId() {
11
+ const idRef = React.useRef(null);
12
+ if (idRef.current == null) {
13
+ instanceCounter += 1;
14
+ idRef.current = `crisp-menusheet-${instanceCounter}`;
15
+ }
16
+ return idRef.current;
17
+ }
18
+ /**
19
+ * A mobile menu sheet -- a focus-trapped bottom sheet (same Zag dialog
20
+ * machine as BottomSheet) whose body is a role=menu list, with the identical
21
+ * keyboard model Menu already uses: arrow keys move, Home/End jump,
22
+ * typeahead, Enter/Space select, Escape closes. Compose items with
23
+ * MenuSheetItem, and group a run of them in one rounded card with
24
+ * MenuSheetGroup.
25
+ */
26
+ export function MenuSheet({ open, onOpenChange, title, description, footer, "aria-label": ariaLabel, children, className, "aria-describedby": describedBy, ...rest }) {
27
+ const id = useMenuSheetInstanceId();
28
+ const listRef = React.useRef(null);
29
+ const service = useMachine(dialog.machine, {
30
+ id,
31
+ open,
32
+ onOpenChange(details) {
33
+ onOpenChange(details.open);
34
+ },
35
+ "aria-label": typeof title === "string" ? title : ariaLabel,
36
+ onEscapeKeyDown(event) {
37
+ if (hasOpenFloatingLayer())
38
+ event.preventDefault();
39
+ },
40
+ // Without this, Zag's own focus trap parks initial focus on the dialog
41
+ // surface itself, which wins the race against useMenuKeyboard's rAF-
42
+ // scheduled "focus the first item" effect below -- the two compete for
43
+ // the same job and Zag's own dialog-level default was landing last.
44
+ // Pointing it at the first menu item directly makes Zag do that job
45
+ // itself instead of fighting useMenuKeyboard for it.
46
+ initialFocusEl() {
47
+ return (listRef.current?.querySelector(MENU_ITEM_SELECTOR) ?? null);
48
+ },
49
+ });
50
+ const api = dialog.connect(service, normalizeProps);
51
+ const close = React.useCallback(() => onOpenChange(false), [onOpenChange]);
52
+ const { onKeyDown: menuKeyDown, items } = useMenuKeyboard({
53
+ open: api.open,
54
+ close,
55
+ contentRef: listRef,
56
+ });
57
+ // Roving tabindex: exactly one item is a real Tab stop at a time (#237 --
58
+ // "selected, or first when none" -- is right in every state but the one
59
+ // that matters, so it is enforced imperatively here rather than trusted to
60
+ // a per-render default). useMenuKeyboard moves FOCUS with arrow keys; this
61
+ // keeps the tabindex in sync with wherever that focus actually lands,
62
+ // including the initial auto-focus Zag's own initialFocusEl performs.
63
+ const onFocus = React.useCallback((e) => {
64
+ for (const el of items())
65
+ el.tabIndex = -1;
66
+ e.target.tabIndex = 0;
67
+ }, [items]);
68
+ React.useEffect(() => {
69
+ if (!api.open)
70
+ return;
71
+ const [first] = items();
72
+ if (first)
73
+ first.tabIndex = 0;
74
+ }, [api.open, items]);
75
+ const ctx = React.useMemo(() => ({ close }), [close]);
76
+ if (!api.open)
77
+ return null;
78
+ return (_jsx(Portal, { children: _jsxs("div", { className: menusheetRecipe(), children: [_jsx("div", { ...api.getBackdropProps(), className: "crisp-menusheet-backdrop" }), _jsx("div", { ...api.getPositionerProps(), className: "crisp-menusheet-positioner", children: _jsxs("div", { ...rest, ...withDescribedBy(api.getContentProps(), describedBy), className: className
79
+ ? `crisp-menusheet-content ${className}`
80
+ : "crisp-menusheet-content", children: [(title != null || description != null) && (_jsxs("div", { className: "crisp-menusheet-header", children: [title != null && (_jsx("span", { ...api.getTitleProps(), className: "crisp-menusheet-title", children: title })), description != null && (_jsx("span", { className: "crisp-menusheet-description", children: description }))] })), _jsx(MenuSheetContext.Provider, { value: ctx, children: _jsx("div", { ref: listRef, role: "menu", className: "crisp-menusheet-list", onKeyDown: menuKeyDown, onFocus: onFocus, children: children }) }), footer != null && (_jsx("div", { className: "crisp-menusheet-footer", children: footer }))] }) })] }) }));
81
+ }
82
+ MenuSheet.displayName = "MenuSheet";
83
+ export function MenuSheetGroup({ children }) {
84
+ return _jsx("div", { className: "crisp-menusheet-group", children: children });
85
+ }
86
+ MenuSheetGroup.displayName = "MenuSheetGroup";
87
+ export function MenuSheetItem({ children, icon, description, tone = "neutral", disabled, onSelect, closeOnSelect = true, }) {
88
+ const ctx = React.useContext(MenuSheetContext);
89
+ return (_jsxs("button", { type: "button", role: "menuitem", tabIndex: -1, disabled: disabled, "data-disabled": disabled ? "" : undefined, "data-tone": tone, className: "crisp-menusheet-item", onClick: () => {
90
+ onSelect?.();
91
+ if (closeOnSelect)
92
+ ctx?.close();
93
+ }, children: [icon != null && (_jsx("span", { className: "crisp-menusheet-item-icon", children: icon })), _jsxs("span", { className: "crisp-menusheet-item-content", children: [_jsx("span", { className: "crisp-menusheet-item-label", children: children }), description != null && (_jsx("span", { className: "crisp-menusheet-item-description", children: description }))] })] }));
94
+ }
95
+ MenuSheetItem.displayName = "MenuSheetItem";
@@ -0,0 +1,100 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // A mobile menu sheet -- daangn/seed-design components menu-sheet.yaml and
4
+ // menu-sheet-item.yaml (dev at e1c486b904297f7b6a8f17b8523f732c37015d60), not
5
+ // Attio (no such component there; crisp#911). Same @zag-js/dialog machine as
6
+ // BottomSheet, and the identical role=menu keyboard model Menu already uses
7
+ // (useMenuKeyboard, shared rather than reimplemented). Differs from
8
+ // BottomSheet in every slot value: r5 radius (not r6), content-level padding
9
+ // (not per-slot), no header alignment / close-button matrix at all -- menu
10
+ // sheet has neither in its own schema. Every numeric value cites its yaml
11
+ // line. NOTE: no apostrophes in this file -- one broke the recipe compiler
12
+ // silently on BottomSheet, see AUTHORING.md Lesson 64.
13
+ const config = defineRecipe({
14
+ name: "menusheet",
15
+ base: `display: contents; font-family: ${c.font.sans};
16
+ & .crisp-menusheet-backdrop {
17
+ position: fixed; inset: 0; z-index: 60;
18
+ background: ${c.bgOverlayStrong}; /* menu-sheet.yaml:95 backdrop.color = $color.bg.overlay */
19
+ opacity: 1; transition: opacity 100ms cubic-bezier(0,0,.15,1); /* menu-sheet.yaml:96-98 enterDuration/enterTimingFunction = $duration.d2/$timing-function.enter */
20
+ @starting-style { opacity: 0; }
21
+ }
22
+ & .crisp-menusheet-positioner {
23
+ position: fixed; inset: 0; z-index: 61;
24
+ display: flex; align-items: flex-end; justify-content: center;
25
+ }
26
+ & .crisp-menusheet-content {
27
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
28
+ position: relative; box-sizing: border-box; display: flex; flex-direction: column;
29
+ overflow: hidden; width: 100%; max-width: 480px; /* menu-sheet.yaml:104 content.maxWidth */
30
+ max-height: 85vh;
31
+ background: ${c.bgSurface}; /* menu-sheet.yaml:103 content.color = $color.bg.layer-floating */
32
+ padding: ${c.space["6"]} ${c.space["4"]} ${c.space["4"]}; /* menu-sheet.yaml:105-107 content.paddingX/paddingTop/paddingBottom = global-gutter/x6/x4 (16px/24px/16px) */
33
+ border-radius: ${c.radius["3xl"]} ${c.radius["3xl"]} 0 0; /* menu-sheet.yaml:108 content.topCornerRadius = $radius.r5 (20px) */
34
+ transform: translateY(0);
35
+ transition: transform 300ms cubic-bezier(.03,.4,.1,1); /* menu-sheet.yaml:109-110 content.enterDuration/enterTimingFunction (enter-expressive) */
36
+ @starting-style { transform: translateY(100%); }
37
+ }
38
+ & .crisp-menusheet-header {
39
+ display: flex; flex-direction: column;
40
+ gap: ${c.space["1"]}; /* menu-sheet.yaml:115 header.gap = $dimension.x1 (4px) */
41
+ padding-bottom: ${c.space["4"]}; /* menu-sheet.yaml:116 header.paddingBottom = $dimension.x4 (16px) */
42
+ flex-shrink: 0;
43
+ }
44
+ & .crisp-menusheet-title {
45
+ color: ${c.fgPrimary}; /* menu-sheet.yaml:121 title.color = $color.fg.neutral */
46
+ font-size: 18px; line-height: 24px; font-weight: ${c.weight.bold}; /* menu-sheet.yaml:118-120 title.fontSize/lineHeight/fontWeight = $font-size.t6/$line-height.t6/bold */
47
+ }
48
+ & .crisp-menusheet-description {
49
+ color: ${c.fgMuted}; /* menu-sheet.yaml:126 description.color = $color.fg.neutral-muted */
50
+ font-size: ${c.text.lg}; line-height: 19px; font-weight: ${c.weight.regular}; /* menu-sheet.yaml:123-125 description.fontSize/lineHeight/fontWeight = $font-size.t4/$line-height.t4/regular */
51
+ }
52
+ & .crisp-menusheet-list {
53
+ display: flex; flex-direction: column;
54
+ gap: ${c.space["2.5"]}; /* menu-sheet.yaml:129 list.gap = $dimension.x2_5 (10px) */
55
+ overflow-y: auto; min-height: 0;
56
+ }
57
+ & .crisp-menusheet-group {
58
+ display: flex; flex-direction: column;
59
+ border-radius: ${c.radius["2xl"]}; overflow: hidden; /* menu-sheet.yaml:131 group.cornerRadius = $radius.r4 (16px) */
60
+ }
61
+ & .crisp-menusheet-group > * + * { border-top: 1px solid ${c.strokeGroupDivider}; } /* menu-sheet.yaml:132-134 divider.strokeBottomWidth/strokeColor = 1px/$color.stroke.neutral-muted */
62
+ & .crisp-menusheet-footer {
63
+ flex-shrink: 0;
64
+ padding-top: ${c.space["2.5"]}; /* menu-sheet.yaml:136 footer.paddingTop = $dimension.x2_5 (10px) */
65
+ }
66
+ & .crisp-menusheet-item {
67
+ position: relative; box-sizing: border-box; display: flex; align-items: center;
68
+ width: 100%; min-height: 52px; /* menu-sheet-item.yaml:72 root.minHeight */
69
+ padding: ${c.space["3.5"]} ${c.space["4"]}; gap: ${c.space["3.5"]}; /* menu-sheet-item.yaml:73-75 root.paddingX/paddingY/gap = x4/x3_5/x3_5 (16px/14px/14px) */
70
+ border: none; margin: 0; cursor: pointer; text-align: start;
71
+ background: ${c.bgNeutralWeak}; /* menu-sheet-item.yaml:71 root.color = $color.bg.neutral-weak */
72
+ font-family: inherit;
73
+ }
74
+ & .crisp-menusheet-item:hover,
75
+ & .crisp-menusheet-item:active { background: color-mix(in srgb, ${c.fgPrimary} 6%, ${c.bgNeutralWeak}); } /* menu-sheet-item.yaml:90-91 pressed.root.color = $color.bg.neutral-weak-pressed, approximated as a tint over the base fill */
76
+ & .crisp-menusheet-item:active .crisp-menusheet-item-content { transform: scale(0.98); } /* menu-sheet-item.yaml:92 pressed.root.scaleScope = content */
77
+ & .crisp-menusheet-item-icon { flex-shrink: 0; display: inline-flex; width: 22px; height: 22px; } /* menu-sheet-item.yaml:77 prefixIcon.size */
78
+ & .crisp-menusheet-item-content {
79
+ flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column;
80
+ gap: ${c.space["0.5"]}; /* menu-sheet-item.yaml:79 content.gap = $dimension.x0_5 (2px) */
81
+ transition: transform 150ms cubic-bezier(0,0,.15,1);
82
+ }
83
+ & .crisp-menusheet-item-label {
84
+ font-size: ${c.text.xl}; line-height: 22px; font-weight: ${c.weight.regular}; /* menu-sheet-item.yaml:81-83 label.fontSize/lineHeight/fontWeight = $font-size.t5/$line-height.t5/regular */
85
+ }
86
+ & .crisp-menusheet-item-description {
87
+ color: ${c.fgNeutralSubtle}; /* menu-sheet-item.yaml:88 description.color = $color.fg.neutral-subtle */
88
+ font-size: ${c.text.md}; line-height: 18px; font-weight: ${c.weight.medium}; /* menu-sheet-item.yaml:85-87 description.fontSize/lineHeight/fontWeight = $font-size.t3/$line-height.t3/medium */
89
+ }
90
+ & .crisp-menusheet-item[data-tone="neutral"] .crisp-menusheet-item-icon,
91
+ & .crisp-menusheet-item[data-tone="neutral"] .crisp-menusheet-item-label { color: ${c.fgPrimary}; } /* menu-sheet-item.yaml:93-98 tone=neutral */
92
+ & .crisp-menusheet-item[data-tone="critical"] .crisp-menusheet-item-icon,
93
+ & .crisp-menusheet-item[data-tone="critical"] .crisp-menusheet-item-label { color: ${c.fgDanger}; } /* menu-sheet-item.yaml:99-104 tone=critical */
94
+ `,
95
+ variants: {},
96
+ defaultVariants: {},
97
+ compoundVariants: [],
98
+ });
99
+ export const menusheetRecipe = resolveRecipe(config);
100
+ export default config;
@@ -35,7 +35,7 @@ export function MultiSelect({ options, value: valueProp, defaultValue, onValueCh
35
35
  // The id belongs on the trigger, not the wrapper: `<label for>` only names a
36
36
  // LABELABLE element, so an id left on the root div meant a FormField label
37
37
  // above this control named nothing (#247). Select already did this.
38
- id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPlaceholderProp, emptyLabel: emptyLabelProp, disabled, readOnly, invalid, size = "medium", variant = "default", 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 }) {
39
39
  // crisp/#471: same resolution order as Select's own.
40
40
  const placeholder = useCrispMessage(placeholderProp, "Select…");
41
41
  const searchPlaceholder = useCrispMessage(searchPlaceholderProp, "Search…");
@@ -186,7 +186,7 @@ id, placeholder: placeholderProp, searchable = true, searchPlaceholder: searchPl
186
186
  }, children: [_jsx("span", { className: "crisp-multiselect-chips", children: selectedOptions.length === 0 ? (_jsx("span", { className: "crisp-multiselect-placeholder", children: placeholder })) : (selectedOptions.map((o) => _jsx(Chip, { opt: o }, o.value))) }), _jsx(ChevronDown, { className: "crisp-multiselect-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-multiselect-listbox", role: "listbox", "aria-multiselectable": "true", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-multiselect-search", children: [_jsx(Search, { size: 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) => {
187
187
  setQuery(e.target.value);
188
188
  setHighlight(0);
189
- }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-multiselect-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
189
+ }, onKeyDown: onNavKeys })] })), searchable && _jsx("div", { className: "crisp-multiselect-divider" }), filtered.length === 0 ? (_jsx("div", { className: "crisp-multiselect-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
190
190
  const sel = selectedSet.has(opt.value);
191
191
  // #343: a header renders once per run of same-`group`
192
192
  // options, so consecutive rows sharing a group render one
@@ -13,10 +13,14 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
13
13
  // styling (shared with RecordsTable/DataTable elsewhere), the checkbox-style
14
14
  // selected indicator, and the toggle-without-closing interaction.
15
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.
16
+ // The search row now follows seed too (owner decision, 2026-09-26: Select
17
+ // AND MultiSelect both follow seed-design, superseding #481's old Attio
18
+ // lock on this row's geometry -- crisp/#904's MultiSelect follow-up). It
19
+ // reads the exact same composed @seed-design/rootage `text-input.yaml`
20
+ // (variant=outline,size=medium) values #913 resolved for Select's own
21
+ // search row -- see that recipe's matching comment for the full citation.
22
+ // `build/spec-check.mjs`'s guard was rewritten to assert these seed values
23
+ // instead of the old 36px Attio measurement.
20
24
  //
21
25
  // The listbox portals to `document.body` (#339), so its rules live in
22
26
  // `globalCss`, not nested under `&` -- see Select's own recipe for the full
@@ -61,13 +65,19 @@ const config = defineRecipe({
61
65
  }
62
66
  .crisp-multiselect-listbox[data-up] { transform-origin: bottom left; }
63
67
  .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}; }
68
+ /* Seed geometry (build/spec-check.mjs guards these four properties
69
+ directly), superseding #481's old Attio lock -- see this file's
70
+ header comment and Select's matching .crisp-select-search comment
71
+ for the full citation. An ordinary flex child of the listbox, so its
72
+ own 8px gap provides the inset/seam on every side, same as Select. */
73
+ .crisp-multiselect-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}; }
68
74
  .crisp-multiselect-search svg { flex-shrink: 0; }
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}; }
75
+ .crisp-multiselect-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}; }
70
76
  .crisp-multiselect-search input::placeholder { color: ${c.fgSelectPlaceholder}; }
77
+ /* Same header/body seam as Select's own .crisp-select-divider -- see
78
+ that recipe's comment for the seed source (select.yaml's divider
79
+ slot, stroke.neutral-muted). */
80
+ .crisp-multiselect-divider { flex-shrink: 0; height: 1px; margin-inline: ${c.space["4"]}; background: ${c.strokeSelectDivider}; }
71
81
  /* Same groupLabel treatment as Select's (13px/regular/neutral-subtle). */
72
82
  .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
83
  .crisp-multiselect-option {
@@ -96,9 +106,32 @@ const config = defineRecipe({
96
106
  .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); }
97
107
  `,
98
108
  variants: {
99
- // seed's two trigger sizes, same tokens as Select's. medium is the
100
- // default -- closest to crisp's previous single size.
109
+ // Owner's follow-up after #867/#879/#882 (crisp/#889): SIZE is Attio's
110
+ // again, LOOK stays seed's -- see Select's own recipe comment for the
111
+ // full reasoning (measured live in GRC's "New employee" dialog: a 40px
112
+ // trigger next to a 32px `Input` visibly mismatched). `small` is the new
113
+ // default: `c.size.menuRow` (32px) and the original, pre-#882
114
+ // Attio-measured asymmetric padding for a chip-wrapping trigger.
115
+ // `medium`/`large` (seed's own 40/52px) stay, opt-in.
116
+ //
117
+ // crisp/#904 (MultiSelect follow-up, briefly moved then reverted, same
118
+ // ruling as Select's own): `small`'s `gap` moved from `space["1"]`
119
+ // (4px) to `space["2"]` (8px), matching `medium`/`large`'s seed gap --
120
+ // team-lead's review ruling on Select's matching change reversed it:
121
+ // padding AND gap are SPACING, and the owner's direction is "sizes/
122
+ // spacing from Attio, look from seed", not seed's literal gap number.
123
+ // Back to `space["1"]` (4px), the original pre-#882 Attio-measured
124
+ // value. `padding` was never touched either way: the pre-#882,
125
+ // Attio-measured asymmetric padding a chip-wrapping trigger needs
126
+ // (chips sit close to the start edge, the chevron gets more room at
127
+ // the end) -- seed has no multi-value combobox of its own to source a
128
+ // replacement number from (see this file's header comment) even if
129
+ // the ruling had gone the other way.
101
130
  size: {
131
+ small: `
132
+ & .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"]}; }
133
+ & .crisp-multiselect-chevron { width: ${c.size.icon}; height: ${c.size.icon}; }
134
+ `,
102
135
  medium: `
103
136
  & .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"]}; }
104
137
  & .crisp-multiselect-chevron { width: ${c.size.icon}; height: ${c.size.icon}; }
@@ -131,7 +164,7 @@ const config = defineRecipe({
131
164
  `,
132
165
  },
133
166
  },
134
- defaultVariants: { size: "medium", invalid: false, variant: "default" },
167
+ defaultVariants: { size: "small", invalid: false, variant: "default" },
135
168
  compoundVariants: [],
136
169
  });
137
170
  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);
@@ -19,7 +19,7 @@ function optionText(o) {
19
19
  * move, Enter/Space pick, Esc close, Home/End jump, opens upward when tight.
20
20
  * Controlled or uncontrolled.
21
21
  */
22
- export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder: placeholderProp, variant = "default", width = "full", size = "medium", searchable, searchPlaceholder: searchPlaceholderProp, onSearchChange, emptyLabel: emptyLabelProp, disabled, readOnly, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
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 }) {
23
23
  // crisp/#471: an explicit prop always wins; missing, it reads the nearest
24
24
  // CrispMessagesProvider's translation of the English default, or that
25
25
  // default itself with no provider — unchanged from before this existed.
@@ -181,11 +181,11 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
181
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",
182
182
  // controlled by the listbox above; `readOnly` would bar it from
183
183
  // validation, which is the only reason this input is not hidden
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: 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) => {
185
185
  setQuery(e.target.value);
186
186
  setHighlight(0);
187
187
  onSearchChange?.(e.target.value);
188
- }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
188
+ }, onKeyDown: onNavKeys })] })), searchable && _jsx("div", { className: "crisp-select-divider" }), filtered.length === 0 ? (_jsx("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
189
189
  const isSel = opt.value === selected;
190
190
  // #343: a header renders once per run of same-`group`
191
191
  // options, so consecutive rows sharing a group render one
@@ -26,11 +26,11 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
26
26
  // pattern with no precedent anywhere else in the repo, for one component.
27
27
  // The enter animation (scale 0.95→1, opacity 0→1, seed's own curve) IS
28
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.
29
+ // - A general-purpose `divider` slot between arbitrary option groups:
30
+ // nothing in crisp or GRC groups options with an explicit separator
31
+ // today, and AUTHORING's rule against building what nothing consumes
32
+ // applies to CSS too. (`select.yaml`'s resolved value IS used below, just
33
+ // for one specific seam — see `.crisp-select-divider`'s own comment.)
34
34
  const config = defineRecipe({
35
35
  name: "select",
36
36
  base: `
@@ -85,10 +85,34 @@ const config = defineRecipe({
85
85
  }
86
86
  .crisp-select-listbox[data-up] { transform-origin: bottom left; }
87
87
  .crisp-select-listbox { @starting-style { opacity: 0; transform: scale(0.95); } }
88
- .crisp-select-search { display: flex; align-items: center; gap: ${c.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${c.space["2"]}) calc(-1 * ${c.space["2"]}) ${c.space["2"]}; padding: 0 ${c.space["2.5"]}; border-bottom: 1px solid ${c.borderSubtle}; color: ${c.fgNeutralMuted}; }
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}; }
89
99
  .crisp-select-search svg { flex-shrink: 0; }
90
- .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.fgSelectNeutral}; }
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}; }
91
101
  .crisp-select-search input::placeholder { color: ${c.fgSelectPlaceholder}; }
102
+ /* select.yaml's own divider slot (marginX x4, height 1px, colour
103
+ stroke.neutral-muted -- described upstream as "the line marking a
104
+ boundary between semantic units: between sections, between content
105
+ and an action area, between a header and a body" -- exactly the
106
+ search-field/option-list boundary crisp#904's review flagged as
107
+ reading too tight). Not the general-purpose separator #867 declined
108
+ to build (nothing groups options with one) -- scoped to this one
109
+ specific header/body seam, which now exists because searchable
110
+ does. Sits as an ordinary flex child, so the listbox's own 8px gap
111
+ still applies on both sides of it -- total visual seam is
112
+ gap+1px+gap (17px), deliberately more than the 8px between two
113
+ plain option rows, so it reads as a section break, not a wider list
114
+ gap. */
115
+ .crisp-select-divider { flex-shrink: 0; height: 1px; margin-inline: ${c.space["4"]}; background: ${c.strokeSelectDivider}; }
92
116
  .crisp-select-empty { padding: ${c.space["3"]} ${c.space["4"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgNeutralSubtle}; }
93
117
  /* select.yaml's groupLabel -- font-size/line-height/weight are the same
94
118
  across sizes, only paddingY differs (medium 8px, large 10px). */
@@ -121,9 +145,38 @@ const config = defineRecipe({
121
145
  full: `width: 100%;`,
122
146
  auto: `width: auto;`,
123
147
  },
124
- // seed's two trigger/item sizes (select-trigger.yaml / select-item.yaml).
125
- // medium is the default -- closest to crisp's previous single size.
148
+ // Owner's follow-up after #867/#879 (crisp/#889): the LOOK stays seed's
149
+ // (border, listbox, radius, shadow, items, states, all unchanged below),
150
+ // but the HEIGHT is Attio's again -- a 40px trigger next to a 32px
151
+ // `Input` in the same form visibly mismatched (measured live in GRC's
152
+ // own "New employee" dialog). `small` is the new default: Attio's
153
+ // original 32px `c.size.menuRow`. `medium`/`large` (seed's own 40/52px)
154
+ // stay, opt-in. Item rows are IDENTICAL across all three -- only the
155
+ // trigger differs; only it was ever the mismatch.
156
+ //
157
+ // crisp/#904 (follow-up, then reverted): `small`'s gap/padding-inline
158
+ // briefly moved to seed's literal `select-trigger.yaml` `size=medium`
159
+ // values (8px/14px), on the theory that even the 32px trigger should
160
+ // read seed's numbers verbatim. Team-lead ruling on that PR's review:
161
+ // padding and gap are SPACING, and the owner's actual direction is
162
+ // "sizes/spacing from Attio, look from seed" -- Select and Input sit
163
+ // side by side in every form, so spacing follows Input's measured
164
+ // values here, same as `medium`/`large` below follow seed's. Reverted
165
+ // to `space["1.5"]`/`space["2.5"]` (6px/10px, `Input`'s own gap/padding
166
+ // at this row height) -- everything else `small` already got from seed
167
+ // (fill/stroke/border/radius/chevron/colours/states, see this file's
168
+ // other comments) is unaffected and stays seed's.
126
169
  size: {
170
+ small: `
171
+ & .crisp-select-trigger { height: ${c.size.menuRow}; border-radius: ${c.radius.md}; gap: ${c.space["1.5"]}; padding-inline: ${c.space["2.5"]}; }
172
+ & .crisp-select-value, & .crisp-select-placeholder { font-size: ${c.text.lg}; line-height: 20px; }
173
+ & .crisp-select-chevron { width: ${c.size.icon}; height: ${c.size.icon}; }
174
+ .crisp-select-option { gap: ${c.space["2"]}; }
175
+ .crisp-select-option-label { font-size: ${c.text.lg}; line-height: 19px; }
176
+ .crisp-select-option-description { font-size: ${c.text.sm}; line-height: 16px; }
177
+ .crisp-select-option-check { width: ${c.space["3"]}; height: ${c.space["3"]}; }
178
+ .crisp-select-label { padding-top: ${c.space["2"]}; }
179
+ `,
127
180
  medium: `
128
181
  & .crisp-select-trigger { height: ${c.size.selectTriggerMd}; border-radius: ${c.radius.md}; gap: ${c.space["2"]}; padding-inline: ${c.space["3.5"]}; }
129
182
  & .crisp-select-value, & .crisp-select-placeholder { font-size: ${c.text.lg}; line-height: 19px; }
@@ -175,7 +228,7 @@ const config = defineRecipe({
175
228
  },
176
229
  defaultVariants: {
177
230
  width: "full",
178
- size: "medium",
231
+ size: "small",
179
232
  invalid: false,
180
233
  variant: "default",
181
234
  },
@@ -0,0 +1,2 @@
1
+ export { SnackbarProvider, useSnackbar, } from "./snackbar.js";
2
+ export { snackbarRecipe } from "./snackbar.recipe.js";
@@ -0,0 +1,84 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CircleAlert, CircleCheck } from "lucide-react";
3
+ import * as React from "react";
4
+ import { useCrispMessage, useFocusAfterRemoval } from "../../core/index.js";
5
+ import { snackbarRecipe } from "./snackbar.recipe.js";
6
+ const SnackbarContext = React.createContext(null);
7
+ const ICONS = {
8
+ positive: CircleCheck,
9
+ critical: CircleAlert,
10
+ };
11
+ function SnackbarPill({ item, onClose, }) {
12
+ const Icon = ICONS[item.variant ?? "default"];
13
+ return (_jsxs("div", { className: "crisp-snackbar", "data-variant": item.variant ?? "default", children: [_jsxs("div", { className: "crisp-snackbar-content", children: [Icon != null && (_jsx("span", { className: "crisp-snackbar-icon", children: _jsx(Icon, { size: 24, strokeWidth: 1.75, "aria-hidden": "true" }) })), _jsx("span", { className: "crisp-snackbar-message", children: item.message })] }), item.action && (_jsx("button", { type: "button", className: "crisp-snackbar-action", onClick: () => {
14
+ item.action?.onClick();
15
+ onClose();
16
+ }, children: item.action.label }))] }));
17
+ }
18
+ /**
19
+ * Wrap your app in a `SnackbarProvider`, then call `useSnackbar().show({...})`
20
+ * from anywhere to queue a bottom-anchored snackbar. Only one shows at a time
21
+ * (seed-design's mobile pattern, crisp#911) -- a second call queues behind the
22
+ * first and shows once it dismisses. Auto-dismisses after `duration` (default
23
+ * 4s; 0 keeps it until `dismiss()` or its action fires -- there is no close
24
+ * button, matching seed's own anatomy, which has none).
25
+ */
26
+ export function SnackbarProvider({ children, duration = 4000, regionLabel: regionLabelProp, }) {
27
+ const regionLabel = useCrispMessage(regionLabelProp, "Notifications");
28
+ const [queue, setQueue] = React.useState([]);
29
+ const idRef = React.useRef(0);
30
+ const timer = React.useRef(null);
31
+ const regionRef = React.useRef(null);
32
+ const cameFrom = React.useRef(null);
33
+ const focusAfterDismiss = useFocusAfterRemoval(regionRef, ".crisp-snackbar-action", cameFrom);
34
+ React.useEffect(() => {
35
+ const onFocusIn = (e) => {
36
+ const target = e.target;
37
+ if (!target || regionRef.current?.contains(target))
38
+ return;
39
+ cameFrom.current = target;
40
+ };
41
+ document.addEventListener("focusin", onFocusIn);
42
+ return () => document.removeEventListener("focusin", onFocusIn);
43
+ }, []);
44
+ const dismiss = React.useCallback((id) => {
45
+ setQueue((list) => list.filter((s) => s.id !== id));
46
+ if (timer.current) {
47
+ clearTimeout(timer.current);
48
+ timer.current = null;
49
+ }
50
+ }, []);
51
+ const show = React.useCallback((o) => {
52
+ idRef.current += 1;
53
+ const id = idRef.current;
54
+ setQueue((list) => [...list, { ...o, id }]);
55
+ return id;
56
+ }, []);
57
+ // Only the FRONT of the queue is ever mounted, so its own auto-dismiss
58
+ // timer starts when it becomes visible, not when `show` was called.
59
+ const current = queue[0];
60
+ React.useEffect(() => {
61
+ if (!current)
62
+ return;
63
+ const d = current.duration ?? duration;
64
+ if (d <= 0)
65
+ return;
66
+ timer.current = setTimeout(() => dismiss(current.id), d);
67
+ return () => {
68
+ if (timer.current)
69
+ clearTimeout(timer.current);
70
+ };
71
+ }, [current, duration, dismiss]);
72
+ const api = React.useMemo(() => ({ show, dismiss }), [show, dismiss]);
73
+ return (_jsxs(SnackbarContext.Provider, { value: api, children: [children, _jsx("div", { className: snackbarRecipe(), children: _jsx("div", { ref: regionRef, className: "crisp-snackbar-region", role: "region", "aria-label": regionLabel, "aria-live": "polite", "aria-atomic": "false", children: current && (_jsx(SnackbarPill, { item: current, onClose: () => {
74
+ focusAfterDismiss(0);
75
+ dismiss(current.id);
76
+ } }, current.id)) }) })] }));
77
+ }
78
+ SnackbarProvider.displayName = "SnackbarProvider";
79
+ export function useSnackbar() {
80
+ const ctx = React.useContext(SnackbarContext);
81
+ if (!ctx)
82
+ throw new Error("useSnackbar must be used within a SnackbarProvider");
83
+ return ctx;
84
+ }