@crisp-ui-kit/crisp 0.50.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/components/composewindow/composewindow.js +144 -0
- package/dist/cjs/components/composewindow/composewindow.recipe.js +45 -0
- package/dist/cjs/components/composewindow/index.js +8 -0
- package/dist/cjs/components/datatable/datatable.js +224 -43
- package/dist/cjs/components/datatable/datatable.recipe.js +37 -3
- package/dist/cjs/components/datatable/index.js +6 -0
- package/dist/cjs/components/datepickerfield/datepickerfield.js +25 -15
- package/dist/cjs/components/datepickerfield/datepickerfield.recipe.js +25 -8
- package/dist/cjs/components/dialog/dialog.recipe.js +9 -1
- package/dist/cjs/components/menu/menu.js +4 -2
- package/dist/cjs/components/multiselect/multiselect.recipe.js +14 -8
- package/dist/cjs/components/select/select.js +2 -1
- package/dist/cjs/components/table/table.recipe.js +12 -3
- package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +1 -1
- package/dist/cjs/components/tabletoolbar/tabletoolbar.js +18 -7
- package/dist/cjs/core/caret-rect.js +73 -0
- package/dist/cjs/core/cell-date.js +11 -0
- package/dist/cjs/core/index.js +8 -1
- package/dist/cjs/core/mention.js +32 -0
- package/dist/cjs/core/use-mention-trigger.js +124 -0
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/product/recordpage/recordpage.js +88 -10
- package/dist/cjs/product/recordpage/recordpage.recipe.js +30 -0
- package/dist/cjs/product/recordpanel/recordpanel.js +11 -29
- package/dist/cjs/product/recordpanel/recordpanel.recipe.js +55 -17
- package/dist/cjs/product/recordstable/editable-field.js +113 -0
- package/dist/cjs/product/recordstable/index.js +4 -1
- package/dist/cjs/product/recordstable/recordstable.js +67 -11
- package/dist/cjs/product/recordstable/recordstable.recipe.js +9 -1
- package/dist/cjs/product/recordstable/recordstable.tag.js +8 -3
- package/dist/cjs/product/richtext/index.js +2 -1
- package/dist/cjs/product/richtext/richtext.js +440 -23
- package/dist/cjs/product/richtext/richtext.recipe.js +59 -9
- package/dist/cjs/product/settingsshell/settingsshell.recipe.js +13 -4
- package/dist/cjs/product/sidebar/sidebar.js +15 -3
- package/dist/cjs/product/sidebar/sidebar.recipe.js +21 -2
- package/dist/composewindow.css +24 -0
- package/dist/composewindow.d.ts +7 -0
- package/dist/composewindow.layered.css +26 -0
- package/dist/datatable.css +33 -14
- package/dist/datatable.layered.css +33 -14
- package/dist/datepickerfield.css +9 -4
- package/dist/datepickerfield.layered.css +9 -4
- package/dist/dialog.css +2 -1
- package/dist/dialog.layered.css +2 -1
- package/dist/esm/components/composewindow/composewindow.js +107 -0
- package/dist/esm/components/composewindow/composewindow.recipe.js +42 -0
- package/dist/esm/components/composewindow/index.js +2 -0
- package/dist/esm/components/datatable/datatable.js +226 -45
- package/dist/esm/components/datatable/datatable.recipe.js +37 -3
- package/dist/esm/components/datatable/index.js +6 -0
- package/dist/esm/components/datepickerfield/datepickerfield.js +26 -16
- package/dist/esm/components/datepickerfield/datepickerfield.recipe.js +25 -8
- package/dist/esm/components/dialog/dialog.recipe.js +9 -1
- package/dist/esm/components/menu/menu.js +4 -2
- package/dist/esm/components/multiselect/multiselect.recipe.js +14 -8
- package/dist/esm/components/select/select.js +2 -1
- package/dist/esm/components/table/table.recipe.js +12 -3
- package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +1 -1
- package/dist/esm/components/tabletoolbar/tabletoolbar.js +18 -7
- package/dist/esm/core/caret-rect.js +70 -0
- package/dist/esm/core/cell-date.js +10 -0
- package/dist/esm/core/index.js +4 -1
- package/dist/esm/core/mention.js +29 -0
- package/dist/esm/core/use-mention-trigger.js +88 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/product/recordpage/recordpage.js +89 -11
- package/dist/esm/product/recordpage/recordpage.recipe.js +30 -0
- package/dist/esm/product/recordpanel/index.js +1 -1
- package/dist/esm/product/recordpanel/recordpanel.js +13 -31
- package/dist/esm/product/recordpanel/recordpanel.recipe.js +56 -18
- package/dist/esm/product/recordstable/editable-field.js +76 -0
- package/dist/esm/product/recordstable/index.js +1 -0
- package/dist/esm/product/recordstable/recordstable.js +69 -13
- package/dist/esm/product/recordstable/recordstable.recipe.js +9 -1
- package/dist/esm/product/recordstable/recordstable.tag.js +8 -3
- package/dist/esm/product/richtext/index.js +1 -1
- package/dist/esm/product/richtext/richtext.js +439 -22
- package/dist/esm/product/richtext/richtext.recipe.js +60 -10
- package/dist/esm/product/settingsshell/settingsshell.recipe.js +13 -4
- package/dist/esm/product/sidebar/sidebar.js +15 -3
- package/dist/esm/product/sidebar/sidebar.recipe.js +21 -2
- package/dist/multiselect.css +8 -8
- package/dist/multiselect.layered.css +8 -8
- package/dist/recordpage.css +25 -13
- package/dist/recordpage.layered.css +25 -13
- package/dist/recordpanel.css +43 -20
- package/dist/recordpanel.layered.css +43 -20
- package/dist/recordstable.css +9 -2
- package/dist/recordstable.layered.css +9 -2
- package/dist/richtext.css +31 -5
- package/dist/richtext.layered.css +31 -5
- package/dist/settingsshell.css +2 -2
- package/dist/settingsshell.layered.css +2 -2
- package/dist/sidebar.css +7 -2
- package/dist/sidebar.layered.css +7 -2
- package/dist/styles.css +199 -74
- package/dist/styles.layered.css +199 -74
- package/dist/table.css +4 -2
- package/dist/table.layered.css +4 -2
- package/dist/tabletoolbar.css +1 -1
- package/dist/tabletoolbar.layered.css +1 -1
- package/dist/types/components/composewindow/composewindow.d.ts +80 -0
- package/dist/types/components/composewindow/composewindow.recipe.d.ts +3 -0
- package/dist/types/components/composewindow/index.d.ts +2 -0
- package/dist/types/components/datatable/datatable.d.ts +58 -4
- package/dist/types/components/datatable/index.d.ts +1 -1
- package/dist/types/components/datepickerfield/datepickerfield.d.ts +18 -4
- package/dist/types/components/menu/menu.d.ts +10 -1
- package/dist/types/components/select/select.d.ts +6 -1
- package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +29 -5
- package/dist/types/core/caret-rect.d.ts +16 -0
- package/dist/types/core/cell-date.d.ts +17 -0
- package/dist/types/core/index.d.ts +4 -1
- package/dist/types/core/mention.d.ts +27 -0
- package/dist/types/core/use-mention-trigger.d.ts +41 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/product/recordpage/recordpage.d.ts +59 -1
- package/dist/types/product/recordpanel/index.d.ts +1 -1
- package/dist/types/product/recordpanel/recordpanel.d.ts +81 -17
- package/dist/types/product/recordstable/editable-field.d.ts +48 -0
- package/dist/types/product/recordstable/index.d.ts +1 -0
- package/dist/types/product/recordstable/recordstable.d.ts +29 -0
- package/dist/types/product/recordstable/recordstable.tag.d.ts +8 -3
- package/dist/types/product/richtext/index.d.ts +1 -1
- package/dist/types/product/richtext/richtext.d.ts +168 -7
- package/dist/types/product/sidebar/sidebar.d.ts +60 -4
- package/package.json +6 -1
|
@@ -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;
|
|
@@ -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
|
|
60
|
-
// a formatter per cell render would be
|
|
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
|
|
63
|
-
let fmt =
|
|
87
|
+
function dateFmtCached(cache, locale, options) {
|
|
88
|
+
let fmt = cache.get(locale);
|
|
64
89
|
if (!fmt) {
|
|
65
|
-
fmt = new Intl.DateTimeFormat(locale,
|
|
66
|
-
|
|
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
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
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 (
|
|
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: (
|
|
610
|
-
// the keyboard model's anchor
|
|
611
|
-
//
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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";
|
|
@@ -48,10 +48,16 @@ const config = defineRecipe({
|
|
|
48
48
|
& .crisp-datatable-th-rename:focus { box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; }
|
|
49
49
|
& .crisp-datatable-th-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
50
50
|
& .crisp-datatable-sort { display: flex; align-items: center; gap: ${c.space["1.5"]}; width: 100%; min-width: 0; height: 100%; margin: 0; padding: 0 ${c.space["2"]}; border: none; background: none; font: inherit; letter-spacing: inherit; color: inherit; text-align: start; cursor: pointer; }
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
/* A right-aligned column's header keeps its field-icon + label packed at
|
|
52
|
+
the START, matching its own values only in that the VALUES right-align
|
|
53
|
+
-- Attio's own right-aligned header (measured live: "Twitter follower
|
|
54
|
+
count") keeps the icon+label left and only the trailing column-menu
|
|
55
|
+
icon sits pinned to the right edge, independent of the label's width
|
|
56
|
+
(#406, reversing #393's own justify-content: flex-end here). The
|
|
57
|
+
chevron's own margin-inline-start: auto below already pins IT to the
|
|
58
|
+
end of a flex-start row with no override needed -- that's what pins
|
|
59
|
+
the trailing icon on Attio too. */
|
|
53
60
|
& .crisp-datatable-chevron { margin-inline-start: auto; flex-shrink: 0; color: ${c.fgMuted}; opacity: 0; transition: opacity .12s; }
|
|
54
|
-
& th[data-align="right"] .crisp-datatable-chevron { margin-inline-start: 0; order: -1; }
|
|
55
61
|
& .crisp-datatable-sort:hover .crisp-datatable-chevron { opacity: 0.6; }
|
|
56
62
|
& .crisp-datatable-sort[data-sorted] .crisp-datatable-chevron { opacity: 1; color: ${c.fgPrimary}; }
|
|
57
63
|
/* sticky first column(s) + horizontal scroll shadow — RecordsTable's feel */
|
|
@@ -74,6 +80,34 @@ const config = defineRecipe({
|
|
|
74
80
|
& .crisp-datatable-editable:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, ${c.bgSurface}); cursor: text; }
|
|
75
81
|
& .crisp-datatable-edit { width: 100%; height: 28px; margin: 0; padding: 0 ${c.space["1.5"]}; border: none; border-radius: ${c.radius.sm}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; outline: none; vertical-align: middle; }
|
|
76
82
|
& .crisp-datatable-edit:focus { box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; }
|
|
83
|
+
/* An async onEdit's in-flight state (#385) -- the field/picker dims and
|
|
84
|
+
stops accepting input rather than disappearing, so the just-typed or
|
|
85
|
+
just-chosen value stays visible while its commit is pending. Same
|
|
86
|
+
treatment Input already gives its own disabled state. */
|
|
87
|
+
& .crisp-datatable-edit:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
88
|
+
& .crisp-datatable-edit-pending { flex-shrink: 0; margin-inline-start: ${c.space["1"]}; color: ${c.fgMuted}; vertical-align: middle; }
|
|
89
|
+
/* The rejection's message, beside the cell's own content -- a sibling in
|
|
90
|
+
the TableCell, never nested inside a column's own render output, so a
|
|
91
|
+
hover-only wrapper (a Tooltip, say) around a custom render can't hide
|
|
92
|
+
it. Block-level: the row grows to fit it rather than floating over
|
|
93
|
+
the row below, so it can never end up clipped by the scroll
|
|
94
|
+
container's overflow the way a position:fixed escape would need
|
|
95
|
+
guarding against. */
|
|
96
|
+
& .crisp-datatable-edit-error { display: block; padding: ${c.space["0.5"]} 0 0; font-size: ${c.text.sm}; color: ${c.bgDanger}; }
|
|
97
|
+
/* editor: "select"/"switch" -- always live, no separate edit mode, so
|
|
98
|
+
the cell IS the picker: same chromeless-fill treatment as
|
|
99
|
+
RecordsTable's own --picker cells (measured there already; DataTable
|
|
100
|
+
shares RecordsTable's grid, Golden Rule 0). */
|
|
101
|
+
& .crisp-datatable-td--picker { padding: 0; }
|
|
102
|
+
& .crisp-datatable-edit-picker { display: flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.tableRow}; padding: 0 ${c.space["2"]}; }
|
|
103
|
+
& .crisp-datatable-edit-picker .crisp-select-trigger { flex: 1; min-width: 0; height: 100%; min-height: 0; border-radius: 0; box-shadow: none; padding: 0; }
|
|
104
|
+
& .crisp-datatable-td--picker .crisp-datatable-edit-error { padding-inline-start: ${c.space["2"]}; }
|
|
105
|
+
/* reference picker: a leading 16px avatar per option, plus a trailing
|
|
106
|
+
"Create X" row when the consumer supplies one (#449) -- same measured
|
|
107
|
+
values as RecordsTable's own .crisp-recordstable-refoption*. */
|
|
108
|
+
& .crisp-datatable-refoption { display: flex; align-items: center; gap: ${c.space["2"]}; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
109
|
+
& .crisp-datatable-refoption-avatar { flex-shrink: 0; }
|
|
110
|
+
& .crisp-datatable-refoption--create { color: ${c.fgMuted}; }
|
|
77
111
|
& .crisp-datatable-empty { padding: ${c.space["8"]} ${c.space["3"]}; text-align: center; color: ${c.fgMuted}; font-size: ${c.text.lg}; }
|
|
78
112
|
/* fixed layout so column widths (and resize) are exact — like RecordsTable */
|
|
79
113
|
& table { table-layout: fixed; }
|
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
export { DataTable, } from "./datatable.js";
|
|
2
|
+
// `EntityValue` (identical shape, `{ name: string; avatar?: string }`) is
|
|
3
|
+
// deliberately NOT re-exported here: `product/recordstable`'s own barrel
|
|
4
|
+
// already exports a type of that name at the package root, and re-exporting
|
|
5
|
+
// this module's copy too makes `EntityValue` an ambiguous export (TS2308) --
|
|
6
|
+
// verified by trying it. A DataTable consumer needing the type imports it
|
|
7
|
+
// from there; it's the same shape either table would want.
|
|
2
8
|
export { datatableRecipe } from "./datatable.recipe.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { CalendarDays } from "lucide-react";
|
|
2
|
+
import { CalendarDays, X } from "lucide-react";
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { DatePicker, } from "../datepicker/index.js";
|
|
5
5
|
import { Popover } from "../popover/index.js";
|
|
@@ -21,7 +21,7 @@ const fromDate = (d) => ({
|
|
|
21
21
|
* popover. This is what `DatePicker` used to be; `DatePicker` is now
|
|
22
22
|
* seed-design's bare calendar, and this composes it.
|
|
23
23
|
*/
|
|
24
|
-
export function DatePickerField({ value, defaultValue, onValueChange, onChange, placeholder = "Set date…", variant = "default", format = defaultFormat, presets, calendarProps, "aria-label": ariaLabel, className, ...rest }) {
|
|
24
|
+
export function DatePickerField({ value, defaultValue, onValueChange, onChange, placeholder = "Set date…", variant = "default", format = defaultFormat, clearable = true, clearLabel = "Clear date", presets, calendarProps, "aria-label": ariaLabel, className, ...rest }) {
|
|
25
25
|
const [internal, setInternal] = React.useState(defaultValue ?? null);
|
|
26
26
|
const selected = value ?? internal;
|
|
27
27
|
const [open, setOpen] = React.useState(false);
|
|
@@ -33,19 +33,29 @@ export function DatePickerField({ value, defaultValue, onValueChange, onChange,
|
|
|
33
33
|
onChange?.(d);
|
|
34
34
|
setOpen(false);
|
|
35
35
|
};
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
selectionMode
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
36
|
+
const clear = () => {
|
|
37
|
+
if (value === undefined)
|
|
38
|
+
setInternal(null);
|
|
39
|
+
onValueChange?.(null);
|
|
40
|
+
onChange?.(null);
|
|
41
|
+
};
|
|
42
|
+
return (_jsx("div", { className: datepickerfieldRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-datepickerfield-box", "data-ghost": variant === "ghost" ? "" : undefined, "data-placeholder": selected ? undefined : "", children: [_jsx(Popover, { open: open, onOpenChange: setOpen, "aria-label": ariaLabel, trigger: _jsxs("button", { type: "button", className: "crisp-datepickerfield-trigger", "aria-label": ariaLabel, children: [_jsx(CalendarDays, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), selected ? format(selected) : placeholder] }), children: _jsxs("div", { className: "crisp-datepickerfield-panel", children: [_jsx(DatePicker
|
|
43
|
+
// DatePickerProps is a union over selectionMode, and TypeScript
|
|
44
|
+
// cannot narrow it through a spread: it tries every branch and
|
|
45
|
+
// reports the range one as missing props. The field is always
|
|
46
|
+
// single-select, so the shape is pinned here rather than inferred.
|
|
47
|
+
, { ...{
|
|
48
|
+
...calendarProps,
|
|
49
|
+
selectionMode: "single",
|
|
50
|
+
value: selected ? fromDate(selected) : undefined,
|
|
51
|
+
onValueChange: (next) => {
|
|
52
|
+
if (next)
|
|
53
|
+
pick(toDate(next));
|
|
54
|
+
},
|
|
55
|
+
} }), presets && presets.length > 0 && (_jsx("div", { className: "crisp-datepickerfield-presets", children: presets.map((p, i) => (_jsx("button", { type: "button", className: "crisp-datepickerfield-preset", onClick: () => pick(p.date()), children: p.label }, typeof p.label === "string" ? p.label : i))) }))] }) }), clearable && selected && (_jsx("button", { type: "button", className: "crisp-datepickerfield-clear", "aria-label": clearLabel, onClick: (e) => {
|
|
56
|
+
// not the trigger button's own click -- Popover would open
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
clear();
|
|
59
|
+
}, children: _jsx(X, { size: 13, strokeWidth: 1.75, "aria-hidden": "true" }) }))] }) }));
|
|
50
60
|
}
|
|
51
61
|
DatePickerField.displayName = "DatePickerField";
|
|
@@ -9,18 +9,35 @@ const config = defineRecipe({
|
|
|
9
9
|
base: `
|
|
10
10
|
display: inline-flex; font-family: ${c.font.sans};
|
|
11
11
|
|
|
12
|
-
& .crisp-datepickerfield-
|
|
13
|
-
display: inline-flex; align-items: center;
|
|
14
|
-
box-sizing: border-box; height: 32px;
|
|
15
|
-
border
|
|
12
|
+
& .crisp-datepickerfield-box {
|
|
13
|
+
display: inline-flex; align-items: center;
|
|
14
|
+
box-sizing: border-box; height: 32px; margin: 0;
|
|
15
|
+
border-radius: ${c.radius.md}; background: transparent;
|
|
16
16
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, ${c.fgPrimary} 7%, transparent);
|
|
17
17
|
color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.lg};
|
|
18
|
-
white-space: nowrap; cursor: pointer;
|
|
19
18
|
}
|
|
20
|
-
& .crisp-datepickerfield-
|
|
21
|
-
& .crisp-datepickerfield-
|
|
22
|
-
& .crisp-datepickerfield-trigger
|
|
19
|
+
& .crisp-datepickerfield-box[data-ghost] { background: transparent; box-shadow: none; }
|
|
20
|
+
& .crisp-datepickerfield-box[data-ghost]:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); }
|
|
21
|
+
& .crisp-datepickerfield-trigger {
|
|
22
|
+
display: inline-flex; align-items: center; gap: ${c.space["2"]};
|
|
23
|
+
height: 100%; padding: 0 ${c.space["2.5"]}; margin: 0;
|
|
24
|
+
border: none; background: none; color: inherit; font-family: inherit;
|
|
25
|
+
font-size: inherit; white-space: nowrap; cursor: pointer;
|
|
26
|
+
}
|
|
27
|
+
& .crisp-datepickerfield-box[data-placeholder] .crisp-datepickerfield-trigger { color: ${c.fgMuted}; }
|
|
23
28
|
& .crisp-datepickerfield-trigger svg { flex-shrink: 0; color: ${c.fgMuted}; }
|
|
29
|
+
/* Reuses TableToolbar's measured filter-chip remove button
|
|
30
|
+
(tabletoolbar.chrome.ts's .crisp-tabletoolbar-fchip-x) -- the same
|
|
31
|
+
small inline "clear this value" affordance, not a freshly-measured
|
|
32
|
+
one: no live Attio composition for a DatePickerField-equivalent
|
|
33
|
+
clear was reachable this round (#394). */
|
|
34
|
+
& .crisp-datepickerfield-clear {
|
|
35
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
36
|
+
width: 20px; height: 20px; margin-inline-end: ${c.space["1"]}; padding: 0;
|
|
37
|
+
border: none; border-radius: ${c.radius.sm}; background: none;
|
|
38
|
+
color: ${c.fgMuted}; font-family: inherit; cursor: pointer;
|
|
39
|
+
}
|
|
40
|
+
& .crisp-datepickerfield-clear:hover { background: color-mix(in srgb, ${c.fgPrimary} 10%, transparent); color: ${c.fgPrimary}; }
|
|
24
41
|
|
|
25
42
|
& .crisp-datepickerfield-panel { padding: ${c.space["2"]}; width: 300px; }
|
|
26
43
|
& .crisp-datepickerfield-presets { display: flex; flex-wrap: wrap; gap: ${c.space["1"]}; margin-top: ${c.space["2"]}; }
|