feat(ui): modals close only via Esc/X/cancel; standard 3:1 form cover processing

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 15:23:14 +03:30
co-authored by Cursor
parent f5b6a47f3c
commit 2f3646d085
19 changed files with 366 additions and 117 deletions
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useEscClose } from '@/lib/modal-stack';
import type { MessageAutomationDelayUnit } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
@@ -92,17 +93,13 @@ function pickImmediate() {
open.value = false;
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') open.value = false;
}
useEscClose(() => open.value, () => { open.value = false; });
watch(open, (isOpen) => {
document.body.style.overflow = isOpen ? 'hidden' : '';
});
onMounted(() => window.addEventListener('keydown', onKey));
onUnmounted(() => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
});
</script>
@@ -116,7 +113,7 @@ onUnmounted(() => {
<Teleport to="body">
<div v-if="open" class="layer">
<button class="dim" type="button" aria-label="بستن" @click="open = false" />
<div class="dim" aria-hidden="true" />
<section class="sheet" role="dialog" aria-modal="true" aria-label="انتخاب فاصله زمانی">
<header>
<div>
+13 -3
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import { useEscClose } from '@/lib/modal-stack';
import type { SavedViewDto, SavedViewScope } from '@mento/shared';
const props = defineProps<{
@@ -22,6 +23,7 @@ const error = ref('');
const saveOpen = ref(false);
const saveName = ref('');
const saveShared = ref(false);
useEscClose(() => saveOpen.value, () => { saveOpen.value = false; });
const activeView = computed(
() => views.value.find((v) => v.id === activeId.value) ?? null,
@@ -151,9 +153,14 @@ onMounted(() => {
<p v-if="error" class="err">{{ error }}</p>
<div v-if="saveOpen" class="sheet">
<div class="sheet-backdrop" @click="saveOpen = false" />
<div class="sheet-panel">
<h3>{{ activeId ? 'به‌روزرسانی فیلتر' : 'ذخیره فیلتر' }}</h3>
<div class="sheet-backdrop" aria-hidden="true" />
<div class="sheet-panel" role="dialog" aria-modal="true">
<header class="sheet-head">
<h3>{{ activeId ? 'به‌روزرسانی فیلتر' : 'ذخیره فیلتر' }}</h3>
<button type="button" class="x-btn" aria-label="بستن (Esc)" title="بستن (Esc)" @click="saveOpen = false">
<UiIcon name="x" :size="16" />
</button>
</header>
<label>
نام
<input v-model="saveName" type="text" maxlength="120" placeholder="مثلاً سرنخ‌های این هفته" />
@@ -253,6 +260,9 @@ input[type='text'] {
margin: 0;
font-size: 1rem;
}
.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.x-btn { width: 32px; height: 32px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; color: #64748b; display: grid; place-items: center; cursor: pointer; }
.x-btn:hover { background: #f8fafc; color: #0f172a; }
.sheet-panel label {
display: grid;
gap: 0.3rem;
@@ -149,7 +149,7 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<div class="layer" role="presentation">
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
<div class="backdrop" aria-hidden="true" />
<section
class="sheet"
role="dialog"
@@ -156,7 +156,7 @@ onBeforeUnmount(() => {
<template>
<Teleport to="body">
<div class="layer" role="presentation">
<button class="backdrop" type="button" aria-label="بستن" @click="close" />
<div class="backdrop" aria-hidden="true" />
<section class="sheet" role="dialog" aria-modal="true" aria-labelledby="postpone-title">
<div class="handle" aria-hidden="true" />
+4 -17
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onUnmounted, watch } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { useEscClose } from '@/lib/modal-stack';
const props = withDefaults(
defineProps<{
@@ -13,33 +13,20 @@ const props = withDefaults(
const emit = defineEmits<{ close: [] }>();
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
watch(
() => props.open,
(open) => {
if (open) window.addEventListener('keydown', onKey);
else window.removeEventListener('keydown', onKey);
},
{ immediate: true },
);
onUnmounted(() => window.removeEventListener('keydown', onKey));
useEscClose(() => props.open, () => emit('close'));
</script>
<template>
<Teleport to="body">
<Transition name="ui-drawer">
<div v-if="open" class="ui-drawer-layer">
<button class="ui-drawer-dim" type="button" aria-label="بستن" @click="emit('close')" />
<div class="ui-drawer-dim" aria-hidden="true" />
<aside class="ui-drawer" role="dialog" aria-modal="true" :aria-label="title" :style="{ '--drawer-w': width }">
<header class="ui-drawer-head">
<slot name="header">
<h2>{{ title }}</h2>
</slot>
<button type="button" class="ui-drawer-close" aria-label="بستن" @click="emit('close')">
<button type="button" class="ui-drawer-close" aria-label="بستن (Esc)" title="بستن (Esc)" @click="emit('close')">
<UiIcon name="x" :size="18" />
</button>
</header>
+5 -22
View File
@@ -5,6 +5,7 @@ let openModalCount = 0;
<script setup lang="ts">
import { onUnmounted, watch } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { useEscClose } from '@/lib/modal-stack';
const props = withDefaults(
defineProps<{
@@ -12,15 +13,9 @@ const props = withDefaults(
title: string;
/** برای مودال روی مودال (مثل انتخاب مخاطب داخل فرم) */
nest?: boolean;
/**
* بستن با کلیک روی پس‌زمینه.
* پیش‌فرض: فقط موبایل (تمام‌صفحه). در دسکتاپ عمداً بسته نمی‌شود.
*/
closeOnBackdrop?: boolean | 'mobile';
}>(),
{
nest: false,
closeOnBackdrop: 'mobile',
},
);
@@ -28,6 +23,8 @@ const emit = defineEmits<{
close: [];
}>();
useEscClose(() => props.open, () => emit('close'));
let locked = false;
function setLock(next: boolean) {
@@ -44,34 +41,20 @@ watch(
);
onUnmounted(() => setLock(false));
function isMobileViewport() {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 900px)').matches;
}
function onBackdrop(event: MouseEvent) {
if (event.target !== event.currentTarget) return;
const mode = props.closeOnBackdrop;
if (mode === false) return;
if (mode === true || (mode === 'mobile' && isMobileViewport())) {
emit('close');
}
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="backdrop" :class="{ nest }" @click="onBackdrop">
<div v-if="open" class="backdrop" :class="{ nest }">
<section
class="dialog"
role="dialog"
aria-modal="true"
:aria-label="title"
@click.stop
>
<header class="head">
<h2>{{ title }}</h2>
<button type="button" class="close" aria-label="بستن" @click="emit('close')">
<button type="button" class="close" aria-label="بستن (Esc)" title="بستن (Esc)" @click="emit('close')">
<UiIcon name="x" :size="18" />
</button>
</header>