Skip to content
Open
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
21 changes: 21 additions & 0 deletions src/components/Button/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,26 @@ import { BBButton } from 'bbb-ui-components-react';
<BBButton label="Share" fullWidth onClick={() => (console.log('clicked'))}/>
```

### Long Labels

`labelOverflow` sets what happens when the label doesn't fit the button's available width, for example a `fullWidth` button in a narrow container or a row of equal-width buttons. It has no visible effect while the label fits.

- `nowrap` (default): the label stays on one line. The button grows to fit it, or the label overflows when the button can't grow.
- `wrap`: the label breaks onto more lines, making the button taller.
- `ellipsis`: the label is truncated with an ellipsis, and the button can shrink below its label's width as a flex item. While truncated, hovering or focusing the button shows the full label in a tooltip, unless `tooltipLabel` is set.

```jsx
import styled from 'styled-components';
import { BBButton } from 'bbb-ui-components-react';

const RoomActionButton = styled(BBButton)`
flex: 1 1 10rem;
`;

<BBButton label="Take presenter role" fullWidth labelOverflow="wrap" onClick={() => (console.log('clicked'))}/>
<RoomActionButton label="Call the moderator for help" labelOverflow="ellipsis" onClick={() => (console.log('clicked'))}/>
```

### Extending with `styled()`

`className` is forwarded to the root element (the `<button>`, or the wrapper `<div>` in the `stacked` layout), so `BBButton` can be extended with `styled()`. This is an escape hatch while `BBButton` is being adopted: prefer native props such as `fullWidth`, and open an issue for any override you find yourself repeating, so it can become a prop.
Expand Down Expand Up @@ -105,6 +125,7 @@ const FooterButton = styled(BBButton)`
| `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | The native `type` attribute of the `<button>`. Defaults to `'button'` so it never submits an enclosing form unless `'submit'` is passed explicitly. |
| `disabled` | `boolean` | `false` | If `true`, the button will be disabled. |
| `fullWidth` | `boolean` | `false` | If `true`, the button fills the width of its container, keeping its content centered. Used for 'default' layout. |
| `labelOverflow` | `'nowrap' \| 'wrap' \| 'ellipsis'` | `'nowrap'` | What happens when the label doesn't fit the button's available width: `nowrap` keeps it on one line, widening the button or overflowing it, `wrap` breaks it onto more lines, `ellipsis` truncates it and shows the full label as a tooltip unless `tooltipLabel` is set. Used for 'default' layout. |
| `className` | `string` | | Class name forwarded to the root element (the `<button>`, or the wrapper `<div>` in the 'stacked' layout), so `styled(BBButton)` works. Escape hatch while BBButton is being adopted; prefer native props and open an issue for recurring needs. |
| `children` | `React.ReactNode` | | The content of the button, typically used for the 'default' layout. |
| `icon` | `React.ReactNode` | | The icon to be displayed. Used for 'circle', 'squared' and 'stacked' layouts. |
Expand Down
53 changes: 49 additions & 4 deletions src/components/Button/component.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
DEFAULT_LAYOUT,
TYPE_VALUES,
DEFAULT_TYPE,
LABEL_OVERFLOW_VALUES,
DEFAULT_LABEL_OVERFLOW,
} from './constants';

const meta = {
Expand Down Expand Up @@ -83,6 +85,12 @@ const meta = {
description: `Makes the button fill the width of its container, keeping its content centered. Only used in the 'default' layout.`,
table: { defaultValue: { summary: 'false' } },
},
labelOverflow: {
control: 'select',
options: LABEL_OVERFLOW_VALUES,
description: `What happens when the label doesn't fit the button's available width. Allowed values: ${LABEL_OVERFLOW_VALUES.join(', ')}. \`nowrap\` keeps it on one line, widening the button or overflowing it, \`wrap\` breaks it onto more lines, \`ellipsis\` truncates it and shows the full label as a tooltip unless \`tooltipLabel\` is set. Has no visible effect while the label fits. Only used in the 'default' layout.`,
table: { defaultValue: { summary: `${DEFAULT_LABEL_OVERFLOW}` } },
},
className: {
control: false,
description: `Class name forwarded to the root element (the \`<button>\`, or the wrapper \`<div>\` in the 'stacked' layout), so \`styled(BBButton)\` works. Escape hatch while BBButton is being adopted; prefer native props and open an issue for recurring needs.`,
Expand Down Expand Up @@ -150,6 +158,13 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;

/** Renders a single button driven entirely by the controls. */
export const Default: Story = {
args: {
label: 'Button',
},
};

/** Renders the `primary` variant across all available colors. */
export const Primary: Story = {
name: 'Primary',
Expand Down Expand Up @@ -401,22 +416,52 @@ export const FullWidth: Story = {
),
};

const RowActionButton = styled(BBButton)`
flex: 1 1 0;
`;

/** Compares every `labelOverflow` value in the same row of equal-width buttons, where the longer labels don't fit; with `ellipsis`, hovering or focusing a truncated button shows its full label. */
export const LabelOverflow: Story = {
name: 'Label Overflow',
args: {
variant: 'secondary',
size: 'sm',
label: 'Call the moderator for help',
},
argTypes: {
labelOverflow: { control: false },
},
render: (args) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{LABEL_OVERFLOW_VALUES.map((labelOverflow) => (
<div key={labelOverflow}>
<code>{`labelOverflow="${labelOverflow}"`}</code>
<div style={{ display: 'flex', gap: '0.5rem', width: '20rem', marginTop: '0.5rem' }}>
<RowActionButton {...args} labelOverflow={labelOverflow} label="Join room" />
<RowActionButton {...args} labelOverflow={labelOverflow} />
<RowActionButton {...args} labelOverflow={labelOverflow} label="Finish all breakout rooms" color="danger" />
</div>
</div>
))}
</div>
),
};

const StyledFooterButton = styled(BBButton)`
min-width: 8.5rem;
`;

const StyledWrappingButton = styled(BBButton)`
const StyledFixedWidthButton = styled(BBButton)`
width: 10rem;
white-space: normal;
`;

/** Customizes the button through `styled(BBButton)`: a minimum width for a short label, and a fixed width that lets a long label wrap. */
/** Customizes the button through `styled(BBButton)`: a minimum width for a short label, and a fixed width for a long label that wraps through `labelOverflow`. */
export const StyledExtension: Story = {
name: 'Extended with styled()',
render: (args) => (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<StyledFooterButton {...args} label="Yes" />
<StyledWrappingButton {...args} label="A long translated label that wraps" />
<StyledFixedWidthButton {...args} label="A long translated label that wraps" labelOverflow="wrap" />
</div>
),
};
Expand Down
40 changes: 37 additions & 3 deletions src/components/Button/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
LAYOUTS,
DEFAULT_TOOLTIP_PLACEMENT,
DEFAULT_TYPE,
DEFAULT_LABEL_OVERFLOW,
LABEL_OVERFLOWS,
} from './constants';
import * as Styled from './styles';
import Tippy from '@tippyjs/react';
Expand Down Expand Up @@ -49,6 +51,10 @@ function Button(props: ButtonProps): JSX.Element {
className,
} = props;

const labelOverflow = (layout === LAYOUTS.DEFAULT && 'labelOverflow' in props && props.labelOverflow)
|| DEFAULT_LABEL_OVERFLOW;
const truncatesLabel = labelOverflow === LABEL_OVERFLOWS.ELLIPSIS;

const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isFeedbackVisible, setFeedbackVisible] = useState(false);

Expand All @@ -66,6 +72,23 @@ function Button(props: ButtonProps): JSX.Element {
}
}, [onClick, showFeedback, feedbackDuration]);

const labelRef = useRef<HTMLSpanElement>(null);
const [isLabelTruncated, setLabelTruncated] = useState(false);

useEffect(() => {
const labelElement = labelRef.current;
if (!truncatesLabel || !labelElement || typeof ResizeObserver === 'undefined') {
setLabelTruncated(false);
return undefined;
}

const observer = new ResizeObserver(() => {
setLabelTruncated(labelElement.scrollWidth > labelElement.clientWidth);
});
observer.observe(labelElement);
return () => observer.disconnect();
}, [truncatesLabel, label, isFeedbackVisible]);

const accessibilityProps: {
'aria-label'?: string;
'aria-labelledby'?: string;
Expand Down Expand Up @@ -195,6 +218,7 @@ function Button(props: ButtonProps): JSX.Element {
$size={size}
$layout={layout}
$fullWidth={fullWidth}
$labelOverflow={labelOverflow}
disabled={disabled}
>
{isFeedbackVisible ? (
Expand All @@ -204,7 +228,11 @@ function Button(props: ButtonProps): JSX.Element {
) : (
<>
{iconStart && iconStart}
{label && <span id={defaultLabelId}>{label}</span>}
{label && (
<Styled.Label id={defaultLabelId} ref={labelRef} $truncate={truncatesLabel}>
{label}
</Styled.Label>
)}
{children}
{iconEnd && iconEnd}
</>
Expand All @@ -213,11 +241,17 @@ function Button(props: ButtonProps): JSX.Element {
);
})();

if (tooltipLabel) {
// The truncated-label tooltip repeats text that is already the accessible name
// (via aria-labelledby), so it must not also be announced as a description.
const showsTruncatedLabelTooltip = !tooltipLabel && truncatesLabel && Boolean(label);

if (tooltipLabel || showsTruncatedLabelTooltip) {
return (
<Tippy
content={tooltipLabel}
content={tooltipLabel || label}
placement={tooltipPlacement}
disabled={showsTruncatedLabelTooltip && !isLabelTruncated}
aria={{ content: showsTruncatedLabelTooltip ? null : 'auto' }}
>
{buttonElement}
</Tippy>
Expand Down
11 changes: 11 additions & 0 deletions src/components/Button/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ const LAYOUTS = {
const LAYOUT_VALUES = Object.values(LAYOUTS);
const DEFAULT_LAYOUT = LAYOUT_VALUES[0];

const LABEL_OVERFLOWS = {
NOWRAP: 'nowrap',
WRAP: 'wrap',
ELLIPSIS: 'ellipsis',
} as const;
const LABEL_OVERFLOW_VALUES = Object.values(LABEL_OVERFLOWS);
const DEFAULT_LABEL_OVERFLOW = LABEL_OVERFLOWS.NOWRAP;

const TOOLTIP_PLACEMENTS = {
TOP: 'top',
BOTTOM: 'bottom',
Expand Down Expand Up @@ -224,6 +232,9 @@ export {
LAYOUTS,
LAYOUT_VALUES,
DEFAULT_LAYOUT,
LABEL_OVERFLOWS,
LABEL_OVERFLOW_VALUES,
DEFAULT_LABEL_OVERFLOW,
TOOLTIP_PLACEMENTS,
TOOLTIP_PLACEMENT_VALUES,
DEFAULT_TOOLTIP_PLACEMENT,
Expand Down
27 changes: 27 additions & 0 deletions src/components/Button/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import {
DEFAULT_VARIANT,
VARIANTS,
LAYOUTS,
LABEL_OVERFLOWS,
} from './constants';
import {
StyledHelperIconContainer, StyledButtonProps,
StyledButtonWrapperProps,
StyledLabelProps,
} from './type';
import {
borderRadiusDefault,
Expand Down Expand Up @@ -148,6 +150,22 @@ const squaredLayoutStyles = css<StyledButtonProps>`
const defaultLayoutStyles = css<StyledButtonProps>`
display: ${({ $fullWidth }) => ($fullWidth ? 'flex' : 'inline-flex')};
${({ $fullWidth }) => $fullWidth && 'width: 100%;'}
${({ $labelOverflow }) => {
switch ($labelOverflow) {
case LABEL_OVERFLOWS.WRAP:
return `
white-space: normal;
overflow-wrap: anywhere;
`;
case LABEL_OVERFLOWS.ELLIPSIS:
return `
min-width: 0;
max-width: 100%;
`;
default:
return null;
}
}}
justify-content: center;
align-items: center;
gap: ${spacingSmall};
Expand Down Expand Up @@ -231,6 +249,15 @@ export const FeedbackContent = styled.span`
gap: ${spacingSmall};
`;

export const Label = styled.span<StyledLabelProps>`
${({ $truncate }) => $truncate && `
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`}
`;

export const ButtonText = styled.div`
color: ${colorTextDefault};
font-size: ${fontSizeSmall};
Expand Down
10 changes: 10 additions & 0 deletions src/components/Button/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
SIZE_VALUES,
VARIANT_VALUES,
LAYOUTS,
LABEL_OVERFLOW_VALUES,
TOOLTIP_PLACEMENT_VALUES,
TYPE_VALUES,
} from './constants';
Expand Down Expand Up @@ -42,6 +43,7 @@ type VariantType = typeof VARIANT_VALUES[number];
type ColorType = typeof COLOR_VALUES[number];
type SizeType = typeof SIZE_VALUES[number];
type LayoutType = typeof LAYOUT_VALUES[number];
type LabelOverflowType = typeof LABEL_OVERFLOW_VALUES[number];
type TooltipPlacementType = typeof TOOLTIP_PLACEMENT_VALUES[number];
type ButtonType = typeof TYPE_VALUES[number];

Expand All @@ -61,9 +63,14 @@ export interface StyledButtonProps {
$size?: SizeType;
$layout?: LayoutType;
$fullWidth?: boolean;
$labelOverflow?: LabelOverflowType;
disabled: boolean;
}

export interface StyledLabelProps {
$truncate: boolean;
}

type BaseButtonProps = {
/** HTML `id` for the root element; auto-generated via `useId` when omitted, and used to derive the label's id and default `data-test` value. @default '' */
id?: string;
Expand Down Expand Up @@ -142,6 +149,9 @@ type DefaultLayoutProps = BaseButtonProps & {

/** Makes the button fill the width of its container, keeping its content centered. @default false */
fullWidth?: boolean;

/** What happens when the label doesn't fit the button's available width: `nowrap` keeps it on one line, widening the button or overflowing it, `wrap` breaks it onto more lines, `ellipsis` truncates it and shows the full label as a tooltip unless `tooltipLabel` is set. @default 'nowrap' */
labelOverflow?: LabelOverflowType;
};

type StackedLayoutProps = BaseButtonProps & {
Expand Down
Loading