@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.
Files changed (59) hide show
  1. package/dist/bulkbar.css +3 -0
  2. package/dist/bulkbar.layered.css +3 -0
  3. package/dist/cjs/components/bulkbar/bulkbar.js +1 -1
  4. package/dist/cjs/components/bulkbar/bulkbar.recipe.js +6 -0
  5. package/dist/cjs/components/datatable/datatable.js +65 -5
  6. package/dist/cjs/components/datatable/datatable.recipe.js +2 -0
  7. package/dist/cjs/components/filldoc/filldoc.js +102 -0
  8. package/dist/cjs/components/filldoc/filldoc.recipe.js +211 -0
  9. package/dist/cjs/components/filldoc/index.js +10 -0
  10. package/dist/cjs/components/filldoc/serialize.js +39 -0
  11. package/dist/cjs/components/fillsheet/fillsheet.js +719 -0
  12. package/dist/cjs/components/fillsheet/fillsheet.recipe.js +325 -0
  13. package/dist/cjs/components/fillsheet/index.js +12 -0
  14. package/dist/cjs/components/fillsheet/serialize.js +82 -0
  15. package/dist/cjs/index.js +2 -0
  16. package/dist/cjs/product/recordpanel/recordpanel.js +4 -2
  17. package/dist/cjs/product/recordpanel/recordpanel.recipe.js +8 -0
  18. package/dist/cjs/product/sidebar/sidebar.js +16 -3
  19. package/dist/datatable.css +13 -5
  20. package/dist/datatable.layered.css +13 -5
  21. package/dist/esm/components/bulkbar/bulkbar.js +1 -1
  22. package/dist/esm/components/bulkbar/bulkbar.recipe.js +6 -0
  23. package/dist/esm/components/datatable/datatable.js +65 -5
  24. package/dist/esm/components/datatable/datatable.recipe.js +2 -0
  25. package/dist/esm/components/filldoc/filldoc.js +66 -0
  26. package/dist/esm/components/filldoc/filldoc.recipe.js +208 -0
  27. package/dist/esm/components/filldoc/index.js +3 -0
  28. package/dist/esm/components/filldoc/serialize.js +35 -0
  29. package/dist/esm/components/fillsheet/fillsheet.js +683 -0
  30. package/dist/esm/components/fillsheet/fillsheet.recipe.js +322 -0
  31. package/dist/esm/components/fillsheet/index.js +3 -0
  32. package/dist/esm/components/fillsheet/serialize.js +76 -0
  33. package/dist/esm/index.js +2 -0
  34. package/dist/esm/product/recordpanel/recordpanel.js +4 -2
  35. package/dist/esm/product/recordpanel/recordpanel.recipe.js +8 -0
  36. package/dist/esm/product/sidebar/sidebar.js +17 -4
  37. package/dist/filldoc.css +31 -0
  38. package/dist/filldoc.d.ts +7 -0
  39. package/dist/filldoc.layered.css +33 -0
  40. package/dist/fillsheet.css +29 -0
  41. package/dist/fillsheet.d.ts +7 -0
  42. package/dist/fillsheet.layered.css +31 -0
  43. package/dist/recordpanel.css +6 -0
  44. package/dist/recordpanel.layered.css +6 -0
  45. package/dist/styles.css +84 -5
  46. package/dist/styles.layered.css +84 -5
  47. package/dist/types/components/datatable/datatable.d.ts +7 -1
  48. package/dist/types/components/filldoc/filldoc.d.ts +79 -0
  49. package/dist/types/components/filldoc/filldoc.recipe.d.ts +201 -0
  50. package/dist/types/components/filldoc/index.d.ts +3 -0
  51. package/dist/types/components/filldoc/serialize.d.ts +5 -0
  52. package/dist/types/components/fillsheet/fillsheet.d.ts +165 -0
  53. package/dist/types/components/fillsheet/fillsheet.recipe.d.ts +202 -0
  54. package/dist/types/components/fillsheet/index.d.ts +3 -0
  55. package/dist/types/components/fillsheet/serialize.d.ts +45 -0
  56. package/dist/types/index.d.ts +2 -0
  57. package/dist/types/product/recordpanel/recordpanel.d.ts +11 -1
  58. package/dist/types/product/sidebar/sidebar.d.ts +56 -0
  59. 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
- onScroll: (e) => setScrolledX(e.currentTarget.scrollLeft > 0),
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, ...stickyCol(i), children: [renaming === col.key ? (_jsx("input", {
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 = stickyCol(i);
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), ...stickyCol(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] }) }) }))] }))] })] }));
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,3 @@
1
+ export { FillDoc } from "./filldoc.js";
2
+ export { filldocRecipe } from "./filldoc.recipe.js";
3
+ export { docToHTML, docToMarkdown } from "./serialize.js";
@@ -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, "&amp;")
12
+ .replace(/</g, "&lt;")
13
+ .replace(/>/g, "&gt;")
14
+ .replace(/"/g, "&quot;");
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
+ }