mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:49:11 -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 { deleteUser, type UserResponseDto } from '@immich/sdk'; | ||||||
|   import { serverConfig } from '$lib/stores/server-config.store'; |   import { serverConfig } from '$lib/stores/server-config.store'; | ||||||
|   import { createEventDispatcher } from 'svelte'; |   import { createEventDispatcher } from 'svelte'; | ||||||
|  |   import Checkbox from '$lib/components/elements/checkbox.svelte'; | ||||||
| 
 | 
 | ||||||
|   export let user: UserResponseDto; |   export let user: UserResponseDto; | ||||||
| 
 | 
 | ||||||
| @ -63,14 +64,10 @@ | |||||||
|       {/if} |       {/if} | ||||||
| 
 | 
 | ||||||
|       <div class="flex justify-center m-4 gap-2"> |       <div class="flex justify-center m-4 gap-2"> | ||||||
|         <label class="text-sm dark:text-immich-dark-fg" for="forceDelete"> |         <Checkbox | ||||||
|           Queue user and assets for immediate deletion |           id="queue-user-deletion-checkbox" | ||||||
|         </label> |           label="Queue user and assets for immediate deletion" | ||||||
| 
 |           labelClass="text-sm dark:text-immich-dark-fg" | ||||||
|         <input |  | ||||||
|           id="forceDelete" |  | ||||||
|           type="checkbox" |  | ||||||
|           class="form-checkbox h-5 w-5" |  | ||||||
|           bind:checked={forceDelete} |           bind:checked={forceDelete} | ||||||
|           on:change={() => { |           on:change={() => { | ||||||
|             deleteButtonDisabled = forceDelete; |             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 { createEventDispatcher } from 'svelte'; | ||||||
|   import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte'; |   import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte'; | ||||||
|   import { showDeleteModal } from '$lib/stores/preferences.store'; |   import { showDeleteModal } from '$lib/stores/preferences.store'; | ||||||
|  |   import Checkbox from '$lib/components/elements/checkbox.svelte'; | ||||||
| 
 | 
 | ||||||
|   export let size: number; |   export let size: number; | ||||||
| 
 | 
 | ||||||
| @ -12,10 +13,6 @@ | |||||||
|     cancel: void; |     cancel: void; | ||||||
|   }>(); |   }>(); | ||||||
| 
 | 
 | ||||||
|   const onToggle = () => { |  | ||||||
|     checked = !checked; |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   const handleConfirm = () => { |   const handleConfirm = () => { | ||||||
|     if (checked) { |     if (checked) { | ||||||
|       $showDeleteModal = false; |       $showDeleteModal = false; | ||||||
| @ -42,16 +39,8 @@ | |||||||
|     </p> |     </p> | ||||||
|     <p><b>You cannot undo this action!</b></p> |     <p><b>You cannot undo this action!</b></p> | ||||||
| 
 | 
 | ||||||
|     <div class="flex gap-2 items-center justify-center pt-4"> |     <div class="pt-4 flex justify-center items-center"> | ||||||
|       <label id="confirm-label" for="confirm-input">Do not show this message again</label> |       <Checkbox id="confirm-deletion-input" label="Do not show this message again" bind:checked /> | ||||||
|       <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> |     </div> | ||||||
|   </svelte:fragment> |   </svelte:fragment> | ||||||
| </ConfirmDialogue> | </ConfirmDialogue> | ||||||
|  | |||||||
| @ -7,26 +7,18 @@ | |||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|  |   import Checkbox from '$lib/components/elements/checkbox.svelte'; | ||||||
|  | 
 | ||||||
|   export let filters: SearchDisplayFilters; |   export let filters: SearchDisplayFilters; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div id="display-options-selection" class="text-sm"> | <div id="display-options-selection"> | ||||||
|   <p class="immich-form-label">DISPLAY OPTIONS</p> |   <fieldset> | ||||||
| 
 |     <legend class="immich-form-label">DISPLAY OPTIONS</legend> | ||||||
|   <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1"> |     <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1"> | ||||||
|     <label class="flex items-center gap-2"> |       <Checkbox id="not-in-album-checkbox" label="Not in any album" bind:checked={filters.isNotInAlbum} /> | ||||||
|       <input type="checkbox" class="size-5 flex-shrink-0" bind:checked={filters.isNotInAlbum} /> |       <Checkbox id="archive-checkbox" label="Archive" bind:checked={filters.isArchive} /> | ||||||
|       <span class="pt-1">Not in any album</span> |       <Checkbox id="favorite-checkbox" label="Favorite" bind:checked={filters.isFavorite} /> | ||||||
|     </label> |     </div> | ||||||
| 
 |   </fieldset> | ||||||
|     <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> | </div> | ||||||
|  | |||||||
| @ -124,7 +124,7 @@ | |||||||
|     on:submit|preventDefault={search} |     on:submit|preventDefault={search} | ||||||
|     on:reset|preventDefault={resetForm} |     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 --> |       <!-- PEOPLE --> | ||||||
|       <SearchPeopleSection width={filterBoxWidth} bind:selectedPeople={filter.personIds} /> |       <SearchPeopleSection width={filterBoxWidth} bind:selectedPeople={filter.personIds} /> | ||||||
| 
 | 
 | ||||||
| @ -153,8 +153,8 @@ | |||||||
|       id="button-row" |       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" |       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="reset" color="gray">Clear all</Button> | ||||||
|       <Button type="submit">SEARCH</Button> |       <Button type="submit">Search</Button> | ||||||
|     </div> |     </div> | ||||||
|   </form> |   </form> | ||||||
| </div> | </div> | ||||||
|  | |||||||
| @ -1,47 +1,17 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|  |   import RadioButton from '$lib/components/elements/radio-button.svelte'; | ||||||
|   import { MediaType } from './search-filter-box.svelte'; |   import { MediaType } from './search-filter-box.svelte'; | ||||||
| 
 | 
 | ||||||
|   export let filteredMedia: MediaType; |   export let filteredMedia: MediaType; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div id="media-type-selection"> | <div id="media-type-selection"> | ||||||
|   <p class="immich-form-label">MEDIA TYPE</p> |   <fieldset> | ||||||
| 
 |     <legend class="immich-form-label">MEDIA TYPE</legend> | ||||||
|   <div class="flex gap-5 mt-1 text-base"> |     <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1"> | ||||||
|     <label for="type-all" class="flex items-center gap-1"> |       <RadioButton name="media-type" id="type-all" bind:group={filteredMedia} label="All" value={MediaType.All} /> | ||||||
|       <input |       <RadioButton name="media-type" id="type-image" bind:group={filteredMedia} label="Image" value={MediaType.Image} /> | ||||||
|         bind:group={filteredMedia} |       <RadioButton name="media-type" id="type-video" bind:group={filteredMedia} label="Video" value={MediaType.Video} /> | ||||||
|         value={MediaType.All} |     </div> | ||||||
|         type="radio" |   </fieldset> | ||||||
|         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> |  | ||||||
| </div> | </div> | ||||||
|  | |||||||
| @ -91,10 +91,10 @@ | |||||||
|             on:click={() => (showAllPeople = !showAllPeople)} |             on:click={() => (showAllPeople = !showAllPeople)} | ||||||
|           > |           > | ||||||
|             {#if showAllPeople} |             {#if showAllPeople} | ||||||
|               <span><Icon path={mdiClose} /></span> |               <span><Icon path={mdiClose} ariaHidden /></span> | ||||||
|               Collapse |               Collapse | ||||||
|             {:else} |             {:else} | ||||||
|               <span><Icon path={mdiArrowRight} /></span> |               <span><Icon path={mdiArrowRight} ariaHidden /></span> | ||||||
|               See all people |               See all people | ||||||
|             {/if} |             {/if} | ||||||
|           </Button> |           </Button> | ||||||
|  | |||||||
| @ -1,4 +1,6 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|  |   import RadioButton from '$lib/components/elements/radio-button.svelte'; | ||||||
|  | 
 | ||||||
|   export let filename: string | undefined; |   export let filename: string | undefined; | ||||||
|   export let context: string | undefined; |   export let context: string | undefined; | ||||||
| 
 | 
 | ||||||
| @ -18,40 +20,45 @@ | |||||||
|   } |   } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div class="flex gap-5"> | <fieldset> | ||||||
|   <label class="immich-form-label" for="context"> |   <legend class="immich-form-label">Search type</legend> | ||||||
|     <input type="radio" name="context" id="context" bind:group={selectedOption} value={TextSearchOptions.Context} /> |   <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1 mb-2"> | ||||||
|     <span>CONTEXT</span> |     <RadioButton | ||||||
|   </label> |       name="query-type" | ||||||
| 
 |       id="context-radio" | ||||||
|   <label class="immich-form-label" for="file-name"> |  | ||||||
|     <input |  | ||||||
|       type="radio" |  | ||||||
|       name="file-name" |  | ||||||
|       id="file-name" |  | ||||||
|       bind:group={selectedOption} |       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} |       value={TextSearchOptions.Filename} | ||||||
|     /> |     /> | ||||||
|     <span>FILE NAME</span> |   </div> | ||||||
|   </label> | </fieldset> | ||||||
| </div> |  | ||||||
| 
 | 
 | ||||||
| {#if selectedOption === TextSearchOptions.Context} | {#if selectedOption === TextSearchOptions.Context} | ||||||
|  |   <label for="context-input" class="immich-form-label">Search by context</label> | ||||||
|   <input |   <input | ||||||
|     class="immich-form-input hover:cursor-text w-full !mt-1" |     class="immich-form-input hover:cursor-text w-full !mt-1" | ||||||
|     type="text" |     type="text" | ||||||
|     id="context" |     id="context-input" | ||||||
|     name="context" |     name="context" | ||||||
|     placeholder="Sunrise on the beach" |     placeholder="Sunrise on the beach" | ||||||
|     bind:value={context} |     bind:value={context} | ||||||
|   /> |   /> | ||||||
| {:else} | {:else} | ||||||
|  |   <label for="file-name-input" class="immich-form-label">Search by file name or extension</label> | ||||||
|   <input |   <input | ||||||
|     class="immich-form-input hover:cursor-text w-full !mt-1" |     class="immich-form-input hover:cursor-text w-full !mt-1" | ||||||
|     type="text" |     type="text" | ||||||
|     id="file-name" |     id="file-name-input" | ||||||
|     name="file-name" |     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} |     bind:value={filename} | ||||||
|  |     aria-labelledby="file-name-label" | ||||||
|   /> |   /> | ||||||
| {/if} | {/if} | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
|  |   import Checkbox from '$lib/components/elements/checkbox.svelte'; | ||||||
|   import { quintOut } from 'svelte/easing'; |   import { quintOut } from 'svelte/easing'; | ||||||
|   import { fly } from 'svelte/transition'; |   import { fly } from 'svelte/transition'; | ||||||
| 
 | 
 | ||||||
| @ -34,17 +35,16 @@ | |||||||
|       {desc} |       {desc} | ||||||
|     </p> |     </p> | ||||||
|   {/if} |   {/if} | ||||||
| 
 |   <div class="flex flex-col gap-2"> | ||||||
|   {#each options as option} |     {#each options as option} | ||||||
|     <label class="flex items-center mb-2"> |       <Checkbox | ||||||
|       <input |         id="{option.value}-checkbox" | ||||||
|         type="checkbox" |         label={option.text} | ||||||
|         class="form-checkbox h-5 w-5 color" |  | ||||||
|         {disabled} |  | ||||||
|         checked={value.includes(option.value)} |         checked={value.includes(option.value)} | ||||||
|  |         {disabled} | ||||||
|  |         labelClass="text-gray-500 dark:text-gray-300" | ||||||
|         on:change={() => handleCheckboxChange(option.value)} |         on:change={() => handleCheckboxChange(option.value)} | ||||||
|       /> |       /> | ||||||
|       <span class="ml-2 text-sm text-gray-500 dark:text-gray-300 pt-1">{option.text}</span> |     {/each} | ||||||
|     </label> |   </div> | ||||||
|   {/each} |  | ||||||
| </div> | </div> | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user