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:
parent
bc1a9008a7
commit
cef03cb2a0
|
|
@ -86,7 +86,6 @@
|
|||
|
||||
<Modal
|
||||
:enabled="showHowItWorks"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => showHowItWorks = false"
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@
|
|||
|
||||
<Modal
|
||||
:enabled="showHowItWorks"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => showHowItWorks = false"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
</XButton>
|
||||
<Modal
|
||||
:enabled="modalOpen"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="() => modalOpen = false"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@
|
|||
</BaseButton>
|
||||
<Modal
|
||||
:enabled="visible"
|
||||
transition-name="fade"
|
||||
:overflow="true"
|
||||
variant="hint-modal"
|
||||
@close="close"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<template>
|
||||
<Modal
|
||||
transition-name="fade"
|
||||
variant="hint-modal"
|
||||
@close="$router.back()"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue