diff --git a/frontend/src/assets/themes/paper-moments.theme b/frontend/src/assets/themes/paper-moments.theme index 964110a..0a24502 100644 --- a/frontend/src/assets/themes/paper-moments.theme +++ b/frontend/src/assets/themes/paper-moments.theme @@ -1,6 +1,6 @@ theme_id: paper-moments name: 纸间时光 · Paper Moments -version: 1.6.1 +version: 1.6.2 author: NotesAgent description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。 min_app_version: 0.2.0 @@ -318,3 +318,7 @@ license: MIT @supports (appearance: base-select) { [data-theme="paper-moments"] ::picker(select) { border: 1px solid #b5a693; outline: 1px dashed #d5c8b5; outline-offset: -4px; background: #fffdf5; box-shadow: var(--shadow-md); } } + +/* Nested choices retain a quiet paper border without repeating tape/shadows. */ +[data-theme="paper-moments"] .surface-nested { border: 1px dashed #c5b9a7; background: #fffdf5; border-radius: 6px; } +[data-theme="paper-moments"] .surface-nested.selected { border-color: var(--color-accent-primary); background: var(--color-accent-soft); } diff --git a/frontend/src/components/common/AppDialog.spec.ts b/frontend/src/components/common/AppDialog.spec.ts new file mode 100644 index 0000000..3f64e60 --- /dev/null +++ b/frontend/src/components/common/AppDialog.spec.ts @@ -0,0 +1,36 @@ +// @vitest-environment happy-dom +import { afterEach, expect, it } from 'vitest' +import { mount, type VueWrapper } from '@vue/test-utils' +import AppDialog from './AppDialog.vue' +const mounted: VueWrapper[] = [] +afterEach(() => { mounted.splice(0).reverse().forEach(w => w.unmount()); document.body.innerHTML = ''; document.body.style.cssText = ''; document.documentElement.style.cssText = '' }) +it('locks all scroll ancestors and restores focus and inline styles', async () => { + const opener = document.createElement('button'); document.body.append(opener); opener.focus() + const host = document.createElement('div'); host.style.setProperty('overflow', 'auto', 'important'); document.body.append(host) + const w = mount(AppDialog, { props:{label:'测试'}, slots:{default:''}, attachTo:host }); mounted.push(w) + expect(w.get('dialog').element.open).toBe(true) + expect(host.style.overflow).toBe('hidden') + expect(document.body.style.overflow).toBe('hidden') + await w.get('dialog').trigger('keydown', {key:'Escape'}) + expect(w.emitted('close')).toHaveLength(1) + w.unmount(); mounted.pop() + expect(host.style.overflow).toBe('auto') + expect(host.style.getPropertyPriority('overflow')).toBe('important') + expect(document.body.style.overflow).toBe('') + expect(document.activeElement).toBe(opener) +}) +it('retains scroll locks until the last nested dialog closes', () => { + const first = mount(AppDialog, {props:{label:'父弹窗'}, attachTo:document.body}); mounted.push(first) + const second = mount(AppDialog, {props:{label:'子弹窗'}, attachTo:document.body}); mounted.push(second) + first.unmount(); mounted.splice(0,1) + expect(document.body.style.overflow).toBe('hidden') + second.unmount(); mounted.pop() + expect(document.body.style.overflow).toBe('') +}) +it('does not dismiss permission or busy dialogs through Escape or backdrop', async () => { + const w = mount(AppDialog, {props:{label:'权限确认',dismissible:false},attachTo:document.body}); mounted.push(w) + await w.get('dialog').trigger('keydown',{key:'Escape'}) + await w.get('dialog').trigger('cancel') + await w.get('dialog').trigger('click') + expect(w.emitted('close')).toBeUndefined() +}) diff --git a/frontend/src/components/common/AppDialog.vue b/frontend/src/components/common/AppDialog.vue new file mode 100644 index 0000000..eb15de7 --- /dev/null +++ b/frontend/src/components/common/AppDialog.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/frontend/src/components/common/dialogScroll.ts b/frontend/src/components/common/dialogScroll.ts new file mode 100644 index 0000000..b814b35 --- /dev/null +++ b/frontend/src/components/common/dialogScroll.ts @@ -0,0 +1,26 @@ +// Reference counts keep the underlying page locked when dialogs are nested. +const locks = new WeakMap() +export function lockDialogScroll(dialog: HTMLElement): () => void { + const elements: HTMLElement[] = [] + for (let element = dialog.parentElement; element; element = element.parentElement) { + const lock = locks.get(element) + if (lock) lock.count++ + else { + locks.set(element, { count: 1, value: element.style.getPropertyValue('overflow'), priority: element.style.getPropertyPriority('overflow') }) + element.style.setProperty('overflow', 'hidden', 'important') + } + elements.push(element) + } + let released = false + return () => { + if (released) return + released = true + for (const element of elements) { + const lock = locks.get(element)! + if (--lock.count) continue + if (lock.value) element.style.setProperty('overflow', lock.value, lock.priority) + else element.style.removeProperty('overflow') + locks.delete(element) + } + } +} diff --git a/frontend/src/features/agent/AgentView.vue b/frontend/src/features/agent/AgentView.vue index 47af04d..a7048a5 100644 --- a/frontend/src/features/agent/AgentView.vue +++ b/frontend/src/features/agent/AgentView.vue @@ -1,4 +1,5 @@