refactor: replace Modal div-based implementation with native dialog element

Replace the custom div-based Modal with the native HTML <dialog> element
using showModal()/close() API. Uses CSS opacity transitions with a
data-closing attribute for Firefox-compatible close animations, Teleport
to body, and focus save/restore. Updates E2E test selectors and fixes
QuickAddOverlay selectors for the new dialog structure.
This commit is contained in:
kolaente 2026-04-02 00:16:42 +02:00 committed by kolaente
parent bc1a9008a7
commit cef03cb2a0
12 changed files with 184 additions and 127 deletions

View File

@ -86,7 +86,6 @@
<Modal
:enabled="showHowItWorks"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="() => showHowItWorks = false"

View File

@ -64,7 +64,6 @@
<Modal
:enabled="showHowItWorks"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="() => showHowItWorks = false"

View File

@ -451,7 +451,10 @@ export default Extension.create<FilterAutocompleteOptions>({
popupElement.style.zIndex = '20000'
popupElement.id = 'filter-autocomplete-popup'
popupElement.appendChild(component.element!)
document.body.appendChild(popupElement)
// Append to the closest dialog (if inside a modal) so the popup
// is not blocked by <dialog> inertness, otherwise fall back to body.
const parentDialog = view.dom.closest('dialog')
;(parentDialog || document.body).appendChild(popupElement)
cleanupFloating = autoUpdate(virtualReference, popupElement, updatePosition)
}

View File

@ -7,7 +7,7 @@
<script setup lang="ts">
withDefaults(defineProps<{
name?: 'fade' | 'flash-background' | 'width' | 'modal'
name?: 'fade' | 'flash-background' | 'width'
}>(), {
name: 'fade',
})
@ -58,13 +58,4 @@ $flash-background-duration: 750ms;
inline-size: 0;
}
.modal-enter,
.modal-leave-active {
opacity: 0;
}
.modal-enter .modal-container,
.modal-leave-active .modal-container {
transform: scale(0.9);
}
</style>

View File

@ -1,128 +1,159 @@
<template>
<Teleport to="body">
<!-- FIXME: transition should not be included in the modal -->
<CustomTransition
:name="transitionName"
appear
<dialog
v-if="showDialog"
ref="dialogRef"
class="modal-dialog"
:class="[
{ 'has-overflow': overflow },
variant,
]"
v-bind="attrs"
@cancel.prevent="$emit('close')"
>
<section
v-if="enabled"
ref="modal"
class="modal-mask"
:class="[
{ 'has-overflow': overflow },
variant,
]"
v-bind="attrs"
<div
class="modal-container"
@mousedown.self.prevent.stop="$emit('close')"
>
<div
v-shortcut="'Escape'"
class="modal-container"
@mousedown.self.prevent.stop="$emit('close')"
<BaseButton
class="close"
@click="$emit('close')"
>
<BaseButton
class="close"
@click="$emit('close')"
>
<Icon icon="times" />
</BaseButton>
<div
class="modal-content"
:class="{
'has-overflow': overflow,
'is-wide': wide
}"
>
<slot>
<div class="modal-header">
<slot name="header" />
</div>
<div class="content">
<slot name="text" />
</div>
<div class="actions">
<XButton
variant="tertiary"
class="has-text-danger"
@click="$emit('close')"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
v-cy="'modalPrimary'"
variant="primary"
:shadow="false"
@click="$emit('submit')"
>
{{ $t('misc.doit') }}
</XButton>
</div>
</slot>
</div>
<Icon icon="times" />
</BaseButton>
<div
class="modal-content"
:class="{
'has-overflow': overflow,
'is-wide': wide
}"
>
<slot>
<div class="modal-header">
<slot name="header" />
</div>
<div class="content">
<slot name="text" />
</div>
<div class="actions">
<XButton
variant="tertiary"
class="has-text-danger"
@click="$emit('close')"
>
{{ $t('misc.cancel') }}
</XButton>
<XButton
v-cy="'modalPrimary'"
variant="primary"
:shadow="false"
@click="$emit('submit')"
>
{{ $t('misc.doit') }}
</XButton>
</div>
</slot>
</div>
</section>
</CustomTransition>
</div>
</dialog>
</Teleport>
</template>
<script lang="ts" setup>
import CustomTransition from '@/components/misc/CustomTransition.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {ref, useAttrs, watchEffect, onBeforeUnmount, watch} from 'vue'
import {useScrollLock} from '@vueuse/core'
import {ref, useAttrs, watch, onBeforeUnmount, onMounted, nextTick} from 'vue'
const props = withDefaults(defineProps<{
enabled?: boolean,
overflow?: boolean,
wide?: boolean,
transitionName?: 'modal' | 'fade',
variant?: 'default' | 'hint-modal' | 'scrolling',
}>(), {
enabled: true,
overflow: false,
wide: false,
transitionName: 'modal',
variant: 'default',
})
const emit = defineEmits(['close', 'submit'])
defineEmits(['close', 'submit'])
defineOptions({
inheritAttrs: false,
})
const TRANSITION_DURATION = 150
const attrs = useAttrs()
const dialogRef = ref<HTMLDialogElement | null>(null)
const previouslyFocused = ref<Element | null>(null)
const showDialog = ref(false)
let closeTimer: ReturnType<typeof setTimeout> | null = null
const modal = ref<HTMLElement | null>(null)
const scrollLock = useScrollLock(modal)
watchEffect(() => {
scrollLock.value = props.enabled
})
function onKeydown(e: KeyboardEvent) {
if (e.code === 'Escape') {
if (e.isComposing) {
return
}
emit('close')
function openDialog() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
previouslyFocused.value = document.activeElement
showDialog.value = true
nextTick(() => {
const dialog = dialogRef.value
if (dialog) {
delete dialog.dataset.closing
dialog.showModal()
}
document.body.style.overflow = 'hidden'
})
}
function closeDialog() {
const dialog = dialogRef.value
if (!dialog) return
// Trigger the fade-out while the dialog is still [open] so the opacity
// transition plays in browsers that don't support allow-discrete (Firefox).
dialog.dataset.closing = ''
document.body.style.overflow = ''
closeTimer = setTimeout(() => {
delete dialog.dataset.closing
dialog.close()
showDialog.value = false
closeTimer = null
if (previouslyFocused.value instanceof HTMLElement) {
previouslyFocused.value.focus()
}
previouslyFocused.value = null
}, TRANSITION_DURATION)
}
watch(
() => props.enabled,
(value: boolean) => {
if (value) {
window.addEventListener('keydown', onKeydown)
(isEnabled) => {
if (isEnabled) {
openDialog()
} else {
window.removeEventListener('keydown', onKeydown)
closeDialog()
}
},
{immediate: true},
{immediate: false},
)
onMounted(() => {
if (props.enabled) {
openDialog()
}
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
document.body.style.overflow = ''
if (previouslyFocused.value instanceof HTMLElement) {
previouslyFocused.value.focus()
}
})
</script>
@ -130,20 +161,49 @@ onBeforeUnmount(() => {
$modal-margin: 4rem;
$modal-width: 1024px;
.modal-mask {
.modal-dialog {
// Reset UA dialog styles
padding: 0;
border: none;
background: transparent;
color: #ffffff;
// Fill viewport
position: fixed;
z-index: 4000;
inset-block-start: 0;
inset-inline-start: 0;
inset: 0;
inline-size: 100%;
block-size: 100%;
background-color: rgba(0, 0, 0, .8);
transition: opacity 150ms ease;
color: #ffffff;
max-inline-size: 100%;
max-block-size: 100%;
// Transitions
opacity: 0;
transition: opacity 150ms ease,
display 150ms ease allow-discrete;
&[open]:not([data-closing]) {
opacity: 1;
@starting-style {
opacity: 0;
}
}
&::backdrop {
background-color: rgba(0, 0, 0, 0);
transition: background-color 150ms ease,
display 150ms ease allow-discrete;
}
&[open]:not([data-closing])::backdrop {
background-color: rgba(0, 0, 0, .8);
@starting-style {
background-color: rgba(0, 0, 0, 0);
}
}
}
.modal-container {
transition: all 150ms ease;
position: relative;
inline-size: 100%;
block-size: 100%;
@ -151,6 +211,7 @@ $modal-width: 1024px;
overflow: auto;
padding-block-start: env(safe-area-inset-top);
padding-block-end: env(safe-area-inset-bottom);
}
.default .modal-content,
@ -161,7 +222,7 @@ $modal-width: 1024px;
inset-block-start: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
[dir="rtl"] & {
transform: translate(50%, -50%);
}
@ -190,7 +251,7 @@ $modal-width: 1024px;
max-block-size: none; // reset bulma
overflow: visible; // reset bulma
@media not print {
max-inline-size: $modal-width;
}
@ -212,8 +273,6 @@ $modal-width: 1024px;
}
.hint-modal {
z-index: 4600;
:deep(.card-content) {
text-align: start;
@ -244,7 +303,7 @@ $modal-width: 1024px;
}
@media print, screen and (max-width: $tablet) {
.modal-mask {
.modal-dialog {
overflow: visible !important;
}
@ -285,7 +344,7 @@ $modal-width: 1024px;
.modal-content :deep(.card .card-header-icon.close) {
display: none;
@media screen and (max-width: $tablet) {
display: block;
}
@ -294,12 +353,12 @@ $modal-width: 1024px;
<style lang="scss">
// Close icon SVG uses currentColor, change the color to keep it visible
.dark .close {
.dark .modal-dialog .close {
color: var(--grey-900);
}
@media print, screen and (max-width: $tablet) {
body:has(.modal-mask) #app {
body:has(dialog[open].modal-dialog) #app {
display: none;
}
}

View File

@ -9,7 +9,6 @@
</XButton>
<Modal
:enabled="modalOpen"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="() => modalOpen = false"

View File

@ -42,7 +42,11 @@ watch(() => baseStore.quickActionsActive, (active) => {
// backdrop, disable scroll, and collapse all extra spacing so the input
// fills the window edge-to-edge.
.quick-add-overlay {
.modal-mask {
dialog.modal-dialog {
background: transparent;
}
dialog.modal-dialog::backdrop {
background: transparent;
}
@ -50,7 +54,7 @@ watch(() => baseStore.quickActionsActive, (active) => {
overflow: hidden;
}
.modal-mask > .close {
dialog.modal-dialog .close {
display: none;
}
}

View File

@ -19,7 +19,6 @@
</BaseButton>
<Modal
:enabled="visible"
transition-name="fade"
:overflow="true"
variant="hint-modal"
@close="close"

View File

@ -1,6 +1,5 @@
<template>
<Modal
transition-name="fade"
variant="hint-modal"
@close="$router.back()"
>

View File

@ -126,8 +126,8 @@ test.describe('Project View Kanban', () => {
await page.locator('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger').first().click()
await page.locator('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item').filter({hasText: 'Delete'}).click()
await expect(page.locator('.modal-mask .modal-container .modal-content .modal-header')).toContainText('Delete the bucket')
await page.locator('.modal-mask .modal-container .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
await expect(page.locator('dialog[open] .modal-content .modal-header')).toContainText('Delete the bucket')
await page.locator('dialog[open] .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
await expect(page.locator('.kanban .bucket .title').filter({hasText: buckets[0].title})).not.toBeVisible()
await expect(page.locator('.kanban .bucket .title').filter({hasText: buckets[1].title})).toBeVisible()
@ -208,8 +208,8 @@ test.describe('Project View Kanban', () => {
await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click()
await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Delete'})).toBeVisible()
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Delete'}).click()
await expect(page.locator('.modal-mask .modal-container .modal-content .modal-header')).toContainText('Delete this task')
await page.locator('.modal-mask .modal-container .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
await expect(page.locator('dialog[open] .modal-content .modal-header')).toContainText('Delete this task')
await page.locator('dialog[open] .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
await expect(page.locator('.global-notification')).toContainText('Success')

View File

@ -103,7 +103,7 @@ test.describe('Projects', () => {
await page.locator('.project-title-dropdown .project-title-button').click()
await page.getByRole('link', {name: /^archive$/i}).click()
await expect(page.locator('.modal-content')).toContainText('Archive this project')
await expect(page.locator('dialog[open] .modal-content')).toContainText('Archive this project')
await page.getByRole('button', {name: /do it/i}).click()
await expect(page.locator('.global-notification')).toContainText('Success')

View File

@ -73,8 +73,13 @@ async function uploadAttachmentAndVerify(page: Page, taskId: number) {
const uploadAttachmentPromise = page.waitForResponse(response =>
response.url().includes(`/tasks/${taskId}/attachments`) && response.request().method() === 'PUT',
)
// The "Add Attachments" button triggers openFilePicker() which may open
// a native file chooser (especially inside a <dialog>). Handle it via the
// filechooser event so it doesn't block the test.
const fileChooserPromise = page.waitForEvent('filechooser')
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Add Attachments'}).click()
await page.locator('input[type=file]#files').setInputFiles('tests/fixtures/image.jpg')
const fileChooser = await fileChooserPromise
await fileChooser.setFiles('tests/fixtures/image.jpg')
await uploadAttachmentPromise
await expect(page.locator('.attachments .attachments .files button.attachment')).toBeVisible()
@ -458,8 +463,8 @@ test.describe('Task', () => {
await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Delete'})).toBeVisible()
await page.locator('.task-view .action-buttons .button').filter({hasText: 'Delete'}).click()
await expect(page.locator('.modal-mask .modal-container .modal-content .modal-header')).toContainText('Delete this task')
await page.locator('.modal-mask .modal-container .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
await expect(page.locator('dialog[open] .modal-content .modal-header')).toContainText('Delete this task')
await page.locator('dialog[open] .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
await expect(page.locator('.global-notification')).toContainText('Success')
await expect(page).toHaveURL(new RegExp(`/projects/${tasks[0].project_id}/`))
@ -580,7 +585,7 @@ test.describe('Task', () => {
await addLabelToTaskAndVerify(page, labels[0].title)
await page.locator('.modal-container > .close').click()
await page.locator('dialog[open] .modal-container > .close').click()
await expect(page.locator('.bucket .task')).toContainText(labels[0].title)
})
@ -973,7 +978,7 @@ test.describe('Task', () => {
await uploadAttachmentAndVerify(page, tasks[0].id)
await page.locator('.modal-container > .close').click()
await page.locator('dialog[open] .modal-container > .close').click()
await expect(page.locator('.bucket .task .footer .icon svg.fa-paperclip')).toBeVisible()
})