Skip to content

Commit 61deade

Browse files
authored
refactor(ui): establish reusable components (#22)
## What changed - establish reusable Button, Dialog, Menu, and Pill primitives - refresh the compact interface with an OpenCode-inspired visual language - clarify selected-scope Probe and Export actions without a count or Clear control - improve keyboard row selection, context menus, and focus behavior ## Stack Depends on the release-notes PR. Review this PR by commit or with its base branch. ## Verification - `bun run check` - `bun run build`
1 parent 2c29df8 commit 61deade

15 files changed

Lines changed: 418 additions & 221 deletions

src/app.css

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
@import "tailwindcss";
22

33
@theme {
4-
--color-bg: #131010;
5-
--color-raised: #1a1616;
6-
--color-line: #2a2424;
7-
--color-text: #f1ecec;
8-
--color-muted: #b7b1b1;
9-
--color-faint: #7c7575;
10-
--color-accent: #89b5ff;
4+
--color-bg: #111213;
5+
--color-raised: #191a1c;
6+
--color-line: #2b2d30;
7+
--color-text: #f1f1ee;
8+
--color-muted: #b6b7b8;
9+
--color-faint: #92959a;
10+
--color-accent: #78a9ff;
1111
--color-good: #8ec07c;
1212
--color-bad: #e0866b;
13+
--radius-control: 6px;
14+
--radius-surface: 10px;
15+
--ease-ui: 140ms ease;
1316
}
1417

1518
html,
@@ -19,6 +22,7 @@ body {
1922
overflow: hidden;
2023
background: var(--color-bg);
2124
color: var(--color-text);
25+
font-family: "SF Mono", "Cascadia Code", "Roboto Mono", monospace;
2226
}
2327

2428
table {
@@ -33,3 +37,13 @@ table {
3337
.chip-strip::-webkit-scrollbar {
3438
display: none;
3539
}
40+
41+
@media (prefers-reduced-motion: no-preference) {
42+
button,
43+
input,
44+
[role="menu"],
45+
[role="dialog"] {
46+
transition: color var(--ease-ui), background-color var(--ease-ui), border-color var(--ease-ui),
47+
box-shadow var(--ease-ui), opacity var(--ease-ui);
48+
}
49+
}

src/lib/ExportMenu.svelte

Lines changed: 6 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,10 @@
11
<script lang="ts">
2-
let { locked, onAycd, onDismiss }: { locked: boolean; onAycd: () => void; onDismiss: () => void } = $props();
2+
import MenuItem from "./ui/MenuItem.svelte";
3+
import MenuSurface from "./ui/MenuSurface.svelte";
34
4-
let item = $state<HTMLButtonElement | null>(null);
5-
6-
$effect(() => {
7-
item?.focus();
8-
});
5+
let { locked, trigger, onAycd, onDismiss }: { locked: boolean; trigger: HTMLElement | null; onAycd: () => void; onDismiss: () => void } = $props();
96
</script>
107

11-
<div
12-
class="absolute right-0 top-full z-30 mt-1 min-w-48 rounded-md border border-line bg-raised py-1 text-xs text-text shadow-none"
13-
role="menu"
14-
tabindex="-1"
15-
onkeydown={(event) => {
16-
if (event.key === "Escape") {
17-
event.preventDefault();
18-
onDismiss();
19-
} else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
20-
event.preventDefault();
21-
item?.focus();
22-
}
23-
}}
24-
>
25-
<button
26-
bind:this={item}
27-
type="button"
28-
class="block w-full px-3 py-1.5 text-left hover:bg-bg disabled:opacity-40"
29-
disabled={locked}
30-
role="menuitem"
31-
onclick={onAycd}
32-
>
33-
Export for AYCD (.json)
34-
</button>
35-
</div>
8+
<MenuSurface class="absolute right-0 top-full mt-1 min-w-48" {trigger} {onDismiss}>
9+
<MenuItem disabled={locked} onclick={onAycd}>Export for AYCD (.json)</MenuItem>
10+
</MenuSurface>

src/lib/Mark.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
2+
import Pill from "./ui/Pill.svelte";
3+
24
let { value }: { value: string } = $props();
35
</script>
46

5-
<span class="inline-flex h-[22px] max-w-full items-center truncate rounded-md border border-line bg-raised px-1.5 font-mono text-xs text-text">
6-
{value}
7-
</span>
7+
<Pill tone="country">{value}</Pill>

src/lib/RowMenu.svelte

Lines changed: 13 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
<script lang="ts">
2+
import MenuItem from "./ui/MenuItem.svelte";
3+
import MenuSurface from "./ui/MenuSurface.svelte";
4+
25
let {
36
x,
47
y,
8+
trigger,
59
locked,
610
onProbe,
711
onExport,
@@ -11,6 +15,7 @@
1115
}: {
1216
x: number;
1317
y: number;
18+
trigger: HTMLElement | null;
1419
locked: boolean;
1520
onProbe: () => void;
1621
onExport: () => void;
@@ -23,64 +28,19 @@
2328
2429
$effect(() => {
2530
const node = root;
26-
if (!node) {
27-
return;
28-
}
31+
if (!node) return;
2932
const left = Math.min(x, window.innerWidth - node.offsetWidth - 8);
3033
const top = Math.min(y, window.innerHeight - node.offsetHeight - 8);
3134
node.style.left = `${Math.max(8, left)}px`;
3235
node.style.top = `${Math.max(8, top)}px`;
33-
node.focus();
3436
});
3537
</script>
3638

37-
<div
38-
bind:this={root}
39-
class="fixed z-30 min-w-32 rounded-md border border-line bg-raised py-1 text-xs text-text shadow-none"
40-
style="left: {x}px; top: {y}px"
41-
role="menu"
42-
tabindex="-1"
43-
onkeydown={(event) => {
44-
if (event.key === "Escape") {
45-
event.preventDefault();
46-
onDismiss();
47-
}
48-
}}
49-
>
50-
<button
51-
type="button"
52-
class="block w-full px-3 py-1.5 text-left hover:bg-bg disabled:opacity-40"
53-
disabled={locked}
54-
role="menuitem"
55-
onclick={onProbe}
56-
>
57-
Probe
58-
</button>
59-
<button
60-
type="button"
61-
class="block w-full px-3 py-1.5 text-left hover:bg-bg disabled:opacity-40"
62-
disabled={locked}
63-
role="menuitem"
64-
onclick={onExport}
65-
>
66-
Export
67-
</button>
68-
<button
69-
type="button"
70-
class="block w-full px-3 py-1.5 text-left hover:bg-bg disabled:opacity-40"
71-
disabled={locked}
72-
role="menuitem"
73-
onclick={onExportAycd}
74-
>
75-
Export for AYCD (.json)
76-
</button>
77-
<button
78-
type="button"
79-
class="block w-full px-3 py-1.5 text-left text-bad hover:bg-bg disabled:opacity-40"
80-
disabled={locked}
81-
role="menuitem"
82-
onclick={onRemove}
83-
>
84-
Remove
85-
</button>
39+
<div bind:this={root} class="fixed z-30" style="left: {x}px; top: {y}px">
40+
<MenuSurface class="min-w-32" {trigger} {onDismiss}>
41+
<MenuItem disabled={locked} onclick={onProbe}>Probe</MenuItem>
42+
<MenuItem disabled={locked} onclick={onExport}>Export</MenuItem>
43+
<MenuItem disabled={locked} onclick={onExportAycd}>Export for AYCD (.json)</MenuItem>
44+
<MenuItem disabled={locked} danger onclick={onRemove}>Remove</MenuItem>
45+
</MenuSurface>
8646
</div>

src/lib/SelectionActions.svelte

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<script lang="ts">
2+
import Button from "./ui/Button.svelte";
3+
import MenuItem from "./ui/MenuItem.svelte";
4+
import MenuSurface from "./ui/MenuSurface.svelte";
5+
6+
let { locked, onExport, onExportAycd, onProbe }: { locked: boolean; onExport: () => void; onExportAycd: () => void; onProbe: () => void } = $props();
7+
8+
let menuOpen = $state(false);
9+
let split = $state<HTMLDivElement | null>(null);
10+
let trigger = $state<HTMLButtonElement | null>(null);
11+
12+
$effect(() => {
13+
if (!menuOpen) return;
14+
function dismiss(event: MouseEvent) {
15+
if (split && event.target instanceof Node && !split.contains(event.target)) menuOpen = false;
16+
}
17+
window.addEventListener("click", dismiss);
18+
return () => window.removeEventListener("click", dismiss);
19+
});
20+
21+
function closeMenu() {
22+
menuOpen = false;
23+
}
24+
25+
function exportText() {
26+
closeMenu();
27+
onExport();
28+
}
29+
30+
function probe() {
31+
closeMenu();
32+
onProbe();
33+
}
34+
</script>
35+
36+
<div class="flex items-center gap-1.5">
37+
<div bind:this={split} class="relative flex">
38+
<Button variant="outline" joined="start" disabled={locked} onclick={exportText}>Export</Button>
39+
<Button bind:ref={trigger} variant="outline" size="narrowIcon" joined="end" aria-label="More export options" aria-expanded={menuOpen} aria-haspopup="menu" disabled={locked} onclick={() => (menuOpen = !menuOpen)}>▾</Button>
40+
{#if menuOpen}
41+
<MenuSurface class="absolute right-0 top-full mt-1 min-w-48" {trigger} onDismiss={closeMenu}>
42+
<MenuItem disabled={locked} onclick={() => { closeMenu(); onExportAycd(); }}>Export for AYCD (.json)</MenuItem>
43+
</MenuSurface>
44+
{/if}
45+
</div>
46+
<Button variant="primary" disabled={locked} onclick={probe}>Probe selected</Button>
47+
</div>

src/lib/SubnetCell.svelte

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
<script lang="ts">
2-
import Mark from "./Mark.svelte";
3-
42
let { cidr }: { cidr: string } = $props();
53
</script>
64

7-
<Mark value={cidr} />
5+
<span class="font-mono text-xs text-text">{cidr}</span>

src/lib/SubnetTable.svelte

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,12 @@
245245
untrack(() => table.setPageIndex(0));
246246
});
247247
248+
$effect(() => {
249+
if (selectedCidrs.size === 0) {
250+
rangeAnchor = null;
251+
}
252+
});
253+
248254
$effect(() => {
249255
const pageCount = Math.max(1, table.getPageCount());
250256
if (pagination.pageIndex >= pageCount) {
@@ -284,12 +290,53 @@
284290
return () => window.removeEventListener("keydown", selectByKeyboard);
285291
});
286292
287-
let menu = $state<{ cidr: string; x: number; y: number } | null>(null);
293+
let menu = $state<{ cidr: string; x: number; y: number; trigger: HTMLElement | null } | null>(null);
288294
let rangeAnchor = $state<string | null>(null);
289295
290296
function openMenu(event: MouseEvent, cidr: string) {
291297
event.preventDefault();
292-
menu = { cidr, x: event.clientX, y: event.clientY };
298+
menu = { cidr, x: event.clientX, y: event.clientY, trigger: event.currentTarget as HTMLElement };
299+
}
300+
301+
function selectRowByKeyboard(event: KeyboardEvent, cidr: string) {
302+
if (isInteractive(event.target)) {
303+
return;
304+
}
305+
if (event.key !== "Enter" && event.key !== " ") {
306+
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
307+
event.preventDefault();
308+
const trigger = event.currentTarget as HTMLElement;
309+
const bounds = trigger.getBoundingClientRect();
310+
menu = { cidr, x: bounds.left + Math.min(24, bounds.width / 2), y: bounds.top + Math.min(24, bounds.height / 2), trigger };
311+
}
312+
return;
313+
}
314+
event.preventDefault();
315+
const modifier = event.metaKey || event.ctrlKey;
316+
if (event.shiftKey && rangeAnchor) {
317+
const start = orderedRows.findIndex((row) => row.original.cidr === rangeAnchor);
318+
const end = orderedRows.findIndex((row) => row.original.cidr === cidr);
319+
if (start >= 0 && end >= 0) {
320+
const range = orderedRows
321+
.slice(Math.min(start, end), Math.max(start, end) + 1)
322+
.map((row) => row.original.cidr);
323+
onSelectionChange(modifier ? new Set([...selectedCidrs, ...range]) : new Set(range));
324+
return;
325+
}
326+
}
327+
const next = new Set(selectedCidrs);
328+
if (modifier || selectedCidrs.has(cidr)) {
329+
if (next.has(cidr)) {
330+
next.delete(cidr);
331+
} else {
332+
next.add(cidr);
333+
}
334+
} else {
335+
next.clear();
336+
next.add(cidr);
337+
}
338+
onSelectionChange(next);
339+
rangeAnchor = cidr;
293340
}
294341
295342
function selectRow(event: MouseEvent, cidr: string) {
@@ -459,12 +506,13 @@
459506
<div class="flex h-8 items-center">
460507
{#if header.column.getCanFilter()}
461508
<input
462-
class="block h-6 w-full rounded-md bg-raised px-1 text-xs font-normal text-text focus:outline-none focus:ring-1 focus:ring-accent {numericIds.has(
509+
class="block h-6 w-full rounded-[var(--radius-control)] border border-transparent bg-raised px-1 text-xs font-normal text-text focus:border-line focus:outline-none focus:ring-1 focus:ring-accent {numericIds.has(
463510
header.column.id,
464511
)
465512
? 'text-right'
466513
: ''}"
467514
value={String(header.column.getFilterValue() ?? "")}
515+
aria-label={`Filter ${String(header.column.columnDef.header)}`}
468516
oninput={(event) => {
469517
header.column.setFilterValue(event.currentTarget.value);
470518
table.setPageIndex(0);
@@ -485,10 +533,12 @@
485533
<tr
486534
class="group h-8 border-t border-line {selectedCidrs.has(row.original.cidr)
487535
? 'bg-accent/10 shadow-[inset_3px_0_0_var(--color-accent)] hover:bg-accent/15'
488-
: 'hover:bg-raised'}"
536+
: 'hover:bg-raised/80'} focus-visible:relative focus-visible:z-10 focus-visible:outline-none focus-visible:shadow-[inset_0_0_0_1px_var(--color-accent)]"
537+
tabindex="0"
489538
aria-selected={selectedCidrs.has(row.original.cidr)}
490539
onclick={(event) => selectRow(event, row.original.cidr)}
491540
oncontextmenu={(event) => openMenu(event, row.original.cidr)}
541+
onkeydown={(event) => selectRowByKeyboard(event, row.original.cidr)}
492542
>
493543
{#each row.getAllCells() as cell (cell.id)}
494544
<td
@@ -527,6 +577,7 @@
527577
<RowMenu
528578
x={menu.x}
529579
y={menu.y}
580+
trigger={menu.trigger}
530581
{locked}
531582
onProbe={() => {
532583
const cidr = menu?.cidr;

src/lib/TagsCell.svelte

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424
{#each tags as tag}
2525
<button
2626
type="button"
27-
class="inline-flex h-[22px] max-w-[104px] shrink-0 items-center rounded-md border border-line bg-raised px-1.5 font-mono text-xs text-text hover:text-muted disabled:opacity-40"
27+
class="inline-flex h-[22px] max-w-[104px] shrink-0 items-center rounded-[var(--radius-control)] border border-line bg-raised px-1.5 font-mono text-xs text-text hover:border-muted hover:text-muted disabled:opacity-40"
2828
disabled={locked}
2929
title={tag}
30+
aria-label={`Remove tag ${tag}`}
3031
onclick={() => onRemove(cidr, tag)}
3132
>
3233
<span class="truncate">{tag}</span>
@@ -35,8 +36,9 @@
3536
{/each}
3637
</div>
3738
<input
38-
class="h-6 w-20 shrink-0 rounded-md bg-transparent px-1 text-xs placeholder:text-faint focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-40"
39+
class="h-6 w-20 shrink-0 rounded-[var(--radius-control)] bg-transparent px-1 text-xs placeholder:text-faint focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-40"
3940
placeholder="add"
41+
aria-label={`Add tag to ${cidr}`}
4042
disabled={locked}
4143
bind:value={draft[cidr]}
4244
onkeydown={(event) => {

0 commit comments

Comments
 (0)