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

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