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
26 changes: 26 additions & 0 deletions src/components/Checkbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,39 @@ import { BBBCheckbox } from 'bbb-ui-components';
<BBBCheckbox label="Round Selection" round />
```

### Checkbox with Helper Text

The helper text is shown below the label, and clicking it toggles the checkbox like the label does. Screen readers announce it as the checkbox's description; when there is no `label` or `ariaLabel`, it names the checkbox instead.

```jsx
import { BBBCheckbox } from 'bbb-ui-components';

<BBBCheckbox label="Mirror my webcam" helperText="Only you see the mirrored image" />
```

### 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`, `ariaLabel` or `helperText`, 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';

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

## Props

| Property | Type | Default | Description |
| ---------- | ---------------------- | --------- | --------------------------------------------------------------------------- |
| `label` | `string` | | The text to be displayed as the label for the checkbox. |
| `layout` | `keyof typeof LAYOUTS` | `'right'` | The position of the label relative to the checkbox. |
| `helperText` | `string` | | Secondary text displayed below the label. It is also exposed as the checkbox's accessible description. |
| `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 `<input>` 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<CheckboxProps, 'inputProps'>` | | Any other props will be passed down to the underlying Material-UI Checkbox component. Use `slotProps.input` to set attributes on the `<input>`. |
68 changes: 67 additions & 1 deletion src/components/Checkbox/component.stories.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -17,10 +22,29 @@ const meta = {
description: 'Position of the label relative to the checkbox.',
table: { defaultValue: { summary: `${DEFAULT_LAYOUT}` } },
},
helperText: {
control: 'text',
description: "Secondary text displayed below the label; it is also exposed as the checkbox's accessible description.",
},
round: {
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' } },
},
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.',
Expand Down Expand Up @@ -49,3 +73,45 @@ export const Round: Story = {
round: true,
},
};

/** Adds secondary text below the label via `helperText`. */
export const WithHelperText: Story = {
args: {
label: 'Mirror my webcam',
helperText: 'Only you see the mirrored image',
},
};

/** Places the label and its helper text to the left of the checkbox. */
export const WithHelperTextOnTheLeft: Story = {
args: {
label: 'Mirror my webcam',
helperText: 'Only you see the mirrored image',
layout: 'left',
},
};

/** 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,
},
};

/** 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',
},
};
103 changes: 92 additions & 11 deletions src/components/Checkbox/component.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) => event.preventDefault();

/**
* A customizable Checkbox component.
Expand All @@ -16,40 +23,114 @@ import { RadioButtonUnchecked, RadioButtonChecked } from '@mui/icons-material';
function Checkbox({
label,
layout = DEFAULT_LAYOUT,
helperText,
round = false,
ariaLabel,
id,
dataTest,
slotProps,
disabled,
tooltipLabel,
tooltipPlacement = DEFAULT_TOOLTIP_PLACEMENT,
...props
}: CheckboxProps): JSX.Element {
const generatedId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const checkboxId = id ?? generatedId;
const ownInputProps = { 'aria-label': ariaLabel, 'data-test': dataTest };
const labelId = `${checkboxId}-label`;
const helperTextId = `${checkboxId}-helper`;
const tooltipDescriptionId = `${checkboxId}-tooltip`;
// The helper text is a second <label> so clicking it toggles the checkbox, but every
// associated <label> joins the accessible name. aria-labelledby keeps the name to the
// label alone, leaving the helper text as the description; with no other name source,
// the helper text names the checkbox and can't also describe it. The tooltip text
// follows the same rule when neither a label nor the helper text names the checkbox.
const hasNameSource = Boolean(ariaLabel || label);
const tooltipNamesCheckbox = Boolean(tooltipLabel && !hasNameSource && !helperText);
let ariaLabelledBy: string | undefined;
if (!ariaLabel && label) ariaLabelledBy = labelId;
else if (tooltipNamesCheckbox) ariaLabelledBy = tooltipDescriptionId;
const describedBy = [
hasNameSource && helperText && helperTextId,
tooltipLabel && !tooltipNamesCheckbox && tooltipDescriptionId,
].filter(Boolean).join(' ');
const ownInputProps = {
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
'aria-describedby': describedBy || 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 = (
<Styled.StyledCheckbox
{...props}
id={checkboxId}
disabled={disabled}
$round={round}
{...(focusableWhileDisabled && { onClick: preventToggle })}
icon={round ? <RadioButtonUnchecked /> : undefined}
checkedIcon={round ? <RadioButtonChecked /> : undefined}
slotProps={{ ...otherSlotProps, input: inputSlotProps }}
/>
);

if (!label) {
if (!label && !helperText && !tooltipLabel) {
return checkbox;
}

const labelElement = label && (
<Styled.Label id={labelId} htmlFor={checkboxId} $disabled={disabled}>{label}</Styled.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 (
<Styled.Container>
{layout === LAYOUTS.LEFT && <Styled.Label htmlFor={checkboxId}>{label}</Styled.Label>}
{checkbox}
{layout === LAYOUTS.RIGHT && <Styled.Label htmlFor={checkboxId}>{label}</Styled.Label>}
</Styled.Container>
<>
<Styled.Container ref={containerRef} $layout={layout} $hasLabel={!!label} $hasHelperText={!!helperText}>
{layout === LAYOUTS.LEFT && labelElement}
{checkbox}
{layout === LAYOUTS.RIGHT && labelElement}
{helperText && (
<Styled.HelperText id={helperTextId} htmlFor={checkboxId} $disabled={disabled}>{helperText}</Styled.HelperText>
)}
{/* aria-describedby needs the text at all times; Tippy mounts it only while shown. */}
{tooltipLabel && (
<span id={tooltipDescriptionId} hidden>{tooltipLabel}</span>
)}
</Styled.Container>
{tooltipLabel && (
<Tippy
reference={containerRef}
content={tooltipLabel}
placement={tooltipPlacement}
trigger="mouseenter focusin"
aria={{ content: null }}
/>
)}
</>
);
}

Expand Down
9 changes: 9 additions & 0 deletions src/components/Checkbox/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
62 changes: 52 additions & 10 deletions src/components/Checkbox/styles.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,72 @@
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 {
colorTextDefault, colorTextLight, colorBrand1, colorNeutral3,
} from '../../stylesheets/palette';
import { fontSizeDefault, fontSizeSmall } from '../../stylesheets/typography';
import { borderRadiusSmall, spacingSmall } from '../../stylesheets/sizing';
import { LAYOUTS } from './constants';
import { StyledCheckboxProps, StyledContainerProps, StyledLabelProps } from './types';

export const Container = styled.div`
display: inline-flex;
const getGridTemplateAreas = ({ $layout, $hasLabel, $hasHelperText }: StyledContainerProps) => {
const textArea = $hasLabel ? 'label' : 'helper';
const rows = $hasLabel || $hasHelperText ? [['box', textArea]] : [['box']];
if ($hasLabel && $hasHelperText) rows.push(['.', 'helper']);
const orderedRows = $layout === LAYOUTS.LEFT ? rows.map((row) => [...row].reverse()) : rows;
return orderedRows.map((row) => `"${row.join(' ')}"`).join(' ');
};

// A grid rather than a flex row so the box stays centered on the label's line
// while the helper text sits below the label, not below the box.
export const Container = styled.div<StyledContainerProps>`
display: inline-grid;
grid-template-areas: ${getGridTemplateAreas};
column-gap: ${spacingSmall};
align-items: center;
gap: ${spacingSmall};
text-align: ${({ $layout }) => ($layout === LAYOUTS.LEFT ? 'end' : 'start')};

& > .MuiCheckbox-root {
grid-area: box;
}
`;

export const Label = styled.label`
color: ${colorTextDefault};
export const Label = styled.label<StyledLabelProps>`
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')};
grid-area: label;
`;

export const HelperText = styled.label<StyledLabelProps>`
color: ${({ $disabled }) => ($disabled ? colorNeutral3 : colorTextLight)};
font-size: ${fontSizeSmall};
font-style: normal;
font-weight: 400;
line-height: normal;
cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
grid-area: helper;
`;

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. 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)<StyledCheckboxProps>`
&.MuiCheckbox-root {
padding: 0;
border-radius: ${({ $round }) => ($round ? '50%' : borderRadiusSmall)};
}

&.Mui-checked {
color: ${colorBrand1};
}

&.Mui-focusVisible,
&.Mui-disabled:has(input:focus-visible) {
outline: 2px solid ${colorBrand1};
outline-offset: 2px;
}
`;
Loading
Loading