diff --git a/src/components/Toggle/README.md b/src/components/Toggle/README.md index 3d59e07..608f6ea 100644 --- a/src/components/Toggle/README.md +++ b/src/components/Toggle/README.md @@ -41,6 +41,18 @@ import { BBBToggle } from 'bbb-ui-components-react'; ``` +### Toggle with Tooltip + +Useful to explain why a disabled toggle can't be changed. The tooltip shows on hover and keyboard focus, and its text is also exposed to screen readers as part of the toggle's description; when there is no `label`, `ariaLabel`, `ariaLabelledBy` or `helperText`, it names the toggle instead. + +A disabled toggle with a `tooltipLabel` stays in the tab order so keyboard users can reach the tooltip: the input gets `aria-disabled="true"` instead of the native `disabled` attribute, and clicks, label clicks and Space are ignored. Like a natively disabled input, it is left out of form submission and validation: its `name` and `required` are dropped while it is disabled. + +```jsx +import { BBBToggle } from 'bbb-ui-components-react'; + + +``` + ## Props | Property | Type | Default | Description | @@ -49,9 +61,11 @@ import { BBBToggle } from 'bbb-ui-components-react'; | `helperText` | `string` | | The helper text displayed below the label. | | `textPosition` | `keyof typeof TEXT_POSITIONS` | `'right'` | The position of the text labels relative to the toggle switch. | | `fullWidth` | `boolean` | `false` | If `true`, the toggle stretches to its container, putting the text and the switch at opposite ends. Only used with `textPosition` `'left'` (text at the start) or `'right'` (text at the end). | -| `disabled` | `boolean` | `false` | If `true`, the toggle will be disabled and unresponsive. | +| `disabled` | `boolean` | `false` | If `true`, the toggle is disabled and unresponsive, and its label and helper text are dimmed. | | `onChange` | `(event: React.ChangeEvent, checked: boolean) => void` | | Callback function that is fired when the toggle state changes. | | `ariaLabel` | `string` | | The accessible name for the toggle. | | `ariaLabelledBy` | `string` | | The ID of the element that labels the toggle. | | `ariaDescribedBy` | `string` | | The ID of the element that describes the toggle. | +| `tooltipLabel` | `string` | | Text shown in a tooltip on hover and keyboard focus. When set, a disabled toggle stays focusable (`aria-disabled`) so the tooltip can be reached. | +| `tooltipPlacement` | `keyof typeof TOOLTIP_PLACEMENTS` | `'top'` | The placement of the tooltip relative to the toggle. | | `...props` | `Omit` | | Any other props will be passed down to the underlying Material-UI Switch component. Use `slotProps.input` to set attributes on the ``. | diff --git a/src/components/Toggle/component.stories.tsx b/src/components/Toggle/component.stories.tsx index 2f32c4e..5956dfd 100644 --- a/src/components/Toggle/component.stories.tsx +++ b/src/components/Toggle/component.stories.tsx @@ -1,7 +1,12 @@ import React from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import BBBToggle from './component'; -import { TEXT_POSITIONS_VALUES, DEFAULT_TEXT_POSITION } from './constants'; +import { + TEXT_POSITIONS_VALUES, + DEFAULT_TEXT_POSITION, + TOOLTIP_PLACEMENT_VALUES, + DEFAULT_TOOLTIP_PLACEMENT, +} from './constants'; const meta = { title: 'BBBToggle', @@ -33,7 +38,18 @@ const meta = { }, disabled: { control: 'boolean', - description: 'Disable the toggle when true.', + description: 'Disables the toggle and dims its label and helper text when true.', + table: { defaultValue: { summary: 'false' } }, + }, + tooltipLabel: { + control: 'text', + description: 'Text shown in a tooltip on hover and keyboard focus; when set, a disabled toggle stays focusable (`aria-disabled`) so the tooltip can be reached.', + }, + tooltipPlacement: { + control: 'select', + options: TOOLTIP_PLACEMENT_VALUES, + description: 'Placement of the tooltip when `tooltipLabel` is provided.', + table: { defaultValue: { summary: `${DEFAULT_TOOLTIP_PLACEMENT}` } }, }, onChange: { control: false, @@ -68,3 +84,36 @@ export const FullWidthTextRight: Story = { ...FullWidth, args: { ...FullWidth.args, textPosition: 'right' }, }; +/** Disabled toggle: the switch, label and helper text are dimmed and the text shows a not-allowed cursor. */ +export const Disabled: Story = { + args: { + label: 'Enable', + helperText: 'Recommended', + disabled: true, + }, +}; +/** Disabled toggle in the on state: the checked track is dimmed like the unchecked one. */ +export const DisabledChecked: Story = { + args: { + label: 'Enable', + helperText: 'Recommended', + disabled: true, + defaultChecked: true, + }, +}; +/** Shows a tooltip on hover and keyboard focus via `tooltipLabel`. */ +export const WithTooltip: Story = { + args: { + label: 'Enable', + tooltipLabel: 'Extra information about this option', + }, +}; +/** Disabled toggle that stays keyboard-focusable so its tooltip can explain why the option is unavailable. */ +export const DisabledWithTooltip: Story = { + args: { + label: 'Enable', + helperText: 'Recommended', + disabled: true, + tooltipLabel: 'Only moderators can change this option', + }, +}; diff --git a/src/components/Toggle/component.tsx b/src/components/Toggle/component.tsx index 21f7899..71bb040 100644 --- a/src/components/Toggle/component.tsx +++ b/src/components/Toggle/component.tsx @@ -1,8 +1,32 @@ -import React, { JSX, useId } from 'react'; -import { SwitchOwnerState } from '@mui/material'; +import React, { + JSX, useEffect, useId, useRef, useState, +} from 'react'; +import { SwitchOwnerState, switchClasses } from '@mui/material'; import { ToggleProps } from './types'; import * as Styled from './styles'; -import { DEFAULT_TEXT_POSITION, TEXT_POSITIONS } from './constants'; +import { DEFAULT_TEXT_POSITION, DEFAULT_TOOLTIP_PLACEMENT, TEXT_POSITIONS } from './constants'; +import Tippy from '@tippyjs/react'; +import 'tippy.js/dist/tippy.css'; + +// 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 +// passed through `slotProps.input`, except `onChange`. +const preventToggle = (event: React.MouseEvent) => event.preventDefault(); + +// Bridges the gap between the text and the switch, which are separate tooltip triggers, +// so moving the pointer from one to the other doesn't hide and reshow the tooltip. +const TOOLTIP_DELAY: [number, number] = [0, 100]; + +const isElement = (element: T | null): element is T => element !== null; + +const getBoundingRect = (elements: HTMLElement[]) => { + const rects = elements.map((element) => element.getBoundingClientRect()); + const left = Math.min(...rects.map((rect) => rect.left)); + const top = Math.min(...rects.map((rect) => rect.top)); + const right = Math.max(...rects.map((rect) => rect.right)); + const bottom = Math.max(...rects.map((rect) => rect.bottom)); + return new DOMRect(left, top, right - left, bottom - top); +}; /** * A customizable Toggle switch component. @@ -16,58 +40,137 @@ function Toggle ({ helperText, textPosition = DEFAULT_TEXT_POSITION, fullWidth = false, + disabled, onChange, ariaLabel, ariaLabelledBy, ariaDescribedBy, id: providedId, slotProps, + tooltipLabel, + tooltipPlacement = DEFAULT_TOOLTIP_PLACEMENT, ...rest }: ToggleProps): JSX.Element { const generatedUniqueId = useId(); + const titleRef = useRef(null); + const helperTextRef = useRef(null); + const switchWrapperRef = useRef(null); + const wrapperRef = useRef(null); + const [tooltipTriggers, setTooltipTriggers] = useState([]); + + // MUI's Switch ref points at its inner button, which a disabled switch hides from the + // pointer; the root, which holds the track, is looked up instead. + const getSwitchRoot = () => ( + switchWrapperRef.current?.querySelector(`.${switchClasses.root}`) ?? null + ); + + // The wrapper spans the whole row, so it can't be the tooltip's trigger or anchor: the + // tooltip would open over empty space and center on the row. The text and the switch + // trigger it instead, and it points at the text, or at the switch when there is none. + useEffect(() => { + if (!tooltipLabel) return; + setTooltipTriggers([titleRef.current, helperTextRef.current, getSwitchRoot()].filter(isElement)); + }, [tooltipLabel, label, helperText]); + + const getTooltipAnchorRect = () => { + const textElements = [titleRef.current, helperTextRef.current].filter(isElement); + const anchorElements = textElements.length > 0 ? textElements : [getSwitchRoot()].filter(isElement); + return anchorElements.length > 0 ? getBoundingRect(anchorElements) : new DOMRect(); + }; const id = providedId ?? generatedUniqueId; const labelId = label ? `${id}-label` : undefined; const helperTextId = helperText ? `${id}-helper` : undefined; + const tooltipDescriptionId = tooltipLabel ? `${id}-tooltip` : undefined; const isFullWidth = fullWidth && (textPosition === TEXT_POSITIONS.LEFT || textPosition === TEXT_POSITIONS.RIGHT); - const ariaLabelledByValue = ariaLabel ? undefined : ariaLabelledBy || labelId; + const explicitLabelledBy = ariaLabel ? undefined : ariaLabelledBy || labelId; // Without a name source, the