From 18030e4d6f581bc7213038a9527897f247716418 Mon Sep 17 00:00:00 2001
From: Katie Langerman <18661030+langermank@users.noreply.github.com>
Date: Tue, 12 Aug 2025 15:46:25 -0700
Subject: [PATCH 1/5] confirmation dialog
---
.../ConfirmationDialog.features.stories.tsx | 55 +++++++
.../ConfirmationDialog.test.tsx | 146 +++++++++++++++++-
.../ConfirmationDialog/ConfirmationDialog.tsx | 14 ++
3 files changed, 214 insertions(+), 1 deletion(-)
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..ab420a382cb 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, container} = render()
+
+ fireEvent.click(getByText('Show dialog'))
+
+ const confirmButton = getByRole('button', {name: 'Delete'})
+
+ // Check for loading spinner (Spinner component renders with specific class)
+ const spinner = container.querySelector('[data-component="loadingSpinner"]')
+ expect(spinner).toBeInTheDocument()
+ expect(confirmButton.contains(spinner)).toBe(true)
+ })
+
+ it('shows loading spinner in cancel button when loading', async () => {
+ const {getByText, getByRole, container} = render()
+
+ fireEvent.click(getByText('Show dialog'))
+
+ const cancelButton = getByRole('button', {name: 'Cancel'})
+
+ // Check for loading spinner in cancel button
+ const spinner = container.querySelector('[data-component="loadingSpinner"]')
+ 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
Date: Tue, 12 Aug 2025 15:56:30 -0700
Subject: [PATCH 2/5] add story for regular dialog
---
.../ConfirmationDialog.test.tsx | 10 +-
.../src/Dialog/Dialog.features.stories.tsx | 143 ++++++++++++++++++
packages/react/src/Dialog/Dialog.test.tsx | 95 ++++++++++++
3 files changed, 243 insertions(+), 5 deletions(-)
diff --git a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
index ab420a382cb..2d3ffbefc21 100644
--- a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
+++ b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
@@ -243,9 +243,9 @@ describe('ConfirmationDialog', () => {
)
const confirmButton = getByRole('button', {name: 'Delete'})
-
+
fireEvent.click(confirmButton)
-
+
// onClose should not be called when button is loading
expect(mockOnClose).not.toHaveBeenCalled()
})
@@ -256,7 +256,7 @@ describe('ConfirmationDialog', () => {
fireEvent.click(getByText('Show dialog'))
const confirmButton = getByRole('button', {name: 'Delete'})
-
+
// Check for loading spinner (Spinner component renders with specific class)
const spinner = container.querySelector('[data-component="loadingSpinner"]')
expect(spinner).toBeInTheDocument()
@@ -269,7 +269,7 @@ describe('ConfirmationDialog', () => {
fireEvent.click(getByText('Show dialog'))
const cancelButton = getByRole('button', {name: 'Cancel'})
-
+
// Check for loading spinner in cancel button
const spinner = container.querySelector('[data-component="loadingSpinner"]')
expect(spinner).toBeInTheDocument()
@@ -282,7 +282,7 @@ describe('ConfirmationDialog', () => {
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)
})
diff --git a/packages/react/src/Dialog/Dialog.features.stories.tsx b/packages/react/src/Dialog/Dialog.features.stories.tsx
index 6fc1cb7d188..84fc96b284c 100644
--- a/packages/react/src/Dialog/Dialog.features.stories.tsx
+++ b/packages/react/src/Dialog/Dialog.features.stories.tsx
@@ -402,3 +402,146 @@ export const RetainsFocusTrapWithDynamicContent = () => {
>
)
}
+
+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..5407167fc8d 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, container} = render(
+ ,
+ )
+
+ const button = getByRole('button', {name: 'Processing...'})
+ const spinner = container.querySelector('[data-component="loadingSpinner"]')
+
+ 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')
+ })
+})
From eb6b11b7f8d2269235882ecb68676c8bd171e8c5 Mon Sep 17 00:00:00 2001
From: Katie Langerman <18661030+langermank@users.noreply.github.com>
Date: Tue, 12 Aug 2025 16:00:55 -0700
Subject: [PATCH 3/5] Create big-oranges-marry.md
---
.changeset/big-oranges-marry.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/big-oranges-marry.md
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
From 64466781b4f6a6cec4eafe7aa57bd890983901d9 Mon Sep 17 00:00:00 2001
From: Katie Langerman <18661030+langermank@users.noreply.github.com>
Date: Tue, 12 Aug 2025 18:34:20 -0700
Subject: [PATCH 4/5] fix test
---
.../src/ConfirmationDialog/ConfirmationDialog.test.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
index 2d3ffbefc21..98e1e23216c 100644
--- a/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
+++ b/packages/react/src/ConfirmationDialog/ConfirmationDialog.test.tsx
@@ -251,27 +251,27 @@ describe('ConfirmationDialog', () => {
})
it('shows loading spinner in confirm button when loading', async () => {
- const {getByText, getByRole, container} = render()
+ const {getByText, getByRole} = render()
fireEvent.click(getByText('Show dialog'))
const confirmButton = getByRole('button', {name: 'Delete'})
- // Check for loading spinner (Spinner component renders with specific class)
- const spinner = container.querySelector('[data-component="loadingSpinner"]')
+ // 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, container} = render()
+ const {getByText, getByRole} = render()
fireEvent.click(getByText('Show dialog'))
const cancelButton = getByRole('button', {name: 'Cancel'})
// Check for loading spinner in cancel button
- const spinner = container.querySelector('[data-component="loadingSpinner"]')
+ const spinner = cancelButton.querySelector('svg')
expect(spinner).toBeInTheDocument()
expect(cancelButton.contains(spinner)).toBe(true)
})
From 252615d4aeea346d8dafa3159c826a8ab6179767 Mon Sep 17 00:00:00 2001
From: Katie Langerman <18661030+langermank@users.noreply.github.com>
Date: Wed, 13 Aug 2025 17:01:36 -0700
Subject: [PATCH 5/5] fix test
---
packages/react/src/Dialog/Dialog.test.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/react/src/Dialog/Dialog.test.tsx b/packages/react/src/Dialog/Dialog.test.tsx
index 5407167fc8d..2e5a374717e 100644
--- a/packages/react/src/Dialog/Dialog.test.tsx
+++ b/packages/react/src/Dialog/Dialog.test.tsx
@@ -264,14 +264,14 @@ describe('Footer button loading states', () => {
})
it('shows loading spinner in button when loading', () => {
- const {getByRole, container} = render(
+ const {getByRole, baseElement} = render(
,
)
const button = getByRole('button', {name: 'Processing...'})
- const spinner = container.querySelector('[data-component="loadingSpinner"]')
+ const spinner = baseElement.querySelector('[data-component="loadingSpinner"]') as HTMLElement
expect(spinner).toBeInTheDocument()
expect(button.contains(spinner)).toBe(true)