1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +00: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:
Ben 2024-08-23 12:34:12 -04:00 committed by GitHub
parent 7fbf50a75e
commit c14e2914f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 180 additions and 31 deletions

View file

@ -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);
} }
}; };

View file

@ -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}

View file

@ -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('');
}
});
});

View file

@ -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={[
{ {

View file

@ -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"

View file

@ -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}

View file

@ -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",