Skip to content

Commit bdac258

Browse files
Add support for loading footer buttons in ConfirmationDialog (#6592)
Co-authored-by: Hector Garcia <[email protected]>
1 parent f781f7f commit bdac258

File tree

6 files changed

+457
-1
lines changed

6 files changed

+457
-1
lines changed

.changeset/big-oranges-marry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
Add support for `loading` footer buttons in ConfirmationDialog

packages/react/src/ConfirmationDialog/ConfirmationDialog.features.stories.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,58 @@ export const ShorthandHookFromActionMenu = () => {
6868
</div>
6969
)
7070
}
71+
72+
export const LoadingStates = () => {
73+
const [isOpen, setIsOpen] = useState(false)
74+
const [isConfirmLoading, setIsConfirmLoading] = useState(false)
75+
const [isCancelLoading, setIsCancelLoading] = useState(false)
76+
77+
const handleConfirm = useCallback(() => {
78+
setIsConfirmLoading(true)
79+
// Simulate async operation
80+
setTimeout(() => {
81+
setIsConfirmLoading(false)
82+
setIsOpen(false)
83+
}, 2000)
84+
}, [])
85+
86+
const handleCancel = useCallback(() => {
87+
setIsCancelLoading(true)
88+
// Simulate async operation
89+
setTimeout(() => {
90+
setIsCancelLoading(false)
91+
setIsOpen(false)
92+
}, 1500)
93+
}, [])
94+
95+
const handleClose = useCallback(
96+
(gesture: 'confirm' | 'close-button' | 'cancel' | 'escape') => {
97+
if (gesture === 'confirm') {
98+
handleConfirm()
99+
} else if (gesture === 'cancel') {
100+
handleCancel()
101+
} else {
102+
setIsOpen(false)
103+
}
104+
},
105+
[handleConfirm, handleCancel],
106+
)
107+
108+
return (
109+
<div className={classes.ButtonContainer}>
110+
<Button onClick={() => setIsOpen(true)}>Show Loading Dialog</Button>
111+
{isOpen && (
112+
<ConfirmationDialog
113+
title="Delete this file?"
114+
confirmButtonType="danger"
115+
confirmButtonContent="Delete"
116+
confirmButtonLoading={isConfirmLoading}
117+
cancelButtonLoading={isCancelLoading}
118+
onClose={handleClose}
119+
>
120+
This action cannot be undone. The file will be permanently deleted from your repository.
121+
</ConfirmationDialog>
122+
)}
123+
</div>
124+
)
125+
}

packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {render, fireEvent} from '@testing-library/react'
2-
import {describe, it, expect} from 'vitest'
2+
import {describe, it, expect, vi} from 'vitest'
33
import type React from 'react'
44
import {useCallback, useRef, useState} from 'react'
55

@@ -96,6 +96,37 @@ const CustomProps = ({
9696
)
9797
}
9898

99+
const LoadingStates = ({
100+
confirmButtonLoading,
101+
cancelButtonLoading,
102+
}: Pick<React.ComponentProps<typeof ConfirmationDialog>, 'confirmButtonLoading' | 'cancelButtonLoading'>) => {
103+
const [isOpen, setIsOpen] = useState(false)
104+
const buttonRef = useRef<HTMLButtonElement>(null)
105+
const onDialogClose = useCallback(() => setIsOpen(false), [])
106+
return (
107+
<ThemeProvider theme={theme}>
108+
<BaseStyles>
109+
<Button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
110+
Show dialog
111+
</Button>
112+
{isOpen && (
113+
<ConfirmationDialog
114+
title="Confirm"
115+
onClose={onDialogClose}
116+
cancelButtonContent="Cancel"
117+
confirmButtonContent="Delete"
118+
confirmButtonType="danger"
119+
confirmButtonLoading={confirmButtonLoading}
120+
cancelButtonLoading={cancelButtonLoading}
121+
>
122+
Are you sure you want to delete this?
123+
</ConfirmationDialog>
124+
)}
125+
</BaseStyles>
126+
</ThemeProvider>
127+
)
128+
}
129+
99130
describe('ConfirmationDialog', () => {
100131
it('focuses the primary action when opened and the confirmButtonType is not set', async () => {
101132
const {getByText, getByRole} = render(<Basic />)
@@ -155,4 +186,117 @@ describe('ConfirmationDialog', () => {
155186
const dialog = getByRole('alertdialog')
156187
expect(dialog.getAttribute('data-height')).toBe('small')
157188
})
189+
190+
describe('loading states', () => {
191+
it('applies loading state to confirm button when confirmButtonLoading is true', async () => {
192+
const {getByText, getByRole} = render(<LoadingStates confirmButtonLoading={true} />)
193+
194+
fireEvent.click(getByText('Show dialog'))
195+
196+
const confirmButton = getByRole('button', {name: 'Delete'})
197+
const cancelButton = getByRole('button', {name: 'Cancel'})
198+
199+
expect(confirmButton).toHaveAttribute('data-loading', 'true')
200+
expect(cancelButton).not.toHaveAttribute('data-loading', 'true')
201+
})
202+
203+
it('applies loading state to cancel button when cancelButtonLoading is true', async () => {
204+
const {getByText, getByRole} = render(<LoadingStates cancelButtonLoading={true} />)
205+
206+
fireEvent.click(getByText('Show dialog'))
207+
208+
const confirmButton = getByRole('button', {name: 'Delete'})
209+
const cancelButton = getByRole('button', {name: 'Cancel'})
210+
211+
expect(cancelButton).toHaveAttribute('data-loading', 'true')
212+
expect(confirmButton).not.toHaveAttribute('data-loading', 'true')
213+
})
214+
215+
it('applies loading state to both buttons when both loading props are true', async () => {
216+
const {getByText, getByRole} = render(<LoadingStates confirmButtonLoading={true} cancelButtonLoading={true} />)
217+
218+
fireEvent.click(getByText('Show dialog'))
219+
220+
const confirmButton = getByRole('button', {name: 'Delete'})
221+
const cancelButton = getByRole('button', {name: 'Cancel'})
222+
223+
expect(confirmButton).toHaveAttribute('data-loading', 'true')
224+
expect(cancelButton).toHaveAttribute('data-loading', 'true')
225+
})
226+
227+
it('disables button clicks when button is loading', async () => {
228+
const mockOnClose = vi.fn()
229+
const {getByRole} = render(
230+
<ThemeProvider theme={theme}>
231+
<BaseStyles>
232+
<ConfirmationDialog
233+
title="Confirm"
234+
onClose={mockOnClose}
235+
confirmButtonLoading={true}
236+
confirmButtonContent="Delete"
237+
cancelButtonContent="Cancel"
238+
>
239+
Test content
240+
</ConfirmationDialog>
241+
</BaseStyles>
242+
</ThemeProvider>,
243+
)
244+
245+
const confirmButton = getByRole('button', {name: 'Delete'})
246+
247+
fireEvent.click(confirmButton)
248+
249+
// onClose should not be called when button is loading
250+
expect(mockOnClose).not.toHaveBeenCalled()
251+
})
252+
253+
it('shows loading spinner in confirm button when loading', async () => {
254+
const {getByText, getByRole} = render(<LoadingStates confirmButtonLoading={true} />)
255+
256+
fireEvent.click(getByText('Show dialog'))
257+
258+
const confirmButton = getByRole('button', {name: 'Delete'})
259+
260+
// Check for loading spinner (Spinner component renders as an SVG)
261+
const spinner = confirmButton.querySelector('svg')
262+
expect(spinner).toBeInTheDocument()
263+
expect(confirmButton.contains(spinner)).toBe(true)
264+
})
265+
266+
it('shows loading spinner in cancel button when loading', async () => {
267+
const {getByText, getByRole} = render(<LoadingStates cancelButtonLoading={true} />)
268+
269+
fireEvent.click(getByText('Show dialog'))
270+
271+
const cancelButton = getByRole('button', {name: 'Cancel'})
272+
273+
// Check for loading spinner in cancel button
274+
const spinner = cancelButton.querySelector('svg')
275+
expect(spinner).toBeInTheDocument()
276+
expect(cancelButton.contains(spinner)).toBe(true)
277+
})
278+
279+
it('maintains proper focus management when confirm button is loading', async () => {
280+
const {getByText, getByRole} = render(<LoadingStates confirmButtonLoading={true} />)
281+
282+
fireEvent.click(getByText('Show dialog'))
283+
284+
const cancelButton = getByRole('button', {name: 'Cancel'})
285+
286+
// When confirm button is loading and dangerous, focus should be on cancel button
287+
expect(cancelButton).toEqual(document.activeElement)
288+
})
289+
290+
it('does not apply loading state when loading props are false', async () => {
291+
const {getByText, getByRole} = render(<LoadingStates confirmButtonLoading={false} cancelButtonLoading={false} />)
292+
293+
fireEvent.click(getByText('Show dialog'))
294+
295+
const confirmButton = getByRole('button', {name: 'Delete'})
296+
const cancelButton = getByRole('button', {name: 'Cancel'})
297+
298+
expect(confirmButton).not.toHaveAttribute('data-loading', 'true')
299+
expect(cancelButton).not.toHaveAttribute('data-loading', 'true')
300+
})
301+
})
158302
})

packages/react/src/ConfirmationDialog/ConfirmationDialog.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ export interface ConfirmationDialogProps {
4141
*/
4242
confirmButtonType?: 'normal' | 'primary' | 'danger'
4343

44+
/**
45+
* Whether the cancel button is in a loading state. Default: false.
46+
*/
47+
cancelButtonLoading?: boolean
48+
49+
/**
50+
* Whether the confirm button is in a loading state. Default: false.
51+
*/
52+
confirmButtonLoading?: boolean
53+
4454
/**
4555
* Additional class names to apply to the dialog
4656
*/
@@ -109,6 +119,8 @@ export const ConfirmationDialog: React.FC<React.PropsWithChildren<ConfirmationDi
109119
cancelButtonContent = 'Cancel',
110120
confirmButtonContent = 'OK',
111121
confirmButtonType = 'normal',
122+
cancelButtonLoading = false,
123+
confirmButtonLoading = false,
112124
children,
113125
className,
114126
width = 'medium',
@@ -126,12 +138,14 @@ export const ConfirmationDialog: React.FC<React.PropsWithChildren<ConfirmationDi
126138
content: cancelButtonContent,
127139
onClick: onCancelButtonClick,
128140
autoFocus: isConfirmationDangerous,
141+
loading: cancelButtonLoading,
129142
}
130143
const confirmButton: DialogButtonProps = {
131144
content: confirmButtonContent,
132145
buttonType: confirmButtonType,
133146
onClick: onConfirmButtonClick,
134147
autoFocus: !isConfirmationDangerous,
148+
loading: confirmButtonLoading,
135149
}
136150
const footerButtons = [cancelButton, confirmButton]
137151
return (

0 commit comments

Comments
 (0)