diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index eb4f286d96..df12e59640 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -51,7 +51,7 @@ </header> <main tabindex="-1" - 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]" + 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 sidebar:grid-cols-[theme(spacing.64)_auto]" > {#if sidebar}{@render sidebar()}{:else if admin} <AdminSideBar /> diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 161407fde4..a6a72e842d 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -23,7 +23,7 @@ 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 { sidebarStore } from '$lib/stores/sidebar.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; interface Props { @@ -62,32 +62,30 @@ > <SkipLink text={$t('skip_to_content')} /> <div - 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]" + 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 sidebar:grid-cols-[theme(spacing.64)_auto]" > <div class="flex flex-row gap-1 mx-4 items-center"> - <div> - <IconButton - id={menuButtonId} - shape="round" - color="secondary" - variant="ghost" - size="medium" - 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> + <IconButton + id={menuButtonId} + shape="round" + color="secondary" + variant="ghost" + size="medium" + aria-label={$t('main_menu')} + icon={mdiMenu} + onclick={() => { + sidebarStore.toggle(); + }} + onmousedown={(event: MouseEvent) => { + if (sidebarStore.isOpen) { + // stops event from reaching the default handler when clicking outside of the sidebar + event.stopPropagation(); + } + }} + class="sidebar:hidden" + /> <a data-sveltekit-preload-data="hover" href={AppRoute.PHOTOS}> - <ImmichLogo class="max-md:h-[48px] h-[50px]" noText={mobileDevice.maxMd} /> + <ImmichLogo class="max-md:h-[48px] h-[50px]" noText={!mobileDevice.isFullSidebar} /> </a> </div> <div class="flex justify-between gap-4 lg:gap-8 pr-6"> diff --git a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte index 47e46c59b5..67d3eaf523 100644 --- a/web/src/lib/components/shared-components/side-bar/purchase-info.svelte +++ b/web/src/lib/components/shared-components/side-bar/purchase-info.svelte @@ -110,7 +110,7 @@ <div> <Icon path={mdiInformationOutline} - class="hidden md:flex text-immich-primary dark:text-immich-dark-primary font-medium" + class="hidden sidebar:flex text-immich-primary dark:text-immich-dark-primary font-medium" size="18" /> </div> @@ -123,7 +123,7 @@ {#if showMessage} <dialog open - 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" + class="hidden sidebar: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)} diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-section.spec.ts b/web/src/lib/components/shared-components/side-bar/side-bar-section.spec.ts new file mode 100644 index 0000000000..16c985ce35 --- /dev/null +++ b/web/src/lib/components/shared-components/side-bar/side-bar-section.spec.ts @@ -0,0 +1,80 @@ +import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte'; +import { sidebarStore } from '$lib/stores/sidebar.svelte'; +import { render, screen } from '@testing-library/svelte'; +import { vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + return { + mobileDevice: { + isFullSidebar: false, + }, + }; +}); + +vi.mock('$lib/stores/mobile-device.svelte', () => ({ + mobileDevice: mocks.mobileDevice, +})); + +vi.mock('$lib/stores/sidebar.svelte', () => ({ + sidebarStore: { + isOpen: false, + reset: vi.fn(), + }, +})); + +describe('SideBarSection component', () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.mobileDevice.isFullSidebar = false; + sidebarStore.isOpen = false; + }); + + it.each` + isFullSidebar | isSidebarOpen | expectedInert + ${false} | ${false} | ${true} + ${false} | ${true} | ${false} + ${true} | ${false} | ${false} + ${true} | ${true} | ${false} + `( + 'inert is $expectedInert when isFullSidebar=$isFullSidebar and isSidebarOpen=$isSidebarOpen', + ({ isFullSidebar, isSidebarOpen, expectedInert }) => { + // setup + mocks.mobileDevice.isFullSidebar = isFullSidebar; + sidebarStore.isOpen = isSidebarOpen; + + // when + render(SideBarSection); + const parent = screen.getByTestId('sidebar-parent'); + + // then + expect(parent.inert).toBe(expectedInert); + }, + ); + + it('should set width when sidebar is expanded', () => { + // setup + mocks.mobileDevice.isFullSidebar = false; + sidebarStore.isOpen = true; + + // when + render(SideBarSection); + const parent = screen.getByTestId('sidebar-parent'); + + // then + expect(parent.classList).toContain('sidebar:w-[16rem]'); // sets the initial width for page load + expect(parent.classList).toContain('w-[min(100vw,16rem)]'); + expect(parent.classList).toContain('shadow-2xl'); + }); + + it('should close the sidebar if it is open on initial render', () => { + // setup + mocks.mobileDevice.isFullSidebar = false; + sidebarStore.isOpen = true; + + // when + render(SideBarSection); + + // then + expect(sidebarStore.reset).toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte b/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte index 4b22a16c9c..74eb7d266b 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar-section.svelte @@ -2,52 +2,45 @@ 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'; + import { mobileDevice } from '$lib/stores/mobile-device.svelte'; + import { sidebarStore } from '$lib/stores/sidebar.svelte'; + import { onMount, type Snippet } from 'svelte'; interface Props { children?: Snippet; } - const mdBreakpoint = 768; - let { children }: Props = $props(); - let innerWidth: number = $state(0); + const isHidden = $derived(!sidebarStore.isOpen && !mobileDevice.isFullSidebar); + const isExpanded = $derived(sidebarStore.isOpen && !mobileDevice.isFullSidebar); - const closeSidebar = (width: number) => { - isSidebarOpen.value = width >= mdBreakpoint; - }; - - $effect(() => { - closeSidebar(innerWidth); + onMount(() => { + closeSidebar(); }); - const isHidden = $derived(!isSidebarOpen.value && innerWidth < mdBreakpoint); - const isExpanded = $derived(isSidebarOpen.value && innerWidth < mdBreakpoint); - - const handleClickOutside = () => { - if (!isSidebarOpen.value) { + const closeSidebar = () => { + if (!isExpanded) { return; } - closeSidebar(innerWidth); + sidebarStore.reset(); if (isHidden) { document.querySelector<HTMLButtonElement>(`#${menuButtonId}`)?.focus(); } }; </script> -<svelte:window bind:innerWidth /> <section id="sidebar" tabindex="-1" - 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="immich-scrollbar relative z-10 w-0 sidebar: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} + class:w-[min(100vw,16rem)]={sidebarStore.isOpen} + data-testid="sidebar-parent" inert={isHidden} - use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }} + use:clickOutside={{ onOutclick: closeSidebar, onEscape: closeSidebar }} use:focusTrap={{ active: isExpanded }} > <div class="pr-6 flex flex-col gap-1 h-max min-h-full"> diff --git a/web/src/lib/stores/mobile-device.svelte.ts b/web/src/lib/stores/mobile-device.svelte.ts index 19fe28b452..ee6fa87dab 100644 --- a/web/src/lib/stores/mobile-device.svelte.ts +++ b/web/src/lib/stores/mobile-device.svelte.ts @@ -2,6 +2,7 @@ import { MediaQuery } from 'svelte/reactivity'; const pointerCoarse = new MediaQuery('pointer:coarse'); const maxMd = new MediaQuery('max-width: 767px'); +const sidebar = new MediaQuery(`min-width: 850px`); export const mobileDevice = { get pointerCoarse() { @@ -10,4 +11,7 @@ export const mobileDevice = { get maxMd() { return maxMd.current; }, + get isFullSidebar() { + return sidebar.current; + }, }; diff --git a/web/src/lib/stores/side-bar.svelte.ts b/web/src/lib/stores/side-bar.svelte.ts deleted file mode 100644 index 791ee32c91..0000000000 --- a/web/src/lib/stores/side-bar.svelte.ts +++ /dev/null @@ -1 +0,0 @@ -export const isSidebarOpen = $state({ value: false }); diff --git a/web/src/lib/stores/sidebar.svelte.ts b/web/src/lib/stores/sidebar.svelte.ts new file mode 100644 index 0000000000..bebc9ca5b2 --- /dev/null +++ b/web/src/lib/stores/sidebar.svelte.ts @@ -0,0 +1,21 @@ +import { mobileDevice } from '$lib/stores/mobile-device.svelte'; + +class SidebarStore { + isOpen = $derived.by(() => mobileDevice.isFullSidebar); + + /** + * Reset the sidebar visibility to the default, based on the current screen width. + */ + reset() { + this.isOpen = mobileDevice.isFullSidebar; + } + + /** + * Toggles the sidebar visibility, if available at the current screen width. + */ + toggle() { + this.isOpen = mobileDevice.isFullSidebar ? true : !this.isOpen; + } +} + +export const sidebarStore = new SidebarStore(); diff --git a/web/tailwind.config.js b/web/tailwind.config.js index e701eefd7a..95611d486d 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -55,6 +55,7 @@ export default { 'max-lg': { max: '1023px' }, 'max-md': { max: '767px' }, 'max-sm': { max: '639px' }, + sidebar: { min: '850px' }, }, }, },