feat(ui): keyboard navigation and focus restore in album grid

- Cursor separate from selection; arrow keys move it, Space/Shift+Arrow/Enter/Esc act
- Click on empty grid space clears selection back to album properties
- Focus returns to the last viewed photo after closing the viewer
This commit is contained in:
iu
2026-10-11 14:51:00 +03:00
parent d99ac77587
commit 7359495a73
9 changed files with 588 additions and 5 deletions
@@ -1,6 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { tick } from 'svelte';
import { api, photosApi } from '$lib/api';
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
@@ -9,7 +10,9 @@
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { density } from '$lib/stores/density.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { viewerReturn } from '$lib/stores/viewer-return';
import { transfer } from '$lib/stores/transfer.svelte';
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
import { SSEClient, type SSEEvent } from '$lib/sse';
@@ -28,6 +31,10 @@
let deletingMenu = $state(false);
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
let cursorIndex = $state<number | null>(null);
let gridFocused = $state(false);
let gridSurface: HTMLDivElement | undefined = $state();
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
let canEdit = $derived(role === 'owner' || role === 'admin');
@@ -57,12 +64,20 @@
$effect(() => {
const id = albumId;
const returnPhotoId = viewerReturn.consume(id);
selection.clear();
cursorIndex = null;
loading = true;
error = '';
Promise.all([loadAlbum(), loadPhotos()])
.catch((e) => (error = (e as Error).message))
.finally(() => (loading = false));
.then(async () => {
loading = false;
if (returnPhotoId) await restoreFocus(id, returnPhotoId);
})
.catch((e) => {
error = (e as Error).message;
loading = false;
});
});
let client: SSEClient | null = null;
@@ -144,6 +159,7 @@
function selectPhoto(i: number, e: MouseEvent) {
const photo = photos[i];
if (!photo) return;
cursorIndex = i;
const cur = selection.albumId === albumId ? selection.items : [];
if (e.shiftKey && selection.anchorId && selection.albumId === albumId) {
const a = photos.findIndex((p) => p.id === selection.anchorId);
@@ -170,6 +186,7 @@
function dragPhoto(i: number, e: DragEvent) {
const photo = photos[i];
if (!photo) return;
cursorIndex = i;
if (!(selection.albumId === albumId && selection.has(photo.id))) {
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
@@ -193,6 +210,7 @@
e.stopPropagation();
const photo = photos[i];
if (!photo) return;
cursorIndex = i;
const inSel = selection.albumId === albumId && selection.has(photo.id);
const multi = inSel && selection.count > 1;
if (!inSel) {
@@ -270,6 +288,151 @@
deletingMenu = false;
}
}
function cellAt(i: number): HTMLElement | null {
return gridSurface?.querySelectorAll<HTMLElement>('[data-photo-cell]')[i] ?? null;
}
function focusCursor(i: number) {
if (i < 0 || i >= photos.length) return;
cursorIndex = i;
const el = cellAt(i);
if (el) {
el.focus({ preventScroll: true });
el.scrollIntoView({ block: 'nearest' });
}
}
function extendSelection(next: number, origin: number) {
let anchorIdx = origin;
if (selection.albumId === albumId && selection.anchorId) {
const found = photos.findIndex((p) => p.id === selection.anchorId);
if (found >= 0) anchorIdx = found;
}
if (anchorIdx < 0 || anchorIdx >= photos.length) anchorIdx = next;
const anchorPhoto = photos[anchorIdx];
if (anchorPhoto) selection.anchorId = anchorPhoto.id;
const lo = Math.min(anchorIdx, next);
const hi = Math.max(anchorIdx, next);
selection.set(albumId, photos.slice(lo, hi + 1));
focusCursor(next);
}
async function moveCursor(next: number, origin: number, shift: boolean) {
if (next < 0) return;
if (next >= photos.length) {
if (!nextCursor) return;
await loadMore();
await tick();
if (next >= photos.length) return;
}
const i = Math.min(photos.length - 1, next);
if (shift) extendSelection(i, origin);
else focusCursor(i);
}
async function restoreFocus(id: string, targetId: string) {
let idx = photos.findIndex((p) => p.id === targetId);
while (idx < 0 && nextCursor) {
await loadMore();
idx = photos.findIndex((p) => p.id === targetId);
}
if (idx < 0) return;
const photo = photos[idx];
selection.set(id, [photo]);
selection.anchorId = photo.id;
cursorIndex = idx;
await tick();
const el = cellAt(idx);
if (el) {
el.focus({ preventScroll: true });
el.scrollIntoView({ block: 'center' });
}
}
function toggleCursor() {
const i = cursorIndex;
if (i == null) return;
const photo = photos[i];
if (!photo) return;
const cur = selection.albumId === albumId ? selection.items : [];
const exists = cur.some((p) => p.id === photo.id);
selection.set(albumId, exists ? cur.filter((p) => p.id !== photo.id) : [...cur, photo]);
selection.anchorId = photo.id;
}
function onGridKeydown(e: KeyboardEvent) {
const target = e.target as Element | null;
if (!target) return;
const inCell = !!target.closest('[data-photo-cell]');
if (!inCell && target.closest('button, a, input, select, textarea')) return;
if (photos.length === 0) return;
const key = e.key;
if (
key === 'ArrowLeft' ||
key === 'ArrowRight' ||
key === 'ArrowUp' ||
key === 'ArrowDown'
) {
e.preventDefault();
if (cursorIndex == null) {
if (e.shiftKey) extendSelection(0, 0);
else focusCursor(0);
return;
}
const origin = cursorIndex;
const cols = Math.max(1, density.cols);
let next = origin;
if (key === 'ArrowLeft') next = origin - 1;
else if (key === 'ArrowRight') next = origin + 1;
else if (key === 'ArrowUp') next = origin - cols;
else next = origin + cols;
moveCursor(next, origin, e.shiftKey);
return;
}
if (key === ' ' || key === 'Spacebar') {
if (cursorIndex == null) return;
e.preventDefault();
toggleCursor();
return;
}
if (key === 'Enter') {
if (cursorIndex == null) return;
const p = photos[cursorIndex];
if (!p) return;
e.preventDefault();
goto(`/albums/${albumId}/photos/${p.id}`);
return;
}
if (key === 'Escape') {
if (selection.albumId === albumId && selection.items.length > 0) {
e.preventDefault();
selection.clear();
}
}
}
function onGridClick(e: MouseEvent) {
const target = e.target as Element | null;
if (!target) return;
if (target.closest('[data-photo-cell]')) return;
if (target.closest('button, a, input, select, textarea, [role="button"]')) return;
if (selection.albumId === albumId && selection.items.length > 0) {
selection.clear();
}
gridSurface?.focus();
}
function onGridFocusIn() {
gridFocused = true;
}
function onGridFocusOut(e: FocusEvent) {
const el = e.currentTarget as Node | null;
const next = e.relatedTarget as Node | null;
if (!el || !next || !el.contains(next)) gridFocused = false;
}
</script>
{#if loading}
@@ -322,7 +485,18 @@
</div>
</div>
<div class="flex-1 p-3 sm:p-4">
<div
class="flex-1 p-3 sm:p-4"
role="grid"
aria-label="Сетка фотографий"
aria-multiselectable="true"
tabindex="-1"
bind:this={gridSurface}
onclick={onGridClick}
onkeydown={onGridKeydown}
onfocusin={onGridFocusIn}
onfocusout={onGridFocusOut}
>
{#if canUpload && photos.length === 0}
<button
type="button"
@@ -336,6 +510,9 @@
<PhotoGrid
{photos}
selectedIds={selection.albumId === albumId ? selection.ids : []}
cursorIndex={cursorIndex}
cursorVisible={gridFocused}
onCursorFocus={(i) => (cursorIndex = i)}
onSelect={selectPhoto}
onDragStart={dragPhoto}
onContextMenu={openMenuForPhoto}