@crisp-ui-kit/crisp 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/cjs/components/composewindow/composewindow.js +144 -0
  2. package/dist/cjs/components/composewindow/composewindow.recipe.js +45 -0
  3. package/dist/cjs/components/composewindow/index.js +8 -0
  4. package/dist/cjs/components/datatable/datatable.js +224 -43
  5. package/dist/cjs/components/datatable/datatable.recipe.js +37 -3
  6. package/dist/cjs/components/datatable/index.js +6 -0
  7. package/dist/cjs/components/datepickerfield/datepickerfield.js +25 -15
  8. package/dist/cjs/components/datepickerfield/datepickerfield.recipe.js +25 -8
  9. package/dist/cjs/components/dialog/dialog.recipe.js +9 -1
  10. package/dist/cjs/components/menu/menu.js +4 -2
  11. package/dist/cjs/components/multiselect/multiselect.recipe.js +14 -8
  12. package/dist/cjs/components/select/select.js +2 -1
  13. package/dist/cjs/components/table/table.recipe.js +12 -3
  14. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +1 -1
  15. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +18 -7
  16. package/dist/cjs/core/caret-rect.js +73 -0
  17. package/dist/cjs/core/cell-date.js +11 -0
  18. package/dist/cjs/core/index.js +8 -1
  19. package/dist/cjs/core/mention.js +32 -0
  20. package/dist/cjs/core/use-mention-trigger.js +124 -0
  21. package/dist/cjs/index.js +1 -0
  22. package/dist/cjs/product/recordpage/recordpage.js +88 -10
  23. package/dist/cjs/product/recordpage/recordpage.recipe.js +30 -0
  24. package/dist/cjs/product/recordpanel/recordpanel.js +11 -29
  25. package/dist/cjs/product/recordpanel/recordpanel.recipe.js +55 -17
  26. package/dist/cjs/product/recordstable/editable-field.js +113 -0
  27. package/dist/cjs/product/recordstable/index.js +4 -1
  28. package/dist/cjs/product/recordstable/recordstable.js +67 -11
  29. package/dist/cjs/product/recordstable/recordstable.recipe.js +9 -1
  30. package/dist/cjs/product/recordstable/recordstable.tag.js +8 -3
  31. package/dist/cjs/product/richtext/index.js +2 -1
  32. package/dist/cjs/product/richtext/richtext.js +440 -23
  33. package/dist/cjs/product/richtext/richtext.recipe.js +59 -9
  34. package/dist/cjs/product/settingsshell/settingsshell.recipe.js +13 -4
  35. package/dist/cjs/product/sidebar/sidebar.js +15 -3
  36. package/dist/cjs/product/sidebar/sidebar.recipe.js +21 -2
  37. package/dist/composewindow.css +24 -0
  38. package/dist/composewindow.d.ts +7 -0
  39. package/dist/composewindow.layered.css +26 -0
  40. package/dist/datatable.css +33 -14
  41. package/dist/datatable.layered.css +33 -14
  42. package/dist/datepickerfield.css +9 -4
  43. package/dist/datepickerfield.layered.css +9 -4
  44. package/dist/dialog.css +2 -1
  45. package/dist/dialog.layered.css +2 -1
  46. package/dist/esm/components/composewindow/composewindow.js +107 -0
  47. package/dist/esm/components/composewindow/composewindow.recipe.js +42 -0
  48. package/dist/esm/components/composewindow/index.js +2 -0
  49. package/dist/esm/components/datatable/datatable.js +226 -45
  50. package/dist/esm/components/datatable/datatable.recipe.js +37 -3
  51. package/dist/esm/components/datatable/index.js +6 -0
  52. package/dist/esm/components/datepickerfield/datepickerfield.js +26 -16
  53. package/dist/esm/components/datepickerfield/datepickerfield.recipe.js +25 -8
  54. package/dist/esm/components/dialog/dialog.recipe.js +9 -1
  55. package/dist/esm/components/menu/menu.js +4 -2
  56. package/dist/esm/components/multiselect/multiselect.recipe.js +14 -8
  57. package/dist/esm/components/select/select.js +2 -1
  58. package/dist/esm/components/table/table.recipe.js +12 -3
  59. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +1 -1
  60. package/dist/esm/components/tabletoolbar/tabletoolbar.js +18 -7
  61. package/dist/esm/core/caret-rect.js +70 -0
  62. package/dist/esm/core/cell-date.js +10 -0
  63. package/dist/esm/core/index.js +4 -1
  64. package/dist/esm/core/mention.js +29 -0
  65. package/dist/esm/core/use-mention-trigger.js +88 -0
  66. package/dist/esm/index.js +1 -0
  67. package/dist/esm/product/recordpage/recordpage.js +89 -11
  68. package/dist/esm/product/recordpage/recordpage.recipe.js +30 -0
  69. package/dist/esm/product/recordpanel/index.js +1 -1
  70. package/dist/esm/product/recordpanel/recordpanel.js +13 -31
  71. package/dist/esm/product/recordpanel/recordpanel.recipe.js +56 -18
  72. package/dist/esm/product/recordstable/editable-field.js +76 -0
  73. package/dist/esm/product/recordstable/index.js +1 -0
  74. package/dist/esm/product/recordstable/recordstable.js +69 -13
  75. package/dist/esm/product/recordstable/recordstable.recipe.js +9 -1
  76. package/dist/esm/product/recordstable/recordstable.tag.js +8 -3
  77. package/dist/esm/product/richtext/index.js +1 -1
  78. package/dist/esm/product/richtext/richtext.js +439 -22
  79. package/dist/esm/product/richtext/richtext.recipe.js +60 -10
  80. package/dist/esm/product/settingsshell/settingsshell.recipe.js +13 -4
  81. package/dist/esm/product/sidebar/sidebar.js +15 -3
  82. package/dist/esm/product/sidebar/sidebar.recipe.js +21 -2
  83. package/dist/multiselect.css +8 -8
  84. package/dist/multiselect.layered.css +8 -8
  85. package/dist/recordpage.css +25 -13
  86. package/dist/recordpage.layered.css +25 -13
  87. package/dist/recordpanel.css +43 -20
  88. package/dist/recordpanel.layered.css +43 -20
  89. package/dist/recordstable.css +9 -2
  90. package/dist/recordstable.layered.css +9 -2
  91. package/dist/richtext.css +31 -5
  92. package/dist/richtext.layered.css +31 -5
  93. package/dist/settingsshell.css +2 -2
  94. package/dist/settingsshell.layered.css +2 -2
  95. package/dist/sidebar.css +7 -2
  96. package/dist/sidebar.layered.css +7 -2
  97. package/dist/styles.css +199 -74
  98. package/dist/styles.layered.css +199 -74
  99. package/dist/table.css +4 -2
  100. package/dist/table.layered.css +4 -2
  101. package/dist/tabletoolbar.css +1 -1
  102. package/dist/tabletoolbar.layered.css +1 -1
  103. package/dist/types/components/composewindow/composewindow.d.ts +80 -0
  104. package/dist/types/components/composewindow/composewindow.recipe.d.ts +3 -0
  105. package/dist/types/components/composewindow/index.d.ts +2 -0
  106. package/dist/types/components/datatable/datatable.d.ts +58 -4
  107. package/dist/types/components/datatable/index.d.ts +1 -1
  108. package/dist/types/components/datepickerfield/datepickerfield.d.ts +18 -4
  109. package/dist/types/components/menu/menu.d.ts +10 -1
  110. package/dist/types/components/select/select.d.ts +6 -1
  111. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +29 -5
  112. package/dist/types/core/caret-rect.d.ts +16 -0
  113. package/dist/types/core/cell-date.d.ts +17 -0
  114. package/dist/types/core/index.d.ts +4 -1
  115. package/dist/types/core/mention.d.ts +27 -0
  116. package/dist/types/core/use-mention-trigger.d.ts +41 -0
  117. package/dist/types/index.d.ts +1 -0
  118. package/dist/types/product/recordpage/recordpage.d.ts +59 -1
  119. package/dist/types/product/recordpanel/index.d.ts +1 -1
  120. package/dist/types/product/recordpanel/recordpanel.d.ts +81 -17
  121. package/dist/types/product/recordstable/editable-field.d.ts +48 -0
  122. package/dist/types/product/recordstable/index.d.ts +1 -0
  123. package/dist/types/product/recordstable/recordstable.d.ts +29 -0
  124. package/dist/types/product/recordstable/recordstable.tag.d.ts +8 -3
  125. package/dist/types/product/richtext/index.d.ts +1 -1
  126. package/dist/types/product/richtext/richtext.d.ts +168 -7
  127. package/dist/types/product/sidebar/sidebar.d.ts +60 -4
  128. package/package.json +6 -1
@@ -5,6 +5,13 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
5
5
  // per theme — light uses a blue-tinted 3-layer shadow, dark drops to a plain
6
6
  // 2-layer black shadow) with a bordered header (title + close), a scrollable
7
7
  // body, and a bordered footer for the primary action.
8
+ //
9
+ // #414: the footer's submit-key caps (⌘/Enter) used to be one span drawing a
10
+ // visible pill (18px, 22%-white background). Attio's own are two separate
11
+ // TRANSPARENT 16x16 glyphs, 4px apart. Their 3px/10px/4px measured values sit
12
+ // between crisp's token steps (space has no 3px, radius starts at 6px, text
13
+ // starts at 11px) — raw px per Golden Rule 3's "genuinely intrinsic size"
14
+ // exception, same as a chip's odd 7px corner.
8
15
  const config = defineRecipe({
9
16
  name: "dialog",
10
17
  base: `display: contents; font-family: ${c.font.sans};
@@ -23,7 +30,8 @@ const config = defineRecipe({
23
30
  & .crisp-dialog-field-label { font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 50%, transparent); }
24
31
  & .crisp-dialog-more { display: inline-flex; align-items: center; gap: ${c.space["2"]}; margin-inline-end: auto; border: none; background: none; padding: 0; font-family: inherit; font-size: ${c.text.md}; color: ${c.fgMuted}; cursor: pointer; }
25
32
  & .crisp-dialog-submit { display: inline-flex; align-items: center; gap: ${c.space["2"]}; height: 28px; padding: 0 ${c.space["1"]} 0 ${c.space["2.5"]}; border: none; border-radius: ${c.radius.md}; background: ${c.bgBrandSolid}; color: ${c.fgOnBrand}; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; cursor: pointer; }
26
- & .crisp-dialog-submit-key { display: inline-flex; align-items: center; justify-content: center; height: 18px; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: rgba(255,255,255,0.22); font-size: ${c.text.xs}; }`,
33
+ & .crisp-dialog-submit-key { display: inline-flex; align-items: center; gap: ${c.space["1"]}; }
34
+ & .crisp-dialog-submit-key kbd { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0 3px; border-radius: 4px; background: transparent; color: ${c.fgOnBrand}; font-family: inherit; font-style: normal; font-size: 10px; }`,
27
35
  variants: {},
28
36
  defaultVariants: {},
29
37
  compoundVariants: [],
@@ -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, "aria-label": ariaLabel, }) {
15
+ export function Menu({ trigger, children, align = "start", 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
@@ -123,7 +123,9 @@ export function Menu({ trigger, children, align = "start", open: openProp, defau
123
123
  setOpen(!open);
124
124
  },
125
125
  });
126
- return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsx("div", { ref: contentRef, className: "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 }) }) }))] }));
126
+ return (_jsxs("div", { ref: rootRef, className: menuRecipe({ className }), children: [triggerEl, open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsx("div", { ref: contentRef, className: contentClassName
127
+ ? `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 }) }) }))] }));
127
129
  }
128
130
  Menu.displayName = "Menu";
129
131
  export function MenuItem({ children, icon, shortcut, danger, disabled, onSelect, closeOnSelect = true, }) {
@@ -19,6 +19,12 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
19
19
  // intact WITHIN the portaled subtree, only the link back to the
20
20
  // trigger-holding root broke). A flat recordstable-tag selector also works
21
21
  // unchanged wherever it's rendered, portaled or not, so it needs no split.
22
+ // This is a third, hand-pasted copy of `recordTagCss`
23
+ // (product/recordstable/recordstable.tag.ts) rather than a splice of it:
24
+ // that helper is written `& .crisp-recordstable-tag { ... }` for nesting
25
+ // under a recipe's `base`, which doesn't compile to anything inside a flat
26
+ // `globalCss` block. Keep the two in sync by hand (#380 added the measured
27
+ // `box-shadow: inset 0 0 0 1px <tone ring colour>` here to match).
22
28
  const config = defineRecipe({
23
29
  name: "multiselect",
24
30
  base: `
@@ -74,14 +80,14 @@ const config = defineRecipe({
74
80
  .crisp-multiselect-option[aria-selected="true"] .crisp-multiselect-check { background: ${c.bgBrandSolid}; box-shadow: none; color: #fff; }
75
81
  .crisp-multiselect-empty { padding: ${c.space["3"]} ${c.space["2"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgMuted}; }
76
82
 
77
- .crisp-recordstable-tag { display: inline-flex; align-items: center; height: ${c.size.chip}; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.chip}; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: color-mix(in srgb, ${c.fgPrimary} 82%, transparent); }
78
- .crisp-recordstable-tag[data-tone="brand"] { background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent); color: color-mix(in srgb, ${c.bgBrandSolid} 82%, ${c.fgPrimary}); }
79
- .crisp-recordstable-tag[data-tone="success"] { background: color-mix(in srgb, ${c.bgSuccess} 16%, transparent); color: color-mix(in srgb, ${c.bgSuccess} 78%, ${c.fgPrimary}); }
80
- .crisp-recordstable-tag[data-tone="warning"] { background: color-mix(in srgb, ${c.bgWarning} 18%, transparent); color: color-mix(in srgb, ${c.bgWarning} 82%, ${c.fgPrimary}); }
81
- .crisp-recordstable-tag[data-tone="danger"] { background: color-mix(in srgb, ${c.bgDanger} 15%, transparent); color: color-mix(in srgb, ${c.bgDanger} 80%, ${c.fgPrimary}); }
82
- .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}); }
83
- .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}); }
84
- .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}); }
83
+ .crisp-recordstable-tag { display: inline-flex; align-items: center; height: ${c.size.chip}; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.chip}; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: color-mix(in srgb, ${c.fgPrimary} 82%, transparent); box-shadow: inset 0 0 0 1px ${c.borderSubtle}; }
84
+ .crisp-recordstable-tag[data-tone="brand"] { background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent); color: color-mix(in srgb, ${c.bgBrandSolid} 82%, ${c.fgPrimary}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgBrandSolid} 30%, transparent); }
85
+ .crisp-recordstable-tag[data-tone="success"] { background: color-mix(in srgb, ${c.bgSuccess} 16%, transparent); color: color-mix(in srgb, ${c.bgSuccess} 78%, ${c.fgPrimary}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgSuccess} 30%, transparent); }
86
+ .crisp-recordstable-tag[data-tone="warning"] { background: color-mix(in srgb, ${c.bgWarning} 18%, transparent); color: color-mix(in srgb, ${c.bgWarning} 82%, ${c.fgPrimary}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgWarning} 30%, transparent); }
87
+ .crisp-recordstable-tag[data-tone="danger"] { background: color-mix(in srgb, ${c.bgDanger} 15%, transparent); color: color-mix(in srgb, ${c.bgDanger} 80%, ${c.fgPrimary}); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgDanger} 30%, transparent); }
88
+ .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); }
89
+ .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); }
90
+ .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); }
85
91
  `,
86
92
  variants: {
87
93
  // Same ring Select's `invalid` draws — see that recipe's comment.
@@ -16,7 +16,7 @@ function optionText(o) {
16
16
  * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
17
17
  * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
18
18
  */
19
- export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
19
+ export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", onSearchChange, emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
20
20
  // Virtual focus: DOM focus stays on the trigger (or the search field), and the
21
21
  // active option is named by `aria-activedescendant`. That is the only thing a
22
22
  // screen reader has to go on -- `data-highlighted` is invisible to it -- so the
@@ -165,6 +165,7 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
165
165
  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, 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.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
166
166
  setQuery(e.target.value);
167
167
  setHighlight(0);
168
+ onSearchChange?.(e.target.value);
168
169
  }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
169
170
  const isSel = opt.value === selected;
170
171
  // #343: a header renders once per run of same-`group`
@@ -11,11 +11,11 @@ const config = defineRecipe({
11
11
  & .crisp-table-scroll { overflow-x: auto; }
12
12
  & table { width: 100%; border-collapse: separate; border-spacing: 0; font-family: ${c.font.sans}; font-size: ${c.text.lg}; color: ${c.fgPrimary}; }
13
13
  & th, & td { position: relative; height: ${c.size.tableRow}; padding: 0 ${c.space["2"]}; 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; }
14
- & th { height: ${c.size.tableHeader}; background: ${c.bgSurface}; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; letter-spacing: -0.14px; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); user-select: none; }
14
+ & th { height: ${c.size.tableHeader}; background: ${c.bgSurface}; border-top: 1px solid ${c.borderSubtle}; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; letter-spacing: -0.14px; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); user-select: none; }
15
15
  & td { color: ${c.fgPrimary}; background: ${c.bgSurface}; }
16
16
  & th:last-child, & td:last-child { border-inline-end: none; }
17
17
  & tbody tr:last-child td { border-bottom: none; }
18
- & tfoot td { border-top: 1px solid ${c.borderSubtle}; border-bottom: none; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
18
+ & tfoot td { border-top: 1px solid ${c.borderSubtle}; border-bottom: 1px solid ${c.borderSubtle}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
19
19
  & caption { padding: ${c.space["2"]} ${c.space["2"]}; text-align: start; font-size: ${c.text.sm}; color: ${c.fgMuted}; caption-side: bottom; }
20
20
  & td[data-align="right"], & th[data-align="right"] { text-align: end; }
21
21
  & td[data-align="center"], & th[data-align="center"] { text-align: center; }`,
@@ -28,8 +28,17 @@ const config = defineRecipe({
28
28
  // card (default): the bordered rounded surface, unchanged for existing
29
29
  // consumers. bleed: drops the outer border/radius/inset so the grid runs
30
30
  // edge-to-edge, like RecordsTable's real full-page records grid.
31
+ //
32
+ // #439: th/tfoot td now carry their own top/bottom border (below) so a
33
+ // bleed table's header and calc footer close the same way Attio's own
34
+ // do. card's own 4-side border already draws that top/bottom edge, so
35
+ // strip the cells' copies there -- same reasoning as `:last-child`
36
+ // dropping the inline-end border and `tbody tr:last-child` dropping the
37
+ // bottom one: the container's edge wins, cells never double it.
31
38
  surface: {
32
- card: `border: 1px solid ${c.borderSubtle}; border-radius: ${c.radius.xl}; overflow: hidden;`,
39
+ card: `border: 1px solid ${c.borderSubtle}; border-radius: ${c.radius.xl}; overflow: hidden;
40
+ & th { border-top: none; }
41
+ & tfoot td { border-bottom: none; }`,
33
42
  bleed: `border: none; border-radius: 0;`,
34
43
  },
35
44
  },
@@ -20,7 +20,7 @@ export const toolbarChromeHead = (p) => ` & .${CLASS_PREFIX}${p}-toolbar { di
20
20
  & .${CLASS_PREFIX}${p}-search svg { flex-shrink: 0; }
21
21
  & .${CLASS_PREFIX}${p}-search input { border: none; outline: none; background: transparent; font-family: inherit; font-size: ${c.text.md}; color: ${c.fgPrimary}; width: 180px; }
22
22
  & .${CLASS_PREFIX}${p}-search input::placeholder { color: ${c.fgMuted}; }
23
- & .${CLASS_PREFIX}${p}-tool { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.row}; margin: 0; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: transparent; box-shadow: inset 0 0 0 1px ${c.borderDefault}; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; cursor: pointer; transition: background-color .12s; }
23
+ & .${CLASS_PREFIX}${p}-tool { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.row}; margin: 0; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: transparent; box-shadow: inset 0 0 0 1px ${c.borderDefault}; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
24
24
  & .${CLASS_PREFIX}${p}-tool:hover { background: ${c.bgRaised}; }
25
25
  & .${CLASS_PREFIX}${p}-tool svg { flex-shrink: 0; color: ${c.fgMuted}; }
26
26
  & .${CLASS_PREFIX}${p}-tool--active { background: ${c.bgRaised}; }
@@ -110,8 +110,19 @@ export function applySorts(rows, sorts, get = (row, key) => row[key]) {
110
110
  * `RecordsTable` renders this same measured chrome internally; this is the
111
111
  * composable version, to put above `DataTable` or any table of your own. Pair
112
112
  * it with `applyFilters` / `applySorts` to turn its state into rows.
113
+ *
114
+ * Sort and Filter read the same `columns` list by default, but can be given
115
+ * their own (`sortColumns`/`filterColumns`) when a consumer's two menus
116
+ * genuinely offer different fields — Attio's own Tasks page does exactly
117
+ * this (Sort: Due date/Assignee/Creation date; Filter: Assignee/Record/
118
+ * Created by), which one shared list can't reproduce.
113
119
  */
114
- export function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsChange, filters: filtersProp, defaultFilters, onFiltersChange, count, children, helpHref, className, sortLabel = "Sort", reorderSortLabel = "Reorder sort", noSortsLabel = "No sorts applied to this view.", addSortLabel = "Add sort", learnSortingLabel = "Learn about sorting", selectConditionLabel = "Select condition", sortFieldLabel = "Sort field", sortDirectionLabel = "Sort direction", removeSortLabel = "Remove sort", filterFieldLabel = "Filter by field", filterFieldPlaceholder = "Filter by…", filterValuePlaceholder = "Value…", ascendingLabel = "Ascending", descendingLabel = "Descending", sortTriggerLabel, filterTriggerLabel, inline, ...rest }) {
120
+ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: filterColumnsProp, sorts: sortsProp, defaultSorts, onSortsChange, filters: filtersProp, defaultFilters, onFiltersChange, count, children, helpHref, className, sortLabel = "Sort", reorderSortLabel = "Reorder sort", noSortsLabel = "No sorts applied to this view.", addSortLabel = "Add sort", learnSortingLabel = "Learn about sorting", selectConditionLabel = "Select condition", sortFieldLabel = "Sort field", sortDirectionLabel = "Sort direction", removeSortLabel = "Remove sort", filterFieldLabel = "Filter by field", filterFieldPlaceholder = "Filter by…", filterValuePlaceholder = "Value…", ascendingLabel = "Ascending", descendingLabel = "Descending", sortTriggerLabel, filterTriggerLabel, inline, ...rest }) {
121
+ // Each side falls back to the shared `columns` independently — a caller
122
+ // that only overrides one (Filter's own Record/Created by, say) still
123
+ // gets `columns` on the other, unchanged from before either prop existed.
124
+ const sortColumns = sortColumnsProp ?? columns ?? [];
125
+ const filterColumns = filterColumnsProp ?? columns ?? [];
115
126
  const [sortsState, setSortsState] = React.useState(defaultSorts ?? []);
116
127
  const [filtersState, setFiltersState] = React.useState(defaultFilters ?? []);
117
128
  const sorts = sortsProp ?? sortsState;
@@ -158,7 +169,7 @@ export function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsC
158
169
  }, [openMenu]);
159
170
  const addSort = () => {
160
171
  const used = new Set(sorts.map((s) => s.key));
161
- const col = columns.find((c) => !used.has(c.key));
172
+ const col = sortColumns.find((c) => !used.has(c.key));
162
173
  if (col)
163
174
  putSorts([...sorts, { key: col.key, dir: "asc" }]);
164
175
  };
@@ -197,9 +208,9 @@ export function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsC
197
208
  // The default phrase only works in English word order, so a caller can
198
209
  // replace the whole thing rather than fill a template.
199
210
  const sortedLabel = sortTriggerLabel
200
- ? sortTriggerLabel(sorts, columns)
211
+ ? sortTriggerLabel(sorts, sortColumns)
201
212
  : sorts.length
202
- ? `Sorted by ${columns.find((c) => c.key === sorts[0].key)?.label}${sorts.length > 1 ? ` +${sorts.length - 1}` : ""}`
213
+ ? `Sorted by ${sortColumns.find((c) => c.key === sorts[0].key)?.label}${sorts.length > 1 ? ` +${sorts.length - 1}` : ""}`
203
214
  : "Sort";
204
215
  return (_jsx("div", { className: tabletoolbarRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-tabletoolbar-toolbar", "data-inline": inline ? "" : undefined, children: [_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: `crisp-tabletoolbar-tool${sorts.length ? " crisp-tabletoolbar-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "sort" ? null : "sort"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "sort", "data-menu-trigger": true, children: [_jsx(ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sortedLabel] }), openMenu === "sort" && (_jsxs("div", { ref: sortListRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-sortpop", role: "dialog", "aria-label": sortLabel, children: [sorts.length === 0 ? (_jsx("div", { className: "crisp-tabletoolbar-sort-empty", children: noSortsLabel })) : (sorts.map((s, i) => (_jsxs("div", { className: "crisp-tabletoolbar-sort-row", onDragOver: (e) => {
205
216
  if (dragSort !== null)
@@ -208,7 +219,7 @@ export function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsC
208
219
  if (dragSort !== null)
209
220
  moveSort(dragSort, i);
210
221
  setDragSort(null);
211
- }, children: [_jsx("button", { type: "button", className: "crisp-tabletoolbar-sort-grip", "aria-label": reorderSortLabel, draggable: true, onDragStart: () => setDragSort(i), onDragEnd: () => setDragSort(null), children: _jsx(GripVertical, { size: 15, strokeWidth: 1.75 }) }), _jsx(Select, { className: "crisp-tabletoolbar-sort-field", searchable: true, value: s.key, onValueChange: (k) => setSortField(i, k), options: columns.map((c) => ({
222
+ }, children: [_jsx("button", { type: "button", className: "crisp-tabletoolbar-sort-grip", "aria-label": reorderSortLabel, draggable: true, onDragStart: () => setDragSort(i), onDragEnd: () => setDragSort(null), children: _jsx(GripVertical, { size: 15, strokeWidth: 1.75 }) }), _jsx(Select, { className: "crisp-tabletoolbar-sort-field", searchable: true, value: s.key, onValueChange: (k) => setSortField(i, k), options: sortColumns.map((c) => ({
212
223
  value: c.key,
213
224
  label: c.icon ? (_jsxs("span", { className: "crisp-tabletoolbar-sort-optlabel", children: [_jsx(c.icon, { size: 15, strokeWidth: 1.75 }), c.label] })) : (c.label),
214
225
  keywords: c.label,
@@ -229,12 +240,12 @@ export function TableToolbar({ columns, sorts: sortsProp, defaultSorts, onSortsC
229
240
  ? `Filter · ${activeFilters}`
230
241
  : "Filter"] }), openMenu === "filter" && (_jsxs("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": filterFieldLabel, children: [_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterFieldPlaceholder, "aria-label": filterFieldLabel,
231
242
  // biome-ignore lint/a11y/noAutofocus: focus the picker on open
232
- autoFocus: true, value: filterQuery, onChange: (e) => setFilterQuery(e.target.value) }), columns
243
+ autoFocus: true, value: filterQuery, onChange: (e) => setFilterQuery(e.target.value) }), filterColumns
233
244
  .filter((col) => col.label
234
245
  .toLowerCase()
235
246
  .includes(filterQuery.trim().toLowerCase()))
236
247
  .map((col) => (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => addFilter(col.key), children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : (_jsx("span", { style: { width: 15 } })), col.label] }, col.key)))] }))] }), filters.map((cond) => {
237
- const col = columns.find((c) => c.key === cond.key);
248
+ const col = filterColumns.find((c) => c.key === cond.key);
238
249
  const open = openMenu === `fcond:${cond.id}`;
239
250
  const hasValue = (!opNeedsValue(cond.op) || cond.value.trim()) &&
240
251
  (!opNeedsValues(cond.op) || (cond.values?.length ?? 0) > 0);
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The pixel position of a caret index inside a `<textarea>`/`<input>`, in
3
+ * viewport coordinates — where a mention picker anchors.
4
+ *
5
+ * Neither element exposes caret geometry directly, so this uses the standard
6
+ * mirror-div technique: an off-screen div replicating every computed style
7
+ * that affects text layout (font, padding, border, wrapping), holding the
8
+ * same text up to the caret plus a marker span. The marker's own
9
+ * `getBoundingClientRect()`, translated back onto the real field's box, is
10
+ * the caret's actual rendered position — nothing here guesses at line
11
+ * height or character width itself.
12
+ */
13
+ export function caretRectInField(field, index) {
14
+ const div = document.createElement("div");
15
+ const style = window.getComputedStyle(field);
16
+ const mirrored = [
17
+ "boxSizing",
18
+ "width",
19
+ "paddingTop",
20
+ "paddingRight",
21
+ "paddingBottom",
22
+ "paddingLeft",
23
+ "borderTopWidth",
24
+ "borderRightWidth",
25
+ "borderBottomWidth",
26
+ "borderLeftWidth",
27
+ "fontFamily",
28
+ "fontSize",
29
+ "fontWeight",
30
+ "fontStyle",
31
+ "letterSpacing",
32
+ "lineHeight",
33
+ "textTransform",
34
+ "textIndent",
35
+ "textAlign",
36
+ "wordSpacing",
37
+ "tabSize",
38
+ ];
39
+ for (const prop of mirrored)
40
+ div.style.setProperty(prop, style[prop]);
41
+ div.style.position = "absolute";
42
+ div.style.visibility = "hidden";
43
+ div.style.top = "0";
44
+ div.style.left = "-9999px";
45
+ div.style.whiteSpace =
46
+ field instanceof HTMLTextAreaElement ? "pre-wrap" : "pre";
47
+ div.style.wordWrap = "break-word";
48
+ div.style.overflow = "hidden";
49
+ div.style.height = "auto";
50
+ const value = field.value;
51
+ div.textContent = value.slice(0, index);
52
+ const marker = document.createElement("span");
53
+ // a trailing empty span collapses to zero width and reports the wrong x —
54
+ // give it a hair of content so its box sits exactly at the caret
55
+ marker.textContent = value.slice(index, index + 1) || ".";
56
+ div.appendChild(marker);
57
+ document.body.appendChild(div);
58
+ div.scrollTop = field.scrollTop;
59
+ div.scrollLeft = field.scrollLeft;
60
+ const fieldRect = field.getBoundingClientRect();
61
+ const divRect = div.getBoundingClientRect();
62
+ const markerRect = marker.getBoundingClientRect();
63
+ const x = fieldRect.left + (markerRect.left - divRect.left) - div.scrollLeft;
64
+ const y = fieldRect.top +
65
+ (markerRect.top - divRect.top) -
66
+ div.scrollTop +
67
+ markerRect.height;
68
+ document.body.removeChild(div);
69
+ return { x, y };
70
+ }
@@ -31,3 +31,13 @@ export function formatCellDate(d) {
31
31
  const pad = (n) => String(n).padStart(2, "0");
32
32
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
33
33
  }
34
+ export function cellDatePrecision(raw) {
35
+ const m = DATE_ONLY.exec(raw);
36
+ if (!m)
37
+ return "day";
38
+ if (!m[2])
39
+ return "year";
40
+ if (!m[3])
41
+ return "month";
42
+ return "day";
43
+ }
@@ -1,4 +1,5 @@
1
- export { formatCellDate, parseCellDate } from "./cell-date.js";
1
+ export { caretRectInField } from "./caret-rect.js";
2
+ export { cellDatePrecision, formatCellDate, parseCellDate, } from "./cell-date.js";
2
3
  export { cx } from "./cx.js";
3
4
  export { defineRecipe } from "./define.js";
4
5
  export { withDescribedBy } from "./dialog-description.js";
@@ -6,6 +7,7 @@ export { isRtlElement } from "./direction.js";
6
7
  export { FloatingPortal } from "./floating-portal.js";
7
8
  export { hasOpenFloatingLayer, isInsideLaterFloatingLayer, registerFloatingLayer, } from "./floating-stack.js";
8
9
  export { formValueStyle } from "./form-value.js";
10
+ export { matchMentionTrigger, } from "./mention.js";
9
11
  export { placeFloating, } from "./place-floating.js";
10
12
  export { Primitive } from "./primitive.js";
11
13
  export { resolveRecipe } from "./resolve.js";
@@ -14,4 +16,5 @@ export { defineSpec, specToRecipe } from "./spec.js";
14
16
  export { useFocusAfterRemoval } from "./use-focus-after-removal.js";
15
17
  export { useFormReset } from "./use-form-reset.js";
16
18
  export { useLabelAssociation } from "./use-label-association.js";
19
+ export { useMentionTrigger, } from "./use-mention-trigger.js";
17
20
  export { usePrefersReducedMotion } from "./use-prefers-reduced-motion.js";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Is there an active "@query" mention trigger ending at the caret?
3
+ *
4
+ * Matches the rule every "@mention" implementation (GitHub, Slack, Linear)
5
+ * actually uses: the trigger character must start a word — the very first
6
+ * character, or preceded by whitespace — and nothing between it and the
7
+ * caret may contain whitespace. `foo@bar` is an email/handle stuck to a word,
8
+ * not a trigger; `@jo next` has already moved past the mention it typed.
9
+ *
10
+ * Takes plain text rather than a live DOM node so it is one pure function
11
+ * usable against a `<textarea>`'s `value` and a `contentEditable` text
12
+ * node's content alike, and is the one thing here worth unit-testing on its
13
+ * own — everything downstream of it (caret geometry, DOM insertion) needs a
14
+ * real browser to verify.
15
+ */
16
+ export function matchMentionTrigger(textBeforeCaret, trigger = "@") {
17
+ if (!trigger)
18
+ return null;
19
+ const start = textBeforeCaret.lastIndexOf(trigger);
20
+ if (start === -1)
21
+ return null;
22
+ const before = start > 0 ? textBeforeCaret[start - 1] : undefined;
23
+ if (before !== undefined && !/\s/.test(before))
24
+ return null;
25
+ const query = textBeforeCaret.slice(start + trigger.length);
26
+ if (/\s/.test(query))
27
+ return null;
28
+ return { query, start };
29
+ }
@@ -0,0 +1,88 @@
1
+ import * as React from "react";
2
+ import { caretRectInField } from "./caret-rect.js";
3
+ import { matchMentionTrigger, } from "./mention.js";
4
+ /**
5
+ * Watches a `<textarea>`/`<input>` for an "@query" mention trigger and
6
+ * reports where to anchor a consumer-supplied picker (#372).
7
+ *
8
+ * crisp draws no picker of its own here — GitHub/Slack/Linear-style @mention
9
+ * UI varies too much by app (avatars, groups, recent-first ordering, a
10
+ * "create new person" row) to have one measured shape, and this repo's rule
11
+ * is measure-first, never invented polish. What is shared and worth owning
12
+ * centrally is the fiddly, easy-to-get-wrong part: deciding when "@" is
13
+ * actually a trigger rather than part of a word or an email address (see
14
+ * `matchMentionTrigger`), and where its picker anchors on screen (see
15
+ * `caretRectInField`, since neither element exposes caret geometry itself).
16
+ *
17
+ * Does not close on blur: a picker built from this needs to survive the
18
+ * field losing focus to a `mousedown` on one of its own rows, the same
19
+ * problem every floating surface in this repo solves for itself (see
20
+ * `RichTextEditor`'s toolbar `keepSel`, or `Popover`'s own outside-pointer-
21
+ * down listener) — so closing on outside interaction is left to the
22
+ * consumer's picker, which already knows what counts as "outside" for its
23
+ * own markup.
24
+ */
25
+ export function useMentionTrigger(ref, { trigger = "@" } = {}) {
26
+ const [state, setState] = React.useState({
27
+ open: false,
28
+ query: "",
29
+ anchor: null,
30
+ });
31
+ const matchRef = React.useRef(null);
32
+ const recompute = React.useCallback(() => {
33
+ const el = ref.current;
34
+ if (!el || el.selectionStart == null) {
35
+ matchRef.current = null;
36
+ setState({ open: false, query: "", anchor: null });
37
+ return;
38
+ }
39
+ const caret = el.selectionStart;
40
+ const match = matchMentionTrigger(el.value.slice(0, caret), trigger);
41
+ matchRef.current = match;
42
+ if (!match) {
43
+ setState({ open: false, query: "", anchor: null });
44
+ return;
45
+ }
46
+ setState({
47
+ open: true,
48
+ query: match.query,
49
+ anchor: caretRectInField(el, caret),
50
+ });
51
+ }, [ref, trigger]);
52
+ React.useEffect(() => {
53
+ const el = ref.current;
54
+ if (!el)
55
+ return;
56
+ // "input" covers typing; "click"/"keyup" cover the caret moving without
57
+ // the text changing (arrow keys, clicking elsewhere on the same line).
58
+ el.addEventListener("input", recompute);
59
+ el.addEventListener("click", recompute);
60
+ el.addEventListener("keyup", recompute);
61
+ return () => {
62
+ el.removeEventListener("input", recompute);
63
+ el.removeEventListener("click", recompute);
64
+ el.removeEventListener("keyup", recompute);
65
+ };
66
+ }, [ref, recompute]);
67
+ const close = React.useCallback(() => {
68
+ matchRef.current = null;
69
+ setState({ open: false, query: "", anchor: null });
70
+ }, []);
71
+ const insert = React.useCallback((text) => {
72
+ const el = ref.current;
73
+ const match = matchRef.current;
74
+ if (!el || !match)
75
+ return;
76
+ const caret = el.selectionStart ?? el.value.length;
77
+ const before = el.value.slice(0, match.start);
78
+ const after = el.value.slice(caret);
79
+ el.value = before + text + after;
80
+ const cursor = before.length + text.length;
81
+ el.focus();
82
+ el.setSelectionRange(cursor, cursor);
83
+ // a controlled consumer listens on "input", the same event typing fires
84
+ el.dispatchEvent(new Event("input", { bubbles: true }));
85
+ close();
86
+ }, [ref, close]);
87
+ return { ...state, close, insert };
88
+ }
package/dist/esm/index.js CHANGED
@@ -15,6 +15,7 @@ export * from "./components/card/index.js";
15
15
  export * from "./components/checkbox/index.js";
16
16
  export * from "./components/chip/index.js";
17
17
  export * from "./components/columncalc/index.js";
18
+ export * from "./components/composewindow/index.js";
18
19
  export * from "./components/contextmenu/index.js";
19
20
  export * from "./components/datatable/index.js";
20
21
  export * from "./components/datepicker/index.js";