@crisp-ui-kit/crisp 0.54.8 → 0.54.10

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 (59) hide show
  1. package/dist/board.css +32 -8
  2. package/dist/board.layered.css +32 -8
  3. package/dist/cjs/components/contextmenu/contextmenu.js +2 -2
  4. package/dist/cjs/components/contextmenu/contextmenu.recipe.js +24 -0
  5. package/dist/cjs/components/datatable/datatable.js +593 -51
  6. package/dist/cjs/components/datatable/datatable.recipe.js +67 -3
  7. package/dist/cjs/components/dialog/dialog.js +65 -1
  8. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +37 -2
  9. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +218 -39
  10. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +48 -1
  11. package/dist/cjs/components/text/text.recipe.js +17 -7
  12. package/dist/cjs/product/board/board.js +62 -28
  13. package/dist/cjs/product/board/board.recipe.js +42 -2
  14. package/dist/cjs/product/comments/comments.js +11 -2
  15. package/dist/cjs/product/comments/comments.recipe.js +14 -4
  16. package/dist/cjs/product/recordstable/recordstable.js +1 -17
  17. package/dist/cjs/product/savedviews/savedviews.js +55 -24
  18. package/dist/cjs/product/savedviews/savedviews.recipe.js +27 -0
  19. package/dist/comments.css +14 -4
  20. package/dist/comments.layered.css +14 -4
  21. package/dist/contextmenu.css +24 -0
  22. package/dist/contextmenu.layered.css +24 -0
  23. package/dist/datatable.css +106 -77
  24. package/dist/datatable.layered.css +106 -77
  25. package/dist/esm/components/contextmenu/contextmenu.js +2 -2
  26. package/dist/esm/components/contextmenu/contextmenu.recipe.js +24 -0
  27. package/dist/esm/components/datatable/datatable.js +593 -52
  28. package/dist/esm/components/datatable/datatable.recipe.js +68 -4
  29. package/dist/esm/components/dialog/dialog.js +65 -1
  30. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +37 -2
  31. package/dist/esm/components/tabletoolbar/tabletoolbar.js +219 -40
  32. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +50 -3
  33. package/dist/esm/components/text/text.recipe.js +17 -7
  34. package/dist/esm/product/board/board.js +63 -29
  35. package/dist/esm/product/board/board.recipe.js +42 -2
  36. package/dist/esm/product/comments/comments.js +12 -3
  37. package/dist/esm/product/comments/comments.recipe.js +14 -4
  38. package/dist/esm/product/recordstable/recordstable.js +2 -18
  39. package/dist/esm/product/savedviews/savedviews.js +57 -26
  40. package/dist/esm/product/savedviews/savedviews.recipe.js +27 -0
  41. package/dist/recordstable.css +33 -31
  42. package/dist/recordstable.layered.css +33 -31
  43. package/dist/savedviews.css +27 -0
  44. package/dist/savedviews.layered.css +27 -0
  45. package/dist/styles.css +282 -125
  46. package/dist/styles.layered.css +282 -125
  47. package/dist/tabletoolbar.css +45 -4
  48. package/dist/tabletoolbar.layered.css +45 -4
  49. package/dist/text.css +1 -1
  50. package/dist/text.layered.css +1 -1
  51. package/dist/types/components/contextmenu/contextmenu.d.ts +8 -1
  52. package/dist/types/components/datatable/datatable.d.ts +98 -2
  53. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +72 -2
  54. package/dist/types/components/text/text.recipe.d.ts +1 -1
  55. package/dist/types/product/board/board.d.ts +48 -2
  56. package/dist/types/product/comments/comments.d.ts +6 -1
  57. package/dist/types/product/recordstable/recordstable.d.ts +0 -1
  58. package/dist/types/product/savedviews/savedviews.d.ts +45 -8
  59. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Check } from "lucide-react";
2
+ import { Check, Columns2, Columns3, Plus, Rows3, Square } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { Menu, MenuItem } from "../../components/menu/index.js";
5
+ import { useRadioGroupKeyboard } from "../../components/radiogroup/index.js";
5
6
  import { isRtlElement, useCrispMessage, useCrispMessages } from "../../core/index.js";
6
7
  import { boardRecipe } from "./board.recipe.js";
7
8
  const BOARD_AGG_ORDER = [
@@ -61,11 +62,44 @@ function computeBoardAgg(agg, values) {
61
62
  * it via `onCardsChange` (the parent owns the cards, so the move is a
62
63
  * controlled state update). Measured from a deals pipeline.
63
64
  */
64
- export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel: newCardLabelProp, getCardLabel, announceMove: announceMoveProp, calc, className, ...rest }) {
65
+ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel: newCardLabelProp, getCardLabel, announceMove: announceMoveProp, calc, density = false, columnDensity: columnDensityProp, onColumnDensityChange, cardDensity: cardDensityProp, onCardDensityChange, collapseColumnsLabel: collapseColumnsLabelProp, expandColumnsLabel: expandColumnsLabelProp, collapseCardsLabel: collapseCardsLabelProp, expandCardsLabel: expandCardsLabelProp, columnDensityGroupLabel: columnDensityGroupLabelProp, cardDensityGroupLabel: cardDensityGroupLabelProp, onAddStage, addStageLabel: addStageLabelProp, className, ...rest }) {
65
66
  // crisp/#471: same resolution order as every other component's own —
66
67
  // explicit prop, then a CrispMessagesProvider's translation of the English
67
68
  // default, then that default itself.
68
69
  const newCardLabel = useCrispMessage(newCardLabelProp, "New");
70
+ const collapseColumnsLabel = useCrispMessage(collapseColumnsLabelProp, "Collapse columns");
71
+ const expandColumnsLabel = useCrispMessage(expandColumnsLabelProp, "Expand columns");
72
+ const collapseCardsLabel = useCrispMessage(collapseCardsLabelProp, "Collapse cards");
73
+ const expandCardsLabel = useCrispMessage(expandCardsLabelProp, "Expand cards");
74
+ const columnDensityGroupLabel = useCrispMessage(columnDensityGroupLabelProp, "Column density");
75
+ const cardDensityGroupLabel = useCrispMessage(cardDensityGroupLabelProp, "Card density");
76
+ const addStageLabel = useCrispMessage(addStageLabelProp, "Add stage");
77
+ // #718: same controlled/uncontrolled shape as `sort`/`selectedIds`
78
+ // elsewhere in crisp — explicit prop wins; otherwise this manages its own
79
+ // state, seeded "expand" (Attio's own board opens expanded by default).
80
+ const [internalColumnDensity, setInternalColumnDensity] = React.useState("expand");
81
+ const [internalCardDensity, setInternalCardDensity] = React.useState("expand");
82
+ const columnDensity = columnDensityProp ?? internalColumnDensity;
83
+ const cardDensity = cardDensityProp ?? internalCardDensity;
84
+ const setColumnDensity = (next) => {
85
+ onColumnDensityChange?.(next);
86
+ if (columnDensityProp === undefined)
87
+ setInternalColumnDensity(next);
88
+ };
89
+ const setCardDensity = (next) => {
90
+ onCardDensityChange?.(next);
91
+ if (cardDensityProp === undefined)
92
+ setInternalCardDensity(next);
93
+ };
94
+ // crisp's shared radio-group keyboard model (#200) — the same
95
+ // ←/→/↑/↓/Home/End-with-roving-tabindex model `Segmented`/`RadioGroup`
96
+ // already use, not a bespoke per-button handler (see that hook's own
97
+ // comment on the bug a per-button closure caused once already). Two
98
+ // independent hook instances: each density pair is its own radio group.
99
+ const { onKeyDown: onColumnDensityKeyDown, groupRef: columnDensityGroupRef } = useRadioGroupKeyboard({
100
+ select: (v) => setColumnDensity(v),
101
+ });
102
+ const { onKeyDown: onCardDensityKeyDown, groupRef: cardDensityGroupRef } = useRadioGroupKeyboard({ select: (v) => setCardDensity(v) });
69
103
  // #604: which BoardAgg each column's footer shows, keyed by column id.
70
104
  // Unset (undefined) means the column still shows the "+ Add calculation"
71
105
  // trigger, exactly like the table footer's own per-column state.
@@ -209,32 +243,32 @@ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, ne
209
243
  setRefocusId(card.id);
210
244
  return true;
211
245
  };
212
- return (_jsxs("div", { ref: rootRef, className: boardRecipe({ className }), ...rest, children: [columns.map((col) => {
213
- const colCards = cards.filter((c) => c.columnId === col.id);
214
- return (_jsxs("div", { ref: (el) => {
215
- if (el)
216
- colRefs.current.set(col.id, el);
217
- else
218
- colRefs.current.delete(col.id);
219
- }, className: "crisp-board-col", "data-over": overCol === col.id ? "" : undefined, children: [_jsxs("div", { className: "crisp-board-col-header", children: [col.accent && (_jsx("span", { className: "crisp-board-col-dot", style: { background: col.accent } })), _jsx("span", { className: "crisp-board-col-title", children: col.title }), _jsx("span", { className: "crisp-board-col-count", children: colCards.length }), onNewCard && (_jsx("button", { type: "button", className: "crisp-board-col-add", "aria-label": `${crispMessages["Add to"] ?? "Add to"} ${col.title}`, onClick: () => onNewCard(col.id), children: "+" }))] }), _jsxs("div", { className: "crisp-board-col-body", role: "list", "aria-label": col.title, children: [onNewCard && (_jsxs("button", { type: "button", className: "crisp-board-newcard", onClick: () => onNewCard(col.id), children: ["+ ", newCardLabel] })), colCards.map((card) => (_jsx("div", { className: "crisp-board-card", role: "listitem",
220
- // biome-ignore lint/a11y/noNoninteractiveTabindex: the rule assumes a listitem cannot be operated. This one carries the board's only keyboard path to move a card, so it has to be reachable by Tab
221
- tabIndex: 0, "aria-label": getCardLabel?.(card), "data-card-id": card.id, "data-dragging": dragId === card.id ? "" : undefined, onPointerDown: (e) => onCardPointerDown(e, card), onPointerMove: onCardPointerMove, onPointerUp: (e) => endCardDrag(e, true), onPointerCancel: (e) => endCardDrag(e, false), onKeyDown: (e) => {
222
- if (!(e.ctrlKey || e.metaKey))
223
- return;
224
- // the columns are a flex row, so under RTL the next one
225
- // is to the LEFT and the keys swap (#255)
226
- const rtl = isRtlElement(e.currentTarget);
227
- const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
228
- const backKey = rtl ? "ArrowRight" : "ArrowLeft";
229
- const delta = e.key === backKey ? -1 : e.key === forwardKey ? 1 : 0;
230
- // only swallow the key when the move actually happens, so
231
- // Ctrl+Right at the last column still reaches the browser
232
- if (delta && moveByKey(card, delta))
233
- e.preventDefault();
234
- }, children: renderCard(card) }, card.id)))] }), calc && (_jsx("div", { className: "crisp-board-col-footer", children: _jsx(Menu, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-board-col-calc", "data-empty": colAgg[col.id] ? undefined : "", children: colAgg[col.id] ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-board-col-calc-label", children: crispMessages[AGG_LABEL[colAgg[col.id]]] ??
235
- AGG_LABEL[colAgg[col.id]] }), _jsx("span", { className: "crisp-board-col-calc-value", children: computeBoardAgg(colAgg[col.id], colCards.map((c) => calc.getValue(c))) })] })) : ((crispMessages["+ Add calculation"] ??
236
- "+ Add calculation")) }), children: BOARD_AGG_ORDER.map((a) => (_jsxs(MenuItem, { onSelect: () => setColAgg((prev) => ({ ...prev, [col.id]: a })), children: [crispMessages[AGG_LABEL[a]] ?? AGG_LABEL[a], colAgg[col.id] === a ? (_jsx(Check, { size: 15, strokeWidth: 1.75 })) : null] }, a))) }) }))] }, col.id));
237
- }), dragId &&
246
+ return (_jsxs("div", { ref: rootRef, className: boardRecipe({ className }), ...rest, children: [density && (_jsxs("div", { className: "crisp-board-toolbar", children: [_jsxs("div", { ref: columnDensityGroupRef, className: "crisp-board-density", role: "radiogroup", "aria-label": columnDensityGroupLabel, onKeyDown: onColumnDensityKeyDown, children: [_jsx("button", { type: "button", role: "radio", "data-value": "collapse", "aria-checked": columnDensity === "collapse", "aria-label": collapseColumnsLabel, "data-state": columnDensity === "collapse" ? "on" : "off", onClick: () => setColumnDensity("collapse"), children: _jsx(Columns3, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("button", { type: "button", role: "radio", "data-value": "expand", "aria-checked": columnDensity === "expand", "aria-label": expandColumnsLabel, "data-state": columnDensity === "expand" ? "on" : "off", onClick: () => setColumnDensity("expand"), children: _jsx(Columns2, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), _jsxs("div", { ref: cardDensityGroupRef, className: "crisp-board-density", role: "radiogroup", "aria-label": cardDensityGroupLabel, onKeyDown: onCardDensityKeyDown, children: [_jsx("button", { type: "button", role: "radio", "data-value": "collapse", "aria-checked": cardDensity === "collapse", "aria-label": collapseCardsLabel, "data-state": cardDensity === "collapse" ? "on" : "off", onClick: () => setCardDensity("collapse"), children: _jsx(Rows3, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), _jsx("button", { type: "button", role: "radio", "data-value": "expand", "aria-checked": cardDensity === "expand", "aria-label": expandCardsLabel, "data-state": cardDensity === "expand" ? "on" : "off", onClick: () => setCardDensity("expand"), children: _jsx(Square, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] })] })), _jsxs("div", { className: "crisp-board-row", children: [columns.map((col) => {
247
+ const colCards = cards.filter((c) => c.columnId === col.id);
248
+ return (_jsxs("div", { ref: (el) => {
249
+ if (el)
250
+ colRefs.current.set(col.id, el);
251
+ else
252
+ colRefs.current.delete(col.id);
253
+ }, className: "crisp-board-col", "data-over": overCol === col.id ? "" : undefined, "data-density": columnDensity === "collapse" ? "collapse" : undefined, children: [_jsxs("div", { className: "crisp-board-col-header", children: [col.accent && (_jsx("span", { className: "crisp-board-col-dot", style: { background: col.accent } })), _jsx("span", { className: "crisp-board-col-title", children: col.title }), _jsx("span", { className: "crisp-board-col-count", children: colCards.length }), onNewCard && columnDensity !== "collapse" && (_jsx("button", { type: "button", className: "crisp-board-col-add", "aria-label": `${crispMessages["Add to"] ?? "Add to"} ${col.title}`, onClick: () => onNewCard(col.id), children: "+" }))] }), _jsxs("div", { className: "crisp-board-col-body", role: "list", "aria-label": col.title, children: [onNewCard && (_jsxs("button", { type: "button", className: "crisp-board-newcard", onClick: () => onNewCard(col.id), children: ["+ ", newCardLabel] })), colCards.map((card) => (_jsx("div", { className: "crisp-board-card", role: "listitem",
254
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: the rule assumes a listitem cannot be operated. This one carries the board's only keyboard path to move a card, so it has to be reachable by Tab
255
+ tabIndex: 0, "aria-label": getCardLabel?.(card), "data-card-id": card.id, "data-dragging": dragId === card.id ? "" : undefined, "data-density": cardDensity === "collapse" ? "collapse" : undefined, onPointerDown: (e) => onCardPointerDown(e, card), onPointerMove: onCardPointerMove, onPointerUp: (e) => endCardDrag(e, true), onPointerCancel: (e) => endCardDrag(e, false), onKeyDown: (e) => {
256
+ if (!(e.ctrlKey || e.metaKey))
257
+ return;
258
+ // the columns are a flex row, so under RTL the next one
259
+ // is to the LEFT and the keys swap (#255)
260
+ const rtl = isRtlElement(e.currentTarget);
261
+ const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
262
+ const backKey = rtl ? "ArrowRight" : "ArrowLeft";
263
+ const delta = e.key === backKey ? -1 : e.key === forwardKey ? 1 : 0;
264
+ // only swallow the key when the move actually happens, so
265
+ // Ctrl+Right at the last column still reaches the browser
266
+ if (delta && moveByKey(card, delta))
267
+ e.preventDefault();
268
+ }, children: renderCard(card, cardDensity) }, card.id)))] }), calc && (_jsx("div", { className: "crisp-board-col-footer", children: _jsx(Menu, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-board-col-calc", "data-empty": colAgg[col.id] ? undefined : "", children: colAgg[col.id] ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-board-col-calc-label", children: crispMessages[AGG_LABEL[colAgg[col.id]]] ??
269
+ AGG_LABEL[colAgg[col.id]] }), _jsx("span", { className: "crisp-board-col-calc-value", children: computeBoardAgg(colAgg[col.id], colCards.map((c) => calc.getValue(c))) })] })) : ((crispMessages["+ Add calculation"] ??
270
+ "+ Add calculation")) }), children: BOARD_AGG_ORDER.map((a) => (_jsxs(MenuItem, { onSelect: () => setColAgg((prev) => ({ ...prev, [col.id]: a })), children: [crispMessages[AGG_LABEL[a]] ?? AGG_LABEL[a], colAgg[col.id] === a ? (_jsx(Check, { size: 15, strokeWidth: 1.75 })) : null] }, a))) }) }))] }, col.id));
271
+ }), onAddStage && (_jsx("button", { type: "button", className: "crisp-board-addstage", "aria-label": addStageLabel, onClick: onAddStage, children: _jsx(Plus, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }))] }), dragId &&
238
272
  ghost &&
239
273
  (() => {
240
274
  const draggedCard = cards.find((c) => c.id === dragId);
@@ -245,7 +279,7 @@ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, ne
245
279
  top: ghost.y,
246
280
  width: ghost.width,
247
281
  height: ghost.height,
248
- }, children: renderCard(draggedCard) }));
282
+ }, children: renderCard(draggedCard, cardDensity) }));
249
283
  })(), _jsx("div", { className: "crisp-board-live", role: "status", "aria-live": "assertive", children: announcement })] }));
250
284
  }
251
285
  Board.displayName = "Board";
@@ -15,8 +15,33 @@ import { contract as c } from "../../tokens/index.js";
15
15
  // reference lacks).
16
16
  const config = defineRecipe({
17
17
  name: "board",
18
- base: `display: flex; gap: ${c.space["2"]}; height: 100%; box-sizing: border-box; padding: ${c.space["3"]}; overflow-x: auto; align-items: flex-start; font-family: ${c.font.sans};
19
- & .crisp-board-col { display: flex; flex-direction: column; width: 268px; flex-shrink: 0; max-height: 100%; box-sizing: border-box; }
18
+ base: `display: flex; flex-direction: column; gap: ${c.space["2"]}; height: 100%; box-sizing: border-box; font-family: ${c.font.sans};
19
+ /* #718: the density toolbar's own row -- everything that used to be on
20
+ the root (padding/overflow/the columns themselves) moves to
21
+ .crisp-board-row below so this can sit above it, unscrolled. */
22
+ & .crisp-board-toolbar { display: flex; align-items: center; justify-content: flex-end; gap: ${c.space["2"]}; padding: 0 ${c.space["3"]}; flex-shrink: 0; }
23
+ /* Measured live (Deals pipeline): a 54×28px group, 24×24px buttons,
24
+ 2px padding/gap -- an 8px gap between the two groups comes from the
25
+ toolbar's own \`gap\` above. */
26
+ & .crisp-board-density { display: flex; align-items: center; gap: 2px; padding: 2px; border-radius: 10px; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); }
27
+ & .crisp-board-density button { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: none; border-radius: 8px; background: none; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); font-family: inherit; cursor: pointer; }
28
+ & .crisp-board-density button[data-state="on"] { background: ${c.bgSurface}; color: ${c.fgPrimary}; }
29
+ & .crisp-board-row { display: flex; gap: ${c.space["2"]}; flex: 1; min-height: 0; box-sizing: border-box; padding: ${c.space["3"]}; overflow-x: auto; align-items: flex-start; }
30
+ & .crisp-board-col { display: flex; flex-direction: column; width: 268px; flex-shrink: 0; max-height: 100%; box-sizing: border-box; transition: width 0.15s; }
31
+ /* #718: a collapsed column is a 52px vertical rail -- header only (dot
32
+ + count stacked above a vertical-rl title), body/footer hidden
33
+ entirely. Measured live: 52px width, 8px 0 padding on the header,
34
+ 14px/600 title text. */
35
+ & .crisp-board-col[data-density="collapse"] { width: 52px; }
36
+ & .crisp-board-col[data-density="collapse"] .crisp-board-col-header { flex-direction: column; align-items: center; height: auto; padding: ${c.space["2"]} 0; gap: ${c.space["1"]}; }
37
+ /* Measured stacking order (top to bottom): dot, count, THEN the vertical
38
+ title -- reordered visually via \`order\`, not by changing the JSX/DOM
39
+ order, which stays dot/title/count (reading order, unaffected by
40
+ density). */
41
+ & .crisp-board-col[data-density="collapse"] .crisp-board-col-title { order: 1; writing-mode: vertical-rl; font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; }
42
+ & .crisp-board-col[data-density="collapse"] .crisp-board-col-count { order: 0; }
43
+ & .crisp-board-col[data-density="collapse"] .crisp-board-col-body,
44
+ & .crisp-board-col[data-density="collapse"] .crisp-board-col-footer { display: none; }
20
45
  & .crisp-board-col-header { display: flex; align-items: center; gap: ${c.space["2"]}; height: 36px; padding: 0 ${c.space["1"]}; flex-shrink: 0; }
21
46
  & .crisp-board-col-dot { width: 12px; height: 12px; border-radius: ${c.radius.full}; flex-shrink: 0; }
22
47
  & .crisp-board-col-title { font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; color: ${c.fgPrimary}; }
@@ -35,6 +60,14 @@ const config = defineRecipe({
35
60
  & .crisp-board-newcard { display: flex; align-items: center; justify-content: center; gap: ${c.space["1.5"]}; height: 32px; flex-shrink: 0; border: none; border-radius: ${c.radius.md}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); color: ${c.fgMuted}; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; cursor: pointer; }
36
61
  & .crisp-board-newcard:hover { background: color-mix(in srgb, ${c.fgPrimary} 7%, transparent); color: ${c.fgPrimary}; }
37
62
  & .crisp-board-card { display: flex; flex-direction: column; gap: ${c.space["2"]}; box-sizing: border-box; padding: ${c.space["2.5"]} ${c.space["3"]}; border-radius: ${c.radius.xl}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.borderSubtle}; cursor: grab; touch-action: none; }
63
+ /* #718: a collapsed card is a single 40px row -- measured live and
64
+ independent of \`c.size.tableRow\`/\`tableHeader\` (36/40px) even
65
+ though it happens to match the latter; a plain literal, not a
66
+ cross-reference to a token measured for a different surface.
67
+ \`renderCard\`'s own second argument is what actually decides what a
68
+ collapsed card SHOWS (Board never sees inside its opaque output);
69
+ this only supplies the row-shaped box that content sits in. */
70
+ & .crisp-board-card[data-density="collapse"] { flex-direction: row; align-items: center; height: 40px; padding: 0 ${c.space["3"]}; }
38
71
  & .crisp-board-card:active { cursor: grabbing; }
39
72
  & .crisp-board-card[data-dragging] { opacity: 0.4; }
40
73
  & .crisp-board-card:focus { outline: none; }
@@ -54,6 +87,13 @@ const config = defineRecipe({
54
87
  & .crisp-board-col-calc[data-empty] { opacity: 0.7; }
55
88
  & .crisp-board-col-calc-label { text-transform: capitalize; }
56
89
  & .crisp-board-col-calc-value { color: ${c.fgPrimary}; font-variant-numeric: tabular-nums; }
90
+ /* #718: "Add stage" -- measured live: 52×52px, transparent, a 1px
91
+ dashed border (rgb(230,231,234), exactly \`borderDropzone\`/\`line150\`
92
+ -- the same dashed-border colour ImportDialog's own dropzone already
93
+ uses, not borderSubtle/line100, a close but different value), radius.xl
94
+ (16px), a centered 16px "+". */
95
+ & .crisp-board-addstage { display: inline-flex; align-items: center; justify-content: center; width: 52px; height: 52px; flex-shrink: 0; border: 1px dashed ${c.borderDropzone}; border-radius: ${c.radius.xl}; background: none; color: ${c.fgPrimary}; font-family: inherit; cursor: pointer; }
96
+ & .crisp-board-addstage:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); }
57
97
  /* announced, never drawn */
58
98
  & .crisp-board-live {
59
99
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowUpRight, Check, ChevronLeft, CornerUpLeft, ListFilter, MessageSquare, MoreHorizontal, SmilePlus, X, } from "lucide-react";
2
+ import { Check, ChevronLeft, CornerUpLeft, ListFilter, MessageSquare, MoreHorizontal, PanelRight, SmilePlus, X, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { Avatar } from "../../components/avatar/index.js";
5
5
  import { Button } from "../../components/button/index.js";
@@ -331,7 +331,7 @@ export function CommentsPanel({ threads, currentUserId, subject, subjectIcon, sh
331
331
  const activeThread = activeThreadId
332
332
  ? threads.find((t) => t.id === activeThreadId)
333
333
  : null;
334
- return (_jsxs("div", { className: commentsRecipe({ className }), role: "dialog", "aria-label": title, ...rest, children: [_jsxs("div", { className: "crisp-comments-head", children: [activeThread ? (_jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": backLabel, onClick: () => setActiveThreadId(null), children: _jsx(ChevronLeft, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })) : null, _jsxs("div", { className: "crisp-comments-title-wrap", children: [_jsx("span", { className: "crisp-comments-title", children: activeThread ? threadTitle : title }), activeThread && (_jsxs("span", { className: "crisp-comments-breadcrumb", children: [breadcrumbPrefix, " ", subjectIcon, " ", subject] }))] }), !activeThread && (_jsxs(Menu, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": filterLabel, children: _jsx(ListFilter, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: [_jsx(MenuCheckboxItem, { checked: showResolved, onCheckedChange: onShowResolvedChange, children: showResolvedLabel }), _jsx(MenuCheckboxItem, { checked: onlyMine, onCheckedChange: onOnlyMineChange, children: onlyMineLabel })] })), _jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": closeLabel, onClick: onClose, children: _jsx(X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), _jsx("div", { className: "crisp-comments-body crisp-comments-body--pad-top", children: activeThread ? (_jsxs("div", { children: [_jsx(CommentRow, { comment: activeThread.root, currentUserId: currentUserId, replyLabel: replyActionLabel, reactLabel: reactActionLabel, resolveLabel: resolveActionLabel, moreLabel: moreActionsLabel, copyLinkLabel: copyLinkLabel, deleteLabel: deleteLabel, addReactionLabel: addReactionLabel, reactionSearchLabel: reactionSearchLabel, emojiOptions: emojiOptions, resolvable: !!onResolve, resolved: activeThread.resolved, resolvedLabel: activeThread.resolved
334
+ return (_jsxs("div", { className: commentsRecipe({ className }), role: "dialog", "aria-label": title, ...rest, children: [_jsxs("div", { className: "crisp-comments-head", children: [activeThread ? (_jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": backLabel, onClick: () => setActiveThreadId(null), children: _jsx(ChevronLeft, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })) : null, _jsxs("div", { className: "crisp-comments-title-wrap", children: [_jsx("span", { className: "crisp-comments-title", children: activeThread ? threadTitle : title }), activeThread && (_jsxs("span", { className: "crisp-comments-breadcrumb", children: [breadcrumbPrefix, " ", subjectIcon, " ", subject] }))] }), !activeThread && (_jsxs(Menu, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": filterLabel, children: _jsx(ListFilter, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: [_jsx(MenuCheckboxItem, { checked: showResolved, onCheckedChange: onShowResolvedChange, children: showResolvedLabel }), _jsx(MenuCheckboxItem, { checked: onlyMine, onCheckedChange: onOnlyMineChange, children: onlyMineLabel })] })), onClose && (_jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": closeLabel, onClick: onClose, children: _jsx(X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }))] }), _jsx("div", { className: "crisp-comments-body crisp-comments-body--pad-top", children: activeThread ? (_jsxs("div", { children: [_jsx(CommentRow, { comment: activeThread.root, currentUserId: currentUserId, replyLabel: replyActionLabel, reactLabel: reactActionLabel, resolveLabel: resolveActionLabel, moreLabel: moreActionsLabel, copyLinkLabel: copyLinkLabel, deleteLabel: deleteLabel, addReactionLabel: addReactionLabel, reactionSearchLabel: reactionSearchLabel, emojiOptions: emojiOptions, resolvable: !!onResolve, resolved: activeThread.resolved, resolvedLabel: activeThread.resolved
335
335
  ? activeThread.resolvedBy?.id === currentUserId ||
336
336
  !activeThread.resolvedBy
337
337
  ? resolvedByYouLabel
@@ -388,6 +388,15 @@ export function CommentCountBadge({ count, hovered, onOpen, onOpenPreview, label
388
388
  ? `crisp-comments-badge ${className}`
389
389
  : "crisp-comments-badge", "aria-hidden": "true", children: _jsx("span", { className: "crisp-comments-badge-dot" }) }));
390
390
  }
391
- return (_jsxs("span", { className: className ? `crisp-comments-badge ${className}` : "crisp-comments-badge", children: [_jsxs("button", { type: "button", className: "crisp-comments-badge", "aria-label": label, onClick: onOpen, children: [_jsx(MessageSquare, { size: 12, strokeWidth: 2, "aria-hidden": "true" }), count > 0 && (_jsx("span", { className: "crisp-comments-badge-count", children: count }))] }), onOpenPreview && (_jsx("button", { type: "button", className: "crisp-comments-badge", "aria-label": previewLabel, onClick: onOpenPreview, children: _jsx(ArrowUpRight, { "data-rtl-flip": "", size: 12, strokeWidth: 2, "aria-hidden": "true" }) }))] }));
391
+ return (_jsxs("span", { className: className ? `crisp-comments-badge ${className}` : "crisp-comments-badge", children: [_jsxs("button", { type: "button", className: "crisp-comments-badge-trigger", "aria-label": label, onClick: (ev) => {
392
+ // this badge lives in a grid row (crisp/#601 supersession) whose
393
+ // own click opens the record -- the same stopPropagation every
394
+ // other per-row affordance in that cell already uses.
395
+ ev.stopPropagation();
396
+ onOpen();
397
+ }, children: [_jsx(MessageSquare, { size: 12, strokeWidth: 2, "aria-hidden": "true" }), count > 0 && (_jsx("span", { className: "crisp-comments-badge-count", children: count }))] }), onOpenPreview && (_jsx("button", { type: "button", className: "crisp-comments-badge-preview", "aria-label": previewLabel, onClick: (ev) => {
398
+ ev.stopPropagation();
399
+ onOpenPreview();
400
+ }, children: _jsx(PanelRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }))] }));
392
401
  }
393
402
  CommentCountBadge.displayName = "CommentCountBadge";
@@ -140,11 +140,21 @@ const config = defineRecipe({
140
140
 
141
141
  /* Row badge (CommentCountBadge) -- lives in its own class family so the
142
142
  grid lanes can drop it into a cell without pulling in the panel's
143
- CSS. Persistent state: a 6px grey dot. Hover state: a 12px comment
144
- icon + 12px count text, both at 55% ink, replacing the dot. */
145
- .crisp-comments-badge { box-sizing: border-box; font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: inline-flex; align-items: center; gap: ${c.space["1"]}; border: none; background: none; padding: 0; margin: 0; cursor: pointer; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); }
143
+ CSS. Re-measured live on a real commented row (crisp/#601
144
+ supersession, right-aligned in a DataTable primary column): a 6px
145
+ grey dot at rest (unchanged, already matched); on hover, the trigger
146
+ button is auto-width with a 12px icon + 12px/500 count text,
147
+ "padding: 2px 6px 2px 4px", radius 6px -- distinct from the preview
148
+ button beside it, a fixed 20px square with a 16px icon, same radius.
149
+ Both share the 55%-ink idle colour and darken on their OWN hover
150
+ (color-mix 4%, the same formula DataTable's own #601 icons use). */
151
+ .crisp-comments-badge { box-sizing: border-box; font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; }
146
152
  .crisp-comments-badge-dot { box-sizing: border-box; width: 6px; height: 6px; border-radius: ${c.radius.full}; background: color-mix(in srgb, ${c.fgPrimary} 38%, transparent); }
147
- .crisp-comments-badge-count { font-size: 12px; line-height: 16px; }
153
+ .crisp-comments-badge-trigger { box-sizing: border-box; display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: 20px; margin: 0; padding: 2px ${c.space["1.5"]} 2px ${c.space["1"]}; border: none; border-radius: ${c.radius.sm}; background: none; font-family: inherit; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); cursor: pointer; }
154
+ .crisp-comments-badge-trigger:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); color: ${c.fgPrimary}; }
155
+ .crisp-comments-badge-preview { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin: 0; padding: 0; border: none; border-radius: ${c.radius.sm}; background: none; font-family: inherit; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); cursor: pointer; flex-shrink: 0; }
156
+ .crisp-comments-badge-preview:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); color: ${c.fgPrimary}; }
157
+ .crisp-comments-badge-count { font-size: 12px; line-height: 16px; font-weight: ${c.weight.medium}; }
148
158
  [dir="rtl"] .crisp-comments-badge [data-rtl-flip] { transform: scaleX(-1); }
149
159
  `,
150
160
  });
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDown, ArrowDownWideNarrow, ArrowLeft, ArrowRight, ArrowUp, ArrowUpDown, ArrowUpNarrowWide, ArrowUpRight, AtSign, Briefcase, Building2, Calendar, Check, CircleHelp, CircleUserRound, Copy, CopyPlus, CornerDownLeft, Download, EyeOff, Globe, GripVertical, Hash, Inbox, ListFilter, ListPlus, Mail, MapPin, MoreHorizontal, Pencil, Plug, Plus, Star, Tag, Trash2, TrendingUp, Upload, Users, Workflow, X, } from "lucide-react";
2
+ import { ArrowDown, ArrowDownWideNarrow, ArrowLeft, ArrowRight, ArrowUp, ArrowUpDown, ArrowUpNarrowWide, ArrowUpRight, AtSign, Briefcase, Building2, Calendar, Check, CircleHelp, CircleUserRound, Copy, CopyPlus, CornerDownLeft, EyeOff, Globe, GripVertical, Hash, Inbox, ListFilter, ListPlus, Mail, MapPin, MoreHorizontal, Pencil, Plug, Plus, Star, Tag, Trash2, TrendingUp, Upload, Users, Workflow, X, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { Avatar } from "../../components/avatar/index.js";
@@ -154,7 +154,6 @@ export const recordsTableLabels = {
154
154
  selectionAnnouncement: (count) => count === 0 ? "Selection cleared" : `${count} selected`,
155
155
  delete: "Delete",
156
156
  sendEmail: "Send email",
157
- exportCsv: "Export CSV",
158
157
  runWorkflow: "Run workflow",
159
158
  calculate: "Calculate",
160
159
  emptyPlaceholder: "Empty",
@@ -1003,21 +1002,6 @@ export function RecordsTable({ labels, columns: columnsProp = defaultColumns, ro
1003
1002
  setData((prev) => prev.filter((r) => !selected.has(r.id)));
1004
1003
  setSelected(new Set());
1005
1004
  };
1006
- const exportSelected = () => {
1007
- const picked = data.filter((r) => selected.has(r.id));
1008
- const esc = (s) => `"${s.replace(/"/g, '""')}"`;
1009
- const csv = [
1010
- cols.map((c) => esc(c.label)).join(","),
1011
- ...picked.map((r) => cols.map((c) => esc(cellText(c, r))).join(",")),
1012
- ].join("\n");
1013
- const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
1014
- const a = document.createElement("a");
1015
- a.href = url;
1016
- a.download = "records.csv";
1017
- a.click();
1018
- URL.revokeObjectURL(url);
1019
- setOpenMenu(null);
1020
- };
1021
1005
  const entityCol = cols.find((c) => c.type === "entity");
1022
1006
  // "Add to list" → pin the selected rows (shows a ★ on the entity)
1023
1007
  const addToList = () => {
@@ -1263,7 +1247,7 @@ export function RecordsTable({ labels, columns: columnsProp = defaultColumns, ro
1263
1247
  // filtered-to-zero ring -- two different Attio surfaces.
1264
1248
  variant: !activeFilters && emptyStateExtra ? "records" : "page", icon: activeFilters ? (_jsx(ListFilter, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" })) : (_jsx(Inbox, { size: !activeFilters && emptyStateExtra ? 40 : 18, strokeWidth: 1.25, "aria-hidden": "true" })), title: activeFilters ? "No records match that filter" : t.noRecords, description: activeFilters
1265
1249
  ? "None of your records match the filters that are set."
1266
- : `Create your first ${cols.find((c) => c.type === "entity")?.label?.toLowerCase() ?? "record"} to get started.`, action: activeFilters ? (_jsxs(Button, { intent: "primary", size: "sm", onClick: () => setFilterConds([]), children: [_jsx(ListFilter, { size: 15, strokeWidth: 1.75 }), t.clearFilter] })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { intent: "primary", size: "sm", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), t.newRecord] }), emptyStateExtra?.onImportCsv && (_jsxs(Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onImportCsv, children: [_jsx(Upload, { size: 15, strokeWidth: 1.75 }), t.importCsv] })), emptyStateExtra?.onConnectDataSource && (_jsxs(Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onConnectDataSource, children: [_jsx(Plug, { size: 15, strokeWidth: 1.75 }), t.connectDataSource] }))] })), learnMore: !activeFilters ? emptyStateExtra?.learnMore : undefined }) })) : (_jsxs("button", { type: "button", className: "crisp-recordstable-addrow", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), t.newRecord] }))] }), _jsx("div", { className: "crisp-recordstable-live", role: "status", "aria-live": "polite", children: selectionAnnouncement }), selected.size > 0 && (_jsxs(BulkBar, { count: selected.size, countLabel: t.selectedCount, onClear: () => setSelected(new Set()), children: [_jsx(BulkBarButton, { icon: _jsx(ListPlus, { size: 15, strokeWidth: 1.75 }), onClick: addToList, children: t.addToList }), _jsx(BulkBarButton, { icon: _jsx(Mail, { size: 15, strokeWidth: 1.75 }), onClick: sendEmail, children: t.sendEmail }), _jsx(BulkBarButton, { icon: _jsx(Workflow, { size: 15, strokeWidth: 1.75 }), onClick: runWorkflow, children: t.runWorkflow }), _jsxs(Menu, { align: "end", "aria-label": t.moreActions, trigger: _jsx(BulkBarButton, { icon: _jsx(MoreHorizontal, { size: 15, strokeWidth: 1.75 }), children: t.more }), children: [_jsx(MenuItem, { icon: _jsx(Download, { size: 15, strokeWidth: 1.75 }), onSelect: exportSelected, children: t.exportCsv }), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, onSelect: deleteSelected, children: t.delete })] })] })), _jsx(CreateRecordDialog, { labels: t, open: creating, onOpenChange: setCreating, columns: cols, entityLabel: cols.find((c) => c.type === "entity")?.label ?? "record", onCreate: (row) => setData((prev) => [...prev, row]) }), ctxMenu &&
1250
+ : `Create your first ${cols.find((c) => c.type === "entity")?.label?.toLowerCase() ?? "record"} to get started.`, action: activeFilters ? (_jsxs(Button, { intent: "primary", size: "sm", onClick: () => setFilterConds([]), children: [_jsx(ListFilter, { size: 15, strokeWidth: 1.75 }), t.clearFilter] })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { intent: "primary", size: "sm", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), t.newRecord] }), emptyStateExtra?.onImportCsv && (_jsxs(Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onImportCsv, children: [_jsx(Upload, { size: 15, strokeWidth: 1.75 }), t.importCsv] })), emptyStateExtra?.onConnectDataSource && (_jsxs(Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onConnectDataSource, children: [_jsx(Plug, { size: 15, strokeWidth: 1.75 }), t.connectDataSource] }))] })), learnMore: !activeFilters ? emptyStateExtra?.learnMore : undefined }) })) : (_jsxs("button", { type: "button", className: "crisp-recordstable-addrow", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), t.newRecord] }))] }), _jsx("div", { className: "crisp-recordstable-live", role: "status", "aria-live": "polite", children: selectionAnnouncement }), selected.size > 0 && (_jsxs(BulkBar, { count: selected.size, countLabel: t.selectedCount, onClear: () => setSelected(new Set()), children: [_jsx(BulkBarButton, { icon: _jsx(ListPlus, { size: 15, strokeWidth: 1.75 }), onClick: addToList, children: t.addToList }), _jsx(BulkBarButton, { icon: _jsx(Mail, { size: 15, strokeWidth: 1.75 }), onClick: sendEmail, children: t.sendEmail }), _jsx(BulkBarButton, { icon: _jsx(Workflow, { size: 15, strokeWidth: 1.75 }), onClick: runWorkflow, children: t.runWorkflow }), _jsx(Menu, { align: "end", "aria-label": t.moreActions, trigger: _jsx(BulkBarButton, { icon: _jsx(MoreHorizontal, { size: 15, strokeWidth: 1.75 }), children: t.more }), children: _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, onSelect: deleteSelected, children: t.delete }) })] })), _jsx(CreateRecordDialog, { labels: t, open: creating, onOpenChange: setCreating, columns: cols, entityLabel: cols.find((c) => c.type === "entity")?.label ?? "record", onCreate: (row) => setData((prev) => [...prev, row]) }), ctxMenu &&
1267
1251
  createPortal(_jsxs("div", { ref: ctxRef, className: "crisp-recordstable-menu crisp-recordstable-ctx", role: "menu", "aria-label": t.recordActions, style: { position: "fixed", top: ctxMenu.y, left: ctxMenu.x }, onKeyDown: onCtxKeyDown, children: [_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => copyRow(ctxMenu.id), children: [_jsx(Copy, { size: 15, strokeWidth: 1.75 }), t.copy] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => duplicateRow(ctxMenu.id), children: [_jsx(CopyPlus, { size: 15, strokeWidth: 1.75 }), t.duplicate] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => pinRow(ctxMenu.id), children: [_jsx(Star, { size: 15, strokeWidth: 1.75 }), t.addToList] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item crisp-recordstable-menu-item--danger", role: "menuitem", tabIndex: -1, onClick: () => deleteRow(ctxMenu.id), children: [_jsx(Trash2, { size: 15, strokeWidth: 1.75 }), t.delete] })] }), rootRef.current ?? document.body)] }));
1268
1252
  }
1269
1253
  export const defaultColumns = [
@@ -1,32 +1,47 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ChevronDown, MoreVertical, Plus, Users2 } from "lucide-react";
3
3
  import * as React from "react";
4
- import { Menu, MenuItem } from "../../components/menu/index.js";
4
+ import { Menu, MenuItem, MenuSeparator } from "../../components/menu/index.js";
5
5
  import { useListboxKeyboard } from "../../components/select/index.js";
6
6
  import { FloatingPortal, hasNestedFloatingLayer, isInsideLaterFloatingLayer, placeFloating, registerFloatingLayer, useCrispMessage, } from "../../core/index.js";
7
7
  import { savedviewsRecipe } from "./savedviews.recipe.js";
8
+ // #618: the same fallback Select's own `optionText` gives a non-string
9
+ // `label` — `keywords` first, then the plain string itself, then (rather
10
+ // than becoming permanently unmatchable) the view's own `id`.
11
+ function viewText(v) {
12
+ if (v.keywords)
13
+ return v.keywords;
14
+ return typeof v.name === "string" ? v.name : v.id;
15
+ }
8
16
  /**
9
17
  * A saved-view switcher — measured from Attio's "All Companies ▾" menu. A
10
18
  * chip trigger opens a popover listing every saved view as a `role="option"`
11
19
  * row (leading coloured icon, name, a hover-revealed kebab of
12
20
  * rename/duplicate/delete), plus a trailing "Create new view" row. The
13
21
  * active view is marked `aria-selected`/`aria-current="page"` and mirrored
14
- * onto the trigger's own icon/label.
22
+ * onto the trigger's own icon/label. The panel opens on a "Search views..."
23
+ * box (#618, P-007) — real DOM focus moves there (`requestAnimationFrame`,
24
+ * same race `Select`'s own `searchable` variant already guards against),
25
+ * not virtual focus on the trigger.
15
26
  *
16
- * Keyboard: while the trigger has focus and the panel is open, ↑/↓ move a
17
- * virtual highlight (`aria-activedescendant`, the same "focus stays on the
18
- * trigger" model `Select`/`MultiSelect` use without a search field), Enter
19
- * chooses the highlighted row, Escape closes — deferring to a still-open
20
- * kebab submenu first (`hasOpenFloatingLayer`), so Escape closes one layer
21
- * at a time.
27
+ * Keyboard: while the panel is open, focus lives on the search input — ↑/↓
28
+ * move a virtual highlight (`aria-activedescendant`) over the (possibly
29
+ * filtered) view list, Enter chooses the highlighted row, Escape closes,
30
+ * deferring to a still-open kebab submenu first (`hasOpenFloatingLayer`),
31
+ * so Escape closes one layer at a time — the same split `Select`'s own
32
+ * `searchable` variant already uses between its trigger and its own search
33
+ * input.
22
34
  */
23
- export function SavedViews({ label, icon, views, activeId, onSelect, onCreate, onRename, onDuplicate, onDelete, onToggleFavorite, createLabel: createLabelProp, renameLabel: renameLabelProp, duplicateLabel: duplicateLabelProp, deleteLabel: deleteLabelProp, favoriteLabel: favoriteLabelProp, viewOptionsLabel: viewOptionsLabelProp, className, "aria-label": ariaLabel, ...rest }) {
35
+ export function SavedViews({ label, icon, views, activeId, onSelect, onCreate, onRename, onDuplicate, onDelete, onToggleFavorite, createLabel: createLabelProp, renameLabel: renameLabelProp, duplicateLabel: duplicateLabelProp, deleteLabel: deleteLabelProp, favoriteLabel: favoriteLabelProp, viewOptionsLabel: viewOptionsLabelProp, searchPlaceholder: searchPlaceholderProp, searchLabel: searchLabelProp, emptyLabel: emptyLabelProp, className, "aria-label": ariaLabel, ...rest }) {
24
36
  const createLabel = useCrispMessage(createLabelProp, "Create new view");
25
37
  const renameLabel = useCrispMessage(renameLabelProp, "Rename");
26
38
  const duplicateLabel = useCrispMessage(duplicateLabelProp, "Duplicate");
27
39
  const deleteLabel = useCrispMessage(deleteLabelProp, "Delete");
28
40
  const favoriteLabel = useCrispMessage(favoriteLabelProp, "Add to favorites");
29
41
  const viewOptionsLabel = useCrispMessage(viewOptionsLabelProp, "View options");
42
+ const searchPlaceholder = useCrispMessage(searchPlaceholderProp, "Search views...");
43
+ const searchLabel = useCrispMessage(searchLabelProp, "Filter views");
44
+ const emptyLabel = useCrispMessage(emptyLabelProp, "No results");
30
45
  const autoId = React.useId();
31
46
  const triggerId = `${autoId}-trigger`;
32
47
  // role="combobox" (below) does not take its name from content — unlike a
@@ -45,13 +60,22 @@ export function SavedViews({ label, icon, views, activeId, onSelect, onCreate, o
45
60
  const [up, setUp] = React.useState(false);
46
61
  const [pos, setPos] = React.useState({});
47
62
  const [highlight, setHighlight] = React.useState(-1);
63
+ // #618: the panel's own "Search views..." box — real DOM focus lives here
64
+ // while open, unlike the virtual-focus-on-trigger model this component
65
+ // used before the search box existed.
66
+ const [query, setQuery] = React.useState("");
48
67
  const rootRef = React.useRef(null);
49
68
  const triggerRef = React.useRef(null);
50
69
  const panelRef = React.useRef(null);
70
+ const searchRef = React.useRef(null);
51
71
  const activeView = views.find((v) => v.id === activeId) ?? null;
72
+ const q = query.trim().toLowerCase();
73
+ const filteredViews = q
74
+ ? views.filter((v) => viewText(v).toLowerCase().includes(q))
75
+ : views;
52
76
  // the trailing "Create new view" row is one more enabled index, past every
53
- // real view
54
- const createIndex = views.length;
77
+ // (possibly filtered) view -- it never itself filters out
78
+ const createIndex = filteredViews.length;
55
79
  const openPanel = () => {
56
80
  const rect = triggerRef.current?.getBoundingClientRect();
57
81
  if (rect) {
@@ -64,8 +88,14 @@ export function SavedViews({ label, icon, views, activeId, onSelect, onCreate, o
64
88
  setUp(flipUp);
65
89
  setPos(style);
66
90
  }
91
+ setQuery("");
67
92
  setHighlight(-1);
68
93
  setOpen(true);
94
+ // Same race Select's own `searchable` variant already guards against
95
+ // (its own comment: calling focus synchronously here loses to the
96
+ // portal's own mount timing) -- a `requestAnimationFrame` after the
97
+ // state update that renders the input wins it.
98
+ requestAnimationFrame(() => searchRef.current?.focus({ preventScroll: true }));
69
99
  };
70
100
  const close = React.useCallback(() => {
71
101
  setOpen(false);
@@ -77,7 +107,7 @@ export function SavedViews({ label, icon, views, activeId, onSelect, onCreate, o
77
107
  close();
78
108
  }
79
109
  else {
80
- const v = views[highlight];
110
+ const v = filteredViews[highlight];
81
111
  if (v) {
82
112
  onSelect(v.id);
83
113
  close();
@@ -85,7 +115,7 @@ export function SavedViews({ label, icon, views, activeId, onSelect, onCreate, o
85
115
  }
86
116
  };
87
117
  const { onKeyDown: onNavKeys } = useListboxKeyboard({
88
- enabledIndexes: Array.from({ length: views.length + 1 }, (_, i) => i),
118
+ enabledIndexes: Array.from({ length: filteredViews.length + 1 }, (_, i) => i),
89
119
  highlight,
90
120
  setHighlight,
91
121
  commit,
@@ -144,25 +174,26 @@ export function SavedViews({ label, icon, views, activeId, onSelect, onCreate, o
144
174
  const triggerIcon = activeView ? activeView.icon : icon;
145
175
  const triggerLabel = activeView ? activeView.name : label;
146
176
  return (_jsxs("div", { ref: rootRef, className: savedviewsRecipe({ className }), ...rest, children: [_jsxs("button", { ref: triggerRef, id: triggerId, type: "button",
147
- // combobox, not the bare default "button" role: aria-activedescendant
148
- // (the virtual-focus highlight, same model Select/MultiSelect use
149
- // without a search field) is only valid on a widget role that
150
- // supports it, and combobox's aria-haspopup explicitly allows
151
- // "dialog" — the value Attio's own trigger measures — alongside the
152
- // more common "listbox".
153
- role: "combobox", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? panelId : undefined, "aria-activedescendant": open && highlight >= 0 ? optionId(highlight) : undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerLabelId, "data-open": open ? "" : undefined, className: "crisp-savedviews-trigger", onClick: () => (open ? setOpen(false) : openPanel()), onKeyDown: (e) => {
154
- if (open)
155
- onNavKeys(e);
156
- else if (e.key === "ArrowDown" || e.key === "Enter") {
177
+ // combobox: aria-haspopup explicitly allows "dialog" — the value
178
+ // Attio's own trigger measures — alongside the more common
179
+ // "listbox". aria-activedescendant lives on the search input (#618)
180
+ // now that real focus moves there while open, not here — the same
181
+ // split Select's own `searchable` variant already uses.
182
+ role: "combobox", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": open ? panelId : undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerLabelId, "data-open": open ? "" : undefined, className: "crisp-savedviews-trigger", onClick: () => (open ? setOpen(false) : openPanel()), onKeyDown: (e) => {
183
+ // navigation is handled on the search input while open (#618)
184
+ if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
157
185
  e.preventDefault();
158
186
  openPanel();
159
187
  }
160
- }, children: [triggerIcon != null && (_jsx("span", { className: "crisp-savedviews-trigger-icon", children: triggerIcon })), _jsx("span", { id: triggerLabelId, className: "crisp-savedviews-trigger-label", children: triggerLabel }), _jsx(ChevronDown, { className: "crisp-savedviews-trigger-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsxs("div", { ref: panelRef, id: panelId, role: "listbox", "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerLabelId, className: "crisp-savedviews-panel", "data-up": up ? "" : undefined, style: pos, children: [views.map((v, i) => {
188
+ }, children: [triggerIcon != null && (_jsx("span", { className: "crisp-savedviews-trigger-icon", children: triggerIcon })), _jsx("span", { id: triggerLabelId, className: "crisp-savedviews-trigger-label", children: triggerLabel }), _jsx(ChevronDown, { className: "crisp-savedviews-trigger-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsxs("div", { ref: panelRef, id: panelId, role: "listbox", "aria-label": ariaLabel, "aria-labelledby": ariaLabel ? undefined : triggerLabelId, className: "crisp-savedviews-panel", "data-up": up ? "" : undefined, style: pos, children: [_jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchLabel, "aria-controls": panelId, "aria-activedescendant": highlight >= 0 ? optionId(highlight) : undefined, className: "crisp-savedviews-search", value: query, onChange: (e) => {
189
+ setQuery(e.target.value);
190
+ setHighlight(-1);
191
+ }, onKeyDown: onNavKeys }), _jsx("div", { className: "crisp-savedviews-search-sep", "aria-hidden": "true" }), filteredViews.length === 0 && (_jsx("div", { className: "crisp-savedviews-empty", children: emptyLabel })), filteredViews.map((v, i) => {
161
192
  const selected = v.id === activeId;
162
193
  return (_jsxs("div", { id: optionId(i), role: "option", "aria-selected": selected, "aria-current": selected ? "page" : undefined, "data-highlighted": i === highlight ? "" : undefined, className: "crisp-savedviews-row", onMouseEnter: () => setHighlight(i), onClick: () => {
163
194
  onSelect(v.id);
164
195
  close();
165
- }, children: [v.icon != null && (_jsx("span", { className: "crisp-savedviews-row-icon", children: v.icon })), _jsx("span", { className: "crisp-savedviews-row-label", children: v.name }), v.shared && (_jsx("span", { className: "crisp-savedviews-row-shared", "aria-hidden": "true", children: _jsx(Users2, { size: 12, strokeWidth: 1.75 }) })), _jsx("div", { className: "crisp-savedviews-kebab-slot", onClick: (e) => e.stopPropagation(), children: _jsxs(Menu, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-savedviews-kebab", "aria-label": viewOptionsLabel, children: _jsx(MoreVertical, { size: 14, strokeWidth: 1.75 }) }), children: [onToggleFavorite && (_jsx(MenuItem, { onSelect: () => onToggleFavorite(v.id), children: favoriteLabel })), _jsx(MenuItem, { onSelect: () => onRename(v.id), children: renameLabel }), _jsx(MenuItem, { onSelect: () => onDuplicate(v.id), children: duplicateLabel }), _jsx(MenuItem, { danger: true, onSelect: () => onDelete(v.id), children: deleteLabel })] }) })] }, v.id));
196
+ }, children: [v.icon != null && (_jsx("span", { className: "crisp-savedviews-row-icon", children: v.icon })), _jsx("span", { className: "crisp-savedviews-row-label", children: v.name }), v.shared && (_jsx("span", { className: "crisp-savedviews-row-shared", "aria-hidden": "true", children: _jsx(Users2, { size: 12, strokeWidth: 1.75 }) })), _jsx("div", { className: "crisp-savedviews-kebab-slot", onClick: (e) => e.stopPropagation(), children: _jsxs(Menu, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-savedviews-kebab", "aria-label": viewOptionsLabel, children: _jsx(MoreVertical, { size: 14, strokeWidth: 1.75 }) }), children: [onToggleFavorite && (_jsx(MenuItem, { onSelect: () => onToggleFavorite(v.id), children: favoriteLabel })), _jsx(MenuItem, { onSelect: () => onRename(v.id), children: renameLabel }), _jsx(MenuItem, { onSelect: () => onDuplicate(v.id), children: duplicateLabel }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { danger: true, onSelect: () => onDelete(v.id), children: deleteLabel }), (v.createdBy != null || v.createdAt != null) && (_jsxs(_Fragment, { children: [_jsx(MenuSeparator, {}), _jsxs("div", { className: "crisp-savedviews-kebab-footer", children: [v.createdBy != null && (_jsx("div", { className: "crisp-savedviews-kebab-footer-line", children: v.createdBy })), v.createdAt != null && (_jsx("div", { className: "crisp-savedviews-kebab-footer-line", children: v.createdAt }))] })] }))] }) })] }, v.id));
166
197
  }), _jsxs("div", { id: optionId(createIndex), role: "option", "aria-selected": false, "data-highlighted": highlight === createIndex ? "" : undefined, className: "crisp-savedviews-row", onMouseEnter: () => setHighlight(createIndex), onClick: () => {
167
198
  onCreate();
168
199
  close();
@@ -60,6 +60,21 @@ const config = defineRecipe({
60
60
  .crisp-savedviews-panel[data-up] { transform-origin: bottom left; }
61
61
  .crisp-savedviews-panel { @starting-style { opacity: 0; transform: scale(0.97) translateY(-1px); } }
62
62
 
63
+ /* #618 (P-007): the panel's own "Search views..." box — measured live:
64
+ 32px tall, 6px/8px padding, NO border/background/leading icon of its
65
+ own (unlike Select's own bordered searchable-input pill), 14px text.
66
+ A hairline divider (borderSubtle, 1px, 3px top margin) separates it
67
+ from the view list, matching the divider Attio's own kebab menu uses
68
+ between its action items and the "Created by" footer below. */
69
+ .crisp-savedviews-search {
70
+ box-sizing: border-box; display: block; width: 100%; height: 32px; flex-shrink: 0;
71
+ margin: 0; padding: ${c.space["1.5"]} ${c.space["2"]}; border: none; background: none;
72
+ font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; outline: none;
73
+ }
74
+ .crisp-savedviews-search::placeholder { color: ${c.fgMuted}; }
75
+ .crisp-savedviews-search-sep { height: 1px; flex-shrink: 0; margin: 3px 0 0; background: ${c.borderSubtle}; }
76
+ .crisp-savedviews-empty { padding: ${c.space["1.5"]} ${c.space["2"]}; font-size: ${c.text.lg}; color: ${c.fgMuted}; }
77
+
63
78
  .crisp-savedviews-row {
64
79
  box-sizing: border-box; position: relative; display: flex; align-items: center; gap: ${c.space["1.5"]};
65
80
  height: ${c.size.menuRow}; flex-shrink: 0; padding: ${c.space["1.5"]} ${c.space["2"]};
@@ -91,6 +106,18 @@ const config = defineRecipe({
91
106
  .crisp-savedviews-row:focus-within .crisp-savedviews-kebab,
92
107
  .crisp-savedviews-kebab[data-open] { opacity: 1; }
93
108
  .crisp-savedviews-kebab:hover, .crisp-savedviews-kebab[data-open] { background: ${c.bgSurface}; color: ${c.fgPrimary}; }
109
+
110
+ /* #618 (P-008): the kebab menu's own "Created by X" / date footer —
111
+ measured live: 2 stacked lines, 6px/8px padding, 2px gap between
112
+ them, 12px/500 text, BOTH lines the same color-mix 50% muted (a
113
+ distinct value from the 63% mix #592's chip breadcrumb uses —
114
+ independently measured, not assumed to match). */
115
+ .crisp-savedviews-kebab-footer {
116
+ display: flex; flex-direction: column; gap: 2px;
117
+ padding: ${c.space["1.5"]} ${c.space["2"]};
118
+ font-size: ${c.text.sm}; font-weight: ${c.weight.medium};
119
+ color: color-mix(in srgb, ${c.fgPrimary} 50%, transparent);
120
+ }
94
121
  `,
95
122
  variants: {},
96
123
  defaultVariants: {},