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