From b71aa4473bf100c6ec3cb9d4adf81606aecfb4c3 Mon Sep 17 00:00:00 2001
From: Ben <45583362+ben-basten@users.noreply.github.com>
Date: Tue, 18 Jun 2024 03:52:38 +0000
Subject: [PATCH] feat(web): keyboard accessible context menus (#10017)
* feat(web,a11y): context menu keyboard navigation
* wip: all context menus visible
* wip: more migrations to the ButtonContextMenu, usability improvements
* wip: migrate Administration, PeopleCard
* wip: refocus the button on click, docs
* fix: more intuitive RightClickContextMenu
- configurable title
- focus management: tab keys, clicks, closing the menu
- automatically closing when an option is selected
* fix: refining the little details
- adjust the aria attributes
- intuitive escape key propagation
- extract context into its own file
* fix: dropdown options not clickable in a
* wip: small fixes
- export selectedColor to prevent unexpected styling
- better context function naming
* chore: revert changes to list navigation, to reduce scope of the PR
* fix: remove topBorder prop
* feat: automatically select the first option on enter or space keypress
* fix: use Svelte store instead to handle selecting menu options
- better prop naming for ButtonContextMenu
* feat: hovering the mouse can change the active element
* fix: remove Portal, more predictable open/close behavior
* feat: make selected item visible using a scroll
- also: minor cleanup of the context-menu-navigation Svelte action
* feat: maintain context menu position on resize
* fix: use the whole padding class as better tailwind convention
* fix: options not announcing with screen reader for ButtonContextMenu
* fix: screen reader announcing right click context menu options
* fix: handle focus out scenario
---------
Co-authored-by: Alex
---
.../lib/actions/context-menu-navigation.ts | 108 ++++++++++++
web/src/lib/actions/focus-outside.ts | 4 +-
.../components/album-page/album-card.svelte | 4 +-
.../components/album-page/albums-list.svelte | 40 ++---
.../album-page/share-info-modal.svelte | 51 ++----
.../asset-viewer/asset-viewer-nav-bar.svelte | 162 +++++++-----------
.../buttons/circle-icon-button.svelte | 22 ++-
.../components/faces-page/people-card.svelte | 48 ++----
.../memory-page/memory-viewer.svelte | 10 +-
.../photos-page/actions/add-to-album.svelte | 4 -
.../asset-select-context-menu.svelte | 35 ----
.../context-menu/button-context-menu.svelte | 151 ++++++++++++++++
.../context-menu/context-menu.svelte | 32 +++-
.../context-menu/menu-option.svelte | 34 +++-
.../shared-components/context-menu/menu.ts | 3 -
.../right-click-context-menu.svelte | 68 ++++++--
web/src/lib/stores/context-menu.store.ts | 6 +
web/src/lib/utils/context-menu.ts | 35 ++--
.../[[assetId=id]]/+page.svelte | 59 ++-----
.../[[assetId=id]]/+page.svelte | 10 +-
.../[[assetId=id]]/+page.svelte | 10 +-
.../[[assetId=id]]/+page.svelte | 6 +-
.../[[assetId=id]]/+page.svelte | 14 +-
.../(user)/photos/[[assetId=id]]/+page.svelte | 10 +-
.../[[assetId=id]]/+page.svelte | 10 +-
.../admin/library-management/+page.svelte | 144 +++++++---------
26 files changed, 639 insertions(+), 441 deletions(-)
create mode 100644 web/src/lib/actions/context-menu-navigation.ts
delete mode 100644 web/src/lib/components/photos-page/asset-select-context-menu.svelte
create mode 100644 web/src/lib/components/shared-components/context-menu/button-context-menu.svelte
delete mode 100644 web/src/lib/components/shared-components/context-menu/menu.ts
create mode 100644 web/src/lib/stores/context-menu.store.ts
diff --git a/web/src/lib/actions/context-menu-navigation.ts b/web/src/lib/actions/context-menu-navigation.ts
new file mode 100644
index 0000000000..3b45e7fe52
--- /dev/null
+++ b/web/src/lib/actions/context-menu-navigation.ts
@@ -0,0 +1,108 @@
+import { shortcuts } from '$lib/actions/shortcut';
+import { tick } from 'svelte';
+import type { Action } from 'svelte/action';
+
+interface Options {
+ /**
+ * A function that is called when the dropdown should be closed.
+ */
+ closeDropdown: () => void;
+ /**
+ * The container element that with direct children that should be navigated.
+ */
+ container: HTMLElement;
+ /**
+ * Indicates if the dropdown is open.
+ */
+ isOpen: boolean;
+ /**
+ * Override the default behavior for the escape key.
+ */
+ onEscape?: (event: KeyboardEvent) => void;
+ /**
+ * A function that is called when the dropdown should be opened.
+ */
+ openDropdown?: (event: KeyboardEvent) => void;
+ /**
+ * The id of the currently selected element.
+ */
+ selectedId: string | undefined;
+ /**
+ * A function that is called when the selection changes, to notify consumers of the new selected id.
+ */
+ selectionChanged: (id: string | undefined) => void;
+}
+
+export const contextMenuNavigation: Action = (node, options: Options) => {
+ const getCurrentElement = () => {
+ const { container, selectedId: activeId } = options;
+ return container?.querySelector(`#${activeId}`) as HTMLElement | null;
+ };
+
+ const close = () => {
+ const { closeDropdown, selectionChanged } = options;
+ selectionChanged(undefined);
+ closeDropdown();
+ };
+
+ const moveSelection = async (direction: 'up' | 'down', event: KeyboardEvent) => {
+ const { selectionChanged, container, openDropdown } = options;
+ if (openDropdown) {
+ openDropdown(event);
+ await tick();
+ }
+
+ const children = Array.from(container?.children).filter((child) => child.tagName !== 'HR') as HTMLElement[];
+ if (children.length === 0) {
+ return;
+ }
+
+ const currentEl = getCurrentElement();
+ const currentIndex = currentEl ? children.indexOf(currentEl) : -1;
+ const directionFactor = (direction === 'up' ? -1 : 1) + (direction === 'up' && currentIndex === -1 ? 1 : 0);
+ const newIndex = (currentIndex + directionFactor + children.length) % children.length;
+ const selectedNode = children[newIndex];
+ selectedNode?.scrollIntoView({ block: 'nearest' });
+
+ selectionChanged(selectedNode?.id);
+ };
+
+ const onEscape = (event: KeyboardEvent) => {
+ const { onEscape } = options;
+ if (onEscape) {
+ onEscape(event);
+ return;
+ }
+ event.stopPropagation();
+ close();
+ };
+
+ const handleClick = (event: KeyboardEvent) => {
+ const { selectedId, isOpen, closeDropdown } = options;
+ if (isOpen && !selectedId) {
+ closeDropdown();
+ return;
+ }
+ if (!selectedId) {
+ void moveSelection('down', event);
+ return;
+ }
+ const currentEl = getCurrentElement();
+ currentEl?.click();
+ };
+
+ const { destroy } = shortcuts(node, [
+ { shortcut: { key: 'ArrowUp' }, onShortcut: (event) => moveSelection('up', event) },
+ { shortcut: { key: 'ArrowDown' }, onShortcut: (event) => moveSelection('down', event) },
+ { shortcut: { key: 'Escape' }, onShortcut: (event) => onEscape(event) },
+ { shortcut: { key: ' ' }, onShortcut: (event) => handleClick(event) },
+ { shortcut: { key: 'Enter' }, onShortcut: (event) => handleClick(event) },
+ ]);
+
+ return {
+ update(newOptions) {
+ options = newOptions;
+ },
+ destroy,
+ };
+};
diff --git a/web/src/lib/actions/focus-outside.ts b/web/src/lib/actions/focus-outside.ts
index c8a4d574cb..07a85b021e 100644
--- a/web/src/lib/actions/focus-outside.ts
+++ b/web/src/lib/actions/focus-outside.ts
@@ -1,5 +1,5 @@
interface Options {
- onFocusOut?: () => void;
+ onFocusOut?: (event: FocusEvent) => void;
}
export function focusOutside(node: HTMLElement, options: Options = {}) {
@@ -7,7 +7,7 @@ export function focusOutside(node: HTMLElement, options: Options = {}) {
const handleFocusOut = (event: FocusEvent) => {
if (onFocusOut && event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)) {
- onFocusOut();
+ onFocusOut(event);
}
};
diff --git a/web/src/lib/components/album-page/album-card.svelte b/web/src/lib/components/album-page/album-card.svelte
index 6ff723d3bb..b536933738 100644
--- a/web/src/lib/components/album-page/album-card.svelte
+++ b/web/src/lib/components/album-page/album-card.svelte
@@ -3,7 +3,7 @@
import { user } from '$lib/stores/user.store';
import type { AlbumResponseDto } from '@immich/sdk';
import { mdiDotsVertical } from '@mdi/js';
- import { getContextMenuPosition, type ContextMenuPosition } from '$lib/utils/context-menu';
+ import { getContextMenuPositionFromEvent, type ContextMenuPosition } from '$lib/utils/context-menu';
import { getShortDateRange } from '$lib/utils/date-time';
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
@@ -20,7 +20,7 @@
const showAlbumContextMenu = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
- onShowContextMenu?.(getContextMenuPosition(e));
+ onShowContextMenu?.(getContextMenuPositionFromEvent(e));
};
diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte
index 9c155b68e4..a5802bf13b 100644
--- a/web/src/lib/components/album-page/albums-list.svelte
+++ b/web/src/lib/components/album-page/albums-list.svelte
@@ -3,7 +3,6 @@
import { groupBy, orderBy } from 'lodash-es';
import { addUsersToAlbum, deleteAlbum, type AlbumUserAddDto, type AlbumResponseDto, isHttpError } from '@immich/sdk';
import { mdiDeleteOutline, mdiShareVariantOutline, mdiFolderDownloadOutline, mdiRenameOutline } from '@mdi/js';
- import Icon from '$lib/components/elements/icon.svelte';
import EditAlbumForm from '$lib/components/forms/edit-album-form.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
import {
@@ -167,6 +166,7 @@
let contextMenuPosition: ContextMenuPosition = { x: 0, y: 0 };
let contextMenuTargetAlbum: AlbumResponseDto | null = null;
+ let isOpen = false;
// Step 1: Filter between Owned and Shared albums, or both.
$: {
@@ -224,7 +224,6 @@
albumGroupIds = groupedAlbums.map(({ id }) => id);
}
- $: showContextMenu = !!contextMenuTargetAlbum;
$: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id;
onMount(async () => {
@@ -253,10 +252,11 @@
x: contextMenuDetail.x,
y: contextMenuDetail.y,
};
+ isOpen = true;
};
const closeAlbumContextMenu = () => {
- contextMenuTargetAlbum = null;
+ isOpen = false;
};
const handleDownloadAlbum = async () => {
@@ -419,34 +419,18 @@
{/if}
-
+
{#if showFullContextMenu}
- contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}>
-
-
- Edit
-
-
- openShareModal()}>
-
-
- Share
-
-
+ contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}
+ />
+ openShareModal()} />
{/if}
- handleDownloadAlbum()}>
-
-
- Download
-
-
+ handleDownloadAlbum()} />
{#if showFullContextMenu}
- setAlbumToDelete()}>
-
-
- Delete
-
-
+ setAlbumToDelete()} />
{/if}
diff --git a/web/src/lib/components/album-page/share-info-modal.svelte b/web/src/lib/components/album-page/share-info-modal.svelte
index c395053c70..82403efcbe 100644
--- a/web/src/lib/components/album-page/share-info-modal.svelte
+++ b/web/src/lib/components/album-page/share-info-modal.svelte
@@ -9,16 +9,14 @@
} from '@immich/sdk';
import { mdiDotsVertical } from '@mdi/js';
import { createEventDispatcher, onMount } from 'svelte';
- import { getContextMenuPosition } from '../../utils/context-menu';
import { handleError } from '../../utils/handle-error';
- import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import ConfirmDialog from '../shared-components/dialog/confirm-dialog.svelte';
- import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import UserAvatar from '../shared-components/user-avatar.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { t } from 'svelte-i18n';
+ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let album: AlbumResponseDto;
export let onClose: () => void;
@@ -29,8 +27,6 @@
}>();
let currentUser: UserResponseDto;
- let position = { x: 0, y: 0 };
- let selectedMenuUser: UserResponseDto | null = null;
let selectedRemoveUser: UserResponseDto | null = null;
$: isOwned = currentUser?.id == album.ownerId;
@@ -43,15 +39,8 @@
}
});
- const showContextMenu = (event: MouseEvent, user: UserResponseDto) => {
- position = getContextMenuPosition(event);
- selectedMenuUser = user;
- selectedRemoveUser = null;
- };
-
- const handleMenuRemove = () => {
- selectedRemoveUser = selectedMenuUser;
- selectedMenuUser = null;
+ const handleMenuRemove = (user: UserResponseDto) => {
+ selectedRemoveUser = user;
};
const handleRemoveUser = async () => {
@@ -118,31 +107,17 @@
{/if}
{#if isOwned}
-
- showContextMenu(event, user)}
- icon={mdiDotsVertical}
- size="20"
- />
-
- {#if selectedMenuUser === user}
- (selectedMenuUser = null)}>
- {#if role === AlbumUserRole.Viewer}
- handleSetReadonly(user, AlbumUserRole.Editor)}
- text={$t('allow_edits')}
- />
- {:else}
- handleSetReadonly(user, AlbumUserRole.Viewer)}
- text={$t('disallow_edits')}
- />
- {/if}
-
-
+
+ {#if role === AlbumUserRole.Viewer}
+ handleSetReadonly(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
+ {:else}
+ handleSetReadonly(user, AlbumUserRole.Viewer)}
+ text={$t('disallow_edits')}
+ />
{/if}
-
+ handleMenuRemove(user)} text={$t('remove')} />
+
{:else if user.id == currentUser?.id}
-
+
diff --git a/web/src/lib/components/shared-components/context-menu/menu.ts b/web/src/lib/components/shared-components/context-menu/menu.ts
deleted file mode 100644
index 108b2fd1aa..0000000000
--- a/web/src/lib/components/shared-components/context-menu/menu.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-const key = {};
-
-export { key };
diff --git a/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte b/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte
index 4d7f38b5b2..f0b0408ff9 100644
--- a/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte
+++ b/web/src/lib/components/shared-components/context-menu/right-click-context-menu.svelte
@@ -1,7 +1,12 @@
{#key uniqueKey}
{#if isOpen}
- ($selectedIdStore = id),
+ }}
+ use:shortcuts={[
+ {
+ shortcut: { key: 'Tab' },
+ onShortcut: closeContextMenu,
+ },
+ {
+ shortcut: { key: 'Tab', shift: true },
+ onShortcut: closeContextMenu,
+ },
+ ]}
>
-
-
-
-
+
+
{/if}
{/key}
diff --git a/web/src/lib/stores/context-menu.store.ts b/web/src/lib/stores/context-menu.store.ts
new file mode 100644
index 0000000000..534c15bd47
--- /dev/null
+++ b/web/src/lib/stores/context-menu.store.ts
@@ -0,0 +1,6 @@
+import { writable } from 'svelte/store';
+
+const selectedIdStore = writable(undefined);
+const optionClickCallbackStore = writable<(() => void) | undefined>(undefined);
+
+export { optionClickCallbackStore, selectedIdStore };
diff --git a/web/src/lib/utils/context-menu.ts b/web/src/lib/utils/context-menu.ts
index 54ae1a3374..aca1033c7a 100644
--- a/web/src/lib/utils/context-menu.ts
+++ b/web/src/lib/utils/context-menu.ts
@@ -2,22 +2,31 @@ export type Align = 'middle' | 'top-left' | 'top-right';
export type ContextMenuPosition = { x: number; y: number };
-export const getContextMenuPosition = (event: MouseEvent, align: Align = 'middle'): ContextMenuPosition => {
- const { x, y, currentTarget, target } = event;
+export const getContextMenuPositionFromEvent = (
+ event: MouseEvent | KeyboardEvent,
+ align: Align = 'middle',
+): ContextMenuPosition => {
+ const { currentTarget, target } = event;
+ const x = 'x' in event ? event.x : 0;
+ const y = 'y' in event ? event.y : 0;
const box = ((currentTarget || target) as HTMLElement)?.getBoundingClientRect();
if (box) {
- switch (align) {
- case 'middle': {
- return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
- }
- case 'top-left': {
- return { x: box.x, y: box.y };
- }
- case 'top-right': {
- return { x: box.x + box.width, y: box.y };
- }
- }
+ return getContextMenuPositionFromBoundingRect(box, align);
}
return { x, y };
};
+
+export const getContextMenuPositionFromBoundingRect = (rect: DOMRect, align: Align = 'middle'): ContextMenuPosition => {
+ switch (align) {
+ case 'middle': {
+ return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
+ }
+ case 'top-left': {
+ return { x: rect.x, y: rect.y };
+ }
+ case 'top-right': {
+ return { x: rect.x + rect.width, y: rect.y };
+ }
+ }
+};
diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index ef6c48814b..7d0dbf0750 100644
--- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -22,9 +22,8 @@
import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
- import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
+ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
- import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
@@ -43,8 +42,6 @@
import { user } from '$lib/stores/user.store';
import { handlePromiseError, s } from '$lib/utils';
import { downloadAlbum } from '$lib/utils/asset-utils';
- import { clickOutside } from '$lib/actions/click-outside';
- import { getContextMenuPosition } from '$lib/utils/context-menu';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute, isSearchRoute } from '$lib/utils/navigation';
@@ -103,7 +100,6 @@
SELECT_USERS = 'select-users',
SELECT_THUMBNAIL = 'select-thumbnail',
SELECT_ASSETS = 'select-assets',
- ALBUM_OPTIONS = 'album-options',
VIEW_USERS = 'view-users',
VIEW = 'view',
OPTIONS = 'options',
@@ -112,7 +108,6 @@
let backUrl: string = AppRoute.ALBUMS;
let viewMode = ViewMode.VIEW;
let isCreatingSharedAlbum = false;
- let contextMenuPosition: { x: number; y: number } = { x: 0, y: 0 };
let isShowActivity = false;
let isLiked: ActivityResponseDto | null = null;
let reactions: ActivityResponseDto[] = [];
@@ -305,11 +300,6 @@
timelineInteractionStore.clearMultiselect();
};
- const handleOpenAlbumOptions = (event: MouseEvent) => {
- contextMenuPosition = getContextMenuPosition(event, 'top-left');
- viewMode = viewMode === ViewMode.VIEW ? ViewMode.ALBUM_OPTIONS : ViewMode.VIEW;
- };
-
const handleSelectFromComputer = async () => {
await openFileUploadDialog({ albumId: album.id });
timelineInteractionStore.clearMultiselect();
@@ -420,14 +410,14 @@
assetInteractionStore.clearMultiselect()}>
-
+
-
+
{#if isAllUserOwned}
assetStore.triggerUpdate()} />
{/if}
-
+
{#if isAllUserOwned}
@@ -447,10 +437,10 @@
{#if isAllUserOwned}
{/if}
-
+
{:else}
- {#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS}
+ {#if viewMode === ViewMode.VIEW}
goto(backUrl)}>
{#if isEditor}
@@ -474,32 +464,19 @@
{#if isOwned}
- (viewMode = ViewMode.VIEW) }}>
-
+ (viewMode = ViewMode.SELECT_THUMBNAIL)}
/>
- {#if viewMode === ViewMode.ALBUM_OPTIONS}
-
- (viewMode = ViewMode.SELECT_THUMBNAIL)}
- />
- (viewMode = ViewMode.OPTIONS)}
- />
- handleRemoveAlbum()}
- />
-
- {/if}
-
+ (viewMode = ViewMode.OPTIONS)}
+ />
+ handleRemoveAlbum()} />
+
{/if}
{/if}
diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 26c0c6e65c..6e3fb4cb28 100644
--- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -8,7 +8,7 @@
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
- import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
+ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
@@ -32,15 +32,15 @@
assetStore.removeAssets(assetIds)} />
-
+
-
+
assetStore.triggerUpdate()} />
-
+
assetStore.removeAssets(assetIds)} />
-
+
{/if}
diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 95ed078c72..49af165ac9 100644
--- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -10,7 +10,7 @@
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
- import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
+ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
@@ -35,17 +35,17 @@
assetStore.removeAssets(assetIds)} />
-
+
-
-
+
+
assetStore.removeAssets(assetIds)} />
assetStore.removeAssets(assetIds)} />
-
+
{/if}
diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 0f8fc244d2..83e2ba3c1f 100644
--- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -4,7 +4,7 @@
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
- import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
+ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import { AppRoute } from '$lib/constants';
@@ -30,10 +30,10 @@
{#if $isMultiSelectState}
-
+
-
+
{:else}
diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
index 7de385f47d..3238d2ddc7 100644
--- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -17,7 +17,6 @@
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
- import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
@@ -57,6 +56,7 @@
import type { PageData } from './$types';
import { listNavigation } from '$lib/actions/list-navigation';
import { t } from 'svelte-i18n';
+ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let data: PageData;
@@ -380,12 +380,12 @@
assetInteractionStore.clearMultiselect()}>
-
+
-
+
assetStore.triggerUpdate()} />
-
+
$assetStore.removeAssets(assetIds)} />
$assetStore.removeAssets(assetIds)} />
-
+
{:else}
{#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
goto(previousRoute)}>
-
+
(viewMode = ViewMode.MERGE_PEOPLE)}
/>
-
+
{/if}
diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
index 3fed3ba1ca..85a497fa99 100644
--- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte
@@ -12,7 +12,7 @@
import StackAction from '$lib/components/photos-page/actions/stack-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
- import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
+ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
@@ -58,12 +58,12 @@
>
-
+
-
+
assetStore.triggerUpdate()} />
-
+
{#if $selectedAssets.size > 1 || isAssetStackSelected}
assetStore.removeAssets(assetIds)} />
-
+
{/if}
diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
index b947720972..7723a86b41 100644
--- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
+++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte
@@ -11,7 +11,7 @@
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
- import AssetSelectContextMenu from '$lib/components/photos-page/asset-select-context-menu.svelte';
+ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
@@ -211,19 +211,19 @@
(selectedAssets = new Set())}>
-
+
-
+
-
+
-
+
{:else}
diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte
index dbf2092edc..adb77a1e88 100644
--- a/web/src/routes/admin/library-management/+page.svelte
+++ b/web/src/routes/admin/library-management/+page.svelte
@@ -6,16 +6,13 @@
import LibraryScanSettingsForm from '$lib/components/forms/library-scan-settings-form.svelte';
import LibraryUserPickerForm from '$lib/components/forms/library-user-picker-form.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
- import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
- import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { ByteUnit, getBytesWithUnit } from '$lib/utils/byte-units';
- import { getContextMenuPosition } from '$lib/utils/context-menu';
import { handleError } from '$lib/utils/handle-error';
import {
createLibrary,
@@ -35,9 +32,9 @@
import { fade, slide } from 'svelte/transition';
import LinkButton from '../../../lib/components/elements/buttons/link-button.svelte';
import type { PageData } from './$types';
- import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { t } from 'svelte-i18n';
+ import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let data: PageData;
@@ -63,10 +60,6 @@
let deleteAssetCount = 0;
let dropdownOpen: boolean[] = [];
- let showContextMenu = false;
- let contextMenuPosition = { x: 0, y: 0 };
- let selectedLibraryIndex = 0;
- let selectedLibrary: LibraryResponseDto | null = null;
let toCreateLibrary = false;
@@ -79,25 +72,12 @@
editScanSettings = null;
renameLibrary = null;
updateLibraryIndex = null;
- showContextMenu = false;
for (let index = 0; index < dropdownOpen.length; index++) {
dropdownOpen[index] = false;
}
};
- const showMenu = (event: MouseEvent, library: LibraryResponseDto, index: number) => {
- contextMenuPosition = getContextMenuPosition(event);
- showContextMenu = !showContextMenu;
-
- selectedLibraryIndex = index;
- selectedLibrary = library;
- };
-
- const onMenuExit = () => {
- showContextMenu = false;
- };
-
const refreshStats = async (listIndex: number) => {
stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId });
@@ -233,72 +213,72 @@
}
};
- const onRenameClicked = () => {
+ const onRenameClicked = (index: number) => {
closeAll();
- renameLibrary = selectedLibraryIndex;
- updateLibraryIndex = selectedLibraryIndex;
+ renameLibrary = index;
+ updateLibraryIndex = index;
};
- const onEditImportPathClicked = () => {
+ const onEditImportPathClicked = (index: number) => {
closeAll();
- editImportPaths = selectedLibraryIndex;
- updateLibraryIndex = selectedLibraryIndex;
+ editImportPaths = index;
+ updateLibraryIndex = index;
};
- const onScanNewLibraryClicked = async () => {
+ const onScanNewLibraryClicked = async (library: LibraryResponseDto) => {
closeAll();
- if (selectedLibrary) {
- await handleScan(selectedLibrary.id);
+ if (library) {
+ await handleScan(library.id);
}
};
- const onScanSettingClicked = () => {
+ const onScanSettingClicked = (index: number) => {
closeAll();
- editScanSettings = selectedLibraryIndex;
- updateLibraryIndex = selectedLibraryIndex;
+ editScanSettings = index;
+ updateLibraryIndex = index;
};
- const onScanAllLibraryFilesClicked = async () => {
+ const onScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => {
closeAll();
- if (selectedLibrary) {
- await handleScanChanges(selectedLibrary.id);
+ if (library) {
+ await handleScanChanges(library.id);
}
};
- const onForceScanAllLibraryFilesClicked = async () => {
+ const onForceScanAllLibraryFilesClicked = async (library: LibraryResponseDto) => {
closeAll();
- if (selectedLibrary) {
- await handleForceScan(selectedLibrary.id);
+ if (library) {
+ await handleForceScan(library.id);
}
};
- const onRemoveOfflineFilesClicked = async () => {
+ const onRemoveOfflineFilesClicked = async (library: LibraryResponseDto) => {
closeAll();
- if (selectedLibrary) {
- await handleRemoveOffline(selectedLibrary.id);
+ if (library) {
+ await handleRemoveOffline(library.id);
}
};
- const onDeleteLibraryClicked = async () => {
+ const onDeleteLibraryClicked = async (library: LibraryResponseDto, index: number) => {
closeAll();
- if (!selectedLibrary) {
+ if (!library) {
return;
}
const isConfirmedLibrary = await dialogController.show({
id: 'delete-library',
- prompt: $t('admin.confirm_delete_library', { values: { library: selectedLibrary.name } }),
+ prompt: $t('admin.confirm_delete_library', { values: { library: library.name } }),
});
if (!isConfirmedLibrary) {
return;
}
- await refreshStats(selectedLibraryIndex);
- if (totalCount[selectedLibraryIndex] > 0) {
- deleteAssetCount = totalCount[selectedLibraryIndex];
+ await refreshStats(index);
+ if (totalCount[index] > 0) {
+ deleteAssetCount = totalCount[index];
const isConfirmedLibraryAssetCount = await dialogController.show({
id: 'delete-library-assets',
@@ -310,7 +290,7 @@
}
await handleDelete();
} else {
- deletedLibrary = selectedLibrary;
+ deletedLibrary = library;
await handleDelete();
}
};
@@ -392,46 +372,38 @@
{/if}
- showMenu(e, library, index)}
- />
-
- {#if showContextMenu}
-
- onMenuExit()}>
- onRenameClicked()} text={$t('rename')} />
-
- {#if selectedLibrary}
- onEditImportPathClicked()} text={$t('edit_import_paths')} />
- onScanSettingClicked()} text={$t('scan_settings')} />
-
- onScanNewLibraryClicked()} text={$t('scan_new_library_files')} />
- onScanAllLibraryFilesClicked()}
- text={$t('scan_all_library_files')}
- subtitle={$t('only_refreshes_modified_files')}
- />
- onForceScanAllLibraryFilesClicked()}
- text={$t('force_re-scan_library_files')}
- subtitle={$t('refreshes_every_file')}
- />
-
- onRemoveOfflineFilesClicked()}
- text={$t('remove_offline_files')}
- />
- onDeleteLibraryClicked()}>
- {$t('delete_library')}
-
- {/if}
-
-
- {/if}
+ >
+ onRenameClicked(index)} text={$t('rename')} />
+ onEditImportPathClicked(index)} text={$t('edit_import_paths')} />
+ onScanSettingClicked(index)} text={$t('scan_settings')} />
+
+ onScanNewLibraryClicked(library)} text={$t('scan_new_library_files')} />
+ onScanAllLibraryFilesClicked(library)}
+ text={$t('scan_all_library_files')}
+ subtitle={$t('only_refreshes_modified_files')}
+ />
+ onForceScanAllLibraryFilesClicked(library)}
+ text={$t('force_re-scan_library_files')}
+ subtitle={$t('refreshes_every_file')}
+ />
+
+ onRemoveOfflineFilesClicked(library)}
+ text={$t('remove_offline_files')}
+ />
+ onDeleteLibraryClicked(library, index)}>
+ {$t('delete_library')}
+
+
|
{#if renameLibrary === index}