@crisp-ui-kit/crisp 0.57.3 → 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 (169) 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 +1 -1
  5. package/dist/button.layered.css +1 -1
  6. package/dist/cjs/components/button/button.recipe.js +15 -10
  7. package/dist/cjs/components/datatable/datatable.js +6 -3
  8. package/dist/cjs/components/datatable/datatable.recipe.js +35 -2
  9. package/dist/cjs/components/menu/menu.js +48 -8
  10. package/dist/cjs/components/multiselect/multiselect.js +3 -0
  11. package/dist/cjs/components/multiselect/multiselect.recipe.js +18 -0
  12. package/dist/cjs/components/select/select.js +2 -2
  13. package/dist/cjs/components/select/select.recipe.js +19 -0
  14. package/dist/cjs/core/index.js +2 -1
  15. package/dist/cjs/core/place-floating.js +38 -0
  16. package/dist/cjs/index.js +8 -0
  17. package/dist/cjs/product/assigneepicker/assigneepicker.js +127 -0
  18. package/dist/cjs/product/assigneepicker/assigneepicker.recipe.js +36 -0
  19. package/dist/cjs/product/assigneepicker/index.js +7 -0
  20. package/dist/cjs/product/notesgallery/index.js +7 -0
  21. package/dist/cjs/product/notesgallery/notesgallery.js +80 -0
  22. package/dist/cjs/product/notesgallery/notesgallery.recipe.js +46 -0
  23. package/dist/cjs/product/objectpagemenu/index.js +17 -0
  24. package/dist/cjs/product/objectpagemenu/objectpagemenu.js +26 -0
  25. package/dist/cjs/product/objectpagemenu/objectpagemenu.recipe.js +30 -0
  26. package/dist/cjs/product/objectsettings/objectsettings.js +107 -14
  27. package/dist/cjs/product/recordpage/recordpage.js +64 -5
  28. package/dist/cjs/product/recordpage/recordpage.recipe.js +38 -0
  29. package/dist/cjs/product/recordpicker/index.js +7 -0
  30. package/dist/cjs/product/recordpicker/recordpicker.js +100 -0
  31. package/dist/cjs/product/recordpicker/recordpicker.recipe.js +36 -0
  32. package/dist/cjs/product/savedviews/index.js +2 -1
  33. package/dist/cjs/product/savedviews/savedviews.js +22 -0
  34. package/dist/cjs/product/savedviews/savedviews.recipe.js +47 -0
  35. package/dist/cjs/product/share/index.js +7 -0
  36. package/dist/cjs/product/share/share.js +179 -0
  37. package/dist/cjs/product/share/share.recipe.js +114 -0
  38. package/dist/cjs/product/shortcutssheet/index.js +17 -0
  39. package/dist/cjs/product/shortcutssheet/shortcutssheet.js +125 -0
  40. package/dist/cjs/product/shortcutssheet/shortcutssheet.recipe.js +50 -0
  41. package/dist/cjs/product/taskeditpopover/index.js +7 -0
  42. package/dist/cjs/product/taskeditpopover/taskeditpopover.js +159 -0
  43. package/dist/cjs/product/taskeditpopover/taskeditpopover.recipe.js +68 -0
  44. package/dist/cjs/product/workspaceswitchermenu/index.js +17 -0
  45. package/dist/cjs/product/workspaceswitchermenu/workspaceswitchermenu.js +38 -0
  46. package/dist/cjs/product/workspaceswitchermenu/workspaceswitchermenu.recipe.js +34 -0
  47. package/dist/cjs/tokens/palette.js +27 -0
  48. package/dist/cjs/tokens/semantic.js +20 -0
  49. package/dist/datatable.css +40 -7
  50. package/dist/datatable.layered.css +40 -7
  51. package/dist/esm/components/button/button.recipe.js +15 -10
  52. package/dist/esm/components/datatable/datatable.js +6 -3
  53. package/dist/esm/components/datatable/datatable.recipe.js +35 -2
  54. package/dist/esm/components/menu/menu.js +47 -7
  55. package/dist/esm/components/multiselect/multiselect.js +3 -0
  56. package/dist/esm/components/multiselect/multiselect.recipe.js +18 -0
  57. package/dist/esm/components/select/select.js +2 -2
  58. package/dist/esm/components/select/select.recipe.js +19 -0
  59. package/dist/esm/core/index.js +1 -1
  60. package/dist/esm/core/place-floating.js +37 -0
  61. package/dist/esm/index.js +8 -0
  62. package/dist/esm/product/assigneepicker/assigneepicker.js +91 -0
  63. package/dist/esm/product/assigneepicker/assigneepicker.recipe.js +33 -0
  64. package/dist/esm/product/assigneepicker/index.js +2 -0
  65. package/dist/esm/product/notesgallery/index.js +2 -0
  66. package/dist/esm/product/notesgallery/notesgallery.js +44 -0
  67. package/dist/esm/product/notesgallery/notesgallery.recipe.js +43 -0
  68. package/dist/esm/product/objectpagemenu/index.js +1 -0
  69. package/dist/esm/product/objectpagemenu/objectpagemenu.js +23 -0
  70. package/dist/esm/product/objectpagemenu/objectpagemenu.recipe.js +27 -0
  71. package/dist/esm/product/objectsettings/objectsettings.js +106 -13
  72. package/dist/esm/product/recordpage/recordpage.js +64 -5
  73. package/dist/esm/product/recordpage/recordpage.recipe.js +38 -0
  74. package/dist/esm/product/recordpicker/index.js +2 -0
  75. package/dist/esm/product/recordpicker/recordpicker.js +64 -0
  76. package/dist/esm/product/recordpicker/recordpicker.recipe.js +33 -0
  77. package/dist/esm/product/savedviews/index.js +1 -1
  78. package/dist/esm/product/savedviews/savedviews.js +22 -1
  79. package/dist/esm/product/savedviews/savedviews.recipe.js +47 -0
  80. package/dist/esm/product/share/index.js +2 -0
  81. package/dist/esm/product/share/share.js +143 -0
  82. package/dist/esm/product/share/share.recipe.js +111 -0
  83. package/dist/esm/product/shortcutssheet/index.js +1 -0
  84. package/dist/esm/product/shortcutssheet/shortcutssheet.js +88 -0
  85. package/dist/esm/product/shortcutssheet/shortcutssheet.recipe.js +47 -0
  86. package/dist/esm/product/taskeditpopover/index.js +2 -0
  87. package/dist/esm/product/taskeditpopover/taskeditpopover.js +123 -0
  88. package/dist/esm/product/taskeditpopover/taskeditpopover.recipe.js +65 -0
  89. package/dist/esm/product/workspaceswitchermenu/index.js +1 -0
  90. package/dist/esm/product/workspaceswitchermenu/workspaceswitchermenu.js +35 -0
  91. package/dist/esm/product/workspaceswitchermenu/workspaceswitchermenu.recipe.js +31 -0
  92. package/dist/esm/tokens/palette.js +27 -0
  93. package/dist/esm/tokens/semantic.js +20 -0
  94. package/dist/multiselect.css +18 -0
  95. package/dist/multiselect.layered.css +18 -0
  96. package/dist/native/tokens.d.ts +1 -1
  97. package/dist/native/tokens.js +56 -4
  98. package/dist/notesgallery.css +15 -0
  99. package/dist/notesgallery.d.ts +7 -0
  100. package/dist/notesgallery.layered.css +17 -0
  101. package/dist/objectpagemenu.css +3 -0
  102. package/dist/objectpagemenu.d.ts +7 -0
  103. package/dist/objectpagemenu.layered.css +5 -0
  104. package/dist/recordpage.css +36 -0
  105. package/dist/recordpage.layered.css +36 -0
  106. package/dist/recordpicker.css +13 -0
  107. package/dist/recordpicker.d.ts +7 -0
  108. package/dist/recordpicker.layered.css +15 -0
  109. package/dist/savedviews.css +47 -0
  110. package/dist/savedviews.layered.css +47 -0
  111. package/dist/select.css +19 -0
  112. package/dist/select.layered.css +19 -0
  113. package/dist/share.css +84 -0
  114. package/dist/share.d.ts +7 -0
  115. package/dist/share.layered.css +86 -0
  116. package/dist/shortcutssheet.css +24 -0
  117. package/dist/shortcutssheet.d.ts +7 -0
  118. package/dist/shortcutssheet.layered.css +26 -0
  119. package/dist/styles.css +387 -8
  120. package/dist/styles.layered.css +387 -8
  121. package/dist/taskeditpopover.css +36 -0
  122. package/dist/taskeditpopover.d.ts +7 -0
  123. package/dist/taskeditpopover.layered.css +38 -0
  124. package/dist/tokens.css +26 -0
  125. package/dist/tokens.json +56 -4
  126. package/dist/types/components/button/button.recipe.d.ts +2 -3
  127. package/dist/types/components/datatable/datatable.d.ts +8 -0
  128. package/dist/types/components/menu/menu.d.ts +50 -7
  129. package/dist/types/components/multiselect/multiselect.d.ts +9 -0
  130. package/dist/types/components/select/select.d.ts +10 -0
  131. package/dist/types/core/index.d.ts +1 -1
  132. package/dist/types/core/place-floating.d.ts +37 -0
  133. package/dist/types/index.d.ts +8 -0
  134. package/dist/types/product/assigneepicker/assigneepicker.d.ts +36 -0
  135. package/dist/types/product/assigneepicker/assigneepicker.recipe.d.ts +3 -0
  136. package/dist/types/product/assigneepicker/index.d.ts +2 -0
  137. package/dist/types/product/notesgallery/index.d.ts +2 -0
  138. package/dist/types/product/notesgallery/notesgallery.d.ts +54 -0
  139. package/dist/types/product/notesgallery/notesgallery.recipe.d.ts +3 -0
  140. package/dist/types/product/objectpagemenu/index.d.ts +1 -0
  141. package/dist/types/product/objectpagemenu/objectpagemenu.d.ts +40 -0
  142. package/dist/types/product/objectpagemenu/objectpagemenu.recipe.d.ts +17 -0
  143. package/dist/types/product/objectsettings/objectsettings.d.ts +71 -15
  144. package/dist/types/product/recordpage/index.d.ts +1 -1
  145. package/dist/types/product/recordpage/recordpage.d.ts +96 -1
  146. package/dist/types/product/recordpicker/index.d.ts +2 -0
  147. package/dist/types/product/recordpicker/recordpicker.d.ts +38 -0
  148. package/dist/types/product/recordpicker/recordpicker.recipe.d.ts +3 -0
  149. package/dist/types/product/savedviews/index.d.ts +1 -1
  150. package/dist/types/product/savedviews/savedviews.d.ts +36 -0
  151. package/dist/types/product/share/index.d.ts +2 -0
  152. package/dist/types/product/share/share.d.ts +85 -0
  153. package/dist/types/product/share/share.recipe.d.ts +3 -0
  154. package/dist/types/product/shortcutssheet/index.d.ts +1 -0
  155. package/dist/types/product/shortcutssheet/shortcutssheet.d.ts +49 -0
  156. package/dist/types/product/shortcutssheet/shortcutssheet.recipe.d.ts +3 -0
  157. package/dist/types/product/taskeditpopover/index.d.ts +2 -0
  158. package/dist/types/product/taskeditpopover/taskeditpopover.d.ts +67 -0
  159. package/dist/types/product/taskeditpopover/taskeditpopover.recipe.d.ts +3 -0
  160. package/dist/types/product/workspaceswitchermenu/index.d.ts +1 -0
  161. package/dist/types/product/workspaceswitchermenu/workspaceswitchermenu.d.ts +54 -0
  162. package/dist/types/product/workspaceswitchermenu/workspaceswitchermenu.recipe.d.ts +19 -0
  163. package/dist/types/tokens/contract.d.ts +13 -0
  164. package/dist/types/tokens/palette.d.ts +52 -0
  165. package/dist/types/tokens/semantic.d.ts +20 -0
  166. package/dist/workspaceswitchermenu.css +4 -0
  167. package/dist/workspaceswitchermenu.d.ts +7 -0
  168. package/dist/workspaceswitchermenu.layered.css +6 -0
  169. package/package.json +41 -1
@@ -315,9 +315,24 @@ const config = defineRecipe({
315
315
  Avatar's own already-shipped shape="rounded" value, not a new number.
316
316
  shape="circle" reuses Avatar's own shape="circle" value (radius.full)
317
317
  the same way, measured live on a real Attio person avatar. */
318
- & .crisp-datatable-avatar { width: ${c.size.icon}; height: ${c.size.icon}; border-radius: 30%; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; line-height: 1; font-weight: ${c.weight.semibold}; text-transform: uppercase; color: ${c.fgOnBrand}; background: ${c.bgBrandSolid}; }
318
+ & .crisp-datatable-avatar { width: ${c.size.icon}; height: ${c.size.icon}; border-radius: 30%; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; line-height: 1; font-weight: ${c.weight.semibold}; text-transform: uppercase; color: ${c.fgOnTone}; background: ${c.bgBrandSolid}; }
319
319
  & .crisp-datatable-avatar[data-shape="circle"] { border-radius: ${c.radius.full}; }
320
- & .crisp-datatable-avatar[data-tone="success"] { background: ${c.bgSuccess}; } & .crisp-datatable-avatar[data-tone="warning"] { background: ${c.bgWarning}; } & .crisp-datatable-avatar[data-tone="danger"] { background: ${c.bgDanger}; } & .crisp-datatable-avatar[data-tone="purple"] { background: ${c.bgPurple}; } & .crisp-datatable-avatar[data-tone="cyan"] { background: ${c.bgCyan}; } & .crisp-datatable-avatar[data-tone="orange"] { background: ${c.bgOrange}; }
320
+ /* your-moon/crisp#891: every data-tone override painted ONLY a
321
+ background, inheriting the base rule's white fgOnBrand -- white
322
+ clears AA against none of these seven fills (brand 3.23:1, success
323
+ 3.96, warning 4.12, danger 3.76, purple 3.58, cyan 2.30, orange
324
+ 2.77). fgOnTone (ink, #101112 -- semantic.ts's own comment: "dark ink
325
+ clears AA on every tone in both themes") clears 4.5:1+ on every fill
326
+ here with a comfortable margin (brand 5.85, success 4.77/6.29,
327
+ danger 5.03/6.19, purple 5.27, cyan 8.23, orange 6.83, light/dark),
328
+ so the base rule's colour moves there and every override keeps it --
329
+ EXCEPT warning, whose raw fill (bgWarning) only clears 4.59:1 in
330
+ light, too thin a margin to rely on: it takes bgWarningSolid instead,
331
+ the same solid-warning-plus-ink pair Badge's own solid variant
332
+ already established (13.98/7.83), for the same reason (see
333
+ tokens/palette.ts's amberSolid comment). No background changes
334
+ otherwise -- the fills themselves are unaffected, only the text. */
335
+ & .crisp-datatable-avatar[data-tone="success"] { background: ${c.bgSuccess}; color: ${c.fgOnTone}; } & .crisp-datatable-avatar[data-tone="warning"] { background: ${c.bgWarningSolid}; color: ${c.fgOnTone}; } & .crisp-datatable-avatar[data-tone="danger"] { background: ${c.bgDanger}; color: ${c.fgOnTone}; } & .crisp-datatable-avatar[data-tone="purple"] { background: ${c.bgPurple}; color: ${c.fgOnTone}; } & .crisp-datatable-avatar[data-tone="cyan"] { background: ${c.bgCyan}; color: ${c.fgOnTone}; } & .crisp-datatable-avatar[data-tone="orange"] { background: ${c.bgOrange}; color: ${c.fgOnTone}; }
321
336
  & .crisp-datatable-avatar img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
322
337
  & .crisp-datatable-tags { display: flex; align-items: center; gap: ${c.space["1"]}; overflow: hidden; }
323
338
  & .crisp-datatable-tag { display: inline-flex; align-items: center; height: ${c.size.chip}; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.chip}; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; white-space: nowrap; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: color-mix(in srgb, ${c.fgPrimary} 82%, transparent); }
@@ -328,6 +343,24 @@ const config = defineRecipe({
328
343
  & .crisp-datatable-tag[data-tone="purple"] { background: color-mix(in srgb, ${c.bgPurple} 16%, transparent); color: color-mix(in srgb, ${c.bgPurple} 80%, ${c.fgPrimary}); }
329
344
  & .crisp-datatable-tag[data-tone="cyan"] { background: color-mix(in srgb, ${c.bgCyan} 18%, transparent); color: color-mix(in srgb, ${c.bgCyan} 82%, ${c.fgPrimary}); }
330
345
  & .crisp-datatable-tag[data-tone="orange"] { background: color-mix(in srgb, ${c.bgOrange} 16%, transparent); color: color-mix(in srgb, ${c.bgOrange} 82%, ${c.fgPrimary}); }
346
+ /* your-moon/crisp#998: Attio's own 12-colour tag palette (measured
347
+ live, see tokens/palette.ts's tagSwatch1 comment) -- flat black
348
+ text, no ring, unlike the tone family above. 7px radius/10px text
349
+ are measured literals (no matching step in the shared scale, same
350
+ as Select's own .crisp-select-swatch). */
351
+ & .crisp-datatable-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; color: ${c.fgTagSwatch}; }
352
+ & .crisp-datatable-swatch[data-swatch="1"] { background: ${c.bgTagSwatch1}; }
353
+ & .crisp-datatable-swatch[data-swatch="2"] { background: ${c.bgTagSwatch2}; }
354
+ & .crisp-datatable-swatch[data-swatch="3"] { background: ${c.bgTagSwatch3}; }
355
+ & .crisp-datatable-swatch[data-swatch="4"] { background: ${c.bgTagSwatch4}; }
356
+ & .crisp-datatable-swatch[data-swatch="5"] { background: ${c.bgTagSwatch5}; }
357
+ & .crisp-datatable-swatch[data-swatch="6"] { background: ${c.bgTagSwatch6}; }
358
+ & .crisp-datatable-swatch[data-swatch="7"] { background: ${c.bgTagSwatch7}; }
359
+ & .crisp-datatable-swatch[data-swatch="8"] { background: ${c.bgTagSwatch8}; }
360
+ & .crisp-datatable-swatch[data-swatch="9"] { background: ${c.bgTagSwatch9}; }
361
+ & .crisp-datatable-swatch[data-swatch="10"] { background: ${c.bgTagSwatch10}; }
362
+ & .crisp-datatable-swatch[data-swatch="11"] { background: ${c.bgTagSwatch11}; }
363
+ & .crisp-datatable-swatch[data-swatch="12"] { background: ${c.bgTagSwatch12}; }
331
364
  & .crisp-datatable-num { font-variant-numeric: tabular-nums; }
332
365
  & .crisp-datatable-muted { color: ${c.fgMuted}; }
333
366
  /* crisp#822 -- a date/datetime cell's danger/overdue tone: same token as
@@ -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,
@@ -141,17 +174,24 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
141
174
  });
142
175
  return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsx("div", { ref: contentRef, className: contentClassName
143
176
  ? `crisp-menu-content ${contentClassName}`
144
- : "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, onFocus: onContentFocus, 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 }) }) }))] }));
145
178
  }
146
179
  Menu.displayName = "Menu";
147
- 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) {
148
187
  const ctx = React.useContext(MenuContext);
149
- 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);
150
190
  onSelect?.();
151
191
  if (closeOnSelect)
152
192
  ctx?.close();
153
- }, 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 })] }));
154
- }
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
+ });
155
195
  MenuItem.displayName = "MenuItem";
156
196
  export function MenuCheckboxItem({ children, checked, onCheckedChange, disabled, }) {
157
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}; }
@@ -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";
@@ -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";
@@ -0,0 +1,43 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // The standalone Notes gallery -- measured live on app.attio.com's Notes
4
+ // page: a toolbar (leading Sort control, trailing view toggle/View
5
+ // settings/New note, both consumer-composed slots -- crisp doesn't own the
6
+ // exact button set, same reasoning as `ListPage`'s own leading/trailing), a
7
+ // dashed "Favorites" region, then date-grouped sections (a group header +
8
+ // count badge, transcribed from `ListPage`'s own already-measured values
9
+ // rather than re-deriving them -- this IS the same Attio pattern, applied
10
+ // above a card grid instead of a row list) holding a responsive card grid of
11
+ // `EntityCard`s.
12
+ //
13
+ // The grid itself: a single real note card measured 356px wide -- with only
14
+ // one note in the workspace there was no second card to read a column gap or
15
+ // count from, so this uses a responsive `auto-fill` grid at that measured
16
+ // per-card width instead of inventing a fixed column count or gap.
17
+ const config = defineRecipe({
18
+ name: "notesgallery",
19
+ base: `display: flex; flex-direction: column; min-height: 0; box-sizing: border-box; font-family: ${c.font.sans}; color: ${c.fgPrimary};
20
+ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
21
+
22
+ & .crisp-notesgallery-tabs { display: flex; align-items: center; gap: ${c.space["2"]}; height: 44px; flex-shrink: 0; padding: 0 ${c.space["3"]}; }
23
+
24
+ & .crisp-notesgallery-toolbar { display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.header}; flex-shrink: 0; padding: 0 ${c.space["3"]}; }
25
+ & .crisp-notesgallery-toolbar-trail { display: flex; align-items: center; gap: ${c.space["2"]}; margin-inline-start: auto; }
26
+
27
+ & .crisp-notesgallery-favorites-section { background: ${c.bgSubtle}; box-shadow: inset 0 -1px 0 ${c.borderSubtle}; }
28
+ & .crisp-notesgallery-favorites-label { padding: ${c.space["2"]} ${c.space["4"]} 0; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
29
+ & .crisp-notesgallery-favorites { display: flex; align-items: center; justify-content: center; min-height: 200px; padding: ${c.space["6"]}; }
30
+ & .crisp-notesgallery-favorites-empty { box-sizing: border-box; width: 100%; padding: ${c.space["6"]}; border: 1px dashed ${c.borderDefault}; border-radius: ${c.radius.xl}; text-align: center; font-size: ${c.text.md}; color: ${c.fgMuted}; }
31
+
32
+ & .crisp-notesgallery-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 ${c.space["3"]} ${c.space["3"]}; }
33
+ & .crisp-notesgallery-group { display: flex; align-items: center; gap: ${c.space["1.5"]}; height: ${c.size.menuRow}; padding: ${c.space["2"]} ${c.space["1"]}; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
34
+ & .crisp-notesgallery-group-count { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); font-size: ${c.text.xs}; color: ${c.fgMuted}; }
35
+ & .crisp-notesgallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: ${c.space["4"]}; padding-bottom: ${c.space["4"]}; }
36
+
37
+ & .crisp-notesgallery-empty { padding: ${c.space["8"]} ${c.space["3"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgMuted}; }`,
38
+ variants: {},
39
+ defaultVariants: {},
40
+ compoundVariants: [],
41
+ });
42
+ export const notesgalleryRecipe = resolveRecipe(config);
43
+ export default config;
@@ -0,0 +1 @@
1
+ export * from "./objectpagemenu.js";
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Bell, ChevronRight, LayoutGrid, MessageSquare, MoreHorizontal, Settings, } from "lucide-react";
3
+ import { Button } from "../../components/button/index.js";
4
+ import { Menu, MenuItem, MenuSeparator } from "../../components/menu/index.js";
5
+ import { useCrispMessage } from "../../core/index.js";
6
+ /**
7
+ * The page-level "…" menu on Attio's object page: Object settings / Manage
8
+ * attributes / a Notifications submenu ("Connect Slack workspace") — crisp
9
+ * parity checklist rows G-020/P-005 (crisp#616). Live-measured on
10
+ * app.attio.com/techpartners/companies: 208×114 popover (12px radius, 4px
11
+ * padding, 32px rows), a hairline separator before Notifications, and its
12
+ * submenu opening flush against the item with no gap, flipping side when
13
+ * the measured width doesn't fit (`placeSubmenu`, core/place-floating.ts).
14
+ */
15
+ export function ObjectPageMenu({ trigger, onObjectSettings, onManageAttributes, onConnectSlack, objectSettingsLabel, manageAttributesLabel, notificationsLabel, connectSlackLabel, "aria-label": ariaLabelProp, align = "end", }) {
16
+ const objectSettings = useCrispMessage(objectSettingsLabel, "Object settings");
17
+ const manageAttributes = useCrispMessage(manageAttributesLabel, "Manage attributes");
18
+ const notifications = useCrispMessage(notificationsLabel, "Notifications");
19
+ const connectSlack = useCrispMessage(connectSlackLabel, "Connect Slack workspace");
20
+ const ariaLabel = useCrispMessage(ariaLabelProp, "Show menu");
21
+ return (_jsxs(Menu, { align: align, "aria-label": ariaLabel, trigger: trigger ?? (_jsx(Button, { intent: "ghost", size: "md", shape: "icon", "aria-label": ariaLabel, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) })), children: [_jsx(MenuItem, { icon: _jsx(Settings, { size: 16, strokeWidth: 1.75 }), onSelect: onObjectSettings, children: objectSettings }), _jsx(MenuItem, { icon: _jsx(LayoutGrid, { size: 16, strokeWidth: 1.75 }), onSelect: onManageAttributes, children: manageAttributes }), _jsx(MenuSeparator, {}), _jsx(Menu, { placement: "submenu", "aria-label": notifications, trigger: _jsx(MenuItem, { icon: _jsx(Bell, { size: 16, strokeWidth: 1.75 }), trailingIcon: _jsx(ChevronRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75 }), closeOnSelect: false, children: notifications }), children: _jsx(MenuItem, { icon: _jsx(MessageSquare, { size: 16, strokeWidth: 1.75 }), onSelect: onConnectSlack, children: connectSlack }) })] }));
22
+ }
23
+ ObjectPageMenu.displayName = "ObjectPageMenu";