Skip to content

Commit 3cb2008

Browse files
authored
Button: use isPending instead of isLoading (#982)
* deprecate isLoading in favor of RAC's isPending * CSS only solution to keep the button width while in a pending state
1 parent 91cd246 commit 3cb2008

File tree

5 files changed

+81
-42
lines changed

5 files changed

+81
-42
lines changed

.changeset/clever-geckos-press.md

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@obosbbl/grunnmuren-react': minor
3+
---
4+
5+
Button: deprecate isLoading in favor of isPending
6+
7+
* change prop name to align with React Aria and the useActionState hook in React.
8+
* improved accessibility for pending state by [utilizing React aria](https://react-spectrum.adobe.com/react-aria/Button.html#pending)
9+
* button events are now disabled when the button is in a pending state.
10+
* refactor to CSS instead of useLayoutEffect when button is in a pending state.
11+

form-demo/app/uncontrolled/SubmitButton.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ import { Button } from '@obosbbl/grunnmuren-react';
55
export default function SubmitButton(props) {
66
const { pending } = useFormStatus();
77

8-
return <Button isLoading={pending} type="submit" {...props} />;
8+
return <Button isPending={pending} type="submit" {...props} />;
99
}

packages/react/src/__stories__/FormValidation.stories.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const Form = (props: {
1717
serverValidate?: boolean;
1818
}) => {
1919
const [errors, setErrors] = useState({});
20-
const [isLoading, setIsLoading] = useState(false);
20+
const [isPending, setIsPending] = useState(false);
2121

2222
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
2323
e.preventDefault();
@@ -29,12 +29,12 @@ const Form = (props: {
2929
return;
3030
}
3131

32-
setIsLoading(true);
32+
setIsPending(true);
3333

3434
// Fake a delay here, so it looks like we're submitting the data to a server
3535
await new Promise((resolve) => setTimeout(resolve, 2000));
3636

37-
setIsLoading(false);
37+
setIsPending(false);
3838

3939
if (!(data['email'] as string).endsWith('.no')) {
4040
setErrors({ email: emailErrorMessage });
@@ -54,7 +54,7 @@ const Form = (props: {
5454
validationErrors={errors}
5555
>
5656
{props.children}
57-
<Button type="submit" isLoading={isLoading}>
57+
<Button type="submit" isPending={isPending}>
5858
Send inn
5959
</Button>
6060
</RACForm>

packages/react/src/button/Button.stories.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const Primary: Story = {
3939
args: {
4040
color: 'green',
4141
variant: 'primary',
42-
isLoading: false,
42+
isPending: false,
4343
},
4444
};
4545

packages/react/src/button/Button.tsx

+64-36
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
1-
import { useRef, useState, forwardRef, type Ref } from 'react';
1+
import { forwardRef, type Ref } from 'react';
22
import { cva, type VariantProps } from 'cva';
3+
import { useProgressBar } from 'react-aria';
34
import {
45
Button as RACButton,
56
Link as RACLink,
67
type ButtonProps as RACButtonProps,
78
} from 'react-aria-components';
89
import { LoadingSpinner } from '@obosbbl/grunnmuren-icons-react';
9-
import { mergeRefs, useLayoutEffect } from '@react-aria/utils';
10+
import { useLocale, type Locale } from '../use-locale';
1011

1112
/**
1213
* Figma: https://www.figma.com/file/9OvSg0ZXI5E1eQYi7AWiWn/Grunnmuren-2.0-%E2%94%82-Designsystem?node-id=30%3A2574&mode=dev
1314
*/
1415

1516
const buttonVariants = cva({
1617
base: [
17-
'inline-flex min-h-[44px] cursor-pointer items-center justify-center whitespace-nowrap rounded-lg font-medium transition-colors duration-200 focus-visible:outline-focus-offset',
18+
'relative inline-flex min-h-[44px] cursor-pointer items-center justify-center whitespace-nowrap rounded-lg font-medium transition-colors duration-200 focus-visible:outline-focus-offset',
1819
],
1920
variants: {
2021
/**
@@ -44,56 +45,69 @@ const buttonVariants = cva({
4445
true: 'p-2 [&>svg]:h-7 [&>svg]:w-7',
4546
false: 'gap-2.5 px-4 py-2',
4647
},
48+
// Make the content of the button transparent to hide it's content, but keep the button width
49+
isPending: { true: '!text-transparent', false: null },
4750
},
4851
compoundVariants: [
4952
{
5053
color: 'green',
5154
variant: 'primary',
5255
// Darken bg by 20% on hover. The color is manually crafted
53-
className: 'bg-green text-white hover:bg-green-dark active:bg-[#007352]',
56+
className:
57+
'bg-green text-white hover:bg-green-dark active:bg-[#007352] [&_[role="progressbar"]]:text-white',
5458
},
5559
{
5660
color: 'green',
5761
variant: 'secondary',
5862
className:
59-
'text-black shadow-green hover:bg-green hover:text-white active:bg-green',
63+
'text-black shadow-green hover:bg-green hover:text-white active:bg-green [&:hover_[role="progressbar"]]:text-white [&_[role="progressbar"]]:text-black',
64+
},
65+
{
66+
color: 'green',
67+
variant: 'tertiary',
68+
className: '[&_[role="progressbar"]]:text-black',
6069
},
6170
{
6271
color: 'mint',
6372
variant: 'primary',
6473
// Darken bg by 20% on hover. The color is manually crafted
65-
className: 'active:[#9ddac6] bg-mint text-black hover:bg-[#8dd4bd]',
74+
className:
75+
'active:[#9ddac6] bg-mint text-black hover:bg-[#8dd4bd] [&_[role="progressbar"]]:text-black',
6676
},
6777
{
6878
color: 'mint',
6979
variant: 'secondary',
70-
className: 'text-mint shadow-mint hover:bg-mint hover:text-black',
80+
className:
81+
'text-mint shadow-mint hover:bg-mint hover:text-black [&:hover_[role="progressbar"]]:text-black [&_[role="progressbar"]]:text-mint',
7182
},
7283
{
7384
color: 'mint',
7485
variant: 'tertiary',
75-
className: 'text-mint',
86+
className: 'text-mint [&_[role="progressbar"]]:text-mint',
7687
},
7788
{
7889
color: 'white',
7990
variant: 'primary',
80-
className: 'bg-white text-black hover:bg-sky active:bg-sky-light',
91+
className:
92+
'bg-white text-black hover:bg-sky active:bg-sky-light [&_[role="progressbar"]]:text-black',
8193
},
8294
{
8395
color: 'white',
8496
variant: 'secondary',
85-
className: 'text-white shadow-white hover:bg-white hover:text-black',
97+
className:
98+
'text-white shadow-white hover:bg-white hover:text-black [&:hover_[role="progressbar"]]:text-black [&_[role="progressbar"]]:text-white',
8699
},
87100
{
88101
color: 'white',
89102
variant: 'tertiary',
90-
className: 'text-white',
103+
className: 'text-white [&_[role="progressbar"]]:text-white',
91104
},
92105
],
93106
defaultVariants: {
94107
variant: 'primary',
95108
color: 'green',
96109
isIconOnly: false,
110+
isPending: false,
97111
},
98112
});
99113

@@ -102,9 +116,12 @@ type ButtonOrLinkProps = VariantProps<typeof buttonVariants> & {
102116
href?: string;
103117
/**
104118
* Display the button in a loading state
119+
* @deprecated Use isPending instead.
105120
* @default false
106121
*/
107122
isLoading?: boolean;
123+
/** Additional style properties for the element. */
124+
style?: React.CSSProperties;
108125
};
109126

110127
type ButtonProps = (
@@ -119,58 +136,69 @@ function isLinkProps(
119136
return !!props.href;
120137
}
121138

139+
type Translation = {
140+
[key in Locale]: string;
141+
};
142+
143+
type Translations = {
144+
[x: string]: Translation;
145+
};
146+
147+
const translations: Translations = {
148+
pending: {
149+
nb: 'venter',
150+
sv: 'väntar',
151+
en: 'pending',
152+
},
153+
};
154+
122155
function Button(
123156
props: ButtonProps,
124-
forwardedRef: Ref<HTMLButtonElement | HTMLAnchorElement>,
157+
ref: Ref<HTMLButtonElement | HTMLAnchorElement>,
125158
) {
126159
const {
127160
children: _children,
128161
color,
129162
isIconOnly,
130163
isLoading,
131164
variant,
132-
style: _style,
165+
isPending: _isPending,
133166
...restProps
134167
} = props;
135168

136-
const [widthOverride, setWidthOverride] = useState<number>();
137-
138-
const ownRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
139-
const ref = mergeRefs(ownRef, forwardedRef);
140-
141-
useLayoutEffect(() => {
142-
if (isLoading) {
143-
const requestID = window.requestAnimationFrame(() => {
144-
setWidthOverride(ownRef.current?.getBoundingClientRect()?.width);
145-
});
146-
return () => {
147-
setWidthOverride(undefined);
148-
cancelAnimationFrame(requestID);
149-
};
150-
}
151-
}, [isLoading, _children]);
169+
const isPending = _isPending || isLoading;
152170

153171
const className = buttonVariants({
154172
className: props.className,
155173
color,
156174
isIconOnly,
157175
variant,
176+
isPending,
158177
});
159178

160-
const children = widthOverride ? (
161-
// remove margin for icon alignment
162-
<LoadingSpinner className="!m-0 mx-auto animate-spin" />
179+
const locale = useLocale();
180+
181+
const { progressBarProps } = useProgressBar({
182+
isIndeterminate: true,
183+
'aria-label': translations.pending[locale],
184+
});
185+
186+
const children = isPending ? (
187+
<>
188+
{_children}
189+
<LoadingSpinner
190+
className="absolute m-auto motion-safe:animate-spin"
191+
{...progressBarProps}
192+
/>
193+
</>
163194
) : (
164195
_children
165196
);
166197

167-
const style = { ..._style, width: widthOverride };
168-
169198
return isLinkProps(restProps) ? (
170199
<RACLink
171200
{...restProps}
172201
className={className}
173-
style={style}
174202
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
175203
>
176204
{children}
@@ -179,7 +207,7 @@ function Button(
179207
<RACButton
180208
{...restProps}
181209
className={className}
182-
style={style}
210+
isPending={isPending}
183211
ref={ref as React.ForwardedRef<HTMLButtonElement>}
184212
>
185213
{children}

0 commit comments

Comments
 (0)