@crisp-ui-kit/crisp 0.57.2 → 0.57.4

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 (200) hide show
  1. package/dist/assigneepicker.css +13 -0
  2. package/dist/assigneepicker.d.ts +7 -0
  3. package/dist/assigneepicker.layered.css +15 -0
  4. package/dist/button.css +3 -0
  5. package/dist/button.d.ts +2 -2
  6. package/dist/button.layered.css +3 -0
  7. package/dist/chip.css +1 -1
  8. package/dist/chip.layered.css +1 -1
  9. package/dist/cjs/components/breadcrumb/breadcrumb.js +1 -1
  10. package/dist/cjs/components/button/button.recipe.js +33 -0
  11. package/dist/cjs/components/chip/chip.recipe.js +3 -1
  12. package/dist/cjs/components/datatable/datatable.js +6 -3
  13. package/dist/cjs/components/datatable/datatable.recipe.js +45 -6
  14. package/dist/cjs/components/datepickerfield/datepickerfield.js +2 -2
  15. package/dist/cjs/components/datepickerfield/datepickerfield.recipe.js +16 -3
  16. package/dist/cjs/components/menu/menu.js +65 -9
  17. package/dist/cjs/components/multiselect/multiselect.js +3 -0
  18. package/dist/cjs/components/multiselect/multiselect.recipe.js +18 -0
  19. package/dist/cjs/components/select/select.js +2 -2
  20. package/dist/cjs/components/select/select.recipe.js +19 -0
  21. package/dist/cjs/components/table/table.recipe.js +7 -1
  22. package/dist/cjs/core/floating-portal.js +7 -4
  23. package/dist/cjs/core/index.js +2 -1
  24. package/dist/cjs/core/place-floating.js +38 -0
  25. package/dist/cjs/index.js +8 -0
  26. package/dist/cjs/product/assigneepicker/assigneepicker.js +127 -0
  27. package/dist/cjs/product/assigneepicker/assigneepicker.recipe.js +36 -0
  28. package/dist/cjs/product/assigneepicker/index.js +7 -0
  29. package/dist/cjs/product/inbox/inbox.js +11 -3
  30. package/dist/cjs/product/notesgallery/index.js +7 -0
  31. package/dist/cjs/product/notesgallery/notesgallery.js +80 -0
  32. package/dist/cjs/product/notesgallery/notesgallery.recipe.js +46 -0
  33. package/dist/cjs/product/objectpagemenu/index.js +17 -0
  34. package/dist/cjs/product/objectpagemenu/objectpagemenu.js +26 -0
  35. package/dist/cjs/product/objectpagemenu/objectpagemenu.recipe.js +30 -0
  36. package/dist/cjs/product/objectsettings/objectsettings.js +107 -14
  37. package/dist/cjs/product/recordpage/index.js +2 -1
  38. package/dist/cjs/product/recordpage/recordpage.js +112 -7
  39. package/dist/cjs/product/recordpage/recordpage.recipe.js +63 -0
  40. package/dist/cjs/product/recordpicker/index.js +7 -0
  41. package/dist/cjs/product/recordpicker/recordpicker.js +100 -0
  42. package/dist/cjs/product/recordpicker/recordpicker.recipe.js +36 -0
  43. package/dist/cjs/product/savedviews/index.js +2 -1
  44. package/dist/cjs/product/savedviews/savedviews.js +22 -0
  45. package/dist/cjs/product/savedviews/savedviews.recipe.js +47 -0
  46. package/dist/cjs/product/share/index.js +7 -0
  47. package/dist/cjs/product/share/share.js +179 -0
  48. package/dist/cjs/product/share/share.recipe.js +114 -0
  49. package/dist/cjs/product/shortcutssheet/index.js +17 -0
  50. package/dist/cjs/product/shortcutssheet/shortcutssheet.js +125 -0
  51. package/dist/cjs/product/shortcutssheet/shortcutssheet.recipe.js +50 -0
  52. package/dist/cjs/product/taskeditpopover/index.js +7 -0
  53. package/dist/cjs/product/taskeditpopover/taskeditpopover.js +159 -0
  54. package/dist/cjs/product/taskeditpopover/taskeditpopover.recipe.js +68 -0
  55. package/dist/cjs/product/workspaceswitchermenu/index.js +17 -0
  56. package/dist/cjs/product/workspaceswitchermenu/workspaceswitchermenu.js +38 -0
  57. package/dist/cjs/product/workspaceswitchermenu/workspaceswitchermenu.recipe.js +34 -0
  58. package/dist/cjs/tokens/palette.js +27 -0
  59. package/dist/cjs/tokens/semantic.js +20 -0
  60. package/dist/datatable.css +50 -11
  61. package/dist/datatable.layered.css +50 -11
  62. package/dist/datepickerfield.css +8 -2
  63. package/dist/datepickerfield.d.ts +2 -2
  64. package/dist/datepickerfield.layered.css +8 -2
  65. package/dist/esm/components/breadcrumb/breadcrumb.js +1 -1
  66. package/dist/esm/components/button/button.recipe.js +33 -0
  67. package/dist/esm/components/chip/chip.recipe.js +3 -1
  68. package/dist/esm/components/datatable/datatable.js +6 -3
  69. package/dist/esm/components/datatable/datatable.recipe.js +45 -6
  70. package/dist/esm/components/datepickerfield/datepickerfield.js +2 -2
  71. package/dist/esm/components/datepickerfield/datepickerfield.recipe.js +16 -3
  72. package/dist/esm/components/menu/menu.js +64 -8
  73. package/dist/esm/components/multiselect/multiselect.js +3 -0
  74. package/dist/esm/components/multiselect/multiselect.recipe.js +18 -0
  75. package/dist/esm/components/select/select.js +2 -2
  76. package/dist/esm/components/select/select.recipe.js +19 -0
  77. package/dist/esm/components/table/table.recipe.js +7 -1
  78. package/dist/esm/core/floating-portal.js +7 -4
  79. package/dist/esm/core/index.js +1 -1
  80. package/dist/esm/core/place-floating.js +37 -0
  81. package/dist/esm/index.js +8 -0
  82. package/dist/esm/product/assigneepicker/assigneepicker.js +91 -0
  83. package/dist/esm/product/assigneepicker/assigneepicker.recipe.js +33 -0
  84. package/dist/esm/product/assigneepicker/index.js +2 -0
  85. package/dist/esm/product/inbox/inbox.js +11 -3
  86. package/dist/esm/product/notesgallery/index.js +2 -0
  87. package/dist/esm/product/notesgallery/notesgallery.js +44 -0
  88. package/dist/esm/product/notesgallery/notesgallery.recipe.js +43 -0
  89. package/dist/esm/product/objectpagemenu/index.js +1 -0
  90. package/dist/esm/product/objectpagemenu/objectpagemenu.js +23 -0
  91. package/dist/esm/product/objectpagemenu/objectpagemenu.recipe.js +27 -0
  92. package/dist/esm/product/objectsettings/objectsettings.js +106 -13
  93. package/dist/esm/product/recordpage/index.js +1 -1
  94. package/dist/esm/product/recordpage/recordpage.js +112 -8
  95. package/dist/esm/product/recordpage/recordpage.recipe.js +63 -0
  96. package/dist/esm/product/recordpicker/index.js +2 -0
  97. package/dist/esm/product/recordpicker/recordpicker.js +64 -0
  98. package/dist/esm/product/recordpicker/recordpicker.recipe.js +33 -0
  99. package/dist/esm/product/savedviews/index.js +1 -1
  100. package/dist/esm/product/savedviews/savedviews.js +22 -1
  101. package/dist/esm/product/savedviews/savedviews.recipe.js +47 -0
  102. package/dist/esm/product/share/index.js +2 -0
  103. package/dist/esm/product/share/share.js +143 -0
  104. package/dist/esm/product/share/share.recipe.js +111 -0
  105. package/dist/esm/product/shortcutssheet/index.js +1 -0
  106. package/dist/esm/product/shortcutssheet/shortcutssheet.js +88 -0
  107. package/dist/esm/product/shortcutssheet/shortcutssheet.recipe.js +47 -0
  108. package/dist/esm/product/taskeditpopover/index.js +2 -0
  109. package/dist/esm/product/taskeditpopover/taskeditpopover.js +123 -0
  110. package/dist/esm/product/taskeditpopover/taskeditpopover.recipe.js +65 -0
  111. package/dist/esm/product/workspaceswitchermenu/index.js +1 -0
  112. package/dist/esm/product/workspaceswitchermenu/workspaceswitchermenu.js +35 -0
  113. package/dist/esm/product/workspaceswitchermenu/workspaceswitchermenu.recipe.js +31 -0
  114. package/dist/esm/tokens/palette.js +27 -0
  115. package/dist/esm/tokens/semantic.js +20 -0
  116. package/dist/multiselect.css +18 -0
  117. package/dist/multiselect.layered.css +18 -0
  118. package/dist/native/tokens.d.ts +1 -1
  119. package/dist/native/tokens.js +56 -4
  120. package/dist/notesgallery.css +15 -0
  121. package/dist/notesgallery.d.ts +7 -0
  122. package/dist/notesgallery.layered.css +17 -0
  123. package/dist/objectpagemenu.css +3 -0
  124. package/dist/objectpagemenu.d.ts +7 -0
  125. package/dist/objectpagemenu.layered.css +5 -0
  126. package/dist/recordpage.css +59 -0
  127. package/dist/recordpage.layered.css +59 -0
  128. package/dist/recordpicker.css +13 -0
  129. package/dist/recordpicker.d.ts +7 -0
  130. package/dist/recordpicker.layered.css +15 -0
  131. package/dist/savedviews.css +47 -0
  132. package/dist/savedviews.layered.css +47 -0
  133. package/dist/select.css +19 -0
  134. package/dist/select.layered.css +19 -0
  135. package/dist/share.css +84 -0
  136. package/dist/share.d.ts +7 -0
  137. package/dist/share.layered.css +86 -0
  138. package/dist/shortcutssheet.css +24 -0
  139. package/dist/shortcutssheet.d.ts +7 -0
  140. package/dist/shortcutssheet.layered.css +26 -0
  141. package/dist/styles.css +438 -16
  142. package/dist/styles.layered.css +438 -16
  143. package/dist/table.css +7 -2
  144. package/dist/table.layered.css +7 -2
  145. package/dist/taskeditpopover.css +36 -0
  146. package/dist/taskeditpopover.d.ts +7 -0
  147. package/dist/taskeditpopover.layered.css +38 -0
  148. package/dist/tokens.css +26 -0
  149. package/dist/tokens.json +56 -4
  150. package/dist/types/components/breadcrumb/breadcrumb.d.ts +9 -1
  151. package/dist/types/components/button/button.d.ts +2 -0
  152. package/dist/types/components/button/button.recipe.d.ts +12 -0
  153. package/dist/types/components/chip/chip.recipe.d.ts +1 -1
  154. package/dist/types/components/datatable/datatable.d.ts +8 -0
  155. package/dist/types/components/datepickerfield/datepickerfield.d.ts +4 -1
  156. package/dist/types/components/datepickerfield/datepickerfield.recipe.d.ts +18 -3
  157. package/dist/types/components/menu/menu.d.ts +50 -7
  158. package/dist/types/components/multiselect/multiselect.d.ts +9 -0
  159. package/dist/types/components/select/select.d.ts +10 -0
  160. package/dist/types/core/floating-portal.d.ts +6 -5
  161. package/dist/types/core/index.d.ts +1 -1
  162. package/dist/types/core/place-floating.d.ts +37 -0
  163. package/dist/types/index.d.ts +8 -0
  164. package/dist/types/product/assigneepicker/assigneepicker.d.ts +36 -0
  165. package/dist/types/product/assigneepicker/assigneepicker.recipe.d.ts +3 -0
  166. package/dist/types/product/assigneepicker/index.d.ts +2 -0
  167. package/dist/types/product/inbox/inbox.d.ts +13 -1
  168. package/dist/types/product/notesgallery/index.d.ts +2 -0
  169. package/dist/types/product/notesgallery/notesgallery.d.ts +54 -0
  170. package/dist/types/product/notesgallery/notesgallery.recipe.d.ts +3 -0
  171. package/dist/types/product/objectpagemenu/index.d.ts +1 -0
  172. package/dist/types/product/objectpagemenu/objectpagemenu.d.ts +40 -0
  173. package/dist/types/product/objectpagemenu/objectpagemenu.recipe.d.ts +17 -0
  174. package/dist/types/product/objectsettings/objectsettings.d.ts +71 -15
  175. package/dist/types/product/recordpage/index.d.ts +1 -1
  176. package/dist/types/product/recordpage/recordpage.d.ts +142 -1
  177. package/dist/types/product/recordpicker/index.d.ts +2 -0
  178. package/dist/types/product/recordpicker/recordpicker.d.ts +38 -0
  179. package/dist/types/product/recordpicker/recordpicker.recipe.d.ts +3 -0
  180. package/dist/types/product/savedviews/index.d.ts +1 -1
  181. package/dist/types/product/savedviews/savedviews.d.ts +36 -0
  182. package/dist/types/product/share/index.d.ts +2 -0
  183. package/dist/types/product/share/share.d.ts +85 -0
  184. package/dist/types/product/share/share.recipe.d.ts +3 -0
  185. package/dist/types/product/shortcutssheet/index.d.ts +1 -0
  186. package/dist/types/product/shortcutssheet/shortcutssheet.d.ts +49 -0
  187. package/dist/types/product/shortcutssheet/shortcutssheet.recipe.d.ts +3 -0
  188. package/dist/types/product/taskeditpopover/index.d.ts +2 -0
  189. package/dist/types/product/taskeditpopover/taskeditpopover.d.ts +67 -0
  190. package/dist/types/product/taskeditpopover/taskeditpopover.recipe.d.ts +3 -0
  191. package/dist/types/product/workspaceswitchermenu/index.d.ts +1 -0
  192. package/dist/types/product/workspaceswitchermenu/workspaceswitchermenu.d.ts +54 -0
  193. package/dist/types/product/workspaceswitchermenu/workspaceswitchermenu.recipe.d.ts +19 -0
  194. package/dist/types/tokens/contract.d.ts +13 -0
  195. package/dist/types/tokens/palette.d.ts +52 -0
  196. package/dist/types/tokens/semantic.d.ts +20 -0
  197. package/dist/workspaceswitchermenu.css +4 -0
  198. package/dist/workspaceswitchermenu.d.ts +7 -0
  199. package/dist/workspaceswitchermenu.layered.css +6 -0
  200. package/package.json +41 -1
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check } from "lucide-react";
3
3
  import * as React from "react";
4
- import { FloatingPortal, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, } from "../../core/index.js";
4
+ import { FloatingPortal, isInsideLaterFloatingLayer, placeFloating, placeSubmenu, registerFloatingLayer, } from "../../core/index.js";
5
5
  import { menuRecipe } from "./menu.recipe.js";
6
6
  import { useMenuKeyboard } from "./menu-keyboard.js";
7
7
  const MenuContext = React.createContext(null);
@@ -12,7 +12,7 @@ const MenuContext = React.createContext(null);
12
12
  * typeahead, Enter/Space select, Esc closes and returns focus to the trigger);
13
13
  * closes on outside click. Flips up / right-aligns when space is tight.
14
14
  */
15
- export function Menu({ trigger, children, align = "start", open: openProp, defaultOpen, onOpenChange, className, contentClassName, "aria-label": ariaLabel, }) {
15
+ export function Menu({ trigger, children, align = "start", placement = "menu", open: openProp, defaultOpen, onOpenChange, className, contentClassName, "aria-label": ariaLabel, }) {
16
16
  // The surface and the button that opens it were two unconnected nodes: the
17
17
  // menu/dialog had no id, so nothing could point at it, and no accessible
18
18
  // name, so a screen reader announced a bare "menu"/"dialog". Labelling the
@@ -28,6 +28,7 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
28
28
  onOpenChange?.(next);
29
29
  }, [openProp, onOpenChange]);
30
30
  const [up, setUp] = React.useState(false);
31
+ const [submenuAlign, setSubmenuAlign] = React.useState("start");
31
32
  const [pos, setPos] = React.useState({});
32
33
  const rootRef = React.useRef(null);
33
34
  const triggerRef = React.useRef(null);
@@ -39,6 +40,38 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
39
40
  const rect = triggerRef.current?.getBoundingClientRect();
40
41
  if (!rect)
41
42
  return;
43
+ if (placement === "submenu") {
44
+ // The content div is already in the DOM by this point (it's part of
45
+ // the same render this effect runs after, just not yet painted) so its
46
+ // real rendered size/padding are measurable, not guessed.
47
+ const content = contentRef.current;
48
+ const size = content?.getBoundingClientRect() ?? {
49
+ width: 200,
50
+ height: 0,
51
+ };
52
+ const padTop = content
53
+ ? Number.parseFloat(getComputedStyle(content).paddingTop) || 0
54
+ : 0;
55
+ const { style, align: side, up: flipUp, } = placeSubmenu({
56
+ // Not a `{...rect}` spread: DOMRect's numeric fields are accessors
57
+ // on its prototype, not own enumerable properties, so a spread
58
+ // silently drops everything except the two overridden below.
59
+ rect: {
60
+ left: rect.left,
61
+ right: rect.right,
62
+ top: rect.top - padTop,
63
+ bottom: rect.bottom - padTop,
64
+ },
65
+ size,
66
+ // Measured flush against the item, no gap (crisp#616).
67
+ gap: 0,
68
+ anchor: triggerRef.current,
69
+ });
70
+ setSubmenuAlign(side);
71
+ setUp(flipUp);
72
+ setPos(style);
73
+ return;
74
+ }
42
75
  const { style, up: flipUp } = placeFloating({
43
76
  rect,
44
77
  align,
@@ -104,12 +137,28 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
104
137
  document.addEventListener("keydown", onKey);
105
138
  return () => document.removeEventListener("keydown", onKey);
106
139
  }, [open, close]);
107
- const { onKeyDown: onContentKeyDown } = useMenuKeyboard({
140
+ const { onKeyDown: onContentKeyDown, items } = useMenuKeyboard({
108
141
  open,
109
142
  close,
110
143
  dismiss: () => setOpen(false),
111
144
  contentRef,
112
145
  });
146
+ // Roving tabindex (crisp#964): every item hardcodes tabIndex={-1} for the
147
+ // arrow-key model above, which means none of them is ever a real Tab stop
148
+ // on its own -- kept in sync with wherever focus actually lands, same as
149
+ // MenuSheet's identical fix (#966).
150
+ const onContentFocus = React.useCallback((e) => {
151
+ for (const el of items())
152
+ el.tabIndex = -1;
153
+ e.target.tabIndex = 0;
154
+ }, [items]);
155
+ React.useEffect(() => {
156
+ if (!open)
157
+ return;
158
+ const [first] = items();
159
+ if (first)
160
+ first.tabIndex = 0;
161
+ }, [open, items]);
113
162
  const triggerEl = React.cloneElement(trigger, {
114
163
  ref: triggerRef,
115
164
  id: triggerId,
@@ -125,17 +174,24 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
125
174
  });
126
175
  return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsx("div", { ref: contentRef, className: contentClassName
127
176
  ? `crisp-menu-content ${contentClassName}`
128
- : "crisp-menu-content", role: "menu", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, children: _jsx(MenuContext.Provider, { value: { close }, children: children }) }) }))] }));
177
+ : "crisp-menu-content", role: "menu", id: surfaceId, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerId, "data-align": placement === "submenu" ? submenuAlign : align, "data-up": up ? "" : undefined, style: pos, onKeyDown: onContentKeyDown, onFocus: onContentFocus, children: _jsx(MenuContext.Provider, { value: { close }, children: children }) }) }))] }));
129
178
  }
130
179
  Menu.displayName = "Menu";
131
- export function MenuItem({ children, icon, shortcut, count, danger, disabled, onSelect, closeOnSelect = true, }) {
180
+ /**
181
+ * A row inside a `Menu`. Forwards its ref and spreads unrecognised button
182
+ * props (crisp#616) so it can double as another `Menu`'s own `trigger` —
183
+ * `Menu` clones its trigger with `ref`/`onClick`/`aria-*`, which a plain
184
+ * function component would silently drop.
185
+ */
186
+ export const MenuItem = React.forwardRef(function MenuItem({ children, icon, shortcut, count, trailingIcon, danger, disabled, onSelect, closeOnSelect = true, onClick, ...rest }, ref) {
132
187
  const ctx = React.useContext(MenuContext);
133
- return (_jsxs("button", { type: "button", role: "menuitem", tabIndex: -1, disabled: disabled, "data-disabled": disabled ? "" : undefined, className: `crisp-menu-item${danger ? " crisp-menu-item--danger" : ""}`, onClick: () => {
188
+ return (_jsxs("button", { ref: ref, type: "button", role: "menuitem", tabIndex: -1, disabled: disabled, "data-disabled": disabled ? "" : undefined, className: `crisp-menu-item${danger ? " crisp-menu-item--danger" : ""}`, onClick: (e) => {
189
+ onClick?.(e);
134
190
  onSelect?.();
135
191
  if (closeOnSelect)
136
192
  ctx?.close();
137
- }, children: [icon, _jsx("span", { className: "crisp-menu-item-label", children: children }), count != null && _jsx("span", { className: "crisp-menu-item-count", children: count }), shortcut && _jsx("span", { className: "crisp-menu-item-shortcut", children: shortcut })] }));
138
- }
193
+ }, ...rest, children: [icon, _jsx("span", { className: "crisp-menu-item-label", children: children }), count != null && (_jsx("span", { className: "crisp-menu-item-count", children: count })), shortcut && (_jsx("span", { className: "crisp-menu-item-shortcut", children: shortcut })), trailingIcon] }));
194
+ });
139
195
  MenuItem.displayName = "MenuItem";
140
196
  export function MenuCheckboxItem({ children, checked, onCheckedChange, disabled, }) {
141
197
  return (_jsxs("button", { type: "button", role: "menuitemcheckbox", "aria-checked": checked, tabIndex: -1, disabled: disabled, "data-disabled": disabled ? "" : undefined, className: "crisp-menu-item", onClick: () => onCheckedChange?.(!checked), children: [checked ? (_jsx(Check, { className: "crisp-menu-item-check", size: 15, strokeWidth: 2 })) : (_jsx("span", { className: "crisp-menu-item-check--placeholder" })), _jsx("span", { className: "crisp-menu-item-label", children: children })] }));
@@ -21,6 +21,9 @@ function toneFor(t) {
21
21
  return TONES[h % TONES.length];
22
22
  }
23
23
  function Chip({ opt }) {
24
+ if (opt.swatch) {
25
+ return (_jsx("span", { className: "crisp-multiselect-swatch", "data-swatch": opt.swatch, children: opt.label }));
26
+ }
24
27
  return (_jsx("span", { className: "crisp-recordstable-tag", "data-tone": opt.tone ?? toneFor(opt.value), children: opt.label }));
25
28
  }
26
29
  /**
@@ -104,6 +104,24 @@ const config = defineRecipe({
104
104
  .crisp-recordstable-tag[data-tone="purple"] { background: color-mix(in srgb, ${c.bgPurple} 16%, transparent); color: color-mix(in srgb, ${c.bgPurple} 80%, ${c.fgPrimary}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgPurple} 30%, transparent); }
105
105
  .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, ${c.bgCyan} 18%, transparent); color: color-mix(in srgb, ${c.bgCyan} 82%, ${c.fgPrimary}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgCyan} 30%, transparent); }
106
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); }
107
+ /* your-moon/crisp#998: Attio's own 12-colour tag palette (measured live,
108
+ see tokens/palette.ts's tagSwatch1 comment) -- flat black text, no
109
+ ring/border, unlike the tone family above. 7px radius/10px text are
110
+ measured literals, no matching step in the shared scale (same as
111
+ Select's own .crisp-select-swatch). */
112
+ .crisp-multiselect-swatch { display: inline-flex; align-items: center; height: ${c.size.chip}; padding: 0 ${c.space["1"]}; border-radius: 7px; font-size: 10px; font-weight: ${c.weight.regular}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: ${c.fgTagSwatch}; }
113
+ .crisp-multiselect-swatch[data-swatch="1"] { background: ${c.bgTagSwatch1}; }
114
+ .crisp-multiselect-swatch[data-swatch="2"] { background: ${c.bgTagSwatch2}; }
115
+ .crisp-multiselect-swatch[data-swatch="3"] { background: ${c.bgTagSwatch3}; }
116
+ .crisp-multiselect-swatch[data-swatch="4"] { background: ${c.bgTagSwatch4}; }
117
+ .crisp-multiselect-swatch[data-swatch="5"] { background: ${c.bgTagSwatch5}; }
118
+ .crisp-multiselect-swatch[data-swatch="6"] { background: ${c.bgTagSwatch6}; }
119
+ .crisp-multiselect-swatch[data-swatch="7"] { background: ${c.bgTagSwatch7}; }
120
+ .crisp-multiselect-swatch[data-swatch="8"] { background: ${c.bgTagSwatch8}; }
121
+ .crisp-multiselect-swatch[data-swatch="9"] { background: ${c.bgTagSwatch9}; }
122
+ .crisp-multiselect-swatch[data-swatch="10"] { background: ${c.bgTagSwatch10}; }
123
+ .crisp-multiselect-swatch[data-swatch="11"] { background: ${c.bgTagSwatch11}; }
124
+ .crisp-multiselect-swatch[data-swatch="12"] { background: ${c.bgTagSwatch12}; }
107
125
  `,
108
126
  variants: {
109
127
  // Owner's follow-up after #867/#879/#882 (crisp/#889): SIZE is Attio's
@@ -181,7 +181,7 @@ 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: 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) => {
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 ? (selectedOption.swatch ? (_jsx("span", { className: "crisp-select-swatch", "data-swatch": selectedOption.swatch, children: selectedOption.triggerLabel ?? selectedOption.label })) : (_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);
@@ -192,7 +192,7 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
192
192
  // heading, not one each.
193
193
  const showGroupLabel = opt.group !== undefined &&
194
194
  opt.group !== filtered[i - 1]?.group;
195
- return (_jsxs(React.Fragment, { children: [showGroupLabel && (_jsx("div", { className: "crisp-select-label", children: opt.group })), _jsxs("button", { type: "button", id: optionId(i), role: "option", "aria-selected": isSel, tabIndex: -1, disabled: opt.disabled, "data-disabled": opt.disabled ? "" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-select-option", onClick: () => pick(opt), onMouseEnter: () => setHighlight(i), children: [opt.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: opt.color } })), opt.description !== undefined ? (_jsxs("span", { className: "crisp-select-option-body", children: [_jsx("span", { className: "crisp-select-option-label", children: opt.label }), _jsx("span", { className: "crisp-select-option-description", children: opt.description })] })) : (_jsx("span", { className: "crisp-select-option-label", children: opt.label })), isSel && (_jsx(Check, { className: "crisp-select-option-check", size: 15, strokeWidth: 2 }))] })] }, opt.value));
195
+ return (_jsxs(React.Fragment, { children: [showGroupLabel && (_jsx("div", { className: "crisp-select-label", children: opt.group })), _jsxs("button", { type: "button", id: optionId(i), role: "option", "aria-selected": isSel, tabIndex: -1, disabled: opt.disabled, "data-disabled": opt.disabled ? "" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-select-option", onClick: () => pick(opt), onMouseEnter: () => setHighlight(i), children: [!opt.swatch && opt.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: opt.color } })), opt.description !== undefined ? (_jsxs("span", { className: "crisp-select-option-body", children: [_jsx("span", { className: "crisp-select-option-label", children: opt.swatch ? (_jsx("span", { className: "crisp-select-swatch", "data-swatch": opt.swatch, children: opt.label })) : (opt.label) }), _jsx("span", { className: "crisp-select-option-description", children: opt.description })] })) : (_jsx("span", { className: "crisp-select-option-label", children: opt.swatch ? (_jsx("span", { className: "crisp-select-swatch", "data-swatch": opt.swatch, children: opt.label })) : (opt.label) })), isSel && (_jsx(Check, { className: "crisp-select-option-check", size: 15, strokeWidth: 2 }))] })] }, opt.value));
196
196
  }))] }) }))] }));
197
197
  }
198
198
  Select.displayName = "Select";
@@ -134,6 +134,25 @@ const config = defineRecipe({
134
134
  .crisp-select-option[data-disabled] .crisp-select-option-description,
135
135
  .crisp-select-option[data-disabled] .crisp-select-option-check { color: ${c.fgDisabled}; }
136
136
  .crisp-select-option-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: ${c.radius.full}; margin-inline-end: ${c.space["2"]}; }
137
+ /* your-moon/crisp#998: Attio's own 12-colour tag palette (measured live,
138
+ see tokens/palette.ts's tagSwatch1 comment) -- the label itself as a
139
+ filled pill, not a dot beside it. 7px radius/10px text have no
140
+ matching step in the shared scale (radius.xs is 4px, .sm is 6px;
141
+ text.xs is 11px), so both are the measured literals, same as
142
+ DataTable's own avatar font-size (#891). */
143
+ .crisp-select-swatch { display: inline-flex; align-items: center; height: ${c.size.chip}; padding: 0 ${c.space["1"]}; border-radius: 7px; font-size: 10px; font-weight: ${c.weight.regular}; color: ${c.fgTagSwatch}; }
144
+ .crisp-select-swatch[data-swatch="1"] { background: ${c.bgTagSwatch1}; }
145
+ .crisp-select-swatch[data-swatch="2"] { background: ${c.bgTagSwatch2}; }
146
+ .crisp-select-swatch[data-swatch="3"] { background: ${c.bgTagSwatch3}; }
147
+ .crisp-select-swatch[data-swatch="4"] { background: ${c.bgTagSwatch4}; }
148
+ .crisp-select-swatch[data-swatch="5"] { background: ${c.bgTagSwatch5}; }
149
+ .crisp-select-swatch[data-swatch="6"] { background: ${c.bgTagSwatch6}; }
150
+ .crisp-select-swatch[data-swatch="7"] { background: ${c.bgTagSwatch7}; }
151
+ .crisp-select-swatch[data-swatch="8"] { background: ${c.bgTagSwatch8}; }
152
+ .crisp-select-swatch[data-swatch="9"] { background: ${c.bgTagSwatch9}; }
153
+ .crisp-select-swatch[data-swatch="10"] { background: ${c.bgTagSwatch10}; }
154
+ .crisp-select-swatch[data-swatch="11"] { background: ${c.bgTagSwatch11}; }
155
+ .crisp-select-swatch[data-swatch="12"] { background: ${c.bgTagSwatch12}; }
137
156
  .crisp-select-option-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: ${c.space["0.5"]}; }
138
157
  .crisp-select-option-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
139
158
  .crisp-select-option-description { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: ${c.fgNeutralSubtle}; }
@@ -21,7 +21,13 @@ const config = defineRecipe({
21
21
  (padding: 0 12px) and independently on a body cell's text inset
22
22
  (getBoundingClientRect delta from the cell edge). Matches
23
23
  ColumnCalc's own already-correct 12px trigger inset. */
24
- & th, & td { position: relative; height: ${c.size.tableRow}; padding: 0 ${c.space["3"]}; border-bottom: 1px solid ${c.borderSubtle}; border-inline-end: 1px solid ${c.borderSubtle}; text-align: start; vertical-align: middle; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
24
+ /* #719: overflow: clip, not hidden -- both render the ellipsis
25
+ identically, but hidden still honours a programmatic scrollLeft
26
+ assignment (a screenshot tool, a browser extension) even though it
27
+ already blocks user-driven wheel/scrollbar input; clip leaves no
28
+ scroll position for anything to move, so the truncated text can
29
+ never be panned out of view. */
30
+ & th, & td { position: relative; height: ${c.size.tableRow}; padding: 0 ${c.space["3"]}; border-bottom: 1px solid ${c.borderSubtle}; border-inline-end: 1px solid ${c.borderSubtle}; text-align: start; vertical-align: middle; white-space: nowrap; overflow: clip; text-overflow: ellipsis; }
25
31
  /* #554: sticky on the vertical axis, mirroring RecordsTable's own
26
32
  \`.crisp-recordstable-th\`. z-index 2 sits above a plain body cell (auto)
27
33
  and below a sticky-column corner cell (DataTable's \`th[data-sticky]\`,
@@ -16,10 +16,11 @@ import { createPortal } from "react-dom";
16
16
  * `Dialog` was rendering offset by the dialog's own box instead of anchored
17
17
  * to its trigger.
18
18
  *
19
- * Every caller renders this conditionally on its own `open` state, which
20
- * starts `false` and is driven only by client interaction — never a prop a
21
- * fixture sets true at mount (unlike Dialog/Drawer/Command) — so this never
22
- * touches `document` during SSR, the same safety Tooltip already had.
19
+ * Every caller renders this conditionally on its own `open` state. A fixture
20
+ * CAN set that true at mount (crisp#964 needs exactly that, to give Menu's
21
+ * roving-tabindex guard something to inspect), so this checks for `window`
22
+ * itself rather than assuming the caller never does — the same guard
23
+ * `@zag-js/react`'s own `Portal` uses for the identical reason.
23
24
  *
24
25
  * `sourceRef` is the caller's own root — still in its original DOM position,
25
26
  * never portaled — used to carry a `data-theme` set on some ancestor of it
@@ -35,6 +36,8 @@ import { createPortal } from "react-dom";
35
36
  * element carrying that attribute.
36
37
  */
37
38
  export function FloatingPortal({ children, sourceRef, }) {
39
+ if (typeof window === "undefined")
40
+ return children;
38
41
  const theme = sourceRef.current
39
42
  ?.closest("[data-theme]")
40
43
  ?.getAttribute("data-theme");
@@ -9,7 +9,7 @@ export { hasNestedFloatingLayer, hasOpenFloatingLayer, isInsideLaterFloatingLaye
9
9
  export { formValueStyle } from "./form-value.js";
10
10
  export { CrispMessagesProvider, useCrispComponentDefaults, useCrispMessage, useCrispMessages, } from "./i18n.js";
11
11
  export { matchMentionTrigger, } from "./mention.js";
12
- export { placeFloating, } from "./place-floating.js";
12
+ export { placeFloating, placeSubmenu, } from "./place-floating.js";
13
13
  export { Primitive } from "./primitive.js";
14
14
  export { resolveRecipe } from "./resolve.js";
15
15
  export * from "./rtl.js";
@@ -33,3 +33,40 @@ export function placeFloating({ rect, align = "start", gap, flipBelow, anchor, }
33
33
  style.left = rect.left;
34
34
  return { style, up };
35
35
  }
36
+ /**
37
+ * Where a cascading submenu goes, BESIDE the item that opens it — not below
38
+ * it, the way `placeFloating`'s own dropdown does. Opens toward the inline
39
+ * end by default (further into the reading direction) and flips to the
40
+ * inline start when the surface's measured width doesn't fit there.
41
+ *
42
+ * Live-measured on Attio's own object-page "…" menu (crisp#616): the
43
+ * Notifications submenu sits with NO gap against the item's opposite edge
44
+ * (its own edge lines up exactly with the item's), so callers pass `gap: 0`
45
+ * for this specific cascade rather than the 4px a dropdown uses against its
46
+ * trigger.
47
+ */
48
+ export function placeSubmenu({ rect, size, gap, anchor, }) {
49
+ const rtl = isRtlElement(anchor);
50
+ // "end" here is the inline-end direction FROM the item, e.g. further
51
+ // right in LTR — the room available there is measured from whichever
52
+ // physical edge that is.
53
+ const roomTowardEnd = rtl ? rect.left : window.innerWidth - rect.right;
54
+ const opensEnd = roomTowardEnd >= size.width + gap;
55
+ const align = opensEnd ? "start" : "end";
56
+ const style = {};
57
+ // Same formula as `placeFloating`'s own `toRight`, applied to the opposite
58
+ // geometry: there `align` picks which of the TRIGGER's edges the layer's
59
+ // matching edge lines up with; here it picks which side of the ITEM the
60
+ // layer cascades toward.
61
+ const anchorRight = align === "end" ? !rtl : rtl;
62
+ if (anchorRight)
63
+ style.right = window.innerWidth - rect.left + gap;
64
+ else
65
+ style.left = rect.right + gap;
66
+ const up = rect.top + size.height > window.innerHeight;
67
+ if (up)
68
+ style.bottom = Math.max(0, window.innerHeight - rect.bottom);
69
+ else
70
+ style.top = rect.top;
71
+ return { style, align, up };
72
+ }
package/dist/esm/index.js CHANGED
@@ -76,6 +76,7 @@ export * from "./core/index.js";
76
76
  // the generic primitives (Table, DataTable, Button, …) to build your own UI from.
77
77
  export * from "./product/activityfeed/index.js";
78
78
  export * from "./product/appshell/index.js";
79
+ export * from "./product/assigneepicker/index.js";
79
80
  export * from "./product/authshell/index.js";
80
81
  export * from "./product/board/index.js";
81
82
  export * from "./product/command/index.js";
@@ -89,21 +90,28 @@ export * from "./product/inbox/index.js";
89
90
  export * from "./product/listpage/index.js";
90
91
  export * from "./product/memberssettings/index.js";
91
92
  export * from "./product/navitem/index.js";
93
+ export * from "./product/notesgallery/index.js";
92
94
  export * from "./product/notificationssettings/index.js";
95
+ export * from "./product/objectpagemenu/index.js";
93
96
  export * from "./product/objectsettings/index.js";
94
97
  export * from "./product/onboarding/index.js";
95
98
  export * from "./product/recordpage/index.js";
96
99
  export * from "./product/recordpanel/index.js";
100
+ export * from "./product/recordpicker/index.js";
97
101
  export * from "./product/richtext/index.js";
98
102
  export * from "./product/savedviews/index.js";
99
103
  export * from "./product/settingssection/index.js";
100
104
  export * from "./product/settingsshell/index.js";
105
+ export * from "./product/share/index.js";
106
+ export * from "./product/shortcutssheet/index.js";
101
107
  export * from "./product/sidebar/index.js";
102
108
  export * from "./product/statlist/index.js";
109
+ export * from "./product/taskeditpopover/index.js";
103
110
  export * from "./product/tasklist/index.js";
104
111
  export * from "./product/themepicker/index.js";
105
112
  export * from "./product/trialbanner/index.js";
106
113
  export * from "./product/viewtoggle/index.js";
107
114
  export * from "./product/workspacegeneralsettings/index.js";
115
+ export * from "./product/workspaceswitchermenu/index.js";
108
116
  // design tokens + recipe core (for building custom components)
109
117
  export * from "./tokens/index.js";
@@ -0,0 +1,91 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Search, X } from "lucide-react";
3
+ import * as React from "react";
4
+ import { Avatar } from "../../components/avatar/index.js";
5
+ import { PersonChip } from "../../components/personchip/index.js";
6
+ import { useListboxKeyboard } from "../../components/select/index.js";
7
+ import { useCrispMessage, useCrispMessages } from "../../core/index.js";
8
+ import { assigneepickerRecipe } from "./assigneepicker.recipe.js";
9
+ // Same five hashed avatar tones as `PersonChip`'s own (transcribed, not
10
+ // imported -- PersonChip's are private to that file, and this is the same
11
+ // one-line pure function `personchip.tsx` itself transcribes from
12
+ // `datatable.tsx`'s `TAG_TONES` rather than sharing an import; see its own
13
+ // comment for why that's fine here).
14
+ const HASH_TONES = ["brand", "orange", "cyan", "purple", "green"];
15
+ function hashTone(name) {
16
+ let h = 0;
17
+ for (let i = 0; i < name.length; i++)
18
+ h = (h * 31 + name.charCodeAt(i)) >>> 0;
19
+ return HASH_TONES[h % HASH_TONES.length];
20
+ }
21
+ function initials(name) {
22
+ const parts = name.trim().split(/\s+/).filter(Boolean);
23
+ if (parts.length === 0)
24
+ return "?";
25
+ return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase();
26
+ }
27
+ /**
28
+ * The picker behind a task's "Assigned to" chip -- measured live on
29
+ * app.attio.com's Tasks page ("Edit task" -> "Assigned to You" -> its own
30
+ * sub-popover, `Popover`-sized at the same measured 350px): a "Find a
31
+ * user..." search, the current assignee as a removable pill, then a plain
32
+ * option list, or "No users" when nothing matches. Attio's task assignment
33
+ * is single-user (one pill, replaced by picking another), unlike a
34
+ * multi-value field.
35
+ */
36
+ export function AssigneePicker({ users, assignee = null, onAssigneeChange, query: queryProp, onQueryChange, searchLabel: searchLabelProp, removeLabel: removeLabelProp, emptyLabel: emptyLabelProp, className, ...rest }) {
37
+ const searchLabel = useCrispMessage(searchLabelProp, "Find a user...");
38
+ const removeLabel = useCrispMessage(removeLabelProp, "Remove assignee");
39
+ // crisp/#471: `emptyLabel` is typed `React.ReactNode`, so it resolves by
40
+ // hand rather than through `useCrispMessage`, which is string-only --
41
+ // same reasoning as `Inbox`'s own `emptyTitle`/`emptySubtitle`.
42
+ const crispMessages = useCrispMessages();
43
+ const emptyLabel = emptyLabelProp ?? crispMessages["No users"] ?? "No users";
44
+ const [internalQuery, setInternalQuery] = React.useState("");
45
+ const query = queryProp ?? internalQuery;
46
+ const setQuery = (q) => {
47
+ if (queryProp === undefined)
48
+ setInternalQuery(q);
49
+ onQueryChange?.(q);
50
+ };
51
+ const [highlight, setHighlight] = React.useState(0);
52
+ const autoId = React.useId();
53
+ const listboxId = `${autoId}-listbox`;
54
+ const optionId = (i) => `${autoId}-opt-${i}`;
55
+ const q = query.trim().toLowerCase();
56
+ const candidates = users.filter((u) => u.id !== assignee?.id);
57
+ const filtered = q
58
+ ? candidates.filter((u) => u.name.toLowerCase().includes(q))
59
+ : candidates;
60
+ const select = (u) => {
61
+ onAssigneeChange?.(u);
62
+ setQuery("");
63
+ setHighlight(0);
64
+ };
65
+ // crisp's shared popup-listbox model (#198), same as Select/MultiSelect --
66
+ // arrows, Home/End and wrapping, instead of a second hand-rolled one.
67
+ const { onKeyDown } = useListboxKeyboard({
68
+ enabledIndexes: filtered.map((_, i) => i),
69
+ highlight,
70
+ setHighlight,
71
+ commit: () => {
72
+ const u = filtered[highlight];
73
+ if (u)
74
+ select(u);
75
+ },
76
+ // Escape/Tab are for the enclosing Popover to handle (it owns open/close
77
+ // here, this picker has no floating state of its own).
78
+ close: () => { },
79
+ dismiss: () => { },
80
+ });
81
+ const activeId = highlight >= 0 && highlight < filtered.length
82
+ ? optionId(highlight)
83
+ : undefined;
84
+ return (_jsxs("div", { className: assigneepickerRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-assigneepicker-search", children: [_jsx(Search, { size: 16, strokeWidth: 1.75 }), _jsx("input", { type: "text", className: "crisp-assigneepicker-search-input", placeholder: searchLabel, value: query, onChange: (e) => {
85
+ setQuery(e.target.value);
86
+ setHighlight(0);
87
+ }, onKeyDown: onKeyDown, role: "combobox", "aria-expanded": "true", "aria-controls": listboxId, "aria-activedescendant": activeId,
88
+ // biome-ignore lint/a11y/noAutofocus: focus the search field the moment its enclosing popover opens
89
+ autoFocus: true })] }), assignee != null && (_jsx("div", { className: "crisp-assigneepicker-pill", children: _jsx(PersonChip, { name: assignee.name, avatar: assignee.avatar, trailing: _jsx("button", { type: "button", className: "crisp-assigneepicker-remove", "aria-label": removeLabel, onClick: () => onAssigneeChange?.(null), children: _jsx(X, { size: 10, strokeWidth: 2 }) }) }) })), filtered.length === 0 ? (_jsx("div", { className: "crisp-assigneepicker-empty", children: emptyLabel })) : (_jsx("div", { className: "crisp-assigneepicker-list", role: "listbox", id: listboxId, children: filtered.map((u, i) => (_jsxs("button", { type: "button", id: optionId(i), role: "option", tabIndex: -1, "aria-selected": i === highlight, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-assigneepicker-option", onMouseEnter: () => setHighlight(i), onClick: () => select(u), children: [_jsx(Avatar, { size: "sm", shape: "circle", tone: u.avatar ? "neutral" : hashTone(u.name), style: { flexShrink: 0, marginInlineEnd: 8 }, children: u.avatar ? _jsx("img", { src: u.avatar, alt: "" }) : initials(u.name) }), u.name] }, u.id))) }))] }));
90
+ }
91
+ AssigneePicker.displayName = "AssigneePicker";
@@ -0,0 +1,33 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // The assignee picker behind a task's "Assigned to" chip -- measured live on
4
+ // app.attio.com's Tasks page (Edit task -> "Assigned to You" -> its own
5
+ // sub-popover): a borderless 14px/500 "Find a user..." search row, the
6
+ // current assignee as a removable pill below it, then a plain option list
7
+ // (32px rows, radius 8, the same measured hover crisp's other listboxes
8
+ // share -- see menu.recipe.ts's own comment) or a centred "No users" line.
9
+ // The popover shell itself (radius/shadow/width) is `Popover`'s own --
10
+ // this is only the 350px-wide content Attio's own picker measured at.
11
+ const config = defineRecipe({
12
+ name: "assigneepicker",
13
+ base: `display: flex; flex-direction: column; box-sizing: border-box; width: 350px; font-family: ${c.font.sans};
14
+
15
+ & .crisp-assigneepicker-search { display: flex; align-items: center; gap: ${c.space["2"]}; height: 36px; padding: 0 ${c.space["3"]}; color: ${c.fgMuted}; }
16
+ & .crisp-assigneepicker-search svg { flex-shrink: 0; }
17
+ & .crisp-assigneepicker-search-input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; line-height: 20px; color: ${c.fgPrimary}; }
18
+ & .crisp-assigneepicker-search-input::placeholder { color: ${c.fgMuted}; }
19
+
20
+ & .crisp-assigneepicker-pill { display: flex; padding: 0 ${c.space["3"]} ${c.space["2"]}; }
21
+ & .crisp-assigneepicker-remove { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 14px; height: 14px; margin: 0; padding: 0; border: none; background: none; color: inherit; font-family: inherit; cursor: pointer; }
22
+
23
+ & .crisp-assigneepicker-empty { padding: ${c.space["4"]} ${c.space["3"]}; text-align: center; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
24
+
25
+ & .crisp-assigneepicker-list { display: flex; flex-direction: column; margin: 0; padding: ${c.space["1"]}; list-style: none; }
26
+ & .crisp-assigneepicker-option { display: flex; align-items: center; width: 100%; box-sizing: border-box; height: 32px; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: none; font-family: inherit; text-align: start; cursor: pointer; }
27
+ & .crisp-assigneepicker-option:hover, & .crisp-assigneepicker-option[data-highlighted] { background: ${c.bgRaised}; }`,
28
+ variants: {},
29
+ defaultVariants: {},
30
+ compoundVariants: [],
31
+ });
32
+ export const assigneepickerRecipe = resolveRecipe(config);
33
+ export default config;
@@ -0,0 +1,2 @@
1
+ export { AssigneePicker, } from "./assigneepicker.js";
2
+ export { assigneepickerRecipe } from "./assigneepicker.recipe.js";
@@ -18,10 +18,18 @@ export function Inbox({ notifications = [], title: titleProp, tabs, activeTab, o
18
18
  // own `empty`.
19
19
  const crispMessages = useCrispMessages();
20
20
  const title = titleProp ?? crispMessages.Notifications ?? "Notifications";
21
- const emptyTitle = emptyTitleProp ?? crispMessages["No notifications"] ?? "No notifications";
22
- const emptySubtitle = emptySubtitleProp ??
21
+ // A tab's own empty copy wins over the instance-level prop, which wins
22
+ // over the built-in default — same precedence as `title`/`emptyAction`.
23
+ const activeTabDef = tabs?.find((t) => t.id === activeTab);
24
+ const emptyTitle = activeTabDef?.emptyTitle ??
25
+ emptyTitleProp ??
26
+ crispMessages["No notifications"] ??
27
+ "No notifications";
28
+ const emptySubtitle = activeTabDef?.emptySubtitle ??
29
+ emptySubtitleProp ??
23
30
  crispMessages["Notifications such as mentions and important updates will be displayed here."] ??
24
31
  "Notifications such as mentions and important updates will be displayed here.";
32
+ const resolvedEmptyAction = activeTabDef?.emptyAction ?? emptyAction;
25
33
  const tabsLabel = useCrispMessage(tabsLabelProp, "Notification views");
26
34
  // crisp's shared tablist model, the one `Tabs` runs (#195). Hand-rolling a
27
35
  // second one is how Inbox ended up with no arrow keys, no roving tabindex
@@ -32,6 +40,6 @@ export function Inbox({ notifications = [], title: titleProp, tabs, activeTab, o
32
40
  select: (id) => onTabChange?.(id),
33
41
  listRef,
34
42
  });
35
- return (_jsxs("div", { className: inboxRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-inbox-head", children: [_jsx("div", { className: "crisp-inbox-title", children: title }), tabs && tabs.length > 0 && (_jsx("div", { ref: listRef, className: "crisp-inbox-tabs", role: "tablist", "aria-label": tabsLabel, onKeyDown: onKeyDown, children: tabs.map((t) => (_jsxs("button", { type: "button", role: "tab", "aria-selected": t.id === activeTab, tabIndex: tabIndexFor(t.id), "data-value": t.id, disabled: t.disabled, className: "crisp-inbox-tab", "data-active": t.id === activeTab ? "" : undefined, onClick: () => onTabChange?.(t.id), children: [t.label, t.count != null && (_jsxs("span", { className: "crisp-inbox-tab-count", children: ["(", t.count, ")"] }))] }, t.id))) }))] }), notifications.length === 0 ? (_jsxs("div", { className: "crisp-inbox-empty", children: [_jsx("span", { className: "crisp-inbox-empty-icon", children: _jsx(Bell, { size: 40, strokeWidth: 1.25 }) }), _jsx("div", { className: "crisp-inbox-empty-title", children: emptyTitle }), emptySubtitle != null && (_jsx("div", { className: "crisp-inbox-empty-sub", children: emptySubtitle })), emptyAction != null && (_jsx("div", { className: "crisp-inbox-empty-action", children: emptyAction }))] })) : (_jsx("div", { className: "crisp-inbox-list", children: notifications.map((n) => (_jsxs("button", { type: "button", className: "crisp-inbox-row", onClick: n.onClick, children: [n.avatar != null && (_jsx("span", { className: "crisp-inbox-row-avatar", children: n.avatar })), _jsxs("span", { className: "crisp-inbox-row-body", children: [_jsx("span", { className: "crisp-inbox-row-msg", children: n.message }), n.time != null && (_jsx("span", { className: "crisp-inbox-row-time", children: n.time }))] }), n.unread && _jsx("span", { className: "crisp-inbox-row-unread" })] }, n.id))) }))] }));
43
+ return (_jsxs("div", { className: inboxRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-inbox-head", children: [_jsx("div", { className: "crisp-inbox-title", children: title }), tabs && tabs.length > 0 && (_jsx("div", { ref: listRef, className: "crisp-inbox-tabs", role: "tablist", "aria-label": tabsLabel, onKeyDown: onKeyDown, children: tabs.map((t) => (_jsxs("button", { type: "button", role: "tab", "aria-selected": t.id === activeTab, tabIndex: tabIndexFor(t.id), "data-value": t.id, disabled: t.disabled, className: "crisp-inbox-tab", "data-active": t.id === activeTab ? "" : undefined, onClick: () => onTabChange?.(t.id), children: [t.label, t.count != null && (_jsxs("span", { className: "crisp-inbox-tab-count", children: ["(", t.count, ")"] }))] }, t.id))) }))] }), notifications.length === 0 ? (_jsxs("div", { className: "crisp-inbox-empty", children: [_jsx("span", { className: "crisp-inbox-empty-icon", children: _jsx(Bell, { size: 40, strokeWidth: 1.25 }) }), _jsx("div", { className: "crisp-inbox-empty-title", children: emptyTitle }), emptySubtitle != null && (_jsx("div", { className: "crisp-inbox-empty-sub", children: emptySubtitle })), resolvedEmptyAction != null && (_jsx("div", { className: "crisp-inbox-empty-action", children: resolvedEmptyAction }))] })) : (_jsx("div", { className: "crisp-inbox-list", children: notifications.map((n) => (_jsxs("button", { type: "button", className: "crisp-inbox-row", onClick: n.onClick, children: [n.avatar != null && (_jsx("span", { className: "crisp-inbox-row-avatar", children: n.avatar })), _jsxs("span", { className: "crisp-inbox-row-body", children: [_jsx("span", { className: "crisp-inbox-row-msg", children: n.message }), n.time != null && (_jsx("span", { className: "crisp-inbox-row-time", children: n.time }))] }), n.unread && _jsx("span", { className: "crisp-inbox-row-unread" })] }, n.id))) }))] }));
36
44
  }
37
45
  Inbox.displayName = "Inbox";
@@ -0,0 +1,2 @@
1
+ export { NotesGallery, } from "./notesgallery.js";
2
+ export { notesgalleryRecipe } from "./notesgallery.recipe.js";
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Tabs } from "../../components/tabs/index.js";
4
+ import { useCrispMessage, useCrispMessages } from "../../core/index.js";
5
+ import { EntityCard } from "../entitycard/index.js";
6
+ import { notesgalleryRecipe } from "./notesgallery.recipe.js";
7
+ function NoteCard({ note }) {
8
+ return (_jsx(EntityCard, { author: note.linkedRecord, title: note.title, excerpt: note.excerpt, footer: note.author, date: note.date, onClick: note.onClick }));
9
+ }
10
+ /**
11
+ * The standalone Notes gallery -- measured live on app.attio.com's Notes
12
+ * page: "Notes N"/"Templates N" sub-tabs, a toolbar (Sort/View
13
+ * toggle/settings/New note, all consumer-composed), a dashed "Favorites"
14
+ * region, and date-grouped card sections. The card itself is `EntityCard`
15
+ * (crisp#474's own "measured from a Notes grid" component) — this shell
16
+ * only adds the gallery chrome around it.
17
+ */
18
+ export function NotesGallery({ notesCount, templatesCount, activeTab: activeTabProp, defaultActiveTab = "notes", onActiveTabChange, notesLabel: notesLabelProp, templatesLabel: templatesLabelProp, leading, trailing, favoritesLabel: favoritesLabelProp, favorites, favoritesEmptyLabel: favoritesEmptyLabelProp, groups, emptyLabel: emptyLabelProp, className, ...rest }) {
19
+ const notesLabel = useCrispMessage(notesLabelProp, "Notes");
20
+ const templatesLabel = useCrispMessage(templatesLabelProp, "Templates");
21
+ const favoritesLabel = useCrispMessage(favoritesLabelProp, "Favorites");
22
+ const favoritesEmptyLabel = useCrispMessage(favoritesEmptyLabelProp, "Notes that you favorite will appear here");
23
+ // crisp/#471: `emptyLabel` is typed `React.ReactNode`, so it resolves by
24
+ // hand rather than through `useCrispMessage`, which is string-only.
25
+ const crispMessages = useCrispMessages();
26
+ const emptyLabel = emptyLabelProp ?? crispMessages["No notes"] ?? "No notes";
27
+ const [internalTab, setInternalTab] = React.useState(defaultActiveTab);
28
+ const activeTab = activeTabProp ?? internalTab;
29
+ const setActiveTab = (v) => {
30
+ if (activeTabProp === undefined)
31
+ setInternalTab(v);
32
+ onActiveTabChange?.(v);
33
+ };
34
+ const total = groups.reduce((s, g) => s + g.notes.length, 0);
35
+ return (_jsxs("div", { className: notesgalleryRecipe({ className }), ...rest, children: [_jsx("div", { className: "crisp-notesgallery-tabs", children: _jsx(Tabs, { tabs: [
36
+ { value: "notes", label: notesLabel, badge: notesCount },
37
+ {
38
+ value: "templates",
39
+ label: templatesLabel,
40
+ badge: templatesCount,
41
+ },
42
+ ], value: activeTab, onValueChange: setActiveTab }) }), _jsxs("div", { className: "crisp-notesgallery-toolbar", children: [leading, trailing != null && (_jsx("div", { className: "crisp-notesgallery-toolbar-trail", children: trailing }))] }), _jsxs("div", { className: "crisp-notesgallery-favorites-section", children: [_jsx("div", { className: "crisp-notesgallery-favorites-label", children: favoritesLabel }), _jsx("div", { className: "crisp-notesgallery-favorites", children: favorites != null && favorites.length > 0 ? (_jsx("div", { className: "crisp-notesgallery-grid", children: favorites.map((n) => (_jsx(NoteCard, { note: n }, n.id))) })) : (_jsx("div", { className: "crisp-notesgallery-favorites-empty", children: favoritesEmptyLabel })) })] }), _jsx("div", { className: "crisp-notesgallery-body", children: total === 0 ? (_jsx("div", { className: "crisp-notesgallery-empty", children: emptyLabel })) : (groups.map((g) => (_jsxs("div", { children: [_jsxs("div", { className: "crisp-notesgallery-group", children: [g.label, _jsx("span", { className: "crisp-notesgallery-group-count", children: g.count })] }), _jsx("div", { className: "crisp-notesgallery-grid", children: g.notes.map((n) => (_jsx(NoteCard, { note: n }, n.id))) })] }, g.id)))) })] }));
43
+ }
44
+ NotesGallery.displayName = "NotesGallery";