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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user