feat(dialogs): gedeelde primitives — useDirtyCloseGuard, useDialogSubmitShortcut, layout-classes

Story 1 van PBI "Alle dialogen conform docs/patterns/dialog.md".

- components/shared/use-dirty-close-guard.tsx — hook + paired AlertDialog
- components/shared/use-dialog-submit-shortcut.ts — Cmd/Ctrl+Enter handler
- components/shared/entity-dialog-layout.ts — MD3-conforme classes voor §4
- TaskDialog refactored om beide hooks + classes te gebruiken (geen
  gedragsverandering)
- 8 nieuwe unit-tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 07:14:07 +02:00
parent b47f62966e
commit b05c4d241b
6 changed files with 219 additions and 47 deletions

View file

@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
function makeEvent(opts: Partial<KeyboardEvent>) {
return {
metaKey: false,
ctrlKey: false,
key: '',
preventDefault: vi.fn(),
...opts,
} as unknown as React.KeyboardEvent
}
describe('useDialogSubmitShortcut', () => {
it('triggert submit op Cmd+Enter', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ metaKey: true, key: 'Enter' })
handler(e)
expect(submit).toHaveBeenCalledTimes(1)
expect(e.preventDefault).toHaveBeenCalled()
})
it('triggert submit op Ctrl+Enter', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ ctrlKey: true, key: 'Enter' })
handler(e)
expect(submit).toHaveBeenCalledTimes(1)
})
it('triggert NIET op Enter zonder modifier', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ key: 'Enter' })
handler(e)
expect(submit).not.toHaveBeenCalled()
expect(e.preventDefault).not.toHaveBeenCalled()
})
it('triggert NIET op Cmd+andere toets', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ metaKey: true, key: 'a' })
handler(e)
expect(submit).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useDirtyCloseGuard } from '@/components/shared/use-dirty-close-guard'
describe('useDirtyCloseGuard', () => {
it('sluit direct als form niet dirty is', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(false, onClose))
act(() => result.current.attemptClose())
expect(onClose).toHaveBeenCalledTimes(1)
expect(result.current.confirmOpen).toBe(false)
})
it('opent confirm als form dirty is', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
act(() => result.current.attemptClose())
expect(onClose).not.toHaveBeenCalled()
expect(result.current.confirmOpen).toBe(true)
})
it('confirmDiscard sluit confirm en roept onClose', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
act(() => result.current.attemptClose())
expect(result.current.confirmOpen).toBe(true)
act(() => result.current.confirmDiscard())
expect(onClose).toHaveBeenCalledTimes(1)
expect(result.current.confirmOpen).toBe(false)
})
it('setConfirmOpen(false) annuleert zonder onClose te roepen', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
act(() => result.current.attemptClose())
act(() => result.current.setConfirmOpen(false))
expect(onClose).not.toHaveBeenCalled()
expect(result.current.confirmOpen).toBe(false)
})
})

View file

@ -28,6 +28,17 @@ import {
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogBodyClasses,
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { PrioritySegmented } from './priority-segmented'
import { StatusSelect } from './status-select'
import { cn } from '@/lib/utils'
@ -70,7 +81,6 @@ const textareaClass = cn(
export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [confirmClose, setConfirmClose] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const isEdit = !!task
@ -90,20 +100,8 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
router.push(closePath)
}
function handleAttemptClose() {
if (form.formState.isDirty) {
setConfirmClose(true)
} else {
handleClose()
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
form.handleSubmit(onSubmit)()
}
}
const closeGuard = useDirtyCloseGuard(form.formState.isDirty, handleClose)
const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)())
function onSubmit(data: TaskInput) {
startTransition(async () => {
@ -167,19 +165,14 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
return (
<>
<Dialog open onOpenChange={(open) => { if (!open) handleAttemptClose() }}>
<Dialog open onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={cn(
'flex flex-col p-0 gap-0',
'max-h-[90vh] w-full max-w-[calc(100%-2rem)]',
'sm:max-w-[90vw] sm:max-h-[85vh]',
'lg:max-w-[50vw] lg:min-w-[480px]',
)}
className={entityDialogContentClasses}
>
{/* Sticky header */}
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b border-outline-variant shrink-0">
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">
{isEdit ? 'Taak bewerken' : 'Nieuwe taak'}
</DialogTitle>
@ -196,7 +189,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
</div>
{/* Scrollable form body */}
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<div className={entityDialogBodyClasses}>
{/* Title */}
<div>
<label className="text-sm font-medium mb-2 block">
@ -323,7 +316,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
</div>
{/* Sticky footer */}
<div className="border-t border-outline-variant px-6 py-4 shrink-0">
<div className={entityDialogFooterClasses}>
<div className="flex items-center justify-between gap-2">
{isEdit ? (
<DemoTooltip show={isDemo}>
@ -344,7 +337,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
<Button
type="button"
variant="ghost"
onClick={handleAttemptClose}
onClick={closeGuard.attemptClose}
disabled={isPending}
>
Annuleren
@ -374,27 +367,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
</Dialog>
{/* Dirty-check confirm */}
<AlertDialog open={confirmClose} onOpenChange={setConfirmClose}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Wijzigingen niet opgeslagen</AlertDialogTitle>
<AlertDialogDescription>
Wil je de wijzigingen weggooien?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmClose(false)}>
Terug
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => { setConfirmClose(false); handleClose() }}
>
Weggooien
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DirtyCloseGuardDialog guard={closeGuard} />
{/* Delete confirm */}
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>

View file

@ -0,0 +1,16 @@
import { cn } from '@/lib/utils'
export const entityDialogContentClasses = cn(
'flex flex-col p-0 gap-0',
'max-h-[90vh] w-full max-w-[calc(100%-2rem)]',
'sm:max-w-[90vw] sm:max-h-[85vh]',
'lg:max-w-[50vw] lg:min-w-[480px]',
)
export const entityDialogHeaderClasses =
'flex items-center justify-between px-6 pt-5 pb-4 border-b border-outline-variant shrink-0'
export const entityDialogBodyClasses = 'flex-1 overflow-y-auto px-6 py-6 space-y-6'
export const entityDialogFooterClasses =
'border-t border-outline-variant px-6 py-4 shrink-0'

View file

@ -0,0 +1,10 @@
import type { KeyboardEvent } from 'react'
export function useDialogSubmitShortcut(submit: () => void) {
return (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
submit()
}
}
}

View file

@ -0,0 +1,66 @@
'use client'
import { useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
export interface DirtyCloseGuard {
confirmOpen: boolean
setConfirmOpen: (v: boolean) => void
attemptClose: () => void
confirmDiscard: () => void
}
export function useDirtyCloseGuard(
isDirty: boolean,
onClose: () => void,
): DirtyCloseGuard {
const [confirmOpen, setConfirmOpen] = useState(false)
function attemptClose() {
if (isDirty) setConfirmOpen(true)
else onClose()
}
function confirmDiscard() {
setConfirmOpen(false)
onClose()
}
return { confirmOpen, setConfirmOpen, attemptClose, confirmDiscard }
}
export function DirtyCloseGuardDialog({
guard,
}: {
guard: DirtyCloseGuard
}) {
return (
<AlertDialog open={guard.confirmOpen} onOpenChange={guard.setConfirmOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Wijzigingen niet opgeslagen</AlertDialogTitle>
<AlertDialogDescription>
Wil je de wijzigingen weggooien?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => guard.setConfirmOpen(false)}>
Terug
</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={guard.confirmDiscard}>
Weggooien
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}