Add desktop bookmark navigation #68
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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user