Skip to content

Commit 418db77

Browse files
authored
feat: Add Button components for teams-components package (#308)
1 parent 71fdd08 commit 418db77

20 files changed

+648
-1
lines changed

packages/teams-components/jest.config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export default {
2222
displayName: 'teams-components',
2323
preset: '../../jest.preset.js',
2424
transform: {
25-
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
25+
'^.+\\.[tj]sx?$': ['@swc/jest', swcJestConfig],
2626
},
2727
moduleFileExtensions: ['ts', 'js', 'html'],
2828
testEnvironment: 'jsdom',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as React from 'react';
2+
import { fireEvent, render, screen, act } from '@testing-library/react';
3+
import { Menu, MenuPopover, MenuTrigger } from '@fluentui/react-components';
4+
import { Button } from './Button';
5+
6+
describe('Button', () => {
7+
beforeEach(() => {
8+
console.error = jest.fn();
9+
});
10+
11+
it('should throw error for icon button if no title or aria-label is provided', () => {
12+
expect(() => render(<Button icon={<i>X</i>} />)).toThrow(
13+
'Icon button must have a title'
14+
);
15+
});
16+
17+
it('should not throw error for icon button if aria-label is provided', () => {
18+
console.error = jest.fn();
19+
expect(() =>
20+
render(<Button aria-label="label" icon={<i>X</i>} />)
21+
).not.toThrow();
22+
});
23+
24+
it('should render title', () => {
25+
jest.useFakeTimers();
26+
const { getByRole } = render(<Button icon={<i>X</i>} title={'Tooltip'} />);
27+
28+
const button = getByRole('button');
29+
fireEvent.pointerEnter(button);
30+
act(() => {
31+
jest.runOnlyPendingTimers();
32+
});
33+
34+
const title = screen.queryByText('Tooltip');
35+
expect(title).not.toBeNull();
36+
37+
expect(title?.textContent).toEqual(button.getAttribute('aria-label'));
38+
});
39+
40+
it('should error when attempting to wrap with a menu', () => {
41+
expect(() =>
42+
render(
43+
<Menu>
44+
<MenuTrigger>
45+
<Button icon={<i>X</i>} />
46+
</MenuTrigger>
47+
<MenuPopover></MenuPopover>
48+
</Menu>
49+
)
50+
).toThrow('Icon button must have a title');
51+
});
52+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as React from 'react';
2+
import {
3+
useButton_unstable,
4+
useButtonStyles_unstable,
5+
renderButton_unstable,
6+
type ButtonProps as ButtonPropsBase,
7+
Tooltip,
8+
} from '@fluentui/react-components';
9+
import { validateIconButton, validateMenuButton } from './validateProps';
10+
import { type StrictCssClass, validateStrictClasses } from '../../strictStyles';
11+
import { type StrictSlot } from '../../strictSlot';
12+
13+
export interface ButtonProps
14+
extends Pick<
15+
ButtonPropsBase,
16+
| 'aria-label'
17+
| 'aria-labelledby'
18+
| 'aria-describedby'
19+
| 'size'
20+
| 'children'
21+
| 'disabled'
22+
| 'disabledFocusable'
23+
> {
24+
appearance?: 'transparent' | 'primary';
25+
className?: StrictCssClass;
26+
icon?: StrictSlot;
27+
onClick?: React.MouseEventHandler<HTMLButtonElement>;
28+
title?: StrictSlot;
29+
}
30+
31+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
32+
(userProps, ref) => {
33+
if (process.env.NODE_ENV !== 'production') {
34+
validateProps(userProps);
35+
}
36+
37+
const { className, icon, title, ...restProps } = userProps;
38+
const props: ButtonPropsBase = {
39+
...restProps,
40+
className: className?.toString(),
41+
iconPosition: 'before',
42+
icon,
43+
};
44+
45+
let state = useButton_unstable(props, ref);
46+
state = useButtonStyles_unstable(state);
47+
48+
const button = renderButton_unstable(state);
49+
50+
if (title) {
51+
return (
52+
<Tooltip content={title} relationship="label">
53+
{button}
54+
</Tooltip>
55+
);
56+
}
57+
58+
return button;
59+
}
60+
);
61+
62+
const validateProps = (props: ButtonProps) => {
63+
validateStrictClasses(props.className);
64+
validateIconButton(props);
65+
validateMenuButton(props);
66+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './Button';
2+
export * from './validateProps';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @throws Error if icon button is missing required props
3+
*/
4+
export const validateIconButton = (props: {
5+
icon?: unknown;
6+
children?: unknown;
7+
title?: unknown;
8+
'aria-label'?: string;
9+
'aria-labelledby'?: string;
10+
}) => {
11+
if (
12+
!props.children &&
13+
props.icon &&
14+
!props.title &&
15+
!(props['aria-label'] || props['aria-labelledby'])
16+
) {
17+
throw new Error(
18+
'@fluentui-contrib/teams-components::Icon button must have a title or aria label'
19+
);
20+
}
21+
};
22+
23+
/**
24+
* Infers a Menu being used by detecting `aria-haspopup` of the MenuTrigger
25+
* @throws Error if a menu is used
26+
*/
27+
export const validateMenuButton = (props: unknown) => {
28+
if (
29+
typeof props === 'object' &&
30+
props &&
31+
'aria-haspopup' in props &&
32+
props['aria-haspopup'] === 'menu'
33+
) {
34+
throw new Error(
35+
'@fluentui-contrib/teams-components:: MenuButton should be used to open a Menu'
36+
);
37+
}
38+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { makeStyles, shorthands, tokens } from '@fluentui/react-components';
2+
3+
export const useStyles = makeStyles({
4+
root: {
5+
...shorthands.padding(tokens.spacingHorizontalM),
6+
...shorthands.border(
7+
tokens.strokeWidthThin,
8+
'solid',
9+
tokens.colorNeutralStroke1
10+
),
11+
color: tokens.colorNeutralForeground1,
12+
backgroundColor: tokens.colorNeutralBackground1,
13+
display: 'flex',
14+
alignItems: 'center',
15+
justifyContent: 'center',
16+
maxWidth: '200px',
17+
':hover': {
18+
backgroundColor: tokens.colorNeutralBackground1Hover,
19+
color: tokens.colorNeutralForeground1Hover,
20+
},
21+
},
22+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as React from 'react';
2+
import { render } from '@testing-library/react';
3+
import { MenuButton } from './MenuButton';
4+
5+
describe('MenuButton', () => {
6+
it('should render', () => {
7+
render(<MenuButton />);
8+
});
9+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as React from 'react';
2+
import {
3+
useMenuButton_unstable,
4+
useMenuButtonStyles_unstable,
5+
renderMenuButton_unstable,
6+
type MenuButtonProps as MenuButtonPropsBase,
7+
Tooltip,
8+
} from '@fluentui/react-components';
9+
import { ButtonProps, validateIconButton } from '../Button';
10+
import { validateStrictClasses } from '../../strictStyles';
11+
12+
export const MenuButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
13+
(userProps, ref) => {
14+
if (process.env.NODE_ENV !== 'production') {
15+
validateProps(userProps);
16+
}
17+
18+
const { className, icon, title, ...restProps } = userProps;
19+
const props: MenuButtonPropsBase = {
20+
...restProps,
21+
className: className?.toString(),
22+
icon,
23+
};
24+
25+
let state = useMenuButton_unstable(props, ref);
26+
state = useMenuButtonStyles_unstable(state);
27+
28+
const button = renderMenuButton_unstable(state);
29+
30+
if (title) {
31+
return (
32+
<Tooltip content={title} relationship="label">
33+
{button}
34+
</Tooltip>
35+
);
36+
}
37+
38+
return button;
39+
}
40+
);
41+
42+
const validateProps = (props: ButtonProps) => {
43+
validateStrictClasses(props.className);
44+
validateIconButton(props);
45+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './MenuButton';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as React from 'react';
2+
import { fireEvent, render, screen, act } from '@testing-library/react';
3+
import { Menu, MenuPopover, MenuTrigger } from '@fluentui/react-components';
4+
import { ToggleButton } from './ToggleButton';
5+
6+
describe('ToggleButton', () => {
7+
beforeEach(() => {
8+
console.error = jest.fn();
9+
});
10+
11+
it('should throw error for icon button if no title or aria-label is provided', () => {
12+
console.error = jest.fn();
13+
expect(() => render(<ToggleButton checked icon={<i>X</i>} />)).toThrow(
14+
'Icon button must have a title'
15+
);
16+
});
17+
18+
it('should not throw error for icon button if aria-label is provided', () => {
19+
console.error = jest.fn();
20+
expect(() =>
21+
render(<ToggleButton checked aria-label="label" icon={<i>X</i>} />)
22+
).not.toThrow();
23+
});
24+
25+
it('should render title', () => {
26+
jest.useFakeTimers();
27+
const { getByRole } = render(
28+
<ToggleButton checked icon={<i>X</i>} title={'Tooltip'} />
29+
);
30+
31+
const button = getByRole('button');
32+
fireEvent.pointerEnter(button);
33+
act(() => {
34+
jest.runOnlyPendingTimers();
35+
});
36+
37+
const title = screen.queryByText('Tooltip');
38+
expect(title).not.toBeNull();
39+
40+
expect(title?.textContent).toEqual(button.getAttribute('aria-label'));
41+
});
42+
43+
it('should error when attempting to wrap with a menu', () => {
44+
expect(() =>
45+
render(
46+
<Menu>
47+
<MenuTrigger>
48+
<ToggleButton checked={false} icon={<i>X</i>} />
49+
</MenuTrigger>
50+
<MenuPopover></MenuPopover>
51+
</Menu>
52+
)
53+
).toThrow('Icon button must have a title');
54+
});
55+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as React from 'react';
2+
import {
3+
type ToggleButtonProps as ToggleButtonPropsBase,
4+
useToggleButtonStyles_unstable,
5+
useToggleButton_unstable,
6+
renderToggleButton_unstable,
7+
Tooltip,
8+
} from '@fluentui/react-components';
9+
import { validateStrictClasses } from '../../strictStyles';
10+
import { ButtonProps, validateIconButton, validateMenuButton } from '../Button';
11+
12+
export interface ToggleButtonProps extends ButtonProps {
13+
checked: boolean;
14+
}
15+
16+
export const ToggleButton = React.forwardRef<
17+
HTMLButtonElement,
18+
ToggleButtonProps
19+
>((userProps, ref) => {
20+
if (process.env.NODE_ENV !== 'production') {
21+
validateProps(userProps);
22+
}
23+
24+
const { className, icon, title, ...restProps } = userProps;
25+
const props: ToggleButtonPropsBase = {
26+
...restProps,
27+
className: className?.toString(),
28+
iconPosition: 'before',
29+
icon,
30+
};
31+
32+
let state = useToggleButton_unstable(props, ref);
33+
state = useToggleButtonStyles_unstable(state);
34+
35+
const button = renderToggleButton_unstable(state);
36+
37+
if (title) {
38+
return (
39+
<Tooltip content={title} relationship="label">
40+
{button}
41+
</Tooltip>
42+
);
43+
}
44+
45+
return button;
46+
});
47+
48+
const validateProps = (props: ToggleButtonProps) => {
49+
validateStrictClasses(props.className);
50+
validateIconButton(props);
51+
validateMenuButton(props);
52+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './ToggleButton';
+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
export { MenuButton } from './components/MenuButton';
2+
export {
3+
ToggleButton,
4+
type ToggleButtonProps,
5+
} from './components/ToggleButton';
16
export {
27
makeStrictStyles,
38
mergeStrictClasses,
49
type StrictCssClass,
510
} from './strictStyles';
11+
export { Button, type ButtonProps } from './components/Button/Button';

0 commit comments

Comments
 (0)