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