Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/big-oranges-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

Add support for `loading` footer buttons in ConfirmationDialog
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,58 @@ export const ShorthandHookFromActionMenu = () => {
</div>
)
}

export const LoadingStates = () => {
const [isOpen, setIsOpen] = useState(false)
const [isConfirmLoading, setIsConfirmLoading] = useState(false)
const [isCancelLoading, setIsCancelLoading] = useState(false)

const handleConfirm = useCallback(() => {
setIsConfirmLoading(true)
// Simulate async operation
setTimeout(() => {
setIsConfirmLoading(false)
setIsOpen(false)
}, 2000)
}, [])

const handleCancel = useCallback(() => {
setIsCancelLoading(true)
// Simulate async operation
setTimeout(() => {
setIsCancelLoading(false)
setIsOpen(false)
}, 1500)
}, [])

const handleClose = useCallback(
(gesture: 'confirm' | 'close-button' | 'cancel' | 'escape') => {
if (gesture === 'confirm') {
handleConfirm()
} else if (gesture === 'cancel') {
handleCancel()
} else {
setIsOpen(false)
}
},
[handleConfirm, handleCancel],
)

return (
<div className={classes.ButtonContainer}>
<Button onClick={() => setIsOpen(true)}>Show Loading Dialog</Button>
{isOpen && (
<ConfirmationDialog
title="Delete this file?"
confirmButtonType="danger"
confirmButtonContent="Delete"
confirmButtonLoading={isConfirmLoading}
cancelButtonLoading={isCancelLoading}
onClose={handleClose}
>
This action cannot be undone. The file will be permanently deleted from your repository.
</ConfirmationDialog>
)}
</div>
)
}
146 changes: 145 additions & 1 deletion packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {render, fireEvent} from '@testing-library/react'
import {describe, it, expect} from 'vitest'
import {describe, it, expect, vi} from 'vitest'
import type React from 'react'
import {useCallback, useRef, useState} from 'react'

Expand Down Expand Up @@ -96,6 +96,37 @@ const CustomProps = ({
)
}

const LoadingStates = ({
confirmButtonLoading,
cancelButtonLoading,
}: Pick<React.ComponentProps<typeof ConfirmationDialog>, 'confirmButtonLoading' | 'cancelButtonLoading'>) => {
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const onDialogClose = useCallback(() => setIsOpen(false), [])
return (
<ThemeProvider theme={theme}>
<BaseStyles>
<Button ref={buttonRef} onClick={() => setIsOpen(!isOpen)}>
Show dialog
</Button>
{isOpen && (
<ConfirmationDialog
title="Confirm"
onClose={onDialogClose}
cancelButtonContent="Cancel"
confirmButtonContent="Delete"
confirmButtonType="danger"
confirmButtonLoading={confirmButtonLoading}
cancelButtonLoading={cancelButtonLoading}
>
Are you sure you want to delete this?
</ConfirmationDialog>
)}
</BaseStyles>
</ThemeProvider>
)
}

describe('ConfirmationDialog', () => {
it('focuses the primary action when opened and the confirmButtonType is not set', async () => {
const {getByText, getByRole} = render(<Basic />)
Expand Down Expand Up @@ -155,4 +186,117 @@ describe('ConfirmationDialog', () => {
const dialog = getByRole('alertdialog')
expect(dialog.getAttribute('data-height')).toBe('small')
})

describe('loading states', () => {
it('applies loading state to confirm button when confirmButtonLoading is true', async () => {
const {getByText, getByRole} = render(<LoadingStates confirmButtonLoading={true} />)

fireEvent.click(getByText('Show dialog'))

const confirmButton = getByRole('button', {name: 'Delete'})
const cancelButton = getByRole('button', {name: 'Cancel'})

expect(confirmButton).toHaveAttribute('data-loading', 'true')
expect(cancelButton).not.toHaveAttribute('data-loading', 'true')
})

it('applies loading state to cancel button when cancelButtonLoading is true', async () => {
const {getByText, getByRole} = render(<LoadingStates cancelButtonLoading={true} />)

fireEvent.click(getByText('Show dialog'))

const confirmButton = getByRole('button', {name: 'Delete'})
const cancelButton = getByRole('button', {name: 'Cancel'})

expect(cancelButton).toHaveAttribute('data-loading', 'true')
expect(confirmButton).not.toHaveAttribute('data-loading', 'true')
})

it('applies loading state to both buttons when both loading props are true', async () => {
const {getByText, getByRole} = render(<LoadingStates confirmButtonLoading={true} cancelButtonLoading={true} />)

fireEvent.click(getByText('Show dialog'))

const confirmButton = getByRole('button', {name: 'Delete'})
const cancelButton = getByRole('button', {name: 'Cancel'})

expect(confirmButton).toHaveAttribute('data-loading', 'true')
expect(cancelButton).toHaveAttribute('data-loading', 'true')
})

it('disables button clicks when button is loading', async () => {
const mockOnClose = vi.fn()
const {getByRole} = render(
<ThemeProvider theme={theme}>
<BaseStyles>
<ConfirmationDialog
title="Confirm"
onClose={mockOnClose}
confirmButtonLoading={true}
confirmButtonContent="Delete"
cancelButtonContent="Cancel"
>
Test content
</ConfirmationDialog>
</BaseStyles>
</ThemeProvider>,
)

const confirmButton = getByRole('button', {name: 'Delete'})

fireEvent.click(confirmButton)

// onClose should not be called when button is loading
expect(mockOnClose).not.toHaveBeenCalled()
})

it('shows loading spinner in confirm button when loading', async () => {
const {getByText, getByRole} = render(<LoadingStates confirmButtonLoading={true} />)

fireEvent.click(getByText('Show dialog'))

const confirmButton = getByRole('button', {name: 'Delete'})

// Check for loading spinner (Spinner component renders as an SVG)
const spinner = confirmButton.querySelector('svg')
expect(spinner).toBeInTheDocument()
expect(confirmButton.contains(spinner)).toBe(true)
})

it('shows loading spinner in cancel button when loading', async () => {
const {getByText, getByRole} = render(<LoadingStates cancelButtonLoading={true} />)

fireEvent.click(getByText('Show dialog'))

const cancelButton = getByRole('button', {name: 'Cancel'})

// Check for loading spinner in cancel button
const spinner = cancelButton.querySelector('svg')
expect(spinner).toBeInTheDocument()
expect(cancelButton.contains(spinner)).toBe(true)
})

it('maintains proper focus management when confirm button is loading', async () => {
const {getByText, getByRole} = render(<LoadingStates confirmButtonLoading={true} />)

fireEvent.click(getByText('Show dialog'))

const cancelButton = getByRole('button', {name: 'Cancel'})

// When confirm button is loading and dangerous, focus should be on cancel button
expect(cancelButton).toEqual(document.activeElement)
})

it('does not apply loading state when loading props are false', async () => {
const {getByText, getByRole} = render(<LoadingStates confirmButtonLoading={false} cancelButtonLoading={false} />)

fireEvent.click(getByText('Show dialog'))

const confirmButton = getByRole('button', {name: 'Delete'})
const cancelButton = getByRole('button', {name: 'Cancel'})

expect(confirmButton).not.toHaveAttribute('data-loading', 'true')
expect(cancelButton).not.toHaveAttribute('data-loading', 'true')
})
})
})
14 changes: 14 additions & 0 deletions packages/react/src/ConfirmationDialog/ConfirmationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ export interface ConfirmationDialogProps {
*/
confirmButtonType?: 'normal' | 'primary' | 'danger'

/**
* Whether the cancel button is in a loading state. Default: false.
*/
cancelButtonLoading?: boolean

/**
* Whether the confirm button is in a loading state. Default: false.
*/
confirmButtonLoading?: boolean

Comment on lines +44 to +53
Copy link
Contributor

@hectahertz hectahertz Aug 15, 2025

Choose a reason for hiding this comment

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

We should group these props in an object for each button, let's merge this for now!

/**
* Additional class names to apply to the dialog
*/
Expand Down Expand Up @@ -109,6 +119,8 @@ export const ConfirmationDialog: React.FC<React.PropsWithChildren<ConfirmationDi
cancelButtonContent = 'Cancel',
confirmButtonContent = 'OK',
confirmButtonType = 'normal',
cancelButtonLoading = false,
confirmButtonLoading = false,
children,
className,
width = 'medium',
Expand All @@ -126,12 +138,14 @@ export const ConfirmationDialog: React.FC<React.PropsWithChildren<ConfirmationDi
content: cancelButtonContent,
onClick: onCancelButtonClick,
autoFocus: isConfirmationDangerous,
loading: cancelButtonLoading,
}
const confirmButton: DialogButtonProps = {
content: confirmButtonContent,
buttonType: confirmButtonType,
onClick: onConfirmButtonClick,
autoFocus: !isConfirmationDangerous,
loading: confirmButtonLoading,
}
const footerButtons = [cancelButton, confirmButton]
return (
Expand Down
Loading
Loading