mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04:00 
			
		
		
		
	feat(web): combobox accessibility improvements (#8007)
* bump skip link z index, to prevent overlap with the search box * combobox refactor initial commit * pull label into the combobox component * feat(web): combobox accessibility improvements * fix: replace crypto.randomUUID, fix border UI bug, simpler focus handling (#2) * fix: handle changes in the selected option * fix: better escape key handling in search bar * fix: remove broken tailwind classes Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * fix: remove custom "outclick" handler logic * fix: use focusout instead of custom key handlers to detect focus change * fix: move escape key handling to the window Also add escape key handling to the input box, to make sure that the "recent searches" dropdown gets closed too. * fix: better input event handling Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * fix: highlighting selected dropdown element --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									033f83a55a
								
							
						
					
					
						commit
						c6d2408517
					
				| @ -14,7 +14,7 @@ | |||||||
|   }; |   }; | ||||||
| </script> | </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 |   <Button | ||||||
|     size={'sm'} |     size={'sm'} | ||||||
|     rounded={false} |     rounded={false} | ||||||
|  | |||||||
| @ -83,8 +83,13 @@ | |||||||
|         /> |         /> | ||||||
|       </div> |       </div> | ||||||
|       <div class="flex flex-col w-full mt-2"> |       <div class="flex flex-col w-full mt-2"> | ||||||
|         <label for="timezone">Timezone</label> |         <Combobox | ||||||
|         <Combobox bind:selectedOption id="timezone" options={timezones} placeholder="Search timezone..." /> |           bind:selectedOption | ||||||
|  |           id="settings-timezone" | ||||||
|  |           label="Timezone" | ||||||
|  |           options={timezones} | ||||||
|  |           placeholder="Search timezone..." | ||||||
|  |         /> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </ConfirmDialogue> |   </ConfirmDialogue> | ||||||
|  | |||||||
| @ -11,48 +11,93 @@ | |||||||
| 
 | 
 | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|   import { fly } from 'svelte/transition'; |   import { fly } from 'svelte/transition'; | ||||||
| 
 |  | ||||||
|   import Icon from '$lib/components/elements/icon.svelte'; |   import Icon from '$lib/components/elements/icon.svelte'; | ||||||
|   import { clickOutside } from '$lib/utils/click-outside'; |  | ||||||
|   import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js'; |   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 IconButton from '../elements/buttons/icon-button.svelte'; | ||||||
|  |   import type { FormEventHandler } from 'svelte/elements'; | ||||||
|  |   import { shortcuts } from '$lib/utils/shortcut'; | ||||||
|  |   import { clickOutside } from '$lib/utils/click-outside'; | ||||||
| 
 | 
 | ||||||
|   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 options: ComboBoxOption[] = []; | ||||||
|   export let selectedOption: ComboBoxOption | undefined; |   export let selectedOption: ComboBoxOption | undefined; | ||||||
|   export let placeholder = ''; |   export let placeholder = ''; | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * Indicates whether or not the dropdown autocomplete list should be visible. | ||||||
|  |    */ | ||||||
|   let isOpen = false; |   let isOpen = false; | ||||||
|   let inputFocused = false; |   /** | ||||||
|  |    * Keeps track of whether the combobox is actively being used. | ||||||
|  |    */ | ||||||
|  |   let isActive = false; | ||||||
|   let searchQuery = selectedOption?.label || ''; |   let searchQuery = selectedOption?.label || ''; | ||||||
|  |   let selectedIndex: number | undefined; | ||||||
|  |   let optionRefs: HTMLElement[] = []; | ||||||
|  |   const inputId = `combobox-${id}`; | ||||||
|  |   const listboxId = `listbox-${id}`; | ||||||
| 
 | 
 | ||||||
|   $: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); |   $: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); | ||||||
| 
 | 
 | ||||||
|  |   $: { | ||||||
|  |     searchQuery = selectedOption ? selectedOption.label : ''; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   const dispatch = createEventDispatcher<{ |   const dispatch = createEventDispatcher<{ | ||||||
|     select: ComboBoxOption | undefined; |     select: ComboBoxOption | undefined; | ||||||
|     click: void; |     click: void; | ||||||
|   }>(); |   }>(); | ||||||
| 
 | 
 | ||||||
|   const handleClick = () => { |   const activate = () => { | ||||||
|     searchQuery = ''; |     isActive = true; | ||||||
|  |     openDropdown(); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const deactivate = () => { | ||||||
|  |     searchQuery = selectedOption ? selectedOption.label : ''; | ||||||
|  |     isActive = false; | ||||||
|  |     closeDropdown(); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const openDropdown = () => { | ||||||
|     isOpen = true; |     isOpen = true; | ||||||
|     inputFocused = true; |  | ||||||
|     dispatch('click'); |  | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   let handleOutClick = () => { |   const closeDropdown = () => { | ||||||
|     // In rare cases it's possible for the input to still have focus and |  | ||||||
|     // outclick to fire. |  | ||||||
|     if (!inputFocused) { |  | ||||||
|       isOpen = false; |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   let handleSelect = (option: ComboBoxOption) => { |  | ||||||
|     selectedOption = option; |  | ||||||
|     dispatch('select', option); |  | ||||||
|     isOpen = false; |     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(); | ||||||
|  |     optionRefs[selectedIndex]?.scrollIntoView({ block: 'nearest' }); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const onInput: FormEventHandler<HTMLInputElement> = (event) => { | ||||||
|  |     openDropdown(); | ||||||
|  |     searchQuery = event.currentTarget.value; | ||||||
|  |     selectedIndex = undefined; | ||||||
|  |     optionRefs[0]?.scrollIntoView({ block: 'nearest' }); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   let onSelect = (option: ComboBoxOption) => { | ||||||
|  |     selectedOption = option; | ||||||
|  |     searchQuery = option.label; | ||||||
|  |     dispatch('select', option); | ||||||
|  |     closeDropdown(); | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   const onClear = () => { |   const onClear = () => { | ||||||
| @ -62,30 +107,80 @@ | |||||||
|   }; |   }; | ||||||
| </script> | </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" | ||||||
|  |   use:clickOutside={{ onOutclick: deactivate }} | ||||||
|  |   on:focusout={(e) => { | ||||||
|  |     if (e.relatedTarget instanceof Node && !e.currentTarget.contains(e.relatedTarget)) { | ||||||
|  |       deactivate(); | ||||||
|  |     } | ||||||
|  |   }} | ||||||
|  | > | ||||||
|   <div> |   <div> | ||||||
|     {#if isOpen} |     {#if isActive} | ||||||
|       <div class="absolute inset-y-0 left-0 flex items-center pl-3"> |       <div class="absolute inset-y-0 left-0 flex items-center pl-3"> | ||||||
|         <div class="dark:text-immich-dark-fg/75"> |         <div class="dark:text-immich-dark-fg/75"> | ||||||
|           <Icon path={mdiMagnify} /> |           <Icon path={mdiMagnify} ariaHidden={true} /> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|     {/if} |     {/if} | ||||||
| 
 | 
 | ||||||
|     <input |     <input | ||||||
|       {id} |  | ||||||
|       {placeholder} |       {placeholder} | ||||||
|       role="combobox" |       aria-activedescendant={selectedIndex || selectedIndex === 0 ? `${listboxId}-${selectedIndex}` : ''} | ||||||
|  |       aria-autocomplete="list" | ||||||
|  |       aria-controls={listboxId} | ||||||
|       aria-expanded={isOpen} |       aria-expanded={isOpen} | ||||||
|       aria-controls={id} |       autocomplete="off" | ||||||
|       class="immich-form-input text-sm text-left w-full !pr-12 transition-all" |       class:!pl-8={isActive} | ||||||
|       class:!pl-8={isOpen} |  | ||||||
|       class:!rounded-b-none={isOpen} |       class:!rounded-b-none={isOpen} | ||||||
|       class:cursor-pointer={!isOpen} |       class:cursor-pointer={!isActive} | ||||||
|       value={isOpen ? '' : selectedOption?.label || ''} |       class="immich-form-input text-sm text-left w-full !pr-12 transition-all" | ||||||
|       on:input={(e) => (searchQuery = e.currentTarget.value)} |       id={inputId} | ||||||
|       on:focus={handleClick} |       on:click={activate} | ||||||
|       on:blur={() => (inputFocused = false)} |       on:focus={activate} | ||||||
|  |       on:input={onInput} | ||||||
|  |       role="combobox" | ||||||
|  |       type="text" | ||||||
|  |       value={searchQuery} | ||||||
|  |       use:shortcuts={[ | ||||||
|  |         { | ||||||
|  |           shortcut: { key: 'ArrowUp' }, | ||||||
|  |           onShortcut: () => { | ||||||
|  |             openDropdown(); | ||||||
|  |             void incrementSelectedIndex(-1); | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           shortcut: { key: 'ArrowDown' }, | ||||||
|  |           onShortcut: () => { | ||||||
|  |             openDropdown(); | ||||||
|  |             void incrementSelectedIndex(1); | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           shortcut: { key: 'ArrowDown', alt: true }, | ||||||
|  |           onShortcut: () => { | ||||||
|  |             openDropdown(); | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           shortcut: { key: 'Enter' }, | ||||||
|  |           onShortcut: () => { | ||||||
|  |             if (selectedIndex !== undefined && filteredOptions.length > 0) { | ||||||
|  |               onSelect(filteredOptions[selectedIndex]); | ||||||
|  |             } | ||||||
|  |             closeDropdown(); | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           shortcut: { key: 'Escape' }, | ||||||
|  |           onShortcut: () => { | ||||||
|  |             closeDropdown(); | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       ]} | ||||||
|     /> |     /> | ||||||
| 
 | 
 | ||||||
|     <div |     <div | ||||||
| @ -95,37 +190,51 @@ | |||||||
|     > |     > | ||||||
|       {#if selectedOption} |       {#if selectedOption} | ||||||
|         <IconButton color="transparent-gray" on:click={onClear} title="Clear value"> |         <IconButton color="transparent-gray" on:click={onClear} title="Clear value"> | ||||||
|           <Icon path={mdiClose} /> |           <Icon path={mdiClose} ariaLabel="Clear value" /> | ||||||
|         </IconButton> |         </IconButton> | ||||||
|       {:else if !isOpen} |       {:else if !isOpen} | ||||||
|         <Icon path={mdiUnfoldMoreHorizontal} /> |         <Icon path={mdiUnfoldMoreHorizontal} ariaHidden={true} /> | ||||||
|       {/if} |       {/if} | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| 
 | 
 | ||||||
|   {#if isOpen} |   <ul | ||||||
|     <div |     role="listbox" | ||||||
|       role="listbox" |     id={listboxId} | ||||||
|       transition:fly={{ duration: 250 }} |     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" |     class="absolute text-left text-sm w-full max-h-64 overflow-y-auto bg-white dark:bg-gray-800 border-t-0 border-gray-300 dark:border-gray-900 rounded-b-xl z-10" | ||||||
|     > |     class:border={isOpen} | ||||||
|  |     tabindex="-1" | ||||||
|  |   > | ||||||
|  |     {#if isOpen} | ||||||
|       {#if filteredOptions.length === 0} |       {#if filteredOptions.length === 0} | ||||||
|         <div class="px-4 py-2 font-medium">No results</div> |         <!-- svelte-ignore a11y-click-events-have-key-events --> | ||||||
|       {/if} |         <li | ||||||
|       {#each filteredOptions as option (option.label)} |           role="option" | ||||||
|         {@const selected = option.label === selectedOption?.label} |           aria-selected={selectedIndex === 0} | ||||||
|         <button |           aria-disabled={true} | ||||||
|           type="button" |           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, index (option.label)} | ||||||
|  |         <!-- svelte-ignore a11y-click-events-have-key-events --> | ||||||
|  |         <li | ||||||
|  |           aria-selected={index === selectedIndex} | ||||||
|  |           bind:this={optionRefs[index]} | ||||||
|  |           class="text-left w-full px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-100 aria-selected:dark:bg-gray-700" | ||||||
|  |           id={`${listboxId}-${index}`} | ||||||
|  |           on:click={() => onSelect(option)} | ||||||
|           role="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} |           {option.label} | ||||||
|         </button> |         </li> | ||||||
|       {/each} |       {/each} | ||||||
|     </div> |     {/if} | ||||||
|   {/if} |   </ul> | ||||||
| </div> | </div> | ||||||
|  | |||||||
| @ -11,6 +11,7 @@ | |||||||
|   import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; |   import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; | ||||||
|   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'; | ||||||
| 
 | 
 | ||||||
|   export let value = ''; |   export let value = ''; | ||||||
|   export let grayTheme: boolean; |   export let grayTheme: boolean; | ||||||
| @ -84,7 +85,16 @@ | |||||||
|   }; |   }; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div class="w-full relative" use:clickOutside on:outclick={onFocusOut} on:escape={onFocusOut}> | <svelte:window | ||||||
|  |   use:shortcut={{ | ||||||
|  |     shortcut: { key: 'Escape' }, | ||||||
|  |     onShortcut: () => { | ||||||
|  |       onFocusOut(); | ||||||
|  |     }, | ||||||
|  |   }} | ||||||
|  | /> | ||||||
|  | 
 | ||||||
|  | <div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }}> | ||||||
|   <form |   <form | ||||||
|     draggable="false" |     draggable="false" | ||||||
|     autocomplete="off" |     autocomplete="off" | ||||||
| @ -118,6 +128,12 @@ | |||||||
|         bind:this={input} |         bind:this={input} | ||||||
|         on:click={onFocusIn} |         on:click={onFocusIn} | ||||||
|         disabled={showFilter} |         disabled={showFilter} | ||||||
|  |         use:shortcut={{ | ||||||
|  |           shortcut: { key: 'Escape' }, | ||||||
|  |           onShortcut: () => { | ||||||
|  |             onFocusOut(); | ||||||
|  |           }, | ||||||
|  |         }} | ||||||
|       /> |       /> | ||||||
| 
 | 
 | ||||||
|       <div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all"> |       <div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all"> | ||||||
|  | |||||||
| @ -40,24 +40,24 @@ | |||||||
| 
 | 
 | ||||||
|   <div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1"> |   <div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1"> | ||||||
|     <div class="w-full"> |     <div class="w-full"> | ||||||
|       <label class="text-sm text-black dark:text-white" for="search-camera-make">Make</label> |  | ||||||
|       <Combobox |       <Combobox | ||||||
|         id="search-camera-make" |         id="camera-make" | ||||||
|         options={toComboBoxOptions(makes)} |         label="Make" | ||||||
|         selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined} |  | ||||||
|         on:select={({ detail }) => (filters.make = detail?.value)} |         on:select={({ detail }) => (filters.make = detail?.value)} | ||||||
|  |         options={toComboBoxOptions(makes)} | ||||||
|         placeholder="Search camera make..." |         placeholder="Search camera make..." | ||||||
|  |         selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined} | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <div class="w-full"> |     <div class="w-full"> | ||||||
|       <label class="text-sm text-black dark:text-white" for="search-camera-model">Model</label> |  | ||||||
|       <Combobox |       <Combobox | ||||||
|         id="search-camera-model" |         id="camera-model" | ||||||
|         options={toComboBoxOptions(models)} |         label="Model" | ||||||
|         selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined} |  | ||||||
|         on:select={({ detail }) => (filters.model = detail?.value)} |         on:select={({ detail }) => (filters.model = detail?.value)} | ||||||
|  |         options={toComboBoxOptions(models)} | ||||||
|         placeholder="Search camera model..." |         placeholder="Search camera model..." | ||||||
|  |         selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined} | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|  | |||||||
| @ -62,35 +62,35 @@ | |||||||
| 
 | 
 | ||||||
|   <div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1"> |   <div class="grid grid-cols-[repeat(auto-fit,minmax(10rem,1fr))] gap-5 mt-1"> | ||||||
|     <div class="w-full"> |     <div class="w-full"> | ||||||
|       <label class="text-sm text-black dark:text-white" for="search-place-country">Country</label> |  | ||||||
|       <Combobox |       <Combobox | ||||||
|         id="search-place-country" |         id="location-country" | ||||||
|         options={toComboBoxOptions(countries)} |         label="Country" | ||||||
|         selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined} |  | ||||||
|         on:select={({ detail }) => (filters.country = detail?.value)} |         on:select={({ detail }) => (filters.country = detail?.value)} | ||||||
|  |         options={toComboBoxOptions(countries)} | ||||||
|         placeholder="Search country..." |         placeholder="Search country..." | ||||||
|  |         selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined} | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <div class="w-full"> |     <div class="w-full"> | ||||||
|       <label class="text-sm text-black dark:text-white" for="search-place-state">State</label> |  | ||||||
|       <Combobox |       <Combobox | ||||||
|         id="search-place-state" |         id="location-state" | ||||||
|         options={toComboBoxOptions(states)} |         label="State" | ||||||
|         selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined} |  | ||||||
|         on:select={({ detail }) => (filters.state = detail?.value)} |         on:select={({ detail }) => (filters.state = detail?.value)} | ||||||
|  |         options={toComboBoxOptions(states)} | ||||||
|         placeholder="Search state..." |         placeholder="Search state..." | ||||||
|  |         selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined} | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|     <div class="w-full"> |     <div class="w-full"> | ||||||
|       <label class="text-sm text-black dark:text-white" for="search-place-city">City</label> |  | ||||||
|       <Combobox |       <Combobox | ||||||
|         id="search-place-city" |         id="location-city" | ||||||
|         options={toComboBoxOptions(cities)} |         label="City" | ||||||
|         selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined} |  | ||||||
|         on:select={({ detail }) => (filters.city = detail?.value)} |         on:select={({ detail }) => (filters.city = detail?.value)} | ||||||
|  |         options={toComboBoxOptions(cities)} | ||||||
|         placeholder="Search city..." |         placeholder="Search city..." | ||||||
|  |         selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined} | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ | |||||||
|   import { fly } from 'svelte/transition'; |   import { fly } from 'svelte/transition'; | ||||||
|   import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; |   import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; | ||||||
| 
 | 
 | ||||||
|  |   export let id: string; | ||||||
|   export let title: string; |   export let title: string; | ||||||
|   export let comboboxPlaceholder: string; |   export let comboboxPlaceholder: string; | ||||||
|   export let subtitle = ''; |   export let subtitle = ''; | ||||||
| @ -32,6 +33,9 @@ | |||||||
|   </div> |   </div> | ||||||
|   <div class="flex items-center"> |   <div class="flex items-center"> | ||||||
|     <Combobox |     <Combobox | ||||||
|  |       {id} | ||||||
|  |       label={title} | ||||||
|  |       hideLabel={true} | ||||||
|       {selectedOption} |       {selectedOption} | ||||||
|       {options} |       {options} | ||||||
|       placeholder={comboboxPlaceholder} |       placeholder={comboboxPlaceholder} | ||||||
|  | |||||||
| @ -86,6 +86,7 @@ | |||||||
|       {#if $locale !== undefined} |       {#if $locale !== undefined} | ||||||
|         <div class="ml-4"> |         <div class="ml-4"> | ||||||
|           <SettingCombobox |           <SettingCombobox | ||||||
|  |             id="custom-locale" | ||||||
|             comboboxPlaceholder="Searching locales..." |             comboboxPlaceholder="Searching locales..." | ||||||
|             {selectedOption} |             {selectedOption} | ||||||
|             options={getAllLanguages()} |             options={getAllLanguages()} | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user