@crisp-ui-kit/crisp 0.54.2 → 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 (116) hide show
  1. package/dist/board.css +13 -1
  2. package/dist/board.layered.css +13 -1
  3. package/dist/chip.css +7 -1
  4. package/dist/chip.d.ts +2 -1
  5. package/dist/chip.layered.css +7 -1
  6. package/dist/cjs/components/chip/chip.js +3 -2
  7. package/dist/cjs/components/chip/chip.recipe.js +37 -1
  8. package/dist/cjs/components/datatable/datatable.js +489 -111
  9. package/dist/cjs/components/emptystate/emptystate.js +3 -2
  10. package/dist/cjs/components/emptystate/emptystate.recipe.js +26 -1
  11. package/dist/cjs/components/personchip/index.js +17 -0
  12. package/dist/cjs/components/personchip/personchip.js +50 -0
  13. package/dist/cjs/components/table/table.js +2 -2
  14. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +41 -4
  15. package/dist/cjs/components/tabs/tabs.js +2 -2
  16. package/dist/cjs/components/tabs/tabs.recipe.js +30 -3
  17. package/dist/cjs/components/text/index.js +7 -0
  18. package/dist/cjs/components/text/text.js +55 -0
  19. package/dist/cjs/components/text/text.recipe.js +34 -0
  20. package/dist/cjs/index.js +4 -0
  21. package/dist/cjs/product/board/board.js +60 -2
  22. package/dist/cjs/product/board/board.recipe.js +10 -0
  23. package/dist/cjs/product/comments/comments.js +426 -0
  24. package/dist/cjs/product/comments/comments.recipe.js +126 -0
  25. package/dist/cjs/product/comments/index.js +8 -0
  26. package/dist/cjs/product/headerbar/headerbar.recipe.js +9 -1
  27. package/dist/cjs/product/listpage/listpage.recipe.js +10 -1
  28. package/dist/cjs/product/objectsettings/index.js +16 -0
  29. package/dist/cjs/product/objectsettings/objectsettings.js +365 -0
  30. package/dist/cjs/product/objectsettings/objectsettings.recipe.js +65 -0
  31. package/dist/cjs/product/recordpage/recordpage.recipe.js +10 -1
  32. package/dist/cjs/product/recordstable/recordstable.js +89 -6
  33. package/dist/cjs/product/richtext/richtext.recipe.js +26 -1
  34. package/dist/cjs/product/taskstable/taskstable.recipe.js +10 -1
  35. package/dist/comments.css +80 -0
  36. package/dist/comments.d.ts +7 -0
  37. package/dist/comments.layered.css +82 -0
  38. package/dist/emptystate.css +15 -1
  39. package/dist/emptystate.d.ts +2 -2
  40. package/dist/emptystate.layered.css +15 -1
  41. package/dist/esm/components/chip/chip.js +3 -2
  42. package/dist/esm/components/chip/chip.recipe.js +37 -1
  43. package/dist/esm/components/datatable/datatable.js +488 -111
  44. package/dist/esm/components/emptystate/emptystate.js +4 -3
  45. package/dist/esm/components/emptystate/emptystate.recipe.js +26 -1
  46. package/dist/esm/components/emptystate/index.js +1 -1
  47. package/dist/esm/components/personchip/index.js +1 -0
  48. package/dist/esm/components/personchip/personchip.js +47 -0
  49. package/dist/esm/components/table/table.js +2 -2
  50. package/dist/esm/components/tabletoolbar/tabletoolbar.js +43 -6
  51. package/dist/esm/components/tabs/tabs.js +2 -2
  52. package/dist/esm/components/tabs/tabs.recipe.js +30 -3
  53. package/dist/esm/components/text/index.js +2 -0
  54. package/dist/esm/components/text/text.js +19 -0
  55. package/dist/esm/components/text/text.recipe.js +31 -0
  56. package/dist/esm/index.js +4 -0
  57. package/dist/esm/product/board/board.js +61 -3
  58. package/dist/esm/product/board/board.recipe.js +10 -0
  59. package/dist/esm/product/comments/comments.js +388 -0
  60. package/dist/esm/product/comments/comments.recipe.js +123 -0
  61. package/dist/esm/product/comments/index.js +2 -0
  62. package/dist/esm/product/headerbar/headerbar.recipe.js +9 -1
  63. package/dist/esm/product/listpage/listpage.recipe.js +10 -1
  64. package/dist/esm/product/objectsettings/index.js +2 -0
  65. package/dist/esm/product/objectsettings/objectsettings.js +322 -0
  66. package/dist/esm/product/objectsettings/objectsettings.recipe.js +62 -0
  67. package/dist/esm/product/recordpage/recordpage.recipe.js +10 -1
  68. package/dist/esm/product/recordstable/recordstable.js +90 -8
  69. package/dist/esm/product/richtext/richtext.recipe.js +26 -1
  70. package/dist/esm/product/taskstable/taskstable.recipe.js +10 -1
  71. package/dist/headerbar.css +1 -1
  72. package/dist/headerbar.layered.css +1 -1
  73. package/dist/listpage.css +8 -1
  74. package/dist/listpage.layered.css +8 -1
  75. package/dist/objectsettings.css +49 -0
  76. package/dist/objectsettings.d.ts +7 -0
  77. package/dist/objectsettings.layered.css +51 -0
  78. package/dist/recordpage.css +13 -2
  79. package/dist/recordpage.layered.css +13 -2
  80. package/dist/richtext.css +11 -1
  81. package/dist/richtext.layered.css +11 -1
  82. package/dist/styles.css +224 -10
  83. package/dist/styles.layered.css +224 -10
  84. package/dist/tabs.css +7 -1
  85. package/dist/tabs.d.ts +2 -2
  86. package/dist/tabs.layered.css +7 -1
  87. package/dist/taskstable.css +8 -1
  88. package/dist/taskstable.layered.css +8 -1
  89. package/dist/text.css +9 -0
  90. package/dist/text.d.ts +7 -0
  91. package/dist/text.layered.css +11 -0
  92. package/dist/types/components/chip/chip.d.ts +32 -0
  93. package/dist/types/components/chip/chip.recipe.d.ts +31 -0
  94. package/dist/types/components/datatable/datatable.d.ts +28 -4
  95. package/dist/types/components/emptystate/emptystate.d.ts +29 -4
  96. package/dist/types/components/emptystate/emptystate.recipe.d.ts +2 -0
  97. package/dist/types/components/emptystate/index.d.ts +1 -1
  98. package/dist/types/components/personchip/index.d.ts +1 -0
  99. package/dist/types/components/personchip/personchip.d.ts +37 -0
  100. package/dist/types/components/table/table.d.ts +36 -0
  101. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +8 -2
  102. package/dist/types/components/tabs/tabs.d.ts +4 -2
  103. package/dist/types/components/tabs/tabs.recipe.d.ts +18 -3
  104. package/dist/types/components/text/index.d.ts +2 -0
  105. package/dist/types/components/text/text.d.ts +32 -0
  106. package/dist/types/components/text/text.recipe.d.ts +40 -0
  107. package/dist/types/index.d.ts +4 -0
  108. package/dist/types/product/board/board.d.ts +20 -1
  109. package/dist/types/product/comments/comments.d.ts +192 -0
  110. package/dist/types/product/comments/comments.recipe.d.ts +3 -0
  111. package/dist/types/product/comments/index.d.ts +2 -0
  112. package/dist/types/product/objectsettings/index.d.ts +2 -0
  113. package/dist/types/product/objectsettings/objectsettings.d.ts +261 -0
  114. package/dist/types/product/objectsettings/objectsettings.recipe.d.ts +3 -0
  115. package/dist/types/product/recordstable/recordstable.d.ts +24 -2
  116. package/package.json +21 -1
@@ -1,7 +1,8 @@
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 { emptystateRecipe } from "./emptystate.recipe.js";
3
3
  /** A centred empty-state panel — icon, title, description, optional action. */
4
- export function EmptyState({ icon, title, description, action, variant, className, ...rest }) {
5
- return (_jsxs("div", { className: emptystateRecipe({ variant, className }), ...rest, children: [icon && _jsx("span", { className: "crisp-emptystate-icon", children: icon }), _jsx("div", { className: "crisp-emptystate-title", children: title }), description != null && (_jsx("div", { className: "crisp-emptystate-desc", children: description })), action && _jsx("div", { className: "crisp-emptystate-action", children: action })] }));
4
+ export function EmptyState({ icon, title, description, action, learnMore, variant, className, ...rest }) {
5
+ const learnMoreInner = learnMore && (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-emptystate-learnmore-icon", children: learnMore.icon }), _jsx("span", { className: "crisp-emptystate-learnmore-title", children: learnMore.title })] }));
6
+ return (_jsxs("div", { className: emptystateRecipe({ variant, className }), ...rest, children: [icon && _jsx("span", { className: "crisp-emptystate-icon", children: icon }), _jsx("div", { className: "crisp-emptystate-title", children: title }), description != null && (_jsx("div", { className: "crisp-emptystate-desc", children: description })), action && _jsx("div", { className: "crisp-emptystate-action", children: action }), learnMore && (_jsxs("div", { className: "crisp-emptystate-learnmore", children: [_jsx("div", { className: "crisp-emptystate-learnmore-label", children: learnMore.label ?? "Learn more" }), learnMore.href ? (_jsx("a", { className: "crisp-emptystate-learnmore-card", href: learnMore.href, onClick: learnMore.onClick, children: learnMoreInner })) : (_jsx("button", { type: "button", className: "crisp-emptystate-learnmore-card", onClick: learnMore.onClick, children: learnMoreInner }))] }))] }));
6
7
  }
7
8
  EmptyState.displayName = "EmptyState";
@@ -7,7 +7,18 @@ const config = defineRecipe({
7
7
  & .crisp-emptystate-icon { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; margin-bottom: ${c.space["1"]}; border-radius: ${c.radius.xl}; background: color-mix(in srgb, ${c.fgPrimary} 5%, transparent); color: ${c.fgMuted}; }
8
8
  & .crisp-emptystate-title { font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; color: ${c.fgPrimary}; }
9
9
  & .crisp-emptystate-desc { max-width: 320px; font-size: ${c.text.md}; color: ${c.fgMuted}; }
10
- & .crisp-emptystate-action { margin-top: ${c.space["2"]}; }`,
10
+ /* #603: measured 8px gap between "Import CSV"/"Connect data source" --
11
+ inline-flex so one action (the common case) or several lay out the
12
+ same way, wrapping if the container is narrow. */
13
+ & .crisp-emptystate-action { display: inline-flex; align-items: center; flex-wrap: wrap; justify-content: center; gap: ${c.space["2"]}; margin-top: ${c.space["2"]}; }
14
+ /* #603: Attio's zero-records "Learn more" card -- a label above a
15
+ clickable icon+title tile. Left/start-aligned, unlike the rest of
16
+ this centred panel, so it overrides the base text-align. */
17
+ & .crisp-emptystate-learnmore { display: flex; flex-direction: column; align-items: stretch; gap: ${c.space["2"]}; margin-top: ${c.space["6"]}; text-align: start; }
18
+ & .crisp-emptystate-learnmore-label { font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 50%, transparent); }
19
+ & .crisp-emptystate-learnmore-card { display: flex; align-items: center; gap: ${c.space["2"]}; padding: ${c.space["2"]} ${c.space["3"]} ${c.space["2"]} ${c.space["2"]}; margin: 0; border: 1px solid ${c.borderSubtle}; border-radius: 18px; background: ${c.bgSurface}; font: inherit; text-align: start; text-decoration: none; color: inherit; cursor: pointer; }
20
+ & .crisp-emptystate-learnmore-icon { display: inline-flex; align-items: center; justify-content: center; width: 56px; height: 56px; flex-shrink: 0; border-radius: ${c.radius.xl}; background: color-mix(in srgb, ${c.fgPrimary} 2%, transparent); color: ${c.fgMuted}; }
21
+ & .crisp-emptystate-learnmore-title { font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; }`,
11
22
  variants: {
12
23
  variant: {
13
24
  // compact icon tile (default)
@@ -22,6 +33,20 @@ const config = defineRecipe({
22
33
  & .crisp-emptystate-title { font-size: 20px; line-height: 24px; }
23
34
  & .crisp-emptystate-desc { max-width: 266px; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; line-height: 20px; }
24
35
  & .crisp-emptystate-action { margin-top: ${c.space["2"]}; }`,
36
+ // #603: Attio's zero-*records* table state (Users/Workspaces with
37
+ // nothing imported) -- the same dotted-grid backdrop `illustrated`
38
+ // uses, at its own measured 138x138 square (illustrated's is a
39
+ // 132x96 rectangle; different surface, not the same number).
40
+ // Attio's own illustration also draws four small "+" tick marks at
41
+ // the tile's corners -- a single proprietary SVG asset (confirmed
42
+ // by inspecting the live DOM: one <svg> with no separately
43
+ // measurable sub-elements), so not reproduced here per AUTHORING's
44
+ // "never copy Attio's icon set" rule; the icon itself is supplied
45
+ // by the consumer via the existing `icon` prop, same as every
46
+ // other variant.
47
+ records: `& .crisp-emptystate-icon { width: 138px; height: 138px; margin-bottom: ${c.space["2"]}; border-radius: 0; color: color-mix(in srgb, ${c.fgPrimary} 22%, transparent); background: radial-gradient(circle, color-mix(in srgb, ${c.fgPrimary} 12%, transparent) 1.2px, transparent 1.2px) 0 0 / 16px 16px; }
48
+ & .crisp-emptystate-title { font-size: 20px; line-height: 24px; }
49
+ & .crisp-emptystate-desc { max-width: 266px; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; line-height: 20px; }`,
25
50
  },
26
51
  },
27
52
  defaultVariants: { variant: "default" },
@@ -1,2 +1,2 @@
1
- export { EmptyState } from "./emptystate.js";
1
+ export { EmptyState, } from "./emptystate.js";
2
2
  export { emptystateRecipe } from "./emptystate.recipe.js";
@@ -0,0 +1 @@
1
+ export * from "./personchip.js";
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ import { Avatar } from "../avatar/index.js";
4
+ import { Chip } from "../chip/index.js";
5
+ // The same five hashed avatar tones DataTable's own `avatarTone="hashed"`
6
+ // picks from (datatable.tsx's private `TAG_TONES`, transcribed rather than
7
+ // imported — DataTable owns its own file, and this is a one-line pure
8
+ // function, not a visual value that could drift). `neutral`/`brand` are
9
+ // left out: `neutral` is Avatar's own no-photo default (wrong here, every
10
+ // name needs a colour to tell people apart) and `brand` is reserved for an
11
+ // entity that IS the record a cell points at (`RecordChip`'s own tone),
12
+ // which a person mention never is.
13
+ const HASH_TONES = ["brand", "orange", "cyan", "purple", "green"];
14
+ function hashTone(name) {
15
+ let h = 0;
16
+ for (let i = 0; i < name.length; i++)
17
+ h = (h * 31 + name.charCodeAt(i)) >>> 0;
18
+ return HASH_TONES[h % HASH_TONES.length];
19
+ }
20
+ function initials(name) {
21
+ const parts = name.trim().split(/\s+/).filter(Boolean);
22
+ if (parts.length === 0)
23
+ return "?";
24
+ return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase();
25
+ }
26
+ /**
27
+ * A person, as a chip: a small avatar (a photo, or hashed-colour initials)
28
+ * plus their name, truncating with an ellipsis when it doesn't fit — Attio's
29
+ * own actor chip (measured live on `techpartners`, Tasks page, "Assigned to"
30
+ * column): a 16px circular avatar in a neutral `Chip`, `gap: 3px` between
31
+ * avatar and name (now `Chip`'s own base — every multi-child `Chip` gets
32
+ * it). Every other value — the pill's height, padding, radius, ring and text
33
+ * — is already `Chip`'s, unchanged; `PersonChip` adds no recipe of its own.
34
+ *
35
+ * Ellipsis only, no `Tooltip` on hover (#623 — no Attio reference for one;
36
+ * a truncated cell there is revealed by its own click-to-expand, #579, a
37
+ * different, DataTable-specific interaction).
38
+ *
39
+ * Composes `Chip`'s `truncate` CSS directly rather than its string-only
40
+ * auto-wrap (children here is an avatar plus a name, not a single string),
41
+ * so the name gets its own `.crisp-chip-label` span while the avatar stays
42
+ * fixed-size.
43
+ */
44
+ export function PersonChip({ name, avatar, muted, className, ...props }) {
45
+ return (_jsxs(Chip, { tone: "neutral", truncate: true, className: className, ...props, children: [_jsx(Avatar, { size: "sm", shape: "circle", tone: avatar ? "neutral" : hashTone(name), children: avatar ? _jsx("img", { src: avatar, alt: "" }) : initials(name) }), _jsx("span", { className: "crisp-chip-label", style: muted ? { color: c.fgNeutralMuted } : undefined, children: name })] }));
46
+ }
47
+ PersonChip.displayName = "PersonChip";
@@ -12,11 +12,11 @@ export const Table = React.forwardRef(({ interactive, surface, className, wrappe
12
12
  // on the root for the same reason as Textarea: the wrapper is the box a
13
13
  // consumer sees and the scroll container they want to size. Native
14
14
  // table attributes still reach the <table> below.
15
- style, ...props }, ref) => (_jsx("div", { className: tableRecipe({
15
+ style, tableStyle, ...props }, ref) => (_jsx("div", { className: tableRecipe({
16
16
  interactive,
17
17
  surface,
18
18
  className: wrapperClassName,
19
- }), style: style, children: _jsx("div", { ...scrollProps, className: `crisp-table-scroll${scrollProps?.className ? ` ${scrollProps.className}` : ""}`, children: _jsx("table", { ref: ref, className: className, ...props, children: children }) }) })));
19
+ }), style: style, children: _jsx("div", { ...scrollProps, className: `crisp-table-scroll${scrollProps?.className ? ` ${scrollProps.className}` : ""}`, children: _jsx("table", { ref: ref, className: className, style: tableStyle, ...props, children: children }) }) })));
20
20
  Table.displayName = "Table";
21
21
  export const TableHeader = React.forwardRef((props, ref) => _jsx("thead", { ref: ref, ...props }));
22
22
  TableHeader.displayName = "TableHeader";
@@ -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
@@ -8,7 +8,7 @@ import { useTablistKeyboard } from "./tabs-keyboard.js";
8
8
  * Home/End jump); activation follows focus. Controlled via
9
9
  * `value`/`onValueChange`, or uncontrolled from `defaultValue`.
10
10
  */
11
- export function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, className, "aria-label": ariaLabel, ...rest }) {
11
+ export function Tabs({ tabs, value, defaultValue, onValueChange, leading, children, className, fill, "aria-label": ariaLabel, ...rest }) {
12
12
  // Scoped to this instance: `tab-${value}` and `panel-${value}` are global,
13
13
  // so two tab bars sharing a value mint the same id twice and every
14
14
  // aria-labelledby resolves to the first one (#227).
@@ -47,7 +47,7 @@ export function Tabs({ tabs, value, defaultValue, onValueChange, leading, childr
47
47
  select,
48
48
  listRef,
49
49
  });
50
- return (_jsxs("div", { className: tabsRecipe({ className }), ...rest, children: [_jsxs("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && _jsx("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t) => {
50
+ return (_jsxs("div", { className: tabsRecipe({ fill, className }), ...rest, children: [_jsxs("div", { ref: listRef, className: "crisp-tabs-list", role: "tablist", "aria-label": ariaLabel, onKeyDown: onKeyDown, children: [leading && _jsx("span", { className: "crisp-tabs-leading", children: leading }), tabs.map((t) => {
51
51
  const selected = t.value === active;
52
52
  return (_jsxs("button", { type: "button", role: "tab", id: tabId(t.value), "aria-selected": selected, "aria-controls": children && selected ? panelId(t.value) : undefined, tabIndex: tabIndexFor(t.value), "data-value": t.value, "data-selected": selected ? "" : undefined, disabled: t.disabled, className: "crisp-tabs-tab", onClick: () => select(t.value), children: [t.icon, _jsx("span", { className: "tabs-tab-label", children: t.label }), t.badge != null && (_jsx("span", { className: "crisp-tabs-tab-badge", children: t.badge }))] }, t.value));
53
53
  }), indicator && (_jsx("span", { className: "crisp-tabs-indicator", style: {
@@ -12,6 +12,22 @@ import { contract as c } from "../../tokens/index.js";
12
12
  // a 16px leading icon and an optional count badge. Measured from the live
13
13
  // app: bar `padding: 0 12px 8px`, 28px pills, 4/8 padding, 6px icon-label
14
14
  // gap, 14/500 labels, 10px badges (rgba(0,0,0,.04)).
15
+ //
16
+ // The panel's own `padding-top` (`c.space["8"]`, 32px) is measured
17
+ // separately, live, on a company record page (Overview tab): the gap from
18
+ // the tab row's own bottom to the content below it (the "Highlights"
19
+ // heading) is a clean 32px with zero margin/padding contributed by either
20
+ // boundary — not the 12px a GRC consumer had been carrying locally, which
21
+ // turned out (checking its own history) to have been measured against that
22
+ // app's own demo rendering, never against Attio itself.
23
+ //
24
+ // The panel is also `display: flex; flex-direction: column` (structural,
25
+ // not measured): a consumer's own content — a table sizing itself to "the
26
+ // rest of the space" — needs a flex-column ancestor with a real height to
27
+ // grow into. A GRC consumer used to supply that itself, wrapping every
28
+ // `children(active)` in its own `<div className="flex h-full min-h-0
29
+ // flex-col">`; dropping that wrapper in favour of `fill` without this would
30
+ // have silently broken every such table the moment the wrapper went away.
15
31
  const config = defineRecipe({
16
32
  name: "tabs",
17
33
  base: `
@@ -29,11 +45,22 @@ const config = defineRecipe({
29
45
  & .crisp-tabs-tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 ${c.space["1"]}; border-radius: ${c.radius.sm}; background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); font-size: ${c.text.xs}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
30
46
  & .crisp-tabs-tab[data-selected] .crisp-tabs-tab-badge { color: ${c.fgPrimary}; }
31
47
  & .crisp-tabs-indicator { position: absolute; bottom: 0; left: 0; z-index: 1; height: 1px; background: ${c.fgPrimary}; border-radius: 2px 2px 0 0; transition: transform .2s cubic-bezier(.4,0,.2,1), width .2s cubic-bezier(.4,0,.2,1); pointer-events: none; }
32
- & .crisp-tabs-panel { flex: 1; min-height: 0; }
48
+ & .crisp-tabs-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; padding-top: ${c.space["8"]}; }
33
49
  & .crisp-tabs-panel:focus-visible { outline: none; }
34
50
  `,
35
- variants: {},
36
- defaultVariants: {},
51
+ variants: {
52
+ // Opt-in: a `Tabs` dropped into a document-flow layout (a docs page, a
53
+ // demo) should size to its content like anything else. `fill` is for a
54
+ // consumer embedding it in a fixed-height shell — a record page, a full
55
+ // screen — matching the record page's own tab area, which visibly
56
+ // fills all available height (the same live check this file's panel
57
+ // measurement came from).
58
+ fill: {
59
+ true: `height: 100%;`,
60
+ false: "",
61
+ },
62
+ },
63
+ defaultVariants: { fill: false },
37
64
  compoundVariants: [],
38
65
  });
39
66
  export const tabsRecipe = Object.assign(resolveRecipe(config), { config });
@@ -0,0 +1,2 @@
1
+ export { Text } from "./text.js";
2
+ export { textRecipe } from "./text.recipe.js";
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { Primitive } from "../../core/index.js";
4
+ import { textRecipe } from "./text.recipe.js";
5
+ /**
6
+ * Plain coloured text — `tone="default"` (the app's primary text colour) or
7
+ * `"muted"` — for the runs of text that aren't a Badge, a Chip, a link, or
8
+ * any of crisp's other typed things: a cell's own yes/no, an empty-state
9
+ * caption, a secondary line under a heading. One component so every such
10
+ * spot reaches for crisp's own tokens instead of each hand-writing its own
11
+ * colour utility for the same two values.
12
+ *
13
+ * Renders a `<span>` (inherits its size/weight/line-height from context —
14
+ * see `text.recipe.ts`'s doc for why `Text` sets none of those itself).
15
+ */
16
+ export const Text = React.forwardRef(({ tone, className, asChild, children, ...props }, ref) => {
17
+ return (_jsx(Primitive.span, { ref: ref, asChild: asChild, className: textRecipe({ tone, className }), ...props, children: children }));
18
+ });
19
+ Text.displayName = "Text";
@@ -0,0 +1,31 @@
1
+ import { defineSpec, resolveRecipe, specToRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // No new measurement — `tone` just names two colour tokens every other
4
+ // component already carries (`fgPrimary`/`fgNeutralMuted`), for the plain
5
+ // runs of text that aren't any of crisp's other typed things (a Badge, a
6
+ // Chip, a link). Font size/weight/line-height are deliberately NOT set here:
7
+ // unlike Badge or Chip, plain text has no single measured size across the
8
+ // app (a table cell, a label, a caption are all different sizes) — `Text`
9
+ // inherits type from its context, the same choice `MiddleTruncate` makes for
10
+ // the same reason (see its own doc).
11
+ const spec = defineSpec({
12
+ name: "text",
13
+ // `display: inline` -- a span's own default, stated rather than left
14
+ // implicit: an EMPTY base compiles to an empty string, and
15
+ // `core/resolve.ts`'s `if (base) classes.push(prefixedName)` treats that
16
+ // falsy string as "no base rule", which drops the `crisp-text` class
17
+ // itself, silently, from every instance (caught by this file's own
18
+ // tests going red on that exact class).
19
+ base: { display: "inline" },
20
+ variants: {
21
+ tone: {
22
+ default: { color: c.fgPrimary },
23
+ muted: { color: c.fgNeutralMuted },
24
+ },
25
+ },
26
+ defaultVariants: { tone: "default" },
27
+ });
28
+ const config = specToRecipe(spec);
29
+ export const textRecipe = Object.assign(resolveRecipe(config), { config });
30
+ export { spec };
31
+ export default config;
package/dist/esm/index.js CHANGED
@@ -37,6 +37,7 @@ export * from "./components/mentionchip/index.js";
37
37
  export * from "./components/menu/index.js";
38
38
  export * from "./components/middletruncate/index.js";
39
39
  export * from "./components/multiselect/index.js";
40
+ export * from "./components/personchip/index.js";
40
41
  export * from "./components/popover/index.js";
41
42
  export * from "./components/progress/index.js";
42
43
  export * from "./components/quantitypicker/index.js";
@@ -54,6 +55,7 @@ export * from "./components/switch/index.js";
54
55
  export * from "./components/table/index.js";
55
56
  export * from "./components/tabletoolbar/index.js";
56
57
  export * from "./components/tabs/index.js";
58
+ export * from "./components/text/index.js";
57
59
  export * from "./components/textarea/index.js";
58
60
  export * from "./components/textlink/index.js";
59
61
  export * from "./components/timepicker/index.js";
@@ -71,6 +73,7 @@ export * from "./product/appshell/index.js";
71
73
  export * from "./product/authshell/index.js";
72
74
  export * from "./product/board/index.js";
73
75
  export * from "./product/command/index.js";
76
+ export * from "./product/comments/index.js";
74
77
  export * from "./product/entitycard/index.js";
75
78
  export * from "./product/flowcanvas/index.js";
76
79
  export * from "./product/formdialog/index.js";
@@ -79,6 +82,7 @@ export * from "./product/importdialog/index.js";
79
82
  export * from "./product/inbox/index.js";
80
83
  export * from "./product/listpage/index.js";
81
84
  export * from "./product/navitem/index.js";
85
+ export * from "./product/objectsettings/index.js";
82
86
  export * from "./product/onboarding/index.js";
83
87
  export * from "./product/recordpage/index.js";
84
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;