chore(openspec): archive grid-keyboard-navigation and sync main specs
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
# 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` и ручной прогон в трёх темах.
|
||||
Reference in New Issue
Block a user