Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/components/Toggle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ import { BBBToggle } from 'bbb-ui-components-react';
<BBBToggle label="Enable Notifications" helperText="This is a toggle to enable the notifications." textPosition="top" />
```

### 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';

<BBBToggle label="Share webcam" disabled tooltipLabel="Webcams are locked by a moderator" />
```

## Props

| Property | Type | Default | Description |
Expand All @@ -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<HTMLInputElement>, 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<SwitchProps, 'inputProps'>` | | Any other props will be passed down to the underlying Material-UI Switch component. Use `slotProps.input` to set attributes on the `<input>`. |
53 changes: 51 additions & 2 deletions src/components/Toggle/component.stories.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
},
};
165 changes: 134 additions & 31 deletions src/components/Toggle/component.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) => 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 = <T,>(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.
Expand All @@ -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<HTMLSpanElement>(null);
const helperTextRef = useRef<HTMLSpanElement>(null);
const switchWrapperRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [tooltipTriggers, setTooltipTriggers] = useState<HTMLElement[]>([]);

// 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<HTMLElement>(`.${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 <label htmlFor> names the input from the helper text,
// so it can't also be the description.
const hasNameSource = !!ariaLabel || !!ariaLabelledByValue;
// so it can't also be the description. The tooltip text follows the same rule when
// neither a label nor the helper text names the toggle.
const hasNameSource = !!ariaLabel || !!explicitLabelledBy;
const tooltipNamesToggle = Boolean(tooltipLabel && !hasNameSource && !helperText);
const a11yInputProps = {
role: 'switch',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledByValue,
'aria-describedby': ariaDescribedBy || (hasNameSource ? helperTextId : undefined),
'aria-labelledby': tooltipNamesToggle ? tooltipDescriptionId : explicitLabelledBy,
'aria-describedby': [
ariaDescribedBy || (hasNameSource ? helperTextId : undefined),
!tooltipNamesToggle && tooltipDescriptionId,
].filter(Boolean).join(' ') || undefined,
};
// A natively disabled input leaves the tab order, so keyboard users could never reach the
// tooltip that explains why it is disabled. With a tooltip, the input stays focusable and is
// announced through aria-disabled instead; dropping `name` and `required` keeps it out of form
// submission and validation, as a natively disabled input would be.
const focusableWhileDisabled = Boolean(disabled && tooltipLabel);
const focusableDisabledInputProps = focusableWhileDisabled
? {
disabled: false,
'aria-disabled': true,
name: undefined,
required: false,
}
: {};
const { input: consumerInputSlotProps, ...otherSlotProps } = slotProps ?? {};
const inputSlotProps = typeof consumerInputSlotProps === 'function'
? (ownerState: SwitchOwnerState) => ({ ...a11yInputProps, ...consumerInputSlotProps(ownerState) })
: { ...a11yInputProps, ...consumerInputSlotProps };
? (ownerState: SwitchOwnerState) => ({
...a11yInputProps,
...consumerInputSlotProps(ownerState),
...focusableDisabledInputProps,
})
: { ...a11yInputProps, ...consumerInputSlotProps, ...focusableDisabledInputProps };

// Tippy sits beside the wrapper instead of wrapping it, so toggling `tooltipLabel` keeps
// the same root and doesn't remount the toggle (losing focus and uncontrolled state).
// The switch root, not the input, triggers it: MUI gives the disabled switch's input
// `pointer-events: none`, so hovering it reaches the root. `focusin` bubbles from the
// input; Tippy's `focus` would not.
return (
<Styled.ToggleWrapper $textPosition={textPosition} $fullWidth={isFullWidth}>
{(label || helperText) && (
<Styled.TextWrapper
$textPosition={textPosition}
$fullWidth={isFullWidth}
htmlFor={id}
$hasLabel={!!label}
$hasHelperText={!!helperText}
>
{label && <Styled.Title id={labelId}>{label}</Styled.Title>}
{helperText && <Styled.HelperText id={helperTextId}>{helperText}</Styled.HelperText>}
</Styled.TextWrapper>
)}
<Styled.ToggleSwitchWrapper>
<Styled.MaterialToggle
{...rest}
id={id}
onChange={onChange}
slotProps={{ ...otherSlotProps, input: inputSlotProps }}
<>
<Styled.ToggleWrapper ref={wrapperRef} $textPosition={textPosition} $fullWidth={isFullWidth}>
{(label || helperText) && (
<Styled.TextWrapper
$textPosition={textPosition}
$fullWidth={isFullWidth}
htmlFor={id}
$hasLabel={!!label}
$hasHelperText={!!helperText}
$disabled={disabled}
>
{label && <Styled.Title ref={titleRef} id={labelId} $disabled={disabled}>{label}</Styled.Title>}
{helperText && (
<Styled.HelperText ref={helperTextRef} id={helperTextId} $disabled={disabled}>{helperText}</Styled.HelperText>
)}
</Styled.TextWrapper>
)}
<Styled.ToggleSwitchWrapper ref={switchWrapperRef}>
<Styled.MaterialToggle
{...rest}
id={id}
disabled={disabled}
{...(focusableWhileDisabled && { onClick: preventToggle })}
onChange={onChange}
slotProps={{ ...otherSlotProps, input: inputSlotProps }}
/>
</Styled.ToggleSwitchWrapper>
{/* aria-describedby needs the text at all times; Tippy mounts it only while shown. */}
{tooltipLabel && (
<span id={tooltipDescriptionId} hidden>{tooltipLabel}</span>
)}
</Styled.ToggleWrapper>
{tooltipLabel && (
<Tippy
reference={wrapperRef}
content={tooltipLabel}
placement={tooltipPlacement}
trigger="mouseenter focusin"
triggerTarget={tooltipTriggers.length > 0 ? tooltipTriggers : null}
getReferenceClientRect={getTooltipAnchorRect}
delay={TOOLTIP_DELAY}
aria={{ content: null }}
/>
</Styled.ToggleSwitchWrapper>
</Styled.ToggleWrapper>
)}
</>
);
}

Expand Down
9 changes: 9 additions & 0 deletions src/components/Toggle/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,12 @@ export const TEXT_POSITIONS = {

export const TEXT_POSITIONS_VALUES = Object.values(TEXT_POSITIONS);
export const DEFAULT_TEXT_POSITION = TEXT_POSITIONS_VALUES[0];

export const TOOLTIP_PLACEMENTS = {
TOP: 'top',
BOTTOM: 'bottom',
LEFT: 'left',
RIGHT: 'right',
} as const;
export const TOOLTIP_PLACEMENT_VALUES = Object.values(TOOLTIP_PLACEMENTS);
export const DEFAULT_TOOLTIP_PLACEMENT = TOOLTIP_PLACEMENTS.TOP;
Loading
Loading