From 00d9aa5451cac813f4a056d032818162297ba95e Mon Sep 17 00:00:00 2001 From: Arthurk12 Date: Mon, 9 Nov 2026 20:40:41 -0300 Subject: [PATCH 1/4] fix(BBBCheckbox): show a visible outline on keyboard focus StyledCheckbox sets `padding: 0` on the MUI root, so MUI's focus ripple only tints the inside of the box. On a light background that is a faint grey square, and nothing else marks keyboard focus, which falls short of WCAG 2.4.7 (Focus Visible). Draw a 2px colorBrand1 outline, offset by 2px, on `.Mui-focusVisible`, matching the color BBButton uses for its focus outline. Keying it on `.Mui-focusVisible` instead of `:focus` keeps the outline off for mouse clicks. The root's border radius follows the shape of the control (borderRadiusSmall for the square box, 50% for the round variant), so the outline traces the box instead of MUI's default circle. Restoring MUI's 9px padding was discarded: it would bring back a 39px ripple halo and change the checkbox's layout footprint for every consumer. Refs #108 --- src/components/Checkbox/component.tsx | 1 + src/components/Checkbox/styles.ts | 13 +++++++++++-- src/components/Checkbox/types.ts | 4 ++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/Checkbox/component.tsx b/src/components/Checkbox/component.tsx index 87e74d3..411796b 100644 --- a/src/components/Checkbox/component.tsx +++ b/src/components/Checkbox/component.tsx @@ -34,6 +34,7 @@ function Checkbox({ : undefined} checkedIcon={round ? : undefined} slotProps={{ ...otherSlotProps, input: inputSlotProps }} diff --git a/src/components/Checkbox/styles.ts b/src/components/Checkbox/styles.ts index 2354210..2e77d39 100644 --- a/src/components/Checkbox/styles.ts +++ b/src/components/Checkbox/styles.ts @@ -2,7 +2,8 @@ import styled from 'styled-components'; import { Checkbox as MuiCheckbox } from '@mui/material'; import { colorTextDefault, colorBrand1 } from '../../stylesheets/palette'; import { fontSizeDefault } from '../../stylesheets/typography'; -import { spacingSmall } from '../../stylesheets/sizing'; +import { borderRadiusSmall, spacingSmall } from '../../stylesheets/sizing'; +import { StyledCheckboxProps } from './types'; export const Container = styled.div` display: inline-flex; @@ -19,12 +20,20 @@ export const Label = styled.label` cursor: pointer; `; -export const StyledCheckbox = styled(MuiCheckbox)` +// The root has no padding, so MUI's focus ripple only tints the inside of the +// box; the outline is what makes keyboard focus visible. +export const StyledCheckbox = styled(MuiCheckbox)` &.MuiCheckbox-root { padding: 0; + border-radius: ${({ $round }) => ($round ? '50%' : borderRadiusSmall)}; } &.Mui-checked { color: ${colorBrand1}; } + + &.Mui-focusVisible { + outline: 2px solid ${colorBrand1}; + outline-offset: 2px; + } `; diff --git a/src/components/Checkbox/types.ts b/src/components/Checkbox/types.ts index b8f7df1..fde7d92 100644 --- a/src/components/Checkbox/types.ts +++ b/src/components/Checkbox/types.ts @@ -19,3 +19,7 @@ export interface CheckboxProps extends Omit { /** `data-test` attribute on the underlying `` element. */ dataTest?: string; } + +export interface StyledCheckboxProps { + $round: boolean; +} From 4ff8a0d2f703cc9953f8749ae6abdec24b7c16b2 Mon Sep 17 00:00:00 2001 From: Arthurk12 Date: Mon, 9 Nov 2026 20:41:06 -0300 Subject: [PATCH 2/4] fix(BBBCheckbox): dim the label and show a not-allowed cursor when disabled The component does not pass `disabled` to its Label, which always has `cursor: pointer` and the default text color. With `disabled`, MUI dims the box, but the label still looks and points as if it were clickable. Destructure `disabled`, pass it on to the MUI checkbox and, as a transient `$disabled` prop, to the Label. A disabled label gets `cursor: not-allowed` and colorNeutral3, the same color BBButton uses for disabled text. The label is built once and placed according to `layout`, instead of being duplicated per layout. Also document `disabled` in the Storybook controls and in the README props table, and add a Disabled story. Refs #108 --- src/components/Checkbox/README.md | 1 + src/components/Checkbox/component.stories.tsx | 13 +++++++++++++ src/components/Checkbox/component.tsx | 10 ++++++++-- src/components/Checkbox/styles.ts | 10 +++++----- src/components/Checkbox/types.ts | 4 ++++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/components/Checkbox/README.md b/src/components/Checkbox/README.md index 9770aad..7b95b98 100644 --- a/src/components/Checkbox/README.md +++ b/src/components/Checkbox/README.md @@ -29,4 +29,5 @@ import { BBBCheckbox } from 'bbb-ui-components'; | `round` | `boolean` | `false` | If `true`, the checkbox will be rendered as a round radio-style button. | | `ariaLabel`| `string` | | The accessible name for the checkbox. | | `dataTest` | `string` | | The `data-test` attribute on the underlying `` element. | +| `disabled` | `boolean` | `false` | If `true`, disables the checkbox and dims its label. | | `...props` | `Omit` | | Any other props will be passed down to the underlying Material-UI Checkbox component. Use `slotProps.input` to set attributes on the ``. | diff --git a/src/components/Checkbox/component.stories.tsx b/src/components/Checkbox/component.stories.tsx index c6f7d22..5cc214a 100644 --- a/src/components/Checkbox/component.stories.tsx +++ b/src/components/Checkbox/component.stories.tsx @@ -21,6 +21,11 @@ const meta = { control: 'boolean', description: 'When true, renders the checkbox as a round radio-style control.', }, + disabled: { + control: 'boolean', + description: 'When true, disables the checkbox and dims its label.', + table: { defaultValue: { summary: 'false' } }, + }, ariaLabel: { control: 'text', description: 'Accessible name for the checkbox.', @@ -49,3 +54,11 @@ export const Round: Story = { round: true, }, }; + +/** Disabled checkbox: the box and the label are dimmed and the label shows a not-allowed cursor. */ +export const Disabled: Story = { + args: { + label: 'Disabled Checkbox', + disabled: true, + }, +}; diff --git a/src/components/Checkbox/component.tsx b/src/components/Checkbox/component.tsx index 411796b..50110fa 100644 --- a/src/components/Checkbox/component.tsx +++ b/src/components/Checkbox/component.tsx @@ -21,6 +21,7 @@ function Checkbox({ id, dataTest, slotProps, + disabled, ...props }: CheckboxProps): JSX.Element { const generatedId = useId(); @@ -34,6 +35,7 @@ function Checkbox({ : undefined} checkedIcon={round ? : undefined} @@ -45,11 +47,15 @@ function Checkbox({ return checkbox; } + const labelElement = ( + {label} + ); + return ( - {layout === LAYOUTS.LEFT && {label}} + {layout === LAYOUTS.LEFT && labelElement} {checkbox} - {layout === LAYOUTS.RIGHT && {label}} + {layout === LAYOUTS.RIGHT && labelElement} ); } diff --git a/src/components/Checkbox/styles.ts b/src/components/Checkbox/styles.ts index 2e77d39..f4f4f44 100644 --- a/src/components/Checkbox/styles.ts +++ b/src/components/Checkbox/styles.ts @@ -1,9 +1,9 @@ import styled from 'styled-components'; import { Checkbox as MuiCheckbox } from '@mui/material'; -import { colorTextDefault, colorBrand1 } from '../../stylesheets/palette'; +import { colorTextDefault, colorBrand1, colorNeutral3 } from '../../stylesheets/palette'; import { fontSizeDefault } from '../../stylesheets/typography'; import { borderRadiusSmall, spacingSmall } from '../../stylesheets/sizing'; -import { StyledCheckboxProps } from './types'; +import { StyledCheckboxProps, StyledLabelProps } from './types'; export const Container = styled.div` display: inline-flex; @@ -11,13 +11,13 @@ export const Container = styled.div` gap: ${spacingSmall}; `; -export const Label = styled.label` - color: ${colorTextDefault}; +export const Label = styled.label` + color: ${({ $disabled }) => ($disabled ? colorNeutral3 : colorTextDefault)}; font-size: ${fontSizeDefault}; font-style: normal; font-weight: 400; line-height: normal; - cursor: pointer; + cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')}; `; // The root has no padding, so MUI's focus ripple only tints the inside of the diff --git a/src/components/Checkbox/types.ts b/src/components/Checkbox/types.ts index fde7d92..a98cc0a 100644 --- a/src/components/Checkbox/types.ts +++ b/src/components/Checkbox/types.ts @@ -20,6 +20,10 @@ export interface CheckboxProps extends Omit { dataTest?: string; } +export interface StyledLabelProps { + $disabled?: boolean; +} + export interface StyledCheckboxProps { $round: boolean; } From bb0090d91988f87e82a4197d644395dcce59b67e Mon Sep 17 00:00:00 2001 From: Arthurk12 Date: Mon, 9 Nov 2026 20:41:30 -0300 Subject: [PATCH 3/4] feat(BBBCheckbox): add tooltipLabel and tooltipPlacement props BBBCheckbox has no way to show a tooltip. When a checkbox is disabled, users get no explanation of why they can't change it. BBButton and BBBAccordion already offer this through `tooltipLabel` and `tooltipPlacement`. Add the same two props, backed by Tippy like the other components. The tooltip is attached to the container around the box and the label, not to the input: MUI gives a disabled box `pointer-events: none`, so hovering it reaches the container. It opens on `mouseenter` and `focusin`, because `focusin` bubbles up from the input and Tippy's `focus` trigger does not. Tippy is rendered beside the container 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 checkbox, dropping its focus and resetting an uncontrolled checkbox to `defaultChecked`. The tooltip text is also kept in a `hidden` element that the input's `aria-describedby` points to, 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 checkbox (no `label` or `ariaLabel`), the tooltip text names it through `aria-labelledby` instead of describing it, so a checkbox 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 checkbox still receives `disabled`, so the box keeps its disabled look; - an `onClick` with `preventDefault()` on the checkbox root cancels mouse clicks, label clicks and Space, and MUI skips `onChange` for a prevented click. It sits on the root 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. Without that, a required, unchecked, aria-disabled checkbox would block its form from being submitted; - MUI never sets `Mui-focusVisible` while disabled, so the focus outline matches it through `.Mui-disabled:has(input:focus-visible)`. Without a tooltip, a disabled checkbox keeps the native `disabled` attribute and leaves the tab order, as before. Making the container focusable was discarded: focus would land on an element with no role or name, which screen readers announce poorly. Refs #108 --- src/components/Checkbox/README.md | 14 ++++ src/components/Checkbox/component.stories.tsx | 34 +++++++- src/components/Checkbox/component.tsx | 79 ++++++++++++++++--- src/components/Checkbox/constants.ts | 9 +++ src/components/Checkbox/styles.ts | 7 +- src/components/Checkbox/types.ts | 9 ++- 6 files changed, 136 insertions(+), 16 deletions(-) diff --git a/src/components/Checkbox/README.md b/src/components/Checkbox/README.md index 7b95b98..e832210 100644 --- a/src/components/Checkbox/README.md +++ b/src/components/Checkbox/README.md @@ -20,6 +20,18 @@ import { BBBCheckbox } from 'bbb-ui-components'; ``` +### Checkbox with Tooltip + +Useful to explain why a disabled checkbox can't be changed. The tooltip shows on hover and keyboard focus, and its text is also exposed to screen readers as the input's description; when there is no `label` or `ariaLabel`, it names the checkbox instead. + +A disabled checkbox 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 { BBBCheckbox } from 'bbb-ui-components'; + + +``` + ## Props | Property | Type | Default | Description | @@ -30,4 +42,6 @@ import { BBBCheckbox } from 'bbb-ui-components'; | `ariaLabel`| `string` | | The accessible name for the checkbox. | | `dataTest` | `string` | | The `data-test` attribute on the underlying `` element. | | `disabled` | `boolean` | `false` | If `true`, disables the checkbox and dims its label. | +| `tooltipLabel` | `string` | | Text shown in a tooltip on hover and keyboard focus. When set, a disabled checkbox stays focusable (`aria-disabled`) so the tooltip can be reached. | +| `tooltipPlacement` | `keyof typeof TOOLTIP_PLACEMENTS` | `'top'` | The placement of the tooltip relative to the checkbox. | | `...props` | `Omit` | | Any other props will be passed down to the underlying Material-UI Checkbox component. Use `slotProps.input` to set attributes on the ``. | diff --git a/src/components/Checkbox/component.stories.tsx b/src/components/Checkbox/component.stories.tsx index 5cc214a..a949d08 100644 --- a/src/components/Checkbox/component.stories.tsx +++ b/src/components/Checkbox/component.stories.tsx @@ -1,6 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react'; import BBBCheckbox from './component'; -import { LAYOUT_VALUES, DEFAULT_LAYOUT } from './constants'; +import { + LAYOUT_VALUES, + DEFAULT_LAYOUT, + TOOLTIP_PLACEMENT_VALUES, + DEFAULT_TOOLTIP_PLACEMENT, +} from './constants'; const meta = { title: 'BBBCheckbox', @@ -26,6 +31,16 @@ const meta = { description: 'When true, disables the checkbox and dims its label.', table: { defaultValue: { summary: 'false' } }, }, + tooltipLabel: { + control: 'text', + description: 'Text shown in a tooltip on hover and keyboard focus; when set, a disabled checkbox 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}` } }, + }, ariaLabel: { control: 'text', description: 'Accessible name for the checkbox.', @@ -62,3 +77,20 @@ export const Disabled: Story = { disabled: true, }, }; + +/** Shows a tooltip on hover and keyboard focus via `tooltipLabel`. */ +export const WithTooltip: Story = { + args: { + label: 'Checkbox with tooltip', + tooltipLabel: 'Extra information about this option', + }, +}; + +/** Disabled checkbox that stays keyboard-focusable so its tooltip can explain why the option is unavailable. */ +export const DisabledWithTooltip: Story = { + args: { + label: 'Unavailable option', + disabled: true, + tooltipLabel: 'Only moderators can change this option', + }, +}; diff --git a/src/components/Checkbox/component.tsx b/src/components/Checkbox/component.tsx index 50110fa..a8b8b7a 100644 --- a/src/components/Checkbox/component.tsx +++ b/src/components/Checkbox/component.tsx @@ -1,9 +1,16 @@ -import React, { JSX, useId } from 'react'; +import React, { JSX, useId, useRef } from 'react'; import { CheckboxOwnerState } from '@mui/material'; import { CheckboxProps } from './types'; import * as Styled from './styles'; -import { DEFAULT_LAYOUT, LAYOUTS } from './constants'; +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'; + +// 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 +// passed through `slotProps.input`, except `onChange`. +const preventToggle = (event: React.MouseEvent) => event.preventDefault(); /** * A customizable Checkbox component. @@ -22,41 +29,89 @@ function Checkbox({ dataTest, slotProps, disabled, + tooltipLabel, + tooltipPlacement = DEFAULT_TOOLTIP_PLACEMENT, ...props }: CheckboxProps): JSX.Element { const generatedId = useId(); + const containerRef = useRef(null); const checkboxId = id ?? generatedId; - const ownInputProps = { 'aria-label': ariaLabel, 'data-test': dataTest }; + const tooltipDescriptionId = `${checkboxId}-tooltip`; + const tooltipNamesCheckbox = Boolean(tooltipLabel && !ariaLabel && !label); + const ownInputProps = { + 'aria-label': ariaLabel, + 'aria-labelledby': tooltipNamesCheckbox ? tooltipDescriptionId : undefined, + 'aria-describedby': tooltipLabel && !tooltipNamesCheckbox ? tooltipDescriptionId : undefined, + 'data-test': dataTest, + }; + // 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: CheckboxOwnerState) => ({ ...ownInputProps, ...consumerInputSlotProps(ownerState) }) - : { ...ownInputProps, ...consumerInputSlotProps }; + ? (ownerState: CheckboxOwnerState) => ({ + ...ownInputProps, + ...consumerInputSlotProps(ownerState), + ...focusableDisabledInputProps, + }) + : { ...ownInputProps, ...consumerInputSlotProps, ...focusableDisabledInputProps }; const checkbox = ( : undefined} checkedIcon={round ? : undefined} slotProps={{ ...otherSlotProps, input: inputSlotProps }} /> ); - if (!label) { + if (!label && !tooltipLabel) { return checkbox; } - const labelElement = ( + const labelElement = label && ( {label} ); + // Tippy sits beside the container instead of wrapping it, so toggling `tooltipLabel` + // keeps the same root and doesn't remount the checkbox (losing focus and uncontrolled + // state). It targets the container, not the input: MUI gives the disabled box + // `pointer-events: none`, so hovering it reaches the container. `focusin` bubbles + // from the input; Tippy's `focus` would not. return ( - - {layout === LAYOUTS.LEFT && labelElement} - {checkbox} - {layout === LAYOUTS.RIGHT && labelElement} - + <> + + {layout === LAYOUTS.LEFT && labelElement} + {checkbox} + {layout === LAYOUTS.RIGHT && labelElement} + {/* aria-describedby needs the text at all times; Tippy mounts it only while shown. */} + {tooltipLabel && ( + + )} + + {tooltipLabel && ( + + )} + ); } diff --git a/src/components/Checkbox/constants.ts b/src/components/Checkbox/constants.ts index 011a808..13249ca 100644 --- a/src/components/Checkbox/constants.ts +++ b/src/components/Checkbox/constants.ts @@ -4,3 +4,12 @@ export const LAYOUTS = { } as const; export const LAYOUT_VALUES = Object.values(LAYOUTS); export const DEFAULT_LAYOUT = LAYOUT_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; diff --git a/src/components/Checkbox/styles.ts b/src/components/Checkbox/styles.ts index f4f4f44..1a50ce1 100644 --- a/src/components/Checkbox/styles.ts +++ b/src/components/Checkbox/styles.ts @@ -21,7 +21,9 @@ export const Label = styled.label` `; // The root has no padding, so MUI's focus ripple only tints the inside of the -// box; the outline is what makes keyboard focus visible. +// box; the outline is what makes keyboard focus visible. MUI never sets +// `Mui-focusVisible` while disabled, so a focusable disabled input (one with a +// tooltip) is matched through `:has` instead. export const StyledCheckbox = styled(MuiCheckbox)` &.MuiCheckbox-root { padding: 0; @@ -32,7 +34,8 @@ export const StyledCheckbox = styled(MuiCheckbox)` color: ${colorBrand1}; } - &.Mui-focusVisible { + &.Mui-focusVisible, + &.Mui-disabled:has(input:focus-visible) { outline: 2px solid ${colorBrand1}; outline-offset: 2px; } diff --git a/src/components/Checkbox/types.ts b/src/components/Checkbox/types.ts index a98cc0a..81ef15b 100644 --- a/src/components/Checkbox/types.ts +++ b/src/components/Checkbox/types.ts @@ -1,7 +1,8 @@ import { CheckboxProps as MuiCheckboxProps } from '@mui/material'; -import { LAYOUTS } from './constants'; +import { LAYOUTS, TOOLTIP_PLACEMENT_VALUES } from './constants'; type Layout = typeof LAYOUTS[keyof typeof LAYOUTS]; +type TooltipPlacement = typeof TOOLTIP_PLACEMENT_VALUES[number]; export interface CheckboxProps extends Omit { /** Text label displayed next to the checkbox. */ @@ -18,6 +19,12 @@ export interface CheckboxProps extends Omit { /** `data-test` attribute on the underlying `` element. */ dataTest?: string; + + /** Text shown in a tooltip on hover and keyboard focus; when set, a disabled checkbox stays focusable (`aria-disabled`) so the tooltip can be reached. */ + tooltipLabel?: string; + + /** Placement of the tooltip when `tooltipLabel` is provided. @default 'top' */ + tooltipPlacement?: TooltipPlacement; } export interface StyledLabelProps { From 3409ffa6984decd1d45ca0ad17b5ddbbf5ab53f1 Mon Sep 17 00:00:00 2001 From: Arthurk12 Date: Mon, 9 Nov 2026 21:16:50 -0300 Subject: [PATCH 4/4] feat(BBBCheckbox): add helperText prop BBBCheckbox has no way to show secondary text under its label, which BBBToggle offers through `helperText`. Consumers that need a short explanation next to a checkbox have to lay it out themselves. Add `helperText`, rendered below the label in fontSizeSmall and colorTextLight (one step below the label, as in BBBToggle), and dimmed like the label when disabled. The container turns from an inline flex row into an inline grid, with the box and the label on the first row and the helper text on the second, under the label. In a flex row, the box would be centered on the whole two-line text block instead of on the label's line. With the `left` layout, the text is aligned to the end, next to the box, so a label that wraps onto several lines is now right-aligned even without helper text. The helper text is a second