@crisp-ui-kit/crisp 0.53.4 → 0.54.0
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/cjs/components/bulkbar/bulkbar.js +5 -1
- package/dist/cjs/components/fileupload/fileupload.js +19 -3
- package/dist/cjs/components/formfield/formfield.js +8 -1
- package/dist/cjs/components/heatmapchart/heatmapchart.js +14 -2
- package/dist/cjs/components/skiplink/index.js +17 -0
- package/dist/cjs/components/skiplink/skiplink.js +16 -0
- package/dist/cjs/components/skiplink/skiplink.recipe.js +33 -0
- package/dist/cjs/components/textlink/index.js +7 -0
- package/dist/cjs/components/textlink/textlink.js +16 -0
- package/dist/cjs/components/textlink/textlink.recipe.js +35 -0
- package/dist/cjs/components/visuallyhidden/index.js +17 -0
- package/dist/cjs/components/visuallyhidden/visuallyhidden.js +16 -0
- package/dist/cjs/components/visuallyhidden/visuallyhidden.recipe.js +20 -0
- package/dist/cjs/index.js +4 -0
- package/dist/cjs/product/board/board.js +11 -2
- package/dist/cjs/product/command/command.js +13 -1
- package/dist/cjs/product/flowcanvas/flowcanvas.js +9 -2
- package/dist/cjs/product/headerbar/headerbar.js +15 -0
- package/dist/cjs/product/headerbar/headerbar.recipe.js +35 -0
- package/dist/cjs/product/headerbar/index.js +17 -0
- package/dist/cjs/product/inbox/inbox.js +13 -1
- package/dist/cjs/product/onboarding/onboarding.js +22 -1
- package/dist/cjs/product/recordpage/index.js +2 -1
- package/dist/cjs/product/recordpage/recordpage.js +197 -123
- package/dist/cjs/product/recordpage/recordpage.recipe.js +5 -0
- package/dist/cjs/product/richtext/richtext.js +3 -3
- package/dist/cjs/product/settingssection/settingssection.recipe.js +11 -2
- package/dist/cjs/product/tasklist/tasklist.js +8 -1
- package/dist/esm/components/bulkbar/bulkbar.js +5 -1
- package/dist/esm/components/fileupload/fileupload.js +20 -4
- package/dist/esm/components/formfield/formfield.js +8 -1
- package/dist/esm/components/heatmapchart/heatmapchart.js +14 -2
- package/dist/esm/components/skiplink/index.js +1 -0
- package/dist/esm/components/skiplink/skiplink.js +13 -0
- package/dist/esm/components/skiplink/skiplink.recipe.js +30 -0
- package/dist/esm/components/textlink/index.js +2 -0
- package/dist/esm/components/textlink/textlink.js +13 -0
- package/dist/esm/components/textlink/textlink.recipe.js +32 -0
- package/dist/esm/components/visuallyhidden/index.js +1 -0
- package/dist/esm/components/visuallyhidden/visuallyhidden.js +13 -0
- package/dist/esm/components/visuallyhidden/visuallyhidden.recipe.js +17 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/product/board/board.js +12 -3
- package/dist/esm/product/command/command.js +14 -2
- package/dist/esm/product/flowcanvas/flowcanvas.js +9 -2
- package/dist/esm/product/headerbar/headerbar.js +12 -0
- package/dist/esm/product/headerbar/headerbar.recipe.js +32 -0
- package/dist/esm/product/headerbar/index.js +1 -0
- package/dist/esm/product/inbox/inbox.js +13 -1
- package/dist/esm/product/onboarding/onboarding.js +22 -1
- package/dist/esm/product/recordpage/index.js +1 -1
- package/dist/esm/product/recordpage/recordpage.js +197 -124
- package/dist/esm/product/recordpage/recordpage.recipe.js +5 -0
- package/dist/esm/product/richtext/richtext.js +3 -3
- package/dist/esm/product/settingssection/settingssection.recipe.js +11 -2
- package/dist/esm/product/tasklist/tasklist.js +8 -1
- package/dist/headerbar.css +7 -0
- package/dist/headerbar.d.ts +7 -0
- package/dist/headerbar.layered.css +9 -0
- package/dist/recordpage.css +17 -31
- package/dist/recordpage.layered.css +17 -31
- package/dist/settingssection.css +2 -2
- package/dist/settingssection.layered.css +2 -2
- package/dist/skiplink.css +5 -0
- package/dist/skiplink.d.ts +7 -0
- package/dist/skiplink.layered.css +7 -0
- package/dist/styles.css +49 -33
- package/dist/styles.layered.css +49 -33
- package/dist/textlink.css +11 -0
- package/dist/textlink.d.ts +7 -0
- package/dist/textlink.layered.css +13 -0
- package/dist/types/components/bulkbar/bulkbar.d.ts +1 -1
- package/dist/types/components/heatmapchart/heatmapchart.d.ts +1 -1
- package/dist/types/components/skiplink/index.d.ts +1 -0
- package/dist/types/components/skiplink/skiplink.d.ts +18 -0
- package/dist/types/components/skiplink/skiplink.recipe.d.ts +3 -0
- package/dist/types/components/textlink/index.d.ts +2 -0
- package/dist/types/components/textlink/textlink.d.ts +19 -0
- package/dist/types/components/textlink/textlink.recipe.d.ts +13 -0
- package/dist/types/components/visuallyhidden/index.d.ts +1 -0
- package/dist/types/components/visuallyhidden/visuallyhidden.d.ts +15 -0
- package/dist/types/components/visuallyhidden/visuallyhidden.recipe.d.ts +3 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/product/board/board.d.ts +1 -1
- package/dist/types/product/command/command.d.ts +1 -1
- package/dist/types/product/flowcanvas/flowcanvas.d.ts +1 -1
- package/dist/types/product/headerbar/headerbar.d.ts +27 -0
- package/dist/types/product/headerbar/headerbar.recipe.d.ts +3 -0
- package/dist/types/product/headerbar/index.d.ts +1 -0
- package/dist/types/product/inbox/inbox.d.ts +1 -1
- package/dist/types/product/onboarding/onboarding.d.ts +1 -1
- package/dist/types/product/recordpage/index.d.ts +1 -1
- package/dist/types/product/recordpage/recordpage.d.ts +90 -1
- package/dist/types/product/richtext/richtext.d.ts +20 -0
- package/dist/types/product/tasklist/tasklist.d.ts +1 -1
- package/dist/visuallyhidden.css +3 -0
- package/dist/visuallyhidden.d.ts +7 -0
- package/dist/visuallyhidden.layered.css +5 -0
- package/package.json +21 -1
|
@@ -8,7 +8,7 @@ import { Select } from "../../components/select/index.js";
|
|
|
8
8
|
import { Spinner } from "../../components/spinner/index.js";
|
|
9
9
|
import { Switch } from "../../components/switch/index.js";
|
|
10
10
|
import { Tabs } from "../../components/tabs/index.js";
|
|
11
|
-
import { isRtlElement, useCrispComponentDefaults } from "../../core/index.js";
|
|
11
|
+
import { cx, isRtlElement, useCrispComponentDefaults } from "../../core/index.js";
|
|
12
12
|
import { editableInputType, tagTone, useEditableField, } from "../recordstable/index.js";
|
|
13
13
|
import { recordpageRecipe } from "./recordpage.recipe.js";
|
|
14
14
|
function valueEmpty(v) {
|
|
@@ -87,7 +87,42 @@ export const recordPageLabels = {
|
|
|
87
87
|
taggedCategories: (count) => (_jsxs(_Fragment, { children: ["Tagged ", _jsx("b", { children: count }), " categories"] })),
|
|
88
88
|
recordCreated: (name) => (_jsxs(_Fragment, { children: [_jsx("b", { children: name }), " record was created"] })),
|
|
89
89
|
noEmailsWith: (name) => _jsxs(_Fragment, { children: ["No emails logged with ", name, " yet."] }),
|
|
90
|
+
noFieldsMatch: (query) => _jsxs(_Fragment, { children: ["No fields match \u201C", query, "\u201D."] }),
|
|
90
91
|
};
|
|
92
|
+
/**
|
|
93
|
+
* crisp/#471: a `CrispMessagesProvider`'s `componentDefaults.recordPage` sits
|
|
94
|
+
* BETWEEN the built-in English defaults and an instance's own explicit
|
|
95
|
+
* `labels` prop -- a provider translates every `RecordPage`/`RecordDetails`
|
|
96
|
+
* on the page at once, and a caller who still passes `labels` for one
|
|
97
|
+
* instance keeps overriding it there. Not `useCrispMessage`: several of
|
|
98
|
+
* these entries (`domainSetTo`, `noFieldsMatch`, …) are formatter FUNCTIONS,
|
|
99
|
+
* which a flat string dictionary can't hold. Shared by `RecordPage` and
|
|
100
|
+
* `RecordDetails` (#528) -- both merge the same three layers the same way,
|
|
101
|
+
* so this is the one place that merge is written, not two copies of it.
|
|
102
|
+
*/
|
|
103
|
+
function useRecordPageLabels(labels) {
|
|
104
|
+
const providerLabels = useCrispComponentDefaults("recordPage");
|
|
105
|
+
return React.useMemo(() => ({
|
|
106
|
+
...recordPageLabels,
|
|
107
|
+
...providerLabels,
|
|
108
|
+
...labels,
|
|
109
|
+
tabs: {
|
|
110
|
+
...recordPageLabels.tabs,
|
|
111
|
+
...providerLabels?.tabs,
|
|
112
|
+
...labels?.tabs,
|
|
113
|
+
},
|
|
114
|
+
highlightLabels: {
|
|
115
|
+
...recordPageLabels.highlightLabels,
|
|
116
|
+
...providerLabels?.highlightLabels,
|
|
117
|
+
...labels?.highlightLabels,
|
|
118
|
+
},
|
|
119
|
+
highlightEmpty: {
|
|
120
|
+
...recordPageLabels.highlightEmpty,
|
|
121
|
+
...providerLabels?.highlightEmpty,
|
|
122
|
+
...labels?.highlightEmpty,
|
|
123
|
+
},
|
|
124
|
+
}), [labels, providerLabels]);
|
|
125
|
+
}
|
|
91
126
|
/** `Select` owns its own closed-by-default `open` state with no prop or
|
|
92
127
|
* imperative way to start it open -- the only thing that opens it is a real
|
|
93
128
|
* click on its trigger button. Mounting one on click (rather than rendering
|
|
@@ -143,6 +178,16 @@ function TagsValue({ values }) {
|
|
|
143
178
|
}
|
|
144
179
|
function FieldValue({ col, row, labels, }) {
|
|
145
180
|
const v = row[col.key];
|
|
181
|
+
// `render` (#528) -- the same full-custom-content escape hatch
|
|
182
|
+
// `RecordsTable`'s own columns already have (recordstable.tsx's own doc:
|
|
183
|
+
// "an escape hatch for a display crisp can't express via type"), reused
|
|
184
|
+
// here so a value this component has no built-in shape for (a reference
|
|
185
|
+
// field's link to another record, say) doesn't need a new `type`/`editor`
|
|
186
|
+
// of its own. Checked before the empty check -- like `RecordsTable`'s own
|
|
187
|
+
// `render`, it owns the cell's content fully, including what "empty"
|
|
188
|
+
// looks like for that column.
|
|
189
|
+
if (col.render)
|
|
190
|
+
return _jsx(_Fragment, { children: col.render(v, row) });
|
|
146
191
|
if (valueEmpty(v))
|
|
147
192
|
return _jsx("span", { className: "crisp-recordpage-empty", children: labels.setAValue });
|
|
148
193
|
// `editor: "select"` at rest (#457) -- checked ahead of `col.type` since
|
|
@@ -210,6 +255,147 @@ function TasksSection({ tasks, onAdd, onToggle, labels, }) {
|
|
|
210
255
|
function EmptyCard({ icon, children, }) {
|
|
211
256
|
return (_jsxs("div", { className: "crisp-recordpage-emptycard", children: [icon, children] }));
|
|
212
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* The Details column's own field list, on its own (#528) — `RecordPage`'s
|
|
260
|
+
* "Record Details" collapsible + searchable field list, the one piece a
|
|
261
|
+
* consumer with a fixed-width rail of its own (no resize handle, no entity
|
|
262
|
+
* header, no CRM Lists panel — GRC's own persistent 320px column, grc#66)
|
|
263
|
+
* actually needs, rather than rebuilding this same click-to-edit/search/
|
|
264
|
+
* `sections` machinery by hand or pulling in the whole page's chrome for a
|
|
265
|
+
* rail that isn't one. `RecordPage` composes this internally for its own
|
|
266
|
+
* resizable aside (same component either way, not a second copy) — this is
|
|
267
|
+
* that same block, exactly, just not wrapped in `RecordPage`'s own topbar/
|
|
268
|
+
* tabs/resizer.
|
|
269
|
+
*
|
|
270
|
+
* Not wrapped in `recordpageRecipe` itself: doing so would duplicate the
|
|
271
|
+
* recipe's own `display:flex;height:100%` base rule as a nested child
|
|
272
|
+
* inside `RecordPage`'s aside (which already carries that class from
|
|
273
|
+
* `RecordPage`'s own root), breaking that layout. A standalone caller
|
|
274
|
+
* needs exactly one `.crisp-recordpage`-scoped ancestor for this
|
|
275
|
+
* component's classes to resolve — wrap it in one, the same way
|
|
276
|
+
* `RecordPage`'s own root does: `<div className={recordpageRecipe()}>`
|
|
277
|
+
* (also exported from this package).
|
|
278
|
+
*/
|
|
279
|
+
export function RecordDetails({ record, columns, sections, onEdit, labels, className, ...rest }) {
|
|
280
|
+
const t = useRecordPageLabels(labels);
|
|
281
|
+
const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = useEditableField(onEdit);
|
|
282
|
+
const [detailsOpen, setDetailsOpen] = React.useState(true);
|
|
283
|
+
const [searchOpen, setSearchOpen] = React.useState(false);
|
|
284
|
+
const [fieldQuery, setFieldQuery] = React.useState("");
|
|
285
|
+
const [showAllValues, setShowAllValues] = React.useState(false);
|
|
286
|
+
const fields = columns.filter((col) => col.type !== "entity");
|
|
287
|
+
const q = fieldQuery.trim().toLowerCase();
|
|
288
|
+
const matches = (col) => !q || col.label.toLowerCase().includes(q);
|
|
289
|
+
// #528: `sections` groups fields under headings, in the caller's own
|
|
290
|
+
// order, and always shows every match — no 5-field reveal (see
|
|
291
|
+
// `RecordPageSection`'s own doc). Omitted, this stays the flat,
|
|
292
|
+
// Attio-measured list with its 5-field "View all values" reveal,
|
|
293
|
+
// unchanged from before #528.
|
|
294
|
+
const FIELD_LIMIT = 5;
|
|
295
|
+
let canRevealFields = false;
|
|
296
|
+
let groups;
|
|
297
|
+
if (sections) {
|
|
298
|
+
const byKey = new Map(fields.map((col) => [col.key, col]));
|
|
299
|
+
groups = sections
|
|
300
|
+
.map((s) => ({
|
|
301
|
+
title: s.title,
|
|
302
|
+
fields: s.fields
|
|
303
|
+
.map((key) => byKey.get(key))
|
|
304
|
+
.filter((col) => col !== undefined)
|
|
305
|
+
.filter(matches),
|
|
306
|
+
}))
|
|
307
|
+
// A section left with no matching fields (a search that matches
|
|
308
|
+
// nothing in it, or a `fields` key naming no real column) doesn't
|
|
309
|
+
// render at all -- heading included, not an empty group under a
|
|
310
|
+
// heading with nothing below it.
|
|
311
|
+
.filter((g) => g.fields.length > 0);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const matched = q ? fields.filter(matches) : fields;
|
|
315
|
+
canRevealFields = !q && matched.length > FIELD_LIMIT;
|
|
316
|
+
const shown = canRevealFields && !showAllValues
|
|
317
|
+
? matched.slice(0, FIELD_LIMIT)
|
|
318
|
+
: matched;
|
|
319
|
+
groups = [{ title: undefined, fields: shown }];
|
|
320
|
+
}
|
|
321
|
+
const totalShown = groups.reduce((n, g) => n + g.fields.length, 0);
|
|
322
|
+
return (_jsxs("div", { className: cx("crisp-recordpage-section", className), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-detailhead", children: [searchOpen ? (_jsx("input", {
|
|
323
|
+
// biome-ignore lint/a11y/noAutofocus: focusing the just-opened field search is expected
|
|
324
|
+
autoFocus: true, type: "text", className: "crisp-recordpage-fieldsearch", placeholder: t.searchPlaceholder, "aria-label": t.searchFields, value: fieldQuery, onChange: (e) => setFieldQuery(e.target.value), onKeyDown: (e) => {
|
|
325
|
+
if (e.key === "Escape") {
|
|
326
|
+
setSearchOpen(false);
|
|
327
|
+
setFieldQuery("");
|
|
328
|
+
}
|
|
329
|
+
} })) : (_jsxs("button", { type: "button", className: "crisp-recordpage-collapse", "data-collapsed": detailsOpen ? undefined : "", "aria-expanded": detailsOpen, onClick: () => setDetailsOpen((o) => !o), children: [t.recordDetails, _jsx(ChevronDown, { className: "crisp-recordpage-chev", size: 12, strokeWidth: 2.5 })] })), _jsx("button", { type: "button", className: "crisp-recordpage-detailsearch", "data-active": searchOpen ? "" : undefined, "aria-label": searchOpen ? t.closeSearch : t.searchFields, "aria-pressed": searchOpen, onClick: () => {
|
|
330
|
+
setSearchOpen((s) => !s);
|
|
331
|
+
setFieldQuery("");
|
|
332
|
+
if (!searchOpen)
|
|
333
|
+
setDetailsOpen(true);
|
|
334
|
+
}, children: searchOpen ? (_jsx(X, { size: 14, strokeWidth: 1.75 })) : (_jsx(Search, { size: 14, strokeWidth: 1.75 })) })] }), detailsOpen &&
|
|
335
|
+
(totalShown > 0 ? (_jsxs(_Fragment, { children: [groups.map((g, i) => (_jsxs(React.Fragment, { children: [sections && g.title != null && (_jsx("div", { className: "crisp-recordpage-section-title crisp-recordpage-fieldsection-title", children: g.title })), g.fields.map((col) => {
|
|
336
|
+
// #431: a field only actually opens when there's an
|
|
337
|
+
// `onEdit` to commit through -- `editable` with nothing
|
|
338
|
+
// to call would be a dead click.
|
|
339
|
+
const canEdit = Boolean(col.editable && onEdit);
|
|
340
|
+
const isPending = pendingKey === col.key;
|
|
341
|
+
const fieldError = error?.key === col.key ? error.message : null;
|
|
342
|
+
const label = (_jsxs("span", { className: "crisp-recordpage-field-label", children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : null, _jsx("span", { className: "crisp-recordpage-field-label-text", children: col.label })] }));
|
|
343
|
+
// `switch` is always its own live widget -- no
|
|
344
|
+
// click-to-open, no separate edit/view mode: flipping the
|
|
345
|
+
// toggle IS the commit gesture, and there's no
|
|
346
|
+
// meaningfully different "rest" display for a boolean
|
|
347
|
+
// (same exception `DataTable`'s own `editor` makes, #453).
|
|
348
|
+
if (canEdit && col.editor === "switch") {
|
|
349
|
+
const value = record[col.key];
|
|
350
|
+
return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsxs("span", { className: "crisp-recordpage-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.label}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commit(record.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
351
|
+
}
|
|
352
|
+
// `select` opens crisp's own `Select` on a SINGLE click
|
|
353
|
+
// (#457, corrects #431/#385's original always-live copy
|
|
354
|
+
// of the same regression #453 fixed on `DataTable`) --
|
|
355
|
+
// like every other editable type here, at rest it shows
|
|
356
|
+
// its value (see `FieldValue`'s own `col.editor ===
|
|
357
|
+
// "select"` case) via the generic click-to-open branch
|
|
358
|
+
// below, and only mounts `Select` once `editingKey`
|
|
359
|
+
// matches. `DataTable`'s own double-click gate doesn't
|
|
360
|
+
// apply: `RecordPage`'s plain text/date fields already
|
|
361
|
+
// open on a single click (measured live for #431), so
|
|
362
|
+
// `select` matches that, not `DataTable`.
|
|
363
|
+
if (canEdit &&
|
|
364
|
+
editingKey === col.key &&
|
|
365
|
+
col.editor === "select") {
|
|
366
|
+
const value = record[col.key];
|
|
367
|
+
return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx(RecordPageSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commit(record.id, col.key, v), onCancel: closeEditing }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
368
|
+
}
|
|
369
|
+
if (canEdit && editingKey === col.key) {
|
|
370
|
+
const value = record[col.key];
|
|
371
|
+
const editor = editableInputType(col.type);
|
|
372
|
+
return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx("input", { className: "crisp-recordpage-edit",
|
|
373
|
+
// biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
|
|
374
|
+
autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
|
|
375
|
+
if (isPending)
|
|
376
|
+
return;
|
|
377
|
+
if (e.key === "Enter")
|
|
378
|
+
commit(record.id, col.key, e.currentTarget.value);
|
|
379
|
+
else if (e.key === "Escape")
|
|
380
|
+
closeEditing();
|
|
381
|
+
}, onBlur: (e) => {
|
|
382
|
+
if (isPending)
|
|
383
|
+
return;
|
|
384
|
+
commit(record.id, col.key, e.currentTarget.value);
|
|
385
|
+
} }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
386
|
+
}
|
|
387
|
+
return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", "data-editable": canEdit ? "" : undefined, children: [canEdit ? (_jsx("button", { type: "button", className: "crisp-recordpage-edit-trigger", onClick: () => openEditing(col.key), children: _jsx(FieldValue, { col: col, row: record, labels: t }) })) : (_jsx(FieldValue, { col: col, row: record, labels: t })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
388
|
+
})] }, g.title != null ? String(g.title) : `section-${i}`))), canRevealFields && (_jsx("button", { type: "button", className: "crisp-recordpage-viewall crisp-recordpage-viewall--section", onClick: () => {
|
|
389
|
+
// Revealing all fields also opens the (focused) field
|
|
390
|
+
// search; collapsing closes it.
|
|
391
|
+
const next = !showAllValues;
|
|
392
|
+
setShowAllValues(next);
|
|
393
|
+
setSearchOpen(next);
|
|
394
|
+
if (!next)
|
|
395
|
+
setFieldQuery("");
|
|
396
|
+
}, children: showAllValues ? t.viewLess : t.viewAllValues }))] })) : (_jsx("div", { className: "crisp-recordpage-noresults", children: t.noFieldsMatch(fieldQuery) })))] }));
|
|
397
|
+
}
|
|
398
|
+
RecordDetails.displayName = "RecordDetails";
|
|
213
399
|
/**
|
|
214
400
|
* The full-page company record view — the page that opens when you click into a
|
|
215
401
|
* record (as opposed to the peek [RecordPanel]). A top breadcrumb bar with
|
|
@@ -218,36 +404,8 @@ function EmptyCard({ icon, children, }) {
|
|
|
218
404
|
* Emails sections (Notes & Tasks are live — add and check things off). Drop it
|
|
219
405
|
* into the app's main content area.
|
|
220
406
|
*/
|
|
221
|
-
export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, ...rest }) {
|
|
222
|
-
const
|
|
223
|
-
// crisp/#471: a CrispMessagesProvider's `componentDefaults.recordPage` sits
|
|
224
|
-
// BETWEEN the built-in English defaults and this instance's own explicit
|
|
225
|
-
// `labels` prop — a provider translates every RecordPage on the page at
|
|
226
|
-
// once, and a caller who still passes `labels` for one instance keeps
|
|
227
|
-
// overriding it there, same as before this existed. Not `useCrispMessage`:
|
|
228
|
-
// several of these entries (`domainSetTo`, `sortedBy`, …) are formatter
|
|
229
|
-
// FUNCTIONS, which a flat string dictionary can't hold.
|
|
230
|
-
const providerLabels = useCrispComponentDefaults("recordPage");
|
|
231
|
-
const t = React.useMemo(() => ({
|
|
232
|
-
...recordPageLabels,
|
|
233
|
-
...providerLabels,
|
|
234
|
-
...labels,
|
|
235
|
-
tabs: {
|
|
236
|
-
...recordPageLabels.tabs,
|
|
237
|
-
...providerLabels?.tabs,
|
|
238
|
-
...labels?.tabs,
|
|
239
|
-
},
|
|
240
|
-
highlightLabels: {
|
|
241
|
-
...recordPageLabels.highlightLabels,
|
|
242
|
-
...providerLabels?.highlightLabels,
|
|
243
|
-
...labels?.highlightLabels,
|
|
244
|
-
},
|
|
245
|
-
highlightEmpty: {
|
|
246
|
-
...recordPageLabels.highlightEmpty,
|
|
247
|
-
...providerLabels?.highlightEmpty,
|
|
248
|
-
...labels?.highlightEmpty,
|
|
249
|
-
},
|
|
250
|
-
}), [labels, providerLabels]);
|
|
407
|
+
export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, sections, highlights: highlightsProp, ...rest }) {
|
|
408
|
+
const t = useRecordPageLabels(labels);
|
|
251
409
|
const [favorite, setFavorite] = React.useState(false);
|
|
252
410
|
// Notes/Tasks are controlled when the caller supplies `notes`/`tasks` (even
|
|
253
411
|
// `[]`) -- otherwise RecordPage keeps its own self-contained state, as
|
|
@@ -259,11 +417,6 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
|
|
|
259
417
|
const notes = notesProp ?? internalNotes;
|
|
260
418
|
const tasks = tasksProp ?? internalTasks;
|
|
261
419
|
const [showAllActivity, setShowAllActivity] = React.useState(false);
|
|
262
|
-
// Collapsible + searchable Record Details.
|
|
263
|
-
const [detailsOpen, setDetailsOpen] = React.useState(true);
|
|
264
|
-
const [searchOpen, setSearchOpen] = React.useState(false);
|
|
265
|
-
const [fieldQuery, setFieldQuery] = React.useState("");
|
|
266
|
-
const [showAllValues, setShowAllValues] = React.useState(false);
|
|
267
420
|
// Drag-resizable left details column.
|
|
268
421
|
const [detailsW, setDetailsW] = React.useState(320);
|
|
269
422
|
const [resizing, setResizing] = React.useState(false);
|
|
@@ -300,17 +453,6 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
|
|
|
300
453
|
? record[entityCol.key]
|
|
301
454
|
: undefined;
|
|
302
455
|
const name = entity?.name ?? "Record";
|
|
303
|
-
const fields = columns.filter((col) => col.type !== "entity");
|
|
304
|
-
const q = fieldQuery.trim().toLowerCase();
|
|
305
|
-
const matchedFields = q
|
|
306
|
-
? fields.filter((col) => col.label.toLowerCase().includes(q))
|
|
307
|
-
: fields;
|
|
308
|
-
// Shows the first 5 fields; "View all values" reveals the rest.
|
|
309
|
-
const FIELD_LIMIT = 5;
|
|
310
|
-
const canRevealFields = !q && matchedFields.length > FIELD_LIMIT;
|
|
311
|
-
const shownFields = canRevealFields && !showAllValues
|
|
312
|
-
? matchedFields.slice(0, FIELD_LIMIT)
|
|
313
|
-
: matchedFields;
|
|
314
456
|
const byKey = React.useMemo(() => new Map(columns.map((col) => [col.key, col])), [columns]);
|
|
315
457
|
// The Overview "Highlights" — a fixed set of enrichment stats (measured
|
|
316
458
|
// from the live app), a couple derived from the record, the rest empty.
|
|
@@ -328,7 +470,11 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
|
|
|
328
470
|
: n >= 1000
|
|
329
471
|
? "1K–10K"
|
|
330
472
|
: "<1K";
|
|
331
|
-
|
|
473
|
+
// #528: the built-in CRM Highlights set, used when the caller omits
|
|
474
|
+
// `highlights` entirely -- a caller passing `highlights` (an array, or
|
|
475
|
+
// `false` to omit the section) replaces this wholesale, the same
|
|
476
|
+
// "caller's own list, not a merge" choice `tabs` already makes (#379).
|
|
477
|
+
const defaultHighlights = [
|
|
332
478
|
{
|
|
333
479
|
label: t.highlightLabels.connectionStrength,
|
|
334
480
|
icon: Zap,
|
|
@@ -366,6 +512,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
|
|
|
366
512
|
empty: t.highlightEmpty.employeeRange,
|
|
367
513
|
},
|
|
368
514
|
];
|
|
515
|
+
const highlights = highlightsProp === undefined ? defaultHighlights : highlightsProp;
|
|
369
516
|
const ti = { size: 15, strokeWidth: 1.75 };
|
|
370
517
|
const builtinTabs = [
|
|
371
518
|
{ value: "overview", label: t.tabs.overview, icon: _jsx(LayoutGrid, { ...ti }) },
|
|
@@ -448,7 +595,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
|
|
|
448
595
|
const builtinPanel = (active) => {
|
|
449
596
|
switch (active) {
|
|
450
597
|
case "overview":
|
|
451
|
-
return (_jsxs("div", { className: "crisp-recordpage-panel", children: [_jsxs("section", { children: [_jsx("div", { className: "crisp-recordpage-section-title", children: t.highlights }), _jsx("div", { className: "crisp-recordpage-hl-card", children: _jsx("div", { className: "crisp-recordpage-hl-grid", children: highlights.map((h) => (_jsxs("div", { className: "crisp-recordpage-hl-cell", children: [_jsx(h.icon, { className: "crisp-recordpage-hl-icon", size: 15, strokeWidth: 1.75 }), _jsx("div", { className: "crisp-recordpage-hl-label", children: h.label }), _jsx("div", { className: "crisp-recordpage-hl-value", children: h.value ?? (_jsx("span", { className: "crisp-recordpage-hl-value--empty", children: h.empty })) })] }, h.label))) }) })] }), activityFeed, _jsxs("section", { children: [_jsx(SectionHead, { title: t.tabs.emails, count: 0 }), emailsEmpty] }), _jsx(NotesSection, { notes: notes, onAdd: addNote, labels: t }), _jsx(TasksSection, { tasks: tasks, onAdd: addTask, onToggle: toggleTask, labels: t })] }));
|
|
598
|
+
return (_jsxs("div", { className: "crisp-recordpage-panel", children: [highlights !== false && (_jsxs("section", { children: [_jsx("div", { className: "crisp-recordpage-section-title", children: t.highlights }), _jsx("div", { className: "crisp-recordpage-hl-card", children: _jsx("div", { className: "crisp-recordpage-hl-grid", children: highlights.map((h) => (_jsxs("div", { className: "crisp-recordpage-hl-cell", children: [h.icon && (_jsx(h.icon, { className: "crisp-recordpage-hl-icon", size: 15, strokeWidth: 1.75 })), _jsx("div", { className: "crisp-recordpage-hl-label", children: h.label }), _jsx("div", { className: "crisp-recordpage-hl-value", children: h.value ?? (_jsx("span", { className: "crisp-recordpage-hl-value--empty", children: h.empty })) })] }, h.label))) }) })] })), activityFeed, _jsxs("section", { children: [_jsx(SectionHead, { title: t.tabs.emails, count: 0 }), emailsEmpty] }), _jsx(NotesSection, { notes: notes, onAdd: addNote, labels: t }), _jsx(TasksSection, { tasks: tasks, onAdd: addTask, onToggle: toggleTask, labels: t })] }));
|
|
452
599
|
case "activity":
|
|
453
600
|
return _jsx("div", { className: "crisp-recordpage-panel", children: activityFeed });
|
|
454
601
|
case "notes":
|
|
@@ -474,81 +621,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
|
|
|
474
621
|
const effectivePanel = tabsProp
|
|
475
622
|
? (active) => tabsProp.find((tb) => tb.id === active)?.render()
|
|
476
623
|
: builtinPanel;
|
|
477
|
-
return (_jsxs("div", { className: recordpageRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-topbar", children: [_jsxs("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [_jsx("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), _jsx("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), _jsx("span", { className: "crisp-recordpage-crumb-name", children: name })] }), _jsxs("div", { className: "crisp-recordpage-actions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [_jsx(Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && (_jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? (_jsx(Minimize2, { size: 15, strokeWidth: 1.75 })) : (_jsx(Maximize2, { size: 15, strokeWidth: 1.75 })) })), _jsxs(Menu, { align: "end", "aria-label": t.recordActions, trigger: _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [_jsx(MenuItem, { icon: _jsx(Copy, { size: 15, strokeWidth: 1.75 }), shortcut: "\u2318C", children: t.copyRecordLink }), _jsx(MenuItem, { icon: _jsx(Plus, { size: 15, strokeWidth: 1.75 }), children: t.addToList }), _jsx(MenuItem, { icon: _jsx(Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, shortcut: "\u232B", children: t.deleteRecord })] })] })] }), _jsxs("div", { className: "crisp-recordpage-body", children: [_jsxs("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [_jsxs("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : (_jsx(Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), _jsx("span", { className: "crisp-recordpage-entity-name", children: name }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: _jsx(Pencil, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "data-active": favorite ? "" : undefined, "aria-pressed": favorite, onClick: () => setFavorite((f) => !f), "aria-label": favorite ? t.removeFromFavourites : t.addToFavourites, children: _jsx(Star, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-quickactions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.copyRecordLink, children: _jsx(Copy, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.logANote, children: _jsx(FileText, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.linkRecords, children: _jsx(Share2, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.createATask, children: _jsx(SquareCheck, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsxs("
|
|
478
|
-
// biome-ignore lint/a11y/noAutofocus: focusing the just-opened field search is expected
|
|
479
|
-
autoFocus: true, type: "text", className: "crisp-recordpage-fieldsearch", placeholder: t.searchPlaceholder, "aria-label": t.searchFields, value: fieldQuery, onChange: (e) => setFieldQuery(e.target.value), onKeyDown: (e) => {
|
|
480
|
-
if (e.key === "Escape") {
|
|
481
|
-
setSearchOpen(false);
|
|
482
|
-
setFieldQuery("");
|
|
483
|
-
}
|
|
484
|
-
} })) : (_jsxs("button", { type: "button", className: "crisp-recordpage-collapse", "data-collapsed": detailsOpen ? undefined : "", "aria-expanded": detailsOpen, onClick: () => setDetailsOpen((o) => !o), children: [t.recordDetails, _jsx(ChevronDown, { className: "crisp-recordpage-chev", size: 12, strokeWidth: 2.5 })] })), _jsx("button", { type: "button", className: "crisp-recordpage-detailsearch", "data-active": searchOpen ? "" : undefined, "aria-label": searchOpen ? t.closeSearch : t.searchFields, "aria-pressed": searchOpen, onClick: () => {
|
|
485
|
-
setSearchOpen((s) => !s);
|
|
486
|
-
setFieldQuery("");
|
|
487
|
-
if (!searchOpen)
|
|
488
|
-
setDetailsOpen(true);
|
|
489
|
-
}, children: searchOpen ? (_jsx(X, { size: 14, strokeWidth: 1.75 })) : (_jsx(Search, { size: 14, strokeWidth: 1.75 })) })] }), detailsOpen &&
|
|
490
|
-
(shownFields.length > 0 ? (_jsxs(_Fragment, { children: [shownFields.map((col) => {
|
|
491
|
-
// #431: a field only actually opens when there's an
|
|
492
|
-
// `onEdit` to commit through -- `editable` with nothing
|
|
493
|
-
// to call would be a dead click.
|
|
494
|
-
const canEdit = Boolean(col.editable && onEdit);
|
|
495
|
-
const isPending = pendingKey === col.key;
|
|
496
|
-
const fieldError = error?.key === col.key ? error.message : null;
|
|
497
|
-
const label = (_jsxs("span", { className: "crisp-recordpage-field-label", children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : null, _jsx("span", { className: "crisp-recordpage-field-label-text", children: col.label })] }));
|
|
498
|
-
// `switch` is always its own live widget -- no
|
|
499
|
-
// click-to-open, no separate edit/view mode: flipping the
|
|
500
|
-
// toggle IS the commit gesture, and there's no
|
|
501
|
-
// meaningfully different "rest" display for a boolean
|
|
502
|
-
// (same exception `DataTable`'s own `editor` makes, #453).
|
|
503
|
-
if (canEdit && col.editor === "switch") {
|
|
504
|
-
const value = record[col.key];
|
|
505
|
-
return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsxs("span", { className: "crisp-recordpage-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.label}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commit(record.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
506
|
-
}
|
|
507
|
-
// `select` opens crisp's own `Select` on a SINGLE click
|
|
508
|
-
// (#457, corrects #431/#385's original always-live
|
|
509
|
-
// copy of the same regression #453 fixed on `DataTable`)
|
|
510
|
-
// -- like every other editable type here, at rest it
|
|
511
|
-
// shows its value (see `FieldValue`'s own `col.editor
|
|
512
|
-
// === "select"` case) via the generic click-to-open
|
|
513
|
-
// branch below, and only mounts `Select` once
|
|
514
|
-
// `editingKey` matches. `DataTable`'s own double-click
|
|
515
|
-
// gate doesn't apply: `RecordPage`'s plain text/date
|
|
516
|
-
// fields already open on a single click (measured live
|
|
517
|
-
// for #431), so `select` matches that, not `DataTable`.
|
|
518
|
-
if (canEdit &&
|
|
519
|
-
editingKey === col.key &&
|
|
520
|
-
col.editor === "select") {
|
|
521
|
-
const value = record[col.key];
|
|
522
|
-
return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx(RecordPageSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commit(record.id, col.key, v), onCancel: closeEditing }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
523
|
-
}
|
|
524
|
-
if (canEdit && editingKey === col.key) {
|
|
525
|
-
const value = record[col.key];
|
|
526
|
-
const editor = editableInputType(col.type);
|
|
527
|
-
return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx("input", { className: "crisp-recordpage-edit",
|
|
528
|
-
// biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
|
|
529
|
-
autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
|
|
530
|
-
if (isPending)
|
|
531
|
-
return;
|
|
532
|
-
if (e.key === "Enter")
|
|
533
|
-
commit(record.id, col.key, e.currentTarget.value);
|
|
534
|
-
else if (e.key === "Escape")
|
|
535
|
-
closeEditing();
|
|
536
|
-
}, onBlur: (e) => {
|
|
537
|
-
if (isPending)
|
|
538
|
-
return;
|
|
539
|
-
commit(record.id, col.key, e.currentTarget.value);
|
|
540
|
-
} }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
541
|
-
}
|
|
542
|
-
return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", "data-editable": canEdit ? "" : undefined, children: [canEdit ? (_jsx("button", { type: "button", className: "crisp-recordpage-edit-trigger", onClick: () => openEditing(col.key), children: _jsx(FieldValue, { col: col, row: record, labels: t }) })) : (_jsx(FieldValue, { col: col, row: record, labels: t })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
|
|
543
|
-
}), canRevealFields && (_jsx("button", { type: "button", className: "crisp-recordpage-viewall crisp-recordpage-viewall--section", onClick: () => {
|
|
544
|
-
// Revealing all fields also opens the (focused)
|
|
545
|
-
// field search; collapsing closes it.
|
|
546
|
-
const next = !showAllValues;
|
|
547
|
-
setShowAllValues(next);
|
|
548
|
-
setSearchOpen(next);
|
|
549
|
-
if (!next)
|
|
550
|
-
setFieldQuery("");
|
|
551
|
-
}, children: showAllValues ? t.viewLess : t.viewAllValues }))] })) : (_jsxs("div", { className: "crisp-recordpage-noresults", children: ["No fields match \u201C", fieldQuery, "\u201D."] })))] }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsx(SectionHead, { title: t.tabs.lists, action: _jsxs("button", { type: "button", className: "crisp-recordpage-addbtn", children: [_jsx(Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), _jsx("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), _jsx("div", { className: "crisp-recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": 300, "aria-valuemax": 560, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
|
|
624
|
+
return (_jsxs("div", { className: recordpageRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-topbar", children: [_jsxs("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [_jsx("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), _jsx("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), _jsx("span", { className: "crisp-recordpage-crumb-name", children: name })] }), _jsxs("div", { className: "crisp-recordpage-actions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [_jsx(Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && (_jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? (_jsx(Minimize2, { size: 15, strokeWidth: 1.75 })) : (_jsx(Maximize2, { size: 15, strokeWidth: 1.75 })) })), _jsxs(Menu, { align: "end", "aria-label": t.recordActions, trigger: _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [_jsx(MenuItem, { icon: _jsx(Copy, { size: 15, strokeWidth: 1.75 }), shortcut: "\u2318C", children: t.copyRecordLink }), _jsx(MenuItem, { icon: _jsx(Plus, { size: 15, strokeWidth: 1.75 }), children: t.addToList }), _jsx(MenuItem, { icon: _jsx(Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, shortcut: "\u232B", children: t.deleteRecord })] })] })] }), _jsxs("div", { className: "crisp-recordpage-body", children: [_jsxs("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [_jsxs("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : (_jsx(Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), _jsx("span", { className: "crisp-recordpage-entity-name", children: name }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: _jsx(Pencil, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "data-active": favorite ? "" : undefined, "aria-pressed": favorite, onClick: () => setFavorite((f) => !f), "aria-label": favorite ? t.removeFromFavourites : t.addToFavourites, children: _jsx(Star, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-quickactions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.copyRecordLink, children: _jsx(Copy, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.logANote, children: _jsx(FileText, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.linkRecords, children: _jsx(Share2, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.createATask, children: _jsx(SquareCheck, { size: 15, strokeWidth: 1.75 }) })] }), _jsx(RecordDetails, { record: record, columns: columns, sections: sections, onEdit: onEdit, labels: labels }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsx(SectionHead, { title: t.tabs.lists, action: _jsxs("button", { type: "button", className: "crisp-recordpage-addbtn", children: [_jsx(Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), _jsx("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), _jsx("div", { className: "crisp-recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": 300, "aria-valuemax": 560, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
|
|
552
625
|
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
|
|
553
626
|
return;
|
|
554
627
|
// The pointer drag learned its direction in #212; this did not.
|
|
@@ -63,6 +63,11 @@ const config = defineRecipe({
|
|
|
63
63
|
& .crisp-recordpage-detailsearch { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: ${c.size.chip}; flex-shrink: 0; padding: 0; margin: 0; border: none; border-radius: ${c.radius.sm}; background: none; color: ${c.fgMuted}; font-family: inherit; cursor: pointer; transition: background-color .12s, color .12s; }
|
|
64
64
|
& .crisp-recordpage-detailsearch:hover, & .crisp-recordpage-detailsearch[data-active] { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
|
|
65
65
|
& .crisp-recordpage-noresults { padding: ${c.space["1.5"]} ${c.space["1"]}; font-size: ${c.text.md}; color: ${c.fgMuted}; }
|
|
66
|
+
/* A caller-supplied \`sections\` grouping (#528) is not an Attio-measured
|
|
67
|
+
shape (its own Details column is flat) -- this heading reuses
|
|
68
|
+
.crisp-recordpage-section-title's own type treatment verbatim, just
|
|
69
|
+
spaced from the group above it. */
|
|
70
|
+
& .crisp-recordpage-fieldsection-title { margin-top: ${c.space["3"]}; }
|
|
66
71
|
& .crisp-recordpage-morevalues { display: inline-flex; align-items: center; padding: 0; margin: 0; border: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgMuted}; cursor: pointer; }
|
|
67
72
|
& .crisp-recordpage-morevalues:hover { color: ${c.fgPrimary}; }
|
|
68
73
|
& .crisp-recordpage-viewall { flex-basis: 100%; margin-top: ${c.space["0.5"]}; border: none; background: none; padding: 0; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); font-family: inherit; font-size: ${c.text.sm}; font-weight: ${c.weight.regular}; cursor: pointer; text-align: start; }
|
|
@@ -113,7 +113,7 @@ const exec = (cmd, value) => document.execCommand(cmd, false, value);
|
|
|
113
113
|
* reach the identical menu, sitting at the end of the body's last line) —
|
|
114
114
|
* only the two entry points #426 asked for.
|
|
115
115
|
*/
|
|
116
|
-
export const RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", imagePromptLabel = "Image URL", textColorLabel = "Text", highlightColorLabel = "Highlight", clearColorLabel = "Default", blockMenuLabel = "Body", blockMenuSearchPlaceholder = "Search items…", blockMenuEmptyLabel = "No matching blocks", mentionTrigger = "@", renderMentionPicker, readOnly = false, ...rest }, ref) {
|
|
116
|
+
export const RichTextEditor = React.forwardRef(function RichTextEditor({ defaultTitle = "", defaultHtml = "", titlePlaceholder = "Untitled", placeholder = "Start writing, or press a toolbar button…", onChange, className, titleLabel = "Title", bodyLabel = "Note body", linkPromptLabel = "Link URL", imagePromptLabel = "Image URL", textColorLabel = "Text", highlightColorLabel = "Highlight", clearColorLabel = "Default", blockMenuLabel = "Body", blockMenuSearchPlaceholder = "Search items…", blockMenuEmptyLabel = "No matching blocks", mentionTrigger = "@", renderMentionPicker, readOnly = false, showTitle = true, ...rest }, ref) {
|
|
117
117
|
const titleRef = React.useRef(null);
|
|
118
118
|
const bodyRef = React.useRef(null);
|
|
119
119
|
const [bar, setBar] = React.useState(null);
|
|
@@ -514,12 +514,12 @@ export const RichTextEditor = React.forwardRef(function RichTextEditor({ default
|
|
|
514
514
|
ol: _jsx(ListOrdered, { ...menuIc }),
|
|
515
515
|
image: _jsx(ImageIcon, { ...menuIc }),
|
|
516
516
|
};
|
|
517
|
-
return (_jsxs("div", { className: richtextRecipe({ className }), ...rest, children: [_jsx("div", { ref: titleRef, className: "crisp-rte-title", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-label": titleLabel, "aria-readonly": readOnly || undefined, "data-placeholder": titlePlaceholder, onInput: readOnly
|
|
517
|
+
return (_jsxs("div", { className: richtextRecipe({ className }), ...rest, children: [showTitle && (_jsx("div", { ref: titleRef, className: "crisp-rte-title", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-label": titleLabel, "aria-readonly": readOnly || undefined, "data-placeholder": titlePlaceholder, onInput: readOnly
|
|
518
518
|
? undefined
|
|
519
519
|
: () => {
|
|
520
520
|
syncEmpty(titleRef.current);
|
|
521
521
|
emit();
|
|
522
|
-
} }), _jsx("div", { ref: bodyRef, className: "crisp-rte-body", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-multiline": "true", "aria-label": bodyLabel, "aria-readonly": readOnly || undefined, "data-placeholder": placeholder, onInput: readOnly
|
|
522
|
+
} })), _jsx("div", { ref: bodyRef, className: "crisp-rte-body", contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", tabIndex: readOnly ? -1 : 0, "aria-multiline": "true", "aria-label": bodyLabel, "aria-readonly": readOnly || undefined, "data-placeholder": placeholder, onInput: readOnly
|
|
523
523
|
? undefined
|
|
524
524
|
: () => {
|
|
525
525
|
syncEmpty(bodyRef.current);
|
|
@@ -3,13 +3,22 @@ import { contract as c } from "../../tokens/index.js";
|
|
|
3
3
|
// A settings section — measured from Settings pages: a 16px/600 title
|
|
4
4
|
// with an optional subtitle, then a stack of rows (form fields, toggles…). Rows
|
|
5
5
|
// gap evenly; an optional trailing action sits on the title row.
|
|
6
|
+
//
|
|
7
|
+
// Title line-height/letter-spacing and subtitle font-size/line-height
|
|
8
|
+
// re-measured live at app.attio.com/<workspace>/settings/account against a
|
|
9
|
+
// subsection heading ("Time preferences") — crisp#522. The subtitle's own
|
|
10
|
+
// colour (measured `rgba(0,0,0,0.63)`, a black-alpha value) is left as
|
|
11
|
+
// `fgMuted` pending a dark-mode measurement: a raw alpha-black has no
|
|
12
|
+
// existing token match and would need its own light/dark pair to stay
|
|
13
|
+
// legible in dark mode (Lesson: theme-constant vs theme-flipping colours
|
|
14
|
+
// must each be independently verified).
|
|
6
15
|
const config = defineRecipe({
|
|
7
16
|
name: "settingssection",
|
|
8
17
|
base: `display: flex; flex-direction: column; box-sizing: border-box; font-family: ${c.font.sans};
|
|
9
18
|
& .crisp-settingssection-head { display: flex; align-items: flex-start; gap: ${c.space["2"]}; }
|
|
10
19
|
& .crisp-settingssection-heading { flex: 1; min-width: 0; }
|
|
11
|
-
& .crisp-settingssection-title { font-size: ${c.text.xl}; line-height:
|
|
12
|
-
& .crisp-settingssection-sub { margin-top: ${c.space["0.5"]}; font-size: ${c.text.
|
|
20
|
+
& .crisp-settingssection-title { font-size: ${c.text.xl}; line-height: 20px; font-weight: ${c.weight.semibold}; letter-spacing: -0.16px; color: ${c.fgPrimary}; }
|
|
21
|
+
& .crisp-settingssection-sub { margin-top: ${c.space["0.5"]}; font-size: ${c.text.lg}; line-height: 20px; color: ${c.fgMuted}; }
|
|
13
22
|
& .crisp-settingssection-action { flex-shrink: 0; }
|
|
14
23
|
& .crisp-settingssection-body { display: flex; flex-direction: column; gap: ${c.space["4"]}; margin-top: ${c.space["3"]}; }`,
|
|
15
24
|
variants: {},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCrispMessage, useCrispMessages } from "../../core/index.js";
|
|
2
3
|
import { tasklistRecipe } from "./tasklist.recipe.js";
|
|
3
4
|
function Row({ task, onToggle, completeLabel, incompleteLabel, }) {
|
|
4
5
|
return (_jsxs("div", { className: "crisp-tasklist-row", "data-done": task.done ? "" : undefined, children: [_jsx("input", { type: "checkbox", className: "crisp-tasklist-check", checked: !!task.done, "aria-label": task.done ? incompleteLabel : completeLabel, onChange: () => onToggle?.(task.id, !task.done) }), _jsx("span", { className: "crisp-tasklist-title", children: task.title }), (task.due != null || task.meta != null) && (_jsxs("span", { className: "crisp-tasklist-meta", children: [task.due != null && (_jsx("span", { className: "crisp-tasklist-due", "data-overdue": task.overdue ? "" : undefined, children: task.due })), task.meta] }))] }));
|
|
@@ -8,9 +9,15 @@ function Row({ task, onToggle, completeLabel, incompleteLabel, }) {
|
|
|
8
9
|
* count, then rows of a round complete-checkbox + title + trailing meta.
|
|
9
10
|
* Completed rows strike through; overdue dates go red.
|
|
10
11
|
*/
|
|
11
|
-
export function TaskList({ tasks, groups, onToggle, emptyLabel
|
|
12
|
+
export function TaskList({ tasks, groups, onToggle, emptyLabel: emptyLabelProp, className, completeLabel: completeLabelProp, incompleteLabel: incompleteLabelProp, ...rest }) {
|
|
12
13
|
const resolved = groups ?? (tasks?.length ? [{ label: null, tasks }] : []);
|
|
13
14
|
const total = resolved.reduce((s, g) => s + g.tasks.length, 0);
|
|
15
|
+
// crisp/#471: `emptyLabel` is typed `React.ReactNode`, so it resolves by
|
|
16
|
+
// hand rather than through `useCrispMessage`, which is string-only.
|
|
17
|
+
const crispMessages = useCrispMessages();
|
|
18
|
+
const emptyLabel = emptyLabelProp ?? crispMessages["No tasks"] ?? "No tasks";
|
|
19
|
+
const completeLabel = useCrispMessage(completeLabelProp, "Mark complete");
|
|
20
|
+
const incompleteLabel = useCrispMessage(incompleteLabelProp, "Mark incomplete");
|
|
14
21
|
return (_jsx("div", { className: tasklistRecipe({ className }), ...rest, children: total === 0 ? (_jsx("div", { className: "crisp-tasklist-empty", children: emptyLabel })) : (resolved.map((g, gi) => (_jsxs("div", { children: [g.label != null && (_jsxs("div", { className: "crisp-tasklist-group", children: [g.label, _jsx("span", { className: "crisp-tasklist-group-count", children: g.tasks.length })] })), g.tasks.map((t) => (_jsx(Row, { task: t, onToggle: onToggle, completeLabel: completeLabel, incompleteLabel: incompleteLabel }, t.id)))] }, gi)))) }));
|
|
15
22
|
}
|
|
16
23
|
TaskList.displayName = "TaskList";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
.crisp-headerbar {
|
|
2
|
+
box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: var(--crisp-space-2); height: var(--crisp-size-header); flex-shrink: 0; padding: 0 var(--crisp-space-3); font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
3
|
+
&[data-bordered] { border-bottom: 1px solid var(--crisp-border-subtle); }
|
|
4
|
+
& .crisp-headerbar-leading { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; min-width: 0; }
|
|
5
|
+
& .crisp-headerbar-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--crisp-space-2); overflow: hidden; }
|
|
6
|
+
& .crisp-headerbar-trailing { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; }
|
|
7
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
@layer crisp-components {
|
|
2
|
+
.crisp-headerbar {
|
|
3
|
+
box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: var(--crisp-space-2); height: var(--crisp-size-header); flex-shrink: 0; padding: 0 var(--crisp-space-3); font-family: var(--crisp-font-sans); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
|
|
4
|
+
&[data-bordered] { border-bottom: 1px solid var(--crisp-border-subtle); }
|
|
5
|
+
& .crisp-headerbar-leading { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; min-width: 0; }
|
|
6
|
+
& .crisp-headerbar-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--crisp-space-2); overflow: hidden; }
|
|
7
|
+
& .crisp-headerbar-trailing { display: flex; align-items: center; gap: var(--crisp-space-2); flex-shrink: 0; }
|
|
8
|
+
}
|
|
9
|
+
}
|