Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(web): rating stars accessibility #11966

Merged
merged 7 commits into from
Aug 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion web/src/lib/actions/focus-outside.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Original file line number Diff line number Diff line change
@@ -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('');
}
});
});
2 changes: 0 additions & 2 deletions web/src/lib/components/shared-components/combobox.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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={[
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
117 changes: 93 additions & 24 deletions web/src/lib/components/shared-components/star-rating.svelte
Original file line number Diff line number Diff line change
@@ -1,50 +1,119 @@
<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) {
return;
}

if (newRating === rating) {
newRating = 0;
return;
}

onRating(newRating);
};

const setHoverRating = (value: number) => {
if (readOnly) {
return;
}
hoverRating = value;
};

rating = newRating;
const reset = () => {
setHoverRating(0);
focusRating = 0;
};

onRating?.(rating);
const handleSelectDebounced = (value: number) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
handleSelect(value);
}, 300);
};
</script>

<div role="presentation" tabindex="-1" on:mouseout={() => (hoverRating = 0)} on:blur|preventDefault>
{#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}
>
<Icon
path={starIcon}
size="1.5em"
strokeWidth={1}
color={filled ? 'currentcolor' : 'transparent'}
strokeColor={filled ? 'currentcolor' : '#c1cce8'}
<!-- 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 && 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>
{/each}
</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}
2 changes: 2 additions & 0 deletions web/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading