Files
ds-galo/openspec/changes/grid-keyboard-navigation/design.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

161 lines
13 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.
# Design
## Context
См. proposal.md — Why. Текущее состояние, формирующее подход:
- `lib/components/PhotoGrid.svelte` — `<ul class="grid">` с CSS-колонками по `density.cols`
(2–8, фиксированное число, `lib/stores/density.svelte.ts`), ячейки — `<button>` с
`aria-pressed`, `onclick`/`ondblclick`/`ondragstart`/`oncontextmenu`.
- `lib/stores/selection.svelte.ts` хранит `items: Photo[]` и `anchorId` (последний клик).
- `lib/components/AlbumPanel.svelte:25-27` выводит правую панель: маршрутное фото → панель
фото; одно выбранное → панель фото; несколько → строки действий; иначе — свойства альбома.
- В сетке нет обработчиков клавиатуры и нет реакции на клик по пустому месту. Viewer уже задаёт
«lightroom»-условности (стрелки, `Space`, `Enter`), но в другом маршруте.
- Сетка точечно решает геометрию по индексу, а действия (range, меню, drag, `goto`) живут в
`routes/(app)/albums/[id]/+page.svelte`.
## Goals / Non-Goals
**Goals:**
- Курсор — состояние, отдельное от выделения; стрелки двигают только курсор.
- Клавиши `Space`/`Shift`+стрелки/`Enter`/`Esc` управляют выделением и просмотром.
- Клик по пустому месту сетки и её отступам снимает выделение и возвращает свойства альбома.
- Клавиатурная доступность без смены семантики существующих ячеек; поведение мыши сохраняется.
**Non-Goals:**
- Изменение контракта стора выделения или API/данных.
- Клавиатурная навигация на обзоре альбомов и в корзине.
- Изменение числа колонок сетки или адаптивного реflow.
## Decisions
### 1. Модель: курсор отдельно от выделения (B)
Выбран вариант B по решению пользователя: стрелки двигают курсор, выделение меняется только
`Space`/`Shift`+стрелки/кликом. Правая панель реагирует только на выделение, поэтому панель не
«мигает» при движении курсора. Альтернатива A (выделение следует за стрелками) отклонена.
### 2. Разделение ответственности: поверхность сетки в странице, PhotoGrid презентационен
Поверхность сетки — контейнер сетки вместе с отступами в `+page.svelte` (тот же, что нужен для
клика по пустому месту). Он владеет `keydown`/`click`/`focusin`/`focusout` и `tabindex="-1"`.
Обработчик `keydown` висит на этой поверхности (не на `window`) и работает, только когда фокус
внутри — это исключает столкновения с полями ввода, диалогами и Viewer. `PhotoGrid` остаётся
презентационным: принимает `cursorIndex` и `cursorVisible`, отдаёт `onCursorFocus(index)` на
`onfocus` ячейки, рисует контур курсора и roving `tabindex`. Страница хранит `cursorIndex` и
решает выделение, `loadMore`, `scrollIntoView`, `goto`.
```
keydown (поверхность сетки = контейнер + отступы)
├─ ArrowLeft/Right/Up/Down ──► moveCursor(next) // курсор
├─ Shift+Arrow ──────────────► moveCursor(next, extend) // курсор + выделение
├─ Space ────────────────────► toggleCursor()
├─ Enter ────────────────────► goto(/albums/:id/photos/:i)
└─ Esc ──────────────────────► selection.clear()
click (поверхность, вне [data-photo-cell] и органов управления)
└─ selection.clear(); поверхность.focus()
onfocus ячейки ──► onCursorFocus(i) // клик и Tab ставят курсор
страница (+page.svelte)
cursorIndex: number | null selection (store) anchorId (store)
```
Альтернатива — держать курсор и `keydown` внутри `PhotoGrid` — отклонена: клик по пустому месту
должен покрывать отступы вокруг сетки, а `cursorIndex` нужен странице для `loadMore`, прокрутки и
синхронизации с мышью. Поэтому поверхностью становится контейнер страницы.
### 3. Геометрия по плоскому индексу
Сетка — плоский список в порядке документа, поэтому:
- `←`/`→` — `index ± 1`; на границе строки переход на соседнюю строку происходит естественно,
отдельного случая не нужно; на краях сетки курсор остаётся на месте.
- `↑`/`↓` — `index ± density.cols`; если целевой индекс выходит за `[0, n-1]`, курсор остаётся
на месте (без вертикального переноса). При наличии `nextCursor` движение вперёд загружает
следующую порцию и только затем перемещает курсор.
Вычисление живёт в `PhotoGrid` (там известен `density.cols`); страница получает уже готовый
индекс.
### 4. Визуализация: рамка выделения + контур курсора
Выделение сохраняет `ring-2 ring-primary`. Курсор получает `data-cursor` и внешний
`outline-2 outline-offset-2` в токене `text` (контрастен во всех темах, включая нейтральную).
Оба состояния рисуются на одной ячейке без конфликта.
```
selection cursor both
+---------+ + - - - - + + - - - - - +
| [photo] | | [photo] | | +-------+ |
| ring-pri| | outline | | |[photo]| |
+---------+ + - - - - + | +-------+ |
+ - - - - - +
```
Контур курсора показывается только при `:focus-within` сетки, чтобы не «зависал» после клика в
панели. Программный `.focus()` не гарантирует `:focus-visible`, поэтому видимость управляется
состоянием (`data-cursor`), а не нативным фокус-рингом.
### 5. Roving tabindex
Ячейка под курсором получает `tabindex="0"`, остальные — `-1`; при пустом курсоре `0` у первой
ячейки. Так Tab вводит фокус в сетку один раз, а внутренняя навигация — стрелками. Роли не
меняются: ячейки остаются `<button aria-pressed>`, что не ломает существующий a11y и мышь.
### 6. Снятие выделения кликом
Обработчик клика вешается на контейнер сетки вместе с отступами (обёртка `PhotoGrid` в
`+page.svelte`, а не `<body>`/`<main>`). Внутри: если `(e.target as Element).closest(
'[data-photo-cell]')` — выход; иначе `selection.clear()`. Клики по тулбару, рейлу и панелям
обработчик не видит физически. Чтобы стрелки работали сразу, контейнер получает
`tabindex="-1"` и `.focus()` после снятия.
### 7. Сброс и синхронизация с мышью
- Клик по фото выставляет и выделение, и `cursorIndex`.
- `ctrl`/`shift`-клик и контекстное меню также ставят курсор.
- При смене альбома `cursorIndex = null` (в том же `$effect`, где `selection.clear()`).
- Курсор не персистится.
### 8. Возврат фокуса из просмотра: однослотовая память
Просмотр и сетка — соседние маршруты, поэтому при возврате `+page.svelte` сетки монтируется
заново и теряет `cursorIndex`/выделение. Вводится модульный стор `lib/stores/viewer-return.ts` с
одним слотом `{ albumId, photoId }`:
```
маршрут просмотра ($effect на photoId) ──► viewerReturn.remember(albumId, photoId)
(обновляется при каждом change)
сетка ($effect на albumId, в начале) ──► viewerReturn.consume(albumId)
├─ вернул photoId этого альбома ──► после загрузки: selection=[photo], cursor=index,
│ .focus() + scrollIntoView({block:'center'})
│ (если фото нет — loadMore() пока не появится)
└─ иначе ──► обычный пустой альбом, без автофокуса
```
Один слот, а не `Map`, сознательно: если после просмотра открыть другой альбом, слот
израсходуется и сбросится — «висячая» память не сработает на постороннем альбоме. Память
хранится в модуле (переживает навигацию/монтирование) и живёт только до следующего монтирования
сетки. Восстановление вызывается после `loading = false` и `await tick()`, иначе DOM-ячейки ещё
нет. Альтернатива — `history.state`/`sessionStorage` — отклонена: не покрывает браузерный Back.
## Risks / Trade-offs
- [Кольца выделения и курсора визуально спорят] → контур курсора с `outline-offset-2` снаружи
рамки и в токене `text`; проверка в светлой, тёмной и нейтральной темах.
- [Стрелки перехватывают прокрутку `Space`] → `keydown` только на контейнере сетки;
`preventDefault` для `Space` лишь при наличии курсора.
- [`↑`/`↓` при неполной последней строке] → если смещение на `density.cols` уводит за
последнюю строку, курсор остаётся на месте, как требует сценарий вертикальных границ.
- [Курсор на ещё не загруженном фото] → при движении вперёд за последний загруженный и наличии
`nextCursor` вызывается `loadMore()`, затем курсор ставится на новое фото; без `nextCursor` —
курсор остаётся на месте.
- [Смена `density.cols` во время навигации] → индекс курсора хранится плоским, поэтому при
изменении числа колонок `↑`/`↓` просто считают по новому значению; отдельного переселения не
требуется.
- [Восстановление фокуса срабатывает не вовремя] → слот израсходуется при первом монтировании
сетки и сбрасывается, если альбом не тот; восстановление выполняется после `loading = false`
и `tick()`, поэтому ячейка уже в DOM.
- [Просмотренное фото вне первой страницы] → `restoreFocus` догружает страницы (`loadMore`) в
цикле, пока фото не появится или пока не кончится `nextCursor`.
## Migration Plan
Только фронтенд, без изменений данных и API. Откат — удаление обработчиков и состояния курсора;
изменений в хранилище нет. Проверка: `cd frontend && npm run check` и ручной прогон в трёх темах.