Skip to content

Commit 84c5af6

Browse files
authored
feat(button): add favorite variant (#11853)
* feat(button): add favorite variant * fix icon import * improved docs and made aria label dynamic * added the star icon * added console warning for a11y * updated icon render logic * star icon updated * removed the plain variant condition * added unit tests * added unit tests
1 parent 2a9c798 commit 84c5af6

File tree

4 files changed

+99
-6
lines changed

4 files changed

+99
-6
lines changed

packages/react-core/src/components/Button/Button.tsx

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { css } from '@patternfly/react-styles';
44
import { Spinner, spinnerSize } from '../Spinner';
55
import { useOUIAProps, OUIAProps } from '../../helpers/OUIA/ouia';
66
import { Badge } from '../Badge';
7+
import StarIcon from '@patternfly/react-icons/dist/esm/icons/star-icon';
8+
import OutlinedStarIcon from '@patternfly/react-icons/dist/esm/icons/outlined-star-icon';
79

810
export enum ButtonVariant {
911
primary = 'primary',
@@ -71,6 +73,10 @@ export interface ButtonProps extends Omit<React.HTMLProps<HTMLButtonElement>, 'r
7173
inoperableEvents?: string[];
7274
/** Adds inline styling to a link button */
7375
isInline?: boolean;
76+
/** Adds favorite styling to a button */
77+
isFavorite?: boolean;
78+
/** Flag indicating whether the button is favorited or not, only when isFavorite is true. */
79+
isFavorited?: boolean;
7480
/** Adds styling which affects the size of the button */
7581
size?: 'default' | 'sm' | 'lg';
7682
/** Sets button type */
@@ -117,6 +123,8 @@ const ButtonBase: React.FunctionComponent<ButtonProps> = ({
117123
size = ButtonSize.default,
118124
inoperableEvents = ['onClick', 'onKeyPress'],
119125
isInline = false,
126+
isFavorite = false,
127+
isFavorited = false,
120128
type = ButtonType.button,
121129
variant = ButtonVariant.primary,
122130
state = ButtonState.unread,
@@ -132,11 +140,19 @@ const ButtonBase: React.FunctionComponent<ButtonProps> = ({
132140
countOptions,
133141
...props
134142
}: ButtonProps) => {
143+
if (isFavorite && !ariaLabel && !props['aria-labelledby']) {
144+
// eslint-disable-next-line no-console
145+
console.error(
146+
'Button: Each favorite button must have a unique accessible name provided via aria-label or aria-labelledby'
147+
);
148+
}
149+
135150
const ouiaProps = useOUIAProps(Button.displayName, ouiaId, ouiaSafe, variant);
136151
const Component = component as any;
137152
const isButtonElement = Component === 'button';
138153
const isInlineSpan = isInline && Component === 'span';
139154
const isIconAlignedAtEnd = iconPosition === 'end' || iconPosition === 'right';
155+
const shouldOverrideIcon = isFavorite;
140156

141157
const preventedEvents = inoperableEvents.reduce(
142158
(handlers, eventToPrevent) => ({
@@ -158,11 +174,36 @@ const ButtonBase: React.FunctionComponent<ButtonProps> = ({
158174
}
159175
};
160176

161-
const _icon = icon && (
162-
<span className={css(styles.buttonIcon, children && styles.modifiers[isIconAlignedAtEnd ? 'end' : 'start'])}>
163-
{icon}
164-
</span>
165-
);
177+
const renderIcon = () => {
178+
let iconContent;
179+
180+
if (isFavorite) {
181+
iconContent = (
182+
<>
183+
<span className={css('pf-v6-c-button__icon-favorite')}>
184+
<OutlinedStarIcon />
185+
</span>
186+
<span className={css('pf-v6-c-button__icon-favorited')}>
187+
<StarIcon />
188+
</span>
189+
</>
190+
);
191+
}
192+
193+
if (icon && !shouldOverrideIcon) {
194+
iconContent = icon;
195+
}
196+
197+
return (
198+
iconContent && (
199+
<span className={css(styles.buttonIcon, children && styles.modifiers[isIconAlignedAtEnd ? 'end' : 'start'])}>
200+
{iconContent}
201+
</span>
202+
)
203+
);
204+
};
205+
206+
const _icon = renderIcon();
166207
const _children = children && <span className={css('pf-v6-c-button__text')}>{children}</span>;
167208
// We only want to render the aria-disabled attribute when true, similar to the disabled attribute natively.
168209
const shouldRenderAriaDisabled = isAriaDisabled || (!isButtonElement && isDisabled);
@@ -181,6 +222,8 @@ const ButtonBase: React.FunctionComponent<ButtonProps> = ({
181222
isAriaDisabled && styles.modifiers.ariaDisabled,
182223
isClicked && styles.modifiers.clicked,
183224
isInline && variant === ButtonVariant.link && styles.modifiers.inline,
225+
isFavorite && styles.modifiers.favorite,
226+
isFavorite && isFavorited && styles.modifiers.favorited,
184227
isDanger && (variant === ButtonVariant.secondary || variant === ButtonVariant.link) && styles.modifiers.danger,
185228
isLoading !== null && variant !== ButtonVariant.plain && styles.modifiers.progress,
186229
isLoading && styles.modifiers.inProgress,

packages/react-core/src/components/Button/__tests__/Button.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,28 @@ test(`Renders basic button`, () => {
248248
const { asFragment } = render(<Button aria-label="basic button">Basic Button</Button>);
249249
expect(asFragment()).toMatchSnapshot();
250250
});
251+
252+
test(`Renders with class ${styles.modifiers.favorite} when isFavorite is true`, () => {
253+
render(<Button isFavorite />);
254+
expect(screen.getByRole('button')).toHaveClass(styles.modifiers.favorite);
255+
});
256+
257+
test(`Renders with class ${styles.modifiers.favorited} when isFavorite is true and isFavorited is true`, () => {
258+
render(<Button isFavorite isFavorited />);
259+
expect(screen.getByRole('button')).toHaveClass(styles.modifiers.favorited);
260+
});
261+
262+
test(`Does not render with class ${styles.modifiers.favorite} when isFavorite is false`, () => {
263+
render(<Button />);
264+
expect(screen.getByRole('button')).not.toHaveClass(styles.modifiers.favorite);
265+
});
266+
267+
test(`Does not render with class ${styles.modifiers.favorited} when isFavorite is true and isFavorited is false`, () => {
268+
render(<Button isFavorite />);
269+
expect(screen.getByRole('button')).not.toHaveClass(styles.modifiers.favorited);
270+
});
271+
272+
test('Overrides icon prop when isFavorite is true', () => {
273+
render(<Button isFavorite icon={<div>Icon content</div>} />);
274+
expect(screen.queryByText('Icon content')).not.toBeInTheDocument();
275+
});

packages/react-core/src/components/Button/examples/Button.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ Stateful buttons are ideal for displaying the state of notifications. Use `varia
125125
```ts file="./ButtonStateful.tsx"
126126
```
127127

128+
### Favorite
129+
130+
You can pass both the `isFavorite` and `variant="plain"` properties into the `<Button>` to create a favorite button. Passing the `isFavorited` property will determine the current favorited state and update styling accordingly.
131+
132+
```ts file = "./ButtonFavorite.tsx"
133+
```
134+
128135
## Using router links
129136

130137
Router links can be used for in-app linking in React environments to prevent page reloading. To use a `Link` component from a router package, you can follow our [custom component example](#custom-component) and pass a callback to the `component` property of the `Button`:
@@ -133,4 +140,4 @@ Router links can be used for in-app linking in React environments to prevent pag
133140
<Button variant="link" component={(props: any) => <Link {...props} to="#" />}>
134141
Router link
135142
</Button>
136-
```
143+
```
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useState } from 'react';
2+
import { Button } from '@patternfly/react-core';
3+
4+
export const ButtonFavorite: React.FunctionComponent = () => {
5+
const [isFavorited, setIsFavorited] = useState(false);
6+
const toggleFavorite = () => {
7+
setIsFavorited(!isFavorited);
8+
};
9+
return (
10+
<Button
11+
variant="plain"
12+
aria-label={isFavorited ? 'Favorite example favorited' : 'Favorite example not favorited'}
13+
isFavorite
14+
isFavorited={isFavorited}
15+
onClick={toggleFavorite}
16+
/>
17+
);
18+
};

0 commit comments

Comments
 (0)