feat(web): expand/collapse sidebar (#16768)

* feat: expand/collapse sidebar

* fix: general PR cleanup

- add skip link unit test
- remove unused tailwind styles
- adjust asset grid spacing
- fix event propogation

* fix: cleaning up event listeners

* fix: purchase modal and button on small screens

* fix: explicit tailwind classes

* fix: no animation on initial page load

* fix: sidebar spacing and reactivity

* chore: reverting changes to icons in nav and account info panel

* fix: remove left margin from the asset grid after merging in new timeline

* chore: extract search-bar changes for a separate PR

* fix: add margin to memories
This commit is contained in:
Ben 2025-04-01 22:12:04 -04:00 committed by GitHub
parent 00d3b8d83a
commit 6e62c09d84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 193 additions and 66 deletions

View File

@ -864,6 +864,7 @@
"loop_videos": "Loop videos",
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
"main_branch_warning": "Youre using a development version; we strongly recommend using a release version!",
"main_menu": "Main menu",
"make": "Make",
"manage_shared_links": "Manage shared links",
"manage_sharing_with_partners": "Manage sharing with partners",

View File

@ -3,15 +3,16 @@
interface Props {
show: boolean;
active?: boolean;
}
let { show = $bindable() }: Props = $props();
let { show = $bindable(), active = $bindable() }: Props = $props();
</script>
<button type="button" onclick={() => (show = true)}>Open</button>
{#if show}
<div use:focusTrap>
<div use:focusTrap={{ active }}>
<div>
<span>text</span>
<button data-testid="one" type="button" onclick={() => (show = false)}>Close</button>

View File

@ -12,6 +12,12 @@ describe('focusTrap action', () => {
expect(document.activeElement).toEqual(screen.getByTestId('one'));
});
it('should not set focus if inactive', async () => {
render(FocusTrapTest, { show: true, active: false });
await tick();
expect(document.activeElement).toBe(document.body);
});
it('supports backward focus wrapping', async () => {
render(FocusTrapTest, { show: true });
await tick();

View File

@ -35,12 +35,12 @@ export function clickOutside(node: HTMLElement, options: Options = {}): ActionRe
}
};
document.addEventListener('mousedown', handleClick, true);
document.addEventListener('mousedown', handleClick, false);
node.addEventListener('keydown', handleKey, false);
return {
destroy() {
document.removeEventListener('mousedown', handleClick, true);
document.removeEventListener('mousedown', handleClick, false);
node.removeEventListener('keydown', handleKey, false);
},
};

View File

@ -1,16 +1,34 @@
import { shortcuts } from '$lib/actions/shortcut';
import { tick } from 'svelte';
const selectors =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
interface Options {
/**
* Set whether the trap is active or not.
*/
active?: boolean;
}
export function focusTrap(container: HTMLElement) {
const selectors =
'button:not([disabled], .hidden), [href]:not(.hidden), input:not([disabled], .hidden), select:not([disabled], .hidden), textarea:not([disabled], .hidden), [tabindex]:not([tabindex="-1"], .hidden)';
export function focusTrap(container: HTMLElement, options?: Options) {
const triggerElement = document.activeElement;
const focusableElement = container.querySelector<HTMLElement>(selectors);
const withDefaults = (options?: Options) => {
return {
active: options?.active ?? true,
};
};
const setInitialFocus = () => {
const focusableElement = container.querySelector<HTMLElement>(selectors);
// Use tick() to ensure focus trap works correctly inside <Portal />
void tick().then(() => focusableElement?.focus());
};
if (withDefaults(options).active) {
setInitialFocus();
}
const getFocusableElements = (): [HTMLElement | null, HTMLElement | null] => {
const focusableElements = container.querySelectorAll<HTMLElement>(selectors);
@ -27,7 +45,7 @@ export function focusTrap(container: HTMLElement) {
shortcut: { key: 'Tab' },
onShortcut: (event) => {
const [firstElement, lastElement] = getFocusableElements();
if (document.activeElement === lastElement) {
if (document.activeElement === lastElement && withDefaults(options).active) {
event.preventDefault();
firstElement?.focus();
}
@ -39,7 +57,7 @@ export function focusTrap(container: HTMLElement) {
shortcut: { key: 'Tab', shift: true },
onShortcut: (event) => {
const [firstElement, lastElement] = getFocusableElements();
if (document.activeElement === firstElement) {
if (document.activeElement === firstElement && withDefaults(options).active) {
event.preventDefault();
lastElement?.focus();
}
@ -48,6 +66,12 @@ export function focusTrap(container: HTMLElement) {
]);
return {
update(newOptions?: Options) {
options = newOptions;
if (withDefaults(options).active) {
setInitialFocus();
}
},
destroy() {
destroyShortcuts?.();
if (triggerElement instanceof HTMLElement) {

View File

@ -7,10 +7,17 @@
* Target for the skip link to move focus to.
*/
target?: string;
/**
* Text for the skip link button.
*/
text?: string;
/**
* Breakpoint at which the skip link is visible. Defaults to always being visible.
*/
breakpoint?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
}
let { target = 'main', text = $t('skip_to_content') }: Props = $props();
let { target = 'main', text = $t('skip_to_content'), breakpoint }: Props = $props();
let isFocused = $state(false);
@ -18,6 +25,29 @@
const targetEl = document.querySelector<HTMLElement>(target);
targetEl?.focus();
};
const getBreakpoint = () => {
if (!breakpoint) {
return '';
}
switch (breakpoint) {
case 'sm': {
return 'hidden sm:block';
}
case 'md': {
return 'hidden md:block';
}
case 'lg': {
return 'hidden lg:block';
}
case 'xl': {
return 'hidden xl:block';
}
case '2xl': {
return 'hidden 2xl:block';
}
}
};
</script>
<div class="absolute z-50 top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
@ -25,6 +55,7 @@
size="sm"
rounded="none"
onclick={moveFocus}
class={getBreakpoint()}
onfocus={() => (isFocused = true)}
onblur={() => (isFocused = false)}
>

View File

@ -51,7 +51,7 @@
</header>
<main
tabindex="-1"
class="relative grid h-screen grid-cols-[theme(spacing.18)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
class="relative grid h-screen grid-cols-[theme(spacing.0)_auto] overflow-hidden bg-immich-bg max-md:pt-[var(--navbar-height-md)] pt-[var(--navbar-height)] dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
>
{#if sidebar}{@render sidebar()}{:else if admin}
<AdminSideBar />
@ -66,7 +66,7 @@
>
<div class="flex gap-2 items-center">
{#if title}
<div class="font-medium" tabindex="-1" id={headerId}>{title}</div>
<div class="font-medium outline-none" tabindex="-1" id={headerId}>{title}</div>
{/if}
{#if description}
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>

View File

@ -726,7 +726,7 @@
class={[
'scrollbar-hidden h-full overflow-y-auto outline-none',
{ 'm-0': isEmpty },
{ 'ml-4 tall:ml-0': !isEmpty },
{ 'ml-0': !isEmpty },
{ 'mr-[60px]': !isEmpty && !usingMobileDevice },
]}
tabindex="-1"

View File

@ -38,7 +38,7 @@
<section
id="memory-lane"
bind:this={memoryLaneElement}
class="relative mt-5 overflow-x-scroll overflow-y-hidden whitespace-nowrap transition-all"
class="relative mt-5 mx-2 overflow-x-scroll overflow-y-hidden whitespace-nowrap transition-all"
style="scrollbar-width:none"
use:resizeObserver={({ width }) => (offsetWidth = width)}
onscroll={onScroll}

View File

@ -1,3 +1,7 @@
<script lang="ts" module>
export const menuButtonId = 'top-menu-button';
</script>
<script lang="ts">
import { page } from '$app/state';
import { clickOutside } from '$lib/actions/click-outside';
@ -12,13 +16,14 @@
import { handleLogout } from '$lib/utils/auth';
import { getAboutInfo, logout, type ServerAboutResponseDto } from '@immich/sdk';
import { Button, IconButton } from '@immich/ui';
import { mdiHelpCircleOutline, mdiMagnify, mdiTrayArrowUp } from '@mdi/js';
import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import ThemeButton from '../theme-button.svelte';
import UserAvatar from '../user-avatar.svelte';
import AccountInfoPanel from './account-info-panel.svelte';
import { isSidebarOpen } from '$lib/stores/side-bar.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
interface Props {
@ -57,11 +62,34 @@
>
<SkipLink text={$t('skip_to_content')} />
<div
class="grid h-full grid-cols-[theme(spacing.18)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
class="grid h-full grid-cols-[theme(spacing.32)_auto] items-center border-b bg-immich-bg py-2 dark:border-b-immich-dark-gray dark:bg-immich-dark-bg md:grid-cols-[theme(spacing.64)_auto]"
>
<a data-sveltekit-preload-data="hover" class="ml-4" href={AppRoute.PHOTOS}>
<div class="flex flex-row gap-1 mx-4 items-center">
<div>
<IconButton
id={menuButtonId}
shape="round"
color="secondary"
variant="ghost"
size="large"
aria-label={$t('main_menu')}
icon={mdiMenu}
onclick={() => {
isSidebarOpen.value = !isSidebarOpen.value;
}}
onmousedown={(event: MouseEvent) => {
if (isSidebarOpen.value) {
// stops event from reaching the default handler when clicking outside of the sidebar
event.stopPropagation();
}
}}
class="md:hidden"
/>
</div>
<a data-sveltekit-preload-data="hover" href={AppRoute.PHOTOS}>
<ImmichLogo class="max-md:h-[48px] h-[50px]" noText={mobileDevice.maxMd} />
</a>
</div>
<div class="flex justify-between gap-4 lg:gap-8 pr-6">
<div class="hidden w-full max-w-5xl flex-1 tall:pl-0 sm:block">
{#if $featureFlags.search}
@ -80,7 +108,6 @@
href={AppRoute.SEARCH}
id="search-button"
class="sm:hidden"
title={$t('go_to_search')}
aria-label={$t('go_to_search')}
/>
{/if}
@ -120,7 +147,6 @@
color="secondary"
variant="ghost"
size="medium"
title={$t('support_and_feedback')}
icon={mdiHelpCircleOutline}
onclick={() => (shouldShowHelpPanel = !shouldShowHelpPanel)}
aria-label={$t('support_and_feedback')}

View File

@ -8,7 +8,9 @@
</script>
<!-- Individual Purchase Option -->
<div class="border border-gray-300 dark:border-gray-800 w-[375px] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900">
<div
class="border border-gray-300 dark:border-gray-800 w-[min(375px,100%)] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900"
>
<div class="text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiAccount} size="56" />
<p class="font-semibold text-lg mt-1">{$t('purchase_individual_title')}</p>

View File

@ -57,7 +57,7 @@
</div>
{/if}
<div class="flex gap-6 mt-4 justify-between">
<div class="flex flex-col sm:flex-row gap-6 mt-4 justify-between">
<ServerPurchaseOptionCard />
<UserPurchaseOptionCard />
</div>

View File

@ -8,7 +8,9 @@
</script>
<!-- SERVER Purchase Options -->
<div class="border border-gray-300 dark:border-gray-800 w-[375px] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900">
<div
class="border border-gray-300 dark:border-gray-800 w-[min(375px,100%)] p-8 rounded-3xl bg-gray-100 dark:bg-gray-900"
>
<div class="text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiServer} size="56" />
<p class="font-semibold text-lg mt-1">{$t('purchase_server_title')}</p>

View File

@ -78,7 +78,7 @@
<LicenseModal onClose={() => (isOpen = false)} />
{/if}
<div class="hidden md:block license-status pl-4 text-sm">
<div class="license-status pl-4 text-sm">
{#if $isPurchased && $preferences.purchase.showSupportBadge}
<button
onclick={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)}
@ -95,7 +95,7 @@
onmouseleave={() => (hoverButton = false)}
onfocus={onButtonHover}
onblur={() => (hoverButton = false)}
class="p-2 flex justify-between place-items-center place-content-center border border-immich-primary/20 dark:border-immich-dark-primary/10 mt-2 rounded-lg shadow-md dark:bg-immich-dark-primary/10 w-full"
class="p-2 flex justify-between place-items-center place-content-center border border-immich-primary/20 dark:border-immich-dark-primary/10 mt-2 rounded-lg shadow-md dark:bg-immich-dark-primary/10 min-w-52 w-full"
>
<div class="flex justify-between w-full place-items-center place-content-center">
<div class="flex place-items-center place-content-center gap-1">
@ -110,7 +110,7 @@
<div>
<Icon
path={mdiInformationOutline}
class="flex text-immich-primary dark:text-immich-dark-primary font-medium"
class="hidden md:flex text-immich-primary dark:text-immich-dark-primary font-medium"
size="18"
/>
</div>
@ -123,7 +123,7 @@
{#if showMessage}
<dialog
open
class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
class="hidden md:block w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
transition:fade={{ duration: 150 }}
onmouseover={() => (hoverMessage = true)}
onmouseleave={() => (hoverMessage = false)}

View File

@ -42,7 +42,7 @@
{/if}
<div
class="text-sm hidden group-hover:sm:flex md:flex pl-5 pr-1 place-items-center place-content-center justify-between"
class="text-sm flex md:flex pl-5 pr-1 place-items-center place-content-center justify-between min-w-52 overflow-hidden"
>
{#if $connected}
<div class="flex gap-2 place-items-center place-content-center">

View File

@ -62,11 +62,9 @@
class="flex w-full place-items-center gap-4 rounded-r-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-immich-gray hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary
{isSelected
? 'bg-immich-primary/10 text-immich-primary hover:bg-immich-primary/10 dark:bg-immich-dark-primary/10 dark:text-immich-dark-primary'
: ''}
pl-5 group-hover:sm:px-5 md:px-5
"
: ''}"
>
<div class="flex w-full place-items-center gap-4 overflow-hidden truncate">
<div class="flex w-full place-items-center gap-4 pl-5 overflow-hidden truncate">
<Icon path={icon} size="1.5em" class="shrink-0" flipped={flippedLogo} ariaHidden />
<span class="text-sm font-medium">{title}</span>
</div>

View File

@ -1,17 +1,56 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { clickOutside } from '$lib/actions/click-outside';
import { focusTrap } from '$lib/actions/focus-trap';
import { menuButtonId } from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import { isSidebarOpen } from '$lib/stores/side-bar.svelte';
import { type Snippet } from 'svelte';
interface Props {
children?: Snippet;
}
const mdBreakpoint = 768;
let { children }: Props = $props();
let innerWidth: number = $state(0);
const closeSidebar = (width: number) => {
isSidebarOpen.value = width >= mdBreakpoint;
};
$effect(() => {
closeSidebar(innerWidth);
});
const isHidden = $derived(!isSidebarOpen.value && innerWidth < mdBreakpoint);
const isExpanded = $derived(isSidebarOpen.value && innerWidth < mdBreakpoint);
const handleClickOutside = () => {
if (!isSidebarOpen.value) {
return;
}
closeSidebar(innerWidth);
if (isHidden) {
document.querySelector<HTMLButtonElement>(`#${menuButtonId}`)?.focus();
}
};
</script>
<svelte:window bind:innerWidth />
<section
id="sidebar"
tabindex="-1"
class="immich-scrollbar group relative z-10 flex w-18 flex-col gap-1 overflow-y-auto bg-immich-bg pt-8 max-md:pt-16 transition-all duration-200 dark:bg-immich-dark-bg hover:sm:w-64 hover:sm:border-r hover:sm:pr-6 hover:sm:shadow-2xl hover:sm:dark:border-r-immich-dark-gray md:w-64 md:pr-6 hover:md:border-none hover:md:shadow-none"
class="immich-scrollbar relative z-10 w-0 md:w-[16rem] overflow-y-auto overflow-x-hidden bg-immich-bg pt-8 transition-all duration-200 dark:bg-immich-dark-bg"
class:shadow-2xl={isExpanded}
class:dark:border-r-immich-dark-gray={isExpanded}
class:border-r={isExpanded}
class:w-[min(100vw,16rem)]={isSidebarOpen.value}
inert={isHidden}
use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }}
use:focusTrap={{ active: isExpanded }}
>
<div class="pr-6 flex flex-col gap-1 h-max min-h-full">
{@render children?.()}
</div>
</section>

View File

@ -84,10 +84,7 @@
bind:isSelected={isSharingSelected}
></SideBarLink>
<div class="text-xs transition-all duration-200 dark:text-immich-dark-fg">
<p class="hidden p-6 group-hover:sm:block md:block">{$t('library').toUpperCase()}</p>
<hr class="mx-4 mb-[31px] mt-8 block group-hover:sm:hidden md:hidden" />
</div>
<p class="text-xs p-6 dark:text-immich-dark-fg">{$t('library').toUpperCase()}</p>
<SideBarLink
title={$t('favorites')}

View File

@ -46,7 +46,7 @@
</script>
<div
class="hidden md:block storage-status p-4 bg-gray-100 dark:bg-immich-dark-primary/10 ml-4 rounded-lg text-sm"
class="storage-status p-4 bg-gray-100 dark:bg-immich-dark-primary/10 ml-4 rounded-lg text-sm min-w-52"
title={$t('storage_usage', {
values: {
used: getByteUnitString(usedBytes, $locale, 3),
@ -54,7 +54,6 @@
},
})}
>
<div class="hidden group-hover:sm:block md:block">
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
{#if userInteraction.serverInfo}
@ -76,4 +75,3 @@
</div>
{/if}
</div>
</div>

View File

@ -32,6 +32,7 @@
href={getLink(path)}
title={value}
class={`flex flex-grow place-items-center pl-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`}
data-sveltekit-keepfocus
>
<button type="button" {onclick} class={Object.values(tree).length === 0 ? 'invisible' : ''}>
<Icon path={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size={20} />

View File

@ -0,0 +1 @@
export const isSidebarOpen = $state({ value: false });

View File

@ -131,7 +131,7 @@
<UserPageLayout title={data.meta.title}>
{#snippet sidebar()}
<SideBarSection>
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} />
<SkipLink target={`#${headerId}`} text={$t('skip_to_folders')} breakpoint="md" />
<section>
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
<div class="h-full">

View File

@ -148,7 +148,7 @@
<UserPageLayout title={data.meta.title}>
{#snippet sidebar()}
<SideBarSection>
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} />
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" />
<section>
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
<div class="h-full">