feat(ui): open photo on tap for touch and fix narrow-screen panel
- Touch/pen tap opens the viewer; mouse click still selects, dblclick opens - Panel and rail emit a single transform utility so the panel actually slides in below 1280px (Tailwind v4 utility ordering made the base translate-x-full win over the conditional translate-x-0) - Add touch-action: manipulation to grid cells
This commit is contained in:
@@ -175,9 +175,9 @@
|
|||||||
></button>
|
></button>
|
||||||
{/if}
|
{/if}
|
||||||
<aside
|
<aside
|
||||||
class="border-border bg-chrome absolute inset-y-0 left-0 z-40 w-60 -translate-x-full overflow-y-auto border-r transition-transform lg:static lg:z-auto lg:w-[var(--rail-w)] lg:translate-x-0 {railOpen
|
class="border-border bg-chrome absolute inset-y-0 left-0 z-40 w-60 overflow-y-auto border-r transition-transform lg:static lg:z-auto lg:w-[var(--rail-w)] lg:translate-x-0 {railOpen
|
||||||
? 'translate-x-0'
|
? 'translate-x-0'
|
||||||
: ''}"
|
: '-translate-x-full'}"
|
||||||
style="--rail-w: {railWidth}px"
|
style="--rail-w: {railWidth}px"
|
||||||
aria-label="Альбомы"
|
aria-label="Альбомы"
|
||||||
>
|
>
|
||||||
@@ -215,9 +215,9 @@
|
|||||||
onkeydown={(e) => nudge('right', e)}
|
onkeydown={(e) => nudge('right', e)}
|
||||||
></button>
|
></button>
|
||||||
<aside
|
<aside
|
||||||
class="border-border bg-chrome absolute inset-y-0 right-0 z-40 w-72 translate-x-full overflow-y-auto border-l transition-transform xl:static xl:z-auto xl:w-[var(--panel-w)] xl:translate-x-0 {panelOpen
|
class="border-border bg-chrome absolute inset-y-0 right-0 z-40 w-72 overflow-y-auto border-l transition-transform xl:static xl:z-auto xl:w-[var(--panel-w)] xl:translate-x-0 {panelOpen
|
||||||
? 'translate-x-0'
|
? 'translate-x-0'
|
||||||
: ''}"
|
: 'translate-x-full'}"
|
||||||
style="--panel-w: {panelWidth}px"
|
style="--panel-w: {panelWidth}px"
|
||||||
aria-label="Панель"
|
aria-label="Панель"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -32,6 +32,14 @@
|
|||||||
const isCursor = (i: number) => cursorIndex === i;
|
const isCursor = (i: number) => cursorIndex === i;
|
||||||
const tabIndexFor = (i: number) =>
|
const tabIndexFor = (i: number) =>
|
||||||
cursorIndex == null ? (i === 0 ? 0 : -1) : isCursor(i) ? 0 : -1;
|
cursorIndex == null ? (i === 0 ? 0 : -1) : isCursor(i) ? 0 : -1;
|
||||||
|
|
||||||
|
let lastPointer = '';
|
||||||
|
function activate(i: number, e: MouseEvent) {
|
||||||
|
const touch = lastPointer === 'touch' || lastPointer === 'pen';
|
||||||
|
lastPointer = '';
|
||||||
|
if (touch) onOpen?.(i);
|
||||||
|
else onSelect?.(i, e);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if photos.length === 0}
|
{#if photos.length === 0}
|
||||||
@@ -48,7 +56,7 @@
|
|||||||
data-photo-cell
|
data-photo-cell
|
||||||
tabindex={tabIndexFor(i)}
|
tabindex={tabIndexFor(i)}
|
||||||
draggable={isSelected(photo.id)}
|
draggable={isSelected(photo.id)}
|
||||||
class="border-border bg-bg-muted group relative block aspect-square w-full overflow-hidden rounded-lg border {isSelected(
|
class="border-border bg-bg-muted group relative block aspect-square w-full touch-manipulation overflow-hidden rounded-lg border {isSelected(
|
||||||
photo.id
|
photo.id
|
||||||
)
|
)
|
||||||
? 'ring-primary ring-2'
|
? 'ring-primary ring-2'
|
||||||
@@ -56,8 +64,11 @@
|
|||||||
? 'outline outline-2 outline-offset-2 outline-text'
|
? 'outline outline-2 outline-offset-2 outline-text'
|
||||||
: ''}"
|
: ''}"
|
||||||
aria-pressed={isSelected(photo.id)}
|
aria-pressed={isSelected(photo.id)}
|
||||||
onfocus={() => onCursorFocus?.(i)}
|
onpointerdown={(e) => (lastPointer = e.pointerType)}
|
||||||
onclick={(e) => onSelect?.(i, e)}
|
onfocus={() => {
|
||||||
|
if (lastPointer !== 'touch' && lastPointer !== 'pen') onCursorFocus?.(i);
|
||||||
|
}}
|
||||||
|
onclick={(e) => activate(i, e)}
|
||||||
ondblclick={() => onOpen?.(i)}
|
ondblclick={() => onOpen?.(i)}
|
||||||
ondragstart={(e) => onDragStart?.(i, e)}
|
ondragstart={(e) => onDragStart?.(i, e)}
|
||||||
oncontextmenu={(e) => onContextMenu?.(i, e)}
|
oncontextmenu={(e) => onContextMenu?.(i, e)}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-11
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
См. proposal.md — Why. Текущее состояние:
|
||||||
|
|
||||||
|
- `AppShell.svelte`: у правого `<aside>` в списке классов одновременно постоянный `translate-x-full`
|
||||||
|
и условный `translate-x-0` (при `panelOpen`). В Tailwind v4 скомпилированный порядок ставит
|
||||||
|
`.translate-x-full` после `.translate-x-0`, поэтому постоянный класс побеждает и панель остаётся
|
||||||
|
за экраном; `panelOpen` управляет только оверлеем. У рейла база `-translate-x-full` сортируется
|
||||||
|
раньше `translate-x-0`, поэтому рейл открывается случайно корректно.
|
||||||
|
- `PhotoGrid.svelte`: `onclick -> onSelect` (выделение), `ondblclick -> onOpen` (просмотр). Touch
|
||||||
|
генерирует `click`, но не `dblclick`, поэтому касание только выделяет.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Панель на узком экране действительно выдвигается кнопкой «Панель».
|
||||||
|
- Исключить класс трансформации, который может перебивать условный (панель и рейл).
|
||||||
|
- Одиночное касание по фото открывает просмотр на сенсорных устройствах.
|
||||||
|
- Мышь и клавиатура сохраняют прежнее поведение.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Новый UI выделения/мультивыбора на сенсорных экранах.
|
||||||
|
- Изменения клавиатурной навигации, ровинга `tabindex` и режима просмотра.
|
||||||
|
- Изменения бэкенда.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Ровно один класс трансформации для панели/рейла
|
||||||
|
Эмитим единственное значение: `translate-x-0` при открытии, иначе `translate-x-full`. Постоянный
|
||||||
|
класс из строки убирается (для рейла — `-translate-x-full`). На рабочем столе видимость задаётся
|
||||||
|
вариантом `xl:translate-x-0` / `lg:translate-x-0`.
|
||||||
|
|
||||||
|
Альтернативы: инлайновый `style:translate`; arbitrary-value классы; `data-open` с собственным CSS.
|
||||||
|
Выбрано исключение конфликта минимальной правкой — совпадает с уже существующим намерением кода и
|
||||||
|
не тянет новый CSS-слой.
|
||||||
|
|
||||||
|
### 2. Детект сенсорного ввода — по `pointerType` события
|
||||||
|
Тип ввода определяется на `pointerdown` (`touch`/`pen` → сенсорный, иначе указатель) и
|
||||||
|
используется при обработке активации. Не используем ширину вьюпорта и `matchMedia('(pointer:coarse)')`.
|
||||||
|
|
||||||
|
Альтернатива: `matchMedia` по грубому указателю. Отклонено: на гибридных устройствах (тач + мышь)
|
||||||
|
это статично и путает при вводе мышью. `pointerType` на конкретном событии точнее.
|
||||||
|
|
||||||
|
### 3. Тап открывает, не выделяет
|
||||||
|
При активации сенсорным вводом вызывается открытие просмотра (`onOpen`); выделение, `cursorIndex` и
|
||||||
|
сведения в панели не трогаются. Клик мышью — как прежде (`onSelect`), двойной — `onOpen`.
|
||||||
|
На сенсорных ячейках имеет смысл `touch-action: manipulation`, чтобы исключить двойной тап-зум.
|
||||||
|
|
||||||
|
### 4. Выделение на touch — долгое нажатие
|
||||||
|
Отдельный UI не добавляем: остаётся существующее контекстное меню ячейки (`oncontextmenu`), которое
|
||||||
|
содержит «Открыть (просмотр)», «Переместить», «Копировать», «Удалить».
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [На части мобильных браузеров долгое нажатие не доходит до нашего `oncontextmenu` (нативное меню
|
||||||
|
или выделение текста)] → зафиксировать как известное ограничение; при необходимости добавить
|
||||||
|
`touch-action`/`user-select` в follow-up, не расширяя этот change.
|
||||||
|
- [`pointerType` пуст для части синтетических кликов] → при не-сенсорном/неизвестном типе сохраняем
|
||||||
|
прежнее поведение (выделение), чтобы мышь не ломалась.
|
||||||
|
- [Регрессия порядка утилит Tailwind] → оба места (панель и рейл) используют единственный класс,
|
||||||
|
поэтому конфликт невозможен независимо от порядка генерации.
|
||||||
|
- [Двойной тап-зум на мобильном] → активация на первом касании делает его нерелевантным; при
|
||||||
|
необходимости подавляется `touch-action: manipulation`.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Обратной совместимости нарушать не требуется. Откат — возврат правки классов/обработчиков.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Нет.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
На узких экранах (< 1280px) кнопка «Панель» не открывает правую панель, а на сенсорных
|
||||||
|
устройствах фото нельзя открыть одним касанием (двойной клик для touch недоступен). Оба
|
||||||
|
дефекта ломают мобильный сценарий просмотра.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Исправить выдвижение правой панели на узких экранах: у элемента панели ровно один класс
|
||||||
|
трансформации вместо конфликтующих постоянного и условного.
|
||||||
|
- Нормализовать по тому же принципу рейл альбомов, чтобы порядок утилит Tailwind v4 не мог
|
||||||
|
вернуть дефект.
|
||||||
|
- На сенсорных устройствах одиночное касание по фото открывает режим просмотра; выделение
|
||||||
|
тапом не выполняется. Мышь сохраняет прежнее поведение: клик — выделение, двойной — открытие.
|
||||||
|
- Мультивыбор на сенсорных устройствах доступен через долгое нажатие (контекстное меню).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
<!-- нет новых возможностей -->
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `ui-workspace`: адаптивность рабочего стола (панель/рейл на узком экране) и выбор фото в сетке
|
||||||
|
(открытие по одному касанию на сенсорных экранах).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `frontend/src/lib/components/AppShell.svelte` — классы трансформации панели и рейла.
|
||||||
|
- `frontend/src/lib/components/PhotoGrid.svelte` и `frontend/src/routes/(app)/albums/[id]/+page.svelte`
|
||||||
|
— обработка одиночного касания/клика по фото.
|
||||||
|
- Спека `ui-workspace`: уточнение двух требований.
|
||||||
|
- Клавиатурная навигация и ровинг `tabindex` не затрагиваются.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Адаптивность рабочего стола
|
||||||
|
На узких экранах рейл SHALL становиться выдвижной панелью слева, контекстная панель — выдвижной
|
||||||
|
панелью справа, а лента миниатюр — горизонтально прокручиваемой. Кнопка «Панель» на узком экране
|
||||||
|
SHALL выдвигать правую панель поверх содержимого; затемняющая область SHALL закрывать панель.
|
||||||
|
|
||||||
|
#### Scenario: Узкий экран
|
||||||
|
- **WHEN** ширина окна меньше порога рабочего стола
|
||||||
|
- **THEN** рейл скрыт за кнопкой открытия, панель сворачиваема, лента прокручивается горизонтально
|
||||||
|
|
||||||
|
#### Scenario: Открытие панели
|
||||||
|
- **WHEN** пользователь нажимает кнопку «Панель» на узком экране
|
||||||
|
- **THEN** правая панель выдвигается и становится видимой поверх содержимого
|
||||||
|
|
||||||
|
### Requirement: Выбор фото в сетке
|
||||||
|
В режиме альбома одиночный выбор фото указателем SHALL отмечать его рамкой и показывать его
|
||||||
|
сведения в контекстной панели, не открывая просмотр; двойной выбор указателем SHALL открывать
|
||||||
|
режим просмотра фото. На сенсорных экранах одиночное касание по фото SHALL открывать режим
|
||||||
|
просмотра этого фото и не SHALL выделять его; выделение на сенсорных экранах SHALL выполняться
|
||||||
|
долгим нажатием.
|
||||||
|
|
||||||
|
#### Scenario: Одиночный выбор
|
||||||
|
- **WHEN** пользователь кликает по фото в сетке указателем
|
||||||
|
- **THEN** фото отмечено рамкой, правая панель показывает сведения об этом фото, просмотр не открывается
|
||||||
|
|
||||||
|
#### Scenario: Двойной выбор
|
||||||
|
- **WHEN** пользователь дважды кликает по фото в сетке указателем
|
||||||
|
- **THEN** открывается режим просмотра этого фото
|
||||||
|
|
||||||
|
#### Scenario: Касание на сенсорном экране
|
||||||
|
- **WHEN** на сенсорном устройстве пользователь касается фото в сетке
|
||||||
|
- **THEN** открывается режим просмотра этого фото, а отметка выделения не появляется
|
||||||
|
|
||||||
|
#### Scenario: Смена альбома
|
||||||
|
- **WHEN** пользователь переходит в другой альбом
|
||||||
|
- **THEN** отметка выбора сбрасывается
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Выдвижение панели и рейла на узком экране
|
||||||
|
|
||||||
|
- [x] 1.1 В `AppShell.svelte` у `<aside aria-label="Панель">` убрать постоянный `translate-x-full` и эмитить единственный класс трансформации: `panelOpen ? 'translate-x-0' : 'translate-x-full'` (вариант `xl:translate-x-0` сохранить). Проверка: при ширине 390px после нажатия «Панель» computed `translate` равен `0px`, `aside` попадает в пределы вьюпорта.
|
||||||
|
- [x] 1.2 Тем же принципом нормализовать `<aside aria-label="Альбомы">` (единственный класс вместо постоянного `-translate-x-full`). Проверка: на 390px кнопка «Альбомы» открывает рейл (computed `translate: 0px`).
|
||||||
|
- [x] 1.3 Проверить отсутствие регрессии на рабочем столе: при ширине >= 1280px обе панели видны статично, разделители ширины работают. Проверка: вьюпорт 1280px — панели видимы, drag разделителя меняет ширину.
|
||||||
|
|
||||||
|
## 2. Открытие фото одним касанием на сенсорных экранах
|
||||||
|
|
||||||
|
- [x] 2.1 В `PhotoGrid.svelte` фиксировать `pointerType` на `pointerdown` ячейки и добавить хелпер активации: для `touch`/`pen` вызывать `onOpen(i)`, иначе — `onSelect(i, e)`. Проверка: тап по фото на touch-эмуляции открывает маршрут просмотра; клик мышью выделяет и не открывает.
|
||||||
|
- [x] 2.2 Оставить `ondblclick -> onOpen` для мыши без изменений. Проверка: двойной клик мышью открывает просмотр.
|
||||||
|
- [x] 2.3 При сенсорной активации не менять `selection`, `cursorIndex` и содержимое панели. Проверка: после тапа `selection` пуст, номер фото в панели не появляется, происходит переход в просмотр.
|
||||||
|
- [x] 2.4 Добавить ячейке `touch-action: manipulation`, чтобы исключить двойной тап-зум. Проверка: быстрый двойной тап не масштабирует страницу и открывает просмотр.
|
||||||
|
- [x] 2.5 Проверить, что клавиатурный `Enter` открывает фото, а навигация стрелками и ровинг `tabindex` не изменились. Проверка: Enter на ячейке открывает просмотр; стрелки двигают курсор, ровно одна ячейка tabbable.
|
||||||
|
|
||||||
|
## 3. Интеграционная проверка
|
||||||
|
|
||||||
|
- [x] 3.1 `npm run check` и `npm run build` во `frontend/` проходят без ошибок. Проверка: обе команды завершаются успешно.
|
||||||
|
- [x] 3.2 CDP-прогон на вьюпорте 390x844 с touch: (а) «Панель» выдвигает панель, оверлей закрывает; (б) тап по фото открывает просмотр; (в) мышь-клик по фото выделяет без перехода; (г) «Альбомы» открывает рейл. Проверка: все четыре пункта наблюдаемы.
|
||||||
|
- [x] 3.3 Убедиться, что сценарии в `openspec/specs/ui-workspace/spec.md` синхронизируются при архивации изменения. Проверка: `openspec validate "mobile-tap-open-and-panel" --strict` проходит; при архивации спека содержит новые сценарии.
|
||||||
Reference in New Issue
Block a user