Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
…nto gpn
  • Loading branch information
hectorjjb committed Feb 12, 2025
2 parents f18a26a + 5198092 commit 4123240
Show file tree
Hide file tree
Showing 20 changed files with 648 additions and 1 deletion.
2 changes: 1 addition & 1 deletion packages/teams-components/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default {
displayName: 'teams-components',
preset: '../../jest.preset.js',
transform: {
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
'^.+\\.[tj]sx?$': ['@swc/jest', swcJestConfig],
},
moduleFileExtensions: ['ts', 'js', 'html'],
testEnvironment: 'jsdom',
Expand Down
52 changes: 52 additions & 0 deletions packages/teams-components/src/components/Button/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as React from 'react';
import { fireEvent, render, screen, act } from '@testing-library/react';
import { Menu, MenuPopover, MenuTrigger } from '@fluentui/react-components';
import { Button } from './Button';

describe('Button', () => {
beforeEach(() => {
console.error = jest.fn();
});

it('should throw error for icon button if no title or aria-label is provided', () => {
expect(() => render(<Button icon={<i>X</i>} />)).toThrow(
'Icon button must have a title'
);
});

it('should not throw error for icon button if aria-label is provided', () => {
console.error = jest.fn();
expect(() =>
render(<Button aria-label="label" icon={<i>X</i>} />)
).not.toThrow();
});

it('should render title', () => {
jest.useFakeTimers();
const { getByRole } = render(<Button icon={<i>X</i>} title={'Tooltip'} />);

const button = getByRole('button');
fireEvent.pointerEnter(button);
act(() => {
jest.runOnlyPendingTimers();
});

const title = screen.queryByText('Tooltip');
expect(title).not.toBeNull();

expect(title?.textContent).toEqual(button.getAttribute('aria-label'));
});

it('should error when attempting to wrap with a menu', () => {
expect(() =>
render(
<Menu>
<MenuTrigger>
<Button icon={<i>X</i>} />
</MenuTrigger>
<MenuPopover></MenuPopover>
</Menu>
)
).toThrow('Icon button must have a title');
});
});
66 changes: 66 additions & 0 deletions packages/teams-components/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import * as React from 'react';
import {
useButton_unstable,
useButtonStyles_unstable,
renderButton_unstable,
type ButtonProps as ButtonPropsBase,
Tooltip,
} from '@fluentui/react-components';
import { validateIconButton, validateMenuButton } from './validateProps';
import { type StrictCssClass, validateStrictClasses } from '../../strictStyles';
import { type StrictSlot } from '../../strictSlot';

export interface ButtonProps
extends Pick<
ButtonPropsBase,
| 'aria-label'
| 'aria-labelledby'
| 'aria-describedby'
| 'size'
| 'children'
| 'disabled'
| 'disabledFocusable'
> {
appearance?: 'transparent' | 'primary';
className?: StrictCssClass;
icon?: StrictSlot;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
title?: StrictSlot;
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(userProps, ref) => {
if (process.env.NODE_ENV !== 'production') {
validateProps(userProps);
}

const { className, icon, title, ...restProps } = userProps;
const props: ButtonPropsBase = {
...restProps,
className: className?.toString(),
iconPosition: 'before',
icon,
};

let state = useButton_unstable(props, ref);
state = useButtonStyles_unstable(state);

const button = renderButton_unstable(state);

if (title) {
return (
<Tooltip content={title} relationship="label">
{button}
</Tooltip>
);
}

return button;
}
);

const validateProps = (props: ButtonProps) => {
validateStrictClasses(props.className);
validateIconButton(props);
validateMenuButton(props);
};
2 changes: 2 additions & 0 deletions packages/teams-components/src/components/Button/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './Button';
export * from './validateProps';
38 changes: 38 additions & 0 deletions packages/teams-components/src/components/Button/validateProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @throws Error if icon button is missing required props
*/
export const validateIconButton = (props: {
icon?: unknown;
children?: unknown;
title?: unknown;
'aria-label'?: string;
'aria-labelledby'?: string;
}) => {
if (
!props.children &&
props.icon &&
!props.title &&
!(props['aria-label'] || props['aria-labelledby'])
) {
throw new Error(
'@fluentui-contrib/teams-components::Icon button must have a title or aria label'
);
}
};

/**
* Infers a Menu being used by detecting `aria-haspopup` of the MenuTrigger
* @throws Error if a menu is used
*/
export const validateMenuButton = (props: unknown) => {
if (
typeof props === 'object' &&
props &&
'aria-haspopup' in props &&
props['aria-haspopup'] === 'menu'
) {
throw new Error(
'@fluentui-contrib/teams-components:: MenuButton should be used to open a Menu'
);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { makeStyles, shorthands, tokens } from '@fluentui/react-components';

export const useStyles = makeStyles({
root: {
...shorthands.padding(tokens.spacingHorizontalM),
...shorthands.border(
tokens.strokeWidthThin,
'solid',
tokens.colorNeutralStroke1
),
color: tokens.colorNeutralForeground1,
backgroundColor: tokens.colorNeutralBackground1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
maxWidth: '200px',
':hover': {
backgroundColor: tokens.colorNeutralBackground1Hover,
color: tokens.colorNeutralForeground1Hover,
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { MenuButton } from './MenuButton';

describe('MenuButton', () => {
it('should render', () => {
render(<MenuButton />);
});
});
45 changes: 45 additions & 0 deletions packages/teams-components/src/components/MenuButton/MenuButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as React from 'react';
import {
useMenuButton_unstable,
useMenuButtonStyles_unstable,
renderMenuButton_unstable,
type MenuButtonProps as MenuButtonPropsBase,
Tooltip,
} from '@fluentui/react-components';
import { ButtonProps, validateIconButton } from '../Button';
import { validateStrictClasses } from '../../strictStyles';

export const MenuButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
(userProps, ref) => {
if (process.env.NODE_ENV !== 'production') {
validateProps(userProps);
}

const { className, icon, title, ...restProps } = userProps;
const props: MenuButtonPropsBase = {
...restProps,
className: className?.toString(),
icon,
};

let state = useMenuButton_unstable(props, ref);
state = useMenuButtonStyles_unstable(state);

const button = renderMenuButton_unstable(state);

if (title) {
return (
<Tooltip content={title} relationship="label">
{button}
</Tooltip>
);
}

return button;
}
);

const validateProps = (props: ButtonProps) => {
validateStrictClasses(props.className);
validateIconButton(props);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './MenuButton';
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as React from 'react';
import { fireEvent, render, screen, act } from '@testing-library/react';
import { Menu, MenuPopover, MenuTrigger } from '@fluentui/react-components';
import { ToggleButton } from './ToggleButton';

describe('ToggleButton', () => {
beforeEach(() => {
console.error = jest.fn();
});

it('should throw error for icon button if no title or aria-label is provided', () => {
console.error = jest.fn();
expect(() => render(<ToggleButton checked icon={<i>X</i>} />)).toThrow(
'Icon button must have a title'
);
});

it('should not throw error for icon button if aria-label is provided', () => {
console.error = jest.fn();
expect(() =>
render(<ToggleButton checked aria-label="label" icon={<i>X</i>} />)
).not.toThrow();
});

it('should render title', () => {
jest.useFakeTimers();
const { getByRole } = render(
<ToggleButton checked icon={<i>X</i>} title={'Tooltip'} />
);

const button = getByRole('button');
fireEvent.pointerEnter(button);
act(() => {
jest.runOnlyPendingTimers();
});

const title = screen.queryByText('Tooltip');
expect(title).not.toBeNull();

expect(title?.textContent).toEqual(button.getAttribute('aria-label'));
});

it('should error when attempting to wrap with a menu', () => {
expect(() =>
render(
<Menu>
<MenuTrigger>
<ToggleButton checked={false} icon={<i>X</i>} />
</MenuTrigger>
<MenuPopover></MenuPopover>
</Menu>
)
).toThrow('Icon button must have a title');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as React from 'react';
import {
type ToggleButtonProps as ToggleButtonPropsBase,
useToggleButtonStyles_unstable,
useToggleButton_unstable,
renderToggleButton_unstable,
Tooltip,
} from '@fluentui/react-components';
import { validateStrictClasses } from '../../strictStyles';
import { ButtonProps, validateIconButton, validateMenuButton } from '../Button';

export interface ToggleButtonProps extends ButtonProps {
checked: boolean;
}

export const ToggleButton = React.forwardRef<
HTMLButtonElement,
ToggleButtonProps
>((userProps, ref) => {
if (process.env.NODE_ENV !== 'production') {
validateProps(userProps);
}

const { className, icon, title, ...restProps } = userProps;
const props: ToggleButtonPropsBase = {
...restProps,
className: className?.toString(),
iconPosition: 'before',
icon,
};

let state = useToggleButton_unstable(props, ref);
state = useToggleButtonStyles_unstable(state);

const button = renderToggleButton_unstable(state);

if (title) {
return (
<Tooltip content={title} relationship="label">
{button}
</Tooltip>
);
}

return button;
});

const validateProps = (props: ToggleButtonProps) => {
validateStrictClasses(props.className);
validateIconButton(props);
validateMenuButton(props);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ToggleButton';
6 changes: 6 additions & 0 deletions packages/teams-components/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
export { MenuButton } from './components/MenuButton';
export {
ToggleButton,
type ToggleButtonProps,
} from './components/ToggleButton';
export {
makeStrictStyles,
mergeStrictClasses,
type StrictCssClass,
} from './strictStyles';
export { Button, type ButtonProps } from './components/Button/Button';
Loading

0 comments on commit 4123240

Please sign in to comment.