feat(web): скрытие боковых панелей в альбоме и хоткеи размера миниатюр

В режиме просмотра альбома добавлена кнопка скрытия левой и правой боковых
панелей (рядом с регулятором размера миниатюр) и хоткей Tab. В скрытом режиме
панели не занимают место в раскладке, а появляются по наведению на левую или
правую кромку окна и прячутся после ухода указателя.

Клавиши + и - меняют размер миниатюр на шаг, как кнопки регулятора; хоткей
работает на всех экранах приложения и не срабатывает при вводе текста, в
открытых диалогах и меню.

Спека ui-workspace дополнена требованиями о скрытии панелей и клавишах
изменения размера миниатюр.
This commit is contained in:
iu
2026-10-20 21:30:06 +03:00
parent 3a44844ff9
commit 45a953f8bb
7 changed files with 214 additions and 28 deletions
+126 -23
View File
@@ -7,6 +7,8 @@
import Icon from './Icon.svelte';
import ThemeSwitcher from './ThemeSwitcher.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { density } from '$lib/stores/density.svelte';
import { panels } from '$lib/stores/panels.svelte';
import type { Snippet } from 'svelte';
interface Props {
@@ -14,9 +16,11 @@
rail?: Snippet;
panel?: Snippet;
panelVisible?: boolean;
/** Показывать переключатель скрытия боковых панелей (режим просмотра альбома). */
panelsToggle?: boolean;
}
let { children, rail, panel, panelVisible = true }: Props = $props();
let { children, rail, panel, panelVisible = true, panelsToggle = false }: Props = $props();
let railOpen = $state(false);
let panelOpen = $state(false);
@@ -29,6 +33,11 @@
let isFullscreen = $state(false);
$effect(() => {
// Режим скрытия действует только там, где он доступен (просмотр альбома).
if (!panelsToggle) panels.reset();
});
onMount(() => {
const r = parseInt(localStorage.getItem(RAIL.key) ?? '', 10);
if (r >= RAIL.min && r <= RAIL.max) railWidth = r;
@@ -93,6 +102,75 @@
const handle =
'bg-transparent hover:bg-primary/40 focus-visible:bg-primary/40 hidden w-1 shrink-0 cursor-col-resize p-0 transition-colors outline-none';
const railClass = $derived(
`border-border bg-chrome absolute inset-y-0 left-0 z-40 w-60 overflow-y-auto border-r transition-transform motion-reduce:transition-none lg:w-[var(--rail-w)] ${
panels.hidden
? `${railOpen ? 'translate-x-0' : '-translate-x-full'} lg:z-30 ${panels.railPeek ? 'lg:translate-x-0' : 'lg:-translate-x-full'}`
: `lg:static lg:z-auto lg:translate-x-0 ${railOpen ? 'translate-x-0' : '-translate-x-full'}`
}`
);
const panelClass = $derived(
`border-border bg-chrome absolute inset-y-0 right-0 z-40 w-72 overflow-y-auto border-l transition-transform motion-reduce:transition-none xl:w-[var(--panel-w)] ${
panels.hidden
? `${panelOpen ? 'translate-x-0' : 'translate-x-full'} xl:z-30 ${panels.panelPeek ? 'xl:translate-x-0' : 'xl:translate-x-full'}`
: `xl:static xl:z-auto xl:translate-x-0 ${panelOpen ? 'translate-x-0' : 'translate-x-full'}`
}`
);
function isTyping(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
/** Как isTyping, но ползунок и служебные input'ы текстом не считаются. */
function isTextEntry(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
if (el.isContentEditable) return true;
const tag = el.tagName;
if (tag === 'TEXTAREA' || tag === 'SELECT') return true;
if (tag === 'INPUT') {
return !['range', 'checkbox', 'radio', 'button', 'submit', 'reset'].includes(
(el as HTMLInputElement).type
);
}
return false;
}
function hasOverlay(): boolean {
return !!document.querySelector('[role="dialog"][aria-modal="true"], [role="menu"]');
}
function onKeydown(ev: KeyboardEvent) {
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.repeat) return;
// `+`/`-` меняют размер миниатюр — как кнопки под регулятором.
if (ev.key === '+' || ev.key === '=' || ev.key === '-' || ev.key === '_') {
if (isTextEntry() || hasOverlay()) return;
ev.preventDefault();
density.stepSize(ev.key === '+' || ev.key === '=' ? 1 : -1);
return;
}
if (!panelsToggle || !panel || !panelVisible) return;
if (ev.key !== 'Tab' || ev.shiftKey) return;
if (isTyping() || hasOverlay()) return;
ev.preventDefault();
panels.toggle();
}
function onAsideLeave(ev: MouseEvent, side: 'left' | 'right') {
if (!panels.hidden) return;
const el = ev.currentTarget as HTMLElement | null;
// Панель с фокусом не прячем — иначе клавиатурный пользователь теряет её.
if (el?.contains(document.activeElement)) return;
if (side === 'left') panels.railPeek = false;
else panels.panelPeek = false;
}
async function logout() {
if (auth.refresh) {
await authApi.logout(auth.refresh).catch(() => {});
@@ -102,6 +180,8 @@
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="bg-canvas text-text flex h-screen flex-col overflow-hidden">
<header
class="border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-2 py-0.5 sm:px-3"
@@ -183,23 +263,25 @@
></button>
{/if}
<aside
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-full'}"
class={railClass}
style="--rail-w: {railWidth}px"
aria-label="Альбомы"
inert={panels.hidden && !panels.railPeek && !railOpen}
onmouseleave={(e) => onAsideLeave(e, 'left')}
>
{#if rail}{@render rail()}{/if}
</aside>
<button
type="button"
class="{handle} lg:block"
aria-label="Изменить ширину панели альбомов"
title="Потяните, чтобы изменить ширину"
onpointerdown={(e) => startResize('left', e)}
onkeydown={(e) => nudge('left', e)}
></button>
{#if !panels.hidden}
<button
type="button"
class="{handle} lg:block"
aria-label="Изменить ширину панели альбомов"
title="Потяните, чтобы изменить ширину"
onpointerdown={(e) => startResize('left', e)}
onkeydown={(e) => nudge('left', e)}
></button>
{/if}
<main class="bg-canvas min-h-0 flex-1 overflow-y-auto">
{@render children()}
@@ -214,23 +296,44 @@
onclick={() => (panelOpen = false)}
></button>
{/if}
<button
type="button"
class="{handle} xl:block"
aria-label="Изменить ширину боковой панели"
title="Потяните, чтобы изменить ширину"
onpointerdown={(e) => startResize('right', e)}
onkeydown={(e) => nudge('right', e)}
></button>
{#if !panels.hidden}
<button
type="button"
class="{handle} xl:block"
aria-label="Изменить ширину боковой панели"
title="Потяните, чтобы изменить ширину"
onpointerdown={(e) => startResize('right', e)}
onkeydown={(e) => nudge('right', e)}
></button>
{/if}
<aside
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-full'}"
class={panelClass}
style="--panel-w: {panelWidth}px"
aria-label="Панель"
inert={panels.hidden && !panels.panelPeek && !panelOpen}
onmouseleave={(e) => onAsideLeave(e, 'right')}
>
{@render panel()}
</aside>
{/if}
{#if panels.hidden}
<div
class="group absolute inset-y-0 left-0 z-20 hidden w-1.5 lg:block"
aria-hidden="true"
onmouseenter={() => (panels.railPeek = true)}
>
<span class="block h-full w-full transition-colors group-hover:bg-primary/40"></span>
</div>
{#if panel && panelVisible}
<div
class="group absolute inset-y-0 right-0 z-20 hidden w-1.5 xl:block"
aria-hidden="true"
onmouseenter={() => (panels.panelPeek = true)}
>
<span class="block h-full w-full transition-colors group-hover:bg-primary/40"></span>
</div>
{/if}
{/if}
</div>
</div>