diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c1e72dc5cc..92731cb95c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -717,8 +717,8 @@ importers:
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@immich/ui':
- specifier: ^0.50.1
- version: 0.50.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
+ specifier: ^0.52.0
+ version: 0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@@ -3054,8 +3054,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
- '@immich/ui@0.50.1':
- resolution: {integrity: sha512-fNlQGh75ZFa/UZAgJaYk9/ItHOXHNNzN4CunjCmE7WocVVkUZbUxopN9Ku3F5GULSqD/zJ5gNO6PQAZ1ZoSaaQ==}
+ '@immich/ui@0.52.0':
+ resolution: {integrity: sha512-ECQIE5qYNpe7Q5+hifIGUDaRQXBkPOp9dvZaHELWWzAGIhbwG+mUYwMpUgU2TO7fV5u8XU6nHyBuC055zApiWQ==}
peerDependencies:
svelte: ^5.0.0
@@ -10677,6 +10677,10 @@ packages:
resolution: {integrity: sha512-i/w5Ie4tENfGYbdCo2iJ+oies0vOFd8QXWHopKOUzudfLCvnmeheF2PpHp89Z2azpc+c2su3lMiWO/SpP+429A==}
engines: {node: '>=0.12.18'}
+ simple-icons@16.4.0:
+ resolution: {integrity: sha512-8CKtCvx1Zq3L0CBsR4RR1MjGCXkXbzdspwl2yCxs8oWkstbzj2+DatRKDee/tuj3Ffd/2CDzwEky9RgG2yggew==}
+ engines: {node: '>=0.12.18'}
+
sirv@2.0.4:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
@@ -15026,14 +15030,14 @@ snapshots:
dependencies:
svelte: 5.46.1
- '@immich/ui@0.50.1(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)':
+ '@immich/ui@0.52.0(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.1)
'@internationalized/date': 3.10.0
'@mdi/js': 7.4.47
bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(yaml@2.8.2)))(svelte@5.46.1)
luxon: 3.7.2
- simple-icons: 15.22.0
+ simple-icons: 16.4.0
svelte: 5.46.1
svelte-highlight: 7.9.0
tailwind-merge: 3.4.0
@@ -24017,6 +24021,8 @@ snapshots:
simple-icons@15.22.0: {}
+ simple-icons@16.4.0: {}
+
sirv@2.0.4:
dependencies:
'@polka/url': 1.0.0-next.29
diff --git a/web/package.json b/web/package.json
index 55f7248375..50a9a7d91d 100644
--- a/web/package.json
+++ b/web/package.json
@@ -19,7 +19,7 @@
"format": "prettier --check .",
"format:fix": "prettier --write . && pnpm run format:i18n",
"format:i18n": "pnpm dlx sort-json ../i18n/*.json",
- "test": "vitest --run",
+ "test": "vitest",
"test:cov": "vitest --coverage",
"test:watch": "vitest dev",
"prepare": "svelte-kit sync"
@@ -28,7 +28,7 @@
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
- "@immich/ui": "^0.50.1",
+ "@immich/ui": "^0.52.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
diff --git a/web/src/lib/components/TestWrapper.svelte b/web/src/lib/components/TestWrapper.svelte
new file mode 100644
index 0000000000..918053cfc3
--- /dev/null
+++ b/web/src/lib/components/TestWrapper.svelte
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/web/src/lib/components/album-page/__tests__/album-card.spec.ts b/web/src/lib/components/album-page/__tests__/album-card.spec.ts
index 9e396bec3e..67ae2daf2e 100644
--- a/web/src/lib/components/album-page/__tests__/album-card.spec.ts
+++ b/web/src/lib/components/album-page/__tests__/album-card.spec.ts
@@ -1,4 +1,5 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
+import { renderWithTooltips } from '$tests/helpers';
import { albumFactory } from '@test-data/factories/album-factory';
import '@testing-library/jest-dom';
import { render, waitFor, type RenderResult } from '@testing-library/svelte';
@@ -88,7 +89,7 @@ describe('AlbumCard component', () => {
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
beforeEach(async () => {
- sut = render(AlbumCard, { album, onShowContextMenu });
+ sut = renderWithTooltips(AlbumCard, { album, onShowContextMenu });
const albumImgElement = sut.getByTestId('album-image');
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
diff --git a/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts b/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts
index 98d99d8d77..126beead9c 100644
--- a/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts
+++ b/web/src/lib/components/asset-viewer/actions/delete-action.spec.ts
@@ -1,7 +1,7 @@
+import { renderWithTooltips } from '$tests/helpers';
import type { AssetResponseDto } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import '@testing-library/jest-dom';
-import { render } from '@testing-library/svelte';
import DeleteAction from './delete-action.svelte';
let asset: AssetResponseDto;
@@ -13,8 +13,12 @@ describe('DeleteAction component', () => {
});
it('displays a button to move the asset to the trash bin', () => {
- const { getByTitle, queryByTitle } = render(DeleteAction, { asset, onAction: vi.fn(), preAction: vi.fn() });
- expect(getByTitle('delete')).toBeInTheDocument();
+ const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
+ asset,
+ onAction: vi.fn(),
+ preAction: vi.fn(),
+ });
+ expect(getByLabelText('delete')).toBeInTheDocument();
expect(queryByTitle('deletePermanently')).toBeNull();
});
});
@@ -25,8 +29,12 @@ describe('DeleteAction component', () => {
});
it('displays a button to permanently delete the asset', () => {
- const { getByTitle, queryByTitle } = render(DeleteAction, { asset, onAction: vi.fn(), preAction: vi.fn() });
- expect(getByTitle('permanently_delete')).toBeInTheDocument();
+ const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
+ asset,
+ onAction: vi.fn(),
+ preAction: vi.fn(),
+ });
+ expect(getByLabelText('permanently_delete')).toBeInTheDocument();
expect(queryByTitle('delete')).toBeNull();
});
});
diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts
index 55231c11ae..48c72221e5 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts
+++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts
@@ -1,9 +1,9 @@
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
+import { renderWithTooltips } from '$tests/helpers';
import { assetFactory } from '@test-data/factories/asset-factory';
import { preferencesFactory } from '@test-data/factories/preferences-factory';
import { userAdminFactory } from '@test-data/factories/user-factory';
import '@testing-library/jest-dom';
-import { render } from '@testing-library/svelte';
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
describe('AssetViewerNavBar component', () => {
@@ -16,12 +16,13 @@ describe('AssetViewerNavBar component', () => {
showShareButton: false,
preAction: () => {},
onZoomImage: () => {},
- onCopyImage: () => {},
onAction: () => {},
onRunJob: () => {},
onPlaySlideshow: () => {},
onShowDetail: () => {},
onClose: () => {},
+ playOriginalVideo: false,
+ setPlayOriginalVideo: () => Promise.resolve(),
};
beforeAll(() => {
@@ -51,8 +52,8 @@ describe('AssetViewerNavBar component', () => {
preferencesStore.set(prefs);
const asset = assetFactory.build({ isTrashed: false });
- const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
- expect(getByTitle('go_back')).toBeInTheDocument();
+ const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
+ expect(getByLabelText('go_back')).toBeInTheDocument();
});
describe('if the current user owns the asset', () => {
@@ -65,8 +66,8 @@ describe('AssetViewerNavBar component', () => {
const prefs = preferencesFactory.build({ cast: { gCastEnabled: false } });
preferencesStore.set(prefs);
- const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
- expect(getByTitle('delete')).toBeInTheDocument();
+ const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
+ expect(getByLabelText('delete')).toBeInTheDocument();
});
});
});
diff --git a/web/src/lib/components/faces-page/manage-people-visibility.spec.ts b/web/src/lib/components/faces-page/manage-people-visibility.spec.ts
deleted file mode 100644
index 6762b31416..0000000000
--- a/web/src/lib/components/faces-page/manage-people-visibility.spec.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
-import { sdkMock } from '$lib/__mocks__/sdk.mock';
-import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte';
-import type { PersonResponseDto } from '@immich/sdk';
-import { personFactory } from '@test-data/factories/person-factory';
-import { render } from '@testing-library/svelte';
-import { tick } from 'svelte';
-
-describe('ManagePeopleVisibility Component', () => {
- let personVisible: PersonResponseDto;
- let personHidden: PersonResponseDto;
- let personWithoutName: PersonResponseDto;
-
- beforeAll(() => {
- // Prevents errors from `img.decode()` in ImageThumbnail
- Object.defineProperty(HTMLImageElement.prototype, 'decode', {
- value: vi.fn(),
- });
- });
-
- beforeEach(() => {
- vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
- personVisible = personFactory.build({ isHidden: false });
- personHidden = personFactory.build({ isHidden: true });
- personWithoutName = personFactory.build({ isHidden: false, name: undefined });
- sdkMock.updatePeople.mockResolvedValue([]);
- });
-
- afterEach(() => {
- vi.resetAllMocks();
- });
-
- it('does not update people when no changes are made', () => {
- const { getByText } = render(ManagePeopleVisibility, {
- props: {
- people: [personVisible, personHidden, personWithoutName],
- totalPeopleCount: 3,
- onClose: vi.fn(),
- loadNextPage: vi.fn(),
- },
- });
-
- const saveButton = getByText('done');
- saveButton.click();
- expect(sdkMock.updatePeople).not.toHaveBeenCalled();
- });
-
- // svelte animations require a real browser
- it.skip('hides unnamed people on first button press', () => {
- const { getByText, getByTitle } = render(ManagePeopleVisibility, {
- props: {
- people: [personVisible, personHidden, personWithoutName],
- totalPeopleCount: 3,
- onClose: vi.fn(),
- loadNextPage: vi.fn(),
- },
- });
-
- getByTitle('hide_unnamed_people').click();
- getByText('done').click();
-
- expect(sdkMock.updatePeople).toHaveBeenCalledWith({
- peopleUpdateDto: {
- people: [{ id: personWithoutName.id, isHidden: true }],
- },
- });
- });
-
- // svelte animations require a real browser
- it.skip('hides all people on second button press', async () => {
- const { getByText, getByTitle } = render(ManagePeopleVisibility, {
- props: {
- people: [personVisible, personHidden, personWithoutName],
- totalPeopleCount: 3,
- onClose: vi.fn(),
- loadNextPage: vi.fn(),
- },
- });
-
- getByTitle('hide_unnamed_people').click();
- await tick();
- getByTitle('hide_all_people').click();
- getByText('done').click();
-
- expect(sdkMock.updatePeople).toHaveBeenCalledWith({
- peopleUpdateDto: {
- people: expect.arrayContaining([
- { id: personVisible.id, isHidden: true },
- { id: personWithoutName.id, isHidden: true },
- ]),
- },
- });
- });
-
- // svelte animations require a real browser
- it.skip('shows all people on third button press', async () => {
- const { getByText, getByTitle } = render(ManagePeopleVisibility, {
- props: {
- people: [personVisible, personHidden, personWithoutName],
- totalPeopleCount: 3,
- onClose: vi.fn(),
- loadNextPage: vi.fn(),
- },
- });
-
- getByTitle('hide_unnamed_people').click();
- await tick();
- getByTitle('hide_all_people').click();
- await tick();
- getByTitle('show_all_people').click();
- getByText('done').click();
-
- expect(sdkMock.updatePeople).toHaveBeenCalledWith({
- peopleUpdateDto: {
- people: [{ id: personHidden.id, isHidden: false }],
- },
- });
- });
-});
diff --git a/web/src/lib/components/shared-components/__test__/combobox.spec.ts b/web/src/lib/components/shared-components/__test__/combobox.spec.ts
deleted file mode 100644
index e1518809b4..0000000000
--- a/web/src/lib/components/shared-components/__test__/combobox.spec.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
-import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
-import Combobox from '$lib/components/shared-components/combobox.svelte';
-import { render, screen } from '@testing-library/svelte';
-
-describe('Combobox component', () => {
- beforeAll(() => {
- vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
- vi.stubGlobal('visualViewport', getVisualViewportMock());
- });
-
- it('shows selected option', () => {
- render(Combobox, {
- label: 'test',
- selectedOption: { label: 'option-1', value: 'option-1' },
- });
-
- expect(screen.getByRole('combobox')).toHaveValue('option-1');
- });
-
- it('clears the selected option when set to undefined', async () => {
- const { rerender } = render(Combobox, {
- label: 'test',
- selectedOption: { label: 'option-1', value: 'option-1' },
- });
-
- await rerender({ selectedOption: undefined });
- expect(screen.getByRole('combobox')).toHaveValue('');
- });
-});
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 3a1d4f49f9..d921392512 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -21,7 +21,14 @@
import { copyToClipboard, getReleaseType, semverToName } from '$lib/utils';
import { maintenanceShouldRedirect } from '$lib/utils/maintenance';
import { isAssetViewerRoute } from '$lib/utils/navigation';
- import { CommandPaletteContext, modalManager, setTranslations, toastManager, type ActionItem } from '@immich/ui';
+ import {
+ CommandPaletteDefaultProvider,
+ TooltipProvider,
+ modalManager,
+ setTranslations,
+ toastManager,
+ type ActionItem,
+ } from '@immich/ui';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiThemeLightDark } from '@mdi/js';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -180,7 +187,7 @@
-
+
{page.data.meta?.title || 'Web'} - Immich
@@ -228,15 +235,17 @@
}}
/>
-{#if page.data.error}
-
-{:else}
- {@render children?.()}
-{/if}
+
+ {#if page.data.error}
+
+ {:else}
+ {@render children?.()}
+ {/if}
-{#if showNavigationLoadingBar}
-
-{/if}
+ {#if showNavigationLoadingBar}
+
+ {/if}
-
-
+
+
+
diff --git a/web/src/routes/admin/library-management/(list)/+layout.svelte b/web/src/routes/admin/library-management/(list)/+layout.svelte
index e741dbd610..3f9374283f 100644
--- a/web/src/routes/admin/library-management/(list)/+layout.svelte
+++ b/web/src/routes/admin/library-management/(list)/+layout.svelte
@@ -8,7 +8,7 @@
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
- import { Button, CommandPaletteContext } from '@immich/ui';
+ import { Button, CommandPaletteDefaultProvider } from '@immich/ui';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -51,7 +51,7 @@
-
+
diff --git a/web/src/routes/admin/library-management/[id]/+layout.svelte b/web/src/routes/admin/library-management/[id]/+layout.svelte
index 62b4891459..762d3ea073 100644
--- a/web/src/routes/admin/library-management/[id]/+layout.svelte
+++ b/web/src/routes/admin/library-management/[id]/+layout.svelte
@@ -16,7 +16,7 @@
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { LibraryResponseDto } from '@immich/sdk';
- import { Code, CommandPaletteContext, Container, Heading, modalManager } from '@immich/ui';
+ import { Code, CommandPaletteDefaultProvider, Container, Heading, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -51,7 +51,7 @@
-
+
-
+
diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte
index 4a8987708e..f531c8337b 100644
--- a/web/src/routes/admin/system-settings/+page.svelte
+++ b/web/src/routes/admin/system-settings/+page.svelte
@@ -26,7 +26,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
import { getSystemConfigActions } from '$lib/services/system-config.service';
- import { Alert, CommandPaletteContext, Container } from '@immich/ui';
+ import { Alert, CommandPaletteDefaultProvider, Container } from '@immich/ui';
import {
mdiAccountOutline,
mdiBackupRestore,
@@ -214,7 +214,7 @@
);
-
+
diff --git a/web/src/routes/admin/users/(list)/+layout.svelte b/web/src/routes/admin/users/(list)/+layout.svelte
index a8c281690d..1979054014 100644
--- a/web/src/routes/admin/users/(list)/+layout.svelte
+++ b/web/src/routes/admin/users/(list)/+layout.svelte
@@ -5,7 +5,7 @@
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
- import { Button, CommandPaletteContext, Container, Icon } from '@immich/ui';
+ import { Button, CommandPaletteDefaultProvider, Container, Icon } from '@immich/ui';
import { mdiInfinity } from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
@@ -44,7 +44,7 @@
{onUserAdminDeleted}
/>
-
+
diff --git a/web/src/routes/admin/users/[id]/+layout.svelte b/web/src/routes/admin/users/[id]/+layout.svelte
index d3fe5fdcaf..c58693fe7f 100644
--- a/web/src/routes/admin/users/[id]/+layout.svelte
+++ b/web/src/routes/admin/users/[id]/+layout.svelte
@@ -17,7 +17,7 @@
Alert,
Badge,
Code,
- CommandPaletteContext,
+ CommandPaletteDefaultProvider,
Container,
getByteUnitString,
Heading,
@@ -99,7 +99,7 @@
{onUserAdminDeleted}
/>
-
+
, K extends Component>(
+ component: K,
+ props: T,
+) => {
+ return render(TestWrapper as Component<{ component: K; componentProps: T }>, {
+ component,
+ componentProps: props,
+ }) as unknown as RenderResult;
+};