mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -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 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);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@ -21,7 +21,7 @@
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
{#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))} />
 | 
			
		||||
  </section>
 | 
			
		||||
{/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 type { FormEventHandler } from 'svelte/elements';
 | 
			
		||||
  import { shortcuts } from '$lib/actions/shortcut';
 | 
			
		||||
  import { clickOutside } from '$lib/actions/click-outside';
 | 
			
		||||
  import { focusOutside } from '$lib/actions/focus-outside';
 | 
			
		||||
  import { generateId } from '$lib/utils/generate-id';
 | 
			
		||||
  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>
 | 
			
		||||
<div
 | 
			
		||||
  class="relative w-full dark:text-gray-300 text-gray-700 text-base"
 | 
			
		||||
  use:clickOutside={{ onOutclick: deactivate }}
 | 
			
		||||
  use:focusOutside={{ onFocusOut: deactivate }}
 | 
			
		||||
  use:shortcuts={[
 | 
			
		||||
    {
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,6 @@
 | 
			
		||||
  import { AppRoute } from '$lib/constants';
 | 
			
		||||
  import { goto } from '$app/navigation';
 | 
			
		||||
  import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store';
 | 
			
		||||
  import { clickOutside } from '$lib/actions/click-outside';
 | 
			
		||||
  import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
 | 
			
		||||
  import SearchHistoryBox from './search-history-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
 | 
			
		||||
    draggable="false"
 | 
			
		||||
    autocomplete="off"
 | 
			
		||||
@ -153,7 +152,7 @@
 | 
			
		||||
    on:focusin={onFocusIn}
 | 
			
		||||
    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>
 | 
			
		||||
      <input
 | 
			
		||||
        type="text"
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,25 @@
 | 
			
		||||
<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 { generateId } from '$lib/utils/generate-id';
 | 
			
		||||
  import { t } from 'svelte-i18n';
 | 
			
		||||
 | 
			
		||||
  export let count = 5;
 | 
			
		||||
  export let rating: number;
 | 
			
		||||
  export let readOnly = false;
 | 
			
		||||
  export let onRating: (rating: number) => void | undefined;
 | 
			
		||||
 | 
			
		||||
  let ratingSelection = 0;
 | 
			
		||||
  let hoverRating = 0;
 | 
			
		||||
  let focusRating = 0;
 | 
			
		||||
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
 | 
			
		||||
 | 
			
		||||
  $: ratingSelection = rating;
 | 
			
		||||
 | 
			
		||||
  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';
 | 
			
		||||
  const id = generateId();
 | 
			
		||||
 | 
			
		||||
  const handleSelect = (newRating: number) => {
 | 
			
		||||
    if (readOnly) {
 | 
			
		||||
@ -17,34 +27,93 @@
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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>
 | 
			
		||||
 | 
			
		||||
<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}
 | 
			
		||||
      {@const value = index + 1}
 | 
			
		||||
    {@const filled = hoverRating >= value || (hoverRating === 0 && rating >= value)}
 | 
			
		||||
    <button
 | 
			
		||||
      type="button"
 | 
			
		||||
      on:click={() => handleSelect(value)}
 | 
			
		||||
      on:mouseover={() => (hoverRating = value)}
 | 
			
		||||
      on:focus|preventDefault={() => (hoverRating = value)}
 | 
			
		||||
      class="shadow-0 outline-0 text-immich-primary dark:text-immich-dark-primary"
 | 
			
		||||
      disabled={readOnly}
 | 
			
		||||
      {@const filled = hoverRating >= value || (hoverRating === 0 && ratingSelection >= value)}
 | 
			
		||||
      {@const starId = `${id}-${value}`}
 | 
			
		||||
      <!-- svelte-ignore a11y-mouse-events-have-key-events -->
 | 
			
		||||
      <!-- svelte-ignore a11y-no-noninteractive-tabindex -->
 | 
			
		||||
      <label
 | 
			
		||||
        for={starId}
 | 
			
		||||
        class:cursor-pointer={!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
 | 
			
		||||
          path={starIcon}
 | 
			
		||||
          size="1.5em"
 | 
			
		||||
          strokeWidth={1}
 | 
			
		||||
          color={filled ? 'currentcolor' : 'transparent'}
 | 
			
		||||
          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}
 | 
			
		||||
</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_settings_server_activated": "The server product key is managed by the admin",
 | 
			
		||||
  "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",
 | 
			
		||||
  "reaction_options": "Reaction options",
 | 
			
		||||
  "read_changelog": "Read Changelog",
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user