mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 19:06:33 +00:00
feat(web): graph search fly-to + collection/status/role filters (TASK-1735) (#703)
* feat(web): graph search fly-to + collection/status/role filters (TASK-1735) Toolbar grows a type-ahead search (ref/title over the post-filter node list; ArrowUp/Down + Enter picks, Escape closes without stealing the page's deselect) that routes through the existing selectNode() — same camera fly-to, highlight, and detail card as a click. Client-side filters subset the rendered graph: collection chips with palette dots, status chips, and a role select (hidden when no node carries a role; the graph endpoint now emits the assigned agent-role slug per node). Edges survive only when both endpoints do; counts read "X of Y" while filtered. Workspace switch resets filters; show-completed doesn't. Filter changes deselect so a vanished node can't strand focus mode. New GraphToolbar.svelte owns the presentational toolbar; the page owns authoritative filter state (CONVE-1688 discipline unchanged). Parent: PLAN-1730. * fix(web): close graph search dropdown on blur per Codex review (round 1) The dropdown opened on focus/input but only closed on pick or Escape, leaving stale results floating over the canvas after clicking away. The result buttons already pick on mousedown+preventDefault, so the input never blurs mid-pick — a plain onblur close is safe. * fix(web): gate search Escape on dropdown visibility per Codex review (round 2) Escape in a focused-but-empty search now falls through to the page-level deselect instead of being swallowed by the searchOpen flag.
This commit is contained in:
@@ -20,6 +20,9 @@ type GraphNode struct {
|
||||
IsTerminal bool `json:"is_terminal"`
|
||||
ChildCount int `json:"child_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Role is the assigned agent-role slug, when set. Feeds the graph
|
||||
// view's role filter (TASK-1735).
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// GraphEdge is one typed relationship between two graph nodes. Source
|
||||
@@ -130,6 +133,7 @@ func (s *Server) handleGetWorkspaceGraph(w http.ResponseWriter, r *http.Request)
|
||||
Status: status,
|
||||
IsTerminal: terminal,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
Role: item.AgentRoleSlug,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -985,6 +985,8 @@ export interface GraphNode {
|
||||
/** number of child items (parent + implements links pointing here) */
|
||||
child_count: number;
|
||||
updated_at: string;
|
||||
/** assigned agent-role slug, when set — feeds the graph view's role filter */
|
||||
role?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import type { NodeObject, LinkObject } from '3d-force-graph';
|
||||
import type { GraphResponse, Item } from '$lib/types';
|
||||
import DetailCard from './DetailCard.svelte';
|
||||
import GraphToolbar from './GraphToolbar.svelte';
|
||||
|
||||
let wsSlug = $derived(page.params.workspace ?? '');
|
||||
let username = $derived(page.params.username ?? '');
|
||||
@@ -66,9 +67,87 @@
|
||||
// commits so a fast re-select can't be clobbered by an older response.
|
||||
let selectSeq = 0;
|
||||
|
||||
// Node-count / edge-count readout for the toolbar.
|
||||
const nodeCount = $derived(graphData?.nodes.length ?? 0);
|
||||
const edgeCount = $derived(graphData?.edges.length ?? 0);
|
||||
// ── Filters (PLAN-1730 / TASK-1735) ──────────────────────────────────────────
|
||||
// Client-side filters over the loaded payload. All three are reactive $state so
|
||||
// the renderer-sync effect (which reads `filteredData`, derived from these) re-
|
||||
// runs on any change. Empty collection/status selection === no filter on that
|
||||
// axis (matches the insights page semantics); a null role === no role filter.
|
||||
// Per CONVE-1688: these are written only from event handlers (the toolbar
|
||||
// callbacks) and a deselect-on-change effect that writes deselect()'s state but
|
||||
// never reads graphData — never read+written by the same effect.
|
||||
let filterCollections = $state<string[]>([]);
|
||||
let filterStatuses = $state<string[]>([]);
|
||||
let filterRole = $state<string | null>(null);
|
||||
|
||||
// Distinct option lists for the toolbar controls, in first-seen order so the
|
||||
// chips stay stable within a payload.
|
||||
const distinctCollections = $derived.by<string[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const n of graphData?.nodes ?? []) {
|
||||
if (!seen.has(n.collection)) {
|
||||
seen.add(n.collection);
|
||||
out.push(n.collection);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const distinctStatuses = $derived.by<string[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const n of graphData?.nodes ?? []) {
|
||||
if (n.status && !seen.has(n.status)) {
|
||||
seen.add(n.status);
|
||||
out.push(n.status);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
const distinctRoles = $derived.by<string[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const n of graphData?.nodes ?? []) {
|
||||
if (n.role && !seen.has(n.role)) {
|
||||
seen.add(n.role);
|
||||
out.push(n.role);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Any filter active? Drives the "X of Y" vs "X" count readout.
|
||||
const filtersActive = $derived(
|
||||
filterCollections.length > 0 || filterStatuses.length > 0 || filterRole !== null
|
||||
);
|
||||
|
||||
// The filtered subset fed to the renderer: nodes matching every active axis, and
|
||||
// edges where BOTH endpoints survive. The force layout re-settles on change —
|
||||
// expected and fine. When no filter is active this is graphData verbatim.
|
||||
const filteredData = $derived.by<GraphResponse | null>(() => {
|
||||
if (!graphData) return null;
|
||||
if (!filtersActive) return graphData;
|
||||
const collSet = new Set(filterCollections);
|
||||
const statusSet = new Set(filterStatuses);
|
||||
const nodes = graphData.nodes.filter((n) => {
|
||||
if (collSet.size > 0 && !collSet.has(n.collection)) return false;
|
||||
if (statusSet.size > 0 && (!n.status || !statusSet.has(n.status))) return false;
|
||||
if (filterRole !== null && n.role !== filterRole) return false;
|
||||
return true;
|
||||
});
|
||||
const surviving = new Set(nodes.map((n) => n.ref));
|
||||
const edges = graphData.edges.filter(
|
||||
(e) => surviving.has(e.source) && surviving.has(e.target)
|
||||
);
|
||||
return { nodes, edges };
|
||||
});
|
||||
|
||||
// Node/edge counts: FILTERED for the live readout, total for the "X of Y" form.
|
||||
const nodeCount = $derived(filteredData?.nodes.length ?? 0);
|
||||
const edgeCount = $derived(filteredData?.edges.length ?? 0);
|
||||
const totalNodeCount = $derived(graphData?.nodes.length ?? 0);
|
||||
const totalEdgeCount = $derived(graphData?.edges.length ?? 0);
|
||||
// Empty state keys off the unfiltered payload — a filter that hides everything is
|
||||
// the user's doing, not an empty workspace.
|
||||
const isEmpty = $derived(graphData !== null && graphData.nodes.length === 0);
|
||||
|
||||
// ── Color palette ────────────────────────────────────────────────────────────
|
||||
@@ -231,7 +310,9 @@
|
||||
// from `graphData.edges` (the source of truth, untouched by the renderer).
|
||||
function computeNeighbors(ref: string): Set<string> {
|
||||
const set = new Set<string>([ref]);
|
||||
const edges = graphData?.edges ?? [];
|
||||
// Use the FILTERED edges so the neighborhood matches what's actually rendered —
|
||||
// a filtered-out neighbor isn't on screen to highlight anyway.
|
||||
const edges = filteredData?.edges ?? [];
|
||||
for (const e of edges) {
|
||||
if (e.source === ref) set.add(e.target);
|
||||
else if (e.target === ref) set.add(e.source);
|
||||
@@ -266,6 +347,37 @@
|
||||
void loadSelectedItem(node.ref);
|
||||
}
|
||||
|
||||
// ── Filter toggles (TASK-1735) ───────────────────────────────────────────────
|
||||
// Event-handler writes to the filter $state (CONVE-1688: no effect reads+writes
|
||||
// these). The deselect-on-filter-change effect handles clearing a now-hidden
|
||||
// selection; the filteredData derived + sync effect handle the re-render.
|
||||
function toggleCollectionFilter(slug: string) {
|
||||
filterCollections = filterCollections.includes(slug)
|
||||
? filterCollections.filter((s) => s !== slug)
|
||||
: [...filterCollections, slug];
|
||||
}
|
||||
function toggleStatusFilter(status: string) {
|
||||
filterStatuses = filterStatuses.includes(status)
|
||||
? filterStatuses.filter((s) => s !== status)
|
||||
: [...filterStatuses, status];
|
||||
}
|
||||
function selectRoleFilter(role: string | null) {
|
||||
filterRole = role;
|
||||
}
|
||||
|
||||
// Search fly-to (TASK-1735). The toolbar emits the chosen ref; resolve it to the
|
||||
// LIVE renderer node — `graph.graphData().nodes` carry the current x/y/z the
|
||||
// camera math in selectNode() needs (our static GraphResponse nodes don't). If
|
||||
// the node isn't found (shouldn't happen — search lists POST-filter nodes that
|
||||
// are by definition in the renderer), no-op gracefully.
|
||||
function flyToRef(ref: string) {
|
||||
if (!graph) return;
|
||||
const nodes = (graph.graphData()?.nodes ?? []) as NodeObject[];
|
||||
const match = nodes.find((n) => asNode(n).ref === ref);
|
||||
if (!match) return;
|
||||
selectNode(asNode(match));
|
||||
}
|
||||
|
||||
async function loadSelectedItem(ref: string) {
|
||||
const seq = ++selectSeq;
|
||||
selectedItem = null;
|
||||
@@ -332,12 +444,37 @@
|
||||
// Drop the previous workspace's graph so it doesn't linger under the new
|
||||
// URL while the fetch is in flight — `loading` covers the gap.
|
||||
graphData = null;
|
||||
// A workspace switch brings a different collection/status/role universe, so
|
||||
// any prior selection would over-filter (none of B's slugs match A's). Reset
|
||||
// to no-filter. NOT done on a show-completed toggle: that's the SAME
|
||||
// workspace with a superset payload, where keeping filters is the right call.
|
||||
// These writes are filter $state this effect doesn't read — and the
|
||||
// filteredData/sync effects don't write them — so it stays CONVE-1688-clean.
|
||||
filterCollections = [];
|
||||
filterStatuses = [];
|
||||
filterRole = null;
|
||||
}
|
||||
if (slug) {
|
||||
void loadGraph(slug, withTerminal);
|
||||
}
|
||||
});
|
||||
|
||||
// Deselect whenever the filters change: the selected node may have just been
|
||||
// filtered out, in which case the dim/highlight + detail card would reference a
|
||||
// node that's no longer rendered. Reads the three filter $states (reactive) and
|
||||
// calls deselect() (which writes selection $state but never reads graphData or
|
||||
// the filter states) — so this effect never read+writes the same $state, keeping
|
||||
// it CONVE-1688-clean. Separate from the renderer-sync effect on purpose: that
|
||||
// one must NOT call deselect (it reads filteredData, and deselect writes selection
|
||||
// state — mixing them risks a read/write cycle on shared state).
|
||||
$effect(() => {
|
||||
// Track the filter axes.
|
||||
filterCollections;
|
||||
filterStatuses;
|
||||
filterRole;
|
||||
deselect();
|
||||
});
|
||||
|
||||
// Push freshly-loaded data into the renderer once both are ready. Reads
|
||||
// graphData (reactive) + rendererReady (reactive); writes only the imperative
|
||||
// `graph` handle and the plain `collectionColors` map, never a tracked $state.
|
||||
@@ -345,10 +482,19 @@
|
||||
// canvas too — otherwise the previous workspace's nodes linger behind the
|
||||
// loading overlay (Codex round-1 finding #1).
|
||||
$effect(() => {
|
||||
const data = graphData;
|
||||
// Feed the FILTERED subset (TASK-1735) — not the raw payload. filteredData is
|
||||
// graphData verbatim when no filter is active, so the unfiltered path is
|
||||
// unchanged. Reads filteredData (reactive, derived from graphData + the filter
|
||||
// $states); writes only the imperative `graph` handle + the plain
|
||||
// collectionColors map, never a tracked $state — CONVE-1688-clean.
|
||||
const data = filteredData;
|
||||
if (!rendererReady || !graph) return;
|
||||
// Reset color assignment so collection→color stays stable per payload.
|
||||
// Reset color assignment so collection→color stays stable per payload. Built
|
||||
// from the FULL node list (not the filtered subset) so a collection keeps its
|
||||
// hue whether or not the current filter happens to include it — the filter
|
||||
// chips and the rendered nodes must agree on color.
|
||||
collectionColors = {};
|
||||
for (const n of graphData?.nodes ?? []) colorForCollection(n.collection);
|
||||
graph.graphData({
|
||||
nodes: data ? data.nodes.map((n) => ({ ...n, id: n.ref, name: n.title })) : [],
|
||||
links: data
|
||||
@@ -402,6 +548,7 @@
|
||||
title: string;
|
||||
collection: string;
|
||||
status?: string;
|
||||
role?: string;
|
||||
is_terminal: boolean;
|
||||
child_count: number;
|
||||
updated_at: string;
|
||||
@@ -425,18 +572,27 @@
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div class="graph-page">
|
||||
<!-- Controls overlay (top-left) -->
|
||||
<div class="toolbar">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" bind:checked={showCompleted} />
|
||||
<span>Show completed</span>
|
||||
</label>
|
||||
<span class="counts">
|
||||
<span class="count">{nodeCount} node{nodeCount === 1 ? '' : 's'}</span>
|
||||
<span class="count-sep">·</span>
|
||||
<span class="count">{edgeCount} edge{edgeCount === 1 ? '' : 's'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Controls overlay (top-left): toggle + filters + search fly-to. -->
|
||||
<GraphToolbar
|
||||
bind:showCompleted
|
||||
{nodeCount}
|
||||
{edgeCount}
|
||||
{totalNodeCount}
|
||||
{totalEdgeCount}
|
||||
filtered={filtersActive}
|
||||
collections={distinctCollections}
|
||||
statuses={distinctStatuses}
|
||||
roles={distinctRoles}
|
||||
selectedCollections={filterCollections}
|
||||
selectedStatuses={filterStatuses}
|
||||
selectedRole={filterRole}
|
||||
searchNodes={filteredData?.nodes ?? []}
|
||||
{colorForCollection}
|
||||
ontogglecollection={toggleCollectionFilter}
|
||||
ontogglestatus={toggleStatusFilter}
|
||||
onselectrole={selectRoleFilter}
|
||||
onsearchpick={flyToRef}
|
||||
/>
|
||||
|
||||
<!-- The renderer mounts here; it owns its own canvas. -->
|
||||
<div class="canvas" bind:this={containerEl}></div>
|
||||
@@ -499,47 +655,6 @@
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* ── Toolbar ──────────────────────────────────────────────────────────────── */
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
left: var(--space-4);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.toggle input {
|
||||
cursor: pointer;
|
||||
}
|
||||
.counts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.78em;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.count-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── State overlays ───────────────────────────────────────────────────────── */
|
||||
.overlay {
|
||||
position: absolute;
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
<script lang="ts">
|
||||
// Toolbar for the 3D workspace graph (PLAN-1730 / TASK-1735).
|
||||
//
|
||||
// Owns the show-completed toggle, the filter controls (collection chips, status
|
||||
// chips, optional role dropdown), the filtered node/edge readout, and the search
|
||||
// fly-to box. It is PURE PRESENTATION + LOCAL UI STATE: the page owns the
|
||||
// authoritative filter $state (read by its renderer-sync effect) and the
|
||||
// selection machinery. This component receives the current filter values + the
|
||||
// distinct option lists, and emits callbacks the page applies.
|
||||
//
|
||||
// Styling mirrors the page's old inline .toolbar (backdrop-blur, color-mix
|
||||
// surfaces) so the control layer reads as the same UI.
|
||||
|
||||
// Minimal node shape the search box needs — a structural subset of the page's
|
||||
// GraphNode3D so the page can pass its mapped/filtered nodes straight through.
|
||||
interface SearchNode {
|
||||
ref: string;
|
||||
title: string;
|
||||
collection: string;
|
||||
}
|
||||
|
||||
let {
|
||||
// show-completed toggle (two-way bound to the page).
|
||||
showCompleted = $bindable(),
|
||||
// Filtered vs total counts for the readout.
|
||||
nodeCount,
|
||||
edgeCount,
|
||||
totalNodeCount,
|
||||
totalEdgeCount,
|
||||
filtered,
|
||||
// Distinct option lists, derived by the page from the loaded payload.
|
||||
collections,
|
||||
statuses,
|
||||
roles,
|
||||
// Current filter selections (page-owned $state, passed down read-only).
|
||||
selectedCollections,
|
||||
selectedStatuses,
|
||||
selectedRole,
|
||||
// POST-filter nodes, for the search type-ahead.
|
||||
searchNodes,
|
||||
// Palette accessor — shared with the renderer so chips match node colors.
|
||||
colorForCollection,
|
||||
// Filter callbacks.
|
||||
ontogglecollection,
|
||||
ontogglestatus,
|
||||
onselectrole,
|
||||
// Search fly-to: page resolves the ref to a live renderer node + selects it.
|
||||
onsearchpick
|
||||
}: {
|
||||
showCompleted: boolean;
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
totalNodeCount: number;
|
||||
totalEdgeCount: number;
|
||||
filtered: boolean;
|
||||
collections: string[];
|
||||
statuses: string[];
|
||||
roles: string[];
|
||||
selectedCollections: string[];
|
||||
selectedStatuses: string[];
|
||||
selectedRole: string | null;
|
||||
searchNodes: SearchNode[];
|
||||
colorForCollection: (slug: string) => string;
|
||||
ontogglecollection: (slug: string) => void;
|
||||
ontogglestatus: (status: string) => void;
|
||||
onselectrole: (role: string | null) => void;
|
||||
onsearchpick: (ref: string) => void;
|
||||
} = $props();
|
||||
|
||||
// ── Filters disclosure ──────────────────────────────────────────────────────
|
||||
// Collapsed by default to keep the toolbar tidy; a count badge hints at active
|
||||
// filters without expanding.
|
||||
let filtersOpen = $state(false);
|
||||
const activeFilterCount = $derived(
|
||||
selectedCollections.length + selectedStatuses.length + (selectedRole ? 1 : 0)
|
||||
);
|
||||
|
||||
// ── Search box (local UI state only) ─────────────────────────────────────────
|
||||
let query = $state('');
|
||||
let searchOpen = $state(false);
|
||||
// Active descendant in the type-ahead list, -1 when none. Reset on every fresh
|
||||
// match set so the highlight never points past the list.
|
||||
let highlight = $state(-1);
|
||||
|
||||
// Case-insensitive match on ref or title, capped at 8 results. $derived so the
|
||||
// list tracks both the query and any payload/filter change underneath it.
|
||||
const MAX_RESULTS = 8;
|
||||
const matches = $derived.by<SearchNode[]>(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const out: SearchNode[] = [];
|
||||
for (const n of searchNodes) {
|
||||
if (n.ref.toLowerCase().includes(q) || n.title.toLowerCase().includes(q)) {
|
||||
out.push(n);
|
||||
if (out.length >= MAX_RESULTS) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Dropdown is visible only when focused AND there are matches to show.
|
||||
const dropdownVisible = $derived(searchOpen && matches.length > 0);
|
||||
|
||||
function pick(ref: string) {
|
||||
onsearchpick(ref);
|
||||
query = '';
|
||||
searchOpen = false;
|
||||
highlight = -1;
|
||||
}
|
||||
|
||||
function onInput() {
|
||||
searchOpen = true;
|
||||
// Reset the active row whenever the query changes; the match set just shifted.
|
||||
highlight = matches.length > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
// Keyboard nav. Escape closes the dropdown WITHOUT bubbling to the page's
|
||||
// window-level Escape-deselect handler — but only when the dropdown is open, so
|
||||
// a stray Escape on an empty/closed box still reaches the page (CONVE-639 spirit).
|
||||
function onSearchKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (!dropdownVisible) return;
|
||||
e.preventDefault();
|
||||
highlight = (highlight + 1) % matches.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
if (!dropdownVisible) return;
|
||||
e.preventDefault();
|
||||
highlight = (highlight - 1 + matches.length) % matches.length;
|
||||
} else if (e.key === 'Enter') {
|
||||
if (!dropdownVisible) return;
|
||||
e.preventDefault();
|
||||
const target = highlight >= 0 ? matches[highlight] : matches[0];
|
||||
if (target) pick(target.ref);
|
||||
} else if (e.key === 'Escape') {
|
||||
// Gate on the dropdown actually being VISIBLE (not just searchOpen):
|
||||
// Escape in a focused-but-empty search should fall through to the
|
||||
// page-level deselect handler (Codex PR #703 round 2).
|
||||
if (dropdownVisible) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
searchOpen = false;
|
||||
highlight = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="row primary">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" bind:checked={showCompleted} />
|
||||
<span>Show completed</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="filters-btn"
|
||||
class:open={filtersOpen}
|
||||
aria-expanded={filtersOpen}
|
||||
onclick={() => (filtersOpen = !filtersOpen)}
|
||||
>
|
||||
Filters{#if activeFilterCount > 0}<span class="badge">{activeFilterCount}</span>{/if}
|
||||
</button>
|
||||
|
||||
<span class="counts">
|
||||
{#if filtered}
|
||||
<span class="count">{nodeCount} of {totalNodeCount} nodes</span>
|
||||
<span class="count-sep">·</span>
|
||||
<span class="count">{edgeCount} of {totalEdgeCount} edges</span>
|
||||
{:else}
|
||||
<span class="count">{nodeCount} node{nodeCount === 1 ? '' : 's'}</span>
|
||||
<span class="count-sep">·</span>
|
||||
<span class="count">{edgeCount} edge{edgeCount === 1 ? '' : 's'}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Search fly-to. The wrapper is relative so the type-ahead anchors to it. -->
|
||||
<div class="row search-row">
|
||||
<div class="search">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Search items…"
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
onfocus={() => (searchOpen = true)}
|
||||
onblur={() => (searchOpen = false)}
|
||||
onkeydown={onSearchKeydown}
|
||||
role="combobox"
|
||||
aria-expanded={dropdownVisible}
|
||||
aria-controls="graph-search-list"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
{#if dropdownVisible}
|
||||
<ul class="search-results" id="graph-search-list" role="listbox">
|
||||
{#each matches as m, i (m.ref)}
|
||||
<li role="option" aria-selected={i === highlight}>
|
||||
<button
|
||||
type="button"
|
||||
class="result"
|
||||
class:active={i === highlight}
|
||||
onmousedown={(e) => {
|
||||
// mousedown (not click) so the pick fires before the input's
|
||||
// blur closes the dropdown out from under it.
|
||||
e.preventDefault();
|
||||
pick(m.ref);
|
||||
}}
|
||||
onmouseenter={() => (highlight = i)}
|
||||
>
|
||||
<span
|
||||
class="dot"
|
||||
style:background-color={colorForCollection(m.collection)}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="result-ref">{m.ref}</span>
|
||||
<span class="result-title">{m.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if filtersOpen}
|
||||
<div class="row filters">
|
||||
{#if collections.length > 0}
|
||||
<div class="filter-group" role="group" aria-label="Filter by collection">
|
||||
<span class="filter-label">Collection</span>
|
||||
<div class="chips">
|
||||
{#each collections as slug (slug)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
class:active={selectedCollections.includes(slug)}
|
||||
aria-pressed={selectedCollections.includes(slug)}
|
||||
onclick={() => ontogglecollection(slug)}
|
||||
>
|
||||
<span
|
||||
class="dot"
|
||||
style:background-color={colorForCollection(slug)}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{slug}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if statuses.length > 0}
|
||||
<div class="filter-group" role="group" aria-label="Filter by status">
|
||||
<span class="filter-label">Status</span>
|
||||
<div class="chips">
|
||||
{#each statuses as status (status)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
class:active={selectedStatuses.includes(status)}
|
||||
aria-pressed={selectedStatuses.includes(status)}
|
||||
onclick={() => ontogglestatus(status)}
|
||||
>
|
||||
{status}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if roles.length > 0}
|
||||
<div class="filter-group">
|
||||
<span class="filter-label" id="graph-role-label">Role</span>
|
||||
<select
|
||||
class="role-select"
|
||||
aria-labelledby="graph-role-label"
|
||||
value={selectedRole ?? ''}
|
||||
onchange={(e) => onselectrole(e.currentTarget.value || null)}
|
||||
>
|
||||
<option value="">All roles</option>
|
||||
{#each roles as role (role)}
|
||||
<option value={role}>{role}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
left: var(--space-4);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
max-width: min(28rem, calc(100% - var(--space-8)));
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.row.primary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.toggle input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filters-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.filters-btn:hover,
|
||||
.filters-btn.open {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
padding: 0 0.3rem;
|
||||
font-size: 0.85em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--btn-primary-text, #fff);
|
||||
background: var(--accent, #6366f1);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.counts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.78em;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.count-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ── Search ───────────────────────────────────────────────────────────────── */
|
||||
.search-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
.search {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: 0.82em;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary, #0a0a1a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 11;
|
||||
margin: 0;
|
||||
padding: var(--space-1);
|
||||
list-style: none;
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 96%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.result.active {
|
||||
background: color-mix(in srgb, var(--accent, #6366f1) 18%, transparent);
|
||||
}
|
||||
.result-ref {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.result-title {
|
||||
font-size: 0.78em;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Filters ──────────────────────────────────────────────────────────────── */
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 0.7em;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: 0.78em;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.chip.active {
|
||||
background: color-mix(in srgb, var(--accent, #6366f1) 15%, transparent);
|
||||
border-color: var(--accent, #6366f1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.role-select {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: 0.8em;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user