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:
@@ -6,16 +6,31 @@
|
||||
interface Props {
|
||||
photos: Photo[];
|
||||
selectedIds?: string[];
|
||||
cursorIndex?: number | null;
|
||||
cursorVisible?: boolean;
|
||||
onSelect?: (index: number, event: MouseEvent) => void;
|
||||
onOpen?: (index: number) => void;
|
||||
onDragStart?: (index: number, event: DragEvent) => void;
|
||||
onContextMenu?: (index: number, event: MouseEvent) => void;
|
||||
onCursorFocus?: (index: number) => void;
|
||||
}
|
||||
|
||||
let { photos, selectedIds = [], onSelect, onOpen, onDragStart, onContextMenu }: Props =
|
||||
$props();
|
||||
let {
|
||||
photos,
|
||||
selectedIds = [],
|
||||
cursorIndex = null,
|
||||
cursorVisible = false,
|
||||
onSelect,
|
||||
onOpen,
|
||||
onDragStart,
|
||||
onContextMenu,
|
||||
onCursorFocus
|
||||
}: Props = $props();
|
||||
|
||||
const isSelected = (id: string) => selectedIds.includes(id);
|
||||
const isCursor = (i: number) => cursorIndex === i;
|
||||
const tabIndexFor = (i: number) =>
|
||||
cursorIndex == null ? (i === 0 ? 0 : -1) : isCursor(i) ? 0 : -1;
|
||||
</script>
|
||||
|
||||
{#if photos.length === 0}
|
||||
@@ -29,13 +44,18 @@
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-photo-cell
|
||||
tabindex={tabIndexFor(i)}
|
||||
draggable={isSelected(photo.id)}
|
||||
class="border-border bg-bg-muted group relative block aspect-square w-full overflow-hidden rounded-lg border {isSelected(
|
||||
photo.id
|
||||
)
|
||||
? 'ring-primary ring-2'
|
||||
: ''} {isCursor(i) && cursorVisible
|
||||
? 'outline outline-2 outline-offset-2 outline-text'
|
||||
: ''}"
|
||||
aria-pressed={isSelected(photo.id)}
|
||||
onfocus={() => onCursorFocus?.(i)}
|
||||
onclick={(e) => onSelect?.(i, e)}
|
||||
ondblclick={() => onOpen?.(i)}
|
||||
ondragstart={(e) => onDragStart?.(i, e)}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
let pending: { albumId: string; photoId: string } | null = null;
|
||||
|
||||
export const viewerReturn = {
|
||||
remember(albumId: string, photoId: string) {
|
||||
if (albumId && photoId) pending = { albumId, photoId };
|
||||
},
|
||||
consume(albumId: string): string | null {
|
||||
const p = pending;
|
||||
pending = null;
|
||||
return p && p.albumId === albumId ? p.photoId : null;
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -5,11 +5,18 @@
|
||||
import Viewer from '$lib/components/Viewer.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||
import { viewerReturn } from '$lib/stores/viewer-return';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
|
||||
const albumId = $derived($page.params.id ?? '');
|
||||
const photoId = $derived($page.params.photoId ?? '');
|
||||
|
||||
$effect(() => {
|
||||
const aid = albumId;
|
||||
const pid = photoId;
|
||||
if (aid && pid) viewerReturn.remember(aid, pid);
|
||||
});
|
||||
|
||||
let photos = $state<Photo[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
Reference in New Issue
Block a user