From ad59d08bcf8bcf2428edacfe9aac153d2f318d32 Mon Sep 17 00:00:00 2001 From: Bartosz Prusinowski Date: Thu, 12 Nov 2026 13:18:16 +0100 Subject: [PATCH] fix: Accessible metadata panel dialog, values list and "Show more" buttons --- CHANGELOG.md | 5 + app/components/metadata-panel.spec.tsx | 231 +++++++++++++++++ app/components/metadata-panel.tsx | 256 +++++++++++++------ app/configurator/components/configurator.tsx | 14 +- app/locales/de/messages.po | 16 ++ app/locales/en/messages.po | 16 ++ app/locales/fr/messages.po | 16 ++ app/locales/it/messages.po | 16 ++ app/utils/use-focus-request.ts | 21 ++ e2e/metadata-panel.spec.ts | 36 +++ 10 files changed, 547 insertions(+), 80 deletions(-) create mode 100644 app/components/metadata-panel.spec.tsx create mode 100644 app/utils/use-focus-request.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ea5873d7..72a0c69f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ You can also check the - Accessible names for icon-only buttons, selects, radio groups, editors and color controls, linked error messages, and a "Skip to main content" link on every page + - Accessibility of the metadata panel: it is now announced as a "Details" + dialog, focuses its labelled close button when opened, closes with Escape + and returns focus to the "Details" button; available values are a list, + "Show more" buttons are named after their dimension, and focus is kept when + switching between the dimension list and a dimension's details ## 6.5.7 - 2026-11-10 diff --git a/app/components/metadata-panel.spec.tsx b/app/components/metadata-panel.spec.tsx new file mode 100644 index 000000000..3d0b1f6ba --- /dev/null +++ b/app/components/metadata-panel.spec.tsx @@ -0,0 +1,231 @@ +import { ThemeProvider } from "@mui/material"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { MetadataPanel } from "@/components/metadata-panel"; +import { + createMetadataPanelStore, + MetadataPanelStoreContext, +} from "@/components/metadata-panel-store"; +import { tableConfig } from "@/docs/fixtures"; +import { DimensionValue, NominalDimension } from "@/domain/data"; +import { DEFAULT_DATA_SOURCE } from "@/domain/data-source"; +import { mkJoinById } from "@/graphql/join"; +import { ComponentId, stringifyComponentId } from "@/graphql/make-component-id"; +import { expectNoAxeViolations } from "@/test/axe"; +import { theme } from "@/themes/theme"; + +vi.mock("@lingui/macro", () => ({ + Trans: ({ children }: { children: ReactNode }) => children, + t: ({ message }: { message: string }) => message, + defineMessage: ({ message }: { message: string }) => message, +})); + +vi.mock("@/locales/use-locale", () => ({ + useLocale: () => "en", +})); + +vi.mock("@/configurator/configurator-state/context", async () => { + const { states } = await import("@/docs/fixtures"); + + return { useConfiguratorState: () => [states[1]] }; +}); + +vi.mock("@/graphql/hooks", () => ({ + useDataCubesMetadataQuery: () => [ + { data: { dataCubesMetadata: [{ iri: CUBE_IRI, title: "Cube" }] } }, + ], + useDataCubesComponentsQuery: () => [ + { + data: { dataCubesComponents: { dimensions: components, measures: [] } }, + fetching: false, + }, + ], +})); + +const CUBE_IRI = "https://example.org/cube"; + +const makeDimension = ({ + label, + values, +}: { + label: string; + values: DimensionValue[]; +}): NominalDimension => ({ + __typename: "NominalDimension", + cubeIri: CUBE_IRI, + id: stringifyComponentId({ + unversionedCubeIri: CUBE_IRI, + unversionedComponentIri: `${CUBE_IRI}/${label}`, + }), + label, + isNumerical: false, + isKeyDimension: true, + values, + relatedLimitValues: [], +}); + +const components = [ + makeDimension({ + label: "Canton", + values: [ + { value: "zh", label: "Zurich", description: "Largest canton" }, + { value: "be", label: "Bern" }, + ], + }), + makeDimension({ + label: "Year", + values: [{ value: "2024", label: "2024" }], + }), +]; + +const [canton] = components; + +const joinedCanton: NominalDimension = { + ...canton, + cubeIri: "joinBy", + id: mkJoinById(0) as ComponentId, + isJoinByDimension: true, + originalIds: [ + { + cubeIri: CUBE_IRI, + dimensionId: canton.id, + label: "Canton", + description: "", + }, + ], +}; + +afterEach(cleanup); + +describe("MetadataPanel", () => { + it("opens as a labelled dialog and returns focus to the toggle on Escape", async () => { + renderPanel(); + const toggle = screen.getByRole("button", { name: "Details" }); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + + const dialog = await openPanel(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + expect(toggle.getAttribute("aria-controls")).toBe(dialog.id); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + const close = within(dialog).getByRole("button", { name: "Close" }); + await waitFor(() => expect(document.activeElement).toBe(close)); + + fireEvent.keyDown(close, { key: "Escape" }); + + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(document.activeElement).toBe(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + }); + + it("names each Show more button after its dimension and moves focus between views", async () => { + renderPanel(); + const dialog = await openPanel( + screen.getByRole("button", { name: "Details" }) + ); + + expect( + within(dialog).getByRole("combobox", { name: "Jump to..." }) + ).toBeTruthy(); + expect(within(dialog).queryByRole("link")).toBeNull(); + expect( + within(dialog).getByRole("button", { name: /^Show more ?: Year$/ }) + ).toBeTruthy(); + await expectNoAxeViolations(dialog); + + fireEvent.click( + within(dialog).getByRole("button", { name: /^Show more ?: Canton$/ }) + ); + + const back = await within(dialog).findByRole("button", { name: "Back" }); + await waitFor(() => expect(document.activeElement).toBe(back)); + expect( + within(dialog).getByRole("heading", { name: "Canton", level: 3 }) + ).toBeTruthy(); + + fireEvent.click(back); + + await waitFor(() => + expect(document.activeElement).toBe( + within(dialog).getByRole("button", { name: /^Show more ?: Canton$/ }) + ) + ); + }); + + it("returns focus to the listed Show more button of a joined dimension opened from outside", async () => { + const store = renderPanel([joinedCanton]); + act(() => store.getState().actions.openComponent(joinedCanton)); + const dialog = await screen.findByRole("dialog", { name: "Details" }); + + fireEvent.click( + await within(dialog).findByRole("button", { name: "Back" }) + ); + + await waitFor(() => + expect(document.activeElement).toBe( + within(dialog).getByRole("button", { name: /^Show more ?: Canton$/ }) + ) + ); + }); + + it("renders the available values as a labelled list", async () => { + renderPanel(); + const dialog = await openPanel( + screen.getByRole("button", { name: "Details" }) + ); + fireEvent.click( + within(dialog).getByRole("button", { name: /^Show more ?: Canton$/ }) + ); + + const list = await within(dialog).findByRole("list", { + name: "Available values", + }); + const items = within(list).getAllByRole("listitem"); + + expect(items).toHaveLength(2); + expect( + items.some( + (item) => + within(item).queryByText(/Zurich/) && + within(item).queryByText("Largest canton") + ) + ).toBe(true); + await expectNoAxeViolations(dialog); + }); +}); + +const renderPanel = (panelComponents = components) => { + const store = createMetadataPanelStore(); + store.getState().actions.setActiveSection("data"); + + render( + + + + + + ); + + return store; +}; + +const openPanel = async (toggle: HTMLElement) => { + toggle.focus(); + fireEvent.click(toggle); + + return screen.findByRole("dialog", { name: "Details" }); +}; diff --git a/app/components/metadata-panel.tsx b/app/components/metadata-panel.tsx index ffe536e72..b6e93f076 100644 --- a/app/components/metadata-panel.tsx +++ b/app/components/metadata-panel.tsx @@ -16,6 +16,7 @@ import { Typography, } from "@mui/material"; import { makeStyles } from "@mui/styles"; +import { useId } from "@reach/auto-id"; import match from "autosuggest-highlight/match"; import parse from "autosuggest-highlight/parse"; import clsx from "clsx"; @@ -27,12 +28,13 @@ import orderBy from "lodash/orderBy"; import sortBy from "lodash/sortBy"; import { useRouter } from "next/router"; import { - Fragment, type MouseEvent, ReactNode, - useCallback, + Ref, + RefObject, useEffect, useMemo, + useRef, useState, } from "react"; @@ -51,6 +53,7 @@ import { useMetadataPanelStoreActions, } from "@/components/metadata-panel-store"; import { MotionBox } from "@/components/presence"; +import { VisuallyHidden } from "@/components/visually-hidden"; import { getChartConfig } from "@/config-utils"; import { BackButton, @@ -79,6 +82,7 @@ import { useDataCubesMetadataQuery, useDataCubesObservationsQuery, } from "@/graphql/hooks"; +import { isJoinByCube } from "@/graphql/join"; import { Icon } from "@/icons"; import SvgIcArrowRight from "@/icons/components/IcArrowRight"; import SvgIcClose from "@/icons/components/IcClose"; @@ -88,6 +92,7 @@ import { assert } from "@/utils/assert"; import { useEventEmitter } from "@/utils/event-emitter"; import { makeDimensionValueSorters } from "@/utils/sorting-values"; import { useEvent } from "@/utils/use-event"; +import { useFocusRequest } from "@/utils/use-focus-request"; const useDrawerStyles = makeStyles((theme) => { return { @@ -145,22 +150,21 @@ const useOtherStyles = makeStyles((theme) => { flexDirection: "column", gap: theme.spacing(4), }, - dimensionButton: { - textAlign: "left", + dimensionName: { lineHeight: 1, - minHeight: "auto", - padding: 0, fontWeight: "bold", - - "& > svg": { - marginLeft: 0, - }, + color: theme.palette.cobalt[700], }, dimensionValues: { flexDirection: "column", gap: theme.spacing(2), marginTop: theme.spacing(3), }, + dimensionValuesList: { + margin: 0, + padding: 0, + listStyle: "none", + }, search: { marginBottom: "0.25rem", "& .MuiAutocomplete-inputRoot": { @@ -206,6 +210,8 @@ const useOtherStyles = makeStyles((theme) => { }; }); +const BACK_BUTTON_FOCUS_KEY = "back"; + const animationProps: Transition = { transition: { duration: 0.2, @@ -285,6 +291,9 @@ export const MetadataPanel = ({ open: state.open, activeSection: state.activeSection, })); + const panelId = useId(); + const titleId = useId(); + const closeButtonRef = useRef(null); const setEnableTransition = useTransitionStore((state) => state.setEnable); const { setOpen, toggle, setActiveSection, reset } = useMetadataPanelStoreActions(); @@ -313,33 +322,56 @@ export const MetadataPanel = ({ }, [setOpen, reset, router.events]); const slideProps = useMemo(() => { + const focusCloseButton = () => closeButtonRef.current?.focus(); + if (container) { return { onEnter: () => setEnableTransition(false), - onEntered: () => setEnableTransition(true), + onEntered: () => { + setEnableTransition(true); + focusCloseButton(); + }, onExit: () => setEnableTransition(false), onExited: () => setEnableTransition(true), }; } + + return { onEntered: focusCloseButton }; }, [setEnableTransition, container]); return ( <> {renderToggle && ( - + )} -
+
+ General @@ -360,7 +392,7 @@ export const MetadataPanel = ({ + Data } @@ -389,9 +421,13 @@ export const MetadataPanel = ({ const ToggleButton = ({ smaller, + open, + panelId, onClick, }: { smaller?: boolean; + open: boolean; + panelId: string | undefined; onClick: () => void; }) => { const classes = useOtherStyles(); @@ -403,6 +439,8 @@ const ToggleButton = ({ variant="text" color="primary" size={smaller ? "xs" : "sm"} + aria-expanded={open} + aria-controls={open ? panelId : undefined} onClick={onClick} > Details @@ -410,16 +448,37 @@ const ToggleButton = ({ ); }; -const Header = ({ onClose }: { onClose: () => void }) => { +const Header = ({ + titleId, + closeButtonRef, + onClose, +}: { + titleId: string | undefined; + closeButtonRef: RefObject; + onClose: () => void; +}) => { const classes = useOtherStyles(); return (
- + Details - - + +
); @@ -516,6 +575,21 @@ const DataPanel = ({ const { setSelectedComponent, clearSelectedComponent } = useMetadataPanelStoreActions(); const [inputValue, setInputValue] = useState(""); + const { requestFocus, getFocusRef } = useFocusRequest(); + const handleSelectComponent = useEvent((component: Component) => { + requestFocus(BACK_BUTTON_FOCUS_KEY); + setSelectedComponent(component); + }); + const handleBack = useEvent(() => { + if (selectedDimension) { + requestFocus(getListedComponentId(selectedDimension)); + } + clearSelectedComponent(); + }); + const searchLabel = t({ + id: "select.controls.metadata.search", + message: "Jump to...", + }); const { options, groupedComponents } = useMemo(() => { const options = components.flatMap( ( @@ -589,7 +663,10 @@ const DataPanel = ({ {selectedDimension ? ( - + Back @@ -609,7 +686,7 @@ const DataPanel = ({ v && setSelectedComponent(v.value)} + onChange={(_, v) => v && handleSelectComponent(v.value)} inputValue={inputValue} onInputChange={(_, v) => setInputValue(v.toLowerCase())} options={sortedOptions} @@ -619,10 +696,11 @@ const DataPanel = ({ handleSelectComponent(component)} /> ))} @@ -710,21 +790,31 @@ const DataPanel = ({ ); }; +const getListedComponentId = (component: Component) => { + return isJoinByComponent(component) && isJoinByCube(component.cubeIri) + ? component.originalIds[0].dimensionId + : component.id; +}; + const ComponentTabPanel = ({ component, dataSource, expanded, cubeIri, + showMoreRef, + onShowMore, }: { component: Component; dataSource: DataSource; expanded: boolean; cubeIri?: string; + showMoreRef?: Ref; + onShowMore?: () => void; }) => { const [state] = useConfiguratorState(); const chartConfig = getChartConfig(state); const classes = useOtherStyles(); - const { setSelectedComponent } = useMetadataPanelStoreActions(); + const valuesLabelId = useId(); const label = useMemo( () => getComponentLabel(component, { cubeIri }), [cubeIri, component] @@ -783,32 +873,18 @@ const ComponentTabPanel = ({ component.id, ]); - const handleClick = useCallback(() => { - if (!expanded) { - setSelectedComponent(component); - } - }, [expanded, component, setSelectedComponent]); - return (
- + {isJoinByComponent(component) ? ( <> {isJoinByComponent(component) ? (
- - Joined with + + + Joined with + {component.originalIds.map((x) => x.cubeIri === loadedComponent.cubeIri ? null : ( @@ -855,7 +933,11 @@ const ComponentTabPanel = ({ )}
) : null} - + Available values @@ -865,29 +947,39 @@ const ComponentTabPanel = ({
) : ( - + )} )} {!expanded && ( )}
); }; -const ComponentValues = ({ component }: { component: Component }) => { +const ComponentValues = ({ + component, + labelId, +}: { + component: Component; + labelId: string | undefined; +}) => { const sortedValues = useMemo(() => { const sorters = makeDimensionValueSorters(component); return orderBy( @@ -912,7 +1004,7 @@ const ComponentValues = ({ component }: { component: Component }) => { case "OrdinalMeasure": case "GeoCoordinatesDimension": case "GeoShapesDimension": - return ; + return ; case "NumericalMeasure": return sortedValues.length > 0 ? ( @@ -925,13 +1017,26 @@ const ComponentValues = ({ component }: { component: Component }) => { } }; -const DimensionValuesNominal = ({ values }: { values: DimensionValue[] }) => { +const DimensionValuesNominal = ({ + values, + labelId, +}: { + values: DimensionValue[]; + labelId: string | undefined; +}) => { + const classes = useOtherStyles(); + return ( - <> + // Safari drops the list semantics of lists with list-style: none. +
    {values.map((d) => d.label ? ( - - +
  • + {d.label}{" "} {d.alternateName ? ( ({d.alternateName}) @@ -944,25 +1049,16 @@ const DimensionValuesNominal = ({ values }: { values: DimensionValue[] }) => { {d.description} ) : null} - +
  • ) : null )} - +
); }; const MeasureValuesNumeric = ({ values }: { values: DimensionValue[] }) => { - const [min, max] = [values[0].value, values[values.length - 1].value]; - return ( - <> - - Min: {min} - - - Max: {max} - - + ); }; @@ -975,15 +1071,29 @@ const DimensionValuesTemporal = ({ }) => { const formatters = useDimensionFormatters([dim]); const format = formatters[dim.id]; - const [min, max] = [values[0].value, values[values.length - 1].value]; + return ( + + ); +}; + +const MinMaxValues = ({ + min, + max, +}: { + min: DimensionValue["value"]; + max: DimensionValue["value"]; +}) => { return ( <> - Min: {format(min)} + Min: {min} - Max: {format(max)} + Max: {max} ); diff --git a/app/configurator/components/configurator.tsx b/app/configurator/components/configurator.tsx index 87b677a91..a37bd2ff5 100644 --- a/app/configurator/components/configurator.tsx +++ b/app/configurator/components/configurator.tsx @@ -15,6 +15,7 @@ import { useRouter } from "next/router"; import { useSession } from "next-auth/react"; import { enqueueSnackbar } from "notistack"; import { + forwardRef, PropsWithChildren, ReactNode, useCallback, @@ -96,14 +97,13 @@ import { replaceLinks } from "@/utils/ui-strings"; import { useEvent } from "@/utils/use-event"; import { useMutate } from "@/utils/use-fetch-data"; -export const BackButton = ({ - size = "xs", - children, - onClick, - ...props -}: { children: ReactNode } & ButtonProps) => { +export const BackButton = forwardRef< + HTMLButtonElement, + { children: ReactNode } & ButtonProps +>(({ size = "xs", children, onClick, ...props }, ref) => { return ( ); -}; +}); export const isAnnotatorField = ( field: string | undefined diff --git a/app/locales/de/messages.po b/app/locales/de/messages.po index eaeed8630..d0e8274c2 100644 --- a/app/locales/de/messages.po +++ b/app/locales/de/messages.po @@ -520,6 +520,10 @@ msgstr "Kopieren" msgid "button.close" msgstr "Schliessen" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.close" +msgstr "Schliessen" + #: app/configurator/components/chart-options-selector/color-component-field.tsx #: app/configurator/components/chart-options-selector/conversion-units-field.tsx #: app/configurator/components/field-i18n.ts @@ -1123,6 +1127,10 @@ msgstr "Verbunden" msgid "dimension.joined-info-icon" msgstr "Verbundene Dimensionen integrieren Daten aus mehreren Datensätzen" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.joined-with" +msgstr "Verbunden mit" + #: app/components/metadata-panel.tsx msgid "select.controls.metadata.search" msgstr "Springen zu..." @@ -1248,6 +1256,10 @@ msgstr "Karte" msgid "controls.adjust-scale-domain.max-error" msgstr "Max muss grösser sein als Min" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.max" +msgstr "Max: {max}" + #: app/configurator/components/chart-options-selector/sorting-field.tsx msgid "controls.sorting.byMeasure" msgstr "Messwert" @@ -1268,6 +1280,10 @@ msgstr "Mittlere Farbe" msgid "controls.adjust-scale-domain.min-error" msgstr "Min muss kleiner als Max sein" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.min" +msgstr "Min: {min}" + #: app/configurator/components/ui-helpers.ts msgid "time-units.Minute" msgstr "Minute" diff --git a/app/locales/en/messages.po b/app/locales/en/messages.po index 37848e0f8..150de9e05 100644 --- a/app/locales/en/messages.po +++ b/app/locales/en/messages.po @@ -520,6 +520,10 @@ msgstr "Click to copy" msgid "button.close" msgstr "Close" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.close" +msgstr "Close" + #: app/configurator/components/chart-options-selector/color-component-field.tsx #: app/configurator/components/chart-options-selector/conversion-units-field.tsx #: app/configurator/components/field-i18n.ts @@ -1123,6 +1127,10 @@ msgstr "Joined" msgid "dimension.joined-info-icon" msgstr "Joined dimensions integrate data from multiple datasets" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.joined-with" +msgstr "Joined with" + #: app/components/metadata-panel.tsx msgid "select.controls.metadata.search" msgstr "Jump to..." @@ -1248,6 +1256,10 @@ msgstr "Map" msgid "controls.adjust-scale-domain.max-error" msgstr "Max must be bigger than min" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.max" +msgstr "Max: {max}" + #: app/configurator/components/chart-options-selector/sorting-field.tsx msgid "controls.sorting.byMeasure" msgstr "Measure" @@ -1268,6 +1280,10 @@ msgstr "Middle color" msgid "controls.adjust-scale-domain.min-error" msgstr "Min must be smaller than max" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.min" +msgstr "Min: {min}" + #: app/configurator/components/ui-helpers.ts msgid "time-units.Minute" msgstr "Minute" diff --git a/app/locales/fr/messages.po b/app/locales/fr/messages.po index 3b27f1a68..a58cf8b4b 100644 --- a/app/locales/fr/messages.po +++ b/app/locales/fr/messages.po @@ -520,6 +520,10 @@ msgstr "Cliquer pour copier" msgid "button.close" msgstr "Fermer" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.close" +msgstr "Fermer" + #: app/configurator/components/chart-options-selector/color-component-field.tsx #: app/configurator/components/chart-options-selector/conversion-units-field.tsx #: app/configurator/components/field-i18n.ts @@ -1123,6 +1127,10 @@ msgstr "Jointe" msgid "dimension.joined-info-icon" msgstr "Les dimensions jointes intègrent les données de plusieurs ensembles de données" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.joined-with" +msgstr "Jointe avec" + #: app/components/metadata-panel.tsx msgid "select.controls.metadata.search" msgstr "Sauter à..." @@ -1248,6 +1256,10 @@ msgstr "Carte" msgid "controls.adjust-scale-domain.max-error" msgstr "Le maximum doit être plus grand que le minimum" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.max" +msgstr "Max : {max}" + #: app/configurator/components/chart-options-selector/sorting-field.tsx msgid "controls.sorting.byMeasure" msgstr "Mesure" @@ -1268,6 +1280,10 @@ msgstr "Couleur moyenne" msgid "controls.adjust-scale-domain.min-error" msgstr "Le minimum doit être inférieur au maximum" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.min" +msgstr "Min : {min}" + #: app/configurator/components/ui-helpers.ts msgid "time-units.Minute" msgstr "Minutes" diff --git a/app/locales/it/messages.po b/app/locales/it/messages.po index dd7175e14..d90bf501e 100644 --- a/app/locales/it/messages.po +++ b/app/locales/it/messages.po @@ -520,6 +520,10 @@ msgstr "Clicca per copiare" msgid "button.close" msgstr "Chiudi" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.close" +msgstr "Chiudi" + #: app/configurator/components/chart-options-selector/color-component-field.tsx #: app/configurator/components/chart-options-selector/conversion-units-field.tsx #: app/configurator/components/field-i18n.ts @@ -1123,6 +1127,10 @@ msgstr "Unita" msgid "dimension.joined-info-icon" msgstr "Le dimensioni unite integrano i dati provenienti da più set di dati" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.joined-with" +msgstr "Unita con" + #: app/components/metadata-panel.tsx msgid "select.controls.metadata.search" msgstr "Salta a..." @@ -1248,6 +1256,10 @@ msgstr "Mappa" msgid "controls.adjust-scale-domain.max-error" msgstr "Il massimo deve essere maggiore del minimo" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.max" +msgstr "Max: {max}" + #: app/configurator/components/chart-options-selector/sorting-field.tsx msgid "controls.sorting.byMeasure" msgstr "Misura" @@ -1268,6 +1280,10 @@ msgstr "Colore medio" msgid "controls.adjust-scale-domain.min-error" msgstr "Il minimo deve essere inferiore al massimo" +#: app/components/metadata-panel.tsx +msgid "controls.metadata-panel.min" +msgstr "Min: {min}" + #: app/configurator/components/ui-helpers.ts msgid "time-units.Minute" msgstr "Minuto" diff --git a/app/utils/use-focus-request.ts b/app/utils/use-focus-request.ts new file mode 100644 index 000000000..35d73e41d --- /dev/null +++ b/app/utils/use-focus-request.ts @@ -0,0 +1,21 @@ +import { useCallback, useRef } from "react"; + +export const useFocusRequest = () => { + const pendingKeyRef = useRef(); + + const requestFocus = useCallback((key: string) => { + pendingKeyRef.current = key; + }, []); + + const getFocusRef = useCallback( + (key: string) => (node: HTMLElement | null) => { + if (node && pendingKeyRef.current === key) { + pendingKeyRef.current = undefined; + node.focus(); + } + }, + [] + ); + + return { requestFocus, getFocusRef }; +}; diff --git a/e2e/metadata-panel.spec.ts b/e2e/metadata-panel.spec.ts index f8a202106..677c37409 100644 --- a/e2e/metadata-panel.spec.ts +++ b/e2e/metadata-panel.spec.ts @@ -1,3 +1,4 @@ +import { expectNoAxeViolations } from "./a11y"; import { setup } from "./common"; const { test, expect } = setup(); @@ -34,3 +35,38 @@ test("it should be possible to open a metadata panel by clicking on elements in await checkKantonDescription(); }); + +test("metadata panel should be an accessible dialog", async ({ + actions, + page, +}) => { + await actions.chart.createFrom({ + iri: "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/6", + dataSource: "Prod", + }); + + const toggle = page.getByTestId("panel-metadata-toggle"); + await toggle.click(); + + const dialog = page.getByRole("dialog", { name: "Details" }); + await expect(dialog).toBeVisible(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(dialog.getByRole("button", { name: "Close" })).toBeFocused(); + + await dialog.getByRole("tab", { name: "Data" }).click(); + const dataPanel = dialog.getByRole("tabpanel", { name: "Data" }); + const showMore = dataPanel.getByRole("button", { name: /^Show more ?: / }); + await expect(showMore.first()).toBeVisible(); + await expect(dataPanel.getByRole("link")).toHaveCount(0); + await expect(dataPanel.locator("..")).toHaveCSS("opacity", "1"); + await expect(dataPanel.locator(":scope > div")).toHaveCSS("opacity", "1"); + await expectNoAxeViolations({ + page, + include: "[data-testid=panel-metadata]", + }); + + await page.keyboard.press("Escape"); + + await expect(dialog).toBeHidden(); + await expect(toggle).toBeFocused(); +});