@crisp-ui-kit/crisp 0.1.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/base.css +25 -0
- package/dist/bulkbar.css +13 -0
- package/dist/bulkbar.d.ts +7 -0
- package/dist/button.css +1 -1
- package/dist/command.css +1 -1
- package/dist/contextmenu.css +1 -1
- package/dist/datatable.css +60 -0
- package/dist/datatable.d.ts +7 -0
- package/dist/datepicker.css +1 -1
- package/dist/dialog.css +1 -1
- package/dist/esm/components/accordion/accordion.js +32 -0
- package/dist/esm/components/accordion/accordion.recipe.js +23 -0
- package/dist/esm/components/accordion/index.js +2 -0
- package/dist/esm/components/avatar/avatar.js +8 -0
- package/dist/esm/components/avatar/avatar.recipe.js +39 -0
- package/dist/esm/components/avatar/index.js +2 -0
- package/dist/esm/components/avatargroup/avatargroup.js +16 -0
- package/dist/esm/components/avatargroup/avatargroup.recipe.js +15 -0
- package/dist/esm/components/avatargroup/index.js +2 -0
- package/dist/esm/components/badge/badge.js +8 -0
- package/dist/esm/components/badge/badge.recipe.js +65 -0
- package/dist/esm/components/badge/index.js +2 -0
- package/dist/esm/components/barchart/barchart.js +29 -0
- package/dist/esm/components/barchart/barchart.recipe.js +26 -0
- package/dist/esm/components/barchart/index.js +2 -0
- package/dist/esm/components/breadcrumb/breadcrumb.js +17 -0
- package/dist/esm/components/breadcrumb/breadcrumb.recipe.js +18 -0
- package/dist/esm/components/breadcrumb/index.js +2 -0
- package/dist/esm/components/bulkbar/bulkbar.js +16 -0
- package/dist/esm/components/bulkbar/bulkbar.recipe.js +28 -0
- package/dist/esm/components/bulkbar/index.js +2 -0
- package/dist/esm/components/button/button.js +15 -0
- package/dist/esm/components/button/button.recipe.js +39 -0
- package/dist/esm/components/button/index.js +2 -0
- package/dist/esm/components/callout/callout.js +22 -0
- package/dist/esm/components/callout/callout.recipe.js +26 -0
- package/dist/esm/components/callout/index.js +2 -0
- package/dist/esm/components/card/card.js +15 -0
- package/dist/esm/components/card/card.recipe.js +28 -0
- package/dist/esm/components/card/index.js +2 -0
- package/dist/esm/components/checkbox/checkbox.js +20 -0
- package/dist/esm/components/checkbox/checkbox.recipe.js +23 -0
- package/dist/esm/components/checkbox/index.js +2 -0
- package/dist/esm/components/contextmenu/contextmenu.js +42 -0
- package/dist/esm/components/contextmenu/contextmenu.recipe.js +21 -0
- package/dist/esm/components/contextmenu/index.js +2 -0
- package/dist/esm/components/datatable/datatable.js +245 -0
- package/dist/esm/components/datatable/datatable.recipe.js +72 -0
- package/dist/esm/components/datatable/index.js +2 -0
- package/dist/esm/components/datepicker/datepicker.js +57 -0
- package/dist/esm/components/datepicker/datepicker.recipe.js +32 -0
- package/dist/esm/components/datepicker/index.js +2 -0
- package/dist/esm/components/dialog/dialog.js +48 -0
- package/dist/esm/components/dialog/dialog.recipe.js +32 -0
- package/dist/esm/components/dialog/index.js +2 -0
- package/dist/esm/components/divider/divider.js +7 -0
- package/dist/esm/components/divider/divider.recipe.js +14 -0
- package/dist/esm/components/divider/index.js +2 -0
- package/dist/esm/components/donutchart/donutchart.js +38 -0
- package/dist/esm/components/donutchart/donutchart.recipe.js +27 -0
- package/dist/esm/components/donutchart/index.js +2 -0
- package/dist/esm/components/drawer/drawer.js +42 -0
- package/dist/esm/components/drawer/drawer.recipe.js +27 -0
- package/dist/esm/components/drawer/index.js +2 -0
- package/dist/esm/components/emptystate/emptystate.js +7 -0
- package/dist/esm/components/emptystate/emptystate.recipe.js +31 -0
- package/dist/esm/components/emptystate/index.js +2 -0
- package/dist/esm/components/formfield/formfield.js +14 -0
- package/dist/esm/components/formfield/formfield.recipe.js +19 -0
- package/dist/esm/components/formfield/index.js +2 -0
- package/dist/esm/components/input/index.js +2 -0
- package/dist/esm/components/input/input.js +8 -0
- package/dist/esm/components/input/input.recipe.js +23 -0
- package/dist/esm/components/kbd/index.js +2 -0
- package/dist/esm/components/kbd/kbd.js +8 -0
- package/dist/esm/components/kbd/kbd.recipe.js +20 -0
- package/dist/esm/components/linechart/index.js +2 -0
- package/dist/esm/components/linechart/linechart.js +41 -0
- package/dist/esm/components/linechart/linechart.recipe.js +27 -0
- package/dist/esm/components/menu/index.js +2 -0
- package/dist/esm/components/menu/menu.js +137 -0
- package/dist/esm/components/menu/menu.recipe.js +51 -0
- package/dist/esm/components/multiselect/index.js +2 -0
- package/dist/esm/components/multiselect/multiselect.js +118 -0
- package/dist/esm/components/multiselect/multiselect.recipe.js +68 -0
- package/dist/esm/components/popover/index.js +2 -0
- package/dist/esm/components/popover/popover.js +70 -0
- package/dist/esm/components/popover/popover.recipe.js +18 -0
- package/dist/esm/components/progress/index.js +2 -0
- package/dist/esm/components/progress/progress.js +23 -0
- package/dist/esm/components/progress/progress.recipe.js +17 -0
- package/dist/esm/components/radiogroup/index.js +2 -0
- package/dist/esm/components/radiogroup/radiogroup.js +46 -0
- package/dist/esm/components/radiogroup/radiogroup.recipe.js +37 -0
- package/dist/esm/components/segmented/index.js +2 -0
- package/dist/esm/components/segmented/segmented.js +59 -0
- package/dist/esm/components/segmented/segmented.recipe.js +20 -0
- package/dist/esm/components/select/index.js +2 -0
- package/dist/esm/components/select/select.js +144 -0
- package/dist/esm/components/select/select.recipe.js +76 -0
- package/dist/esm/components/skeleton/index.js +2 -0
- package/dist/esm/components/skeleton/skeleton.js +7 -0
- package/dist/esm/components/skeleton/skeleton.recipe.js +14 -0
- package/dist/esm/components/spinner/index.js +2 -0
- package/dist/esm/components/spinner/spinner.js +11 -0
- package/dist/esm/components/spinner/spinner.recipe.js +13 -0
- package/dist/esm/components/stagebar/index.js +2 -0
- package/dist/esm/components/stagebar/stagebar.js +23 -0
- package/dist/esm/components/stagebar/stagebar.recipe.js +14 -0
- package/dist/esm/components/statcard/index.js +2 -0
- package/dist/esm/components/statcard/statcard.js +12 -0
- package/dist/esm/components/statcard/statcard.recipe.js +34 -0
- package/dist/esm/components/switch/index.js +2 -0
- package/dist/esm/components/switch/switch.js +22 -0
- package/dist/esm/components/switch/switch.recipe.js +26 -0
- package/dist/esm/components/table/index.js +2 -0
- package/dist/esm/components/table/table.js +30 -0
- package/dist/esm/components/table/table.recipe.js +40 -0
- package/dist/esm/components/tabletoolbar/index.js +2 -0
- package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +79 -0
- package/dist/esm/components/tabletoolbar/tabletoolbar.js +191 -0
- package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +23 -0
- package/dist/esm/components/tabs/index.js +2 -0
- package/dist/esm/components/tabs/tabs.js +70 -0
- package/dist/esm/components/tabs/tabs.recipe.js +33 -0
- package/dist/esm/components/textarea/index.js +2 -0
- package/dist/esm/components/textarea/textarea.js +20 -0
- package/dist/esm/components/textarea/textarea.recipe.js +31 -0
- package/dist/esm/components/toast/index.js +2 -0
- package/dist/esm/components/toast/toast.js +58 -0
- package/dist/esm/components/toast/toast.recipe.js +28 -0
- package/dist/esm/components/tooltip/index.js +2 -0
- package/dist/esm/components/tooltip/tooltip.js +45 -0
- package/dist/esm/components/tooltip/tooltip.recipe.js +13 -0
- package/dist/esm/core/cx.js +1 -0
- package/dist/esm/core/define.js +3 -0
- package/dist/esm/core/index.js +4 -0
- package/dist/esm/core/primitive.js +13 -0
- package/dist/esm/core/resolve.js +41 -0
- package/dist/esm/core/types.js +1 -0
- package/dist/esm/index.js +71 -0
- package/dist/esm/product/appshell/appshell.js +44 -0
- package/dist/esm/product/appshell/appshell.recipe.js +27 -0
- package/dist/esm/product/appshell/index.js +2 -0
- package/dist/esm/product/board/board.js +42 -0
- package/dist/esm/product/board/board.recipe.js +28 -0
- package/dist/esm/product/board/index.js +2 -0
- package/dist/esm/product/command/command.js +188 -0
- package/dist/esm/product/command/command.recipe.js +19 -0
- package/dist/esm/product/command/index.js +2 -0
- package/dist/esm/product/entitycard/entitycard.js +11 -0
- package/dist/esm/product/entitycard/entitycard.recipe.js +29 -0
- package/dist/esm/product/entitycard/index.js +2 -0
- package/dist/esm/product/flowcanvas/flowcanvas.js +85 -0
- package/dist/esm/product/flowcanvas/flowcanvas.recipe.js +25 -0
- package/dist/esm/product/flowcanvas/index.js +2 -0
- package/dist/esm/product/inbox/inbox.js +13 -0
- package/dist/esm/product/inbox/inbox.recipe.js +43 -0
- package/dist/esm/product/inbox/index.js +2 -0
- package/dist/esm/product/listpage/index.js +2 -0
- package/dist/esm/product/listpage/listpage.js +21 -0
- package/dist/esm/product/listpage/listpage.recipe.js +43 -0
- package/dist/esm/product/navitem/index.js +1 -0
- package/dist/esm/product/navitem/navitem.js +8 -0
- package/dist/esm/product/navitem/navitem.recipe.js +21 -0
- package/dist/esm/product/onboarding/index.js +2 -0
- package/dist/esm/product/onboarding/onboarding.js +14 -0
- package/dist/esm/product/onboarding/onboarding.recipe.js +41 -0
- package/dist/esm/product/recordpage/index.js +2 -0
- package/dist/esm/product/recordpage/recordpage.js +286 -0
- package/dist/esm/product/recordpage/recordpage.recipe.js +157 -0
- package/dist/esm/product/recordpanel/index.js +2 -0
- package/dist/esm/product/recordpanel/recordpanel.js +47 -0
- package/dist/esm/product/recordpanel/recordpanel.recipe.js +52 -0
- package/dist/esm/product/recordstable/company-logos.js +26 -0
- package/dist/esm/product/recordstable/index.js +2 -0
- package/dist/esm/product/recordstable/recordstable.js +963 -0
- package/dist/esm/product/recordstable/recordstable.recipe.js +149 -0
- package/dist/esm/product/richtext/index.js +2 -0
- package/dist/esm/product/richtext/richtext.js +111 -0
- package/dist/esm/product/richtext/richtext.recipe.js +40 -0
- package/dist/esm/product/settingssection/index.js +2 -0
- package/dist/esm/product/settingssection/settingssection.js +11 -0
- package/dist/esm/product/settingssection/settingssection.recipe.js +20 -0
- package/dist/esm/product/settingsshell/index.js +2 -0
- package/dist/esm/product/settingsshell/settingsshell.js +23 -0
- package/dist/esm/product/settingsshell/settingsshell.recipe.js +38 -0
- package/dist/esm/product/sidebar/index.js +2 -0
- package/dist/esm/product/sidebar/sidebar.js +112 -0
- package/dist/esm/product/sidebar/sidebar.recipe.js +18 -0
- package/dist/esm/product/tasklist/index.js +2 -0
- package/dist/esm/product/tasklist/tasklist.js +16 -0
- package/dist/esm/product/tasklist/tasklist.recipe.js +35 -0
- package/dist/esm/product/trialbanner/index.js +2 -0
- package/dist/esm/product/trialbanner/trialbanner.js +10 -0
- package/dist/esm/product/trialbanner/trialbanner.recipe.js +29 -0
- package/dist/esm/product/viewtoggle/index.js +2 -0
- package/dist/esm/product/viewtoggle/viewtoggle.js +11 -0
- package/dist/esm/product/viewtoggle/viewtoggle.recipe.js +24 -0
- package/dist/esm/tokens/contract.js +18 -0
- package/dist/esm/tokens/index.js +4 -0
- package/dist/esm/tokens/palette.js +30 -0
- package/dist/esm/tokens/scale.js +60 -0
- package/dist/esm/tokens/semantic.js +30 -0
- package/dist/menu.css +2 -2
- package/dist/multiselect.css +3 -3
- package/dist/popover.css +1 -1
- package/dist/recordpage.css +5 -5
- package/dist/recordstable.css +5 -14
- package/dist/select.css +10 -4
- package/dist/select.d.ts +2 -1
- package/dist/sidebar.css +1 -1
- package/dist/statcard.css +6 -0
- package/dist/statcard.d.ts +2 -2
- package/dist/styles.css +237 -37
- package/dist/table.css +26 -0
- package/dist/table.d.ts +8 -0
- package/dist/tabletoolbar.css +70 -0
- package/dist/tabletoolbar.d.ts +7 -0
- package/dist/tokens.css +24 -0
- package/dist/types/components/bulkbar/bulkbar.d.ts +32 -0
- package/dist/types/components/bulkbar/bulkbar.recipe.d.ts +3 -0
- package/dist/types/components/bulkbar/index.d.ts +2 -0
- package/dist/types/components/datatable/datatable.d.ts +98 -0
- package/dist/types/components/datatable/datatable.recipe.d.ts +3 -0
- package/dist/types/components/datatable/index.d.ts +2 -0
- package/dist/types/components/select/select.d.ts +3 -1
- package/dist/types/components/select/select.recipe.d.ts +12 -0
- package/dist/types/components/statcard/statcard.d.ts +6 -1
- package/dist/types/components/statcard/statcard.recipe.d.ts +12 -2
- package/dist/types/components/table/index.d.ts +2 -0
- package/dist/types/components/table/table.d.ts +53 -0
- package/dist/types/components/table/table.recipe.d.ts +21 -0
- package/dist/types/components/tabletoolbar/index.d.ts +2 -0
- package/dist/types/components/tabletoolbar/tabletoolbar.chrome.d.ts +2 -0
- package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +63 -0
- package/dist/types/components/tabletoolbar/tabletoolbar.recipe.d.ts +3 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/product/recordstable/company-logos.d.ts +1 -0
- package/dist/types/product/recordstable/recordstable.d.ts +44 -1
- package/dist/types/product/sidebar/index.d.ts +1 -1
- package/dist/types/product/sidebar/sidebar.d.ts +85 -0
- package/dist/types/tokens/contract.d.ts +12 -0
- package/dist/types/tokens/palette.d.ts +48 -0
- package/dist/types/tokens/semantic.d.ts +12 -0
- package/dist/viewtoggle.css +2 -2
- package/package.json +259 -9
- package/dist/index.js +0 -9653
|
@@ -0,0 +1,963 @@
|
|
|
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, Copy, CopyPlus, Download, EyeOff, Globe, GripVertical, Hash, Inbox, ListFilter, ListPlus, Mail, MapPin, MoreHorizontal, Pencil, Plus, Star, Tag, Trash2, TrendingUp, 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 { DatePicker } from "../../components/datepicker/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 } 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 { companyLogos } from "./company-logos.js";
|
|
18
|
+
import { recordstableRecipe } from "./recordstable.recipe.js";
|
|
19
|
+
const EDITABLE = new Set(["text", "number", "url", "currency"]);
|
|
20
|
+
// types whose cell is an always-interactive picker (not double-click-to-edit)
|
|
21
|
+
const PICKER = new Set(["status", "reference", "date", "tags"]);
|
|
22
|
+
// a `render` column owns its content fully — never editable, never a picker
|
|
23
|
+
const canEditCol = (col) => EDITABLE.has(col.type ?? "text") && !col.render;
|
|
24
|
+
const isPickerCol = (col) => PICKER.has(col.type ?? "text") && !col.render;
|
|
25
|
+
// deterministic pastel colour per tag value (a select option keeps its colour)
|
|
26
|
+
const TAG_TONES = [
|
|
27
|
+
"brand",
|
|
28
|
+
"success",
|
|
29
|
+
"warning",
|
|
30
|
+
"danger",
|
|
31
|
+
"purple",
|
|
32
|
+
"cyan",
|
|
33
|
+
"orange",
|
|
34
|
+
];
|
|
35
|
+
export function tagTone(t) {
|
|
36
|
+
let h = 0;
|
|
37
|
+
for (let i = 0; i < t.length; i++)
|
|
38
|
+
h = (h * 31 + t.charCodeAt(i)) >>> 0;
|
|
39
|
+
return TAG_TONES[h % TAG_TONES.length];
|
|
40
|
+
}
|
|
41
|
+
// deterministic per-name colour for `avatarTone="hashed"` — a distinct
|
|
42
|
+
// palette from TAG_TONES because it must stay within the tones the `Avatar`
|
|
43
|
+
// DS component itself supports (no "success"/"warning"/"danger").
|
|
44
|
+
const AVATAR_TONES = ["brand", "orange", "cyan", "purple", "green"];
|
|
45
|
+
function avatarToneFor(name) {
|
|
46
|
+
let h = 0;
|
|
47
|
+
for (let i = 0; i < name.length; i++)
|
|
48
|
+
h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
|
49
|
+
return AVATAR_TONES[h % AVATAR_TONES.length];
|
|
50
|
+
}
|
|
51
|
+
const AGG_LABELS = {
|
|
52
|
+
none: "None",
|
|
53
|
+
count: "Count all",
|
|
54
|
+
filled: "Not empty",
|
|
55
|
+
sum: "Sum",
|
|
56
|
+
avg: "Average",
|
|
57
|
+
min: "Min",
|
|
58
|
+
max: "Max",
|
|
59
|
+
};
|
|
60
|
+
// currency is numeric data too — it gets the same numeric aggregations as
|
|
61
|
+
// `number` (only `number` auto-defaults to Sum on mount; currency starts at
|
|
62
|
+
// "none" like every other type and is picked explicitly from this menu)
|
|
63
|
+
const aggOptions = (col) => col.type === "number" || col.type === "currency"
|
|
64
|
+
? ["sum", "avg", "min", "max", "count", "filled", "none"]
|
|
65
|
+
: ["count", "filled", "none"];
|
|
66
|
+
const compact = new Intl.NumberFormat("en", {
|
|
67
|
+
notation: "compact",
|
|
68
|
+
maximumFractionDigits: 1,
|
|
69
|
+
});
|
|
70
|
+
const CHECK_W = 40;
|
|
71
|
+
const ADDCOL_W = 44;
|
|
72
|
+
const colWidth = (col) => col.width ?? (col.type === "entity" ? 240 : 180);
|
|
73
|
+
const OP_LABELS = {
|
|
74
|
+
contains: "contains",
|
|
75
|
+
is: "is",
|
|
76
|
+
isnot: "is not",
|
|
77
|
+
empty: "is empty",
|
|
78
|
+
notempty: "is not empty",
|
|
79
|
+
};
|
|
80
|
+
const OP_ORDER = ["contains", "is", "isnot", "empty", "notempty"];
|
|
81
|
+
const opNeedsValue = (op) => op === "contains" || op === "is" || op === "isnot";
|
|
82
|
+
function matchesFilter(col, row, cond) {
|
|
83
|
+
if (cond.op === "empty")
|
|
84
|
+
return !isFilled(col, row);
|
|
85
|
+
if (cond.op === "notempty")
|
|
86
|
+
return isFilled(col, row);
|
|
87
|
+
// filter always reads the raw underlying value, never `format`'s display
|
|
88
|
+
// string — a formatted "$1,200" must still match a filter value of "1200"
|
|
89
|
+
const t = rawText(col, row).toLowerCase();
|
|
90
|
+
const v = cond.value.trim().toLowerCase();
|
|
91
|
+
if (!v)
|
|
92
|
+
return true;
|
|
93
|
+
if (cond.op === "contains")
|
|
94
|
+
return t.includes(v);
|
|
95
|
+
if (cond.op === "is")
|
|
96
|
+
return t === v;
|
|
97
|
+
return t !== v; // isnot
|
|
98
|
+
}
|
|
99
|
+
// the raw value stringified, ignoring `format`/`render` entirely — the basis
|
|
100
|
+
// for filtering and the fallback for `cellText`'s copy path
|
|
101
|
+
function rawText(col, row) {
|
|
102
|
+
const v = row[col.key];
|
|
103
|
+
if (v == null)
|
|
104
|
+
return "";
|
|
105
|
+
if (col.type === "entity")
|
|
106
|
+
return v.name ?? "";
|
|
107
|
+
if (Array.isArray(v))
|
|
108
|
+
return v.join(", ");
|
|
109
|
+
return String(v);
|
|
110
|
+
}
|
|
111
|
+
// what ⌘C / row-copy / CSV export puts on the clipboard for this cell: a
|
|
112
|
+
// `render` column has no serializable node, so this never touches `render` —
|
|
113
|
+
// it uses `format`'s display string when the column has one, else the raw
|
|
114
|
+
// value stringified exactly like a plain column.
|
|
115
|
+
function cellText(col, row) {
|
|
116
|
+
if (col.format)
|
|
117
|
+
return col.format(row[col.key], row);
|
|
118
|
+
return rawText(col, row);
|
|
119
|
+
}
|
|
120
|
+
function isFilled(col, row) {
|
|
121
|
+
const v = row[col.key];
|
|
122
|
+
if (v == null || v === "")
|
|
123
|
+
return false;
|
|
124
|
+
if (Array.isArray(v))
|
|
125
|
+
return v.length > 0;
|
|
126
|
+
if (col.type === "entity")
|
|
127
|
+
return Boolean(v.name);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
function summarize(col, agg, rows) {
|
|
131
|
+
if (agg === "none")
|
|
132
|
+
return null;
|
|
133
|
+
if (agg === "count")
|
|
134
|
+
return { label: "count", value: String(rows.length) };
|
|
135
|
+
if (agg === "filled") {
|
|
136
|
+
const n = rows.filter((r) => isFilled(col, r)).length;
|
|
137
|
+
return { label: "filled", value: String(n) };
|
|
138
|
+
}
|
|
139
|
+
const nums = rows
|
|
140
|
+
.map((r) => r[col.key])
|
|
141
|
+
.filter((v) => typeof v === "number");
|
|
142
|
+
if (!nums.length)
|
|
143
|
+
return { label: agg, value: "—" };
|
|
144
|
+
let n;
|
|
145
|
+
if (agg === "sum")
|
|
146
|
+
n = nums.reduce((a, b) => a + b, 0);
|
|
147
|
+
else if (agg === "avg")
|
|
148
|
+
n = nums.reduce((a, b) => a + b, 0) / nums.length;
|
|
149
|
+
else if (agg === "min")
|
|
150
|
+
n = Math.min(...nums);
|
|
151
|
+
else
|
|
152
|
+
n = Math.max(...nums);
|
|
153
|
+
return { label: agg, value: compact.format(n) };
|
|
154
|
+
}
|
|
155
|
+
function EntityCell({ value, pinned, onOpenPage, avatarTone = "brand", }) {
|
|
156
|
+
// a real favicon/logo when we have one; fall back to the letter tile if it
|
|
157
|
+
// 404s or the row has no avatar
|
|
158
|
+
const [imgFailed, setImgFailed] = React.useState(false);
|
|
159
|
+
const showImg = Boolean(value.avatar) && !imgFailed;
|
|
160
|
+
return (_jsxs("span", { className: "recordstable-entity", children: [showImg ? (_jsx("img", { className: "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: "recordstable-entity-name recordstable-entity-name--link", onClick: (e) => {
|
|
161
|
+
e.stopPropagation();
|
|
162
|
+
onOpenPage();
|
|
163
|
+
}, children: value.name })) : (_jsx("span", { className: "recordstable-entity-name", children: value.name })), pinned ? (_jsx(Star, { className: "recordstable-pin", size: 13, strokeWidth: 2, "aria-label": "In a list" })) : null] }));
|
|
164
|
+
}
|
|
165
|
+
function CellView({ column, row, pinned, onOpenPage, avatarTone = "brand", }) {
|
|
166
|
+
const value = row[column.key];
|
|
167
|
+
// `render` fully owns the cell's content — no empty-dash fallback, no
|
|
168
|
+
// type renderer; it's the only thing drawn here (see the doc comment on
|
|
169
|
+
// `Column.render`).
|
|
170
|
+
if (column.render)
|
|
171
|
+
return _jsx(_Fragment, { children: column.render(value, row) });
|
|
172
|
+
if (value == null ||
|
|
173
|
+
value === "" ||
|
|
174
|
+
(Array.isArray(value) && !value.length)) {
|
|
175
|
+
return _jsx("span", { className: "recordstable-muted", children: "\u2014" });
|
|
176
|
+
}
|
|
177
|
+
// `format` keeps the type renderer's chrome below and only replaces the
|
|
178
|
+
// text it displays.
|
|
179
|
+
const display = column.format?.(value, row);
|
|
180
|
+
switch (column.type) {
|
|
181
|
+
case "entity":
|
|
182
|
+
return (_jsx(EntityCell, { value: value, pinned: pinned, onOpenPage: onOpenPage, avatarTone: avatarTone }));
|
|
183
|
+
case "tags":
|
|
184
|
+
return (_jsx("span", { className: "recordstable-cell recordstable-cell--tags", children: value.map((t) => (_jsx("span", { className: "recordstable-tag", "data-tone": tagTone(t), children: t }, t))) }));
|
|
185
|
+
case "number":
|
|
186
|
+
return (_jsx("span", { className: "recordstable-cell recordstable-cell--num", children: display ??
|
|
187
|
+
(typeof value === "number" ? value.toLocaleString() : value) }));
|
|
188
|
+
case "url":
|
|
189
|
+
return (_jsx("a", { className: "recordstable-link", href: `https://${value}`, target: "_blank", rel: "noreferrer", onClick: (e) => e.stopPropagation(), children: display ?? value }));
|
|
190
|
+
case "date":
|
|
191
|
+
return _jsx("span", { className: "recordstable-muted", children: display ?? value });
|
|
192
|
+
case "currency": {
|
|
193
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
194
|
+
return (_jsx("span", { className: "recordstable-cell recordstable-cell--num", children: display ??
|
|
195
|
+
`${column.symbol ?? "$"}${Number.isFinite(n) ? n.toLocaleString() : value}` }));
|
|
196
|
+
}
|
|
197
|
+
case "status": {
|
|
198
|
+
const opt = column.options?.find((o) => o.value === value);
|
|
199
|
+
if (!opt)
|
|
200
|
+
return _jsx("span", { className: "recordstable-muted", children: "\u2014" });
|
|
201
|
+
return (_jsxs("span", { className: "recordstable-status", children: [_jsx("span", { className: "recordstable-status-dot", style: { background: opt.color } }), display ?? opt.label] }));
|
|
202
|
+
}
|
|
203
|
+
case "reference": {
|
|
204
|
+
const opt = column.refOptions?.find((o) => o.value === value);
|
|
205
|
+
const label = display ?? opt?.label ?? String(value);
|
|
206
|
+
return (_jsxs("span", { className: "recordstable-entity", children: [_jsx(Avatar, { size: "sm", shape: "rounded", tone: avatarTone === "hashed" ? avatarToneFor(label) : "brand", children: label.charAt(0) }), _jsx("span", { className: "recordstable-entity-name", children: label })] }));
|
|
207
|
+
}
|
|
208
|
+
default:
|
|
209
|
+
return (_jsx("span", { className: "recordstable-entity-name", children: display ?? value }));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Always-interactive cell editors for the picker column types (status / date /
|
|
213
|
+
// reference). Each is a ghost crisp control that reads the cell value and
|
|
214
|
+
// commits the new one — matching Attio's inline field editors.
|
|
215
|
+
function StatusCell({ column, row, onChange, }) {
|
|
216
|
+
return (_jsx(Select, { variant: "ghost", "aria-label": column.label, placeholder: "\u2014", value: row[column.key] ?? undefined, onValueChange: onChange, options: column.options ?? [] }));
|
|
217
|
+
}
|
|
218
|
+
function ReferenceCell({ column, row, onChange, }) {
|
|
219
|
+
return (_jsx(Select, { variant: "ghost", searchable: true, searchPlaceholder: "Search records\u2026", "aria-label": column.label, placeholder: "Set a record\u2026", value: row[column.key] ?? undefined, onValueChange: onChange, options: (column.refOptions ?? []).map((o) => ({
|
|
220
|
+
value: o.value,
|
|
221
|
+
label: o.label,
|
|
222
|
+
})) }));
|
|
223
|
+
}
|
|
224
|
+
function TagsCell({ column, row, options, onChange, }) {
|
|
225
|
+
const value = row[column.key] ?? [];
|
|
226
|
+
return (_jsx(MultiSelect, { options: options, value: value, onValueChange: onChange, placeholder: "Empty", searchPlaceholder: "Search options\u2026", "aria-label": column.label }));
|
|
227
|
+
}
|
|
228
|
+
function DateCell({ column, row, onChange, }) {
|
|
229
|
+
const raw = row[column.key];
|
|
230
|
+
const value = raw ? new Date(raw) : null;
|
|
231
|
+
return (_jsx(DatePicker, { variant: "ghost", "aria-label": column.label, value: value, onChange: (d) => onChange(d.toISOString().slice(0, 10)) }));
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The "New <object>" create modal — measured from Attio's create-record dialog:
|
|
235
|
+
* a stacked label + "Set …" input per column, a "Create more" toggle, and a
|
|
236
|
+
* primary "Create record" action (⌘↵). Building the row mirrors the inline
|
|
237
|
+
* editor's per-type coercion so a created record reads back like an edited one.
|
|
238
|
+
*/
|
|
239
|
+
function CreateRecordDialog({ open, onOpenChange, columns, entityLabel, onCreate, }) {
|
|
240
|
+
const [values, setValues] = React.useState({});
|
|
241
|
+
const [createMore, setCreateMore] = React.useState(false);
|
|
242
|
+
const firstRef = React.useRef(null);
|
|
243
|
+
// reset the form each time the modal (re)opens
|
|
244
|
+
React.useEffect(() => {
|
|
245
|
+
if (open)
|
|
246
|
+
setValues({});
|
|
247
|
+
}, [open]);
|
|
248
|
+
const set = (key, v) => setValues((prev) => ({ ...prev, [key]: v }));
|
|
249
|
+
const buildRow = () => {
|
|
250
|
+
const row = {
|
|
251
|
+
id: `new-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
252
|
+
};
|
|
253
|
+
for (const col of columns) {
|
|
254
|
+
const raw = (values[col.key] ?? "").trim();
|
|
255
|
+
if (col.type === "entity") {
|
|
256
|
+
row[col.key] = { name: raw || "Untitled" };
|
|
257
|
+
}
|
|
258
|
+
else if (!raw) {
|
|
259
|
+
// leave empty fields unset so the cell renders its "—" placeholder
|
|
260
|
+
}
|
|
261
|
+
else if (col.type === "tags") {
|
|
262
|
+
row[col.key] = raw
|
|
263
|
+
.split(",")
|
|
264
|
+
.map((t) => t.trim())
|
|
265
|
+
.filter(Boolean);
|
|
266
|
+
}
|
|
267
|
+
else if (col.type === "number") {
|
|
268
|
+
row[col.key] = Number(raw.replace(/[^0-9.-]/g, "")) || null;
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
row[col.key] = raw;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return row;
|
|
275
|
+
};
|
|
276
|
+
const submit = () => {
|
|
277
|
+
onCreate(buildRow());
|
|
278
|
+
if (createMore) {
|
|
279
|
+
setValues({});
|
|
280
|
+
firstRef.current?.focus();
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
onOpenChange(false);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
const entityCol = columns.find((c) => c.type === "entity");
|
|
287
|
+
const EntityIcon = entityCol?.icon;
|
|
288
|
+
return (_jsxs(Dialog, { open: open, onOpenChange: onOpenChange, title: `New ${entityLabel}`, icon: EntityIcon ? _jsx(EntityIcon, { size: 16, strokeWidth: 1.75 }) : undefined, width: 720, children: [_jsx(DialogBody, { children: columns.map((col, i) => (_jsxs("div", { className: "dialog-field", children: [_jsx("label", { className: "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: `Set ${col.label}…`, onKeyDown: (e) => {
|
|
289
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey))
|
|
290
|
+
submit();
|
|
291
|
+
} })] }, col.key))) }), _jsxs(DialogFooter, { children: [_jsxs("span", { className: "dialog-more", children: ["Create more", _jsx(Switch, { checked: createMore, onCheckedChange: setCreateMore, "aria-label": "Create more" })] }), _jsxs("button", { type: "button", className: "dialog-submit", onClick: submit, children: ["Create record", _jsx("span", { className: "dialog-submit-key", children: "\u2318\u21B5" })] })] })] }));
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Attio-style record grid — fully featured. Toolbar (search / sort status /
|
|
295
|
+
* count / New), sticky checkbox + entity columns, 36px rows, typed cells,
|
|
296
|
+
* click-to-sort headers, column resize by dragging a header edge, inline cell
|
|
297
|
+
* editing (double-click a text/number/url/date cell), add-row and add-column,
|
|
298
|
+
* and a floating bulk-action bar (delete / deselect) when rows are selected.
|
|
299
|
+
* Selection can be controlled via `selectedIds`/`onSelectionChange`.
|
|
300
|
+
*/
|
|
301
|
+
export function RecordsTable({ columns: columnsProp = defaultColumns, rows: rowsProp = defaultRows, selectedIds, onSelectionChange, showNew = true, allowAddColumn = true, onOpenRecord, onOpenRecordPage, avatarTone = "brand", className, }) {
|
|
302
|
+
const [cols, setCols] = React.useState(columnsProp);
|
|
303
|
+
const [data, setData] = React.useState(rowsProp);
|
|
304
|
+
// Attio's multi-criteria sort: an ordered list of {field, direction} rows.
|
|
305
|
+
const [sorts, setSorts] = React.useState([]);
|
|
306
|
+
const [dragSort, setDragSort] = React.useState(null);
|
|
307
|
+
const [editing, setEditing] = React.useState(null);
|
|
308
|
+
const [renamingCol, setRenamingCol] = React.useState(null);
|
|
309
|
+
const [creating, setCreating] = React.useState(false);
|
|
310
|
+
// Attio-style filter: an ordered list of conditions, each shown as a toolbar
|
|
311
|
+
// chip with an editable value (operator is "contains")
|
|
312
|
+
const [filterConds, setFilterConds] = React.useState([]);
|
|
313
|
+
const [filterQuery, setFilterQuery] = React.useState("");
|
|
314
|
+
// one popover open at a time: "sort" | "filter" | "more" | `sum:<key>`
|
|
315
|
+
const [openMenu, setOpenMenu] = React.useState(null);
|
|
316
|
+
// viewport-anchored position for menus that live inside the scroll container
|
|
317
|
+
// (summary footer, column headers) — rendered fixed to escape the overflow clip
|
|
318
|
+
const [menuPos, setMenuPos] = React.useState(null);
|
|
319
|
+
const [pinned, setPinned] = React.useState(() => new Set());
|
|
320
|
+
const [scrolledX, setScrolledX] = React.useState(false);
|
|
321
|
+
const [scrolledY, setScrolledY] = React.useState(false);
|
|
322
|
+
const idCounter = React.useRef(0);
|
|
323
|
+
// fixed menus (summary / header) portal here — outside the sticky cells'
|
|
324
|
+
// stacking contexts, but still inside .recordstable so the styles apply
|
|
325
|
+
const rootRef = React.useRef(null);
|
|
326
|
+
const scrollRef = React.useRef(null);
|
|
327
|
+
// spreadsheet-style cell selection (click a cell, arrow-key to navigate)
|
|
328
|
+
const [selCell, setSelCell] = React.useState(null);
|
|
329
|
+
// shift-click range selection — anchor row + whether shift was held on click
|
|
330
|
+
const selAnchor = React.useRef(null);
|
|
331
|
+
const shiftHeld = React.useRef(false);
|
|
332
|
+
// right-click row context menu (fixed at the cursor)
|
|
333
|
+
const [ctxMenu, setCtxMenu] = React.useState(null);
|
|
334
|
+
// column drag-to-reorder — key of the header currently being dragged
|
|
335
|
+
const [dragCol, setDragCol] = React.useState(null);
|
|
336
|
+
// Let a plain vertical wheel scroll the (wide) table horizontally when it has
|
|
337
|
+
// no vertical overflow of its own — otherwise the wheel just scrolls the page
|
|
338
|
+
// and the extra columns feel unreachable. Non-passive so we can preventDefault.
|
|
339
|
+
React.useEffect(() => {
|
|
340
|
+
const el = scrollRef.current;
|
|
341
|
+
if (!el)
|
|
342
|
+
return;
|
|
343
|
+
const onWheel = (e) => {
|
|
344
|
+
// don't hijack the wheel when it's over an open cell popover (its own list
|
|
345
|
+
// scrolls) — only map vertical wheel to horizontal for the table itself
|
|
346
|
+
if (e.target?.closest?.(".select-listbox, .multiselect-listbox, .datepicker-cal, .recordstable-menu"))
|
|
347
|
+
return;
|
|
348
|
+
const canH = el.scrollWidth > el.clientWidth;
|
|
349
|
+
const canV = el.scrollHeight > el.clientHeight;
|
|
350
|
+
if (canH && !canV && e.deltaX === 0 && e.deltaY !== 0) {
|
|
351
|
+
el.scrollLeft += e.deltaY;
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
356
|
+
return () => el.removeEventListener("wheel", onWheel);
|
|
357
|
+
}, []);
|
|
358
|
+
// close any open menu on outside click (anything not inside a menu or its
|
|
359
|
+
// trigger) or Escape
|
|
360
|
+
React.useEffect(() => {
|
|
361
|
+
if (!openMenu)
|
|
362
|
+
return;
|
|
363
|
+
const onDown = (e) => {
|
|
364
|
+
const t = e.target;
|
|
365
|
+
if (!t.closest(".recordstable-menu, [data-menu-trigger]"))
|
|
366
|
+
setOpenMenu(null);
|
|
367
|
+
};
|
|
368
|
+
const onKey = (e) => e.key === "Escape" && setOpenMenu(null);
|
|
369
|
+
document.addEventListener("mousedown", onDown);
|
|
370
|
+
document.addEventListener("keydown", onKey);
|
|
371
|
+
return () => {
|
|
372
|
+
document.removeEventListener("mousedown", onDown);
|
|
373
|
+
document.removeEventListener("keydown", onKey);
|
|
374
|
+
};
|
|
375
|
+
}, [openMenu]);
|
|
376
|
+
// dismiss the right-click row context menu on any outside click / Escape
|
|
377
|
+
React.useEffect(() => {
|
|
378
|
+
if (!ctxMenu)
|
|
379
|
+
return;
|
|
380
|
+
const onDown = (e) => {
|
|
381
|
+
if (!e.target.closest(".recordstable-ctx"))
|
|
382
|
+
setCtxMenu(null);
|
|
383
|
+
};
|
|
384
|
+
const onKey = (e) => e.key === "Escape" && setCtxMenu(null);
|
|
385
|
+
document.addEventListener("mousedown", onDown);
|
|
386
|
+
document.addEventListener("keydown", onKey);
|
|
387
|
+
return () => {
|
|
388
|
+
document.removeEventListener("mousedown", onDown);
|
|
389
|
+
document.removeEventListener("keydown", onKey);
|
|
390
|
+
};
|
|
391
|
+
}, [ctxMenu]);
|
|
392
|
+
const activeFilters = filterConds.length;
|
|
393
|
+
const addFilter = (key) => {
|
|
394
|
+
idCounter.current += 1;
|
|
395
|
+
const id = `filter-${idCounter.current}`;
|
|
396
|
+
setFilterConds((prev) => [...prev, { id, key, op: "contains", value: "" }]);
|
|
397
|
+
setFilterQuery("");
|
|
398
|
+
setOpenMenu(`fcond:${id}`); // open its condition editor right away
|
|
399
|
+
};
|
|
400
|
+
const patchFilter = (id, patch) => setFilterConds((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c)));
|
|
401
|
+
const removeFilter = (id) => {
|
|
402
|
+
setFilterConds((prev) => prev.filter((c) => c.id !== id));
|
|
403
|
+
setOpenMenu(null);
|
|
404
|
+
};
|
|
405
|
+
const [agg, setAgg] = React.useState(() => {
|
|
406
|
+
const m = {};
|
|
407
|
+
for (const col of columnsProp) {
|
|
408
|
+
m[col.key] =
|
|
409
|
+
col.type === "number"
|
|
410
|
+
? "sum"
|
|
411
|
+
: col.type === "entity"
|
|
412
|
+
? "count"
|
|
413
|
+
: "none";
|
|
414
|
+
}
|
|
415
|
+
return m;
|
|
416
|
+
});
|
|
417
|
+
// selection — controlled if `selectedIds` is provided, else internal
|
|
418
|
+
const [internalSel, setInternalSel] = React.useState(() => new Set());
|
|
419
|
+
const selected = React.useMemo(() => (selectedIds ? new Set(selectedIds) : internalSel), [selectedIds, internalSel]);
|
|
420
|
+
const setSelected = React.useCallback((next) => {
|
|
421
|
+
onSelectionChange?.([...next]);
|
|
422
|
+
if (!selectedIds)
|
|
423
|
+
setInternalSel(next);
|
|
424
|
+
}, [onSelectionChange, selectedIds]);
|
|
425
|
+
const filtered = React.useMemo(() => {
|
|
426
|
+
const active = filterConds.filter((c) => !opNeedsValue(c.op) || c.value.trim());
|
|
427
|
+
if (!active.length)
|
|
428
|
+
return data;
|
|
429
|
+
return data.filter((row) => active.every((cond) => {
|
|
430
|
+
const col = cols.find((c) => c.key === cond.key);
|
|
431
|
+
return col ? matchesFilter(col, row, cond) : true;
|
|
432
|
+
}));
|
|
433
|
+
}, [data, cols, filterConds]);
|
|
434
|
+
const visibleRows = React.useMemo(() => {
|
|
435
|
+
if (sorts.length === 0)
|
|
436
|
+
return filtered;
|
|
437
|
+
const getters = sorts.map((s) => {
|
|
438
|
+
const col = cols.find((c) => c.key === s.key);
|
|
439
|
+
return (r) => {
|
|
440
|
+
const v = r[s.key];
|
|
441
|
+
if (col?.type === "entity")
|
|
442
|
+
return v?.name ?? "";
|
|
443
|
+
if (Array.isArray(v))
|
|
444
|
+
return v[0] ?? "";
|
|
445
|
+
return v ?? "";
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
return [...filtered].sort((a, b) => {
|
|
449
|
+
for (let i = 0; i < sorts.length; i++) {
|
|
450
|
+
const get = getters[i];
|
|
451
|
+
const av = get(a);
|
|
452
|
+
const bv = get(b);
|
|
453
|
+
const cmp = typeof av === "number" && typeof bv === "number"
|
|
454
|
+
? av - bv
|
|
455
|
+
: String(av).localeCompare(String(bv));
|
|
456
|
+
if (cmp !== 0)
|
|
457
|
+
return sorts[i].dir === "asc" ? cmp : -cmp;
|
|
458
|
+
}
|
|
459
|
+
return 0;
|
|
460
|
+
});
|
|
461
|
+
}, [filtered, cols, sorts]);
|
|
462
|
+
// arrow-key navigation over the selected cell (spreadsheet-style)
|
|
463
|
+
const onGridKeyDown = (e) => {
|
|
464
|
+
if (!selCell || editing)
|
|
465
|
+
return;
|
|
466
|
+
// ⌘C / Ctrl+C — copy the selected cell's rendered text to the clipboard
|
|
467
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === "c" || e.key === "C")) {
|
|
468
|
+
const row = visibleRows.find((rw) => rw.id === selCell.id);
|
|
469
|
+
const col = cols.find((cc) => cc.key === selCell.key);
|
|
470
|
+
if (row && col)
|
|
471
|
+
navigator.clipboard?.writeText(cellText(col, row));
|
|
472
|
+
e.preventDefault();
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const r = visibleRows.findIndex((row) => row.id === selCell.id);
|
|
476
|
+
const c = cols.findIndex((col) => col.key === selCell.key);
|
|
477
|
+
if (r < 0 || c < 0)
|
|
478
|
+
return;
|
|
479
|
+
let nr = r;
|
|
480
|
+
let nc = c;
|
|
481
|
+
if (e.key === "ArrowUp")
|
|
482
|
+
nr = Math.max(0, r - 1);
|
|
483
|
+
else if (e.key === "ArrowDown")
|
|
484
|
+
nr = Math.min(visibleRows.length - 1, r + 1);
|
|
485
|
+
else if (e.key === "ArrowLeft")
|
|
486
|
+
nc = Math.max(0, c - 1);
|
|
487
|
+
else if (e.key === "ArrowRight")
|
|
488
|
+
nc = Math.min(cols.length - 1, c + 1);
|
|
489
|
+
else if (e.key === "Escape") {
|
|
490
|
+
setSelCell(null);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
else if (e.key === "Enter") {
|
|
494
|
+
if (canEditCol(cols[c])) {
|
|
495
|
+
e.preventDefault();
|
|
496
|
+
setEditing({ id: selCell.id, key: selCell.key });
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
else
|
|
501
|
+
return;
|
|
502
|
+
e.preventDefault();
|
|
503
|
+
setSelCell({ id: visibleRows[nr].id, key: cols[nc].key });
|
|
504
|
+
};
|
|
505
|
+
const allSelected = visibleRows.length > 0 && visibleRows.every((r) => selected.has(r.id));
|
|
506
|
+
const someSelected = selected.size > 0 && !allSelected;
|
|
507
|
+
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(visibleRows.map((r) => r.id)));
|
|
508
|
+
const toggleRow = (id) => {
|
|
509
|
+
const next = new Set(selected);
|
|
510
|
+
// shift-click → select the contiguous range from the anchor to this row
|
|
511
|
+
if (shiftHeld.current && selAnchor.current) {
|
|
512
|
+
const a = visibleRows.findIndex((r) => r.id === selAnchor.current);
|
|
513
|
+
const b = visibleRows.findIndex((r) => r.id === id);
|
|
514
|
+
if (a >= 0 && b >= 0) {
|
|
515
|
+
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
516
|
+
for (let i = lo; i <= hi; i++)
|
|
517
|
+
next.add(visibleRows[i].id);
|
|
518
|
+
shiftHeld.current = false;
|
|
519
|
+
setSelected(next);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (next.has(id))
|
|
524
|
+
next.delete(id);
|
|
525
|
+
else
|
|
526
|
+
next.add(id);
|
|
527
|
+
selAnchor.current = id;
|
|
528
|
+
shiftHeld.current = false;
|
|
529
|
+
setSelected(next);
|
|
530
|
+
};
|
|
531
|
+
const addSort = () => {
|
|
532
|
+
const used = new Set(sorts.map((s) => s.key));
|
|
533
|
+
const col = cols.find((c) => !used.has(c.key));
|
|
534
|
+
if (col)
|
|
535
|
+
setSorts([...sorts, { key: col.key, dir: "asc" }]);
|
|
536
|
+
};
|
|
537
|
+
const moveSort = (from, to) => setSorts((prev) => {
|
|
538
|
+
if (from === to)
|
|
539
|
+
return prev;
|
|
540
|
+
const next = [...prev];
|
|
541
|
+
const [moved] = next.splice(from, 1);
|
|
542
|
+
next.splice(to, 0, moved);
|
|
543
|
+
return next;
|
|
544
|
+
});
|
|
545
|
+
const setSortField = (i, key) => setSorts(sorts.map((s, j) => (j === i ? { ...s, key } : s)));
|
|
546
|
+
const setSortDir = (i, dir) => setSorts(sorts.map((s, j) => (j === i ? { ...s, dir } : s)));
|
|
547
|
+
const removeSort = (i) => setSorts(sorts.filter((_, j) => j !== i));
|
|
548
|
+
const commitEdit = (id, key, raw) => {
|
|
549
|
+
const col = cols.find((c) => c.key === key);
|
|
550
|
+
const numeric = col?.type === "number" || col?.type === "currency";
|
|
551
|
+
const coerce = (r) => {
|
|
552
|
+
if (numeric)
|
|
553
|
+
return raw === "" ? null : Number(raw.replace(/[^0-9.-]/g, ""));
|
|
554
|
+
if (col?.type === "tags")
|
|
555
|
+
return raw
|
|
556
|
+
.split(",")
|
|
557
|
+
.map((t) => t.trim())
|
|
558
|
+
.filter(Boolean);
|
|
559
|
+
if (col?.type === "entity")
|
|
560
|
+
return { ...r[key], name: raw };
|
|
561
|
+
return raw;
|
|
562
|
+
};
|
|
563
|
+
setData((prev) => prev.map((r) => (r.id === id ? { ...r, [key]: coerce(r) } : r)));
|
|
564
|
+
setEditing(null);
|
|
565
|
+
};
|
|
566
|
+
// the option set for a `tags` column — the union of every value in that column,
|
|
567
|
+
// so the tag editor offers the existing options (each keeps a stable colour).
|
|
568
|
+
const tagOptionsFor = React.useCallback((key) => {
|
|
569
|
+
const set = new Set();
|
|
570
|
+
for (const r of data) {
|
|
571
|
+
const v = r[key];
|
|
572
|
+
if (Array.isArray(v))
|
|
573
|
+
for (const t of v)
|
|
574
|
+
set.add(t);
|
|
575
|
+
}
|
|
576
|
+
return [...set].map((t) => ({ value: t, label: t, tone: tagTone(t) }));
|
|
577
|
+
}, [data]);
|
|
578
|
+
// set any cell value directly (used by the status / date / reference pickers)
|
|
579
|
+
// biome-ignore lint/suspicious/noExplicitAny: cell values are column-type dependent
|
|
580
|
+
const setCell = (id, key, value) => {
|
|
581
|
+
setData((prev) => prev.map((r) => (r.id === id ? { ...r, [key]: value } : r)));
|
|
582
|
+
};
|
|
583
|
+
const addRow = () => {
|
|
584
|
+
idCounter.current += 1;
|
|
585
|
+
const id = `new-${idCounter.current}`;
|
|
586
|
+
const entityKey = cols.find((c) => c.type === "entity")?.key;
|
|
587
|
+
const row = { id };
|
|
588
|
+
if (entityKey)
|
|
589
|
+
row[entityKey] = { name: "Untitled" };
|
|
590
|
+
setData((prev) => [...prev, row]);
|
|
591
|
+
if (entityKey)
|
|
592
|
+
setEditing(null);
|
|
593
|
+
};
|
|
594
|
+
const sortBy = (key, dir) => {
|
|
595
|
+
setSorts([{ key, dir }]);
|
|
596
|
+
setOpenMenu(null);
|
|
597
|
+
};
|
|
598
|
+
const hideColumn = (key) => {
|
|
599
|
+
setCols((prev) => prev.filter((c) => c.key !== key));
|
|
600
|
+
setOpenMenu(null);
|
|
601
|
+
};
|
|
602
|
+
// Move a column one slot left/right. The first (entity) column is sticky and
|
|
603
|
+
// stays pinned, so reordering is clamped to index >= 1.
|
|
604
|
+
const moveColumn = (key, dir) => {
|
|
605
|
+
setCols((prev) => {
|
|
606
|
+
const i = prev.findIndex((c) => c.key === key);
|
|
607
|
+
const j = i + dir;
|
|
608
|
+
if (i < 1 || j < 1 || j >= prev.length)
|
|
609
|
+
return prev;
|
|
610
|
+
const next = [...prev];
|
|
611
|
+
[next[i], next[j]] = [next[j], next[i]];
|
|
612
|
+
return next;
|
|
613
|
+
});
|
|
614
|
+
setOpenMenu(null);
|
|
615
|
+
};
|
|
616
|
+
// drop the dragged column immediately before `toKey` (drag-to-reorder). The
|
|
617
|
+
// sticky entity column (index 0) stays pinned — never reorder into slot 0.
|
|
618
|
+
const reorderColumn = (fromKey, toKey) => {
|
|
619
|
+
setDragCol(null);
|
|
620
|
+
if (fromKey === toKey)
|
|
621
|
+
return;
|
|
622
|
+
setCols((prev) => {
|
|
623
|
+
const from = prev.findIndex((c) => c.key === fromKey);
|
|
624
|
+
let to = prev.findIndex((c) => c.key === toKey);
|
|
625
|
+
if (from < 1 || to < 1)
|
|
626
|
+
return prev;
|
|
627
|
+
const next = [...prev];
|
|
628
|
+
const [moved] = next.splice(from, 1);
|
|
629
|
+
to = next.findIndex((c) => c.key === toKey);
|
|
630
|
+
next.splice(to, 0, moved);
|
|
631
|
+
return next;
|
|
632
|
+
});
|
|
633
|
+
};
|
|
634
|
+
const renameColumn = (key, label) => {
|
|
635
|
+
const name = label.trim();
|
|
636
|
+
if (name)
|
|
637
|
+
setCols((prev) => prev.map((c) => (c.key === key ? { ...c, label: name } : c)));
|
|
638
|
+
setRenamingCol(null);
|
|
639
|
+
};
|
|
640
|
+
const openColumnMenu = (key, el) => {
|
|
641
|
+
if (openMenu === `col:${key}`) {
|
|
642
|
+
setOpenMenu(null);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const r = el.getBoundingClientRect();
|
|
646
|
+
setMenuPos({ top: r.bottom + 4, left: r.left });
|
|
647
|
+
setOpenMenu(`col:${key}`);
|
|
648
|
+
};
|
|
649
|
+
const addColumn = () => {
|
|
650
|
+
idCounter.current += 1;
|
|
651
|
+
const key = `field-${idCounter.current}`;
|
|
652
|
+
setCols((prev) => [
|
|
653
|
+
...prev,
|
|
654
|
+
{ key, label: `Field ${prev.length}`, type: "text", width: 160 },
|
|
655
|
+
]);
|
|
656
|
+
};
|
|
657
|
+
const deleteSelected = () => {
|
|
658
|
+
setData((prev) => prev.filter((r) => !selected.has(r.id)));
|
|
659
|
+
setSelected(new Set());
|
|
660
|
+
};
|
|
661
|
+
const exportSelected = () => {
|
|
662
|
+
const picked = data.filter((r) => selected.has(r.id));
|
|
663
|
+
const esc = (s) => `"${s.replace(/"/g, '""')}"`;
|
|
664
|
+
const csv = [
|
|
665
|
+
cols.map((c) => esc(c.label)).join(","),
|
|
666
|
+
...picked.map((r) => cols.map((c) => esc(cellText(c, r))).join(",")),
|
|
667
|
+
].join("\n");
|
|
668
|
+
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
|
|
669
|
+
const a = document.createElement("a");
|
|
670
|
+
a.href = url;
|
|
671
|
+
a.download = "records.csv";
|
|
672
|
+
a.click();
|
|
673
|
+
URL.revokeObjectURL(url);
|
|
674
|
+
setOpenMenu(null);
|
|
675
|
+
};
|
|
676
|
+
const entityKey = cols.find((c) => c.type === "entity")?.key;
|
|
677
|
+
// "Add to list" → pin the selected rows (shows a ★ on the entity)
|
|
678
|
+
const addToList = () => {
|
|
679
|
+
setPinned((prev) => new Set([...prev, ...selected]));
|
|
680
|
+
setSelected(new Set());
|
|
681
|
+
};
|
|
682
|
+
// "Send email" → open the mail client addressed to the selected records
|
|
683
|
+
const sendEmail = () => {
|
|
684
|
+
const names = data
|
|
685
|
+
.filter((r) => selected.has(r.id))
|
|
686
|
+
.map((r) => entityKey ? cellText({ key: entityKey, label: "" }, r) : r.id);
|
|
687
|
+
window.open(`mailto:?subject=${encodeURIComponent(names.join(", "))}`);
|
|
688
|
+
};
|
|
689
|
+
// "Run workflow" → a demo automation: duplicate the selected records
|
|
690
|
+
const runWorkflow = () => {
|
|
691
|
+
setData((prev) => {
|
|
692
|
+
const copies = prev
|
|
693
|
+
.filter((r) => selected.has(r.id))
|
|
694
|
+
.map((r) => {
|
|
695
|
+
idCounter.current += 1;
|
|
696
|
+
return { ...r, id: `${r.id}-copy-${idCounter.current}` };
|
|
697
|
+
});
|
|
698
|
+
return [...prev, ...copies];
|
|
699
|
+
});
|
|
700
|
+
setSelected(new Set());
|
|
701
|
+
};
|
|
702
|
+
// single-row actions used by the right-click context menu
|
|
703
|
+
const duplicateRow = (id) => {
|
|
704
|
+
setData((prev) => {
|
|
705
|
+
const i = prev.findIndex((r) => r.id === id);
|
|
706
|
+
if (i < 0)
|
|
707
|
+
return prev;
|
|
708
|
+
idCounter.current += 1;
|
|
709
|
+
const copy = { ...prev[i], id: `${id}-copy-${idCounter.current}` };
|
|
710
|
+
const next = [...prev];
|
|
711
|
+
next.splice(i + 1, 0, copy);
|
|
712
|
+
return next;
|
|
713
|
+
});
|
|
714
|
+
setCtxMenu(null);
|
|
715
|
+
};
|
|
716
|
+
const deleteRow = (id) => {
|
|
717
|
+
setData((prev) => prev.filter((r) => r.id !== id));
|
|
718
|
+
if (selected.has(id)) {
|
|
719
|
+
const next = new Set(selected);
|
|
720
|
+
next.delete(id);
|
|
721
|
+
setSelected(next);
|
|
722
|
+
}
|
|
723
|
+
setCtxMenu(null);
|
|
724
|
+
};
|
|
725
|
+
const copyRow = (id) => {
|
|
726
|
+
const row = data.find((r) => r.id === id);
|
|
727
|
+
const col = cols.find((c) => c.type === "entity");
|
|
728
|
+
if (row && col)
|
|
729
|
+
navigator.clipboard?.writeText(cellText(col, row));
|
|
730
|
+
setCtxMenu(null);
|
|
731
|
+
};
|
|
732
|
+
const pinRow = (id) => {
|
|
733
|
+
setPinned((prev) => new Set([...prev, id]));
|
|
734
|
+
setCtxMenu(null);
|
|
735
|
+
};
|
|
736
|
+
const setAggValue = (key, value) => {
|
|
737
|
+
setAgg((prev) => ({ ...prev, [key]: value }));
|
|
738
|
+
setOpenMenu(null);
|
|
739
|
+
};
|
|
740
|
+
// column resize via pointer drag on the header edge
|
|
741
|
+
const resizeCol = (key, startX, startW) => {
|
|
742
|
+
// guard the whole document during the drag: kill text selection and force a
|
|
743
|
+
// resize cursor so the gesture doesn't select cells or flicker (was buggy)
|
|
744
|
+
const prevSelect = document.body.style.userSelect;
|
|
745
|
+
const prevCursor = document.body.style.cursor;
|
|
746
|
+
document.body.style.userSelect = "none";
|
|
747
|
+
document.body.style.cursor = "col-resize";
|
|
748
|
+
const onMove = (e) => {
|
|
749
|
+
const w = Math.round(Math.max(72, startW + (e.clientX - startX)));
|
|
750
|
+
setCols((prev) => prev.map((c) => (c.key === key ? { ...c, width: w } : c)));
|
|
751
|
+
};
|
|
752
|
+
const onUp = () => {
|
|
753
|
+
document.body.style.userSelect = prevSelect;
|
|
754
|
+
document.body.style.cursor = prevCursor;
|
|
755
|
+
window.removeEventListener("pointermove", onMove);
|
|
756
|
+
window.removeEventListener("pointerup", onUp);
|
|
757
|
+
};
|
|
758
|
+
window.addEventListener("pointermove", onMove);
|
|
759
|
+
window.addEventListener("pointerup", onUp);
|
|
760
|
+
};
|
|
761
|
+
return (_jsxs("div", { className: recordstableRecipe({ className }), ref: rootRef, children: [_jsxs("div", { className: "recordstable-toolbar", children: [_jsxs("span", { className: "recordstable-toolgroup", children: [_jsxs("button", { type: "button", className: `recordstable-tool${sorts.length ? " 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
|
|
762
|
+
? `Sorted by ${cols.find((c) => c.key === sorts[0].key)?.label}${sorts.length > 1 ? ` +${sorts.length - 1}` : ""}`
|
|
763
|
+
: "Sort"] }), openMenu === "sort" && (_jsxs("div", { className: "recordstable-menu recordstable-sortpop", role: "dialog", "aria-label": "Sort", children: [sorts.length === 0 ? (_jsx("div", { className: "recordstable-sort-empty", children: "No sorts applied to this view." })) : (sorts.map((s, i) => (_jsxs("div", { className: "recordstable-sort-row", onDragOver: (e) => {
|
|
764
|
+
if (dragSort !== null)
|
|
765
|
+
e.preventDefault();
|
|
766
|
+
}, onDrop: () => {
|
|
767
|
+
if (dragSort !== null)
|
|
768
|
+
moveSort(dragSort, i);
|
|
769
|
+
setDragSort(null);
|
|
770
|
+
}, children: [_jsx("button", { type: "button", className: "recordstable-sort-grip", "aria-label": "Reorder sort", draggable: true, onDragStart: () => setDragSort(i), onDragEnd: () => setDragSort(null), children: _jsx(GripVertical, { size: 15, strokeWidth: 1.75 }) }), _jsx(Select, { className: "recordstable-sort-field", searchable: true, value: s.key, onValueChange: (k) => setSortField(i, k), options: cols.map((c) => ({
|
|
771
|
+
value: c.key,
|
|
772
|
+
label: c.icon ? (_jsxs("span", { className: "recordstable-sort-optlabel", children: [_jsx(c.icon, { size: 15, strokeWidth: 1.75 }), c.label] })) : (c.label),
|
|
773
|
+
keywords: c.label,
|
|
774
|
+
})), "aria-label": "Sort field" }), _jsx(Select, { className: "recordstable-sort-dir", value: s.dir, onValueChange: (d) => setSortDir(i, d), options: [
|
|
775
|
+
{
|
|
776
|
+
value: "asc",
|
|
777
|
+
label: (_jsxs("span", { className: "recordstable-sort-optlabel", children: [_jsx(ArrowUpNarrowWide, { size: 15, strokeWidth: 1.75 }), "Ascending"] })),
|
|
778
|
+
keywords: "Ascending",
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
value: "desc",
|
|
782
|
+
label: (_jsxs("span", { className: "recordstable-sort-optlabel", children: [_jsx(ArrowDownWideNarrow, { size: 15, strokeWidth: 1.75 }), "Descending"] })),
|
|
783
|
+
keywords: "Descending",
|
|
784
|
+
},
|
|
785
|
+
], "aria-label": "Sort direction" }), _jsx("button", { type: "button", className: "recordstable-sort-remove", onClick: () => removeSort(i), "aria-label": "Remove sort", children: _jsx(X, { size: 15, strokeWidth: 1.75 }) })] }, s.key)))), _jsx("div", { className: "recordstable-sort-addrow", children: _jsxs("button", { type: "button", className: "recordstable-sort-add", onClick: addSort, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), "Add sort"] }) }), _jsx("div", { className: "recordstable-sort-foot", children: _jsxs("a", { className: "recordstable-sort-learn", href: "https://attio.com/help", target: "_blank", rel: "noreferrer", children: [_jsx(CircleHelp, { size: 14, strokeWidth: 1.75 }), "Learn about sorting"] }) })] }))] }), _jsxs("span", { className: "recordstable-toolgroup", children: [_jsxs("button", { type: "button", className: `recordstable-tool recordstable-tool--ghost${activeFilters ? " recordstable-tool--active" : ""}`, onClick: () => setOpenMenu(openMenu === "filter" ? null : "filter"), "aria-haspopup": "menu", "aria-expanded": openMenu === "filter", "data-menu-trigger": true, children: [_jsx(ListFilter, { size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), activeFilters ? `Filter · ${activeFilters}` : "Filter"] }), openMenu === "filter" && (_jsxs("div", { className: "recordstable-menu", role: "menu", children: [_jsx("input", { className: "recordstable-menu-input", placeholder: "Filter by\u2026", "aria-label": "Filter by field",
|
|
786
|
+
// biome-ignore lint/a11y/noAutofocus: focus the picker on open
|
|
787
|
+
autoFocus: true, value: filterQuery, onChange: (e) => setFilterQuery(e.target.value) }), cols
|
|
788
|
+
.filter((col) => col.label
|
|
789
|
+
.toLowerCase()
|
|
790
|
+
.includes(filterQuery.trim().toLowerCase()))
|
|
791
|
+
.map((col) => (_jsxs("button", { type: "button", className: "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) => {
|
|
792
|
+
const col = cols.find((c) => c.key === cond.key);
|
|
793
|
+
const open = openMenu === `fcond:${cond.id}`;
|
|
794
|
+
const hasValue = !opNeedsValue(cond.op) || cond.value.trim();
|
|
795
|
+
return (_jsxs("span", { className: "recordstable-toolgroup", children: [_jsxs("span", { className: "recordstable-fchip", children: [_jsxs("button", { type: "button", className: "recordstable-fchip-main", "data-menu-trigger": true, "aria-haspopup": "menu", onClick: () => setOpenMenu(open ? null : `fcond:${cond.id}`), children: [_jsx("span", { className: "recordstable-fchip-field", children: col?.label }), hasValue ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "recordstable-fchip-op", children: OP_LABELS[cond.op] }), opNeedsValue(cond.op) && (_jsx("span", { className: "recordstable-fchip-val", children: cond.value }))] })) : (_jsx("span", { className: "recordstable-fchip-op", children: "Select condition" }))] }), _jsx("button", { type: "button", className: "recordstable-fchip-x", "aria-label": `Remove ${col?.label} filter`, onClick: () => removeFilter(cond.id), children: _jsx(X, { size: 13, strokeWidth: 2 }) })] }), open && (_jsxs("div", { className: "recordstable-menu", role: "menu", children: [_jsxs("div", { className: "recordstable-menu-head", children: [col?.label, " condition"] }), OP_ORDER.map((op) => (_jsxs("button", { type: "button", className: "recordstable-menu-item", "data-on": cond.op === op ? "" : undefined, onClick: () => patchFilter(cond.id, { op }), children: [OP_LABELS[op], cond.op === op ? (_jsx(Check, { className: "recordstable-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, op))), opNeedsValue(cond.op) && (_jsx("input", { className: "recordstable-menu-input", placeholder: "Value\u2026", "aria-label": `${col?.label} filter value`,
|
|
796
|
+
// biome-ignore lint/a11y/noAutofocus: focus the value field
|
|
797
|
+
autoFocus: true, value: cond.value, onChange: (e) => patchFilter(cond.id, { value: e.target.value }) }))] }))] }, cond.id));
|
|
798
|
+
}), _jsx("span", { className: "recordstable-spacer" }), _jsxs("span", { className: "recordstable-count", children: [visibleRows.length, " record", visibleRows.length === 1 ? "" : "s"] }), showNew && (_jsxs("button", { type: "button", className: "recordstable-new", onClick: () => setCreating(true), children: [_jsx(Plus, { size: 15, strokeWidth: 2, "aria-hidden": "true" }), "New"] }))] }), _jsxs("div", { ref: scrollRef, className: "recordstable-scroll", tabIndex: -1, "data-scrolled": scrolledX ? "" : undefined, "data-scrolled-y": scrolledY ? "" : undefined, onKeyDown: onGridKeyDown, onScroll: (e) => {
|
|
799
|
+
const left = e.currentTarget.scrollLeft > 0;
|
|
800
|
+
const top = e.currentTarget.scrollTop > 0;
|
|
801
|
+
setScrolledX((prev) => (prev === left ? prev : left));
|
|
802
|
+
setScrolledY((prev) => (prev === top ? prev : top));
|
|
803
|
+
}, children: [_jsxs("table", { className: "recordstable-table", style: {
|
|
804
|
+
// fill the container; the trailing add-column absorbs the slack (a
|
|
805
|
+
// flexible filler). minWidth keeps real column widths and drives the
|
|
806
|
+
// horizontal scroll once the columns outgrow the container.
|
|
807
|
+
width: "100%",
|
|
808
|
+
minWidth: CHECK_W +
|
|
809
|
+
ADDCOL_W +
|
|
810
|
+
cols.reduce((sum, c) => sum + colWidth(c), 0),
|
|
811
|
+
}, 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: "recordstable-th recordstable-col-check", children: _jsx("span", { className: "recordstable-check-inner", children: _jsx(Checkbox, { "aria-label": "Select all", checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }) }), cols.map((col, i) => {
|
|
812
|
+
const Icon = col.icon;
|
|
813
|
+
const sc = sorts.find((s) => s.key === col.key);
|
|
814
|
+
return (_jsxs("th", { className: `recordstable-th${i === 0 ? " recordstable-col-entity" : ""}`, "data-sorted": sc?.dir, "data-drop": dragCol && dragCol !== col.key && i >= 1 ? "" : undefined, onDragOver: dragCol && i >= 1 ? (e) => e.preventDefault() : undefined, onDrop: dragCol && i >= 1
|
|
815
|
+
? (e) => {
|
|
816
|
+
e.preventDefault();
|
|
817
|
+
reorderColumn(dragCol, col.key);
|
|
818
|
+
}
|
|
819
|
+
: undefined, children: [renamingCol === col.key ? (_jsx("input", { className: "recordstable-th-rename",
|
|
820
|
+
// biome-ignore lint/a11y/noAutofocus: focus the rename field the moment it opens
|
|
821
|
+
autoFocus: true, defaultValue: col.label, "aria-label": "Column label", onKeyDown: (e) => {
|
|
822
|
+
if (e.key === "Enter")
|
|
823
|
+
renameColumn(col.key, e.currentTarget.value);
|
|
824
|
+
else if (e.key === "Escape")
|
|
825
|
+
setRenamingCol(null);
|
|
826
|
+
}, onBlur: (e) => renameColumn(col.key, e.currentTarget.value) })) : (_jsxs("button", { type: "button", className: "recordstable-th-inner", "data-menu-trigger": true, "aria-haspopup": "menu", draggable: i >= 1, onDragStart: i >= 1
|
|
827
|
+
? (e) => {
|
|
828
|
+
setDragCol(col.key);
|
|
829
|
+
e.dataTransfer.effectAllowed = "move";
|
|
830
|
+
}
|
|
831
|
+
: undefined, onDragEnd: () => setDragCol(null), onClick: (e) => openColumnMenu(col.key, e.currentTarget), children: [Icon ? _jsx(Icon, { size: 14, strokeWidth: 1.75 }) : null, _jsx("span", { className: "recordstable-th-label", children: col.label }), _jsx("span", { className: "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}` &&
|
|
832
|
+
rootRef.current &&
|
|
833
|
+
createPortal(_jsxs("div", { className: "recordstable-menu recordstable-menu--fixed recordstable-menu--hdr", role: "menu", style: menuPos ?? undefined, children: [_jsxs("button", { type: "button", className: "recordstable-menu-item", onClick: () => sortBy(col.key, "asc"), children: [_jsx(ArrowUp, { size: 15, strokeWidth: 1.75 }), "Sort ascending"] }), _jsxs("button", { type: "button", className: "recordstable-menu-item", onClick: () => sortBy(col.key, "desc"), children: [_jsx(ArrowDown, { size: 15, strokeWidth: 1.75 }), "Sort descending"] }), _jsxs("button", { type: "button", className: "recordstable-menu-item", disabled: i <= 1, onClick: () => moveColumn(col.key, -1), children: [_jsx(ArrowLeft, { size: 15, strokeWidth: 1.75 }), "Move left"] }), _jsxs("button", { type: "button", className: "recordstable-menu-item", disabled: i === 0 || i >= cols.length - 1, onClick: () => moveColumn(col.key, 1), children: [_jsx(ArrowRight, { size: 15, strokeWidth: 1.75 }), "Move right"] }), _jsx("div", { className: "recordstable-menu-sep" }), _jsxs("button", { type: "button", className: "recordstable-menu-item", onClick: () => {
|
|
834
|
+
setRenamingCol(col.key);
|
|
835
|
+
setOpenMenu(null);
|
|
836
|
+
}, children: [_jsx(Pencil, { size: 15, strokeWidth: 1.75 }), "Edit column label"] }), cols.length > 1 && (_jsxs("button", { type: "button", className: "recordstable-menu-item", onClick: () => hideColumn(col.key), children: [_jsx(EyeOff, { size: 15, strokeWidth: 1.75 }), "Hide from view"] }))] }), rootRef.current), _jsx("span", { className: "recordstable-resize", "aria-hidden": "true", onPointerDown: (e) => {
|
|
837
|
+
e.preventDefault();
|
|
838
|
+
e.stopPropagation();
|
|
839
|
+
const th = e.currentTarget
|
|
840
|
+
.parentElement;
|
|
841
|
+
resizeCol(col.key, e.clientX, th ? th.getBoundingClientRect().width : 180);
|
|
842
|
+
}, onClick: (e) => e.stopPropagation() })] }, col.key));
|
|
843
|
+
}), _jsx("th", { className: "recordstable-th recordstable-addcol", children: allowAddColumn && (_jsx("button", { type: "button", onClick: addColumn, "aria-label": "Add column", 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) => {
|
|
844
|
+
e.preventDefault();
|
|
845
|
+
setCtxMenu({ x: e.clientX, y: e.clientY, id: row.id });
|
|
846
|
+
}, children: [_jsx("td", { className: "recordstable-td recordstable-col-check", children: _jsx("span", { className: "recordstable-check-inner", children: _jsx(Checkbox, { "aria-label": `Select ${row.id}`, checked: selected.has(row.id), onClick: (e) => {
|
|
847
|
+
shiftHeld.current = e.shiftKey;
|
|
848
|
+
}, onChange: () => toggleRow(row.id) }) }) }), cols.map((col, i) => {
|
|
849
|
+
const canEdit = canEditCol(col);
|
|
850
|
+
const isPicker = isPickerCol(col);
|
|
851
|
+
const isEditing = editing?.id === row.id && editing?.key === col.key;
|
|
852
|
+
return (_jsxs("td", { className: `recordstable-td${i === 0 ? " recordstable-col-entity" : ""}${canEdit ? " recordstable-td--editable" : ""}${isPicker ? " recordstable-td--picker" : ""}`, "data-selected": selCell?.id === row.id && selCell?.key === col.key
|
|
853
|
+
? ""
|
|
854
|
+
: undefined, onClick: () => {
|
|
855
|
+
if (isPicker)
|
|
856
|
+
return;
|
|
857
|
+
setSelCell({ id: row.id, key: col.key });
|
|
858
|
+
scrollRef.current?.focus({ preventScroll: true });
|
|
859
|
+
}, onDoubleClick: canEdit
|
|
860
|
+
? () => setEditing({ id: row.id, key: col.key })
|
|
861
|
+
: 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, { column: col, row: row, onChange: (v) => setCell(row.id, col.key, v) })) : col.type === "tags" ? (_jsx(TagsCell, { 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: `recordstable-edit${col.type === "number" ? " recordstable-edit--num" : ""}`,
|
|
862
|
+
// biome-ignore lint/a11y/noAutofocus: focusing the just-opened editor is the expected behavior
|
|
863
|
+
autoFocus: true, defaultValue: cellText(col, row), onBlur: (e) => commitEdit(row.id, col.key, e.target.value), onKeyDown: (e) => {
|
|
864
|
+
if (e.key === "Enter")
|
|
865
|
+
commitEdit(row.id, col.key, e.target.value);
|
|
866
|
+
if (e.key === "Escape")
|
|
867
|
+
setEditing(null);
|
|
868
|
+
} })) : (_jsx(CellView, { column: col, row: row, pinned: i === 0 && pinned.has(row.id), onOpenPage: i === 0 && onOpenRecordPage
|
|
869
|
+
? () => onOpenRecordPage(row)
|
|
870
|
+
: undefined, avatarTone: avatarTone })), i === 0 && onOpenRecord && !isEditing && (_jsx("button", { type: "button", className: "recordstable-open", "aria-label": `Open ${cellText(col, row)}`, onClick: (e) => {
|
|
871
|
+
e.stopPropagation();
|
|
872
|
+
onOpenRecord(row);
|
|
873
|
+
}, children: _jsx(ArrowUpRight, { size: 15, strokeWidth: 1.75 }) }))] }, col.key));
|
|
874
|
+
}), _jsx("td", { className: "recordstable-td recordstable-addcol" })] }, row.id))) }), _jsx("tfoot", { children: _jsxs("tr", { children: [_jsx("td", { className: "recordstable-sum recordstable-col-check" }), cols.map((col, i) => {
|
|
875
|
+
const a = agg[col.key] ?? "none";
|
|
876
|
+
const res = summarize(col, a, visibleRows);
|
|
877
|
+
const sumOpen = openMenu === `sum:${col.key}`;
|
|
878
|
+
return (_jsx("td", { className: `recordstable-sum${i === 0 ? " recordstable-col-entity" : ""}`, children: _jsxs("span", { className: "recordstable-toolgroup recordstable-sumgroup", children: [_jsx("button", { type: "button", className: "recordstable-sum-btn", "data-empty": res ? undefined : "", "data-menu-trigger": true, "aria-haspopup": "menu", onClick: (e) => {
|
|
879
|
+
if (sumOpen) {
|
|
880
|
+
setOpenMenu(null);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
884
|
+
setMenuPos({
|
|
885
|
+
bottom: window.innerHeight - r.top + 4,
|
|
886
|
+
right: window.innerWidth - r.right,
|
|
887
|
+
});
|
|
888
|
+
setOpenMenu(`sum:${col.key}`);
|
|
889
|
+
}, children: res ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "recordstable-sum-label", children: AGG_LABELS[a] }), _jsx("span", { className: "recordstable-sum-value", children: res.value })] })) : (_jsx("span", { className: "recordstable-sum-label", children: "Calculate" })) }), sumOpen &&
|
|
890
|
+
rootRef.current &&
|
|
891
|
+
createPortal(_jsx("div", { className: "recordstable-menu recordstable-menu--fixed recordstable-menu--up", role: "menu", style: menuPos ?? undefined, children: aggOptions(col).map((opt) => (_jsxs("button", { type: "button", className: "recordstable-menu-item", "data-on": a === opt ? "" : undefined, onClick: () => setAggValue(col.key, opt), children: [AGG_LABELS[opt], a === opt ? (_jsx(Check, { className: "recordstable-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, opt))) }), rootRef.current)] }) }, col.key));
|
|
892
|
+
}), _jsx("td", { className: "recordstable-sum recordstable-addcol" })] }) })] }), visibleRows.length === 0 ? (_jsx("div", { className: "recordstable-empty", children: _jsx(EmptyState, { variant: "page", icon: activeFilters ? (_jsx(ListFilter, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" })) : (_jsx(Inbox, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" })), title: activeFilters
|
|
893
|
+
? "No records match that filter"
|
|
894
|
+
: "No records yet", description: activeFilters
|
|
895
|
+
? "None of your records match the filters that are set."
|
|
896
|
+
: `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 }), "Clear filter"] })) : (_jsxs(Button, { intent: "primary", size: "sm", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), "New record"] })) }) })) : (_jsxs("button", { type: "button", className: "recordstable-addrow", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), "New record"] }))] }), selected.size > 0 && (_jsxs(BulkBar, { count: selected.size, countLabel: "Selected", onClear: () => setSelected(new Set()), children: [_jsx(BulkBarButton, { icon: _jsx(ListPlus, { size: 15, strokeWidth: 1.75 }), onClick: addToList, children: "Add to list" }), _jsx(BulkBarButton, { icon: _jsx(Mail, { size: 15, strokeWidth: 1.75 }), onClick: sendEmail, children: "Send email" }), _jsx(BulkBarButton, { icon: _jsx(Workflow, { size: 15, strokeWidth: 1.75 }), onClick: runWorkflow, children: "Run workflow" }), _jsxs(Menu, { align: "end", "aria-label": "More actions", trigger: _jsx(BulkBarButton, { icon: _jsx(MoreHorizontal, { size: 15, strokeWidth: 1.75 }), children: "More" }), children: [_jsx(MenuItem, { icon: _jsx(Download, { size: 15, strokeWidth: 1.75 }), onSelect: exportSelected, children: "Export CSV" }), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, onSelect: deleteSelected, children: "Delete" })] })] })), _jsx(CreateRecordDialog, { open: creating, onOpenChange: setCreating, columns: cols, entityLabel: cols.find((c) => c.type === "entity")?.label ?? "record", onCreate: (row) => setData((prev) => [...prev, row]) }), ctxMenu &&
|
|
897
|
+
createPortal(_jsxs("div", { className: "recordstable-menu recordstable-ctx", role: "menu", style: { position: "fixed", top: ctxMenu.y, left: ctxMenu.x }, children: [_jsxs("button", { type: "button", className: "recordstable-menu-item", onClick: () => copyRow(ctxMenu.id), children: [_jsx(Copy, { size: 15, strokeWidth: 1.75 }), "Copy"] }), _jsxs("button", { type: "button", className: "recordstable-menu-item", onClick: () => duplicateRow(ctxMenu.id), children: [_jsx(CopyPlus, { size: 15, strokeWidth: 1.75 }), "Duplicate"] }), _jsxs("button", { type: "button", className: "recordstable-menu-item", onClick: () => pinRow(ctxMenu.id), children: [_jsx(Star, { size: 15, strokeWidth: 1.75 }), "Add to list"] }), _jsxs("button", { type: "button", className: "recordstable-menu-item recordstable-menu-item--danger", onClick: () => deleteRow(ctxMenu.id), children: [_jsx(Trash2, { size: 15, strokeWidth: 1.75 }), "Delete"] })] }), rootRef.current ?? document.body)] }));
|
|
898
|
+
}
|
|
899
|
+
export const defaultColumns = [
|
|
900
|
+
{ key: "company", label: "Company", type: "entity", icon: Building2 },
|
|
901
|
+
{ key: "domain", label: "Domain", type: "url", icon: Globe },
|
|
902
|
+
{
|
|
903
|
+
key: "categories",
|
|
904
|
+
label: "Categories",
|
|
905
|
+
type: "tags",
|
|
906
|
+
icon: Tag,
|
|
907
|
+
width: 240,
|
|
908
|
+
},
|
|
909
|
+
{
|
|
910
|
+
key: "employees",
|
|
911
|
+
label: "Employees",
|
|
912
|
+
type: "number",
|
|
913
|
+
icon: Users,
|
|
914
|
+
width: 120,
|
|
915
|
+
},
|
|
916
|
+
{ key: "location", label: "Location", type: "text", icon: MapPin },
|
|
917
|
+
{ key: "stage", label: "Stage", type: "tags", icon: TrendingUp, width: 150 },
|
|
918
|
+
{ key: "ceo", label: "CEO", type: "text", icon: Briefcase, width: 170 },
|
|
919
|
+
{ key: "twitter", label: "Twitter", type: "url", icon: AtSign, width: 150 },
|
|
920
|
+
{
|
|
921
|
+
key: "followers",
|
|
922
|
+
label: "Followers",
|
|
923
|
+
type: "number",
|
|
924
|
+
icon: Users,
|
|
925
|
+
width: 120,
|
|
926
|
+
},
|
|
927
|
+
{
|
|
928
|
+
key: "linkedin",
|
|
929
|
+
label: "LinkedIn",
|
|
930
|
+
type: "url",
|
|
931
|
+
icon: Globe,
|
|
932
|
+
width: 180,
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
key: "founded",
|
|
936
|
+
label: "Founded",
|
|
937
|
+
type: "date",
|
|
938
|
+
icon: Calendar,
|
|
939
|
+
width: 110,
|
|
940
|
+
},
|
|
941
|
+
{ key: "arr", label: "Est. ARR", type: "number", icon: Hash, width: 130 },
|
|
942
|
+
];
|
|
943
|
+
// biome-ignore format: one row per line keeps the wide demo dataset readable
|
|
944
|
+
const RAW_ROWS = [
|
|
945
|
+
{ 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 },
|
|
946
|
+
{ 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 },
|
|
947
|
+
{ 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 },
|
|
948
|
+
{ 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 },
|
|
949
|
+
{ 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 },
|
|
950
|
+
{ 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 },
|
|
951
|
+
{ 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 },
|
|
952
|
+
{ 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 },
|
|
953
|
+
];
|
|
954
|
+
// Attach a real favicon per company (derived from the domain); the entity cell
|
|
955
|
+
// falls back to a letter tile if it fails to load.
|
|
956
|
+
export const defaultRows = RAW_ROWS.map((r) => ({
|
|
957
|
+
...r,
|
|
958
|
+
company: {
|
|
959
|
+
...r.company,
|
|
960
|
+
avatar: companyLogos[r.domain] ??
|
|
961
|
+
`https://www.google.com/s2/favicons?domain=${r.domain}&sz=64`,
|
|
962
|
+
},
|
|
963
|
+
}));
|