1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

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 <Portal>

* 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 <alex.tran1502@gmail.com>
This commit is contained in:
Ben 2024-06-18 03:52:38 +00:00 committed by GitHub
parent 99c6fdbc1c
commit b71aa4473b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 639 additions and 441 deletions

View file

@ -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<HTMLElement, Options> = (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,
};
};

View file

@ -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);
}
};

View file

@ -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));
};
</script>

View file

@ -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}
<!-- Context Menu -->
<RightClickContextMenu {...contextMenuPosition} isOpen={showContextMenu} onClose={closeAlbumContextMenu}>
<RightClickContextMenu title={$t('album_options')} {...contextMenuPosition} {isOpen} onClose={closeAlbumContextMenu}>
{#if showFullContextMenu}
<MenuOption on:click={() => contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}>
<p class="flex gap-2">
<Icon path={mdiRenameOutline} size="18" />
Edit
</p>
</MenuOption>
<MenuOption on:click={() => openShareModal()}>
<p class="flex gap-2">
<Icon path={mdiShareVariantOutline} size="18" />
Share
</p>
</MenuOption>
<MenuOption
icon={mdiRenameOutline}
text={$t('edit_album')}
on:click={() => contextMenuTargetAlbum && handleEdit(contextMenuTargetAlbum)}
/>
<MenuOption icon={mdiShareVariantOutline} text={$t('share')} on:click={() => openShareModal()} />
{/if}
<MenuOption on:click={() => handleDownloadAlbum()}>
<p class="flex gap-2">
<Icon path={mdiFolderDownloadOutline} size="18" />
Download
</p>
</MenuOption>
<MenuOption icon={mdiFolderDownloadOutline} text={$t('download')} on:click={() => handleDownloadAlbum()} />
{#if showFullContextMenu}
<MenuOption on:click={() => setAlbumToDelete()}>
<p class="flex gap-2">
<Icon path={mdiDeleteOutline} size="18" />
Delete
</p>
</MenuOption>
<MenuOption icon={mdiDeleteOutline} text={$t('delete')} on:click={() => setAlbumToDelete()} />
{/if}
</RightClickContextMenu>

View file

@ -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}
</div>
{#if isOwned}
<div>
<CircleIconButton
title={$t('options')}
on:click={(event) => showContextMenu(event, user)}
icon={mdiDotsVertical}
size="20"
/>
{#if selectedMenuUser === user}
<ContextMenu {...position} onClose={() => (selectedMenuUser = null)}>
{#if role === AlbumUserRole.Viewer}
<MenuOption
on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)}
text={$t('allow_edits')}
/>
{:else}
<MenuOption
on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
text={$t('disallow_edits')}
/>
{/if}
<MenuOption on:click={handleMenuRemove} text={$t('remove')} />
</ContextMenu>
<ButtonContextMenu icon={mdiDotsVertical} size="20" title={$t('options')}>
{#if role === AlbumUserRole.Viewer}
<MenuOption on:click={() => handleSetReadonly(user, AlbumUserRole.Editor)} text={$t('allow_edits')} />
{:else}
<MenuOption
on:click={() => handleSetReadonly(user, AlbumUserRole.Viewer)}
text={$t('disallow_edits')}
/>
{/if}
</div>
<MenuOption on:click={() => handleMenuRemove(user)} text={$t('remove')} />
</ButtonContextMenu>
{:else if user.id == currentUser?.id}
<button
type="button"

View file

@ -4,8 +4,6 @@
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName } from '$lib/utils';
import { clickOutside } from '$lib/actions/click-outside';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetJobName, AssetTypeEnum, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
import {
@ -36,9 +34,9 @@
mdiUpload,
} from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let asset: AssetResponseDto;
export let album: AlbumResponseDto | null = null;
@ -79,21 +77,11 @@
const dispatch = createEventDispatcher<EventTypes>();
let contextMenuPosition = { x: 0, y: 0 };
let isShowAssetOptions = false;
const showOptionsMenu = (event: MouseEvent) => {
contextMenuPosition = getContextMenuPosition(event, 'top-right');
isShowAssetOptions = !isShowAssetOptions;
};
const onJobClick = (name: AssetJobName) => {
isShowAssetOptions = false;
dispatch('runJob', name);
};
const onMenuClick = (eventName: keyof EventTypes) => {
isShowAssetOptions = false;
dispatch(eventName);
};
</script>
@ -187,90 +175,72 @@
on:delete={() => dispatch('delete')}
on:permanentlyDelete={() => dispatch('permanentlyDelete')}
/>
<div
use:clickOutside={{
onOutclick: () => (isShowAssetOptions = false),
onEscape: () => (isShowAssetOptions = false),
}}
>
<CircleIconButton color="opaque" icon={mdiDotsVertical} on:click={showOptionsMenu} title={$t('more')} />
{#if isShowAssetOptions}
<ContextMenu {...contextMenuPosition} direction="left">
{#if showSlideshow}
<MenuOption
icon={mdiPresentationPlay}
on:click={() => onMenuClick('playSlideShow')}
text={$t('slideshow')}
/>
{/if}
{#if showDownloadButton}
<MenuOption
icon={mdiFolderDownloadOutline}
on:click={() => onMenuClick('download')}
text={$t('download')}
/>
{/if}
{#if asset.isTrashed}
<MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text={$t('restore')} />
{:else}
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text={$t('add_to_album')} />
<MenuOption
icon={mdiShareVariantOutline}
on:click={() => onMenuClick('addToSharedAlbum')}
text={$t('add_to_shared_album')}
/>
{/if}
{#if isOwner}
{#if hasStackChildren}
<MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text={$t('unstack')} />
{/if}
{#if album}
<MenuOption
text={$t('set_as_album_cover')}
icon={mdiImageOutline}
on:click={() => onMenuClick('setAsAlbumCover')}
/>
{/if}
{#if asset.type === AssetTypeEnum.Image}
<MenuOption
icon={mdiAccountCircleOutline}
on:click={() => onMenuClick('asProfileImage')}
text={$t('set_as_profile_picture')}
/>
{/if}
<MenuOption
on:click={() => onMenuClick('toggleArchive')}
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
text={asset.isArchived ? $t('unarchive') : $t('to_archive')}
/>
<MenuOption
icon={mdiUpload}
on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text={$t('replace_with_upload')}
/>
<hr />
<MenuOption
icon={mdiDatabaseRefreshOutline}
on:click={() => onJobClick(AssetJobName.RefreshMetadata)}
text={getAssetJobName(AssetJobName.RefreshMetadata)}
/>
<MenuOption
icon={mdiImageRefreshOutline}
on:click={() => onJobClick(AssetJobName.RegenerateThumbnail)}
text={getAssetJobName(AssetJobName.RegenerateThumbnail)}
/>
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiCogRefreshOutline}
on:click={() => onJobClick(AssetJobName.TranscodeVideo)}
text={getAssetJobName(AssetJobName.TranscodeVideo)}
/>
{/if}
{/if}
</ContextMenu>
<ButtonContextMenu direction="left" align="top-right" color="opaque" title={$t('more')} icon={mdiDotsVertical}>
{#if showSlideshow}
<MenuOption icon={mdiPresentationPlay} on:click={() => onMenuClick('playSlideShow')} text={$t('slideshow')} />
{/if}
</div>
{#if showDownloadButton}
<MenuOption icon={mdiFolderDownloadOutline} on:click={() => onMenuClick('download')} text={$t('download')} />
{/if}
{#if asset.isTrashed}
<MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text={$t('restore')} />
{:else}
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text={$t('add_to_album')} />
<MenuOption
icon={mdiShareVariantOutline}
on:click={() => onMenuClick('addToSharedAlbum')}
text={$t('add_to_shared_album')}
/>
{/if}
{#if isOwner}
{#if hasStackChildren}
<MenuOption icon={mdiImageMinusOutline} on:click={() => onMenuClick('unstack')} text={$t('unstack')} />
{/if}
{#if album}
<MenuOption
text={$t('set_as_album_cover')}
icon={mdiImageOutline}
on:click={() => onMenuClick('setAsAlbumCover')}
/>
{/if}
{#if asset.type === AssetTypeEnum.Image}
<MenuOption
icon={mdiAccountCircleOutline}
on:click={() => onMenuClick('asProfileImage')}
text={$t('set_as_profile_picture')}
/>
{/if}
<MenuOption
on:click={() => onMenuClick('toggleArchive')}
icon={asset.isArchived ? mdiArchiveArrowUpOutline : mdiArchiveArrowDownOutline}
text={asset.isArchived ? $t('unarchive') : $t('to_archive')}
/>
<MenuOption
icon={mdiUpload}
on:click={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text={$t('replace_with_upload')}
/>
<hr />
<MenuOption
icon={mdiDatabaseRefreshOutline}
on:click={() => onJobClick(AssetJobName.RefreshMetadata)}
text={getAssetJobName(AssetJobName.RefreshMetadata)}
/>
<MenuOption
icon={mdiImageRefreshOutline}
on:click={() => onJobClick(AssetJobName.RegenerateThumbnail)}
text={getAssetJobName(AssetJobName.RegenerateThumbnail)}
/>
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiCogRefreshOutline}
on:click={() => onJobClick(AssetJobName.TranscodeVideo)}
text={getAssetJobName(AssetJobName.TranscodeVideo)}
/>
{/if}
{/if}
</ButtonContextMenu>
{/if}
</div>
</div>

View file

@ -1,12 +1,21 @@
<script lang="ts" context="module">
export type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque';
</script>
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
type Color = 'transparent' | 'light' | 'dark' | 'gray' | 'primary' | 'opaque';
export let type: 'button' | 'submit' | 'reset' = 'button';
export let icon: string;
export let color: Color = 'transparent';
export let title: string;
/**
* The padding of the button, used by the `p-{padding}` Tailwind CSS class.
*/
export let padding = '3';
/**
* Size of the button, used for a CSS value.
*/
export let size = '24';
export let hideMobile = false;
export let buttonSize: string | undefined = undefined;
@ -14,6 +23,10 @@
* viewBox attribute for the SVG icon.
*/
export let viewBox: string | undefined = undefined;
export let id: string | undefined = undefined;
export let ariaHasPopup: boolean | undefined = undefined;
export let ariaExpanded: boolean | undefined = undefined;
export let ariaControls: string | undefined = undefined;
/**
* Override the default styling of the button for specific use cases, such as the icon color.
@ -33,14 +46,19 @@
$: colorClass = colorClasses[color];
$: mobileClass = hideMobile ? 'hidden sm:flex' : '';
$: paddingClass = `p-${padding}`;
</script>
<button
{id}
{title}
{type}
style:width={buttonSize ? buttonSize + 'px' : ''}
style:height={buttonSize ? buttonSize + 'px' : ''}
class="flex place-content-center place-items-center rounded-full {colorClass} p-{padding} transition-all hover:dark:text-immich-dark-gray {className} {mobileClass}"
class="flex place-content-center place-items-center rounded-full {colorClass} {paddingClass} transition-all hover:dark:text-immich-dark-gray {className} {mobileClass}"
aria-haspopup={ariaHasPopup}
aria-expanded={ariaExpanded}
aria-controls={ariaControls}
on:click
>
<Icon path={icon} {size} ariaLabel={title} {viewBox} color="currentColor" />

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { AppRoute, QueryParameter } from '$lib/constants';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { type PersonResponseDto } from '@immich/sdk';
import {
mdiAccountEditOutline,
@ -12,11 +11,10 @@
} from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import Portal from '../shared-components/portal/portal.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
import { focusOutside } from '$lib/actions/focus-outside';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
export let person: PersonResponseDto;
export let preload = false;
@ -30,17 +28,7 @@
}>();
let showVerticalDots = false;
let showContextMenu = false;
let contextMenuPosition = { x: 0, y: 0 };
const showMenu = (event: MouseEvent) => {
contextMenuPosition = getContextMenuPosition(event);
showContextMenu = !showContextMenu;
};
const onMenuExit = () => {
showContextMenu = false;
};
const onMenuClick = (event: MenuItemEvent) => {
onMenuExit();
dispatch(event);
};
</script>
@ -51,8 +39,13 @@
on:mouseenter={() => (showVerticalDots = true)}
on:mouseleave={() => (showVerticalDots = false)}
role="group"
use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
>
<a href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}" draggable="false">
<a
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}"
draggable="false"
on:focus={() => (showVerticalDots = true)}
>
<div class="w-full h-full rounded-xl brightness-95 filter">
<ImageThumbnail
shadow
@ -73,22 +66,15 @@
{/if}
</a>
<div class="absolute right-2 top-2" class:hidden={!showVerticalDots}>
<CircleIconButton
<div class="absolute top-2 right-2">
<ButtonContextMenu
buttonClass="icon-white-drop-shadow focus:opacity-100 {showVerticalDots ? 'opacity-100' : 'opacity-0'}"
color="opaque"
padding="2"
size="20"
icon={mdiDotsVertical}
title={$t('show_person_options')}
size="20"
padding="2"
class="icon-white-drop-shadow"
on:click={showMenu}
/>
</div>
</div>
{#if showContextMenu}
<Portal target="body">
<ContextMenu {...contextMenuPosition} onClose={() => onMenuExit()}>
>
<MenuOption on:click={() => onMenuClick('hide-person')} icon={mdiEyeOffOutline} text={$t('hide_person')} />
<MenuOption on:click={() => onMenuClick('change-name')} icon={mdiAccountEditOutline} text={$t('change_name')} />
<MenuOption
@ -101,6 +87,6 @@
icon={mdiAccountMultipleCheckOutline}
text={$t('merge_people')}
/>
</ContextMenu>
</Portal>
{/if}
</ButtonContextMenu>
</div>
</div>

View file

@ -12,7 +12,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';
@ -146,20 +146,20 @@
<CreateSharedLink />
<CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
</ButtonContextMenu>
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
<DeleteAssets menuItem {onAssetDelete} />
</AssetSelectContextMenu>
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{/if}

View file

@ -3,7 +3,6 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { addAssetsToAlbum, addAssetsToNewAlbum } from '$lib/utils/asset-utils';
import type { AlbumResponseDto } from '@immich/sdk';
import { getMenuContext } from '../asset-select-context-menu.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
@ -13,16 +12,13 @@
let showAlbumPicker = false;
const { getAssets, clearSelect } = getAssetControlContext();
const closeMenu = getMenuContext();
const handleHideAlbumPicker = () => {
showAlbumPicker = false;
closeMenu();
};
const handleAddToNewAlbum = async (albumName: string) => {
showAlbumPicker = false;
closeMenu();
const assetIds = [...getAssets()].map((asset) => asset.id);
await addAssetsToNewAlbum(albumName, assetIds);

View file

@ -1,35 +0,0 @@
<script lang="ts" context="module">
import { clickOutside } from '$lib/actions/click-outside';
import { createContext } from '$lib/utils/context';
const { get: getMenuContext, set: setContext } = createContext<() => void>();
export { getMenuContext };
</script>
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { getContextMenuPosition } from '$lib/utils/context-menu';
export let icon: string;
export let title: string;
let showContextMenu = false;
let contextMenuPosition = { x: 0, y: 0 };
const handleShowMenu = (event: MouseEvent) => {
contextMenuPosition = getContextMenuPosition(event, 'top-left');
showContextMenu = !showContextMenu;
};
setContext(() => (showContextMenu = false));
</script>
<div use:clickOutside={{ onOutclick: () => (showContextMenu = false) }}>
<CircleIconButton {title} {icon} on:click={handleShowMenu} />
{#if showContextMenu}
<ContextMenu {...contextMenuPosition}>
<slot />
</ContextMenu>
{/if}
</div>

View file

@ -0,0 +1,151 @@
<script lang="ts">
import CircleIconButton, { type Color } from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import {
getContextMenuPositionFromBoundingRect,
getContextMenuPositionFromEvent,
type Align,
} from '$lib/utils/context-menu';
import { generateId } from '$lib/utils/generate-id';
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import { clickOutside } from '$lib/actions/click-outside';
import { shortcuts } from '$lib/actions/shortcut';
export let icon: string;
export let title: string;
/**
* The alignment of the context menu relative to the button.
*/
export let align: Align = 'top-left';
/**
* The direction in which the context menu should open.
*/
export let direction: 'left' | 'right' = 'right';
export let color: Color = 'transparent';
export let size: string | undefined = undefined;
export let padding: string | undefined = undefined;
/**
* Additional classes to apply to the button.
*/
export let buttonClass: string | undefined = undefined;
let isOpen = false;
let contextMenuPosition = { x: 0, y: 0 };
let menuContainer: HTMLUListElement;
let buttonContainer: HTMLDivElement;
const id = generateId();
const buttonId = `context-menu-button-${id}`;
const menuId = `context-menu-${id}`;
$: {
if (isOpen) {
$optionClickCallbackStore = handleOptionClick;
}
}
const openDropdown = (event: KeyboardEvent | MouseEvent) => {
contextMenuPosition = getContextMenuPositionFromEvent(event, align);
isOpen = true;
menuContainer?.focus();
};
const handleClick = (event: MouseEvent) => {
if (isOpen) {
closeDropdown();
return;
}
openDropdown(event);
};
const onEscape = (event: KeyboardEvent) => {
if (isOpen) {
// if the dropdown is open, stop the event from propagating
event.stopPropagation();
}
closeDropdown();
};
const onResize = () => {
if (!isOpen) {
return;
}
contextMenuPosition = getContextMenuPositionFromBoundingRect(buttonContainer.getBoundingClientRect(), align);
};
const closeDropdown = () => {
if (!isOpen) {
return;
}
focusButton();
isOpen = false;
$selectedIdStore = undefined;
};
const handleOptionClick = () => {
closeDropdown();
};
const focusButton = () => {
const button: HTMLButtonElement | null = buttonContainer.querySelector(`#${buttonId}`);
button?.focus();
};
</script>
<svelte:window on:resize={onResize} />
<div
use:contextMenuNavigation={{
closeDropdown,
container: menuContainer,
isOpen,
onEscape,
openDropdown,
selectedId: $selectedIdStore,
selectionChanged: (id) => ($selectedIdStore = id),
}}
use:clickOutside={{ onOutclick: closeDropdown }}
on:resize={onResize}
>
<div bind:this={buttonContainer}>
<CircleIconButton
{color}
{icon}
{padding}
{size}
{title}
ariaControls={menuId}
ariaExpanded={isOpen}
ariaHasPopup={true}
class={buttonClass}
id={buttonId}
on:click={handleClick}
/>
</div>
<div
use:shortcuts={[
{
shortcut: { key: 'Tab' },
onShortcut: closeDropdown,
preventDefault: false,
},
{
shortcut: { key: 'Tab', shift: true },
onShortcut: closeDropdown,
preventDefault: false,
},
]}
>
<ContextMenu
{...contextMenuPosition}
{direction}
ariaActiveDescendant={$selectedIdStore}
ariaLabelledBy={buttonId}
bind:menuElement={menuContainer}
id={menuId}
isVisible={isOpen}
>
<slot />
</ContextMenu>
</div>
</div>

View file

@ -3,11 +3,16 @@
import { slide } from 'svelte/transition';
import { clickOutside } from '$lib/actions/click-outside';
export let isVisible: boolean = false;
export let direction: 'left' | 'right' = 'right';
export let x = 0;
export let y = 0;
export let id: string | undefined = undefined;
export let ariaLabel: string | undefined = undefined;
export let ariaLabelledBy: string | undefined = undefined;
export let ariaActiveDescendant: string | undefined = undefined;
export let menuElement: HTMLDivElement | undefined = undefined;
export let menuElement: HTMLUListElement | undefined = undefined;
export let onClose: (() => void) | undefined = undefined;
let left: number;
@ -30,16 +35,25 @@
</script>
<div
bind:this={menuElement}
bind:clientHeight={height}
transition:slide={{ duration: 250, easing: quintOut }}
class="absolute z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
style:top="{top}px"
class="fixed z-10 min-w-[200px] w-max max-w-[300px] overflow-hidden rounded-lg shadow-lg"
style:left="{left}px"
role="menu"
use:clickOutside={{ onOutclick: onClose, onEscape: onClose }}
style:top="{top}px"
transition:slide={{ duration: 250, easing: quintOut }}
use:clickOutside={{ onOutclick: onClose }}
>
<div class="flex flex-col rounded-lg">
<ul
{id}
aria-activedescendant={ariaActiveDescendant ?? ''}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
bind:this={menuElement}
class:max-h-[100vh]={isVisible}
class:max-h-0={!isVisible}
class="flex flex-col transition-all duration-[250ms] ease-in-out"
role="menu"
tabindex="-1"
>
<slot />
</div>
</ul>
</div>

View file

@ -1,15 +1,37 @@
<script>
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { generateId } from '$lib/utils/generate-id';
import { createEventDispatcher } from 'svelte';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
export let text = '';
export let subtitle = '';
export let icon = '';
let id: string = generateId();
$: isActive = $selectedIdStore === id;
const dispatch = createEventDispatcher<{
click: void;
}>();
const handleClick = () => {
$optionClickCallbackStore?.();
dispatch('click');
};
</script>
<button
type="button"
on:click
class="w-full bg-slate-100 p-4 text-left text-sm font-medium text-immich-fg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg"
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<li
{id}
on:click={handleClick}
on:mouseover={() => ($selectedIdStore = id)}
on:mouseleave={() => ($selectedIdStore = undefined)}
class="w-full p-4 text-left text-sm font-medium text-immich-fg focus:outline-none focus:ring-2 focus:ring-inset dark:text-immich-dark-bg cursor-pointer border-gray-200"
class:bg-slate-300={isActive}
class:bg-slate-100={!isActive}
role="menuitem"
>
{#if text}
@ -30,4 +52,4 @@
{subtitle}
</p>
</slot>
</button>
</li>

View file

@ -1,3 +0,0 @@
const key = {};
export { key };

View file

@ -1,7 +1,12 @@
<script lang="ts">
import { tick } from 'svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { shortcuts } from '$lib/actions/shortcut';
import { generateId } from '$lib/utils/generate-id';
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
export let title: string;
export let direction: 'left' | 'right' = 'right';
export let x = 0;
export let y = 0;
@ -9,7 +14,19 @@
export let onClose: (() => unknown) | undefined;
let uniqueKey = {};
let contextMenuElement: HTMLDivElement;
let menuContainer: HTMLUListElement;
let triggerElement: HTMLElement | undefined = undefined;
const id = generateId();
const menuId = `context-menu-${id}`;
$: {
if (isOpen && menuContainer) {
triggerElement = document.activeElement as HTMLElement;
menuContainer.focus();
$optionClickCallbackStore = closeContextMenu;
}
}
const reopenContextMenu = async (event: MouseEvent) => {
const contextMenuEvent = new MouseEvent('contextmenu', {
@ -22,7 +39,7 @@
const elements = document.elementsFromPoint(event.x, event.y);
if (elements.includes(contextMenuElement)) {
if (elements.includes(menuContainer)) {
// User right-clicked on the context menu itself, we keep the context
// menu as is
return;
@ -38,20 +55,51 @@
};
const closeContextMenu = () => {
triggerElement?.focus();
onClose?.();
};
</script>
{#key uniqueKey}
{#if isOpen}
<section
class="fixed left-0 top-0 z-10 flex h-screen w-screen"
on:contextmenu|preventDefault={reopenContextMenu}
role="presentation"
<div
use:contextMenuNavigation={{
closeDropdown: closeContextMenu,
container: menuContainer,
isOpen,
selectedId: $selectedIdStore,
selectionChanged: (id) => ($selectedIdStore = id),
}}
use:shortcuts={[
{
shortcut: { key: 'Tab' },
onShortcut: closeContextMenu,
},
{
shortcut: { key: 'Tab', shift: true },
onShortcut: closeContextMenu,
},
]}
>
<ContextMenu {x} {y} {direction} onClose={closeContextMenu} bind:menuElement={contextMenuElement}>
<slot />
</ContextMenu>
</section>
<section
class="fixed left-0 top-0 z-10 flex h-screen w-screen"
on:contextmenu|preventDefault={reopenContextMenu}
role="presentation"
>
<ContextMenu
{direction}
{x}
{y}
ariaActiveDescendant={$selectedIdStore}
ariaLabel={title}
bind:menuElement={menuContainer}
id={menuId}
isVisible
onClose={closeContextMenu}
>
<slot />
</ContextMenu>
</section>
</div>
{/if}
{/key}

View file

@ -0,0 +1,6 @@
import { writable } from 'svelte/store';
const selectedIdStore = writable<string | undefined>(undefined);
const optionClickCallbackStore = writable<(() => void) | undefined>(undefined);
export { optionClickCallbackStore, selectedIdStore };

View file

@ -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 };
}
}
};

View file

@ -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 @@
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
</ButtonContextMenu>
{#if isAllUserOwned}
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
{/if}
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem filename="{album.albumName}.zip" />
{#if isAllUserOwned}
<ChangeDate menuItem />
@ -447,10 +437,10 @@
{#if isAllUserOwned}
<DeleteAssets menuItem onAssetDelete={handleRemoveAssets} />
{/if}
</AssetSelectContextMenu>
</ButtonContextMenu>
</AssetSelectControlBar>
{:else}
{#if viewMode === ViewMode.VIEW || viewMode === ViewMode.ALBUM_OPTIONS}
{#if viewMode === ViewMode.VIEW}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(backUrl)}>
<svelte:fragment slot="trailing">
{#if isEditor}
@ -474,32 +464,19 @@
<CircleIconButton title={$t('download')} on:click={handleDownloadAlbum} icon={mdiFolderDownloadOutline} />
{#if isOwned}
<div use:clickOutside={{ onOutclick: () => (viewMode = ViewMode.VIEW) }}>
<CircleIconButton
title={$t('album_options')}
on:click={handleOpenAlbumOptions}
icon={mdiDotsVertical}
<ButtonContextMenu icon={mdiDotsVertical} title={$t('album_options')}>
<MenuOption
icon={mdiImageOutline}
text={$t('select_album_cover')}
on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)}
/>
{#if viewMode === ViewMode.ALBUM_OPTIONS}
<ContextMenu {...contextMenuPosition}>
<MenuOption
icon={mdiImageOutline}
text={$t('select_album_cover')}
on:click={() => (viewMode = ViewMode.SELECT_THUMBNAIL)}
/>
<MenuOption
icon={mdiCogOutline}
text={$t('options')}
on:click={() => (viewMode = ViewMode.OPTIONS)}
/>
<MenuOption
icon={mdiDeleteOutline}
text={$t('delete_album')}
on:click={() => handleRemoveAlbum()}
/>
</ContextMenu>
{/if}
</div>
<MenuOption
icon={mdiCogOutline}
text={$t('options')}
on:click={() => (viewMode = ViewMode.OPTIONS)}
/>
<MenuOption icon={mdiDeleteOutline} text={$t('delete_album')} on:click={() => handleRemoveAlbum()} />
</ButtonContextMenu>
{/if}
{/if}

View file

@ -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 @@
<ArchiveAction unarchive onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
<CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
</ButtonContextMenu>
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
<DownloadAction menuItem />
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
</AssetSelectContextMenu>
</ButtonContextMenu>
</AssetSelectControlBar>
{/if}

View file

@ -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 @@
<FavoriteAction removeFavorite onFavorite={(assetIds) => assetStore.removeAssets(assetIds)} />
<CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
</ButtonContextMenu>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
</AssetSelectContextMenu>
</ButtonContextMenu>
</AssetSelectControlBar>
{/if}

View file

@ -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}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={clearMultiselect}>
<CreateSharedLink />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add')}>
<ButtonContextMenu icon={mdiPlus} title={$t('add')}>
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
</ButtonContextMenu>
<DownloadAction />
</AssetSelectControlBar>
{:else}

View file

@ -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 @@
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
</ButtonContextMenu>
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
<DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
<MenuOption
icon={mdiAccountMultipleCheckOutline}
@ -396,13 +396,13 @@
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
<DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} />
</AssetSelectContextMenu>
</ButtonContextMenu>
</AssetSelectControlBar>
{:else}
{#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close={() => goto(previousRoute)}>
<svelte:fragment slot="trailing">
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<MenuOption
text={$t('select_featured_photo')}
icon={mdiAccountBoxOutline}
@ -423,7 +423,7 @@
icon={mdiAccountMultipleCheckOutline}
on:click={() => (viewMode = ViewMode.MERGE_PEOPLE)}
/>
</AssetSelectContextMenu>
</ButtonContextMenu>
</svelte:fragment>
</ControlAppBar>
{/if}

View file

@ -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 @@
>
<CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
</ButtonContextMenu>
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if $selectedAssets.size > 1 || isAssetStackSelected}
<StackAction
@ -78,7 +78,7 @@
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
<hr />
<AssetJobActions />
</AssetSelectContextMenu>
</ButtonContextMenu>
</AssetSelectControlBar>
{/if}

View file

@ -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 @@
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
<CreateSharedLink />
<CircleIconButton title={$t('select_all')} icon={mdiSelectAll} on:click={handleSelectAll} />
<AssetSelectContextMenu icon={mdiPlus} title={$t('add_to')}>
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
</ButtonContextMenu>
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={triggerAssetUpdate} />
<AssetSelectContextMenu icon={mdiDotsVertical} title={$t('add')}>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('add')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchived} onArchive={triggerAssetUpdate} />
<DeleteAssets menuItem {onAssetDelete} />
</AssetSelectContextMenu>
</ButtonContextMenu>
</AssetSelectControlBar>
</div>
{:else}

View file

@ -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}
<td class=" text-ellipsis px-4 text-sm">
<CircleIconButton
<ButtonContextMenu
align="top-right"
direction="left"
color="primary"
size="16"
icon={mdiDotsVertical}
title={$t('library_options')}
size="16"
on:click={(e) => showMenu(e, library, index)}
/>
{#if showContextMenu}
<Portal target="body">
<ContextMenu {...contextMenuPosition} onClose={() => onMenuExit()}>
<MenuOption on:click={() => onRenameClicked()} text={$t('rename')} />
{#if selectedLibrary}
<MenuOption on:click={() => onEditImportPathClicked()} text={$t('edit_import_paths')} />
<MenuOption on:click={() => onScanSettingClicked()} text={$t('scan_settings')} />
<hr />
<MenuOption on:click={() => onScanNewLibraryClicked()} text={$t('scan_new_library_files')} />
<MenuOption
on:click={() => onScanAllLibraryFilesClicked()}
text={$t('scan_all_library_files')}
subtitle={$t('only_refreshes_modified_files')}
/>
<MenuOption
on:click={() => onForceScanAllLibraryFilesClicked()}
text={$t('force_re-scan_library_files')}
subtitle={$t('refreshes_every_file')}
/>
<hr />
<MenuOption
on:click={() => onRemoveOfflineFilesClicked()}
text={$t('remove_offline_files')}
/>
<MenuOption on:click={() => onDeleteLibraryClicked()}>
<p class="text-red-600">{$t('delete_library')}</p>
</MenuOption>
{/if}
</ContextMenu>
</Portal>
{/if}
>
<MenuOption on:click={() => onRenameClicked(index)} text={$t('rename')} />
<MenuOption on:click={() => onEditImportPathClicked(index)} text={$t('edit_import_paths')} />
<MenuOption on:click={() => onScanSettingClicked(index)} text={$t('scan_settings')} />
<hr />
<MenuOption on:click={() => onScanNewLibraryClicked(library)} text={$t('scan_new_library_files')} />
<MenuOption
on:click={() => onScanAllLibraryFilesClicked(library)}
text={$t('scan_all_library_files')}
subtitle={$t('only_refreshes_modified_files')}
/>
<MenuOption
on:click={() => onForceScanAllLibraryFilesClicked(library)}
text={$t('force_re-scan_library_files')}
subtitle={$t('refreshes_every_file')}
/>
<hr />
<MenuOption
on:click={() => onRemoveOfflineFilesClicked(library)}
text={$t('remove_offline_files')}
/>
<MenuOption on:click={() => onDeleteLibraryClicked(library, index)}>
<p class="text-red-600">{$t('delete_library')}</p>
</MenuOption>
</ButtonContextMenu>
</td>
</tr>
{#if renameLibrary === index}