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 <Modal
:enabled="showHowItWorks" :enabled="showHowItWorks"
transition-name="fade"
:overflow="true" :overflow="true"
variant="hint-modal" variant="hint-modal"
@close="() => showHowItWorks = false" @close="() => showHowItWorks = false"

View File

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

View File

@ -451,7 +451,10 @@ export default Extension.create<FilterAutocompleteOptions>({
popupElement.style.zIndex = '20000' popupElement.style.zIndex = '20000'
popupElement.id = 'filter-autocomplete-popup' popupElement.id = 'filter-autocomplete-popup'
popupElement.appendChild(component.element!) 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) cleanupFloating = autoUpdate(virtualReference, popupElement, updatePosition)
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,5 @@
<template> <template>
<Modal <Modal
transition-name="fade"
variant="hint-modal" variant="hint-modal"
@close="$router.back()" @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-trigger').first().click()
await page.locator('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item').filter({hasText: 'Delete'}).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 expect(page.locator('dialog[open] .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 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[0].title})).not.toBeVisible()
await expect(page.locator('.kanban .bucket .title').filter({hasText: buckets[1].title})).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 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 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 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 expect(page.locator('dialog[open] .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 page.locator('dialog[open] .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
await expect(page.locator('.global-notification')).toContainText('Success') 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.locator('.project-title-dropdown .project-title-button').click()
await page.getByRole('link', {name: /^archive$/i}).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 page.getByRole('button', {name: /do it/i}).click()
await expect(page.locator('.global-notification')).toContainText('Success') 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 => const uploadAttachmentPromise = page.waitForResponse(response =>
response.url().includes(`/tasks/${taskId}/attachments`) && response.request().method() === 'PUT', 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('.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 uploadAttachmentPromise
await expect(page.locator('.attachments .attachments .files button.attachment')).toBeVisible() 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 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 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 expect(page.locator('dialog[open] .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 page.locator('dialog[open] .modal-content .actions .button').filter({hasText: 'Do it!'}).click()
await expect(page.locator('.global-notification')).toContainText('Success') await expect(page.locator('.global-notification')).toContainText('Success')
await expect(page).toHaveURL(new RegExp(`/projects/${tasks[0].project_id}/`)) 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 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) await expect(page.locator('.bucket .task')).toContainText(labels[0].title)
}) })
@ -973,7 +978,7 @@ test.describe('Task', () => {
await uploadAttachmentAndVerify(page, tasks[0].id) 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() await expect(page.locator('.bucket .task .footer .icon svg.fa-paperclip')).toBeVisible()
}) })