---
/**
* RecordsTable: CRM-style AI spreadsheet. Click a header to configure the
* column and run "Go calculate". The + header adds an AI property.
*
* Ported from Beautiful UI `components/primitives/RecordsTable.tsx`
* (MIT, © 2026 Shane Levine).
*
* - Real <table>; sortable headers expose `aria-sort`.
* - Resize by dragging the header edge or ←/→ on the handle (Shift = bigger steps).
* - Popovers use the top layer (Popover API), with a `position: fixed` fallback.
* - Renders without JS (sorted by name); interaction needs the script.
*
* Events (bubbling, on the root):
* vs-records-selection detail: { ids: string[] }
* vs-records-sort detail: { key: "name" | "last" | "strength", dir: 1 | -1 }
* vs-records-calculate detail: { column: ColumnKey, label: string }
* vs-records-property detail: { type: string }
* vs-records-add-calculation
*
* @example
* <RecordsTable rows={accounts} caption="Partner accounts" aiColumn={{ label: "Competing brands" }} />
*/
import type { HTMLAttributes } from "astro/types";
import GlideMenu from "../glide-menu/GlideMenu.astro";
import Switch from "../switch/Switch.astro";
export type Strength = "strong" | "weak" | "veryweak" | "none";
export type ColumnKey = "company" | "categories" | "last" | "strength" | "links" | "ai";
export interface RecordRow {
id: string;
name: string;
tags: string[];
/** Display text for the last interaction, e.g. "9 days ago" or "No contact". */
last: string;
/** Days since the last interaction, for sorting. Omit (or null) for no contact. */
lastDays?: number | null;
strength: Strength;
/** Bare domain, e.g. "northwind.test". */
website?: string;
/** The value the AI column resolves to for this row. */
ai?: string;
}
export interface RecordsPrompt {
before: string;
/** An @-mentioned input, rendered as a chip. */
chip?: string;
after?: string;
}
export interface ColumnConfig {
label: string;
/** Property type shown in the popover (one of the property types). */
type: string;
/** Tool label, e.g. a model name, "Web search" or "User input". */
tool: string;
toolKind: "model" | "web" | "user";
inputs: string[];
prompt?: RecordsPrompt;
}
export interface AiColumn {
label: string;
type?: string;
tool?: string;
prompt?: RecordsPrompt;
}
type Props = HTMLAttributes<"div"> & {
rows: RecordRow[];
/** Table caption (visually hidden) and scroll-region name. */
caption?: string;
/** Header labels. */
columns?: Partial<Record<Exclude<ColumnKey, "ai">, string>>;
/** Per-column popover configuration overrides. */
columnConfig?: Partial<Record<ColumnKey, Partial<ColumnConfig>>>;
/** Tag → palette name or CSS color. Unlisted tags get a stable color. */
tagColors?: Record<string, string>;
/** Labels for the four strength levels. */
strengthLabels?: Partial<Record<Strength, string>>;
/** Models offered in the Tool picker; the first is the default. */
models?: string[];
/** The AI property the + menu adds. `null` hides the + menu's effect. */
aiColumn?: AiColumn | null;
/** Initial sort. */
sort?: { key: "name" | "last" | "strength"; dir?: 1 | -1 };
/** Stretch to fill a parent card, dropping the border and shadow. */
fill?: boolean;
/** Max height of the scroll viewport (ignored with `fill`). */
maxHeight?: string;
};
const {
rows,
caption = "Records",
columns = {},
columnConfig = {},
tagColors = {},
strengthLabels = {},
models = ["Atlas 5", "Atlas 4.2", "Atlas Mini"],
aiColumn = { label: "Competitors" },
sort = { key: "name", dir: 1 },
fill = false,
maxHeight = "438px",
class: className,
style,
...rest
} = Astro.props;
const uid = `vs-rt-${Math.random().toString(36).slice(2, 8)}`;
/* ── labels ─────────────────────────────────────────────── */
const L = {
company: columns.company ?? "Company",
categories: columns.categories ?? "Categories",
last: columns.last ?? "Last interaction",
strength: columns.strength ?? "Connection strength",
links: columns.links ?? "Links",
ai: aiColumn?.label ?? "Insights",
};
const STRENGTH: Record<Strength, { label: string; color: string; rank: number }> = {
strong: { label: strengthLabels.strong ?? "Very strong", color: "var(--vs-green)", rank: 3 },
weak: { label: strengthLabels.weak ?? "Weak", color: "var(--vs-orange)", rank: 2 },
veryweak: { label: strengthLabels.veryweak ?? "Very weak", color: "var(--vs-red)", rank: 1 },
none: { label: strengthLabels.none ?? "No communication", color: "var(--vs-ink-3)", rank: 0 },
};
/* ── tag colors: one mid-lightness base hue per tag, mixed against the theme ── */
const PALETTE: Record<string, string> = {
amber: "oklch(0.76 0.13 70)",
lime: "oklch(0.77 0.16 122)",
yellow: "oklch(0.80 0.15 101)",
purple: "oklch(0.62 0.18 293)",
orange: "oklch(0.71 0.16 48)",
cyan: "oklch(0.72 0.10 221)",
red: "oklch(0.64 0.19 27)",
magenta: "oklch(0.66 0.21 323)",
green: "oklch(0.70 0.13 162)",
pink: "oklch(0.67 0.19 3)",
};
const paletteKeys = Object.keys(PALETTE);
const tagBase = (tag: string) => {
const chosen = tagColors[tag];
if (chosen) return PALETTE[chosen] ?? chosen;
let h = 0;
for (const ch of tag) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return PALETTE[paletteKeys[h % paletteKeys.length]];
};
/* ── glyphs (upstream-drawn, MIT) ───────────────────────── */
const TYPE_GLYPHS: Record<string, string> = {
Text: '<path d="M4 6h16M4 12h10M4 18h7" />',
File: '<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" />',
Collection: '<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" />',
"Single select": '<circle cx="12" cy="12" r="9" /><path d="m8.5 12 2.4 2.4 4.6-4.9" />',
"Multi select": '<path d="M11 6h9M11 12h9M11 18h9" /><path d="M4 6l1.5 1.5L8 5M4 12l1.5 1.5L8 11M4 18l1.5 1.5L8 17" />',
URL: '<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" />',
Reference: '<path d="M7 17 17 7M9 7h8v8" />',
JSON: '<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" />',
"File splitter": '<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" />',
Date: '<rect x="3" y="5" width="18" height="16" rx="2.5" /><path d="M8 3v4M16 3v4M3 10h18" />',
};
const TOOL_GLYPHS = {
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" />',
web: '<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" />',
user: '<circle cx="12" cy="8" r="4" /><path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" />',
};
const PROPERTY_TYPES = ["Text", "File", "Collection", "Single select", "Multi select", "URL", "Reference", "JSON", "File splitter"];
const svg = (inner: string, size = 14, sw = 1.8) =>
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
const modelSvg = () =>
`<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">${TOOL_GLYPHS.model}</svg>`;
const I = {
check: '<path d="m5 12 4 4L19 6" />',
chevron: '<path d="M9 6l6 6-6 6" />',
arrowDown: '<path d="M12 5v14M5 12l7 7 7-7" />',
plus: '<path d="M12 5v14M5 12h14" />',
external: '<path d="M14 5h5v5M19 5l-8 8" />',
info: '<circle cx="12" cy="12" r="9" /><path d="M12 8h.01M11 12h1v4h1" />',
refresh: '<path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6" />',
pin: '<path d="M12 17v5M8 3h8l-1 7 3 3H6l3-3-1-7z" />',
gear: '<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" />',
eyeOff: '<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" />',
compact: '<path d="M4 8h16M7 4 3 8l4 4M17 4l4 4-4 4M4 16h16" />',
reset: '<path d="M3 12a9 9 0 1 0 3-6.7M3 4v6h6" />',
clear: '<path d="M5 5l14 14M19 5 5 19" />',
};
/* ── per-column popover configuration ───────────────────── */
const model0 = models[0] ?? "Model";
const baseConfig: Record<ColumnKey, ColumnConfig> = {
company: { label: L.company, type: "Text", tool: "User input", toolKind: "user", inputs: [] },
categories: {
label: L.categories, type: "Multi select", tool: model0, toolKind: "model", inputs: [L.company],
prompt: { before: "Tag each ", chip: L.company, after: " with its market categories." },
},
last: { label: L.last, type: "Date", tool: "User input", toolKind: "user", inputs: [] },
strength: {
label: L.strength, type: "Single select", tool: model0, toolKind: "model", inputs: [L.last],
prompt: { before: "Score the relationship from ", chip: L.last, after: "." },
},
links: {
label: L.links, type: "URL", tool: "Web search", toolKind: "web", inputs: [L.company],
prompt: { before: "Find the website for ", chip: L.company, after: "." },
},
ai: {
label: L.ai, type: aiColumn?.type ?? "Text", tool: aiColumn?.tool ?? "Web search", toolKind: "web", inputs: [L.company],
prompt: aiColumn?.prompt ?? { before: `Find ${L.ai.toLowerCase()} for `, chip: L.company },
},
};
const config = Object.fromEntries(
(Object.keys(baseConfig) as ColumnKey[]).map((k) => [k, { ...baseConfig[k], ...columnConfig[k] }]),
) as Record<ColumnKey, ColumnConfig>;
const inputOptions = [L.company, L.categories, L.last, L.strength, L.links];
/* ── initial order (server render matches the script's sort) ── */
const rank = (s: Strength) => STRENGTH[s].rank;
const lastValue = (r: RecordRow) => (r.lastDays === undefined || r.lastDays === null ? Number.POSITIVE_INFINITY : r.lastDays);
const dir = sort.dir ?? 1;
const sorted = [...rows].sort((a, b) => {
const v =
sort.key === "name"
? a.name.localeCompare(b.name)
: sort.key === "last"
? lastValue(a) - lastValue(b) || a.name.localeCompare(b.name)
: rank(a.strength) - rank(b.strength);
return (Number.isNaN(v) ? 0 : v) * dir;
});
const avg = rows.length ? Math.round((rows.reduce((sum, r) => sum + rank(r.strength), 0) / rows.length / 3) * 100) : 0;
const avgColor = avg >= 67 ? "var(--vs-green)" : avg >= 34 ? "var(--vs-orange)" : "var(--vs-red)";
const linkCount = rows.filter((r) => r.website).length;
const ariaSort = (key: string) => (sort.key === key ? (dir === 1 ? "ascending" : "descending") : undefined);
const clientConfig = {
uid,
rows: rows.map((r) => ({ id: r.id, name: r.name, lastDays: lastValue(r), last: r.last, rank: rank(r.strength), ai: r.ai ?? "" })),
sort: { key: sort.key, dir },
columns: config,
typeGlyphs: TYPE_GLYPHS,
toolGlyphs: TOOL_GLYPHS,
aiEnabled: aiColumn !== null,
};
const rootStyle = [fill ? "" : `--rt-max-h: ${maxHeight}`, typeof style === "string" ? style : ""].filter(Boolean).join("; ") || undefined;
const headers: Array<{ key: Exclude<ColumnKey, "company" | "ai">; glyph: string; sortKey?: "last" | "strength" }> = [
{ key: "categories", glyph: TYPE_GLYPHS["Multi select"] },
{ key: "last", glyph: TYPE_GLYPHS.Date, sortKey: "last" },
{ key: "strength", glyph: TYPE_GLYPHS["Single select"], sortKey: "strength" },
{ key: "links", glyph: TYPE_GLYPHS.URL },
];
---
<div class:list={["vs-records-table", { "is-fill": fill }, className]} style={rootStyle} data-uid={uid} {...rest}>
<script type="application/json" data-vs-config set:html={JSON.stringify(clientConfig)} />
<div class="rt-scroll" tabindex="0" role="region" aria-label={`${caption}. Scroll horizontally and vertically to view all columns and records.`}>
<table class="rt-table">
<caption class="rt-sr">{caption}</caption>
<colgroup>
<col data-col="company" style="width: 270px" />
<col data-col="categories" style="width: 275px" />
<col data-col="last" style="width: 190px" />
<col data-col="strength" style="width: 210px" />
<col data-col="links" style="width: 175px" />
<col data-col="actions" style="width: 100px" />
</colgroup>
<thead>
<tr>
<th scope="col" class="rt-th rt-sticky" data-col="company" aria-sort={ariaSort("name")}>
<div class="rt-head rt-head--company">
<label class="rt-checkbox">
<input type="checkbox" data-select-all aria-label={`Select all ${caption.toLowerCase()}`} />
<span class="rt-box" aria-hidden="true"><Fragment set:html={svg(I.check, 12)} /><span class="rt-dash"></span></span>
</label>
<button type="button" class="rt-head-btn" data-prop="company" aria-haspopup="dialog" aria-expanded="false">
<span class="rt-trunc">{L.company}</span>
</button>
<button type="button" class="rt-sort" data-sort="name" aria-label={`Sort by ${L.company}`}>
<Fragment set:html={svg(I.arrowDown, 12)} />
</button>
</div>
<span class="rt-resize" role="separator" tabindex="0" aria-orientation="vertical" aria-label={`Resize ${L.company} column`} data-resize="company" data-min="180"></span>
</th>
{
headers.map((h) => (
<th scope="col" class="rt-th" data-col={h.key} aria-sort={h.sortKey ? ariaSort(h.sortKey) : undefined}>
<div class="rt-head">
<button type="button" class="rt-head-btn" data-prop={h.key} aria-haspopup="dialog" aria-expanded="false">
<span class="rt-head-icon" set:html={svg(h.glyph, 15)} />
<span class="rt-trunc">{L[h.key]}</span>
</button>
{h.sortKey && (
<button type="button" class="rt-sort" data-sort={h.sortKey} aria-label={`Sort by ${L[h.key]}`}>
<Fragment set:html={svg(I.arrowDown, 12)} />
</button>
)}
</div>
<span class="rt-resize" role="separator" tabindex="0" aria-orientation="vertical" aria-label={`Resize ${L[h.key]} column`} data-resize={h.key} data-min="120"></span>
</th>
))
}
<th scope="col" class="rt-th" data-col="ai" hidden>
<div class="rt-head">
<button type="button" class="rt-head-btn" data-prop="ai" aria-haspopup="dialog" aria-expanded="false">
<span class="rt-head-icon" data-ai-icon set:html={svg(TYPE_GLYPHS[config.ai.type] ?? TYPE_GLYPHS.Text, 15)} />
<span class="rt-trunc">{L.ai}</span>
</button>
</div>
<span class="rt-resize" role="separator" tabindex="0" aria-orientation="vertical" aria-label={`Resize ${L.ai} column`} data-resize="ai" data-min="120"></span>
</th>
<th scope="col" class="rt-th" data-col="actions">
<span class="rt-sr">Table actions</span>
<div class="rt-actions">
<button type="button" class="rt-icon-btn" data-open-add aria-label="New property" aria-haspopup="menu" aria-expanded="false" data-recpop>
<Fragment set:html={svg(I.plus, 15, 2)} />
</button>
<button type="button" class="rt-icon-btn rt-icon-btn--quiet" data-open-options aria-label="Table options" aria-haspopup="menu" aria-expanded="false" data-recpop>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><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>
</button>
</div>
</th>
</tr>
</thead>
<tbody>
{
sorted.map((row, index) => {
const s = STRENGTH[row.strength];
return (
<tr class="rt-row" data-id={row.id}>
<td class="rt-cell rt-sticky rt-company" data-col="company">
<div class="rt-company-inner">
<span class="rt-gutter">
<span class="rt-rownum" aria-hidden="true">{index + 1}</span>
<label class="rt-checkbox">
<input type="checkbox" data-select={row.id} aria-label={`Select ${row.name}`} />
<span class="rt-box" aria-hidden="true"><Fragment set:html={svg(I.check, 12)} /><span class="rt-dash"></span></span>
</label>
</span>
<span class="rt-mark" aria-hidden="true">{row.name.slice(0, 1).toUpperCase()}</span>
{row.website ? (
<a class="rt-name has-link" href={`https://${row.website}`} title={row.name}>{row.name}</a>
) : (
<span class="rt-name" title={row.name}>{row.name}</span>
)}
</div>
</td>
<td class="rt-cell" data-col="categories">
<span class="rt-val">
<span class="rt-tags" title={row.tags.join(", ")}>
{row.tags.map((tag) => (
<span class="rt-tag" style={`--rt-tag: ${tagBase(tag)}`}>{tag}</span>
))}
<span class="rt-more" hidden aria-hidden="true"></span>
</span>
</span>
</td>
<td class:list={["rt-cell", { "rt-muted": row.lastDays === undefined || row.lastDays === null }]} data-col="last">
<span class="rt-val">{row.last}</span>
</td>
<td class="rt-cell" data-col="strength">
<span class="rt-val rt-strength">
<span class="rt-dot" style={`background: ${s.color}`} aria-hidden="true"></span>
{s.label}
</span>
</td>
<td class="rt-cell" data-col="links">
<span class="rt-val">
{row.website ? (
<a class="rt-link" href={`https://${row.website}`} title={row.website} target="_blank" rel="noreferrer">
<span class="rt-link-label">{row.website}</span>
<Fragment set:html={svg(I.external, 12)} />
<span class="rt-sr">(opens in a new tab)</span>
</a>
) : (
<span class="rt-muted" aria-label="No link">–</span>
)}
</span>
</td>
<td class="rt-cell" data-col="ai" hidden>
<span class="rt-val"><span class="rt-muted">–</span></span>
</td>
<td class="rt-cell" data-col="actions"></td>
</tr>
);
})
}
</tbody>
<tfoot>
<tr>
<td class="rt-cell rt-sticky" data-col="company">
<span class="rt-foot rt-foot-label"><span class="rt-foot-num">{rows.length}</span> count</span>
</td>
<td class="rt-cell" data-col="categories">
<button type="button" class="rt-add-calc" data-add-calc><Fragment set:html={svg(I.plus, 15)} />Add calculation</button>
</td>
<td class="rt-cell rt-muted" data-col="last"><span class="rt-foot">–</span></td>
<td class="rt-cell" data-col="strength">
<span class="rt-foot rt-average"><span class="rt-dot" style={`background: ${avgColor}`} aria-hidden="true"></span>{avg}% average</span>
</td>
<td class="rt-cell" data-col="links"><span class="rt-foot rt-muted">{linkCount} links</span></td>
<td class="rt-cell rt-muted" data-col="ai" hidden><span class="rt-foot" data-ai-foot>–</span></td>
<td class="rt-cell" data-col="actions"></td>
</tr>
</tfoot>
</table>
</div>
<p class="rt-sr" aria-live="polite" data-live></p>
{/* ── property configuration popover ─────────────────── */}
<div class="rt-pop rt-prop" id={`${uid}-prop`} popover="manual" role="dialog" aria-labelledby={`${uid}-prop-title`} data-recpop data-pop="prop">
<div class="rt-prop-title" id={`${uid}-prop-title`} data-prop-title>{L.company}</div>
<div class="rt-config-row">
<span class="rt-config-label" id={`${uid}-type-label`}>Type</span>
<button type="button" class="rt-config-btn" data-picker-open="type" aria-haspopup="menu" aria-expanded="false" aria-labelledby={`${uid}-type-label ${uid}-type-value`}>
<span class="rt-ink-2" data-type-icon set:html={svg(TYPE_GLYPHS.Text)} />
<span id={`${uid}-type-value`} data-type-label>Text</span>
<span class="rt-ink-3" set:html={svg(I.chevron, 12, 2.2)} />
</button>
<div class="rt-picker" data-picker="type" hidden>
<div class="rt-picker-label" aria-hidden="true">Property type</div>
<GlideMenu class="rt-picker-list" role="menu" aria-label="Property type" arrowKeys>
{
PROPERTY_TYPES.map((type) => (
<button type="button" class="rt-menu-row" data-menu-row role="menuitemradio" aria-checked="false" data-value={type}>
<span class="rt-row-icon rt-ink-2" set:html={svg(TYPE_GLYPHS[type], 15)} />
<span class="rt-row-label">{type}</span>
<span class="rt-row-check" set:html={svg(I.check, 14, 2.2)} />
</button>
))
}
</GlideMenu>
</div>
</div>
<div class="rt-config-row">
<span class="rt-config-label" id={`${uid}-tool-label`}>Tool</span>
<button type="button" class="rt-config-btn" data-picker-open="tool" aria-haspopup="menu" aria-expanded="false" aria-labelledby={`${uid}-tool-label ${uid}-tool-value`}>
<span class="rt-ink-2" data-tool-icon set:html={svg(TOOL_GLYPHS.user)} />
<span id={`${uid}-tool-value`} data-tool-label>User input</span>
<span class="rt-ink-3" set:html={svg(I.chevron, 12, 2.2)} />
</button>
<div class="rt-picker" data-picker="tool" hidden>
<div class="rt-picker-label" aria-hidden="true">Model</div>
<GlideMenu class="rt-picker-list" role="menu" aria-label="Model" arrowKeys>
{
models.map((m) => (
<button type="button" class="rt-menu-row" data-menu-row role="menuitemradio" aria-checked="false" data-value={m}>
<span class="rt-row-icon rt-accent" set:html={modelSvg()} />
<span class="rt-row-label">{m}</span>
<span class="rt-row-check" set:html={svg(I.check, 14, 2.2)} />
</button>
))
}
</GlideMenu>
</div>
</div>
<div class="rt-config-row">
<span class="rt-config-label">Grounding</span>
<span class="rt-config-group">
<Switch size="sm" tone="accent" label="Grounding" />
<button type="button" class="rt-help-btn" data-help aria-label="About grounding" aria-expanded="false" aria-controls={`${uid}-help`}>
<Fragment set:html={svg(I.info, 13)} />
</button>
</span>
<div class="rt-help" id={`${uid}-help`} hidden>Grounding lets the model verify generated values against connected sources.</div>
</div>
<div class="rt-config-row">
<span class="rt-config-label" id={`${uid}-inputs-label`}>Inputs</span>
<button type="button" class="rt-config-btn rt-config-btn--inputs" data-picker-open="inputs" aria-haspopup="menu" aria-expanded="false" aria-labelledby={`${uid}-inputs-label ${uid}-inputs-value`}>
<span class="rt-input-chips" id={`${uid}-inputs-value`} data-inputs-value>Select inputs</span>
<span class="rt-ink-3" set:html={svg(I.chevron, 12, 2.2)} />
</button>
<div class="rt-picker rt-picker--wide" data-picker="inputs" hidden>
<div class="rt-picker-label" aria-hidden="true">Use values from</div>
<GlideMenu class="rt-picker-list" role="menu" aria-label="Calculation inputs" arrowKeys>
{
inputOptions.map((input) => (
<button type="button" class="rt-menu-row" data-menu-row role="menuitemcheckbox" aria-checked="false" data-value={input}>
<span class="rt-mini-box" aria-hidden="true" set:html={svg(I.check, 11, 2.4)} />
<span class="rt-row-label">{input}</span>
</button>
))
}
</GlideMenu>
</div>
</div>
<div class="rt-prompt" contenteditable="true" role="textbox" aria-multiline="true" spellcheck="true" aria-label="Calculation prompt" data-placeholder="Set a prompt (press @ to mention an input)" data-prompt></div>
<button type="button" class="rt-go" data-calc>
<Fragment set:html={svg(I.refresh, 14, 1.9)} />
Go calculate
</button>
<GlideMenu class="rt-prop-actions" highlightInset="-6px">
<button type="button" class="rt-action-row" data-menu-row data-pin aria-pressed="false">
<span class="rt-ink-2" data-pin-icon set:html={svg(I.pin, 15)} />
<span data-pin-label>Pin</span>
</button>
<button type="button" class="rt-action-row" data-menu-row data-more aria-expanded="false" aria-controls={`${uid}-more`}>
<span class="rt-ink-2" set:html={svg(I.gear, 15)} />
<span class="rt-grow">More settings</span>
<span class="rt-ink-3 rt-more-chevron" set:html={svg(I.chevron, 12, 2.2)} />
</button>
<button type="button" class="rt-action-row" data-menu-row data-hide hidden>
<span class="rt-ink-2" set:html={svg(I.eyeOff, 15)} />
Hide from view
</button>
</GlideMenu>
<div class="rt-more-settings" id={`${uid}-more`} hidden>
<div class="rt-subhead">Behavior</div>
<div class="rt-config-row"><span class="rt-config-label">Required value</span><Switch size="sm" tone="accent" label="Required value" /></div>
<div class="rt-config-row"><span class="rt-config-label">Allow empty results</span><Switch size="sm" tone="accent" label="Allow empty results" checked /></div>
<div class="rt-config-row"><span class="rt-config-label">Show confidence</span><Switch size="sm" tone="accent" label="Show confidence" /></div>
</div>
</div>
{/* ── new property menu ─────────────────────────────── */}
<div class="rt-pop rt-menu rt-menu--add" popover="manual" data-recpop data-pop="add">
<div class="rt-menu-label" aria-hidden="true">New property</div>
<GlideMenu class="rt-menu-list" role="menu" aria-label="New property" arrowKeys>
{
PROPERTY_TYPES.map((type) => (
<button type="button" class="rt-menu-item" data-menu-row role="menuitem" data-add-type={type}>
<span class="rt-ink-2" set:html={svg(TYPE_GLYPHS[type], 15)} />
{type}
</button>
))
}
</GlideMenu>
</div>
{/* ── table options menu ─────────────────────────────── */}
<div class="rt-pop rt-menu rt-menu--options" popover="manual" data-recpop data-pop="options">
<div class="rt-menu-label" aria-hidden="true">Table options</div>
<GlideMenu class="rt-menu-list" role="menu" aria-label="Table options" arrowKeys>
<button type="button" class="rt-menu-item" data-menu-row role="menuitem" data-option="add"><span class="rt-ink-2" set:html={svg(I.plus, 15, 2)} />Add property</button>
<button type="button" class="rt-menu-item" data-menu-row role="menuitem" data-option="compact"><span class="rt-ink-2" set:html={svg(I.compact, 15)} />Compact columns</button>
<button type="button" class="rt-menu-item" data-menu-row role="menuitem" data-option="reset"><span class="rt-ink-2" set:html={svg(I.reset, 15)} />Reset column widths</button>
<div class="rt-menu-sep" role="separator"></div>
<button type="button" class="rt-menu-item" data-menu-row role="menuitem" data-option="clear"><span class="rt-ink-2" set:html={svg(I.clear, 15)} />Clear selection</button>
</GlideMenu>
</div>
</div>
<script>
import { initGlideMenu } from "../glide-menu/glide-menu";
type ColumnKey = "company" | "categories" | "last" | "strength" | "links" | "ai";
type SortKey = "name" | "last" | "strength";
interface Prompt {
before: string;
chip?: string;
after?: string;
}
interface ColumnConfig {
label: string;
type: string;
tool: string;
toolKind: "model" | "web" | "user";
inputs: string[];
prompt?: Prompt;
}
interface Config {
uid: string;
rows: { id: string; name: string; lastDays: number | null; last: string; rank: number; ai: string }[];
sort: { key: SortKey; dir: 1 | -1 };
columns: Record<ColumnKey, ColumnConfig>;
typeGlyphs: Record<string, string>;
toolGlyphs: Record<"model" | "web" | "user", string>;
aiEnabled: boolean;
}
const DEFAULT_WIDTHS: Record<string, number> = { company: 270, categories: 275, last: 190, strength: 210, links: 175, ai: 240, actions: 100 };
const COMPACT_WIDTHS: Record<string, number> = { company: 220, categories: 220, last: 155, strength: 180, links: 160, ai: 200 };
const STEP_MS = 110;
const svg = (inner: string, size = 14, sw = 1.8) =>
`<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
function init(root: HTMLElement) {
const configEl = root.querySelector<HTMLScriptElement>("script[data-vs-config]");
if (!configEl) return;
const cfg = JSON.parse(configEl.textContent || "{}") as Config;
// JSON turns Infinity into null; null means "no contact" (sorts last).
const rowsById = new Map(cfg.rows.map((r) => [r.id, { ...r, lastDays: r.lastDays ?? Number.POSITIVE_INFINITY }]));
const columns = cfg.columns;
const scroller = root.querySelector<HTMLElement>(".rt-scroll")!;
const table = root.querySelector<HTMLTableElement>(".rt-table")!;
const colgroup = table.querySelector("colgroup")!;
const tbody = table.tBodies[0];
const live = root.querySelector<HTMLElement>("[data-live]")!;
const selectAll = root.querySelector<HTMLInputElement>("[data-select-all]")!;
const propPop = root.querySelector<HTMLElement>('[data-pop="prop"]')!;
const addPop = root.querySelector<HTMLElement>('[data-pop="add"]')!;
const optionsPop = root.querySelector<HTMLElement>('[data-pop="options"]')!;
const addBtn = root.querySelector<HTMLButtonElement>("[data-open-add]")!;
const optionsBtn = root.querySelector<HTMLButtonElement>("[data-open-options]")!;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const cleanups: Array<() => void> = [];
const rowEls = () => Array.from(tbody.rows) as HTMLTableRowElement[];
const colEl = (key: string) => colgroup.querySelector<HTMLTableColElement>(`col[data-col="${key}"]`);
const cellsOf = (key: string) => Array.from(table.querySelectorAll<HTMLTableCellElement>(`th[data-col="${key}"], td[data-col="${key}"]`));
/* ── selection ─────────────────────────────────────── */
const selected = new Set<string>();
const syncSelection = (announce = true) => {
const all = rowEls();
for (const tr of all) {
const id = tr.dataset.id!;
const on = selected.has(id);
tr.classList.toggle("is-selected", on);
const box = tr.querySelector<HTMLInputElement>("[data-select]");
if (box) box.checked = on;
}
const count = all.filter((tr) => selected.has(tr.dataset.id!)).length;
selectAll.checked = count > 0 && count === all.length;
selectAll.indeterminate = count > 0 && count < all.length;
if (announce) root.dispatchEvent(new CustomEvent("vs-records-selection", { bubbles: true, detail: { ids: [...selected] } }));
};
tbody.addEventListener("change", (event) => {
const box = (event.target as Element).closest<HTMLInputElement>("[data-select]");
if (!box) return;
const id = box.dataset.select!;
if (box.checked) selected.add(id);
else selected.delete(id);
syncSelection();
});
selectAll.addEventListener("change", () => {
const ids = rowEls().map((tr) => tr.dataset.id!);
if (selectAll.checked) ids.forEach((id) => selected.add(id));
else ids.forEach((id) => selected.delete(id));
syncSelection();
});
/* ── sorting ───────────────────────────────────────── */
let sort = { ...cfg.sort };
const sortHeaders: Record<SortKey, HTMLTableCellElement | null> = {
name: table.querySelector('th[data-col="company"]'),
last: table.querySelector('th[data-col="last"]'),
strength: table.querySelector('th[data-col="strength"]'),
};
const applySort = () => {
const trs = rowEls();
trs.sort((a, b) => {
const ra = rowsById.get(a.dataset.id!)!;
const rb = rowsById.get(b.dataset.id!)!;
const v =
sort.key === "name"
? ra.name.localeCompare(rb.name)
: sort.key === "last"
? ra.lastDays - rb.lastDays || ra.name.localeCompare(rb.name)
: ra.rank - rb.rank;
return (Number.isNaN(v) ? 0 : v) * sort.dir;
});
trs.forEach((tr, i) => {
tbody.appendChild(tr);
const num = tr.querySelector(".rt-rownum");
if (num) num.textContent = String(i + 1);
});
for (const [key, th] of Object.entries(sortHeaders) as [SortKey, HTMLTableCellElement | null][]) {
if (!th) continue;
if (key === sort.key) th.setAttribute("aria-sort", sort.dir === 1 ? "ascending" : "descending");
else th.removeAttribute("aria-sort");
th.querySelector(".rt-sort")?.classList.toggle("is-visible", key === sort.key);
th.querySelector(".rt-sort")?.classList.toggle("is-desc", key === sort.key && sort.dir === -1);
}
};
root.querySelectorAll<HTMLButtonElement>("[data-sort]").forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.dataset.sort as SortKey;
sort = sort.key === key ? { key, dir: (sort.dir * -1) as 1 | -1 } : { key, dir: 1 };
closeAll();
applySort();
paintCalc();
root.dispatchEvent(new CustomEvent("vs-records-sort", { bubbles: true, detail: { ...sort } }));
});
});
/* ── tags: fit as many as the cell allows, then "+N" ── */
const tagWidths = new WeakMap<HTMLElement, { tags: number[]; more: number }>();
const measureTags = (box: HTMLElement) => {
const tags = Array.from(box.querySelectorAll<HTMLElement>(".rt-tag"));
const more = box.querySelector<HTMLElement>(".rt-more")!;
tags.forEach((t) => (t.hidden = false));
more.textContent = `+${tags.length}`;
more.hidden = false;
const m = { tags: tags.map((t) => t.offsetWidth), more: more.offsetWidth };
tagWidths.set(box, m);
return m;
};
const fitTags = (box: HTMLElement) => {
if (!box.isConnected || box.getClientRects().length === 0) return;
const m = tagWidths.get(box) ?? measureTags(box);
if (!m.tags.length || m.tags[0] === 0) return;
const available = box.clientWidth;
let used = 0;
let count = 0;
for (let i = 0; i < m.tags.length; i += 1) {
const next = used + (count > 0 ? 4 : 0) + m.tags[i];
const hiddenAfter = m.tags.length - (i + 1);
if (next + (hiddenAfter > 0 ? 4 + m.more : 0) > available) break;
used = next;
count += 1;
}
const tags = box.querySelectorAll<HTMLElement>(".rt-tag");
tags.forEach((t, i) => (t.hidden = i >= count));
const more = box.querySelector<HTMLElement>(".rt-more")!;
const hidden = tags.length - count;
more.hidden = hidden <= 0;
more.textContent = `+${hidden}`;
};
const tagBoxes = Array.from(root.querySelectorAll<HTMLElement>(".rt-tags"));
const tagObserver = new ResizeObserver((entries) => entries.forEach((e) => fitTags(e.target as HTMLElement)));
tagBoxes.forEach((box) => tagObserver.observe(box));
document.fonts?.ready.then(() => tagBoxes.forEach((box) => (tagWidths.delete(box), fitTags(box))));
cleanups.push(() => tagObserver.disconnect());
/* ── column widths: fill once, then lock every column explicitly ── */
const widths: Record<string, number> = { ...DEFAULT_WIDTHS };
let initialWidths: Record<string, number> = { ...DEFAULT_WIDTHS };
let aiAdded = false;
const applyWidths = () => {
const keys = ["company", "categories", "last", "strength", "links", ...(aiAdded ? ["ai"] : []), "actions"];
for (const key of keys) {
const col = colEl(key);
if (col) col.style.width = `${widths[key]}px`;
}
const total = keys.reduce((sum, k) => sum + widths[k], 0);
table.style.width = `${total}px`;
table.style.minWidth = `${total}px`;
root.querySelectorAll<HTMLElement>("[data-resize]").forEach((h) => {
const key = h.dataset.resize!;
h.setAttribute("aria-valuenow", String(Math.round(widths[key])));
h.setAttribute("aria-valuemin", h.dataset.min ?? "120");
h.setAttribute("aria-valuemax", "800");
h.setAttribute("aria-valuetext", `${Math.round(widths[key])} pixels`);
});
};
const lockWidths = () => {
if (scroller.getClientRects().length === 0) return false;
const ths = Array.from(table.tHead!.rows[0].cells).filter((th) => !th.hidden);
for (const th of ths) {
const key = th.dataset.col!;
widths[key] = th.getBoundingClientRect().width;
}
initialWidths = { ...widths };
applyWidths();
return true;
};
// Measure once visible (the gallery may render this inside a hidden variant panel).
if (!lockWidths()) {
const vis = new ResizeObserver(() => {
if (lockWidths()) vis.disconnect();
});
vis.observe(scroller);
cleanups.push(() => vis.disconnect());
}
const startResize = (handle: HTMLElement, event: PointerEvent) => {
event.preventDefault();
event.stopPropagation();
closeAll();
const key = handle.dataset.resize!;
const min = Number(handle.dataset.min ?? 120);
const startX = event.clientX;
const startWidth = widths[key];
const prevCursor = document.body.style.cursor;
const prevSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
handle.classList.add("is-resizing");
const move = (e: PointerEvent) => {
widths[key] = Math.max(min, startWidth + e.clientX - startX);
applyWidths();
};
const finish = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", finish);
window.removeEventListener("pointercancel", finish);
document.body.style.cursor = prevCursor;
document.body.style.userSelect = prevSelect;
handle.classList.remove("is-resizing");
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", finish);
window.addEventListener("pointercancel", finish);
};
root.querySelectorAll<HTMLElement>("[data-resize]").forEach((handle) => {
handle.addEventListener("pointerdown", (e) => startResize(handle, e));
handle.addEventListener("keydown", (e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const key = handle.dataset.resize!;
const step = e.shiftKey ? 40 : 10;
widths[key] = Math.min(800, Math.max(Number(handle.dataset.min ?? 120), widths[key] + (e.key === "ArrowRight" ? step : -step)));
applyWidths();
});
});
/* ── popovers (top layer when supported) ───────────── */
const supportsPopover = typeof HTMLElement.prototype.showPopover === "function";
let openPop: HTMLElement | null = null;
let openTrigger: HTMLElement | null = null;
let propCol: ColumnKey | null = null;
let ignoreScroll = false;
let placement: { pop: HTMLElement; anchorEl: HTMLElement; width: number; align: "left" | "right" } | null = null;
const positionPop = (pop: HTMLElement, anchor: DOMRect, width: number, align: "left" | "right") => {
const h = pop.offsetHeight;
const left =
align === "right"
? Math.max(8, Math.min(anchor.right - width, window.innerWidth - width - 8))
: Math.max(8, Math.min(anchor.left, window.innerWidth - width - 8));
let top = anchor.bottom + 6;
if (top + h > window.innerHeight - 8 && anchor.top - h - 6 > 8) top = anchor.top - h - 6;
pop.style.left = `${left}px`;
pop.style.top = `${top}px`;
pop.style.transformOrigin = align === "right" ? "top right" : "top left";
};
const showPop = (pop: HTMLElement, anchor: DOMRect, width: number, align: "left" | "right" = "left", anchorEl?: HTMLElement) => {
pop.classList.add("is-open");
if (supportsPopover) pop.showPopover();
positionPop(pop, anchor, width, align);
placement = anchorEl ? { pop, anchorEl, width, align } : null;
pop.classList.remove("is-anim");
void pop.offsetWidth;
pop.classList.add("is-anim");
};
const hidePop = (pop: HTMLElement) => {
pop.classList.remove("is-open", "is-anim");
if (supportsPopover && pop.matches(":popover-open")) pop.hidePopover();
};
const closeAll = (restoreFocus = false) => {
closePickers();
setHelp(false);
if (openPop) hidePop(openPop);
openPop = null;
placement = null;
root.querySelectorAll('[data-prop][aria-expanded="true"], [data-open-add], [data-open-options]').forEach((el) => el.setAttribute("aria-expanded", "false"));
table.querySelectorAll(".is-colsel").forEach((el) => el.classList.remove("is-colsel"));
const trigger = openTrigger;
openTrigger = null;
propCol = null;
if (restoreFocus && trigger?.isConnected) trigger.focus();
};
const focusFirst = (container: HTMLElement) => {
const target = container.querySelector<HTMLElement>('[aria-checked="true"][data-menu-row]') ?? container.querySelector<HTMLElement>("[data-menu-row]:not([hidden])");
target?.focus({ preventScroll: true });
};
const onDocPointerDown = (event: PointerEvent) => {
if (!openPop) return;
const target = event.target as Element;
if (target.closest("[data-recpop]") || (openTrigger && openTrigger.contains(target))) return;
closeAll();
};
const onDocKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !openPop) return;
if (closePickers(true)) return;
event.preventDefault();
closeAll(true);
};
document.addEventListener("pointerdown", onDocPointerDown);
document.addEventListener("keydown", onDocKeyDown);
// Re-anchor rather than close: on-screen keyboards fire resize while typing in the popover.
const onWinResize = () => {
if (placement?.anchorEl.isConnected) positionPop(placement.pop, placement.anchorEl.getBoundingClientRect(), placement.width, placement.align);
};
window.addEventListener("resize", onWinResize);
window.addEventListener("scroll", onWinResize, { passive: true });
cleanups.push(() => {
document.removeEventListener("pointerdown", onDocPointerDown);
document.removeEventListener("keydown", onDocKeyDown);
window.removeEventListener("resize", onWinResize);
window.removeEventListener("scroll", onWinResize);
});
scroller.addEventListener("scroll", () => {
if (ignoreScroll) {
ignoreScroll = false;
return;
}
if (openPop) closeAll();
});
/* ── property popover ──────────────────────────────── */
const overrides: Partial<Record<ColumnKey, Partial<ColumnConfig>>> = {};
const pinned = new Set<ColumnKey>();
const metaOf = (col: ColumnKey): ColumnConfig => ({ ...columns[col], ...overrides[col] });
const q = <T extends HTMLElement = HTMLElement>(sel: string) => propPop.querySelector<T>(sel)!;
const titleEl = q("[data-prop-title]");
const typeIcon = q("[data-type-icon]");
const typeLabel = q("[data-type-label]");
const toolIcon = q("[data-tool-icon]");
const toolLabel = q("[data-tool-label]");
const inputsValue = q("[data-inputs-value]");
const promptEl = q("[data-prompt]");
const calcBtn = q<HTMLButtonElement>("[data-calc]");
const pinBtn = q<HTMLButtonElement>("[data-pin]");
const moreBtn = q<HTMLButtonElement>("[data-more]");
const hideBtn = q<HTMLButtonElement>("[data-hide]");
const moreSettings = q("#" + CSS.escape(`${cfg.uid}-more`));
const helpBtn = q<HTMLButtonElement>("[data-help]");
const helpEl = q("#" + CSS.escape(`${cfg.uid}-help`));
const setHelp = (open: boolean) => {
helpBtn.setAttribute("aria-expanded", String(open));
helpEl.hidden = !open;
};
helpBtn.addEventListener("click", () => setHelp(Boolean(helpEl.hidden)));
const toolSvg = (kind: ColumnConfig["toolKind"]) =>
kind === "model"
? `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">${cfg.toolGlyphs.model}</svg>`
: svg(cfg.toolGlyphs[kind]);
const renderInputs = (list: string[]) => {
if (!list.length) {
inputsValue.textContent = "Select inputs";
return;
}
inputsValue.innerHTML =
list
.slice(0, 2)
.map((i) => `<span class="rt-input-chip">${escapeHtml(i)}</span>`)
.join("") + (list.length > 2 ? `<span class="rt-input-more">+${list.length - 2}</span>` : "");
};
const renderProp = (col: ColumnKey) => {
const meta = metaOf(col);
titleEl.textContent = meta.label;
typeIcon.innerHTML = svg(cfg.typeGlyphs[meta.type] ?? cfg.typeGlyphs.Text);
typeLabel.textContent = meta.type;
toolIcon.innerHTML = toolSvg(meta.toolKind);
toolIcon.className = meta.toolKind === "model" ? "rt-accent" : "rt-ink-2";
toolLabel.textContent = meta.tool;
renderInputs(meta.inputs);
propPop.querySelectorAll<HTMLElement>('[data-picker="inputs"] [data-value]').forEach((row) => {
row.hidden = row.dataset.value === meta.label;
row.setAttribute("aria-checked", String(meta.inputs.includes(row.dataset.value!)));
});
propPop.querySelectorAll<HTMLElement>('[data-picker="type"] [data-value]').forEach((row) => row.setAttribute("aria-checked", String(row.dataset.value === meta.type)));
propPop.querySelectorAll<HTMLElement>('[data-picker="tool"] [data-value]').forEach((row) => row.setAttribute("aria-checked", String(row.dataset.value === meta.tool)));
promptEl.setAttribute("aria-label", `${meta.label} calculation prompt`);
if (meta.prompt) {
promptEl.innerHTML =
escapeHtml(meta.prompt.before) +
(meta.prompt.chip ? `<span class="rt-prompt-chip" contenteditable="false">${escapeHtml(meta.prompt.chip)}</span>` : "") +
escapeHtml(meta.prompt.after ?? "");
} else promptEl.innerHTML = "";
calcBtn.disabled = col === "company" || calc !== null;
renderPin(col);
hideBtn.hidden = col !== "ai";
setMore(false);
setHelp(false);
};
const renderPin = (col: ColumnKey) => {
const isPinned = pinned.has(col);
pinBtn.setAttribute("aria-pressed", String(isPinned));
pinBtn.classList.toggle("is-pinned", isPinned);
q("[data-pin-label]").textContent = isPinned ? "Unpin" : "Pin";
};
const setMore = (open: boolean) => {
moreBtn.setAttribute("aria-expanded", String(open));
moreBtn.classList.toggle("is-open", open);
moreSettings.hidden = !open;
};
moreBtn.addEventListener("click", () => setMore(Boolean(moreSettings.hidden)));
pinBtn.addEventListener("click", () => {
if (!propCol) return;
if (pinned.has(propCol)) pinned.delete(propCol);
else pinned.add(propCol);
renderPin(propCol);
});
const openProp = (col: ColumnKey, trigger: HTMLElement, viaKeyboard: boolean) => {
const reopenSame = openPop === propPop && propCol === col;
closeAll();
if (reopenSame) return;
const th = trigger.closest("th")!;
propCol = col;
openTrigger = trigger;
openPop = propPop;
trigger.setAttribute("aria-expanded", "true");
cellsOf(col).forEach((c) => c.classList.add("is-colsel"));
renderProp(col);
showPop(propPop, th.getBoundingClientRect(), 320, "left", th);
if (viaKeyboard) q<HTMLButtonElement>('[data-picker-open="type"]').focus({ preventScroll: true });
};
root.querySelectorAll<HTMLButtonElement>("[data-prop]").forEach((btn) => {
btn.addEventListener("click", (e) => openProp(btn.dataset.prop as ColumnKey, btn, e.detail === 0));
});
const setAiIcon = (type: string) => {
root.querySelector("[data-ai-icon]")!.innerHTML = svg(cfg.typeGlyphs[type] ?? cfg.typeGlyphs.Text, 15);
};
/* pickers inside the popover */
let openPicker: HTMLElement | null = null;
const closePickers = (restoreFocus = false) => {
if (!openPicker) return false;
const trigger = propPop.querySelector<HTMLElement>(`[data-picker-open="${openPicker.dataset.picker}"]`);
openPicker.hidden = true;
trigger?.setAttribute("aria-expanded", "false");
openPicker = null;
if (restoreFocus) trigger?.focus();
return true;
};
propPop.querySelectorAll<HTMLButtonElement>("[data-picker-open]").forEach((btn) => {
btn.addEventListener("click", (e) => {
const name = btn.dataset.pickerOpen!;
const picker = propPop.querySelector<HTMLElement>(`[data-picker="${name}"]`)!;
const wasOpen = openPicker === picker;
closePickers();
setHelp(false);
if (wasOpen) return;
picker.hidden = false;
openPicker = picker;
btn.setAttribute("aria-expanded", "true");
// Flip to the left when the right side has no room.
const popRect = propPop.getBoundingClientRect();
picker.classList.toggle("is-flipped", popRect.right + 20 + picker.offsetWidth > window.innerWidth - 8);
picker.classList.remove("is-anim");
void picker.offsetWidth;
picker.classList.add("is-anim");
if (e.detail === 0) focusFirst(picker);
});
});
propPop.querySelector('[data-picker="type"]')!.addEventListener("click", (e) => {
const row = (e.target as Element).closest<HTMLElement>("[data-value]");
if (!row || !propCol) return;
overrides[propCol] = { ...overrides[propCol], type: row.dataset.value! };
if (propCol === "ai") setAiIcon(row.dataset.value!);
renderProp(propCol);
closePickers(true);
});
propPop.querySelector('[data-picker="tool"]')!.addEventListener("click", (e) => {
const row = (e.target as Element).closest<HTMLElement>("[data-value]");
if (!row || !propCol) return;
overrides[propCol] = { ...overrides[propCol], tool: row.dataset.value!, toolKind: "model" };
renderProp(propCol);
closePickers(true);
});
propPop.querySelector('[data-picker="inputs"]')!.addEventListener("click", (e) => {
const row = (e.target as Element).closest<HTMLElement>("[data-value]");
if (!row || !propCol) return;
const current = metaOf(propCol).inputs;
const value = row.dataset.value!;
const next = current.includes(value) ? current.filter((i) => i !== value) : [...current, value];
overrides[propCol] = { ...overrides[propCol], inputs: next };
row.setAttribute("aria-checked", String(next.includes(value)));
renderInputs(next);
});
/* ── calculation: resolve one row per beat ─────────── */
let calc: { col: ColumnKey; resolved: number; timer: number } | null = null;
let aiDone = false;
const aiFoot = root.querySelector<HTMLElement>("[data-ai-foot]")!;
const calcMarkup = '<span class="rt-calc"><span class="rt-muted">Calculating…</span><span class="rt-pulse"></span></span>';
const renderAiCell = (td: HTMLTableCellElement) => {
const id = td.parentElement!.dataset.id!;
const value = rowsById.get(id)?.ai ?? "";
td.querySelector(".rt-val")!.innerHTML = aiDone && value ? escapeHtml(value) : '<span class="rt-muted">–</span>';
td.title = aiDone ? value : "";
};
const paintCalc = () => {
if (!calc) return;
const { col, resolved } = calc;
rowEls().forEach((tr, i) => {
const td = tr.querySelector<HTMLTableCellElement>(`td[data-col="${col}"]`);
if (!td) return;
const pending = i >= resolved;
if (pending && !td.classList.contains("is-calc")) {
td.classList.add("is-calc");
td.insertAdjacentHTML("beforeend", calcMarkup);
} else if (!pending && td.classList.contains("is-calc")) {
td.classList.remove("is-calc");
td.querySelector(".rt-calc")?.remove();
if (col === "ai") {
const value = rowsById.get(tr.dataset.id!)?.ai ?? "";
td.querySelector(".rt-val")!.innerHTML = value ? escapeHtml(value) : '<span class="rt-muted">–</span>';
td.title = value;
}
if (col === "categories") {
const box = td.querySelector<HTMLElement>(".rt-tags");
if (box) fitTags(box);
}
}
});
};
const finishCalc = () => {
if (!calc) return;
const { col } = calc;
window.clearInterval(calc.timer);
calc.resolved = Number.POSITIVE_INFINITY;
paintCalc();
calc = null;
if (col === "ai") {
aiDone = true;
aiFoot.textContent = `${cfg.rows.length} filled`;
}
live.textContent = `${metaOf(col).label} calculated for ${cfg.rows.length} rows.`;
};
const tick = () => {
if (!calc) return;
calc.resolved += 1;
if (calc.resolved > cfg.rows.length) finishCalc();
else paintCalc();
};
const runCalc = (col: ColumnKey) => {
if (calc || col === "company") return;
calc = { col, resolved: 0, timer: 0 };
live.textContent = `Calculating ${metaOf(col).label}…`;
root.dispatchEvent(new CustomEvent("vs-records-calculate", { bubbles: true, detail: { column: col, label: metaOf(col).label } }));
if (reduce.matches) {
finishCalc();
return;
}
paintCalc();
calc.timer = window.setInterval(tick, STEP_MS);
};
cleanups.push(() => calc && window.clearInterval(calc.timer));
calcBtn.addEventListener("click", () => {
if (!propCol) return;
const col = propCol;
closeAll(true);
runCalc(col);
});
/* ── the AI column ─────────────────────────────────── */
const setAiVisible = (on: boolean) => {
aiAdded = on;
cellsOf("ai").forEach((cell) => (cell.hidden = !on));
const existing = colEl("ai");
if (on && !existing) {
const col = document.createElement("col");
col.dataset.col = "ai";
colEl("actions")!.before(col);
} else if (!on && existing) existing.remove();
applyWidths();
};
const addAiColumn = (type: string) => {
if (!cfg.aiEnabled) return;
if (calc?.col === "ai") {
window.clearInterval(calc.timer);
calc = null;
}
aiDone = false;
overrides.ai = { ...overrides.ai, type };
setAiIcon(type);
aiFoot.textContent = "–";
table.querySelectorAll<HTMLTableCellElement>('td[data-col="ai"]').forEach((td) => {
if (td.closest("tbody")) {
td.classList.remove("is-calc");
td.querySelector(".rt-calc")?.remove();
renderAiCell(td);
}
});
setAiVisible(true);
root.dispatchEvent(new CustomEvent("vs-records-property", { bubbles: true, detail: { type } }));
live.textContent = `Added ${metaOf("ai").label} column.`;
// Reveal the new column, then open its configuration anchored to it.
requestAnimationFrame(() => {
ignoreScroll = true;
scroller.scrollLeft = scroller.scrollWidth;
const btn = root.querySelector<HTMLButtonElement>('[data-prop="ai"]')!;
requestAnimationFrame(() => openProp("ai", btn, true));
});
};
hideBtn.addEventListener("click", () => {
if (calc?.col === "ai") {
window.clearInterval(calc.timer);
calc = null;
}
aiDone = false;
closeAll();
setAiVisible(false);
live.textContent = `${metaOf("ai").label} column hidden.`;
addBtn.focus();
});
/* + menu and options menu */
const toggleMenu = (pop: HTMLElement, btn: HTMLButtonElement, align: "left" | "right", viaKeyboard: boolean, width: number) => {
const wasOpen = openPop === pop;
closeAll();
if (wasOpen) return;
openPop = pop;
openTrigger = btn;
btn.setAttribute("aria-expanded", "true");
showPop(pop, btn.getBoundingClientRect(), width, align, btn);
if (viaKeyboard) focusFirst(pop);
};
addBtn.addEventListener("click", (e) => toggleMenu(addPop, addBtn, "left", e.detail === 0, 260));
optionsBtn.addEventListener("click", (e) => toggleMenu(optionsPop, optionsBtn, "right", e.detail === 0, 220));
addPop.addEventListener("click", (e) => {
const item = (e.target as Element).closest<HTMLElement>("[data-add-type]");
if (!item) return;
closeAll();
addAiColumn(item.dataset.addType!);
});
optionsPop.addEventListener("click", (e) => {
const item = (e.target as Element).closest<HTMLElement>("[data-option]");
if (!item) return;
const option = item.dataset.option;
if (option === "add") {
const anchor = optionsBtn.getBoundingClientRect();
closeAll();
openPop = addPop;
openTrigger = addBtn;
addBtn.setAttribute("aria-expanded", "true");
showPop(addPop, anchor, 260, "right", optionsBtn);
if (e.detail === 0) focusFirst(addPop);
return;
}
if (option === "compact") Object.assign(widths, COMPACT_WIDTHS);
if (option === "reset") Object.assign(widths, initialWidths);
if (option === "clear") {
selected.clear();
syncSelection();
}
applyWidths();
closeAll(true);
});
root.querySelector("[data-add-calc]")?.addEventListener("click", () => {
root.dispatchEvent(new CustomEvent("vs-records-add-calculation", { bubbles: true }));
});
// Light up GlideMenus inside popovers that GlideMenu's own boot may have missed.
root.querySelectorAll<HTMLElement>(".vs-glide-menu:not([data-vs-ready])").forEach((menu) => {
menu.dataset.vsReady = "";
cleanups.push(initGlideMenu(menu));
});
applySort();
syncSelection(false);
return () => {
closeAll();
cleanups.forEach((fn) => fn());
};
}
const teardowns = new Set<() => void>();
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-records-table:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
const stop = init(root);
if (stop) teardowns.add(stop);
});
boot();
document.addEventListener("astro:page-load", boot);
document.addEventListener("astro:before-swap", () => {
teardowns.forEach((fn) => fn());
teardowns.clear();
});
</script>
<style>
.vs-records-table {
--rt-line: color-mix(in srgb, var(--vs-line) 78%, transparent);
position: relative;
width: 100%;
min-width: 0;
overflow: hidden;
border: 1px solid var(--vs-line);
border-radius: var(--vs-radius-card);
background: var(--vs-surface);
box-shadow: 0 1px 2px oklch(0 0 0 / 0.06);
color: var(--vs-ink);
font-family: var(--vs-font-sans);
text-align: left;
}
.vs-records-table.is-fill {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
}
.is-fill .rt-scroll {
max-height: none;
flex: 1;
}
.rt-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.rt-ink-2 {
display: inline-flex;
color: var(--vs-ink-2);
}
.rt-ink-3 {
display: inline-flex;
color: var(--vs-ink-3);
}
.rt-accent {
display: inline-flex;
color: var(--vs-accent-ink);
}
.rt-muted {
color: var(--vs-ink-3);
}
.rt-trunc {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
button:focus-visible,
a:focus-visible,
[tabindex]:focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
/* ── scroll viewport + grid ─────────────────────────── */
.rt-scroll {
max-height: var(--rt-max-h, 438px);
overflow: auto;
overscroll-behavior: none;
scrollbar-color: var(--vs-line-strong) transparent;
scrollbar-gutter: stable;
}
.rt-scroll:focus-visible {
box-shadow: inset 0 0 0 2px var(--vs-accent);
}
.rt-table {
width: 100%;
min-width: 1220px;
border-collapse: separate;
border-spacing: 0;
color: var(--vs-ink);
font-size: 13px;
font-weight: 500;
table-layout: fixed;
}
.rt-table th,
.rt-table td {
border-right: 1px solid var(--rt-line);
border-bottom: 1px solid var(--rt-line);
text-align: left;
vertical-align: middle;
}
.rt-table tr > :last-child {
border-right: 0;
}
.rt-table thead th {
position: sticky;
z-index: 5;
top: 0;
height: 35px;
padding: 0;
background: var(--vs-surface);
color: var(--vs-ink-2);
font-size: 12.5px;
font-weight: 500;
}
.rt-table thead th.rt-sticky {
z-index: 7;
}
/* header: property button + sort arrow */
.rt-head {
display: flex;
height: 35px;
align-items: center;
transition: background-color 120ms ease-out, color 120ms ease-out;
}
.rt-head--company {
gap: 8px;
padding-left: 6px;
}
.rt-th:hover .rt-head {
background: var(--vs-hover);
}
.rt-head-btn {
display: flex;
min-width: 0;
height: 35px;
flex: 1;
align-items: center;
gap: 8px;
padding: 0 12px;
color: var(--vs-ink-2);
text-align: left;
transition: color 120ms ease-out;
}
.rt-head--company .rt-head-btn {
padding-left: 0;
}
.rt-th:hover .rt-head-btn {
color: var(--vs-ink);
}
.rt-head-btn:focus-visible {
box-shadow: inset 0 0 0 2px var(--vs-accent);
}
.rt-head-icon {
display: inline-flex;
flex-shrink: 0;
color: var(--vs-ink-3);
}
.rt-sort {
display: inline-flex;
width: 24px;
height: 24px;
flex-shrink: 0;
align-items: center;
justify-content: center;
margin-right: 8px;
margin-left: -8px;
border-radius: var(--vs-radius-chip);
color: var(--vs-ink-2);
opacity: 0;
transition: opacity 120ms ease-out, transform 160ms var(--vs-ease-out), background-color 120ms ease-out;
}
.rt-sort:hover {
background: var(--vs-hover-2);
color: var(--vs-ink);
}
.rt-th:hover .rt-sort,
.rt-sort:focus-visible,
.rt-sort.is-visible {
opacity: 1;
}
.rt-sort.is-desc {
transform: rotate(180deg);
}
.rt-resize {
position: absolute;
z-index: 12;
top: 0;
right: -6px;
width: 12px;
height: 100%;
cursor: col-resize;
touch-action: none;
}
.rt-resize::after {
position: absolute;
top: 7px;
bottom: 7px;
left: 5px;
width: 2px;
border-radius: 99px;
background: var(--vs-accent);
content: "";
opacity: 0;
transform: scaleY(0.65);
transition: opacity 120ms ease-out, transform 150ms var(--vs-ease-out);
}
.rt-resize:hover::after,
.rt-resize:focus-visible::after,
.rt-resize.is-resizing::after {
opacity: 1;
transform: scaleY(1);
}
.rt-resize:focus-visible {
box-shadow: none;
}
.rt-actions {
display: flex;
height: 35px;
align-items: center;
gap: 4px;
padding: 0 8px;
}
.rt-icon-btn {
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 7px;
color: var(--vs-ink-2);
transition: background-color 100ms ease-out, color 100ms ease-out;
}
.rt-icon-btn--quiet {
color: var(--vs-ink-3);
}
.rt-icon-btn:hover,
.rt-icon-btn[aria-expanded="true"] {
background: var(--vs-hover);
color: var(--vs-ink);
}
/* ── checkbox ───────────────────────────────────────── */
.rt-checkbox {
position: relative;
display: inline-flex;
width: 24px;
height: 24px;
flex: 0 0 24px;
align-items: center;
justify-content: center;
border-radius: var(--vs-radius-chip);
cursor: pointer;
}
.rt-checkbox input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.rt-box {
display: inline-flex;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
border: 1px solid color-mix(in oklab, var(--vs-ink-3) 55%, var(--vs-surface));
border-radius: 6px;
background: color-mix(in oklab, var(--vs-ink) 5%, var(--vs-surface));
color: var(--vs-on-accent);
pointer-events: none;
transition: border-color 140ms ease-out, background-color 140ms ease-out, transform 140ms var(--vs-ease-out);
}
.rt-box :global(svg),
.rt-dash {
display: none;
}
.rt-dash {
width: 8px;
height: 1.5px;
border-radius: 99px;
background: var(--vs-on-accent);
}
.rt-checkbox:hover .rt-box {
border-color: color-mix(in oklab, var(--vs-ink-3) 80%, var(--vs-surface));
background: var(--vs-hover);
}
.rt-checkbox:active .rt-box {
transform: scale(0.96);
}
.rt-checkbox input:focus-visible + .rt-box {
box-shadow: var(--vs-focus-ring);
}
.rt-checkbox input:checked + .rt-box,
.rt-checkbox input:indeterminate + .rt-box {
border-color: var(--vs-accent);
background: var(--vs-accent);
}
.rt-checkbox input:checked + .rt-box :global(svg) {
display: block;
}
.rt-checkbox input:indeterminate + .rt-box .rt-dash {
display: block;
}
/* ── cells ──────────────────────────────────────────── */
.rt-cell {
height: 35px;
padding: 0 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-sticky {
position: sticky;
left: 0;
z-index: 2;
background: var(--vs-surface);
box-shadow: 5px 0 8px -10px oklch(0 0 0 / 0.4);
}
.rt-company {
padding-left: 6px;
}
.rt-company-inner {
display: flex;
min-width: 0;
align-items: center;
}
.rt-row > .rt-cell {
transition: background-color 120ms ease-out, color 120ms ease-out;
}
.rt-row:hover > .rt-cell {
background: var(--vs-hover);
}
.rt-row.is-selected > .rt-cell {
background: color-mix(in srgb, var(--vs-accent) 7%, var(--vs-surface));
}
/* gutter: the row number gives way to the checkbox on hover, focus or selection */
.rt-gutter {
position: relative;
display: inline-flex;
width: 24px;
height: 24px;
margin-right: 4px;
}
.rt-rownum {
display: inline-flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
color: var(--vs-ink-3);
font-size: 11.5px;
font-variant-numeric: tabular-nums;
}
.rt-gutter .rt-checkbox {
position: absolute;
inset: 0;
opacity: 0;
}
.rt-row:hover .rt-gutter .rt-checkbox,
.rt-row.is-selected .rt-gutter .rt-checkbox,
.rt-gutter:focus-within .rt-checkbox {
opacity: 1;
}
.rt-row:hover .rt-rownum,
.rt-row.is-selected .rt-rownum,
.rt-gutter:focus-within .rt-rownum {
visibility: hidden;
}
.rt-mark {
display: inline-flex;
width: 20px;
height: 20px;
margin-right: 4px;
align-items: center;
justify-content: center;
border-radius: 6px;
color: var(--vs-ink-2);
background: var(--vs-field);
font-size: 10px;
font-weight: 500;
}
.rt-name {
min-width: 0;
overflow: hidden;
color: var(--vs-ink);
font-size: 13px;
font-weight: 500;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-name.has-link:hover,
.rt-name.has-link:focus-visible {
color: var(--vs-accent-ink);
text-decoration: underline;
text-underline-offset: 3px;
}
.rt-val {
display: contents;
}
/* tags */
.rt-tags {
position: relative;
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 4px;
overflow: hidden;
}
.rt-tag {
display: inline-flex;
height: 23px;
max-width: 115px;
flex-shrink: 0;
align-items: center;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--rt-tag) 34%, var(--vs-surface));
border-radius: 8px;
padding: 0 7px;
/* Text leans on --vs-ink, which flips with the mode: AA (4.5:1+) on the tint in paper and ink. */
color: color-mix(in srgb, var(--rt-tag) 45%, var(--vs-ink));
background: color-mix(in srgb, var(--rt-tag) 22%, var(--vs-surface));
font-size: 13px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-tag[hidden],
.rt-more[hidden] {
display: none;
}
.rt-more {
display: inline-flex;
height: 23px;
flex-shrink: 0;
align-items: center;
border: 1px solid var(--vs-line-strong);
border-radius: 6px;
padding: 0 7px;
color: var(--vs-ink-3);
background: var(--vs-inset);
font-size: 11px;
font-weight: 500;
}
.rt-strength {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--vs-ink-2);
}
.rt-dot {
display: inline-block;
width: 8px;
height: 8px;
flex: 0 0 8px;
border-radius: 50%;
}
.rt-link {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 5px;
overflow: hidden;
color: var(--vs-accent-ink);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
text-underline-offset: 3px;
transition: color 120ms ease-out, text-decoration-color 120ms ease-out;
}
.rt-link-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-link :global(svg) {
flex-shrink: 0;
}
.rt-link:hover,
.rt-link:focus-visible {
color: var(--vs-ink);
text-decoration-color: currentColor;
}
/* selected column: the property being configured */
.rt-table th:global(.is-colsel) {
background: color-mix(in srgb, var(--vs-accent) 8%, var(--vs-surface));
box-shadow: inset 0 2px 0 var(--vs-accent);
}
.rt-table th:global(.is-colsel) .rt-head-btn,
.rt-table th:global(.is-colsel) .rt-head-icon {
color: var(--vs-accent-ink);
}
.rt-table td:global(.is-colsel) {
background: color-mix(in srgb, var(--vs-accent) 4%, var(--vs-surface));
}
.rt-row:hover > td:global(.is-colsel) {
background: color-mix(in srgb, var(--vs-accent) 8%, var(--vs-surface));
}
.rt-row.is-selected > td:global(.is-colsel) {
background: color-mix(in srgb, var(--vs-accent) 10%, var(--vs-surface));
}
/* calculating state */
.rt-cell:global(.is-calc) > .rt-val {
display: none;
}
.vs-records-table :global(.rt-calc) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.vs-records-table :global(.rt-pulse) {
width: 8px;
height: 8px;
flex: 0 0 8px;
border-radius: 50%;
background: var(--vs-accent);
animation: vs-pulse 1.1s ease-in-out infinite;
}
/* footer calculations */
.rt-table tfoot td {
position: sticky;
z-index: 4;
bottom: 0;
height: 35px;
background: var(--vs-inset);
color: var(--vs-ink-2);
font-size: 14px;
font-weight: 500;
line-height: 1;
}
.rt-table tfoot .rt-sticky {
z-index: 6;
background: var(--vs-inset);
}
.rt-foot {
display: inline-flex;
height: 35px;
align-items: center;
vertical-align: middle;
}
.rt-foot-label {
color: var(--vs-ink-2);
}
.rt-foot-num {
margin-right: 3px;
color: var(--vs-ink);
font-variant-numeric: tabular-nums;
}
.rt-average {
gap: 7px;
color: var(--vs-ink-2);
}
.rt-add-calc {
display: inline-flex;
height: 35px;
align-items: center;
gap: 6px;
color: var(--vs-ink-3);
font-size: 14px;
font-weight: 500;
line-height: 1;
transition: color 120ms ease-out, transform 140ms var(--vs-ease-out);
}
.rt-add-calc:hover {
color: var(--vs-ink);
}
.rt-add-calc:active {
transform: scale(0.96);
}
/* ── popovers ───────────────────────────────────────── */
.rt-pop {
position: fixed;
inset: auto;
z-index: 50;
display: none;
margin: 0;
border: 0;
overflow: visible;
background: var(--vs-surface);
color: var(--vs-ink);
box-shadow: var(--vs-shadow-overlay);
font-family: var(--vs-font-sans);
font-size: 13px;
text-align: left;
}
.rt-pop.is-open {
display: block;
}
.rt-pop.is-anim {
animation: vs-pop-in 160ms var(--vs-ease-out) both;
}
.rt-prop {
width: 320px;
padding: 12px 12px 6px;
border-radius: 14px;
}
.rt-menu {
padding: 6px;
border-radius: 14px;
}
.rt-menu--add {
width: 260px;
}
.rt-menu--options {
width: 220px;
}
.rt-prop-title {
padding-bottom: 8px;
font-size: 13.5px;
font-weight: 500;
color: var(--vs-ink);
}
.rt-config-row {
position: relative;
display: flex;
height: 32px;
align-items: center;
justify-content: space-between;
}
.rt-config-label {
font-size: 13px;
color: var(--vs-ink-3);
}
.rt-config-group {
display: flex;
align-items: center;
gap: 8px;
}
.rt-config-btn {
display: flex;
max-width: 220px;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
color: var(--vs-ink);
transition: background-color 100ms ease-out;
}
.rt-config-btn:hover,
.rt-config-btn[aria-expanded="true"] {
background: var(--vs-hover);
}
.rt-config-btn--inputs {
font-weight: 400;
color: var(--vs-ink-2);
}
.rt-input-chips {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
}
.vs-records-table :global(.rt-input-chip),
.vs-records-table :global(.rt-prompt-chip) {
max-width: 92px;
overflow: hidden;
border-radius: 5px;
padding: 2px 6px;
background: var(--vs-accent-tint);
color: var(--vs-accent-ink);
font-size: 12px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.vs-records-table :global(.rt-prompt-chip) {
max-width: none;
}
.vs-records-table :global(.rt-input-more) {
font-size: 11px;
font-weight: 500;
color: var(--vs-ink-3);
}
.rt-help-btn {
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
border-radius: 6px;
color: var(--vs-ink-3);
transition: background-color 100ms ease-out, color 100ms ease-out;
}
.rt-help-btn:hover {
background: var(--vs-hover);
color: var(--vs-ink);
}
.rt-help {
position: absolute;
z-index: 30;
top: 30px;
right: 0;
width: 230px;
padding: 10px 12px;
border-radius: 10px;
background: var(--vs-tooltip-bg);
color: var(--vs-tooltip-fg);
box-shadow: var(--vs-shadow-overlay);
font-size: 12px;
line-height: 1.6;
}
/* side pickers */
.rt-picker {
position: absolute;
z-index: 30;
top: 0;
left: calc(100% + 20px);
width: 210px;
padding: 6px;
border-radius: 12px;
background: var(--vs-surface);
box-shadow: var(--vs-shadow-overlay);
transform-origin: top left;
}
.rt-picker--wide {
width: 220px;
}
.rt-picker.is-flipped {
right: calc(100% + 20px);
left: auto;
transform-origin: top right;
}
.rt-picker:global(.is-anim) {
animation: vs-pop-in 140ms var(--vs-ease-out) both;
}
.rt-picker[hidden],
.rt-help[hidden],
.rt-more-settings[hidden] {
display: none;
}
.rt-picker-label,
.rt-menu-label {
padding: 2px 8px 4px;
font-size: 11.5px;
font-weight: 500;
color: var(--vs-ink-3);
}
.rt-menu-label {
padding-top: 4px;
font-size: 12px;
}
.rt-picker-list,
.rt-menu-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.rt-menu-row {
display: flex;
width: 100%;
height: 32px;
align-items: center;
gap: 6px;
padding: 0 6px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--vs-ink);
text-align: left;
}
.rt-menu-row[hidden] {
display: none;
}
.rt-row-icon {
display: flex;
width: 16px;
height: 16px;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.rt-row-label {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rt-row-check {
display: inline-flex;
visibility: hidden;
color: var(--vs-ink);
}
.rt-menu-row[aria-checked="true"] .rt-row-check {
visibility: visible;
}
.rt-mini-box {
display: flex;
width: 16px;
height: 16px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border: 1px solid var(--vs-line-strong);
border-radius: 5px;
color: transparent;
}
.rt-menu-row[aria-checked="true"] .rt-mini-box {
border-color: var(--vs-accent);
background: var(--vs-accent);
color: var(--vs-on-accent);
}
.rt-menu-item {
display: flex;
width: 100%;
height: 36px;
align-items: center;
gap: 10px;
padding: 0 8px;
border-radius: 8px;
font-size: 13px;
color: var(--vs-ink);
text-align: left;
}
.rt-menu-sep {
height: 1px;
margin: 4px 0;
background: var(--vs-line);
}
.rt-menu-row:focus-visible,
.rt-menu-item:focus-visible,
.rt-action-row:focus-visible {
box-shadow: none;
outline: none;
}
/* prompt */
.rt-prompt {
min-height: 88px;
margin-top: 8px;
padding: 12px;
border-radius: 10px;
background: var(--vs-inset);
box-shadow: var(--vs-shadow-hairline);
color: var(--vs-ink);
font-size: 13px;
line-height: 1.625;
cursor: text;
outline: none;
transition: box-shadow 150ms ease-out;
}
.rt-prompt:focus {
box-shadow: 0 0 0 2px var(--vs-accent);
}
.rt-prompt:empty::before {
content: attr(data-placeholder);
color: var(--vs-ink-3);
}
.rt-go {
display: flex;
width: 100%;
height: 36px;
margin-top: 10px;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 9px;
color: var(--vs-ink);
box-shadow: var(--vs-shadow-btn);
font-size: 12.5px;
font-weight: 500;
transition: background-color 150ms ease-out, transform 150ms ease-out;
}
.rt-go:hover {
background: var(--vs-hover);
}
.rt-go:active {
transform: scale(0.98);
}
.rt-go:disabled {
opacity: 0.6;
cursor: default;
}
.rt-prop-actions {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid var(--vs-line);
}
.rt-action-row {
display: flex;
height: 32px;
align-items: center;
gap: 10px;
margin: 0 -6px;
padding: 0 6px;
border-radius: 8px;
color: var(--vs-ink);
font-size: 13px;
line-height: 1;
text-align: left;
transition: transform 150ms ease-out;
}
.rt-action-row[hidden] {
display: none;
}
.rt-action-row:active {
transform: scale(0.96);
}
.rt-action-row.is-pinned > :first-child {
color: var(--vs-accent-ink);
}
.rt-grow {
flex: 1;
}
.rt-more-chevron {
transition: transform 150ms ease-out;
}
.rt-action-row.is-open .rt-more-chevron {
transform: rotate(90deg);
}
.rt-more-settings {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--vs-line);
animation: vs-fade-up 160ms var(--vs-ease-out) both;
}
.rt-subhead {
padding-bottom: 4px;
font-size: 11.5px;
font-weight: 500;
color: var(--vs-ink-3);
}
/* touch: :hover sticks after a tap, so neutralize row/header hovers there */
@media (hover: none) {
.rt-row:hover > .rt-cell {
background: transparent;
}
.rt-row:hover > td:global(.is-colsel) {
background: color-mix(in srgb, var(--vs-accent) 4%, var(--vs-surface));
}
.rt-th:hover .rt-head {
background: transparent;
}
.rt-th:hover .rt-head-btn {
color: var(--vs-ink-2);
}
.rt-gutter .rt-checkbox {
opacity: 1;
}
.rt-rownum {
visibility: hidden;
}
}
@media (prefers-reduced-motion: reduce) {
.rt-pop.is-anim,
.rt-picker:global(.is-anim),
.rt-more-settings {
animation: none;
}
.vs-records-table :global(.rt-pulse) {
animation: none;
opacity: 1;
}
.rt-sort,
.rt-row > .rt-cell,
.rt-box {
transition: none;
}
}
@media (forced-colors: active) {
.rt-pop {
outline: 1px solid CanvasText;
}
.rt-box {
forced-color-adjust: none;
}
}
</style>