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 && ( + + This is some text + + )} + + ) +} + +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 && ( + + This is some text + + )} + + ) +} 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( + {}} + footerButtons={[ + {buttonType: 'primary', content: 'Submit', loading: true}, + {buttonType: 'default', content: 'Cancel', loading: false}, + ]} + > + Dialog content + , + ) + + 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( + {}} footerButtons={[{buttonType: 'primary', content: 'Processing...', loading: true}]}> + Dialog content + , + ) + + 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( + {}} + footerButtons={[{buttonType: 'primary', content: 'Submit', loading: true, onClick: mockOnClick}]} + > + Dialog content + , + ) + + 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( + {}} + footerButtons={[ + {buttonType: 'default', content: 'Cancel', autoFocus: true}, + {buttonType: 'primary', content: 'Submit', loading: true}, + ]} + > + Dialog content + , + ) + + const cancelButton = getByRole('button', {name: 'Cancel'}) + + await waitFor(() => expect(cancelButton).toHaveFocus()) + }) + + it('handles multiple loading buttons correctly', () => { + const {getByRole} = render( + {}} + footerButtons={[ + {buttonType: 'default', content: 'Save Draft', loading: true}, + {buttonType: 'primary', content: 'Publish', loading: true}, + {buttonType: 'danger', content: 'Delete', loading: false}, + ]} + > + Dialog content + , + ) + + 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') + }) +})