@crisp-ui-kit/crisp 0.54.4 → 0.54.6

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 (49) hide show
  1. package/dist/authshell.css +4 -0
  2. package/dist/authshell.layered.css +4 -0
  3. package/dist/chip.css +1 -1
  4. package/dist/chip.layered.css +1 -1
  5. package/dist/cjs/components/chip/chip.recipe.js +12 -0
  6. package/dist/cjs/components/datatable/datatable.js +28 -5
  7. package/dist/cjs/components/datatable/datatable.recipe.js +6 -0
  8. package/dist/cjs/components/personchip/personchip.js +13 -3
  9. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +75 -2
  10. package/dist/cjs/components/tooltip/tooltip.js +2 -2
  11. package/dist/cjs/components/tooltip/tooltip.recipe.js +37 -0
  12. package/dist/cjs/product/authshell/authshell.js +1 -1
  13. package/dist/cjs/product/authshell/authshell.recipe.js +18 -0
  14. package/dist/cjs/product/comments/comments.js +13 -21
  15. package/dist/cjs/product/comments/comments.recipe.js +29 -14
  16. package/dist/cjs/product/recordstable/recordstable.js +12 -3
  17. package/dist/cjs/product/richtext/richtext.js +31 -4
  18. package/dist/comments.css +21 -15
  19. package/dist/comments.layered.css +21 -15
  20. package/dist/datatable.css +8 -4
  21. package/dist/datatable.layered.css +8 -4
  22. package/dist/esm/components/chip/chip.recipe.js +12 -0
  23. package/dist/esm/components/datatable/datatable.js +28 -5
  24. package/dist/esm/components/datatable/datatable.recipe.js +6 -0
  25. package/dist/esm/components/personchip/personchip.js +13 -3
  26. package/dist/esm/components/tabletoolbar/tabletoolbar.js +76 -3
  27. package/dist/esm/components/tooltip/tooltip.js +2 -2
  28. package/dist/esm/components/tooltip/tooltip.recipe.js +37 -0
  29. package/dist/esm/product/authshell/authshell.js +1 -1
  30. package/dist/esm/product/authshell/authshell.recipe.js +18 -0
  31. package/dist/esm/product/comments/comments.js +13 -21
  32. package/dist/esm/product/comments/comments.recipe.js +29 -14
  33. package/dist/esm/product/recordstable/recordstable.js +12 -3
  34. package/dist/esm/product/richtext/richtext.js +31 -4
  35. package/dist/styles.css +41 -20
  36. package/dist/styles.layered.css +41 -20
  37. package/dist/tooltip.css +7 -0
  38. package/dist/tooltip.d.ts +2 -2
  39. package/dist/tooltip.layered.css +7 -0
  40. package/dist/types/components/chip/chip.recipe.d.ts +1 -0
  41. package/dist/types/components/datatable/datatable.d.ts +50 -11
  42. package/dist/types/components/personchip/personchip.d.ts +14 -2
  43. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +32 -1
  44. package/dist/types/components/tooltip/tooltip.d.ts +16 -0
  45. package/dist/types/components/tooltip/tooltip.recipe.d.ts +30 -2
  46. package/dist/types/product/authshell/authshell.d.ts +4 -1
  47. package/dist/types/product/comments/comments.d.ts +1 -2
  48. package/dist/types/product/recordstable/recordstable.d.ts +12 -0
  49. package/package.json +1 -1
@@ -98,29 +98,44 @@ const config = (0, core_1.defineRecipe)({
98
98
  RichTextEditor's own \`.crisp-rte-mention\` (crisp#428), reused here
99
99
  rather than re-derived since it's the same atomic-chip affordance. */
100
100
  & .crisp-comments-mention { display: inline; padding: 0 2px; border-radius: 4px; background: color-mix(in srgb, ${tokens_1.contract.bgBrandSolid} 12%, transparent); color: ${tokens_1.contract.bgBrandSolid}; font-weight: ${tokens_1.contract.weight.medium}; user-select: none; }
101
-
101
+ ${core_1.rtlFlipCss}`,
102
+ variants: {},
103
+ defaultVariants: {},
104
+ compoundVariants: [],
105
+ // Two pieces below intentionally live OUTSIDE the nested \`base\` above,
106
+ // the same reason Menu's own \`.crisp-menu-content\` does (menu.recipe.ts's
107
+ // own comment, crisp#357): nesting compiles to a descendant combinator
108
+ // scoped to \`.crisp-comments\`, which needs real DOM containment. Both of
109
+ // these escape it -- \`ReactionPicker\` because \`Popover\` portals its
110
+ // content to \`document.body\`, and \`CommentCountBadge\` because it's built
111
+ // to be dropped into a table cell that has no \`.crisp-comments\` ancestor
112
+ // at all (its own module doc comment). Nested, this shipped invisible:
113
+ // \`CommentCountBadge\`'s dot rendered at 0×0 and the reaction picker fell
114
+ // back to \`Popover\`'s own generic (unstyled-by-this-recipe) surface --
115
+ // caught taking this component's own screenshots, not by any gate, since
116
+ // \`spec-check\` reads recipe source text and both classes were right there
117
+ // in it.
118
+ globalCss: `
102
119
  /* Reaction picker -- see \`ReactionPicker\`'s own doc comment: the exact
103
120
  chrome wasn't independently pixel-measured this pass (screenshot
104
121
  capture broke mid-session), so this reuses Menu's already-measured
105
122
  floating-popover shell rather than a new value. */
106
- & .crisp-comments-reactionpicker { width: 240px; padding: ${tokens_1.contract.space["2"]}; border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgSurface}; box-shadow: ${tokens_1.elevation.floating}; }
107
- & .crisp-comments-reactionpicker-search { box-sizing: border-box; width: 100%; height: 28px; margin-bottom: ${tokens_1.contract.space["2"]}; padding: 0 ${tokens_1.contract.space["2"]}; border: none; border-radius: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgRaised}; font-family: inherit; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgPrimary}; }
108
- & .crisp-comments-reactionpicker-search::placeholder { color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 40%, transparent); }
109
- & .crisp-comments-reactionpicker-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: ${tokens_1.contract.space["0.5"]}; max-height: 160px; overflow-y: auto; }
110
- & .crisp-comments-reactionpicker-item { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: ${tokens_1.contract.radius.sm}; background: none; font-family: inherit; font-size: 16px; line-height: 1; cursor: pointer; }
111
- & .crisp-comments-reactionpicker-item:hover { background: ${tokens_1.contract.bgRaised}; }
123
+ .crisp-comments-reactionpicker { font-family: ${tokens_1.contract.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; box-sizing: border-box; width: 240px; padding: ${tokens_1.contract.space["2"]}; border-radius: ${tokens_1.contract.radius.xl}; background: ${tokens_1.contract.bgSurface}; box-shadow: ${tokens_1.elevation.floating}; }
124
+ .crisp-comments-reactionpicker-search { box-sizing: border-box; width: 100%; height: 28px; margin-bottom: ${tokens_1.contract.space["2"]}; padding: 0 ${tokens_1.contract.space["2"]}; border: none; border-radius: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgRaised}; font-family: inherit; font-size: ${tokens_1.contract.text.md}; color: ${tokens_1.contract.fgPrimary}; }
125
+ .crisp-comments-reactionpicker-search::placeholder { color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 40%, transparent); }
126
+ .crisp-comments-reactionpicker-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: ${tokens_1.contract.space["0.5"]}; max-height: 160px; overflow-y: auto; }
127
+ .crisp-comments-reactionpicker-item { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: ${tokens_1.contract.radius.sm}; background: none; font-family: inherit; font-size: 16px; line-height: 1; cursor: pointer; }
128
+ .crisp-comments-reactionpicker-item:hover { background: ${tokens_1.contract.bgRaised}; }
112
129
 
113
130
  /* Row badge (CommentCountBadge) -- lives in its own class family so the
114
131
  grid lanes can drop it into a cell without pulling in the panel's
115
132
  CSS. Persistent state: a 6px grey dot. Hover state: a 12px comment
116
133
  icon + 12px count text, both at 55% ink, replacing the dot. */
117
- & .crisp-comments-badge { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; border: none; background: none; padding: 0; margin: 0; font-family: inherit; cursor: pointer; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 55%, transparent); }
118
- & .crisp-comments-badge-dot { width: 6px; height: 6px; border-radius: ${tokens_1.contract.radius.full}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 38%, transparent); }
119
- & .crisp-comments-badge-count { font-size: 12px; line-height: 16px; }
120
- ${core_1.rtlFlipCss}`,
121
- variants: {},
122
- defaultVariants: {},
123
- compoundVariants: [],
134
+ .crisp-comments-badge { box-sizing: border-box; font-family: ${tokens_1.contract.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; border: none; background: none; padding: 0; margin: 0; cursor: pointer; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 55%, transparent); }
135
+ .crisp-comments-badge-dot { box-sizing: border-box; width: 6px; height: 6px; border-radius: ${tokens_1.contract.radius.full}; background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 38%, transparent); }
136
+ .crisp-comments-badge-count { font-size: 12px; line-height: 16px; }
137
+ [dir="rtl"] .crisp-comments-badge [data-rtl-flip] { transform: scaleX(-1); }
138
+ `,
124
139
  });
125
140
  exports.commentsRecipe = (0, core_1.resolveRecipe)(config);
126
141
  exports.default = config;
@@ -76,8 +76,15 @@ function isLabelTruncated(el) {
76
76
  * viewport change, label change) via `ResizeObserver`; SSR-safe the same way
77
77
  * `MiddleTruncate` is -- the DOM is only touched inside an effect, so the
78
78
  * server (and the first client paint) renders the plain span.
79
+ *
80
+ * #580: a `referencedType` column (Attio's own `type: "reference"`) appends
81
+ * that type's icon + name after the label and switches to `Tooltip`'s
82
+ * `"reference"` chrome variant (Attio's second, structurally distinct
83
+ * tooltip — same background/colour/radius/padding/font as the plain one, a
84
+ * different `box-shadow`, crisp#563). Every other column stays label-only,
85
+ * the `"plain"` variant #534/#558 shipped.
79
86
  */
80
- function ThLabel({ label }) {
87
+ function ThLabel({ label, referencedType, }) {
81
88
  const ref = React.useRef(null);
82
89
  const [overflowing, setOverflowing] = React.useState(false);
83
90
  // biome-ignore lint/correctness/useExhaustiveDependencies: `label` isn't read in the body, but a rename (col.label changing with the column's own width unchanged) needs a re-measure a ResizeObserver on `el` won't fire for -- same reasoning as Tabs' own re-measure effect.
@@ -106,7 +113,9 @@ function ThLabel({ label }) {
106
113
  // actually show. `open={false}` when it fits keeps the Tooltip mounted
107
114
  // (asChild costs no extra DOM either way) but pins it permanently closed,
108
115
  // so hovering a non-overflowing header still opens nothing.
109
- return ((0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { content: label, asChild: true, open: overflowing ? undefined : false, children: (0, jsx_runtime_1.jsx)("span", { ref: ref, className: "crisp-recordstable-th-label", children: label }) }));
116
+ const ReferenceIcon = referencedType?.icon;
117
+ const content = referencedType ? ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tooltip-reference-row", children: [label, ReferenceIcon && (0, jsx_runtime_1.jsx)(ReferenceIcon, { size: 16, strokeWidth: 1.75 }), referencedType.label] })) : (label);
118
+ return ((0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { content: content, asChild: true, variant: referencedType ? "reference" : "plain", open: overflowing ? undefined : false, children: (0, jsx_runtime_1.jsx)("span", { ref: ref, className: "crisp-recordstable-th-label", children: label }) }));
110
119
  }
111
120
  const EDITABLE = new Set(["text", "number", "url", "currency"]);
112
121
  // types whose cell is an always-interactive picker (not double-click-to-edit)
@@ -1213,7 +1222,7 @@ function RecordsTable({ labels, columns: columnsProp = exports.defaultColumns, r
1213
1222
  setDragCol(col.key);
1214
1223
  e.dataTransfer.effectAllowed = "move";
1215
1224
  }
1216
- : undefined, onDragEnd: () => setDragCol(null), onClick: (e) => openColumnMenu(col.key, e.currentTarget), children: [Icon ? (0, jsx_runtime_1.jsx)(Icon, { size: 16, strokeWidth: 1.75 }) : null, (0, jsx_runtime_1.jsx)(ThLabel, { label: col.label }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordstable-sort", children: (0, jsx_runtime_1.jsx)("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("path", { d: "m4 6 4 4 4-4" }) }) })] })), openMenu === `col:${col.key}` &&
1225
+ : undefined, onDragEnd: () => setDragCol(null), onClick: (e) => openColumnMenu(col.key, e.currentTarget), children: [Icon ? (0, jsx_runtime_1.jsx)(Icon, { size: 16, strokeWidth: 1.75 }) : null, (0, jsx_runtime_1.jsx)(ThLabel, { label: col.label, referencedType: col.referencedType }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-recordstable-sort", children: (0, jsx_runtime_1.jsx)("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", "aria-hidden": "true", children: (0, jsx_runtime_1.jsx)("path", { d: "m4 6 4 4 4-4" }) }) })] })), openMenu === `col:${col.key}` &&
1217
1226
  rootRef.current &&
1218
1227
  (0, react_dom_1.createPortal)((0, jsx_runtime_1.jsxs)("div", { ref: menuRef, className: "crisp-recordstable-menu crisp-recordstable-menu--fixed crisp-recordstable-menu--hdr", role: "menu", "aria-label": `${col.label} column`, style: menuPos ?? undefined, onKeyDown: onMenuKeyDown, children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => sortBy(col.key, "asc"), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ArrowUp, { size: 15, strokeWidth: 1.75 }), t.sortAscending] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => sortBy(col.key, "desc"), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ArrowDown, { size: 15, strokeWidth: 1.75 }), t.sortDescending] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, disabled: i <= 1, onClick: () => moveColumn(col.key, -1), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ArrowLeft, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }), t.moveLeft] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, disabled: i === 0 || i >= cols.length - 1, onClick: () => moveColumn(col.key, 1), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.ArrowRight, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }), t.moveRight] }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-recordstable-menu-sep" }), (0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => {
1219
1228
  setRenamingCol(col.key);
@@ -159,6 +159,17 @@ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle
159
159
  // The Range covering a live "/query" (trigger included), mirroring the
160
160
  // mention primitive below — only set in "slash" mode.
161
161
  const slashRangeRef = React.useRef(null);
162
+ // #672: the selection the user actually had when they opened the toolbar's
163
+ // "Body ▾" menu — only set in "toolbar" mode. That menu's own search
164
+ // input steals DOM focus from the body the moment it opens, so by the
165
+ // time an item is picked, `window.getSelection()` no longer points at
166
+ // the block the user meant; without this, `run()`'s `body.focus()`
167
+ // resets the caret to the start of the document (the FIRST block)
168
+ // instead, and every conversion landed there regardless of what was
169
+ // selected. Cloned, since the live Range would otherwise track DOM
170
+ // mutations (and the selection moving to the search input) between now
171
+ // and when it's restored.
172
+ const toolbarSelectionRef = React.useRef(null);
162
173
  const blockSearchRef = React.useRef(null);
163
174
  const [mention, setMention] = React.useState(null);
164
175
  // The Range covering the live "@query" text (trigger included) — kept
@@ -264,6 +275,7 @@ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle
264
275
  }, [readOnly]);
265
276
  const closeBlockMenu = React.useCallback(() => {
266
277
  slashRangeRef.current = null;
278
+ toolbarSelectionRef.current = null;
267
279
  setBlockMenu(null);
268
280
  }, []);
269
281
  /** Mirrors the mention-trigger detection below: only handles the caret
@@ -442,6 +454,14 @@ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle
442
454
  sel?.removeAllRanges();
443
455
  sel?.addRange(range);
444
456
  }
457
+ else if (blockMenu?.mode === "toolbar" && toolbarSelectionRef.current) {
458
+ // #672: no trigger text to delete here (nothing was typed to open
459
+ // this menu) -- just put the selection back where the user actually
460
+ // had it, before `applyBlockItem` -> `run()` refocuses the body.
461
+ const sel = window.getSelection();
462
+ sel?.removeAllRanges();
463
+ sel?.addRange(toolbarSelectionRef.current);
464
+ }
445
465
  applyBlockItem(item.id);
446
466
  closeBlockMenu();
447
467
  };
@@ -464,14 +484,21 @@ exports.RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle
464
484
  const toggleBlockMenuFromToolbar = () => {
465
485
  if (!bar)
466
486
  return;
467
- setBlockMenu((cur) => cur?.mode === "toolbar"
468
- ? null
469
- : {
487
+ setBlockMenu((cur) => {
488
+ if (cur?.mode === "toolbar")
489
+ return null;
490
+ // #672: capture the live selection NOW, before the menu's own search
491
+ // input (focused by the effect below) steals it away.
492
+ const sel = window.getSelection();
493
+ toolbarSelectionRef.current =
494
+ sel && sel.rangeCount > 0 ? sel.getRangeAt(0).cloneRange() : null;
495
+ return {
470
496
  anchor: { x: bar.x, y: bar.y },
471
497
  query: "",
472
498
  highlighted: 0,
473
499
  mode: "toolbar",
474
- });
500
+ };
501
+ });
475
502
  };
476
503
  // biome-ignore lint/correctness/useExhaustiveDependencies: syncEmpty only reads/writes stable refs, same as run()'s own call to it just below
477
504
  const insertMention = React.useCallback((item) => {
package/dist/comments.css CHANGED
@@ -1,3 +1,24 @@
1
+
2
+ /* Reaction picker -- see `ReactionPicker`'s own doc comment: the exact
3
+ chrome wasn't independently pixel-measured this pass (screenshot
4
+ capture broke mid-session), so this reuses Menu's already-measured
5
+ floating-popover shell rather than a new value. */
6
+ .crisp-comments-reactionpicker { font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; box-sizing: border-box; width: 240px; padding: var(--crisp-space-2); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); }
7
+ .crisp-comments-reactionpicker-search { box-sizing: border-box; width: 100%; height: 28px; margin-bottom: var(--crisp-space-2); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: var(--crisp-bg-raised); font-family: inherit; font-size: var(--crisp-text-md); color: var(--crisp-fg-primary); }
8
+ .crisp-comments-reactionpicker-search::placeholder { color: color-mix(in srgb, var(--crisp-fg-primary) 40%, transparent); }
9
+ .crisp-comments-reactionpicker-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: var(--crisp-space-0-5); max-height: 160px; overflow-y: auto; }
10
+ .crisp-comments-reactionpicker-item { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: var(--crisp-radius-sm); background: none; font-family: inherit; font-size: 16px; line-height: 1; cursor: pointer; }
11
+ .crisp-comments-reactionpicker-item:hover { background: var(--crisp-bg-raised); }
12
+
13
+ /* Row badge (CommentCountBadge) -- lives in its own class family so the
14
+ grid lanes can drop it into a cell without pulling in the panel's
15
+ CSS. Persistent state: a 6px grey dot. Hover state: a 12px comment
16
+ icon + 12px count text, both at 55% ink, replacing the dot. */
17
+ .crisp-comments-badge { box-sizing: border-box; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: inline-flex; align-items: center; gap: var(--crisp-space-1); border: none; background: none; padding: 0; margin: 0; cursor: pointer; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); }
18
+ .crisp-comments-badge-dot { box-sizing: border-box; width: 6px; height: 6px; border-radius: var(--crisp-radius-full); background: color-mix(in srgb, var(--crisp-fg-primary) 38%, transparent); }
19
+ .crisp-comments-badge-count { font-size: 12px; line-height: 16px; }
20
+ [dir="rtl"] .crisp-comments-badge [data-rtl-flip] { transform: scaleX(-1); }
21
+
1
22
  .crisp-comments {
2
23
  display: flex; flex-direction: column; box-sizing: border-box; width: 320px; height: 100%; background: var(--crisp-bg-surface); border-inline-start: 1px solid var(--crisp-border-subtle); font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
3
24
  & * { box-sizing: border-box; }
@@ -53,21 +74,6 @@
53
74
  /* An inserted "@mention" chip in the composer -- same shape as
54
75
  RichTextEditor's own `.crisp-rte-mention` (crisp#428), reused here rather than re-derived since it's the same atomic-chip affordance. */
55
76
  & .crisp-comments-mention { display: inline; padding: 0 2px; border-radius: 4px; background: color-mix(in srgb, var(--crisp-bg-brand-solid) 12%, transparent); color: var(--crisp-bg-brand-solid); font-weight: var(--crisp-weight-medium); user-select: none; }
56
-
57
- /* Reaction picker -- see `ReactionPicker`'s own doc comment: the exact chrome wasn't independently pixel-measured this pass (screenshot
58
- capture broke mid-session), so this reuses Menu's already-measured floating-popover shell rather than a new value. */
59
- & .crisp-comments-reactionpicker { width: 240px; padding: var(--crisp-space-2); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); }
60
- & .crisp-comments-reactionpicker-search { box-sizing: border-box; width: 100%; height: 28px; margin-bottom: var(--crisp-space-2); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: var(--crisp-bg-raised); font-family: inherit; font-size: var(--crisp-text-md); color: var(--crisp-fg-primary); }
61
- & .crisp-comments-reactionpicker-search::placeholder { color: color-mix(in srgb, var(--crisp-fg-primary) 40%, transparent); }
62
- & .crisp-comments-reactionpicker-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: var(--crisp-space-0-5); max-height: 160px; overflow-y: auto; }
63
- & .crisp-comments-reactionpicker-item { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: var(--crisp-radius-sm); background: none; font-family: inherit; font-size: 16px; line-height: 1; cursor: pointer; }
64
- & .crisp-comments-reactionpicker-item:hover { background: var(--crisp-bg-raised); }
65
- /* Row badge (CommentCountBadge) -- lives in its own class family so the grid lanes can drop it into a cell without pulling in the panel's
66
- CSS. Persistent state: a 6px grey dot. Hover state: a 12px comment
67
- icon + 12px count text, both at 55% ink, replacing the dot. */
68
- & .crisp-comments-badge { display: inline-flex; align-items: center; gap: var(--crisp-space-1); border: none; background: none; padding: 0; margin: 0; font-family: inherit; cursor: pointer; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); }
69
- & .crisp-comments-badge-dot { width: 6px; height: 6px; border-radius: var(--crisp-radius-full); background: color-mix(in srgb, var(--crisp-fg-primary) 38%, transparent); }
70
- & .crisp-comments-badge-count { font-size: 12px; line-height: 16px; }
71
77
  [dir="rtl"] & [data-rtl-flip] { transform: scaleX(-1); }
72
78
  }
73
79
 
@@ -1,4 +1,25 @@
1
1
  @layer crisp-components {
2
+
3
+ /* Reaction picker -- see `ReactionPicker`'s own doc comment: the exact
4
+ chrome wasn't independently pixel-measured this pass (screenshot
5
+ capture broke mid-session), so this reuses Menu's already-measured
6
+ floating-popover shell rather than a new value. */
7
+ .crisp-comments-reactionpicker { font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; box-sizing: border-box; width: 240px; padding: var(--crisp-space-2); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); }
8
+ .crisp-comments-reactionpicker-search { box-sizing: border-box; width: 100%; height: 28px; margin-bottom: var(--crisp-space-2); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: var(--crisp-bg-raised); font-family: inherit; font-size: var(--crisp-text-md); color: var(--crisp-fg-primary); }
9
+ .crisp-comments-reactionpicker-search::placeholder { color: color-mix(in srgb, var(--crisp-fg-primary) 40%, transparent); }
10
+ .crisp-comments-reactionpicker-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: var(--crisp-space-0-5); max-height: 160px; overflow-y: auto; }
11
+ .crisp-comments-reactionpicker-item { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: var(--crisp-radius-sm); background: none; font-family: inherit; font-size: 16px; line-height: 1; cursor: pointer; }
12
+ .crisp-comments-reactionpicker-item:hover { background: var(--crisp-bg-raised); }
13
+
14
+ /* Row badge (CommentCountBadge) -- lives in its own class family so the
15
+ grid lanes can drop it into a cell without pulling in the panel's
16
+ CSS. Persistent state: a 6px grey dot. Hover state: a 12px comment
17
+ icon + 12px count text, both at 55% ink, replacing the dot. */
18
+ .crisp-comments-badge { box-sizing: border-box; font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: inline-flex; align-items: center; gap: var(--crisp-space-1); border: none; background: none; padding: 0; margin: 0; cursor: pointer; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); }
19
+ .crisp-comments-badge-dot { box-sizing: border-box; width: 6px; height: 6px; border-radius: var(--crisp-radius-full); background: color-mix(in srgb, var(--crisp-fg-primary) 38%, transparent); }
20
+ .crisp-comments-badge-count { font-size: 12px; line-height: 16px; }
21
+ [dir="rtl"] .crisp-comments-badge [data-rtl-flip] { transform: scaleX(-1); }
22
+
2
23
  .crisp-comments {
3
24
  display: flex; flex-direction: column; box-sizing: border-box; width: 320px; height: 100%; background: var(--crisp-bg-surface); border-inline-start: 1px solid var(--crisp-border-subtle); font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
4
25
  & * { box-sizing: border-box; }
@@ -54,21 +75,6 @@
54
75
  /* An inserted "@mention" chip in the composer -- same shape as
55
76
  RichTextEditor's own `.crisp-rte-mention` (crisp#428), reused here rather than re-derived since it's the same atomic-chip affordance. */
56
77
  & .crisp-comments-mention { display: inline; padding: 0 2px; border-radius: 4px; background: color-mix(in srgb, var(--crisp-bg-brand-solid) 12%, transparent); color: var(--crisp-bg-brand-solid); font-weight: var(--crisp-weight-medium); user-select: none; }
57
-
58
- /* Reaction picker -- see `ReactionPicker`'s own doc comment: the exact chrome wasn't independently pixel-measured this pass (screenshot
59
- capture broke mid-session), so this reuses Menu's already-measured floating-popover shell rather than a new value. */
60
- & .crisp-comments-reactionpicker { width: 240px; padding: var(--crisp-space-2); border-radius: var(--crisp-radius-xl); background: var(--crisp-bg-surface); box-shadow: inset 0 0 0 1px var(--crisp-ring-popover), 0 0 0 1px var(--crisp-shadow-popover1), 0 4px 8px -4px var(--crisp-shadow-popover2), 0 4px 12px -2px var(--crisp-shadow-popover3); }
61
- & .crisp-comments-reactionpicker-search { box-sizing: border-box; width: 100%; height: 28px; margin-bottom: var(--crisp-space-2); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-md); background: var(--crisp-bg-raised); font-family: inherit; font-size: var(--crisp-text-md); color: var(--crisp-fg-primary); }
62
- & .crisp-comments-reactionpicker-search::placeholder { color: color-mix(in srgb, var(--crisp-fg-primary) 40%, transparent); }
63
- & .crisp-comments-reactionpicker-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: var(--crisp-space-0-5); max-height: 160px; overflow-y: auto; }
64
- & .crisp-comments-reactionpicker-item { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: var(--crisp-radius-sm); background: none; font-family: inherit; font-size: 16px; line-height: 1; cursor: pointer; }
65
- & .crisp-comments-reactionpicker-item:hover { background: var(--crisp-bg-raised); }
66
- /* Row badge (CommentCountBadge) -- lives in its own class family so the grid lanes can drop it into a cell without pulling in the panel's
67
- CSS. Persistent state: a 6px grey dot. Hover state: a 12px comment
68
- icon + 12px count text, both at 55% ink, replacing the dot. */
69
- & .crisp-comments-badge { display: inline-flex; align-items: center; gap: var(--crisp-space-1); border: none; background: none; padding: 0; margin: 0; font-family: inherit; cursor: pointer; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); }
70
- & .crisp-comments-badge-dot { width: 6px; height: 6px; border-radius: var(--crisp-radius-full); background: color-mix(in srgb, var(--crisp-fg-primary) 38%, transparent); }
71
- & .crisp-comments-badge-count { font-size: 12px; line-height: 16px; }
72
78
  [dir="rtl"] & [data-rtl-flip] { transform: scaleX(-1); }
73
79
  }
74
80
 
@@ -114,10 +114,14 @@
114
114
  & .crisp-datatable-tag[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-orange) 82%, var(--crisp-fg-primary)); }
115
115
  & .crisp-datatable-num { font-variant-numeric: tabular-nums; }
116
116
  & .crisp-datatable-muted { color: var(--crisp-fg-muted); }
117
- /* row drag-to-reorder -- a Settings > Objects > Attributes screen measures
118
- a leading 20px grip column, a 16px grip icon, and the row's own hairline doubling as the drop indicator (it tints toward the brand colour as the active drop target). The affordance + geometry are measured; the drag MECHANISM is crisp's existing house idiom -- HTML5
119
- draggable + dataTransfer, the same one column reorder already uses
120
- (RecordsTable's reorderColumn) -- not a proprietary pointer-events implementation. */
117
+ /* url/email cell types (#654) -- same measured values as RecordsTable's own .crisp-recordstable-link, which this mirrors: no underline at rest, underline on hover, own-colour text rather than a browser-blue link. */
118
+ & .crisp-datatable-link { color: var(--crisp-fg-primary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
119
+ & .crisp-datatable-link:hover { text-decoration: underline; }
120
+ /* row drag-to-reorder -- a Settings > Objects > Attributes screen measures a leading 20px grip column, a 16px grip icon, and the row's own
121
+ hairline doubling as the drop indicator (it tints toward the brand
122
+ colour as the active drop target). The affordance + geometry are
123
+ measured; the drag MECHANISM is crisp's existing house idiom -- HTML5 draggable + dataTransfer, the same one column reorder already uses (RecordsTable's reorderColumn) -- not a proprietary
124
+ pointer-events implementation. */
121
125
  & .crisp-datatable-col-grip { width: 20px; padding: 0; text-align: center; }
122
126
  & .crisp-datatable-grip { display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 100%; margin: 0; border: none; border-radius: 0; background: none; font-family: inherit; color: var(--crisp-fg-muted); cursor: grab; }
123
127
  & .crisp-datatable-grip:active { cursor: grabbing; }
@@ -115,10 +115,14 @@
115
115
  & .crisp-datatable-tag[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-orange) 82%, var(--crisp-fg-primary)); }
116
116
  & .crisp-datatable-num { font-variant-numeric: tabular-nums; }
117
117
  & .crisp-datatable-muted { color: var(--crisp-fg-muted); }
118
- /* row drag-to-reorder -- a Settings > Objects > Attributes screen measures
119
- a leading 20px grip column, a 16px grip icon, and the row's own hairline doubling as the drop indicator (it tints toward the brand colour as the active drop target). The affordance + geometry are measured; the drag MECHANISM is crisp's existing house idiom -- HTML5
120
- draggable + dataTransfer, the same one column reorder already uses
121
- (RecordsTable's reorderColumn) -- not a proprietary pointer-events implementation. */
118
+ /* url/email cell types (#654) -- same measured values as RecordsTable's own .crisp-recordstable-link, which this mirrors: no underline at rest, underline on hover, own-colour text rather than a browser-blue link. */
119
+ & .crisp-datatable-link { color: var(--crisp-fg-primary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
120
+ & .crisp-datatable-link:hover { text-decoration: underline; }
121
+ /* row drag-to-reorder -- a Settings > Objects > Attributes screen measures a leading 20px grip column, a 16px grip icon, and the row's own
122
+ hairline doubling as the drop indicator (it tints toward the brand
123
+ colour as the active drop target). The affordance + geometry are
124
+ measured; the drag MECHANISM is crisp's existing house idiom -- HTML5 draggable + dataTransfer, the same one column reorder already uses (RecordsTable's reorderColumn) -- not a proprietary
125
+ pointer-events implementation. */
122
126
  & .crisp-datatable-col-grip { width: 20px; padding: 0; text-align: center; }
123
127
  & .crisp-datatable-grip { display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 100%; margin: 0; border: none; border-radius: 0; background: none; font-family: inherit; color: var(--crisp-fg-muted); cursor: grab; }
124
128
  & .crisp-datatable-grip:active { cursor: grabbing; }
@@ -27,6 +27,18 @@ const spec = defineSpec({
27
27
  // -- `inline-flex` items don't by default -- which `truncate` below
28
28
  // depends on and any consumer constraining a Chip's width already needs.
29
29
  minWidth: 0,
30
+ // Caps the chip at its containing block's width (#668) -- `min-width: 0`
31
+ // alone only lets it shrink inside a flex/grid row with `flex-shrink`;
32
+ // it does nothing in a plain block or table-cell ancestor (GRC's own
33
+ // `DataTable` cells, a render-uniform `<td>`, not a flex row), where the
34
+ // chip just grew to content width and overflowed instead. Checked
35
+ // Attio's own actor chip for a value to match instead of guessing one:
36
+ // `max-width: none` there too -- it has no pixel cap either, and stays
37
+ // contained only because its own ancestor chain is a real flex row the
38
+ // whole way down, which `Chip` alone can't assume for every consumer.
39
+ // `100%` is the honest equivalent: never wider than the container,
40
+ // whatever kind of container it is.
41
+ maxWidth: "100%",
30
42
  paddingInline: c.space["1"],
31
43
  borderRadius: c.radius.chip,
32
44
  fontSize: c.text.lg,
@@ -26,8 +26,16 @@ export function isLabelTruncated(el) {
26
26
  * viewport change, header text change) via `ResizeObserver`; SSR-safe the
27
27
  * same way `MiddleTruncate` is -- the DOM is only touched inside an effect,
28
28
  * so the server (and the first client paint) render the closed tooltip.
29
+ *
30
+ * #580: a `referencedType` column appends that type's icon + name after the
31
+ * label (Attio's own reference-column tooltip, e.g. "Primary location" +
32
+ * Country's icon + "Country") and uses `Tooltip`'s `"reference"` chrome
33
+ * variant -- Attio's second, structurally distinct tooltip (same
34
+ * background/colour/radius/padding/font as the plain one, a different
35
+ * `box-shadow`, crisp#563). Every other column stays exactly as #534/#558
36
+ * shipped: label-only, the `"plain"` variant.
29
37
  */
30
- function ThLabel({ header }) {
38
+ function ThLabel({ header, referencedType, }) {
31
39
  const ref = React.useRef(null);
32
40
  const [overflowing, setOverflowing] = React.useState(false);
33
41
  // biome-ignore lint/correctness/useExhaustiveDependencies: `header` isn't read in the body, but a rename (col.header changing with the column's own width unchanged) needs a re-measure a ResizeObserver on `el` won't fire for -- same reasoning as Tabs' own re-measure effect.
@@ -56,7 +64,9 @@ function ThLabel({ header }) {
56
64
  // actually show. `open={false}` when it fits keeps the Tooltip mounted
57
65
  // (asChild costs no extra DOM either way) but pins it permanently closed,
58
66
  // so hovering a non-overflowing header still opens nothing.
59
- return (_jsx(Tooltip, { content: header, asChild: true, open: overflowing ? undefined : false, children: _jsx("span", { ref: ref, className: "crisp-datatable-th-label", children: header }) }));
67
+ const ReferenceIcon = referencedType?.icon;
68
+ const content = referencedType ? (_jsxs("span", { className: "crisp-tooltip-reference-row", children: [header, ReferenceIcon && _jsx(ReferenceIcon, { size: 16, strokeWidth: 1.75 }), referencedType.label] })) : (header);
69
+ return (_jsx(Tooltip, { content: content, asChild: true, variant: referencedType ? "reference" : "plain", open: overflowing ? undefined : false, children: _jsx("span", { ref: ref, className: "crisp-datatable-th-label", children: header }) }));
60
70
  }
61
71
  /** A `date` input needs `yyyy-mm-dd`; cells may hold a Date, an ISO string or
62
72
  * a display string. Returns "" when it cannot be parsed, so the field opens
@@ -252,6 +262,16 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US") {
252
262
  // translation) can never silently repaint the chip
253
263
  return (_jsx("span", { className: "crisp-datatable-tag", "data-tone": col.tones?.[s] ?? toneFor(s), children: display || s }));
254
264
  }
265
+ case "url":
266
+ case "email": {
267
+ const text = display || asText(value);
268
+ if (!text)
269
+ return empty;
270
+ return (_jsx("a", { className: "crisp-datatable-link", href: col.type === "url" ? `https://${text}` : `mailto:${text}`, target: col.type === "url" ? "_blank" : undefined, rel: col.type === "url" ? "noreferrer" : undefined,
271
+ // Same reasoning as `entity`'s `onEntityClick` link above: the
272
+ // link's own navigation must not also fire the row's onRowClick.
273
+ onClick: (ev) => ev.stopPropagation(), children: text }));
274
+ }
255
275
  default: {
256
276
  const text = display || formatCell(col.type, value, col.symbol, locale);
257
277
  const isNum = col.type === "number" || col.type === "currency";
@@ -387,7 +407,8 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
387
407
  keywords: query,
388
408
  });
389
409
  }
390
- return (_jsx("span", { ref: ref, style: { display: "contents" }, children: _jsx(Select, { variant: "ghost", searchable: true, searchPlaceholder: col.refSearchPlaceholder, onSearchChange: setQuery, "aria-label": `Edit ${col.key}`, value: asText(value) || undefined, disabled: isPending, options: options, onValueChange: (v) => {
410
+ const isActor = col.editor === "actor";
411
+ return (_jsx("span", { ref: ref, style: { display: "contents" }, children: _jsx(Select, { variant: "ghost", searchable: true, searchPlaceholder: col.refSearchPlaceholder ?? (isActor ? "Search people…" : undefined), emptyLabel: col.refEmptyLabel ?? (isActor ? "No users" : undefined), onSearchChange: setQuery, "aria-label": `Edit ${col.key}`, value: asText(value) || undefined, disabled: isPending, options: options, onValueChange: (v) => {
391
412
  if (v === CREATE_REFERENCE_VALUE)
392
413
  col.onCreateReference?.(query);
393
414
  else
@@ -1162,7 +1183,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1162
1183
  ? "ascending"
1163
1184
  : "descending"
1164
1185
  : "none";
1165
- const inner = (_jsxs("span", { className: "crisp-datatable-th-inner", children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx(ThLabel, { header: col.header }), col.sortable ? _jsx(ChevronFor, { colKey: col.key }) : null] }));
1186
+ const inner = (_jsxs("span", { className: "crisp-datatable-th-inner", children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx(ThLabel, { header: col.header, referencedType: col.referencedType }), col.sortable ? _jsx(ChevronFor, { colKey: col.key }) : null] }));
1166
1187
  return (_jsxs(TableHead, { align: alignFor(col), "aria-sort": ariaSort, "aria-colindex": colAriaIndex(i), ...dataStickyCol(i), children: [renaming === col.key ? (_jsx("input", {
1167
1188
  // biome-ignore lint/a11y/noAutofocus: the rename item opens this field
1168
1189
  autoFocus: true, className: "crisp-datatable-th-rename", defaultValue: typeof col.header === "string" ? col.header : "", "aria-label": renameColumnLabel, onBlur: (e) => {
@@ -1243,7 +1264,9 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1243
1264
  return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1244
1265
  }
1245
1266
  if (isEditing &&
1246
- (col.editor === "select" || col.editor === "reference")) {
1267
+ (col.editor === "select" ||
1268
+ col.editor === "reference" ||
1269
+ col.editor === "actor")) {
1247
1270
  return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined,
1248
1271
  // Same as the plain text editor's own cell:
1249
1272
  // opening/using the picker must not also fire
@@ -141,6 +141,12 @@ const config = defineRecipe({
141
141
  & .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}); }
142
142
  & .crisp-datatable-num { font-variant-numeric: tabular-nums; }
143
143
  & .crisp-datatable-muted { color: ${c.fgMuted}; }
144
+ /* url/email cell types (#654) -- same measured values as RecordsTable's
145
+ own .crisp-recordstable-link, which this mirrors: no underline at
146
+ rest, underline on hover, own-colour text rather than a browser-blue
147
+ link. */
148
+ & .crisp-datatable-link { color: ${c.fgPrimary}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
149
+ & .crisp-datatable-link:hover { text-decoration: underline; }
144
150
  /* row drag-to-reorder -- a Settings > Objects > Attributes screen measures
145
151
  a leading 20px grip column, a 16px grip icon, and the row's own
146
152
  hairline doubling as the drop indicator (it tints toward the brand
@@ -39,9 +39,19 @@ function initials(name) {
39
39
  * Composes `Chip`'s `truncate` CSS directly rather than its string-only
40
40
  * auto-wrap (children here is an avatar plus a name, not a single string),
41
41
  * so the name gets its own `.crisp-chip-label` span while the avatar stays
42
- * fixed-size.
42
+ * fixed-size. `trailing` (#683) is the same idea for a consumer's own
43
+ * icon/badge after the name — inside the chip's own flex row rather than a
44
+ * second one a consumer would otherwise wrap this in, which needs its own
45
+ * `min-width: 0` to keep shrinking correctly and, missing that, overflows
46
+ * its container the same way #668 did before `Chip` itself was fixed.
43
47
  */
44
- export function PersonChip({ name, avatar, muted, className, ...props }) {
45
- return (_jsxs(Chip, { tone: "neutral", truncate: true, className: className, ...props, children: [_jsx(Avatar, { size: "sm", shape: "circle", tone: avatar ? "neutral" : hashTone(name), children: avatar ? _jsx("img", { src: avatar, alt: "" }) : initials(name) }), _jsx("span", { className: "crisp-chip-label", style: muted ? { color: c.fgNeutralMuted } : undefined, children: name })] }));
48
+ export function PersonChip({ name, avatar, muted, trailing, className, ...props }) {
49
+ return (_jsxs(Chip, { tone: "neutral", truncate: true, className: className, ...props, children: [_jsx(Avatar, { size: "sm", shape: "circle", tone: avatar ? "neutral" : hashTone(name),
50
+ // Explicit rather than relying on the flex default (a fixed-size,
51
+ // non-wrapping element's own min-content floor already resists
52
+ // shrinking in every browser tested) — #668 asked "does the avatar
53
+ // stay" as its own question, so it gets its own undeniable answer
54
+ // rather than an implicit one.
55
+ style: { flexShrink: 0 }, children: avatar ? _jsx("img", { src: avatar, alt: "" }) : initials(name) }), _jsx("span", { className: "crisp-chip-label", style: muted ? { color: c.fgNeutralMuted } : undefined, children: name }), trailing != null && (_jsx("span", { className: "crisp-personchip-trailing", style: { flexShrink: 0 }, children: trailing }))] }));
46
56
  }
47
57
  PersonChip.displayName = "PersonChip";
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide, Check, CircleHelp, GripVertical, ListFilter, MoreVertical, Plus, X, } from "lucide-react";
2
+ import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide, Check, CircleHelp, GripVertical, ListFilter, MoreVertical, Plus, SlidersHorizontal, X, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { isInsideLaterFloatingLayer, useCrispMessage, useCrispMessages, useFocusAfterRemoval, } from "../../core/index.js";
5
5
  import { MenuCheckboxItem, useMenuKeyboard } from "../menu/index.js";
@@ -138,7 +138,7 @@ export function applySorts(rows, sorts, get = (row, key) => row[key]) {
138
138
  * this (Sort: Due date/Assignee/Creation date; Filter: Assignee/Record/
139
139
  * Created by), which one shared list can't reproduce.
140
140
  */
141
- export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: filterColumnsProp, sorts: sortsProp, defaultSorts, onSortsChange, filters: filtersProp, defaultFilters, onFiltersChange, count, children, helpHref, className, sortLabel: sortLabelProp, filterLabel: filterLabelProp, reorderSortLabel: reorderSortLabelProp, noSortsLabel: noSortsLabelProp, addSortLabel: addSortLabelProp, learnSortingLabel: learnSortingLabelProp, selectConditionLabel: selectConditionLabelProp, sortFieldLabel: sortFieldLabelProp, sortDirectionLabel: sortDirectionLabelProp, removeSortLabel: removeSortLabelProp, filterFieldLabel: filterFieldLabelProp, filterFieldPlaceholder: filterFieldPlaceholderProp, filterValuePlaceholder: filterValuePlaceholderProp, filterOptionsLabel: filterOptionsLabelProp, deleteFilterLabel: deleteFilterLabelProp, ascendingLabel: ascendingLabelProp, descendingLabel: descendingLabelProp, sortTriggerLabel, filterTriggerLabel, inline, ...rest }) {
141
+ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: filterColumnsProp, sorts: sortsProp, defaultSorts, onSortsChange, filters: filtersProp, defaultFilters, onFiltersChange, count, children, helpHref, className, sortLabel: sortLabelProp, filterLabel: filterLabelProp, reorderSortLabel: reorderSortLabelProp, noSortsLabel: noSortsLabelProp, addSortLabel: addSortLabelProp, learnSortingLabel: learnSortingLabelProp, selectConditionLabel: selectConditionLabelProp, sortFieldLabel: sortFieldLabelProp, sortDirectionLabel: sortDirectionLabelProp, removeSortLabel: removeSortLabelProp, filterFieldLabel: filterFieldLabelProp, filterFieldPlaceholder: filterFieldPlaceholderProp, filterValuePlaceholder: filterValuePlaceholderProp, filterOptionsLabel: filterOptionsLabelProp, deleteFilterLabel: deleteFilterLabelProp, ascendingLabel: ascendingLabelProp, descendingLabel: descendingLabelProp, sortTriggerLabel, filterTriggerLabel, inline, viewColumns: viewColumnsProp, shownKeys: shownKeysProp, defaultShownKeys, onShownKeysChange, viewSettingsLabel: viewSettingsLabelProp, reorderAttributeLabel: reorderAttributeLabelProp, hideAttributeLabel, addAttributeToViewLabel: addAttributeToViewLabelProp, searchAttributesPlaceholder: searchAttributesPlaceholderProp, ...rest }) {
142
142
  // crisp/#471: an explicit prop always wins; missing, it reads the nearest
143
143
  // CrispMessagesProvider's translation of the English default below, or
144
144
  // that default itself with no provider — unchanged from before this
@@ -161,6 +161,10 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
161
161
  const deleteFilterLabel = useCrispMessage(deleteFilterLabelProp, "Delete filter");
162
162
  const ascendingLabel = useCrispMessage(ascendingLabelProp, "Ascending");
163
163
  const descendingLabel = useCrispMessage(descendingLabelProp, "Descending");
164
+ const viewSettingsLabel = useCrispMessage(viewSettingsLabelProp, "View settings");
165
+ const reorderAttributeLabel = useCrispMessage(reorderAttributeLabelProp, "Reorder attribute");
166
+ const addAttributeToViewLabel = useCrispMessage(addAttributeToViewLabelProp, "Add attribute to view");
167
+ const searchAttributesPlaceholder = useCrispMessage(searchAttributesPlaceholderProp, "Search attributes…");
164
168
  // OP_LABELS' own words ("contains", "is", "is any of", …) have no prop of
165
169
  // their own — a whole lookup table, not one string — so they translate
166
170
  // straight from the provider's dictionary at the two spots they render
@@ -172,10 +176,17 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
172
176
  // gets `columns` on the other, unchanged from before either prop existed.
173
177
  const sortColumns = sortColumnsProp ?? columns ?? [];
174
178
  const filterColumns = filterColumnsProp ?? columns ?? [];
179
+ const viewColumns = viewColumnsProp ?? columns ?? [];
175
180
  const [sortsState, setSortsState] = React.useState(defaultSorts ?? []);
176
181
  const [filtersState, setFiltersState] = React.useState(defaultFilters ?? []);
182
+ // #590: "everything shown" unless told otherwise -- lazy initializer so
183
+ // this only reads `viewColumns` once, at mount, the same way `defaultSorts`/
184
+ // `defaultFilters` above seed once and are never re-derived from a prop
185
+ // that may keep changing identity across renders.
186
+ const [shownKeysState, setShownKeysState] = React.useState(() => defaultShownKeys ?? viewColumns.map((c) => c.key));
177
187
  const sorts = sortsProp ?? sortsState;
178
188
  const filters = filtersProp ?? filtersState;
189
+ const shownKeys = shownKeysProp ?? shownKeysState;
179
190
  const putSorts = (next) => {
180
191
  if (sortsProp === undefined)
181
192
  setSortsState(next);
@@ -186,11 +197,28 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
186
197
  setFiltersState(next);
187
198
  onFiltersChange?.(next);
188
199
  };
200
+ const putShownKeys = (next) => {
201
+ if (shownKeysProp === undefined)
202
+ setShownKeysState(next);
203
+ onShownKeysChange?.(next);
204
+ };
189
205
  const [openMenu, setOpenMenu] = React.useState(null);
190
206
  const [dragSort, setDragSort] = React.useState(null);
191
207
  const [filterQuery, setFilterQuery] = React.useState("");
192
208
  // filters the "isanyof" checkbox list, same lifetime as filterQuery above
193
209
  const [anyOfQuery, setAnyOfQuery] = React.useState("");
210
+ const [dragAttr, setDragAttr] = React.useState(null);
211
+ // #590: the popover has two screens -- the draggable shown-attribute list
212
+ // (default) and, behind "Add attribute to view", a search list of the
213
+ // rest. Measured live, 2026-09-25: clicking "View settings" opens a
214
+ // `role="dialog"` of dnd-kit `aria-roledescription="sortable"` rows --
215
+ // grip, type icon, label -- with no search box, ending in a static
216
+ // "Add attribute to view" row; each row also carries a "View attribute
217
+ // menu" kebab whose own contents weren't explored (undefined scope, not
218
+ // built here -- hiding an attribute is a trailing control instead, see
219
+ // the hide button below).
220
+ const [addingAttribute, setAddingAttribute] = React.useState(false);
221
+ const [addAttrQuery, setAddAttrQuery] = React.useState("");
194
222
  // close any open menu on outside click (anything not inside a menu or its
195
223
  // trigger) or Escape
196
224
  React.useEffect(() => {
@@ -253,6 +281,27 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
253
281
  putFilters(filters.filter((c) => c.id !== id));
254
282
  setOpenMenu(null);
255
283
  };
284
+ // #590: shown attributes, in `shownKeys`' order -- a key with no matching
285
+ // `viewColumns` entry (stale state after a column is removed upstream)
286
+ // drops out rather than rendering a blank row.
287
+ const shownAttrs = shownKeys
288
+ .map((k) => viewColumns.find((c) => c.key === k))
289
+ .filter((c) => c != null);
290
+ const hiddenAttrs = viewColumns.filter((c) => !shownKeys.includes(c.key));
291
+ const moveAttr = (from, to) => {
292
+ if (from === to)
293
+ return;
294
+ const next = [...shownKeys];
295
+ const [moved] = next.splice(from, 1);
296
+ next.splice(to, 0, moved);
297
+ putShownKeys(next);
298
+ };
299
+ const hideAttr = (key) => putShownKeys(shownKeys.filter((k) => k !== key));
300
+ const showAttr = (key) => {
301
+ putShownKeys([...shownKeys, key]);
302
+ setAddingAttribute(false);
303
+ setAddAttrQuery("");
304
+ };
256
305
  // #594: the filter chip's kebab is a real role="menu" (crisp/#188's shared
257
306
  // model — arrows, Home/End, typeahead, Escape), not a hand-rolled div like
258
307
  // the sort/condition popovers above (those are role="dialog", which makes
@@ -342,7 +391,31 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
342
391
  });
343
392
  }, children: v }, v));
344
393
  })] }))] }))] }, cond.id));
345
- }), _jsx("span", { className: "crisp-tabletoolbar-spacer" }), count != null && (_jsx("span", { className: "crisp-tabletoolbar-count", children: count })), children] }) }));
394
+ }), _jsx("span", { className: "crisp-tabletoolbar-divider", "aria-hidden": "true" }), _jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("button", { type: "button", className: "crisp-tabletoolbar-tool crisp-tabletoolbar-tool--ghost", onClick: () => {
395
+ if (openMenu === "viewsettings") {
396
+ setOpenMenu(null);
397
+ }
398
+ else {
399
+ setAddingAttribute(false);
400
+ setAddAttrQuery("");
401
+ setOpenMenu("viewsettings");
402
+ }
403
+ }, "aria-haspopup": "dialog", "aria-expanded": openMenu === "viewsettings", "data-menu-trigger": true, children: [_jsx(SlidersHorizontal, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), viewSettingsLabel] }), openMenu === "viewsettings" &&
404
+ (addingAttribute ? (_jsxs("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": addAttributeToViewLabel, children: [_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: searchAttributesPlaceholder, "aria-label": searchAttributesPlaceholder,
405
+ // biome-ignore lint/a11y/noAutofocus: focus the search box on open, same as Filter's field picker
406
+ autoFocus: true, value: addAttrQuery, onChange: (e) => setAddAttrQuery(e.target.value) }), hiddenAttrs
407
+ .filter((col) => col.label
408
+ .toLowerCase()
409
+ .includes(addAttrQuery.trim().toLowerCase()))
410
+ .map((col) => (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => showAttr(col.key), children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : (_jsx("span", { style: { width: 15 } })), col.label] }, col.key)))] })) : (_jsxs("div", { className: "crisp-tabletoolbar-menu crisp-tabletoolbar-sortpop", role: "dialog", "aria-label": viewSettingsLabel, children: [shownAttrs.map((col, i) => (_jsxs("div", { className: "crisp-tabletoolbar-sort-row", onDragOver: (e) => {
411
+ if (dragAttr !== null)
412
+ e.preventDefault();
413
+ }, onDrop: () => {
414
+ if (dragAttr !== null)
415
+ moveAttr(dragAttr, i);
416
+ setDragAttr(null);
417
+ }, children: [_jsx("button", { type: "button", className: "crisp-tabletoolbar-sort-grip", "aria-label": reorderAttributeLabel, draggable: true, onDragStart: () => setDragAttr(i), onDragEnd: () => setDragAttr(null), children: _jsx(GripVertical, { size: 15, strokeWidth: 1.75 }) }), _jsxs("span", { className: "crisp-tabletoolbar-sort-optlabel crisp-tabletoolbar-sort-field", children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : null, col.label] }), _jsx("button", { type: "button", className: "crisp-tabletoolbar-sort-remove", onClick: () => hideAttr(col.key), "aria-label": hideAttributeLabel?.(col.label) ??
418
+ `${crispMessages.Hide ?? "Hide"} ${col.label} ${crispMessages["from view"] ?? "from view"}`, children: _jsx(X, { size: 15, strokeWidth: 1.75 }) })] }, col.key))), _jsx("div", { className: "crisp-tabletoolbar-sort-addrow", children: _jsxs("button", { type: "button", className: "crisp-tabletoolbar-sort-add", onClick: () => setAddingAttribute(true), children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), addAttributeToViewLabel] }) })] })))] }), _jsx("span", { className: "crisp-tabletoolbar-spacer" }), count != null && (_jsx("span", { className: "crisp-tabletoolbar-count", children: count })), children] }) }));
346
419
  }
347
420
  /**
348
421
  * The Sort/Filter chip's own chrome (`.crisp-tabletoolbar-tool`), standalone