Share one modal shell and inline confirm across the app

Modal owns the overlay, header, close button and footer for the balance,
planned-day and QR modals, and closes on Escape (held off while the QR
fetch runs). InlineConfirm replaces the hand-built delete and
remove-member confirmations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:14:05 +02:00
parent e9d7cee66b
commit d5596afe10
8 changed files with 979 additions and 459 deletions
@@ -0,0 +1,187 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { InlineConfirm } from './InlineConfirm'
describe('InlineConfirm', () => {
it('renders the prompt and both buttons', () => {
render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
)
expect(screen.getByText('Are you sure?')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Yes' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'No' })).toBeInTheDocument()
})
it('defaults to cancel before confirm', () => {
render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons.map((b) => b.textContent)).toEqual(['No', 'Yes'])
})
it('shows confirm before cancel when confirmFirst is set', () => {
render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={vi.fn()}
onCancel={vi.fn()}
confirmFirst
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons.map((b) => b.textContent)).toEqual(['Yes', 'No'])
})
it('calls onConfirm when the confirm button is clicked', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={onConfirm}
onCancel={vi.fn()}
/>,
)
await user.click(screen.getByRole('button', { name: 'Yes' }))
expect(onConfirm).toHaveBeenCalledTimes(1)
})
it('calls onCancel when the cancel button is clicked', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={vi.fn()}
onCancel={onCancel}
/>,
)
await user.click(screen.getByRole('button', { name: 'No' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('disables both buttons while busy', () => {
render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={vi.fn()}
onCancel={vi.fn()}
isBusy
/>,
)
expect(screen.getByRole('button', { name: 'Yes' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'No' })).toBeDisabled()
})
it('leaves both buttons enabled when not busy', () => {
render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Yes' })).not.toBeDisabled()
expect(screen.getByRole('button', { name: 'No' })).not.toBeDisabled()
})
it('appends btn--sm to both buttons when small is set', () => {
render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={vi.fn()}
onCancel={vi.fn()}
small
/>,
)
expect(screen.getByRole('button', { name: 'Yes' })).toHaveClass('btn--sm')
expect(screen.getByRole('button', { name: 'No' })).toHaveClass('btn--sm')
})
it('does not append btn--sm by default', () => {
render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Yes' })).not.toHaveClass('btn--sm')
expect(screen.getByRole('button', { name: 'No' })).not.toHaveClass('btn--sm')
})
it('wires up confirmTestId and cancelTestId', () => {
render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={vi.fn()}
onCancel={vi.fn()}
confirmTestId="confirm-test"
cancelTestId="cancel-test"
/>,
)
expect(screen.getByTestId('confirm-test')).toBeInTheDocument()
expect(screen.getByTestId('cancel-test')).toBeInTheDocument()
})
it('wires up the wrapper testId and className', () => {
const { container } = render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={vi.fn()}
onCancel={vi.fn()}
testId="wrapper-test"
className="my-wrapper"
/>,
)
const wrapper = screen.getByTestId('wrapper-test')
expect(wrapper).toHaveClass('my-wrapper')
expect(container.firstChild).toBe(wrapper)
})
it('wires up promptClassName on the prompt span', () => {
render(
<InlineConfirm
prompt="Are you sure?"
confirmLabel="Yes"
cancelLabel="No"
onConfirm={vi.fn()}
onCancel={vi.fn()}
promptClassName="prompt-class"
/>,
)
expect(screen.getByText('Are you sure?')).toHaveClass('prompt-class')
})
})
@@ -0,0 +1,97 @@
import { ReactNode } from 'react'
export interface InlineConfirmProps {
prompt: ReactNode
confirmLabel: ReactNode
cancelLabel: ReactNode
onConfirm: () => void
onCancel: () => void
isBusy?: boolean
confirmTestId?: string
cancelTestId?: string
/** Class for the outer wrapper — the two callers use different elements
* for this (`.modal__delete-confirm`, `.settings-panel__remove-confirm`),
* so the wrapper's look stays theirs rather than being unified here. */
className?: string
/** Class for the span wrapping `prompt` — only `PlannedDayModal` needs
* one (`.modal__delete-confirm-text`, which pushes the buttons right via
* `margin-right: auto`); `Settings`' row has no such class. */
promptClassName?: string
/** `Settings`' remove-member row shows the danger confirm before cancel;
* `PlannedDayModal`'s delete row shows cancel first. Defaults to cancel
* first. */
confirmFirst?: boolean
/** Appends `btn--sm` to both buttons — `Settings`' compact row uses it,
* `PlannedDayModal`'s footer-sized row doesn't. */
small?: boolean
/** `data-testid` on the outer wrapper — `PlannedDayModal`'s row has one
* (`delete-confirm`); `Settings`' row doesn't. */
testId?: string
}
/**
* The shared "are you sure" row — a prompt plus a danger confirm and a
* secondary cancel, both disabled while busy. Replaces the hand-copied
* versions in `PlannedDayModal` (delete confirm) and `Settings` (remove-
* member confirm), which differ only in wrapper class, prompt wrapping,
* button order and button size — all exposed as props here so neither
* caller's DOM has to change.
*/
export function InlineConfirm({
prompt,
confirmLabel,
cancelLabel,
onConfirm,
onCancel,
isBusy = false,
confirmTestId,
cancelTestId,
className,
promptClassName,
confirmFirst = false,
small = false,
testId,
}: InlineConfirmProps): ReactNode {
const sizeClass = small ? ' btn--sm' : ''
const confirmButton = (
<button
type="button"
className={`btn btn--danger${sizeClass}`}
onClick={onConfirm}
disabled={isBusy}
data-testid={confirmTestId}
>
{confirmLabel}
</button>
)
const cancelButton = (
<button
type="button"
className={`btn btn--secondary${sizeClass}`}
onClick={onCancel}
disabled={isBusy}
data-testid={cancelTestId}
>
{cancelLabel}
</button>
)
return (
<div className={className} data-testid={testId}>
<span className={promptClassName}>{prompt}</span>
{confirmFirst ? (
<>
{confirmButton}
{cancelButton}
</>
) : (
<>
{cancelButton}
{confirmButton}
</>
)}
</div>
)
}
@@ -0,0 +1,184 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Modal } from './Modal'
describe('Modal', () => {
it('renders the title, close button and children', () => {
render(
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close">
<p>Body content</p>
</Modal>,
)
expect(screen.getByRole('heading', { name: 'Hello' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
expect(screen.getByText('Body content')).toBeInTheDocument()
})
it('defaults to the md content size', () => {
const { container } = render(
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close">
<p>Body</p>
</Modal>,
)
expect(container.querySelector('.modal__content--md')).not.toBeNull()
})
it('applies the sm content size when requested', () => {
const { container } = render(
<Modal onClose={vi.fn()} title="Hello" size="sm" closeAriaLabel="Close">
<p>Body</p>
</Modal>,
)
expect(container.querySelector('.modal__content--sm')).not.toBeNull()
})
it('calls onClose when the close button is clicked', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
<p>Body</p>
</Modal>,
)
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('disables the close button when closeDisabled is set', () => {
render(
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close" closeDisabled>
<p>Body</p>
</Modal>,
)
expect(screen.getByRole('button', { name: 'Close' })).toBeDisabled()
})
it('closes on overlay click by default', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const { container } = render(
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
<p>Body</p>
</Modal>,
)
await user.click(container.querySelector('.modal__overlay') as Element)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('does not close on overlay click when closeOnOverlayClick is false', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const { container } = render(
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close" closeOnOverlayClick={false}>
<p>Body</p>
</Modal>,
)
await user.click(container.querySelector('.modal__overlay') as Element)
expect(onClose).not.toHaveBeenCalled()
})
it('does not close when clicking inside the content box', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
<p>Body content</p>
</Modal>,
)
await user.click(screen.getByText('Body content'))
expect(onClose).not.toHaveBeenCalled()
})
it('calls onClose on Escape', () => {
const onClose = vi.fn()
render(
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
<p>Body</p>
</Modal>,
)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('ignores other keys', () => {
const onClose = vi.fn()
render(
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
<p>Body</p>
</Modal>,
)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(onClose).not.toHaveBeenCalled()
})
it('does not call onClose on Escape when closeDisabled is set', () => {
const onClose = vi.fn()
render(
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close" closeDisabled>
<p>Body</p>
</Modal>,
)
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(onClose).not.toHaveBeenCalled()
})
it('removes the Escape listener on unmount', () => {
const onClose = vi.fn()
const { unmount } = render(
<Modal onClose={onClose} title="Hello" closeAriaLabel="Close">
<p>Body</p>
</Modal>,
)
unmount()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(onClose).not.toHaveBeenCalled()
})
it('renders a footer when provided', () => {
render(
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close" footer={<button>Save</button>}>
<p>Body</p>
</Modal>,
)
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it('renders no footer when omitted', () => {
const { container } = render(
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close">
<p>Body</p>
</Modal>,
)
expect(container.querySelector('.modal__footer')).toBeNull()
})
it('wires up testId, contentTestId, titleTestId, titleId and role', () => {
render(
<Modal
onClose={vi.fn()}
title="Hello"
closeAriaLabel="Close"
testId="overlay-test"
contentTestId="content-test"
titleTestId="title-test"
titleId="hello-title"
role="dialog"
>
<p>Body</p>
</Modal>,
)
expect(screen.getByTestId('overlay-test')).toHaveAttribute('role', 'dialog')
expect(screen.getByTestId('content-test')).toBeInTheDocument()
expect(screen.getByTestId('title-test')).toHaveAttribute('id', 'hello-title')
})
it('omits role when not provided', () => {
const { container } = render(
<Modal onClose={vi.fn()} title="Hello" closeAriaLabel="Close">
<p>Body</p>
</Modal>,
)
expect(container.querySelector('.modal__overlay')).not.toHaveAttribute('role')
})
})
+96
View File
@@ -0,0 +1,96 @@
import { ReactNode, useEffect } from 'react'
import { Icon } from './Icon'
export interface ModalProps {
onClose: () => void
title: ReactNode
/** Maps to `.modal__content--{size}` in `src/styles/primitives.css`. */
size?: 'sm' | 'md'
/** Disables the close button and stops Escape from closing — mirrors
* `QRCodeModal`'s "can't close while the scrape is running" rule. */
closeDisabled?: boolean
/** Whether clicking the overlay (outside the content box) closes the
* modal. Defaults to true; `QRCodeModal` sets it false so a stray click
* can't drop a running fetch. */
closeOnOverlayClick?: boolean
footer?: ReactNode
/** `data-testid` on the overlay div. */
testId?: string
/** `data-testid` on the content box. */
contentTestId?: string
/** `data-testid` on the title `<h2>`. */
titleTestId?: string
/** `id` on the title `<h2>`, for a caller that wants to wire up
* `aria-labelledby` on the overlay itself. */
titleId?: string
/** Set to `'dialog'` when the caller wants that role on the overlay
* (`PlannedDayModal` does; the others don't add one). */
role?: 'dialog'
closeAriaLabel: string
children: ReactNode
}
/**
* The shared `.modal__overlay > .modal__content > .modal__header (h2 +
* close button) > .modal__body > .modal__footer` shell — hand-copied across
* `ParentBalanceForm`, `PlannedDayModal` and `QRCodeModal` before this.
* Standardises the close button on `<Icon name="close">`; the caller still
* supplies its own `aria-label` text. No focus trap — out of scope here.
*/
export function Modal({
onClose,
title,
size = 'md',
closeDisabled = false,
closeOnOverlayClick = true,
footer,
testId,
contentTestId,
titleTestId,
titleId,
role,
closeAriaLabel,
children,
}: ModalProps): ReactNode {
useEffect(() => {
if (closeDisabled) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [closeDisabled, onClose])
const handleOverlayClick = () => {
if (closeOnOverlayClick) onClose()
}
return (
<div className="modal__overlay" onClick={handleOverlayClick} data-testid={testId} role={role}>
<div
className={`modal__content modal__content--${size}`}
onClick={(e) => e.stopPropagation()}
data-testid={contentTestId}
>
<div className="modal__header">
<h2 id={titleId} data-testid={titleTestId}>
{title}
</h2>
<button
type="button"
className="btn btn--icon"
onClick={onClose}
disabled={closeDisabled}
aria-label={closeAriaLabel}
>
<Icon name="close" size={20} />
</button>
</div>
<div className="modal__body">{children}</div>
{footer && <div className="modal__footer">{footer}</div>}
</div>
</div>
)
}
@@ -3,6 +3,7 @@ import { useTranslation } from '@/i18n'
import { isApiError, type ApiRequestError } from '@/services/errors'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import { Modal } from '@/components/common/Modal'
import './parent-balance-form.css'
export interface ParentBalanceInitial {
@@ -206,22 +207,25 @@ export function ParentBalanceForm({
}
}
return (
<div className="modal__overlay" onClick={onClose}>
<div className="modal__content modal__content--md" onClick={(e) => e.stopPropagation()}>
<div className="modal__header">
<h2>{isSelf ? t('parentBalance.titleSelf') : t('parentBalance.title', { name: parentName })}</h2>
<button
type="button"
className="btn btn--icon"
onClick={onClose}
aria-label={t('parentBalance.closeAria')}
>
×
const footer = (
<>
<button type="button" className="btn btn--secondary" onClick={onClose} disabled={isSaving}>
{t('parentBalance.cancelButton')}
</button>
</div>
<button type="button" className="btn btn--primary" onClick={() => void handleSave()} disabled={isSaving}>
{isSaving ? t('parentBalance.savingButton') : t('parentBalance.saveButton')}
</button>
</>
)
<div className="modal__body">
return (
<Modal
onClose={onClose}
title={isSelf ? t('parentBalance.titleSelf') : t('parentBalance.title', { name: parentName })}
size="md"
closeAriaLabel={t('parentBalance.closeAria')}
footer={footer}
>
<p className="parent-balance-form__description">{t('parentBalance.description')}</p>
{(error || saveError) && <Alert variant="error">{error || saveError}</Alert>}
@@ -328,27 +332,6 @@ export function ParentBalanceForm({
</FormField>
)}
</div>
</div>
<div className="modal__footer">
<button
type="button"
className="btn btn--secondary"
onClick={onClose}
disabled={isSaving}
>
{t('parentBalance.cancelButton')}
</button>
<button
type="button"
className="btn btn--primary"
onClick={() => void handleSave()}
disabled={isSaving}
>
{isSaving ? t('parentBalance.savingButton') : t('parentBalance.saveButton')}
</button>
</div>
</div>
</div>
</Modal>
)
}
@@ -4,6 +4,8 @@ import { useTranslation } from '@/i18n'
import { Icon } from '@/components/common/Icon'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import { Modal } from '@/components/common/Modal'
import { InlineConfirm } from '@/components/common/InlineConfirm'
import './planned-day-modal.css'
/** The default path — todo.md #10/#11: the planner decides which day type
@@ -268,21 +270,71 @@ export function PlannedDayModal({
const parentModeSelectValue = parentMode === 'specific' ? specificParentId : parentMode
return (
<div className="modal__overlay" onClick={onClose} data-testid="planned-day-modal__overlay" role="dialog">
<div
className="modal__content modal__content--sm"
onClick={(e) => e.stopPropagation()}
data-testid="planned-day-modal__content"
const footer = (
<>
{!isRangeMode && existingDay && onDelete && isConfirmingDelete && (
<InlineConfirm
testId="delete-confirm"
className="modal__delete-confirm"
promptClassName="modal__delete-confirm-text"
prompt={t('plannedDayModal.confirmDelete')}
cancelLabel={t('plannedDayModal.cancelButton')}
confirmLabel={isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteConfirmButton')}
onCancel={handleCancelDelete}
onConfirm={() => void handleConfirmDelete()}
isBusy={isSaving}
cancelTestId="delete-cancel-button"
confirmTestId="delete-confirm-button"
/>
)}
{!isConfirmingDelete && (
<>
{!isRangeMode && existingDay && onDelete && (
<button
type="button"
className="btn btn--danger btn--start"
onClick={handleDeleteClick}
disabled={isSaving}
data-testid="delete-button"
>
<div className="modal__header">
<h2 data-testid="modal-title">{modalTitle}</h2>
<button type="button" className="btn btn--icon" onClick={onClose} aria-label={t('plannedDayModal.closeAria')}>
<Icon name="close" size={20} />
{isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteButton')}
</button>
</div>
)}
<button
type="button"
className="btn btn--secondary"
onClick={onClose}
disabled={isSaving}
data-testid="cancel-button"
>
{t('plannedDayModal.cancelButton')}
</button>
<button
type="button"
className="btn btn--primary"
onClick={() => void handleSave()}
disabled={isSaving}
data-testid="save-button"
>
{isSaving ? t('plannedDayModal.savingButton') : t('plannedDayModal.saveButton')}
</button>
</>
)}
</>
)
<div className="modal__body">
return (
<Modal
onClose={onClose}
title={modalTitle}
size="sm"
closeAriaLabel={t('plannedDayModal.closeAria')}
testId="planned-day-modal__overlay"
contentTestId="planned-day-modal__content"
titleTestId="modal-title"
role="dialog"
footer={footer}
>
{!isRangeMode && <p className="planned-day-modal__date">{displayDate}</p>}
{(error || localError) && <Alert variant="error">{error || localError}</Alert>}
@@ -455,69 +507,6 @@ export function PlannedDayModal({
</div>
)}
</div>
</div>
<div className="modal__footer">
{!isRangeMode && existingDay && onDelete && isConfirmingDelete && (
<div className="modal__delete-confirm" data-testid="delete-confirm">
<span className="modal__delete-confirm-text">
{t('plannedDayModal.confirmDelete')}
</span>
<button
type="button"
className="btn btn--secondary"
onClick={handleCancelDelete}
disabled={isSaving}
data-testid="delete-cancel-button"
>
{t('plannedDayModal.cancelButton')}
</button>
<button
type="button"
className="btn btn--danger"
onClick={() => void handleConfirmDelete()}
disabled={isSaving}
data-testid="delete-confirm-button"
>
{isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteConfirmButton')}
</button>
</div>
)}
{!isConfirmingDelete && (
<>
{!isRangeMode && existingDay && onDelete && (
<button
type="button"
className="btn btn--danger btn--start"
onClick={handleDeleteClick}
disabled={isSaving}
data-testid="delete-button"
>
{isSaving ? t('plannedDayModal.deletingButton') : t('plannedDayModal.deleteButton')}
</button>
)}
<button
type="button"
className="btn btn--secondary"
onClick={onClose}
disabled={isSaving}
data-testid="cancel-button"
>
{t('plannedDayModal.cancelButton')}
</button>
<button
type="button"
className="btn btn--primary"
onClick={() => void handleSave()}
disabled={isSaving}
data-testid="save-button"
>
{isSaving ? t('plannedDayModal.savingButton') : t('plannedDayModal.saveButton')}
</button>
</>
)}
</div>
</div>
</div>
</Modal>
)
}
+26 -37
View File
@@ -3,6 +3,7 @@ import type { ScraperStatus } from '@/hooks/useScraperFetch'
import { useTranslation, type TranslationKey } from '@/i18n'
import { Icon } from '@/components/common/Icon'
import { Alert } from '@/components/common/Alert'
import { Modal } from '@/components/common/Modal'
import './qrcode-modal.css'
// Every `ScraperStatus` maps to a translated `balance.status.*` label — the
@@ -47,27 +48,33 @@ export function QRCodeModal({
const { t } = useTranslation()
if (!isOpen) return null
const handleClose = () => {
if (isRunning) return
onClose()
}
const footer =
status === 'done' || isRunning ? (
<>
{status === 'done' && (
<button type="button" className="btn btn--primary" onClick={onDone}>
{t('qrModal.closeButton')}
</button>
)}
{isRunning && (
<button type="button" className="btn btn--danger" onClick={() => void onCancel()}>
{t('qrModal.cancelButton')}
</button>
)}
</>
) : null
return (
<div className="modal__overlay">
<div className="modal__content modal__content--sm">
<div className="modal__header">
<h2>{t('qrModal.title')}</h2>
<button
type="button"
className="btn btn--icon"
onClick={handleClose}
disabled={isRunning}
aria-label={t('qrModal.closeAria')}
<Modal
onClose={onClose}
title={t('qrModal.title')}
size="sm"
closeAriaLabel={t('qrModal.closeAria')}
closeDisabled={isRunning}
closeOnOverlayClick={false}
footer={footer}
>
×
</button>
</div>
<div className="modal__body">
{status && (
<div className="qrcode-modal__status-container">
<div className="qrcode-modal__status-row">
@@ -118,24 +125,6 @@ export function QRCodeModal({
<strong>{t('qrModal.errorLabel')}</strong> {error}
</Alert>
)}
</div>
{(status === 'done' || isRunning) && (
<div className="modal__footer">
{status === 'done' && (
<button type="button" className="btn btn--primary" onClick={onDone}>
{t('qrModal.closeButton')}
</button>
)}
{isRunning && (
<button type="button" className="btn btn--danger" onClick={() => void onCancel()}>
{t('qrModal.cancelButton')}
</button>
)}
</div>
)}
</div>
</div>
</Modal>
)
}
+14 -19
View File
@@ -24,6 +24,7 @@ import { CalendarExceptions } from '@/components/calendar/CalendarExceptions'
import { BalanceFlow } from '@/components/balance/BalanceFlow'
import { PartnerInvitePanel } from '@/components/settings/PartnerInvitePanel'
import { Alert } from '@/components/common/Alert'
import { InlineConfirm } from '@/components/common/InlineConfirm'
import { orderSelfFirst } from '@/utils/members'
import type { VacationDays } from '@/types'
import './settings.css'
@@ -780,25 +781,19 @@ export function Settings(): ReactNode {
</button>
{!isSelf &&
(isConfirmingRemove ? (
<span className="settings-panel__remove-confirm">
{t('settings.removeConfirmPrompt', { name: member.name })}
<button
onClick={() => handleConfirmRemove(member.uuid)}
className="btn btn--danger btn--sm"
disabled={isSavingFamily}
data-testid={`remove-confirm-${member.uuid}`}
>
{t('settings.removeConfirmButton')}
</button>
<button
onClick={handleCancelRemove}
className="btn btn--secondary btn--sm"
disabled={isSavingFamily}
data-testid={`remove-cancel-${member.uuid}`}
>
{t('settings.cancelButton')}
</button>
</span>
<InlineConfirm
className="settings-panel__remove-confirm"
prompt={t('settings.removeConfirmPrompt', { name: member.name })}
confirmLabel={t('settings.removeConfirmButton')}
cancelLabel={t('settings.cancelButton')}
onConfirm={() => handleConfirmRemove(member.uuid)}
onCancel={handleCancelRemove}
isBusy={isSavingFamily}
confirmTestId={`remove-confirm-${member.uuid}`}
cancelTestId={`remove-cancel-${member.uuid}`}
confirmFirst
small
/>
) : (
<button
onClick={() => handleRequestRemove(member.uuid)}