-
-
Notifications
You must be signed in to change notification settings - Fork 17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(a11y): disabled button behavior and color contrast #65
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,9 @@ | ||
import React, { useState } from "react"; | ||
import { Meta, StoryObj } from "@storybook/react"; | ||
|
||
import { FormControl } from "../form-control"; | ||
import { FormGroup } from "../form-group"; | ||
import { ControlLabel } from "../control-label"; | ||
import { Button } from "."; | ||
|
||
const story = { | ||
|
@@ -115,4 +119,37 @@ export const AsADownloadLink: Story = { | |
}, | ||
}; | ||
|
||
const FormWithSubmitButton = () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a story to demonstrate the usage of |
||
const [username, setUsername] = useState(""); | ||
|
||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||
setUsername(event.target.value); | ||
}; | ||
|
||
const handleSubmit = () => { | ||
alert("Submitted"); | ||
}; | ||
|
||
return ( | ||
<form onSubmit={handleSubmit}> | ||
<FormGroup controlId="username"> | ||
<ControlLabel>Username</ControlLabel> | ||
<FormControl | ||
componentClass="input" | ||
type="text" | ||
onChange={handleChange} | ||
/> | ||
</FormGroup> | ||
|
||
<Button type="submit" disabled={!username}> | ||
Submit | ||
</Button> | ||
</form> | ||
); | ||
}; | ||
|
||
export const AsASubmitButton: Story = { | ||
render: FormWithSubmitButton, | ||
}; | ||
|
||
export default story; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -71,6 +71,28 @@ describe("<Button />", () => { | |
expect(onClick).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it("should not trigger form submission if the button has `submit` type and is disabled", async () => { | ||
const handleSubmit = jest.fn(); | ||
|
||
render( | ||
<form onSubmit={handleSubmit}> | ||
<label> | ||
Username | ||
<input type="text" /> | ||
</label> | ||
|
||
<Button type="submit" disabled> | ||
Submit | ||
</Button> | ||
</form>, | ||
); | ||
|
||
const button = screen.getByRole("button", { name: "Submit" }); | ||
await userEvent.click(button); | ||
|
||
expect(handleSubmit).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it("should render an anchor element if the `href` prop is defined", () => { | ||
render(<Button href="https://www.freecodecamp.org">freeCodeCamp</Button>); | ||
|
||
|
@@ -115,3 +137,17 @@ describe("<Button />", () => { | |
expect(onClick).toHaveBeenCalledTimes(1); | ||
}); | ||
}); | ||
|
||
// ------------------------------ | ||
// Type tests | ||
// ------------------------------ | ||
|
||
// @ts-expect-error - Button with `danger` variant cannot be disabled | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So with the proposed change to the I'm writing tests for the changes using
|
||
<Button variant="danger" disabled> | ||
Button text | ||
</Button>; | ||
|
||
// @ts-expect-error - Button with `info` variant cannot be disabled | ||
<Button variant="info" disabled> | ||
Button text | ||
</Button>; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -18,9 +18,6 @@ const defaultClassNames = [ | |
"active:before:border-transparent", | ||
"active:before:bg-gray-900", | ||
"active:before:opacity-20", | ||
// Disabled state | ||
"aria-disabled:cursor-not-allowed", | ||
"aria-disabled:opacity-50", | ||
Comment on lines
-21
to
-23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved the disabled styles to the primary variant, instead of leaving it in the base/default class list. |
||
// Focus state | ||
"focus:outline-none", // Hide the default browser outline | ||
"focus-visible:ring", | ||
|
@@ -54,48 +51,45 @@ const computeClassNames = ({ | |
"border-foreground-danger", | ||
"bg-background-danger", | ||
"text-foreground-danger", | ||
...(disabled | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed disabled styles from the |
||
? ["active:before:hidden"] | ||
: [ | ||
"hover:bg-foreground-danger", | ||
"hover:text-background-danger", | ||
// This hover rule is redundant for the component library, | ||
// but is needed to override the border color set in client's `global.css`. | ||
// We can remove it once we have completely removed the CSS overrides in client. | ||
"hover:border-foreground-danger", | ||
"dark:hover:bg-background-danger", | ||
"dark:hover:text-foreground-danger", | ||
]), | ||
"hover:bg-foreground-danger", | ||
"hover:text-background-danger", | ||
// This hover rule is redundant for the component library, | ||
// but is needed to override the border color set in client's `global.css`. | ||
// We can remove it once we have completely removed the CSS overrides in client. | ||
"hover:border-foreground-danger", | ||
"dark:hover:bg-background-danger", | ||
"dark:hover:text-foreground-danger", | ||
); | ||
break; | ||
case "info": | ||
classNames.push( | ||
"border-foreground-info", | ||
"bg-background-info", | ||
"text-foreground-info", | ||
...(disabled | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed disabled styles from the |
||
? ["active:before:hidden"] | ||
: [ | ||
"hover:bg-foreground-info", | ||
"hover:text-background-info", | ||
// This hover rule is redundant for the component library, | ||
// but is needed to override the border color set in client's `global.css`. | ||
// We can remove it once we have completely removed the CSS overrides in client. | ||
"hover:border-foreground-info", | ||
"dark:hover:bg-background-info", | ||
"dark:hover:text-foreground-info", | ||
]), | ||
"hover:bg-foreground-info", | ||
"hover:text-background-info", | ||
// This hover rule is redundant for the component library, | ||
// but is needed to override the border color set in client's `global.css`. | ||
// We can remove it once we have completely removed the CSS overrides in client. | ||
"hover:border-foreground-info", | ||
"dark:hover:bg-background-info", | ||
"dark:hover:text-foreground-info", | ||
); | ||
break; | ||
// default variant is 'primary' | ||
default: | ||
classNames.push( | ||
"border-foreground-secondary", | ||
"bg-background-quaternary", | ||
"text-foreground-secondary", | ||
...(disabled | ||
? ["active:before:hidden"] | ||
? [ | ||
"active:before:hidden", | ||
"border-gray-450", | ||
"aria-disabled:cursor-not-allowed", | ||
"aria-disabled:opacity-80", | ||
] | ||
: [ | ||
"border-foreground-secondary", | ||
"hover:bg-foreground-primary", | ||
"hover:text-background-primary", | ||
// This hover rule is redundant for the component library, | ||
|
@@ -133,6 +127,7 @@ const StylessButton = React.forwardRef<React.ElementRef<"button">, ButtonProps>( | |
// Ref: https://css-tricks.com/making-disabled-buttons-more-inclusive/#aa-the-difference-between-disabled-and-aria-disabled | ||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { | ||
if (disabled) { | ||
event.preventDefault(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the one-liner to fix the submission issue. |
||
return; | ||
} | ||
|
||
|
@@ -197,6 +192,11 @@ export const HeadlessButton = React.forwardRef< | |
); | ||
} else { | ||
return ( | ||
// @ts-expect-error - Type check error is expected. | ||
// `disabled` can either be `boolean | undefined` or `false | undefined` depending on the union member. | ||
// TypeScript can't infer the actual union member (that ties to the `variant`), | ||
// so it complains about the `disabled` type being incompatible. | ||
// Ref: https://github.com/Microsoft/TypeScript/issues/30518 | ||
<StylessButton | ||
className={className} | ||
onClick={onClick} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,16 +4,34 @@ export type ButtonVariant = "primary" | "danger" | "info"; | |
|
||
export type ButtonSize = "small" | "medium" | "large"; | ||
|
||
export interface ButtonProps | ||
interface BaseButtonProps | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reorganized the props so that we can enforce stricter type checking. As proposed, only the |
||
extends React.ButtonHTMLAttributes<HTMLButtonElement | HTMLAnchorElement> { | ||
children: React.ReactNode; | ||
variant?: ButtonVariant; | ||
size?: ButtonSize; | ||
onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>; | ||
type?: "submit" | "button"; | ||
disabled?: boolean; | ||
block?: boolean; | ||
href?: string; | ||
download?: string; | ||
target?: React.HTMLAttributeAnchorTarget; | ||
} | ||
|
||
interface PrimaryButtonProps extends BaseButtonProps { | ||
variant?: "primary"; | ||
disabled?: boolean; | ||
} | ||
|
||
interface InfoButtonProps extends BaseButtonProps { | ||
variant: "info"; | ||
disabled?: false; | ||
} | ||
|
||
interface DangerButtonProps extends BaseButtonProps { | ||
variant: "danger"; | ||
disabled?: false; | ||
} | ||
|
||
export type ButtonProps = | ||
| PrimaryButtonProps | ||
| InfoButtonProps | ||
| DangerButtonProps; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's much easier to review the changes if you review by commit.