Records Table
Data & TablesCRM-style grid with tags, sorting, and relationship status.
Interactive Live Preview
components/ui/records-table.tsxTypeScript · React 19
1"use client";23import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";4import GlideMenu from "@/components/primitives/GlideMenu";56/* ─────────────────────────────────────────────────────────7 * RECORDS TABLE — an AI spreadsheet grid. Columns are8 * *properties*: click a header to open its configuration9 * popover (type, tool, grounding, inputs, prompt, run), add10 * a new AI property from the + header, and watch cells11 * resolve row-by-row while it calculates.12 * ───────────────────────────────────────────────────────── */1314type Strength = "strong" | "weak" | "veryweak" | "none";15type SortKey = "name" | "last" | "strength";16type ColumnKey = "company" | "categories" | "last" | "strength" | "links" | "ai";1718const 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};2627const 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};3334// A single mid-lightness base hue per tag. Background, text, and border are35// 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 };3839const 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};5152const 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};6667type Row = {68 id: string;69 name: string;70 tags: string[];71 last: string;72 strength: Strength;73 website?: string;74};7576const 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];138139/* 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]}`;152153function 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}160161/* 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};174175const 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};180181/* 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 };185186const 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};194195const 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"];198199function 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}209210function Tag({ name }: { name: string }) {211 const color = TAG_COLORS[name] ?? { base: "var(--ink-3)" };212 return (213 <span214 className="records-tag"215 style={{ "--tag-base": color.base } as React.CSSProperties}216 >217 {name}218 </span>219 );220}221222function TagList({ tags }: { tags: string[] }) {223 const containerRef = useRef<HTMLDivElement>(null);224 const measureRef = useRef<HTMLDivElement>(null);225 const [visibleCount, setVisibleCount] = useState(tags.length);226227 useLayoutEffect(() => {228 const container = containerRef.current;229 const measure = measureRef.current;230 if (!container || !measure) return;231232 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;238239 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 }247248 setVisibleCount(count);249 };250251 update();252 const observer = new ResizeObserver(update);253 observer.observe(container);254 return () => observer.disconnect();255 }, [tags]);256257 const hiddenCount = tags.length - visibleCount;258259 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}270271function CalcCell() {272 return (273 <span className="records-calc">274 <span className="records-muted">Calculating…</span>275 <span className="records-pulse" />276 </span>277 );278}279280function MiniSwitch({ on, onToggle, label }: { on: boolean; onToggle: () => void; label: string }) {281 return (282 <button283 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 <span292 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}298299function 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 <span308 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 <span330 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}339340/* 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}349350function 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 <div363 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 <button372 key={option.label}373 data-menu-row374 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}391392function InputPicker({393 options,394 selected,395 onToggle,396}: {397 options: string[];398 selected: string[];399 onToggle: (value: string) => void;400}) {401 return (402 <div403 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 <button414 key={option}415 data-menu-row416 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}433434export 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);444445 /* 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);467468 /* Let the table fill its available space once, then capture those rendered469 * widths before paint. From that point on every column is explicit, so a470 * 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;475476 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]);489490 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]);500501 /* 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]);512513 /* after adding the AI column, scroll it into view and open its config514 * 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]);526527 /* 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]);543544 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 };558559 const isCalc = (col: string, index: number) => !!calc && calc.col === col && index >= calc.resolved;560561 const allSelected = visibleRows.length > 0 && visibleRows.every((row) => selected.has(row.id));562 const partiallySelected = !allSelected && visibleRows.some((row) => selected.has(row.id));563564 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);574575 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);582583 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 };595596 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 });611612 const meta = prop ? { ...COLUMN_META[prop.col], ...columnOverrides[prop.col] } : null;613 const selectedInputs = prop && meta614 ? 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;617618 return (619 <div className={`records-shell${fill ? " is-fill" : ""}`}>620 <div621 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 <button672 type="button"673 aria-label="New property"674 data-recpop675 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 <button686 type="button"687 aria-label="Table options"688 aria-expanded={!!tableMenuOpen}689 data-recpop690 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>746747 {/* ── property configuration popover ─────────────────── */}748 {prop && meta && (749 <div750 data-recpop751 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>755756 <ConfigRow label="Type">757 <button758 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 <ConfigPicker770 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 <button782 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 <ConfigPicker798 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 <button815 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 <button832 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 <InputPicker852 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>864865 {/* prompt — @-mention chips inline */}866 <div867 contentEditable868 suppressContentEditableWarning869 role="textbox"870 aria-label={`${prop.col} calculation prompt`}871 aria-multiline="true"872 spellCheck873 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>885886 <button887 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 calculate897 </button>898899 <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 <button901 data-menu-row902 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 <button915 data-menu-row916 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 <button927 data-menu-row928 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 view938 </button>939 )}940 </GlideMenu>941942 {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 )}958959 {/* ── new property type menu ─────────────────────────── */}960 {addOpen && (961 <div962 data-recpop963 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 <button970 key={type}971 data-menu-row972 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 )}988989 {/* ── table options menu ─────────────────────────────── */}990 {tableMenuOpen && (991 <div992 data-recpop993 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 <button999 data-menu-row1000 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 property1010 </button>1011 <button1012 data-menu-row1013 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 columns1022 </button>1023 <button1024 data-menu-row1025 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 widths1034 </button>1035 <div className="my-1 h-px bg-line" />1036 <button1037 data-menu-row1038 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 selection1047 </button>1048 </GlideMenu>1049 </div>1050 )}1051 </div>1052 );1053}1054
Props & Specification
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | "default" | Visual state and layout presentation mode. |
| className | string | "" | Optional Tailwind CSS wrapper class overrides. |
Related Components in Data & Tables
Audit Activity Stream↗
Real-time enterprise agent activity audit trail with JSON payload diff inspector.
Episodic Vector Memory↗
Vector semantic memory recall grid showing episodic memories and decay score.
Benchmark Evaluation Matrix↗
Multi-test LLM evals table testing accuracy, latency, and hallucination rate.