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): combobox accessibility improvements #8007

Merged
merged 16 commits into from
Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion web/src/lib/components/elements/buttons/skip-link.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
};
</script>

<div class="absolute top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
<div class="absolute z-50 top-2 left-2 transition-transform {isFocused ? 'translate-y-0' : '-translate-y-10 sr-only'}">
<Button
size={'sm'}
rounded={false}
Expand Down
9 changes: 7 additions & 2 deletions web/src/lib/components/shared-components/change-date.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,13 @@
/>
</div>
<div class="flex flex-col w-full mt-2">
<label for="timezone">Timezone</label>
<Combobox bind:selectedOption id="timezone" options={timezones} placeholder="Search timezone..." />
<Combobox
bind:selectedOption
id="settings-timezone"
label="Timezone"
options={timezones}
placeholder="Search timezone..."
/>
</div>
</div>
</ConfirmDialogue>
Expand Down
233 changes: 183 additions & 50 deletions web/src/lib/components/shared-components/combobox.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,81 +11,198 @@

<script lang="ts">
import { fly } from 'svelte/transition';

import Icon from '$lib/components/elements/icon.svelte';
import { clickOutside } from '$lib/utils/click-outside';
import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js';
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, tick } from 'svelte';
import IconButton from '../elements/buttons/icon-button.svelte';
import type { FormEventHandler } from 'svelte/elements';
import { shortcuts } from '$lib/utils/shortcut';
import { onMount, onDestroy } from 'svelte';

export let id: string | undefined = undefined;
/**
* Unique identifier for the combobox.
*/
export let id: string;
export let label: string;
export let hideLabel = false;
export let options: ComboBoxOption[] = [];
export let selectedOption: ComboBoxOption | undefined;
export let placeholder = '';

/**
* Indicates whether or not the dropdown autocomplete list should be visible.
*/
let isOpen = false;
let inputFocused = false;
/**
* Keeps track of whether the combobox is actively being used.
*/
let isActive = false;
let searchQuery = selectedOption?.label || '';
let selectedIndex: number | undefined;
let comboboxRef: HTMLElement;
let optionRefs: HTMLElement[] = [];
const inputId = `combobox-${id}`;
const listboxId = `listbox-${id}`;

$: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()));

$: {
searchQuery = selectedOption ? selectedOption.label : '';
}

onMount(() => {
window.addEventListener('click', onClick);
});

onDestroy(() => {
window.removeEventListener('click', onClick);
});

const dispatch = createEventDispatcher<{
select: ComboBoxOption | undefined;
click: void;
}>();

const handleClick = () => {
searchQuery = '';
const activate = () => {
isActive = true;
openDropdown();
};

const deactivate = () => {
searchQuery = selectedOption ? selectedOption.label : '';
isActive = false;
closeDropdown();
};

const openDropdown = () => {
isOpen = true;
inputFocused = true;
dispatch('click');
};

let handleOutClick = () => {
// In rare cases it's possible for the input to still have focus and
// outclick to fire.
if (!inputFocused) {
isOpen = false;
const closeDropdown = () => {
isOpen = false;
selectedIndex = undefined;
};

const incrementSelectedIndex = async (increment: number) => {
if (filteredOptions.length === 0) {
selectedIndex = 0;
} else if (selectedIndex === undefined) {
selectedIndex = increment === 1 ? 0 : filteredOptions.length - 1;
} else {
selectedIndex = (selectedIndex + increment + filteredOptions.length) % filteredOptions.length;
}
await tick();
michelheusschen marked this conversation as resolved.
Show resolved Hide resolved
optionRefs[selectedIndex]?.scrollIntoView({ block: 'nearest' });
};

const onInput: FormEventHandler<HTMLInputElement> = (event) => {
openDropdown();
searchQuery = (event.target as HTMLInputElement).value;
ben-basten marked this conversation as resolved.
Show resolved Hide resolved
selectedIndex = undefined;
optionRefs[0]?.scrollIntoView({ block: 'nearest' });
};

let handleSelect = (option: ComboBoxOption) => {
let onSelect = (option: ComboBoxOption) => {
selectedOption = option;
searchQuery = option.label;
dispatch('select', option);
isOpen = false;
closeDropdown();
};

const onClear = () => {
selectedOption = undefined;
searchQuery = '';
dispatch('select', selectedOption);
};

const onClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (comboboxRef && !comboboxRef.contains(target)) {
deactivate();
}
};
</script>

<div class="relative w-full dark:text-gray-300 text-gray-700 text-base" use:clickOutside on:outclick={handleOutClick}>
<label class="text-sm text-black dark:text-white" class:sr-only={hideLabel} for={inputId}>{label}</label>
<div class="relative w-full dark:text-gray-300 text-gray-700 text-base" bind:this={comboboxRef}>
<div>
{#if isOpen}
{#if isActive}
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
<div class="dark:text-immich-dark-fg/75">
<Icon path={mdiMagnify} />
<Icon path={mdiMagnify} ariaHidden={true} />
</div>
</div>
{/if}

<input
{id}
{placeholder}
role="combobox"
aria-activedescendant={selectedIndex || selectedIndex === 0 ? `${listboxId}-${selectedIndex}` : ''}
aria-autocomplete="list"
aria-controls={listboxId}
aria-expanded={isOpen}
aria-controls={id}
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
class:!pl-8={isOpen}
autocomplete="off"
class:!pl-8={isActive}
class:!rounded-b-none={isOpen}
class:cursor-pointer={!isOpen}
value={isOpen ? '' : selectedOption?.label || ''}
on:input={(e) => (searchQuery = e.currentTarget.value)}
on:focus={handleClick}
on:blur={() => (inputFocused = false)}
class:cursor-pointer={!isActive}
class="immich-form-input text-sm text-left w-full !pr-12 transition-all"
id={inputId}
on:click={activate}
on:focus={activate}
michelheusschen marked this conversation as resolved.
Show resolved Hide resolved
on:input={onInput}
role="combobox"
type="text"
value={searchQuery}
use:shortcuts={[
{
shortcut: { key: 'Tab' },
ben-basten marked this conversation as resolved.
Show resolved Hide resolved
preventDefault: false,
onShortcut: () => {
deactivate();
},
},
{
shortcut: { key: 'Tab', shift: true },
preventDefault: false,
onShortcut: () => {
deactivate();
},
},
{
shortcut: { key: 'ArrowUp' },
onShortcut: () => {
openDropdown();
void incrementSelectedIndex(-1);
},
},
{
shortcut: { key: 'ArrowDown' },
onShortcut: () => {
openDropdown();
void incrementSelectedIndex(1);
},
},
{
shortcut: { key: 'ArrowDown', alt: true },
onShortcut: () => {
openDropdown();
michelheusschen marked this conversation as resolved.
Show resolved Hide resolved
},
},
{
shortcut: { key: 'Enter' },
onShortcut: () => {
if (selectedIndex !== undefined && filteredOptions.length > 0) {
onSelect(filteredOptions[selectedIndex]);
}
closeDropdown();
},
},
{
shortcut: { key: 'Escape' },
ben-basten marked this conversation as resolved.
Show resolved Hide resolved
onShortcut: () => {
closeDropdown();
},
},
]}
/>

<div
Expand All @@ -95,37 +212,53 @@
>
{#if selectedOption}
<IconButton color="transparent-gray" on:click={onClear} title="Clear value">
<Icon path={mdiClose} />
<Icon path={mdiClose} ariaLabel="Clear value" />
michelheusschen marked this conversation as resolved.
Show resolved Hide resolved
</IconButton>
{:else if !isOpen}
<Icon path={mdiUnfoldMoreHorizontal} />
<Icon path={mdiUnfoldMoreHorizontal} ariaHidden={true} />
{/if}
</div>
</div>

{#if isOpen}
<div
role="listbox"
transition:fly={{ duration: 250 }}
class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 rounded-b-lg border border-t-0 border-gray-300 dark:border-gray-900 z-10"
>
<ul
role="listbox"
id={listboxId}
transition:fly={{ duration: 250 }}
class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 r unded-b-lg border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-10"
ben-basten marked this conversation as resolved.
Show resolved Hide resolved
class:border={isOpen}
tabindex="-1"
>
{#if isOpen}
{#if filteredOptions.length === 0}
<div class="px-4 py-2 font-medium">No results</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
role="option"
aria-selected={selectedIndex === 0}
aria-disabled={true}
class:bg-gray-100={selectedIndex === 0}
class:dark:bg-gray-700={selectedIndex === 0}
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default"
id={`${listboxId}-${0}`}
on:click={() => closeDropdown()}
>
No results
</li>
{/if}
{#each filteredOptions as option (option.label)}
{@const selected = option.label === selectedOption?.label}
<button
type="button"
{#each filteredOptions as option, index (option.label)}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
aria-selected={index === selectedIndex}
bind:this={optionRefs[index]}
class:bg-gray-100={index === selectedIndex}
class:dark:bg-gray-700={index === selectedIndex}
ben-basten marked this conversation as resolved.
Show resolved Hide resolved
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer"
id={`${listboxId}-${index}`}
on:click={() => onSelect(option)}
role="option"
aria-selected={selected}
class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"
class:bg-gray-300={selected}
class:dark:bg-gray-600={selected}
on:click={() => handleSelect(option)}
>
{option.label}
</button>
</li>
{/each}
</div>
{/if}
{/if}
</ul>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { handlePromiseError } from '$lib/utils';
import { shortcut } from '$lib/utils/shortcut';

export let value = '';
export let grayTheme: boolean;
Expand Down Expand Up @@ -84,7 +85,18 @@
};
</script>

<div class="w-full relative" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}>
<div
class="w-full relative"
use:clickOutside
on:outclick={onFocusOut}
tabindex="-1"
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
onFocusOut();
ben-basten marked this conversation as resolved.
Show resolved Hide resolved
},
}}
>
<form
draggable="false"
autocomplete="off"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,24 +40,24 @@

<div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1">
<div class="w-full">
<label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label>
<Combobox
id="search-camera-make"
options={toComboBoxOptions(makes)}
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
id="camera-make"
label="Make"
on:select={({ detail }) => (filters.make = detail?.value)}
options={toComboBoxOptions(makes)}
placeholder="Search camera make..."
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined}
/>
</div>

<div class="w-full">
<label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label>
<Combobox
id="search-camera-model"
options={toComboBoxOptions(models)}
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
id="camera-model"
label="Model"
on:select={({ detail }) => (filters.model = detail?.value)}
options={toComboBoxOptions(models)}
placeholder="Search camera model..."
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined}
/>
</div>
</div>
Expand Down