Skip to content

Commit 9ffb292

Browse files
janhasselfrancineluccakodiakhq[bot]
authored
feat: add experimental combo-button and menu-button components (#13224)
* feat: add experimental combo-button component * docs(combo-button): add disabled and danger item to demo * docs: add menuitem subcomponents where applicable * docs(combo-button): fix subcomponents placing * refactor(combo-button): outsource common logic to useAttachedMenu hook * feat: add experimental menu-button component * feat(combo-button, menu-button): add props.className support * style(combo-button): remove props.kind as only primary is supported * feat(combo-button, menu-button): add to ts index * style(menu-button): remove support for secondary button kind * style(menu-button): add min-width and align trigger with menu * fix(menu): avoid column gap from empty icon grid cell * style(combo-button): ensure container min-width of 10rem * feat(combo-button): add i18n support * feat(combo-button): add support for prop-controllable tooltip alignment * feat(menu-button): add support for ref and additional props * test(menu-button): add tests * feat(combo-button): add support for ref and additional props * test(combo-button): add tests * test(menu-button): align tests * test: update react exports snapshot * test(menu-button): add test to verify disabled prop works * test(combo-button): extend tests * test(menu-button): extend tests * docs(combo-buton): add default story * docs(menu-button): add default story * style(combo-button, menu-button): make lg default size * fix(menu): remove inline style * fix(combo-button): remove inline style * fix(menu-button): remove inline style * test: fix typos * docs(menu-button): sync playground and default story * docs(combo-button): sync playground and default story * feat(menu): add support fort props.onOpen * fix(combo-button, menu-button): simplify menu width styling * fix(combo-button): fix menu width on firefox * fix(menu): set position style immediately when calculated * docs(menu-button): add more stories * docs(combo-button): add "with-danger" story * docs(menu-button): hide children, className in story * docs(combo-button): hide children, className, translateWithId in story * docs(combo-button): only hide certain props in playground story * docs(menu-button): only hide certain props in playground story --------- Co-authored-by: Francine Lucca <40550942+francinelucca@users.noreply.github.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
1 parent d87508d commit 9ffb292

File tree

27 files changed

+1271
-58
lines changed

27 files changed

+1271
-58
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Copyright IBM Corp. 2023
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
'use strict';
9+
10+
const { expect, test } = require('@playwright/test');
11+
const { themes } = require('../../test-utils/env');
12+
const { snapshotStory, visitStory } = require('../../test-utils/storybook');
13+
14+
test.describe('ComboButton', () => {
15+
themes.forEach((theme) => {
16+
test.describe(theme, () => {
17+
test('combo-button @vrt', async ({ page }) => {
18+
await snapshotStory(page, {
19+
component: 'ComboButton',
20+
id: 'experimental-unstable-combobutton--default',
21+
theme,
22+
});
23+
});
24+
});
25+
});
26+
27+
test('accessibility-checker @avt', async ({ page }) => {
28+
await visitStory(page, {
29+
component: 'ComboButton',
30+
id: 'experimental-unstable-combobutton--default',
31+
globals: {
32+
theme: 'white',
33+
},
34+
});
35+
await expect(page).toHaveNoACViolations('ComboButton');
36+
});
37+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Copyright IBM Corp. 2023
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
'use strict';
9+
10+
const { expect, test } = require('@playwright/test');
11+
const { themes } = require('../../test-utils/env');
12+
const { snapshotStory, visitStory } = require('../../test-utils/storybook');
13+
14+
test.describe('MenuButton', () => {
15+
themes.forEach((theme) => {
16+
test.describe(theme, () => {
17+
test('menu-button @vrt', async ({ page }) => {
18+
await snapshotStory(page, {
19+
component: 'MenuButton',
20+
id: 'experimental-unstable-menubutton--default',
21+
theme,
22+
});
23+
});
24+
});
25+
});
26+
27+
test('accessibility-checker @avt', async ({ page }) => {
28+
await visitStory(page, {
29+
component: 'MenuButton',
30+
id: 'experimental-unstable-menubutton--default',
31+
globals: {
32+
theme: 'white',
33+
},
34+
});
35+
await expect(page).toHaveNoACViolations('MenuButton');
36+
});
37+
});

packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9038,6 +9038,57 @@ Map {
90389038
"8": "cool-gray",
90399039
"9": "warm-gray",
90409040
},
9041+
"unstable_ComboButton" => Object {
9042+
"$$typeof": Symbol(react.forward_ref),
9043+
"propTypes": Object {
9044+
"children": Object {
9045+
"isRequired": true,
9046+
"type": "node",
9047+
},
9048+
"className": Object {
9049+
"type": "string",
9050+
},
9051+
"disabled": Object {
9052+
"type": "bool",
9053+
},
9054+
"label": Object {
9055+
"isRequired": true,
9056+
"type": "string",
9057+
},
9058+
"onClick": Object {
9059+
"type": "func",
9060+
},
9061+
"size": Object {
9062+
"args": Array [
9063+
Array [
9064+
"sm",
9065+
"md",
9066+
"lg",
9067+
],
9068+
],
9069+
"type": "oneOf",
9070+
},
9071+
"tooltipAlign": Object {
9072+
"args": Array [
9073+
Array [
9074+
"top",
9075+
"top-left",
9076+
"top-right",
9077+
"bottom",
9078+
"bottom-left",
9079+
"bottom-right",
9080+
"left",
9081+
"right",
9082+
],
9083+
],
9084+
"type": "oneOf",
9085+
},
9086+
"translateWithId": Object {
9087+
"type": "func",
9088+
},
9089+
},
9090+
"render": [Function],
9091+
},
90419092
"unstable_FeatureFlags" => Object {
90429093
"propTypes": Object {
90439094
"children": Object {
@@ -9101,6 +9152,9 @@ Map {
91019152
"onClose": Object {
91029153
"type": "func",
91039154
},
9155+
"onOpen": Object {
9156+
"type": "func",
9157+
},
91049158
"open": Object {
91059159
"type": "bool",
91069160
},
@@ -9157,6 +9211,46 @@ Map {
91579211
},
91589212
"render": [Function],
91599213
},
9214+
"unstable_MenuButton" => Object {
9215+
"$$typeof": Symbol(react.forward_ref),
9216+
"propTypes": Object {
9217+
"children": Object {
9218+
"isRequired": true,
9219+
"type": "node",
9220+
},
9221+
"className": Object {
9222+
"type": "string",
9223+
},
9224+
"disabled": Object {
9225+
"type": "bool",
9226+
},
9227+
"kind": Object {
9228+
"args": Array [
9229+
Array [
9230+
"primary",
9231+
"tertiary",
9232+
"ghost",
9233+
],
9234+
],
9235+
"type": "oneOf",
9236+
},
9237+
"label": Object {
9238+
"isRequired": true,
9239+
"type": "string",
9240+
},
9241+
"size": Object {
9242+
"args": Array [
9243+
Array [
9244+
"sm",
9245+
"md",
9246+
"lg",
9247+
],
9248+
],
9249+
"type": "oneOf",
9250+
},
9251+
},
9252+
"render": [Function],
9253+
},
91609254
"unstable_MenuItem" => Object {
91619255
"$$typeof": Symbol(react.forward_ref),
91629256
"propTypes": Object {

packages/react/src/__tests__/index-test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,11 @@ describe('Carbon Components React', () => {
221221
"UnorderedList",
222222
"VStack",
223223
"types",
224+
"unstable_ComboButton",
224225
"unstable_FeatureFlags",
225226
"unstable_LayoutDirection",
226227
"unstable_Menu",
228+
"unstable_MenuButton",
227229
"unstable_MenuItem",
228230
"unstable_MenuItemDivider",
229231
"unstable_MenuItemGroup",
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* Copyright IBM Corp. 2016, 2023
3+
*
4+
* This source code is licensed under the Apache-2.0 license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import { render, screen } from '@testing-library/react';
9+
import userEvent from '@testing-library/user-event';
10+
import React from 'react';
11+
12+
import { MenuItem } from '../Menu';
13+
14+
import { ComboButton } from './';
15+
16+
const prefix = 'cds';
17+
18+
describe('ComboButton', () => {
19+
describe('renders as expected - Component API', () => {
20+
it('supports a ref on the outermost element', () => {
21+
const ref = jest.fn();
22+
const { container } = render(
23+
<ComboButton label="Primary action" ref={ref}>
24+
<MenuItem label="Additional action" />
25+
</ComboButton>
26+
);
27+
expect(ref).toHaveBeenCalledWith(container.firstChild);
28+
});
29+
30+
it('supports a custom class name on the outermost element', () => {
31+
const { container } = render(
32+
<ComboButton label="Primary action" className="test">
33+
<MenuItem label="Additional action" />
34+
</ComboButton>
35+
);
36+
expect(container.firstChild).toHaveClass('test');
37+
});
38+
39+
it('forwards additional props on the outermost element', () => {
40+
const { container } = render(
41+
<ComboButton label="Primary action" data-testid="test">
42+
<MenuItem label="Additional action" />
43+
</ComboButton>
44+
);
45+
expect(container.firstChild).toHaveAttribute('data-testid', 'test');
46+
});
47+
48+
it('renders props.label on the trigger button', () => {
49+
render(
50+
<ComboButton label="Test">
51+
<MenuItem label="Additional action" />
52+
</ComboButton>
53+
);
54+
expect(screen.getAllByRole('button')[0]).toHaveTextContent(/^Test$/);
55+
});
56+
57+
it('supports props.disabled', () => {
58+
render(
59+
<ComboButton label="Primary action" disabled>
60+
<MenuItem label="Additional action" />
61+
</ComboButton>
62+
);
63+
64+
// primary action button
65+
expect(screen.getAllByRole('button')[0]).toBeDisabled();
66+
67+
// trigger button
68+
expect(screen.getAllByRole('button')[1]).toBeDisabled();
69+
});
70+
71+
describe('supports props.size', () => {
72+
const sizes = ['sm', 'md', 'lg'];
73+
74+
sizes.forEach((size) => {
75+
it(`size="${size}"`, () => {
76+
const { container } = render(
77+
<ComboButton label="Primary action" size={size}>
78+
<MenuItem label="Additional action" />
79+
</ComboButton>
80+
);
81+
82+
expect(container.firstChild).toHaveClass(
83+
`${prefix}--combo-button__container--${size}`
84+
);
85+
});
86+
});
87+
});
88+
89+
describe('supports props.tooltipAlign', () => {
90+
const alignments = [
91+
'top',
92+
'top-left',
93+
'top-right',
94+
'bottom',
95+
'bottom-left',
96+
'bottom-right',
97+
'left',
98+
'right',
99+
];
100+
101+
alignments.forEach((alignment) => {
102+
it(`tooltipAlign="${alignment}"`, () => {
103+
const { container } = render(
104+
<ComboButton label="Primary action" tooltipAlign={alignment}>
105+
<MenuItem label="Additional action" />
106+
</ComboButton>
107+
);
108+
109+
expect(container.firstChild.lastChild).toHaveClass(
110+
`${prefix}--popover--${alignment}`
111+
);
112+
});
113+
});
114+
});
115+
116+
it('supports props.translateWithId', () => {
117+
const t = () => 'test';
118+
119+
render(
120+
<ComboButton label="Primary action" translateWithId={t}>
121+
<MenuItem label="Additional action" />
122+
</ComboButton>
123+
);
124+
125+
const triggerButton = screen.getAllByRole('button')[1];
126+
const tooltipId = triggerButton.getAttribute('aria-labelledby');
127+
const tooltip = document.getElementById(tooltipId);
128+
129+
expect(tooltip).toHaveTextContent(t());
130+
});
131+
});
132+
133+
describe('behaves as expected', () => {
134+
it('emits props.onClick on primary action click', async () => {
135+
const onClick = jest.fn();
136+
render(
137+
<ComboButton label="Test" onClick={onClick}>
138+
<MenuItem label="Additional action" />
139+
</ComboButton>
140+
);
141+
142+
expect(onClick).toHaveBeenCalledTimes(0);
143+
await userEvent.click(screen.getAllByRole('button')[0]);
144+
expect(onClick).toHaveBeenCalledTimes(1);
145+
});
146+
147+
it('opens a menu on click on the trigger button', async () => {
148+
render(
149+
<ComboButton label="Primary action">
150+
<MenuItem label="Additional action" />
151+
</ComboButton>
152+
);
153+
154+
await userEvent.click(screen.getAllByRole('button')[1]);
155+
156+
expect(screen.getByRole('menu')).toBeTruthy();
157+
expect(screen.getByRole('menuitem')).toHaveTextContent(
158+
/^Additional action$/
159+
);
160+
});
161+
});
162+
});

0 commit comments

Comments
 (0)