diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d8856a2f..7ea5873d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,19 @@ You can also check the - Keyboard focus is now visible on buttons, links, tabs and menu items, the switch has sufficient contrast, and switches and tabs stay visible in forced colors (high contrast) mode + - Keyboard access to shared widgets: menus (chart and dashboard actions, + "Download data", user menu, profile rows) open on their first item and + work with the arrow keys; confirmation dialogs open outside the menu; the + "Add chart" popover, color palette select and color picker (saturation, + hue, swatches) are operable by keyboard; time sliders and time range + brushes move with arrow keys, Home and End and announce their value; Tab + no longer gets trapped in the rich text editor, whose toolbar uses arrow + keys and shows the active format, also in forced colors mode; Escape + closes the editor drawers, the link dialog and the date picker without + closing the options panel + - 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 ## 6.5.7 - 2026-11-10 diff --git a/app/browse/ui/search-dataset-sort-control.tsx b/app/browse/ui/search-dataset-sort-control.tsx index c8b553e5c4..4e7756743e 100644 --- a/app/browse/ui/search-dataset-sort-control.tsx +++ b/app/browse/ui/search-dataset-sort-control.tsx @@ -43,13 +43,14 @@ export const SearchDatasetSortControl = ({ return ( - ); }; diff --git a/app/charts/shared/brush/index.tsx b/app/charts/shared/brush/index.tsx index a55b5a5199..2acc8f35fc 100644 --- a/app/charts/shared/brush/index.tsx +++ b/app/charts/shared/brush/index.tsx @@ -1,5 +1,5 @@ +import { t } from "@lingui/macro"; import { useEventCallback } from "@mui/material"; -import { bisector } from "d3-array"; import { BrushSelection, brushX } from "d3-brush"; import { pointer, pointers, select, Selection } from "d3-selection"; import { Transition } from "d3-transition"; @@ -17,11 +17,7 @@ import type { import { useChartState } from "@/charts/shared/chart-state"; import { useChartTheme } from "@/charts/shared/use-chart-theme"; import { DashboardTimeRangeFilter } from "@/configurator"; -import { - Observation, - TemporalDimension, - TemporalEntityDimension, -} from "@/domain/data"; +import { TemporalDimension, TemporalEntityDimension } from "@/domain/data"; import { useTimeFormatUnit } from "@/formatters"; import { useChartInteractiveFilters, @@ -221,67 +217,36 @@ export const BrushTime = ({ yOffset }: { yOffset?: number }) => { } }); - /** Keyboard support */ - const moveBrushOnKeyPress = useCallback( - (event: $FixMe, handleDirection: "w" | "e") => { - if (from && to) { - updateBrushEndedStatus(false); - - const bisectDateLeft = bisector( - (ds: Observation, date: Date) => - getDate(ds).getTime() - date.getTime() - ).left; - const bisectDateRight = bisector( - (ds: Observation, date: Date) => - getDate(ds).getTime() - date.getTime() - ).right; - - if (event.keyCode === 37 && handleDirection === "w") { - // west handle, moving left - const index = bisectDateLeft(fullData, from, 1); - const indexLeft = fullData[index - 1]; - - if (getDate(indexLeft).getTime() < to.getTime()) { - // new lower than "to" - setTimeRange(getDate(indexLeft), to); - } else { - // new too high, don't do anything - setTimeRange(from, to); - } - } else if (event.keyCode === 39 && handleDirection === "w") { - // west handle, moving right - const index = bisectDateRight(fullData, from, 1); - const indexRight = fullData[index]; - if (getDate(indexRight).getTime() < to.getTime()) { - setTimeRange(getDate(indexRight), to); - } else { - setTimeRange(from, to); - } - } else if (event.keyCode === 37 && handleDirection === "e") { - // east handle, moving left - const index = bisectDateLeft(fullData, to, 1); - const indexLeft = fullData[index - 1]; - - if (getDate(indexLeft).getTime() > from.getTime()) { - setTimeRange(from, getDate(indexLeft)); - } else { - setTimeRange(from, to); - } - } else if (event.keyCode === 39 && handleDirection === "e") { - // east handle, moving right - const index = bisectDateRight(fullData, to, 1); - const indexLeft = fullData[index]; - - if (indexLeft && getDate(indexLeft).getTime() > from.getTime()) { - setTimeRange(from, getDate(indexLeft)); - } else { - setTimeRange(from, to); - } - } - updateBrushEndedStatus(true); + const dates = useMemo(() => { + return Array.from(new Set(fullData.map((d) => getDate(d).getTime()))) + .sort((a, b) => a - b) + .map((d) => new Date(d)); + }, [fullData, getDate]); + + const moveBrushOnKeyDown = useEventCallback( + (event: KeyboardEvent, handle: "w" | "e") => { + const fromIndex = getDateIndex(dates, closestFrom); + const toIndex = getDateIndex(dates, closestTo); + const [min, max, index] = + handle === "w" + ? [0, toIndex - 1, fromIndex] + : [fromIndex + 1, dates.length - 1, toIndex]; + const newIndex = getKeyboardIndex({ key: event.key, index, min, max }); + + if (newIndex === undefined || !ref.current) { + return; } - }, - [fullData, setTimeRange, from, getDate, to] + + event.preventDefault(); + const [newFrom, newTo] = + handle === "w" + ? [dates[newIndex], closestTo] + : [closestFrom, dates[newIndex]]; + select(ref.current).call(brush.move, [ + brushWidthScale(newFrom), + brushWidthScale(newTo), + ]); + } ); useEffect(() => { @@ -329,10 +294,12 @@ export const BrushTime = ({ yOffset }: { yOffset?: number }) => { g.select(".handle--w") .attr("tabindex", 0) - .on("keydown", (e: $FixMe) => moveBrushOnKeyPress(e, "w")); + .attr("role", "slider") + .on("keydown", (e: KeyboardEvent) => moveBrushOnKeyDown(e, "w")); g.select(".handle--e") .attr("tabindex", 0) - .on("keydown", (e: $FixMe) => moveBrushOnKeyPress(e, "e")); + .attr("role", "slider") + .on("keydown", (e: KeyboardEvent) => moveBrushOnKeyDown(e, "e")); }; g.call(mkBrush); @@ -344,10 +311,38 @@ export const BrushTime = ({ yOffset }: { yOffset?: number }) => { brushHandleStrokeColor, brushOverlayColor, brushSelectionColor, - moveBrushOnKeyPress, + moveBrushOnKeyDown, selectionExtent, ]); + useEffect(() => { + if (!ref.current || !closestFrom || !closestTo) { + return; + } + + const g = select(ref.current); + const handles = [ + { + selector: ".handle--w", + label: t({ id: "controls.filters.select.from", message: "From" }), + date: closestFrom, + }, + { + selector: ".handle--e", + label: t({ id: "controls.filters.select.to", message: "To" }), + date: closestTo, + }, + ]; + handles.forEach(({ selector, label, date }) => { + g.select(selector) + .attr("aria-label", label) + .attr("aria-valuemin", 0) + .attr("aria-valuemax", dates.length - 1) + .attr("aria-valuenow", getDateIndex(dates, date)) + .attr("aria-valuetext", formatDate(date)); + }); + }); + // This effect allows "snapping" to actual data points // after brush is ended and interactive-filters state is updated const closestFromStr = closestFrom?.toString(); // Local variables to prevent eslint-plugin-react-hooks bug @@ -433,6 +428,35 @@ export const BrushTime = ({ yOffset }: { yOffset?: number }) => { ) : null; }; +const getDateIndex = (dates: Date[], date: Date) => { + return dates.findIndex((d) => d.getTime() === date.getTime()); +}; + +const getKeyboardIndex = ({ + key, + index, + min, + max, +}: { + key: string; + index: number; + min: number; + max: number; +}) => { + switch (key) { + case "ArrowLeft": + case "ArrowDown": + return Math.max(min, index - 1); + case "ArrowRight": + case "ArrowUp": + return Math.min(max, index + 1); + case "Home": + return min; + case "End": + return max; + } +}; + // Unused BrushOrdinal // export const BrushOrdinal = ({ debug = false }: { debug?: boolean }) => { diff --git a/app/charts/shared/chart-data-filters/index.tsx b/app/charts/shared/chart-data-filters/index.tsx index 396a5c6488..770ee3e6e6 100644 --- a/app/charts/shared/chart-data-filters/index.tsx +++ b/app/charts/shared/chart-data-filters/index.tsx @@ -1,5 +1,6 @@ import { t, Trans } from "@lingui/macro"; import { Button, SelectChangeEvent, Typography } from "@mui/material"; +import { useId } from "@reach/auto-id"; import isEmpty from "lodash/isEmpty"; import isEqual from "lodash/isEqual"; import mapValues from "lodash/mapValues"; @@ -310,6 +311,7 @@ export const DataFilterGenericDimension = ({ options: _options, disabled, }: DataFilterGenericDimensionProps) => { + const id = `data-filter-${useId()}`; const { label, isKeyDimension } = dimension; const noneLabel = t({ id: "controls.dimensionvalue.select", @@ -371,7 +373,7 @@ export const DataFilterGenericDimension = ({ return ( { ({ + Trans: ({ children }: { children: ReactNode }) => children, + t: ({ message }: { message: string }) => message, +})); + +afterEach(cleanup); + +const WrappedItem = ({ label }: { label: string }) => { + return ( + {}} + /> + ); +}; + +const TestMenu = ({ + onDelete, + onParentClick, +}: { + onDelete: () => void; + onParentClick: () => void; +}) => { + const [anchor, setAnchor] = useState(null); + + return ( +
+ + setAnchor(null)} + transitionDuration={0} + > + + + + +
+ ); +}; + +const renderMenu = () => { + const onDelete = vi.fn(); + const onParentClick = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Open" })); + onParentClick.mockClear(); + + return { onDelete, onParentClick }; +}; + +describe("ActionMenu", () => { + it("focuses the first item even when items are wrapped in components", () => { + renderMenu(); + + expect(document.activeElement).toBe( + screen.getByRole("menuitem", { name: "Duplicate" }) + ); + }); + + it("moves focus between items with the arrow keys", () => { + renderMenu(); + fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" }); + + expect(document.activeElement).toBe( + screen.getByRole("menuitem", { name: "Export" }) + ); + }); + + it("renders the confirmation outside the closed menu", async () => { + const { onDelete, onParentClick } = renderMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); + + expect(screen.getByRole("dialog", { name: "Delete chart?" })).toBeTruthy(); + expect(onParentClick).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByRole("menu", { hidden: true })).toBeNull(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Yes" })); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + it("keeps the menu closed when the confirmation is cancelled", async () => { + const { onDelete } = renderMenu(); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete" })); + fireEvent.click(screen.getByRole("button", { name: "No" })); + + expect(onDelete).not.toHaveBeenCalled(); + await waitFor(() => { + expect(screen.queryByRole("menu", { hidden: true })).toBeNull(); + }); + }); +}); diff --git a/app/components/action-menu.tsx b/app/components/action-menu.tsx new file mode 100644 index 0000000000..134221a92f --- /dev/null +++ b/app/components/action-menu.tsx @@ -0,0 +1,60 @@ +import { Menu, MenuProps } from "@mui/material"; +import { createContext, MouseEvent, useContext, useState } from "react"; + +import { + ConfirmationDialog, + ConfirmationRequest, +} from "@/components/confirmation-dialog"; +import { focusFirst } from "@/utils/focus-first"; + +const MENU_ITEM_SELECTOR = '[role^="menuitem"]:not([aria-disabled="true"])'; + +const RequestConfirmationContext = createContext< + ((request: ConfirmationRequest) => void) | undefined +>(undefined); + +export const useRequestConfirmation = () => { + return useContext(RequestConfirmationContext); +}; + +export const ActionMenu = ({ + open, + onClose, + TransitionProps, + ...props +}: MenuProps) => { + const [request, setRequest] = useState(); + + return ( + + { + focusFirst(node, MENU_ITEM_SELECTOR); + TransitionProps?.onEntering?.(node, isAppearing); + }, + }} + {...props} + /> + {request ? ( + { + setRequest(undefined); + onClose?.(event, reason); + }} + /> + ) : null} + + ); +}; + +const stopPropagation = (e: MouseEvent) => { + e.stopPropagation(); +}; diff --git a/app/components/arrow-menu.tsx b/app/components/arrow-menu.tsx index da2151d4f6..594a741997 100644 --- a/app/components/arrow-menu.tsx +++ b/app/components/arrow-menu.tsx @@ -1,4 +1,6 @@ -import { Menu, paperClasses, styled } from "@mui/material"; +import { paperClasses, Popover, styled } from "@mui/material"; + +import { ActionMenu } from "@/components/action-menu"; const commonBeforeStyles: Partial = { content: '" "', @@ -11,7 +13,7 @@ const commonBeforeStyles: Partial = { transform: "rotate(45deg)", }; -export const ArrowMenuTopCenter = styled(Menu)({ +const topCenterStyles = { [`& .${paperClasses.root}`]: { overflow: "visible", "&:before": { @@ -21,9 +23,13 @@ export const ArrowMenuTopCenter = styled(Menu)({ right: 0, }, }, -}); +}; + +export const ArrowMenuTopCenter = styled(ActionMenu)(topCenterStyles); + +export const ArrowPopoverTopCenter = styled(Popover)(topCenterStyles); -export const ArrowMenuBottomCenter = styled(Menu)({ +export const ArrowMenuBottomCenter = styled(ActionMenu)({ [`& .${paperClasses.root}`]: { overflow: "visible", "&:before": { @@ -35,7 +41,7 @@ export const ArrowMenuBottomCenter = styled(Menu)({ }, }); -export const ArrowMenuTopBottom = styled(Menu)({ +export const ArrowMenuTopBottom = styled(ActionMenu)({ [`& .${paperClasses.root}`]: { overflow: "visible", "&:before": { @@ -47,7 +53,7 @@ export const ArrowMenuTopBottom = styled(Menu)({ }, }); -export const ArrowMenuBottomTop = styled(Menu)({ +export const ArrowMenuBottomTop = styled(ActionMenu)({ [`& .${paperClasses.root}`]: { overflow: "visible", "&:before": { diff --git a/app/components/chart-selection-tabs.tsx b/app/components/chart-selection-tabs.tsx index 6a95931446..540e9d81b5 100644 --- a/app/components/chart-selection-tabs.tsx +++ b/app/components/chart-selection-tabs.tsx @@ -23,7 +23,10 @@ import { import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; import { AddButton } from "@/components/add-button"; -import { ArrowMenuTopCenter } from "@/components/arrow-menu"; +import { + ArrowMenuTopCenter, + ArrowPopoverTopCenter, +} from "@/components/arrow-menu"; import { DuplicateChartMenuActionItem } from "@/components/chart-shared"; import { DragHandle } from "@/components/drag-handle"; import { Flex } from "@/components/flex"; @@ -44,9 +47,11 @@ import { } from "@/configurator"; import { useSearchDatasetPanelStore } from "@/configurator/components/add-new-dataset-panel"; import { ChartTypeSelector } from "@/configurator/components/chart-type-selector"; +import { getFieldLabel } from "@/configurator/components/field-i18n"; import { getIconName } from "@/configurator/components/ui-helpers"; -import { Icon, IconName } from "@/icons"; +import { Icon } from "@/icons"; import { useLocale } from "@/locales"; +import { focusFirst } from "@/utils/focus-first"; import { useEvent } from "@/utils/use-event"; type TabsState = { @@ -163,6 +168,14 @@ const TabsEditable = (props: TabsEditableProps) => { <> 1} @@ -186,7 +199,7 @@ const TabsEditable = (props: TabsEditableProps) => { /> {tabsState.popoverType === "add" ? ( - { vertical: "top", }} onClose={handleClose} + TransitionProps={{ + onEntering: (node) => focusFirst(node, "button:not(:disabled)"), + }} PaperProps={{ + role: "dialog", + "aria-label": t({ + id: "chart-selection-tabs.add-chart", + message: "Add chart", + }), sx: { width: "calc(320px + 2rem)", mt: 3, py: "1rem", - "& > .MuiList-root": { - py: 0, - }, + boxShadow: 4, }, }} > @@ -250,7 +269,7 @@ const TabsEditable = (props: TabsEditableProps) => { - + ) : null} {tabsState.popoverType === "edit" ? ( @@ -408,6 +427,8 @@ const useTabsInnerStyles = makeStyles((theme) => ({ const TabsInner = ({ data, + addPopoverOpen, + menuChartKey, addable, editable, draggable, @@ -416,6 +437,8 @@ const TabsInner = ({ onChartSwitch, }: { data: TabDatum[]; + addPopoverOpen?: boolean; + menuChartKey?: string; addable: boolean; editable: boolean; draggable: boolean; @@ -496,11 +519,12 @@ const TabsInner = ({ }} label={ { @@ -526,7 +550,11 @@ const TabsInner = ({ {addable && ( - + Add chart )} @@ -603,11 +631,12 @@ export const useIconStyles = makeStyles< })); const TabContent = (props: { - iconName: IconName; + chartType: ChartType; chartKey: string; editable?: boolean; draggable?: boolean; active?: boolean; + menuOpen?: boolean; dragging?: boolean; label?: string; onChevronDownClick?: ( @@ -617,11 +646,12 @@ const TabContent = (props: { onSwitchClick?: () => void; }) => { const { - iconName, + chartType, chartKey, editable, draggable, active, + menuOpen, label, dragging, onChevronDownClick, @@ -639,6 +669,9 @@ const TabContent = (props: { + ); }; diff --git a/app/components/dashboard-interactive-filters.tsx b/app/components/dashboard-interactive-filters.tsx index 30b1141e6d..e5a8ff7abf 100644 --- a/app/components/dashboard-interactive-filters.tsx +++ b/app/components/dashboard-interactive-filters.tsx @@ -1,3 +1,4 @@ +import { t } from "@lingui/macro"; import { Box, BoxProps, @@ -8,6 +9,7 @@ import { } from "@mui/material"; import { Theme } from "@mui/material/styles"; import { makeStyles } from "@mui/styles"; +import { bisectLeft } from "d3-array"; import uniq from "lodash/uniq"; import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; @@ -213,43 +215,57 @@ const DashboardTimeRangeSlider = ({ const mountedForSomeTime = useTimeout(500, mounted); const combinedTemporalDimension = useCombinedTemporalDimension(); - const sliderRange = useMemo(() => { - const { values } = combinedTemporalDimension; - const min = values[0]?.value; - const max = values[values.length - 1]?.value; - - if (!min || !max) { - return; - } - - return [ - toUnixSeconds(parseDate(min as string)), - toUnixSeconds(parseDate(max as string)), - ]; + const markValues = useMemo(() => { + return combinedTemporalDimension.values.map(({ value }) => + toUnixSeconds(parseDate(value as string)) + ); }, [combinedTemporalDimension]); + const formatIndex = (index: number) => valueLabelFormat(markValues[index]); - if (!timeRange || !filter.active || !sliderRange) { + if (!timeRange || !filter.active || markValues.length === 0) { return null; } return ( handleChangeSlider(value)} + onChange={(_ev, value) => { + handleChangeSlider( + (Array.isArray(value) ? value : [value]).map((i) => markValues[i]) + ); + }} onChangeCommitted={() => setEnableTransition(true)} - valueLabelFormat={valueLabelFormat} - step={null} - min={sliderRange[0]} - max={sliderRange[1]} + getAriaLabel={(index) => + index === 0 + ? t({ id: "controls.filters.select.from", message: "From" }) + : t({ id: "controls.filters.select.to", message: "To" }) + } + getAriaValueText={formatIndex} + valueLabelFormat={formatIndex} + step={1} + min={0} + max={markValues.length - 1} valueLabelDisplay={mountedForSomeTime ? "on" : "off"} - value={timeRange} - marks={combinedTemporalDimension.values.map(({ value }) => ({ - value: toUnixSeconds(parseDate(value as string)), - }))} + value={timeRange.map((value) => getClosestIndex(markValues, value))} + marks={markValues.map((_, i) => ({ value: i }))} /> ); }; +const getClosestIndex = (values: number[], value: number) => { + const i = bisectLeft(values, value); + + if (i === 0) { + return 0; + } + + if (i === values.length) { + return values.length - 1; + } + + return value - values[i - 1] < values[i] - value ? i - 1 : i; +}; + const useDataFilterStyles = makeStyles((theme: Theme) => ({ wrapper: { display: "grid", diff --git a/app/components/dashboard-shared.tsx b/app/components/dashboard-shared.tsx index 9b4d499fc4..dc9ca5792c 100644 --- a/app/components/dashboard-shared.tsx +++ b/app/components/dashboard-shared.tsx @@ -71,53 +71,49 @@ export const BlockMoreButton = ({ blockKey }: { blockKey: string }) => { sx={{ height: "fit-content" }} {...DISABLE_SCREENSHOT_ATTR} data-testid="block-more-button" + aria-label={t({ id: "menu.more-actions", message: "More actions" })} + aria-haspopup="menu" + aria-expanded={!!anchor} > { - (e as Event).stopPropagation(); - handleClose(); - }} + onClose={handleClose} anchorOrigin={{ horizontal: "right", vertical: "bottom" }} transformOrigin={{ horizontal: "right", vertical: "top" }} > -
- { - e.stopPropagation(); - handleDuplicate(); - handleClose(); - }} - leadingIconName="duplicate" - label={t({ id: "block-controls.duplicate", message: "Duplicate" })} - /> - { - e.stopPropagation(); - handleRemove(); - handleClose(); - }} - leadingIconName="trash" - label={t({ id: "block-controls.delete", message: "Delete" })} - /> -
+ { + handleDuplicate(); + handleClose(); + }} + leadingIconName="duplicate" + label={t({ id: "block-controls.duplicate", message: "Duplicate" })} + /> + { + handleRemove(); + handleClose(); + }} + leadingIconName="trash" + label={t({ id: "block-controls.delete", message: "Delete" })} + />
); diff --git a/app/components/data-source-menu.tsx b/app/components/data-source-menu.tsx index 1e64269f39..4129c93b03 100644 --- a/app/components/data-source-menu.tsx +++ b/app/components/data-source-menu.tsx @@ -22,11 +22,17 @@ export const DataSourceMenu = () => { return ( - + Data source:{" "} + ); + const input = screen.getByRole("textbox", { name: "Title" }); + + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(input.getAttribute("aria-describedby")).toBe("title-error"); + expect(document.getElementById("title-error")?.textContent).toBe( + "Name taken" + ); + }); + + it("keeps an empty live region while there is no error", () => { + render(); + const input = screen.getByRole("textbox", { name: "Title" }); + + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(input.getAttribute("aria-describedby")).toBeNull(); + expect( + document.getElementById("title-error")?.getAttribute("aria-live") + ).toBe("polite"); + }); +}); + +describe("Select", () => { + it("names the combobox by its label", () => { + render( + + Region + + } + value="zh" + options={[{ value: "zh", label: "Zurich" }]} + /> + ); + + expect(screen.getByRole("button", { name: "Info" })).toBeTruthy(); + expect(screen.getByRole("combobox", { name: /^Region Info/ })).toBeTruthy(); + }); +}); diff --git a/app/components/form.tsx b/app/components/form.tsx index 7d04c3869a..77739eaadd 100644 --- a/app/components/form.tsx +++ b/app/components/form.tsx @@ -9,6 +9,7 @@ import { quotePlugin, thematicBreakPlugin, toolbarPlugin, + Translation, } from "@mdxeditor/editor"; import { Box, @@ -64,6 +65,8 @@ import { linkDialogPlugin } from "@/components/mdx-editor/link-dialog"; import { LinkDialogToggle } from "@/components/mdx-editor/link-dialog-toggle"; import { ListToggles } from "@/components/mdx-editor/list-toggles"; import { maxLengthPlugin } from "@/components/mdx-editor/max-length-plugin"; +import { tabNavigationPlugin } from "@/components/mdx-editor/tab-navigation-plugin"; +import { ToolbarButtons } from "@/components/mdx-editor/toolbar-buttons"; import { VisuallyHidden } from "@/components/visually-hidden"; import { FieldProps, @@ -105,23 +108,49 @@ export const FormControlLabel = ( }; export const Label = ({ + id, htmlFor, children, sx, }: { - htmlFor: string; + id?: string; + htmlFor?: string; children: ReactNode; sx?: TypographyProps["sx"]; }) => { return ( - + {children} ); }; -export const RadioGroup = ({ children }: { children: ReactNode }) => { - return {children}; +export const RadioGroup = ({ + label, + labelId, + children, +}: { + children: ReactNode; +} & ( + | { label: string; labelId?: never } + | { labelId: string; label?: never } +)) => { + return ( + + {children} + + ); }; export const Radio = ({ @@ -299,6 +328,7 @@ export type SelectOptionGroup = [OptionGroupKey, SelectOption[]]; export const Select = ({ label, id, + labelId, variant, size = "md", value, @@ -350,7 +380,7 @@ export const Select = ({ {label && ( ); diff --git a/app/components/rename-dialog.tsx b/app/components/rename-dialog.tsx index c73389b1d8..caf7036ba1 100644 --- a/app/components/rename-dialog.tsx +++ b/app/components/rename-dialog.tsx @@ -87,7 +87,7 @@ export const RenameDialog = ({ : config.data.layout.meta; return ( - +
diff --git a/app/components/row-actions.tsx b/app/components/row-actions.tsx index 0b997dbd50..490d1ab628 100644 --- a/app/components/row-actions.tsx +++ b/app/components/row-actions.tsx @@ -1,3 +1,4 @@ +import { t } from "@lingui/macro"; import { IconButton } from "@mui/material"; import { Flex } from "@/components/flex"; @@ -5,7 +6,13 @@ import { MenuActionItem, MenuActionProps } from "@/components/menu-action-item"; import { useFlipMenu } from "@/components/use-flip-menu"; import { Icon } from "@/icons"; -export const RowActions = ({ actions }: { actions: MenuActionProps[] }) => { +export const RowActions = ({ + actions, + label, +}: { + actions: MenuActionProps[]; + label: string; +}) => { const [primaryAction, ...otherActions] = actions; const { buttonRef, @@ -22,7 +29,16 @@ export const RowActions = ({ actions }: { actions: MenuActionProps[] }) => { return ( - + { + it("is not exposed or focused as an option", () => { + render( + + ); + + const options = screen.getAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "Blues", + "Greens", + ]); + expect(screen.getByText("Sequential").getAttribute("role")).toBe( + "presentation" + ); + expect(document.activeElement).toBe(options[0]); + }); +}); diff --git a/app/components/select-group-header.tsx b/app/components/select-group-header.tsx new file mode 100644 index 0000000000..3b4f88f4f9 --- /dev/null +++ b/app/components/select-group-header.tsx @@ -0,0 +1,18 @@ +import { ListSubheader, ListSubheaderProps } from "@mui/material"; +import { ReactNode } from "react"; + +export const SelectGroupHeader = ({ + children, + sx, +}: { + children: ReactNode; + sx?: ListSubheaderProps["sx"]; +}) => { + return ( + + {children} + + ); +}; + +SelectGroupHeader.muiSkipListHighlight = true; diff --git a/app/components/skip-link.tsx b/app/components/skip-link.tsx new file mode 100644 index 0000000000..fa6583abca --- /dev/null +++ b/app/components/skip-link.tsx @@ -0,0 +1,35 @@ +import { t } from "@lingui/macro"; +import { Link, Theme } from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import { visuallyHidden } from "@mui/utils"; + +export const MAIN_CONTENT_ID = "main"; + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + ...visuallyHidden, + + "&:focus": { + position: "static", + display: "inline-block", + width: "auto", + height: "auto", + margin: theme.spacing(2), + padding: theme.spacing(1, 2), + overflow: "visible", + clip: "auto", + whiteSpace: "normal", + ...theme.typography.body2, + }, + }, +})); + +export const SkipLink = () => { + const classes = useStyles(); + + return ( + + {t({ id: "skip-link.main-content", message: "Skip to main content" })} + + ); +}; diff --git a/app/configurator/components/chart-annotations/chart-annotations-selector.tsx b/app/configurator/components/chart-annotations/chart-annotations-selector.tsx index 73a20a48c4..1d5992a1fb 100644 --- a/app/configurator/components/chart-annotations/chart-annotations-selector.tsx +++ b/app/configurator/components/chart-annotations/chart-annotations-selector.tsx @@ -190,18 +190,19 @@ export const ChartAnnotationsSelector = () => { {isSegmented ? null : ( - + Style - + { id: "controls.fill-and-text", message: "Fill and text box", })} + name={`annotation-${annotation.key}-style`} value="filled" checked={annotation.highlightType === "filled"} disabled={!hasTargets} @@ -344,7 +346,7 @@ const AnnotationDrawer = ({ } return ( - +
void; sideControls?: ReactNode; disableLabel?: boolean; + labelId?: string; }) => { const locale = useLocale(); const [state] = useConfiguratorState(); @@ -157,13 +159,15 @@ export const DataFilterSelectGeneric = ({ data?.dataCubesComponents?.dimensions.find( (d) => d.cubeIri === rawDimension.cubeIri ) ?? rawDimension; + const { label } = dimension; + const id = `select-single-filter-${index}`; const sharedProps = { dimension, label: disableLabel ? null : ( <> - + {`${index + 1}. ${dimension.label}`} @@ -180,6 +184,10 @@ export const DataFilterSelectGeneric = ({ @@ -190,7 +198,7 @@ export const DataFilterSelectGeneric = ({ ), sideControls, - id: `select-single-filter-${index}`, + id, disabled: fetching || disabled, optional: !dimension.isKeyDimension, loading: fetching, @@ -209,7 +217,11 @@ export const DataFilterSelectGeneric = ({ ); } else { return ( - + ); } }; diff --git a/app/configurator/components/chart-controls/color-palette.tsx b/app/configurator/components/chart-controls/color-palette.tsx index a9f286a21e..b7bd9f4f56 100644 --- a/app/configurator/components/chart-controls/color-palette.tsx +++ b/app/configurator/components/chart-controls/color-palette.tsx @@ -2,20 +2,22 @@ import { Trans } from "@lingui/macro"; import { Box, Button, - Divider, MenuItem, + MenuItemProps, Select, Typography, } from "@mui/material"; import { makeStyles } from "@mui/styles"; +import { useId } from "@reach/auto-id"; import get from "lodash/get"; -import { MouseEventHandler, useMemo, useState } from "react"; +import { MouseEventHandler, ReactNode, useMemo, useState } from "react"; import { EncodingFieldType } from "@/charts/chart-config-ui-options"; import { hasDimensionColors } from "@/charts/shared/colors"; import { Flex } from "@/components/flex"; -import { selectMenuProps } from "@/components/form"; -import { Label } from "@/components/form"; +import { Label, selectMenuProps } from "@/components/form"; +import { SelectGroupHeader } from "@/components/select-group-header"; +import { VisuallyHidden } from "@/components/visually-hidden"; import { getChartConfig } from "@/config-utils"; import { ConfiguratorStateConfiguringChart, @@ -177,6 +179,7 @@ export const ColorPalette = ({ } }; + const labelId = `color-palette-label-${useId()}`; const [anchorEl, setAnchorEl] = useState(); const handleOpenCreateColorPalette: MouseEventHandler = useEvent( (e) => { @@ -224,10 +227,12 @@ export const ColorPalette = ({ }} >
-
+ ); }; diff --git a/app/login/components/profile-tables.tsx b/app/login/components/profile-tables.tsx index adb900953f..95c6093469 100644 --- a/app/login/components/profile-tables.tsx +++ b/app/login/components/profile-tables.tsx @@ -433,7 +433,7 @@ const ProfileVisualizationsRow = ({ })} - + - {shouldShowGQLDebug ? : null} + {shouldShowGQLDebug ? : null} diff --git a/app/utils/focus-first.ts b/app/utils/focus-first.ts new file mode 100644 index 0000000000..8ac3ec1f8c --- /dev/null +++ b/app/utils/focus-first.ts @@ -0,0 +1,3 @@ +export const focusFirst = (node: HTMLElement, selector: string) => { + node.querySelector(selector)?.focus(); +}; diff --git a/app/utils/observables.spec.ts b/app/utils/observables.spec.ts index 0c6d720b3d..6328fbeda0 100644 --- a/app/utils/observables.spec.ts +++ b/app/utils/observables.spec.ts @@ -49,6 +49,22 @@ describe("Timeline", () => { expect(timeline.domain).toEqual(values.map((d) => (d - min) / (max - min))); }); + it("should set the value by index", () => { + timeline.setIndex(3); + + expect(timeline.value).toEqual(500); + expect(timeline.position).toEqual(3); + expect(timeline.formattedValue).toEqual("500ms"); + }); + + it("should interpolate the position between values", () => { + timeline.setProgress(0.375); + + expect(timeline.position).toBeGreaterThan(2); + expect(timeline.position).toBeLessThan(3); + expect(timeline.length).toEqual(values.length); + }); + it("should set formatted extent", () => { expect(timeline.formattedExtent).toEqual([ formatValue(min), diff --git a/app/utils/observables.ts b/app/utils/observables.ts index 2ddff8aa82..5784a111b4 100644 --- a/app/utils/observables.ts +++ b/app/utils/observables.ts @@ -203,6 +203,12 @@ export class Timeline extends Observable { this.notify(); }; + public setIndex = (index: number) => { + this.msValue = this.msValues[index]; + this.animationProgress = this.msRelativeValues[index]; + this.notify(); + }; + private setValue = (progress: number) => { const ms = Math.round(this.msValueScale(progress)); const i = bisect(this.msValues, ms) - 1; @@ -213,6 +219,23 @@ export class Timeline extends Observable { return this.msRelativeValues; } + get length() { + return this.msValues.length; + } + + get position() { + const progress = this.progress; + const i = Math.max(0, bisect(this.msRelativeValues, progress) - 1); + const current = this.msRelativeValues[i]; + const next = this.msRelativeValues[i + 1]; + + if (next === undefined) { + return i; + } + + return i + (progress - current) / (next - current); + } + get value() { return this.type === "interval" ? this.msValue diff --git a/e2e/actions.ts b/e2e/actions.ts index bcc58f3613..9e0daf0117 100644 --- a/e2e/actions.ts +++ b/e2e/actions.ts @@ -119,7 +119,7 @@ export const createActions = ({ }, drawer: { close: async () => - await page.locator('button[aria-label="Back to main"]').click(), + await page.getByRole("button", { name: /^Back to main:/ }).click(), }, common: { switchLang: async (lang: "de" | "fr" | "en" | "it") => { diff --git a/e2e/color-palettes.spec.ts b/e2e/color-palettes.spec.ts index fa7bbf6baa..07b1457b66 100644 --- a/e2e/color-palettes.spec.ts +++ b/e2e/color-palettes.spec.ts @@ -14,14 +14,17 @@ describe("Color Picker Swatches", () => { await selectors.chart.loaded(); await actions.editor.selectActiveField("Segmentation"); await selectors.edition.drawerLoaded(); - await selectors.panels.drawer().getByRole("combobox", { name: "None" }).click(); + await selectors.panels + .drawer() + .getByRole("combobox", { name: "None" }) + .click(); await actions.mui.selectOption("Kanton"); await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await sleep(1_000); await page - .getByRole("button", { name: "Open Color Picker" }) + .getByRole("button", { name: /^Open color picker:/ }) .first() .click(); await sleep(1_000); @@ -53,7 +56,7 @@ describe("Color Picker Swatches", () => { await actions.editor.selectActiveField("Vertical Axis"); await page - .getByRole("button", { name: "Open Color Picker" }) + .getByRole("button", { name: /^Open color picker:/ }) .first() .click(); await sleep(1_000); diff --git a/e2e/custom-color-picker.spec.ts b/e2e/custom-color-picker.spec.ts index 6bb4b14c3b..069d2ae5ea 100644 --- a/e2e/custom-color-picker.spec.ts +++ b/e2e/custom-color-picker.spec.ts @@ -14,7 +14,10 @@ test("Custom Color Picker", async ({ page, selectors }) => { await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await sleep(1_000); - await page.getByRole("button", { name: "Open Color Picker" }).first().click(); + await page + .getByRole("button", { name: /^Open color picker:/ }) + .first() + .click(); await sleep(1_000); const colorSquare = page.getByTestId("color-square").first(); diff --git a/e2e/filter-position.spec.ts b/e2e/filter-position.spec.ts index ca8018f6bd..5c1c280db0 100644 --- a/e2e/filter-position.spec.ts +++ b/e2e/filter-position.spec.ts @@ -12,7 +12,9 @@ test("Filters should be sorted by position", async ({ selectors, actions }) => { await actions.editor.selectActiveField("Segmentation"); - const selectorLocator = selectors.panels.drawer().getByRole("combobox", { name: "None" }); + const selectorLocator = selectors.panels + .drawer() + .getByRole("combobox", { name: "None" }); await selectorLocator.click(); await actions.mui.selectOption("Status IUCN"); @@ -28,7 +30,7 @@ test("Filters should be sorted by position", async ({ selectors, actions }) => { const rawTexts = await filtersValueLocator.allTextContents(); const texts = rawTexts.map((x) => - x.replace("Open Color Picker", "").replace("Show value", "") + x.replace(/(Show value|Open color picker):.*/, "") ); expect(texts).toEqual([ "Data deficient", diff --git a/e2e/fixtures/column-two-charts-chart-config.json b/e2e/fixtures/column-two-charts-chart-config.json new file mode 100644 index 0000000000..6e2f568556 --- /dev/null +++ b/e2e/fixtures/column-two-charts-chart-config.json @@ -0,0 +1,232 @@ +{ + "state": "CONFIGURING_CHART", + "layout": { + "type": "tab", + "meta": { + "label": { + "de": "", + "en": "", + "fr": "", + "it": "" + }, + "title": { + "de": "", + "en": "", + "fr": "", + "it": "" + }, + "description": { + "de": "", + "en": "", + "fr": "", + "it": "" + } + }, + "blocks": [ + { + "type": "chart", + "key": "chart1", + "initialized": true + }, + { + "type": "chart", + "key": "chart2", + "initialized": true + } + ] + }, + "version": "4.2.0", + "dataSource": { + "url": "https://cached.lindas.admin.ch/query", + "type": "sparql" + }, + "chartConfigs": [ + { + "key": "chart1", + "meta": { + "label": { + "de": "", + "en": "", + "fr": "", + "it": "" + }, + "title": { + "de": "", + "en": "Column with filters", + "fr": "", + "it": "" + }, + "description": { + "de": "", + "en": "", + "fr": "", + "it": "" + } + }, + "cubes": [ + { + "iri": "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/10", + "filters": { + "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/Kanton": { + "type": "single", + "value": "https://ld.admin.ch/canton/1" + }, + "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/Jahr": { + "to": "VISUALIZE_MOST_RECENT_VALUE", + "from": "2015", + "type": "range" + } + } + } + ], + "fields": { + "x": { + "sorting": { + "sortingType": "byAuto", + "sortingOrder": "asc" + }, + "componentId": "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/Jahr" + }, + "y": { + "componentId": "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/AnzahlAnlagen" + }, + "color": { + "type": "single", + "color": "#006699", + "paletteId": "category10" + } + }, + "version": "4.1.0", + "chartType": "column", + "interactiveFiltersConfig": { + "legend": { + "active": false, + "componentId": "" + }, + "timeRange": { + "active": true, + "presets": { + "to": "VISUALIZE_MOST_RECENT_VALUE", + "from": "2015", + "type": "range" + }, + "componentId": "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/Jahr" + }, + "calculation": { + "type": "identity", + "active": false + }, + "dataFilters": { + "active": true, + "componentIds": [ + "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/Kanton" + ], + "defaultValueOverrides": {}, + "filterTypes": {}, + "defaultOpen": true + } + } + }, + { + "key": "chart2", + "meta": { + "label": { + "de": "", + "en": "", + "fr": "", + "it": "" + }, + "title": { + "de": "", + "en": "Column with filters", + "fr": "", + "it": "" + }, + "description": { + "de": "", + "en": "", + "fr": "", + "it": "" + } + }, + "cubes": [ + { + "iri": "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/10", + "filters": { + "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/Kanton": { + "type": "single", + "value": "https://ld.admin.ch/canton/1" + }, + "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/Jahr": { + "to": "VISUALIZE_MOST_RECENT_VALUE", + "from": "2015", + "type": "range" + } + } + } + ], + "fields": { + "x": { + "sorting": { + "sortingType": "byAuto", + "sortingOrder": "asc" + }, + "componentId": "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/Jahr" + }, + "y": { + "componentId": "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/AnzahlAnlagen" + }, + "color": { + "type": "single", + "color": "#006699", + "paletteId": "category10" + } + }, + "version": "4.1.0", + "chartType": "column", + "interactiveFiltersConfig": { + "legend": { + "active": false, + "componentId": "" + }, + "timeRange": { + "active": true, + "presets": { + "to": "VISUALIZE_MOST_RECENT_VALUE", + "from": "2015", + "type": "range" + }, + "componentId": "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/Jahr" + }, + "calculation": { + "type": "identity", + "active": false + }, + "dataFilters": { + "active": true, + "componentIds": [ + "https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen(VISUALIZE.ADMIN_COMPONENT_ID_SEPARATOR)https://energy.ld.admin.ch/sfoe/bfe_ogd84_einmalverguetung_fuer_photovoltaikanlagen/Kanton" + ], + "defaultValueOverrides": {}, + "filterTypes": {}, + "defaultOpen": true + } + } + } + ], + "activeChartKey": "chart1", + "dashboardFilters": { + "timeRange": { + "active": false, + "presets": { + "to": "", + "from": "" + }, + "timeUnit": "" + }, + "dataFilters": { + "filters": {}, + "componentIds": [] + } + } +} diff --git a/e2e/keyboard-widgets.spec.ts b/e2e/keyboard-widgets.spec.ts new file mode 100644 index 0000000000..f55a958b25 --- /dev/null +++ b/e2e/keyboard-widgets.spec.ts @@ -0,0 +1,258 @@ +import { Page } from "@playwright/test"; + +import { loadChartInLocalStorage } from "./charts-utils"; +import { setup } from "./common"; +import columnTwoCharts from "./fixtures/column-two-charts-chart-config.json"; + +const { expect, test } = setup(); + +const openEditor = async ({ + page, + activeField, + description = "", +}: { + page: Page; + activeField?: string; + description?: string; +}) => { + const key = `keyboard-${activeField ?? "main"}`; + const config = structuredClone(columnTwoCharts); + config.chartConfigs[0].activeField = activeField; + config.chartConfigs[0].meta.description.en = description; + await loadChartInLocalStorage(page, key, config); + await page.goto(`/en/create/${key}`); + await page.waitForSelector('[data-chart-loaded="true"]', { timeout: 30_000 }); +}; + +const focused = (page: Page) => page.locator(":focus"); + +test.describe("Keyboard operability", () => { + test("chart actions menu opens on its first item and activates items", async ({ + page, + }) => { + await openEditor({ page }); + await page.getByTestId("chart-more-button").first().focus(); + await page.keyboard.press("Enter"); + + await expect(focused(page)).toHaveAttribute("role", "menuitem"); + await expect(focused(page)).toHaveText("Duplicate"); + + await page.keyboard.press("ArrowDown"); + await expect(focused(page)).toHaveText("Table view"); + + await page.keyboard.press("Enter"); + await expect(page.locator("table").first()).toBeVisible(); + }); + + test("delete confirmation from a menu is reachable by Tab", async ({ + page, + }) => { + await openEditor({ page }); + await page + .getByTestId("chart-selection-tab") + .first() + .getByRole("button", { name: "More actions", exact: true }) + .focus(); + await page.keyboard.press("Enter"); + await page.keyboard.press("End"); + await expect(focused(page)).toHaveText("Delete"); + await page.keyboard.press("Enter"); + + const dialog = page.getByRole("dialog", { name: "Delete chart?" }); + await expect(dialog).toBeVisible(); + await expect(page.getByRole("menu")).toHaveCount(0); + + await page.keyboard.press("Tab"); + await expect(focused(page)).toHaveText("No"); + await page.keyboard.press("Tab"); + await expect(focused(page)).toHaveText("Yes"); + + await page.keyboard.press("Enter"); + await expect(page.getByTestId("chart-selection-tab")).toHaveCount(1); + }); + + test("add chart popover can add a chart by keyboard", async ({ page }) => { + await openEditor({ page }); + await page.getByRole("button", { name: "Add chart" }).focus(); + await page.keyboard.press("Enter"); + + const popover = page.getByRole("dialog", { name: "Add chart" }); + await expect(popover).toBeVisible(); + await expect(focused(page)).toHaveText("Columns"); + + await page.keyboard.press("Tab"); + await expect(focused(page)).toHaveText("Bars"); + await page.keyboard.press("Enter"); + + await expect(page.getByTestId("chart-selection-tab")).toHaveCount(3); + }); + + test("Tab leaves the rich text editor without inserting a tab", async ({ + page, + }) => { + await openEditor({ page, activeField: "title" }); + const editor = page.getByRole("textbox", { name: "English" }); + await editor.focus(); + await page.keyboard.press("End"); + await page.keyboard.press("Tab"); + + await expect(editor).not.toBeFocused(); + expect(await editor.evaluate((e) => e.textContent)).toBe( + "Column with filters" + ); + + await page.keyboard.press("Shift+Tab"); + await expect(editor).toBeFocused(); + + await page.keyboard.press("Shift+Tab"); + await expect(focused(page)).toHaveAttribute("aria-label", "Bold"); + await page.keyboard.press("ArrowRight"); + await expect(focused(page)).toHaveAttribute("aria-label", "Italic"); + }); + + test("saving a link returns focus to the editor", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await openEditor({ + page, + activeField: "description", + description: "See docs here", + }); + const editor = page.getByRole("textbox", { name: "English" }); + await editor.focus(); + await page.keyboard.press("Home"); + for (let i = 0; i < 4; i++) { + await page.keyboard.press("ArrowRight"); + } + for (let i = 0; i < 4; i++) { + await page.keyboard.press("Shift+ArrowRight"); + } + await page.waitForTimeout(300); + await page.keyboard.press("Shift+Tab"); + await page.keyboard.press("End"); + await expect(focused(page)).toHaveAttribute("aria-label", "Link"); + await page.keyboard.press("Enter"); + + const dialog = page.getByRole("dialog", { name: "Edit link" }); + await expect(dialog).toBeVisible(); + await page.keyboard.press("Tab"); + await page.keyboard.type("https://example.com"); + await page.keyboard.press("Tab"); + await page.keyboard.press("Tab"); + await expect(focused(page)).toHaveText("Save"); + await page.keyboard.press("Enter"); + + await expect(editor.getByRole("link", { name: "docs" })).toBeVisible(); + await page.waitForTimeout(1000); + await expect(editor).toBeFocused(); + }); + + test("color palette select works with the keyboard", async ({ page }) => { + await openEditor({ page, activeField: "segment" }); + const drawer = page.getByTestId("panel-drawer"); + await drawer.getByRole("combobox").first().focus(); + await page.keyboard.press("Enter"); + await page.getByRole("option", { name: "Kanton" }).focus(); + await page.keyboard.press("Enter"); + + const palette = drawer.getByRole("combobox", { name: /^Color palette/ }); + await expect(palette).toHaveAccessibleName("Color palette category10"); + await palette.focus(); + await page.keyboard.press("Enter"); + await expect(focused(page)).toHaveAttribute("role", "option"); + await expect( + page.getByRole("option", { name: /color palettes/ }) + ).toHaveCount(0); + await page.keyboard.press("ArrowDown"); + const next = await focused(page).getAttribute("data-value"); + await page.keyboard.press("Enter"); + + await expect(palette).toBeFocused(); + await expect(palette).toHaveAccessibleName(`Color palette ${next}`); + await expect + .poll(() => + page.evaluate( + () => + JSON.parse( + localStorage.getItem( + "vizualize-configurator-state:keyboard-segment" + ) ?? "{}" + ).chartConfigs[0].fields.color.paletteId + ) + ) + .toBe(next); + }); + + test("color picker is operable by keyboard", async ({ page }) => { + await openEditor({ page, activeField: "segment" }); + const drawer = page.getByTestId("panel-drawer"); + await drawer.getByRole("combobox").first().focus(); + await page.keyboard.press("Enter"); + await page.getByRole("option", { name: "Kanton" }).focus(); + await page.keyboard.press("Enter"); + + const opener = drawer.getByRole("button", { + name: "Open color picker: Zurich", + }); + await opener.focus(); + await page.keyboard.press("Enter"); + + const dialog = page.getByRole("dialog", { name: "Color picker" }); + await expect(dialog).toBeVisible(); + const saturation = dialog.getByRole("slider", { + name: "Saturation and brightness", + }); + await expect(saturation).toBeFocused(); + + const hex = dialog.getByRole("textbox", { name: "Hex color" }); + const before = await hex.inputValue(); + await page.keyboard.press("Shift+ArrowRight"); + await expect(hex).not.toHaveValue(before); + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(opener).toBeFocused(); + }); + + test("Escape closes the edit filters drawer", async ({ page }) => { + await openEditor({ page, activeField: "segment" }); + const drawer = page.getByTestId("panel-drawer"); + await drawer.getByRole("combobox").first().focus(); + await page.keyboard.press("Enter"); + await page.getByRole("option", { name: "Kanton" }).focus(); + await page.keyboard.press("Enter"); + + const editFilters = drawer.getByRole("button", { name: "Edit filters" }); + await editFilters.focus(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("edition-filters-drawer")).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(page.getByTestId("edition-filters-drawer")).toBeHidden(); + await expect(editFilters).toBeFocused(); + }); + + test("time range brush handles move with arrow keys", async ({ page }) => { + await openEditor({ page }); + const from = page.locator(".handle--w").first(); + await from.focus(); + const before = await from.getAttribute("aria-valuetext"); + + await page.keyboard.press("ArrowRight"); + await expect(from).not.toHaveAttribute("aria-valuetext", before!); + + await page.keyboard.press("ArrowLeft"); + await expect(from).toHaveAttribute("aria-valuetext", before!); + }); + + test("skip link moves focus to the main content", async ({ page }) => { + await page.goto("/en"); + await page + .getByRole("link", { name: "Skip to main content" }) + .waitFor({ state: "attached" }); + await page.keyboard.press("Tab"); + await expect(focused(page)).toHaveText("Skip to main content"); + + await page.keyboard.press("Enter"); + await expect(page.locator("main")).toBeFocused(); + }); +}); diff --git a/e2e/time-slider.spec.ts b/e2e/time-slider.spec.ts index 1a40c837e8..3d7f7ea94b 100644 --- a/e2e/time-slider.spec.ts +++ b/e2e/time-slider.spec.ts @@ -26,6 +26,30 @@ test("is should be possible to use time slider", async ({ expect(thumbX1).toBeLessThan(thumbX0); }); +test("time slider steps through values with the keyboard", async ({ + page, + selectors, +}) => { + const key = "time-slider-keyboard"; + await loadChartInLocalStorage(page, key, CONFIGURATOR_STATE); + await page.goto(`/en/create/${key}`); + await selectors.chart.loaded(); + + const slider = page.getByRole("slider", { name: "Jahr der Vergütung" }); + await slider.focus(); + await expect(slider).toHaveAttribute("aria-valuetext", "2023"); + + await page.keyboard.press("ArrowLeft"); + await expect(slider).toHaveAttribute("aria-valuetext", "2022"); + + await page.keyboard.press("Home"); + await expect(slider).toHaveAttribute("aria-valuetext", "2014"); + + await page.keyboard.press("End"); + await expect(slider).toHaveAttribute("aria-valuetext", "2023"); + await expect(page.getByRole("button", { name: "Play" })).toBeVisible(); +}); + const CONFIGURATOR_STATE = { version: "4.4.0", state: "CONFIGURING_CHART",