@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
@@ -0,0 +1,82 @@
1
+ @layer crisp-components {
2
+ .crisp-comments {
3
+ 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
+ & * { box-sizing: border-box; }
5
+ & .crisp-comments-head { display: flex; align-items: center; gap: var(--crisp-space-1); flex-shrink: 0; height: 49px; padding: 0 var(--crisp-space-2) 0 var(--crisp-space-4); }
6
+ & .crisp-comments-headbtn { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: var(--crisp-radius-md); background: none; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); font-family: inherit; cursor: pointer; transition: background-color .12s, color .12s; }
7
+ & .crisp-comments-headbtn:hover { background: var(--crisp-bg-raised); color: var(--crisp-fg-primary); }
8
+ & .crisp-comments-title-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; }
9
+ & .crisp-comments-title { font-size: var(--crisp-text-lg); line-height: 20px; font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10
+ & .crisp-comments-breadcrumb { display: flex; align-items: center; gap: var(--crisp-space-1); font-size: 12px; font-weight: var(--crisp-weight-medium); color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
11
+ & .crisp-comments-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 var(--crisp-space-4); display: flex; flex-direction: column; gap: var(--crisp-space-4); }
12
+ & .crisp-comments-body--pad-top { padding-top: var(--crisp-space-3); }
13
+ /* One comment row -- avatar 16px (Avatar size="sm"), 6px gap to the name/time line, 14px/600 name + 12px/500 63%-muted time, 14px/400 body two lines below. Hover actions fade in flush with the name line. */
14
+ & .crisp-comments-row { position: relative; display: flex; gap: var(--crisp-space-1-5); }
15
+ & .crisp-comments-row-main { flex: 1; min-width: 0; }
16
+ & .crisp-comments-row-head { display: flex; align-items: baseline; gap: var(--crisp-space-1-5); height: 20px; }
17
+ & .crisp-comments-author { font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-semibold); color: var(--crisp-fg-primary); }
18
+ & .crisp-comments-time { font-size: 12px; font-weight: var(--crisp-weight-medium); color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); white-space: nowrap; }
19
+ & .crisp-comments-resolved-label { display: flex; align-items: center; gap: var(--crisp-space-1); margin-bottom: var(--crisp-space-1); font-size: 12px; font-weight: var(--crisp-weight-medium); color: color-mix(in srgb, var(--crisp-fg-primary) 63%, transparent); }
20
+ & .crisp-comments-body-text { font-size: var(--crisp-text-lg); line-height: 20px; font-weight: var(--crisp-weight-regular); color: var(--crisp-fg-primary); white-space: pre-wrap; word-break: break-word; }
21
+ /* Hover actions -- measured: 4 buttons, 28x28, radius 8, flush side-by-side (0 gap), transparent by default, 16px icon at 55% ink. */
22
+ & .crisp-comments-row-actions { position: absolute; top: 0; inset-inline-end: 0; display: flex; opacity: 0; pointer-events: none; background: var(--crisp-bg-surface); }
23
+ & .crisp-comments-row:hover .crisp-comments-row-actions, & .crisp-comments-row-actions:focus-within { opacity: 1; pointer-events: auto; }
24
+ & .crisp-comments-action { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: var(--crisp-radius-md); background: transparent; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); font-family: inherit; cursor: pointer; transition: background-color .12s, color .12s; }
25
+ & .crisp-comments-action:hover { background: var(--crisp-bg-raised); color: var(--crisp-fg-primary); }
26
+ & .crisp-comments-action[data-active] { color: var(--crisp-bg-brand-solid); }
27
+ /* Reactions -- measured: 28px pill, radius 8, subtle 4% ink fill, padding 4px 8px 4px 6px, gap 4px between the emoji glyph and count. */
28
+ & .crisp-comments-reactions { display: flex; flex-wrap: wrap; gap: var(--crisp-space-1); margin-top: var(--crisp-space-1-5); }
29
+ & .crisp-comments-reaction { display: inline-flex; align-items: center; gap: var(--crisp-space-1); height: 28px; padding: 4px 8px 4px 6px; border: none; border-radius: var(--crisp-radius-md); background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); font-family: inherit; font-size: 12px; font-weight: var(--crisp-weight-medium); color: var(--crisp-fg-primary); cursor: pointer; }
30
+ & .crisp-comments-reaction[data-reacted] { background: color-mix(in srgb, var(--crisp-bg-brand-solid) 12%, transparent); color: var(--crisp-bg-brand-solid); }
31
+ & .crisp-comments-reaction-add { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; border-radius: var(--crisp-radius-md); background: transparent; font-family: inherit; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); cursor: pointer; opacity: 0; transition: opacity .12s, background-color .12s; }
32
+ & .crisp-comments-row:hover .crisp-comments-reaction-add { opacity: 1; }
33
+ & .crisp-comments-reaction-add:hover { background: var(--crisp-bg-raised); color: var(--crisp-fg-primary); }
34
+ /* Reply-in-thread view -- an indented block of replies below the root. */
35
+ & .crisp-comments-replies { display: flex; flex-direction: column; gap: var(--crisp-space-4); margin-top: var(--crisp-space-3); padding-inline-start: var(--crisp-space-4); border-inline-start: 1px solid var(--crisp-border-subtle); }
36
+ & .crisp-comments-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--crisp-space-1); padding: var(--crisp-space-6) var(--crisp-space-4); text-align: center; color: color-mix(in srgb, var(--crisp-fg-primary) 55%, transparent); font-size: var(--crisp-text-lg); }
37
+ /* Composer -- measured: 14px/20px body text, 16px gap above the reply/mention icon row, Comment button 84x32 padding 6px 10px radius 9 (= radius.lg) brand-solid fill, 14px/500 white label, opacity .4 disabled / 1 enabled (no color/background change between the two). */
38
+ & .crisp-comments-composer { flex-shrink: 0; padding: var(--crisp-space-3) var(--crisp-space-4) var(--crisp-space-4); border-top: 1px solid var(--crisp-border-subtle); }
39
+ & .crisp-comments-field { min-height: 20px; max-height: 120px; overflow-y: auto; font-family: inherit; font-size: var(--crisp-text-lg); line-height: 20px; font-weight: var(--crisp-weight-regular); color: var(--crisp-fg-primary); outline: none; white-space: pre-wrap; word-break: break-word; }
40
+ & .crisp-comments-field:focus-visible { outline: 2px solid var(--crisp-stroke-focus-ring); outline-offset: 2px; }
41
+ & .crisp-comments-field:empty::before { content: attr(data-placeholder); color: color-mix(in srgb, var(--crisp-fg-primary) 40%, transparent); }
42
+ & .crisp-comments-composer-foot { display: flex; align-items: center; justify-content: space-between; margin-top: var(--crisp-space-4); }
43
+ & .crisp-comments-composer-tools { display: flex; align-items: center; gap: var(--crisp-space-1); }
44
+ & .crisp-comments-tool { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin: 0; padding: 0; border: none; border-radius: var(--crisp-radius-sm); background: none; color: color-mix(in srgb, var(--crisp-fg-primary) 45%, transparent); font-family: inherit; cursor: pointer; }
45
+ & .crisp-comments-tool:hover { color: var(--crisp-fg-primary); }
46
+ /* Same fill/shadow as Button's own intent="primary" (crisp/button.recipe.ts) --
47
+ confirmed byte-identical against the live "Comment" button's computed box-shadow, so this reuses that literal rather than re-deriving it. 32px/radius.lg/6px-10px padding has no matching Button `size` yet (24/28/40 are the three that exist), so this stays hand-rolled instead of stretching one of those. */
48
+ & .crisp-comments-post { display: inline-flex; align-items: center; justify-content: center; height: 32px; padding: 6px var(--crisp-space-2-5); border: none; border-radius: var(--crisp-radius-lg); background: var(--crisp-bg-brand-solid); box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(38,109,240,0.12), 0 3px 6px -2px rgba(38,109,240,0.08); font-family: inherit; font-size: var(--crisp-text-lg); font-weight: var(--crisp-weight-semibold); color: var(--crisp-fg-on-brand); cursor: pointer; opacity: 1; transition: opacity .12s, background-color .12s; }
49
+ & .crisp-comments-post:hover:not(:disabled) { background: var(--crisp-bg-brand-solid-pressed); }
50
+ & .crisp-comments-post:disabled { opacity: .4; cursor: default; }
51
+ /* Filter menu -- measured: 220px, radius 12 (radius.xl), 4px padding, elevation.floating -- a plain reuse of crisp's own Menu, so no rules
52
+ live here; kept as a comment for the record. */
53
+
54
+ /* An inserted "@mention" chip in the composer -- same shape as
55
+ RichTextEditor's own `.crisp-rte-mention` (crisp#428), reused here rather than re-derived since it's the same atomic-chip affordance. */
56
+ & .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
+ [dir="rtl"] & [data-rtl-flip] { transform: scaleX(-1); }
73
+ }
74
+
75
+ @media (prefers-reduced-motion: reduce) {
76
+ .crisp-comments, .crisp-comments * {
77
+ animation-duration: 0.01ms !important;
78
+ animation-iteration-count: 1 !important;
79
+ transition-duration: 0.01ms !important;
80
+ }
81
+ }
82
+ }
@@ -1,20 +1,30 @@
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, Plus, X, } from "lucide-react";
2
+ import { ArrowDownWideNarrow, ArrowUpDown, ArrowUpNarrowWide, Check, CircleHelp, GripVertical, ListFilter, MoreVertical, Plus, X, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { isInsideLaterFloatingLayer, useCrispMessage, useCrispMessages, useFocusAfterRemoval, } from "../../core/index.js";
5
- import { MenuCheckboxItem } from "../menu/index.js";
5
+ import { MenuCheckboxItem, useMenuKeyboard } from "../menu/index.js";
6
6
  import { Select } from "../select/index.js";
7
7
  import { tabletoolbarRecipe } from "./tabletoolbar.recipe.js";
8
8
  const OP_LABELS = {
9
9
  contains: "contains",
10
+ doesnotcontain: "does not contain",
11
+ startswith: "starts with",
12
+ endswith: "ends with",
10
13
  is: "is",
11
14
  isnot: "is not",
12
15
  isanyof: "is any of",
13
16
  empty: "is empty",
14
17
  notempty: "is not empty",
15
18
  };
19
+ // #619: order and label set match Attio's own text-filter operator picker
20
+ // verbatim (measured live, per RecordsTable's own #578 port of the same
21
+ // set: contains · does not contain · starts with · ends with · is · is not ·
22
+ // empty · not empty).
16
23
  const OP_ORDER = [
17
24
  "contains",
25
+ "doesnotcontain",
26
+ "startswith",
27
+ "endswith",
18
28
  "is",
19
29
  "isnot",
20
30
  "empty",
@@ -23,7 +33,12 @@ const OP_ORDER = [
23
33
  // "isanyof" is appended only for a column that supplies `values` — there is
24
34
  // nothing to check off otherwise.
25
35
  const opsFor = (col) => col?.values?.length ? [...OP_ORDER, "isanyof"] : OP_ORDER;
26
- const opNeedsValue = (op) => op === "contains" || op === "is" || op === "isnot";
36
+ const opNeedsValue = (op) => op === "contains" ||
37
+ op === "doesnotcontain" ||
38
+ op === "startswith" ||
39
+ op === "endswith" ||
40
+ op === "is" ||
41
+ op === "isnot";
27
42
  const opNeedsValues = (op) => op === "isanyof";
28
43
  // "Department is any of IT, Finance" past two values becomes "… +N": the
29
44
  // chip is one line, and a field can have far more than two facet values.
@@ -71,9 +86,15 @@ export function applyFilters(rows, filters, get = (row, key) => row[key]) {
71
86
  const v = f.value.trim().toLowerCase();
72
87
  if (f.op === "contains")
73
88
  return t.includes(v);
89
+ if (f.op === "doesnotcontain")
90
+ return !t.includes(v);
91
+ if (f.op === "startswith")
92
+ return t.startsWith(v);
93
+ if (f.op === "endswith")
94
+ return t.endsWith(v);
74
95
  if (f.op === "is")
75
96
  return t === v;
76
- return t !== v;
97
+ return t !== v; // isnot
77
98
  }));
78
99
  }
79
100
  /**
@@ -117,7 +138,7 @@ export function applySorts(rows, sorts, get = (row, key) => row[key]) {
117
138
  * this (Sort: Due date/Assignee/Creation date; Filter: Assignee/Record/
118
139
  * Created by), which one shared list can't reproduce.
119
140
  */
120
- 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, 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, ...rest }) {
121
142
  // crisp/#471: an explicit prop always wins; missing, it reads the nearest
122
143
  // CrispMessagesProvider's translation of the English default below, or
123
144
  // that default itself with no provider — unchanged from before this
@@ -136,6 +157,8 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
136
157
  const filterFieldLabel = useCrispMessage(filterFieldLabelProp, "Filter by field");
137
158
  const filterFieldPlaceholder = useCrispMessage(filterFieldPlaceholderProp, "Filter by…");
138
159
  const filterValuePlaceholder = useCrispMessage(filterValuePlaceholderProp, "Value…");
160
+ const filterOptionsLabel = useCrispMessage(filterOptionsLabelProp, "Filter options");
161
+ const deleteFilterLabel = useCrispMessage(deleteFilterLabelProp, "Delete filter");
139
162
  const ascendingLabel = useCrispMessage(ascendingLabelProp, "Ascending");
140
163
  const descendingLabel = useCrispMessage(descendingLabelProp, "Descending");
141
164
  // OP_LABELS' own words ("contains", "is", "is any of", …) have no prop of
@@ -230,6 +253,19 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
230
253
  putFilters(filters.filter((c) => c.id !== id));
231
254
  setOpenMenu(null);
232
255
  };
256
+ // #594: the filter chip's kebab is a real role="menu" (crisp/#188's shared
257
+ // model — arrows, Home/End, typeahead, Escape), not a hand-rolled div like
258
+ // the sort/condition popovers above (those are role="dialog", which makes
259
+ // no such promise). `openMenu` already only ever names ONE open surface at
260
+ // a time, so one hook call at the top level — not one per filter row,
261
+ // which would break the Rules of Hooks inside `filters.map` below — tracks
262
+ // whichever chip's kebab is currently open via its content ref.
263
+ const kebabMenuRef = React.useRef(null);
264
+ const { onKeyDown: onKebabKeyDown } = useMenuKeyboard({
265
+ open: typeof openMenu === "string" && openMenu.startsWith("fkebab:"),
266
+ close: () => setOpenMenu(null),
267
+ contentRef: kebabMenuRef,
268
+ });
233
269
  const activeFilters = filters.length;
234
270
  // The default phrase only works in English word order, so a caller can
235
271
  // replace the whole thing rather than fill a template — crisp/#471's
@@ -279,10 +315,11 @@ export function TableToolbar({ columns, sortColumns: sortColumnsProp, filterColu
279
315
  .map((col) => (_jsxs("button", { type: "button", className: "crisp-tabletoolbar-menu-item", onClick: () => addFilter(col.key), children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : (_jsx("span", { style: { width: 15 } })), col.label] }, col.key)))] }))] }), filters.map((cond) => {
280
316
  const col = filterColumns.find((c) => c.key === cond.key);
281
317
  const open = openMenu === `fcond:${cond.id}`;
318
+ const kebabOpen = openMenu === `fkebab:${cond.id}`;
282
319
  const hasValue = (!opNeedsValue(cond.op) || cond.value.trim()) &&
283
320
  (!opNeedsValues(cond.op) || (cond.values?.length ?? 0) > 0);
284
321
  return (_jsxs("span", { className: "crisp-tabletoolbar-toolgroup", children: [_jsxs("span", { className: "crisp-tabletoolbar-fchip", children: [_jsxs("button", { type: "button", className: "crisp-tabletoolbar-fchip-main", "data-menu-trigger": true, "aria-haspopup": "dialog", onClick: () => setOpenMenu(open ? null : `fcond:${cond.id}`), children: [_jsx("span", { className: "crisp-tabletoolbar-fchip-field", children: col?.label }), hasValue ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-tabletoolbar-fchip-op", children: crispMessages[OP_LABELS[cond.op]] ??
285
- OP_LABELS[cond.op] }), opNeedsValue(cond.op) && (_jsx("span", { className: "crisp-tabletoolbar-fchip-val", children: cond.value })), cond.op === "isanyof" && cond.values && (_jsx("span", { className: "crisp-tabletoolbar-fchip-val", children: isAnyOfSummary(cond.values) }))] })) : (_jsx("span", { className: "crisp-tabletoolbar-fchip-op", children: selectConditionLabel }))] }), _jsx("button", { type: "button", className: "crisp-tabletoolbar-fchip-x", "aria-label": `${crispMessages.Remove ?? "Remove"} ${col?.label} ${crispMessages.filter ?? "filter"}`, onClick: () => removeFilter(cond.id), children: _jsx(X, { size: 13, strokeWidth: 2 }) })] }), open && (_jsxs("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": `${col?.label} ${crispMessages.condition ?? "condition"}`, children: [_jsxs("div", { className: "crisp-tabletoolbar-menu-head", children: [col?.label, " ", crispMessages.condition ?? "condition"] }), opsFor(col).map((op) => (_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 ? (_jsx(Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))), opNeedsValue(cond.op) && (_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter value`,
322
+ OP_LABELS[cond.op] }), opNeedsValue(cond.op) && (_jsx("span", { className: "crisp-tabletoolbar-fchip-val", children: cond.value })), cond.op === "isanyof" && cond.values && (_jsx("span", { className: "crisp-tabletoolbar-fchip-val", children: isAnyOfSummary(cond.values) }))] })) : (_jsx("span", { className: "crisp-tabletoolbar-fchip-op", children: selectConditionLabel }))] }), _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: _jsx(MoreVertical, { size: 13, strokeWidth: 2 }) })] }), kebabOpen && (_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: _jsx("button", { type: "button", role: "menuitem", className: "crisp-tabletoolbar-menu-item", onClick: () => removeFilter(cond.id), children: deleteFilterLabel }) })), open && (_jsxs("div", { className: "crisp-tabletoolbar-menu", role: "dialog", "aria-label": `${col?.label} ${crispMessages.condition ?? "condition"}`, children: [_jsxs("div", { className: "crisp-tabletoolbar-menu-head", children: [col?.label, " ", crispMessages.condition ?? "condition"] }), opsFor(col).map((op) => (_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 ? (_jsx(Check, { className: "crisp-tabletoolbar-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))), opNeedsValue(cond.op) && (_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter value`,
286
323
  // biome-ignore lint/a11y/noAutofocus: focus the value field
287
324
  autoFocus: true, value: cond.value, onChange: (e) => patchFilter(cond.id, { value: e.target.value }) })), cond.op === "isanyof" && col?.values && (_jsxs(_Fragment, { children: [_jsx("input", { className: "crisp-tabletoolbar-menu-input", placeholder: filterValuePlaceholder, "aria-label": `${col?.label} filter values`,
288
325
  // biome-ignore lint/a11y/noAutofocus: focus the search field
package/dist/esm/index.js CHANGED
@@ -73,6 +73,7 @@ export * from "./product/appshell/index.js";
73
73
  export * from "./product/authshell/index.js";
74
74
  export * from "./product/board/index.js";
75
75
  export * from "./product/command/index.js";
76
+ export * from "./product/comments/index.js";
76
77
  export * from "./product/entitycard/index.js";
77
78
  export * from "./product/flowcanvas/index.js";
78
79
  export * from "./product/formdialog/index.js";
@@ -81,6 +82,7 @@ export * from "./product/importdialog/index.js";
81
82
  export * from "./product/inbox/index.js";
82
83
  export * from "./product/listpage/index.js";
83
84
  export * from "./product/navitem/index.js";
85
+ export * from "./product/objectsettings/index.js";
84
86
  export * from "./product/onboarding/index.js";
85
87
  export * from "./product/recordpage/index.js";
86
88
  export * from "./product/recordpanel/index.js";
@@ -1,18 +1,74 @@
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
+ import { Check } from "lucide-react";
2
3
  import * as React from "react";
4
+ import { Menu, MenuItem } from "../../components/menu/index.js";
3
5
  import { isRtlElement, useCrispMessage, useCrispMessages } from "../../core/index.js";
4
6
  import { boardRecipe } from "./board.recipe.js";
7
+ const BOARD_AGG_ORDER = [
8
+ "countEmpty",
9
+ "countFilled",
10
+ "percentEmpty",
11
+ "percentFilled",
12
+ "sum",
13
+ "average",
14
+ "max",
15
+ "min",
16
+ ];
17
+ // English default text, looked up through `useCrispMessages()` (crisp/#471)
18
+ // the same way Board's other built-in fragments already are.
19
+ const AGG_LABEL = {
20
+ countEmpty: "Count empty",
21
+ countFilled: "Count filled",
22
+ percentEmpty: "Percent empty",
23
+ percentFilled: "Percent filled",
24
+ sum: "Sum",
25
+ average: "Average",
26
+ max: "Max",
27
+ min: "Min",
28
+ };
29
+ function computeBoardAgg(agg, values) {
30
+ const filled = values.filter((v) => v != null);
31
+ const total = values.length;
32
+ switch (agg) {
33
+ case "countEmpty":
34
+ return String(total - filled.length);
35
+ case "countFilled":
36
+ return String(filled.length);
37
+ case "percentEmpty":
38
+ return total === 0
39
+ ? "0%"
40
+ : `${Math.round(((total - filled.length) / total) * 100)}%`;
41
+ case "percentFilled":
42
+ return total === 0
43
+ ? "0%"
44
+ : `${Math.round((filled.length / total) * 100)}%`;
45
+ case "sum":
46
+ return filled.reduce((a, b) => a + b, 0).toLocaleString();
47
+ case "average":
48
+ return filled.length === 0
49
+ ? "0"
50
+ : (filled.reduce((a, b) => a + b, 0) / filled.length).toLocaleString(undefined, { maximumFractionDigits: 2 });
51
+ case "max":
52
+ return filled.length === 0 ? "—" : Math.max(...filled).toLocaleString();
53
+ case "min":
54
+ return filled.length === 0 ? "—" : Math.min(...filled).toLocaleString();
55
+ }
56
+ }
5
57
  /**
6
58
  * A kanban board with native drag-and-drop between columns. Cards are
7
59
  * `draggable`; dropping one on a column reparents it via `onCardsChange`
8
60
  * (the parent owns the cards, so the move is a controlled state update).
9
61
  * Measured from a deals pipeline.
10
62
  */
11
- export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel: newCardLabelProp, getCardLabel, announceMove: announceMoveProp, className, ...rest }) {
63
+ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel: newCardLabelProp, getCardLabel, announceMove: announceMoveProp, calc, className, ...rest }) {
12
64
  // crisp/#471: same resolution order as every other component's own —
13
65
  // explicit prop, then a CrispMessagesProvider's translation of the English
14
66
  // default, then that default itself.
15
67
  const newCardLabel = useCrispMessage(newCardLabelProp, "New");
68
+ // #604: which BoardAgg each column's footer shows, keyed by column id.
69
+ // Unset (undefined) means the column still shows the "+ Add calculation"
70
+ // trigger, exactly like the table footer's own per-column state.
71
+ const [colAgg, setColAgg] = React.useState({});
16
72
  const crispMessages = useCrispMessages();
17
73
  const announceMove = announceMoveProp ??
18
74
  ((column, card) => card
@@ -92,7 +148,9 @@ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, ne
92
148
  // Ctrl+Right at the last column still reaches the browser
93
149
  if (delta && moveByKey(card, delta))
94
150
  e.preventDefault();
95
- }, children: renderCard(card) }, card.id)))] })] }, col.id));
151
+ }, 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]]] ??
152
+ 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"] ??
153
+ "+ 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));
96
154
  }), _jsx("div", { className: "crisp-board-live", role: "status", "aria-live": "polite", children: announcement })] }));
97
155
  }
98
156
  Board.displayName = "Board";
@@ -32,6 +32,16 @@ const config = defineRecipe({
32
32
  & .crisp-board-card[data-dragging] { opacity: 0.4; }
33
33
  & .crisp-board-card:focus { outline: none; }
34
34
  & .crisp-board-card:focus-visible { outline: 2px solid ${c.bgBrandSolid}; outline-offset: 1px; }
35
+ /* #604: a per-column calc footer, mirroring the table's own (measured
36
+ live, Deals pipeline). Sits below the scrollable card body, not
37
+ inside it or pinned to it -- Attio's own is position: static, not
38
+ sticky, so it's a plain flex-shrink:0 sibling here too. */
39
+ & .crisp-board-col-footer { flex-shrink: 0; padding: ${c.space["1"]} 0; }
40
+ & .crisp-board-col-calc { display: flex; align-items: center; justify-content: center; gap: ${c.space["1"]}; width: 100%; height: 28px; padding: ${c.space["1"]} ${c.space["2"]}; margin: 0; border: none; border-radius: ${c.radius.md}; background: none; font-family: inherit; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); cursor: pointer; }
41
+ & .crisp-board-col-calc:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); }
42
+ & .crisp-board-col-calc[data-empty] { opacity: 0.7; }
43
+ & .crisp-board-col-calc-label { text-transform: capitalize; }
44
+ & .crisp-board-col-calc-value { color: ${c.fgPrimary}; font-variant-numeric: tabular-nums; }
35
45
  /* announced, never drawn */
36
46
  & .crisp-board-live {
37
47
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;