From 87ccba7f9ddf5154558f5b785fc27b64d4de89f4 Mon Sep 17 00:00:00 2001 From: Ben Basten <45583362+ben-basten@users.noreply.github.com> Date: Thu, 21 Mar 2024 12:24:19 +0000 Subject: [PATCH] feat(web): keyboard access for search dropdown, combobox fixes (#8079) * feat(web): keyboard access for search dropdown Also: fixing cosmetic issue with combobox component. * fix: revert changing required field * fix: create new focusChange action * fix: combobox usability improvements * handle escape key on the clear button * move focus to input when clear button is clicked * leave the dropdown closed if the user has already closed the dropdown and tabs over to the clear button * activate the combobox if a user tabs backwards onto the clear button * rename focusChange to focusOutside * small fixes * do not activate combobox on backwards tabbing * simplify classes in "No results" option * prevent dropdown option from being preselected when clear button is clicked * fix: remove unused event dispatcher interface --- .../shared-components/combobox.svelte | 25 ++++++++++++------- .../search-bar/search-bar.svelte | 4 ++- web/src/lib/utils/focus-outside.ts | 21 ++++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/utils/focus-outside.ts diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 19fd73d25f..28a2b7253d 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -18,6 +18,7 @@ import type { FormEventHandler } from 'svelte/elements'; import { shortcuts } from '$lib/utils/shortcut'; import { clickOutside } from '$lib/utils/click-outside'; + import { focusOutside } from '$lib/utils/focus-outside'; /** * Unique identifier for the combobox. @@ -40,6 +41,7 @@ let searchQuery = selectedOption?.label || ''; let selectedIndex: number | undefined; let optionRefs: HTMLElement[] = []; + let input: HTMLInputElement; const inputId = `combobox-${id}`; const listboxId = `listbox-${id}`; @@ -51,7 +53,6 @@ const dispatch = createEventDispatcher<{ select: ComboBoxOption | undefined; - click: void; }>(); const activate = () => { @@ -101,6 +102,8 @@ }; const onClear = () => { + input?.focus(); + selectedIndex = undefined; selectedOption = undefined; searchQuery = ''; dispatch('select', selectedOption); @@ -111,11 +114,16 @@
{ - if (e.relatedTarget instanceof Node && !e.currentTarget.contains(e.relatedTarget)) { - deactivate(); - } - }} + use:focusOutside={{ onFocusOut: deactivate }} + use:shortcuts={[ + { + shortcut: { key: 'Escape' }, + onShortcut: (event) => { + event.stopPropagation(); + closeDropdown(); + }, + }, + ]} >
{#if isActive} @@ -133,6 +141,7 @@ aria-controls={listboxId} aria-expanded={isOpen} autocomplete="off" + bind:this={input} class:!pl-8={isActive} class:!rounded-b-none={isOpen} class:cursor-pointer={!isActive} @@ -213,9 +222,7 @@ 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" + class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-default aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700" id={`${listboxId}-${0}`} on:click={() => closeDropdown()} > 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 8afa56df71..6c057483d4 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 @@ -12,6 +12,7 @@ import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { handlePromiseError } from '$lib/utils'; import { shortcut } from '$lib/utils/shortcut'; + import { focusOutside } from '$lib/utils/focus-outside'; export let value = ''; export let grayTheme: boolean; @@ -94,7 +95,7 @@ }} /> -
+
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(); + } + }; + + node.addEventListener('focusout', handleFocusOut); + + return { + destroy() { + node.removeEventListener('focusout', handleFocusOut); + }, + }; +}