mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +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:
parent
99c6fdbc1c
commit
b71aa4473b
26 changed files with 639 additions and 441 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
const key = {};
|
||||
|
||||
export { key };
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue