Files

13 KiB
Raw Permalink Blame History

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 и ручной прогон в трёх темах.