From 269205a29b4ed387ea7ad79ee81eada7795990a7 Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 28 Oct 2026 14:52:03 -0700 Subject: [PATCH] Add desktop bookmark navigation --- .changeset/desktop-bookmark-navigation.md | 5 + apps/web/e2e/details-dialog.e2e.ts | 26 ++- apps/web/e2e/search.e2e.ts | 6 + .../bookmarks/BookmarkDetailsDialog.svelte | 168 +++++++++++++++--- apps/web/src/routes/(protected)/+page.svelte | 30 +++- 5 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 .changeset/desktop-bookmark-navigation.md diff --git a/.changeset/desktop-bookmark-navigation.md b/.changeset/desktop-bookmark-navigation.md new file mode 100644 index 0000000..93a3985 --- /dev/null +++ b/.changeset/desktop-bookmark-navigation.md @@ -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. diff --git a/apps/web/e2e/details-dialog.e2e.ts b/apps/web/e2e/details-dialog.e2e.ts index 77f188b..8ae540a 100644 --- a/apps/web/e2e/details-dialog.e2e.ts +++ b/apps/web/e2e/details-dialog.e2e.ts @@ -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(); diff --git a/apps/web/e2e/search.e2e.ts b/apps/web/e2e/search.e2e.ts index 779bf69..470ba6e 100644 --- a/apps/web/e2e/search.e2e.ts +++ b/apps/web/e2e/search.e2e.ts @@ -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); }); } diff --git a/apps/web/src/lib/components/bookmarks/BookmarkDetailsDialog.svelte b/apps/web/src/lib/components/bookmarks/BookmarkDetailsDialog.svelte index 4d83f4b..70b9c47 100644 --- a/apps/web/src/lib/components/bookmarks/BookmarkDetailsDialog.svelte +++ b/apps/web/src/lib/components/bookmarks/BookmarkDetailsDialog.svelte @@ -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(); + let scrollContent = $state(); + let previousButton = $state(); + let nextButton = $state(); $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} { event.preventDefault(); @@ -70,30 +112,70 @@ > {#snippet child({ props })}
-
- - {#snippet child({ props: titleProps })} - - {/snippet} - +
+
+ + {#snippet child({ props: titleProps })} + + {/snippet} + +
+
+ + {#snippet child({ props: closeProps })} + + {/snippet} + +
-
- - {#snippet child({ props: closeProps })} - - {/snippet} - -
+
+ + + {/if}
{/snippet}
@@ -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; } diff --git a/apps/web/src/routes/(protected)/+page.svelte b/apps/web/src/routes/(protected)/+page.svelte index f715959..00002e6 100644 --- a/apps/web/src/routes/(protected)/+page.svelte +++ b/apps/web/src/routes/(protected)/+page.svelte @@ -24,6 +24,7 @@ let searchStatus = $state<'idle' | 'loading' | 'ready' | 'failed'>('idle'); let searchInput = $state(); let resultList = $state(); + let groupsElement = $state(); 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('a.tile') ?? []; + function findBookmarkTile(id: string) { + const links = groupsElement?.querySelectorAll('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} -
+
{#if searchResults !== null} {#if searchResults.length > 0}