Skip to content

Commit 6a647e2

Browse files
committed
feat(button): added support for disabled theme without disabling button
This is really helpful if you want a button to look disabled without actually disabling it. A great example for this is creating async buttons that show a loading progress while some background process or API call is happening. If the button became `:disabled` during this, keyboard focus would be lost which isn't ideal.
1 parent 0e14a09 commit 6a647e2

File tree

4 files changed

+1279
-133
lines changed

4 files changed

+1279
-133
lines changed

packages/button/src/Button.tsx

+38-3
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,16 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
7676
rippleClassName,
7777
rippleContainerClassName,
7878
enablePressedAndRipple: propEnablePressedAndRipple,
79+
disablePressedFallback,
80+
onClick,
81+
onKeyUp,
82+
onKeyDown,
83+
onMouseUp,
84+
onMouseDown,
85+
onMouseLeave,
86+
onTouchStart,
87+
onTouchMove,
88+
onTouchEnd,
7989
...props
8090
},
8191
ref
@@ -84,19 +94,31 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
8494
typeof propEnablePressedAndRipple === "boolean"
8595
? propEnablePressedAndRipple
8696
: themeType === "contained";
97+
const propHandlers = {
98+
onKeyUp,
99+
onKeyDown,
100+
onMouseUp,
101+
onMouseDown,
102+
onMouseLeave,
103+
onTouchStart,
104+
onTouchMove,
105+
onTouchEnd,
106+
};
87107

108+
const isDisabledTheme = theme === "disabled";
88109
const { ripples, className, handlers } = useInteractionStates({
89-
handlers: props,
110+
handlers: propHandlers,
90111
className: buttonThemeClassNames({
91112
theme,
92113
themeType,
93114
buttonType,
94115
disabled,
95116
className: propClassName,
96117
}),
97-
disabled,
118+
disabled: disabled || isDisabledTheme,
98119
disableRipple,
99120
disableProgrammaticRipple,
121+
disablePressedFallback,
100122
rippleTimeout,
101123
rippleClassNames,
102124
rippleClassName,
@@ -107,8 +129,10 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
107129
return (
108130
<FAB position={floating} {...floatingProps}>
109131
<button
132+
aria-disabled={isDisabledTheme || undefined}
110133
{...props}
111-
{...handlers}
134+
{...(isDisabledTheme ? undefined : handlers)}
135+
onClick={isDisabledTheme ? undefined : onClick}
112136
ref={ref}
113137
type={type}
114138
className={className}
@@ -134,13 +158,15 @@ if (process.env.NODE_ENV !== "production") {
134158
"secondary",
135159
"warning",
136160
"error",
161+
"disabled",
137162
]),
138163
themeType: PropTypes.oneOf(["flat", "outline", "contained"]),
139164
buttonType: PropTypes.oneOf(["text", "icon"]),
140165
disabled: PropTypes.bool,
141166
children: PropTypes.node,
142167
disableRipple: PropTypes.bool,
143168
disableProgrammaticRipple: PropTypes.bool,
169+
disablePressedFallback: PropTypes.bool,
144170
rippleTimeout: PropTypes.oneOfType([
145171
PropTypes.number,
146172
PropTypes.shape({
@@ -172,6 +198,15 @@ if (process.env.NODE_ENV !== "production") {
172198
"bottom-right",
173199
]),
174200
floatingProps: PropTypes.object,
201+
onClick: PropTypes.func,
202+
onKeyUp: PropTypes.func,
203+
onKeyDown: PropTypes.func,
204+
onMouseUp: PropTypes.func,
205+
onMouseDown: PropTypes.func,
206+
onMouseLeave: PropTypes.func,
207+
onTouchStart: PropTypes.func,
208+
onTouchMove: PropTypes.func,
209+
onTouchEnd: PropTypes.func,
175210
};
176211
} catch (e) {}
177212
}

packages/button/src/__tests__/Button.tsx

+108-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { render } from "@testing-library/react";
2+
import { render, fireEvent } from "@testing-library/react";
33

44
import Button from "../Button";
55
import {
@@ -16,6 +16,7 @@ const themes: ButtonTheme[] = [
1616
"secondary",
1717
"warning",
1818
"error",
19+
"disabled",
1920
];
2021
const themeTypes: ButtonThemeType[] = ["flat", "contained", "outline"];
2122
const buttonTypes: ButtonType[] = ["text", "icon"];
@@ -98,4 +99,110 @@ describe("Button", () => {
9899
expect(button.className).toContain("rmd-button--contained");
99100
expect(button.className).toContain("rmd-button--primary");
100101
});
102+
103+
it("should correctly pass the event handlers down and fire them as expected", () => {
104+
const onClick = jest.fn();
105+
const onKeyUp = jest.fn();
106+
const onKeyDown = jest.fn();
107+
const onMouseUp = jest.fn();
108+
const onMouseDown = jest.fn();
109+
const onMouseLeave = jest.fn();
110+
const onTouchStart = jest.fn();
111+
const onTouchMove = jest.fn();
112+
const onTouchEnd = jest.fn();
113+
const handlers = {
114+
onClick,
115+
onKeyUp,
116+
onKeyDown,
117+
onMouseUp,
118+
onMouseDown,
119+
onMouseLeave,
120+
onTouchStart,
121+
onTouchMove,
122+
onTouchEnd,
123+
};
124+
125+
const { getByRole } = render(
126+
<Button
127+
{...handlers}
128+
disableRipple
129+
disablePressedFallback
130+
enablePressedAndRipple={false}
131+
>
132+
Button
133+
</Button>
134+
);
135+
const button = getByRole("button");
136+
137+
fireEvent.keyDown(button);
138+
fireEvent.keyUp(button);
139+
fireEvent.mouseDown(button);
140+
fireEvent.mouseUp(button);
141+
fireEvent.mouseLeave(button);
142+
fireEvent.click(button);
143+
fireEvent.touchStart(button);
144+
fireEvent.touchMove(button);
145+
fireEvent.touchEnd(button);
146+
147+
expect(onClick).toBeCalled();
148+
expect(onKeyUp).toBeCalled();
149+
expect(onKeyDown).toBeCalled();
150+
expect(onMouseUp).toBeCalled();
151+
expect(onMouseDown).toBeCalled();
152+
expect(onMouseLeave).toBeCalled();
153+
expect(onTouchStart).toBeCalled();
154+
expect(onTouchMove).toBeCalled();
155+
expect(onTouchEnd).toBeCalled();
156+
});
157+
158+
it("should not allow for any of the interaction state handlers to be called while the theme is disabled", () => {
159+
const onClick = jest.fn();
160+
const onKeyUp = jest.fn();
161+
const onKeyDown = jest.fn();
162+
const onMouseUp = jest.fn();
163+
const onMouseDown = jest.fn();
164+
const onMouseLeave = jest.fn();
165+
const onTouchStart = jest.fn();
166+
const onTouchMove = jest.fn();
167+
const onTouchEnd = jest.fn();
168+
const handlers = {
169+
onClick,
170+
onKeyUp,
171+
onKeyDown,
172+
onMouseUp,
173+
onMouseDown,
174+
onMouseLeave,
175+
onTouchStart,
176+
onTouchMove,
177+
onTouchEnd,
178+
};
179+
180+
const { getByRole } = render(
181+
<Button {...handlers} theme="disabled">
182+
Button
183+
</Button>
184+
);
185+
const button = getByRole("button");
186+
187+
fireEvent.keyDown(button);
188+
fireEvent.keyUp(button);
189+
fireEvent.mouseDown(button);
190+
fireEvent.mouseUp(button);
191+
fireEvent.click(button);
192+
fireEvent.mouseLeave(button);
193+
fireEvent.touchStart(button);
194+
fireEvent.touchMove(button);
195+
fireEvent.touchEnd(button);
196+
197+
expect(onClick).not.toBeCalled();
198+
expect(onKeyUp).not.toBeCalled();
199+
expect(onKeyDown).not.toBeCalled();
200+
expect(onMouseUp).not.toBeCalled();
201+
expect(onMouseDown).not.toBeCalled();
202+
expect(onMouseLeave).not.toBeCalled();
203+
expect(onTouchStart).not.toBeCalled();
204+
expect(onTouchMove).not.toBeCalled();
205+
expect(onTouchEnd).not.toBeCalled();
206+
expect(button).toHaveAttribute("aria-disabled", "true");
207+
});
101208
});

0 commit comments

Comments
 (0)