@crisp-ui-kit/crisp 0.34.0 → 0.36.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/bulkbar.css +3 -0
- package/dist/bulkbar.layered.css +3 -0
- package/dist/cjs/components/bulkbar/bulkbar.js +1 -1
- package/dist/cjs/components/bulkbar/bulkbar.recipe.js +6 -0
- package/dist/cjs/components/datatable/datatable.js +65 -5
- package/dist/cjs/components/datatable/datatable.recipe.js +2 -0
- package/dist/cjs/components/filldoc/filldoc.js +102 -0
- package/dist/cjs/components/filldoc/filldoc.recipe.js +211 -0
- package/dist/cjs/components/filldoc/index.js +10 -0
- package/dist/cjs/components/filldoc/serialize.js +39 -0
- package/dist/cjs/components/fillsheet/fillsheet.js +719 -0
- package/dist/cjs/components/fillsheet/fillsheet.recipe.js +325 -0
- package/dist/cjs/components/fillsheet/index.js +12 -0
- package/dist/cjs/components/fillsheet/serialize.js +82 -0
- package/dist/cjs/index.js +2 -0
- package/dist/cjs/product/recordpanel/recordpanel.js +4 -2
- package/dist/cjs/product/recordpanel/recordpanel.recipe.js +8 -0
- package/dist/cjs/product/sidebar/sidebar.js +16 -3
- package/dist/datatable.css +13 -5
- package/dist/datatable.layered.css +13 -5
- package/dist/esm/components/bulkbar/bulkbar.js +1 -1
- package/dist/esm/components/bulkbar/bulkbar.recipe.js +6 -0
- package/dist/esm/components/datatable/datatable.js +65 -5
- package/dist/esm/components/datatable/datatable.recipe.js +2 -0
- package/dist/esm/components/filldoc/filldoc.js +66 -0
- package/dist/esm/components/filldoc/filldoc.recipe.js +208 -0
- package/dist/esm/components/filldoc/index.js +3 -0
- package/dist/esm/components/filldoc/serialize.js +35 -0
- package/dist/esm/components/fillsheet/fillsheet.js +683 -0
- package/dist/esm/components/fillsheet/fillsheet.recipe.js +322 -0
- package/dist/esm/components/fillsheet/index.js +3 -0
- package/dist/esm/components/fillsheet/serialize.js +76 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/product/recordpanel/recordpanel.js +4 -2
- package/dist/esm/product/recordpanel/recordpanel.recipe.js +8 -0
- package/dist/esm/product/sidebar/sidebar.js +17 -4
- package/dist/filldoc.css +31 -0
- package/dist/filldoc.d.ts +7 -0
- package/dist/filldoc.layered.css +33 -0
- package/dist/fillsheet.css +29 -0
- package/dist/fillsheet.d.ts +7 -0
- package/dist/fillsheet.layered.css +31 -0
- package/dist/recordpanel.css +6 -0
- package/dist/recordpanel.layered.css +6 -0
- package/dist/styles.css +84 -5
- package/dist/styles.layered.css +84 -5
- package/dist/types/components/datatable/datatable.d.ts +7 -1
- package/dist/types/components/filldoc/filldoc.d.ts +79 -0
- package/dist/types/components/filldoc/filldoc.recipe.d.ts +201 -0
- package/dist/types/components/filldoc/index.d.ts +3 -0
- package/dist/types/components/filldoc/serialize.d.ts +5 -0
- package/dist/types/components/fillsheet/fillsheet.d.ts +165 -0
- package/dist/types/components/fillsheet/fillsheet.recipe.d.ts +202 -0
- package/dist/types/components/fillsheet/index.d.ts +3 -0
- package/dist/types/components/fillsheet/serialize.d.ts +45 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/product/recordpanel/recordpanel.d.ts +11 -1
- package/dist/types/product/sidebar/sidebar.d.ts +56 -0
- package/package.json +11 -1
|
@@ -15,6 +15,12 @@ const config = defineRecipe({
|
|
|
15
15
|
& .crisp-bulkbar-count { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.menuRow}; margin: 0; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
|
|
16
16
|
& .crisp-bulkbar-count:hover { background: color-mix(in srgb, ${c.fgPrimary} 8%, transparent); color: ${c.fgPrimary}; }
|
|
17
17
|
& .crisp-bulkbar-count svg { flex-shrink: 0; }
|
|
18
|
+
/* the ", Clear selection" ARIA concatenates onto the pill's name (#324)
|
|
19
|
+
-- read, never drawn. Same technique as Slider's thumblabel. */
|
|
20
|
+
& .crisp-bulkbar-clearlabel {
|
|
21
|
+
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
|
22
|
+
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
|
23
|
+
}
|
|
18
24
|
& .crisp-bulkbar-btn { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.menuRow}; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: transparent; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
|
|
19
25
|
& .crisp-bulkbar-btn:hover { background: ${c.bgRaised}; }
|
|
20
26
|
& .crisp-bulkbar-btn--danger { color: ${c.bgDanger}; }
|
|
@@ -135,7 +135,7 @@ function renderTyped(col, value, avatarTone, display, locale = "en-US") {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
const CHECK_W = 40;
|
|
138
|
-
export function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel = "Select all rows", selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement = (count) => count === 0 ? "Selection cleared" : `${count} selected`, columnMenu = false, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder = "Search…", searchLabel = "Search table", onEdit, stickyFirst = true, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", empty = "No results.", showCount = true, countLabel = (shown, total) => shown === total
|
|
138
|
+
export function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel = "Select all rows", selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement = (count) => count === 0 ? "Selection cleared" : `${count} selected`, columnMenu = false, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder = "Search…", searchLabel = "Search table", onEdit, stickyFirst = true, stickyLast = false, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel = "New record", empty = "No results.", showCount = true, countLabel = (shown, total) => shown === total
|
|
139
139
|
? `${total} ${total === 1 ? "row" : "rows"}`
|
|
140
140
|
: `${shown} of ${total}`, toolbar, className, reorderRowLabel = "Reorder row", ...rest }) {
|
|
141
141
|
// Two different defaults on purpose. FORMATTING falls back to "en-US", which
|
|
@@ -235,6 +235,21 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
235
235
|
}
|
|
236
236
|
const [editing, setEditing] = React.useState(null);
|
|
237
237
|
const [scrolledX, setScrolledX] = React.useState(false);
|
|
238
|
+
// Mirrors `scrolledX` for the trailing edge: true while there is still
|
|
239
|
+
// unrevealed content sliding under the sticky trailing column, i.e. while
|
|
240
|
+
// NOT scrolled all the way to the end. Recomputed after every render (see
|
|
241
|
+
// the effect below), not just on scroll, because a column resize or a data
|
|
242
|
+
// change can move `scrollWidth` without firing a scroll event.
|
|
243
|
+
const [scrolledXEnd, setScrolledXEnd] = React.useState(false);
|
|
244
|
+
const scrollRef = React.useRef(null);
|
|
245
|
+
React.useEffect(() => {
|
|
246
|
+
if (!stickyLast)
|
|
247
|
+
return;
|
|
248
|
+
const el = scrollRef.current;
|
|
249
|
+
if (!el)
|
|
250
|
+
return;
|
|
251
|
+
setScrolledXEnd(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
|
252
|
+
});
|
|
238
253
|
// column widths (drag-to-resize) + fixed-layout sizing
|
|
239
254
|
const [widths, setWidths] = React.useState({});
|
|
240
255
|
const [resizingKey, setResizingKey] = React.useState(null);
|
|
@@ -433,6 +448,43 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
433
448
|
};
|
|
434
449
|
return {};
|
|
435
450
|
};
|
|
451
|
+
// Mirror of the group above, for a single trailing column (#327): the last
|
|
452
|
+
// VISIBLE data column (`columns`, not `allColumns` — a hidden column can't
|
|
453
|
+
// be the one that pins). `insetInlineEnd`, never `right`, for the same
|
|
454
|
+
// direction-safety reason `stickyCol` uses `insetInlineStart`: a physical
|
|
455
|
+
// `right` pins to the trailing edge only in LTR and swaps to the LEADING
|
|
456
|
+
// edge under `dir="rtl"`, which is backwards for a column meant to stay
|
|
457
|
+
// pinned to the reading direction's end.
|
|
458
|
+
const stickyLastCol = (index) => {
|
|
459
|
+
if (!stickyLast || index !== columns.length - 1)
|
|
460
|
+
return {};
|
|
461
|
+
return {
|
|
462
|
+
"data-sticky": "",
|
|
463
|
+
"data-sticky-edge-end": "",
|
|
464
|
+
style: { insetInlineEnd: 0 },
|
|
465
|
+
};
|
|
466
|
+
};
|
|
467
|
+
// Merges the two groups for a data-column cell. A column can be pinned by
|
|
468
|
+
// both (a one-column table with both flags on) — the header sticky outranks
|
|
469
|
+
// either via its own z-index rule (#285's ordering still holds), and here
|
|
470
|
+
// the two style objects merge rather than one overwriting the other.
|
|
471
|
+
const dataStickyCol = (index) => {
|
|
472
|
+
const lead = stickyCol(index);
|
|
473
|
+
const trail = stickyLastCol(index);
|
|
474
|
+
// Not `!lead["data-sticky"]`: the attribute's value is `""`, which is
|
|
475
|
+
// falsy, so that check silently treated every pinned column as unpinned.
|
|
476
|
+
if (!("data-sticky" in lead) && !("data-sticky" in trail))
|
|
477
|
+
return {};
|
|
478
|
+
return {
|
|
479
|
+
...lead,
|
|
480
|
+
...trail,
|
|
481
|
+
"data-sticky": "",
|
|
482
|
+
style: {
|
|
483
|
+
...lead.style,
|
|
484
|
+
...trail.style,
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
};
|
|
436
488
|
const alignFor = (col) => col.align ??
|
|
437
489
|
(col.type === "number" || col.type === "currency" ? "right" : undefined);
|
|
438
490
|
function startResize(col, e) {
|
|
@@ -470,8 +522,16 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
470
522
|
return (_jsx(ChevronsUpDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
|
|
471
523
|
}
|
|
472
524
|
return (_jsxs("div", { ref: gridRef, className: datatableRecipe({ className }), tabIndex: -1, onKeyDown: onGridKeyDown, ...rest, children: [_jsx("div", { className: "crisp-datatable-live", role: "status", "aria-live": "polite", children: selectionText }), (filterable || showCount || toolbar || columnMenu) && (_jsxs("div", { className: "crisp-datatable-toolbar", children: [columnMenu && (_jsxs(Menu, { align: "start", trigger: _jsxs("button", { type: "button", className: "crisp-datatable-viewsettings", children: [_jsx(SlidersHorizontal, { size: 14, strokeWidth: 1.75 }), "View settings"] }), children: [_jsx("div", { className: "crisp-datatable-group-label", children: "Columns" }), allColumns.map((c) => (_jsxs(MenuItem, { onSelect: () => c.hidden ? showColumn(c.key) : hideColumn(c.key), children: [_jsx(Check, { size: 14, strokeWidth: 2, style: { opacity: c.hidden ? 0 : 1 } }), typeof c.header === "string" ? c.header : c.key] }, c.key)))] })), filterable && (_jsxs("span", { className: "crisp-datatable-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchLabel })] })), showCount && (_jsx("span", { className: "crisp-datatable-count", children: countLabel(rows.length, data.length) })), toolbar] })), _jsxs(Table, { interactive: interactive, surface: surface, style: { minWidth }, scrollProps: {
|
|
473
|
-
|
|
525
|
+
ref: scrollRef,
|
|
526
|
+
onScroll: (e) => {
|
|
527
|
+
const el = e.currentTarget;
|
|
528
|
+
setScrolledX(el.scrollLeft > 0);
|
|
529
|
+
if (stickyLast) {
|
|
530
|
+
setScrolledXEnd(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
|
531
|
+
}
|
|
532
|
+
},
|
|
474
533
|
...(scrolledX ? { "data-scrolled-x": "" } : {}),
|
|
534
|
+
...(scrolledXEnd ? { "data-scrolled-x-end": "" } : {}),
|
|
475
535
|
}, children: [_jsxs("colgroup", { children: [showGrip && _jsx("col", { style: { width: GRIP_W } }), selectable && _jsx("col", { style: { width: CHECK_W } }), columns.map((col) => (_jsx("col", { style: { width: colWidth(col) } }, col.key)))] }), _jsx(TableHeader, { children: _jsxs(TableRow, { children: [showGrip && (_jsx(TableHead, { className: "crisp-datatable-col-grip", ...stickyCol(-2) })), selectable && (_jsx(TableHead, { className: "crisp-datatable-col-check", ...stickyCol(-1), children: _jsx("span", { className: "crisp-datatable-check", children: _jsx(Checkbox, { "aria-label": selectAllLabel, checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }) })), columns.map((col, i) => {
|
|
476
536
|
const Icon = col.icon;
|
|
477
537
|
// The sort direction was painted (`data-sorted`) and never
|
|
@@ -489,7 +549,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
489
549
|
: "descending"
|
|
490
550
|
: "none";
|
|
491
551
|
const inner = (_jsxs("span", { className: "crisp-datatable-th-inner", children: [Icon ? _jsx(Icon, { size: 14, strokeWidth: 1.75 }) : null, _jsx("span", { className: "crisp-datatable-th-label", children: col.header }), col.sortable ? _jsx(ChevronFor, { colKey: col.key }) : null] }));
|
|
492
|
-
return (_jsxs(TableHead, { align: alignFor(col), "aria-sort": ariaSort, ...
|
|
552
|
+
return (_jsxs(TableHead, { align: alignFor(col), "aria-sort": ariaSort, ...dataStickyCol(i), children: [renaming === col.key ? (_jsx("input", {
|
|
493
553
|
// biome-ignore lint/a11y/noAutofocus: the rename item opens this field
|
|
494
554
|
autoFocus: true, className: "crisp-datatable-th-rename", defaultValue: typeof col.header === "string" ? col.header : "", "aria-label": `Rename column`, onBlur: (e) => {
|
|
495
555
|
renameColumn(col.key, e.currentTarget.value);
|
|
@@ -529,7 +589,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
529
589
|
}, children: _jsx(GripVertical, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) })), selectable && (_jsx(TableCell, { className: "crisp-datatable-col-check", ...stickyCol(-1), onClick: (e) => e.stopPropagation(), children: _jsx("span", { className: "crisp-datatable-check", children: _jsx(Checkbox, { "aria-label": selectRowLabel(row), checked: selected.has(row.id), onChange: () => toggleRow(row.id) }) }) })), columns.map((col, i) => {
|
|
530
590
|
const isEditing = editing?.id === row.id && editing?.key === col.key;
|
|
531
591
|
const value = cellValue(col, row);
|
|
532
|
-
const sticky =
|
|
592
|
+
const sticky = dataStickyCol(i);
|
|
533
593
|
if (isEditing) {
|
|
534
594
|
return (_jsx(TableCell, { align: alignFor(col), ...sticky, onClick: (e) => e.stopPropagation(), children: _jsx("input", { className: "crisp-datatable-edit",
|
|
535
595
|
// biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
|
|
@@ -562,6 +622,6 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
562
622
|
}, children: col.render
|
|
563
623
|
? col.render(value, row)
|
|
564
624
|
: renderTyped(col, value, avatarTone, col.format?.(value, row), locale) }, col.key));
|
|
565
|
-
})] }, row.id)))] }, group ?? "__ungrouped")))) }), (hasSummary || onAddRow) && (_jsxs(TableFooter, { children: [hasSummary && (_jsxs(TableRow, { children: [showGrip && _jsx(TableCell, { ...stickyCol(-2) }), selectable && _jsx(TableCell, { ...stickyCol(-1) }), columns.map((col, i) => (_jsx(TableCell, { align: alignFor(col), ...
|
|
625
|
+
})] }, row.id)))] }, group ?? "__ungrouped")))) }), (hasSummary || onAddRow) && (_jsxs(TableFooter, { children: [hasSummary && (_jsxs(TableRow, { children: [showGrip && _jsx(TableCell, { ...stickyCol(-2) }), selectable && _jsx(TableCell, { ...stickyCol(-1) }), columns.map((col, i) => (_jsx(TableCell, { align: alignFor(col), ...dataStickyCol(i), children: col.summary ? col.summary(rows) : null }, col.key)))] })), onAddRow && (_jsx(TableRow, { children: _jsx(TableCell, { className: "crisp-datatable-addcell", colSpan: totalCols, children: _jsxs("button", { type: "button", className: "crisp-datatable-addrow", onClick: onAddRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), addRowLabel] }) }) }))] }))] })] }));
|
|
566
626
|
}
|
|
567
627
|
DataTable.displayName = "DataTable";
|
|
@@ -59,6 +59,8 @@ const config = defineRecipe({
|
|
|
59
59
|
& tbody tr[data-selected] td[data-sticky] { background: color-mix(in srgb, ${c.bgBrandSolid} 10%, ${c.bgSurface}); }
|
|
60
60
|
& tbody tr:hover td[data-sticky] { background: color-mix(in srgb, ${c.fgPrimary} 4%, ${c.bgSurface}); }
|
|
61
61
|
& .crisp-table-scroll[data-scrolled-x] [data-sticky-edge] { clip-path: inset(0 -38px 0 0); box-shadow: 6px 0 16px 4px rgba(0,0,0,0.12); }
|
|
62
|
+
/* mirror of the rule above, for stickyLast's trailing column (#327) */
|
|
63
|
+
& .crisp-table-scroll[data-scrolled-x-end] [data-sticky-edge-end] { clip-path: inset(0 0 0 -38px); box-shadow: -6px 0 16px 4px rgba(0,0,0,0.12); }
|
|
62
64
|
/* add-row + summary footer */
|
|
63
65
|
& .crisp-datatable-addcell { padding: 0; border-top: none; }
|
|
64
66
|
& .crisp-datatable-addrow { display: flex; align-items: center; gap: ${c.space["2"]}; width: 100%; height: ${c.size.tableRow}; padding: 0 ${c.space["2"]}; border: none; border-top: 1px solid ${c.borderSubtle}; background: ${c.bgSurface}; color: ${c.fgMuted}; font-family: inherit; font-size: ${c.text.lg}; cursor: pointer; transition: background-color .12s; }
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cx } from "../../core/index.js";
|
|
5
|
+
import { FillSheet } from "../fillsheet/index.js";
|
|
6
|
+
import { filldocRecipe } from "./filldoc.recipe.js";
|
|
7
|
+
/** Size a textarea to its content; see FillSheet's autoGrow for the reset. */
|
|
8
|
+
function autoGrow(el) {
|
|
9
|
+
if (!el)
|
|
10
|
+
return;
|
|
11
|
+
el.style.height = "auto";
|
|
12
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
13
|
+
}
|
|
14
|
+
let seq = 0;
|
|
15
|
+
const nextId = () => `b${++seq}`;
|
|
16
|
+
const emptyTable = () => ({
|
|
17
|
+
id: nextId(),
|
|
18
|
+
type: "table",
|
|
19
|
+
columns: [{ width: 160 }, { width: 320 }],
|
|
20
|
+
value: [
|
|
21
|
+
["", ""],
|
|
22
|
+
["", ""],
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* A page you can write on, of which a sheet is one block.
|
|
27
|
+
*
|
|
28
|
+
* FillSheet is the table; this is the document it sits in. The two share a
|
|
29
|
+
* look on purpose -- a real page, real margins, square corners, and nothing
|
|
30
|
+
* drawn that is not content -- and the same split between the person who
|
|
31
|
+
* MAKES a form and the person who fills it in: in `edit` you write the prose
|
|
32
|
+
* and add or remove blocks, in `fill` the prose is fixed and only the sheets
|
|
33
|
+
* take input.
|
|
34
|
+
*
|
|
35
|
+
* Every block's controls are hidden until the pointer is in that block. A
|
|
36
|
+
* page covered in handles and grips stops looking like a page, which is the
|
|
37
|
+
* whole thing this is for.
|
|
38
|
+
*/
|
|
39
|
+
export const FillDoc = React.forwardRef(function FillDoc({ blocks, onBlocksChange, mode = "edit", page = "a4", label, addTextLabel = "Text", addHeadingLabel = "Heading", addTableLabel = "Table", removeLabel = "Remove", className, style, ...rest }, forwardedRef) {
|
|
40
|
+
const editable = mode === "edit";
|
|
41
|
+
const replace = (i, block) => {
|
|
42
|
+
const next = blocks.slice();
|
|
43
|
+
next[i] = block;
|
|
44
|
+
onBlocksChange?.(next);
|
|
45
|
+
};
|
|
46
|
+
const insertAfter = (i, block) => {
|
|
47
|
+
const next = blocks.slice();
|
|
48
|
+
next.splice(i + 1, 0, block);
|
|
49
|
+
onBlocksChange?.(next);
|
|
50
|
+
};
|
|
51
|
+
const removeAt = (i) => {
|
|
52
|
+
const next = blocks.slice();
|
|
53
|
+
next.splice(i, 1);
|
|
54
|
+
onBlocksChange?.(next);
|
|
55
|
+
};
|
|
56
|
+
return (_jsx("div", { ...rest, ref: forwardedRef,
|
|
57
|
+
// The role rides WITH the name: `aria-label` on a role-less div is
|
|
58
|
+
// not exposed at all, and a bare `role="group"` on an unnamed page
|
|
59
|
+
// just adds an empty group to the tree. Either both or neither.
|
|
60
|
+
role: label ? "group" : undefined, "aria-label": label, className: cx(filldocRecipe({ page }), className), style: style, children: blocks.map((block, i) => (_jsxs("div", { className: "crisp-filldoc-block", children: [editable && (_jsx("div", { className: "crisp-filldoc-tools", children: _jsx("button", { type: "button", "aria-label": removeLabel, title: removeLabel, onClick: () => removeAt(i), children: "\u00D7" }) })), block.type === "table" ? (_jsx(FillSheet, { label: block.label, mode: mode, surface: "panel", columns: block.columns, value: block.value, onValueChange: (value) => replace(i, { ...block, value }) })) : editable ? (_jsx("textarea", { ref: autoGrow, className: cx("crisp-filldoc-writer", block.type === "heading"
|
|
61
|
+
? "crisp-filldoc-heading"
|
|
62
|
+
: "crisp-filldoc-text"), rows: 1, value: block.text, "aria-label": block.type === "heading" ? addHeadingLabel : addTextLabel, onChange: (e) => {
|
|
63
|
+
autoGrow(e.currentTarget);
|
|
64
|
+
replace(i, { ...block, text: e.target.value });
|
|
65
|
+
} })) : block.type === "heading" ? (_jsx("h2", { className: "crisp-filldoc-heading", children: block.text })) : (_jsx("p", { className: "crisp-filldoc-text", children: block.text })), editable && (_jsxs("div", { className: "crisp-filldoc-insert", children: [_jsx("span", { className: "crisp-filldoc-insert-rule" }), _jsxs("button", { type: "button", onClick: () => insertAfter(i, { id: nextId(), type: "text", text: "" }), children: ["+ ", addTextLabel] }), _jsxs("button", { type: "button", onClick: () => insertAfter(i, { id: nextId(), type: "heading", text: "" }), children: ["+ ", addHeadingLabel] }), _jsxs("button", { type: "button", onClick: () => insertAfter(i, emptyTable()), children: ["+ ", addTableLabel] }), _jsx("span", { className: "crisp-filldoc-insert-rule" })] }))] }, block.id))) }));
|
|
66
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { defineSpec, resolveRecipe, specToRecipe } from "../../core/index.js";
|
|
2
|
+
import { contract as c, elevation as e } from "../../tokens/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* FillDoc — a page of blocks, of which a sheet is one.
|
|
5
|
+
*
|
|
6
|
+
* The measurement rule is waived here for the same reason it is waived for
|
|
7
|
+
* FillSheet, and by the same decision: Attio has no document editor. It spends
|
|
8
|
+
* the measured scale everywhere a value exists on it.
|
|
9
|
+
*
|
|
10
|
+
* The look is FillSheet's, continued: a real page with real margins, square
|
|
11
|
+
* corners, and nothing on it that is not content. A block shows no chrome at
|
|
12
|
+
* rest — no handle, no outline, no drag grip — because a page covered in
|
|
13
|
+
* controls stops looking like a page. The controls appear where the pointer
|
|
14
|
+
* is, and only in `edit`.
|
|
15
|
+
*/
|
|
16
|
+
const line = c.borderDefault;
|
|
17
|
+
// A4 at 96dpi, in the unit that actually means A4. `mm` is a real CSS unit,
|
|
18
|
+
// so this prints at the size it says.
|
|
19
|
+
const PAGE_WIDTH = "210mm";
|
|
20
|
+
const PAGE_MIN_HEIGHT = "297mm";
|
|
21
|
+
const PAGE_MARGIN = "20mm";
|
|
22
|
+
const spec = defineSpec({
|
|
23
|
+
name: "filldoc",
|
|
24
|
+
base: {
|
|
25
|
+
display: "flex",
|
|
26
|
+
flexDirection: "column",
|
|
27
|
+
gap: c.space["4"],
|
|
28
|
+
boxSizing: "border-box",
|
|
29
|
+
width: PAGE_WIDTH,
|
|
30
|
+
minHeight: PAGE_MIN_HEIGHT,
|
|
31
|
+
padding: PAGE_MARGIN,
|
|
32
|
+
margin: "0 auto",
|
|
33
|
+
background: c.bgSurface,
|
|
34
|
+
fontFamily: c.font.sans,
|
|
35
|
+
fontSize: c.text.lg,
|
|
36
|
+
lineHeight: "22px",
|
|
37
|
+
color: c.fgPrimary,
|
|
38
|
+
// Paper is not rounded, and the lift is what says it is a sheet rather
|
|
39
|
+
// than a panel in a layout.
|
|
40
|
+
borderRadius: "0",
|
|
41
|
+
boxShadow: e.floating,
|
|
42
|
+
},
|
|
43
|
+
parts: [
|
|
44
|
+
{ selector: "&:focus, &:focus-visible", decls: { outline: "none" } },
|
|
45
|
+
{
|
|
46
|
+
slot: "block",
|
|
47
|
+
selector: "& .crisp-filldoc-block",
|
|
48
|
+
decls: { position: "relative" },
|
|
49
|
+
},
|
|
50
|
+
// --- text ------------------------------------------------------------
|
|
51
|
+
{
|
|
52
|
+
slot: "heading",
|
|
53
|
+
selector: "& .crisp-filldoc-heading",
|
|
54
|
+
decls: {
|
|
55
|
+
margin: "0",
|
|
56
|
+
fontSize: c.text.xl,
|
|
57
|
+
fontWeight: c.weight.semibold,
|
|
58
|
+
lineHeight: "26px",
|
|
59
|
+
color: c.fgPrimary,
|
|
60
|
+
whiteSpace: "pre-wrap",
|
|
61
|
+
overflowWrap: "break-word",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
slot: "paragraph",
|
|
66
|
+
selector: "& .crisp-filldoc-text",
|
|
67
|
+
decls: {
|
|
68
|
+
margin: "0",
|
|
69
|
+
fontSize: c.text.lg,
|
|
70
|
+
lineHeight: "22px",
|
|
71
|
+
color: c.fgPrimary,
|
|
72
|
+
whiteSpace: "pre-wrap",
|
|
73
|
+
overflowWrap: "break-word",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
// The editor for a text block is the text: same font, same line box, no
|
|
77
|
+
// box of its own. Anything else and the words move when you click them.
|
|
78
|
+
{
|
|
79
|
+
slot: "writer",
|
|
80
|
+
selector: "& .crisp-filldoc-writer",
|
|
81
|
+
decls: {
|
|
82
|
+
display: "block",
|
|
83
|
+
width: "100%",
|
|
84
|
+
margin: "0",
|
|
85
|
+
padding: "0",
|
|
86
|
+
border: "none",
|
|
87
|
+
outline: "none",
|
|
88
|
+
background: "transparent",
|
|
89
|
+
fontFamily: "inherit",
|
|
90
|
+
fontSize: "inherit",
|
|
91
|
+
fontWeight: "inherit",
|
|
92
|
+
lineHeight: "inherit",
|
|
93
|
+
color: "inherit",
|
|
94
|
+
resize: "none",
|
|
95
|
+
overflow: "hidden",
|
|
96
|
+
// the page turns text selection off so a block drag does not smear
|
|
97
|
+
// one across it; the writer has to opt back in
|
|
98
|
+
userSelect: "text",
|
|
99
|
+
WebkitUserSelect: "text",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
// --- the between-blocks control --------------------------------------
|
|
103
|
+
// A page covered in buttons stops reading as a page, so the only thing
|
|
104
|
+
// that ever appears is a hairline with a + on it, in the gap the pointer
|
|
105
|
+
// is actually in.
|
|
106
|
+
{
|
|
107
|
+
slot: "inserter",
|
|
108
|
+
selector: "& .crisp-filldoc-insert",
|
|
109
|
+
decls: {
|
|
110
|
+
position: "absolute",
|
|
111
|
+
insetInlineStart: "0",
|
|
112
|
+
insetInlineEnd: "0",
|
|
113
|
+
insetBlockEnd: `calc(${c.space["4"]} / -2)`,
|
|
114
|
+
height: c.space["4"],
|
|
115
|
+
display: "flex",
|
|
116
|
+
alignItems: "center",
|
|
117
|
+
gap: c.space["2"],
|
|
118
|
+
opacity: "0",
|
|
119
|
+
transition: "opacity .1s",
|
|
120
|
+
cursor: "pointer",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
selector: "& .crisp-filldoc-block:hover .crisp-filldoc-insert, & .crisp-filldoc-insert:focus-within",
|
|
125
|
+
decls: { opacity: "1" },
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
selector: "& .crisp-filldoc-insert-rule",
|
|
129
|
+
decls: { flex: "1", height: "1px", background: line },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
slot: "insertButton",
|
|
133
|
+
selector: "& .crisp-filldoc-insert button",
|
|
134
|
+
decls: {
|
|
135
|
+
display: "inline-flex",
|
|
136
|
+
alignItems: "center",
|
|
137
|
+
height: "20px",
|
|
138
|
+
padding: `0 ${c.space["1.5"]}`,
|
|
139
|
+
border: "none",
|
|
140
|
+
borderRadius: c.radius.sm,
|
|
141
|
+
background: c.bgRaised,
|
|
142
|
+
fontFamily: "inherit",
|
|
143
|
+
fontSize: c.text.sm,
|
|
144
|
+
color: c.fgMuted,
|
|
145
|
+
cursor: "pointer",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
selector: "& .crisp-filldoc-insert button:hover",
|
|
150
|
+
decls: { background: c.bgNeutralWeak, color: c.fgPrimary },
|
|
151
|
+
},
|
|
152
|
+
// --- the block's own controls ----------------------------------------
|
|
153
|
+
{
|
|
154
|
+
slot: "blockTools",
|
|
155
|
+
selector: "& .crisp-filldoc-tools",
|
|
156
|
+
decls: {
|
|
157
|
+
position: "absolute",
|
|
158
|
+
insetInlineEnd: "100%",
|
|
159
|
+
insetBlockStart: "0",
|
|
160
|
+
paddingInlineEnd: c.space["2"],
|
|
161
|
+
display: "flex",
|
|
162
|
+
gap: c.space["1"],
|
|
163
|
+
opacity: "0",
|
|
164
|
+
transition: "opacity .1s",
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
selector: "& .crisp-filldoc-block:hover .crisp-filldoc-tools, & .crisp-filldoc-tools:focus-within",
|
|
169
|
+
decls: { opacity: "1" },
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
selector: "& .crisp-filldoc-tools button",
|
|
173
|
+
decls: {
|
|
174
|
+
width: "20px",
|
|
175
|
+
height: "20px",
|
|
176
|
+
display: "inline-flex",
|
|
177
|
+
alignItems: "center",
|
|
178
|
+
justifyContent: "center",
|
|
179
|
+
padding: "0",
|
|
180
|
+
border: "none",
|
|
181
|
+
borderRadius: c.radius.sm,
|
|
182
|
+
background: "transparent",
|
|
183
|
+
fontFamily: "inherit",
|
|
184
|
+
fontSize: c.text.sm,
|
|
185
|
+
color: c.fgMuted,
|
|
186
|
+
cursor: "pointer",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
selector: "& .crisp-filldoc-tools button:hover",
|
|
191
|
+
decls: { background: c.bgRaised, color: c.fgPrimary },
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
variants: {
|
|
195
|
+
page: {
|
|
196
|
+
// A real sheet of A4. The default, because the point is a document.
|
|
197
|
+
a4: {},
|
|
198
|
+
// Grows to its content and fills its container instead, for a document
|
|
199
|
+
// embedded in a screen rather than printed.
|
|
200
|
+
auto: { width: "100%", minHeight: "0", maxWidth: PAGE_WIDTH },
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
defaultVariants: { page: "a4" },
|
|
204
|
+
});
|
|
205
|
+
const config = specToRecipe(spec);
|
|
206
|
+
export const filldocRecipe = Object.assign(resolveRecipe(config), { config });
|
|
207
|
+
export { spec };
|
|
208
|
+
export default config;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { toHTML, toMarkdown } from "../fillsheet/serialize.js";
|
|
2
|
+
/**
|
|
3
|
+
* The whole document, in the formats a document reads.
|
|
4
|
+
*
|
|
5
|
+
* The same trick as the sheet's serialisers: plain functions over plain data,
|
|
6
|
+
* no React and no DOM, so a server route can render an export. A table block
|
|
7
|
+
* hands straight off to the sheet's own writer, so there is one implementation
|
|
8
|
+
* of table escaping rather than two that drift.
|
|
9
|
+
*/
|
|
10
|
+
const escapeHtml = (s) => s
|
|
11
|
+
.replace(/&/g, "&")
|
|
12
|
+
.replace(/</g, "<")
|
|
13
|
+
.replace(/>/g, ">")
|
|
14
|
+
.replace(/"/g, """);
|
|
15
|
+
/** An HTML document body: what Word, Google Docs and Pages paste as a document. */
|
|
16
|
+
export function docToHTML(blocks) {
|
|
17
|
+
return blocks
|
|
18
|
+
.map((b) => {
|
|
19
|
+
if (b.type === "table")
|
|
20
|
+
return toHTML(b.value, { columns: b.columns });
|
|
21
|
+
const text = escapeHtml(b.text).replace(/\r?\n/g, "<br>");
|
|
22
|
+
return b.type === "heading" ? `<h2>${text}</h2>` : `<p>${text}</p>`;
|
|
23
|
+
})
|
|
24
|
+
.join("\n");
|
|
25
|
+
}
|
|
26
|
+
/** The same document as Markdown. */
|
|
27
|
+
export function docToMarkdown(blocks) {
|
|
28
|
+
return blocks
|
|
29
|
+
.map((b) => {
|
|
30
|
+
if (b.type === "table")
|
|
31
|
+
return toMarkdown(b.value, { columns: b.columns });
|
|
32
|
+
return b.type === "heading" ? `## ${b.text}` : b.text;
|
|
33
|
+
})
|
|
34
|
+
.join("\n\n");
|
|
35
|
+
}
|