Skip to content
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

Add loading prop for Button and IconButton #3582

Merged
merged 84 commits into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
84 commits
Select commit Hold shift + click to select a range
a1c03e2
draft loading state
langermank Aug 1, 2023
cfd0944
cleanup
langermank Aug 1, 2023
9408220
add story to trigger loading
langermank Aug 1, 2023
18d7954
Merge branch 'main' into button-loading-state
langermank Sep 21, 2023
1cc0465
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Sep 22, 2023
a9a7771
merge
langermank Sep 28, 2023
5c901d4
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Oct 4, 2023
fae28dd
Merge branch 'main' into button-loading-state
langermank Oct 11, 2023
72a0247
icon button
langermank Oct 11, 2023
71b747d
Create lazy-jobs-pump.md
langermank Oct 11, 2023
556aff9
add prop for loading message
langermank Oct 11, 2023
03ce69f
Merge branch 'button-loading-state' of https://github.com/primer/reac…
langermank Oct 11, 2023
f7b316d
Merge branch 'button-loading-state' of github.com:primer/react into b…
mperrotti Oct 12, 2023
6f98511
handle no visuals loading state
langermank Oct 17, 2023
5080989
Merge branch 'button-loading-state' of github.com:primer/react into b…
mperrotti Nov 28, 2023
f3e6d07
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Nov 28, 2023
ee21a63
updates snapshots after merging from main
mperrotti Nov 28, 2023
5956393
Merge branch 'main' into button-loading-state
mperrotti Nov 28, 2023
9687486
uses unique ID for loading messages, preserves aria-describedby passe…
mperrotti Nov 28, 2023
2fc7c4d
adds Storybook examples for btn loading error message
mperrotti Nov 28, 2023
7f981b0
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Nov 28, 2023
49d8853
reverts unintentional default link underlining
mperrotti Nov 28, 2023
3aa7225
changes loadingMessage to loadingAnnouncement
mperrotti Nov 28, 2023
7f7f326
updates draft Button component with loading prop
mperrotti Nov 28, 2023
73b59c7
updates legacy button counter behavior when loading
mperrotti Nov 29, 2023
fc15b72
Merge branch 'main' into button-loading-state
mperrotti Nov 29, 2023
c277957
Revert "updates draft Button component with loading prop"
mperrotti Nov 29, 2023
d556bc9
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Dec 1, 2023
ccaa4ae
moves error behavior stories to 'Examples' section
mperrotti Dec 1, 2023
30fb01d
screenreader fixes
mperrotti Dec 1, 2023
22c7c65
adds and updates unit tests
mperrotti Dec 1, 2023
787bfaa
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Dec 1, 2023
6dcf5b3
re-updates snapshots after using correct VisuallyHidden
mperrotti Dec 1, 2023
09a3199
Merge branch 'main' into button-loading-state
mperrotti Dec 5, 2023
0e276de
documents loading props
mperrotti Dec 5, 2023
e8fcd21
adds VRTs, updates loading feature stories
mperrotti Dec 5, 2023
3eee19a
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Dec 5, 2023
e63e61c
simplifies inner visual/spinner rendering logic
mperrotti Dec 5, 2023
8a901da
removes example stories (we can put them back when Flash supports foc…
mperrotti Dec 5, 2023
39f2d4e
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Dec 5, 2023
24abbcd
Merge branch 'main' into button-loading-state
mperrotti Dec 7, 2023
ffdff03
excludes loading buttons from axe contrast check
mperrotti Dec 7, 2023
9d133a0
fixes visual regression: button counter vertical alignment
mperrotti Dec 7, 2023
e069e04
Merge branch 'main' into button-loading-state
mperrotti Dec 11, 2023
611eea0
prevents double spinners when leading and trailing visuals are passed
mperrotti Dec 11, 2023
7cf14ca
test(e2e): update story ids
joshblack Dec 13, 2023
1a4a08d
test(vrt): update snapshots
joshblack Dec 13, 2023
a091125
test(e2e): disable animations in screenshots
joshblack Dec 13, 2023
e4a6f41
Merge branch 'button-loading-state' of github.com:primer/react into b…
joshblack Dec 13, 2023
c9a0743
test(vrt): update snapshots
joshblack Dec 13, 2023
4d9128e
preserves rest state styles when button is loading
mperrotti Dec 14, 2023
3c38084
adds story for success and error announcement
mperrotti Dec 14, 2023
9408882
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Dec 14, 2023
ceb5ba2
test(vrt): update snapshots
langermank Dec 14, 2023
c7501e2
Merge branch 'main' into button-loading-state
mperrotti Dec 18, 2023
ad02496
Merge branch 'main' into button-loading-state
mperrotti Dec 18, 2023
db094c5
fixes ButtonGroup regression
mperrotti Dec 18, 2023
fe2dcee
Merge branch 'main' into button-loading-state
mperrotti Dec 19, 2023
e300738
Merge branch 'main' into button-loading-state
mperrotti Dec 19, 2023
fb6d639
Merge branch 'main' into button-loading-state
mperrotti Dec 19, 2023
0911586
delete broken snapshots
langermank Dec 19, 2023
1416fcb
also targets anchor tags in ButtonGroup styles
mperrotti Dec 19, 2023
e245fe9
Merge branch 'main' into button-loading-state
mperrotti Dec 19, 2023
5e8877e
add conditional wrapper
langermank Dec 19, 2023
f2ccc8e
Merge branch 'button-loading-state' of https://github.com/primer/reac…
langermank Dec 19, 2023
f301ffe
fix group
langermank Dec 19, 2023
e7a25ef
test(vrt): update snapshots
langermank Dec 19, 2023
71de776
Merge branch 'main' into button-loading-state
langermank Dec 19, 2023
2e5ed47
lint
mperrotti Dec 20, 2023
6c425f9
Merge branch 'button-loading-state' of github.com:primer/react into b…
mperrotti Dec 20, 2023
01e07a1
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Dec 20, 2023
20d81f4
fixes unit tests, updates snapshots
mperrotti Dec 20, 2023
b3cdce6
Update src/Button/Button.docs.json
mperrotti Dec 21, 2023
c38523c
Merge branch 'main' into button-loading-state
mperrotti Dec 21, 2023
9ad96f2
fixes 'block' layout for loading buttons
mperrotti Dec 21, 2023
7e5bc66
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Mar 12, 2024
f781434
uses new internal Status component for loading announcement
mperrotti Mar 12, 2024
6f9f6ed
updates Tooltip V2 tests to account for loading messageID in button's…
mperrotti Mar 12, 2024
8809ba5
fixes BoxProps type import to new preferred syntax
mperrotti Mar 12, 2024
9649e4e
appease the linter
mperrotti Mar 12, 2024
87fa84b
rms ConditionalWrapper
mperrotti Mar 12, 2024
5acdcb9
revert back to using ConditionalWrapper with aria-describedby
mperrotti Mar 13, 2024
c0e2a3d
Merge branch 'main' of github.com:primer/react into button-loading-state
mperrotti Mar 14, 2024
a7b114b
test(vrt): update snapshots
mperrotti Mar 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-jobs-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

Add `loading` state to `Button` and `IconButton`
27 changes: 26 additions & 1 deletion src/Button/Button.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {EyeIcon, TriangleDownIcon, HeartIcon} from '@primer/octicons-react'
import {EyeIcon, TriangleDownIcon, HeartIcon, DownloadIcon} from '@primer/octicons-react'
import React, {useState} from 'react'
import {Button} from '.'

Expand Down Expand Up @@ -87,3 +87,28 @@ export const Small = () => <Button size="small">Default</Button>
export const Medium = () => <Button size="medium">Default</Button>

export const Large = () => <Button size="large">Default</Button>

export const Loading = () => <Button loading>Default</Button>

export const LoadingTrigger = () => {
const [isLoading, setIsLoading] = useState(false)

const handleClick = () => {
setIsLoading(true)
setTimeout(() => {
setIsLoading(false)
}, 3000)
}

return (
<Button loading={isLoading} onClick={handleClick} leadingIcon={DownloadIcon}>
Export
</Button>
)
}

export const LoadingCustomMessage = () => (
<Button loading loadingMessage="This is a custom loading message">
Default
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it an sr-only message?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, visually hidden. Should the name of this prop be more specific like hiddenLoadingMessage?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm good question. I think it is a good idea to indicate it in the name because I searched the code to see where we display the message and when we add this to the prop data it might not be obvious that it is sr only.

hiddenLoadingMessage sounds like an option. visuallyHiddenLoadingMessage is too long. Is sr-only wording out-dated? 🤔 Could it be srOnlyLoadingMessage?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi!

The prop should ideally hint at what purpose it serves, but I think it doesn't have to describe implementation details.

If loadingMessage feels misguiding as the message is not shown in the button, how do you feel about something along the lines of loadingAnnouncement? It signals a live region for screen readers without getting into what it doesn't do (visuallyHidden / srOnly)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love that @siddharthkp! Thank you 😄

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love it too 😍

</Button>
)
5 changes: 5 additions & 0 deletions src/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export default {
type: 'boolean',
},
},
loading: {
control: {
type: 'boolean',
},
},
leadingVisual: OcticonArgType([EyeClosedIcon, EyeIcon, SearchIcon, XIcon, HeartIcon]),
trailingVisual: OcticonArgType([EyeClosedIcon, EyeIcon, SearchIcon, XIcon, HeartIcon]),
trailingAction: OcticonArgType([TriangleDownIcon]),
Expand Down
86 changes: 55 additions & 31 deletions src/Button/ButtonBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {ButtonProps, StyledButton} from './types'
import {getVariantStyles, getButtonStyles, getAlignContentSize} from './styles'
import {useRefObjectAsForwardedRef} from '../hooks/useRefObjectAsForwardedRef'
import {defaultSxProp} from '../utils/defaultSxProp'
import VisuallyHidden from '../_VisuallyHidden'
import Spinner from '../Spinner'

const ButtonBase = forwardRef(
({children, as: Component = 'button', sx: sxProp = defaultSxProp, ...props}, forwardedRef): JSX.Element => {
Expand All @@ -21,6 +23,8 @@ const ButtonBase = forwardRef(
size = 'medium',
alignContent = 'center',
block = false,
loading = false,
loadingMessage = 'Loading',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi!

Here's the new place to add props to the documentation: https://github.com/primer/react/blob/main/src/Button/Button.docs.json

...rest
} = props
const LeadingVisual = leadingVisual ?? leadingIcon
Expand Down Expand Up @@ -62,40 +66,60 @@ const ButtonBase = forwardRef(
}

return (
<StyledButton
as={Component}
sx={sxStyles}
{...rest}
ref={innerRef}
data-block={block ? 'block' : null}
data-size={size === 'small' || size === 'large' ? size : undefined}
data-no-visuals={!LeadingVisual && !TrailingVisual && !TrailingAction ? true : undefined}
>
{Icon ? (
<Icon />
) : (
<>
<Box as="span" data-component="buttonContent" sx={getAlignContentSize(alignContent)}>
{LeadingVisual && (
<Box as="span" data-component="leadingVisual" sx={{...iconWrapStyles}}>
<LeadingVisual />
</Box>
)}
{children && <span data-component="text">{children}</span>}
{TrailingVisual && (
<Box as="span" data-component="trailingVisual" sx={{...iconWrapStyles}}>
<TrailingVisual />
<>
<StyledButton
as={Component}
sx={sxStyles}
{...rest}
ref={innerRef}
data-block={block ? 'block' : null}
data-size={size === 'small' || size === 'large' ? size : undefined}
data-no-visuals={!LeadingVisual && !TrailingVisual && !TrailingAction ? true : undefined}
aria-disabled={loading ? true : undefined}
aria-describedby={loading ? 'loading-message' : undefined}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be?

Suggested change
aria-describedby={loading ? 'loading-message' : undefined}
aria-describedby={loading ? loadingMessage : undefined}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure aria-describedby is meant to reference the id of the message/string, not actually contain the string.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohhh sorry yes 100% 😄

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need a unique loading message ID for each button. We could have a page with multiple buttons in a loading state, and we can't have duplicate IDs.

I've picked up this PR, so I'll take care of this

>
{Icon ? (
loading ? (
<Spinner size="small" />
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the spinner size always small or should it depend on the size of the button? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think it looks good in the large button as well. I had the same thought 😆 I'll check again

Copy link
Member

@siddharthkp siddharthkp Oct 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bonus of not changing the size: it always matches the size of leadingVisual for this satisfying interaction:

CleanShot.2023-10-11.at.14.55.13.mp4

) : (
<Icon />
)
) : (
<>
<Box as="span" data-component="buttonContent" sx={getAlignContentSize(alignContent)}>
{loading && (
<Box as="span" data-component="leadingVisual" sx={{...iconWrapStyles}}>
<Spinner size="small" />
</Box>
)}
{LeadingVisual && !loading && (
<Box as="span" data-component="leadingVisual" sx={{...iconWrapStyles}}>
<LeadingVisual />
</Box>
)}
{children && <span data-component="text">{children}</span>}
{TrailingVisual && (
<Box as="span" data-component="trailingVisual" sx={{...iconWrapStyles}}>
<TrailingVisual />
</Box>
)}
</Box>
{TrailingAction && (
<Box as="span" data-component="trailingAction" sx={{...iconWrapStyles}}>
<TrailingAction />
</Box>
)}
</Box>
{TrailingAction && (
<Box as="span" data-component="trailingAction" sx={{...iconWrapStyles}}>
<TrailingAction />
</Box>
)}
</>
</>
)}
</StyledButton>
{loading && (
<VisuallyHidden>
<span aria-live="polite" aria-busy="true" id="loading-message">
{loadingMessage}
</span>
</VisuallyHidden>
)}
</StyledButton>
</>
)
},
) as PolymorphicForwardRefComponent<'button' | 'a', ButtonProps>
Expand Down
19 changes: 17 additions & 2 deletions src/Button/IconButton.features.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {HeartIcon} from '@primer/octicons-react'
import React from 'react'
import {HeartIcon, DownloadIcon} from '@primer/octicons-react'
import React, {useState} from 'react'
import {IconButton} from '.'

export default {
Expand All @@ -19,3 +19,18 @@ export const Small = () => <IconButton size="small" icon={HeartIcon} aria-label=
export const Medium = () => <IconButton size="medium" icon={HeartIcon} aria-label="Default" />

export const Large = () => <IconButton size="large" icon={HeartIcon} aria-label="Default" />

export const Loading = () => <IconButton loading icon={HeartIcon} variant="primary" aria-label="Primary" />

export const LoadingTrigger = () => {
const [isLoading, setIsLoading] = useState(false)

const handleClick = () => {
setIsLoading(true)
setTimeout(() => {
setIsLoading(false)
}, 3000)
}

return <IconButton loading={isLoading} onClick={handleClick} icon={DownloadIcon} aria-label="Download" />
}
38 changes: 19 additions & 19 deletions src/Button/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ export const getVariantStyles = (variant: VariantType = 'default', theme?: Theme
color: 'btn.text',
backgroundColor: 'btn.bg',
boxShadow: `${theme?.shadows.btn.shadow}, ${theme?.shadows.btn.insetShadow}`,
'&:hover:not([disabled])': {
'&:hover:not([disabled]):not([aria-disabled])': {
backgroundColor: 'btn.hoverBg',
borderColor: 'btn.hoverBorder',
},
'&:active:not([disabled])': {
'&:active:not([disabled]):not([aria-disabled])': {
backgroundColor: 'btn.activeBg',
borderColor: 'btn.activeBorder',
},
'&:disabled': {
'&:disabled, &[aria-disabled]': {
color: 'primer.fg.disabled',
'[data-component=ButtonCounter]': {
color: 'inherit',
Expand All @@ -34,21 +34,21 @@ export const getVariantStyles = (variant: VariantType = 'default', theme?: Theme
backgroundColor: 'btn.primary.bg',
borderColor: 'btn.primary.border',
boxShadow: `${theme?.shadows.btn.primary.shadow}`,
'&:hover:not([disabled])': {
'&:hover:not([disabled]):not([aria-disabled])': {
color: 'btn.primary.hoverText',
backgroundColor: 'btn.primary.hoverBg',
},
'&:focus:not([disabled])': {
'&:focus:not([disabled]):not([aria-disabled])': {
boxShadow: 'inset 0 0 0 3px',
},
'&:focus-visible:not([disabled])': {
'&:focus-visible:not([disabled]):not([aria-disabled])': {
boxShadow: 'inset 0 0 0 3px',
},
'&:active:not([disabled])': {
'&:active:not([disabled]):not([aria-disabled])': {
backgroundColor: 'btn.primary.selectedBg',
boxShadow: `${theme?.shadows.btn.primary.selectedShadow}`,
},
'&:disabled': {
'&:disabled, &[aria-disabled]': {
color: 'btn.primary.disabledText',
backgroundColor: 'btn.primary.disabledBg',
'[data-component=ButtonCounter]': {
Expand All @@ -68,7 +68,7 @@ export const getVariantStyles = (variant: VariantType = 'default', theme?: Theme
color: 'btn.danger.text',
backgroundColor: 'btn.bg',
boxShadow: `${theme?.shadows.btn.shadow}`,
'&:hover:not([disabled])': {
'&:hover:not([disabled]):not([aria-disabled])': {
color: 'btn.danger.hoverText',
backgroundColor: 'btn.danger.hoverBg',
borderColor: 'btn.danger.hoverBorder',
Expand All @@ -78,13 +78,13 @@ export const getVariantStyles = (variant: VariantType = 'default', theme?: Theme
color: 'btn.danger.hoverCounterFg',
},
},
'&:active:not([disabled])': {
'&:active:not([disabled]):not([aria-disabled])': {
color: 'btn.danger.selectedText',
backgroundColor: 'btn.danger.selectedBg',
boxShadow: `${theme?.shadows.btn.danger.selectedShadow}`,
borderColor: 'btn.danger.selectedBorder',
},
'&:disabled': {
'&:disabled, &[aria-disabled]': {
color: 'btn.danger.disabledText',
backgroundColor: 'btn.danger.disabledBg',
borderColor: 'btn.danger.disabledBorder',
Expand Down Expand Up @@ -115,13 +115,13 @@ export const getVariantStyles = (variant: VariantType = 'default', theme?: Theme
textDecoration: 'var(--prefers-link-underlines, underline)',
},
},
'&:hover:not([disabled])': {
'&:hover:not([disabled]):not([aria-disabled])': {
backgroundColor: 'btn.hoverBg',
},
'&:active:not([disabled])': {
'&:active:not([disabled]):not([aria-disabled])': {
backgroundColor: 'btn.selectedBg',
},
'&:disabled': {
'&:disabled, &[aria-disabled]': {
color: 'primer.fg.disabled',
'[data-component=ButtonCounter], [data-component="leadingVisual"], [data-component="trailingAction"]': {
color: 'inherit',
Expand All @@ -145,7 +145,7 @@ export const getVariantStyles = (variant: VariantType = 'default', theme?: Theme
'&:has([data-component="ButtonCounter"])': {
color: 'accent.fg',
},
'&:disabled[data-no-visuals]': {
'&:disabled[data-no-visuals], &[aria-disabled][data-no-visuals]': {
color: 'primer.fg.disabled',
'[data-component=ButtonCounter]': {
color: 'inherit',
Expand All @@ -158,7 +158,7 @@ export const getVariantStyles = (variant: VariantType = 'default', theme?: Theme
borderColor: 'btn.border',
backgroundColor: 'btn.bg',

'&:hover:not([disabled])': {
'&:hover:not([disabled]):not([aria-disabled])': {
color: 'btn.outline.hoverText',
backgroundColor: 'btn.outline.hoverBg',
borderColor: 'btn.outline.hoverBorder',
Expand All @@ -168,14 +168,14 @@ export const getVariantStyles = (variant: VariantType = 'default', theme?: Theme
color: 'btn.outline.hoverCounterFg',
},
},
'&:active:not([disabled])': {
'&:active:not([disabled]):not([aria-disabled])': {
color: 'btn.outline.selectedText',
backgroundColor: 'btn.outline.selectedBg',
boxShadow: `${theme?.shadows.btn.outline.selectedShadow}`,
borderColor: 'btn.outline.selectedBorder',
},

'&:disabled': {
'&:disabled, &[aria-disabled]': {
color: 'btn.outline.disabledText',
backgroundColor: 'btn.outline.disabledBg',
borderColor: 'btn.border',
Expand Down Expand Up @@ -232,7 +232,7 @@ export const getBaseStyles = (theme?: Theme) => ({
'&:active': {
transition: 'none',
},
'&:disabled': {
'&:disabled, &[aria-disabled]': {
cursor: 'not-allowed',
boxShadow: 'none',
},
Expand Down
2 changes: 2 additions & 0 deletions src/Button/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export type ButtonBaseProps = {
* Allow button width to fill its container.
*/
block?: boolean
loading?: boolean
loadingMessage?: string
} & SxProp &
React.ButtonHTMLAttributes<HTMLButtonElement>

Expand Down
Loading