Skip to content

Commit d99bf11

Browse files
committed
Merge branch 'feature/button-group' into q/1.0
2 parents 27db9f2 + 48b19fd commit d99bf11

4 files changed

Lines changed: 394 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import '@testing-library/jest-dom';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import React from 'react';
5+
import { getWrapper } from '../../testUtils';
6+
import { Button } from '../buttonv2/Buttonv2.component';
7+
import { ButtonGroup } from './ButtonGroup.component';
8+
9+
describe('ButtonGroup', () => {
10+
const { Wrapper } = getWrapper();
11+
12+
it('renders its child buttons inside a group', () => {
13+
render(
14+
<ButtonGroup>
15+
<Button value="name" label="Name" />
16+
<Button value="version" label="Version" />
17+
</ButtonGroup>,
18+
{ wrapper: Wrapper },
19+
);
20+
21+
expect(screen.getByRole('group')).toBeInTheDocument();
22+
expect(screen.getByRole('button', { name: 'Name' })).toBeInTheDocument();
23+
expect(screen.getByRole('button', { name: 'Version' })).toBeInTheDocument();
24+
});
25+
26+
it('marks the selected child as pressed when selection is enabled', () => {
27+
render(
28+
<ButtonGroup value="version" onChange={jest.fn()}>
29+
<Button value="name" label="Name" />
30+
<Button value="version" label="Version" />
31+
</ButtonGroup>,
32+
{ wrapper: Wrapper },
33+
);
34+
35+
expect(screen.getByRole('button', { name: 'Name' })).toHaveAttribute(
36+
'aria-pressed',
37+
'false',
38+
);
39+
expect(screen.getByRole('button', { name: 'Version' })).toHaveAttribute(
40+
'aria-pressed',
41+
'true',
42+
);
43+
});
44+
45+
it('calls onChange with the clicked child value', async () => {
46+
const onChange = jest.fn();
47+
render(
48+
<ButtonGroup value="name" onChange={onChange}>
49+
<Button value="name" label="Name" />
50+
<Button value="version" label="Version" />
51+
</ButtonGroup>,
52+
{ wrapper: Wrapper },
53+
);
54+
55+
await userEvent.click(screen.getByRole('button', { name: 'Version' }));
56+
57+
expect(onChange).toHaveBeenCalledTimes(1);
58+
expect(onChange).toHaveBeenCalledWith('version');
59+
});
60+
61+
it('preserves a child onClick handler while still notifying the group', async () => {
62+
const onChange = jest.fn();
63+
const childOnClick = jest.fn();
64+
render(
65+
<ButtonGroup value={null} onChange={onChange}>
66+
<Button value="name" label="Name" onClick={childOnClick} />
67+
</ButtonGroup>,
68+
{ wrapper: Wrapper },
69+
);
70+
71+
await userEvent.click(screen.getByRole('button', { name: 'Name' }));
72+
73+
expect(childOnClick).toHaveBeenCalledTimes(1);
74+
expect(onChange).toHaveBeenCalledWith('name');
75+
});
76+
77+
it('does not mark a valueless child as a toggle button in selectable mode', () => {
78+
render(
79+
<ButtonGroup value="name" onChange={jest.fn()}>
80+
<Button value="name" label="Name" />
81+
<Button label="Static" />
82+
</ButtonGroup>,
83+
{ wrapper: Wrapper },
84+
);
85+
86+
expect(screen.getByRole('button', { name: 'Name' })).toHaveAttribute(
87+
'aria-pressed',
88+
);
89+
expect(screen.getByRole('button', { name: 'Static' })).not.toHaveAttribute(
90+
'aria-pressed',
91+
);
92+
});
93+
94+
it('does not inject selection props without onChange', () => {
95+
render(
96+
<ButtonGroup>
97+
<Button value="name" label="Name" />
98+
</ButtonGroup>,
99+
{ wrapper: Wrapper },
100+
);
101+
102+
expect(screen.getByRole('button', { name: 'Name' })).not.toHaveAttribute(
103+
'aria-pressed',
104+
);
105+
});
106+
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import {
2+
Children,
3+
cloneElement,
4+
isValidElement,
5+
type HTMLAttributes,
6+
type MouseEvent,
7+
type ReactElement,
8+
type ReactNode,
9+
} from 'react';
10+
import styled, { css } from 'styled-components';
11+
import { spacing } from '../../spacing';
12+
13+
type Orientation = 'horizontal' | 'vertical';
14+
15+
export type ButtonGroupProps = Omit<
16+
HTMLAttributes<HTMLDivElement>,
17+
'onChange'
18+
> & {
19+
/** Lay the buttons out in a row (default) or a column. */
20+
orientation?: Orientation;
21+
/**
22+
* Value of the selected button. Providing `value` together with `onChange`
23+
* turns the group into a single-select segmented control: every child must
24+
* carry a `value`, and the child whose `value` matches is rendered selected.
25+
* Pass `null` to render no selection.
26+
*/
27+
value?: string | null;
28+
/** Called with the clicked child's `value` when selection is enabled. */
29+
onChange?: (value: string) => void;
30+
/** The buttons to group — typically `Button` from `@scality/core-ui/next`. */
31+
children: ReactNode;
32+
};
33+
34+
const ButtonGroupContainer = styled.div<{ orientation: Orientation }>`
35+
display: inline-flex;
36+
flex-direction: ${(props) =>
37+
props.orientation === 'vertical' ? 'column' : 'row'};
38+
align-items: stretch;
39+
border: ${spacing.r1} solid ${(props) => props.theme.border};
40+
border-radius: ${spacing.r4};
41+
overflow: hidden;
42+
43+
/* Each child button is wrapped (by Button's Tooltip) in extra elements.
44+
Let those wrappers and the button itself fill their cell so a vertical
45+
group reads as equal-width rows; horizontal cells keep their natural
46+
width since the group never stretches them on the main axis. */
47+
& > * {
48+
display: flex;
49+
}
50+
& > * > * {
51+
flex: 1;
52+
display: flex;
53+
}
54+
55+
/* Strip each child button of its own framing so the group reads as one
56+
segmented control rather than a row of separate buttons. */
57+
.sc-button {
58+
flex: 1;
59+
border: none;
60+
border-radius: 0;
61+
background: transparent;
62+
box-shadow: none;
63+
color: ${(props) => props.theme.textSecondary};
64+
}
65+
66+
/* A single hairline separator between adjacent buttons. */
67+
${(props) =>
68+
props.orientation === 'vertical'
69+
? css`
70+
& > *:not(:last-child) .sc-button {
71+
border-bottom: ${spacing.r1} solid ${props.theme.border};
72+
}
73+
`
74+
: css`
75+
& > *:not(:last-child) .sc-button {
76+
border-right: ${spacing.r1} solid ${props.theme.border};
77+
}
78+
`}
79+
80+
/* Doubled class selector keeps these states above Button's own variant
81+
rules without resorting to !important. */
82+
&& .sc-button:hover:enabled {
83+
background: ${(props) => props.theme.backgroundLevel1};
84+
color: ${(props) => props.theme.textPrimary};
85+
}
86+
87+
&& .sc-button[aria-pressed='true'] {
88+
background: ${(props) => props.theme.backgroundLevel1};
89+
color: ${(props) => props.theme.textPrimary};
90+
box-shadow: inset 0 0 0 ${spacing.r1} ${(props) => props.theme.selectedActive};
91+
}
92+
`;
93+
94+
type SelectableChildProps = {
95+
value?: string;
96+
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
97+
};
98+
99+
function ButtonGroup({
100+
orientation = 'horizontal',
101+
value,
102+
onChange,
103+
children,
104+
...rest
105+
}: ButtonGroupProps) {
106+
const selectable = typeof onChange === 'function';
107+
108+
const items = selectable
109+
? Children.map(children, (child) => {
110+
if (!isValidElement(child)) {
111+
return child;
112+
}
113+
114+
const element = child as ReactElement<SelectableChildProps>;
115+
const childValue = element.props.value;
116+
const selected = childValue != null && childValue === value;
117+
118+
return cloneElement(element, {
119+
// Only a child with a `value` is a real toggle button; without one,
120+
// clicking is a no-op, so it must not advertise a pressed state.
121+
...(childValue != null && { 'aria-pressed': selected }),
122+
onClick: (event: MouseEvent<HTMLButtonElement>) => {
123+
element.props.onClick?.(event);
124+
if (childValue != null) {
125+
onChange(childValue);
126+
}
127+
},
128+
} as Partial<SelectableChildProps> & { 'aria-pressed'?: boolean });
129+
})
130+
: children;
131+
132+
return (
133+
<ButtonGroupContainer
134+
className="sc-button-group"
135+
role="group"
136+
orientation={orientation}
137+
{...rest}
138+
>
139+
{items}
140+
</ButtonGroupContainer>
141+
);
142+
}
143+
144+
export { ButtonGroup };

src/lib/next.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import '@fortawesome/fontawesome-free/css/all.css';
22
import './index.css';
33
export { Button } from './components/buttonv2/Buttonv2.component';
44
export { CopyButton } from './components/buttonv2/CopyButton.component';
5+
export { ButtonGroup } from './components/buttongroup/ButtonGroup.component';
6+
export type { ButtonGroupProps } from './components/buttongroup/ButtonGroup.component';
57
export { Tabs, Tab } from './components/tabsv2/Tabsv2.component';
68
export { Table } from './components/tablev2/Tablev2.component';
79

0 commit comments

Comments
 (0)