mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-12 01:00:13 -04:00
feat(ui-components): add states and full-width support to Button component (#45665)
* add active style * add disable state * add support for full-width * add custom focus outline
This commit is contained in:
@@ -1,27 +0,0 @@
|
||||
.storybook-button {
|
||||
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.storybook-button--small {
|
||||
font-size: 12px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.storybook-button--medium {
|
||||
font-size: 14px;
|
||||
padding: 11px 20px;
|
||||
}
|
||||
.storybook-button--large {
|
||||
font-size: 16px;
|
||||
padding: 12px 24px;
|
||||
}
|
||||
|
||||
.button-default-style {
|
||||
@apply bg-default-background-quaternary;
|
||||
@apply border-default-foreground-secondary;
|
||||
@apply text-default-foreground-secondary;
|
||||
@apply border-2;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const story = {
|
||||
component: Button,
|
||||
parameters: {
|
||||
controls: {
|
||||
include: ['children', 'variant', 'size']
|
||||
include: ['children', 'variant', 'size', 'disabled', 'block']
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
@@ -17,6 +17,14 @@ const story = {
|
||||
},
|
||||
size: {
|
||||
options: ['small', 'medium', 'large']
|
||||
},
|
||||
disabled: {
|
||||
options: [true, false],
|
||||
control: { type: 'radio' }
|
||||
},
|
||||
block: {
|
||||
options: [true, false],
|
||||
control: { type: 'radio' }
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -54,4 +62,16 @@ Small.args = {
|
||||
children: 'Button'
|
||||
};
|
||||
|
||||
export const Disabled = Template.bind({});
|
||||
Disabled.args = {
|
||||
children: 'Button',
|
||||
disabled: true
|
||||
};
|
||||
|
||||
export const FullWidth = Template.bind({});
|
||||
FullWidth.args = {
|
||||
children: 'Button',
|
||||
block: true
|
||||
};
|
||||
|
||||
export default story;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Silence the `jest-dom/prefer-enabled-disabled` rule as the rule looks for the `disabled` attribute
|
||||
// while the Button component doesn't use it.
|
||||
/* eslint-disable jest-dom/prefer-enabled-disabled */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
@@ -40,4 +44,31 @@ describe('Button', () => {
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should reflect the disabled state using the aria-disabled attribute', () => {
|
||||
render(<Button disabled>Hello world</Button>);
|
||||
|
||||
const button = screen.getByRole('button', { name: /hello world/i });
|
||||
|
||||
expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
// Ensure that the `disabled` attribute is not used.
|
||||
expect(button).not.toHaveAttribute('disabled', 'true');
|
||||
});
|
||||
|
||||
it('should not trigger the onClick prop if the button is disabled', () => {
|
||||
const onClick = jest.fn();
|
||||
|
||||
render(
|
||||
<Button disabled onClick={onClick}>
|
||||
Hello world
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: /hello world/i });
|
||||
|
||||
userEvent.click(button);
|
||||
|
||||
expect(onClick).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { ButtonProps, ButtonSize, ButtonVariant } from './types';
|
||||
|
||||
const defaultClassNames = ['cursor-pointer', 'inline-block', 'border-3'];
|
||||
const defaultClassNames = [
|
||||
'relative',
|
||||
'cursor-pointer',
|
||||
'inline-block',
|
||||
'border-3',
|
||||
'active:before:w-full',
|
||||
'active:before:h-full',
|
||||
'active:before:absolute',
|
||||
'active:before:inset-0',
|
||||
'active:before:border-3',
|
||||
'active:before:border-transparent',
|
||||
'active:before:bg-gray-900',
|
||||
'active:before:opacity-20',
|
||||
'aria-disabled:cursor-not-allowed',
|
||||
'aria-disabled:opacity-50',
|
||||
'focus:outline-none', // Hide the default browser outline
|
||||
'focus:ring',
|
||||
'focus:ring-focus-outline-color'
|
||||
];
|
||||
|
||||
const computeClassNames = ({
|
||||
size,
|
||||
variant
|
||||
variant,
|
||||
disabled,
|
||||
block
|
||||
}: {
|
||||
size: ButtonSize;
|
||||
variant: ButtonVariant;
|
||||
disabled?: boolean;
|
||||
block?: boolean;
|
||||
}) => {
|
||||
const classNames = [...defaultClassNames];
|
||||
|
||||
if (block) {
|
||||
classNames.push('block', 'w-full');
|
||||
}
|
||||
|
||||
// TODO: support 'link' variant
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
@@ -19,8 +45,12 @@ const computeClassNames = ({
|
||||
'border-default-foreground-danger',
|
||||
'bg-default-background-danger',
|
||||
'text-default-foreground-danger',
|
||||
'hover:bg-default-background-danger-hover',
|
||||
'hover:text-default-foreground-danger-hover'
|
||||
...(disabled
|
||||
? []
|
||||
: [
|
||||
'hover:bg-default-background-danger-hover',
|
||||
'hover:text-default-foreground-danger-hover'
|
||||
])
|
||||
);
|
||||
break;
|
||||
case 'info':
|
||||
@@ -28,8 +58,12 @@ const computeClassNames = ({
|
||||
'border-default-foreground-info',
|
||||
'bg-default-background-info',
|
||||
'text-default-foreground-info',
|
||||
'hover:bg-default-background-info-hover',
|
||||
'hover:text-default-foreground-info-hover'
|
||||
...(disabled
|
||||
? []
|
||||
: [
|
||||
'hover:bg-default-background-info-hover',
|
||||
'hover:text-default-foreground-info-hover'
|
||||
])
|
||||
);
|
||||
break;
|
||||
// default variant is 'primary'
|
||||
@@ -38,8 +72,12 @@ const computeClassNames = ({
|
||||
'border-default-foreground-secondary',
|
||||
'bg-default-background-quaternary',
|
||||
'text-default-foreground-secondary',
|
||||
'hover:bg-default-background-primary-hover',
|
||||
'hover:text-default-foreground-primary-hover'
|
||||
...(disabled
|
||||
? []
|
||||
: [
|
||||
'hover:bg-default-background-primary-hover',
|
||||
'hover:text-default-foreground-primary-hover'
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,17 +103,39 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
size = 'medium',
|
||||
type = 'button',
|
||||
onClick,
|
||||
children
|
||||
children,
|
||||
disabled,
|
||||
block
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const classes = useMemo(
|
||||
() => computeClassNames({ size, variant }),
|
||||
[size, variant]
|
||||
() => computeClassNames({ size, variant, disabled, block }),
|
||||
[size, variant, disabled, block]
|
||||
);
|
||||
|
||||
// Manually prevent click event if the button is disabled
|
||||
// as `aria-disabled` marks the element disabled but still registers the click event.
|
||||
// Ref: https://css-tricks.com/making-disabled-buttons-more-inclusive/#aa-the-difference-between-disabled-and-aria-disabled
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const ariaDisabled = event.currentTarget.getAttribute('aria-disabled');
|
||||
|
||||
if (!ariaDisabled && onClick) {
|
||||
onClick(event);
|
||||
}
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button ref={ref} className={classes} type={type} onClick={onClick}>
|
||||
<button
|
||||
ref={ref}
|
||||
className={classes}
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -11,4 +11,6 @@ export interface ButtonProps
|
||||
size?: ButtonSize;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
type?: 'submit' | 'button';
|
||||
disabled?: boolean;
|
||||
block?: boolean;
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ div.light {
|
||||
--default-background-primary-hover: var(--gray90);
|
||||
--default-background-danger-hover: var(--red15);
|
||||
--default-background-info-hover: var(--blue30);
|
||||
|
||||
--focus-outline-color: var(--blue50);
|
||||
}
|
||||
|
||||
html.dark,
|
||||
@@ -137,4 +139,6 @@ div.dark {
|
||||
--default-background-primary-hover: var(--gray00);
|
||||
--default-background-danger-hover: var(--red90);
|
||||
--default-background-info-hover: var(--blue90);
|
||||
|
||||
--focus-outline-color: var(--blue50);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const plugin = require('tailwindcss/plugin');
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/**/*.html',
|
||||
@@ -37,6 +39,19 @@ module.exports = {
|
||||
'default-background-danger-hover':
|
||||
'var(--default-background-danger-hover)',
|
||||
'default-background-info-hover': 'var(--default-background-info-hover)',
|
||||
// Focus outline
|
||||
'focus-outline-color': 'var(--focus-outline-color)',
|
||||
gray: {
|
||||
0: 'var(--gray00)',
|
||||
50: 'var(--gray05)',
|
||||
100: 'var(--gray10)',
|
||||
150: 'var(--gray15)',
|
||||
450: 'var(--gray45)',
|
||||
750: 'var(--gray75)',
|
||||
800: 'var(--gray80)',
|
||||
850: 'var(--gray85)',
|
||||
900: 'var(--gray90)'
|
||||
},
|
||||
green: {
|
||||
50: 'var(--green05)',
|
||||
100: 'var(--green10)',
|
||||
@@ -91,5 +106,9 @@ module.exports = {
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
plugins: [
|
||||
plugin(({ addVariant }) => {
|
||||
addVariant('aria-disabled', '[aria-disabled="true"]');
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user