@crisp-ui-kit/crisp 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/README.md +6 -0
  2. package/dist/base.css +25 -0
  3. package/dist/button.css +1 -1
  4. package/dist/command.css +1 -1
  5. package/dist/contextmenu.css +1 -1
  6. package/dist/datatable.css +60 -0
  7. package/dist/datatable.d.ts +7 -0
  8. package/dist/datepicker.css +1 -1
  9. package/dist/dialog.css +1 -1
  10. package/dist/esm/components/accordion/accordion.js +32 -0
  11. package/dist/esm/components/accordion/accordion.recipe.js +23 -0
  12. package/dist/esm/components/accordion/index.js +2 -0
  13. package/dist/esm/components/avatar/avatar.js +8 -0
  14. package/dist/esm/components/avatar/avatar.recipe.js +39 -0
  15. package/dist/esm/components/avatar/index.js +2 -0
  16. package/dist/esm/components/avatargroup/avatargroup.js +16 -0
  17. package/dist/esm/components/avatargroup/avatargroup.recipe.js +15 -0
  18. package/dist/esm/components/avatargroup/index.js +2 -0
  19. package/dist/esm/components/badge/badge.js +8 -0
  20. package/dist/esm/components/badge/badge.recipe.js +65 -0
  21. package/dist/esm/components/badge/index.js +2 -0
  22. package/dist/esm/components/barchart/barchart.js +29 -0
  23. package/dist/esm/components/barchart/barchart.recipe.js +26 -0
  24. package/dist/esm/components/barchart/index.js +2 -0
  25. package/dist/esm/components/breadcrumb/breadcrumb.js +17 -0
  26. package/dist/esm/components/breadcrumb/breadcrumb.recipe.js +18 -0
  27. package/dist/esm/components/breadcrumb/index.js +2 -0
  28. package/dist/esm/components/bulkbar/bulkbar.js +16 -0
  29. package/dist/esm/components/bulkbar/bulkbar.recipe.js +28 -0
  30. package/dist/esm/components/bulkbar/index.js +2 -0
  31. package/dist/esm/components/button/button.js +15 -0
  32. package/dist/esm/components/button/button.recipe.js +39 -0
  33. package/dist/esm/components/button/index.js +2 -0
  34. package/dist/esm/components/callout/callout.js +22 -0
  35. package/dist/esm/components/callout/callout.recipe.js +26 -0
  36. package/dist/esm/components/callout/index.js +2 -0
  37. package/dist/esm/components/card/card.js +15 -0
  38. package/dist/esm/components/card/card.recipe.js +28 -0
  39. package/dist/esm/components/card/index.js +2 -0
  40. package/dist/esm/components/checkbox/checkbox.js +20 -0
  41. package/dist/esm/components/checkbox/checkbox.recipe.js +23 -0
  42. package/dist/esm/components/checkbox/index.js +2 -0
  43. package/dist/esm/components/contextmenu/contextmenu.js +42 -0
  44. package/dist/esm/components/contextmenu/contextmenu.recipe.js +21 -0
  45. package/dist/esm/components/contextmenu/index.js +2 -0
  46. package/dist/esm/components/datatable/datatable.js +245 -0
  47. package/dist/esm/components/datatable/datatable.recipe.js +72 -0
  48. package/dist/esm/components/datatable/index.js +2 -0
  49. package/dist/esm/components/datepicker/datepicker.js +57 -0
  50. package/dist/esm/components/datepicker/datepicker.recipe.js +32 -0
  51. package/dist/esm/components/datepicker/index.js +2 -0
  52. package/dist/esm/components/dialog/dialog.js +48 -0
  53. package/dist/esm/components/dialog/dialog.recipe.js +32 -0
  54. package/dist/esm/components/dialog/index.js +2 -0
  55. package/dist/esm/components/divider/divider.js +7 -0
  56. package/dist/esm/components/divider/divider.recipe.js +14 -0
  57. package/dist/esm/components/divider/index.js +2 -0
  58. package/dist/esm/components/donutchart/donutchart.js +38 -0
  59. package/dist/esm/components/donutchart/donutchart.recipe.js +27 -0
  60. package/dist/esm/components/donutchart/index.js +2 -0
  61. package/dist/esm/components/drawer/drawer.js +42 -0
  62. package/dist/esm/components/drawer/drawer.recipe.js +27 -0
  63. package/dist/esm/components/drawer/index.js +2 -0
  64. package/dist/esm/components/emptystate/emptystate.js +7 -0
  65. package/dist/esm/components/emptystate/emptystate.recipe.js +31 -0
  66. package/dist/esm/components/emptystate/index.js +2 -0
  67. package/dist/esm/components/formfield/formfield.js +14 -0
  68. package/dist/esm/components/formfield/formfield.recipe.js +19 -0
  69. package/dist/esm/components/formfield/index.js +2 -0
  70. package/dist/esm/components/input/index.js +2 -0
  71. package/dist/esm/components/input/input.js +8 -0
  72. package/dist/esm/components/input/input.recipe.js +23 -0
  73. package/dist/esm/components/kbd/index.js +2 -0
  74. package/dist/esm/components/kbd/kbd.js +8 -0
  75. package/dist/esm/components/kbd/kbd.recipe.js +20 -0
  76. package/dist/esm/components/linechart/index.js +2 -0
  77. package/dist/esm/components/linechart/linechart.js +41 -0
  78. package/dist/esm/components/linechart/linechart.recipe.js +27 -0
  79. package/dist/esm/components/menu/index.js +2 -0
  80. package/dist/esm/components/menu/menu.js +137 -0
  81. package/dist/esm/components/menu/menu.recipe.js +51 -0
  82. package/dist/esm/components/multiselect/index.js +2 -0
  83. package/dist/esm/components/multiselect/multiselect.js +118 -0
  84. package/dist/esm/components/multiselect/multiselect.recipe.js +68 -0
  85. package/dist/esm/components/popover/index.js +2 -0
  86. package/dist/esm/components/popover/popover.js +70 -0
  87. package/dist/esm/components/popover/popover.recipe.js +18 -0
  88. package/dist/esm/components/progress/index.js +2 -0
  89. package/dist/esm/components/progress/progress.js +23 -0
  90. package/dist/esm/components/progress/progress.recipe.js +17 -0
  91. package/dist/esm/components/radiogroup/index.js +2 -0
  92. package/dist/esm/components/radiogroup/radiogroup.js +46 -0
  93. package/dist/esm/components/radiogroup/radiogroup.recipe.js +37 -0
  94. package/dist/esm/components/segmented/index.js +2 -0
  95. package/dist/esm/components/segmented/segmented.js +59 -0
  96. package/dist/esm/components/segmented/segmented.recipe.js +20 -0
  97. package/dist/esm/components/select/index.js +2 -0
  98. package/dist/esm/components/select/select.js +144 -0
  99. package/dist/esm/components/select/select.recipe.js +76 -0
  100. package/dist/esm/components/skeleton/index.js +2 -0
  101. package/dist/esm/components/skeleton/skeleton.js +7 -0
  102. package/dist/esm/components/skeleton/skeleton.recipe.js +14 -0
  103. package/dist/esm/components/spinner/index.js +2 -0
  104. package/dist/esm/components/spinner/spinner.js +11 -0
  105. package/dist/esm/components/spinner/spinner.recipe.js +13 -0
  106. package/dist/esm/components/stagebar/index.js +2 -0
  107. package/dist/esm/components/stagebar/stagebar.js +23 -0
  108. package/dist/esm/components/stagebar/stagebar.recipe.js +14 -0
  109. package/dist/esm/components/statcard/index.js +2 -0
  110. package/dist/esm/components/statcard/statcard.js +12 -0
  111. package/dist/esm/components/statcard/statcard.recipe.js +34 -0
  112. package/dist/esm/components/switch/index.js +2 -0
  113. package/dist/esm/components/switch/switch.js +22 -0
  114. package/dist/esm/components/switch/switch.recipe.js +26 -0
  115. package/dist/esm/components/table/index.js +2 -0
  116. package/dist/esm/components/table/table.js +30 -0
  117. package/dist/esm/components/table/table.recipe.js +40 -0
  118. package/dist/esm/components/tabletoolbar/index.js +2 -0
  119. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +79 -0
  120. package/dist/esm/components/tabletoolbar/tabletoolbar.js +191 -0
  121. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +23 -0
  122. package/dist/esm/components/tabs/index.js +2 -0
  123. package/dist/esm/components/tabs/tabs.js +70 -0
  124. package/dist/esm/components/tabs/tabs.recipe.js +33 -0
  125. package/dist/esm/components/textarea/index.js +2 -0
  126. package/dist/esm/components/textarea/textarea.js +20 -0
  127. package/dist/esm/components/textarea/textarea.recipe.js +31 -0
  128. package/dist/esm/components/toast/index.js +2 -0
  129. package/dist/esm/components/toast/toast.js +58 -0
  130. package/dist/esm/components/toast/toast.recipe.js +28 -0
  131. package/dist/esm/components/tooltip/index.js +2 -0
  132. package/dist/esm/components/tooltip/tooltip.js +45 -0
  133. package/dist/esm/components/tooltip/tooltip.recipe.js +13 -0
  134. package/dist/esm/core/cx.js +1 -0
  135. package/dist/esm/core/define.js +3 -0
  136. package/dist/esm/core/index.js +4 -0
  137. package/dist/esm/core/primitive.js +13 -0
  138. package/dist/esm/core/resolve.js +41 -0
  139. package/dist/esm/core/types.js +1 -0
  140. package/dist/esm/index.js +71 -0
  141. package/dist/esm/product/appshell/appshell.js +44 -0
  142. package/dist/esm/product/appshell/appshell.recipe.js +27 -0
  143. package/dist/esm/product/appshell/index.js +2 -0
  144. package/dist/esm/product/board/board.js +42 -0
  145. package/dist/esm/product/board/board.recipe.js +28 -0
  146. package/dist/esm/product/board/index.js +2 -0
  147. package/dist/esm/product/command/command.js +188 -0
  148. package/dist/esm/product/command/command.recipe.js +19 -0
  149. package/dist/esm/product/command/index.js +2 -0
  150. package/dist/esm/product/entitycard/entitycard.js +11 -0
  151. package/dist/esm/product/entitycard/entitycard.recipe.js +29 -0
  152. package/dist/esm/product/entitycard/index.js +2 -0
  153. package/dist/esm/product/flowcanvas/flowcanvas.js +85 -0
  154. package/dist/esm/product/flowcanvas/flowcanvas.recipe.js +25 -0
  155. package/dist/esm/product/flowcanvas/index.js +2 -0
  156. package/dist/esm/product/inbox/inbox.js +13 -0
  157. package/dist/esm/product/inbox/inbox.recipe.js +43 -0
  158. package/dist/esm/product/inbox/index.js +2 -0
  159. package/dist/esm/product/listpage/index.js +2 -0
  160. package/dist/esm/product/listpage/listpage.js +21 -0
  161. package/dist/esm/product/listpage/listpage.recipe.js +43 -0
  162. package/dist/esm/product/navitem/index.js +1 -0
  163. package/dist/esm/product/navitem/navitem.js +8 -0
  164. package/dist/esm/product/navitem/navitem.recipe.js +21 -0
  165. package/dist/esm/product/onboarding/index.js +2 -0
  166. package/dist/esm/product/onboarding/onboarding.js +14 -0
  167. package/dist/esm/product/onboarding/onboarding.recipe.js +41 -0
  168. package/dist/esm/product/recordpage/index.js +2 -0
  169. package/dist/esm/product/recordpage/recordpage.js +286 -0
  170. package/dist/esm/product/recordpage/recordpage.recipe.js +157 -0
  171. package/dist/esm/product/recordpanel/index.js +2 -0
  172. package/dist/esm/product/recordpanel/recordpanel.js +47 -0
  173. package/dist/esm/product/recordpanel/recordpanel.recipe.js +52 -0
  174. package/dist/esm/product/recordstable/company-logos.js +26 -0
  175. package/dist/esm/product/recordstable/index.js +2 -0
  176. package/dist/esm/product/recordstable/recordstable.js +963 -0
  177. package/dist/esm/product/recordstable/recordstable.recipe.js +149 -0
  178. package/dist/esm/product/richtext/index.js +2 -0
  179. package/dist/esm/product/richtext/richtext.js +111 -0
  180. package/dist/esm/product/richtext/richtext.recipe.js +40 -0
  181. package/dist/esm/product/settingssection/index.js +2 -0
  182. package/dist/esm/product/settingssection/settingssection.js +11 -0
  183. package/dist/esm/product/settingssection/settingssection.recipe.js +20 -0
  184. package/dist/esm/product/settingsshell/index.js +2 -0
  185. package/dist/esm/product/settingsshell/settingsshell.js +23 -0
  186. package/dist/esm/product/settingsshell/settingsshell.recipe.js +38 -0
  187. package/dist/esm/product/sidebar/index.js +2 -0
  188. package/dist/esm/product/sidebar/sidebar.js +112 -0
  189. package/dist/esm/product/sidebar/sidebar.recipe.js +18 -0
  190. package/dist/esm/product/tasklist/index.js +2 -0
  191. package/dist/esm/product/tasklist/tasklist.js +16 -0
  192. package/dist/esm/product/tasklist/tasklist.recipe.js +35 -0
  193. package/dist/esm/product/trialbanner/index.js +2 -0
  194. package/dist/esm/product/trialbanner/trialbanner.js +10 -0
  195. package/dist/esm/product/trialbanner/trialbanner.recipe.js +29 -0
  196. package/dist/esm/product/viewtoggle/index.js +2 -0
  197. package/dist/esm/product/viewtoggle/viewtoggle.js +11 -0
  198. package/dist/esm/product/viewtoggle/viewtoggle.recipe.js +24 -0
  199. package/dist/esm/tokens/contract.js +18 -0
  200. package/dist/esm/tokens/index.js +4 -0
  201. package/dist/esm/tokens/palette.js +30 -0
  202. package/dist/esm/tokens/scale.js +60 -0
  203. package/dist/esm/tokens/semantic.js +30 -0
  204. package/dist/menu.css +2 -2
  205. package/dist/multiselect.css +3 -3
  206. package/dist/popover.css +1 -1
  207. package/dist/recordpage.css +5 -5
  208. package/dist/recordstable.css +4 -4
  209. package/dist/select.css +10 -4
  210. package/dist/select.d.ts +2 -1
  211. package/dist/sidebar.css +1 -1
  212. package/dist/statcard.css +6 -0
  213. package/dist/statcard.d.ts +2 -2
  214. package/dist/styles.css +222 -27
  215. package/dist/table.css +26 -0
  216. package/dist/table.d.ts +8 -0
  217. package/dist/tabletoolbar.css +70 -0
  218. package/dist/tabletoolbar.d.ts +7 -0
  219. package/dist/tokens.css +24 -0
  220. package/dist/types/components/datatable/datatable.d.ts +98 -0
  221. package/dist/types/components/datatable/datatable.recipe.d.ts +3 -0
  222. package/dist/types/components/datatable/index.d.ts +2 -0
  223. package/dist/types/components/select/select.d.ts +3 -1
  224. package/dist/types/components/select/select.recipe.d.ts +12 -0
  225. package/dist/types/components/statcard/statcard.d.ts +6 -1
  226. package/dist/types/components/statcard/statcard.recipe.d.ts +12 -2
  227. package/dist/types/components/table/index.d.ts +2 -0
  228. package/dist/types/components/table/table.d.ts +53 -0
  229. package/dist/types/components/table/table.recipe.d.ts +21 -0
  230. package/dist/types/components/tabletoolbar/index.d.ts +2 -0
  231. package/dist/types/components/tabletoolbar/tabletoolbar.chrome.d.ts +2 -0
  232. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +63 -0
  233. package/dist/types/components/tabletoolbar/tabletoolbar.recipe.d.ts +3 -0
  234. package/dist/types/index.d.ts +3 -0
  235. package/dist/types/product/recordstable/company-logos.d.ts +1 -0
  236. package/dist/types/product/recordstable/recordstable.d.ts +42 -1
  237. package/dist/types/product/sidebar/index.d.ts +1 -1
  238. package/dist/types/product/sidebar/sidebar.d.ts +85 -0
  239. package/dist/types/tokens/contract.d.ts +12 -0
  240. package/dist/types/tokens/palette.d.ts +48 -0
  241. package/dist/types/tokens/semantic.d.ts +12 -0
  242. package/dist/viewtoggle.css +2 -2
  243. package/package.json +259 -9
  244. package/dist/index.js +0 -9690
@@ -0,0 +1,137 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Check } from "lucide-react";
3
+ import * as React from "react";
4
+ import { menuRecipe } from "./menu.recipe.js";
5
+ const MenuContext = React.createContext(null);
6
+ const ITEM_SELECTOR = '[role="menuitem"]:not([data-disabled]),[role="menuitemcheckbox"]:not([data-disabled]),[role="menuitemradio"]:not([data-disabled])';
7
+ /**
8
+ * A dropdown menu — the ⋯ / sort / filter menu surface, on Attio's measured
9
+ * popover. Give it a `trigger` element and Menu items as children. Opens on
10
+ * click / ↓ / Enter; roving-focus keyboard model (↑/↓ move, Home/End jump,
11
+ * typeahead, Enter/Space select, Esc closes and returns focus to the trigger);
12
+ * closes on outside click. Flips up / right-aligns when space is tight.
13
+ */
14
+ export function Menu({ trigger, children, align = "start", className, "aria-label": ariaLabel, }) {
15
+ const [open, setOpen] = React.useState(false);
16
+ const [up, setUp] = React.useState(false);
17
+ const [pos, setPos] = React.useState({});
18
+ const rootRef = React.useRef(null);
19
+ const triggerRef = React.useRef(null);
20
+ const contentRef = React.useRef(null);
21
+ const typeahead = React.useRef("");
22
+ const typeaheadTimer = React.useRef(undefined);
23
+ // Position the (fixed) menu against the trigger so it escapes any overflow
24
+ // clip; flip up / right-align when there isn't room.
25
+ const openMenu = () => {
26
+ const rect = triggerRef.current?.getBoundingClientRect();
27
+ if (!rect) {
28
+ setOpen(true);
29
+ return;
30
+ }
31
+ const gap = 4;
32
+ const flipUp = window.innerHeight - rect.bottom < 280;
33
+ const style = flipUp
34
+ ? { bottom: window.innerHeight - rect.top + gap }
35
+ : { top: rect.bottom + gap };
36
+ if (align === "end")
37
+ style.right = window.innerWidth - rect.right;
38
+ else
39
+ style.left = rect.left;
40
+ setUp(flipUp);
41
+ setPos(style);
42
+ setOpen(true);
43
+ };
44
+ const close = React.useCallback(() => {
45
+ setOpen(false);
46
+ triggerRef.current?.focus?.();
47
+ }, []);
48
+ const items = () => Array.from(contentRef.current?.querySelectorAll(ITEM_SELECTOR) ?? []);
49
+ // focus the first item when the menu opens
50
+ React.useEffect(() => {
51
+ if (open)
52
+ requestAnimationFrame(() => contentRef.current?.querySelector(ITEM_SELECTOR)?.focus());
53
+ }, [open]);
54
+ // close on outside pointer-down
55
+ React.useEffect(() => {
56
+ if (!open)
57
+ return;
58
+ const onDown = (e) => {
59
+ if (!rootRef.current?.contains(e.target))
60
+ setOpen(false);
61
+ };
62
+ document.addEventListener("pointerdown", onDown);
63
+ return () => document.removeEventListener("pointerdown", onDown);
64
+ }, [open]);
65
+ const onContentKeyDown = (e) => {
66
+ const list = items();
67
+ const idx = list.indexOf(document.activeElement);
68
+ if (e.key === "ArrowDown") {
69
+ e.preventDefault();
70
+ list[(idx + 1) % list.length]?.focus();
71
+ }
72
+ else if (e.key === "ArrowUp") {
73
+ e.preventDefault();
74
+ list[(idx - 1 + list.length) % list.length]?.focus();
75
+ }
76
+ else if (e.key === "Home") {
77
+ e.preventDefault();
78
+ list[0]?.focus();
79
+ }
80
+ else if (e.key === "End") {
81
+ e.preventDefault();
82
+ list[list.length - 1]?.focus();
83
+ }
84
+ else if (e.key === "Escape") {
85
+ e.preventDefault();
86
+ close();
87
+ }
88
+ else if (e.key === "Tab") {
89
+ setOpen(false);
90
+ }
91
+ else if (e.key.length === 1 && /\S/.test(e.key)) {
92
+ // typeahead: jump to the next item starting with the typed run
93
+ typeahead.current += e.key.toLowerCase();
94
+ clearTimeout(typeaheadTimer.current);
95
+ typeaheadTimer.current = setTimeout(() => {
96
+ typeahead.current = "";
97
+ }, 500);
98
+ const match = list.find((el) => (el.textContent ?? "")
99
+ .trim()
100
+ .toLowerCase()
101
+ .startsWith(typeahead.current));
102
+ match?.focus();
103
+ }
104
+ };
105
+ const triggerEl = React.cloneElement(trigger, {
106
+ ref: triggerRef,
107
+ "aria-haspopup": "menu",
108
+ "aria-expanded": open,
109
+ onClick: (e) => {
110
+ trigger.props.onClick?.(e);
111
+ open ? setOpen(false) : openMenu();
112
+ },
113
+ });
114
+ return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(MenuContext.Provider, { value: { close }, children: _jsx("div", { ref: contentRef, className: "menu-content", role: "menu", "aria-label": ariaLabel, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: children }) }))] }));
115
+ }
116
+ Menu.displayName = "Menu";
117
+ export function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
118
+ const ctx = React.useContext(MenuContext);
119
+ return (_jsxs("button", { type: "button", role: "menuitem", tabIndex: -1, disabled: disabled, "data-disabled": disabled ? "" : undefined, className: `menu-item${danger ? " menu-item--danger" : ""}`, onClick: () => {
120
+ onSelect?.();
121
+ if (closeOnSelect)
122
+ ctx?.close();
123
+ }, children: [icon, _jsx("span", { className: "menu-item-label", children: children }), shortcut && _jsx("span", { className: "menu-item-shortcut", children: shortcut })] }));
124
+ }
125
+ MenuItem.displayName = "MenuItem";
126
+ export function MenuCheckboxItem({ children, checked, onCheckedChange, disabled, }) {
127
+ return (_jsxs("button", { type: "button", role: "menuitemcheckbox", "aria-checked": checked, tabIndex: -1, disabled: disabled, "data-disabled": disabled ? "" : undefined, className: "menu-item", onClick: () => onCheckedChange?.(!checked), children: [checked ? (_jsx(Check, { className: "menu-item-check", size: 15, strokeWidth: 2 })) : (_jsx("span", { className: "menu-item-check--placeholder" })), _jsx("span", { className: "menu-item-label", children: children })] }));
128
+ }
129
+ MenuCheckboxItem.displayName = "MenuCheckboxItem";
130
+ export function MenuSeparator() {
131
+ return _jsx("hr", { className: "menu-sep" });
132
+ }
133
+ MenuSeparator.displayName = "MenuSeparator";
134
+ export function MenuLabel({ children }) {
135
+ return _jsx("div", { className: "menu-label", children: children });
136
+ }
137
+ MenuLabel.displayName = "MenuLabel";
@@ -0,0 +1,51 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // A dropdown menu — same popover surface Attio uses for its ⋯ / sort / filter
4
+ // menus (measured from the app's listboxes): radius 12, 4px padding, a layered
5
+ // soft shadow, over 32px items (radius 8, icon + label + optional shortcut /
6
+ // check). The content is rendered inside this relative root so it keeps these
7
+ // nested styles while escaping the trigger's own box.
8
+ const config = defineRecipe({
9
+ name: "menu",
10
+ base: `
11
+ position: relative; display: inline-flex; font-family: ${c.font.sans};
12
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
13
+
14
+ & .menu-content {
15
+ position: fixed; z-index: 60; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
16
+ min-width: 200px; max-height: 360px; overflow-y: auto; padding: ${c.space["1"]};
17
+ border-radius: ${c.radius.xl}; background: ${c.bgPopover};
18
+ box-shadow: inset 0 0 0 1px ${c.ringPopover}, 0 0 0 1px ${c.shadowPopover1}, 0 4px 8px -4px ${c.shadowPopover2}, 0 4px 12px -2px ${c.shadowPopover3};
19
+ transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
20
+ }
21
+ & .menu-content[data-align="end"] { transform-origin: top right; }
22
+ & .menu-content[data-up] { transform-origin: bottom left; }
23
+ & .menu-content[data-up][data-align="end"] { transform-origin: bottom right; }
24
+ & .menu-content { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
25
+
26
+ & .menu-item {
27
+ box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["1.5"]}; width: 100%; height: 32px; flex-shrink: 0;
28
+ margin: 0; padding: 0 ${c.space["2"]}; border: none; background: none; border-radius: ${c.radius.md};
29
+ font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; text-align: left; cursor: pointer; white-space: nowrap;
30
+ }
31
+ & .menu-item svg { flex-shrink: 0; color: ${c.fgMuted}; }
32
+ & .menu-item:focus { outline: none; }
33
+ & .menu-item:hover:not([data-disabled]), & .menu-item:focus-visible:not([data-disabled]), & .menu-item[data-highlighted]:not([data-disabled]) { background: ${c.bgRaised}; }
34
+ & .menu-item[data-disabled] { opacity: 0.5; cursor: not-allowed; }
35
+ & .menu-item--danger { color: ${c.bgDanger}; }
36
+ & .menu-item--danger svg { color: ${c.bgDanger}; }
37
+ & .menu-item--danger:hover, & .menu-item--danger:focus-visible, & .menu-item--danger[data-highlighted] { background: color-mix(in srgb, ${c.bgDanger} 10%, transparent); }
38
+ & .menu-item-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
39
+ & .menu-item-shortcut { flex-shrink: 0; margin-left: ${c.space["3"]}; font-size: ${c.text.sm}; color: ${c.fgMuted}; letter-spacing: 0.02em; }
40
+ & .menu-item-check { flex-shrink: 0; color: ${c.bgBrandSolid}; }
41
+ & .menu-item-check--placeholder { width: 15px; }
42
+
43
+ & .menu-sep { height: 1px; flex-shrink: 0; border: none; margin: ${c.space["1"]} calc(-1 * ${c.space["1"]}); background: ${c.borderSubtle}; }
44
+ & .menu-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}; }
45
+ `,
46
+ variants: {},
47
+ defaultVariants: {},
48
+ compoundVariants: [],
49
+ });
50
+ export const menuRecipe = Object.assign(resolveRecipe(config), { config });
51
+ export default config;
@@ -0,0 +1,2 @@
1
+ export { MultiSelect, } from "./multiselect.js";
2
+ export { multiselectRecipe } from "./multiselect.recipe.js";
@@ -0,0 +1,118 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Check, ChevronDown, Search } from "lucide-react";
3
+ import * as React from "react";
4
+ import { multiselectRecipe } from "./multiselect.recipe.js";
5
+ const TONES = [
6
+ "brand",
7
+ "success",
8
+ "warning",
9
+ "danger",
10
+ "purple",
11
+ "cyan",
12
+ "orange",
13
+ ];
14
+ // deterministic pastel per value (a value keeps its colour), matching the table
15
+ function toneFor(t) {
16
+ let h = 0;
17
+ for (let i = 0; i < t.length; i++)
18
+ h = (h * 31 + t.charCodeAt(i)) >>> 0;
19
+ return TONES[h % TONES.length];
20
+ }
21
+ function Chip({ opt }) {
22
+ return (_jsx("span", { className: "recordstable-tag", "data-tone": opt.tone ?? toneFor(opt.value), children: opt.label }));
23
+ }
24
+ /**
25
+ * A multi-select / tag editor — measured from Attio's cell tag picker. The
26
+ * outlined trigger shows the selected values as coloured chips; it opens a
27
+ * popover with a search field over rows of "[chip] … [checkbox]" that toggle on
28
+ * click (the menu stays open). Keyboard: ↑/↓ move, Enter toggles, Esc closes.
29
+ */
30
+ export function MultiSelect({ options, value, onValueChange, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", disabled, className, "aria-label": ariaLabel, }) {
31
+ const [open, setOpen] = React.useState(false);
32
+ const [up, setUp] = React.useState(false);
33
+ const [pos, setPos] = React.useState({});
34
+ const [query, setQuery] = React.useState("");
35
+ const [highlight, setHighlight] = React.useState(0);
36
+ const rootRef = React.useRef(null);
37
+ const triggerRef = React.useRef(null);
38
+ const searchRef = React.useRef(null);
39
+ const selectedSet = React.useMemo(() => new Set(value), [value]);
40
+ const selectedOptions = options.filter((o) => selectedSet.has(o.value));
41
+ const q = query.trim().toLowerCase();
42
+ const filtered = searchable && q
43
+ ? options.filter((o) => o.label.toLowerCase().includes(q))
44
+ : options;
45
+ const openMenu = () => {
46
+ if (disabled)
47
+ return;
48
+ const rect = triggerRef.current?.getBoundingClientRect();
49
+ if (rect) {
50
+ const flipUp = window.innerHeight - rect.bottom < 300;
51
+ setUp(flipUp);
52
+ setPos({
53
+ left: rect.left,
54
+ minWidth: rect.width,
55
+ ...(flipUp
56
+ ? { bottom: window.innerHeight - rect.top + 4 }
57
+ : { top: rect.bottom + 4 }),
58
+ });
59
+ }
60
+ setQuery("");
61
+ setHighlight(0);
62
+ setOpen(true);
63
+ if (searchable)
64
+ requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
65
+ };
66
+ const close = () => {
67
+ setOpen(false);
68
+ triggerRef.current?.focus({ preventScroll: true });
69
+ };
70
+ const toggle = (v) => {
71
+ onValueChange(selectedSet.has(v) ? value.filter((x) => x !== v) : [...value, v]);
72
+ };
73
+ const onNavKeys = (e) => {
74
+ if (e.key === "ArrowDown") {
75
+ e.preventDefault();
76
+ setHighlight((h) => Math.min(h + 1, filtered.length - 1));
77
+ }
78
+ else if (e.key === "ArrowUp") {
79
+ e.preventDefault();
80
+ setHighlight((h) => Math.max(h - 1, 0));
81
+ }
82
+ else if (e.key === "Enter") {
83
+ e.preventDefault();
84
+ const opt = filtered[highlight];
85
+ if (opt)
86
+ toggle(opt.value);
87
+ }
88
+ else if (e.key === "Escape") {
89
+ e.preventDefault();
90
+ close();
91
+ }
92
+ };
93
+ React.useEffect(() => {
94
+ if (!open)
95
+ return;
96
+ const onDown = (e) => {
97
+ if (!rootRef.current?.contains(e.target))
98
+ setOpen(false);
99
+ };
100
+ document.addEventListener("pointerdown", onDown);
101
+ return () => document.removeEventListener("pointerdown", onDown);
102
+ }, [open]);
103
+ return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ className }), children: [_jsxs("button", { ref: triggerRef, type: "button", "aria-haspopup": "listbox", "aria-expanded": open, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
104
+ if (open && !searchable)
105
+ onNavKeys(e);
106
+ else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
107
+ e.preventDefault();
108
+ openMenu();
109
+ }
110
+ }, children: [_jsx("span", { className: "multiselect-chips", children: selectedOptions.length === 0 ? (_jsx("span", { className: "multiselect-placeholder", children: placeholder })) : (selectedOptions.map((o) => _jsx(Chip, { opt: o }, o.value))) }), _jsx(ChevronDown, { className: "multiselect-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsxs("div", { className: "multiselect-listbox", role: "listbox", "aria-multiselectable": "true", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "multiselect-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, value: query, onChange: (e) => {
111
+ setQuery(e.target.value);
112
+ setHighlight(0);
113
+ }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "multiselect-empty", children: "No results" })) : (filtered.map((opt, i) => {
114
+ const sel = selectedSet.has(opt.value);
115
+ return (_jsxs("button", { type: "button", role: "option", "aria-selected": sel, "data-highlighted": i === highlight ? "" : undefined, className: "multiselect-option", onClick: () => toggle(opt.value), onMouseEnter: () => setHighlight(i), children: [_jsx("span", { className: "multiselect-option-chip", children: _jsx(Chip, { opt: opt }) }), _jsx("span", { className: "multiselect-check", children: sel && _jsx(Check, { size: 12, strokeWidth: 3 }) })] }, opt.value));
116
+ }))] }))] }));
117
+ }
118
+ MultiSelect.displayName = "MultiSelect";
@@ -0,0 +1,68 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // A multi-select / tag editor — measured from Attio's 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 keeps these nested styles by rendering inside
7
+ // this relative root.
8
+ const config = defineRecipe({
9
+ name: "multiselect",
10
+ base: `
11
+ position: relative; display: inline-flex; width: 100%; font-family: ${c.font.sans};
12
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
13
+
14
+ & .multiselect-trigger {
15
+ box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["1"]}; width: 100%; min-width: 0; min-height: 32px;
16
+ margin: 0; padding: ${c.space["0.5"]} ${c.space["1.5"]} ${c.space["0.5"]} ${c.space["2"]};
17
+ border: none; border-radius: ${c.radius.md}; background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 7%, transparent);
18
+ font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; cursor: pointer; text-align: left;
19
+ transition: background-color .12s ease, box-shadow .12s ease;
20
+ }
21
+ & .multiselect-trigger:hover { background: ${c.bgRaised}; }
22
+ & .multiselect-trigger[data-open], & .multiselect-trigger:focus-visible { outline: none; box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; background: ${c.bgSurface}; }
23
+ & .multiselect-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
24
+ & .multiselect-chips { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: ${c.space["1"]}; }
25
+ & .multiselect-placeholder { color: ${c.fgMuted}; }
26
+ & .multiselect-chevron { flex-shrink: 0; color: ${c.fgMuted}; transition: transform .15s ease; }
27
+ & .multiselect-trigger[data-open] .multiselect-chevron { transform: rotate(180deg); }
28
+
29
+ & .multiselect-listbox {
30
+ position: fixed; z-index: 60; box-sizing: border-box; display: flex; flex-direction: column; gap: ${c.space.px};
31
+ max-height: 300px; overflow-y: auto; padding: ${c.space["1"]};
32
+ border-radius: ${c.radius.xl}; background: ${c.bgPopover};
33
+ box-shadow: inset 0 0 0 1px ${c.ringPopover}, 0 0 0 1px ${c.shadowPopover1}, 0 4px 8px -4px ${c.shadowPopover2}, 0 4px 12px -2px ${c.shadowPopover3};
34
+ transform-origin: top left; opacity: 1; transform: none; transition: opacity .08s ease, transform .08s ease;
35
+ }
36
+ & .multiselect-listbox[data-up] { transform-origin: bottom left; }
37
+ & .multiselect-listbox { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
38
+ & .multiselect-search { display: flex; align-items: center; gap: ${c.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${c.space["1"]}) calc(-1 * ${c.space["1"]}) ${c.space["1"]}; padding: 0 ${c.space["2.5"]}; border-bottom: 1px solid ${c.borderSubtle}; color: ${c.fgMuted}; }
39
+ & .multiselect-search svg { flex-shrink: 0; }
40
+ & .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}; }
41
+ & .multiselect-search input::placeholder { color: ${c.fgMuted}; }
42
+ & .multiselect-option {
43
+ box-sizing: border-box; display: flex; align-items: center; gap: ${c.space["2"]}; height: 32px; flex-shrink: 0;
44
+ padding: 0 ${c.space["2"]}; margin: 0; border: none; background: none; border-radius: ${c.radius.md}; cursor: pointer; text-align: left;
45
+ }
46
+ & .multiselect-option[data-highlighted] { background: ${c.bgRaised}; }
47
+ & .multiselect-option-chip { flex: 0 1 auto; min-width: 0; overflow: hidden; }
48
+ & .multiselect-check { flex-shrink: 0; margin-left: 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; }
49
+ & .multiselect-option[aria-selected="true"] .multiselect-check { background: ${c.bgBrandSolid}; box-shadow: none; color: #fff; }
50
+ & .multiselect-empty { padding: ${c.space["3"]} ${c.space["2"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgMuted}; }
51
+
52
+ & .recordstable-tag { display: inline-flex; align-items: center; height: 22px; padding: 0 ${c.space["1"]}; border-radius: 7px; 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); }
53
+ & .recordstable-tag[data-tone="brand"] { background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent); color: color-mix(in srgb, ${c.bgBrandSolid} 82%, ${c.fgPrimary}); }
54
+ & .recordstable-tag[data-tone="success"] { background: color-mix(in srgb, ${c.bgSuccess} 16%, transparent); color: color-mix(in srgb, ${c.bgSuccess} 78%, ${c.fgPrimary}); }
55
+ & .recordstable-tag[data-tone="warning"] { background: color-mix(in srgb, ${c.bgWarning} 18%, transparent); color: color-mix(in srgb, ${c.bgWarning} 82%, ${c.fgPrimary}); }
56
+ & .recordstable-tag[data-tone="danger"] { background: color-mix(in srgb, ${c.bgDanger} 15%, transparent); color: color-mix(in srgb, ${c.bgDanger} 80%, ${c.fgPrimary}); }
57
+ & .recordstable-tag[data-tone="purple"] { background: color-mix(in srgb, ${c.bgPurple} 16%, transparent); color: color-mix(in srgb, ${c.bgPurple} 80%, ${c.fgPrimary}); }
58
+ & .recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, ${c.bgCyan} 18%, transparent); color: color-mix(in srgb, ${c.bgCyan} 82%, ${c.fgPrimary}); }
59
+ & .recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, ${c.bgOrange} 16%, transparent); color: color-mix(in srgb, ${c.bgOrange} 82%, ${c.fgPrimary}); }
60
+ `,
61
+ variants: {},
62
+ defaultVariants: {},
63
+ compoundVariants: [],
64
+ });
65
+ export const multiselectRecipe = Object.assign(resolveRecipe(config), {
66
+ config,
67
+ });
68
+ export default config;
@@ -0,0 +1,2 @@
1
+ export { Popover } from "./popover.js";
2
+ export { popoverRecipe } from "./popover.recipe.js";
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { popoverRecipe } from "./popover.recipe.js";
4
+ /**
5
+ * A generic anchored popover — the Menu/Select surface holding arbitrary
6
+ * content. Opens below the trigger (flipping up when there's no room),
7
+ * closes on outside pointer-down and Escape. Controlled or self-managed.
8
+ */
9
+ export function Popover({ trigger, children, align = "start", open: openProp, onOpenChange, "aria-label": ariaLabel, className, }) {
10
+ const [internalOpen, setInternalOpen] = React.useState(false);
11
+ const open = openProp ?? internalOpen;
12
+ const rootRef = React.useRef(null);
13
+ const triggerRef = React.useRef(null);
14
+ const [pos, setPos] = React.useState({});
15
+ const [up, setUp] = React.useState(false);
16
+ const setOpen = React.useCallback((next) => {
17
+ if (openProp === undefined)
18
+ setInternalOpen(next);
19
+ onOpenChange?.(next);
20
+ }, [openProp, onOpenChange]);
21
+ const place = () => {
22
+ const el = triggerRef.current;
23
+ if (!el)
24
+ return;
25
+ const rect = el.getBoundingClientRect();
26
+ const gap = 6;
27
+ const flipUp = window.innerHeight - rect.bottom < 280;
28
+ const style = flipUp
29
+ ? { bottom: window.innerHeight - rect.top + gap }
30
+ : { top: rect.bottom + gap };
31
+ if (align === "end")
32
+ style.right = window.innerWidth - rect.right;
33
+ else
34
+ style.left = rect.left;
35
+ setUp(flipUp);
36
+ setPos(style);
37
+ };
38
+ // Position when opening (before paint so it never flashes at 0,0).
39
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reposition each time it opens
40
+ React.useLayoutEffect(() => {
41
+ if (open)
42
+ place();
43
+ }, [open]);
44
+ React.useEffect(() => {
45
+ if (!open)
46
+ return;
47
+ const onDown = (e) => {
48
+ if (!rootRef.current?.contains(e.target))
49
+ setOpen(false);
50
+ };
51
+ document.addEventListener("pointerdown", onDown);
52
+ return () => document.removeEventListener("pointerdown", onDown);
53
+ }, [open, setOpen]);
54
+ const triggerEl = React.cloneElement(trigger, {
55
+ ref: triggerRef,
56
+ "aria-haspopup": "dialog",
57
+ "aria-expanded": open,
58
+ onClick: (e) => {
59
+ trigger.props.onClick?.(e);
60
+ setOpen(!open);
61
+ },
62
+ });
63
+ return (_jsxs("div", { ref: rootRef, className: popoverRecipe({ className }), children: [triggerEl, open && (_jsx("div", { className: "popover-content", role: "dialog", "aria-label": ariaLabel, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: (e) => {
64
+ if (e.key === "Escape") {
65
+ e.preventDefault();
66
+ setOpen(false);
67
+ }
68
+ }, children: children }))] }));
69
+ }
70
+ Popover.displayName = "Popover";
@@ -0,0 +1,18 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // A generic anchored popover — the same measured surface as Menu / Select
4
+ // (radius 12, 4px padding, layered soft shadow, inset border), but holding
5
+ // arbitrary content. Rendered inside a display:contents root and lifted out of
6
+ // flow with position:fixed, so its styles survive while it floats.
7
+ const config = defineRecipe({
8
+ name: "popover",
9
+ base: `display: contents; font-family: ${c.font.sans};
10
+ & .popover-content { position: fixed; z-index: 60; box-sizing: border-box; min-width: 200px; max-width: min(360px, 92vw); padding: ${c.space["1"]}; border-radius: ${c.radius.xl}; background: ${c.bgPopover}; color: ${c.fgPrimary}; box-shadow: inset 0 0 0 1px ${c.ringPopover}, 0 0 0 1px ${c.shadowPopover1}, 0 4px 8px -4px ${c.shadowPopover2}, 0 4px 12px -2px ${c.shadowPopover3}; opacity: 1; transform: none; transition: opacity 0.08s ease, transform 0.08s ease; transform-origin: top left; @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
11
+ & .popover-content[data-align="end"] { transform-origin: top right; }
12
+ & .popover-content[data-up] { transform-origin: bottom left; }`,
13
+ variants: {},
14
+ defaultVariants: {},
15
+ compoundVariants: [],
16
+ });
17
+ export const popoverRecipe = resolveRecipe(config);
18
+ export default config;
@@ -0,0 +1,2 @@
1
+ export { Progress } from "./progress.js";
2
+ export { progressRecipe } from "./progress.recipe.js";
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { progressRecipe } from "./progress.recipe.js";
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.
7
+ */
8
+ export function Progress({ value, max = 100, segments, "aria-label": ariaLabel, className, }) {
9
+ const pct = Math.max(0, Math.min(100, (value / max) * 100));
10
+ const aria = {
11
+ role: "progressbar",
12
+ "aria-valuenow": value,
13
+ "aria-valuemin": 0,
14
+ "aria-valuemax": max,
15
+ "aria-label": ariaLabel,
16
+ };
17
+ if (segments && segments > 0) {
18
+ const filled = Math.round((pct / 100) * segments);
19
+ return (_jsx("div", { ...aria, className: progressRecipe({ className }), "data-segmented": "", children: Array.from({ length: segments }, (_, i) => (_jsx("span", { className: "progress-seg", "data-filled": i < filled ? "" : undefined }, i))) }));
20
+ }
21
+ return (_jsx("div", { ...aria, className: progressRecipe({ className }), children: _jsx("span", { className: "progress-fill", style: { width: `${pct}%` } }) }));
22
+ }
23
+ Progress.displayName = "Progress";
@@ -0,0 +1,17 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // A progress indicator — a continuous brand-filled track, or a segmented row of
4
+ // pills (Attio's "Getting started 3/6" bar).
5
+ const config = defineRecipe({
6
+ name: "progress",
7
+ base: `display: block; box-sizing: border-box; height: 6px; border-radius: ${c.radius.full}; background: color-mix(in srgb, ${c.fgPrimary} 8%, transparent); overflow: hidden;
8
+ & .progress-fill { display: block; height: 100%; border-radius: ${c.radius.full}; background: ${c.bgBrandSolid}; transition: width 0.25s ease; }
9
+ &[data-segmented] { display: flex; gap: ${c.space["1"]}; background: transparent; overflow: visible; }
10
+ &[data-segmented] .progress-seg { flex: 1; height: 100%; border-radius: ${c.radius.full}; background: color-mix(in srgb, ${c.fgPrimary} 8%, transparent); transition: background 0.2s; }
11
+ &[data-segmented] .progress-seg[data-filled] { background: ${c.bgBrandSolid}; }`,
12
+ variants: {},
13
+ defaultVariants: {},
14
+ compoundVariants: [],
15
+ });
16
+ export const progressRecipe = resolveRecipe(config);
17
+ export default config;
@@ -0,0 +1,2 @@
1
+ export { Radio, RadioGroup, } from "./radiogroup.js";
2
+ export { radiogroupRecipe } from "./radiogroup.recipe.js";
@@ -0,0 +1,46 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { radiogroupRecipe } from "./radiogroup.recipe.js";
4
+ const RadioContext = React.createContext(null);
5
+ /**
6
+ * A radio group — measured from Attio's settings. `default` renders rows (a ring
7
+ * that fills brand when selected + a label); `card` renders a row of bordered
8
+ * preview cards whose border turns brand when picked (the Theme picker). Arrow
9
+ * keys move between options (roving focus). Controlled or uncontrolled.
10
+ */
11
+ export function RadioGroup({ value, defaultValue, onValueChange, variant = "default", name, className, "aria-label": ariaLabel, children, }) {
12
+ const [internal, setInternal] = React.useState(defaultValue);
13
+ const selected = value ?? internal;
14
+ const generatedName = React.useId();
15
+ const groupName = name ?? generatedName;
16
+ const select = React.useCallback((v) => {
17
+ if (value === undefined)
18
+ setInternal(v);
19
+ onValueChange?.(v);
20
+ }, [value, onValueChange]);
21
+ const onKeyDown = (e) => {
22
+ if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft"].includes(e.key))
23
+ return;
24
+ const radios = Array.from(e.currentTarget.querySelectorAll('[role="radio"]:not([disabled])'));
25
+ const idx = radios.indexOf(document.activeElement);
26
+ const fwd = e.key === "ArrowDown" || e.key === "ArrowRight";
27
+ const next = radios[(idx + (fwd ? 1 : -1) + radios.length) % radios.length];
28
+ if (next) {
29
+ e.preventDefault();
30
+ next.focus();
31
+ next.dataset.value && select(next.dataset.value);
32
+ }
33
+ };
34
+ return (_jsx(RadioContext.Provider, { value: { value: selected, select, name: groupName, variant }, children: _jsx("div", { role: "radiogroup", "aria-label": ariaLabel, "data-variant": variant, className: radiogroupRecipe({ className }), onKeyDown: onKeyDown, children: children }) }));
35
+ }
36
+ RadioGroup.displayName = "RadioGroup";
37
+ export function Radio({ value, label, children, disabled }) {
38
+ const ctx = React.useContext(RadioContext);
39
+ if (!ctx)
40
+ throw new Error("Radio must be used within a RadioGroup");
41
+ const checked = ctx.value === value;
42
+ return (_jsx("button", { type: "button", role: "radio", "aria-checked": checked, name: ctx.name, disabled: disabled, "data-value": value, "data-checked": checked ? "" : undefined,
43
+ // roving tabindex: the selected (or first, when none) radio is tabbable
44
+ tabIndex: checked ? 0 : -1, className: "radio", onClick: () => ctx.select(value), children: ctx.variant === "card" ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "radio-preview", children: children }), _jsx("span", { className: "radio-cardfoot", children: _jsx("span", { className: "radio-label", children: label }) })] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "radio-dot" }), _jsx("span", { className: "radio-label", children: label ?? children })] })) }));
45
+ }
46
+ Radio.displayName = "Radio";
@@ -0,0 +1,37 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // A radio group with two shapes, measured from Attio's settings: the default is
4
+ // a column of rows (a 16px ring that fills brand when selected + a label); the
5
+ // `card` variant is a row of bordered preview cards (radius 16) whose border
6
+ // turns brand (#266df0) when selected — the Appearance "Theme" picker.
7
+ const config = defineRecipe({
8
+ name: "radiogroup",
9
+ base: `
10
+ display: flex; flex-direction: column; gap: ${c.space["1"]}; font-family: ${c.font.sans};
11
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
12
+
13
+ & .radio { display: flex; align-items: center; gap: ${c.space["2"]}; margin: 0; padding: ${c.space["1"]} ${c.space["1"]}; border: none; background: none; border-radius: ${c.radius.md}; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; text-align: left; cursor: pointer; }
14
+ & .radio:disabled { opacity: 0.5; cursor: not-allowed; }
15
+ & .radio:focus-visible { outline: none; }
16
+ & .radio-dot { position: relative; flex-shrink: 0; width: 16px; height: 16px; border-radius: ${c.radius.full}; box-shadow: inset 0 0 0 1.5px ${c.borderDefault}; transition: box-shadow .12s ease; }
17
+ & .radio:hover .radio-dot { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, ${c.fgPrimary} 40%, transparent); }
18
+ & .radio[data-checked] .radio-dot { box-shadow: inset 0 0 0 1.5px ${c.bgBrandSolid}; }
19
+ & .radio[data-checked] .radio-dot::after { content: ""; position: absolute; inset: 0; margin: auto; width: 8px; height: 8px; border-radius: ${c.radius.full}; background: ${c.bgBrandSolid}; }
20
+ & .radio:focus-visible .radio-dot { outline: 2px solid ${c.bgBrandSolid}; outline-offset: 2px; }
21
+ & .radio-label { min-width: 0; }
22
+
23
+ &[data-variant="card"] { flex-direction: row; flex-wrap: wrap; gap: ${c.space["3"]}; }
24
+ &[data-variant="card"] .radio { flex: 1 1 150px; min-width: 150px; flex-direction: column; align-items: stretch; gap: 0; padding: 0; border-radius: ${c.radius["2xl"]}; box-shadow: inset 0 0 0 1px ${c.borderDefault}; overflow: hidden; transition: box-shadow .12s ease; }
25
+ &[data-variant="card"] .radio:hover { box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 22%, transparent); }
26
+ &[data-variant="card"] .radio[data-checked] { box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; }
27
+ &[data-variant="card"] .radio-preview { flex: 1; min-height: 0; background: ${c.bgRaised}; }
28
+ &[data-variant="card"] .radio-cardfoot { display: flex; align-items: center; gap: ${c.space["2"]}; padding: ${c.space["2"]} ${c.space["2.5"]}; border-top: 1px solid ${c.borderSubtle}; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; }
29
+ `,
30
+ variants: {},
31
+ defaultVariants: {},
32
+ compoundVariants: [],
33
+ });
34
+ export const radiogroupRecipe = Object.assign(resolveRecipe(config), {
35
+ config,
36
+ });
37
+ export default config;
@@ -0,0 +1,2 @@
1
+ export { Segmented, } from "./segmented.js";
2
+ export { segmentedRecipe } from "./segmented.recipe.js";