Add desktop bookmark navigation #68

Merged
eleith merged 1 commits from desktop-bookmark-navigation into main 2026-10-28 22:24:17 +00:00
5 changed files with 207 additions and 28 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
'@directory/web': patch
---
Browse bookmarks with desktop previous/next controls and arrow keys, preserving the current filter and keeping browser history tidy.
+25 -1
View File
@@ -82,6 +82,8 @@ test('focus trap, URL selection, dismissal, history and return focus work togeth
await hydrated(page);
const tile = page.locator('a[href="/?link=beta"]');
const close = page.getByRole('button', { name: 'Close', exact: true });
const previous = page.getByRole('button', { name: 'Previous bookmark', exact: true });
const next = page.getByRole('button', { name: 'Next bookmark', exact: true });
const external = page.getByRole('link', { name: /^Open Beta service/ });
const destination = page.getByRole('textbox', { name: 'Destination', exact: true });
await tile.click();
@@ -89,6 +91,8 @@ test('focus trap, URL selection, dismissal, history and return focus work togeth
await page.keyboard.press('Tab');
await expect(close).toBeFocused();
await page.keyboard.press('Tab');
await expect(previous).toBeFocused();
await page.keyboard.press('Tab');
await expect(destination).toBeFocused();
const destinationLength = (await destination.inputValue()).length;
await expect(destination).toHaveJSProperty('selectionStart', 0);
@@ -96,11 +100,15 @@ test('focus trap, URL selection, dismissal, history and return focus work togeth
await destination.click();
await expect(destination).toHaveJSProperty('selectionStart', 0);
await expect(destination).toHaveJSProperty('selectionEnd', destinationLength);
await page.keyboard.press('ArrowLeft');
await expect(page).toHaveURL(`${server.url}/?link=beta`);
await page.keyboard.press('Tab');
await expect(external).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(destination).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(previous).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(close).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(external).toBeFocused();
@@ -109,11 +117,26 @@ test('focus trap, URL selection, dismissal, history and return focus work togeth
await expect(tile).toBeFocused();
await page.goForward();
await expect(external).toBeFocused();
await expect(next).toBeDisabled();
await page.keyboard.press('ArrowRight');
await expect(page).toHaveURL(`${server.url}/?link=beta`);
await page.keyboard.press('ArrowLeft');
await expect(page).toHaveURL(`${server.url}/?link=alpha`);
await expect(previous).toBeDisabled();
await expect(next).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(page).toHaveURL(`${server.url}/?link=alpha`);
await expect(page.getByRole('heading', { name: 'Alpha service', exact: true })).toBeInViewport();
await page.goBack();
await expect(page.getByRole('dialog')).toHaveCount(0);
await expect(tile).toBeFocused();
await expect(page.locator('a[href="/?link=alpha"]')).toBeFocused();
await page.keyboard.press('ArrowRight');
await expect(page).toHaveURL(`${server.url}/`);
await page.goForward();
await expect(close).toBeVisible();
await page.keyboard.press('ArrowRight');
await expect(page).toHaveURL(`${server.url}/?link=beta`);
await expect(previous).toBeFocused();
await page.locator('[data-dialog-overlay]').click({ position: { x: 5, y: 5 } });
await expect(page).toHaveURL(`${server.url}/`);
await expect(tile).toBeFocused();
@@ -183,6 +206,7 @@ test('long details keep actions reachable on a narrow screen', async ({ page })
await page.goto(`${server.url}/?link=alpha`);
const dialog = page.getByRole('dialog', { name: 'Alpha service' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('button', { name: /^(Previous|Next) bookmark$/ })).toHaveCount(0);
const action = dialog.getByRole('link', { name: /^Open / });
await action.scrollIntoViewIfNeeded();
await expect(action).toBeInViewport();
+6
View File
@@ -54,6 +54,7 @@ test('search finds a bookmark, opens its details and clears back to browsing', a
await results.getByRole('link').press('Enter');
const details = page.getByRole('dialog', { name: 'Family photos', exact: true });
await expect(details).toBeVisible();
await expect(details.getByRole('button', { name: /^(Previous|Next) bookmark$/ })).toHaveCount(0);
await expect(
details.getByRole('link', { name: 'Open Family photos (opens in a new tab)', exact: true })
).toHaveAttribute('href', 'https://photos.example.test/');
@@ -91,5 +92,10 @@ for (const { filter, query, titles } of [
await expect(page).toHaveURL(`${server.url}/?${new URLSearchParams({ q: query })}`);
await expect(results.getByRole('link')).toHaveCount(titles.length);
await expect(results.getByRole('link')).toContainText(titles);
await results.getByRole('link').first().click();
await page.getByRole('button', { name: 'Next bookmark', exact: true }).click();
await expect(page.getByRole('dialog', { name: titles[1]!, exact: true })).toBeVisible();
await expect(page).toHaveURL((url) => url.searchParams.get('q') === query);
});
}
@@ -2,6 +2,8 @@
import { tick, untrack } from 'svelte';
import { Dialog } from 'bits-ui';
import IconX from 'virtual:icons/ph/x';
import IconCaretLeft from 'virtual:icons/ph/caret-left';
import IconCaretRight from 'virtual:icons/ph/caret-right';
import type { DisplayLink } from '$lib/display/types.js';
import BookmarkDetails from './BookmarkDetails.svelte';
import Button from '../ui/Button.svelte';
@@ -10,6 +12,9 @@
link,
groupLabel,
openLinksInNewTab,
hasPrevious,
hasNext,
onNavigate,
onClose,
onClosed,
onFilterSelect
@@ -17,6 +22,9 @@
link?: DisplayLink | undefined;
groupLabel?: string | undefined;
openLinksInNewTab: boolean;
hasPrevious: boolean;
hasNext: boolean;
onNavigate: (step: -1 | 1) => void;
onClose: () => void;
onClosed: () => void;
onFilterSelect: (query: string) => void;
@@ -26,6 +34,9 @@
untrack(() => (link ? { link, groupLabel } : undefined))
);
let openAction = $state<HTMLAnchorElement>();
let scrollContent = $state<HTMLDivElement>();
let previousButton = $state<HTMLButtonElement>();
let nextButton = $state<HTMLButtonElement>();
$effect.pre(() => {
if (link) retainedDetails = { link, groupLabel };
@@ -37,6 +48,36 @@
openAction.focus();
}
async function navigate(event: MouseEvent, step: -1 | 1) {
const button = event.currentTarget as HTMLButtonElement;
const hadFocus = button === document.activeElement;
onNavigate(step);
await tick();
if (scrollContent) scrollContent.scrollTop = 0;
if (hadFocus && button.disabled) {
(step === 1 ? previousButton : nextButton)?.focus({ preventScroll: true });
}
}
function handleNavigationKeydown(event: KeyboardEvent) {
if (!link || event.defaultPrevented || event.isComposing || event.keyCode === 229) return;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
const editing = event
.composedPath()
.some(
(target) =>
target instanceof HTMLElement &&
(target.isContentEditable || target.matches('input, textarea, select, [role="textbox"]'))
);
if (editing) return;
const button = event.key === 'ArrowLeft' ? previousButton : nextButton;
if (!button || button.disabled || !button.checkVisibility()) return;
event.preventDefault();
button.focus({ preventScroll: true });
button.click();
}
async function handleOpenChangeComplete(open: boolean) {
if (open || link || !retainedDetails) return;
retainedDetails = undefined;
@@ -63,6 +104,7 @@
{/snippet}
</Dialog.Overlay>
<Dialog.Content
onkeydown={handleNavigationKeydown}
onOpenAutoFocus={focusOpenAction}
onCloseAutoFocus={(event) => {
event.preventDefault();
@@ -70,30 +112,70 @@
>
{#snippet child({ props })}
<section {...props} class="details" inert={!link}>
<div class="scroll-content">
<Dialog.Title>
{#snippet child({ props: titleProps })}
<BookmarkDetails
link={details.link}
groupLabel={details.groupLabel}
{openLinksInNewTab}
bind:openAction
headingAttributes={titleProps}
reserveCloseSpace
{onFilterSelect}
/>
{/snippet}
</Dialog.Title>
<div class="card">
<div class="scroll-content" bind:this={scrollContent}>
<Dialog.Title aria-live="polite" aria-atomic="true">
{#snippet child({ props: titleProps })}
<BookmarkDetails
link={details.link}
groupLabel={details.groupLabel}
{openLinksInNewTab}
bind:openAction
headingAttributes={titleProps}
reserveCloseSpace
{onFilterSelect}
/>
{/snippet}
</Dialog.Title>
</div>
<div class="close">
<Dialog.Close>
{#snippet child({ props: closeProps })}
<Button {...closeProps} variant="surface" size="icon" aria-label="Close">
<IconX aria-hidden="true" focusable="false" />
</Button>
{/snippet}
</Dialog.Close>
</div>
</div>
<div class="close">
<Dialog.Close>
{#snippet child({ props: closeProps })}
<Button {...closeProps} variant="surface" size="icon" aria-label="Close">
<IconX aria-hidden="true" focusable="false" />
{#if hasPrevious || hasNext}
<div class="navigation">
<div class="previous">
<Button
size="icon"
aria-label="Previous bookmark"
aria-keyshortcuts="ArrowLeft"
disabled={!hasPrevious}
bind:element={previousButton}
onclick={(event) => navigate(event, -1)}
>
<IconCaretLeft
width="2rem"
height="2rem"
aria-hidden="true"
focusable="false"
/>
</Button>
{/snippet}
</Dialog.Close>
</div>
</div>
<div class="next">
<Button
size="icon"
aria-label="Next bookmark"
aria-keyshortcuts="ArrowRight"
disabled={!hasNext}
bind:element={nextButton}
onclick={(event) => navigate(event, 1)}
>
<IconCaretRight
width="2rem"
height="2rem"
aria-hidden="true"
focusable="false"
/>
</Button>
</div>
</div>
{/if}
</section>
{/snippet}
</Dialog.Content>
@@ -127,12 +209,20 @@
max-height: calc(100dvh - var(--space-8));
padding: 0;
color: var(--color-text);
overflow-wrap: anywhere;
}
.card {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
max-height: inherit;
background: var(--color-surface-card);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: 1.5rem 1.5rem 0 0;
box-shadow: 0 -8px 24px rgb(0 0 0 / 12%);
overflow: hidden;
overflow-wrap: anywhere;
}
.details[data-state='open'] {
@@ -158,15 +248,47 @@
z-index: 1;
}
.navigation {
display: none;
}
.navigation :global(button.ui-button) {
color: #fff;
background: transparent;
box-shadow: none;
outline-color: currentColor;
}
@media (min-width: 48rem) {
.details {
inset: 50% auto auto 50%;
width: min(32rem, calc(100vw - var(--space-8)));
transform: translate(-50%, -50%);
}
.card {
border-radius: var(--radius-card);
box-shadow: 0 1rem 3rem rgb(0 0 0 / 18%);
}
.navigation {
display: block;
}
.navigation > div {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.previous {
inset-inline-end: calc(100% + var(--space-5));
}
.next {
inset-inline-start: calc(100% + var(--space-5));
}
.details[data-state='open'] {
animation: card-fade-in 0.15s ease-out;
}
+26 -4
View File
@@ -24,6 +24,7 @@
let searchStatus = $state<'idle' | 'loading' | 'ready' | 'failed'>('idle');
let searchInput = $state<HTMLInputElement>();
let resultList = $state<HTMLUListElement>();
let groupsElement = $state<HTMLDivElement>();
let mounted = $state(false);
let isClosingDetails = false;
let pendingFilterQuery: string | undefined;
@@ -44,6 +45,12 @@
data.directory.groups.find((group) => group.links.some((link) => link.id === selectedId))
);
const selectedLink = $derived(selectedGroup?.links.find((link) => link.id === selectedId));
const detailOrder = $derived.by(() => {
if (searchQuery.trim() && searchStatus !== 'ready') return [];
if (searchResults !== null) return searchResults.map((result) => result.link.id);
return data.directory.groups.flatMap((group) => group.links.map((link) => link.id));
});
const detailIndex = $derived(detailOrder.indexOf(selectedId ?? ''));
$effect(() => {
if (selectedLink) {
@@ -68,8 +75,8 @@
return `/?${parameters}`;
}
function findResultLink(id: string) {
const links = resultList?.querySelectorAll<HTMLAnchorElement>('a.tile') ?? [];
function findBookmarkTile(id: string) {
const links = groupsElement?.querySelectorAll<HTMLAnchorElement>('a.tile') ?? [];
return Array.from(links).find((link) => new URL(link.href).searchParams.get('link') === id);
}
@@ -135,6 +142,18 @@
pushState(href, { ...page.state, directoryDetail: { id, fromGrid: true } });
}
function navigateDetails(step: -1 | 1) {
if (isClosingDetails || detailIndex < 0) return;
const id = detailOrder[detailIndex + step];
if (!id) return;
returnTile = findBookmarkTile(id);
// eslint-disable-next-line svelte/no-navigation-without-resolve -- Root-only URLs must stay absolute.
replaceState(detailURL(id), {
...page.state,
directoryDetail: { id, fromGrid: page.state.directoryDetail?.fromGrid ?? false }
});
}
function closeDetails() {
if (isClosingDetails) return;
isClosingDetails = true;
@@ -168,7 +187,7 @@
function restoreDirectoryFocus() {
if (returnTile && !returnTile.isConnected) {
const id = new URL(returnTile.href).searchParams.get('link');
if (id) returnTile = findResultLink(id);
if (id) returnTile = findBookmarkTile(id);
}
if (returnTile?.isConnected) {
returnTile.focus({ preventScroll: true });
@@ -237,6 +256,9 @@
link={selectedLink}
groupLabel={selectedGroup?.label}
openLinksInNewTab={data.directory.site.open_links_in_new_tab}
hasPrevious={detailIndex > 0}
hasNext={detailIndex >= 0 && detailIndex < detailOrder.length - 1}
onNavigate={navigateDetails}
onClose={closeDetails}
onClosed={handleDetailsClosed}
onFilterSelect={selectDetailFilter}
@@ -296,7 +318,7 @@
{/if}
</div>
<div class="groups">
<div class="groups" bind:this={groupsElement}>
{#if searchResults !== null}
{#if searchResults.length > 0}
<ul