From c14e2914f89b378d7ddfde08d9240af4882c2b59 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Fri, 23 Aug 2024 12:34:12 -0400 Subject: [PATCH] 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 --- web/src/lib/actions/focus-outside.ts | 5 +- .../detail-panel-star-rating.svelte | 2 +- .../__test__/star-rating.spec.ts | 78 ++++++++++++ .../shared-components/combobox.svelte | 2 - .../search-bar/search-bar.svelte | 5 +- .../shared-components/star-rating.svelte | 117 ++++++++++++++---- web/src/lib/i18n/en.json | 2 + 7 files changed, 180 insertions(+), 31 deletions(-) create mode 100644 web/src/lib/components/shared-components/__test__/star-rating.spec.ts diff --git a/web/src/lib/actions/focus-outside.ts b/web/src/lib/actions/focus-outside.ts index 07a85b021e..2266ea8f0f 100644 --- a/web/src/lib/actions/focus-outside.ts +++ b/web/src/lib/actions/focus-outside.ts @@ -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); } }; diff --git a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte index 131d2ca436..8b18d14f03 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte @@ -21,7 +21,7 @@ {#if !isSharedLink() && $preferences?.rating?.enabled} -
+
handlePromiseError(handleChangeRating(rating))} />
{/if} diff --git a/web/src/lib/components/shared-components/__test__/star-rating.spec.ts b/web/src/lib/components/shared-components/__test__/star-rating.spec.ts new file mode 100644 index 0000000000..cf33573b77 --- /dev/null +++ b/web/src/lib/components/shared-components/__test__/star-rating.spec.ts @@ -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(''); + } + }); +}); diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 7cdcef9e40..64ec16fda6 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -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 @@
-
+
-
+
+ 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 | 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); }; -
(hoverRating = 0)} on:blur|preventDefault> - {#each { length: count } as _, index} - {@const value = index + 1} - {@const filled = hoverRating >= value || (hoverRating === 0 && rating >= value)} - - {/each} -
+ {/each} +
+ +{#if ratingSelection > 0 && !readOnly} + +{/if} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 91fb1aba43..3609b9c274 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -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",