@crisp-ui-kit/crisp 0.49.0 → 0.51.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 (104) hide show
  1. package/dist/cjs/components/composewindow/composewindow.js +144 -0
  2. package/dist/cjs/components/composewindow/composewindow.recipe.js +45 -0
  3. package/dist/cjs/components/composewindow/index.js +8 -0
  4. package/dist/cjs/components/datatable/datatable.js +224 -43
  5. package/dist/cjs/components/datatable/datatable.recipe.js +38 -4
  6. package/dist/cjs/components/datatable/index.js +6 -0
  7. package/dist/cjs/components/datepickerfield/datepickerfield.js +25 -15
  8. package/dist/cjs/components/datepickerfield/datepickerfield.recipe.js +25 -8
  9. package/dist/cjs/components/dialog/dialog.recipe.js +9 -1
  10. package/dist/cjs/components/menu/menu.js +4 -2
  11. package/dist/cjs/components/multiselect/multiselect.recipe.js +14 -8
  12. package/dist/cjs/components/select/select.js +2 -1
  13. package/dist/cjs/components/table/table.recipe.js +12 -3
  14. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +2 -1
  15. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +39 -8
  16. package/dist/cjs/core/cell-date.js +11 -0
  17. package/dist/cjs/core/index.js +2 -1
  18. package/dist/cjs/index.js +1 -0
  19. package/dist/cjs/product/recordpage/recordpage.js +88 -10
  20. package/dist/cjs/product/recordpage/recordpage.recipe.js +30 -0
  21. package/dist/cjs/product/recordpanel/recordpanel.js +54 -4
  22. package/dist/cjs/product/recordpanel/recordpanel.recipe.js +29 -0
  23. package/dist/cjs/product/recordstable/editable-field.js +110 -0
  24. package/dist/cjs/product/recordstable/index.js +4 -1
  25. package/dist/cjs/product/recordstable/recordstable.js +67 -11
  26. package/dist/cjs/product/recordstable/recordstable.recipe.js +9 -1
  27. package/dist/cjs/product/settingsshell/settingsshell.recipe.js +13 -4
  28. package/dist/cjs/product/sidebar/sidebar.js +15 -3
  29. package/dist/cjs/product/sidebar/sidebar.recipe.js +21 -2
  30. package/dist/composewindow.css +24 -0
  31. package/dist/composewindow.d.ts +7 -0
  32. package/dist/composewindow.layered.css +26 -0
  33. package/dist/datatable.css +34 -15
  34. package/dist/datatable.layered.css +34 -15
  35. package/dist/datepickerfield.css +9 -4
  36. package/dist/datepickerfield.layered.css +9 -4
  37. package/dist/dialog.css +2 -1
  38. package/dist/dialog.layered.css +2 -1
  39. package/dist/esm/components/composewindow/composewindow.js +107 -0
  40. package/dist/esm/components/composewindow/composewindow.recipe.js +42 -0
  41. package/dist/esm/components/composewindow/index.js +2 -0
  42. package/dist/esm/components/datatable/datatable.js +226 -45
  43. package/dist/esm/components/datatable/datatable.recipe.js +38 -4
  44. package/dist/esm/components/datatable/index.js +6 -0
  45. package/dist/esm/components/datepickerfield/datepickerfield.js +26 -16
  46. package/dist/esm/components/datepickerfield/datepickerfield.recipe.js +25 -8
  47. package/dist/esm/components/dialog/dialog.recipe.js +9 -1
  48. package/dist/esm/components/menu/menu.js +4 -2
  49. package/dist/esm/components/multiselect/multiselect.recipe.js +14 -8
  50. package/dist/esm/components/select/select.js +2 -1
  51. package/dist/esm/components/table/table.recipe.js +12 -3
  52. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +2 -1
  53. package/dist/esm/components/tabletoolbar/tabletoolbar.js +38 -8
  54. package/dist/esm/core/cell-date.js +10 -0
  55. package/dist/esm/core/index.js +1 -1
  56. package/dist/esm/index.js +1 -0
  57. package/dist/esm/product/recordpage/recordpage.js +89 -11
  58. package/dist/esm/product/recordpage/recordpage.recipe.js +30 -0
  59. package/dist/esm/product/recordpanel/recordpanel.js +55 -5
  60. package/dist/esm/product/recordpanel/recordpanel.recipe.js +29 -0
  61. package/dist/esm/product/recordstable/editable-field.js +73 -0
  62. package/dist/esm/product/recordstable/index.js +1 -0
  63. package/dist/esm/product/recordstable/recordstable.js +69 -13
  64. package/dist/esm/product/recordstable/recordstable.recipe.js +9 -1
  65. package/dist/esm/product/settingsshell/settingsshell.recipe.js +13 -4
  66. package/dist/esm/product/sidebar/sidebar.js +15 -3
  67. package/dist/esm/product/sidebar/sidebar.recipe.js +21 -2
  68. package/dist/multiselect.css +8 -8
  69. package/dist/multiselect.layered.css +8 -8
  70. package/dist/recordpage.css +25 -13
  71. package/dist/recordpage.layered.css +25 -13
  72. package/dist/recordpanel.css +22 -5
  73. package/dist/recordpanel.layered.css +22 -5
  74. package/dist/recordstable.css +10 -2
  75. package/dist/recordstable.layered.css +10 -2
  76. package/dist/settingsshell.css +2 -2
  77. package/dist/settingsshell.layered.css +2 -2
  78. package/dist/sidebar.css +7 -2
  79. package/dist/sidebar.layered.css +7 -2
  80. package/dist/styles.css +150 -55
  81. package/dist/styles.layered.css +150 -55
  82. package/dist/table.css +4 -2
  83. package/dist/table.layered.css +4 -2
  84. package/dist/tabletoolbar.css +2 -1
  85. package/dist/tabletoolbar.layered.css +2 -1
  86. package/dist/types/components/composewindow/composewindow.d.ts +80 -0
  87. package/dist/types/components/composewindow/composewindow.recipe.d.ts +3 -0
  88. package/dist/types/components/composewindow/index.d.ts +2 -0
  89. package/dist/types/components/datatable/datatable.d.ts +58 -4
  90. package/dist/types/components/datatable/index.d.ts +1 -1
  91. package/dist/types/components/datepickerfield/datepickerfield.d.ts +18 -4
  92. package/dist/types/components/menu/menu.d.ts +10 -1
  93. package/dist/types/components/select/select.d.ts +6 -1
  94. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +62 -5
  95. package/dist/types/core/cell-date.d.ts +17 -0
  96. package/dist/types/core/index.d.ts +1 -1
  97. package/dist/types/index.d.ts +1 -0
  98. package/dist/types/product/recordpage/recordpage.d.ts +59 -1
  99. package/dist/types/product/recordpanel/recordpanel.d.ts +11 -1
  100. package/dist/types/product/recordstable/editable-field.d.ts +45 -0
  101. package/dist/types/product/recordstable/index.d.ts +1 -0
  102. package/dist/types/product/recordstable/recordstable.d.ts +29 -0
  103. package/dist/types/product/sidebar/sidebar.d.ts +60 -4
  104. package/package.json +6 -1
@@ -1,11 +1,16 @@
1
1
  @layer crisp-components {
2
2
  .crisp-datepickerfield {
3
3
  display: inline-flex; font-family: var(--crisp-font-sans);
4
- & .crisp-datepickerfield-trigger { display: inline-flex; align-items: center; gap: var(--crisp-space-2); box-sizing: border-box; height: 32px; padding: 0 var(--crisp-space-2-5); margin: 0; border: none; border-radius: var(--crisp-radius-md); background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 7%, transparent); color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); white-space: nowrap; cursor: pointer; }
5
- & .crisp-datepickerfield-trigger[data-ghost] { background: transparent; box-shadow: none; }
6
- & .crisp-datepickerfield-trigger[data-ghost]:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); }
7
- & .crisp-datepickerfield-trigger[data-placeholder] { color: var(--crisp-fg-muted); }
4
+ & .crisp-datepickerfield-box { display: inline-flex; align-items: center; box-sizing: border-box; height: 32px; margin: 0; border-radius: var(--crisp-radius-md); background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-fg-primary) 7%, transparent); color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-lg); }
5
+ & .crisp-datepickerfield-box[data-ghost] { background: transparent; box-shadow: none; }
6
+ & .crisp-datepickerfield-box[data-ghost]:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 4%, transparent); }
7
+ & .crisp-datepickerfield-trigger { display: inline-flex; align-items: center; gap: var(--crisp-space-2); height: 100%; padding: 0 var(--crisp-space-2-5); margin: 0; border: none; background: none; color: inherit; font-family: inherit; font-size: inherit; white-space: nowrap; cursor: pointer; }
8
+ & .crisp-datepickerfield-box[data-placeholder] .crisp-datepickerfield-trigger { color: var(--crisp-fg-muted); }
8
9
  & .crisp-datepickerfield-trigger svg { flex-shrink: 0; color: var(--crisp-fg-muted); }
10
+ /* Reuses TableToolbar's measured filter-chip remove button
11
+ (tabletoolbar.chrome.ts's .crisp-tabletoolbar-fchip-x) -- the same small inline "clear this value" affordance, not a freshly-measured one: no live Attio composition for a DatePickerField-equivalent clear was reachable this round (#394). */
12
+ & .crisp-datepickerfield-clear { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin-inline-end: var(--crisp-space-1); padding: 0; border: none; border-radius: var(--crisp-radius-sm); background: none; color: var(--crisp-fg-muted); font-family: inherit; cursor: pointer; }
13
+ & .crisp-datepickerfield-clear:hover { background: color-mix(in srgb, var(--crisp-fg-primary) 10%, transparent); color: var(--crisp-fg-primary); }
9
14
  & .crisp-datepickerfield-panel { padding: var(--crisp-space-2); width: 300px; }
10
15
  & .crisp-datepickerfield-presets { display: flex; flex-wrap: wrap; gap: var(--crisp-space-1); margin-top: var(--crisp-space-2); }
11
16
  & .crisp-datepickerfield-preset { height: var(--crisp-size-chip); padding: 0 var(--crisp-space-2); border: none; border-radius: var(--crisp-radius-chip); background: var(--crisp-bg-raised); color: var(--crisp-fg-primary); font-family: inherit; font-size: var(--crisp-text-sm); cursor: pointer; }
package/dist/dialog.css CHANGED
@@ -15,7 +15,8 @@
15
15
  & .crisp-dialog-field-label { font-size: var(--crisp-text-sm); font-weight: var(--crisp-weight-medium); color: color-mix(in srgb, var(--crisp-fg-primary) 50%, transparent); }
16
16
  & .crisp-dialog-more { display: inline-flex; align-items: center; gap: var(--crisp-space-2); margin-inline-end: auto; border: none; background: none; padding: 0; font-family: inherit; font-size: var(--crisp-text-md); color: var(--crisp-fg-muted); cursor: pointer; }
17
17
  & .crisp-dialog-submit { display: inline-flex; align-items: center; gap: var(--crisp-space-2); height: 28px; padding: 0 var(--crisp-space-1) 0 var(--crisp-space-2-5); border: none; border-radius: var(--crisp-radius-md); background: var(--crisp-bg-brand-solid); color: var(--crisp-fg-on-brand); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); cursor: pointer; }
18
- & .crisp-dialog-submit-key { display: inline-flex; align-items: center; justify-content: center; height: 18px; padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-sm); background: rgba(255,255,255,0.22); font-size: var(--crisp-text-xs); }
18
+ & .crisp-dialog-submit-key { display: inline-flex; align-items: center; gap: var(--crisp-space-1); }
19
+ & .crisp-dialog-submit-key kbd { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0 3px; border-radius: 4px; background: transparent; color: var(--crisp-fg-on-brand); font-family: inherit; font-style: normal; font-size: 10px; }
19
20
  }
20
21
 
21
22
  @media (prefers-reduced-motion: reduce) {
@@ -16,7 +16,8 @@
16
16
  & .crisp-dialog-field-label { font-size: var(--crisp-text-sm); font-weight: var(--crisp-weight-medium); color: color-mix(in srgb, var(--crisp-fg-primary) 50%, transparent); }
17
17
  & .crisp-dialog-more { display: inline-flex; align-items: center; gap: var(--crisp-space-2); margin-inline-end: auto; border: none; background: none; padding: 0; font-family: inherit; font-size: var(--crisp-text-md); color: var(--crisp-fg-muted); cursor: pointer; }
18
18
  & .crisp-dialog-submit { display: inline-flex; align-items: center; gap: var(--crisp-space-2); height: 28px; padding: 0 var(--crisp-space-1) 0 var(--crisp-space-2-5); border: none; border-radius: var(--crisp-radius-md); background: var(--crisp-bg-brand-solid); color: var(--crisp-fg-on-brand); font-family: inherit; font-size: var(--crisp-text-md); font-weight: var(--crisp-weight-medium); cursor: pointer; }
19
- & .crisp-dialog-submit-key { display: inline-flex; align-items: center; justify-content: center; height: 18px; padding: 0 var(--crisp-space-1); border-radius: var(--crisp-radius-sm); background: rgba(255,255,255,0.22); font-size: var(--crisp-text-xs); }
19
+ & .crisp-dialog-submit-key { display: inline-flex; align-items: center; gap: var(--crisp-space-1); }
20
+ & .crisp-dialog-submit-key kbd { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0 3px; border-radius: 4px; background: transparent; color: var(--crisp-fg-on-brand); font-family: inherit; font-style: normal; font-size: 10px; }
20
21
  }
21
22
 
22
23
  @media (prefers-reduced-motion: reduce) {
@@ -0,0 +1,107 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as dialog from "@zag-js/dialog";
3
+ import { normalizeProps, Portal, useMachine } from "@zag-js/react";
4
+ import { Minus, Pin, X } from "lucide-react";
5
+ import * as React from "react";
6
+ import { cx, withDescribedBy } from "../../core/index.js";
7
+ import { composewindowRecipe } from "./composewindow.recipe.js";
8
+ // A globally unique id per mounted instance — see command.tsx/dialog.tsx for
9
+ // the rationale (the machine defers its dismissal listeners a frame, so a
10
+ // tree-relative id could let a stale registration resolve to a new instance).
11
+ let instanceCounter = 0;
12
+ function useComposeWindowInstanceId() {
13
+ const idRef = React.useRef(null);
14
+ if (idRef.current == null) {
15
+ instanceCounter += 1;
16
+ idRef.current = `crisp-composewindow-${instanceCounter}`;
17
+ }
18
+ return idRef.current;
19
+ }
20
+ /**
21
+ * A floating, minimizable, pinnable window — Attio's own note "compose
22
+ * window" chrome (measured live, read-only, 2026-09-24; see HANDOFF), not a
23
+ * new visual recipe: it reuses `Dialog`'s exact measured frosted-glass panel
24
+ * (`bgOverlayFrost` 60% + `blur(15px)`, `elevation.modal`'s 3-layer shadow,
25
+ * 16px outer radius) — those numbers matched Attio's compose window exactly
26
+ * when re-checked against it, panel-for-panel. What's actually different
27
+ * from `Dialog` is the header (a breadcrumb plus minimize/pin/close, not a
28
+ * title plus close) and two extra pieces of state Dialog has no concept of.
29
+ *
30
+ * `minimized` is measured, not guessed — the FIRST version of this file built
31
+ * an invented "minimized bar" (a Gmail-style dock, since a bar was never
32
+ * actually exercised against Attio). Re-measured properly: minimizing an
33
+ * existing note does not produce any bar, tray, dock, or badge anywhere on
34
+ * screen. Confirmed four independent ways, both with synthetic and real
35
+ * CDP-dispatched trusted clicks — the element count under the note's own
36
+ * subtree drops by ~110 nodes, `[role="dialog"]` stops matching it, its
37
+ * `data-hidden="true"` ancestor computes `visibility: hidden; opacity: 0`,
38
+ * and the URL drops the `?id=…&modal=note` query exactly like Close does —
39
+ * but the subtree is NOT removed: `document.querySelectorAll('[aria-label=
40
+ * "Minimize"]')` still finds it afterward, `visibility: hidden` (not
41
+ * `display: none`) preserves layout, and nothing in localStorage or the DOM
42
+ * suggests state was discarded. So: minimized hides the panel — mounted,
43
+ * `visibility: hidden; opacity: 0` — rather than unmounting or closing it,
44
+ * and crisp renders no restore affordance of its own (Attio has none
45
+ * either); reopening is the consumer's own UI, driven by `minimized`/
46
+ * `onMinimizedChange` the same way `open` already works.
47
+ *
48
+ * "Pinned" is implemented as the one directly-supported behavior that
49
+ * matches its name (Zag's `closeOnInteractOutside`, off while pinned) —
50
+ * Attio's own multi-instance stacking/docking when pinned was not exercised,
51
+ * so nothing here repositions the window when pinned.
52
+ *
53
+ * Not modal: Attio's own compose window dims nothing behind it (`Dialog`'s
54
+ * backdrop is `background: transparent` too — the blur alone does the
55
+ * separation), and the page behind stays visibly interactive in the
56
+ * measured screenshot. `trapFocus` follows the same call — a picture-in-
57
+ * picture window, not a modal, so Tab is not caught inside it.
58
+ */
59
+ export function ComposeWindow({ open, onOpenChange, breadcrumb, minimized: minimizedProp, defaultMinimized, onMinimizedChange, pinned: pinnedProp, defaultPinned, onPinnedChange, width = 968, "aria-label": ariaLabel, minimizeLabel = "Minimize", pinLabel = "Pin to screen", closeLabel = "Close", children, className, style, "aria-describedby": describedBy, ...rest }) {
60
+ const id = useComposeWindowInstanceId();
61
+ const [internalMinimized, setInternalMinimized] = React.useState(defaultMinimized ?? false);
62
+ const minimized = minimizedProp ?? internalMinimized;
63
+ const setMinimized = React.useCallback((next) => {
64
+ if (minimizedProp === undefined)
65
+ setInternalMinimized(next);
66
+ onMinimizedChange?.(next);
67
+ }, [minimizedProp, onMinimizedChange]);
68
+ const [internalPinned, setInternalPinned] = React.useState(defaultPinned ?? false);
69
+ const pinned = pinnedProp ?? internalPinned;
70
+ const setPinned = React.useCallback((next) => {
71
+ if (pinnedProp === undefined)
72
+ setInternalPinned(next);
73
+ onPinnedChange?.(next);
74
+ }, [pinnedProp, onPinnedChange]);
75
+ const service = useMachine(dialog.machine, {
76
+ id,
77
+ // Always `open` while the consumer says so — NOT `open && !minimized`.
78
+ // Zag's own "closed" state sets `hidden` on the content element, which
79
+ // the browser's UA stylesheet turns into `display: none` — the exact
80
+ // thing minimized must NOT be (Attio's own measured minimized subtree
81
+ // keeps `display: flex`, only `visibility`/`opacity` change). Disabling
82
+ // `closeOnEscape`/`closeOnInteractOutside` below gets the same "stand
83
+ // down while minimized" result without that side effect.
84
+ open,
85
+ onOpenChange(details) {
86
+ // Zag only ever asks to close (Escape / outside pointerdown) — it
87
+ // never asks to open on its own, so this is always a close request.
88
+ if (!details.open)
89
+ onOpenChange(false);
90
+ },
91
+ modal: false,
92
+ trapFocus: false,
93
+ closeOnInteractOutside: !pinned && !minimized,
94
+ closeOnEscape: !minimized,
95
+ "aria-label": ariaLabel,
96
+ });
97
+ const api = dialog.connect(service, normalizeProps);
98
+ if (!open)
99
+ return null;
100
+ return (_jsx(Portal, { children: _jsxs("div", { className: composewindowRecipe(), children: [_jsx("div", { ...api.getBackdropProps(), className: "crisp-composewindow-backdrop" }), _jsx("div", { ...api.getPositionerProps(), className: "crisp-composewindow-positioner", children: _jsx("div", { ...rest, ...withDescribedBy(api.getContentProps(), describedBy), className: cx("crisp-composewindow-content", className), "data-crisp-composewindow": id, "data-minimized": minimized || undefined, style: { ...style, maxWidth: `min(${width}px, 90vw)` }, children: _jsxs("div", { className: "crisp-composewindow-card", children: [_jsxs("div", { className: "crisp-composewindow-header", children: [_jsx("span", { ...api.getTitleProps(), className: "crisp-composewindow-breadcrumb", children: breadcrumb }), _jsxs("div", { className: "crisp-composewindow-actions", children: [_jsx("button", { type: "button", className: "crisp-composewindow-action-btn", "aria-label": minimizeLabel, onClick: () => setMinimized(true), children: _jsx(Minus, { size: 14, strokeWidth: 2, "aria-hidden": "true" }) }), _jsx("button", { type: "button", className: "crisp-composewindow-action-btn", "aria-label": pinLabel, "aria-pressed": pinned, "data-state": pinned ? "on" : "off", onClick: () => setPinned(!pinned), children: _jsx(Pin, { size: 14, strokeWidth: 2, "aria-hidden": "true" }) }), _jsx("button", { type: "button", ...api.getCloseTriggerProps(), className: "crisp-composewindow-action-btn", "aria-label": closeLabel, children: _jsx(X, { size: 14, strokeWidth: 2, "aria-hidden": "true" }) })] })] }), _jsx("div", { className: "crisp-composewindow-body", children: children })] }) }) })] }) }));
101
+ }
102
+ ComposeWindow.displayName = "ComposeWindow";
103
+ export function ComposeWindowBody({ children, className, }) {
104
+ return (_jsx("div", { className: className
105
+ ? `crisp-composewindow-content-body ${className}`
106
+ : "crisp-composewindow-content-body", children: children }));
107
+ }
@@ -0,0 +1,42 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c, elevation as e } from "../../tokens/index.js";
3
+ // Attio's floating "compose window" — measured live from an existing note,
4
+ // read-only, 2026-09-24 (#427). Re-checked independently against the same
5
+ // live note a second time before shipping — every number below matched.
6
+ //
7
+ // The panel itself is NOT a new visual recipe: `bgOverlayFrost` 60% +
8
+ // `blur(15px)` + `elevation.modal`'s 3-layer shadow + 16px radius (`2xl`) are
9
+ // the exact same measured values `Dialog` already carries — Attio's own note
10
+ // window and crisp's existing modal panel are the same surface family. What
11
+ // IS new here is the header (a breadcrumb plus minimize/pin/close, not a
12
+ // title plus close) and `[data-minimized]`.
13
+ //
14
+ // Minimizing does NOT produce a bar/tray/dock — that was this file's first
15
+ // pass, before minimizing was ever actually exercised against a live note.
16
+ // Re-measured: nothing appears anywhere on screen; the note's own subtree
17
+ // stays mounted with `visibility: hidden; opacity: 0` (see composewindow.tsx's
18
+ // doc comment for the four ways this was confirmed). `[data-minimized]`
19
+ // reproduces exactly that — hidden, not display:none, so state and scroll
20
+ // position survive until a consumer sets `minimized={false}` again.
21
+ const config = defineRecipe({
22
+ name: "composewindow",
23
+ base: `display: contents; font-family: ${c.font.sans};
24
+ & .crisp-composewindow-backdrop { position: fixed; inset: 0; z-index: 60; background: transparent; pointer-events: none; }
25
+ & .crisp-composewindow-positioner { position: fixed; inset: 0; z-index: 61; display: flex; align-items: flex-start; justify-content: center; padding: 15vh ${c.space["4"]} ${c.space["4"]}; pointer-events: none; }
26
+ & .crisp-composewindow-content { pointer-events: auto; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; position: relative; display: flex; flex-direction: column; box-sizing: border-box; width: 90vw; max-height: 74vh; padding: ${c.space["1"]}; border-radius: ${c.radius["2xl"]}; background: color-mix(in srgb, ${c.bgOverlayFrost} 60%, transparent); backdrop-filter: blur(15px); -webkit-backdrop-filter: blur(15px); box-shadow: ${e.modal}; opacity: 1; transform: scale(1); transition: opacity 0.2s ease, transform 0.2s ease; @starting-style { opacity: 0; transform: scale(0.97); } }
27
+ & .crisp-composewindow-content[data-minimized] { visibility: hidden; opacity: 0; }
28
+ & .crisp-composewindow-card { display: flex; flex-direction: column; flex: 1; min-height: 0; box-sizing: border-box; border-radius: ${c.radius.xl}; background: ${c.bgSurface}; box-shadow: 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 8%, transparent); overflow: hidden; }
29
+ & .crisp-composewindow-header { display: flex; align-items: center; justify-content: space-between; gap: ${c.space["2"]}; flex-shrink: 0; height: 52px; padding: 0 ${c.space["2"]} 0 ${c.space["4"]}; border-bottom: 1px solid ${c.borderSubtle}; }
30
+ & .crisp-composewindow-breadcrumb { display: flex; align-items: center; gap: ${c.space["1"]}; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: ${c.text.md}; color: ${c.fgMuted}; }
31
+ & .crisp-composewindow-actions { display: flex; align-items: center; gap: ${c.space["0.5"]}; flex-shrink: 0; }
32
+ & .crisp-composewindow-action-btn { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 28px; height: 28px; margin: 0; padding: 0; border: none; border-radius: ${c.radius.md}; background: none; color: ${c.fgMuted}; font-family: inherit; cursor: pointer; }
33
+ & .crisp-composewindow-action-btn:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); color: ${c.fgPrimary}; }
34
+ & .crisp-composewindow-action-btn[data-state="on"] { background: color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent); color: ${c.bgBrandSolid}; }
35
+ & .crisp-composewindow-body { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; padding: ${c.space["5"]} ${c.space["4"]}; }
36
+ & .crisp-composewindow-content-body { display: flex; flex-direction: column; gap: ${c.space["4"]}; }`,
37
+ variants: {},
38
+ defaultVariants: {},
39
+ compoundVariants: [],
40
+ });
41
+ export const composewindowRecipe = resolveRecipe(config);
42
+ export default config;
@@ -0,0 +1,2 @@
1
+ export { ComposeWindow, ComposeWindowBody, } from "./composewindow.js";
2
+ export { composewindowRecipe } from "./composewindow.recipe.js";
@@ -1,9 +1,13 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDown, ArrowUp, Check, ChevronsUpDown, GripVertical, Plus, Search, SlidersHorizontal, } from "lucide-react";
2
+ import { ArrowDown, ArrowUp, Check, ChevronsUpDown, CircleUserRound, GripVertical, Plus, Search, SlidersHorizontal, } from "lucide-react";
3
3
  import * as React from "react";
4
- import { formatCellDate, isRtlElement, parseCellDate } from "../../core/index.js";
4
+ import { cellDatePrecision, formatCellDate, isRtlElement, parseCellDate, } from "../../core/index.js";
5
+ import { Avatar } from "../avatar/index.js";
5
6
  import { Checkbox } from "../checkbox/index.js";
6
7
  import { Menu, MenuItem } from "../menu/index.js";
8
+ import { Select } from "../select/index.js";
9
+ import { Spinner } from "../spinner/index.js";
10
+ import { Switch } from "../switch/index.js";
7
11
  import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "../table/index.js";
8
12
  import { datatableRecipe } from "./datatable.recipe.js";
9
13
  /** A `date` input needs `yyyy-mm-dd`; cells may hold a Date, an ISO string or
@@ -22,6 +26,26 @@ function toDateInputValue(v) {
22
26
  }
23
27
  return "";
24
28
  }
29
+ /** A `datetime-local` input needs `yyyy-mm-ddThh:mm`, in local time. Unlike
30
+ * `toDateInputValue`, this reads the value with a REAL `new Date(v)`, not
31
+ * `parseCellDate` -- `parseCellDate` exists specifically to treat a
32
+ * date-ONLY string as local midnight rather than UTC midnight (#221); a
33
+ * datetime value already carries its own time-of-day (and, if present, its
34
+ * own offset), so the ordinary `Date` constructor is the correct parse, and
35
+ * `parseCellDate` would silently discard the time half this type exists to
36
+ * keep (#432). Returns "" when it cannot be parsed, same contract as
37
+ * `toDateInputValue`. */
38
+ function toDateTimeInputValue(v) {
39
+ const d = v instanceof Date
40
+ ? v
41
+ : typeof v === "string" || typeof v === "number"
42
+ ? new Date(v)
43
+ : null;
44
+ if (!d || Number.isNaN(d.getTime()))
45
+ return "";
46
+ const pad = (n) => String(n).padStart(2, "0");
47
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
48
+ }
25
49
  function cellValue(col, row) {
26
50
  if (col.accessor)
27
51
  return col.accessor(row);
@@ -56,21 +80,46 @@ function initials(name) {
56
80
  const p = name.trim().split(/\s+/);
57
81
  return ((p[0]?.[0] ?? "") + (p[1]?.[0] ?? "")).toUpperCase() || "?";
58
82
  }
59
- // One Intl.DateTimeFormat per locale, built lazily and reused — constructing
60
- // a formatter per cell render would be wasteful on a large grid.
83
+ // One Intl.DateTimeFormat per locale (and per precision it's asked to print),
84
+ // built lazily and reused — constructing a formatter per cell render would be
85
+ // wasteful on a large grid.
61
86
  const DATE_FMT_CACHE = new Map();
62
- function dateFmt(locale) {
63
- let fmt = DATE_FMT_CACHE.get(locale);
87
+ function dateFmtCached(cache, locale, options) {
88
+ let fmt = cache.get(locale);
64
89
  if (!fmt) {
65
- fmt = new Intl.DateTimeFormat(locale, {
66
- month: "short",
67
- day: "numeric",
68
- year: "numeric",
69
- });
70
- DATE_FMT_CACHE.set(locale, fmt);
90
+ fmt = new Intl.DateTimeFormat(locale, options);
91
+ cache.set(locale, fmt);
71
92
  }
72
93
  return fmt;
73
94
  }
95
+ function dateFmt(locale) {
96
+ return dateFmtCached(DATE_FMT_CACHE, locale, {
97
+ month: "short",
98
+ day: "numeric",
99
+ year: "numeric",
100
+ });
101
+ }
102
+ // "Sep 1998" — a value that named a month but not a day (#312).
103
+ const MONTH_FMT_CACHE = new Map();
104
+ function monthFmt(locale) {
105
+ return dateFmtCached(MONTH_FMT_CACHE, locale, {
106
+ month: "short",
107
+ year: "numeric",
108
+ });
109
+ }
110
+ // Same caching, extended with the time-of-day fields `dateFmt` deliberately
111
+ // omits (#432) -- its own cache/formatter, not `dateFmt` with options bolted
112
+ // on, so `date`'s own output can never shift by adding this type.
113
+ const DATETIME_FMT_CACHE = new Map();
114
+ function dateTimeFmt(locale) {
115
+ return dateFmtCached(DATETIME_FMT_CACHE, locale, {
116
+ month: "short",
117
+ day: "numeric",
118
+ year: "numeric",
119
+ hour: "numeric",
120
+ minute: "2-digit",
121
+ });
122
+ }
74
123
  function formatCell(type, v, symbol, locale = "en-US") {
75
124
  if (type === "number")
76
125
  return typeof v === "number" ? v.toLocaleString(locale) : asText(v);
@@ -82,8 +131,29 @@ function formatCell(type, v, symbol, locale = "en-US") {
82
131
  // parseCellDate, not `new Date(v)`: a date-only value is UTC midnight per
83
132
  // ECMA-262, and formatting that in the reader's timezone renders it a day
84
133
  // early west of UTC (#221, the same defect #220 fixed in RecordsTable).
85
- const d = v ? parseCellDate(String(v)) : null;
86
- return d ? dateFmt(locale).format(d) : asText(v);
134
+ const raw = v ? String(v) : "";
135
+ const d = raw ? parseCellDate(raw) : null;
136
+ if (!d)
137
+ return asText(v);
138
+ // parseCellDate fills in a missing month/day so a year-only or
139
+ // year-month value can become a Date; only print the precision the raw
140
+ // value actually carried, or "1998" renders as an invented "Jan 1, 1998"
141
+ // (#312).
142
+ const precision = cellDatePrecision(raw);
143
+ if (precision === "year")
144
+ return String(d.getFullYear());
145
+ if (precision === "month")
146
+ return monthFmt(locale).format(d);
147
+ return dateFmt(locale).format(d);
148
+ }
149
+ if (type === "datetime") {
150
+ // A REAL `new Date(v)`, not `parseCellDate` -- see toDateTimeInputValue's
151
+ // own comment; this value carries a time-of-day `parseCellDate` would
152
+ // discard.
153
+ const d = v instanceof Date ? v : v ? new Date(String(v)) : null;
154
+ return d && !Number.isNaN(d.getTime())
155
+ ? dateTimeFmt(locale).format(d)
156
+ : asText(v);
87
157
  }
88
158
  return asText(v);
89
159
  }
@@ -96,7 +166,9 @@ function formatCell(type, v, symbol, locale = "en-US") {
96
166
  // display string belongs -- as an entity's name, a status' label -- while the
97
167
  // raw value keeps driving structure and tone.
98
168
  function renderTyped(col, value, avatarTone, display, locale = "en-US") {
99
- const empty = _jsx("span", { className: "crisp-datatable-muted", children: "\u2014" });
169
+ // Attio's own empty cell is genuinely blank -- no placeholder character at
170
+ // all, for any column type (#412).
171
+ const empty = null;
100
172
  switch (col.type) {
101
173
  case "entity": {
102
174
  const raw = (typeof value === "object" && value ? value : { name: asText(value) });
@@ -135,6 +207,44 @@ function renderTyped(col, value, avatarTone, display, locale = "en-US") {
135
207
  }
136
208
  }
137
209
  const CHECK_W = 40;
210
+ // A sentinel that can't collide with a real record id (crisp never generates
211
+ // one, and a consumer's own ids come from their own data store) -- picking
212
+ // this row calls `onCreateReference`, not the cell's own commit (#449).
213
+ const CREATE_REFERENCE_VALUE = "__crisp_create_reference__";
214
+ /** The 16px avatar Attio's own reference picker draws before every option's
215
+ * label -- a photo when the option carries one, a generic person glyph
216
+ * otherwise (measured live, #449 -- the same measurement and component
217
+ * shape as RecordsTable's own `ReferenceCell`, which this mirrors). */
218
+ function dataReferenceOptionLabel(o) {
219
+ return (_jsxs("span", { className: "crisp-datatable-refoption", children: [_jsx(Avatar, { size: "sm", className: "crisp-datatable-refoption-avatar", children: o.avatar ? (_jsx("img", { src: o.avatar, alt: "" })) : (_jsx(CircleUserRound, { size: 12, strokeWidth: 1.75, "aria-hidden": "true" })) }), o.label] }));
220
+ }
221
+ /** `editor: "reference"`'s picker, its own component (not inline in the
222
+ * row-render loop) because it needs its own `useState` for the search query
223
+ * -- one instance per cell, same reasoning `select`/`switch` don't need a
224
+ * component of their own but this does. */
225
+ function DataReferenceEditor({ col, value, isPending, onCommit, }) {
226
+ const [query, setQuery] = React.useState("");
227
+ const options = (col.refOptions ?? []).map((o) => ({
228
+ value: o.value,
229
+ label: dataReferenceOptionLabel(o),
230
+ keywords: o.label,
231
+ }));
232
+ if (col.onCreateReference) {
233
+ options.push({
234
+ value: CREATE_REFERENCE_VALUE,
235
+ label: (_jsxs("span", { className: "crisp-datatable-refoption crisp-datatable-refoption--create", children: [_jsx(Plus, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }), `Create ${col.createLabel ?? "record"}`] })),
236
+ // always reachable regardless of the typed query -- it's an action,
237
+ // not a record to filter away
238
+ keywords: query,
239
+ });
240
+ }
241
+ return (_jsx(Select, { variant: "ghost", searchable: true, searchPlaceholder: col.refSearchPlaceholder, onSearchChange: setQuery, "aria-label": `Edit ${col.key}`, value: asText(value) || undefined, disabled: isPending, options: options, onValueChange: (v) => {
242
+ if (v === CREATE_REFERENCE_VALUE)
243
+ col.onCreateReference?.(query);
244
+ else
245
+ onCommit(v);
246
+ } }));
247
+ }
138
248
  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
249
  ? `${total} ${total === 1 ? "row" : "rows"}`
140
250
  : `${shown} of ${total}`, toolbar, className, reorderRowLabel = "Reorder row", ...rest }) {
@@ -234,6 +344,10 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
234
344
  setSelectionText(selectionAnnouncement(selected.size));
235
345
  }
236
346
  const [editing, setEditing] = React.useState(null);
347
+ // Set only while an async `onEdit` is in flight (#385) -- a sync `onEdit`
348
+ // never touches either of these.
349
+ const [pending, setPending] = React.useState(null);
350
+ const [editError, setEditError] = React.useState(null);
237
351
  const [scrolledX, setScrolledX] = React.useState(false);
238
352
  // Mirrors `scrolledX` for the trailing edge: true while there is still
239
353
  // unrevealed content sliding under the sticky trailing column, i.e. while
@@ -344,9 +458,18 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
344
458
  * sibling — rather than giving every type the same text box:
345
459
  * number/currency numeric, with a decimal keypad on touch
346
460
  * date a native date field
461
+ * datetime a native datetime field -- `type="date"` drops the
462
+ * time-of-day half of the value on commit; this is the
463
+ * one type that keeps it (#432), via a native
464
+ * `datetime-local` field rather than a custom popover:
465
+ * the plain-editor family is native-input-backed
466
+ * throughout, and the browser's own picker already
467
+ * carries both a calendar and a time control.
347
468
  * text/entity a plain text field
348
469
  * `status`, `reference` and `tags` are pickers in RecordsTable, not
349
- * double-click editors, so they are not editable here either.
470
+ * double-click editors, so they are not editable here either -- except
471
+ * `editor: "reference"` (#432), which is a picker for the same reason
472
+ * `select`/`switch` are: always live, no double-click gate.
350
473
  */
351
474
  function editorFor(col) {
352
475
  switch (col.type) {
@@ -355,6 +478,8 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
355
478
  return { type: "number", inputMode: "decimal", step: "any" };
356
479
  case "date":
357
480
  return { type: "date" };
481
+ case "datetime":
482
+ return { type: "datetime-local" };
358
483
  default:
359
484
  return { type: "text", inputMode: "text" };
360
485
  }
@@ -398,11 +523,15 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
398
523
  return;
399
524
  }
400
525
  else if (e.key === "Enter") {
401
- if (columns[c]?.editable) {
526
+ // select/switch columns have no separate editing mode to enter --
527
+ // they're already their own live widget (#385); Enter there is a
528
+ // no-op here, same as any other non-editable cell that isn't wired to
529
+ // onRowClick.
530
+ if (columns[c]?.editable && !columns[c]?.editor) {
402
531
  e.preventDefault();
403
- setEditing({ id: selCell.id, key: selCell.key });
532
+ openEditing(selCell.id, selCell.key);
404
533
  }
405
- else if (onRowClick) {
534
+ else if (!columns[c]?.editable && onRowClick) {
406
535
  e.preventDefault();
407
536
  onRowClick(rows[r]);
408
537
  }
@@ -413,8 +542,43 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
413
542
  e.preventDefault();
414
543
  setSelCell({ id: rows[nr].id, key: columns[nc].key });
415
544
  }
545
+ // Opening a cell's editor also clears any stale error left over from a
546
+ // PREVIOUS failed commit on it (#385) -- otherwise a rejection from three
547
+ // edits ago would still be sitting there while the reader tries again.
548
+ function openEditing(id, key) {
549
+ setEditError(null);
550
+ setEditing({ id, key });
551
+ }
552
+ // Async-aware (#385): a sync `onEdit` (returns undefined) commits exactly
553
+ // as before -- close the editor immediately. A Promise-returning `onEdit`
554
+ // keeps the editor open with a pending state while it settles, then either
555
+ // closes it (resolve) or closes it AND reverts to the last-known-good
556
+ // value while showing the rejection's message beside the cell (reject).
557
+ // The revert needs no extra code: this component never mutates `data`
558
+ // itself, so once `editing` clears, the cell simply shows `data` again.
416
559
  function commitEdit(id, key, value) {
417
- onEdit?.(id, key, value);
560
+ setEditError(null);
561
+ const result = onEdit?.(id, key, value);
562
+ if (result && typeof result.then === "function") {
563
+ setPending({ id, key });
564
+ result.then(() => {
565
+ setPending(null);
566
+ setEditing(null);
567
+ }, (err) => {
568
+ setPending(null);
569
+ setEditing(null);
570
+ setEditError({
571
+ id,
572
+ key,
573
+ message: err instanceof Error
574
+ ? err.message
575
+ : typeof err === "string"
576
+ ? err
577
+ : "Could not save",
578
+ });
579
+ });
580
+ return;
581
+ }
418
582
  setEditing(null);
419
583
  }
420
584
  const totalCols = columns.length + (selectable ? 1 : 0) + (showGrip ? 1 : 0);
@@ -548,7 +712,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
548
712
  ? "ascending"
549
713
  : "descending"
550
714
  : "none";
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] }));
715
+ const inner = (_jsxs("span", { className: "crisp-datatable-th-inner", children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx("span", { className: "crisp-datatable-th-label", children: col.header }), col.sortable ? _jsx(ChevronFor, { colKey: col.key }) : null] }));
552
716
  return (_jsxs(TableHead, { align: alignFor(col), "aria-sort": ariaSort, ...dataStickyCol(i), children: [renaming === col.key ? (_jsx("input", {
553
717
  // biome-ignore lint/a11y/noAutofocus: the rename item opens this field
554
718
  autoFocus: true, className: "crisp-datatable-th-rename", defaultValue: typeof col.header === "string" ? col.header : "", "aria-label": `Rename column`, onBlur: (e) => {
@@ -590,38 +754,55 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
590
754
  const isEditing = editing?.id === row.id && editing?.key === col.key;
591
755
  const value = cellValue(col, row);
592
756
  const sticky = dataStickyCol(i);
757
+ const isPending = pending?.id === row.id && pending?.key === col.key;
758
+ const errorHere = editError?.id === row.id && editError?.key === col.key
759
+ ? editError.message
760
+ : null;
761
+ // A select/switch editable column is ALWAYS its own
762
+ // live widget -- no double-click gate, no separate
763
+ // edit/view mode, the same convention RecordsTable's
764
+ // status/reference pickers already use: choosing a
765
+ // value or flipping a toggle IS the commit gesture
766
+ // (#385).
767
+ if (col.editable && col.editor) {
768
+ return (_jsxs(TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? (_jsx(Select, { variant: "ghost", "aria-label": `Edit ${col.key}`, value: asText(value) || undefined, disabled: isPending, options: col.options ?? [], onValueChange: (v) => commitEdit(row.id, col.key, v) })) : col.editor === "reference" ? (_jsx(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : (_jsx(Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) })), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
769
+ }
593
770
  if (isEditing) {
594
- return (_jsx(TableCell, { align: alignFor(col), ...sticky, onClick: (e) => e.stopPropagation(), children: _jsx("input", { className: "crisp-datatable-edit",
595
- // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
596
- autoFocus: true, ...editorFor(col), "data-editor": editorFor(col).type, defaultValue: col.type === "date"
597
- ? toDateInputValue(value)
598
- : asText(value), "aria-label": `Edit ${col.key}`, onKeyDown: (e) => {
599
- if (e.key === "Enter")
771
+ return (_jsxs(TableCell, { align: alignFor(col), ...sticky, onClick: (e) => e.stopPropagation(), children: [_jsx("input", { className: "crisp-datatable-edit",
772
+ // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
773
+ autoFocus: true, ...editorFor(col), "data-editor": editorFor(col).type, disabled: isPending, defaultValue: col.type === "date"
774
+ ? toDateInputValue(value)
775
+ : col.type === "datetime"
776
+ ? toDateTimeInputValue(value)
777
+ : asText(value), "aria-label": `Edit ${col.key}`, onKeyDown: (e) => {
778
+ if (isPending)
779
+ return;
780
+ if (e.key === "Enter")
781
+ commitEdit(row.id, col.key, e.currentTarget.value);
782
+ else if (e.key === "Escape")
783
+ setEditing(null);
784
+ }, onBlur: (e) => {
785
+ if (isPending)
786
+ return;
600
787
  commitEdit(row.id, col.key, e.currentTarget.value);
601
- else if (e.key === "Escape")
602
- setEditing(null);
603
- }, onBlur: (e) => commitEdit(row.id, col.key, e.currentTarget.value) }) }, col.key));
788
+ } }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }, col.key));
604
789
  }
605
- return (_jsx(TableCell, { align: alignFor(col), ...sticky, className: col.editable
790
+ return (_jsxs(TableCell, { align: alignFor(col), ...sticky, className: col.editable
606
791
  ? "crisp-datatable-editable"
607
792
  : undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
608
793
  ? ""
609
- : undefined, onClick: (e) => {
610
- // the keyboard model's anchor; also opens the
611
- // editor when the column allows it
794
+ : undefined, onClick: () => {
795
+ // the keyboard model's anchor -- a single click
796
+ // only selects. A genuine double-click (or Enter
797
+ // on the selected cell) opens the editor (#411),
798
+ // matching RecordsTable's own onDoubleClick.
612
799
  setSelCell({ id: row.id, key: col.key });
613
800
  gridRef.current?.focus({ preventScroll: true });
614
- if (col.editable) {
615
- // Opening the inline editor must not also fire
616
- // the row's onRowClick and navigate away. Every
617
- // other cell click has to keep bubbling, or
618
- // onRowClick never fires at all.
619
- e.stopPropagation();
620
- setEditing({ id: row.id, key: col.key });
621
- }
622
- }, children: col.render
623
- ? col.render(value, row)
624
- : renderTyped(col, value, avatarTone, col.format?.(value, row), locale) }, col.key));
801
+ }, onDoubleClick: col.editable
802
+ ? () => openEditing(row.id, col.key)
803
+ : undefined, children: [col.render
804
+ ? col.render(value, row)
805
+ : renderTyped(col, value, avatarTone, col.format?.(value, row), locale), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
625
806
  })] }, 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] }) }) }))] }))] })] }));
626
807
  }
627
808
  DataTable.displayName = "DataTable";