mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	fix(web): rating stars accessibility (#11966)
* fix(web): exif ratings accessibility * chore: add tests * fix: eslint errors * fix: clean up issues from changes in use:focusOutside
This commit is contained in:
		
							parent
							
								
									7fbf50a75e
								
							
						
					
					
						commit
						c14e2914f8
					
				@ -6,7 +6,10 @@ export function focusOutside(node: HTMLElement, options: Options = {}) {
 | 
				
			|||||||
  const { onFocusOut } = options;
 | 
					  const { onFocusOut } = options;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleFocusOut = (event: FocusEvent) => {
 | 
					  const handleFocusOut = (event: FocusEvent) => {
 | 
				
			||||||
    if (onFocusOut && event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)) {
 | 
					    if (
 | 
				
			||||||
 | 
					      onFocusOut &&
 | 
				
			||||||
 | 
					      (!event.relatedTarget || (event.relatedTarget instanceof Node && !node.contains(event.relatedTarget as Node)))
 | 
				
			||||||
 | 
					    ) {
 | 
				
			||||||
      onFocusOut(event);
 | 
					      onFocusOut(event);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
				
			|||||||
@ -21,7 +21,7 @@
 | 
				
			|||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{#if !isSharedLink() && $preferences?.rating?.enabled}
 | 
					{#if !isSharedLink() && $preferences?.rating?.enabled}
 | 
				
			||||||
  <section class="relative flex px-4 pt-2">
 | 
					  <section class="px-4 pt-2">
 | 
				
			||||||
    <StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
 | 
					    <StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
 | 
				
			||||||
  </section>
 | 
					  </section>
 | 
				
			||||||
{/if}
 | 
					{/if}
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,78 @@
 | 
				
			|||||||
 | 
					import StarRating from '$lib/components/shared-components/star-rating.svelte';
 | 
				
			||||||
 | 
					import { render } from '@testing-library/svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					describe('StarRating component', () => {
 | 
				
			||||||
 | 
					  it('renders correctly', () => {
 | 
				
			||||||
 | 
					    const component = render(StarRating, {
 | 
				
			||||||
 | 
					      count: 3,
 | 
				
			||||||
 | 
					      rating: 2,
 | 
				
			||||||
 | 
					      readOnly: false,
 | 
				
			||||||
 | 
					      onRating: vi.fn(),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    const container = component.getByTestId('star-container') as HTMLImageElement;
 | 
				
			||||||
 | 
					    expect(container.className).toBe('flex flex-row');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const radioButtons = component.getAllByRole('radio') as HTMLInputElement[];
 | 
				
			||||||
 | 
					    expect(radioButtons.length).toBe(3);
 | 
				
			||||||
 | 
					    const labels = component.getAllByTestId('star') as HTMLLabelElement[];
 | 
				
			||||||
 | 
					    expect(labels.length).toBe(3);
 | 
				
			||||||
 | 
					    const labelText = component.getAllByText('rating_count') as HTMLSpanElement[];
 | 
				
			||||||
 | 
					    expect(labelText.length).toBe(3);
 | 
				
			||||||
 | 
					    const clearButton = component.getByRole('button') as HTMLButtonElement;
 | 
				
			||||||
 | 
					    expect(clearButton).toBeInTheDocument();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check the clear button content
 | 
				
			||||||
 | 
					    expect(clearButton.textContent).toBe('rating_clear');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check the initial state
 | 
				
			||||||
 | 
					    expect(radioButtons[0].checked).toBe(false);
 | 
				
			||||||
 | 
					    expect(radioButtons[1].checked).toBe(true);
 | 
				
			||||||
 | 
					    expect(radioButtons[2].checked).toBe(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check the radio button attributes
 | 
				
			||||||
 | 
					    for (const [index, radioButton] of radioButtons.entries()) {
 | 
				
			||||||
 | 
					      expect(radioButton.id).toBe(labels[index].htmlFor);
 | 
				
			||||||
 | 
					      expect(radioButton.name).toBe('stars');
 | 
				
			||||||
 | 
					      expect(radioButton.value).toBe((index + 1).toString());
 | 
				
			||||||
 | 
					      expect(radioButton.disabled).toBe(false);
 | 
				
			||||||
 | 
					      expect(radioButton.className).toBe('sr-only');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check the label attributes
 | 
				
			||||||
 | 
					    for (const label of labels) {
 | 
				
			||||||
 | 
					      expect(label.className).toBe('cursor-pointer');
 | 
				
			||||||
 | 
					      expect(label.tabIndex).toBe(-1);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it('renders correctly with readOnly', () => {
 | 
				
			||||||
 | 
					    const component = render(StarRating, {
 | 
				
			||||||
 | 
					      count: 3,
 | 
				
			||||||
 | 
					      rating: 2,
 | 
				
			||||||
 | 
					      readOnly: true,
 | 
				
			||||||
 | 
					      onRating: vi.fn(),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					    const radioButtons = component.getAllByRole('radio') as HTMLInputElement[];
 | 
				
			||||||
 | 
					    expect(radioButtons.length).toBe(3);
 | 
				
			||||||
 | 
					    const labels = component.getAllByTestId('star') as HTMLLabelElement[];
 | 
				
			||||||
 | 
					    expect(labels.length).toBe(3);
 | 
				
			||||||
 | 
					    const clearButton = component.queryByRole('button');
 | 
				
			||||||
 | 
					    expect(clearButton).toBeNull();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check the initial state
 | 
				
			||||||
 | 
					    expect(radioButtons[0].checked).toBe(false);
 | 
				
			||||||
 | 
					    expect(radioButtons[1].checked).toBe(true);
 | 
				
			||||||
 | 
					    expect(radioButtons[2].checked).toBe(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check the radio button attributes
 | 
				
			||||||
 | 
					    for (const [index, radioButton] of radioButtons.entries()) {
 | 
				
			||||||
 | 
					      expect(radioButton.id).toBe(labels[index].htmlFor);
 | 
				
			||||||
 | 
					      expect(radioButton.disabled).toBe(true);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check the label attributes
 | 
				
			||||||
 | 
					    for (const label of labels) {
 | 
				
			||||||
 | 
					      expect(label.className).toBe('');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
@ -23,7 +23,6 @@
 | 
				
			|||||||
  import { createEventDispatcher, tick } from 'svelte';
 | 
					  import { createEventDispatcher, tick } from 'svelte';
 | 
				
			||||||
  import type { FormEventHandler } from 'svelte/elements';
 | 
					  import type { FormEventHandler } from 'svelte/elements';
 | 
				
			||||||
  import { shortcuts } from '$lib/actions/shortcut';
 | 
					  import { shortcuts } from '$lib/actions/shortcut';
 | 
				
			||||||
  import { clickOutside } from '$lib/actions/click-outside';
 | 
					 | 
				
			||||||
  import { focusOutside } from '$lib/actions/focus-outside';
 | 
					  import { focusOutside } from '$lib/actions/focus-outside';
 | 
				
			||||||
  import { generateId } from '$lib/utils/generate-id';
 | 
					  import { generateId } from '$lib/utils/generate-id';
 | 
				
			||||||
  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
					  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
				
			||||||
@ -124,7 +123,6 @@
 | 
				
			|||||||
<label class="immich-form-label" class:sr-only={hideLabel} for={inputId}>{label}</label>
 | 
					<label class="immich-form-label" class:sr-only={hideLabel} for={inputId}>{label}</label>
 | 
				
			||||||
<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:focusOutside={{ onFocusOut: deactivate }}
 | 
					  use:focusOutside={{ onFocusOut: deactivate }}
 | 
				
			||||||
  use:shortcuts={[
 | 
					  use:shortcuts={[
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,6 @@
 | 
				
			|||||||
  import { AppRoute } from '$lib/constants';
 | 
					  import { AppRoute } from '$lib/constants';
 | 
				
			||||||
  import { goto } from '$app/navigation';
 | 
					  import { goto } from '$app/navigation';
 | 
				
			||||||
  import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store';
 | 
					  import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store';
 | 
				
			||||||
  import { clickOutside } from '$lib/actions/click-outside';
 | 
					 | 
				
			||||||
  import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
 | 
					  import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
 | 
				
			||||||
  import SearchHistoryBox from './search-history-box.svelte';
 | 
					  import SearchHistoryBox from './search-history-box.svelte';
 | 
				
			||||||
  import SearchFilterBox from './search-filter-box.svelte';
 | 
					  import SearchFilterBox from './search-filter-box.svelte';
 | 
				
			||||||
@ -142,7 +141,7 @@
 | 
				
			|||||||
  ]}
 | 
					  ]}
 | 
				
			||||||
/>
 | 
					/>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="w-full relative" use:clickOutside={{ onOutclick: onFocusOut }} use:focusOutside={{ onFocusOut }}>
 | 
					<div class="w-full relative" use:focusOutside={{ onFocusOut }} tabindex="-1">
 | 
				
			||||||
  <form
 | 
					  <form
 | 
				
			||||||
    draggable="false"
 | 
					    draggable="false"
 | 
				
			||||||
    autocomplete="off"
 | 
					    autocomplete="off"
 | 
				
			||||||
@ -153,7 +152,7 @@
 | 
				
			|||||||
    on:focusin={onFocusIn}
 | 
					    on:focusin={onFocusIn}
 | 
				
			||||||
    role="search"
 | 
					    role="search"
 | 
				
			||||||
  >
 | 
					  >
 | 
				
			||||||
    <div use:focusOutside={{ onFocusOut: closeDropdown }}>
 | 
					    <div use:focusOutside={{ onFocusOut: closeDropdown }} tabindex="-1">
 | 
				
			||||||
      <label for="main-search-bar" class="sr-only">{$t('search_your_photos')}</label>
 | 
					      <label for="main-search-bar" class="sr-only">{$t('search_your_photos')}</label>
 | 
				
			||||||
      <input
 | 
					      <input
 | 
				
			||||||
        type="text"
 | 
					        type="text"
 | 
				
			||||||
 | 
				
			|||||||
@ -1,15 +1,25 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
					  import { focusOutside } from '$lib/actions/focus-outside';
 | 
				
			||||||
 | 
					  import { shortcuts } from '$lib/actions/shortcut';
 | 
				
			||||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
					  import Icon from '$lib/components/elements/icon.svelte';
 | 
				
			||||||
 | 
					  import { generateId } from '$lib/utils/generate-id';
 | 
				
			||||||
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let count = 5;
 | 
					  export let count = 5;
 | 
				
			||||||
  export let rating: number;
 | 
					  export let rating: number;
 | 
				
			||||||
  export let readOnly = false;
 | 
					  export let readOnly = false;
 | 
				
			||||||
  export let onRating: (rating: number) => void | undefined;
 | 
					  export let onRating: (rating: number) => void | undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let ratingSelection = 0;
 | 
				
			||||||
  let hoverRating = 0;
 | 
					  let hoverRating = 0;
 | 
				
			||||||
 | 
					  let focusRating = 0;
 | 
				
			||||||
 | 
					  let timeoutId: ReturnType<typeof setTimeout> | undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  $: ratingSelection = rating;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const starIcon =
 | 
					  const starIcon =
 | 
				
			||||||
    'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z';
 | 
					    'M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z';
 | 
				
			||||||
 | 
					  const id = generateId();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleSelect = (newRating: number) => {
 | 
					  const handleSelect = (newRating: number) => {
 | 
				
			||||||
    if (readOnly) {
 | 
					    if (readOnly) {
 | 
				
			||||||
@ -17,34 +27,93 @@
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (newRating === rating) {
 | 
					    if (newRating === rating) {
 | 
				
			||||||
      newRating = 0;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    rating = newRating;
 | 
					    onRating(newRating);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    onRating?.(rating);
 | 
					  const setHoverRating = (value: number) => {
 | 
				
			||||||
 | 
					    if (readOnly) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    hoverRating = value;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const reset = () => {
 | 
				
			||||||
 | 
					    setHoverRating(0);
 | 
				
			||||||
 | 
					    focusRating = 0;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleSelectDebounced = (value: number) => {
 | 
				
			||||||
 | 
					    clearTimeout(timeoutId);
 | 
				
			||||||
 | 
					    timeoutId = setTimeout(() => {
 | 
				
			||||||
 | 
					      handleSelect(value);
 | 
				
			||||||
 | 
					    }, 300);
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div role="presentation" tabindex="-1" on:mouseout={() => (hoverRating = 0)} on:blur|preventDefault>
 | 
					<!-- svelte-ignore a11y-mouse-events-have-key-events -->
 | 
				
			||||||
 | 
					<fieldset
 | 
				
			||||||
 | 
					  class="text-immich-primary dark:text-immich-dark-primary w-fit cursor-default"
 | 
				
			||||||
 | 
					  on:mouseleave={() => setHoverRating(0)}
 | 
				
			||||||
 | 
					  use:focusOutside={{ onFocusOut: reset }}
 | 
				
			||||||
 | 
					  use:shortcuts={[
 | 
				
			||||||
 | 
					    { shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
 | 
				
			||||||
 | 
					    { shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: (event) => event.stopPropagation() },
 | 
				
			||||||
 | 
					  ]}
 | 
				
			||||||
 | 
					>
 | 
				
			||||||
 | 
					  <legend class="sr-only">{$t('rating')}</legend>
 | 
				
			||||||
 | 
					  <div class="flex flex-row" data-testid="star-container">
 | 
				
			||||||
    {#each { length: count } as _, index}
 | 
					    {#each { length: count } as _, index}
 | 
				
			||||||
      {@const value = index + 1}
 | 
					      {@const value = index + 1}
 | 
				
			||||||
    {@const filled = hoverRating >= value || (hoverRating === 0 && rating >= value)}
 | 
					      {@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)}
 | 
				
			||||||
    <button
 | 
					      {@const starId = `${id}-${value}`}
 | 
				
			||||||
      type="button"
 | 
					      <!-- svelte-ignore a11y-mouse-events-have-key-events -->
 | 
				
			||||||
      on:click={() => handleSelect(value)}
 | 
					      <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
 | 
				
			||||||
      on:mouseover={() => (hoverRating = value)}
 | 
					      <label
 | 
				
			||||||
      on:focus|preventDefault={() => (hoverRating = value)}
 | 
					        for={starId}
 | 
				
			||||||
      class="shadow-0 outline-0 text-immich-primary dark:text-immich-dark-primary"
 | 
					        class:cursor-pointer={!readOnly}
 | 
				
			||||||
      disabled={readOnly}
 | 
					        class:ring-2={focusRating === value}
 | 
				
			||||||
 | 
					        on:mouseover={() => setHoverRating(value)}
 | 
				
			||||||
 | 
					        tabindex={-1}
 | 
				
			||||||
 | 
					        data-testid="star"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
 | 
					        <span class="sr-only">{$t('rating_count', { values: { count: value } })}</span>
 | 
				
			||||||
        <Icon
 | 
					        <Icon
 | 
				
			||||||
          path={starIcon}
 | 
					          path={starIcon}
 | 
				
			||||||
          size="1.5em"
 | 
					          size="1.5em"
 | 
				
			||||||
          strokeWidth={1}
 | 
					          strokeWidth={1}
 | 
				
			||||||
          color={filled ? 'currentcolor' : 'transparent'}
 | 
					          color={filled ? 'currentcolor' : 'transparent'}
 | 
				
			||||||
          strokeColor={filled ? 'currentcolor' : '#c1cce8'}
 | 
					          strokeColor={filled ? 'currentcolor' : '#c1cce8'}
 | 
				
			||||||
 | 
					          ariaHidden
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </label>
 | 
				
			||||||
 | 
					      <input
 | 
				
			||||||
 | 
					        type="radio"
 | 
				
			||||||
 | 
					        name="stars"
 | 
				
			||||||
 | 
					        {value}
 | 
				
			||||||
 | 
					        id={starId}
 | 
				
			||||||
 | 
					        bind:group={ratingSelection}
 | 
				
			||||||
 | 
					        disabled={readOnly}
 | 
				
			||||||
 | 
					        on:focus={() => {
 | 
				
			||||||
 | 
					          focusRating = value;
 | 
				
			||||||
 | 
					        }}
 | 
				
			||||||
 | 
					        on:change={() => handleSelectDebounced(value)}
 | 
				
			||||||
 | 
					        class="sr-only"
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    </button>
 | 
					 | 
				
			||||||
    {/each}
 | 
					    {/each}
 | 
				
			||||||
</div>
 | 
					  </div>
 | 
				
			||||||
 | 
					</fieldset>
 | 
				
			||||||
 | 
					{#if ratingSelection > 0 && !readOnly}
 | 
				
			||||||
 | 
					  <button
 | 
				
			||||||
 | 
					    type="button"
 | 
				
			||||||
 | 
					    on:click={() => {
 | 
				
			||||||
 | 
					      ratingSelection = 0;
 | 
				
			||||||
 | 
					      handleSelect(ratingSelection);
 | 
				
			||||||
 | 
					    }}
 | 
				
			||||||
 | 
					    class="cursor-pointer text-xs text-immich-primary dark:text-immich-dark-primary"
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					    {$t('rating_clear')}
 | 
				
			||||||
 | 
					  </button>
 | 
				
			||||||
 | 
					{/if}
 | 
				
			||||||
 | 
				
			|||||||
@ -972,6 +972,8 @@
 | 
				
			|||||||
  "purchase_server_title": "Server",
 | 
					  "purchase_server_title": "Server",
 | 
				
			||||||
  "purchase_settings_server_activated": "The server product key is managed by the admin",
 | 
					  "purchase_settings_server_activated": "The server product key is managed by the admin",
 | 
				
			||||||
  "rating": "Star rating",
 | 
					  "rating": "Star rating",
 | 
				
			||||||
 | 
					  "rating_clear": "Clear rating",
 | 
				
			||||||
 | 
					  "rating_count": "{count, plural, one {# star} other {# stars}}",
 | 
				
			||||||
  "rating_description": "Display the exif rating in the info panel",
 | 
					  "rating_description": "Display the exif rating in the info panel",
 | 
				
			||||||
  "reaction_options": "Reaction options",
 | 
					  "reaction_options": "Reaction options",
 | 
				
			||||||
  "read_changelog": "Read Changelog",
 | 
					  "read_changelog": "Read Changelog",
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user