Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web): keyboard accessible context menus #10017

Merged
merged 32 commits into from
Jun 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f11f579
feat(web,a11y): context menu keyboard navigation
ben-basten May 29, 2024
124e401
Merge branch 'main' of https://github.com/immich-app/immich into feat…
ben-basten Jun 2, 2024
081a82a
wip: all context menus visible
ben-basten Jun 3, 2024
059f923
Merge branch 'main' of https://github.com/immich-app/immich into feat…
ben-basten Jun 5, 2024
8b9a1e5
wip: more migrations to the ButtonContextMenu, usability improvements
ben-basten Jun 6, 2024
5c9d06c
Merge branch 'main' of https://github.com/immich-app/immich into feat…
ben-basten Jun 7, 2024
7c30a19
wip: migrate Administration, PeopleCard
ben-basten Jun 7, 2024
276a446
wip: refocus the button on click, docs
ben-basten Jun 7, 2024
675110c
Merge branch 'main' of https://github.com/immich-app/immich into feat…
ben-basten Jun 9, 2024
a755acc
fix: more intuitive RightClickContextMenu
ben-basten Jun 10, 2024
84fdc9e
Merge branch 'main' of https://github.com/immich-app/immich into feat…
ben-basten Jun 10, 2024
04b06d2
fix: refining the little details
ben-basten Jun 11, 2024
57fb25d
fix: dropdown options not clickable in a <Portal>
ben-basten Jun 12, 2024
29d5f3e
Merge branch 'main' of https://github.com/immich-app/immich into feat…
ben-basten Jun 12, 2024
ac51083
wip: small fixes
ben-basten Jun 12, 2024
6ef0d7b
Merge branch 'main' of https://github.com/immich-app/immich into feat…
ben-basten Jun 14, 2024
c3f9643
chore: revert changes to list navigation, to reduce scope of the PR
ben-basten Jun 14, 2024
1dd5556
fix: remove topBorder prop
ben-basten Jun 14, 2024
b25b8ad
feat: automatically select the first option on enter or space keypress
ben-basten Jun 14, 2024
c93a7aa
Merge branch 'main' of https://github.com/immich-app/immich into feat…
ben-basten Jun 14, 2024
bbf8ab5
fix: use Svelte store instead to handle selecting menu options
ben-basten Jun 15, 2024
08426e1
feat: hovering the mouse can change the active element
ben-basten Jun 15, 2024
03d1263
fix: remove Portal, more predictable open/close behavior
ben-basten Jun 16, 2024
fbc373a
feat: make selected item visible using a scroll
ben-basten Jun 16, 2024
ede2f0d
feat: maintain context menu position on resize
ben-basten Jun 16, 2024
057b3df
Merge branch 'main' of https://github.com/immich-app/immich into feat…
ben-basten Jun 16, 2024
5da00aa
fix: use the whole padding class as better tailwind convention
ben-basten Jun 16, 2024
749f0be
fix: options not announcing with screen reader for ButtonContextMenu
ben-basten Jun 16, 2024
76ae57a
fix: screen reader announcing right click context menu options
ben-basten Jun 16, 2024
3b70ae5
fix: handle focus out scenario
ben-basten Jun 17, 2024
96da36a
Merge branch 'main' of https://github.com/immich-app/immich into feat…
ben-basten Jun 17, 2024
1f7f457
Merge branch 'main' of github.com:immich-app/immich into feat/accessi…
alextran1502 Jun 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions web/src/lib/actions/context-menu-navigation.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
4 changes: 2 additions & 2 deletions web/src/lib/actions/focus-outside.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
interface Options {
onFocusOut?: () => void;
onFocusOut?: (event: FocusEvent) => void;
}

export function focusOutside(node: HTMLElement, options: Options = {}) {
const { onFocusOut } = options;

const handleFocusOut = (event: FocusEvent) => {
if (onFocusOut && event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)) {
onFocusOut();
onFocusOut(event);
}
};

Expand Down
4 changes: 2 additions & 2 deletions web/src/lib/components/album-page/album-card.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,7 +20,7 @@
const showAlbumContextMenu = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
onShowContextMenu?.(getContextMenuPosition(e));
onShowContextMenu?.(getContextMenuPositionFromEvent(e));
};
</script>

Expand Down
40 changes: 12 additions & 28 deletions web/src/lib/components/album-page/albums-list.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
$: {
Expand Down Expand Up @@ -224,7 +224,6 @@
albumGroupIds = groupedAlbums.map(({ id }) => id);
}

$: showContextMenu = !!contextMenuTargetAlbum;
$: showFullContextMenu = allowEdit && contextMenuTargetAlbum && contextMenuTargetAlbum.ownerId === $user.id;

onMount(async () => {
Expand Down Expand Up @@ -253,10 +252,11 @@
x: contextMenuDetail.x,
y: contextMenuDetail.y,
};
isOpen = true;
};

const closeAlbumContextMenu = () => {
contextMenuTargetAlbum = null;
isOpen = false;
};

const handleDownloadAlbum = async () => {
Expand Down Expand Up @@ -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>

Expand Down
51 changes: 13 additions & 38 deletions web/src/lib/components/album-page/share-info-modal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading