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,149 +207,131 @@ export function ParentBalanceForm({
}
}
const 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>
</>
)
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')}
<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>}
<div className="stack">
<FormField id="sjukpenning-left" label={t('parentBalance.sjukpenningLeftLabel')} error={sjukpenningError}>
<input
type="number"
inputMode="numeric"
min={0}
max={390}
value={sjukpenningLeft ?? ''}
onChange={(e) => {
setSjukpenningLeft(parseField(e.target.value))
clearBackendFieldError('sjukpenning')
}}
disabled={isSaving}
className="field__control"
/>
</FormField>
<FormField id="lagsta-left" label={t('parentBalance.lagstaLeftLabel')} error={lagstaError}>
<input
type="number"
inputMode="numeric"
min={0}
max={90}
value={lagstaNivaLeft ?? ''}
onChange={(e) => {
setLagstaNivaLeft(parseField(e.target.value))
clearBackendFieldError('lagsta')
}}
disabled={isSaving}
className="field__control"
/>
</FormField>
<FormField
id="dubbeldag-left"
label={t('parentBalance.dubbeldagarLeftLabel')}
error={dubbeldagarError}
helperText={t('parentBalance.dubbeldagarHelper')}
>
<input
type="number"
inputMode="numeric"
min={0}
max={60}
value={dubbeldagarLeft ?? ''}
onChange={(e) => {
setDubbeldagarLeft(parseField(e.target.value))
clearBackendFieldError('dubbeldagar')
}}
disabled={isSaving}
className="field__control"
/>
</FormField>
<button
type="button"
className="btn btn--secondary btn--sm"
onClick={applyPreset}
disabled={isSaving}
>
{t('parentBalance.presetButton')}
</button>
<p className="parent-balance-form__summary">
{t('parentBalance.summary', { left: daysLeft, total: totalDays })}
<br />
{t('parentBalance.summaryDubbeldagar', { count: dubbeldagarLeft ?? 0 })}
</p>
{totalError && <p className="field__error">{totalError}</p>}
<button
type="button"
className="btn btn--link"
aria-expanded={showAdvanced}
onClick={() => setShowAdvanced((v) => !v)}
>
{t('parentBalance.advancedToggle')}
</button>
{showAdvanced && (
<FormField
id="reserved-left"
label={t('parentBalance.reservedLeftLabel')}
error={reservedError}
helperText={t('parentBalance.reservedLeftHelper')}
>
×
</button>
</div>
<div className="modal__body">
<p className="parent-balance-form__description">{t('parentBalance.description')}</p>
{(error || saveError) && <Alert variant="error">{error || saveError}</Alert>}
<div className="stack">
<FormField id="sjukpenning-left" label={t('parentBalance.sjukpenningLeftLabel')} error={sjukpenningError}>
<input
type="number"
inputMode="numeric"
min={0}
max={390}
value={sjukpenningLeft ?? ''}
onChange={(e) => {
setSjukpenningLeft(parseField(e.target.value))
clearBackendFieldError('sjukpenning')
}}
disabled={isSaving}
className="field__control"
/>
</FormField>
<FormField id="lagsta-left" label={t('parentBalance.lagstaLeftLabel')} error={lagstaError}>
<input
type="number"
inputMode="numeric"
min={0}
max={90}
value={lagstaNivaLeft ?? ''}
onChange={(e) => {
setLagstaNivaLeft(parseField(e.target.value))
clearBackendFieldError('lagsta')
}}
disabled={isSaving}
className="field__control"
/>
</FormField>
<FormField
id="dubbeldag-left"
label={t('parentBalance.dubbeldagarLeftLabel')}
error={dubbeldagarError}
helperText={t('parentBalance.dubbeldagarHelper')}
>
<input
type="number"
inputMode="numeric"
min={0}
max={60}
value={dubbeldagarLeft ?? ''}
onChange={(e) => {
setDubbeldagarLeft(parseField(e.target.value))
clearBackendFieldError('dubbeldagar')
}}
disabled={isSaving}
className="field__control"
/>
</FormField>
<button
type="button"
className="btn btn--secondary btn--sm"
onClick={applyPreset}
<input
type="number"
inputMode="numeric"
min={0}
max={90}
value={reservedDaysLeft ?? ''}
onChange={(e) => {
setReservedDaysLeft(parseField(e.target.value))
clearBackendFieldError('reserved')
}}
disabled={isSaving}
>
{t('parentBalance.presetButton')}
</button>
<p className="parent-balance-form__summary">
{t('parentBalance.summary', { left: daysLeft, total: totalDays })}
<br />
{t('parentBalance.summaryDubbeldagar', { count: dubbeldagarLeft ?? 0 })}
</p>
{totalError && <p className="field__error">{totalError}</p>}
<button
type="button"
className="btn btn--link"
aria-expanded={showAdvanced}
onClick={() => setShowAdvanced((v) => !v)}
>
{t('parentBalance.advancedToggle')}
</button>
{showAdvanced && (
<FormField
id="reserved-left"
label={t('parentBalance.reservedLeftLabel')}
error={reservedError}
helperText={t('parentBalance.reservedLeftHelper')}
>
<input
type="number"
inputMode="numeric"
min={0}
max={90}
value={reservedDaysLeft ?? ''}
onChange={(e) => {
setReservedDaysLeft(parseField(e.target.value))
clearBackendFieldError('reserved')
}}
disabled={isSaving}
className="field__control"
/>
</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>
className="field__control"
/>
</FormField>
)}
</div>
</div>
</Modal>
)
}
+208 -219
View File
@@ -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,59 +270,205 @@ 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"
>
<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} />
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"
>
{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>
</div>
<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">
{!isRangeMode && <p className="planned-day-modal__date">{displayDate}</p>}
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>}
{(error || localError) && <Alert variant="error">{error || localError}</Alert>}
{!isRangeMode &&
excludedEntries.map((entry) => (
<div
key={entry.id}
className="planned-day-modal__excluded-notice"
data-testid="excluded-notice"
{!isRangeMode &&
excludedEntries.map((entry) => (
<div
key={entry.id}
className="planned-day-modal__excluded-notice"
data-testid="excluded-notice"
>
<span>{t('plannedDayModal.excludedNotice', { parent: entry.parentName })}</span>
{onRestoreExcluded && (
<button
type="button"
className="btn btn--secondary btn--sm"
onClick={() => void onRestoreExcluded(entry.id)}
disabled={isSaving}
data-testid={`restore-excluded-${entry.id}`}
>
<span>{t('plannedDayModal.excludedNotice', { parent: entry.parentName })}</span>
{onRestoreExcluded && (
<button
type="button"
className="btn btn--secondary btn--sm"
onClick={() => void onRestoreExcluded(entry.id)}
disabled={isSaving}
data-testid={`restore-excluded-${entry.id}`}
>
{t('plannedDayModal.restoreExcludedButton')}
</button>
)}
</div>
))}
{t('plannedDayModal.restoreExcludedButton')}
</button>
)}
</div>
))}
<div className="stack">
{!forcedAdvanced && hasMultipleParents && (
<FormField id="parent-mode-select" label={t('plannedDayModal.parentModeLabel')}>
<div className="stack">
{!forcedAdvanced && hasMultipleParents && (
<FormField id="parent-mode-select" label={t('plannedDayModal.parentModeLabel')}>
<select
value={parentModeSelectValue}
onChange={(e) => handleParentModeChange(e.target.value)}
className="field__control"
disabled={isSaving}
data-testid="parent-mode-select"
>
<option value="either">{t('wishEditor.eitherParent')}</option>
<option value="both">{t('wishEditor.bothParents')}</option>
{parents.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</FormField>
)}
{!forcedAdvanced && hasMultipleParents && (
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={wantsDubbeldag}
onChange={(e) => setWantsDubbeldag(e.target.checked)}
disabled={isSaving || parentMode !== 'both'}
data-testid="wants-dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.wantsDubbeldagLabel')}
</label>
<p className="planned-day-modal__hint">
{parentMode === 'both'
? t('plannedDayModal.wantsDubbeldagHint')
: t('plannedDayModal.wantsDubbeldagRequiresBothHint')}
</p>
</div>
)}
{/* The default path's own copy of the "will stay off" notice —
* `showPinnedControls` is false here (Advanced isn't open), so
* the copy inside that section below never renders for this
* case. Gated on `!showPinnedControls` rather than re-deriving
* "default path" so the two copies can never both render. */}
{!forcedAdvanced && hasMultipleParents && !showPinnedControls && freeDaysWillStayOff && (
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
{t('plannedDayModal.freeDaysStayOffNotice')}
</p>
)}
{!forcedAdvanced && hasMultipleParents && (
<button
type="button"
className="btn btn--link planned-day-modal__advanced-toggle"
onClick={() => setIsAdvancedOpen((open) => !open)}
disabled={isSaving}
data-testid="advanced-toggle"
aria-expanded={isAdvancedOpen}
>
{isAdvancedOpen ? t('plannedDayModal.hideAdvanced') : t('plannedDayModal.showAdvanced')}
</button>
)}
{showPinnedControls && (
<div className="planned-day-modal__advanced" data-testid="advanced-section">
<FormField id="day-type" label={t('plannedDayModal.leaveTypeLabel')}>
<select
value={dayType}
onChange={(e) => {
const value = e.target.value
const next: DayTypeChoice = isPinnedDayType(value) ? value : 'auto'
setDayType(next)
if (next === 'vacation' || next === 'auto') setIsDubbeldag(false)
}}
className="field__control"
disabled={isSaving}
data-testid="day-type-select"
>
{soloDirect && <option value="auto">{t('plannedDayModal.autoOption')}</option>}
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
</select>
</FormField>
{freeDaysWillStayOff && (
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
{t('plannedDayModal.freeDaysStayOffNotice')}
</p>
)}
{showPinExclusionNotice && (
<p className="planned-day-modal__hint" data-testid="pin-exclusion-notice">
{t('plannedDayModal.pinExcludesFromSyncNotice')}
</p>
)}
{hasMultipleParents && (
<FormField id="parent-select" label={t('plannedDayModal.parentLabel')}>
<select
value={parentModeSelectValue}
onChange={(e) => handleParentModeChange(e.target.value)}
value={selectedParentId}
onChange={(e) => handleAdvancedParentChange(e.target.value)}
className="field__control"
disabled={isSaving}
data-testid="parent-mode-select"
data-testid="parent-select"
>
<option value="either">{t('wishEditor.eitherParent')}</option>
<option value="both">{t('wishEditor.bothParents')}</option>
{parents.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
@@ -330,194 +478,35 @@ export function PlannedDayModal({
</FormField>
)}
{!forcedAdvanced && hasMultipleParents && (
{dayType !== 'auto' && (
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={wantsDubbeldag}
onChange={(e) => setWantsDubbeldag(e.target.checked)}
disabled={isSaving || parentMode !== 'both'}
data-testid="wants-dubbeldag-checkbox"
checked={isDubbeldag}
onChange={(e) => setIsDubbeldag(e.target.checked)}
disabled={isSaving || dayType === 'vacation'}
data-testid="dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.wantsDubbeldagLabel')}
{' '}{t('plannedDayModal.doubleDayLabel')}
</label>
<p className="planned-day-modal__hint">
{parentMode === 'both'
? t('plannedDayModal.wantsDubbeldagHint')
: t('plannedDayModal.wantsDubbeldagRequiresBothHint')}
</p>
{dayType === 'vacation' && (
<p className="planned-day-modal__hint">
{t('plannedDayModal.doubleDayHint')}
</p>
)}
</div>
)}
{/* The default path's own copy of the "will stay off" notice —
* `showPinnedControls` is false here (Advanced isn't open), so
* the copy inside that section below never renders for this
* case. Gated on `!showPinnedControls` rather than re-deriving
* "default path" so the two copies can never both render. */}
{!forcedAdvanced && hasMultipleParents && !showPinnedControls && freeDaysWillStayOff && (
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
{t('plannedDayModal.freeDaysStayOffNotice')}
{existingDay?.isPinned && (
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
<Icon name="pin" size={16} />
<span>{t('plannedDayModal.pinnedNotice')}</span>
</p>
)}
{!forcedAdvanced && hasMultipleParents && (
<button
type="button"
className="btn btn--link planned-day-modal__advanced-toggle"
onClick={() => setIsAdvancedOpen((open) => !open)}
disabled={isSaving}
data-testid="advanced-toggle"
aria-expanded={isAdvancedOpen}
>
{isAdvancedOpen ? t('plannedDayModal.hideAdvanced') : t('plannedDayModal.showAdvanced')}
</button>
)}
{showPinnedControls && (
<div className="planned-day-modal__advanced" data-testid="advanced-section">
<FormField id="day-type" label={t('plannedDayModal.leaveTypeLabel')}>
<select
value={dayType}
onChange={(e) => {
const value = e.target.value
const next: DayTypeChoice = isPinnedDayType(value) ? value : 'auto'
setDayType(next)
if (next === 'vacation' || next === 'auto') setIsDubbeldag(false)
}}
className="field__control"
disabled={isSaving}
data-testid="day-type-select"
>
{soloDirect && <option value="auto">{t('plannedDayModal.autoOption')}</option>}
<option value="vacation">{t('plannedDayModal.vacationOption')}</option>
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
</select>
</FormField>
{freeDaysWillStayOff && (
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
{t('plannedDayModal.freeDaysStayOffNotice')}
</p>
)}
{showPinExclusionNotice && (
<p className="planned-day-modal__hint" data-testid="pin-exclusion-notice">
{t('plannedDayModal.pinExcludesFromSyncNotice')}
</p>
)}
{hasMultipleParents && (
<FormField id="parent-select" label={t('plannedDayModal.parentLabel')}>
<select
value={selectedParentId}
onChange={(e) => handleAdvancedParentChange(e.target.value)}
className="field__control"
disabled={isSaving}
data-testid="parent-select"
>
{parents.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</FormField>
)}
{dayType !== 'auto' && (
<div className="field">
<label className="planned-day-modal__checkbox-label">
<input
type="checkbox"
checked={isDubbeldag}
onChange={(e) => setIsDubbeldag(e.target.checked)}
disabled={isSaving || dayType === 'vacation'}
data-testid="dubbeldag-checkbox"
/>
{' '}{t('plannedDayModal.doubleDayLabel')}
</label>
{dayType === 'vacation' && (
<p className="planned-day-modal__hint">
{t('plannedDayModal.doubleDayHint')}
</p>
)}
</div>
)}
{existingDay?.isPinned && (
<p className="planned-day-modal__pinned-notice" data-testid="pinned-notice">
<Icon name="pin" size={16} />
<span>{t('plannedDayModal.pinnedNotice')}</span>
</p>
)}
</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>
)
}
+70 -81
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,95 +48,83 @@ 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')}
>
×
</button>
</div>
<div className="modal__body">
{status && (
<div className="qrcode-modal__status-container">
<div className="qrcode-modal__status-row">
<span className="qrcode-modal__status-label">{t('qrModal.statusLabel')}</span>
<span
className={`qrcode-modal__status-value qrcode-modal__status-value--${
status === 'done' ? 'success' : status === 'error' ? 'error' : 'pending'
}`}
data-testid="qrcode-modal-status"
>
{t(STATUS_LABEL_KEYS[status])}
</span>
{message && <span className="qrcode-modal__status-message">— {message}</span>}
</div>
<Modal
onClose={onClose}
title={t('qrModal.title')}
size="sm"
closeAriaLabel={t('qrModal.closeAria')}
closeDisabled={isRunning}
closeOnOverlayClick={false}
footer={footer}
>
{status && (
<div className="qrcode-modal__status-container">
<div className="qrcode-modal__status-row">
<span className="qrcode-modal__status-label">{t('qrModal.statusLabel')}</span>
<span
className={`qrcode-modal__status-value qrcode-modal__status-value--${
status === 'done' ? 'success' : status === 'error' ? 'error' : 'pending'
}`}
data-testid="qrcode-modal-status"
>
{t(STATUS_LABEL_KEYS[status])}
</span>
{message && <span className="qrcode-modal__status-message">— {message}</span>}
</div>
{status === 'awaiting_scan' && qrData && (
<div className="qrcode-modal__qr-section">
<p className="qrcode-modal__qr-instruction">{t('qrModal.qrInstruction')}</p>
<img
src={`data:image/png;base64,${qrData}`}
alt={t('qrModal.qrAlt')}
className="qrcode-modal__qr-image"
/>
</div>
)}
{(status === 'authenticating' || status === 'fetching') && (
<p className="qrcode-modal__loading-message">
<Icon name="clock" />{' '}
{status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')}
</p>
)}
{status === 'done' && result && (
<p className="qrcode-modal__success-message">
<Icon name="check" /> {t('qrModal.success')}
</p>
)}
{status === 'cancelled' && (
<p className="qrcode-modal__cancelled-message">{t('qrModal.cancelled')}</p>
)}
{status === 'awaiting_scan' && qrData && (
<div className="qrcode-modal__qr-section">
<p className="qrcode-modal__qr-instruction">{t('qrModal.qrInstruction')}</p>
<img
src={`data:image/png;base64,${qrData}`}
alt={t('qrModal.qrAlt')}
className="qrcode-modal__qr-image"
/>
</div>
)}
{error && (
<Alert variant="error">
<strong>{t('qrModal.errorLabel')}</strong> {error}
</Alert>
{(status === 'authenticating' || status === 'fetching') && (
<p className="qrcode-modal__loading-message">
<Icon name="clock" />{' '}
{status === 'authenticating' ? t('qrModal.authenticating') : t('qrModal.fetching')}
</p>
)}
{status === 'done' && result && (
<p className="qrcode-modal__success-message">
<Icon name="check" /> {t('qrModal.success')}
</p>
)}
{status === 'cancelled' && (
<p className="qrcode-modal__cancelled-message">{t('qrModal.cancelled')}</p>
)}
</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>
{error && (
<Alert variant="error">
<strong>{t('qrModal.errorLabel')}</strong> {error}
</Alert>
)}
</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)}