@crisp-ui-kit/crisp 0.54.11 → 0.55.1
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/badge.css +1 -1
- package/dist/badge.layered.css +1 -1
- package/dist/cjs/components/badge/badge.recipe.js +10 -5
- package/dist/cjs/components/datatable/countries.js +73 -0
- package/dist/cjs/components/datatable/datatable.js +318 -40
- package/dist/cjs/components/datatable/datatable.recipe.js +18 -1
- package/dist/cjs/components/resizablepanel/resizablepanel.js +10 -1
- package/dist/cjs/components/table/table.js +8 -1
- package/dist/cjs/components/tabletoolbar/tabletoolbar.js +110 -27
- package/dist/cjs/index.js +0 -2
- package/dist/cjs/product/recordpage/index.js +6 -1
- package/dist/cjs/product/recordpage/recordpage.js +20 -4
- package/dist/datatable.css +141 -102
- package/dist/datatable.layered.css +141 -102
- package/dist/esm/components/badge/badge.recipe.js +10 -5
- package/dist/esm/components/datatable/countries.js +68 -0
- package/dist/esm/components/datatable/datatable.js +319 -41
- package/dist/esm/components/datatable/datatable.recipe.js +18 -1
- package/dist/esm/components/resizablepanel/resizablepanel.js +10 -1
- package/dist/esm/components/table/table.js +8 -1
- package/dist/esm/components/tabletoolbar/tabletoolbar.js +111 -28
- package/dist/esm/index.js +0 -2
- package/dist/esm/product/recordpage/index.js +2 -0
- package/dist/esm/product/recordpage/recordpage.js +20 -4
- package/dist/styles.css +142 -375
- package/dist/styles.layered.css +142 -375
- package/dist/types/components/badge/badge.recipe.d.ts +3 -1
- package/dist/types/components/datatable/countries.d.ts +12 -0
- package/dist/types/components/datatable/datatable.d.ts +27 -3
- package/dist/types/components/table/table.d.ts +14 -0
- package/dist/types/index.d.ts +0 -2
- package/dist/types/product/recordpage/index.d.ts +2 -0
- package/package.json +2 -12
- package/dist/cjs/product/recordstable/company-logos.js +0 -29
- package/dist/cjs/product/recordstable/index.js +0 -13
- package/dist/cjs/product/recordstable/recordstable.js +0 -1362
- package/dist/cjs/product/recordstable/recordstable.recipe.js +0 -175
- package/dist/cjs/product/recordstable/recordstable.tag.js +0 -26
- package/dist/cjs/product/taskstable/index.js +0 -7
- package/dist/cjs/product/taskstable/taskstable.js +0 -32
- package/dist/cjs/product/taskstable/taskstable.recipe.js +0 -56
- package/dist/esm/product/recordstable/company-logos.js +0 -26
- package/dist/esm/product/recordstable/index.js +0 -3
- package/dist/esm/product/recordstable/recordstable.js +0 -1322
- package/dist/esm/product/recordstable/recordstable.recipe.js +0 -172
- package/dist/esm/product/recordstable/recordstable.tag.js +0 -23
- package/dist/esm/product/taskstable/index.js +0 -2
- package/dist/esm/product/taskstable/taskstable.js +0 -29
- package/dist/esm/product/taskstable/taskstable.recipe.js +0 -53
- package/dist/recordstable.css +0 -240
- package/dist/recordstable.d.ts +0 -7
- package/dist/recordstable.layered.css +0 -242
- package/dist/taskstable.css +0 -30
- package/dist/taskstable.d.ts +0 -7
- package/dist/taskstable.layered.css +0 -32
- package/dist/types/product/recordstable/company-logos.d.ts +0 -1
- package/dist/types/product/recordstable/index.d.ts +0 -3
- package/dist/types/product/recordstable/recordstable.d.ts +0 -294
- package/dist/types/product/recordstable/recordstable.recipe.d.ts +0 -5
- package/dist/types/product/recordstable/recordstable.tag.d.ts +0 -15
- package/dist/types/product/taskstable/index.d.ts +0 -2
- package/dist/types/product/taskstable/taskstable.d.ts +0 -59
- package/dist/types/product/taskstable/taskstable.recipe.d.ts +0 -3
|
@@ -1,1322 +0,0 @@
|
|
|
1
|
-
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { ArrowDown, ArrowDownWideNarrow, ArrowLeft, ArrowRight, ArrowUp, ArrowUpDown, ArrowUpNarrowWide, ArrowUpRight, AtSign, Briefcase, Building2, Calendar, Check, CircleHelp, CircleUserRound, Copy, CopyPlus, CornerDownLeft, EyeOff, Globe, GripVertical, Hash, Inbox, ListFilter, ListPlus, Mail, MapPin, MoreHorizontal, Pencil, Plug, Plus, Star, Tag, Trash2, TrendingUp, Upload, Users, Workflow, X, } from "lucide-react";
|
|
3
|
-
import * as React from "react";
|
|
4
|
-
import { createPortal } from "react-dom";
|
|
5
|
-
import { Avatar } from "../../components/avatar/index.js";
|
|
6
|
-
import { BulkBar, BulkBarButton } from "../../components/bulkbar/index.js";
|
|
7
|
-
import { Button } from "../../components/button/index.js";
|
|
8
|
-
import { Checkbox } from "../../components/checkbox/index.js";
|
|
9
|
-
import { DatePickerField } from "../../components/datepickerfield/index.js";
|
|
10
|
-
import { Dialog, DialogBody, DialogFooter } from "../../components/dialog/index.js";
|
|
11
|
-
import { EmptyState, } from "../../components/emptystate/index.js";
|
|
12
|
-
import { Input } from "../../components/input/index.js";
|
|
13
|
-
import { Menu, MenuItem, useMenuKeyboard } from "../../components/menu/index.js";
|
|
14
|
-
import { MultiSelect } from "../../components/multiselect/index.js";
|
|
15
|
-
import { Select } from "../../components/select/index.js";
|
|
16
|
-
import { Switch } from "../../components/switch/index.js";
|
|
17
|
-
import { Tooltip } from "../../components/tooltip/index.js";
|
|
18
|
-
import { isInsideLaterFloatingLayer, isRtlElement, useCrispComponentDefaults, usePrefersReducedMotion, } from "../../core/index.js";
|
|
19
|
-
import { companyLogos } from "./company-logos.js";
|
|
20
|
-
import { recordstableRecipe } from "./recordstable.recipe.js";
|
|
21
|
-
// #534: whether an element's own content is being cut off by its own box --
|
|
22
|
-
// the same fact `.crisp-recordstable-th-label`'s CSS ellipsis is silently
|
|
23
|
-
// applying, exported so the comparison itself (not layout) can be
|
|
24
|
-
// mutation-tested without a real browser. Identical to DataTable's own
|
|
25
|
-
// `isLabelTruncated` (components/datatable/datatable.tsx) -- not shared
|
|
26
|
-
// because `product/` can depend on `components/` but not the other way, and
|
|
27
|
-
// this file already duplicates DataTable's other measured header behaviour
|
|
28
|
-
// rather than reach across that boundary.
|
|
29
|
-
export function isLabelTruncated(el) {
|
|
30
|
-
return el.scrollWidth > el.clientWidth;
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* The header label span: plain when it fits, wrapped in the shared `Tooltip`
|
|
34
|
-
* (full text, `asChild` so the wrapper never displaces the ellipsis CSS the
|
|
35
|
-
* span already carries -- see `Tooltip`'s own `asChild` doc) once it's
|
|
36
|
-
* actually cut off. Re-measures on mount and on resize (column drag,
|
|
37
|
-
* viewport change, label change) via `ResizeObserver`; SSR-safe the same way
|
|
38
|
-
* `MiddleTruncate` is -- the DOM is only touched inside an effect, so the
|
|
39
|
-
* server (and the first client paint) renders the plain span.
|
|
40
|
-
*
|
|
41
|
-
* #580: a `referencedType` column (Attio's own `type: "reference"`) appends
|
|
42
|
-
* that type's icon + name after the label and switches to `Tooltip`'s
|
|
43
|
-
* `"reference"` chrome variant (Attio's second, structurally distinct
|
|
44
|
-
* tooltip — same background/colour/radius/padding/font as the plain one, a
|
|
45
|
-
* different `box-shadow`, crisp#563). Every other column stays label-only,
|
|
46
|
-
* the `"plain"` variant #534/#558 shipped.
|
|
47
|
-
*/
|
|
48
|
-
function ThLabel({ label, referencedType, }) {
|
|
49
|
-
const ref = React.useRef(null);
|
|
50
|
-
const [overflowing, setOverflowing] = React.useState(false);
|
|
51
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: `label` isn't read in the body, but a rename (col.label changing with the column's own width unchanged) needs a re-measure a ResizeObserver on `el` won't fire for -- same reasoning as Tabs' own re-measure effect.
|
|
52
|
-
React.useLayoutEffect(() => {
|
|
53
|
-
const el = ref.current;
|
|
54
|
-
if (!el)
|
|
55
|
-
return;
|
|
56
|
-
const measure = () => setOverflowing(isLabelTruncated(el));
|
|
57
|
-
measure();
|
|
58
|
-
if (typeof ResizeObserver === "undefined")
|
|
59
|
-
return;
|
|
60
|
-
const ro = new ResizeObserver(measure);
|
|
61
|
-
ro.observe(el);
|
|
62
|
-
return () => ro.disconnect();
|
|
63
|
-
}, [label]);
|
|
64
|
-
// `Tooltip` always wraps the SAME span (never swapped in/out of the tree
|
|
65
|
-
// based on `overflowing`) so the span's own DOM node identity survives
|
|
66
|
-
// every toggle -- a real-browser-only bug (happy-dom has no
|
|
67
|
-
// ResizeObserver, so the test suite never exercised this path): swapping
|
|
68
|
-
// between a bare `<span>` and `<Tooltip asChild><span/></Tooltip>` changes
|
|
69
|
-
// the element type at this position, so React unmounts the old span and
|
|
70
|
-
// mounts a new one; the effect above never re-runs (its deps are
|
|
71
|
-
// `[label]`, unchanged), so its ResizeObserver keeps watching the now
|
|
72
|
-
// -detached old node, whose deferred initial callback then reports 0x0 and
|
|
73
|
-
// flips `overflowing` straight back to false -- the tooltip would never
|
|
74
|
-
// actually show. `open={false}` when it fits keeps the Tooltip mounted
|
|
75
|
-
// (asChild costs no extra DOM either way) but pins it permanently closed,
|
|
76
|
-
// so hovering a non-overflowing header still opens nothing.
|
|
77
|
-
const ReferenceIcon = referencedType?.icon;
|
|
78
|
-
const content = referencedType ? (_jsxs("span", { className: "crisp-tooltip-reference-row", children: [label, ReferenceIcon && _jsx(ReferenceIcon, { size: 16, strokeWidth: 1.75 }), referencedType.label] })) : (label);
|
|
79
|
-
return (_jsx(Tooltip, { content: content, asChild: true, variant: referencedType ? "reference" : "plain", open: overflowing ? undefined : false, children: _jsx("span", { ref: ref, className: "crisp-recordstable-th-label", children: label }) }));
|
|
80
|
-
}
|
|
81
|
-
const EDITABLE = new Set(["text", "number", "url", "currency"]);
|
|
82
|
-
// types whose cell is an always-interactive picker (not double-click-to-edit)
|
|
83
|
-
const PICKER = new Set(["status", "reference", "date", "tags"]);
|
|
84
|
-
// a `render` column owns its content fully — never editable, never a picker
|
|
85
|
-
const canEditCol = (col) => EDITABLE.has(col.type ?? "text") && !col.render;
|
|
86
|
-
const isPickerCol = (col) => PICKER.has(col.type ?? "text") && !col.render;
|
|
87
|
-
// deterministic pastel colour per tag value (a select option keeps its colour)
|
|
88
|
-
const TAG_TONES = [
|
|
89
|
-
"brand",
|
|
90
|
-
"success",
|
|
91
|
-
"warning",
|
|
92
|
-
"danger",
|
|
93
|
-
"purple",
|
|
94
|
-
"cyan",
|
|
95
|
-
"orange",
|
|
96
|
-
];
|
|
97
|
-
export function tagTone(t) {
|
|
98
|
-
let h = 0;
|
|
99
|
-
for (let i = 0; i < t.length; i++)
|
|
100
|
-
h = (h * 31 + t.charCodeAt(i)) >>> 0;
|
|
101
|
-
return TAG_TONES[h % TAG_TONES.length];
|
|
102
|
-
}
|
|
103
|
-
// deterministic per-name colour for `avatarTone="hashed"` — a distinct
|
|
104
|
-
// palette from TAG_TONES because it must stay within the tones the `Avatar`
|
|
105
|
-
// DS component itself supports (no "success"/"warning"/"danger").
|
|
106
|
-
const AVATAR_TONES = ["brand", "orange", "cyan", "purple", "green"];
|
|
107
|
-
function avatarToneFor(name) {
|
|
108
|
-
let h = 0;
|
|
109
|
-
for (let i = 0; i < name.length; i++)
|
|
110
|
-
h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
|
111
|
-
return AVATAR_TONES[h % AVATAR_TONES.length];
|
|
112
|
-
}
|
|
113
|
-
export const recordsTableLabels = {
|
|
114
|
-
selectAll: "Select all",
|
|
115
|
-
sort: "Sort",
|
|
116
|
-
sortField: "Sort field",
|
|
117
|
-
sortDirection: "Sort direction",
|
|
118
|
-
removeSort: "Remove sort",
|
|
119
|
-
reorderSort: "Reorder sort",
|
|
120
|
-
addSort: "Add sort",
|
|
121
|
-
ascending: "Ascending",
|
|
122
|
-
descending: "Descending",
|
|
123
|
-
noSorts: "No sorts applied to this view.",
|
|
124
|
-
learnSorting: "Learn about sorting",
|
|
125
|
-
filter: "Filter",
|
|
126
|
-
addFilter: "Add filter",
|
|
127
|
-
filterByField: "Filter by field",
|
|
128
|
-
filterByPlaceholder: "Filter by…",
|
|
129
|
-
filterValuePlaceholder: "Value…",
|
|
130
|
-
selectCondition: "Select condition",
|
|
131
|
-
clearFilter: "Clear filter",
|
|
132
|
-
addColumn: "Add column",
|
|
133
|
-
columnLabel: "Column label",
|
|
134
|
-
editColumnLabel: "Edit column label",
|
|
135
|
-
hideFromView: "Hide from view",
|
|
136
|
-
moveLeft: "Move left",
|
|
137
|
-
moveRight: "Move right",
|
|
138
|
-
sortAscending: "Sort ascending",
|
|
139
|
-
sortDescending: "Sort descending",
|
|
140
|
-
newRecord: "New record",
|
|
141
|
-
createRecord: "Create record",
|
|
142
|
-
createMore: "Create more",
|
|
143
|
-
newButton: "New",
|
|
144
|
-
more: "More",
|
|
145
|
-
moreActions: "More actions",
|
|
146
|
-
recordActions: "Record actions",
|
|
147
|
-
copy: "Copy",
|
|
148
|
-
duplicate: "Duplicate",
|
|
149
|
-
addToList: "Add to list",
|
|
150
|
-
inAList: "In a list",
|
|
151
|
-
searchRecordsPlaceholder: "Search records…",
|
|
152
|
-
searchOptionsPlaceholder: "Search options…",
|
|
153
|
-
selectedCount: "Selected",
|
|
154
|
-
selectionAnnouncement: (count) => count === 0 ? "Selection cleared" : `${count} selected`,
|
|
155
|
-
delete: "Delete",
|
|
156
|
-
sendEmail: "Send email",
|
|
157
|
-
runWorkflow: "Run workflow",
|
|
158
|
-
calculate: "Calculate",
|
|
159
|
-
emptyPlaceholder: "Empty",
|
|
160
|
-
setRecordPlaceholder: "Set a record…",
|
|
161
|
-
noRecords: "No records yet",
|
|
162
|
-
untitled: "Untitled",
|
|
163
|
-
importCsv: "Import CSV",
|
|
164
|
-
connectDataSource: "Connect data source",
|
|
165
|
-
agg: {
|
|
166
|
-
none: "None",
|
|
167
|
-
count: "Count all",
|
|
168
|
-
filled: "Not empty",
|
|
169
|
-
sum: "Sum",
|
|
170
|
-
avg: "Average",
|
|
171
|
-
min: "Min",
|
|
172
|
-
max: "Max",
|
|
173
|
-
},
|
|
174
|
-
op: {
|
|
175
|
-
contains: "contains",
|
|
176
|
-
doesnotcontain: "does not contain",
|
|
177
|
-
startswith: "starts with",
|
|
178
|
-
endswith: "ends with",
|
|
179
|
-
is: "is",
|
|
180
|
-
isnot: "is not",
|
|
181
|
-
empty: "is empty",
|
|
182
|
-
notempty: "is not empty",
|
|
183
|
-
},
|
|
184
|
-
newRecordTitle: (entity) => `New ${entity}`,
|
|
185
|
-
setFieldPlaceholder: (field) => `Set ${field}…`,
|
|
186
|
-
newColumnLabel: (index) => `Field ${index}`,
|
|
187
|
-
sortedBy: (first, extra) => `Sorted by ${first}${extra ? ` +${extra}` : ""}`,
|
|
188
|
-
filterCount: (active) => (active ? `Filter · ${active}` : "Filter"),
|
|
189
|
-
recordCount: (n) => `${n} record${n === 1 ? "" : "s"}`,
|
|
190
|
-
conditionTitle: (field) => `${field} condition`,
|
|
191
|
-
};
|
|
192
|
-
// currency is numeric data too — it gets the same numeric aggregations as
|
|
193
|
-
// `number` (only `number` auto-defaults to Sum on mount; currency starts at
|
|
194
|
-
// "none" like every other type and is picked explicitly from this menu)
|
|
195
|
-
const aggOptions = (col) => col.type === "number" || col.type === "currency"
|
|
196
|
-
? ["sum", "avg", "min", "max", "count", "filled", "none"]
|
|
197
|
-
: ["count", "filled", "none"];
|
|
198
|
-
const compact = new Intl.NumberFormat("en", {
|
|
199
|
-
notation: "compact",
|
|
200
|
-
maximumFractionDigits: 1,
|
|
201
|
-
});
|
|
202
|
-
const CHECK_W = 40;
|
|
203
|
-
const ADDCOL_W = 44;
|
|
204
|
-
const colWidth = (col) => col.width ?? (col.type === "entity" ? 240 : 180);
|
|
205
|
-
const OP_ORDER = [
|
|
206
|
-
"contains",
|
|
207
|
-
"doesnotcontain",
|
|
208
|
-
"startswith",
|
|
209
|
-
"endswith",
|
|
210
|
-
"is",
|
|
211
|
-
"isnot",
|
|
212
|
-
"empty",
|
|
213
|
-
"notempty",
|
|
214
|
-
];
|
|
215
|
-
const opNeedsValue = (op) => op !== "empty" && op !== "notempty";
|
|
216
|
-
function matchesFilter(col, row, cond) {
|
|
217
|
-
if (cond.op === "empty")
|
|
218
|
-
return !isFilled(col, row);
|
|
219
|
-
if (cond.op === "notempty")
|
|
220
|
-
return isFilled(col, row);
|
|
221
|
-
// filter always reads the raw underlying value, never `format`'s display
|
|
222
|
-
// string — a formatted "$1,200" must still match a filter value of "1200"
|
|
223
|
-
const t = rawText(col, row).toLowerCase();
|
|
224
|
-
const v = cond.value.trim().toLowerCase();
|
|
225
|
-
if (!v)
|
|
226
|
-
return true;
|
|
227
|
-
if (cond.op === "contains")
|
|
228
|
-
return t.includes(v);
|
|
229
|
-
if (cond.op === "doesnotcontain")
|
|
230
|
-
return !t.includes(v);
|
|
231
|
-
if (cond.op === "startswith")
|
|
232
|
-
return t.startsWith(v);
|
|
233
|
-
if (cond.op === "endswith")
|
|
234
|
-
return t.endsWith(v);
|
|
235
|
-
if (cond.op === "is")
|
|
236
|
-
return t === v;
|
|
237
|
-
return t !== v; // isnot
|
|
238
|
-
}
|
|
239
|
-
// the raw value stringified, ignoring `format`/`render` entirely — the basis
|
|
240
|
-
// for filtering and the fallback for `cellText`'s copy path
|
|
241
|
-
function rawText(col, row) {
|
|
242
|
-
const v = row[col.key];
|
|
243
|
-
if (v == null)
|
|
244
|
-
return "";
|
|
245
|
-
if (col.type === "entity")
|
|
246
|
-
return v.name ?? "";
|
|
247
|
-
if (Array.isArray(v))
|
|
248
|
-
return v.join(", ");
|
|
249
|
-
return String(v);
|
|
250
|
-
}
|
|
251
|
-
// what ⌘C / row-copy / CSV export puts on the clipboard for this cell: a
|
|
252
|
-
// `render` column has no serializable node, so this never touches `render` —
|
|
253
|
-
// it uses `format`'s display string when the column has one, else the raw
|
|
254
|
-
// value stringified exactly like a plain column.
|
|
255
|
-
function cellText(col, row) {
|
|
256
|
-
if (col.format)
|
|
257
|
-
return col.format(row[col.key], row);
|
|
258
|
-
return rawText(col, row);
|
|
259
|
-
}
|
|
260
|
-
function isFilled(col, row) {
|
|
261
|
-
const v = row[col.key];
|
|
262
|
-
if (v == null || v === "")
|
|
263
|
-
return false;
|
|
264
|
-
if (Array.isArray(v))
|
|
265
|
-
return v.length > 0;
|
|
266
|
-
if (col.type === "entity")
|
|
267
|
-
return Boolean(v.name);
|
|
268
|
-
return true;
|
|
269
|
-
}
|
|
270
|
-
function summarize(col, agg, rows) {
|
|
271
|
-
if (agg === "none")
|
|
272
|
-
return null;
|
|
273
|
-
if (agg === "count")
|
|
274
|
-
return { label: "count", value: String(rows.length) };
|
|
275
|
-
if (agg === "filled") {
|
|
276
|
-
const n = rows.filter((r) => isFilled(col, r)).length;
|
|
277
|
-
return { label: "filled", value: String(n) };
|
|
278
|
-
}
|
|
279
|
-
const nums = rows
|
|
280
|
-
.map((r) => r[col.key])
|
|
281
|
-
.filter((v) => typeof v === "number");
|
|
282
|
-
if (!nums.length)
|
|
283
|
-
return { label: agg, value: "—" };
|
|
284
|
-
let n;
|
|
285
|
-
if (agg === "sum")
|
|
286
|
-
n = nums.reduce((a, b) => a + b, 0);
|
|
287
|
-
else if (agg === "avg")
|
|
288
|
-
n = nums.reduce((a, b) => a + b, 0) / nums.length;
|
|
289
|
-
else if (agg === "min")
|
|
290
|
-
n = Math.min(...nums);
|
|
291
|
-
else
|
|
292
|
-
n = Math.max(...nums);
|
|
293
|
-
return { label: agg, value: compact.format(n) };
|
|
294
|
-
}
|
|
295
|
-
function EntityCell({ value, pinned, onOpenPage, avatarTone = "brand", labels, }) {
|
|
296
|
-
// a real favicon/logo when we have one; fall back to the letter tile if it
|
|
297
|
-
// 404s or the row has no avatar
|
|
298
|
-
const [imgFailed, setImgFailed] = React.useState(false);
|
|
299
|
-
const showImg = Boolean(value.avatar) && !imgFailed;
|
|
300
|
-
return (_jsxs("span", { className: "crisp-recordstable-entity", children: [showImg ? (_jsx("img", { className: "crisp-recordstable-fav", src: value.avatar, alt: "", onError: () => setImgFailed(true) })) : (_jsx(Avatar, { size: "sm", shape: "rounded", tone: avatarTone === "hashed" ? avatarToneFor(value.name) : "brand", children: value.name.charAt(0) })), onOpenPage ? (_jsx("button", { type: "button", className: "crisp-recordstable-entity-name crisp-recordstable-entity-name--link", onClick: (e) => {
|
|
301
|
-
e.stopPropagation();
|
|
302
|
-
onOpenPage();
|
|
303
|
-
}, children: value.name })) : (_jsx("span", { className: "crisp-recordstable-entity-name", children: value.name })), pinned ? (_jsx(Star, { className: "crisp-recordstable-pin", size: 13, strokeWidth: 2, "aria-label": labels.inAList })) : null] }));
|
|
304
|
-
}
|
|
305
|
-
function CellView({ column, row, pinned, onOpenPage, avatarTone = "brand", labels, }) {
|
|
306
|
-
const value = row[column.key];
|
|
307
|
-
// `render` fully owns the cell's content — no empty fallback, no type
|
|
308
|
-
// renderer; it's the only thing drawn here (see the doc comment on
|
|
309
|
-
// `Column.render`).
|
|
310
|
-
if (column.render)
|
|
311
|
-
return _jsx(_Fragment, { children: column.render(value, row) });
|
|
312
|
-
if (value == null ||
|
|
313
|
-
value === "" ||
|
|
314
|
-
(Array.isArray(value) && !value.length)) {
|
|
315
|
-
// Attio's own empty cell is genuinely blank -- no placeholder character
|
|
316
|
-
// at all (#412).
|
|
317
|
-
return null;
|
|
318
|
-
}
|
|
319
|
-
// `format` keeps the type renderer's chrome below and only replaces the
|
|
320
|
-
// text it displays.
|
|
321
|
-
const display = column.format?.(value, row);
|
|
322
|
-
switch (column.type) {
|
|
323
|
-
case "entity":
|
|
324
|
-
return (_jsx(EntityCell, { labels: labels, value: value, pinned: pinned, onOpenPage: onOpenPage, avatarTone: avatarTone }));
|
|
325
|
-
case "tags":
|
|
326
|
-
return (_jsx("span", { className: "crisp-recordstable-cell crisp-recordstable-cell--tags", children: value.map((t) => (_jsx("span", { className: "crisp-recordstable-tag", "data-tone": tagTone(t), children: t }, t))) }));
|
|
327
|
-
case "number":
|
|
328
|
-
return (_jsx("span", { className: "crisp-recordstable-cell crisp-recordstable-cell--num", children: display ??
|
|
329
|
-
(typeof value === "number" ? value.toLocaleString() : value) }));
|
|
330
|
-
case "url":
|
|
331
|
-
return (_jsx("a", { className: "crisp-recordstable-link", href: `https://${value}`, target: "_blank", rel: "noreferrer", onClick: (e) => e.stopPropagation(), children: display ?? value }));
|
|
332
|
-
case "date":
|
|
333
|
-
return (_jsx("span", { className: "crisp-recordstable-muted", children: display ?? value }));
|
|
334
|
-
case "currency": {
|
|
335
|
-
const n = typeof value === "number" ? value : Number(value);
|
|
336
|
-
return (_jsx("span", { className: "crisp-recordstable-cell crisp-recordstable-cell--num", children: display ??
|
|
337
|
-
`${column.symbol ?? "$"}${Number.isFinite(n) ? n.toLocaleString() : value}` }));
|
|
338
|
-
}
|
|
339
|
-
case "status": {
|
|
340
|
-
const opt = column.options?.find((o) => o.value === value);
|
|
341
|
-
if (!opt)
|
|
342
|
-
return _jsx("span", { className: "crisp-recordstable-muted", children: "\u2014" });
|
|
343
|
-
return (_jsxs("span", { className: "crisp-recordstable-status", children: [_jsx("span", { className: "crisp-recordstable-status-dot", style: { background: opt.color } }), display ?? opt.label] }));
|
|
344
|
-
}
|
|
345
|
-
case "reference": {
|
|
346
|
-
const opt = column.refOptions?.find((o) => o.value === value);
|
|
347
|
-
const label = display ?? opt?.label ?? String(value);
|
|
348
|
-
return (_jsxs("span", { className: "crisp-recordstable-entity", children: [_jsx(Avatar, { size: "sm", shape: "rounded", tone: avatarTone === "hashed" ? avatarToneFor(label) : "brand", children: label.charAt(0) }), _jsx("span", { className: "crisp-recordstable-entity-name", children: label })] }));
|
|
349
|
-
}
|
|
350
|
-
default:
|
|
351
|
-
return (_jsx("span", { className: "crisp-recordstable-entity-name", children: display ?? value }));
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
// Always-interactive cell editors for the picker column types (status / date /
|
|
355
|
-
// reference). Each is a ghost crisp control that reads the cell value and
|
|
356
|
-
// commits the new one — matching measured inline field editors.
|
|
357
|
-
function StatusCell({ column, row, onChange, }) {
|
|
358
|
-
return (_jsx(Select, { variant: "ghost", "aria-label": column.label, placeholder: "\u2014", value: row[column.key] ?? undefined, onValueChange: onChange, options: column.options ?? [] }));
|
|
359
|
-
}
|
|
360
|
-
// A sentinel that can't collide with a real record id (crisp never generates
|
|
361
|
-
// one, and a consumer's own ids come from their own data store) -- picking
|
|
362
|
-
// this row calls `onCreateReference`, not `onChange` (#449).
|
|
363
|
-
const CREATE_REFERENCE_VALUE = "__crisp_create_reference__";
|
|
364
|
-
/** The 16px avatar Attio's own reference picker draws before every option's
|
|
365
|
-
* label -- a photo when the option carries one, a generic person glyph
|
|
366
|
-
* otherwise (measured live, #449: Attio's own fallback is an icon, not
|
|
367
|
-
* initials -- a different, deliberate choice from the entity cell's own
|
|
368
|
-
* initials fallback elsewhere in this file). */
|
|
369
|
-
function referenceOptionLabel(o) {
|
|
370
|
-
return (_jsxs("span", { className: "crisp-recordstable-refoption", children: [_jsx(Avatar, { size: "sm", className: "crisp-recordstable-refoption-avatar", children: o.avatar ? (_jsx("img", { src: o.avatar, alt: "" })) : (_jsx(CircleUserRound, { size: 12, strokeWidth: 1.75, "aria-hidden": "true" })) }), o.label] }));
|
|
371
|
-
}
|
|
372
|
-
function ReferenceCell({ column, row, onChange, labels, }) {
|
|
373
|
-
const [query, setQuery] = React.useState("");
|
|
374
|
-
const options = (column.refOptions ?? []).map((o) => ({
|
|
375
|
-
value: o.value,
|
|
376
|
-
label: referenceOptionLabel(o),
|
|
377
|
-
keywords: o.label,
|
|
378
|
-
}));
|
|
379
|
-
if (column.onCreateReference) {
|
|
380
|
-
options.push({
|
|
381
|
-
value: CREATE_REFERENCE_VALUE,
|
|
382
|
-
label: (_jsxs("span", { className: "crisp-recordstable-refoption crisp-recordstable-refoption--create", children: [_jsx(Plus, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }), `Create ${column.createLabel ?? "record"}`] })),
|
|
383
|
-
// always reachable regardless of the typed query -- it's an action,
|
|
384
|
-
// not a record to filter away
|
|
385
|
-
keywords: query,
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
return (_jsx(Select, { variant: "ghost", searchable: true, searchPlaceholder: labels.searchRecordsPlaceholder, onSearchChange: setQuery, "aria-label": column.label, placeholder: labels.setRecordPlaceholder, value: row[column.key] ?? undefined, onValueChange: (v) => {
|
|
389
|
-
if (v === CREATE_REFERENCE_VALUE)
|
|
390
|
-
column.onCreateReference?.(query);
|
|
391
|
-
else
|
|
392
|
-
onChange(v);
|
|
393
|
-
}, options: options }));
|
|
394
|
-
}
|
|
395
|
-
function TagsCell({ column, row, options, onChange, labels, }) {
|
|
396
|
-
const value = row[column.key] ?? [];
|
|
397
|
-
return (_jsx(MultiSelect, { options: options, value: value, onValueChange: onChange, placeholder: labels.emptyPlaceholder, searchPlaceholder: labels.searchOptionsPlaceholder, "aria-label": column.label }));
|
|
398
|
-
}
|
|
399
|
-
/**
|
|
400
|
-
* A date-only cell is a CALENDAR DATE, not an instant. ECMA-262 parses the
|
|
401
|
-
* date-only ISO forms — `1998`, `1998-09`, `1998-09-04` — as UTC midnight, and
|
|
402
|
-
* the field then renders that instant in the reader's own timezone. A
|
|
403
|
-
* `founded: "1998"` field therefore read **Dec 31, 1997** for every reader west
|
|
404
|
-
* of UTC, at every hour of every day, and a date picked east of UTC
|
|
405
|
-
* round-tripped back a day short through `toISOString()` (#220).
|
|
406
|
-
*
|
|
407
|
-
* A string that carries a time is left alone: that one really is an instant,
|
|
408
|
-
* and shifting it would move the moment it names.
|
|
409
|
-
*/
|
|
410
|
-
// parseCellDate/formatCellDate moved to core/cell-date.ts so DataTable shares
|
|
411
|
-
// one implementation rather than a second copy -- #221 was this defect fixed
|
|
412
|
-
// here (#220) and left in DataTable. Re-exported because they were already
|
|
413
|
-
// reachable from this module's public surface.
|
|
414
|
-
// imported for use here AND re-exported, because a bare `export ... from` does
|
|
415
|
-
// not bind the names locally
|
|
416
|
-
import { cellDatePrecision, formatCellDate, parseCellDate, } from "../../core/index.js";
|
|
417
|
-
export { formatCellDate, parseCellDate };
|
|
418
|
-
// "1998" for a year-only raw value, "Sep 1998" for year-month, and the full
|
|
419
|
-
// "Sep 4, 1998" only once the raw value actually named a day (#312) --
|
|
420
|
-
// DatePickerField's own default format always prints full precision, since
|
|
421
|
-
// as a general calendar field it has no raw string to read a precision from.
|
|
422
|
-
function formatByPrecision(d, precision) {
|
|
423
|
-
if (precision === "year")
|
|
424
|
-
return String(d.getFullYear());
|
|
425
|
-
return d.toLocaleDateString(undefined, {
|
|
426
|
-
month: "short",
|
|
427
|
-
day: precision === "month" ? undefined : "numeric",
|
|
428
|
-
year: "numeric",
|
|
429
|
-
});
|
|
430
|
-
}
|
|
431
|
-
function DateCell({ column, row, onChange, }) {
|
|
432
|
-
const raw = row[column.key];
|
|
433
|
-
const value = parseCellDate(raw);
|
|
434
|
-
const precision = raw ? cellDatePrecision(raw) : "day";
|
|
435
|
-
return (_jsx(DatePickerField, { variant: "ghost", "aria-label": column.label, value: value,
|
|
436
|
-
// Out of scope for #394 (which added Clear to DatePickerField itself):
|
|
437
|
-
// a cell's OWN clear-to-empty affordance is a different, unmeasured
|
|
438
|
-
// question, and this callback's `string` contract has no "empty" of
|
|
439
|
-
// its own to report yet. Keeps this cell's exact pre-#394 behavior.
|
|
440
|
-
clearable: false,
|
|
441
|
-
// #417: the trigger displays the value at its stored precision
|
|
442
|
-
// (year-only stays year-only) rather than always a full date.
|
|
443
|
-
format: (d) => formatByPrecision(d, precision), onChange: (d) => {
|
|
444
|
-
if (d)
|
|
445
|
-
onChange(formatCellDate(d));
|
|
446
|
-
} }));
|
|
447
|
-
}
|
|
448
|
-
/**
|
|
449
|
-
* The "New <object>" create modal — measured from a create-record dialog:
|
|
450
|
-
* a stacked label + "Set …" input per column, a "Create more" toggle, and a
|
|
451
|
-
* primary "Create record" action (⌘↵). Building the row mirrors the inline
|
|
452
|
-
* editor's per-type coercion so a created record reads back like an edited one.
|
|
453
|
-
*/
|
|
454
|
-
function CreateRecordDialog({ open, onOpenChange, columns, entityLabel, onCreate, labels, }) {
|
|
455
|
-
const [values, setValues] = React.useState({});
|
|
456
|
-
const [createMore, setCreateMore] = React.useState(false);
|
|
457
|
-
const firstRef = React.useRef(null);
|
|
458
|
-
// reset the form each time the modal (re)opens
|
|
459
|
-
React.useEffect(() => {
|
|
460
|
-
if (open)
|
|
461
|
-
setValues({});
|
|
462
|
-
}, [open]);
|
|
463
|
-
const set = (key, v) => setValues((prev) => ({ ...prev, [key]: v }));
|
|
464
|
-
const buildRow = () => {
|
|
465
|
-
const row = {
|
|
466
|
-
id: `new-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
467
|
-
};
|
|
468
|
-
for (const col of columns) {
|
|
469
|
-
const raw = (values[col.key] ?? "").trim();
|
|
470
|
-
if (col.type === "entity") {
|
|
471
|
-
row[col.key] = { name: raw || labels.untitled };
|
|
472
|
-
}
|
|
473
|
-
else if (!raw) {
|
|
474
|
-
// leave empty fields unset so the cell renders blank (#412)
|
|
475
|
-
}
|
|
476
|
-
else if (col.type === "tags") {
|
|
477
|
-
row[col.key] = raw
|
|
478
|
-
.split(",")
|
|
479
|
-
.map((t) => t.trim())
|
|
480
|
-
.filter(Boolean);
|
|
481
|
-
}
|
|
482
|
-
else if (col.type === "number") {
|
|
483
|
-
row[col.key] = Number(raw.replace(/[^0-9.-]/g, "")) || null;
|
|
484
|
-
}
|
|
485
|
-
else {
|
|
486
|
-
row[col.key] = raw;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
return row;
|
|
490
|
-
};
|
|
491
|
-
const submit = () => {
|
|
492
|
-
onCreate(buildRow());
|
|
493
|
-
if (createMore) {
|
|
494
|
-
setValues({});
|
|
495
|
-
firstRef.current?.focus();
|
|
496
|
-
}
|
|
497
|
-
else {
|
|
498
|
-
onOpenChange(false);
|
|
499
|
-
}
|
|
500
|
-
};
|
|
501
|
-
const entityCol = columns.find((c) => c.type === "entity");
|
|
502
|
-
const EntityIcon = entityCol?.icon;
|
|
503
|
-
return (_jsxs(Dialog, { open: open, onOpenChange: onOpenChange, title: labels.newRecordTitle(entityLabel), icon: EntityIcon ? _jsx(EntityIcon, { size: 16, strokeWidth: 1.75 }) : undefined, width: 720, children: [_jsx(DialogBody, { children: columns.map((col, i) => (_jsxs("div", { className: "crisp-dialog-field", children: [_jsx("label", { className: "crisp-dialog-field-label", htmlFor: `create-${col.key}`, children: col.label }), _jsx(Input, { id: `create-${col.key}`, ref: i === 0 ? firstRef : undefined, value: values[col.key] ?? "", onChange: (e) => set(col.key, e.currentTarget.value), placeholder: labels.setFieldPlaceholder(col.label), onKeyDown: (e) => {
|
|
504
|
-
if (e.key === "Enter" && (e.metaKey || e.ctrlKey))
|
|
505
|
-
submit();
|
|
506
|
-
} })] }, col.key))) }), _jsxs(DialogFooter, { children: [_jsxs("span", { className: "crisp-dialog-more", children: [_jsx(Switch, { checked: createMore, onCheckedChange: setCreateMore, "aria-label": labels.createMore }), labels.createMore] }), _jsxs("button", { type: "button", className: "crisp-dialog-submit", onClick: submit, children: [labels.createRecord, _jsxs("span", { className: "crisp-dialog-submit-key", "aria-hidden": "true", children: [_jsx("kbd", { children: "\u2318" }), _jsx("kbd", { children: _jsx(CornerDownLeft, { size: 10, strokeWidth: 2 }) })] })] })] })] }));
|
|
507
|
-
}
|
|
508
|
-
/**
|
|
509
|
-
* A measured-style record grid — fully featured. Toolbar (search / sort status /
|
|
510
|
-
* count / New), sticky checkbox + entity columns, 36px rows, typed cells,
|
|
511
|
-
* click-to-sort headers, column resize by dragging a header edge, inline cell
|
|
512
|
-
* editing (double-click a text/number/url/date cell), add-row and add-column,
|
|
513
|
-
* and a floating bulk-action bar (delete / deselect) when rows are selected.
|
|
514
|
-
* Selection can be controlled via `selectedIds`/`onSelectionChange`.
|
|
515
|
-
*
|
|
516
|
-
* @deprecated Reach for `DataTable` instead — a single Attio-exact records
|
|
517
|
-
* grid replaces the separate table variants. `RecordsTable` has no
|
|
518
|
-
* consumers left (checked GRC and the fms/Mercury admin) and is slated for
|
|
519
|
-
* removal in a future minor.
|
|
520
|
-
*/
|
|
521
|
-
export function RecordsTable({ labels, columns: columnsProp = defaultColumns, rows: rowsProp = defaultRows, selectedIds, onSelectionChange, showNew = true, allowAddColumn = true, onOpenRecord, onOpenRecordPage, avatarTone = "brand", emptyStateExtra, className, ...rest }) {
|
|
522
|
-
const [cols, setCols] = React.useState(columnsProp);
|
|
523
|
-
const [data, setData] = React.useState(rowsProp);
|
|
524
|
-
// The table owns `cols`/`data` so it can rename a column, edit a cell, or
|
|
525
|
-
// append a record without a round trip. That state is seeded from the props,
|
|
526
|
-
// so without these it would be a mount-time SNAPSHOT: an app that renders
|
|
527
|
-
// `rows={[]}` and re-renders with fetched rows would show "No records yet"
|
|
528
|
-
// for ever. Re-seed whenever the caller hands us a new array -- caller wins,
|
|
529
|
-
// exactly as DataTable does for its columns. Local edits survive until then,
|
|
530
|
-
// because a caller passing a stable array never triggers this.
|
|
531
|
-
React.useEffect(() => setCols(columnsProp), [columnsProp]);
|
|
532
|
-
React.useEffect(() => setData(rowsProp), [rowsProp]);
|
|
533
|
-
// Multi-criteria sort: an ordered list of {field, direction} rows.
|
|
534
|
-
const [sorts, setSorts] = React.useState([]);
|
|
535
|
-
const [dragSort, setDragSort] = React.useState(null);
|
|
536
|
-
const [editing, setEditing] = React.useState(null);
|
|
537
|
-
const [renamingCol, setRenamingCol] = React.useState(null);
|
|
538
|
-
const [creating, setCreating] = React.useState(false);
|
|
539
|
-
// A measured-style filter: an ordered list of conditions, each shown as a toolbar
|
|
540
|
-
// chip with an editable value (operator is "contains")
|
|
541
|
-
const [filterConds, setFilterConds] = React.useState([]);
|
|
542
|
-
const [filterQuery, setFilterQuery] = React.useState("");
|
|
543
|
-
// one popover open at a time: "sort" | "filter" | "more" | `sum:<key>`
|
|
544
|
-
// crisp/#471: same "provider sits between the built-in default and this
|
|
545
|
-
// instance's own explicit labels prop" order as RecordPage's own — see
|
|
546
|
-
// its comment. Several entries here (`selectionAnnouncement`, `sortedBy`,
|
|
547
|
-
// `recordCount`, …) are formatter functions, which is why this goes
|
|
548
|
-
// through `useCrispComponentDefaults`, not `useCrispMessage`.
|
|
549
|
-
const providerLabels = useCrispComponentDefaults("recordsTable");
|
|
550
|
-
const t = React.useMemo(() => ({
|
|
551
|
-
...recordsTableLabels,
|
|
552
|
-
...providerLabels,
|
|
553
|
-
...labels,
|
|
554
|
-
agg: {
|
|
555
|
-
...recordsTableLabels.agg,
|
|
556
|
-
...providerLabels?.agg,
|
|
557
|
-
...labels?.agg,
|
|
558
|
-
},
|
|
559
|
-
op: { ...recordsTableLabels.op, ...providerLabels?.op, ...labels?.op },
|
|
560
|
-
}), [labels, providerLabels]);
|
|
561
|
-
const [openMenu, setOpenMenu] = React.useState(null);
|
|
562
|
-
// the element that opened the current menu, so Esc/select can hand focus back
|
|
563
|
-
const menuReturn = React.useRef(null);
|
|
564
|
-
const menuRef = React.useRef(null);
|
|
565
|
-
const ctxRef = React.useRef(null);
|
|
566
|
-
const ctxReturn = React.useRef(null);
|
|
567
|
-
// viewport-anchored position for menus that live inside the scroll container
|
|
568
|
-
// (summary footer, column headers) — rendered fixed to escape the overflow clip
|
|
569
|
-
const [menuPos, setMenuPos] = React.useState(null);
|
|
570
|
-
const [pinned, setPinned] = React.useState(() => new Set());
|
|
571
|
-
const [scrolledX, setScrolledX] = React.useState(false);
|
|
572
|
-
const idCounter = React.useRef(0);
|
|
573
|
-
// fixed menus (summary / header) portal here — outside the sticky cells'
|
|
574
|
-
// stacking contexts, but still inside .recordstable so the styles apply
|
|
575
|
-
const rootRef = React.useRef(null);
|
|
576
|
-
const scrollRef = React.useRef(null);
|
|
577
|
-
// spreadsheet-style cell selection (click a cell, arrow-key to navigate)
|
|
578
|
-
const [selCell, setSelCell] = React.useState(null);
|
|
579
|
-
// shift-click range selection — anchor row + whether shift was held on click
|
|
580
|
-
const selAnchor = React.useRef(null);
|
|
581
|
-
const shiftHeld = React.useRef(false);
|
|
582
|
-
// right-click row context menu (fixed at the cursor)
|
|
583
|
-
const [ctxMenu, setCtxMenu] = React.useState(null);
|
|
584
|
-
// column drag-to-reorder — key of the header currently being dragged
|
|
585
|
-
const [dragCol, setDragCol] = React.useState(null);
|
|
586
|
-
// Let a plain vertical wheel scroll the (wide) table horizontally when it has
|
|
587
|
-
// no vertical overflow of its own — otherwise the wheel just scrolls the page
|
|
588
|
-
// and the extra columns feel unreachable. Non-passive so we can preventDefault.
|
|
589
|
-
React.useEffect(() => {
|
|
590
|
-
const el = scrollRef.current;
|
|
591
|
-
if (!el)
|
|
592
|
-
return;
|
|
593
|
-
const onWheel = (e) => {
|
|
594
|
-
// don't hijack the wheel when it's over an open cell popover (its own list
|
|
595
|
-
// scrolls) — only map vertical wheel to horizontal for the table itself
|
|
596
|
-
if (e.target?.closest?.(".crisp-select-listbox, .crisp-multiselect-listbox, .datepicker-cal, .crisp-recordstable-menu"))
|
|
597
|
-
return;
|
|
598
|
-
const canH = el.scrollWidth > el.clientWidth;
|
|
599
|
-
const canV = el.scrollHeight > el.clientHeight;
|
|
600
|
-
if (canH && !canV && e.deltaX === 0 && e.deltaY !== 0) {
|
|
601
|
-
el.scrollLeft += e.deltaY;
|
|
602
|
-
e.preventDefault();
|
|
603
|
-
}
|
|
604
|
-
};
|
|
605
|
-
el.addEventListener("wheel", onWheel, { passive: false });
|
|
606
|
-
return () => el.removeEventListener("wheel", onWheel);
|
|
607
|
-
}, []);
|
|
608
|
-
// close any open menu on outside click (anything not inside a menu or its
|
|
609
|
-
// trigger) or Escape
|
|
610
|
-
React.useEffect(() => {
|
|
611
|
-
if (!openMenu)
|
|
612
|
-
return;
|
|
613
|
-
const onDown = (e) => {
|
|
614
|
-
const t = e.target;
|
|
615
|
-
if (t.closest(".crisp-recordstable-menu, [data-menu-trigger]"))
|
|
616
|
-
return;
|
|
617
|
-
// #339: a Select/MultiSelect/Menu opened from inside this popover now
|
|
618
|
-
// portals to document.body, so it's no longer inside
|
|
619
|
-
// .crisp-recordstable-menu by DOM containment -- check the shared
|
|
620
|
-
// floating-layer stack too.
|
|
621
|
-
if (isInsideLaterFloatingLayer(t, null))
|
|
622
|
-
return;
|
|
623
|
-
setOpenMenu(null);
|
|
624
|
-
};
|
|
625
|
-
const onKey = (e) => e.key === "Escape" && setOpenMenu(null);
|
|
626
|
-
document.addEventListener("mousedown", onDown);
|
|
627
|
-
document.addEventListener("keydown", onKey);
|
|
628
|
-
return () => {
|
|
629
|
-
document.removeEventListener("mousedown", onDown);
|
|
630
|
-
document.removeEventListener("keydown", onKey);
|
|
631
|
-
};
|
|
632
|
-
}, [openMenu]);
|
|
633
|
-
// dismiss the right-click row context menu on any outside click / Escape
|
|
634
|
-
React.useEffect(() => {
|
|
635
|
-
if (!ctxMenu)
|
|
636
|
-
return;
|
|
637
|
-
const onDown = (e) => {
|
|
638
|
-
const t = e.target;
|
|
639
|
-
if (t.closest(".crisp-recordstable-ctx"))
|
|
640
|
-
return;
|
|
641
|
-
if (isInsideLaterFloatingLayer(t, null))
|
|
642
|
-
return;
|
|
643
|
-
setCtxMenu(null);
|
|
644
|
-
};
|
|
645
|
-
const onKey = (e) => e.key === "Escape" && setCtxMenu(null);
|
|
646
|
-
document.addEventListener("mousedown", onDown);
|
|
647
|
-
document.addEventListener("keydown", onKey);
|
|
648
|
-
return () => {
|
|
649
|
-
document.removeEventListener("mousedown", onDown);
|
|
650
|
-
document.removeEventListener("keydown", onKey);
|
|
651
|
-
};
|
|
652
|
-
}, [ctxMenu]);
|
|
653
|
-
const activeFilters = filterConds.length;
|
|
654
|
-
const addFilter = (key) => {
|
|
655
|
-
idCounter.current += 1;
|
|
656
|
-
const id = `filter-${idCounter.current}`;
|
|
657
|
-
setFilterConds((prev) => [...prev, { id, key, op: "contains", value: "" }]);
|
|
658
|
-
setFilterQuery("");
|
|
659
|
-
setOpenMenu(`fcond:${id}`); // open its condition editor right away
|
|
660
|
-
};
|
|
661
|
-
const patchFilter = (id, patch) => setFilterConds((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c)));
|
|
662
|
-
const removeFilter = (id) => {
|
|
663
|
-
setFilterConds((prev) => prev.filter((c) => c.id !== id));
|
|
664
|
-
setOpenMenu(null);
|
|
665
|
-
};
|
|
666
|
-
const [agg, setAgg] = React.useState(() => {
|
|
667
|
-
const m = {};
|
|
668
|
-
for (const col of columnsProp) {
|
|
669
|
-
m[col.key] =
|
|
670
|
-
col.type === "number"
|
|
671
|
-
? "sum"
|
|
672
|
-
: col.type === "entity"
|
|
673
|
-
? "count"
|
|
674
|
-
: "none";
|
|
675
|
-
}
|
|
676
|
-
return m;
|
|
677
|
-
});
|
|
678
|
-
// selection — controlled if `selectedIds` is provided, else internal
|
|
679
|
-
const [internalSel, setInternalSel] = React.useState(() => new Set());
|
|
680
|
-
const selected = React.useMemo(() => (selectedIds ? new Set(selectedIds) : internalSel), [selectedIds, internalSel]);
|
|
681
|
-
// The selection is announced from a region that is part of the table and
|
|
682
|
-
// outlives every selection, because the bulk bar the consumer mounts on the
|
|
683
|
-
// first pick cannot announce its own arrival (#234). Derived by adjusting
|
|
684
|
-
// state during render rather than in an effect: it must not fire on mount —
|
|
685
|
-
// nobody has selected anything yet — and it has to cover a `selectedIds`
|
|
686
|
-
// change the consumer makes as well as a click in the table.
|
|
687
|
-
const prevSelectedCount = React.useRef(selected.size);
|
|
688
|
-
const [selectionAnnouncement, setSelectionAnnouncement] = React.useState("");
|
|
689
|
-
if (prevSelectedCount.current !== selected.size) {
|
|
690
|
-
prevSelectedCount.current = selected.size;
|
|
691
|
-
setSelectionAnnouncement(t.selectionAnnouncement(selected.size));
|
|
692
|
-
}
|
|
693
|
-
const setSelected = React.useCallback((next) => {
|
|
694
|
-
onSelectionChange?.([...next]);
|
|
695
|
-
if (!selectedIds)
|
|
696
|
-
setInternalSel(next);
|
|
697
|
-
}, [onSelectionChange, selectedIds]);
|
|
698
|
-
const filtered = React.useMemo(() => {
|
|
699
|
-
const active = filterConds.filter((c) => !opNeedsValue(c.op) || c.value.trim());
|
|
700
|
-
if (!active.length)
|
|
701
|
-
return data;
|
|
702
|
-
return data.filter((row) => active.every((cond) => {
|
|
703
|
-
const col = cols.find((c) => c.key === cond.key);
|
|
704
|
-
return col ? matchesFilter(col, row, cond) : true;
|
|
705
|
-
}));
|
|
706
|
-
}, [data, cols, filterConds]);
|
|
707
|
-
const visibleRows = React.useMemo(() => {
|
|
708
|
-
if (sorts.length === 0)
|
|
709
|
-
return filtered;
|
|
710
|
-
const getters = sorts.map((s) => {
|
|
711
|
-
const col = cols.find((c) => c.key === s.key);
|
|
712
|
-
return (r) => {
|
|
713
|
-
const v = r[s.key];
|
|
714
|
-
if (col?.type === "entity")
|
|
715
|
-
return v?.name ?? "";
|
|
716
|
-
if (Array.isArray(v))
|
|
717
|
-
return v[0] ?? "";
|
|
718
|
-
return v ?? "";
|
|
719
|
-
};
|
|
720
|
-
});
|
|
721
|
-
return [...filtered].sort((a, b) => {
|
|
722
|
-
for (let i = 0; i < sorts.length; i++) {
|
|
723
|
-
const get = getters[i];
|
|
724
|
-
const av = get(a);
|
|
725
|
-
const bv = get(b);
|
|
726
|
-
const cmp = typeof av === "number" && typeof bv === "number"
|
|
727
|
-
? av - bv
|
|
728
|
-
: String(av).localeCompare(String(bv));
|
|
729
|
-
if (cmp !== 0)
|
|
730
|
-
return sorts[i].dir === "asc" ? cmp : -cmp;
|
|
731
|
-
}
|
|
732
|
-
return 0;
|
|
733
|
-
});
|
|
734
|
-
}, [filtered, cols, sorts]);
|
|
735
|
-
// arrow-key navigation over the selected cell (spreadsheet-style)
|
|
736
|
-
const onGridKeyDown = (e) => {
|
|
737
|
-
if (!selCell || editing)
|
|
738
|
-
return;
|
|
739
|
-
// ⌘C / Ctrl+C — copy the selected cell's rendered text to the clipboard
|
|
740
|
-
if ((e.metaKey || e.ctrlKey) && (e.key === "c" || e.key === "C")) {
|
|
741
|
-
const row = visibleRows.find((rw) => rw.id === selCell.id);
|
|
742
|
-
const col = cols.find((cc) => cc.key === selCell.key);
|
|
743
|
-
if (row && col)
|
|
744
|
-
navigator.clipboard?.writeText(cellText(col, row));
|
|
745
|
-
e.preventDefault();
|
|
746
|
-
return;
|
|
747
|
-
}
|
|
748
|
-
const r = visibleRows.findIndex((row) => row.id === selCell.id);
|
|
749
|
-
const c = cols.findIndex((col) => col.key === selCell.key);
|
|
750
|
-
if (r < 0 || c < 0)
|
|
751
|
-
return;
|
|
752
|
-
let nr = r;
|
|
753
|
-
let nc = c;
|
|
754
|
-
if (e.key === "ArrowUp")
|
|
755
|
-
nr = Math.max(0, r - 1);
|
|
756
|
-
else if (e.key === "ArrowDown")
|
|
757
|
-
nr = Math.min(visibleRows.length - 1, r + 1);
|
|
758
|
-
// The arrows follow what the user SEES. Under RTL the first column renders
|
|
759
|
-
// rightmost, so ArrowLeft moves to the next column and ArrowRight to the
|
|
760
|
-
// previous — the grid's own half-conversion, of the same kind the resize
|
|
761
|
-
// grips had (#255).
|
|
762
|
-
else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
|
763
|
-
const rtl = isRtlElement(e.currentTarget);
|
|
764
|
-
const forward = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
|
|
765
|
-
nc = forward ? Math.min(cols.length - 1, c + 1) : Math.max(0, c - 1);
|
|
766
|
-
}
|
|
767
|
-
else if (e.key === "Escape") {
|
|
768
|
-
setSelCell(null);
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
else if (e.key === "Enter") {
|
|
772
|
-
if (canEditCol(cols[c])) {
|
|
773
|
-
e.preventDefault();
|
|
774
|
-
setEditing({ id: selCell.id, key: selCell.key });
|
|
775
|
-
}
|
|
776
|
-
return;
|
|
777
|
-
}
|
|
778
|
-
else
|
|
779
|
-
return;
|
|
780
|
-
e.preventDefault();
|
|
781
|
-
setSelCell({ id: visibleRows[nr].id, key: cols[nc].key });
|
|
782
|
-
};
|
|
783
|
-
const allSelected = visibleRows.length > 0 && visibleRows.every((r) => selected.has(r.id));
|
|
784
|
-
const someSelected = selected.size > 0 && !allSelected;
|
|
785
|
-
// The header checkbox speaks for the rows you can SEE. Rebuilding the whole
|
|
786
|
-
// set from `visibleRows` would silently drop anything an active filter is
|
|
787
|
-
// hiding -- on a surface with a bulk Delete, that loses a selection the user
|
|
788
|
-
// made. Add/remove the visible rows and leave the rest of the set alone.
|
|
789
|
-
const toggleAll = () => {
|
|
790
|
-
const next = new Set(selected);
|
|
791
|
-
for (const r of visibleRows) {
|
|
792
|
-
if (allSelected)
|
|
793
|
-
next.delete(r.id);
|
|
794
|
-
else
|
|
795
|
-
next.add(r.id);
|
|
796
|
-
}
|
|
797
|
-
setSelected(next);
|
|
798
|
-
};
|
|
799
|
-
const toggleRow = (id) => {
|
|
800
|
-
const next = new Set(selected);
|
|
801
|
-
// shift-click → select the contiguous range from the anchor to this row
|
|
802
|
-
if (shiftHeld.current && selAnchor.current) {
|
|
803
|
-
const a = visibleRows.findIndex((r) => r.id === selAnchor.current);
|
|
804
|
-
const b = visibleRows.findIndex((r) => r.id === id);
|
|
805
|
-
if (a >= 0 && b >= 0) {
|
|
806
|
-
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
807
|
-
for (let i = lo; i <= hi; i++)
|
|
808
|
-
next.add(visibleRows[i].id);
|
|
809
|
-
shiftHeld.current = false;
|
|
810
|
-
setSelected(next);
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
if (next.has(id))
|
|
815
|
-
next.delete(id);
|
|
816
|
-
else
|
|
817
|
-
next.add(id);
|
|
818
|
-
selAnchor.current = id;
|
|
819
|
-
shiftHeld.current = false;
|
|
820
|
-
setSelected(next);
|
|
821
|
-
};
|
|
822
|
-
const addSort = () => {
|
|
823
|
-
const used = new Set(sorts.map((s) => s.key));
|
|
824
|
-
const col = cols.find((c) => !used.has(c.key));
|
|
825
|
-
if (col)
|
|
826
|
-
setSorts([...sorts, { key: col.key, dir: "asc" }]);
|
|
827
|
-
};
|
|
828
|
-
const moveSort = (from, to) => setSorts((prev) => {
|
|
829
|
-
if (from === to)
|
|
830
|
-
return prev;
|
|
831
|
-
const next = [...prev];
|
|
832
|
-
const [moved] = next.splice(from, 1);
|
|
833
|
-
next.splice(to, 0, moved);
|
|
834
|
-
return next;
|
|
835
|
-
});
|
|
836
|
-
const setSortField = (i, key) => setSorts(sorts.map((s, j) => (j === i ? { ...s, key } : s)));
|
|
837
|
-
const setSortDir = (i, dir) => setSorts(sorts.map((s, j) => (j === i ? { ...s, dir } : s)));
|
|
838
|
-
const removeSort = (i) => setSorts(sorts.filter((_, j) => j !== i));
|
|
839
|
-
/**
|
|
840
|
-
* Keep the selected cell on screen.
|
|
841
|
-
*
|
|
842
|
-
* Focus stays on the scroll region, never on a cell, so the browser has
|
|
843
|
-
* nothing to scroll to: the arrow keys moved the outline and the viewport
|
|
844
|
-
* stayed put. Measured in Chrome on a 384px-tall region over 40 rows —
|
|
845
|
-
* twenty ArrowDowns left `scrollTop` at 0 with the selected cell 761px below
|
|
846
|
-
* the top edge, i.e. the user was driving a selection they could not see
|
|
847
|
-
* (#257).
|
|
848
|
-
*
|
|
849
|
-
* `nearest` on both axes: a cell already in view is left exactly where it
|
|
850
|
-
* is, which is what makes this safe to run after a click too.
|
|
851
|
-
*
|
|
852
|
-
* No `behavior` of our own — the page's `scroll-behavior` decides, and it
|
|
853
|
-
* jumps rather than glides — except under reduced motion, where an explicit
|
|
854
|
-
* "auto" is the only thing that beats a host page that set
|
|
855
|
-
* `scroll-behavior: smooth` on the scroller (#208's rule: JS motion reads
|
|
856
|
-
* the preference itself, because CSS cannot reach an explicit argument).
|
|
857
|
-
*/
|
|
858
|
-
const reduceMotion = usePrefersReducedMotion();
|
|
859
|
-
React.useEffect(() => {
|
|
860
|
-
if (!selCell)
|
|
861
|
-
return;
|
|
862
|
-
const cell = scrollRef.current?.querySelector(`[data-cell="${CSS.escape(`${selCell.id}:${selCell.key}`)}"]`);
|
|
863
|
-
cell?.scrollIntoView({
|
|
864
|
-
block: "nearest",
|
|
865
|
-
inline: "nearest",
|
|
866
|
-
...(reduceMotion ? { behavior: "auto" } : null),
|
|
867
|
-
});
|
|
868
|
-
}, [selCell, reduceMotion]);
|
|
869
|
-
const commitEdit = (id, key, raw) => {
|
|
870
|
-
const col = cols.find((c) => c.key === key);
|
|
871
|
-
const numeric = col?.type === "number" || col?.type === "currency";
|
|
872
|
-
const coerce = (r) => {
|
|
873
|
-
if (numeric)
|
|
874
|
-
return raw === "" ? null : Number(raw.replace(/[^0-9.-]/g, ""));
|
|
875
|
-
if (col?.type === "tags")
|
|
876
|
-
return raw
|
|
877
|
-
.split(",")
|
|
878
|
-
.map((t) => t.trim())
|
|
879
|
-
.filter(Boolean);
|
|
880
|
-
if (col?.type === "entity")
|
|
881
|
-
return { ...r[key], name: raw };
|
|
882
|
-
return raw;
|
|
883
|
-
};
|
|
884
|
-
setData((prev) => prev.map((r) => (r.id === id ? { ...r, [key]: coerce(r) } : r)));
|
|
885
|
-
setEditing(null);
|
|
886
|
-
};
|
|
887
|
-
// the option set for a `tags` column — the union of every value in that column,
|
|
888
|
-
// so the tag editor offers the existing options (each keeps a stable colour).
|
|
889
|
-
const tagOptionsFor = React.useCallback((key) => {
|
|
890
|
-
const set = new Set();
|
|
891
|
-
for (const r of data) {
|
|
892
|
-
const v = r[key];
|
|
893
|
-
if (Array.isArray(v))
|
|
894
|
-
for (const t of v)
|
|
895
|
-
set.add(t);
|
|
896
|
-
}
|
|
897
|
-
return [...set].map((t) => ({ value: t, label: t, tone: tagTone(t) }));
|
|
898
|
-
}, [data]);
|
|
899
|
-
// set any cell value directly (used by the status / date / reference pickers)
|
|
900
|
-
// biome-ignore lint/suspicious/noExplicitAny: cell values are column-type dependent
|
|
901
|
-
const setCell = (id, key, value) => {
|
|
902
|
-
setData((prev) => prev.map((r) => (r.id === id ? { ...r, [key]: value } : r)));
|
|
903
|
-
};
|
|
904
|
-
const addRow = () => {
|
|
905
|
-
idCounter.current += 1;
|
|
906
|
-
const id = `new-${idCounter.current}`;
|
|
907
|
-
const entityKey = cols.find((c) => c.type === "entity")?.key;
|
|
908
|
-
const row = { id };
|
|
909
|
-
if (entityKey)
|
|
910
|
-
row[entityKey] = { name: t.untitled };
|
|
911
|
-
setData((prev) => [...prev, row]);
|
|
912
|
-
if (entityKey)
|
|
913
|
-
setEditing(null);
|
|
914
|
-
};
|
|
915
|
-
const sortBy = (key, dir) => {
|
|
916
|
-
setSorts([{ key, dir }]);
|
|
917
|
-
setOpenMenu(null);
|
|
918
|
-
};
|
|
919
|
-
const hideColumn = (key) => {
|
|
920
|
-
setCols((prev) => prev.filter((c) => c.key !== key));
|
|
921
|
-
setOpenMenu(null);
|
|
922
|
-
};
|
|
923
|
-
// Move a column one slot left/right. The first (entity) column is sticky and
|
|
924
|
-
// stays pinned, so reordering is clamped to index >= 1.
|
|
925
|
-
const moveColumn = (key, dir) => {
|
|
926
|
-
setCols((prev) => {
|
|
927
|
-
const i = prev.findIndex((c) => c.key === key);
|
|
928
|
-
const j = i + dir;
|
|
929
|
-
if (i < 1 || j < 1 || j >= prev.length)
|
|
930
|
-
return prev;
|
|
931
|
-
const next = [...prev];
|
|
932
|
-
[next[i], next[j]] = [next[j], next[i]];
|
|
933
|
-
return next;
|
|
934
|
-
});
|
|
935
|
-
setOpenMenu(null);
|
|
936
|
-
};
|
|
937
|
-
// drop the dragged column INTO `toKey`'s slot (drag-to-reorder) — the slot the
|
|
938
|
-
// dragover indicator highlights, and the same result the header menu's "Move
|
|
939
|
-
// left"/"Move right" gives for a neighbouring column. The sticky entity
|
|
940
|
-
// column (index 0) stays pinned — never reorder into slot 0.
|
|
941
|
-
const reorderColumn = (fromKey, toKey) => {
|
|
942
|
-
setDragCol(null);
|
|
943
|
-
if (fromKey === toKey)
|
|
944
|
-
return;
|
|
945
|
-
setCols((prev) => {
|
|
946
|
-
const from = prev.findIndex((c) => c.key === fromKey);
|
|
947
|
-
const to = prev.findIndex((c) => c.key === toKey);
|
|
948
|
-
if (from < 1 || to < 1)
|
|
949
|
-
return prev;
|
|
950
|
-
const next = [...prev];
|
|
951
|
-
const [moved] = next.splice(from, 1);
|
|
952
|
-
// insert at the TARGET'S ORIGINAL index, which is the slot the dragover
|
|
953
|
-
// indicator highlighted. Re-finding the target after the splice shifts
|
|
954
|
-
// every rightward drag one slot left, so an adjacent one does nothing.
|
|
955
|
-
next.splice(to, 0, moved);
|
|
956
|
-
return next;
|
|
957
|
-
});
|
|
958
|
-
};
|
|
959
|
-
const renameColumn = (key, label) => {
|
|
960
|
-
const name = label.trim();
|
|
961
|
-
if (name)
|
|
962
|
-
setCols((prev) => prev.map((c) => (c.key === key ? { ...c, label: name } : c)));
|
|
963
|
-
setRenamingCol(null);
|
|
964
|
-
};
|
|
965
|
-
// the two `role="menu"` popovers driven by `openMenu`; the sort and filter
|
|
966
|
-
// popovers are dialogs and keep their own focus handling
|
|
967
|
-
const roleMenuOpen = !!openMenu && (openMenu.startsWith("col:") || openMenu.startsWith("sum:"));
|
|
968
|
-
const closeRoleMenu = React.useCallback(() => {
|
|
969
|
-
setOpenMenu(null);
|
|
970
|
-
menuReturn.current?.focus();
|
|
971
|
-
}, []);
|
|
972
|
-
const { onKeyDown: onMenuKeyDown } = useMenuKeyboard({
|
|
973
|
-
open: roleMenuOpen,
|
|
974
|
-
close: closeRoleMenu,
|
|
975
|
-
dismiss: () => setOpenMenu(null),
|
|
976
|
-
contentRef: menuRef,
|
|
977
|
-
});
|
|
978
|
-
const closeCtxMenu = React.useCallback(() => {
|
|
979
|
-
setCtxMenu(null);
|
|
980
|
-
ctxReturn.current?.focus();
|
|
981
|
-
}, []);
|
|
982
|
-
const { onKeyDown: onCtxKeyDown } = useMenuKeyboard({
|
|
983
|
-
open: !!ctxMenu,
|
|
984
|
-
close: closeCtxMenu,
|
|
985
|
-
dismiss: () => setCtxMenu(null),
|
|
986
|
-
contentRef: ctxRef,
|
|
987
|
-
});
|
|
988
|
-
const openColumnMenu = (key, el) => {
|
|
989
|
-
if (openMenu === `col:${key}`) {
|
|
990
|
-
setOpenMenu(null);
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
const r = el.getBoundingClientRect();
|
|
994
|
-
setMenuPos({ top: r.bottom + 4, left: r.left });
|
|
995
|
-
menuReturn.current = el;
|
|
996
|
-
setOpenMenu(`col:${key}`);
|
|
997
|
-
};
|
|
998
|
-
const addColumn = () => {
|
|
999
|
-
idCounter.current += 1;
|
|
1000
|
-
const key = `field-${idCounter.current}`;
|
|
1001
|
-
setCols((prev) => [
|
|
1002
|
-
...prev,
|
|
1003
|
-
{ key, label: t.newColumnLabel(prev.length), type: "text", width: 160 },
|
|
1004
|
-
]);
|
|
1005
|
-
};
|
|
1006
|
-
const deleteSelected = () => {
|
|
1007
|
-
setData((prev) => prev.filter((r) => !selected.has(r.id)));
|
|
1008
|
-
setSelected(new Set());
|
|
1009
|
-
};
|
|
1010
|
-
const entityCol = cols.find((c) => c.type === "entity");
|
|
1011
|
-
// "Add to list" → pin the selected rows (shows a ★ on the entity)
|
|
1012
|
-
const addToList = () => {
|
|
1013
|
-
setPinned((prev) => new Set([...prev, ...selected]));
|
|
1014
|
-
setSelected(new Set());
|
|
1015
|
-
};
|
|
1016
|
-
// "Send email" → open the mail client addressed to the selected records
|
|
1017
|
-
const sendEmail = () => {
|
|
1018
|
-
const names = data
|
|
1019
|
-
.filter((r) => selected.has(r.id))
|
|
1020
|
-
// Pass the real column: `cellText` -> `rawText` reads `col.type` to pull
|
|
1021
|
-
// an entity's `.name`, so a `{key, label}` stand-in with no `type` falls
|
|
1022
|
-
// through to String(value) and yields "[object Object]".
|
|
1023
|
-
.map((r) => (entityCol ? cellText(entityCol, r) : r.id));
|
|
1024
|
-
window.open(`mailto:?subject=${encodeURIComponent(names.join(", "))}`);
|
|
1025
|
-
};
|
|
1026
|
-
// "Run workflow" → a demo automation: duplicate the selected records
|
|
1027
|
-
const runWorkflow = () => {
|
|
1028
|
-
setData((prev) => {
|
|
1029
|
-
const copies = prev
|
|
1030
|
-
.filter((r) => selected.has(r.id))
|
|
1031
|
-
.map((r) => {
|
|
1032
|
-
idCounter.current += 1;
|
|
1033
|
-
return { ...r, id: `${r.id}-copy-${idCounter.current}` };
|
|
1034
|
-
});
|
|
1035
|
-
return [...prev, ...copies];
|
|
1036
|
-
});
|
|
1037
|
-
setSelected(new Set());
|
|
1038
|
-
};
|
|
1039
|
-
// single-row actions used by the right-click context menu
|
|
1040
|
-
const duplicateRow = (id) => {
|
|
1041
|
-
setData((prev) => {
|
|
1042
|
-
const i = prev.findIndex((r) => r.id === id);
|
|
1043
|
-
if (i < 0)
|
|
1044
|
-
return prev;
|
|
1045
|
-
idCounter.current += 1;
|
|
1046
|
-
const copy = { ...prev[i], id: `${id}-copy-${idCounter.current}` };
|
|
1047
|
-
const next = [...prev];
|
|
1048
|
-
next.splice(i + 1, 0, copy);
|
|
1049
|
-
return next;
|
|
1050
|
-
});
|
|
1051
|
-
setCtxMenu(null);
|
|
1052
|
-
};
|
|
1053
|
-
const deleteRow = (id) => {
|
|
1054
|
-
setData((prev) => prev.filter((r) => r.id !== id));
|
|
1055
|
-
if (selected.has(id)) {
|
|
1056
|
-
const next = new Set(selected);
|
|
1057
|
-
next.delete(id);
|
|
1058
|
-
setSelected(next);
|
|
1059
|
-
}
|
|
1060
|
-
setCtxMenu(null);
|
|
1061
|
-
};
|
|
1062
|
-
const copyRow = (id) => {
|
|
1063
|
-
const row = data.find((r) => r.id === id);
|
|
1064
|
-
const col = cols.find((c) => c.type === "entity");
|
|
1065
|
-
if (row && col)
|
|
1066
|
-
navigator.clipboard?.writeText(cellText(col, row));
|
|
1067
|
-
setCtxMenu(null);
|
|
1068
|
-
};
|
|
1069
|
-
const pinRow = (id) => {
|
|
1070
|
-
setPinned((prev) => new Set([...prev, id]));
|
|
1071
|
-
setCtxMenu(null);
|
|
1072
|
-
};
|
|
1073
|
-
const setAggValue = (key, value) => {
|
|
1074
|
-
setAgg((prev) => ({ ...prev, [key]: value }));
|
|
1075
|
-
setOpenMenu(null);
|
|
1076
|
-
};
|
|
1077
|
-
// column resize via pointer drag on the header edge
|
|
1078
|
-
const resizeCol = (key, startX, startW, grip) => {
|
|
1079
|
-
// A table's columns reverse under RTL — measured — so the grip sits on the
|
|
1080
|
-
// column's leading edge there and a rightward pull must SHRINK it. The
|
|
1081
|
-
// anchor is `inset-inline-end`; this is the other half (#214).
|
|
1082
|
-
const dir = isRtlElement(grip) ? -1 : 1;
|
|
1083
|
-
// guard the whole document during the drag: kill text selection and force a
|
|
1084
|
-
// resize cursor so the gesture doesn't select cells or flicker (was buggy)
|
|
1085
|
-
const prevSelect = document.body.style.userSelect;
|
|
1086
|
-
const prevCursor = document.body.style.cursor;
|
|
1087
|
-
document.body.style.userSelect = "none";
|
|
1088
|
-
document.body.style.cursor = "col-resize";
|
|
1089
|
-
const onMove = (e) => {
|
|
1090
|
-
const w = Math.round(Math.max(72, startW + dir * (e.clientX - startX)));
|
|
1091
|
-
setCols((prev) => prev.map((c) => (c.key === key ? { ...c, width: w } : c)));
|
|
1092
|
-
};
|
|
1093
|
-
const onUp = () => {
|
|
1094
|
-
document.body.style.userSelect = prevSelect;
|
|
1095
|
-
document.body.style.cursor = prevCursor;
|
|
1096
|
-
window.removeEventListener("pointermove", onMove);
|
|
1097
|
-
window.removeEventListener("pointerup", onUp);
|
|
1098
|
-
};
|
|
1099
|
-
window.addEventListener("pointermove", onMove);
|
|
1100
|
-
window.addEventListener("pointerup", onUp);
|
|
1101
|
-
};
|
|
1102
|
-
return (_jsxs("div", { className: recordstableRecipe({ className }), ref: rootRef, ...rest, children: [_jsxs("div", { className: "crisp-recordstable-toolbar", children: [_jsxs("span", { className: "crisp-recordstable-toolgroup", children: [_jsxs("button", { type: "button", className: `crisp-recordstable-tool${sorts.length ? " crisp-recordstable-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "sort" ? null : "sort"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "sort", "data-menu-trigger": true, children: [_jsx(ArrowUpDown, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), sorts.length
|
|
1103
|
-
? t.sortedBy(cols.find((c) => c.key === sorts[0].key)?.label ?? "", sorts.length - 1)
|
|
1104
|
-
: t.sort] }), openMenu === "sort" && (_jsxs("div", { className: "crisp-recordstable-menu crisp-recordstable-sortpop", role: "dialog", "aria-label": t.sort, children: [sorts.length === 0 ? (_jsx("div", { className: "crisp-recordstable-sort-empty", children: t.noSorts })) : (sorts.map((s, i) => (_jsxs("div", { className: "crisp-recordstable-sort-row", onDragOver: (e) => {
|
|
1105
|
-
if (dragSort !== null)
|
|
1106
|
-
e.preventDefault();
|
|
1107
|
-
}, onDrop: () => {
|
|
1108
|
-
if (dragSort !== null)
|
|
1109
|
-
moveSort(dragSort, i);
|
|
1110
|
-
setDragSort(null);
|
|
1111
|
-
}, children: [_jsx("button", { type: "button", className: "crisp-recordstable-sort-grip", "aria-label": t.reorderSort, draggable: true, onDragStart: () => setDragSort(i), onDragEnd: () => setDragSort(null), children: _jsx(GripVertical, { size: 15, strokeWidth: 1.75 }) }), _jsx(Select, { className: "crisp-recordstable-sort-field", searchable: true, value: s.key, onValueChange: (k) => setSortField(i, k), options: cols.map((c) => ({
|
|
1112
|
-
value: c.key,
|
|
1113
|
-
label: c.icon ? (_jsxs("span", { className: "crisp-recordstable-sort-optlabel", children: [_jsx(c.icon, { size: 15, strokeWidth: 1.75 }), c.label] })) : (c.label),
|
|
1114
|
-
keywords: c.label,
|
|
1115
|
-
})), "aria-label": t.sortField }), _jsx(Select, { className: "crisp-recordstable-sort-dir", value: s.dir, onValueChange: (d) => setSortDir(i, d), options: [
|
|
1116
|
-
{
|
|
1117
|
-
value: "asc",
|
|
1118
|
-
label: (_jsxs("span", { className: "crisp-recordstable-sort-optlabel", children: [_jsx(ArrowUpNarrowWide, { size: 15, strokeWidth: 1.75 }), t.ascending] })),
|
|
1119
|
-
keywords: t.ascending,
|
|
1120
|
-
},
|
|
1121
|
-
{
|
|
1122
|
-
value: "desc",
|
|
1123
|
-
label: (_jsxs("span", { className: "crisp-recordstable-sort-optlabel", children: [_jsx(ArrowDownWideNarrow, { size: 15, strokeWidth: 1.75 }), t.descending] })),
|
|
1124
|
-
keywords: t.descending,
|
|
1125
|
-
},
|
|
1126
|
-
], "aria-label": t.sortDirection }), _jsx("button", { type: "button", className: "crisp-recordstable-sort-remove", onClick: () => removeSort(i), "aria-label": t.removeSort, children: _jsx(X, { size: 15, strokeWidth: 1.75 }) })] }, s.key)))), _jsx("div", { className: "crisp-recordstable-sort-addrow", children: _jsxs("button", { type: "button", className: "crisp-recordstable-sort-add", onClick: addSort, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), t.addSort] }) }), _jsx("div", { className: "crisp-recordstable-sort-foot", children: t.learnSortingHref ? (_jsxs("a", { className: "crisp-recordstable-sort-learn", href: t.learnSortingHref, target: "_blank", rel: "noreferrer", children: [_jsx(CircleHelp, { size: 14, strokeWidth: 1.75 }), t.learnSorting] })) : (_jsxs("span", { className: "crisp-recordstable-sort-learn", children: [_jsx(CircleHelp, { size: 14, strokeWidth: 1.75 }), t.learnSorting] })) })] }))] }), _jsxs("span", { className: "crisp-recordstable-toolgroup", children: [_jsxs("button", { type: "button", className: `crisp-recordstable-tool crisp-recordstable-tool--ghost${activeFilters ? " crisp-recordstable-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "filter" ? null : "filter"), "aria-haspopup": "dialog", "aria-expanded": openMenu === "filter", "data-menu-trigger": true, children: [_jsx(ListFilter, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), t.filterCount(activeFilters)] }), openMenu === "filter" && (_jsxs("div", { className: "crisp-recordstable-menu", role: "dialog", "aria-label": t.addFilter, children: [_jsx("input", { className: "crisp-recordstable-menu-input", placeholder: t.filterByPlaceholder, "aria-label": t.filterByField,
|
|
1127
|
-
// biome-ignore lint/a11y/noAutofocus: focus the picker on open
|
|
1128
|
-
autoFocus: true, value: filterQuery, onChange: (e) => setFilterQuery(e.target.value) }), cols
|
|
1129
|
-
.filter((col) => col.label
|
|
1130
|
-
.toLowerCase()
|
|
1131
|
-
.includes(filterQuery.trim().toLowerCase()))
|
|
1132
|
-
.map((col) => (_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", onClick: () => addFilter(col.key), children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : (_jsx("span", { style: { width: 15 } })), col.label] }, col.key)))] }))] }), filterConds.map((cond) => {
|
|
1133
|
-
const col = cols.find((c) => c.key === cond.key);
|
|
1134
|
-
const open = openMenu === `fcond:${cond.id}`;
|
|
1135
|
-
const hasValue = !opNeedsValue(cond.op) || cond.value.trim();
|
|
1136
|
-
return (_jsxs("span", { className: "crisp-recordstable-toolgroup", children: [_jsxs("span", { className: "crisp-recordstable-fchip", children: [_jsxs("button", { type: "button", className: "crisp-recordstable-fchip-main", "data-menu-trigger": true, "aria-haspopup": "dialog", "aria-expanded": open, onClick: () => setOpenMenu(open ? null : `fcond:${cond.id}`), children: [_jsx("span", { className: "crisp-recordstable-fchip-field", children: col?.label }), hasValue ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-recordstable-fchip-op", children: t.op[cond.op] }), opNeedsValue(cond.op) && (_jsx("span", { className: "crisp-recordstable-fchip-val", children: cond.value }))] })) : (_jsx("span", { className: "crisp-recordstable-fchip-op", children: t.selectCondition }))] }), _jsx("button", { type: "button", className: "crisp-recordstable-fchip-x", "aria-label": `Remove ${col?.label} filter`, onClick: () => removeFilter(cond.id), children: _jsx(X, { size: 13, strokeWidth: 2 }) })] }), open && (_jsxs("div", { className: "crisp-recordstable-menu", role: "dialog", "aria-label": `${col?.label} condition`, children: [_jsxs("div", { className: "crisp-recordstable-menu-head", children: [col?.label, " condition"] }), OP_ORDER.map((op) => (_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", "data-on": cond.op === op ? "" : undefined, onClick: () => patchFilter(cond.id, { op }), children: [t.op[op], cond.op === op ? (_jsx(Check, { className: "crisp-recordstable-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))), opNeedsValue(cond.op) && (_jsx("input", { className: "crisp-recordstable-menu-input", placeholder: t.filterValuePlaceholder, "aria-label": `${col?.label} filter value`,
|
|
1137
|
-
// biome-ignore lint/a11y/noAutofocus: focus the value field
|
|
1138
|
-
autoFocus: true, value: cond.value, onChange: (e) => patchFilter(cond.id, { value: e.target.value }) }))] }))] }, cond.id));
|
|
1139
|
-
}), _jsx("span", { className: "crisp-recordstable-spacer" }), _jsx("span", { className: "crisp-recordstable-count", children: t.recordCount(visibleRows.length) }), showNew && (_jsxs("button", { type: "button", className: "crisp-recordstable-new", onClick: () => setCreating(true), children: [_jsx(Plus, { size: 15, strokeWidth: 2, "aria-hidden": "true" }), t.newButton] }))] }), _jsxs("div", { ref: scrollRef, className: "crisp-recordstable-scroll", tabIndex: -1, "data-scrolled": scrolledX ? "" : undefined, onKeyDown: onGridKeyDown, onScroll: (e) => {
|
|
1140
|
-
const left = e.currentTarget.scrollLeft > 0;
|
|
1141
|
-
setScrolledX((prev) => (prev === left ? prev : left));
|
|
1142
|
-
}, children: [_jsxs("table", { className: "crisp-recordstable-table", style: {
|
|
1143
|
-
// fill the container; the trailing add-column absorbs the slack (a
|
|
1144
|
-
// flexible filler). minWidth keeps real column widths and drives the
|
|
1145
|
-
// horizontal scroll once the columns outgrow the container.
|
|
1146
|
-
width: "100%",
|
|
1147
|
-
minWidth: CHECK_W +
|
|
1148
|
-
ADDCOL_W +
|
|
1149
|
-
cols.reduce((sum, c) => sum + colWidth(c), 0),
|
|
1150
|
-
}, children: [_jsxs("colgroup", { children: [_jsx("col", { style: { width: CHECK_W } }), cols.map((col) => (_jsx("col", { style: { width: colWidth(col) } }, col.key))), _jsx("col", {})] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "crisp-recordstable-th crisp-recordstable-col-check", children: _jsx("span", { className: "crisp-recordstable-check-inner", children: _jsx(Checkbox, { "aria-label": t.selectAll, checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }) }), cols.map((col, i) => {
|
|
1151
|
-
const Icon = col.icon;
|
|
1152
|
-
const sc = sorts.find((s) => s.key === col.key);
|
|
1153
|
-
return (_jsxs("th", { className: `crisp-recordstable-th${i === 0 ? " crisp-recordstable-col-entity" : ""}`, "data-sorted": sc?.dir, "aria-sort": sorts[0]?.key === col.key
|
|
1154
|
-
? sorts[0].dir === "asc"
|
|
1155
|
-
? "ascending"
|
|
1156
|
-
: "descending"
|
|
1157
|
-
: "none", "data-drop": dragCol && dragCol !== col.key && i >= 1 ? "" : undefined, onDragOver: dragCol && i >= 1 ? (e) => e.preventDefault() : undefined, onDrop: dragCol && i >= 1
|
|
1158
|
-
? (e) => {
|
|
1159
|
-
e.preventDefault();
|
|
1160
|
-
reorderColumn(dragCol, col.key);
|
|
1161
|
-
}
|
|
1162
|
-
: undefined, children: [renamingCol === col.key ? (_jsx("input", { className: "crisp-recordstable-th-rename",
|
|
1163
|
-
// biome-ignore lint/a11y/noAutofocus: focus the rename field the moment it opens
|
|
1164
|
-
autoFocus: true, defaultValue: col.label, "aria-label": t.columnLabel, onKeyDown: (e) => {
|
|
1165
|
-
if (e.key === "Enter")
|
|
1166
|
-
renameColumn(col.key, e.currentTarget.value);
|
|
1167
|
-
else if (e.key === "Escape")
|
|
1168
|
-
setRenamingCol(null);
|
|
1169
|
-
}, onBlur: (e) => renameColumn(col.key, e.currentTarget.value) })) : (_jsxs("button", { type: "button", className: "crisp-recordstable-th-inner", "data-menu-trigger": true, "aria-haspopup": "menu", draggable: i >= 1, onDragStart: i >= 1
|
|
1170
|
-
? (e) => {
|
|
1171
|
-
setDragCol(col.key);
|
|
1172
|
-
e.dataTransfer.effectAllowed = "move";
|
|
1173
|
-
}
|
|
1174
|
-
: undefined, onDragEnd: () => setDragCol(null), onClick: (e) => openColumnMenu(col.key, e.currentTarget), children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx(ThLabel, { label: col.label, referencedType: col.referencedType }), _jsx("span", { className: "crisp-recordstable-sort", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", "aria-hidden": "true", children: _jsx("path", { d: "m4 6 4 4 4-4" }) }) })] })), openMenu === `col:${col.key}` &&
|
|
1175
|
-
rootRef.current &&
|
|
1176
|
-
createPortal(_jsxs("div", { ref: menuRef, className: "crisp-recordstable-menu crisp-recordstable-menu--fixed crisp-recordstable-menu--hdr", role: "menu", "aria-label": `${col.label} column`, style: menuPos ?? undefined, onKeyDown: onMenuKeyDown, children: [_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => sortBy(col.key, "asc"), children: [_jsx(ArrowUp, { size: 15, strokeWidth: 1.75 }), t.sortAscending] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => sortBy(col.key, "desc"), children: [_jsx(ArrowDown, { size: 15, strokeWidth: 1.75 }), t.sortDescending] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, disabled: i <= 1, onClick: () => moveColumn(col.key, -1), children: [_jsx(ArrowLeft, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }), t.moveLeft] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, disabled: i === 0 || i >= cols.length - 1, onClick: () => moveColumn(col.key, 1), children: [_jsx(ArrowRight, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }), t.moveRight] }), _jsx("div", { className: "crisp-recordstable-menu-sep" }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => {
|
|
1177
|
-
setRenamingCol(col.key);
|
|
1178
|
-
setOpenMenu(null);
|
|
1179
|
-
}, children: [_jsx(Pencil, { size: 15, strokeWidth: 1.75 }), t.editColumnLabel] }), cols.length > 1 && (_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => hideColumn(col.key), children: [_jsx(EyeOff, { size: 15, strokeWidth: 1.75 }), t.hideFromView] }))] }), rootRef.current), _jsx("span", { className: "crisp-recordstable-resize", "aria-hidden": "true", onPointerDown: (e) => {
|
|
1180
|
-
e.preventDefault();
|
|
1181
|
-
e.stopPropagation();
|
|
1182
|
-
const th = e.currentTarget
|
|
1183
|
-
.parentElement;
|
|
1184
|
-
resizeCol(col.key, e.clientX, th ? th.getBoundingClientRect().width : 180, e.currentTarget);
|
|
1185
|
-
}, onClick: (e) => e.stopPropagation() })] }, col.key));
|
|
1186
|
-
}), _jsx("th", { className: "crisp-recordstable-th crisp-recordstable-addcol", children: allowAddColumn && (_jsx("button", { type: "button", onClick: addColumn, "aria-label": t.addColumn, children: _jsx(Plus, { size: 15, strokeWidth: 1.75 }) })) })] }) }), _jsx("tbody", { children: visibleRows.map((row) => (_jsxs("tr", { "data-selected": selected.has(row.id) ? "" : undefined, onContextMenu: (e) => {
|
|
1187
|
-
e.preventDefault();
|
|
1188
|
-
ctxReturn.current = document.activeElement;
|
|
1189
|
-
setCtxMenu({ x: e.clientX, y: e.clientY, id: row.id });
|
|
1190
|
-
}, children: [_jsx("td", { className: "crisp-recordstable-td crisp-recordstable-col-check", children: _jsx("span", { className: "crisp-recordstable-check-inner", children: _jsx(Checkbox, { "aria-label": `Select ${row.id}`, checked: selected.has(row.id), onClick: (e) => {
|
|
1191
|
-
shiftHeld.current = e.shiftKey;
|
|
1192
|
-
}, onChange: () => toggleRow(row.id) }) }) }), cols.map((col, i) => {
|
|
1193
|
-
const canEdit = canEditCol(col);
|
|
1194
|
-
const isPicker = isPickerCol(col);
|
|
1195
|
-
const isEditing = editing?.id === row.id && editing?.key === col.key;
|
|
1196
|
-
// The first column is the record's NAME — it is what the row
|
|
1197
|
-
// is, not a value the row has. As a `<th scope="row">` a
|
|
1198
|
-
// screen reader reads "Google, Employees, 182,000" across the
|
|
1199
|
-
// row instead of a bare "182,000", and the same header is
|
|
1200
|
-
// repeated in every cell's context (#231).
|
|
1201
|
-
const Cell = (i === 0 ? "th" : "td");
|
|
1202
|
-
return (_jsxs(Cell, { scope: i === 0 ? "row" : undefined, "data-cell": `${row.id}:${col.key}`, className: `crisp-recordstable-td${i === 0 ? " crisp-recordstable-col-entity" : ""}${canEdit ? " crisp-recordstable-td--editable" : ""}${isPicker ? " crisp-recordstable-td--picker" : ""}`, "data-enriched": col.enriched ? "" : undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
|
|
1203
|
-
? ""
|
|
1204
|
-
: undefined, onClick: () => {
|
|
1205
|
-
if (isPicker)
|
|
1206
|
-
return;
|
|
1207
|
-
setSelCell({ id: row.id, key: col.key });
|
|
1208
|
-
scrollRef.current?.focus({ preventScroll: true });
|
|
1209
|
-
}, onDoubleClick: canEdit
|
|
1210
|
-
? () => setEditing({ id: row.id, key: col.key })
|
|
1211
|
-
: undefined, children: [isPicker ? (col.type === "status" ? (_jsx(StatusCell, { column: col, row: row, onChange: (v) => setCell(row.id, col.key, v) })) : col.type === "reference" ? (_jsx(ReferenceCell, { labels: t, column: col, row: row, onChange: (v) => setCell(row.id, col.key, v) })) : col.type === "tags" ? (_jsx(TagsCell, { labels: t, column: col, row: row, options: tagOptionsFor(col.key), onChange: (v) => setCell(row.id, col.key, v) })) : (_jsx(DateCell, { column: col, row: row, onChange: (v) => setCell(row.id, col.key, v) }))) : isEditing ? (_jsx("input", { className: `crisp-recordstable-edit${col.type === "number" ? " crisp-recordstable-edit--num" : ""}`,
|
|
1212
|
-
// biome-ignore lint/a11y/noAutofocus: focusing the just-opened editor is the expected behavior
|
|
1213
|
-
autoFocus: true, defaultValue: cellText(col, row), onBlur: (e) => commitEdit(row.id, col.key, e.target.value), onKeyDown: (e) => {
|
|
1214
|
-
if (e.key === "Enter")
|
|
1215
|
-
commitEdit(row.id, col.key, e.target.value);
|
|
1216
|
-
if (e.key === "Escape")
|
|
1217
|
-
setEditing(null);
|
|
1218
|
-
} })) : (_jsx(CellView, { labels: t, column: col, row: row, pinned: i === 0 && pinned.has(row.id), onOpenPage: i === 0 && onOpenRecordPage
|
|
1219
|
-
? () => onOpenRecordPage(row)
|
|
1220
|
-
: undefined, avatarTone: avatarTone })), i === 0 && onOpenRecord && !isEditing && (_jsx("button", { type: "button", className: "crisp-recordstable-open", "aria-label": `Open ${cellText(col, row)}`, onClick: (e) => {
|
|
1221
|
-
e.stopPropagation();
|
|
1222
|
-
onOpenRecord(row);
|
|
1223
|
-
}, children: _jsx(ArrowUpRight, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }) }))] }, col.key));
|
|
1224
|
-
}), _jsx("td", { className: "crisp-recordstable-td crisp-recordstable-addcol" })] }, row.id))) }), _jsx("tfoot", { children: _jsxs("tr", { children: [_jsx("td", { className: "crisp-recordstable-sum crisp-recordstable-col-check" }), cols.map((col, i) => {
|
|
1225
|
-
const a = agg[col.key] ?? "none";
|
|
1226
|
-
const res = summarize(col, a, visibleRows);
|
|
1227
|
-
const sumOpen = openMenu === `sum:${col.key}`;
|
|
1228
|
-
return (_jsx("td", { className: `crisp-recordstable-sum${i === 0 ? " crisp-recordstable-col-entity" : ""}`, children: _jsxs("span", { className: "crisp-recordstable-toolgroup crisp-recordstable-sumgroup", children: [_jsx("button", { type: "button", className: "crisp-recordstable-sum-btn", "data-empty": res ? undefined : "", "data-menu-trigger": true, "aria-haspopup": "menu", onClick: (e) => {
|
|
1229
|
-
if (sumOpen) {
|
|
1230
|
-
setOpenMenu(null);
|
|
1231
|
-
return;
|
|
1232
|
-
}
|
|
1233
|
-
const r = e.currentTarget.getBoundingClientRect();
|
|
1234
|
-
setMenuPos({
|
|
1235
|
-
bottom: window.innerHeight - r.top + 4,
|
|
1236
|
-
right: window.innerWidth - r.right,
|
|
1237
|
-
});
|
|
1238
|
-
menuReturn.current = e.currentTarget;
|
|
1239
|
-
setOpenMenu(`sum:${col.key}`);
|
|
1240
|
-
}, children: res ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-recordstable-sum-label", children: t.agg[a] }), _jsx("span", { className: "crisp-recordstable-sum-value", children: res.value })] })) : (_jsx("span", { className: "crisp-recordstable-sum-label", children: t.calculate })) }), sumOpen &&
|
|
1241
|
-
rootRef.current &&
|
|
1242
|
-
createPortal(_jsx("div", { ref: menuRef, className: "crisp-recordstable-menu crisp-recordstable-menu--fixed crisp-recordstable-menu--up", role: "menu", "aria-label": `${col.label} summary`, style: menuPos ?? undefined, onKeyDown: onMenuKeyDown, children: aggOptions(col).map((opt) => (_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitemradio", tabIndex: -1, "aria-checked": a === opt, "data-on": a === opt ? "" : undefined, onClick: () => setAggValue(col.key, opt), children: [t.agg[opt], a === opt ? (_jsx(Check, { className: "crisp-recordstable-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, opt))) }), rootRef.current)] }) }, col.key));
|
|
1243
|
-
}), _jsx("td", { className: "crisp-recordstable-sum crisp-recordstable-addcol" })] }) })] }), visibleRows.length === 0 ? (_jsx("div", { className: "crisp-recordstable-empty", children: _jsx(EmptyState
|
|
1244
|
-
// #603: the unfiltered zero-records state, when the consumer
|
|
1245
|
-
// opts into the extra CTAs, uses Attio's own larger measured
|
|
1246
|
-
// tile (Users/Workspaces, 138px) instead of the 45px
|
|
1247
|
-
// filtered-to-zero ring -- two different Attio surfaces.
|
|
1248
|
-
, {
|
|
1249
|
-
// #603: the unfiltered zero-records state, when the consumer
|
|
1250
|
-
// opts into the extra CTAs, uses Attio's own larger measured
|
|
1251
|
-
// tile (Users/Workspaces, 138px) instead of the 45px
|
|
1252
|
-
// filtered-to-zero ring -- two different Attio surfaces.
|
|
1253
|
-
variant: !activeFilters && emptyStateExtra ? "records" : "page", icon: activeFilters ? (_jsx(ListFilter, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" })) : (_jsx(Inbox, { size: !activeFilters && emptyStateExtra ? 40 : 18, strokeWidth: 1.25, "aria-hidden": "true" })), title: activeFilters ? "No records match that filter" : t.noRecords, description: activeFilters
|
|
1254
|
-
? "None of your records match the filters that are set."
|
|
1255
|
-
: `Create your first ${cols.find((c) => c.type === "entity")?.label?.toLowerCase() ?? "record"} to get started.`, action: activeFilters ? (_jsxs(Button, { intent: "primary", size: "sm", onClick: () => setFilterConds([]), children: [_jsx(ListFilter, { size: 15, strokeWidth: 1.75 }), t.clearFilter] })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { intent: "primary", size: "sm", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), t.newRecord] }), emptyStateExtra?.onImportCsv && (_jsxs(Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onImportCsv, children: [_jsx(Upload, { size: 15, strokeWidth: 1.75 }), t.importCsv] })), emptyStateExtra?.onConnectDataSource && (_jsxs(Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onConnectDataSource, children: [_jsx(Plug, { size: 15, strokeWidth: 1.75 }), t.connectDataSource] }))] })), learnMore: !activeFilters ? emptyStateExtra?.learnMore : undefined }) })) : (_jsxs("button", { type: "button", className: "crisp-recordstable-addrow", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), t.newRecord] }))] }), _jsx("div", { className: "crisp-recordstable-live", role: "status", "aria-live": "polite", children: selectionAnnouncement }), selected.size > 0 && (_jsxs(BulkBar, { count: selected.size, countLabel: t.selectedCount, onClear: () => setSelected(new Set()), children: [_jsx(BulkBarButton, { icon: _jsx(ListPlus, { size: 15, strokeWidth: 1.75 }), onClick: addToList, children: t.addToList }), _jsx(BulkBarButton, { icon: _jsx(Mail, { size: 15, strokeWidth: 1.75 }), onClick: sendEmail, children: t.sendEmail }), _jsx(BulkBarButton, { icon: _jsx(Workflow, { size: 15, strokeWidth: 1.75 }), onClick: runWorkflow, children: t.runWorkflow }), _jsx(Menu, { align: "end", "aria-label": t.moreActions, trigger: _jsx(BulkBarButton, { icon: _jsx(MoreHorizontal, { size: 15, strokeWidth: 1.75 }), children: t.more }), children: _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, onSelect: deleteSelected, children: t.delete }) })] })), _jsx(CreateRecordDialog, { labels: t, open: creating, onOpenChange: setCreating, columns: cols, entityLabel: cols.find((c) => c.type === "entity")?.label ?? "record", onCreate: (row) => setData((prev) => [...prev, row]) }), ctxMenu &&
|
|
1256
|
-
createPortal(_jsxs("div", { ref: ctxRef, className: "crisp-recordstable-menu crisp-recordstable-ctx", role: "menu", "aria-label": t.recordActions, style: { position: "fixed", top: ctxMenu.y, left: ctxMenu.x }, onKeyDown: onCtxKeyDown, children: [_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => copyRow(ctxMenu.id), children: [_jsx(Copy, { size: 15, strokeWidth: 1.75 }), t.copy] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => duplicateRow(ctxMenu.id), children: [_jsx(CopyPlus, { size: 15, strokeWidth: 1.75 }), t.duplicate] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => pinRow(ctxMenu.id), children: [_jsx(Star, { size: 15, strokeWidth: 1.75 }), t.addToList] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item crisp-recordstable-menu-item--danger", role: "menuitem", tabIndex: -1, onClick: () => deleteRow(ctxMenu.id), children: [_jsx(Trash2, { size: 15, strokeWidth: 1.75 }), t.delete] })] }), rootRef.current ?? document.body)] }));
|
|
1257
|
-
}
|
|
1258
|
-
export const defaultColumns = [
|
|
1259
|
-
{ key: "company", label: "Company", type: "entity", icon: Building2 },
|
|
1260
|
-
{ key: "domain", label: "Domain", type: "url", icon: Globe },
|
|
1261
|
-
{
|
|
1262
|
-
key: "categories",
|
|
1263
|
-
label: "Categories",
|
|
1264
|
-
type: "tags",
|
|
1265
|
-
icon: Tag,
|
|
1266
|
-
width: 240,
|
|
1267
|
-
},
|
|
1268
|
-
{
|
|
1269
|
-
key: "employees",
|
|
1270
|
-
label: "Employees",
|
|
1271
|
-
type: "number",
|
|
1272
|
-
icon: Users,
|
|
1273
|
-
width: 120,
|
|
1274
|
-
},
|
|
1275
|
-
{ key: "location", label: "Location", type: "text", icon: MapPin },
|
|
1276
|
-
{ key: "stage", label: "Stage", type: "tags", icon: TrendingUp, width: 150 },
|
|
1277
|
-
{ key: "ceo", label: "CEO", type: "text", icon: Briefcase, width: 170 },
|
|
1278
|
-
{ key: "twitter", label: "Twitter", type: "url", icon: AtSign, width: 150 },
|
|
1279
|
-
{
|
|
1280
|
-
key: "followers",
|
|
1281
|
-
label: "Followers",
|
|
1282
|
-
type: "number",
|
|
1283
|
-
icon: Users,
|
|
1284
|
-
width: 120,
|
|
1285
|
-
},
|
|
1286
|
-
{
|
|
1287
|
-
key: "linkedin",
|
|
1288
|
-
label: "LinkedIn",
|
|
1289
|
-
type: "url",
|
|
1290
|
-
icon: Globe,
|
|
1291
|
-
width: 180,
|
|
1292
|
-
},
|
|
1293
|
-
{
|
|
1294
|
-
key: "founded",
|
|
1295
|
-
label: "Founded",
|
|
1296
|
-
type: "date",
|
|
1297
|
-
icon: Calendar,
|
|
1298
|
-
width: 110,
|
|
1299
|
-
},
|
|
1300
|
-
{ key: "arr", label: "Est. ARR", type: "number", icon: Hash, width: 130 },
|
|
1301
|
-
];
|
|
1302
|
-
// biome-ignore format: one row per line keeps the wide demo dataset readable
|
|
1303
|
-
const RAW_ROWS = [
|
|
1304
|
-
{ id: "google", company: { name: "Google" }, domain: "google.com", categories: ["Search", "Ads", "Cloud", "AI", "Android", "Maps", "Hardware", "Workspace", "Chrome"], employees: 182000, location: "Mountain View, CA", stage: ["Public"], ceo: "Sundar Pichai", twitter: "twitter.com/google", followers: 29800000, linkedin: "linkedin.com/company/google", founded: "1998", arr: 307000000000 },
|
|
1305
|
-
{ id: "stripe", company: { name: "Stripe" }, domain: "stripe.com", categories: ["Payments", "Fintech"], employees: 8000, location: "San Francisco, CA", stage: ["Series I"], ceo: "Patrick Collison", twitter: "twitter.com/stripe", followers: 1200000, linkedin: "linkedin.com/company/stripe", founded: "2010", arr: 16000000000 },
|
|
1306
|
-
{ id: "figma", company: { name: "Figma" }, domain: "figma.com", categories: ["Design", "SaaS"], employees: 1300, location: "San Francisco, CA", stage: ["Series E"], ceo: "Dylan Field", twitter: "twitter.com/figma", followers: 540000, linkedin: "linkedin.com/company/figma", founded: "2012", arr: 600000000 },
|
|
1307
|
-
{ id: "notion", company: { name: "Notion" }, domain: "notion.so", categories: ["Productivity", "SaaS"], employees: 800, location: "San Francisco, CA", stage: ["Series C"], ceo: "Ivan Zhao", twitter: "twitter.com/notionhq", followers: 480000, linkedin: "linkedin.com/company/notionhq", founded: "2013", arr: 400000000 },
|
|
1308
|
-
{ id: "linear", company: { name: "Linear" }, domain: "linear.app", categories: ["Dev tools"], employees: 60, location: "Remote", stage: ["Series B"], ceo: "Karri Saarinen", twitter: "twitter.com/linear", followers: 120000, linkedin: "linkedin.com/company/linear", founded: "2019", arr: 30000000 },
|
|
1309
|
-
{ id: "vercel", company: { name: "Vercel" }, domain: "vercel.com", categories: ["Dev tools", "Cloud"], employees: 500, location: "San Francisco, CA", stage: ["Series E"], ceo: "Guillermo Rauch", twitter: "twitter.com/vercel", followers: 310000, linkedin: "linkedin.com/company/vercel", founded: "2015", arr: 100000000 },
|
|
1310
|
-
{ id: "openai", company: { name: "OpenAI" }, domain: "openai.com", categories: ["AI", "Research"], employees: 3500, location: "San Francisco, CA", stage: ["Late stage"], ceo: "Sam Altman", twitter: "twitter.com/openai", followers: 4200000, linkedin: "linkedin.com/company/openai", founded: "2015", arr: 3400000000 },
|
|
1311
|
-
{ id: "anthropic", company: { name: "Anthropic" }, domain: "anthropic.com", categories: ["AI", "Research"], employees: 1000, location: "San Francisco, CA", stage: ["Series E"], ceo: "Dario Amodei", twitter: "twitter.com/anthropicai", followers: 520000, linkedin: "linkedin.com/company/anthropic", founded: "2021", arr: 1000000000 },
|
|
1312
|
-
];
|
|
1313
|
-
// Attach a real favicon per company (derived from the domain); the entity cell
|
|
1314
|
-
// falls back to a letter tile if it fails to load.
|
|
1315
|
-
export const defaultRows = RAW_ROWS.map((r) => ({
|
|
1316
|
-
...r,
|
|
1317
|
-
company: {
|
|
1318
|
-
...r.company,
|
|
1319
|
-
avatar: companyLogos[r.domain] ??
|
|
1320
|
-
`https://www.google.com/s2/favicons?domain=${r.domain}&sz=64`,
|
|
1321
|
-
},
|
|
1322
|
-
}));
|