diff --git a/src/components/Accordion/component.tsx b/src/components/Accordion/component.tsx index 9524240..2400c19 100644 --- a/src/components/Accordion/component.tsx +++ b/src/components/Accordion/component.tsx @@ -4,6 +4,7 @@ import * as Styled from './styles'; import { MdExpandMore } from 'react-icons/md'; import Tippy from '@tippyjs/react'; import 'tippy.js/dist/tippy.css'; +import { TOOLTIP_PLUGINS } from '../../utils/tooltip'; import { DEFAULT_TOOLTIP_PLACEMENT, DEFAULT_BUTTON_HEADER_POSITION } from './constants'; /** @@ -49,7 +50,7 @@ function Accordion({ return ( <> {tooltipLabel ? ( - + {expandButton} ) : ( diff --git a/src/components/Avatar/component.tsx b/src/components/Avatar/component.tsx index fe2e161..d77b7f0 100644 --- a/src/components/Avatar/component.tsx +++ b/src/components/Avatar/component.tsx @@ -1,6 +1,7 @@ import React, { JSX, useEffect, useState } from 'react'; import Tippy from '@tippyjs/react'; import 'tippy.js/dist/tippy.css'; +import { TOOLTIP_PLUGINS } from '../../utils/tooltip'; import * as Styled from './styles'; import { AvatarProps } from './types'; import { DEFAULT_AVATAR_SIZE, AVATAR_FALLBACK_COLORS } from './constants'; @@ -63,7 +64,7 @@ function Avatar({ } return ( - + {avatarElement} ); diff --git a/src/components/Button/component.tsx b/src/components/Button/component.tsx index acf13a8..b3b1d01 100644 --- a/src/components/Button/component.tsx +++ b/src/components/Button/component.tsx @@ -15,6 +15,7 @@ import { import * as Styled from './styles'; import Tippy from '@tippyjs/react'; import 'tippy.js/dist/tippy.css'; +import { TOOLTIP_PLUGINS } from '../../utils/tooltip'; /** * A versatile Button component. @@ -218,6 +219,7 @@ function Button(props: ButtonProps): JSX.Element { {buttonElement} diff --git a/src/components/Checkbox/component.tsx b/src/components/Checkbox/component.tsx index 751efcc..8368265 100644 --- a/src/components/Checkbox/component.tsx +++ b/src/components/Checkbox/component.tsx @@ -6,6 +6,7 @@ import { DEFAULT_LAYOUT, DEFAULT_TOOLTIP_PLACEMENT, LAYOUTS } from './constants' import { RadioButtonUnchecked, RadioButtonChecked } from '@mui/icons-material'; import Tippy from '@tippyjs/react'; import 'tippy.js/dist/tippy.css'; +import { TOOLTIP_PLUGINS } from '../../utils/tooltip'; // Cancelling the click (mouse, label or Space) keeps the box unchanged, and MUI skips // `onChange` for a prevented click. It sits on the root because MUI drops event handlers @@ -128,6 +129,7 @@ function Checkbox({ placement={tooltipPlacement} trigger="mouseenter focusin" aria={{ content: null }} + plugins={TOOLTIP_PLUGINS} /> )} diff --git a/src/components/Slider/component.tsx b/src/components/Slider/component.tsx index 6f21302..1cb642a 100644 --- a/src/components/Slider/component.tsx +++ b/src/components/Slider/component.tsx @@ -3,6 +3,7 @@ import { createTheme, ThemeProvider, useTheme } from '@mui/material/styles'; import { SliderProps } from './types'; import * as Styled from './styles'; import { DEFAULT_ANIMATE, DEFAULT_SHOW_MARK_TICKS, DEFAULT_VALUE_LABEL_DISPLAY } from './constants'; +import DismissibleValueLabel from './valueLabel'; /** * A horizontal, single-thumb slider for picking a value inside a range. @@ -26,6 +27,7 @@ function Slider ({ ariaLabel, ariaLabelledBy, ariaDescribedBy, + slots, slotProps, ...rest }: SliderProps): JSX.Element { @@ -90,6 +92,7 @@ function Slider ({ aria-label={ariaLabel} aria-labelledby={ariaLabel ? undefined : ariaLabelledBy || labelId} data-test={dataTest} + slots={{ valueLabel: DismissibleValueLabel, ...slots }} slotProps={{ ...slotProps, input: { diff --git a/src/components/Slider/valueLabel.tsx b/src/components/Slider/valueLabel.tsx new file mode 100644 index 0000000..168315b --- /dev/null +++ b/src/components/Slider/valueLabel.tsx @@ -0,0 +1,33 @@ +import React, { JSX, useEffect, useState } from 'react'; +import { SliderValueLabel, SliderValueLabelProps } from '@mui/material'; +import { ValueLabelDisplay } from './types'; +import { VALUE_LABEL_DISPLAYS } from './constants'; + +// WCAG 1.4.13 requires a tooltip to be dismissible without moving the pointer or focus, and +// MUI keeps the label's open state internal with no Escape handling. A label that is always +// shown ('on') is not hover content, so it stays. +function DismissibleValueLabel({ + open, + ...props +}: SliderValueLabelProps & { valueLabelDisplay?: ValueLabelDisplay }): JSX.Element { + const [dismissed, setDismissed] = useState(false); + const dismissible = open && props.valueLabelDisplay !== VALUE_LABEL_DISPLAYS.ON; + + useEffect(() => { + if (!dismissible) { + setDismissed(false); + return undefined; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setDismissed(true); + }; + document.addEventListener('keydown', onKeyDown, true); + + return () => document.removeEventListener('keydown', onKeyDown, true); + }, [dismissible]); + + return ; +} + +export default DismissibleValueLabel; diff --git a/src/components/Toggle/component.tsx b/src/components/Toggle/component.tsx index 71bb040..f151b9a 100644 --- a/src/components/Toggle/component.tsx +++ b/src/components/Toggle/component.tsx @@ -7,6 +7,7 @@ import * as Styled from './styles'; import { DEFAULT_TEXT_POSITION, DEFAULT_TOOLTIP_PLACEMENT, TEXT_POSITIONS } from './constants'; import Tippy from '@tippyjs/react'; import 'tippy.js/dist/tippy.css'; +import { TOOLTIP_PLUGINS } from '../../utils/tooltip'; // Cancelling the click (mouse, label or Space) keeps the switch unchanged, and MUI skips // `onChange` for a prevented click. It sits on the switch because MUI drops event handlers @@ -168,6 +169,7 @@ function Toggle ({ getReferenceClientRect={getTooltipAnchorRect} delay={TOOLTIP_DELAY} aria={{ content: null }} + plugins={TOOLTIP_PLUGINS} /> )} diff --git a/src/utils/tooltip.ts b/src/utils/tooltip.ts new file mode 100644 index 0000000..15f6a97 --- /dev/null +++ b/src/utils/tooltip.ts @@ -0,0 +1,23 @@ +import type { Plugin } from 'tippy.js'; + +// WCAG 1.4.13 requires a tooltip to be dismissible without moving the pointer or focus. +export const hideOnEsc: Plugin = { + name: 'hideOnEsc', + defaultValue: true, + fn({ hide }) { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') hide(); + }; + const stopListening = () => document.removeEventListener('keydown', onKeyDown, true); + + return { + onShow() { + document.addEventListener('keydown', onKeyDown, true); + }, + onHide: stopListening, + onDestroy: stopListening, + }; + }, +}; + +export const TOOLTIP_PLUGINS = [hideOnEsc];