Files
ds-galo/openspec/changes/grid-keyboard-navigation/tasks.md
T
iu 7359495a73 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
2026-10-11 14:51:00 +03:00

36 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Tasks
## 1. PhotoGrid: API курсора и визуализация
- [x] 1.1 Добавить в `lib/components/PhotoGrid.svelte` проп `cursorIndex` и колбэки `onCursor`, `onToggle`, `onExtend`, `onOpen`, `onClear`; прокинуть их из `routes/(app)/albums/[id]/+page.svelte`. Проверка: `cd frontend && npm run check` проходит, сетка рендерится без изменений поведения мыши.
- [x] 1.2 Реализовать roving tabindex: у ячейки под курсором `tabindex="0"`, у остальных `-1`; при пустом курсоре `0` у первой ячейки. Проверка: Tab вводит фокус в сетку один раз, стрелки не покидают сетку.
- [x] 1.3 Добавить визуальный контур курсора (`data-cursor`, внешний `outline-2 outline-offset-2` в токене `text`) с показом только при `:focus-within`. Проверка: в светлой, тёмной и нейтральной темах контур отличим от `ring-primary` и читается, когда ячейка одновременно выделена и под курсором.
## 2. Клавиши сетки
- [x] 2.1 Реализовать геометрию перемещения по плоскому индексу: `←`/`→` — `index ± 1` (естественный переход на соседнюю строку), `↑`/`↓` — `index ± density.cols` с зажимом в `[0, n-1]`. Проверка: ручной прогон — `→` в конце строки уходит на следующую строку, `↑`/`↓` не переносят через верх/низ.
- [x] 2.2 Повесить `keydown` на контейнер сетки (не на `window`); игнорировать события вне фокуса сетки; `preventDefault` только для обрабатываемых клавиш. Проверка: при фокусе в поле ввода панели или в диалоге стрелки и `Space` не двигают курсор.
- [x] 2.3 Обработать клавиши-действия: `Space` → `onToggle`, `Shift`+стрелка → `onExtend`, `Enter` → `onOpen`, `Esc` → `onClear`. Проверка: `Space` переключает выделение и обновляет правую панель, `Shift`+стрелка расширяет диапазон от якоря, `Enter` открывает просмотр, `Esc` снимает выделение и оставляет курсор.
## 3. Снятие выделения кликом
- [x] 3.1 Добавить обработчик клика на контейнер сетки вместе с отступами: пропускать цель внутри `[data-photo-cell]`, иначе `selection.clear()`; контейнеру дать `tabindex="-1"` и фокусировать его после снятия. Проверка: клик по пустому месту возвращает в правой панели свойства альбома, клик по фото/кнопке тулбара/элементу панели выделение не снимает, после снятия стрелки сразу двигают курсор.
## 4. Интеграция страницы альбома
- [x] 4.1 Ввести `cursorIndex` в `+page.svelte`; синхронизировать с мышью (клик, `ctrl`/`cmd`-клик, `shift`-клик, контекстное меню ставят курсор); сбрасывать в `null` при смене альбома в том же `$effect`, где `selection.clear()`.
- [x] 4.2 При перемещении курсора вызывать `scrollIntoView({ block: 'nearest' })` для ячейки. Проверка: движение к краю прокручивает сетку, ячейка под курсором видна.
- [x] 4.3 При движении вперёд за последнее загруженное фото и наличии `nextCursor` вызывать `loadMore()`, затем ставить курсор на новое фото; без `nextCursor` — зажим. Проверка: `→` на последнем загруженном фото догружает следующую порцию и перемещает курсор.
- [x] 4.4 Реализовать `onExtend`: если якорь не задан, взять текущий курсор; выставлять выделение диапазоном от якоря до курсора через `selection.set`. Проверка: `Shift`+стрелка добавляет соседние фото в выделение, правая панель показывает количество.
## 5. Интеграционная проверка
- [x] 5.1 Прогнать `cd frontend && npm run check` без ошибок типов и предупреждений.
- [x] 5.2 Вручную пройти сценарии дельты `ui-workspace` в светлой, тёмной и нейтральной темах: движение курсора без смены панели, `Space`+панель, `Shift`-диапазон, `Enter`, `Esc`, клик по пустому месту, догрузка; убедиться, что клик/дабл-клик/контекстное меню/drag работают как прежде.
## 6. Возврат фокуса из просмотра
- [x] 6.1 Добавить модульный стор `lib/stores/viewer-return.ts` (однослотовая память `{albumId, photoId}` с `remember`/`consume`) и вызывать `remember(albumId, photoId)` в `routes/(app)/albums/[id]/photos/[photoId]/+page.svelte` через `$effect` на `photoId`. Проверка: `npm run check` проходит; при открытии просмотра и листании слот хранит текущий `photoId`.
- [x] 6.2 В `routes/(app)/albums/[id]/+page.svelte` в `$effect` на `albumId` вызывать `viewerReturn.consume(albumId)`, а после `loading = false` и `tick()` — восстанавливать фокус: `selection.set(albumId, [photo])`, `anchorId`, `cursorIndex`, `.focus({preventScroll:true})` и `scrollIntoView({block:'center'})`; если фото нет среди загруженных — догружать `loadMore()` в цикле. Проверка: возврат из просмотра выделяет и фокусирует просмотренное фото, стрелки работают сразу; фото со второй страницы догружается.
- [x] 6.3 Расширить QA-скрипт сценариями возврата (закрытие просмотра, листание и возврат, фото вне первой страницы, возврат в другой альбом) и прогнать его. Проверка: все проверки PASS, `npm run check` без ошибок и предупреждений.