refactor(defects): use the native <dialog> for the defect modal

DaisyUI's modal drives the native <dialog> via showModal/close, so Escape,
focus-trapping and the backdrop work without custom handling. The parent's
sectionId controls open/close and the dialog's native close event maps to the
component's close emit.
This commit is contained in:
Julien Calixte
2026-05-28 00:00:42 +02:00
parent cb19b2df55
commit a894e3715d
2 changed files with 54 additions and 23 deletions

View File

@@ -2,6 +2,9 @@
// File a defect against a board section (C3, F2). Driven by `sectionId`: when a // File a defect against a board section (C3, F2). Driven by `sectionId`: when a
// section is clicked the modal opens pre-targeted to it; pick a project, type // section is clicked the modal opens pre-targeted to it; pick a project, type
// what's wrong, submit. Reporter and timestamp are added server-side. // what's wrong, submit. Reporter and timestamp are added server-side.
//
// Uses the native <dialog> (DaisyUI `modal`), so Escape, focus-trapping and the
// backdrop come for free; the parent-controlled `sectionId` drives open/close.
import { sections } from '~/board/definition' import { sections } from '~/board/definition'
interface Project { interface Project {
@@ -19,6 +22,7 @@ interface Defect {
const props = defineProps<{ sectionId: string | null }>() const props = defineProps<{ sectionId: string | null }>()
const emit = defineEmits<{ close: []; filed: [Defect] }>() const emit = defineEmits<{ close: []; filed: [Defect] }>()
const dialog = ref<HTMLDialogElement>()
const project = ref<Project | null>(null) const project = ref<Project | null>(null)
const verbatim = ref('') const verbatim = ref('')
const busy = ref(false) const busy = ref(false)
@@ -31,15 +35,22 @@ const canSubmit = computed(
() => project.value !== null && verbatim.value.trim().length > 0, () => project.value !== null && verbatim.value.trim().length > 0,
) )
// Each time a (new) section opens, start from a clean form. // Mirror the parent-controlled section into the dialog's open state, resetting
watch( // the form each time a (new) section opens.
() => props.sectionId, function sync() {
() => { const el = dialog.value
if (!el) return
if (props.sectionId !== null) {
project.value = null project.value = null
verbatim.value = '' verbatim.value = ''
error.value = '' error.value = ''
}, if (!el.open) el.showModal()
) } else if (el.open) {
el.close()
}
}
onMounted(sync)
watch(() => props.sectionId, sync)
async function submit() { async function submit() {
if (!canSubmit.value || busy.value || !props.sectionId) return if (!canSubmit.value || busy.value || !props.sectionId) return
@@ -64,11 +75,15 @@ async function submit() {
</script> </script>
<template> <template>
<dialog class="modal" :class="{ 'modal-open': sectionId !== null }"> <dialog ref="dialog" class="modal" @close="emit('close')">
<div class="modal-box"> <div class="modal-box">
<h3 class="text-lg font-bold">Report a problem {{ sectionLabel }}</h3> <h3 class="text-lg font-bold">Report a problem {{ sectionLabel }}</h3>
<form class="mt-4 flex flex-col gap-4" @submit.prevent="submit"> <form
class="mt-4 flex flex-col gap-4"
data-test="defect-form"
@submit.prevent="submit"
>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend class="fieldset-legend">Project</legend> <legend class="fieldset-legend">Project</legend>
<ProjectAutocomplete v-model="project" /> <ProjectAutocomplete v-model="project" />
@@ -88,7 +103,7 @@ async function submit() {
<p v-if="error" class="text-error text-sm" role="alert">{{ error }}</p> <p v-if="error" class="text-error text-sm" role="alert">{{ error }}</p>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn btn-ghost" @click="emit('close')"> <button type="button" class="btn btn-ghost" @click="dialog?.close()">
Cancel Cancel
</button> </button>
<button <button
@@ -103,12 +118,9 @@ async function submit() {
</form> </form>
</div> </div>
<!-- Click the backdrop to dismiss. --> <!-- Native backdrop: clicking it closes the dialog. -->
<button <form method="dialog" class="modal-backdrop">
type="button" <button aria-label="Close">close</button>
class="modal-backdrop" </form>
aria-label="Close"
@click="emit('close')"
/>
</dialog> </dialog>
</template> </template>

View File

@@ -22,23 +22,43 @@ beforeEach(() => {
) )
}) })
async function pickProject(wrapper: Awaited<ReturnType<typeof mountSuspended>>) { type Wrapper = Awaited<ReturnType<typeof mountSuspended>>
const dialogEl = (w: Wrapper) => w.find('dialog').element as HTMLDialogElement
async function pickProject(wrapper: Wrapper) {
await wrapper.find('[data-test="project-input"]').trigger('focus') await wrapper.find('[data-test="project-input"]').trigger('focus')
await wrapper.find('[data-test="project-option"]').trigger('click') await wrapper.find('[data-test="project-option"]').trigger('click')
} }
describe('DefectForm', () => { describe('DefectForm', () => {
it('opens for the clicked section and shows its name', async () => { it('opens the native dialog for the clicked section and shows its name', async () => {
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: 'macroplan' } }) const wrapper = await mountSuspended(DefectForm, { props: { sectionId: 'macroplan' } })
await flushPromises() await flushPromises()
expect(wrapper.find('dialog').classes()).toContain('modal-open') expect(dialogEl(wrapper).open).toBe(true)
expect(wrapper.text()).toContain('Macroplan') expect(wrapper.text()).toContain('Macroplan')
}) })
it('is closed when no section is selected', async () => { it('stays closed when no section is selected', async () => {
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: null } }) const wrapper = await mountSuspended(DefectForm, { props: { sectionId: null } })
expect(wrapper.find('dialog').classes()).not.toContain('modal-open') await flushPromises()
expect(dialogEl(wrapper).open).toBe(false)
})
it('closes the dialog when the section is cleared', async () => {
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: 'macroplan' } })
await flushPromises()
await wrapper.setProps({ sectionId: null })
await flushPromises()
expect(dialogEl(wrapper).open).toBe(false)
})
it('emits close when the dialog is dismissed (Escape/backdrop fire native close)', async () => {
const wrapper = await mountSuspended(DefectForm, { props: { sectionId: 'macroplan' } })
await flushPromises()
dialogEl(wrapper).dispatchEvent(new Event('close'))
expect(wrapper.emitted('close')).toBeTruthy()
}) })
it('cannot submit until both a project and verbatim are provided', async () => { it('cannot submit until both a project and verbatim are provided', async () => {
@@ -48,7 +68,6 @@ describe('DefectForm', () => {
expect(wrapper.find('[data-test="submit"]').attributes('disabled')).toBeDefined() expect(wrapper.find('[data-test="submit"]').attributes('disabled')).toBeDefined()
await pickProject(wrapper) await pickProject(wrapper)
// project but no verbatim → still blocked
expect(wrapper.find('[data-test="submit"]').attributes('disabled')).toBeDefined() expect(wrapper.find('[data-test="submit"]').attributes('disabled')).toBeDefined()
await wrapper.find('[data-test="verbatim"]').setValue('builds are flaky') await wrapper.find('[data-test="verbatim"]').setValue('builds are flaky')
@@ -61,7 +80,7 @@ describe('DefectForm', () => {
await pickProject(wrapper) await pickProject(wrapper)
await wrapper.find('[data-test="verbatim"]').setValue('builds are flaky') await wrapper.find('[data-test="verbatim"]').setValue('builds are flaky')
await wrapper.find('form').trigger('submit') await wrapper.find('[data-test="defect-form"]').trigger('submit')
await vi.waitFor(() => expect(wrapper.emitted('filed')).toBeTruthy()) await vi.waitFor(() => expect(wrapper.emitted('filed')).toBeTruthy())
expect(filed).toEqual({ expect(filed).toEqual({