@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.
- package/dist/board.css +13 -1
- package/dist/board.layered.css +13 -1
- package/dist/chip.css +7 -1
- package/dist/chip.d.ts +2 -1
- package/dist/chip.layered.css +7 -1
- package/dist/cjs/components/chip/chip.js +3 -2
- package/dist/cjs/components/chip/chip.recipe.js +37 -1
- package/dist/cjs/components/datatable/datatable.js +489 -111
- package/dist/cjs/components/emptystate/emptystate.js +3 -2
- package/dist/cjs/components/emptystate/emptystate.recipe.js +26 -1
- package/dist/cjs/components/personchip/index.js +17 -0
- package/dist/cjs/components/personchip/personchip.js +50 -0
- package/dist/cjs/components/table/table.js +2 -2
- package/dist/cjs/components/tabletoolbar/tabletoolbar.js +41 -4
- package/dist/cjs/components/tabs/tabs.js +2 -2
- package/dist/cjs/components/tabs/tabs.recipe.js +30 -3
- package/dist/cjs/components/text/index.js +7 -0
- package/dist/cjs/components/text/text.js +55 -0
- package/dist/cjs/components/text/text.recipe.js +34 -0
- package/dist/cjs/index.js +4 -0
- package/dist/cjs/product/board/board.js +60 -2
- package/dist/cjs/product/board/board.recipe.js +10 -0
- package/dist/cjs/product/comments/comments.js +426 -0
- package/dist/cjs/product/comments/comments.recipe.js +126 -0
- package/dist/cjs/product/comments/index.js +8 -0
- package/dist/cjs/product/headerbar/headerbar.recipe.js +9 -1
- package/dist/cjs/product/listpage/listpage.recipe.js +10 -1
- package/dist/cjs/product/objectsettings/index.js +16 -0
- package/dist/cjs/product/objectsettings/objectsettings.js +365 -0
- package/dist/cjs/product/objectsettings/objectsettings.recipe.js +65 -0
- package/dist/cjs/product/recordpage/recordpage.recipe.js +10 -1
- package/dist/cjs/product/recordstable/recordstable.js +89 -6
- package/dist/cjs/product/richtext/richtext.recipe.js +26 -1
- package/dist/cjs/product/taskstable/taskstable.recipe.js +10 -1
- package/dist/comments.css +80 -0
- package/dist/comments.d.ts +7 -0
- package/dist/comments.layered.css +82 -0
- package/dist/emptystate.css +15 -1
- package/dist/emptystate.d.ts +2 -2
- package/dist/emptystate.layered.css +15 -1
- package/dist/esm/components/chip/chip.js +3 -2
- package/dist/esm/components/chip/chip.recipe.js +37 -1
- package/dist/esm/components/datatable/datatable.js +488 -111
- package/dist/esm/components/emptystate/emptystate.js +4 -3
- package/dist/esm/components/emptystate/emptystate.recipe.js +26 -1
- package/dist/esm/components/emptystate/index.js +1 -1
- package/dist/esm/components/personchip/index.js +1 -0
- package/dist/esm/components/personchip/personchip.js +47 -0
- package/dist/esm/components/table/table.js +2 -2
- package/dist/esm/components/tabletoolbar/tabletoolbar.js +43 -6
- package/dist/esm/components/tabs/tabs.js +2 -2
- package/dist/esm/components/tabs/tabs.recipe.js +30 -3
- package/dist/esm/components/text/index.js +2 -0
- package/dist/esm/components/text/text.js +19 -0
- package/dist/esm/components/text/text.recipe.js +31 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/product/board/board.js +61 -3
- package/dist/esm/product/board/board.recipe.js +10 -0
- package/dist/esm/product/comments/comments.js +388 -0
- package/dist/esm/product/comments/comments.recipe.js +123 -0
- package/dist/esm/product/comments/index.js +2 -0
- package/dist/esm/product/headerbar/headerbar.recipe.js +9 -1
- package/dist/esm/product/listpage/listpage.recipe.js +10 -1
- package/dist/esm/product/objectsettings/index.js +2 -0
- package/dist/esm/product/objectsettings/objectsettings.js +322 -0
- package/dist/esm/product/objectsettings/objectsettings.recipe.js +62 -0
- package/dist/esm/product/recordpage/recordpage.recipe.js +10 -1
- package/dist/esm/product/recordstable/recordstable.js +90 -8
- package/dist/esm/product/richtext/richtext.recipe.js +26 -1
- package/dist/esm/product/taskstable/taskstable.recipe.js +10 -1
- package/dist/headerbar.css +1 -1
- package/dist/headerbar.layered.css +1 -1
- package/dist/listpage.css +8 -1
- package/dist/listpage.layered.css +8 -1
- package/dist/objectsettings.css +49 -0
- package/dist/objectsettings.d.ts +7 -0
- package/dist/objectsettings.layered.css +51 -0
- package/dist/recordpage.css +13 -2
- package/dist/recordpage.layered.css +13 -2
- package/dist/richtext.css +11 -1
- package/dist/richtext.layered.css +11 -1
- package/dist/styles.css +224 -10
- package/dist/styles.layered.css +224 -10
- package/dist/tabs.css +7 -1
- package/dist/tabs.d.ts +2 -2
- package/dist/tabs.layered.css +7 -1
- package/dist/taskstable.css +8 -1
- package/dist/taskstable.layered.css +8 -1
- package/dist/text.css +9 -0
- package/dist/text.d.ts +7 -0
- package/dist/text.layered.css +11 -0
- package/dist/types/components/chip/chip.d.ts +32 -0
- package/dist/types/components/chip/chip.recipe.d.ts +31 -0
- package/dist/types/components/datatable/datatable.d.ts +28 -4
- package/dist/types/components/emptystate/emptystate.d.ts +29 -4
- package/dist/types/components/emptystate/emptystate.recipe.d.ts +2 -0
- package/dist/types/components/emptystate/index.d.ts +1 -1
- package/dist/types/components/personchip/index.d.ts +1 -0
- package/dist/types/components/personchip/personchip.d.ts +37 -0
- package/dist/types/components/table/table.d.ts +36 -0
- package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +8 -2
- package/dist/types/components/tabs/tabs.d.ts +4 -2
- package/dist/types/components/tabs/tabs.recipe.d.ts +18 -3
- package/dist/types/components/text/index.d.ts +2 -0
- package/dist/types/components/text/text.d.ts +32 -0
- package/dist/types/components/text/text.recipe.d.ts +40 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/product/board/board.d.ts +20 -1
- package/dist/types/product/comments/comments.d.ts +192 -0
- package/dist/types/product/comments/comments.recipe.d.ts +3 -0
- package/dist/types/product/comments/index.d.ts +2 -0
- package/dist/types/product/objectsettings/index.d.ts +2 -0
- package/dist/types/product/objectsettings/objectsettings.d.ts +261 -0
- package/dist/types/product/objectsettings/objectsettings.recipe.d.ts +3 -0
- package/dist/types/product/recordstable/recordstable.d.ts +24 -2
- package/package.json +21 -1
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ArrowUpRight, Check, ChevronLeft, CornerUpLeft, ListFilter, MessageSquare, MoreHorizontal, SmilePlus, X, } from "lucide-react";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Avatar } from "../../components/avatar/index.js";
|
|
5
|
+
import { Button } from "../../components/button/index.js";
|
|
6
|
+
import { Dialog, DialogBody, DialogFooter } from "../../components/dialog/index.js";
|
|
7
|
+
import { Menu, MenuCheckboxItem, MenuItem } from "../../components/menu/index.js";
|
|
8
|
+
import { Popover } from "../../components/popover/index.js";
|
|
9
|
+
import { matchMentionTrigger, useCrispMessage, } from "../../core/index.js";
|
|
10
|
+
import { commentsRecipe } from "./comments.recipe.js";
|
|
11
|
+
/** A single literal JSX occurrence for the reply glyph, used at two sizes --
|
|
12
|
+
* so the RTL-mirroring sweep (which counts the flip-marker attribute against
|
|
13
|
+
* source-text occurrences, not runtime renders) sees one mark for one
|
|
14
|
+
* direction-bearing icon, not two. */
|
|
15
|
+
function ReplyGlyph({ size }) {
|
|
16
|
+
return (_jsx(CornerUpLeft, { "data-rtl-flip": "", size: size, strokeWidth: 2, "aria-hidden": "true" }));
|
|
17
|
+
}
|
|
18
|
+
const DEFAULT_EMOJI = [
|
|
19
|
+
"👍",
|
|
20
|
+
"👎",
|
|
21
|
+
"😀",
|
|
22
|
+
"😂",
|
|
23
|
+
"😍",
|
|
24
|
+
"🎉",
|
|
25
|
+
"🙏",
|
|
26
|
+
"👏",
|
|
27
|
+
"🔥",
|
|
28
|
+
"❤️",
|
|
29
|
+
"✅",
|
|
30
|
+
"👀",
|
|
31
|
+
"🚀",
|
|
32
|
+
"💯",
|
|
33
|
+
"😢",
|
|
34
|
+
"🤔",
|
|
35
|
+
];
|
|
36
|
+
function initial(name) {
|
|
37
|
+
return (name.trim().charAt(0) || "?").toUpperCase();
|
|
38
|
+
}
|
|
39
|
+
function AuthorAvatar({ author }) {
|
|
40
|
+
if (author.avatar != null)
|
|
41
|
+
return _jsx(_Fragment, { children: author.avatar });
|
|
42
|
+
return (_jsx(Avatar, { size: "sm", tone: "cyan", children: initial(author.name) }));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A minimal reaction picker — search box + a curated emoji grid. Attio's own
|
|
46
|
+
* picker (`Search Emojis`, categorised sections) wasn't independently
|
|
47
|
+
* pixel-measured this pass: the live CDP session's `Page.captureScreenshot`
|
|
48
|
+
* started timing out after the panel/composer/row measurements were already
|
|
49
|
+
* captured, and the popover itself closed on every retry before a shadow/
|
|
50
|
+
* radius read landed. The chrome below reuses crisp's own already-measured
|
|
51
|
+
* floating-popover shell (`radius.xl`, `elevation.floating`-family shadow —
|
|
52
|
+
* see `Menu`) rather than inventing new values, and the search placeholder
|
|
53
|
+
* ("Search Emojis") IS the live-read copy. The emoji SET is crisp's own
|
|
54
|
+
* curated 16, not Attio's full category grid — a full emoji library is out
|
|
55
|
+
* of scope for a dependency-free kit; swap `emojiOptions` for a real one.
|
|
56
|
+
*/
|
|
57
|
+
function ReactionPicker({ options, searchLabel, onPick, }) {
|
|
58
|
+
const [query, setQuery] = React.useState("");
|
|
59
|
+
const filtered = query.trim()
|
|
60
|
+
? options.filter((e) => e.includes(query.trim()))
|
|
61
|
+
: options;
|
|
62
|
+
return (_jsxs("div", { className: "crisp-comments-reactionpicker", children: [_jsx("input", { className: "crisp-comments-reactionpicker-search", placeholder: searchLabel, "aria-label": searchLabel, value: query, onChange: (e) => setQuery(e.target.value) }), _jsx("div", { className: "crisp-comments-reactionpicker-grid", children: filtered.map((emoji) => (_jsx("button", { type: "button", className: "crisp-comments-reactionpicker-item", onClick: () => onPick(emoji), children: emoji }, emoji))) })] }));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Reads the composer field's DOM into plain text, serializing each mention
|
|
66
|
+
* chip as `@[label](id)` — a fixed, parseable token a consumer (GRC) can
|
|
67
|
+
* pull the mentioned record's id back out of.
|
|
68
|
+
*
|
|
69
|
+
* This is the actual fix for a real bug (GRC's own composer, a plain
|
|
70
|
+
* `<textarea>` holding `@[name](actor:id)` as its literal editable value:
|
|
71
|
+
* a caret placed inside that markup can edit it like any other character,
|
|
72
|
+
* so a stray keystroke corrupts the token and the broken markup posts
|
|
73
|
+
* verbatim — seen live, `/private/tmp/grc-land138/mentions-6-posted.png`).
|
|
74
|
+
* A mention here is NEVER the editable representation: it's a real DOM node
|
|
75
|
+
* (`contentEditable=false`, so a browser won't place a caret inside it to
|
|
76
|
+
* begin with) carrying its id/label in two `data-` attributes set once, at
|
|
77
|
+
* insertion (`insertMention`), and never touched again. This function reads
|
|
78
|
+
* those attributes — not the chip's own `.textContent` — specifically so
|
|
79
|
+
* that even if something DID manage to mutate the chip's interior (a
|
|
80
|
+
* hostile paste, an IME composition, a direct DOM call bypassing the
|
|
81
|
+
* `contentEditable=false` guard entirely — exactly the class of thing a
|
|
82
|
+
* real browser mostly prevents but a test, or a buggy extension, would
|
|
83
|
+
* not), the token this function emits still can't be corrupted: it was
|
|
84
|
+
* never read from the part that changed.
|
|
85
|
+
*/
|
|
86
|
+
export function serializeComposerField(field) {
|
|
87
|
+
let out = "";
|
|
88
|
+
for (const node of Array.from(field.childNodes)) {
|
|
89
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
90
|
+
out += node.textContent ?? "";
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
94
|
+
continue;
|
|
95
|
+
const el = node;
|
|
96
|
+
if (el.classList.contains("crisp-comments-mention")) {
|
|
97
|
+
const id = el.dataset.mentionId ?? "";
|
|
98
|
+
const label = el.dataset.mentionLabel ?? "";
|
|
99
|
+
out += `@[${label}](${id})`;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (el.tagName === "BR") {
|
|
103
|
+
out += "\n";
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// A wrapper the browser itself inserted (Enter's own new-line block,
|
|
107
|
+
// Shift+Enter's, a pasted `<span>`) -- recurse so a mention nested
|
|
108
|
+
// inside one still serializes atomically instead of falling through to
|
|
109
|
+
// a flattened `.textContent` read that would re-expose its raw text.
|
|
110
|
+
out += serializeComposerField(el);
|
|
111
|
+
if (el.tagName === "DIV" || el.tagName === "P")
|
|
112
|
+
out += "\n";
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The bottom composer — a plain `contentEditable` field (no rich-text
|
|
118
|
+
* toolbar; Attio's own comment composer has none, unlike its note editor),
|
|
119
|
+
* with an "@" mention trigger mirroring `RichTextEditor`'s own detection
|
|
120
|
+
* (`matchMentionTrigger` + a live `Range`), and a `Comment`/`Reply` button
|
|
121
|
+
* disabled until the field holds text. Uncontrolled — reads its own DOM on
|
|
122
|
+
* submit, the same shape `RichTextEditor` uses for its body.
|
|
123
|
+
*/
|
|
124
|
+
const Composer = React.forwardRef(function Composer({ placeholder, postLabel, mentionTrigger, renderMentionPicker, replyToolLabel, mentionToolLabel, onSubmit, }, ref) {
|
|
125
|
+
const fieldRef = React.useRef(null);
|
|
126
|
+
const [empty, setEmpty] = React.useState(true);
|
|
127
|
+
const [mention, setMention] = React.useState(null);
|
|
128
|
+
const mentionRangeRef = React.useRef(null);
|
|
129
|
+
const syncEmpty = () => setEmpty((fieldRef.current?.textContent ?? "") === "");
|
|
130
|
+
const closeMention = React.useCallback(() => {
|
|
131
|
+
mentionRangeRef.current = null;
|
|
132
|
+
setMention(null);
|
|
133
|
+
}, []);
|
|
134
|
+
const updateMention = React.useCallback(() => {
|
|
135
|
+
const field = fieldRef.current;
|
|
136
|
+
const sel = window.getSelection();
|
|
137
|
+
if (!field ||
|
|
138
|
+
!sel?.isCollapsed ||
|
|
139
|
+
sel.rangeCount === 0 ||
|
|
140
|
+
!field.contains(sel.anchorNode)) {
|
|
141
|
+
closeMention();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const range = sel.getRangeAt(0);
|
|
145
|
+
const node = range.startContainer;
|
|
146
|
+
if (node.nodeType !== Node.TEXT_NODE) {
|
|
147
|
+
closeMention();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const textBefore = (node.textContent ?? "").slice(0, range.startOffset);
|
|
151
|
+
const match = matchMentionTrigger(textBefore, mentionTrigger);
|
|
152
|
+
if (!match) {
|
|
153
|
+
closeMention();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const triggerRange = document.createRange();
|
|
157
|
+
triggerRange.setStart(node, match.start);
|
|
158
|
+
triggerRange.setEnd(node, range.startOffset);
|
|
159
|
+
mentionRangeRef.current = triggerRange;
|
|
160
|
+
const rect = triggerRange.getBoundingClientRect();
|
|
161
|
+
setMention({
|
|
162
|
+
query: match.query,
|
|
163
|
+
anchor: { x: rect.left, y: rect.bottom },
|
|
164
|
+
});
|
|
165
|
+
}, [mentionTrigger, closeMention]);
|
|
166
|
+
const insertMention = (item) => {
|
|
167
|
+
const field = fieldRef.current;
|
|
168
|
+
const sel = window.getSelection();
|
|
169
|
+
if (!field || !sel)
|
|
170
|
+
return;
|
|
171
|
+
field.focus();
|
|
172
|
+
const range = mentionRangeRef.current ??
|
|
173
|
+
(sel.rangeCount > 0 ? sel.getRangeAt(0) : null);
|
|
174
|
+
if (!range)
|
|
175
|
+
return;
|
|
176
|
+
const chip = document.createElement("span");
|
|
177
|
+
chip.className = "crisp-comments-mention";
|
|
178
|
+
chip.contentEditable = "false";
|
|
179
|
+
// An immutable snapshot, read back only from these two attributes at
|
|
180
|
+
// submit time -- never from the chip's own live text (see `serialize`'s
|
|
181
|
+
// own doc comment for why).
|
|
182
|
+
chip.dataset.mentionId = item.id;
|
|
183
|
+
chip.dataset.mentionLabel = item.label;
|
|
184
|
+
chip.textContent = `${mentionTrigger}${item.label}`;
|
|
185
|
+
range.deleteContents();
|
|
186
|
+
range.insertNode(chip);
|
|
187
|
+
const space = document.createTextNode(" ");
|
|
188
|
+
chip.after(space);
|
|
189
|
+
const after = document.createRange();
|
|
190
|
+
after.setStartAfter(space);
|
|
191
|
+
after.collapse(true);
|
|
192
|
+
sel.removeAllRanges();
|
|
193
|
+
sel.addRange(after);
|
|
194
|
+
closeMention();
|
|
195
|
+
syncEmpty();
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* Delete a mention chip as one unit, the same "Backspace removes it whole"
|
|
199
|
+
* behaviour a real browser already gives a `contentEditable=false` node --
|
|
200
|
+
* made explicit rather than relied on, since that native behaviour isn't
|
|
201
|
+
* consistent enough across engines to trust alone. Only fires when the
|
|
202
|
+
* caret sits immediately after a chip with nothing selected; a caret
|
|
203
|
+
* anywhere else (including genuinely inside a chip, which a real
|
|
204
|
+
* `contentEditable=false` node refuses to place one in) is untouched.
|
|
205
|
+
*/
|
|
206
|
+
const backspaceMention = (e) => {
|
|
207
|
+
const field = fieldRef.current;
|
|
208
|
+
const sel = window.getSelection();
|
|
209
|
+
if (!field || !sel?.isCollapsed || sel.rangeCount === 0)
|
|
210
|
+
return false;
|
|
211
|
+
const range = sel.getRangeAt(0);
|
|
212
|
+
let mention = null;
|
|
213
|
+
if (range.startContainer.nodeType === Node.TEXT_NODE) {
|
|
214
|
+
if (range.startOffset === 0) {
|
|
215
|
+
const prev = range.startContainer.previousSibling;
|
|
216
|
+
if (prev instanceof HTMLElement)
|
|
217
|
+
mention = prev;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (range.startContainer === field) {
|
|
221
|
+
const prev = field.childNodes[range.startOffset - 1];
|
|
222
|
+
if (prev instanceof HTMLElement)
|
|
223
|
+
mention = prev;
|
|
224
|
+
}
|
|
225
|
+
if (!mention?.classList.contains("crisp-comments-mention"))
|
|
226
|
+
return false;
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
mention.remove();
|
|
229
|
+
syncEmpty();
|
|
230
|
+
return true;
|
|
231
|
+
};
|
|
232
|
+
const clear = () => {
|
|
233
|
+
if (fieldRef.current)
|
|
234
|
+
fieldRef.current.innerHTML = "";
|
|
235
|
+
setEmpty(true);
|
|
236
|
+
closeMention();
|
|
237
|
+
};
|
|
238
|
+
React.useImperativeHandle(ref, () => ({
|
|
239
|
+
clear,
|
|
240
|
+
focus: () => fieldRef.current?.focus(),
|
|
241
|
+
}));
|
|
242
|
+
const submit = () => {
|
|
243
|
+
const field = fieldRef.current;
|
|
244
|
+
const text = field ? serializeComposerField(field).trim() : "";
|
|
245
|
+
if (!text)
|
|
246
|
+
return;
|
|
247
|
+
onSubmit(text);
|
|
248
|
+
clear();
|
|
249
|
+
};
|
|
250
|
+
return (_jsxs("div", { className: "crisp-comments-composer", children: [_jsx("div", { ref: fieldRef, className: "crisp-comments-field", contentEditable: true, suppressContentEditableWarning: true, role: "textbox", tabIndex: 0, "aria-multiline": "true", "aria-label": placeholder, "data-placeholder": placeholder, onInput: () => {
|
|
251
|
+
syncEmpty();
|
|
252
|
+
updateMention();
|
|
253
|
+
}, onKeyUp: updateMention, onMouseUp: updateMention, onKeyDown: (e) => {
|
|
254
|
+
if (e.key === "Backspace" && backspaceMention(e))
|
|
255
|
+
return;
|
|
256
|
+
if (e.key === "Enter" && !e.shiftKey && !mention) {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
submit();
|
|
259
|
+
}
|
|
260
|
+
} }), _jsxs("div", { className: "crisp-comments-composer-foot", children: [_jsxs("div", { className: "crisp-comments-composer-tools", children: [_jsx("button", { type: "button", className: "crisp-comments-tool", "aria-label": replyToolLabel, children: _jsx(ReplyGlyph, { size: 14 }) }), _jsx("button", { type: "button", className: "crisp-comments-tool", "aria-label": mentionToolLabel, onClick: () => {
|
|
261
|
+
const field = fieldRef.current;
|
|
262
|
+
if (!field)
|
|
263
|
+
return;
|
|
264
|
+
field.focus();
|
|
265
|
+
document.execCommand("insertText", false, mentionTrigger);
|
|
266
|
+
syncEmpty();
|
|
267
|
+
updateMention();
|
|
268
|
+
}, children: "@" })] }), _jsx("button", { type: "button", className: "crisp-comments-post", disabled: empty, onClick: submit, children: postLabel })] }), mention &&
|
|
269
|
+
renderMentionPicker?.({
|
|
270
|
+
query: mention.query,
|
|
271
|
+
anchor: mention.anchor,
|
|
272
|
+
select: insertMention,
|
|
273
|
+
close: closeMention,
|
|
274
|
+
})] }));
|
|
275
|
+
});
|
|
276
|
+
function CommentRow({ comment, replyLabel, reactLabel, resolveLabel, moreLabel, copyLinkLabel, deleteLabel, addReactionLabel, reactionSearchLabel, emojiOptions, resolvable, resolved, resolvedLabel, onReply, onReact, onResolve, onCopyLink, onDeleteRequest, }) {
|
|
277
|
+
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(ReplyGlyph, { size: 16 }) })), _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 })] })] })] }));
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Per-record comment threads (crisp#625) — measured live on app.attio.com's
|
|
281
|
+
* own right-hand comments panel: a header (title, filter menu), a scrolling
|
|
282
|
+
* thread list or a single narrowed thread, and a persistent bottom composer.
|
|
283
|
+
* Controlled: pass `threads` and wire the callbacks; the panel renders
|
|
284
|
+
* nothing on its own beyond that data. See `CommentCountBadge` for the
|
|
285
|
+
* separate row affordance (P-051) — deliberately NOT part of this
|
|
286
|
+
* component, since it lives in a table cell this component doesn't own.
|
|
287
|
+
*/
|
|
288
|
+
export function CommentsPanel({ threads, currentUserId, subject, subjectIcon, showResolved = false, onShowResolvedChange, onlyMine = false, onOnlyMineChange, activeThreadId: activeThreadIdProp, onActiveThreadChange, onPost, onReact, onResolve, onDelete, onCopyLink, emojiOptions = DEFAULT_EMOJI, mentionTrigger = "@", renderMentionPicker, onClose, closeLabel: closeLabelProp, backLabel: backLabelProp, title: titleProp, threadTitle: threadTitleProp, breadcrumbPrefix: breadcrumbPrefixProp, composerPlaceholder: composerPlaceholderProp, replyPlaceholder: replyPlaceholderProp, postLabel: postLabelProp, replyPostLabel: replyPostLabelProp, emptyLabel, filterLabel: filterLabelProp, showResolvedLabel: showResolvedLabelProp, onlyMineLabel: onlyMineLabelProp, replyActionLabel: replyActionLabelProp, reactActionLabel: reactActionLabelProp, resolveActionLabel: resolveActionLabelProp, resolvedByYouLabel: resolvedByYouLabelProp, resolvedByLabel, moreActionsLabel: moreActionsLabelProp, copyLinkLabel: copyLinkLabelProp, deleteLabel: deleteLabelProp, addReactionLabel: addReactionLabelProp, reactionSearchLabel: reactionSearchLabelProp, deleteConfirmTitle: deleteConfirmTitleProp, deleteConfirmBody: deleteConfirmBodyProp, deleteConfirmActionLabel: deleteConfirmActionLabelProp, cancelLabel: cancelLabelProp, composerReplyToolLabel: composerReplyToolLabelProp, composerMentionToolLabel: composerMentionToolLabelProp, className, ...rest }) {
|
|
289
|
+
const closeLabel = useCrispMessage(closeLabelProp, "Close");
|
|
290
|
+
const backLabel = useCrispMessage(backLabelProp, "Back");
|
|
291
|
+
const title = useCrispMessage(titleProp, "All comments");
|
|
292
|
+
const threadTitle = useCrispMessage(threadTitleProp, "Comments");
|
|
293
|
+
const breadcrumbPrefix = useCrispMessage(breadcrumbPrefixProp, "Comment on");
|
|
294
|
+
const composerPlaceholder = useCrispMessage(composerPlaceholderProp, "Add comment...");
|
|
295
|
+
const replyPlaceholder = useCrispMessage(replyPlaceholderProp, "Reply...");
|
|
296
|
+
const postLabel = useCrispMessage(postLabelProp, "Comment");
|
|
297
|
+
const replyPostLabel = useCrispMessage(replyPostLabelProp, "Reply");
|
|
298
|
+
const filterLabel = useCrispMessage(filterLabelProp, "Filter comments");
|
|
299
|
+
const showResolvedLabel = useCrispMessage(showResolvedLabelProp, "Show resolved comments");
|
|
300
|
+
const onlyMineLabel = useCrispMessage(onlyMineLabelProp, "Only my threads");
|
|
301
|
+
const replyActionLabel = useCrispMessage(replyActionLabelProp, "Reply to comment");
|
|
302
|
+
const reactActionLabel = useCrispMessage(reactActionLabelProp, "Pick emoji");
|
|
303
|
+
const resolveActionLabel = useCrispMessage(resolveActionLabelProp, "Resolve comment");
|
|
304
|
+
const resolvedByYouLabel = useCrispMessage(resolvedByYouLabelProp, "Resolved by you");
|
|
305
|
+
const moreActionsLabel = useCrispMessage(moreActionsLabelProp, "More actions");
|
|
306
|
+
const copyLinkLabel = useCrispMessage(copyLinkLabelProp, "Copy link to comment");
|
|
307
|
+
const deleteLabel = useCrispMessage(deleteLabelProp, "Delete comment");
|
|
308
|
+
const addReactionLabel = useCrispMessage(addReactionLabelProp, "Add reaction");
|
|
309
|
+
const reactionSearchLabel = useCrispMessage(reactionSearchLabelProp, "Search Emojis");
|
|
310
|
+
const deleteConfirmTitle = useCrispMessage(deleteConfirmTitleProp, "Delete comment");
|
|
311
|
+
const deleteConfirmBody = useCrispMessage(deleteConfirmBodyProp, "You're about to delete this comment. Once deleted it cannot be restored.");
|
|
312
|
+
const deleteConfirmActionLabel = useCrispMessage(deleteConfirmActionLabelProp, "Delete");
|
|
313
|
+
const cancelLabel = useCrispMessage(cancelLabelProp, "Cancel");
|
|
314
|
+
const composerReplyToolLabel = useCrispMessage(composerReplyToolLabelProp, "Reply");
|
|
315
|
+
const composerMentionToolLabel = useCrispMessage(composerMentionToolLabelProp, "Mention someone");
|
|
316
|
+
const [internalActiveId, setInternalActiveId] = React.useState(null);
|
|
317
|
+
const activeThreadId = activeThreadIdProp ?? internalActiveId;
|
|
318
|
+
const setActiveThreadId = (id) => {
|
|
319
|
+
if (activeThreadIdProp === undefined)
|
|
320
|
+
setInternalActiveId(id);
|
|
321
|
+
onActiveThreadChange?.(id);
|
|
322
|
+
};
|
|
323
|
+
const [deleteTarget, setDeleteTarget] = React.useState(null);
|
|
324
|
+
const copyLink = (threadId, commentId) => {
|
|
325
|
+
if (onCopyLink) {
|
|
326
|
+
onCopyLink(threadId, commentId);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const url = `${typeof window !== "undefined" ? window.location.href.split("#")[0] : ""}#comment-${commentId}`;
|
|
330
|
+
navigator.clipboard?.writeText(url).catch(() => { });
|
|
331
|
+
};
|
|
332
|
+
const visibleThreads = threads.filter((t) => {
|
|
333
|
+
if (!showResolved && t.resolved)
|
|
334
|
+
return false;
|
|
335
|
+
if (onlyMine && !t.mine)
|
|
336
|
+
return false;
|
|
337
|
+
return true;
|
|
338
|
+
});
|
|
339
|
+
const activeThread = activeThreadId
|
|
340
|
+
? threads.find((t) => t.id === activeThreadId)
|
|
341
|
+
: null;
|
|
342
|
+
return (_jsxs("div", { className: commentsRecipe({ className }), role: "dialog", "aria-label": title, ...rest, children: [_jsxs("div", { className: "crisp-comments-head", children: [activeThread ? (_jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": backLabel, onClick: () => setActiveThreadId(null), children: _jsx(ChevronLeft, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })) : null, _jsxs("div", { className: "crisp-comments-title-wrap", children: [_jsx("span", { className: "crisp-comments-title", children: activeThread ? threadTitle : title }), activeThread && (_jsxs("span", { className: "crisp-comments-breadcrumb", children: [breadcrumbPrefix, " ", subjectIcon, " ", subject] }))] }), !activeThread && (_jsxs(Menu, { align: "end", trigger: _jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": filterLabel, children: _jsx(ListFilter, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }), children: [_jsx(MenuCheckboxItem, { checked: showResolved, onCheckedChange: onShowResolvedChange, children: showResolvedLabel }), _jsx(MenuCheckboxItem, { checked: onlyMine, onCheckedChange: onOnlyMineChange, children: onlyMineLabel })] })), _jsx("button", { type: "button", className: "crisp-comments-headbtn", "aria-label": closeLabel, onClick: onClose, children: _jsx(X, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) })] }), _jsx("div", { className: "crisp-comments-body crisp-comments-body--pad-top", children: activeThread ? (_jsxs("div", { children: [_jsx(CommentRow, { comment: activeThread.root, currentUserId: currentUserId, replyLabel: replyActionLabel, reactLabel: reactActionLabel, resolveLabel: resolveActionLabel, moreLabel: moreActionsLabel, copyLinkLabel: copyLinkLabel, deleteLabel: deleteLabel, addReactionLabel: addReactionLabel, reactionSearchLabel: reactionSearchLabel, emojiOptions: emojiOptions, resolvable: !!onResolve, resolved: activeThread.resolved, resolvedLabel: activeThread.resolved
|
|
343
|
+
? activeThread.resolvedBy?.id === currentUserId ||
|
|
344
|
+
!activeThread.resolvedBy
|
|
345
|
+
? resolvedByYouLabel
|
|
346
|
+
: (resolvedByLabel?.(activeThread.resolvedBy.name) ??
|
|
347
|
+
`Resolved by ${activeThread.resolvedBy.name}`)
|
|
348
|
+
: 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({
|
|
349
|
+
threadId: activeThread.id,
|
|
350
|
+
commentId: activeThread.root.id,
|
|
351
|
+
}) }), 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({
|
|
352
|
+
threadId: activeThread.id,
|
|
353
|
+
commentId: reply.id,
|
|
354
|
+
}) }, 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
|
|
355
|
+
? t.resolvedBy?.id === currentUserId || !t.resolvedBy
|
|
356
|
+
? resolvedByYouLabel
|
|
357
|
+
: (resolvedByLabel?.(t.resolvedBy.name) ??
|
|
358
|
+
`Resolved by ${t.resolvedBy.name}`)
|
|
359
|
+
: 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, replyToolLabel: composerReplyToolLabel, mentionToolLabel: composerMentionToolLabel, onSubmit: (text) => onPost(text, activeThread ? activeThread.id : null) }, activeThread ? activeThread.id : "root"), _jsxs(Dialog, { open: deleteTarget != null, onOpenChange: (open) => {
|
|
360
|
+
if (!open)
|
|
361
|
+
setDeleteTarget(null);
|
|
362
|
+
}, 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: () => {
|
|
363
|
+
if (deleteTarget)
|
|
364
|
+
onDelete(deleteTarget.threadId, deleteTarget.commentId);
|
|
365
|
+
setDeleteTarget(null);
|
|
366
|
+
}, children: deleteConfirmActionLabel })] })] })] }));
|
|
367
|
+
}
|
|
368
|
+
CommentsPanel.displayName = "CommentsPanel";
|
|
369
|
+
/**
|
|
370
|
+
* The row's own comment affordance (P-051) — a persistent 6px dot once a
|
|
371
|
+
* record has ≥1 comment, replaced on hover by a comment-count icon (P-035's
|
|
372
|
+
* "icon 1") plus, when `onOpenPreview` is given, the row's second hover icon
|
|
373
|
+
* ("Open preview"). A standalone piece by design (see the module doc
|
|
374
|
+
* comment) — the grid lane wires it into a cell.
|
|
375
|
+
*/
|
|
376
|
+
export function CommentCountBadge({ count, hovered, onOpen, onOpenPreview, label: labelProp, previewLabel: previewLabelProp, className, }) {
|
|
377
|
+
const label = useCrispMessage(labelProp, count === 1 ? "1 comment" : `${count} comments`);
|
|
378
|
+
const previewLabel = useCrispMessage(previewLabelProp, "Open preview");
|
|
379
|
+
if (count === 0 && !hovered)
|
|
380
|
+
return null;
|
|
381
|
+
if (!hovered) {
|
|
382
|
+
return (_jsx("span", { className: className
|
|
383
|
+
? `crisp-comments-badge ${className}`
|
|
384
|
+
: "crisp-comments-badge", "aria-hidden": "true", children: _jsx("span", { className: "crisp-comments-badge-dot" }) }));
|
|
385
|
+
}
|
|
386
|
+
return (_jsxs("span", { className: className ? `crisp-comments-badge ${className}` : "crisp-comments-badge", children: [_jsxs("button", { type: "button", className: "crisp-comments-badge", "aria-label": label, onClick: onOpen, children: [_jsx(MessageSquare, { size: 12, strokeWidth: 2, "aria-hidden": "true" }), count > 0 && (_jsx("span", { className: "crisp-comments-badge-count", children: count }))] }), onOpenPreview && (_jsx("button", { type: "button", className: "crisp-comments-badge", "aria-label": previewLabel, onClick: onOpenPreview, children: _jsx(ArrowUpRight, { "data-rtl-flip": "", size: 12, strokeWidth: 2, "aria-hidden": "true" }) }))] }));
|
|
387
|
+
}
|
|
388
|
+
CommentCountBadge.displayName = "CommentCountBadge";
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { defineRecipe, resolveRecipe, rtlFlipCss } from "../../core/index.js";
|
|
2
|
+
import { contract as c, elevation as e } from "../../tokens/index.js";
|
|
3
|
+
// Per-record comment threads (crisp#625) — measured live on app.attio.com's
|
|
4
|
+
// own per-record comments panel (open the "zz-parity-crisp-comments" test
|
|
5
|
+
// company's Overview tab, click the speech-bubble icon top-right of the
|
|
6
|
+
// record header). Unlike `RecordPanel`'s peek, this panel is laid out
|
|
7
|
+
// IN-FLOW beside the record view, not floated over it: `getComputedStyle`
|
|
8
|
+
// on the panel wrapper read a shadow of `none` and no left-edge border at all
|
|
9
|
+
// (elevation.floating was tried first and contradicted the live read), so
|
|
10
|
+
// this recipe carries no shadow of its own — only a hairline
|
|
11
|
+
// `border-inline-start` for the seam visible in every screenshot. Width read
|
|
12
|
+
// 319px in situ; used here as 320px (one hairline off, almost certainly a
|
|
13
|
+
// scrollbar/subpixel artifact of the sampled window, not a real 319px
|
|
14
|
+
// design value — flag this if a future pass measures a *different* window
|
|
15
|
+
// width and still gets an odd number).
|
|
16
|
+
const config = defineRecipe({
|
|
17
|
+
name: "comments",
|
|
18
|
+
base: `display: flex; flex-direction: column; box-sizing: border-box; width: 320px; height: 100%;
|
|
19
|
+
background: ${c.bgSurface}; border-inline-start: 1px solid ${c.borderSubtle};
|
|
20
|
+
font-family: ${c.font.sans};
|
|
21
|
+
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
22
|
+
& * { box-sizing: border-box; }
|
|
23
|
+
|
|
24
|
+
& .crisp-comments-head { display: flex; align-items: center; gap: ${c.space["1"]}; flex-shrink: 0; height: 49px; padding: 0 ${c.space["2"]} 0 ${c.space["4"]}; }
|
|
25
|
+
& .crisp-comments-headbtn { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: ${c.radius.md}; background: none; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); font-family: inherit; cursor: pointer; transition: background-color .12s, color .12s; }
|
|
26
|
+
& .crisp-comments-headbtn:hover { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
|
|
27
|
+
& .crisp-comments-title-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
28
|
+
& .crisp-comments-title { font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
29
|
+
& .crisp-comments-breadcrumb { display: flex; align-items: center; gap: ${c.space["1"]}; font-size: 12px; font-weight: ${c.weight.medium}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
30
|
+
|
|
31
|
+
& .crisp-comments-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 ${c.space["4"]}; display: flex; flex-direction: column; gap: ${c.space["4"]}; }
|
|
32
|
+
& .crisp-comments-body--pad-top { padding-top: ${c.space["3"]}; }
|
|
33
|
+
|
|
34
|
+
/* One comment row -- avatar 16px (Avatar size="sm"), 6px gap to the
|
|
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. */
|
|
37
|
+
& .crisp-comments-row { position: relative; display: flex; gap: ${c.space["1.5"]}; }
|
|
38
|
+
& .crisp-comments-row-main { flex: 1; min-width: 0; }
|
|
39
|
+
& .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; }
|
|
42
|
+
& .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
|
+
& .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
|
+
|
|
45
|
+
/* Hover actions -- measured: 4 buttons, 28x28, radius 8, flush
|
|
46
|
+
side-by-side (0 gap), transparent by default, 16px icon at 55% ink. */
|
|
47
|
+
& .crisp-comments-row-actions { position: absolute; top: 0; inset-inline-end: 0; display: flex; opacity: 0; pointer-events: none; background: ${c.bgSurface}; }
|
|
48
|
+
& .crisp-comments-row:hover .crisp-comments-row-actions,
|
|
49
|
+
& .crisp-comments-row-actions:focus-within { opacity: 1; pointer-events: auto; }
|
|
50
|
+
& .crisp-comments-action { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: ${c.radius.md}; background: transparent; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); font-family: inherit; cursor: pointer; transition: background-color .12s, color .12s; }
|
|
51
|
+
& .crisp-comments-action:hover { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
|
|
52
|
+
& .crisp-comments-action[data-active] { color: ${c.bgBrandSolid}; }
|
|
53
|
+
|
|
54
|
+
/* Reactions -- measured: 28px pill, radius 8, subtle 4% ink fill,
|
|
55
|
+
padding 4px 8px 4px 6px, gap 4px between the emoji glyph and count. */
|
|
56
|
+
& .crisp-comments-reactions { display: flex; flex-wrap: wrap; gap: ${c.space["1"]}; margin-top: ${c.space["1.5"]}; }
|
|
57
|
+
& .crisp-comments-reaction { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: 28px; padding: 4px 8px 4px 6px; border: none; border-radius: ${c.radius.md}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); font-family: inherit; font-size: 12px; font-weight: ${c.weight.medium}; color: ${c.fgPrimary}; cursor: pointer; }
|
|
58
|
+
& .crisp-comments-reaction[data-reacted] { background: color-mix(in srgb, ${c.bgBrandSolid} 12%, transparent); color: ${c.bgBrandSolid}; }
|
|
59
|
+
& .crisp-comments-reaction-add { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; border-radius: ${c.radius.md}; background: transparent; font-family: inherit; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); cursor: pointer; opacity: 0; transition: opacity .12s, background-color .12s; }
|
|
60
|
+
& .crisp-comments-row:hover .crisp-comments-reaction-add { opacity: 1; }
|
|
61
|
+
& .crisp-comments-reaction-add:hover { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
|
|
62
|
+
|
|
63
|
+
/* Reply-in-thread view -- an indented block of replies below the root. */
|
|
64
|
+
& .crisp-comments-replies { display: flex; flex-direction: column; gap: ${c.space["4"]}; margin-top: ${c.space["3"]}; padding-inline-start: ${c.space["4"]}; border-inline-start: 1px solid ${c.borderSubtle}; }
|
|
65
|
+
|
|
66
|
+
& .crisp-comments-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: ${c.space["1"]}; padding: ${c.space["6"]} ${c.space["4"]}; text-align: center; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); font-size: ${c.text.lg}; }
|
|
67
|
+
|
|
68
|
+
/* Composer -- measured: 14px/20px body text, 16px gap above the
|
|
69
|
+
reply/mention icon row, Comment button 84x32 padding 6px 10px radius
|
|
70
|
+
9 (= radius.lg) brand-solid fill, 14px/500 white label, opacity .4
|
|
71
|
+
disabled / 1 enabled (no color/background change between the two). */
|
|
72
|
+
& .crisp-comments-composer { flex-shrink: 0; padding: ${c.space["3"]} ${c.space["4"]} ${c.space["4"]}; border-top: 1px solid ${c.borderSubtle}; }
|
|
73
|
+
& .crisp-comments-field { min-height: 20px; max-height: 120px; overflow-y: auto; font-family: inherit; font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.regular}; color: ${c.fgPrimary}; outline: none; white-space: pre-wrap; word-break: break-word; }
|
|
74
|
+
& .crisp-comments-field:focus-visible { outline: 2px solid ${c.strokeFocusRing}; outline-offset: 2px; }
|
|
75
|
+
& .crisp-comments-field:empty::before { content: attr(data-placeholder); color: color-mix(in srgb, ${c.fgPrimary} 40%, transparent); }
|
|
76
|
+
& .crisp-comments-composer-foot { display: flex; align-items: center; justify-content: space-between; margin-top: ${c.space["4"]}; }
|
|
77
|
+
& .crisp-comments-composer-tools { display: flex; align-items: center; gap: ${c.space["1"]}; }
|
|
78
|
+
& .crisp-comments-tool { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin: 0; padding: 0; border: none; border-radius: ${c.radius.sm}; background: none; color: color-mix(in srgb, ${c.fgPrimary} 45%, transparent); font-family: inherit; cursor: pointer; }
|
|
79
|
+
& .crisp-comments-tool:hover { color: ${c.fgPrimary}; }
|
|
80
|
+
/* Same fill/shadow as Button's own intent="primary" (crisp/button.recipe.ts) --
|
|
81
|
+
confirmed byte-identical against the live "Comment" button's computed
|
|
82
|
+
box-shadow, so this reuses that literal rather than re-deriving it.
|
|
83
|
+
32px/radius.lg/6px-10px padding has no matching Button \`size\` yet
|
|
84
|
+
(24/28/40 are the three that exist), so this stays hand-rolled instead
|
|
85
|
+
of stretching one of those. */
|
|
86
|
+
& .crisp-comments-post { display: inline-flex; align-items: center; justify-content: center; height: 32px; padding: 6px ${c.space["2.5"]}; border: none; border-radius: ${c.radius.lg}; background: ${c.bgBrandSolid}; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06), 0 2px 4px -2px rgba(38,109,240,0.12), 0 3px 6px -2px rgba(38,109,240,0.08); font-family: inherit; font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; color: ${c.fgOnBrand}; cursor: pointer; opacity: 1; transition: opacity .12s, background-color .12s; }
|
|
87
|
+
& .crisp-comments-post:hover:not(:disabled) { background: ${c.bgBrandSolidPressed}; }
|
|
88
|
+
& .crisp-comments-post:disabled { opacity: .4; cursor: default; }
|
|
89
|
+
|
|
90
|
+
/* Filter menu -- measured: 220px, radius 12 (radius.xl), 4px padding,
|
|
91
|
+
elevation.floating -- a plain reuse of crisp's own Menu, so no rules
|
|
92
|
+
live here; kept as a comment for the record. */
|
|
93
|
+
|
|
94
|
+
/* An inserted "@mention" chip in the composer -- same shape as
|
|
95
|
+
RichTextEditor's own \`.crisp-rte-mention\` (crisp#428), reused here
|
|
96
|
+
rather than re-derived since it's the same atomic-chip affordance. */
|
|
97
|
+
& .crisp-comments-mention { display: inline; padding: 0 2px; border-radius: 4px; background: color-mix(in srgb, ${c.bgBrandSolid} 12%, transparent); color: ${c.bgBrandSolid}; font-weight: ${c.weight.medium}; user-select: none; }
|
|
98
|
+
|
|
99
|
+
/* Reaction picker -- see \`ReactionPicker\`'s own doc comment: the exact
|
|
100
|
+
chrome wasn't independently pixel-measured this pass (screenshot
|
|
101
|
+
capture broke mid-session), so this reuses Menu's already-measured
|
|
102
|
+
floating-popover shell rather than a new value. */
|
|
103
|
+
& .crisp-comments-reactionpicker { width: 240px; padding: ${c.space["2"]}; border-radius: ${c.radius.xl}; background: ${c.bgSurface}; box-shadow: ${e.floating}; }
|
|
104
|
+
& .crisp-comments-reactionpicker-search { box-sizing: border-box; width: 100%; height: 28px; margin-bottom: ${c.space["2"]}; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: ${c.bgRaised}; font-family: inherit; font-size: ${c.text.md}; color: ${c.fgPrimary}; }
|
|
105
|
+
& .crisp-comments-reactionpicker-search::placeholder { color: color-mix(in srgb, ${c.fgPrimary} 40%, transparent); }
|
|
106
|
+
& .crisp-comments-reactionpicker-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: ${c.space["0.5"]}; max-height: 160px; overflow-y: auto; }
|
|
107
|
+
& .crisp-comments-reactionpicker-item { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: ${c.radius.sm}; background: none; font-family: inherit; font-size: 16px; line-height: 1; cursor: pointer; }
|
|
108
|
+
& .crisp-comments-reactionpicker-item:hover { background: ${c.bgRaised}; }
|
|
109
|
+
|
|
110
|
+
/* Row badge (CommentCountBadge) -- lives in its own class family so the
|
|
111
|
+
grid lanes can drop it into a cell without pulling in the panel's
|
|
112
|
+
CSS. Persistent state: a 6px grey dot. Hover state: a 12px comment
|
|
113
|
+
icon + 12px count text, both at 55% ink, replacing the dot. */
|
|
114
|
+
& .crisp-comments-badge { display: inline-flex; align-items: center; gap: ${c.space["1"]}; border: none; background: none; padding: 0; margin: 0; font-family: inherit; cursor: pointer; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); }
|
|
115
|
+
& .crisp-comments-badge-dot { width: 6px; height: 6px; border-radius: ${c.radius.full}; background: color-mix(in srgb, ${c.fgPrimary} 38%, transparent); }
|
|
116
|
+
& .crisp-comments-badge-count { font-size: 12px; line-height: 16px; }
|
|
117
|
+
${rtlFlipCss}`,
|
|
118
|
+
variants: {},
|
|
119
|
+
defaultVariants: {},
|
|
120
|
+
compoundVariants: [],
|
|
121
|
+
});
|
|
122
|
+
export const commentsRecipe = resolveRecipe(config);
|
|
123
|
+
export default config;
|
|
@@ -17,10 +17,18 @@ import { contract as c } from "../../tokens/index.js";
|
|
|
17
17
|
// `title`/`trailing` slot content a consumer supplies. `size.header` (48px)
|
|
18
18
|
// is reused rather than a new token, since it's the same "workspace-switcher
|
|
19
19
|
// header height" measured elsewhere in the sidebar.
|
|
20
|
+
//
|
|
21
|
+
// crisp#576: `border-box` plus a plain `height: ${size.header}` meant the
|
|
22
|
+
// bordered variant's own 1px border was eaten OUT OF the 48px box, leaving
|
|
23
|
+
// only 47px of content -- caught in review of internal/grc's own shell
|
|
24
|
+
// shifting up 1px end-to-end after adopting this component. Re-measured
|
|
25
|
+
// live: Attio's own `<header>` is 49px total (border-box) with the 1px
|
|
26
|
+
// border OUTSIDE the 48px content row, not inside it. `[data-bordered]` now
|
|
27
|
+
// grows the box by the border's own width so content stays 48px either way.
|
|
20
28
|
const config = defineRecipe({
|
|
21
29
|
name: "headerbar",
|
|
22
30
|
base: `box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: ${c.space["2"]}; height: ${c.size.header}; flex-shrink: 0; padding: 0 ${c.space["3"]}; font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
23
|
-
&[data-bordered] { border-bottom: 1px solid ${c.borderSubtle}; }
|
|
31
|
+
&[data-bordered] { height: calc(${c.size.header} + 1px); border-bottom: 1px solid ${c.borderSubtle}; }
|
|
24
32
|
& .crisp-headerbar-leading { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; min-width: 0; }
|
|
25
33
|
& .crisp-headerbar-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: ${c.space["2"]}; overflow: hidden; }
|
|
26
34
|
& .crisp-headerbar-trailing { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; }`,
|
|
@@ -12,7 +12,16 @@ const config = defineRecipe({
|
|
|
12
12
|
& .crisp-listpage-toolbar { display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.header}; flex-shrink: 0; padding: 0 ${c.space["3"]}; }
|
|
13
13
|
& .crisp-listpage-toolbar-lead { display: flex; align-items: center; gap: ${c.space["2"]}; }
|
|
14
14
|
& .crisp-listpage-count { margin-inline-start: auto; font-size: ${c.text.md}; color: ${c.fgMuted}; white-space: nowrap; }
|
|
15
|
-
|
|
15
|
+
/* #641: trailing (View settings / New) has to reach the toolbar's own
|
|
16
|
+
inline-end edge on its own -- the only thing pushing it there used
|
|
17
|
+
to be .crisp-listpage-count's margin-inline-start: auto, which a
|
|
18
|
+
consumer with no count badge (TasksTable, measured live: trailing
|
|
19
|
+
sat right after leading, nowhere near the row's own right edge)
|
|
20
|
+
never renders. A bare margin-inline-start: auto here is safely
|
|
21
|
+
overridden by the more specific rule right below whenever count IS
|
|
22
|
+
present, so the two cases don't fight -- count's own auto margin
|
|
23
|
+
still does the pushing there, this one only fires when it's absent. */
|
|
24
|
+
& .crisp-listpage-toolbar-trail { display: flex; align-items: center; gap: ${c.space["2"]}; margin-inline-start: auto; }
|
|
16
25
|
& .crisp-listpage-count + .crisp-listpage-toolbar-trail { margin-inline-start: ${c.space["3"]}; }
|
|
17
26
|
|
|
18
27
|
& .crisp-listpage-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 ${c.space["3"]} ${c.space["3"]}; }
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { ATTRIBUTE_AI_AUTOFILL_OPTIONS, ATTRIBUTE_TYPE_OPTIONS, AttributesTable, AttributeTypeSelect, CreateAttributeDialog, ObjectAppearanceSection, ObjectDetailPage, ObjectGeneralSection, ObjectSettings, ObjectsList, } from "./objectsettings.js";
|
|
2
|
+
export { objectsettingsRecipe } from "./objectsettings.recipe.js";
|