Skip to content

Commit bb8513f

Browse files
committed
update the RFC and provide and example/showcase for button component
1 parent a991d3f commit bb8513f

File tree

11 files changed

+967
-228
lines changed

11 files changed

+967
-228
lines changed

docs/react-v9/contributing/rfcs/react-components/convergence/unstyled-components.md

Lines changed: 726 additions & 172 deletions
Large diffs are not rendered by default.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { ButtonUnstyled } from '@fluentui/react-button';
2+
3+
console.log(ButtonUnstyled);
4+
5+
export default {
6+
name: 'ButtonUnstyled',
7+
};

packages/react-components/react-button/library/etc/react-button.api.md

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,18 @@ import type { SlotClassNames } from '@fluentui/react-utilities';
1616
// @public
1717
export const Button: ForwardRefComponent<ButtonProps>;
1818

19+
// @public (undocumented)
20+
export type ButtonBaseProps = ComponentProps<ButtonSlots> & {
21+
disabledFocusable?: boolean;
22+
disabled?: boolean;
23+
iconPosition?: 'before' | 'after';
24+
};
25+
26+
// @public (undocumented)
27+
export type ButtonBaseState = ComponentState<ButtonSlots> & Required<Pick<ButtonBaseProps, 'disabledFocusable' | 'disabled' | 'iconPosition'>> & {
28+
iconOnly: boolean;
29+
};
30+
1931
// @public (undocumented)
2032
export const buttonClassNames: SlotClassNames<ButtonSlots>;
2133

@@ -29,11 +41,8 @@ export interface ButtonContextValue {
2941
}
3042

3143
// @public (undocumented)
32-
export type ButtonProps = ComponentProps<ButtonSlots> & {
44+
export type ButtonProps = ButtonBaseProps & {
3345
appearance?: 'secondary' | 'primary' | 'outline' | 'subtle' | 'transparent';
34-
disabledFocusable?: boolean;
35-
disabled?: boolean;
36-
iconPosition?: 'before' | 'after';
3746
shape?: 'rounded' | 'circular' | 'square';
3847
size?: ButtonSize;
3948
};
@@ -45,9 +54,10 @@ export type ButtonSlots = {
4554
};
4655

4756
// @public (undocumented)
48-
export type ButtonState = ComponentState<ButtonSlots> & Required<Pick<ButtonProps, 'appearance' | 'disabledFocusable' | 'disabled' | 'iconPosition' | 'shape' | 'size'>> & {
49-
iconOnly: boolean;
50-
};
57+
export type ButtonState = ButtonBaseState & Required<Pick<ButtonProps, 'appearance' | 'disabledFocusable' | 'disabled' | 'iconPosition' | 'shape' | 'size'>>;
58+
59+
// @public
60+
export const ButtonUnstyled: React_2.ForwardRefExoticComponent<ButtonBaseProps & React_2.RefAttributes<HTMLAnchorElement | HTMLButtonElement>>;
5161

5262
// @public
5363
export const CompoundButton: ForwardRefComponent<CompoundButtonProps>;
@@ -133,7 +143,10 @@ export type ToggleButtonProps = ButtonProps & {
133143
export type ToggleButtonState = ButtonState & Required<Pick<ToggleButtonProps, 'checked'>>;
134144

135145
// @public
136-
export const useButton_unstable: (props: ButtonProps, ref: React_2.Ref<HTMLButtonElement | HTMLAnchorElement>) => ButtonState;
146+
export const useButton_unstable: (props: ButtonProps, ref: React.Ref<HTMLButtonElement | HTMLAnchorElement>) => ButtonState;
147+
148+
// @public
149+
export const useButtonBehavior_unstable: (props: ButtonBaseProps, ref: React_2.Ref<HTMLButtonElement | HTMLAnchorElement>) => ButtonBaseState;
137150

138151
// @internal
139152
export const useButtonContext: () => ButtonContextValue;
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
export type { ButtonProps, ButtonSlots, ButtonState } from './components/Button/index';
1+
export type {
2+
ButtonBaseProps,
3+
ButtonBaseState,
4+
ButtonProps,
5+
ButtonSlots,
6+
ButtonState,
7+
} from './components/Button/index';
28
export {
39
Button,
10+
ButtonUnstyled,
411
buttonClassNames,
512
renderButton_unstable,
613
useButtonStyles_unstable,
714
useButton_unstable,
15+
useButtonBehavior_unstable,
816
} from './components/Button/index';

packages/react-components/react-button/library/src/components/Button/Button.types.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,10 @@ export type ButtonSlots = {
1818
*/
1919
export type ButtonSize = 'small' | 'medium' | 'large';
2020

21-
export type ButtonProps = ComponentProps<ButtonSlots> & {
22-
/**
23-
* A button can have its content and borders styled for greater emphasis or to be subtle.
24-
* - 'secondary' (default): Gives emphasis to the button in such a way that it indicates a secondary action.
25-
* - 'primary': Emphasizes the button as a primary action.
26-
* - 'outline': Removes background styling.
27-
* - 'subtle': Minimizes emphasis to blend into the background until hovered or focused.
28-
* - 'transparent': Removes background and border styling.
29-
*
30-
* @default 'secondary'
31-
*/
32-
appearance?: 'secondary' | 'primary' | 'outline' | 'subtle' | 'transparent';
33-
21+
/**
22+
* Defines the base props for the Button component (behavior only, no design props).
23+
*/
24+
export type ButtonBaseProps = ComponentProps<ButtonSlots> & {
3425
/**
3526
* When set, allows the button to be focusable even when it has been disabled. This is used in scenarios where it
3627
* is important to keep a consistent tab order for screen reader and keyboard users. The primary example of this
@@ -53,6 +44,20 @@ export type ButtonProps = ComponentProps<ButtonSlots> & {
5344
* @default 'before'
5445
*/
5546
iconPosition?: 'before' | 'after';
47+
};
48+
49+
export type ButtonProps = ButtonBaseProps & {
50+
/**
51+
* A button can have its content and borders styled for greater emphasis or to be subtle.
52+
* - 'secondary' (default): Gives emphasis to the button in such a way that it indicates a secondary action.
53+
* - 'primary': Emphasizes the button as a primary action.
54+
* - 'outline': Removes background styling.
55+
* - 'subtle': Minimizes emphasis to blend into the background until hovered or focused.
56+
* - 'transparent': Removes background and border styling.
57+
*
58+
* @default 'secondary'
59+
*/
60+
appearance?: 'secondary' | 'primary' | 'outline' | 'subtle' | 'transparent';
5661

5762
/**
5863
* A button can be rounded, circular, or square.
@@ -69,12 +74,18 @@ export type ButtonProps = ComponentProps<ButtonSlots> & {
6974
size?: ButtonSize;
7075
};
7176

72-
export type ButtonState = ComponentState<ButtonSlots> &
73-
Required<Pick<ButtonProps, 'appearance' | 'disabledFocusable' | 'disabled' | 'iconPosition' | 'shape' | 'size'>> & {
77+
/**
78+
* Defines the base state for the Button component (behavior only, no design state).
79+
*/
80+
export type ButtonBaseState = ComponentState<ButtonSlots> &
81+
Required<Pick<ButtonBaseProps, 'disabledFocusable' | 'disabled' | 'iconPosition'>> & {
7482
/**
7583
* A button can contain only an icon.
7684
*
7785
* @default false
7886
*/
7987
iconOnly: boolean;
8088
};
89+
90+
export type ButtonState = ButtonBaseState &
91+
Required<Pick<ButtonProps, 'appearance' | 'disabledFocusable' | 'disabled' | 'iconPosition' | 'shape' | 'size'>>;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { renderButton_unstable } from './renderButton';
5+
import type { ButtonBaseProps, ButtonState } from './Button.types';
6+
import { useButtonBehavior_unstable } from './useButtonBehavior';
7+
8+
/**
9+
* ButtonUnstyled - an unstyled version of the Button component, has no default Fluent styles applied but provides
10+
* the necessary structure and behavior.
11+
*
12+
* @param props - Button props
13+
* @param ref - Ref to the button element
14+
*/
15+
export const ButtonUnstyled = React.forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonBaseProps>((props, ref) => {
16+
const state = useButtonBehavior_unstable(props, ref);
17+
18+
return renderButton_unstable(state as ButtonState);
19+
});
20+
21+
ButtonUnstyled.displayName = 'ButtonUnstyled';
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export { Button } from './Button';
2+
export { ButtonUnstyled } from './ButtonUnstyled';
23
// Explicit exports to omit ButtonCommons
3-
export type { ButtonProps, ButtonSlots, ButtonState } from './Button.types';
4+
export type { ButtonBaseProps, ButtonBaseState, ButtonProps, ButtonSlots, ButtonState } from './Button.types';
45
export { renderButton_unstable } from './renderButton';
56
export { useButton_unstable } from './useButton';
7+
export { useButtonBehavior_unstable } from './useButtonBehavior';
68
export { buttonClassNames, useButtonStyles_unstable } from './useButtonStyles.styles';
Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
'use client';
22

3-
import * as React from 'react';
4-
import { ARIAButtonSlotProps, useARIAButtonProps } from '@fluentui/react-aria';
5-
import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities';
63
import { useButtonContext } from '../../contexts/ButtonContext';
4+
import { useButtonBehavior_unstable } from './useButtonBehavior';
75
import type { ButtonProps, ButtonState } from './Button.types';
86

97
/**
10-
* Given user props, defines default props for the Button, calls useButtonState, and returns processed state.
8+
* Given user props, defines default props for the Button, calls useButtonBehavior_unstable, and returns processed state.
119
* @param props - User provided props to the Button component.
1210
* @param ref - User provided ref to be passed to the Button component.
1311
*/
@@ -16,34 +14,13 @@ export const useButton_unstable = (
1614
ref: React.Ref<HTMLButtonElement | HTMLAnchorElement>,
1715
): ButtonState => {
1816
const { size: contextSize } = useButtonContext();
19-
const {
20-
appearance = 'secondary',
21-
as = 'button',
22-
disabled = false,
23-
disabledFocusable = false,
24-
icon,
25-
iconPosition = 'before',
26-
shape = 'rounded',
27-
size = contextSize ?? 'medium',
28-
} = props;
29-
const iconShorthand = slot.optional(icon, { elementType: 'span' });
17+
const { appearance = 'secondary', shape = 'rounded', size = contextSize ?? 'medium' } = props;
18+
const state = useButtonBehavior_unstable(props, ref);
19+
3020
return {
31-
// Props passed at the top-level
21+
...state,
3222
appearance,
33-
disabled,
34-
disabledFocusable,
35-
iconPosition,
3623
shape,
37-
size, // State calculated from a set of props
38-
iconOnly: Boolean(iconShorthand?.children && !props.children), // Slots definition
39-
components: { root: 'button', icon: 'span' },
40-
root: slot.always<ARIAButtonSlotProps<'a'>>(getIntrinsicElementProps(as, useARIAButtonProps(props.as, props)), {
41-
elementType: 'button',
42-
defaultProps: {
43-
ref: ref as React.Ref<HTMLButtonElement & HTMLAnchorElement>,
44-
type: as === 'button' ? 'button' : undefined,
45-
},
46-
}),
47-
icon: iconShorthand,
24+
size,
4825
};
4926
};
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import * as React from 'react';
2+
import { render, renderHook } from '@testing-library/react';
3+
import { mergeClasses } from '@griffel/react';
4+
import { useButtonBehavior_unstable } from './useButtonBehavior';
5+
import type { ButtonBaseProps } from './Button.types';
6+
7+
describe('useButtonBehavior', () => {
8+
it('returns the correct initial state', () => {
9+
const { result } = renderHook(() => useButtonBehavior_unstable({}, React.createRef()));
10+
expect(result.current).toMatchObject({
11+
components: { root: 'button', icon: 'span' },
12+
disabled: false,
13+
disabledFocusable: false,
14+
iconPosition: 'before',
15+
iconOnly: false,
16+
root: { type: 'button' },
17+
icon: undefined,
18+
});
19+
});
20+
21+
it('returns the correct state with passed props', () => {
22+
const { result } = renderHook(() =>
23+
useButtonBehavior_unstable({ disabled: true, icon: 'icon' }, React.createRef()),
24+
);
25+
expect(result.current).toMatchObject({
26+
components: { root: 'button', icon: 'span' },
27+
disabled: true,
28+
disabledFocusable: false,
29+
iconPosition: 'before',
30+
iconOnly: true,
31+
root: { type: 'button' },
32+
icon: {
33+
children: 'icon',
34+
},
35+
});
36+
});
37+
38+
describe('used as a headless hook to build a custom component', () => {
39+
type CustomButtonProps = ButtonBaseProps & {
40+
appearance?: 'primary' | 'secondary' | 'tertiary' | 'ghost' | 'link';
41+
size?: 'tiny' | 'small' | 'medium' | 'large' | 'huge';
42+
};
43+
44+
const CustomButton = React.forwardRef<HTMLButtonElement | HTMLAnchorElement, CustomButtonProps>(
45+
({ appearance = 'primary', size = 'medium', className, ...props }, ref) => {
46+
// Used as a headless hook to build a custom component
47+
const state = useButtonBehavior_unstable(
48+
{
49+
className: mergeClasses('btn', `btn-${appearance}`, `btn-${size}`, className),
50+
...props,
51+
},
52+
ref,
53+
);
54+
55+
// Render the root slot as an anchor or button based on the as prop
56+
if (state.root.as === 'a') {
57+
return <a {...state.root} />;
58+
}
59+
60+
// Render the root slot as a button
61+
return <button {...state.root} />;
62+
},
63+
);
64+
65+
it('renders a button when as is not provided', () => {
66+
const { getByRole } = render(
67+
<CustomButton appearance="tertiary" size="medium">
68+
Button
69+
</CustomButton>,
70+
);
71+
const button = getByRole('button', { name: 'Button' });
72+
expect(button).toBeDefined();
73+
expect(button.getAttribute('type')).toBe('button');
74+
expect(button.getAttribute('disabled')).toBeFalsy();
75+
});
76+
77+
it('renders an anchor when as is provided', () => {
78+
const { getByRole } = render(
79+
<CustomButton as="a" href="#">
80+
Link
81+
</CustomButton>,
82+
);
83+
const anchor = getByRole('link', { name: 'Link' });
84+
expect(anchor).toBeDefined();
85+
expect(anchor.getAttribute('href')).toBe('#');
86+
});
87+
88+
it('disables the button when "disabled" is passed as a prop', () => {
89+
const { getByRole } = render(<CustomButton disabled>Button</CustomButton>);
90+
const button = getByRole('button', { name: 'Button' });
91+
expect(button.hasAttribute('disabled')).toBe(true);
92+
});
93+
94+
it('disables the anchor when "disabled" is passed as a prop', () => {
95+
const { getByRole } = render(
96+
<CustomButton as="a" href="#" disabled>
97+
Link
98+
</CustomButton>,
99+
);
100+
const anchor = getByRole('link');
101+
expect(anchor.hasAttribute('aria-disabled')).toBe(true);
102+
});
103+
});
104+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { type ARIAButtonSlotProps, useARIAButtonProps } from '@fluentui/react-aria';
5+
import { getIntrinsicElementProps, slot } from '@fluentui/react-utilities';
6+
import { ButtonBaseProps, ButtonBaseState } from './Button.types';
7+
8+
/**
9+
* Given user props, defines default props for the Button behavior state and returns it.
10+
*
11+
* @param props - User provided props to the Button component.
12+
* @param ref - User provided ref to be passed to the Button component.
13+
* @returns Button behavior state
14+
*/
15+
export const useButtonBehavior_unstable = (
16+
props: ButtonBaseProps,
17+
ref: React.Ref<HTMLButtonElement | HTMLAnchorElement>,
18+
): ButtonBaseState => {
19+
const { as = 'button', disabled = false, disabledFocusable = false, icon, iconPosition = 'before' } = props;
20+
const iconShorthand = slot.optional(icon, { elementType: 'span' });
21+
22+
return {
23+
disabled,
24+
disabledFocusable,
25+
iconPosition,
26+
iconOnly: Boolean(iconShorthand?.children && !props.children),
27+
root: slot.always<ARIAButtonSlotProps<'a'>>(
28+
getIntrinsicElementProps(as, useARIAButtonProps(props.as, props as ARIAButtonSlotProps<'a'>)),
29+
{
30+
elementType: as,
31+
defaultProps: {
32+
ref: ref as React.Ref<HTMLButtonElement & HTMLAnchorElement>,
33+
type: as === 'button' ? 'button' : undefined,
34+
},
35+
},
36+
),
37+
icon: iconShorthand,
38+
components: { root: as, icon: 'span' },
39+
};
40+
};

0 commit comments

Comments
 (0)