From 11c1362a7c782a314b048448200d4fc2b459a89e Mon Sep 17 00:00:00 2001 From: Arthurk12 Date: Mon, 9 Nov 2026 21:50:17 -0300 Subject: [PATCH 1/4] fix(BBBToggle): show a visible outline on keyboard focus When BBBToggle is focused from the keyboard, MUI's focus ripple only tints the small thumb. That faint halo is the only focus cue, which falls short of WCAG 2.4.7 (Focus Visible). Draw a 2px colorBrand1 outline, offset by 2px, around the switch while it holds `.Mui-focusVisible`, the same treatment BBButton and BBBCheckbox use, so mouse clicks don't show it. The outline goes on the switch root because the track is clipped by the root's overflow. The root gets the track's border radius only while focused: a permanent radius would clip the track's anti-aliased edge a second time and slightly change how an unfocused toggle renders. --- src/components/Toggle/styles.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/Toggle/styles.ts b/src/components/Toggle/styles.ts index 2121f6f..0d5fb75 100644 --- a/src/components/Toggle/styles.ts +++ b/src/components/Toggle/styles.ts @@ -92,11 +92,18 @@ export const HelperText = styled.span` line-height: normal; `; +// MUI's focus ripple only tints the small thumb, so the outline is what makes keyboard +// focus visible. It goes on the root: the track is clipped by the root's overflow. export const MaterialToggle = materialStyled(Switch)(({ theme }) => ({ width: '2.3rem', height: '1.2rem', padding: 0, display: 'flex', + '&:has(.Mui-focusVisible)': { + borderRadius: '0.6rem', + outline: `2px solid ${colorBrand1}`, + outlineOffset: '2px', + }, '&:active': { '& .MuiSwitch-switchBase.Mui-checked': { transform: 'translateX(1.2rem)', From efa5b496c87c503efa8b65008591f01fd6abf652 Mon Sep 17 00:00:00 2001 From: Arthurk12 Date: Mon, 9 Nov 2026 21:50:39 -0300 Subject: [PATCH 2/4] fix(BBBToggle): dim the label and helper text when disabled The component does not pass `disabled` to its text. With `disabled`, MUI dims the switch, but the label and helper text keep their regular colors and the text keeps `cursor: pointer`, so they look and point as if the toggle could still be changed. Destructure `disabled`, pass it on to the MUI switch and, as a transient `$disabled` prop, to the text wrapper, the label and the helper text. Disabled text gets `cursor: not-allowed` and colorNeutral3, the same color BBButton and BBBCheckbox use for disabled text. Also give the Storybook `disabled` control its default value, reword it and the README row to mention the dimmed text, and add a Disabled story. --- src/components/Toggle/README.md | 2 +- src/components/Toggle/component.stories.tsx | 11 ++++++++++- src/components/Toggle/component.tsx | 9 +++++++-- src/components/Toggle/styles.ts | 14 +++++++------- src/components/Toggle/types.ts | 5 +++++ 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/components/Toggle/README.md b/src/components/Toggle/README.md index 3d59e07..2f055c2 100644 --- a/src/components/Toggle/README.md +++ b/src/components/Toggle/README.md @@ -49,7 +49,7 @@ 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. | diff --git a/src/components/Toggle/component.stories.tsx b/src/components/Toggle/component.stories.tsx index 2f32c4e..12296a4 100644 --- a/src/components/Toggle/component.stories.tsx +++ b/src/components/Toggle/component.stories.tsx @@ -33,7 +33,8 @@ 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' } }, }, onChange: { control: false, @@ -68,3 +69,11 @@ 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, + }, +}; diff --git a/src/components/Toggle/component.tsx b/src/components/Toggle/component.tsx index 21f7899..63683c5 100644 --- a/src/components/Toggle/component.tsx +++ b/src/components/Toggle/component.tsx @@ -16,6 +16,7 @@ function Toggle ({ helperText, textPosition = DEFAULT_TEXT_POSITION, fullWidth = false, + disabled, onChange, ariaLabel, ariaLabelledBy, @@ -54,15 +55,19 @@ function Toggle ({ htmlFor={id} $hasLabel={!!label} $hasHelperText={!!helperText} + $disabled={disabled} > - {label && {label}} - {helperText && {helperText}} + {label && {label}} + {helperText && ( + {helperText} + )} )} diff --git a/src/components/Toggle/styles.ts b/src/components/Toggle/styles.ts index 0d5fb75..e8b14c2 100644 --- a/src/components/Toggle/styles.ts +++ b/src/components/Toggle/styles.ts @@ -3,10 +3,10 @@ import { styled as materialStyled } from '@mui/material/styles'; import styled, { css } from 'styled-components'; import { colorBrand1, colorIconDefault, colorTextDefault, colorTextLight, colorWhite, - colorShadowDefault, colorIconDefaultDark, + colorShadowDefault, colorIconDefaultDark, colorNeutral3, } from '../../stylesheets/palette'; import { TEXT_POSITIONS } from './constants'; -import { StyledTextWrapperProps, StyledToggleWrapperProps } from './types'; +import { StyledTextProps, StyledTextWrapperProps, StyledToggleWrapperProps } from './types'; import { fontSizeBig, fontSizeDefault } from '../../stylesheets/typography'; export const ToggleWrapper = styled.div` @@ -53,7 +53,7 @@ export const TextWrapper = styled.label` } return 1; }}; - cursor: pointer; + cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')}; ${({ $hasLabel, $hasHelperText }) => (!$hasLabel || !$hasHelperText) && `justify-content: center;`} ${({ $textPosition, $fullWidth }) => { if ($textPosition === TEXT_POSITIONS.TOP || $textPosition === TEXT_POSITIONS.BOTTOM) { @@ -76,16 +76,16 @@ export const TextWrapper = styled.label` }}; `; -export const Title = styled.span` - color: ${colorTextDefault}; +export const Title = styled.span` + color: ${({ $disabled }) => ($disabled ? colorNeutral3 : colorTextDefault)}; font-size: ${fontSizeBig}; font-style: normal; font-weight: 400; line-height: normal; `; -export const HelperText = styled.span` - color: ${colorTextLight}; +export const HelperText = styled.span` + color: ${({ $disabled }) => ($disabled ? colorNeutral3 : colorTextLight)}; font-size: ${fontSizeDefault}; font-style: normal; font-weight: 400; diff --git a/src/components/Toggle/types.ts b/src/components/Toggle/types.ts index 38038d4..5a786a2 100644 --- a/src/components/Toggle/types.ts +++ b/src/components/Toggle/types.ts @@ -13,6 +13,11 @@ export interface StyledToggleWrapperProps { export interface StyledTextWrapperProps extends StyledToggleWrapperProps{ $hasLabel: boolean; $hasHelperText: boolean; + $disabled?: boolean; +} + +export interface StyledTextProps { + $disabled?: boolean; } export interface ToggleProps extends Omit { From bd0ccbf5ecffa54793ccdc2d9d45f29ad19d80f8 Mon Sep 17 00:00:00 2001 From: Arthurk12 Date: Mon, 9 Nov 2026 21:50:57 -0300 Subject: [PATCH 3/4] fix(BBBToggle): dim the checked track when disabled A disabled BBBToggle in the on state looks enabled: its track stays at full brand color. The checked track's `opacity: 1` override outranks the opacity MUI applies to a disabled track, so only the unchecked track is dimmed. Restate MUI's disabled track opacity (0.12 in the light theme, 0.2 in the dark one) for a checked, disabled switch, so both states dim the same way. Add a DisabledChecked story. --- src/components/Toggle/component.stories.tsx | 9 +++++++++ src/components/Toggle/styles.ts | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/src/components/Toggle/component.stories.tsx b/src/components/Toggle/component.stories.tsx index 12296a4..11dbd2a 100644 --- a/src/components/Toggle/component.stories.tsx +++ b/src/components/Toggle/component.stories.tsx @@ -77,3 +77,12 @@ export const Disabled: Story = { 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, + }, +}; diff --git a/src/components/Toggle/styles.ts b/src/components/Toggle/styles.ts index e8b14c2..49cc551 100644 --- a/src/components/Toggle/styles.ts +++ b/src/components/Toggle/styles.ts @@ -92,6 +92,9 @@ export const HelperText = styled.span` line-height: normal; `; +// The checked track's `opacity: 1` outranks MUI's disabled track opacity, so the disabled +// opacity (MUI's light/dark values) is restated for a checked, disabled switch. +// // MUI's focus ripple only tints the small thumb, so the outline is what makes keyboard // focus visible. It goes on the root: the track is clipped by the root's overflow. export const MaterialToggle = materialStyled(Switch)(({ theme }) => ({ @@ -121,6 +124,12 @@ export const MaterialToggle = materialStyled(Switch)(({ theme }) => ({ backgroundColor: colorBrand1, }), }, + '&.Mui-disabled + .MuiSwitch-track': { + opacity: 0.12, + ...theme.applyStyles('dark', { + opacity: 0.2, + }), + }, }, }, '& .MuiSwitch-thumb': { From 5de415274c5cd9bfba4d256629e741639c44df69 Mon Sep 17 00:00:00 2001 From: Arthurk12 Date: Mon, 9 Nov 2026 21:51:16 -0300 Subject: [PATCH 4/4] feat(BBBToggle): add tooltipLabel and tooltipPlacement props BBBToggle has no way to show a tooltip. When a toggle is disabled, users get no explanation of why they can't change it. BBButton, BBBAccordion and BBBCheckbox already offer this through `tooltipLabel` and `tooltipPlacement`. Add the same two props, backed by Tippy and wired as in BBBCheckbox. Unlike BBBCheckbox's container, the toggle's wrapper spans the whole row, so it can't trigger or anchor the tooltip: the tooltip would open over empty space and center on the row. The text and the switch root trigger it instead, and it points at the text, or at the switch when there is no text. A 100ms hide delay bridges the gap between the text and the switch, so crossing it doesn't hide and reshow the tooltip. Tippy is rendered beside the wrapper and pointed at it through `reference`, instead of wrapping it: wrapping would change the root element whenever `tooltipLabel` is added or removed, and React would remount the toggle, dropping its focus and resetting an uncontrolled toggle to `defaultChecked`. The switch root is looked up through MUI's `switchClasses.root`: the Switch ref points at its inner button, which a disabled switch hides from the pointer with `pointer-events: none`. Passing a ref through `slotProps.root` was discarded, as it could override a consumer's own root slot props. The tooltip opens on `mouseenter` and `focusin`, because `focusin` bubbles up from the input and Tippy's `focus` trigger does not. The tooltip text is also kept in a `hidden` element that the input's `aria-describedby` points to, after the helper text or `ariaDescribedBy`, since Tippy only mounts its content while the tooltip is shown. A visually hidden copy was discarded: it is still page text, so screen readers would read the tooltip twice in browse mode. When nothing else names the toggle (no `label`, `ariaLabel`, `ariaLabelledBy` or `helperText`), the tooltip text names it through `aria-labelledby` instead of describing it, so a toggle with only a tooltip still has an accessible name. A natively disabled input leaves the tab order, so keyboard users could never reach the tooltip that explains why it is disabled. When `disabled` and `tooltipLabel` are both set, the input gets `aria-disabled="true"` instead of the native `disabled` attribute and stays focusable: - the MUI switch still receives `disabled`, so it keeps its disabled look; - an `onClick` with `preventDefault()` on the switch cancels mouse clicks, label clicks and Space, and MUI skips `onChange` for a prevented click. It sits on the switch because MUI drops event handlers passed through `slotProps.input`, except `onChange`; - `name` and `required` are dropped from the input, so it stays out of form submission and validation, as a natively disabled input would; - MUI never sets `Mui-focusVisible` while disabled, so the focus outline also matches `.Mui-disabled input:focus-visible`. Without a tooltip, a disabled toggle keeps the native `disabled` attribute and leaves the tab order, as before. --- src/components/Toggle/README.md | 14 ++ src/components/Toggle/component.stories.tsx | 33 +++- src/components/Toggle/component.tsx | 168 ++++++++++++++++---- src/components/Toggle/constants.ts | 9 ++ src/components/Toggle/styles.ts | 8 +- src/components/Toggle/types.ts | 10 +- 6 files changed, 202 insertions(+), 40 deletions(-) diff --git a/src/components/Toggle/README.md b/src/components/Toggle/README.md index 2f055c2..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 | @@ -54,4 +66,6 @@ import { BBBToggle } from 'bbb-ui-components-react'; | `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 11dbd2a..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', @@ -36,6 +41,16 @@ const meta = { 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, description: 'Change handler fired when toggle state changes.', @@ -86,3 +101,19 @@ export const DisabledChecked: Story = { 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 63683c5..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. @@ -23,56 +47,130 @@ function Toggle ({ 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