refactor: settings accordion reactivity (#28281)

This commit is contained in:
Daniel Dietzler 2026-05-07 21:00:23 +02:00 committed by GitHub
parent 52b00b0bad
commit 2039c129f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 178 additions and 202 deletions

View file

@ -11,5 +11,6 @@
"@types/oidc-provider": "^9.0.0", "@types/oidc-provider": "^9.0.0",
"oidc-provider": "^9.0.0", "oidc-provider": "^9.0.0",
"tsx": "^4.20.6" "tsx": "^4.20.6"
} },
"packageManager": "pnpm@10.33.1"
} }

View file

@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import { accordionManager } from '$lib/managers/accordion-manager.svelte';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { onDestroy, onMount, type Snippet } from 'svelte'; import { onDestroy, type Snippet } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { getAccordionState } from './SettingAccordionState.svelte';
const accordionState = getAccordionState();
interface Props { interface Props {
title: string; title: string;
@ -21,7 +19,7 @@
title, title,
subtitle = '', subtitle = '',
key, key,
isOpen = $bindable($accordionState.has(key)), isOpen = $bindable(false),
autoScrollTo = false, autoScrollTo = false,
icon = '', icon = '',
subtitleSnippet, subtitleSnippet,
@ -30,9 +28,15 @@
let accordionElement: HTMLDivElement | undefined = $state(); let accordionElement: HTMLDivElement | undefined = $state();
const setIsOpen = (isOpen: boolean) => { $effect(() => {
isOpen = accordionManager.isOpen(key);
});
const toggleOpen = () => {
if (isOpen) { if (isOpen) {
$accordionState = $accordionState.add(key); accordionManager.close(key);
} else {
accordionManager.open(key);
if (autoScrollTo) { if (autoScrollTo) {
setTimeout(() => { setTimeout(() => {
@ -42,24 +46,11 @@
}); });
}, 200); }, 200);
} }
} else {
$accordionState.delete(key);
// eslint-disable-next-line no-self-assign
$accordionState = $accordionState;
} }
}; };
onDestroy(() => { onDestroy(() => {
setIsOpen(false); accordionManager.close(key);
});
const onclick = () => {
isOpen = !isOpen;
setIsOpen(isOpen);
};
onMount(() => {
setIsOpen(isOpen);
}); });
</script> </script>
@ -72,7 +63,7 @@
<button <button
type="button" type="button"
aria-expanded={isOpen} aria-expanded={isOpen}
{onclick} onclick={toggleOpen}
class="flex w-full place-items-center justify-between text-start" class="flex w-full place-items-center justify-between text-start"
> >
<div> <div>

View file

@ -1,43 +0,0 @@
<script lang="ts" module>
export type AccordionState = Set<string>;
const { get: getAccordionState, set: setAccordionState } = createContext<Writable<AccordionState>>();
export { getAccordionState };
</script>
<script lang="ts">
import { writable, type Writable } from 'svelte/store';
import { createContext } from '$lib/utils/context';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
import { handlePromiseError } from '$lib/utils';
import { SvelteURLSearchParams } from 'svelte/reactivity';
const getParamValues = (param: string) => {
return new Set((page.url.searchParams.get(param) || '').split(' ').filter((x) => x !== ''));
};
interface Props {
queryParam: string;
state?: Writable<AccordionState>;
children?: Snippet;
}
let { queryParam, state = writable(getParamValues(queryParam)), children }: Props = $props();
setAccordionState(state);
const searchParams = new SvelteURLSearchParams(page.url.searchParams);
$effect(() => {
if ($state.size > 0) {
searchParams.set(queryParam, [...$state].join(' '));
} else {
searchParams.delete(queryParam);
}
handlePromiseError(goto(`?${searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true }));
});
</script>
{@render children?.()}

View file

@ -0,0 +1,46 @@
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { QueryParameter } from '$lib/constants';
import { handlePromiseError } from '$lib/utils';
class AccordionManager {
// needs to be derived since `page.url.searchParams` isn't actually initialized by the time this class gets instantiated.
#searchParams = $derived(new SvelteURLSearchParams(page.url.searchParams));
#state = $derived(
new SvelteSet(
this.#searchParams
.get(QueryParameter.IS_OPEN)
?.split(' ')
.filter((x) => x !== ''),
),
);
isOpen(key: string) {
return this.#state.has(key);
}
#refreshSearchParams() {
if (this.#state.size === 0) {
this.#searchParams.delete(QueryParameter.IS_OPEN);
} else {
this.#searchParams.set(QueryParameter.IS_OPEN, [...this.#state].join(' '));
}
handlePromiseError(
goto(`?${this.#searchParams.toString()}`, { replaceState: true, noScroll: true, keepFocus: true }),
);
}
open(key: string) {
this.#state.add(key);
this.#refreshSearchParams();
}
close(key: string) {
this.#state.delete(key);
this.#refreshSearchParams();
}
}
export const accordionManager = new AccordionManager();

View file

@ -1,8 +0,0 @@
import { getContext, setContext } from 'svelte';
export function createContext<T>(key: string | symbol = Symbol()) {
return {
get: () => getContext<T>(key),
set: (context: T) => setContext<T>(key, context),
};
}

View file

@ -26,7 +26,6 @@
mdiTwoFactorAuthentication, mdiTwoFactorAuthentication,
} from '@mdi/js'; } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import SettingAccordionState from '$lib/components/shared-components/settings/SettingAccordionState.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import AppSettings from './AppSettings.svelte'; import AppSettings from './AppSettings.svelte';
import ChangePasswordSettings from './ChangePasswordSettings.svelte'; import ChangePasswordSettings from './ChangePasswordSettings.svelte';
@ -48,7 +47,6 @@
$page.url.searchParams.get(QueryParameter.OPEN_SETTING) === OpenQueryParam.OAUTH; $page.url.searchParams.get(QueryParameter.OPEN_SETTING) === OpenQueryParam.OAUTH;
</script> </script>
<SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
<SettingAccordion <SettingAccordion
icon={mdiCogOutline} icon={mdiCogOutline}
key="app-settings" key="app-settings"
@ -160,4 +158,3 @@
> >
<UserPurchaseSettings /> <UserPurchaseSettings />
</SettingAccordion> </SettingAccordion>
</SettingAccordionState>

View file

@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte'; import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import MaintenanceBackupsList from '$lib/components/maintenance/MaintenanceBackupsList.svelte'; import MaintenanceBackupsList from '$lib/components/maintenance/MaintenanceBackupsList.svelte';
import SettingAccordionState from '$lib/components/shared-components/settings/SettingAccordionState.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import { QueryParameter } from '$lib/constants';
import { getMaintenanceAdminActions } from '$lib/services/maintenance.service'; import { getMaintenanceAdminActions } from '$lib/services/maintenance.service';
import { mdiRefresh } from '@mdi/js'; import { mdiRefresh } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@ -20,7 +18,6 @@
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[StartMaintenance]}> <AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[StartMaintenance]}>
<section id="setting-content" class="flex place-content-center sm:mx-4"> <section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-212.5"> <section class="w-full pb-28 sm:w-5/6 md:w-212.5">
<SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
<SettingAccordion <SettingAccordion
title={$t('admin.maintenance_restore_database_backup')} title={$t('admin.maintenance_restore_database_backup')}
subtitle={$t('admin.maintenance_restore_database_backup_description')} subtitle={$t('admin.maintenance_restore_database_backup_description')}
@ -29,7 +26,6 @@
> >
<MaintenanceBackupsList backups={data.backups} expectedVersion={data.expectedVersion} /> <MaintenanceBackupsList backups={data.backups} expectedVersion={data.expectedVersion} />
</SettingAccordion> </SettingAccordion>
</SettingAccordionState>
</section> </section>
</section> </section>
</AdminPageLayout> </AdminPageLayout>

View file

@ -18,9 +18,7 @@
import TrashSettings from './TrashSettings.svelte'; import TrashSettings from './TrashSettings.svelte';
import UserSettings from './UserSettings.svelte'; import UserSettings from './UserSettings.svelte';
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte'; import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import SettingAccordionState from '$lib/components/shared-components/settings/SettingAccordionState.svelte';
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte'; import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
import { QueryParameter } from '$lib/constants';
import SearchBar from '$lib/elements/SearchBar.svelte'; import SearchBar from '$lib/elements/SearchBar.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
@ -215,12 +213,10 @@
<div> <div>
<SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} /> <SearchBar placeholder={$t('search_settings')} bind:name={searchQuery} showLoadingSpinner={false} />
</div> </div>
<SettingAccordionState queryParam={QueryParameter.IS_OPEN}>
{#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)} {#each filteredSettings as { component: Component, title, subtitle, key, icon } (key)}
<SettingAccordion {title} {subtitle} {key} {icon}> <SettingAccordion {title} {subtitle} {key} {icon}>
<Component /> <Component />
</SettingAccordion> </SettingAccordion>
{/each} {/each}
</SettingAccordionState>
</Container> </Container>
</AdminPageLayout> </AdminPageLayout>