mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 18:35:00 -04:00 
			
		
		
		
	feat(web,a11y): form and search filter accessibility (#9087)
* feat(web,a11y): search filter accessibility - visible focus rings - labels for text search - responsive buttons / radio buttons / checkboxes - buttons to lowercase - add fieldsets to radio buttons and checkboxes, so the screen reader announces the label for the group * feat: extract inputs into reusable components, replace all checkboxes * chore: revert changes to responsive buttons --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		
							parent
							
								
									00d186ec52
								
							
						
					
					
						commit
						53d571d29e
					
				| @ -4,6 +4,7 @@ | ||||
|   import { deleteUser, type UserResponseDto } from '@immich/sdk'; | ||||
|   import { serverConfig } from '$lib/stores/server-config.store'; | ||||
|   import { createEventDispatcher } from 'svelte'; | ||||
|   import Checkbox from '$lib/components/elements/checkbox.svelte'; | ||||
| 
 | ||||
|   export let user: UserResponseDto; | ||||
| 
 | ||||
| @ -63,14 +64,10 @@ | ||||
|       {/if} | ||||
| 
 | ||||
|       <div class="flex justify-center m-4 gap-2"> | ||||
|         <label class="text-sm dark:text-immich-dark-fg" for="forceDelete"> | ||||
|           Queue user and assets for immediate deletion | ||||
|         </label> | ||||
| 
 | ||||
|         <input | ||||
|           id="forceDelete" | ||||
|           type="checkbox" | ||||
|           class="form-checkbox h-5 w-5" | ||||
|         <Checkbox | ||||
|           id="queue-user-deletion-checkbox" | ||||
|           label="Queue user and assets for immediate deletion" | ||||
|           labelClass="text-sm dark:text-immich-dark-fg" | ||||
|           bind:checked={forceDelete} | ||||
|           on:change={() => { | ||||
|             deleteButtonDisabled = forceDelete; | ||||
|  | ||||
							
								
								
									
										23
									
								
								web/src/lib/components/elements/checkbox.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								web/src/lib/components/elements/checkbox.svelte
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| <script lang="ts"> | ||||
|   export let id: string; | ||||
|   export let label: string; | ||||
|   export let checked: boolean | undefined = undefined; | ||||
|   export let disabled: boolean = false; | ||||
|   export let labelClass: string | undefined = undefined; | ||||
|   export let name: string | undefined = undefined; | ||||
|   export let value: string | undefined = undefined; | ||||
| </script> | ||||
| 
 | ||||
| <div class="flex items-center space-x-2"> | ||||
|   <input | ||||
|     type="checkbox" | ||||
|     {name} | ||||
|     {id} | ||||
|     {value} | ||||
|     {disabled} | ||||
|     class="size-5 flex-shrink-0 focus-visible:ring" | ||||
|     bind:checked | ||||
|     on:change | ||||
|   /> | ||||
|   <label class={labelClass} for={id}>{label}</label> | ||||
| </div> | ||||
							
								
								
									
										12
									
								
								web/src/lib/components/elements/radio-button.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								web/src/lib/components/elements/radio-button.svelte
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | ||||
| <script lang="ts"> | ||||
|   export let id: string; | ||||
|   export let label: string; | ||||
|   export let name: string; | ||||
|   export let value: string; | ||||
|   export let group: string | undefined = undefined; | ||||
| </script> | ||||
| 
 | ||||
| <div class="flex items-center space-x-2"> | ||||
|   <input type="radio" {name} {id} {value} class="focus-visible:ring" bind:group /> | ||||
|   <label for={id}>{label}</label> | ||||
| </div> | ||||
| @ -2,6 +2,7 @@ | ||||
|   import { createEventDispatcher } from 'svelte'; | ||||
|   import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte'; | ||||
|   import { showDeleteModal } from '$lib/stores/preferences.store'; | ||||
|   import Checkbox from '$lib/components/elements/checkbox.svelte'; | ||||
| 
 | ||||
|   export let size: number; | ||||
| 
 | ||||
| @ -12,10 +13,6 @@ | ||||
|     cancel: void; | ||||
|   }>(); | ||||
| 
 | ||||
|   const onToggle = () => { | ||||
|     checked = !checked; | ||||
|   }; | ||||
| 
 | ||||
|   const handleConfirm = () => { | ||||
|     if (checked) { | ||||
|       $showDeleteModal = false; | ||||
| @ -42,16 +39,8 @@ | ||||
|     </p> | ||||
|     <p><b>You cannot undo this action!</b></p> | ||||
| 
 | ||||
|     <div class="flex gap-2 items-center justify-center pt-4"> | ||||
|       <label id="confirm-label" for="confirm-input">Do not show this message again</label> | ||||
|       <input | ||||
|         id="confirm-input" | ||||
|         aria-labelledby="confirm-input" | ||||
|         class="disabled::cursor-not-allowed h-3 w-3 opacity-1" | ||||
|         type="checkbox" | ||||
|         bind:checked | ||||
|         on:click={onToggle} | ||||
|       /> | ||||
|     <div class="pt-4 flex justify-center items-center"> | ||||
|       <Checkbox id="confirm-deletion-input" label="Do not show this message again" bind:checked /> | ||||
|     </div> | ||||
|   </svelte:fragment> | ||||
| </ConfirmDialogue> | ||||
|  | ||||
| @ -7,26 +7,18 @@ | ||||
| </script> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
|   import Checkbox from '$lib/components/elements/checkbox.svelte'; | ||||
| 
 | ||||
|   export let filters: SearchDisplayFilters; | ||||
| </script> | ||||
| 
 | ||||
| <div id="display-options-selection" class="text-sm"> | ||||
|   <p class="immich-form-label">DISPLAY OPTIONS</p> | ||||
| 
 | ||||
|   <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1"> | ||||
|     <label class="flex items-center gap-2"> | ||||
|       <input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filters.isNotInAlbum} /> | ||||
|       <span class="pt-1">Not in any album</span> | ||||
|     </label> | ||||
| 
 | ||||
|     <label class="flex items-center gap-2"> | ||||
|       <input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filters.isArchive} /> | ||||
|       <span class="pt-1">Archive</span> | ||||
|     </label> | ||||
| 
 | ||||
|     <label class="flex items-center gap-2"> | ||||
|       <input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filters.isFavorite} /> | ||||
|       <span class="pt-1">Favorite</span> | ||||
|     </label> | ||||
|   </div> | ||||
| <div id="display-options-selection"> | ||||
|   <fieldset> | ||||
|     <legend class="immich-form-label">DISPLAY OPTIONS</legend> | ||||
|     <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1"> | ||||
|       <Checkbox id="not-in-album-checkbox" label="Not in any album" bind:checked={filters.isNotInAlbum} /> | ||||
|       <Checkbox id="archive-checkbox" label="Archive" bind:checked={filters.isArchive} /> | ||||
|       <Checkbox id="favorite-checkbox" label="Favorite" bind:checked={filters.isFavorite} /> | ||||
|     </div> | ||||
|   </fieldset> | ||||
| </div> | ||||
|  | ||||
| @ -124,7 +124,7 @@ | ||||
|     on:submit|preventDefault={search} | ||||
|     on:reset|preventDefault={resetForm} | ||||
|   > | ||||
|     <div class="px-4 sm:px-6 py-4 space-y-10 max-h-[calc(100dvh-12rem)] overflow-y-auto immich-scrollbar"> | ||||
|     <div class="px-4 sm:px-6 py-4 space-y-10 max-h-[calc(100dvh-12rem)] overflow-y-auto immich-scrollbar" tabindex="-1"> | ||||
|       <!-- PEOPLE --> | ||||
|       <SearchPeopleSection width={filterBoxWidth} bind:selectedPeople={filter.personIds} /> | ||||
| 
 | ||||
| @ -153,8 +153,8 @@ | ||||
|       id="button-row" | ||||
|       class="flex justify-end gap-4 border-t dark:border-gray-800 dark:bg-immich-dark-gray px-4 sm:py-6 py-4 mt-2 rounded-b-3xl" | ||||
|     > | ||||
|       <Button type="reset" color="gray">CLEAR ALL</Button> | ||||
|       <Button type="submit">SEARCH</Button> | ||||
|       <Button type="reset" color="gray">Clear all</Button> | ||||
|       <Button type="submit">Search</Button> | ||||
|     </div> | ||||
|   </form> | ||||
| </div> | ||||
|  | ||||
| @ -1,47 +1,17 @@ | ||||
| <script lang="ts"> | ||||
|   import RadioButton from '$lib/components/elements/radio-button.svelte'; | ||||
|   import { MediaType } from './search-filter-box.svelte'; | ||||
| 
 | ||||
|   export let filteredMedia: MediaType; | ||||
| </script> | ||||
| 
 | ||||
| <div id="media-type-selection"> | ||||
|   <p class="immich-form-label">MEDIA TYPE</p> | ||||
| 
 | ||||
|   <div class="flex gap-5 mt-1 text-base"> | ||||
|     <label for="type-all" class="flex items-center gap-1"> | ||||
|       <input | ||||
|         bind:group={filteredMedia} | ||||
|         value={MediaType.All} | ||||
|         type="radio" | ||||
|         name="radio-type" | ||||
|         id="type-all" | ||||
|         class="size-4" | ||||
|       /> | ||||
|       <span class="pt-0.5">All</span> | ||||
|     </label> | ||||
| 
 | ||||
|     <label for="type-image" class="flex items-center gap-1"> | ||||
|       <input | ||||
|         bind:group={filteredMedia} | ||||
|         value={MediaType.Image} | ||||
|         type="radio" | ||||
|         name="media-type" | ||||
|         id="type-image" | ||||
|         class="size-4" | ||||
|       /> | ||||
|       <span class="pt-0.5">Image</span> | ||||
|     </label> | ||||
| 
 | ||||
|     <label for="type-video" class="flex items-center gap-1"> | ||||
|       <input | ||||
|         bind:group={filteredMedia} | ||||
|         value={MediaType.Video} | ||||
|         type="radio" | ||||
|         name="radio-type" | ||||
|         id="type-video" | ||||
|         class="size-4" | ||||
|       /> | ||||
|       <span class="pt-0.5">Video</span> | ||||
|     </label> | ||||
|   </div> | ||||
|   <fieldset> | ||||
|     <legend class="immich-form-label">MEDIA TYPE</legend> | ||||
|     <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1"> | ||||
|       <RadioButton name="media-type" id="type-all" bind:group={filteredMedia} label="All" value={MediaType.All} /> | ||||
|       <RadioButton name="media-type" id="type-image" bind:group={filteredMedia} label="Image" value={MediaType.Image} /> | ||||
|       <RadioButton name="media-type" id="type-video" bind:group={filteredMedia} label="Video" value={MediaType.Video} /> | ||||
|     </div> | ||||
|   </fieldset> | ||||
| </div> | ||||
|  | ||||
| @ -91,10 +91,10 @@ | ||||
|             on:click={() => (showAllPeople = !showAllPeople)} | ||||
|           > | ||||
|             {#if showAllPeople} | ||||
|               <span><Icon path={mdiClose} /></span> | ||||
|               <span><Icon path={mdiClose} ariaHidden /></span> | ||||
|               Collapse | ||||
|             {:else} | ||||
|               <span><Icon path={mdiArrowRight} /></span> | ||||
|               <span><Icon path={mdiArrowRight} ariaHidden /></span> | ||||
|               See all people | ||||
|             {/if} | ||||
|           </Button> | ||||
|  | ||||
| @ -1,4 +1,6 @@ | ||||
| <script lang="ts"> | ||||
|   import RadioButton from '$lib/components/elements/radio-button.svelte'; | ||||
| 
 | ||||
|   export let filename: string | undefined; | ||||
|   export let context: string | undefined; | ||||
| 
 | ||||
| @ -18,40 +20,45 @@ | ||||
|   } | ||||
| </script> | ||||
| 
 | ||||
| <div class="flex gap-5"> | ||||
|   <label class="immich-form-label" for="context"> | ||||
|     <input type="radio" name="context" id="context" bind:group={selectedOption} value={TextSearchOptions.Context} /> | ||||
|     <span>CONTEXT</span> | ||||
|   </label> | ||||
| 
 | ||||
|   <label class="immich-form-label" for="file-name"> | ||||
|     <input | ||||
|       type="radio" | ||||
|       name="file-name" | ||||
|       id="file-name" | ||||
| <fieldset> | ||||
|   <legend class="immich-form-label">Search type</legend> | ||||
|   <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1 mb-2"> | ||||
|     <RadioButton | ||||
|       name="query-type" | ||||
|       id="context-radio" | ||||
|       bind:group={selectedOption} | ||||
|       label="Context" | ||||
|       value={TextSearchOptions.Context} | ||||
|     /> | ||||
|     <RadioButton | ||||
|       name="query-type" | ||||
|       id="file-name-radio" | ||||
|       bind:group={selectedOption} | ||||
|       label="File name or extension" | ||||
|       value={TextSearchOptions.Filename} | ||||
|     /> | ||||
|     <span>FILE NAME</span> | ||||
|   </label> | ||||
| </div> | ||||
|   </div> | ||||
| </fieldset> | ||||
| 
 | ||||
| {#if selectedOption === TextSearchOptions.Context} | ||||
|   <label for="context-input" class="immich-form-label">Search by context</label> | ||||
|   <input | ||||
|     class="immich-form-input hover:cursor-text w-full !mt-1" | ||||
|     type="text" | ||||
|     id="context" | ||||
|     id="context-input" | ||||
|     name="context" | ||||
|     placeholder="Sunrise on the beach" | ||||
|     bind:value={context} | ||||
|   /> | ||||
| {:else} | ||||
|   <label for="file-name-input" class="immich-form-label">Search by file name or extension</label> | ||||
|   <input | ||||
|     class="immich-form-input hover:cursor-text w-full !mt-1" | ||||
|     type="text" | ||||
|     id="file-name" | ||||
|     id="file-name-input" | ||||
|     name="file-name" | ||||
|     placeholder="File name or extension i.e. IMG_1234.JPG or PNG" | ||||
|     placeholder="i.e. IMG_1234.JPG or PNG" | ||||
|     bind:value={filename} | ||||
|     aria-labelledby="file-name-label" | ||||
|   /> | ||||
| {/if} | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| <script lang="ts"> | ||||
|   import Checkbox from '$lib/components/elements/checkbox.svelte'; | ||||
|   import { quintOut } from 'svelte/easing'; | ||||
|   import { fly } from 'svelte/transition'; | ||||
| 
 | ||||
| @ -34,17 +35,16 @@ | ||||
|       {desc} | ||||
|     </p> | ||||
|   {/if} | ||||
| 
 | ||||
|   {#each options as option} | ||||
|     <label class="flex items-center mb-2"> | ||||
|       <input | ||||
|         type="checkbox" | ||||
|         class="form-checkbox h-5 w-5 color" | ||||
|         {disabled} | ||||
|   <div class="flex flex-col gap-2"> | ||||
|     {#each options as option} | ||||
|       <Checkbox | ||||
|         id="{option.value}-checkbox" | ||||
|         label={option.text} | ||||
|         checked={value.includes(option.value)} | ||||
|         {disabled} | ||||
|         labelClass="text-gray-500 dark:text-gray-300" | ||||
|         on:change={() => handleCheckboxChange(option.value)} | ||||
|       /> | ||||
|       <span class="ml-2 text-sm text-gray-500 dark:text-gray-300 pt-1">{option.text}</span> | ||||
|     </label> | ||||
|   {/each} | ||||
|     {/each} | ||||
|   </div> | ||||
| </div> | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user