1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-22 01:47:08 +02:00

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
This commit is contained in:
Ben Basten 2024-03-21 12:24:19 +00:00 committed by GitHub
parent e21c96c0ef
commit 87ccba7f9d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 40 additions and 10 deletions

View File

@ -18,6 +18,7 @@
import type { FormEventHandler } from 'svelte/elements'; import type { FormEventHandler } from 'svelte/elements';
import { shortcuts } from '$lib/utils/shortcut'; import { shortcuts } from '$lib/utils/shortcut';
import { clickOutside } from '$lib/utils/click-outside'; import { clickOutside } from '$lib/utils/click-outside';
import { focusOutside } from '$lib/utils/focus-outside';
/** /**
* Unique identifier for the combobox. * Unique identifier for the combobox.
@ -40,6 +41,7 @@
let searchQuery = selectedOption?.label || ''; let searchQuery = selectedOption?.label || '';
let selectedIndex: number | undefined; let selectedIndex: number | undefined;
let optionRefs: HTMLElement[] = []; let optionRefs: HTMLElement[] = [];
let input: HTMLInputElement;
const inputId = `combobox-${id}`; const inputId = `combobox-${id}`;
const listboxId = `listbox-${id}`; const listboxId = `listbox-${id}`;
@ -51,7 +53,6 @@
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
select: ComboBoxOption | undefined; select: ComboBoxOption | undefined;
click: void;
}>(); }>();
const activate = () => { const activate = () => {
@ -101,6 +102,8 @@
}; };
const onClear = () => { const onClear = () => {
input?.focus();
selectedIndex = undefined;
selectedOption = undefined; selectedOption = undefined;
searchQuery = ''; searchQuery = '';
dispatch('select', selectedOption); dispatch('select', selectedOption);
@ -111,11 +114,16 @@
<div <div
class="relative w-full dark:text-gray-300 text-gray-700 text-base" class="relative w-full dark:text-gray-300 text-gray-700 text-base"
use:clickOutside={{ onOutclick: deactivate }} use:clickOutside={{ onOutclick: deactivate }}
on:focusout={(e) => { use:focusOutside={{ onFocusOut: deactivate }}
if (e.relatedTarget instanceof Node && !e.currentTarget.contains(e.relatedTarget)) { use:shortcuts={[
deactivate(); {
} shortcut: { key: 'Escape' },
}} onShortcut: (event) => {
event.stopPropagation();
closeDropdown();
},
},
]}
> >
<div> <div>
{#if isActive} {#if isActive}
@ -133,6 +141,7 @@
aria-controls={listboxId} aria-controls={listboxId}
aria-expanded={isOpen} aria-expanded={isOpen}
autocomplete="off" autocomplete="off"
bind:this={input}
class:!pl-8={isActive} class:!pl-8={isActive}
class:!rounded-b-none={isOpen} class:!rounded-b-none={isOpen}
class:cursor-pointer={!isActive} class:cursor-pointer={!isActive}
@ -213,9 +222,7 @@
role="option" role="option"
aria-selected={selectedIndex === 0} aria-selected={selectedIndex === 0}
aria-disabled={true} aria-disabled={true}
class:bg-gray-100={selectedIndex === 0} 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"
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}`} id={`${listboxId}-${0}`}
on:click={() => closeDropdown()} on:click={() => closeDropdown()}
> >

View File

@ -12,6 +12,7 @@
import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { shortcut } from '$lib/utils/shortcut'; import { shortcut } from '$lib/utils/shortcut';
import { focusOutside } from '$lib/utils/focus-outside';
export let value = ''; export let value = '';
export let grayTheme: boolean; export let grayTheme: boolean;
@ -94,7 +95,7 @@
}} }}
/> />
<div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }}> <div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }} use:focusOutside={{ onFocusOut }}>
<form <form
draggable="false" draggable="false"
autocomplete="off" autocomplete="off"
@ -127,6 +128,7 @@
bind:value bind:value
bind:this={input} bind:this={input}
on:click={onFocusIn} on:click={onFocusIn}
on:focus={onFocusIn}
disabled={showFilter} disabled={showFilter}
use:shortcut={{ use:shortcut={{
shortcut: { key: 'Escape' }, shortcut: { key: 'Escape' },

View File

@ -0,0 +1,21 @@
interface Options {
onFocusOut?: () => 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);
},
};
}