@crisp-ui-kit/crisp 0.54.6 → 0.54.8

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 (113) hide show
  1. package/dist/authshell.css +5 -1
  2. package/dist/authshell.layered.css +5 -1
  3. package/dist/badge.css +5 -5
  4. package/dist/badge.layered.css +5 -5
  5. package/dist/board.css +13 -9
  6. package/dist/board.layered.css +13 -9
  7. package/dist/button.css +3 -1
  8. package/dist/button.layered.css +3 -1
  9. package/dist/cjs/components/badge/badge.recipe.js +28 -5
  10. package/dist/cjs/components/button/button.js +33 -2
  11. package/dist/cjs/components/button/button.recipe.js +31 -5
  12. package/dist/cjs/components/datatable/datatable.js +66 -7
  13. package/dist/cjs/components/datatable/datatable.recipe.js +57 -2
  14. package/dist/cjs/components/dialog/dialog.js +8 -5
  15. package/dist/cjs/components/dialog/dialog.recipe.js +15 -1
  16. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +19 -1
  17. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +239 -50
  18. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +44 -0
  19. package/dist/cjs/components/tabs/tabs.recipe.js +11 -2
  20. package/dist/cjs/components/tooltip/tooltip.js +23 -9
  21. package/dist/cjs/components/tooltip/tooltip.recipe.js +10 -9
  22. package/dist/cjs/product/authshell/authshell.js +14 -1
  23. package/dist/cjs/product/authshell/authshell.recipe.js +13 -1
  24. package/dist/cjs/product/board/board.js +122 -27
  25. package/dist/cjs/product/board/board.recipe.js +14 -2
  26. package/dist/cjs/product/comments/comments.js +23 -10
  27. package/dist/cjs/product/comments/comments.recipe.js +17 -3
  28. package/dist/cjs/product/formdialog/formdialog.js +10 -4
  29. package/dist/cjs/product/navitem/navitem.js +7 -2
  30. package/dist/cjs/product/navitem/navitem.recipe.js +16 -0
  31. package/dist/cjs/product/objectsettings/index.js +1 -2
  32. package/dist/cjs/product/objectsettings/objectsettings.js +3 -13
  33. package/dist/cjs/product/richtext/richtext.js +76 -1
  34. package/dist/cjs/product/sidebar/sidebar.js +68 -15
  35. package/dist/cjs/product/sidebar/sidebar.recipe.js +6 -1
  36. package/dist/cjs/tokens/palette.js +32 -1
  37. package/dist/cjs/tokens/semantic.js +11 -0
  38. package/dist/comments.css +36 -10
  39. package/dist/comments.layered.css +36 -10
  40. package/dist/datatable.css +47 -9
  41. package/dist/datatable.layered.css +47 -9
  42. package/dist/dialog.css +13 -1
  43. package/dist/dialog.layered.css +13 -1
  44. package/dist/esm/components/badge/badge.recipe.js +28 -5
  45. package/dist/esm/components/button/button.js +34 -3
  46. package/dist/esm/components/button/button.recipe.js +31 -5
  47. package/dist/esm/components/datatable/datatable.js +67 -8
  48. package/dist/esm/components/datatable/datatable.recipe.js +57 -2
  49. package/dist/esm/components/dialog/dialog.js +8 -5
  50. package/dist/esm/components/dialog/dialog.recipe.js +15 -1
  51. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +19 -1
  52. package/dist/esm/components/tabletoolbar/tabletoolbar.js +240 -51
  53. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +44 -0
  54. package/dist/esm/components/tabs/tabs.recipe.js +11 -2
  55. package/dist/esm/components/tooltip/tooltip.js +23 -9
  56. package/dist/esm/components/tooltip/tooltip.recipe.js +10 -9
  57. package/dist/esm/product/authshell/authshell.js +15 -2
  58. package/dist/esm/product/authshell/authshell.recipe.js +13 -1
  59. package/dist/esm/product/authshell/index.js +1 -1
  60. package/dist/esm/product/board/board.js +122 -27
  61. package/dist/esm/product/board/board.recipe.js +14 -2
  62. package/dist/esm/product/comments/comments.js +23 -10
  63. package/dist/esm/product/comments/comments.recipe.js +17 -3
  64. package/dist/esm/product/formdialog/formdialog.js +10 -4
  65. package/dist/esm/product/navitem/index.js +1 -1
  66. package/dist/esm/product/navitem/navitem.js +7 -2
  67. package/dist/esm/product/navitem/navitem.recipe.js +16 -0
  68. package/dist/esm/product/objectsettings/index.js +1 -1
  69. package/dist/esm/product/objectsettings/objectsettings.js +2 -12
  70. package/dist/esm/product/richtext/richtext.js +76 -1
  71. package/dist/esm/product/sidebar/sidebar.js +69 -16
  72. package/dist/esm/product/sidebar/sidebar.recipe.js +6 -1
  73. package/dist/esm/tokens/palette.js +32 -1
  74. package/dist/esm/tokens/semantic.js +11 -0
  75. package/dist/native/tokens.d.ts +1 -1
  76. package/dist/native/tokens.js +16 -2
  77. package/dist/navitem.css +7 -0
  78. package/dist/navitem.layered.css +7 -0
  79. package/dist/recordstable.css +2 -1
  80. package/dist/recordstable.layered.css +2 -1
  81. package/dist/sidebar.css +6 -1
  82. package/dist/sidebar.layered.css +6 -1
  83. package/dist/styles.css +165 -42
  84. package/dist/styles.layered.css +165 -42
  85. package/dist/tabletoolbar.css +17 -1
  86. package/dist/tabletoolbar.layered.css +17 -1
  87. package/dist/tabs.css +1 -1
  88. package/dist/tabs.layered.css +1 -1
  89. package/dist/tokens.css +9 -1
  90. package/dist/tokens.json +16 -2
  91. package/dist/tooltip.css +1 -1
  92. package/dist/tooltip.layered.css +1 -1
  93. package/dist/types/components/badge/badge.recipe.d.ts +5 -1
  94. package/dist/types/components/button/button.recipe.d.ts +18 -5
  95. package/dist/types/components/datatable/datatable.d.ts +9 -0
  96. package/dist/types/components/dialog/dialog.d.ts +25 -2
  97. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +47 -5
  98. package/dist/types/components/tooltip/tooltip.d.ts +12 -4
  99. package/dist/types/components/tooltip/tooltip.recipe.d.ts +1 -1
  100. package/dist/types/product/authshell/authshell.d.ts +26 -2
  101. package/dist/types/product/authshell/index.d.ts +1 -1
  102. package/dist/types/product/board/board.d.ts +5 -4
  103. package/dist/types/product/comments/comments.d.ts +10 -2
  104. package/dist/types/product/formdialog/formdialog.d.ts +2 -1
  105. package/dist/types/product/navitem/index.d.ts +1 -1
  106. package/dist/types/product/navitem/navitem.d.ts +23 -0
  107. package/dist/types/product/objectsettings/index.d.ts +1 -1
  108. package/dist/types/product/objectsettings/objectsettings.d.ts +0 -10
  109. package/dist/types/product/sidebar/sidebar.d.ts +33 -5
  110. package/dist/types/tokens/contract.d.ts +4 -0
  111. package/dist/types/tokens/palette.d.ts +13 -1
  112. package/dist/types/tokens/semantic.d.ts +11 -0
  113. package/package.json +1 -1
@@ -1,5 +1,18 @@
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 { isValidElement } from "react";
2
3
  import { authshellRecipe } from "./authshell.recipe.js";
4
+ /** A plain `{ icon, title }` object (not a React element) — the shape
5
+ * `logo` renders as `AuthShell`'s own icon-badge + wordmark layout, as
6
+ * opposed to a caller-supplied node it just centers as-is. Same test
7
+ * `sidebar.tsx`'s `isSidebarFooterCopy` already uses for its own "plain
8
+ * shape vs. any node" prop. */
9
+ function isAuthShellBrand(value) {
10
+ return (typeof value === "object" &&
11
+ value !== null &&
12
+ !isValidElement(value) &&
13
+ "icon" in value &&
14
+ "title" in value);
15
+ }
3
16
  /**
4
17
  * The welcome/auth layout — measured live.
5
18
  * A centered page frame: an optional top progress bar, a logo, a two-pane
@@ -13,6 +26,6 @@ export function AuthShell({ progress, logo, title, children, preview, footer, cl
13
26
  const pct = hasProgress
14
27
  ? Math.round(Math.max(0, Math.min(1, progress)) * 100)
15
28
  : 0;
16
- return (_jsxs("div", { className: authshellRecipe({ className }), "data-single-pane": preview == null ? "" : undefined, ...rest, children: [hasProgress && (_jsx("div", { className: "crisp-authshell-progress", role: "progressbar", "aria-valuenow": pct, "aria-valuemin": 0, "aria-valuemax": 100, children: _jsx("div", { className: "crisp-authshell-progress-fill", style: { width: `${pct}%` } }) })), logo != null && _jsx("div", { className: "crisp-authshell-logo", children: logo }), _jsxs("div", { className: "crisp-authshell-card", children: [_jsx("div", { className: "crisp-authshell-pane", children: _jsxs("div", { className: "crisp-authshell-body", children: [title != null && (_jsx("div", { className: "crisp-authshell-title", children: title })), _jsx("div", { className: "crisp-authshell-form", children: children })] }) }), preview != null && (_jsx("div", { className: "crisp-authshell-preview", children: preview }))] }), footer != null && _jsx("div", { className: "crisp-authshell-footer", children: footer })] }));
29
+ return (_jsxs("div", { className: authshellRecipe({ className }), "data-single-pane": preview == null ? "" : undefined, ...rest, children: [hasProgress && (_jsx("div", { className: "crisp-authshell-progress", role: "progressbar", "aria-valuenow": pct, "aria-valuemin": 0, "aria-valuemax": 100, children: _jsx("div", { className: "crisp-authshell-progress-fill", style: { width: `${pct}%` } }) })), logo != null && (_jsx("div", { className: "crisp-authshell-logo", children: isAuthShellBrand(logo) ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "crisp-authshell-logo-badge", children: logo.icon }), _jsxs("div", { className: "crisp-authshell-logo-text", children: [_jsx("div", { className: "crisp-authshell-logo-title", children: logo.title }), logo.subtitle != null && (_jsx("div", { className: "crisp-authshell-logo-subtitle", children: logo.subtitle }))] })] })) : (logo) })), _jsxs("div", { className: "crisp-authshell-card", children: [_jsx("div", { className: "crisp-authshell-pane", children: _jsxs("div", { className: "crisp-authshell-body", children: [title != null && (_jsx("div", { className: "crisp-authshell-title", children: title })), _jsx("div", { className: "crisp-authshell-form", children: children })] }) }), preview != null && (_jsx("div", { className: "crisp-authshell-preview", children: preview }))] }), footer != null && _jsx("div", { className: "crisp-authshell-footer", children: footer })] }));
17
30
  }
18
31
  AuthShell.displayName = "AuthShell";
@@ -20,6 +20,14 @@ import { contract as c } from "../../tokens/index.js";
20
20
  // roughly centred in the viewport rather than pinned near the top.
21
21
  // `data-single-pane` now drops the card chrome and narrows the body to
22
22
  // match; the two-pane card is unchanged.
23
+ //
24
+ // your-moon/crisp#722: `.crisp-authshell-logo` is `min-height: 24px`, not
25
+ // `height` -- Attio's own mark (a single flat 96x24 SVG, measured live on
26
+ // the logged-out `/login` page) fits exactly at 24px, but a fixed height
27
+ // clipped any consumer logo taller than that. Found via internal/grc's real
28
+ // lockup (an icon badge beside a two-line TECH/tagline wordmark, ~36px
29
+ // tall), silently cut down to a bare one-line span rather than reported as
30
+ // not fitting.
23
31
  const config = defineRecipe({
24
32
  name: "authshell",
25
33
  base: `min-height: 100%; box-sizing: border-box; display: flex; flex-direction: column; align-items: center; position: relative;
@@ -31,7 +39,11 @@ const config = defineRecipe({
31
39
  & .crisp-authshell-progress { position: absolute; top: 0; left: 0; width: 100%; height: 2px; background: ${c.borderDefault}; }
32
40
  & .crisp-authshell-progress-fill { height: 2px; background: ${c.fgPrimary}; border-radius: 4px; transition: width .2s; }
33
41
 
34
- & .crisp-authshell-logo { margin-top: ${c.space["8"]}; height: 24px; display: flex; align-items: center; }
42
+ & .crisp-authshell-logo { margin-top: ${c.space["8"]}; min-height: 24px; display: flex; align-items: center; gap: ${c.space["2.5"]}; }
43
+ & .crisp-authshell-logo-badge { flex-shrink: 0; box-sizing: border-box; width: 32px; height: 32px; border-radius: ${c.radius.md}; background: ${c.bgBrandSolid}; color: #fff; display: flex; align-items: center; justify-content: center; }
44
+ & .crisp-authshell-logo-text { display: flex; flex-direction: column; line-height: 1.2; }
45
+ & .crisp-authshell-logo-title { font-size: ${c.text.xl}; font-weight: ${c.weight.bold}; letter-spacing: -0.2px; color: ${c.fgPrimary}; }
46
+ & .crisp-authshell-logo-subtitle { font-size: ${c.text.sm}; color: ${c.fgMuted}; }
35
47
 
36
48
  & .crisp-authshell-card { margin-top: ${c.space["6"]}; display: flex; height: 692px; border-radius: 20px; border: 1px solid ${c.borderSubtle}; overflow: hidden; }
37
49
  &[data-single-pane] .crisp-authshell-card { height: auto; border: none; border-radius: 0; overflow: visible; }
@@ -1,2 +1,2 @@
1
- export { AuthShell } from "./authshell.js";
1
+ export { AuthShell, } from "./authshell.js";
2
2
  export { authshellRecipe } from "./authshell.recipe.js";
@@ -55,10 +55,11 @@ function computeBoardAgg(agg, values) {
55
55
  }
56
56
  }
57
57
  /**
58
- * A kanban board with native drag-and-drop between columns. Cards are
59
- * `draggable`; dropping one on a column reparents it via `onCardsChange`
60
- * (the parent owns the cards, so the move is a controlled state update).
61
- * Measured from a deals pipeline.
58
+ * A kanban board with pointer-events drag-and-drop between columns (#674 —
59
+ * Attio's own mechanism, confirmed live: mousedown, several mousemoves,
60
+ * mouseup, no `[draggable]` anywhere). Dropping a card on a column reparents
61
+ * it via `onCardsChange` (the parent owns the cards, so the move is a
62
+ * controlled state update). Measured from a deals pipeline.
62
63
  */
63
64
  export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, newCardLabel: newCardLabelProp, getCardLabel, announceMove: announceMoveProp, calc, className, ...rest }) {
64
65
  // crisp/#471: same resolution order as every other component's own —
@@ -76,11 +77,37 @@ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, ne
76
77
  : `${crispMessages["Moved to"] ?? "Moved to"} ${column}`);
77
78
  const [dragId, setDragId] = React.useState(null);
78
79
  const [overCol, setOverCol] = React.useState(null);
80
+ const [ghost, setGhost] = React.useState(null);
79
81
  const [announcement, setAnnouncement] = React.useState("");
80
82
  // the card a keyboard move just sent to another column; React unmounts and
81
83
  // remounts it under the new parent, so focus has to be put back by hand
82
84
  const [refocusId, setRefocusId] = React.useState(null);
83
85
  const rootRef = React.useRef(null);
86
+ const colRefs = React.useRef(new Map());
87
+ // #674: which column a client point is over, read off each column's own
88
+ // rect at drag time rather than `elementFromPoint` -- the fixed-position
89
+ // ghost sits on top of everything (deliberately, so it never itself
90
+ // becomes the hit) and this is the one MEASURED interaction a test has a
91
+ // real handle on (`elementFromPoint` needs an actual layout engine bun
92
+ // test's happy-dom doesn't run).
93
+ //
94
+ // X only, not a full box: `.crisp-board`'s columns are a single
95
+ // non-wrapping row (`display: flex`, no `flex-wrap`), so X alone already
96
+ // identifies the column unambiguously. Checking Y against each column's
97
+ // OWN rect looked more precise but was actually a bug caught driving this
98
+ // in a real headed browser (Playwright), not by the tests passing --
99
+ // `align-items: flex-start` on the row means a short/empty column's own
100
+ // box ends where its content does, well short of a taller neighbour's, so
101
+ // a drop below that point silently found no column at all even while
102
+ // visually still inside the board.
103
+ const columnAt = (x) => {
104
+ for (const [id, el] of colRefs.current) {
105
+ const r = el.getBoundingClientRect();
106
+ if (x >= r.left && x <= r.right)
107
+ return id;
108
+ }
109
+ return null;
110
+ };
84
111
  const move = (cardId, toColumn) => {
85
112
  const card = cards.find((c) => c.id === cardId);
86
113
  if (!card || card.columnId === toColumn)
@@ -96,8 +123,77 @@ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, ne
96
123
  setRefocusId(null);
97
124
  }, [refocusId]);
98
125
  /**
99
- * Native HTML5 drag-and-drop is pointer-only by specification, so the board's
100
- * primary action needs its own key model or it has none at all.
126
+ * #674: Attio's own board drag is driven by pointer events (mousedown ->
127
+ * several mousemoves -> mouseup) -- confirmed live, no `[draggable]`
128
+ * anywhere -- not native HTML5 `draggable`/`onDragStart`, which is
129
+ * pointer-only by specification (no touch/pen support) and fires its own
130
+ * separate, browser-chrome-owned drag session crisp never controlled: no
131
+ * custom drag image, no way to redraw the dragged card while it moves.
132
+ * Pointer events give the board its own tracked drag state instead, the
133
+ * same primitive `resizeHandle`/`recordstable`'s own column-resize already
134
+ * use elsewhere in crisp.
135
+ *
136
+ * A drag doesn't "start" the instant the pointer goes down on a card --
137
+ * only once it has moved past a small threshold (4px). Below that, the
138
+ * gesture reads as a click/tap that never happened here (the card carries
139
+ * no click handler of its own today, but a future one would otherwise
140
+ * fire through a phantom near-zero-distance "drag").
141
+ */
142
+ const dragStateRef = React.useRef(null);
143
+ const DRAG_THRESHOLD = 4;
144
+ const onCardPointerDown = (e, card) => {
145
+ if (!e.isPrimary)
146
+ return;
147
+ const r = e.currentTarget.getBoundingClientRect();
148
+ dragStateRef.current = {
149
+ id: card.id,
150
+ pointerId: e.pointerId,
151
+ startX: e.clientX,
152
+ startY: e.clientY,
153
+ active: false,
154
+ grabDx: e.clientX - r.left,
155
+ grabDy: e.clientY - r.top,
156
+ width: r.width,
157
+ height: r.height,
158
+ };
159
+ };
160
+ const onCardPointerMove = (e) => {
161
+ const st = dragStateRef.current;
162
+ if (!st || st.pointerId !== e.pointerId)
163
+ return;
164
+ if (!st.active) {
165
+ const dx = e.clientX - st.startX;
166
+ const dy = e.clientY - st.startY;
167
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD)
168
+ return;
169
+ st.active = true;
170
+ setDragId(st.id);
171
+ e.currentTarget.setPointerCapture(e.pointerId);
172
+ }
173
+ setGhost({
174
+ x: e.clientX - st.grabDx,
175
+ y: e.clientY - st.grabDy,
176
+ width: st.width,
177
+ height: st.height,
178
+ });
179
+ setOverCol(columnAt(e.clientX));
180
+ };
181
+ const endCardDrag = (e, commit) => {
182
+ const st = dragStateRef.current;
183
+ if (!st || st.pointerId !== e.pointerId)
184
+ return;
185
+ if (commit && st.active && overCol)
186
+ move(st.id, overCol);
187
+ dragStateRef.current = null;
188
+ setDragId(null);
189
+ setOverCol(null);
190
+ setGhost(null);
191
+ };
192
+ /**
193
+ * Neither drag mechanism the board has used (the old native HTML5 kind,
194
+ * this file's own pointer-events one now) is reachable from a keyboard, so
195
+ * the board's primary action needs its own key model or it has none at
196
+ * all.
101
197
  *
102
198
  * Ctrl/Cmd + Left/Right rather than plain arrows: a screen reader owns the
103
199
  * bare arrow keys in browse mode, and a board scrolls horizontally, so plain
@@ -115,27 +211,14 @@ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, ne
115
211
  };
116
212
  return (_jsxs("div", { ref: rootRef, className: boardRecipe({ className }), ...rest, children: [columns.map((col) => {
117
213
  const colCards = cards.filter((c) => c.columnId === col.id);
118
- return (_jsxs("div", { className: "crisp-board-col", "data-over": overCol === col.id ? "" : undefined, onDragOver: (e) => {
119
- if (!dragId)
120
- return;
121
- e.preventDefault();
122
- setOverCol(col.id);
123
- }, onDragLeave: (e) => {
124
- if (!e.currentTarget.contains(e.relatedTarget)) {
125
- setOverCol((v) => (v === col.id ? null : v));
126
- }
127
- }, onDrop: (e) => {
128
- e.preventDefault();
129
- if (dragId)
130
- move(dragId, col.id);
131
- setDragId(null);
132
- setOverCol(null);
133
- }, children: [_jsxs("div", { className: "crisp-board-col-header", children: [col.accent && (_jsx("span", { className: "crisp-board-col-dot", style: { background: col.accent } })), _jsx("span", { className: "crisp-board-col-title", children: col.title }), _jsx("span", { className: "crisp-board-col-count", children: colCards.length }), onNewCard && (_jsx("button", { type: "button", className: "crisp-board-col-add", "aria-label": `${crispMessages["Add to"] ?? "Add to"} ${col.title}`, onClick: () => onNewCard(col.id), children: "+" }))] }), _jsxs("div", { className: "crisp-board-col-body", role: "list", "aria-label": col.title, children: [onNewCard && (_jsxs("button", { type: "button", className: "crisp-board-newcard", onClick: () => onNewCard(col.id), children: ["+ ", newCardLabel] })), colCards.map((card) => (_jsx("div", { className: "crisp-board-card", role: "listitem",
214
+ return (_jsxs("div", { ref: (el) => {
215
+ if (el)
216
+ colRefs.current.set(col.id, el);
217
+ else
218
+ colRefs.current.delete(col.id);
219
+ }, className: "crisp-board-col", "data-over": overCol === col.id ? "" : undefined, children: [_jsxs("div", { className: "crisp-board-col-header", children: [col.accent && (_jsx("span", { className: "crisp-board-col-dot", style: { background: col.accent } })), _jsx("span", { className: "crisp-board-col-title", children: col.title }), _jsx("span", { className: "crisp-board-col-count", children: colCards.length }), onNewCard && (_jsx("button", { type: "button", className: "crisp-board-col-add", "aria-label": `${crispMessages["Add to"] ?? "Add to"} ${col.title}`, onClick: () => onNewCard(col.id), children: "+" }))] }), _jsxs("div", { className: "crisp-board-col-body", role: "list", "aria-label": col.title, children: [onNewCard && (_jsxs("button", { type: "button", className: "crisp-board-newcard", onClick: () => onNewCard(col.id), children: ["+ ", newCardLabel] })), colCards.map((card) => (_jsx("div", { className: "crisp-board-card", role: "listitem",
134
220
  // biome-ignore lint/a11y/noNoninteractiveTabindex: the rule assumes a listitem cannot be operated. This one carries the board's only keyboard path to move a card, so it has to be reachable by Tab
135
- tabIndex: 0, "aria-label": getCardLabel?.(card), "data-card-id": card.id, draggable: true, "data-dragging": dragId === card.id ? "" : undefined, onDragStart: () => setDragId(card.id), onDragEnd: () => {
136
- setDragId(null);
137
- setOverCol(null);
138
- }, onKeyDown: (e) => {
221
+ tabIndex: 0, "aria-label": getCardLabel?.(card), "data-card-id": card.id, "data-dragging": dragId === card.id ? "" : undefined, onPointerDown: (e) => onCardPointerDown(e, card), onPointerMove: onCardPointerMove, onPointerUp: (e) => endCardDrag(e, true), onPointerCancel: (e) => endCardDrag(e, false), onKeyDown: (e) => {
139
222
  if (!(e.ctrlKey || e.metaKey))
140
223
  return;
141
224
  // the columns are a flex row, so under RTL the next one
@@ -151,6 +234,18 @@ export function Board({ columns, cards, onCardsChange, renderCard, onNewCard, ne
151
234
  }, children: renderCard(card) }, card.id)))] }), calc && (_jsx("div", { className: "crisp-board-col-footer", children: _jsx(Menu, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-board-col-calc", "data-empty": colAgg[col.id] ? undefined : "", children: colAgg[col.id] ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-board-col-calc-label", children: crispMessages[AGG_LABEL[colAgg[col.id]]] ??
152
235
  AGG_LABEL[colAgg[col.id]] }), _jsx("span", { className: "crisp-board-col-calc-value", children: computeBoardAgg(colAgg[col.id], colCards.map((c) => calc.getValue(c))) })] })) : ((crispMessages["+ Add calculation"] ??
153
236
  "+ Add calculation")) }), children: BOARD_AGG_ORDER.map((a) => (_jsxs(MenuItem, { onSelect: () => setColAgg((prev) => ({ ...prev, [col.id]: a })), children: [crispMessages[AGG_LABEL[a]] ?? AGG_LABEL[a], colAgg[col.id] === a ? (_jsx(Check, { size: 15, strokeWidth: 1.75 })) : null] }, a))) }) }))] }, col.id));
154
- }), _jsx("div", { className: "crisp-board-live", role: "status", "aria-live": "polite", children: announcement })] }));
237
+ }), dragId &&
238
+ ghost &&
239
+ (() => {
240
+ const draggedCard = cards.find((c) => c.id === dragId);
241
+ if (!draggedCard)
242
+ return null;
243
+ return (_jsx("div", { className: "crisp-board-card crisp-board-ghost", style: {
244
+ left: ghost.x,
245
+ top: ghost.y,
246
+ width: ghost.width,
247
+ height: ghost.height,
248
+ }, children: renderCard(draggedCard) }));
249
+ })(), _jsx("div", { className: "crisp-board-live", role: "status", "aria-live": "assertive", children: announcement })] }));
155
250
  }
156
251
  Board.displayName = "Board";
@@ -20,18 +20,30 @@ const config = defineRecipe({
20
20
  & .crisp-board-col-header { display: flex; align-items: center; gap: ${c.space["2"]}; height: 36px; padding: 0 ${c.space["1"]}; flex-shrink: 0; }
21
21
  & .crisp-board-col-dot { width: 12px; height: 12px; border-radius: ${c.radius.full}; flex-shrink: 0; }
22
22
  & .crisp-board-col-title { font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; color: ${c.fgPrimary}; }
23
- & .crisp-board-col-count { 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} 4%, transparent); font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
23
+ /* crisp#697: HANDOFF's #472 note claimed the count pill's
24
+ color-mix(fgPrimary, 63%, transparent) reproduced the measured
25
+ rgb(80,81,85) -- it doesn't; 63% of fgPrimary composited on white
26
+ paints rgb(104,105,105), visibly lighter. Text now reads
27
+ fgNeutralSoft directly (the same rgb(80,81,85) token Badge's neutral
28
+ tone uses, #696), and the ring the pill was also missing
29
+ (inset 0 0 0 1px borderSubtle, measured rgb(238,239,241)) is added. */
30
+ & .crisp-board-col-count { 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} 4%, transparent); box-shadow: inset 0 0 0 1px ${c.borderSubtle}; font-size: ${c.text.sm}; line-height: 16px; font-weight: ${c.weight.medium}; color: ${c.fgNeutralSoft}; }
24
31
  & .crisp-board-col-add { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin-inline-start: auto; padding: 0; border: none; border-radius: ${c.radius.sm}; background: none; color: ${c.fgMuted}; font-family: inherit; cursor: pointer; }
25
32
  & .crisp-board-col-add:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: ${c.fgPrimary}; }
26
33
  & .crisp-board-col-body { display: flex; flex-direction: column; gap: ${c.space["2"]}; flex: 1; min-height: 48px; padding: ${c.space["1"]}; border-radius: ${c.radius.lg}; overflow-y: auto; transition: background 0.12s; }
27
34
  & .crisp-board-col[data-over] .crisp-board-col-body { background: color-mix(in srgb, ${c.bgBrandSolid} 7%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.bgBrandSolid} 30%, transparent); }
28
35
  & .crisp-board-newcard { display: flex; align-items: center; justify-content: center; gap: ${c.space["1.5"]}; height: 32px; flex-shrink: 0; border: none; border-radius: ${c.radius.md}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); color: ${c.fgMuted}; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; cursor: pointer; }
29
36
  & .crisp-board-newcard:hover { background: color-mix(in srgb, ${c.fgPrimary} 7%, transparent); color: ${c.fgPrimary}; }
30
- & .crisp-board-card { display: flex; flex-direction: column; gap: ${c.space["2"]}; box-sizing: border-box; padding: ${c.space["2.5"]} ${c.space["3"]}; border-radius: ${c.radius.xl}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.borderSubtle}; cursor: grab; }
37
+ & .crisp-board-card { display: flex; flex-direction: column; gap: ${c.space["2"]}; box-sizing: border-box; padding: ${c.space["2.5"]} ${c.space["3"]}; border-radius: ${c.radius.xl}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.borderSubtle}; cursor: grab; touch-action: none; }
31
38
  & .crisp-board-card:active { cursor: grabbing; }
32
39
  & .crisp-board-card[data-dragging] { opacity: 0.4; }
33
40
  & .crisp-board-card:focus { outline: none; }
34
41
  & .crisp-board-card:focus-visible { outline: 2px solid ${c.bgBrandSolid}; outline-offset: 1px; }
42
+ /* #674: the pointer-events drag's own drag image -- the same card
43
+ surface above, just pinned to the pointer instead of the flex flow.
44
+ No shadow lift or scale: neither was measured against Attio, and this
45
+ is the same card, momentarily somewhere else, not a new look. */
46
+ & .crisp-board-ghost { position: fixed; z-index: 1; pointer-events: none; cursor: grabbing; }
35
47
  /* #604: a per-column calc footer, mirroring the table's own (measured
36
48
  live, Deals pipeline). Sits below the scrollable card body, not
37
49
  inside it or pinned to it -- Attio's own is position: static, not
@@ -266,8 +266,8 @@ const Composer = React.forwardRef(function Composer({ placeholder, postLabel, me
266
266
  close: closeMention,
267
267
  })] }));
268
268
  });
269
- function CommentRow({ comment, replyLabel, reactLabel, resolveLabel, moreLabel, copyLinkLabel, deleteLabel, addReactionLabel, reactionSearchLabel, emojiOptions, resolvable, resolved, resolvedLabel, onReply, onReact, onResolve, onCopyLink, onDeleteRequest, }) {
270
- return (_jsxs("div", { className: "crisp-comments-row", children: [_jsx(AuthorAvatar, { author: comment.author }), _jsxs("div", { className: "crisp-comments-row-main", children: [resolvedLabel != null && (_jsxs("div", { className: "crisp-comments-resolved-label", children: [_jsx(Check, { size: 12, strokeWidth: 2.5, "aria-hidden": "true" }), resolvedLabel] })), _jsxs("div", { className: "crisp-comments-row-head", children: [_jsx("span", { className: "crisp-comments-author", children: comment.author.name }), _jsx("span", { className: "crisp-comments-time", children: comment.time })] }), _jsx("div", { className: "crisp-comments-body-text", children: comment.body }), comment.reactions && comment.reactions.length > 0 && (_jsxs("div", { className: "crisp-comments-reactions", children: [comment.reactions.map((r) => (_jsxs("button", { type: "button", className: "crisp-comments-reaction", "data-reacted": r.reactedByMe ? "" : undefined, onClick: () => onReact(r.emoji), children: [_jsx("span", { "aria-hidden": "true", children: r.emoji }), r.count] }, r.emoji))), _jsx(Popover, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-comments-reaction-add", "aria-label": addReactionLabel, children: _jsx(SmilePlus, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: _jsx(ReactionPicker, { options: emojiOptions, searchLabel: reactionSearchLabel, onPick: onReact }) })] }))] }), _jsxs("div", { className: "crisp-comments-row-actions", children: [onReply && (_jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": replyLabel, onClick: onReply, children: _jsx(CornerUpLeft, { "data-rtl-flip": "", size: 16, strokeWidth: 2, "aria-hidden": "true" }) })), _jsx(Popover, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": reactLabel, children: _jsx(SmilePlus, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: _jsx(ReactionPicker, { options: emojiOptions, searchLabel: reactionSearchLabel, onPick: onReact }) }), resolvable && (_jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": resolveLabel, "aria-pressed": !!resolved, "data-active": resolved ? "" : undefined, onClick: onResolve, children: _jsx(Check, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })), _jsxs(Menu, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": moreLabel, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: [_jsx(MenuItem, { onSelect: onCopyLink, children: copyLinkLabel }), _jsx(MenuItem, { danger: true, onSelect: onDeleteRequest, children: deleteLabel })] })] })] }));
269
+ function CommentRow({ comment, replyLabel, reactLabel, resolveLabel, moreLabel, copyLinkLabel, deleteLabel, addReactionLabel, reactionSearchLabel, emojiOptions, resolvable, resolved, resolvedLabel, reactable, onReply, onReact, onResolve, onCopyLink, onDeleteRequest, }) {
270
+ return (_jsxs("div", { className: "crisp-comments-row", children: [_jsx(AuthorAvatar, { author: comment.author }), _jsxs("div", { className: "crisp-comments-row-main", children: [resolvedLabel != null && (_jsxs("div", { className: "crisp-comments-resolved-label", children: [_jsx(Check, { size: 12, strokeWidth: 2.5, "aria-hidden": "true" }), resolvedLabel] })), _jsxs("div", { className: "crisp-comments-row-head", children: [_jsx("span", { className: "crisp-comments-author", children: comment.author.name }), _jsx("span", { className: "crisp-comments-time", children: comment.time })] }), _jsx("div", { className: "crisp-comments-body-text", children: comment.body }), comment.reactions && comment.reactions.length > 0 && (_jsxs("div", { className: "crisp-comments-reactions", children: [comment.reactions.map((r) => (_jsxs("button", { type: "button", className: "crisp-comments-reaction", "data-reacted": r.reactedByMe ? "" : undefined, onClick: () => onReact?.(r.emoji), children: [_jsx("span", { "aria-hidden": "true", children: r.emoji }), r.count] }, r.emoji))), _jsx(Popover, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-comments-reaction-add", "aria-label": addReactionLabel, children: _jsx(SmilePlus, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: _jsx(ReactionPicker, { options: emojiOptions, searchLabel: reactionSearchLabel, onPick: (emoji) => onReact?.(emoji) }) })] }))] }), _jsxs("div", { className: "crisp-comments-row-actions", children: [onReply && (_jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": replyLabel, onClick: onReply, children: _jsx(CornerUpLeft, { "data-rtl-flip": "", size: 16, strokeWidth: 2, "aria-hidden": "true" }) })), reactable && (_jsx(Popover, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": reactLabel, children: _jsx(SmilePlus, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: _jsx(ReactionPicker, { options: emojiOptions, searchLabel: reactionSearchLabel, onPick: (emoji) => onReact?.(emoji) }) })), resolvable && (_jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": resolveLabel, "aria-pressed": !!resolved, "data-active": resolved ? "" : undefined, onClick: onResolve, children: _jsx(Check, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })), _jsxs(Menu, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-comments-action", "aria-label": moreLabel, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: [_jsx(MenuItem, { onSelect: onCopyLink, children: copyLinkLabel }), onDeleteRequest && (_jsx(MenuItem, { danger: true, onSelect: onDeleteRequest, children: deleteLabel }))] })] })] }));
271
271
  }
272
272
  /**
273
273
  * Per-record comment threads (crisp#625) — measured live on app.attio.com's
@@ -337,23 +337,36 @@ export function CommentsPanel({ threads, currentUserId, subject, subjectIcon, sh
337
337
  ? resolvedByYouLabel
338
338
  : (resolvedByLabel?.(activeThread.resolvedBy.name) ??
339
339
  `Resolved by ${activeThread.resolvedBy.name}`)
340
- : undefined, onReact: (emoji) => onReact(activeThread.id, activeThread.root.id, emoji), onResolve: onResolve ? () => onResolve(activeThread.id) : undefined, onCopyLink: () => copyLink(activeThread.id, activeThread.root.id), onDeleteRequest: () => setDeleteTarget({
341
- threadId: activeThread.id,
342
- commentId: activeThread.root.id,
343
- }) }), activeThread.replies && activeThread.replies.length > 0 && (_jsx("div", { className: "crisp-comments-replies", children: activeThread.replies.map((reply) => (_jsx(CommentRow, { comment: reply, currentUserId: currentUserId, replyLabel: replyActionLabel, reactLabel: reactActionLabel, resolveLabel: resolveActionLabel, moreLabel: moreActionsLabel, copyLinkLabel: copyLinkLabel, deleteLabel: deleteLabel, addReactionLabel: addReactionLabel, reactionSearchLabel: reactionSearchLabel, emojiOptions: emojiOptions, onReact: (emoji) => onReact(activeThread.id, reply.id, emoji), onCopyLink: () => copyLink(activeThread.id, reply.id), onDeleteRequest: () => setDeleteTarget({
340
+ : undefined, reactable: !!onReact, onReact: onReact
341
+ ? (emoji) => onReact(activeThread.id, activeThread.root.id, emoji)
342
+ : undefined, onResolve: onResolve ? () => onResolve(activeThread.id) : undefined, onCopyLink: () => copyLink(activeThread.id, activeThread.root.id), onDeleteRequest: onDelete
343
+ ? () => setDeleteTarget({
344
344
  threadId: activeThread.id,
345
- commentId: reply.id,
346
- }) }, reply.id))) }))] })) : visibleThreads.length === 0 && emptyLabel != null ? (_jsx("div", { className: "crisp-comments-empty", children: emptyLabel })) : (visibleThreads.map((t) => (_jsx(CommentRow, { comment: t.root, currentUserId: currentUserId, replyLabel: replyActionLabel, reactLabel: reactActionLabel, resolveLabel: resolveActionLabel, moreLabel: moreActionsLabel, copyLinkLabel: copyLinkLabel, deleteLabel: deleteLabel, addReactionLabel: addReactionLabel, reactionSearchLabel: reactionSearchLabel, emojiOptions: emojiOptions, resolvable: !!onResolve, resolved: t.resolved, resolvedLabel: t.resolved
345
+ commentId: activeThread.root.id,
346
+ })
347
+ : undefined }), activeThread.replies && activeThread.replies.length > 0 && (_jsx("div", { className: "crisp-comments-replies", children: activeThread.replies.map((reply) => (_jsx(CommentRow, { comment: reply, currentUserId: currentUserId, replyLabel: replyActionLabel, reactLabel: reactActionLabel, resolveLabel: resolveActionLabel, moreLabel: moreActionsLabel, copyLinkLabel: copyLinkLabel, deleteLabel: deleteLabel, addReactionLabel: addReactionLabel, reactionSearchLabel: reactionSearchLabel, emojiOptions: emojiOptions, reactable: !!onReact, onReact: onReact
348
+ ? (emoji) => onReact(activeThread.id, reply.id, emoji)
349
+ : undefined, onCopyLink: () => copyLink(activeThread.id, reply.id), onDeleteRequest: onDelete
350
+ ? () => setDeleteTarget({
351
+ threadId: activeThread.id,
352
+ commentId: reply.id,
353
+ })
354
+ : undefined }, reply.id))) }))] })) : visibleThreads.length === 0 && emptyLabel != null ? (_jsx("div", { className: "crisp-comments-empty", children: emptyLabel })) : (visibleThreads.map((t) => (_jsx(CommentRow, { comment: t.root, currentUserId: currentUserId, replyLabel: replyActionLabel, reactLabel: reactActionLabel, resolveLabel: resolveActionLabel, moreLabel: moreActionsLabel, copyLinkLabel: copyLinkLabel, deleteLabel: deleteLabel, addReactionLabel: addReactionLabel, reactionSearchLabel: reactionSearchLabel, emojiOptions: emojiOptions, resolvable: !!onResolve, resolved: t.resolved, resolvedLabel: t.resolved
347
355
  ? t.resolvedBy?.id === currentUserId || !t.resolvedBy
348
356
  ? resolvedByYouLabel
349
357
  : (resolvedByLabel?.(t.resolvedBy.name) ??
350
358
  `Resolved by ${t.resolvedBy.name}`)
351
- : undefined, onReply: () => setActiveThreadId(t.id), onReact: (emoji) => onReact(t.id, t.root.id, emoji), onResolve: onResolve ? () => onResolve(t.id) : undefined, onCopyLink: () => copyLink(t.id, t.root.id), onDeleteRequest: () => setDeleteTarget({ threadId: t.id, commentId: t.root.id }) }, t.id)))) }), _jsx(Composer, { placeholder: activeThread ? replyPlaceholder : composerPlaceholder, postLabel: activeThread ? replyPostLabel : postLabel, mentionTrigger: mentionTrigger, renderMentionPicker: renderMentionPicker, mentionToolLabel: composerMentionToolLabel, onSubmit: (text) => onPost(text, activeThread ? activeThread.id : null) }, activeThread ? activeThread.id : "root"), _jsxs(Dialog, { open: deleteTarget != null, onOpenChange: (open) => {
359
+ : undefined, onReply: () => setActiveThreadId(t.id), reactable: !!onReact, onReact: onReact ? (emoji) => onReact(t.id, t.root.id, emoji) : undefined, onResolve: onResolve ? () => onResolve(t.id) : undefined, onCopyLink: () => copyLink(t.id, t.root.id), onDeleteRequest: onDelete
360
+ ? () => setDeleteTarget({ threadId: t.id, commentId: t.root.id })
361
+ : undefined }, t.id)))) }), _jsx(Composer, { placeholder: activeThread ? replyPlaceholder : composerPlaceholder, postLabel: activeThread ? replyPostLabel : postLabel, mentionTrigger: mentionTrigger, renderMentionPicker: renderMentionPicker, mentionToolLabel: composerMentionToolLabel, onSubmit: (text) => onPost(text, activeThread ? activeThread.id : null) }, activeThread ? activeThread.id : "root"), _jsxs(Dialog, { open: deleteTarget != null, onOpenChange: (open) => {
352
362
  if (!open)
353
363
  setDeleteTarget(null);
354
364
  }, title: deleteConfirmTitle, children: [_jsx(DialogBody, { children: deleteConfirmBody }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", intent: "neutral", size: "md", onClick: () => setDeleteTarget(null), children: cancelLabel }), _jsx(Button, { type: "button", intent: "danger", size: "md", onClick: () => {
365
+ // `deleteTarget` can only ever be set via `onDeleteRequest`
366
+ // (above), which itself only exists when `onDelete` was
367
+ // given -- this call is never reached otherwise.
355
368
  if (deleteTarget)
356
- onDelete(deleteTarget.threadId, deleteTarget.commentId);
369
+ onDelete?.(deleteTarget.threadId, deleteTarget.commentId);
357
370
  setDeleteTarget(null);
358
371
  }, children: deleteConfirmActionLabel })] })] })] }));
359
372
  }
@@ -33,12 +33,26 @@ const config = defineRecipe({
33
33
 
34
34
  /* One comment row -- avatar 16px (Avatar size="sm"), 6px gap to the
35
35
  name/time line, 14px/600 name + 12px/500 63%-muted time, 14px/400 body
36
- two lines below. Hover actions fade in flush with the name line. */
36
+ two lines below. Hover actions fade in flush with the name line.
37
+ .crisp-comments-row-head's own height: 20px (above) is a single
38
+ text-line's worth, on the same assumption .crisp-comments-title/
39
+ .crisp-comments-breadcrumb already act on higher up in this same
40
+ recipe -- a name that doesn't fit wraps to two lines with nothing
41
+ reserving room for the second one, overflowing straight into the
42
+ body text underneath (found live: this app's own dev-bypass author
43
+ name, an email long enough to wrap, sat on top of the comment's own
44
+ body text). .crisp-comments-author gets the identical nowrap-
45
+ ellipsis treatment those two already have, plus min-width: 0 so a
46
+ flex item is actually allowed to shrink below its own text's natural
47
+ width in the first place; .crisp-comments-time gets flex-shrink: 0
48
+ so it's the name that gives, never the timestamp -- the same "one
49
+ side shrinks, the other one doesn't" split PersonChip's own name/
50
+ avatar pair already uses (crisp#668/#671). */
37
51
  & .crisp-comments-row { position: relative; display: flex; gap: ${c.space["1.5"]}; }
38
52
  & .crisp-comments-row-main { flex: 1; min-width: 0; }
39
53
  & .crisp-comments-row-head { display: flex; align-items: baseline; gap: ${c.space["1.5"]}; height: 20px; }
40
- & .crisp-comments-author { font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; color: ${c.fgPrimary}; }
41
- & .crisp-comments-time { font-size: 12px; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); white-space: nowrap; }
54
+ & .crisp-comments-author { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; color: ${c.fgPrimary}; }
55
+ & .crisp-comments-time { flex-shrink: 0; font-size: 12px; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); white-space: nowrap; }
42
56
  & .crisp-comments-resolved-label { display: flex; align-items: center; gap: ${c.space["1"]}; margin-bottom: ${c.space["1"]}; font-size: 12px; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
43
57
  & .crisp-comments-body-text { font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.regular}; color: ${c.fgPrimary}; white-space: pre-wrap; word-break: break-word; }
44
58
 
@@ -32,13 +32,19 @@ export function useFormDialogOptional(required) {
32
32
  * per-field wiring). A record needs several fields at once to be valid,
33
33
  * which is why records are not created by inline editing.
34
34
  */
35
- export function FormDialog({ title, submitLabel, onSubmit, onClose, busy, error, children, wide = false, size, createMode = false, createMoreLabel: createMoreLabelProp, cancelLabel: cancelLabelProp, discardTitle: discardTitleProp, discardConfirmLabel: discardConfirmLabelProp, discardCancelLabel: discardCancelLabelProp, className, ...rest }) {
35
+ export function FormDialog({ title, submitLabel, onSubmit, onClose, busy, error, children, wide = false, size, createMode = false, createMoreLabel: createMoreLabelProp, cancelLabel: cancelLabelProp, discardTitle: discardTitleProp, discardDescription: discardDescriptionProp, discardConfirmLabel: discardConfirmLabelProp, discardCancelLabel: discardCancelLabelProp, className, ...rest }) {
36
36
  const createMoreLabel = useCrispMessage(createMoreLabelProp, "Create more");
37
37
  const createRecordLabel = useCrispMessage(undefined, "Create record");
38
38
  const cancelLabel = useCrispMessage(cancelLabelProp, "Cancel");
39
- const discardTitle = useCrispMessage(discardTitleProp, "Discard changes? Your changes will be lost.");
39
+ // crisp#677: Attio's own confirm is two lines (a title, then a separate
40
+ // sentence), not one combined string — measured live, both quoted
41
+ // verbatim off app.attio.com's create-attribute dialog's own discard
42
+ // confirm. The buttons read "Cancel"/"Discard" there too, not crisp's
43
+ // prior "Keep editing" (a paraphrase, never independently measured).
44
+ const discardTitle = useCrispMessage(discardTitleProp, "Discard changes");
45
+ const discardDescription = useCrispMessage(discardDescriptionProp, "Are you sure you want to discard these changes?");
40
46
  const discardConfirmLabel = useCrispMessage(discardConfirmLabelProp, "Discard");
41
- const discardCancelLabel = useCrispMessage(discardCancelLabelProp, "Keep editing");
47
+ const discardCancelLabel = useCrispMessage(discardCancelLabelProp, "Cancel");
42
48
  const formId = React.useId();
43
49
  const formRef = React.useRef(null);
44
50
  const switchId = React.useId();
@@ -126,7 +132,7 @@ export function FormDialog({ title, submitLabel, onSubmit, onClose, busy, error,
126
132
  return (_jsxs(_Fragment, { children: [_jsxs(Dialog, { open: true, onOpenChange: (open) => !open && requestClose(), title: title, width: size ?? (wide ? 760 : 640), className: formdialogRecipe({ className }), ...rest, children: [_jsxs(DialogBody, { children: [_jsx(OptionalLabelsContext.Provider, { value: createMode, children: _jsx("form", { ref: formRef, id: formId, className: "crisp-formdialog-form", onSubmit: (e) => {
127
133
  e.preventDefault();
128
134
  onSubmit(createMore);
129
- }, onChange: markDirty, onInput: markDirty, onClick: markDirty, children: children }) }), error != null && _jsx("p", { className: "crisp-formdialog-error", children: error })] }), _jsx(DialogFooter, { children: createMode ? (_jsxs("div", { className: "crisp-formdialog-createfoot", children: [_jsxs("span", { className: "crisp-dialog-more", children: [_jsx(Switch, { id: switchId, checked: createMore, onCheckedChange: setCreateMore, "aria-label": createMoreLabel }), _jsx("label", { className: "crisp-formdialog-more-label", htmlFor: switchId, children: createMoreLabel })] }), _jsxs("button", { type: "submit", form: formId, className: "crisp-dialog-submit", disabled: busy, children: [createRecordLabel, _jsxs("span", { className: "crisp-dialog-submit-key", "aria-hidden": "true", children: [_jsx("kbd", { children: "\u2318" }), _jsx("kbd", { children: "\u21B5" })] })] })] })) : (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", intent: "neutral", onClick: requestClose, children: cancelLabel }), _jsx(Button, { type: "submit", form: formId, intent: "primary", loading: busy, children: submitLabel })] })) })] }), _jsx(Dialog, { open: confirmingDiscard, onOpenChange: (open) => !open && setConfirmingDiscard(false), title: discardTitle, width: 400, children: _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", intent: "neutral", onClick: () => setConfirmingDiscard(false), children: discardCancelLabel }), _jsx(Button, { type: "button", intent: "danger", onClick: () => {
135
+ }, onChange: markDirty, onInput: markDirty, onClick: markDirty, children: children }) }), error != null && _jsx("p", { className: "crisp-formdialog-error", children: error })] }), _jsx(DialogFooter, { children: createMode ? (_jsxs("div", { className: "crisp-formdialog-createfoot", children: [_jsxs("span", { className: "crisp-dialog-more", children: [_jsx(Switch, { id: switchId, checked: createMore, onCheckedChange: setCreateMore, "aria-label": createMoreLabel }), _jsx("label", { className: "crisp-formdialog-more-label", htmlFor: switchId, children: createMoreLabel })] }), _jsxs("button", { type: "submit", form: formId, className: "crisp-dialog-submit", disabled: busy, children: [createRecordLabel, _jsxs("span", { className: "crisp-dialog-submit-key", "aria-hidden": "true", children: [_jsx("kbd", { children: "\u2318" }), _jsx("kbd", { children: "\u21B5" })] })] })] })) : (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", intent: "neutral", onClick: requestClose, children: cancelLabel }), _jsx(Button, { type: "submit", form: formId, intent: "primary", loading: busy, children: submitLabel })] })) })] }), _jsx(Dialog, { open: confirmingDiscard, onOpenChange: (open) => !open && setConfirmingDiscard(false), variant: "alert", title: discardTitle, description: discardDescription, width: 440, children: _jsxs(DialogFooter, { variant: "alert", children: [_jsx(Button, { type: "button", intent: "neutral", onClick: () => setConfirmingDiscard(false), children: discardCancelLabel }), _jsx(Button, { type: "button", intent: "danger", onClick: () => {
130
136
  setConfirmingDiscard(false);
131
137
  onClose();
132
138
  }, children: discardConfirmLabel })] }) })] }));
@@ -1 +1 @@
1
- export { NavItem } from "./navitem.js";
1
+ export { NavItem, } from "./navitem.js";
@@ -1,8 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
+ import { Kbd } from "../../components/kbd/index.js";
4
+ import { Tooltip } from "../../components/tooltip/index.js";
3
5
  import { Primitive } from "../../core/index.js";
4
6
  import { navitemRecipe } from "./navitem.recipe.js";
5
- export const NavItem = React.forwardRef(({ active, icon, trailing, className, children, ...props }, ref) => {
6
- return (_jsxs(Primitive.a, { ref: ref, "data-active": active || undefined, className: navitemRecipe({ className }), ...props, children: [icon, _jsx("span", { className: "crisp-navitem-label", children: children }), trailing != null && (_jsx("span", { className: "crisp-navitem-trailing", children: trailing }))] }));
7
+ export const NavItem = React.forwardRef(({ active, icon, trailing, shortcut, className, children, ...props }, ref) => {
8
+ const row = (_jsxs(Primitive.a, { ref: ref, "data-active": active || undefined, className: navitemRecipe({ className }), ...props, children: [icon, _jsx("span", { className: "crisp-navitem-label", children: children }), trailing != null && (_jsx("span", { className: "crisp-navitem-trailing", children: trailing }))] }));
9
+ if (!shortcut)
10
+ return row;
11
+ return (_jsx(Tooltip, { asChild: true, side: "right", delay: 500, content: _jsxs("span", { className: "crisp-navitem-shortcut", children: [_jsx("span", { className: "crisp-navitem-shortcut-desc", children: shortcut.description }), _jsx("span", { className: "crisp-navitem-shortcut-chord", children: shortcut.keys.map((key, i) => (_jsxs(React.Fragment, { children: [i > 0 && (_jsx("span", { className: "crisp-navitem-shortcut-then", children: "then" })), _jsx(Kbd, { children: key })] }, i))) })] }), children: row }));
7
12
  });
8
13
  NavItem.displayName = "NavItem";
@@ -18,6 +18,22 @@ const config = defineRecipe({
18
18
  variants: {},
19
19
  defaultVariants: {},
20
20
  compoundVariants: [],
21
+ // Tooltip portals its content to document.body (same reason sidebar.recipe.ts's
22
+ // menu content lives in globalCss, not a `&`-nested rule): a shortcut hint
23
+ // never sits inside `.crisp-navitem` in the live DOM, so it needs an
24
+ // unscoped rule to be reached at all. Values measured live on
25
+ // app.attio.com's own "G then <letter>" hint (crisp#633): the description
26
+ // inherits Tooltip's own #eeeff1 text colour for free (it's the same hex),
27
+ // but "then" and the Kbd chip are their own greys, hardcoded raw rather
28
+ // than through a token -- same call tooltip.recipe.ts already made for
29
+ // this exact bubble, which is always dark regardless of the app's theme.
30
+ globalCss: `
31
+ .crisp-navitem-shortcut { display: flex; align-items: baseline; justify-content: space-between; gap: ${c.space["3"]}; }
32
+ .crisp-navitem-shortcut-desc { font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; line-height: 16px; white-space: nowrap; }
33
+ .crisp-navitem-shortcut-chord { display: inline-flex; align-items: center; gap: ${c.space["1"]}; flex-shrink: 0; }
34
+ .crisp-navitem-shortcut-then { font-size: ${c.text.xs}; font-weight: ${c.weight.medium}; color: #898a8d; }
35
+ .crisp-navitem-shortcut-chord .crisp-kbd { text-transform: uppercase; font-weight: ${c.weight.regular}; color: #a2a4a7; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2); }
36
+ `,
21
37
  });
22
38
  export const navitemRecipe = Object.assign(resolveRecipe(config), {
23
39
  config,
@@ -1,2 +1,2 @@
1
- export { ATTRIBUTE_AI_AUTOFILL_OPTIONS, ATTRIBUTE_TYPE_OPTIONS, AttributesTable, AttributeTypeSelect, CreateAttributeDialog, ObjectAppearanceSection, ObjectDetailPage, ObjectGeneralSection, ObjectSettings, ObjectsList, } from "./objectsettings.js";
1
+ export { ATTRIBUTE_AI_AUTOFILL_OPTIONS, ATTRIBUTE_TYPE_OPTIONS, AttributesTable, AttributeTypeSelect, CreateAttributeDialog, ObjectAppearanceSection, ObjectDetailPage, ObjectGeneralSection, ObjectsList, } from "./objectsettings.js";
2
2
  export { objectsettingsRecipe } from "./objectsettings.recipe.js";
@@ -114,7 +114,7 @@ export function ObjectDetailPage({ icon, iconColor, name, typeLabel, description
114
114
  setInternalTab(next);
115
115
  onTabChange?.(next);
116
116
  };
117
- return (_jsx("div", { className: objectsettingsRecipe(), children: _jsxs("div", { className: "crisp-objectsettings-page", children: [_jsx("div", { className: "crisp-objectsettings-head", children: _jsxs("div", { style: { display: "flex", alignItems: "flex-start", gap: 12 }, children: [_jsx("span", { className: "crisp-objectsettings-icon crisp-objectsettings-icon--lg", style: { background: iconColor ?? "currentColor" }, children: icon }), _jsxs("div", { className: "crisp-objectsettings-heading", children: [_jsxs("h1", { className: "crisp-objectsettings-title", children: [name, typeLabel != null && (_jsx(Badge, { tone: "neutral", size: "small", children: typeLabel }))] }), description != null && (_jsx("p", { className: "crisp-objectsettings-subtitle", children: description }))] })] }) }), _jsx(Tabs, { value: active, onValueChange: setTab, tabs: [
117
+ return (_jsx("div", { className: objectsettingsRecipe(), children: _jsxs("div", { className: "crisp-objectsettings-page", children: [_jsx("div", { className: "crisp-objectsettings-head", children: _jsxs("div", { style: { display: "flex", alignItems: "flex-start", gap: 12 }, children: [_jsx("span", { className: "crisp-objectsettings-icon crisp-objectsettings-icon--lg", style: { background: iconColor ?? "currentColor" }, children: icon }), _jsxs("div", { className: "crisp-objectsettings-heading", children: [_jsxs("h1", { className: "crisp-objectsettings-title", children: [name, typeLabel != null && (_jsx(Badge, { tone: "neutral", size: "medium", children: typeLabel }))] }), description != null && (_jsx("p", { className: "crisp-objectsettings-subtitle", children: description }))] })] }) }), _jsx(Tabs, { value: active, onValueChange: setTab, tabs: [
118
118
  { value: "configuration", label: configurationLabel },
119
119
  {
120
120
  value: "attributes",
@@ -202,7 +202,7 @@ export function AttributesTable({ attributes, onOpenAttribute, onEditAttribute,
202
202
  {
203
203
  key: "properties",
204
204
  header: propertiesColumnLabel,
205
- render: (_v, row) => (_jsxs("span", { style: { display: "flex", gap: 6 }, children: [row.system && (_jsx(Badge, { tone: "neutral", size: "small", children: systemLabel })), row.enriched && (_jsx(Badge, { tone: "purple", size: "small", children: enrichedLabel }))] })),
205
+ render: (_v, row) => (_jsxs("span", { style: { display: "flex", gap: 6 }, children: [row.system && (_jsx(Badge, { tone: "neutral", size: "medium", children: systemLabel })), row.enriched && (_jsx(Badge, { tone: "purple", size: "medium", children: enrichedLabel }))] })),
206
206
  },
207
207
  {
208
208
  key: "actions",
@@ -310,13 +310,3 @@ export function CreateAttributeDialog({ type, onTypeChange, typeOptions, aiAutof
310
310
  return (_jsxs(FormDialog, { title: title, submitLabel: _jsxs(_Fragment, { children: [createLabel, _jsxs("span", { className: "crisp-objectsettings-kbd", "aria-hidden": "true", children: [_jsx("kbd", { children: "\u2318" }), _jsx("kbd", { children: "\u21B5" })] })] }), onSubmit: onSubmit, onClose: onClose, busy: busy, error: error, size: 520, className: objectsettingsRecipe(), children: [_jsx(FormField, { label: attributeTypeLabel, children: _jsx(AttributeTypeSelect, { value: type, onValueChange: onTypeChange, options: typeOptions, aiAutofillOptions: aiAutofillTypeOptions }) }), _jsx(FormField, { label: nameLabel, htmlFor: nameId, children: _jsx(Input, { id: nameId, autoFocus: true, value: name, onChange: (e) => onNameChange(e.target.value) }) }), _jsx(FormField, { label: descriptionLabel, optional: true, children: _jsx(Textarea, { placeholder: descriptionPlaceholder, value: description, onChange: (e) => onDescriptionChange(e.target.value) }) }), _jsxs("div", { className: "crisp-objectsettings-aiautofill", children: [_jsx("span", { className: "crisp-objectsettings-aiautofill-label", children: aiAutofillLabel }), _jsxs("label", { className: "crisp-objectsettings-aiautofill-row", htmlFor: switchId, children: [_jsx(Sparkles, { size: 14 }), _jsx("span", { children: setUpAiAutofillLabel }), _jsx(Switch, { id: switchId, checked: aiAutofillEnabled, onCheckedChange: onAiAutofillChange })] })] })] }));
311
311
  }
312
312
  CreateAttributeDialog.displayName = "CreateAttributeDialog";
313
- /**
314
- * This folder holds several components (`ObjectsList`, `ObjectDetailPage`,
315
- * `AttributesTable`, `CreateAttributeDialog`, …), not one named after the
316
- * slug — unlike every other product dir, whose docs `<ManualInstallation>`
317
- * resolves an import binding from the folder slug ("avatargroup" →
318
- * `AvatarGroup`) by reading this file's exports. This alias exists only so
319
- * that resolver finds a match for "objectsettings"; consumers should import
320
- * the real names above, never this one.
321
- */
322
- export const ObjectSettings = ObjectsList;