Records Table

Data & Tables

CRM-style grid with tags, sorting, and relationship status.

Interactive Live Preview
components/ui/records-table.tsxTypeScript · React 19
1"use client";
2
3import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4import GlideMenu from "@/components/primitives/GlideMenu";
5
6/* ─────────────────────────────────────────────────────────
7 * RECORDS TABLE — an AI spreadsheet grid. Columns are
8 * *properties*: click a header to open its configuration
9 * popover (type, tool, grounding, inputs, prompt, run), add
10 * a new AI property from the + header, and watch cells
11 * resolve row-by-row while it calculates.
12 * ───────────────────────────────────────────────────────── */
13
14type Strength = "strong" | "weak" | "veryweak" | "none";
15type SortKey = "name" | "last" | "strength";
16type ColumnKey = "company" | "categories" | "last" | "strength" | "links" | "ai";
17
18const DEFAULT_COLUMN_WIDTHS: Record<ColumnKey, number> = {
19 company: 270,
20 categories: 275,
21 last: 190,
22 strength: 210,
23 links: 175,
24 ai: 240,
25};
26
27const STRENGTH: Record<Strength, { label: string; color: string; rank: number }> = {
28 strong: { label: "Very strong", color: "var(--green)", rank: 3 },
29 weak: { label: "Weak", color: "var(--orange)", rank: 2 },
30 veryweak: { label: "Very weak", color: "var(--red)", rank: 1 },
31 none: { label: "No communication", color: "var(--ink-3)", rank: 0 },
32};
33
34// A single mid-lightness base hue per tag. Background, text, and border are
35// derived from this via color-mix() against the theme tokens in .records-tag,
36// so the chips adapt to light and dark automatically (same pattern as FilterTable).
37type TagColor = { base: string };
38
39const TAG_PALETTE: Record<string, TagColor> = {
40 amber: { base: "oklch(0.76 0.13 70)" },
41 lime: { base: "oklch(0.77 0.16 122)" },
42 yellow: { base: "oklch(0.80 0.15 101)" },
43 purple: { base: "oklch(0.62 0.18 293)" },
44 orange: { base: "oklch(0.71 0.16 48)" },
45 cyan: { base: "oklch(0.72 0.10 221)" },
46 red: { base: "oklch(0.64 0.19 27)" },
47 magenta: { base: "oklch(0.66 0.21 323)" },
48 green: { base: "oklch(0.70 0.13 162)" },
49 pink: { base: "oklch(0.67 0.19 3)" },
50};
51
52const TAG_COLORS: Record<string, TagColor> = {
53 B2B: TAG_PALETTE.amber,
54 B2C: TAG_PALETTE.lime,
55 Cafe: TAG_PALETTE.red,
56 Catering: TAG_PALETTE.magenta,
57 "Dairy-free": TAG_PALETTE.cyan,
58 Gelato: TAG_PALETTE.purple,
59 Imports: TAG_PALETTE.orange,
60 Local: TAG_PALETTE.green,
61 Seasonal: TAG_PALETTE.yellow,
62 Sorbet: TAG_PALETTE.pink,
63 Vegan: TAG_PALETTE.lime,
64 Wholesale: TAG_PALETTE.amber,
65};
66
67type Row = {
68 id: string;
69 name: string;
70 tags: string[];
71 last: string;
72 strength: Strength;
73 website?: string;
74};
75
76const INITIAL_ROWS: Row[] = [
77 { id: "aurora", name: "Aurora Scoops — Reykjavík", tags: ["Gelato", "Seasonal"], last: "9 days ago", strength: "strong", website: "aurora-scoops.example.com" },
78 { id: "kumo", name: "Kumo Creamery — Tokyo", tags: ["B2C", "Cafe", "Vegan"], last: "3 weeks ago", strength: "strong", website: "kumo-creamery.example.com" },
79 { id: "sol-nieve", name: "Sol y Nieve — Buenos Aires", tags: ["Gelato", "Local"], last: "2 months ago", strength: "weak", website: "sol-y-nieve.example.com" },
80 { id: "maple-orbit", name: "Maple Orbit — Montréal", tags: ["B2B", "Wholesale", "Seasonal"], last: "15 days ago", strength: "weak", website: "maple-orbit.example.com" },
81 { id: "blue-fig", name: "Blue Fig Gelato — Florence", tags: ["Gelato", "Cafe"], last: "over 1 year ago", strength: "veryweak", website: "blue-fig.example.com" },
82 { id: "sahara-swirl", name: "Sahara Swirl — Marrakech", tags: ["Sorbet", "Local"], last: "5 months ago", strength: "veryweak" },
83 { id: "cloudberry", name: "Cloudberry Cone — Helsinki", tags: ["Dairy-free", "Seasonal"], last: "No contact", strength: "none", website: "cloudberry-cone.example.com" },
84 { id: "palm-sugar", name: "Palm Sugar Creamery — Bangkok", tags: ["B2C", "Vegan"], last: "3 months ago", strength: "veryweak", website: "palm-sugar.example.com" },
85 { id: "cape-vanilla", name: "Cape Vanilla Co. — Cape Town", tags: ["Wholesale", "Imports"], last: "over 1 year ago", strength: "veryweak", website: "cape-vanilla.example.com" },
86 { id: "andes-snow", name: "Andes Snow Creamery — Quito", tags: ["Gelato", "Catering"], last: "almost 2 years ago", strength: "veryweak" },
87 { id: "tasman-sea", name: "Tasman Sea Gelato — Hobart", tags: ["Gelato", "Local"], last: "2 months ago", strength: "weak", website: "tasman-sea.example.com" },
88 { id: "silk-road", name: "Silk Road Sorbet — Tbilisi", tags: ["Sorbet", "Imports"], last: "about 1 month ago", strength: "weak", website: "silk-road.example.com" },
89 { id: "rosewater", name: "Rosewater Kulfi — Jaipur", tags: ["B2C", "Seasonal"], last: "2 months ago", strength: "veryweak" },
90 { id: "lumen", name: "Lumen Soft Serve — Copenhagen", tags: ["Dairy-free", "Cafe"], last: "8 months ago", strength: "weak", website: "lumen-soft-serve.example.com" },
91 { id: "cacao-norte", name: "Cacao Norte — Oaxaca", tags: ["B2B", "Local", "Wholesale"], last: "about 2 years ago", strength: "none", website: "cacao-norte.example.com" },
92 { id: "pine-pistachio", name: "Pine & Pistachio — Istanbul", tags: ["Gelato", "Catering"], last: "about 1 month ago", strength: "veryweak" },
93 { id: "ember-cone", name: "Ember Cone Company — Seoul", tags: ["B2C", "Vegan"], last: "15 days ago", strength: "weak", website: "ember-cone.example.com" },
94 { id: "coral-coast", name: "Coral Coast Sorbet — Honolulu", tags: ["Sorbet", "Local"], last: "9 days ago", strength: "strong", website: "coral-coast.example.com" },
95 { id: "sunbird", name: "Sunbird Gelateria — Lisbon", tags: ["Gelato", "Cafe"], last: "over 2 years ago", strength: "none", website: "sunbird.example.com" },
96 { id: "mooncake", name: "Mooncake Ice Cream — Singapore", tags: ["B2B", "Wholesale"], last: "about 1 month ago", strength: "veryweak", website: "mooncake-ice-cream.example.com" },
97 { id: "juniper", name: "Juniper & Cream — Vancouver", tags: ["Dairy-free", "Catering"], last: "No contact", strength: "none" },
98 { id: "mango-moon", name: "Mango Moon Gelato — Nairobi", tags: ["Sorbet", "Vegan"], last: "almost 2 years ago", strength: "veryweak", website: "mango-moon.example.com" },
99 { id: "fjord-fizz", name: "Fjord Fizz Ice — Oslo", tags: ["Dairy-free", "Seasonal"], last: "No contact", strength: "none" },
100 { id: "pampa", name: "Pampa Creamery — Córdoba", tags: ["B2C", "Local"], last: "12 months ago", strength: "veryweak", website: "pampa-creamery.example.com" },
101 { id: "lotus-leaf", name: "Lotus Leaf Scoops — Hanoi", tags: ["Vegan", "Cafe"], last: "15 days ago", strength: "weak" },
102 { id: "saffron-sky", name: "Saffron Sky Kulfi — Dubai", tags: ["Imports", "Catering"], last: "almost 2 years ago", strength: "veryweak", website: "saffron-sky.example.com" },
103 { id: "alpine-churn", name: "Alpine Churn — Zürich", tags: ["B2B", "Gelato", "Wholesale"], last: "4 days ago", strength: "strong", website: "alpine-churn.example.com" },
104 { id: "monsoon-mango", name: "Monsoon Mango — Mumbai", tags: ["Sorbet", "Vegan", "Catering"], last: "18 days ago", strength: "weak", website: "monsoon-mango.example.com" },
105 { id: "cedar-spoon", name: "Cedar Spoon — Beirut", tags: ["Cafe", "Local", "Seasonal"], last: "6 days ago", strength: "strong", website: "cedar-spoon.example.com" },
106 { id: "baltic-berry", name: "Baltic Berry — Tallinn", tags: ["Dairy-free", "Seasonal", "B2C"], last: "5 weeks ago", strength: "weak", website: "baltic-berry.example.com" },
107 { id: "delta-dairy", name: "Delta Dairy Works — New Orleans", tags: ["B2B", "Wholesale", "Local"], last: "2 days ago", strength: "strong", website: "delta-dairy.example.com" },
108 { id: "yuzu-yard", name: "Yuzu Yard — Kyoto", tags: ["Sorbet", "Cafe", "Seasonal"], last: "11 days ago", strength: "strong", website: "yuzu-yard.example.com" },
109 { id: "copper-cone", name: "Copper Cone — Melbourne", tags: ["Gelato", "Cafe", "B2C"], last: "about 1 month ago", strength: "weak", website: "copper-cone.example.com" },
110 { id: "mint-medina", name: "Mint Medina — Tunis", tags: ["Dairy-free", "Vegan", "Local"], last: "No contact", strength: "none" },
111 { id: "glacier-grove", name: "Glacier Grove — Anchorage", tags: ["Seasonal", "Local", "Catering"], last: "7 weeks ago", strength: "weak", website: "glacier-grove.example.com" },
112 { id: "orchard-cloud", name: "Orchard Cloud — Lyon", tags: ["Gelato", "Seasonal", "Cafe"], last: "5 days ago", strength: "strong", website: "orchard-cloud.example.com" },
113 { id: "tamarind-tide", name: "Tamarind Tide — Chennai", tags: ["Vegan", "Sorbet", "B2C"], last: "9 months ago", strength: "veryweak", website: "tamarind-tide.example.com" },
114 { id: "amber-scoop", name: "Amber Scoop — Prague", tags: ["Gelato", "B2B"], last: "over 1 year ago", strength: "none" },
115 { id: "boreal-batch", name: "Boreal Batch — Yellowknife", tags: ["Dairy-free", "Local", "Seasonal"], last: "8 days ago", strength: "strong", website: "boreal-batch.example.com" },
116 { id: "coconut-commons", name: "Coconut Commons — Manila", tags: ["Vegan", "B2C", "Cafe"], last: "24 days ago", strength: "weak", website: "coconut-commons.example.com" },
117 { id: "dolomite-dairy", name: "Dolomite Dairy — Bolzano", tags: ["Gelato", "Wholesale"], last: "3 days ago", strength: "strong", website: "dolomite-dairy.example.com" },
118 { id: "equator-cream", name: "Equator Cream — Kampala", tags: ["B2B", "Catering", "Local"], last: "10 months ago", strength: "veryweak", website: "equator-cream.example.com" },
119 { id: "hibiscus-house", name: "Hibiscus House — Accra", tags: ["Sorbet", "Cafe"], last: "6 weeks ago", strength: "weak", website: "hibiscus-house.example.com" },
120 { id: "lagoon-ladle", name: "Lagoon Ladle — Venice", tags: ["Gelato", "Seasonal", "Catering"], last: "7 days ago", strength: "strong", website: "lagoon-ladle.example.com" },
121 { id: "midnight-milk", name: "Midnight Milk — Tromsø", tags: ["Dairy-free", "Vegan", "Wholesale"], last: "No contact", strength: "none" },
122 { id: "nomad-nougat", name: "Nomad Nougat — Ulaanbaatar", tags: ["Imports", "B2B"], last: "almost 2 years ago", strength: "none", website: "nomad-nougat.example.com" },
123 { id: "olive-snow", name: "Olive Snow — Athens", tags: ["Gelato", "Cafe", "Local"], last: "4 days ago", strength: "strong", website: "olive-snow.example.com" },
124 { id: "pacific-pear", name: "Pacific Pear — Valparaíso", tags: ["Sorbet", "Seasonal"], last: "2 months ago", strength: "weak", website: "pacific-pear.example.com" },
125 { id: "quartz-cone", name: "Quartz Cone — Denver", tags: ["B2C", "Wholesale"], last: "10 days ago", strength: "strong", website: "quartz-cone.example.com" },
126 { id: "red-lantern", name: "Red Lantern Creamery — Taipei", tags: ["Cafe", "Vegan"], last: "about 1 month ago", strength: "weak", website: "red-lantern.example.com" },
127 { id: "salt-silk", name: "Salt & Silk — Muscat", tags: ["Imports", "Catering", "Gelato"], last: "8 months ago", strength: "veryweak", website: "salt-and-silk.example.com" },
128 { id: "tropic-churn", name: "Tropic Churn — San Juan", tags: ["Sorbet", "Local", "B2C"], last: "6 days ago", strength: "strong", website: "tropic-churn.example.com" },
129 { id: "umber-cream", name: "Umber Cream — Warsaw", tags: ["B2B", "Wholesale", "Cafe"], last: "5 weeks ago", strength: "weak", website: "umber-cream.example.com" },
130 { id: "vanilla-vale", name: "Vanilla Vale — Antananarivo", tags: ["Imports", "Local"], last: "No contact", strength: "none" },
131 { id: "willow-whip", name: "Willow Whip — Portland", tags: ["Dairy-free", "Vegan", "Cafe"], last: "3 days ago", strength: "strong", website: "willow-whip.example.com" },
132 { id: "zenith-gelato", name: "Zenith Gelato — Auckland", tags: ["Gelato", "Seasonal"], last: "3 weeks ago", strength: "weak", website: "zenith-gelato.example.com" },
133 { id: "apricot-atlas", name: "Apricot Atlas — Algiers", tags: ["Sorbet", "Imports"], last: "11 months ago", strength: "veryweak", website: "apricot-atlas.example.com" },
134 { id: "black-sesame", name: "Black Sesame Social — Bandung", tags: ["Vegan", "Cafe", "B2C"], last: "9 days ago", strength: "strong", website: "black-sesame.example.com" },
135 { id: "crimson-clover", name: "Crimson Clover — Brussels", tags: ["Gelato", "Wholesale", "Catering"], last: "2 months ago", strength: "weak", website: "crimson-clover.example.com" },
136 { id: "dragonfruit-dock", name: "Dragonfruit Dock — Shenzhen", tags: ["Sorbet", "B2B", "Wholesale"], last: "No contact", strength: "none" },
137];
138
139/* the AI column resolves to fictional competitor pairs */
140const AI_LABEL = "Competitors";
141const COMPETITOR_POOL = [
142 "Frost & Ladle",
143 "Polar Pint Co.",
144 "Meltwater Creamery",
145 "Cirrus Scoops",
146 "Golden Churn",
147 "Velvet Freeze",
148 "North Cone Collective",
149 "Sundae Syndicate",
150];
151const competitorsFor = (index: number) => `${COMPETITOR_POOL[index % 8]}, ${COMPETITOR_POOL[(index + 3) % 8]}`;
152
153function Icon({ children, size = 14, strokeWidth = 1.8 }: { children: React.ReactNode; size?: number; strokeWidth?: number }) {
154 return (
155 <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
156 {children}
157 </svg>
158 );
159}
160
161/* glyph library for property types & tools */
162const TYPE_GLYPHS: Record<string, React.ReactNode> = {
163 Text: <path d="M4 6h16M4 12h10M4 18h7" />,
164 File: <g><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></g>,
165 Collection: <g><ellipse cx="12" cy="5" rx="8" ry="3" /><path d="M4 5v14c0 1.66 3.58 3 8 3s8-1.34 8-3V5M4 12c0 1.66 3.58 3 8 3s8-1.34 8-3" /></g>,
166 "Single select": <g><circle cx="12" cy="12" r="9" /><path d="m8.5 12 2.4 2.4 4.6-4.9" /></g>,
167 "Multi select": <g><path d="M11 6h9M11 12h9M11 18h9" /><path d="M4 6l1.5 1.5L8 5M4 12l1.5 1.5L8 11M4 18l1.5 1.5L8 17" /></g>,
168 URL: <g><path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1" /><path d="M14 11a5 5 0 0 0-7.1-.1l-2 2A5 5 0 0 0 12 20l1.1-1.1" /></g>,
169 Reference: <path d="M7 17 17 7M9 7h8v8" />,
170 JSON: <g><path d="M8 4c-2 0-2 2-2 3s.5 3-2 3c2.5 0 2 2 2 3s0 3 2 3" /><path d="M16 4c2 0 2 2 2 3s-.5 3 2 3c-2.5 0-2 2-2 3s0 3-2 3" /></g>,
171 "File splitter": <g><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" /></g>,
172 Date: <g><rect x="3" y="5" width="18" height="16" rx="2.5" /><path d="M8 3v4M16 3v4M3 10h18" /></g>,
173};
174
175const TOOL_GLYPHS: Record<string, React.ReactNode> = {
176 model: <path d="M12 3l1.7 5.1a2 2 0 0 0 1.2 1.2L20 11l-5.1 1.7a2 2 0 0 0-1.2 1.2L12 19l-1.7-5.1a2 2 0 0 0-1.2-1.2L4 11l5.1-1.7a2 2 0 0 0 1.2-1.2z" />,
177 web: <g><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a13.5 13.5 0 0 1 3.5 9 13.5 13.5 0 0 1-3.5 9 13.5 13.5 0 0 1-3.5-9A13.5 13.5 0 0 1 12 3z" /></g>,
178 user: <g><circle cx="12" cy="8" r="4" /><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" /></g>,
179};
180
181/* per-property configuration shown in the popover */
182type Prompt = { before: string; chip?: string; after?: string };
183type ToolKind = "model" | "web" | "user";
184type ColumnMeta = { type: string; tool: string; toolKind: ToolKind; inputs?: string; prompt?: Prompt };
185
186const COLUMN_META: Record<string, ColumnMeta> = {
187 Company: { type: "Text", tool: "User input", toolKind: "user" },
188 Categories: { type: "Multi select", tool: "Sprinkles 5", toolKind: "model", inputs: "Company", prompt: { before: "Tag each ", chip: "Company", after: " with its market categories." } },
189 "Last interaction": { type: "Date", tool: "User input", toolKind: "user" },
190 "Connection strength": { type: "Single select", tool: "Sprinkles 5", toolKind: "model", inputs: "Last interaction", prompt: { before: "Score the relationship from ", chip: "Last interaction", after: "." } },
191 Links: { type: "URL", tool: "Web search", toolKind: "web", inputs: "Company", prompt: { before: "Find the website for ", chip: "Company", after: "." } },
192 [AI_LABEL]: { type: "Text", tool: "Web search", toolKind: "web", inputs: "Company", prompt: { before: "Find competitors for ", chip: "Company" } },
193};
194
195const NEW_PROPERTY_TYPES = ["Text", "File", "Collection", "Single select", "Multi select", "URL", "Reference", "JSON", "File splitter"];
196const MODEL_OPTIONS = ["Sprinkles 5", "Sprinkles 4.2", "Sprinkles Mini"];
197const INPUT_OPTIONS = ["Company", "Categories", "Last interaction", "Connection strength", "Links"];
198
199function Checkbox({ checked, mixed = false, onChange, label }: { checked: boolean; mixed?: boolean; onChange: () => void; label: string }) {
200 return (
201 <label className="records-checkbox" title={label} onClick={(event) => event.stopPropagation()}>
202 <input type="checkbox" checked={checked} onChange={onChange} aria-label={label} />
203 <span className={`records-checkbox-box ${checked || mixed ? "is-active" : ""}`}>
204 {mixed ? <span className="records-checkbox-dash" /> : checked ? <Icon size={12}><path d="m5 12 4 4L19 6" /></Icon> : null}
205 </span>
206 </label>
207 );
208}
209
210function Tag({ name }: { name: string }) {
211 const color = TAG_COLORS[name] ?? { base: "var(--ink-3)" };
212 return (
213 <span
214 className="records-tag"
215 style={{ "--tag-base": color.base } as React.CSSProperties}
216 >
217 {name}
218 </span>
219 );
220}
221
222function TagList({ tags }: { tags: string[] }) {
223 const containerRef = useRef<HTMLDivElement>(null);
224 const measureRef = useRef<HTMLDivElement>(null);
225 const [visibleCount, setVisibleCount] = useState(tags.length);
226
227 useLayoutEffect(() => {
228 const container = containerRef.current;
229 const measure = measureRef.current;
230 if (!container || !measure) return;
231
232 const update = () => {
233 const available = container.clientWidth;
234 const tagWidths = Array.from(measure.querySelectorAll<HTMLElement>("[data-tag-measure]"), (tag) => tag.offsetWidth);
235 const moreWidth = measure.querySelector<HTMLElement>("[data-more-measure]")?.offsetWidth ?? 0;
236 let used = 0;
237 let count = 0;
238
239 for (let index = 0; index < tagWidths.length; index += 1) {
240 const nextUsed = used + (count > 0 ? 4 : 0) + tagWidths[index];
241 const hiddenAfter = tags.length - (index + 1);
242 const totalWithOverflow = nextUsed + (hiddenAfter > 0 ? 4 + moreWidth : 0);
243 if (totalWithOverflow > available) break;
244 used = nextUsed;
245 count += 1;
246 }
247
248 setVisibleCount(count);
249 };
250
251 update();
252 const observer = new ResizeObserver(update);
253 observer.observe(container);
254 return () => observer.disconnect();
255 }, [tags]);
256
257 const hiddenCount = tags.length - visibleCount;
258
259 return (
260 <div ref={containerRef} className="records-tags" title={tags.join(", ")} aria-label={`Categories: ${tags.join(", ")}`}>
261 <div ref={measureRef} className="records-tags-measure" aria-hidden>
262 {tags.map((tag) => <span key={tag} data-tag-measure><Tag name={tag} /></span>)}
263 <span data-more-measure className="records-more-tag">+{tags.length}</span>
264 </div>
265 {tags.slice(0, visibleCount).map((tag) => <Tag key={tag} name={tag} />)}
266 {hiddenCount > 0 && <span className="records-more-tag">+{hiddenCount}</span>}
267 </div>
268 );
269}
270
271function CalcCell() {
272 return (
273 <span className="records-calc">
274 <span className="records-muted">Calculating…</span>
275 <span className="records-pulse" />
276 </span>
277 );
278}
279
280function MiniSwitch({ on, onToggle, label }: { on: boolean; onToggle: () => void; label: string }) {
281 return (
282 <button
283 type="button"
284 role="switch"
285 aria-checked={on}
286 aria-label={label}
287 onClick={onToggle}
288 className="relative h-4.5 w-7.5 shrink-0 rounded-full transition-colors duration-150"
289 style={{ background: on ? "var(--accent)" : "var(--line-strong)" }}
290 >
291 <span
292 className="absolute top-0.5 left-0.5 size-3.5 rounded-full bg-white shadow-btn transition-transform duration-150"
293 style={{ transform: on ? "translateX(12px)" : "translateX(0)", transitionTimingFunction: "cubic-bezier(0.23,1,0.32,1)" }}
294 />
295 </button>
296 );
297}
298
299function HeaderCell({ label, icon, sortKey, sort, onSort, onResizeStart, resizing = false, className = "", selected = false, onPick }: { label: string; icon: React.ReactNode; sortKey?: SortKey; sort: { key: SortKey; dir: 1 | -1 }; onSort: (key: SortKey) => void; onResizeStart: (event: React.PointerEvent<HTMLSpanElement>) => void; resizing?: boolean; className?: string; selected?: boolean; onPick?: (event: React.MouseEvent) => void }) {
300 return (
301 <th className={`records-header-cell ${selected ? "is-colsel" : ""} ${className}`}>
302 {/* header click opens the property config; the arrow sorts */}
303 <button type="button" className="records-header-button" onClick={onPick}>
304 <span className="records-header-icon">{icon}</span>
305 <span className="truncate">{label}</span>
306 {sortKey && (
307 <span
308 role="button"
309 tabIndex={0}
310 aria-label={`Sort by ${label}`}
311 onClick={(event) => {
312 event.stopPropagation();
313 onSort(sortKey);
314 }}
315 onKeyDown={(event) => {
316 if (event.key === "Enter" || event.key === " ") {
317 event.preventDefault();
318 event.stopPropagation();
319 onSort(sortKey);
320 }
321 }}
322 className={`records-sort ${sort.key === sortKey ? "is-visible" : ""}`}
323 style={{ transform: sort.key === sortKey && sort.dir === -1 ? "rotate(180deg)" : undefined }}
324 >
325 <Icon size={12}><path d="M12 5v14M5 12l7 7 7-7" /></Icon>
326 </span>
327 )}
328 </button>
329 <span
330 role="separator"
331 aria-orientation="vertical"
332 aria-label={`Resize ${label} column`}
333 className={`records-resize-handle ${resizing ? "is-resizing" : ""}`}
334 onPointerDown={onResizeStart}
335 />
336 </th>
337 );
338}
339
340/* config row inside the property popover */
341function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) {
342 return (
343 <div className="relative flex h-8 items-center justify-between">
344 <span className="text-[13px] text-ink-3">{label}</span>
345 {children}
346 </div>
347 );
348}
349
350function ConfigPicker({
351 label,
352 options,
353 selected,
354 onSelect,
355}: {
356 label: string;
357 options: { label: string; icon: React.ReactNode }[];
358 selected: string;
359 onSelect: (value: string) => void;
360}) {
361 return (
362 <div
363 role="menu"
364 aria-label={label}
365 className="absolute left-full top-0 z-30 ml-5 w-[210px] rounded-[12px] bg-surface p-1.5 shadow-overlay"
366 style={{ animation: "pop-in 140ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "top left" }}
367 >
368 <div className="px-2 pb-1 pt-0.5 text-[11.5px] font-medium text-ink-3">{label}</div>
369 <GlideMenu className="flex flex-col gap-px">
370 {options.map((option) => (
371 <button
372 key={option.label}
373 data-menu-row
374 type="button"
375 role="menuitemradio"
376 aria-checked={selected === option.label}
377 onClick={() => onSelect(option.label)}
378 className="relative z-10 flex h-8 w-full items-center gap-1.5 rounded-[8px] px-1.5 text-left text-[13px] font-medium text-ink"
379 >
380 <span className="flex size-4 shrink-0 items-center justify-center text-ink-2">{option.icon}</span>
381 <span className="min-w-0 flex-1 truncate">{option.label}</span>
382 <span className={selected === option.label ? "text-ink" : "invisible"}>
383 <Icon size={14} strokeWidth={2.2}><path d="m5 12 4 4L19 6" /></Icon>
384 </span>
385 </button>
386 ))}
387 </GlideMenu>
388 </div>
389 );
390}
391
392function InputPicker({
393 options,
394 selected,
395 onToggle,
396}: {
397 options: string[];
398 selected: string[];
399 onToggle: (value: string) => void;
400}) {
401 return (
402 <div
403 role="menu"
404 aria-label="Calculation inputs"
405 className="absolute left-full top-0 z-30 ml-5 w-[220px] rounded-[12px] bg-surface p-1.5 shadow-overlay"
406 style={{ animation: "pop-in 140ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "top left" }}
407 >
408 <div className="px-2 pb-1 pt-0.5 text-[11.5px] font-medium text-ink-3">Use values from</div>
409 <GlideMenu className="flex flex-col gap-px">
410 {options.map((option) => {
411 const checked = selected.includes(option);
412 return (
413 <button
414 key={option}
415 data-menu-row
416 type="button"
417 role="menuitemcheckbox"
418 aria-checked={checked}
419 onClick={() => onToggle(option)}
420 className="relative z-10 flex h-8 w-full items-center gap-1.5 rounded-[8px] px-1.5 text-left text-[13px] font-medium text-ink"
421 >
422 <span className={`flex size-4 shrink-0 items-center justify-center rounded-[5px] border ${checked ? "border-accent bg-accent text-white" : "border-line-strong text-transparent"}`}>
423 <Icon size={11} strokeWidth={2.4}><path d="m5 12 4 4L19 6" /></Icon>
424 </span>
425 <span className="min-w-0 flex-1 truncate">{option}</span>
426 </button>
427 );
428 })}
429 </GlideMenu>
430 </div>
431 );
432}
433
434export default function RecordsTable({ fill = false }: { fill?: boolean; variant?: string }) {
435 const rows = INITIAL_ROWS;
436 const [selected, setSelected] = useState<Set<string>>(new Set());
437 const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: "name", dir: 1 });
438 const [columnWidths, setColumnWidths] = useState(DEFAULT_COLUMN_WIDTHS);
439 const [actionColumnWidth, setActionColumnWidth] = useState(100);
440 const [columnWidthsLocked, setColumnWidthsLocked] = useState(false);
441 const [resizingColumn, setResizingColumn] = useState<ColumnKey | null>(null);
442 const initialColumnWidthsRef = useRef<Record<ColumnKey, number> | null>(null);
443 const tableRef = useRef<HTMLTableElement>(null);
444
445 /* property popover, anchored to the clicked header */
446 const [prop, setProp] = useState<{ col: string; x: number; y: number } | null>(null);
447 const [grounding, setGrounding] = useState(false);
448 const [groundingHelpOpen, setGroundingHelpOpen] = useState(false);
449 const [configMenu, setConfigMenu] = useState<"type" | "tool" | "inputs" | null>(null);
450 const [columnOverrides, setColumnOverrides] = useState<Record<string, Partial<ColumnMeta>>>({});
451 const [inputSelections, setInputSelections] = useState<Record<string, string[]>>({});
452 const [pinnedColumns, setPinnedColumns] = useState<Set<string>>(new Set());
453 const [moreSettingsOpen, setMoreSettingsOpen] = useState(false);
454 const [advancedSettings, setAdvancedSettings] = useState({ required: false, allowEmpty: true, confidence: false });
455 /* + new-property menu */
456 const [addOpen, setAddOpen] = useState<{ x: number; y: number } | null>(null);
457 const [tableMenuOpen, setTableMenuOpen] = useState<{ x: number; y: number } | null>(null);
458 /* the added AI column and its lifecycle */
459 const [aiAdded, setAiAdded] = useState(false);
460 const [aiDone, setAiDone] = useState(false);
461 const [pendingOpenAi, setPendingOpenAi] = useState(false);
462 const aiThRef = useRef<HTMLTableCellElement>(null);
463 /* programmatic scrolls (revealing the new column) shouldn't close popovers */
464 const ignoreScrollRef = useRef(false);
465 /* a running calculation resolves rows one by one */
466 const [calc, setCalc] = useState<{ col: string; resolved: number } | null>(null);
467
468 /* Let the table fill its available space once, then capture those rendered
469 * widths before paint. From that point on every column is explicit, so a
470 * resize changes only the dragged column and the table's total width. */
471 useLayoutEffect(() => {
472 if (columnWidthsLocked || !tableRef.current) return;
473 const headers = Array.from(tableRef.current.querySelectorAll<HTMLTableCellElement>("thead th"));
474 if (headers.length < 6) return;
475
476 const measured: Record<ColumnKey, number> = {
477 company: headers[0].getBoundingClientRect().width,
478 categories: headers[1].getBoundingClientRect().width,
479 last: headers[2].getBoundingClientRect().width,
480 strength: headers[3].getBoundingClientRect().width,
481 links: headers[4].getBoundingClientRect().width,
482 ai: DEFAULT_COLUMN_WIDTHS.ai,
483 };
484 initialColumnWidthsRef.current = measured;
485 setColumnWidths(measured);
486 setActionColumnWidth(headers[headers.length - 1].getBoundingClientRect().width);
487 setColumnWidthsLocked(true);
488 }, [columnWidthsLocked]);
489
490 const visibleRows = useMemo(() => {
491 return [...rows].sort((a, b) => {
492 const value = sort.key === "name"
493 ? a.name.localeCompare(b.name)
494 : sort.key === "last"
495 ? a.last.localeCompare(b.last)
496 : STRENGTH[a.strength].rank - STRENGTH[b.strength].rank;
497 return value * sort.dir;
498 });
499 }, [sort]);
500
501 /* stagger: one row resolves every beat */
502 useEffect(() => {
503 if (!calc) return;
504 if (calc.resolved > visibleRows.length) {
505 if (calc.col === AI_LABEL) setAiDone(true);
506 setCalc(null);
507 return;
508 }
509 const t = setTimeout(() => setCalc((current) => (current ? { ...current, resolved: current.resolved + 1 } : current)), 110);
510 return () => clearTimeout(t);
511 }, [calc, visibleRows.length]);
512
513 /* after adding the AI column, scroll it into view and open its config
514 * anchored to the new header */
515 useEffect(() => {
516 if (!pendingOpenAi || !aiThRef.current) return;
517 const scroller = aiThRef.current.closest(".records-scroll");
518 if (scroller) {
519 ignoreScrollRef.current = true;
520 scroller.scrollLeft = scroller.scrollWidth;
521 }
522 const rect = aiThRef.current.getBoundingClientRect();
523 setProp({ col: AI_LABEL, x: Math.min(rect.left, window.innerWidth - 336), y: rect.bottom + 6 });
524 setPendingOpenAi(false);
525 }, [pendingOpenAi, aiAdded]);
526
527 /* click anywhere else closes popovers */
528 useEffect(() => {
529 if (!prop && !addOpen && !tableMenuOpen) return;
530 const close = (event: PointerEvent) => {
531 if (!(event.target as Element).closest("[data-recpop]")) {
532 setProp(null);
533 setConfigMenu(null);
534 setGroundingHelpOpen(false);
535 setMoreSettingsOpen(false);
536 setAddOpen(null);
537 setTableMenuOpen(null);
538 }
539 };
540 document.addEventListener("pointerdown", close);
541 return () => document.removeEventListener("pointerdown", close);
542 }, [prop, addOpen, tableMenuOpen]);
543
544 const openProp = (col: string) => (event: React.MouseEvent) => {
545 const th = (event.currentTarget as Element).closest("th");
546 if (!th) return;
547 setAddOpen(null);
548 setTableMenuOpen(null);
549 setConfigMenu(null);
550 setGroundingHelpOpen(false);
551 setMoreSettingsOpen(false);
552 setProp((current) => {
553 if (current?.col === col) return null;
554 const rect = th.getBoundingClientRect();
555 return { col, x: Math.min(rect.left, window.innerWidth - 336), y: rect.bottom + 6 };
556 });
557 };
558
559 const isCalc = (col: string, index: number) => !!calc && calc.col === col && index >= calc.resolved;
560
561 const allSelected = visibleRows.length > 0 && visibleRows.every((row) => selected.has(row.id));
562 const partiallySelected = !allSelected && visibleRows.some((row) => selected.has(row.id));
563
564 const toggleSort = (key: SortKey) => setSort((current) => current.key === key ? { key, dir: (current.dir * -1) as 1 | -1 } : { key, dir: 1 });
565 const startColumnResize = (key: ColumnKey, minWidth = 120) => (event: React.PointerEvent<HTMLSpanElement>) => {
566 event.preventDefault();
567 event.stopPropagation();
568 setProp(null);
569 setConfigMenu(null);
570 setGroundingHelpOpen(false);
571 setMoreSettingsOpen(false);
572 setAddOpen(null);
573 setTableMenuOpen(null);
574
575 const startX = event.clientX;
576 const startWidth = columnWidths[key];
577 const previousCursor = document.body.style.cursor;
578 const previousSelection = document.body.style.userSelect;
579 document.body.style.cursor = "col-resize";
580 document.body.style.userSelect = "none";
581 setResizingColumn(key);
582
583 const move = (moveEvent: PointerEvent) => {
584 const width = Math.max(minWidth, startWidth + moveEvent.clientX - startX);
585 setColumnWidths((current) => ({ ...current, [key]: width }));
586 };
587 const finish = () => {
588 window.removeEventListener("pointermove", move);
589 window.removeEventListener("pointerup", finish);
590 window.removeEventListener("pointercancel", finish);
591 document.body.style.cursor = previousCursor;
592 document.body.style.userSelect = previousSelection;
593 setResizingColumn(null);
594 };
595
596 window.addEventListener("pointermove", move);
597 window.addEventListener("pointerup", finish);
598 window.addEventListener("pointercancel", finish);
599 };
600 const toggleRow = (id: string) => setSelected((current) => {
601 const next = new Set(current);
602 next.has(id) ? next.delete(id) : next.add(id);
603 return next;
604 });
605 const toggleAll = () => setSelected((current) => {
606 const next = new Set(current);
607 if (allSelected) visibleRows.forEach((row) => next.delete(row.id));
608 else visibleRows.forEach((row) => next.add(row.id));
609 return next;
610 });
611
612 const meta = prop ? { ...COLUMN_META[prop.col], ...columnOverrides[prop.col] } : null;
613 const selectedInputs = prop && meta
614 ? inputSelections[prop.col] ?? (meta.inputs ? [meta.inputs] : [])
615 : [];
616 const tableWidth = columnWidths.company + columnWidths.categories + columnWidths.last + columnWidths.strength + columnWidths.links + (aiAdded ? columnWidths.ai : 0) + actionColumnWidth;
617
618 return (
619 <div className={`records-shell${fill ? " is-fill" : ""}`}>
620 <div
621 className="records-scroll"
622 tabIndex={0}
623 aria-label="Companies table. Scroll horizontally and vertically to view all columns and records."
624 onScroll={() => {
625 if (ignoreScrollRef.current) {
626 ignoreScrollRef.current = false;
627 return;
628 }
629 setProp(null);
630 setConfigMenu(null);
631 setGroundingHelpOpen(false);
632 setMoreSettingsOpen(false);
633 setAddOpen(null);
634 setTableMenuOpen(null);
635 }}
636 >
637 <table ref={tableRef} className="records-table" style={{ width: columnWidthsLocked ? tableWidth : "100%", minWidth: tableWidth }}>
638 <colgroup>
639 <col className="records-company-col" style={{ width: columnWidths.company }} />
640 <col className="records-category-col" style={{ width: columnWidths.categories }} />
641 <col className="records-last-col" style={{ width: columnWidths.last }} />
642 <col className="records-strength-col" style={{ width: columnWidths.strength }} />
643 <col className="records-link-col" style={{ width: columnWidths.links }} />
644 {aiAdded && <col style={{ width: columnWidths.ai }} />}
645 <col style={{ width: 100 }} />
646 </colgroup>
647 <thead>
648 <tr>
649 <th className={`records-header-cell records-sticky-cell ${prop?.col === "Company" ? "is-colsel" : ""}`}>
650 <div className="records-company-header" style={{ cursor: "pointer" }} onClick={(event) => openProp("Company")(event)}>
651 <Checkbox checked={allSelected} mixed={partiallySelected} onChange={toggleAll} label="Select all companies" />
652 <span>Company</span>
653 </div>
654 <span role="separator" aria-orientation="vertical" aria-label="Resize Company column" className={`records-resize-handle ${resizingColumn === "company" ? "is-resizing" : ""}`} onPointerDown={startColumnResize("company", 180)} />
655 </th>
656 <HeaderCell label="Categories" selected={prop?.col === "Categories"} onPick={openProp("Categories")} sort={sort} onSort={toggleSort} onResizeStart={startColumnResize("categories")} resizing={resizingColumn === "categories"} icon={<Icon size={15}>{TYPE_GLYPHS["Multi select"]}</Icon>} />
657 <HeaderCell label="Last interaction" selected={prop?.col === "Last interaction"} onPick={openProp("Last interaction")} sortKey="last" sort={sort} onSort={toggleSort} onResizeStart={startColumnResize("last")} resizing={resizingColumn === "last"} icon={<Icon size={15}>{TYPE_GLYPHS.Date}</Icon>} />
658 <HeaderCell label="Connection strength" selected={prop?.col === "Connection strength"} onPick={openProp("Connection strength")} sortKey="strength" sort={sort} onSort={toggleSort} onResizeStart={startColumnResize("strength")} resizing={resizingColumn === "strength"} icon={<Icon size={15}>{TYPE_GLYPHS["Single select"]}</Icon>} />
659 <HeaderCell label="Links" selected={prop?.col === "Links"} onPick={openProp("Links")} sort={sort} onSort={toggleSort} onResizeStart={startColumnResize("links")} resizing={resizingColumn === "links"} icon={<Icon size={15}>{TYPE_GLYPHS.URL}</Icon>} />
660 {aiAdded && (
661 <th ref={aiThRef} className={`records-header-cell ${prop?.col === AI_LABEL ? "is-colsel" : ""}`}>
662 <button type="button" className="records-header-button" onClick={openProp(AI_LABEL)}>
663 <span className="records-header-icon"><Icon size={15}>{TYPE_GLYPHS.Text}</Icon></span>
664 <span className="truncate">{AI_LABEL}</span>
665 </button>
666 <span role="separator" aria-orientation="vertical" aria-label={`Resize ${AI_LABEL} column`} className={`records-resize-handle ${resizingColumn === "ai" ? "is-resizing" : ""}`} onPointerDown={startColumnResize("ai")} />
667 </th>
668 )}
669 <th className="records-header-cell">
670 <div className="flex h-[35px] items-center gap-1 px-2">
671 <button
672 type="button"
673 aria-label="New property"
674 data-recpop
675 onClick={(event) => {
676 setProp(null);
677 setTableMenuOpen(null);
678 const rect = (event.currentTarget as Element).getBoundingClientRect();
679 setAddOpen((current) => (current ? null : { x: Math.min(rect.left, window.innerWidth - 276), y: rect.bottom + 6 }));
680 }}
681 className="flex size-7 items-center justify-center rounded-[7px] text-ink-2 transition-colors duration-100 hover:bg-hover hover:text-ink"
682 >
683 <Icon size={15} strokeWidth={2}><path d="M12 5v14M5 12h14" /></Icon>
684 </button>
685 <button
686 type="button"
687 aria-label="Table options"
688 aria-expanded={!!tableMenuOpen}
689 data-recpop
690 onClick={(event) => {
691 setProp(null);
692 setAddOpen(null);
693 const rect = event.currentTarget.getBoundingClientRect();
694 setTableMenuOpen((current) => current ? null : {
695 x: Math.max(8, Math.min(rect.right - 220, window.innerWidth - 228)),
696 y: rect.bottom + 6,
697 });
698 }}
699 className="flex size-7 items-center justify-center rounded-[7px] text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
700 >
701 <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden><circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" /></svg>
702 </button>
703 </div>
704 </th>
705 </tr>
706 </thead>
707 {/* data cells stay silent — the papery link/flick sound is too much when scanning rows */}
708 <tbody data-cuelume-silent>
709 {visibleRows.map((row, index) => {
710 const selectedRow = selected.has(row.id);
711 const strength = STRENGTH[row.strength];
712 return <tr key={row.id} className={`records-row ${selectedRow ? "is-selected" : ""}`}>
713 <td className={`records-cell records-sticky-cell records-company-cell ${prop?.col === "Company" ? "is-colsel" : ""}`}><span className="records-rownum">{index + 1}</span><Checkbox checked={selectedRow} onChange={() => toggleRow(row.id)} label={`Select ${row.name}`} /><span className="records-company-mark">{row.name.slice(0, 1).toUpperCase()}</span><a href={row.website ? `https://${row.website}` : "#"} onClick={(event) => !row.website && event.preventDefault()} title={row.name} className={`records-company-name ${row.website ? "has-link" : ""}`}>{row.name}</a></td>
714 <td className={`records-cell ${prop?.col === "Categories" ? "is-colsel" : ""}`}>{isCalc("Categories", index) ? <CalcCell /> : <TagList tags={row.tags} />}</td>
715 <td className={`records-cell ${row.last === "No contact" ? "records-muted" : ""} ${prop?.col === "Last interaction" ? "is-colsel" : ""}`}>{isCalc("Last interaction", index) ? <CalcCell /> : row.last}</td>
716 <td className={`records-cell ${prop?.col === "Connection strength" ? "is-colsel" : ""}`}>{isCalc("Connection strength", index) ? <CalcCell /> : <span className="records-strength"><span className="records-strength-dot" style={{ background: strength.color }} />{strength.label}</span>}</td>
717 <td className={`records-cell ${prop?.col === "Links" ? "is-colsel" : ""}`}>{isCalc("Links", index) ? <CalcCell /> : row.website ? <a className="records-link" href={`https://${row.website}`} title={row.website} target="_blank" rel="noreferrer"><span className="records-link-label">{row.website}</span><Icon size={12}><path d="M14 5h5v5M19 5l-8 8" /></Icon></a> : <span className="records-muted">—</span>}</td>
718 {aiAdded && (
719 <td className={`records-cell ${prop?.col === AI_LABEL ? "is-colsel" : ""}`}>
720 {calc?.col === AI_LABEL ? (index < calc.resolved ? competitorsFor(index) : <CalcCell />) : aiDone ? competitorsFor(index) : <span className="records-muted">—</span>}
721 </td>
722 )}
723 <td className="records-cell" />
724 </tr>;
725 })}
726 </tbody>
727 <tfoot>
728 <tr className="records-calculation-row">
729 <td className="records-cell records-sticky-cell">
730 <span className="records-footer-value records-calculation-label"><span className="records-calculation-number">{rows.length}</span> count</span>
731 </td>
732 <td className="records-cell">
733 <button type="button" className="records-add-calculation"><Icon size={15}><path d="M12 5v14M5 12h14" /></Icon>Add calculation</button>
734 </td>
735 <td className="records-cell records-muted"><span className="records-footer-value">—</span></td>
736 <td className="records-cell">
737 <span className="records-footer-value records-average"><span className="records-strength-dot" style={{ background: "var(--orange)" }} />{Math.round(rows.reduce((sum, row) => sum + STRENGTH[row.strength].rank, 0) / rows.length / 3 * 100)}% average</span>
738 </td>
739 <td className="records-cell"><span className="records-footer-value records-muted">{rows.filter((row) => row.website).length} links</span></td>
740 {aiAdded && <td className="records-cell records-muted"><span className="records-footer-value">{aiDone ? `${rows.length} filled` : "—"}</span></td>}
741 <td className="records-cell" />
742 </tr>
743 </tfoot>
744 </table>
745 </div>
746
747 {/* ── property configuration popover ─────────────────── */}
748 {prop && meta && (
749 <div
750 data-recpop
751 className="fixed z-50 w-[320px] rounded-[14px] bg-surface px-3 pt-3 pb-1.5 shadow-overlay"
752 style={{ top: prop.y, left: prop.x, animation: "pop-in 160ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "top left" }}
753 >
754 <div className="pb-2 text-[13.5px] font-medium text-ink">{prop.col}</div>
755
756 <ConfigRow label="Type">
757 <button
758 type="button"
759 aria-haspopup="menu"
760 aria-expanded={configMenu === "type"}
761 onClick={() => setConfigMenu((current) => current === "type" ? null : "type")}
762 className="flex items-center gap-1.5 rounded-[6px] px-1.5 py-1 text-[13px] font-medium text-ink transition-colors duration-100 hover:bg-hover"
763 >
764 <span className="text-ink-2"><Icon size={14}>{TYPE_GLYPHS[meta.type] ?? TYPE_GLYPHS.Text}</Icon></span>
765 {meta.type}
766 <span className="text-ink-3"><Icon size={12} strokeWidth={2.2}><path d="M9 6l6 6-6 6" /></Icon></span>
767 </button>
768 {configMenu === "type" && (
769 <ConfigPicker
770 label="Property type"
771 selected={meta.type}
772 options={NEW_PROPERTY_TYPES.map((type) => ({ label: type, icon: <Icon size={15}>{TYPE_GLYPHS[type]}</Icon> }))}
773 onSelect={(type) => {
774 setColumnOverrides((current) => ({ ...current, [prop.col]: { ...current[prop.col], type } }));
775 setConfigMenu(null);
776 }}
777 />
778 )}
779 </ConfigRow>
780 <ConfigRow label="Tool">
781 <button
782 type="button"
783 aria-haspopup="menu"
784 aria-expanded={configMenu === "tool"}
785 onClick={() => setConfigMenu((current) => current === "tool" ? null : "tool")}
786 className="flex items-center gap-1.5 rounded-[6px] px-1.5 py-1 text-[13px] font-medium text-ink transition-colors duration-100 hover:bg-hover"
787 >
788 <span className={meta.toolKind === "model" ? "text-accent" : "text-ink-2"}>
789 {meta.toolKind === "model"
790 ? <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>{TOOL_GLYPHS.model}</svg>
791 : <Icon size={14}>{TOOL_GLYPHS[meta.toolKind]}</Icon>}
792 </span>
793 {meta.tool}
794 <span className="text-ink-3"><Icon size={12} strokeWidth={2.2}><path d="M9 6l6 6-6 6" /></Icon></span>
795 </button>
796 {configMenu === "tool" && (
797 <ConfigPicker
798 label="Model"
799 selected={meta.tool}
800 options={MODEL_OPTIONS.map((model) => ({
801 label: model,
802 icon: <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>{TOOL_GLYPHS.model}</svg>,
803 }))}
804 onSelect={(tool) => {
805 setColumnOverrides((current) => ({ ...current, [prop.col]: { ...current[prop.col], tool, toolKind: "model" } }));
806 setConfigMenu(null);
807 }}
808 />
809 )}
810 </ConfigRow>
811 <ConfigRow label="Grounding">
812 <span className="flex items-center gap-2">
813 <MiniSwitch label="Grounding" on={grounding} onToggle={() => setGrounding((current) => !current)} />
814 <button
815 type="button"
816 aria-label="About grounding"
817 aria-expanded={groundingHelpOpen}
818 onClick={() => setGroundingHelpOpen((open) => !open)}
819 className="flex size-6 items-center justify-center rounded-[6px] text-ink-3 transition-colors duration-100 hover:bg-hover hover:text-ink"
820 >
821 <Icon size={13}><g><circle cx="12" cy="12" r="9" /><path d="M12 8h.01M11 12h1v4h1" /></g></Icon>
822 </button>
823 </span>
824 {groundingHelpOpen && (
825 <div className="absolute right-0 top-[30px] z-30 w-[230px] rounded-[10px] px-3 py-2.5 text-[12px] leading-relaxed shadow-overlay" style={{ color: "var(--tooltip-fg)", background: "var(--tooltip-bg)" }} role="status">
826 Grounding lets the model verify generated values against connected sources.
827 </div>
828 )}
829 </ConfigRow>
830 <ConfigRow label="Inputs">
831 <button
832 type="button"
833 aria-haspopup="menu"
834 aria-expanded={configMenu === "inputs"}
835 onClick={() => setConfigMenu((current) => current === "inputs" ? null : "inputs")}
836 className="flex max-w-[220px] items-center gap-1.5 rounded-[6px] px-1.5 py-1 text-[13px] text-ink-2 transition-colors duration-100 hover:bg-hover hover:text-ink"
837 >
838 {selectedInputs.length ? (
839 <span className="flex min-w-0 items-center gap-1">
840 {selectedInputs.slice(0, 2).map((input) => (
841 <span key={input} className="max-w-[92px] truncate rounded-[5px] bg-accent-tint px-1.5 py-0.5 text-[12px] font-medium text-accent-ink">{input}</span>
842 ))}
843 {selectedInputs.length > 2 && <span className="text-[11px] font-medium text-ink-3">+{selectedInputs.length - 2}</span>}
844 </span>
845 ) : (
846 <span>Select inputs</span>
847 )}
848 <span className="shrink-0 text-ink-3"><Icon size={12} strokeWidth={2.2}><path d="M9 6l6 6-6 6" /></Icon></span>
849 </button>
850 {configMenu === "inputs" && (
851 <InputPicker
852 selected={selectedInputs}
853 options={INPUT_OPTIONS.filter((input) => input !== prop.col)}
854 onToggle={(input) => {
855 setInputSelections((current) => {
856 const existing = current[prop.col] ?? (meta.inputs ? [meta.inputs] : []);
857 const next = existing.includes(input) ? existing.filter((item) => item !== input) : [...existing, input];
858 return { ...current, [prop.col]: next };
859 });
860 }}
861 />
862 )}
863 </ConfigRow>
864
865 {/* prompt — @-mention chips inline */}
866 <div
867 contentEditable
868 suppressContentEditableWarning
869 role="textbox"
870 aria-label={`${prop.col} calculation prompt`}
871 aria-multiline="true"
872 spellCheck
873 className="mt-2 min-h-[88px] cursor-text rounded-[10px] bg-inset p-3 text-[13px] leading-relaxed shadow-hairline outline-none transition-[box-shadow] duration-150 focus:shadow-[0_0_0_2px_var(--accent)]"
874 >
875 {meta.prompt ? (
876 <span className="text-ink">
877 {meta.prompt.before}
878 {meta.prompt.chip && <span contentEditable={false} className="rounded-[5px] bg-accent-tint px-1.5 py-0.5 text-[12px] font-medium text-accent-ink">{meta.prompt.chip}</span>}
879 {meta.prompt.after}
880 </span>
881 ) : (
882 <span className="text-ink-3">Set a prompt (press @ to mention an input)</span>
883 )}
884 </div>
885
886 <button
887 type="button"
888 disabled={!!calc}
889 onClick={() => {
890 setCalc({ col: prop.col, resolved: 0 });
891 setProp(null);
892 }}
893 className="mt-2.5 flex h-9 w-full items-center justify-center gap-2 rounded-[9px] text-[12.5px] font-medium text-ink shadow-btn transition-[background-color,transform] duration-150 hover:bg-hover active:scale-[0.98] disabled:opacity-60"
894 >
895 <Icon size={14} strokeWidth={1.9}><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" /></Icon>
896 Go calculate
897 </button>
898
899 <GlideMenu className="mt-3 flex flex-col gap-0.5 border-t border-line pt-2" highlightClassName="-inset-x-1.5 rounded-[8px] bg-hover">
900 <button
901 data-menu-row
902 type="button"
903 aria-pressed={pinnedColumns.has(prop.col)}
904 onClick={() => setPinnedColumns((current) => {
905 const next = new Set(current);
906 next.has(prop.col) ? next.delete(prop.col) : next.add(prop.col);
907 return next;
908 })}
909 className="relative z-10 -mx-1.5 flex h-8 items-center gap-2.5 rounded-[8px] px-1.5 text-left text-[13px] leading-none text-ink transition-transform duration-150 active:scale-[0.96]"
910 >
911 <span className={pinnedColumns.has(prop.col) ? "text-accent" : "text-ink-2"}><Icon size={15}><path d="M12 17v5M8 3h8l-1 7 3 3H6l3-3-1-7z" /></Icon></span>
912 {pinnedColumns.has(prop.col) ? "Unpin" : "Pin"}
913 </button>
914 <button
915 data-menu-row
916 type="button"
917 aria-expanded={moreSettingsOpen}
918 onClick={() => setMoreSettingsOpen((open) => !open)}
919 className="relative z-10 -mx-1.5 flex h-8 items-center gap-2.5 rounded-[8px] px-1.5 text-left text-[13px] leading-none text-ink transition-transform duration-150 active:scale-[0.96]"
920 >
921 <span className={moreSettingsOpen ? "text-ink" : "text-ink-2"}><Icon size={15}><g><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1 7 17M17 7l2.1-2.1" /></g></Icon></span>
922 <span className="flex-1">More settings</span>
923 <span className={`text-ink-3 transition-transform duration-150 ${moreSettingsOpen ? "rotate-90" : ""}`}><Icon size={12} strokeWidth={2.2}><path d="M9 6l6 6-6 6" /></Icon></span>
924 </button>
925 {prop.col === AI_LABEL && (
926 <button
927 data-menu-row
928 type="button"
929 onClick={() => {
930 setAiAdded(false);
931 setAiDone(false);
932 setProp(null);
933 }}
934 className="relative z-10 -mx-1.5 flex h-8 items-center gap-2.5 rounded-[8px] px-1.5 text-left text-[13px] leading-none text-ink transition-transform duration-150 active:scale-[0.96]"
935 >
936 <span className="text-ink-2"><Icon size={15}><g><path d="M10.6 5.1A9.8 9.8 0 0 1 12 5c7 0 10 7 10 7a16.3 16.3 0 0 1-2.1 3M6.6 6.6A16 16 0 0 0 2 12s3 7 10 7a9.7 9.7 0 0 0 5.4-1.6M3 3l18 18" /><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" /></g></Icon></span>
937 Hide from view
938 </button>
939 )}
940 </GlideMenu>
941
942 {moreSettingsOpen && (
943 <div className="mt-2 border-t border-line pt-2" style={{ animation: "fade-up 160ms cubic-bezier(0.23,1,0.32,1) both" }}>
944 <div className="pb-1 text-[11.5px] font-medium text-ink-3">Behavior</div>
945 <ConfigRow label="Required value">
946 <MiniSwitch label="Required value" on={advancedSettings.required} onToggle={() => setAdvancedSettings((current) => ({ ...current, required: !current.required }))} />
947 </ConfigRow>
948 <ConfigRow label="Allow empty results">
949 <MiniSwitch label="Allow empty results" on={advancedSettings.allowEmpty} onToggle={() => setAdvancedSettings((current) => ({ ...current, allowEmpty: !current.allowEmpty }))} />
950 </ConfigRow>
951 <ConfigRow label="Show confidence">
952 <MiniSwitch label="Show confidence" on={advancedSettings.confidence} onToggle={() => setAdvancedSettings((current) => ({ ...current, confidence: !current.confidence }))} />
953 </ConfigRow>
954 </div>
955 )}
956 </div>
957 )}
958
959 {/* ── new property type menu ─────────────────────────── */}
960 {addOpen && (
961 <div
962 data-recpop
963 className="fixed z-50 w-[260px] rounded-[14px] bg-surface p-1.5 shadow-overlay"
964 style={{ top: addOpen.y, left: addOpen.x, animation: "pop-in 160ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "top left" }}
965 >
966 <div className="px-2 pb-1 pt-1 text-[12px] font-medium text-ink-3">New property</div>
967 <GlideMenu className="flex flex-col gap-px">
968 {NEW_PROPERTY_TYPES.map((type) => (
969 <button
970 key={type}
971 data-menu-row
972 type="button"
973 onClick={() => {
974 setAddOpen(null);
975 setAiDone(false);
976 setAiAdded(true);
977 setPendingOpenAi(true);
978 }}
979 className="relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[8px] px-2 text-left text-[13px] text-ink"
980 >
981 <span className="text-ink-2"><Icon size={15}>{TYPE_GLYPHS[type]}</Icon></span>
982 {type}
983 </button>
984 ))}
985 </GlideMenu>
986 </div>
987 )}
988
989 {/* ── table options menu ─────────────────────────────── */}
990 {tableMenuOpen && (
991 <div
992 data-recpop
993 className="fixed z-50 w-[220px] rounded-[14px] bg-surface p-1.5 shadow-overlay"
994 style={{ top: tableMenuOpen.y, left: tableMenuOpen.x, animation: "pop-in 160ms cubic-bezier(0.23,1,0.32,1) both", transformOrigin: "top right" }}
995 >
996 <div className="px-2 pb-1 pt-1 text-[12px] font-medium text-ink-3">Table options</div>
997 <GlideMenu className="flex flex-col gap-px">
998 <button
999 data-menu-row
1000 type="button"
1001 onClick={() => {
1002 const position = tableMenuOpen;
1003 setTableMenuOpen(null);
1004 setAddOpen({ x: Math.min(position.x, window.innerWidth - 276), y: position.y });
1005 }}
1006 className="relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[8px] px-2 text-left text-[13px] text-ink"
1007 >
1008 <span className="text-ink-2"><Icon size={15} strokeWidth={2}><path d="M12 5v14M5 12h14" /></Icon></span>
1009 Add property
1010 </button>
1011 <button
1012 data-menu-row
1013 type="button"
1014 onClick={() => {
1015 setColumnWidths({ company: 220, categories: 220, last: 155, strength: 180, links: 160, ai: 200 });
1016 setTableMenuOpen(null);
1017 }}
1018 className="relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[8px] px-2 text-left text-[13px] text-ink"
1019 >
1020 <span className="text-ink-2"><Icon size={15}><path d="M4 8h16M7 4 3 8l4 4M17 4l4 4-4 4M4 16h16" /></Icon></span>
1021 Compact columns
1022 </button>
1023 <button
1024 data-menu-row
1025 type="button"
1026 onClick={() => {
1027 setColumnWidths({ ...(initialColumnWidthsRef.current ?? DEFAULT_COLUMN_WIDTHS) });
1028 setTableMenuOpen(null);
1029 }}
1030 className="relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[8px] px-2 text-left text-[13px] text-ink"
1031 >
1032 <span className="text-ink-2"><Icon size={15}><path d="M3 12a9 9 0 1 0 3-6.7M3 4v6h6" /></Icon></span>
1033 Reset column widths
1034 </button>
1035 <div className="my-1 h-px bg-line" />
1036 <button
1037 data-menu-row
1038 type="button"
1039 onClick={() => {
1040 setSelected(new Set());
1041 setTableMenuOpen(null);
1042 }}
1043 className="relative z-10 flex h-9 w-full items-center gap-2.5 rounded-[8px] px-2 text-left text-[13px] text-ink"
1044 >
1045 <span className="text-ink-2"><Icon size={15}><path d="M5 5l14 14M19 5 5 19" /></Icon></span>
1046 Clear selection
1047 </button>
1048 </GlideMenu>
1049 </div>
1050 )}
1051 </div>
1052 );
1053}
1054

Props & Specification

PropTypeDefaultDescription
variantstring&quot;default&quot;Visual state and layout presentation mode.
classNamestring""Optional Tailwind CSS wrapper class overrides.

Related Components in Data & Tables