From a78eeb9b9c045953740100ce820b813df7d14b1c Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Fri, 26 Jul 2024 17:45:15 -0400 Subject: [PATCH] feat(web): search bar keyboard accessibility (#11323) * feat(web): search bar keyboard accessibility * fix: adjust aria attributes * fix: safari announcing the correct option count * minor adjustments - CircleIconButton disabled cursor - more generic selection handler * fix: more subtle border color in dark mode --------- Co-authored-by: Alex --- .../buttons/circle-icon-button.svelte | 6 +- .../search-bar/search-bar.svelte | 156 ++++++++++++----- .../search-bar/search-filter-box.svelte | 2 +- .../search-bar/search-history-box.svelte | 159 +++++++++++++----- web/src/lib/i18n/en.json | 1 + .../[[assetId=id]]/+page.svelte | 2 +- 6 files changed, 237 insertions(+), 89 deletions(-) diff --git a/web/src/lib/components/elements/buttons/circle-icon-button.svelte b/web/src/lib/components/elements/buttons/circle-icon-button.svelte index e37ad44254..1d444ae73c 100644 --- a/web/src/lib/components/elements/buttons/circle-icon-button.svelte +++ b/web/src/lib/components/elements/buttons/circle-icon-button.svelte @@ -27,6 +27,8 @@ export let ariaHasPopup: boolean | undefined = undefined; export let ariaExpanded: boolean | undefined = undefined; export let ariaControls: string | undefined = undefined; + export let tabindex: number | undefined = undefined; + export let disabled: boolean | undefined = undefined; /** * Override the default styling of the button for specific use cases, such as the icon color. @@ -53,9 +55,11 @@ {id} {title} {type} + {tabindex} + {disabled} style:width={buttonSize ? buttonSize + 'px' : ''} style:height={buttonSize ? buttonSize + 'px' : ''} - class="flex place-content-center place-items-center rounded-full {colorClass} {paddingClass} transition-all hover:dark:text-immich-dark-gray {className} {mobileClass}" + class="flex place-content-center place-items-center rounded-full {colorClass} {paddingClass} transition-all disabled:cursor-default hover:dark:text-immich-dark-gray {className} {mobileClass}" aria-haspopup={ariaHasPopup} aria-expanded={ariaExpanded} aria-controls={ariaControls} diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 0e09b0e5b9..8fa9233771 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -13,21 +13,31 @@ import { focusOutside } from '$lib/actions/focus-outside'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import { t } from 'svelte-i18n'; + import { generateId } from '$lib/utils/generate-id'; + import { tick } from 'svelte'; export let value = ''; export let grayTheme: boolean; export let searchQuery: MetadataSearchDto | SmartSearchDto = {}; + $: showClearIcon = value.length > 0; + let input: HTMLInputElement; - let showHistory = false; + let showSuggestions = false; let showFilter = false; - $: showClearIcon = value.length > 0; + let isSearchSuggestions = false; + let selectedId: string | undefined; + let moveSelection: (direction: 1 | -1) => void; + let clearSelection: () => void; + let selectActiveOption: () => void; + + const listboxId = generateId(); const onSearch = async (payload: SmartSearchDto | MetadataSearchDto) => { const params = getMetadataSearchQuery(payload); - showHistory = false; + closeDropdown(); showFilter = false; $isSearchEnabled = false; await goto(`${AppRoute.SEARCH}?${params}`); @@ -39,7 +49,8 @@ }; const saveSearchTerm = (saveValue: string) => { - $savedSearchTerms = [saveValue, ...$savedSearchTerms]; + const filteredSearchTerms = $savedSearchTerms.filter((item) => item.toLowerCase() !== saveValue.toLowerCase()); + $savedSearchTerms = [saveValue, ...filteredSearchTerms]; if ($savedSearchTerms.length > 5) { $savedSearchTerms = $savedSearchTerms.slice(0, 5); @@ -52,7 +63,6 @@ }; const onFocusIn = () => { - showHistory = true; $isSearchEnabled = true; }; @@ -61,12 +71,13 @@ $preventRaceConditionSearchBar = true; } - showHistory = false; + closeDropdown(); $isSearchEnabled = false; showFilter = false; }; const onHistoryTermClick = async (searchTerm: string) => { + value = searchTerm; const searchPayload = { query: searchTerm }; await onSearch(searchPayload); }; @@ -76,7 +87,7 @@ value = ''; if (showFilter) { - showHistory = false; + closeDropdown(); } }; @@ -84,12 +95,49 @@ handlePromiseError(onSearch({ query: value })); saveSearchTerm(value); }; + + const onClear = () => { + value = ''; + input.focus(); + }; + + const onEscape = () => { + closeDropdown(); + showFilter = false; + }; + + const onArrow = async (direction: 1 | -1) => { + openDropdown(); + await tick(); + moveSelection(direction); + }; + + const onEnter = (event: KeyboardEvent) => { + if (selectedId) { + event.preventDefault(); + selectActiveOption(); + } + }; + + const onInput = () => { + openDropdown(); + clearSelection(); + }; + + const openDropdown = () => { + showSuggestions = true; + }; + + const closeDropdown = () => { + showSuggestions = false; + clearSelection(); + }; input.focus() }, + { shortcut: { key: 'Escape' }, onShortcut: onEscape }, + { shortcut: { ctrl: true, key: 'k' }, onShortcut: () => input.select() }, { shortcut: { ctrl: true, shift: true, key: 'k' }, onShortcut: onFilterClick }, ]} /> @@ -102,53 +150,69 @@ action={AppRoute.SEARCH} on:reset={() => (value = '')} on:submit|preventDefault={onSubmit} + on:focusin={onFocusIn} + role="search" > -
- +
+ + onArrow(-1) }, + { shortcut: { key: 'ArrowDown' }, onShortcut: () => onArrow(1) }, + { shortcut: { key: 'Enter' }, onShortcut: onEnter, preventDefault: false }, + { shortcut: { key: 'ArrowDown', alt: true }, onShortcut: openDropdown }, + ]} + /> + + + clearSearchTerm(searchTerm)} + onSelectSearchTerm={(searchTerm) => handlePromiseError(onHistoryTermClick(searchTerm))} + onActiveSelectionChange={(id) => (selectedId = id)} + />
- -
{#if showClearIcon}
- +
{/if} - - - {#if showHistory && $savedSearchTerms.length > 0} - clearSearchTerm(searchTerm)} - on:selectSearchTerm={({ detail: searchTerm }) => handlePromiseError(onHistoryTermClick(searchTerm))} - /> - {/if} +
+ +
{#if showFilter} diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte index 595acf3c49..5fa92ac7b2 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte @@ -117,7 +117,7 @@
(); + export let id: string; + export let searchQuery: string = ''; + export let isSearchSuggestions: boolean = false; + export let isOpen: boolean = false; + export let onSelectSearchTerm: (searchTerm: string) => void; + export let onClearSearchTerm: (searchTerm: string) => void; + export let onClearAllSearchTerms: () => void; + export let onActiveSelectionChange: (selectedId: string | undefined) => void; + + $: filteredSearchTerms = $savedSearchTerms.filter((term) => term.toLowerCase().includes(searchQuery.toLowerCase())); + $: isSearchSuggestions = filteredSearchTerms.length > 0; + $: showClearAll = searchQuery === ''; + $: suggestionCount = showClearAll ? filteredSearchTerms.length + 1 : filteredSearchTerms.length; + + let selectedIndex: number | undefined = undefined; + let element: HTMLDivElement; + + export function moveSelection(increment: 1 | -1) { + if (!isSearchSuggestions) { + return; + } else if (selectedIndex === undefined) { + selectedIndex = increment === 1 ? 0 : suggestionCount - 1; + } else if (selectedIndex + increment < 0 || selectedIndex + increment >= suggestionCount) { + clearSelection(); + } else { + selectedIndex = (selectedIndex + increment + suggestionCount) % suggestionCount; + } + onActiveSelectionChange(getId(selectedIndex)); + } + + export function clearSelection() { + selectedIndex = undefined; + onActiveSelectionChange(undefined); + } + + export function selectActiveOption() { + if (selectedIndex === undefined) { + return; + } + const selectedElement = element.querySelector(`#${getId(selectedIndex)}`) as HTMLElement; + selectedElement?.click(); + } + + const handleClearAll = () => { + clearSelection(); + onClearAllSearchTerms(); + }; + + const handleClearSingle = (searchTerm: string) => { + clearSelection(); + onClearSearchTerm(searchTerm); + }; + + const handleSelect = (searchTerm: string) => { + clearSelection(); + onSelectSearchTerm(searchTerm); + }; + + const getId = (index: number | undefined) => { + if (index === undefined) { + return undefined; + } + return `${id}-${index}`; + }; -
- {#if $savedSearchTerms.length > 0} -
-

{$t('recent_searches').toUpperCase()}

- +
+ {#if isOpen && isSearchSuggestions} +
+
+

{$t('recent_searches').toUpperCase()}

+ {#if showClearAll} + + {/if} +
+ + {#each filteredSearchTerms as savedSearchTerm, i (i)} + {@const index = showClearAll ? i + 1 : i} +
+
+ +
handleSelect(savedSearchTerm)} + role="option" + tabindex="-1" + aria-selected={selectedIndex === index} + aria-label={savedSearchTerm} + > + + {savedSearchTerm} +
+
+ handleClearSingle(savedSearchTerm)} + /> +
+
+
+ {/each}
{/if} - - {#each $savedSearchTerms as savedSearchTerm, i (i)} -
-
- -
- -
-
-
- {/each}
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index f4f57c4427..8173324f8e 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -429,6 +429,7 @@ "city": "City", "clear": "Clear", "clear_all": "Clear all", + "clear_all_recent_searches": "Clear all recent searches", "clear_message": "Clear message", "clear_value": "Clear value", "close": "Close", diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9eb7d76546..98e00b6970 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -230,7 +230,7 @@
goto(previousRoute)} backIcon={mdiArrowLeft}>
- +