diff --git a/.changeset/big-oranges-marry.md b/.changeset/big-oranges-marry.md
new file mode 100644
index 00000000000..9707a97d3b0
--- /dev/null
+++ b/.changeset/big-oranges-marry.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": patch
+---
+
+Add support for `loading` footer buttons in ConfirmationDialog
diff --git a/packages/react/src/ConfirmationDialog/ConfirmationDialog.features.stories.tsx b/packages/react/src/ConfirmationDialog/ConfirmationDialog.features.stories.tsx
index 36071c6d87c..4fa7a953b3f 100644
--- a/packages/react/src/ConfirmationDialog/ConfirmationDialog.features.stories.tsx
+++ b/packages/react/src/ConfirmationDialog/ConfirmationDialog.features.stories.tsx
@@ -68,3 +68,58 @@ export const ShorthandHookFromActionMenu = () => {
)
}
+
+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 (
+
+
+ {isOpen && (
+
+ This action cannot be undone. The file will be permanently deleted from your repository.
+
+ )}
+
+ )
+}
diff --git a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
index 3ca4ad42f23..98e1e23216c 100644
--- a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
+++ b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
@@ -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'
@@ -96,6 +96,37 @@ const CustomProps = ({
)
}
+const LoadingStates = ({
+ confirmButtonLoading,
+ cancelButtonLoading,
+}: Pick, 'confirmButtonLoading' | 'cancelButtonLoading'>) => {
+ const [isOpen, setIsOpen] = useState(false)
+ const buttonRef = useRef(null)
+ const onDialogClose = useCallback(() => setIsOpen(false), [])
+ return (
+
+
+
+ {isOpen && (
+
+ Are you sure you want to delete this?
+
+ )}
+
+
+ )
+}
+
describe('ConfirmationDialog', () => {
it('focuses the primary action when opened and the confirmButtonType is not set', async () => {
const {getByText, getByRole} = render()
@@ -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()
+
+ 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()
+
+ 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()
+
+ 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(
+
+
+
+ Test content
+
+
+ ,
+ )
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+
+ 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')
+ })
+ })
})
diff --git a/packages/react/src/ConfirmationDialog/ConfirmationDialog.tsx b/packages/react/src/ConfirmationDialog/ConfirmationDialog.tsx
index e3218aaf149..6223dfebfbf 100644
--- a/packages/react/src/ConfirmationDialog/ConfirmationDialog.tsx
+++ b/packages/react/src/ConfirmationDialog/ConfirmationDialog.tsx
@@ -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
+
/**
* Additional class names to apply to the dialog
*/
@@ -109,6 +119,8 @@ export const ConfirmationDialog: React.FC {
>
)
}
+
+export const LoadingFooterButtons = () => {
+ const [isOpen, setIsOpen] = useState(true)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [isDeleting, setIsDeleting] = useState(false)
+ const buttonRef = useRef(null)
+
+ const onDialogClose = useCallback(() => {
+ setIsOpen(false)
+ setIsSubmitting(false)
+ setIsDeleting(false)
+ }, [])
+
+ const handleSubmit = useCallback(() => {
+ setIsSubmitting(true)
+ // Simulate async operation
+ setTimeout(() => {
+ setIsSubmitting(false)
+ setIsOpen(false)
+ }, 2000)
+ }, [])
+
+ const handleDelete = useCallback(() => {
+ setIsDeleting(true)
+ // Simulate async operation
+ setTimeout(() => {
+ setIsDeleting(false)
+ setIsOpen(false)
+ }, 3000)
+ }, [])
+
+ return (
+ <>
+
+ {isOpen && (
+
+ )}
+ >
+ )
+}
+
+function LoadingCustomFooter({footerButtons}: React.PropsWithChildren) {
+ return {footerButtons && }
+}
+
+export const LoadingCustomFooterButtonsCould = () => {
+ const [isOpen, setIsOpen] = useState(true)
+ const [isProcessing, setIsProcessing] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const buttonRef = useRef(null)
+
+ const onDialogClose = useCallback(() => {
+ setIsOpen(false)
+ setIsProcessing(false)
+ setIsSaving(false)
+ }, [])
+
+ const handleProcess = useCallback(() => {
+ setIsProcessing(true)
+ // Simulate async operation
+ setTimeout(() => {
+ setIsProcessing(false)
+ setIsOpen(false)
+ }, 2500)
+ }, [])
+
+ const handleSave = useCallback(() => {
+ setIsSaving(true)
+ // Simulate async operation
+ setTimeout(() => {
+ setIsSaving(false)
+ setIsOpen(false)
+ }, 1500)
+ }, [])
+
+ return (
+ <>
+
+ {isOpen && (
+
+ )}
+ >
+ )
+}
diff --git a/packages/react/src/Dialog/Dialog.test.tsx b/packages/react/src/Dialog/Dialog.test.tsx
index e0a55b4bb03..2e5a374717e 100644
--- a/packages/react/src/Dialog/Dialog.test.tsx
+++ b/packages/react/src/Dialog/Dialog.test.tsx
@@ -241,3 +241,98 @@ it('automatically focuses the element that is specified as initialFocusRef', ()
expect(getByRole('link')).toHaveFocus()
})
+
+describe('Footer button loading states', () => {
+ it('applies loading state to footer buttons', () => {
+ const {getByRole} = render(
+ ,
+ )
+
+ const submitButton = getByRole('button', {name: 'Submit'})
+ const cancelButton = getByRole('button', {name: 'Cancel'})
+
+ expect(submitButton).toHaveAttribute('data-loading', 'true')
+ expect(cancelButton).not.toHaveAttribute('data-loading', 'true')
+ })
+
+ it('shows loading spinner in button when loading', () => {
+ const {getByRole, baseElement} = render(
+ ,
+ )
+
+ const button = getByRole('button', {name: 'Processing...'})
+ const spinner = baseElement.querySelector('[data-component="loadingSpinner"]') as HTMLElement
+
+ expect(spinner).toBeInTheDocument()
+ expect(button.contains(spinner)).toBe(true)
+ })
+
+ it('disables button clicks when loading', async () => {
+ const mockOnClick = vi.fn()
+ const {getByRole} = render(
+ ,
+ )
+
+ const button = getByRole('button', {name: 'Submit'})
+
+ fireEvent.click(button)
+
+ expect(mockOnClick).not.toHaveBeenCalled()
+ })
+
+ it('maintains focus management when button is loading', async () => {
+ const {getByRole} = render(
+ ,
+ )
+
+ const cancelButton = getByRole('button', {name: 'Cancel'})
+
+ await waitFor(() => expect(cancelButton).toHaveFocus())
+ })
+
+ it('handles multiple loading buttons correctly', () => {
+ const {getByRole} = render(
+ ,
+ )
+
+ const saveDraftButton = getByRole('button', {name: 'Save Draft'})
+ const publishButton = getByRole('button', {name: 'Publish'})
+ const deleteButton = getByRole('button', {name: 'Delete'})
+
+ expect(saveDraftButton).toHaveAttribute('data-loading', 'true')
+ expect(publishButton).toHaveAttribute('data-loading', 'true')
+ expect(deleteButton).not.toHaveAttribute('data-loading', 'true')
+ })
+})