@crisp-ui-kit/crisp 0.54.3 → 0.54.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/board.css +13 -1
  2. package/dist/board.layered.css +13 -1
  3. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +41 -4
  4. package/dist/cjs/index.js +2 -0
  5. package/dist/cjs/product/board/board.js +60 -2
  6. package/dist/cjs/product/board/board.recipe.js +10 -0
  7. package/dist/cjs/product/comments/comments.js +426 -0
  8. package/dist/cjs/product/comments/comments.recipe.js +126 -0
  9. package/dist/cjs/product/comments/index.js +8 -0
  10. package/dist/cjs/product/listpage/listpage.recipe.js +10 -1
  11. package/dist/cjs/product/objectsettings/index.js +16 -0
  12. package/dist/cjs/product/objectsettings/objectsettings.js +365 -0
  13. package/dist/cjs/product/objectsettings/objectsettings.recipe.js +65 -0
  14. package/dist/cjs/product/taskstable/taskstable.recipe.js +10 -1
  15. package/dist/comments.css +80 -0
  16. package/dist/comments.d.ts +7 -0
  17. package/dist/comments.layered.css +82 -0
  18. package/dist/esm/components/tabletoolbar/tabletoolbar.js +43 -6
  19. package/dist/esm/index.js +2 -0
  20. package/dist/esm/product/board/board.js +61 -3
  21. package/dist/esm/product/board/board.recipe.js +10 -0
  22. package/dist/esm/product/comments/comments.js +388 -0
  23. package/dist/esm/product/comments/comments.recipe.js +123 -0
  24. package/dist/esm/product/comments/index.js +2 -0
  25. package/dist/esm/product/listpage/listpage.recipe.js +10 -1
  26. package/dist/esm/product/objectsettings/index.js +2 -0
  27. package/dist/esm/product/objectsettings/objectsettings.js +322 -0
  28. package/dist/esm/product/objectsettings/objectsettings.recipe.js +62 -0
  29. package/dist/esm/product/taskstable/taskstable.recipe.js +10 -1
  30. package/dist/listpage.css +8 -1
  31. package/dist/listpage.layered.css +8 -1
  32. package/dist/objectsettings.css +49 -0
  33. package/dist/objectsettings.d.ts +7 -0
  34. package/dist/objectsettings.layered.css +51 -0
  35. package/dist/styles.css +160 -3
  36. package/dist/styles.layered.css +160 -3
  37. package/dist/taskstable.css +8 -1
  38. package/dist/taskstable.layered.css +8 -1
  39. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +8 -2
  40. package/dist/types/index.d.ts +2 -0
  41. package/dist/types/product/board/board.d.ts +20 -1
  42. package/dist/types/product/comments/comments.d.ts +192 -0
  43. package/dist/types/product/comments/comments.recipe.d.ts +3 -0
  44. package/dist/types/product/comments/index.d.ts +2 -0
  45. package/dist/types/product/objectsettings/index.d.ts +2 -0
  46. package/dist/types/product/objectsettings/objectsettings.d.ts +261 -0
  47. package/dist/types/product/objectsettings/objectsettings.recipe.d.ts +3 -0
  48. package/package.json +11 -1
package/dist/board.css CHANGED
@@ -16,8 +16,20 @@
16
16
  & .crisp-board-card[data-dragging] { opacity: 0.4; }
17
17
  & .crisp-board-card:focus { outline: none; }
18
18
  & .crisp-board-card:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
19
+ /* #604: a per-column calc footer, mirroring the table's own (measured
20
+ live, Deals pipeline). Sits below the scrollable card body, not
21
+ inside it or pinned to it -- Attio's own is position: static, not sticky, so it's a plain flex-shrink:0 sibling here too. */
22
+ & .crisp-board-col-footer { flex-shrink: 0; padding: var(--crisp-space-1) 0; }
23
+ & .crisp-board-col-calc { display: flex; align-items: center; justify-content: center; gap: var(--crisp-space-1); width: 100%; height: 28px; padding: var(--crisp-space-1) var(--crisp-space-2); margin: 0; border: none; border-radius: var(--crisp-radius-md); background: none; font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); cursor: pointer; }
24
+ & .crisp-board-col-calc:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); }
25
+ & .crisp-board-col-calc[data-empty] { opacity: 0.7; }
26
+ & .crisp-board-col-calc-label { text-transform: capitalize; }
27
+ & .crisp-board-col-calc-value { color: var(--crisp-fg-primary); font-variant-numeric: tabular-nums; }
19
28
  /* announced, never drawn */
20
- & .crisp-board-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
29
+ & .crisp-board-live {
30
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
31
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
32
+ }
21
33
  }
22
34
 
23
35
  @media (prefers-reduced-motion: reduce) {
@@ -17,8 +17,20 @@
17
17
  & .crisp-board-card[data-dragging] { opacity: 0.4; }
18
18
  & .crisp-board-card:focus { outline: none; }
19
19
  & .crisp-board-card:focus-visible { outline: 2px solid var(--crisp-bg-brand-solid); outline-offset: 1px; }
20
+ /* #604: a per-column calc footer, mirroring the table's own (measured
21
+ live, Deals pipeline). Sits below the scrollable card body, not
22
+ inside it or pinned to it -- Attio's own is position: static, not sticky, so it's a plain flex-shrink:0 sibling here too. */
23
+ & .crisp-board-col-footer { flex-shrink: 0; padding: var(--crisp-space-1) 0; }
24
+ & .crisp-board-col-calc { display: flex; align-items: center; justify-content: center; gap: var(--crisp-space-1); width: 100%; height: 28px; padding: var(--crisp-space-1) var(--crisp-space-2); margin: 0; border: none; border-radius: var(--crisp-radius-md); background: none; font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-medium); color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); cursor: pointer; }
25
+ & .crisp-board-col-calc:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); }
26
+ & .crisp-board-col-calc[data-empty] { opacity: 0.7; }
27
+ & .crisp-board-col-calc-label { text-transform: capitalize; }
28
+ & .crisp-board-col-calc-value { color: var(--crisp-fg-primary); font-variant-numeric: tabular-nums; }
20
29
  /* announced, never drawn */
21
- & .crisp-board-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
30
+ & .crisp-board-live {
31
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
32
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
33
+ }
22
34
  }
23
35
 
24
36
  @media (prefers-reduced-motion: reduce) {
@@ -46,14 +46,24 @@ const select_1 = require("../select");
46
46
  const tabletoolbar_recipe_1 = require("./tabletoolbar.recipe");
47
47
  const OP_LABELS = {
48
48
  contains: "contains",
49
+ doesnotcontain: "does not contain",
50
+ startswith: "starts with",
51
+ endswith: "ends with",
49
52
  is: "is",
50
53
  isnot: "is not",
51
54
  isanyof: "is any of",
52
55
  empty: "is empty",
53
56
  notempty: "is not empty",
54
57
  };
58
+ // #619: order and label set match Attio's own text-filter operator picker
59
+ // verbatim (measured live, per RecordsTable's own #578 port of the same
60
+ // set: contains · does not contain · starts with · ends with · is · is not ·
61
+ // empty · not empty).
55
62
  const OP_ORDER = [
56
63
  "contains",
64
+ "doesnotcontain",
65
+ "startswith",
66
+ "endswith",
57
67
  "is",
58
68
  "isnot",
59
69
  "empty",
@@ -62,7 +72,12 @@ const OP_ORDER = [
62
72
  // "isanyof" is appended only for a column that supplies `values` — there is
63
73
  // nothing to check off otherwise.
64
74
  const opsFor = (col) => col?.values?.length ? [...OP_ORDER, "isanyof"] : OP_ORDER;
65
- const opNeedsValue = (op) => op === "contains" || op === "is" || op === "isnot";
75
+ const opNeedsValue = (op) => op === "contains" ||
76
+ op === "doesnotcontain" ||
77
+ op === "startswith" ||
78
+ op === "endswith" ||
79
+ op === "is" ||
80
+ op === "isnot";
66
81
  const opNeedsValues = (op) => op === "isanyof";
67
82
  // "Department is any of IT, Finance" past two values becomes "… +N": the
68
83
  // chip is one line, and a field can have far more than two facet values.
@@ -110,9 +125,15 @@ function applyFilters(rows, filters, get = (row, key) => row[key]) {
110
125
  const v = f.value.trim().toLowerCase();
111
126
  if (f.op === "contains")
112
127
  return t.includes(v);
128
+ if (f.op === "doesnotcontain")
129
+ return !t.includes(v);
130
+ if (f.op === "startswith")
131
+ return t.startsWith(v);
132
+ if (f.op === "endswith")
133
+ return t.endsWith(v);
113
134
  if (f.op === "is")
114
135
  return t === v;
115
- return t !== v;
136
+ return t !== v; // isnot
116
137
  }));
117
138
  }
118
139
  /**
@@ -156,7 +177,7 @@ function applySorts(rows, sorts, get = (row, key) => row[key]) {
156
177
  * this (Sort: Due date/Assignee/Creation date; Filter: Assignee/Record/
157
178
  * Created by), which one shared list can't reproduce.
158
179
  */
159
- 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, ascendingLabel: ascendingLabelProp, descendingLabel: descendingLabelProp, sortTriggerLabel, filterTriggerLabel, inline, ...rest }) {
180
+ 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 }) {
160
181
  // crisp/#471: an explicit prop always wins; missing, it reads the nearest
161
182
  // CrispMessagesProvider's translation of the English default below, or
162
183
  // that default itself with no provider — unchanged from before this
@@ -175,6 +196,8 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
175
196
  const filterFieldLabel = (0, core_1.useCrispMessage)(filterFieldLabelProp, "Filter by field");
176
197
  const filterFieldPlaceholder = (0, core_1.useCrispMessage)(filterFieldPlaceholderProp, "Filter by…");
177
198
  const filterValuePlaceholder = (0, core_1.useCrispMessage)(filterValuePlaceholderProp, "Value…");
199
+ const filterOptionsLabel = (0, core_1.useCrispMessage)(filterOptionsLabelProp, "Filter options");
200
+ const deleteFilterLabel = (0, core_1.useCrispMessage)(deleteFilterLabelProp, "Delete filter");
178
201
  const ascendingLabel = (0, core_1.useCrispMessage)(ascendingLabelProp, "Ascending");
179
202
  const descendingLabel = (0, core_1.useCrispMessage)(descendingLabelProp, "Descending");
180
203
  // OP_LABELS' own words ("contains", "is", "is any of", …) have no prop of
@@ -269,6 +292,19 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
269
292
  putFilters(filters.filter((c) => c.id !== id));
270
293
  setOpenMenu(null);
271
294
  };
295
+ // #594: the filter chip's kebab is a real role="menu" (crisp/#188's shared
296
+ // model — arrows, Home/End, typeahead, Escape), not a hand-rolled div like
297
+ // the sort/condition popovers above (those are role="dialog", which makes
298
+ // no such promise). `openMenu` already only ever names ONE open surface at
299
+ // a time, so one hook call at the top level — not one per filter row,
300
+ // which would break the Rules of Hooks inside `filters.map` below — tracks
301
+ // whichever chip's kebab is currently open via its content ref.
302
+ const kebabMenuRef = React.useRef(null);
303
+ const { onKeyDown: onKebabKeyDown } = (0, menu_1.useMenuKeyboard)({
304
+ open: typeof openMenu === "string" && openMenu.startsWith("fkebab:"),
305
+ close: () => setOpenMenu(null),
306
+ contentRef: kebabMenuRef,
307
+ });
272
308
  const activeFilters = filters.length;
273
309
  // The default phrase only works in English word order, so a caller can
274
310
  // replace the whole thing rather than fill a template — crisp/#471's
@@ -318,10 +354,11 @@ function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColumns: fi
318
354
  .map((col) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => addFilter(col.key), children: [col.icon ? ((0, jsx_runtime_1.jsx)(col.icon, { size: 15, strokeWidth: 1.75 })) : ((0, jsx_runtime_1.jsx)("span", { style: { width: 15 } })), col.label] }, col.key)))] }))] }), filters.map((cond) => {
319
355
  const col = filterColumns.find((c) => c.key === cond.key);
320
356
  const open = openMenu === `fcond:${cond.id}`;
357
+ const kebabOpen = openMenu === `fkebab:${cond.id}`;
321
358
  const hasValue = (!opNeedsValue(cond.op) || cond.value.trim()) &&
322
359
  (!opNeedsValues(cond.op) || (cond.values?.length ?? 0) > 0);
323
360
  return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-toolgroup", children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-tabletoolbar-fchip", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-fchip-main", "data-menu-trigger": true, "aria-haspopup": "dialog", onClick: () => setOpenMenu(open ? null : `fcond:${cond.id}`), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-field", children: col?.label }), hasValue ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-op", children: crispMessages[OP_LABELS[cond.op]] ??
324
- OP_LABELS[cond.op] }), opNeedsValue(cond.op) && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-val", children: cond.value })), cond.op === "isanyof" && cond.values && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-val", children: isAnyOfSummary(cond.values) }))] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-op", children: selectConditionLabel }))] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-tabletoolbar-fchip-x", "aria-label": `${crispMessages.Remove ?? "Remove"} ${col?.label} ${crispMessages.filter ?? "filter"}`, onClick: () => removeFilter(cond.id), children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2 }) })] }), open && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": `${col?.label} ${crispMessages.condition ?? "condition"}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-menu-head", children: [col?.label, " ", crispMessages.condition ?? "condition"] }), opsFor(col).map((op) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", "data-on": cond.op === op ? "" : undefined, onClick: () => patchFilter(cond.id, { op }), children: [crispMessages[OP_LABELS[op]] ?? OP_LABELS[op], cond.op === op ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))), opNeedsValue(cond.op) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter value`,
361
+ OP_LABELS[cond.op] }), opNeedsValue(cond.op) && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-val", children: cond.value })), cond.op === "isanyof" && cond.values && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-val", children: isAnyOfSummary(cond.values) }))] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-tabletoolbar-fchip-op", children: selectConditionLabel }))] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-tabletoolbar-fchip-x", "data-menu-trigger": true, "aria-haspopup": "menu", "aria-expanded": kebabOpen, "aria-label": `${col?.label} ${filterOptionsLabel}`, onClick: () => setOpenMenu(kebabOpen ? null : `fkebab:${cond.id}`), children: (0, jsx_runtime_1.jsx)(lucide_react_1.MoreVertical, { size: 13, strokeWidth: 2 }) })] }), kebabOpen && ((0, jsx_runtime_1.jsx)("div", { ref: kebabMenuRef, className: "crisp-tabletoolbar-menu crisp-tabletoolbar-menu--right crisp-tabletoolbar-menu--hdr", role: "menu", "aria-label": `${col?.label} ${filterOptionsLabel}`, onKeyDown: onKebabKeyDown, children: (0, jsx_runtime_1.jsx)("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item", onClick: () => removeFilter(cond.id), children: deleteFilterLabel }) })), open && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": `${col?.label} ${crispMessages.condition ?? "condition"}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "crisp-tabletoolbar-menu-head", children: [col?.label, " ", crispMessages.condition ?? "condition"] }), opsFor(col).map((op) => ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-tabletoolbar-menu-item", "data-on": cond.op === op ? "" : undefined, onClick: () => patchFilter(cond.id, { op }), children: [crispMessages[OP_LABELS[op]] ?? OP_LABELS[op], cond.op === op ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))), opNeedsValue(cond.op) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter value`,
325
362
  // biome-ignore lint/a11y/noAutofocus: focus the value field
326
363
  autoFocus: true, value: cond.value, onChange: (e) => patchFilter(cond.id, { value: e.target.value }) })), cond.op === "isanyof" && col?.values && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter values`,
327
364
  // biome-ignore lint/a11y/noAutofocus: focus the search field
package/dist/cjs/index.js CHANGED
@@ -89,6 +89,7 @@ __exportStar(require("./product/appshell"), exports);
89
89
  __exportStar(require("./product/authshell"), exports);
90
90
  __exportStar(require("./product/board"), exports);
91
91
  __exportStar(require("./product/command"), exports);
92
+ __exportStar(require("./product/comments"), exports);
92
93
  __exportStar(require("./product/entitycard"), exports);
93
94
  __exportStar(require("./product/flowcanvas"), exports);
94
95
  __exportStar(require("./product/formdialog"), exports);
@@ -97,6 +98,7 @@ __exportStar(require("./product/importdialog"), exports);
97
98
  __exportStar(require("./product/inbox"), exports);
98
99
  __exportStar(require("./product/listpage"), exports);
99
100
  __exportStar(require("./product/navitem"), exports);
101
+ __exportStar(require("./product/objectsettings"), exports);
100
102
  __exportStar(require("./product/onboarding"), exports);
101
103
  __exportStar(require("./product/recordpage"), exports);
102
104
  __exportStar(require("./product/recordpanel"), exports);
@@ -35,20 +35,76 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.Board = Board;
37
37
  const jsx_runtime_1 = require("react/jsx-runtime");
38
+ const lucide_react_1 = require("lucide-react");
38
39
  const React = __importStar(require("react"));
40
+ const menu_1 = require("../../components/menu");
39
41
  const core_1 = require("../../core");
40
42
  const board_recipe_1 = require("./board.recipe");
43
+ const BOARD_AGG_ORDER = [
44
+ "countEmpty",
45
+ "countFilled",
46
+ "percentEmpty",
47
+ "percentFilled",
48
+ "sum",
49
+ "average",
50
+ "max",
51
+ "min",
52
+ ];
53
+ // English default text, looked up through `useCrispMessages()` (crisp/#471)
54
+ // the same way Board's other built-in fragments already are.
55
+ const AGG_LABEL = {
56
+ countEmpty: "Count empty",
57
+ countFilled: "Count filled",
58
+ percentEmpty: "Percent empty",
59
+ percentFilled: "Percent filled",
60
+ sum: "Sum",
61
+ average: "Average",
62
+ max: "Max",
63
+ min: "Min",
64
+ };
65
+ function computeBoardAgg(agg, values) {
66
+ const filled = values.filter((v) => v != null);
67
+ const total = values.length;
68
+ switch (agg) {
69
+ case "countEmpty":
70
+ return String(total - filled.length);
71
+ case "countFilled":
72
+ return String(filled.length);
73
+ case "percentEmpty":
74
+ return total === 0
75
+ ? "0%"
76
+ : `${Math.round(((total - filled.length) / total) * 100)}%`;
77
+ case "percentFilled":
78
+ return total === 0
79
+ ? "0%"
80
+ : `${Math.round((filled.length / total) * 100)}%`;
81
+ case "sum":
82
+ return filled.reduce((a, b) => a + b, 0).toLocaleString();
83
+ case "average":
84
+ return filled.length === 0
85
+ ? "0"
86
+ : (filled.reduce((a, b) => a + b, 0) / filled.length).toLocaleString(undefined, { maximumFractionDigits: 2 });
87
+ case "max":
88
+ return filled.length === 0 ? "—" : Math.max(...filled).toLocaleString();
89
+ case "min":
90
+ return filled.length === 0 ? "—" : Math.min(...filled).toLocaleString();
91
+ }
92
+ }
41
93
  /**
42
94
  * A kanban board with native drag-and-drop between columns. Cards are
43
95
  * `draggable`; dropping one on a column reparents it via `onCardsChange`
44
96
  * (the parent owns the cards, so the move is a controlled state update).
45
97
  * Measured from a deals pipeline.
46
98
  */
47
- function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel: newCardLabelProp, getCardLabel, announceMove: announceMoveProp, className, ...rest }) {
99
+ function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel: newCardLabelProp, getCardLabel, announceMove: announceMoveProp, calc, className, ...rest }) {
48
100
  // crisp/#471: same resolution order as every other component's own —
49
101
  // explicit prop, then a CrispMessagesProvider's translation of the English
50
102
  // default, then that default itself.
51
103
  const newCardLabel = (0, core_1.useCrispMessage)(newCardLabelProp, "New");
104
+ // #604: which BoardAgg each column's footer shows, keyed by column id.
105
+ // Unset (undefined) means the column still shows the "+ Add calculation"
106
+ // trigger, exactly like the table footer's own per-column state.
107
+ const [colAgg, setColAgg] = React.useState({});
52
108
  const crispMessages = (0, core_1.useCrispMessages)();
53
109
  const announceMove = announceMoveProp ??
54
110
  ((column, card) => card
@@ -128,7 +184,9 @@ function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLa
128
184
  // Ctrl+Right at the last column still reaches the browser
129
185
  if (delta && moveByKey(card, delta))
130
186
  e.preventDefault();
131
- }, children: renderCard(card) }, card.id)))] })] }, col.id));
187
+ }, children: renderCard(card) }, card.id)))] }), calc && ((0, jsx_runtime_1.jsx)("div", { className: "crisp-board-col-footer", children: (0, jsx_runtime_1.jsx)(menu_1.Menu, { align: "start", trigger: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-board-col-calc", "data-empty": colAgg[col.id] ? undefined : "", children: colAgg[col.id] ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-calc-label", children: crispMessages[AGG_LABEL[colAgg[col.id]]] ??
188
+ AGG_LABEL[colAgg[col.id]] }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-board-col-calc-value", children: computeBoardAgg(colAgg[col.id], colCards.map((c) => calc.getValue(c))) })] })) : ((crispMessages["+ Add calculation"] ??
189
+ "+ Add calculation")) }), children: BOARD_AGG_ORDER.map((a) => ((0, jsx_runtime_1.jsxs)(menu_1.MenuItem, { onSelect: () => setColAgg((prev) => ({ ...prev, [col.id]: a })), children: [crispMessages[AGG_LABEL[a]] ?? AGG_LABEL[a], colAgg[col.id] === a ? ((0, jsx_runtime_1.jsx)(lucide_react_1.Check, { size: 15, strokeWidth: 1.75 })) : null] }, a))) }) }))] }, col.id));
132
190
  }), (0, jsx_runtime_1.jsx)("div", { className: "crisp-board-live", role: "status", "aria-live": "polite", children: announcement })] }));
133
191
  }
134
192
  Board.displayName = "Board";
@@ -35,6 +35,16 @@ const config = (0, core_1.defineRecipe)({
35
35
  & .crisp-board-card[data-dragging] { opacity: 0.4; }
36
36
  & .crisp-board-card:focus { outline: none; }
37
37
  & .crisp-board-card:focus-visible { outline: 2px solid ${tokens_1.contract.bgBrandSolid}; outline-offset: 1px; }
38
+ /* #604: a per-column calc footer, mirroring the table's own (measured
39
+ live, Deals pipeline). Sits below the scrollable card body, not
40
+ inside it or pinned to it -- Attio's own is position: static, not
41
+ sticky, so it's a plain flex-shrink:0 sibling here too. */
42
+ & .crisp-board-col-footer { flex-shrink: 0; padding: ${tokens_1.contract.space["1"]} 0; }
43
+ & .crisp-board-col-calc { display: flex; align-items: center; justify-content: center; gap: ${tokens_1.contract.space["1"]}; width: 100%; height: 28px; padding: ${tokens_1.contract.space["1"]} ${tokens_1.contract.space["2"]}; margin: 0; border: none; border-radius: ${tokens_1.contract.radius.md}; background: none; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); cursor: pointer; }
44
+ & .crisp-board-col-calc:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); }
45
+ & .crisp-board-col-calc[data-empty] { opacity: 0.7; }
46
+ & .crisp-board-col-calc-label { text-transform: capitalize; }
47
+ & .crisp-board-col-calc-value { color: ${tokens_1.contract.fgPrimary}; font-variant-numeric: tabular-nums; }
38
48
  /* announced, never drawn */
39
49
  & .crisp-board-live {
40
50
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;