Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
</head>
<body>
<div id="app"></div>
<!-- Target of every <Teleport>: an overlay inserted among <body>'s own children restyles the whole page. -->
<div id="overlay-root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
13 changes: 12 additions & 1 deletion frontend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* 提供全局 mock 和测试工具
*/
import { config } from '@vue/test-utils'
import { vi } from 'vitest'
import { beforeEach, vi } from 'vitest'

function createMemoryStorage(): Storage {
const values = new Map<string, string>()
Expand Down Expand Up @@ -89,6 +89,17 @@ class MockResizeObserver {

globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver

// Overlays teleport into #overlay-root (index.html); many specs reset document.body, so recreate it per test.
function ensureOverlayRoot() {
if (typeof document === 'undefined' || document.getElementById('overlay-root')) return
const overlayRoot = document.createElement('div')
overlayRoot.id = 'overlay-root'
document.body.appendChild(overlayRoot)
}

ensureOverlayRoot()
beforeEach(ensureOverlayRoot)

// Vue Test Utils 全局配置
config.global.stubs = {
// 可以在这里添加全局 stub
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/account/AccountGroupsCell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
</div>

<!-- Popover 显示完整列表 -->
<Teleport to="body">
<Teleport to="#overlay-root">
<Transition
enter-active-class="transition duration-150 ease-out"
enter-from-class="opacity-0 scale-95"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/account/AccountTestModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@
</div>

<!-- Image Lightbox -->
<Teleport to="body">
<Teleport to="#overlay-root">
<Transition name="fade">
<div
v-if="previewImageUrl"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<button class="btn btn-secondary btn-sm" :disabled="store.loadingJobs || store.jobPage.page * store.jobPage.pageSize >= store.jobPage.total" @click="loadHistory(store.jobPage.page + 1)">{{ t('admin.accountTasks.nextPage') }}</button>
</template>
</BaseDialog>
<Teleport to="body">
<Teleport to="#overlay-root">
<Transition name="operation-dock">
<aside v-if="dockJob && !store.drawerOpen && !store.embeddedOpen" class="fixed bottom-4 right-4 z-40 w-[calc(100%-2rem)] border border-line bg-canvas shadow-outline sm:w-80" :aria-label="t('admin.accountTasks.inProgress')" data-test="operation-dock">
<div class="flex items-center gap-3 px-4 py-3">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<Teleport to="body">
<Teleport to="#overlay-root">
<div v-if="show && anchorRect">
<!-- Backdrop: click anywhere outside to close -->
<div class="fixed inset-0 z-[9998]" @click="emit('close')"></div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<Teleport to="body">
<Teleport to="#overlay-root">
<Transition
enter-active-class="transition-opacity duration-200"
enter-from-class="opacity-0"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/admin/account/AccountTestModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@
</div>

<!-- Image Lightbox -->
<Teleport to="body">
<Teleport to="#overlay-root">
<Transition name="fade">
<div
v-if="previewImageUrl"
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/admin/usage/UsageTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@
</div>

<!-- Token Tooltip Portal -->
<Teleport to="body">
<Teleport to="#overlay-root">
<div
v-if="tokenTooltipVisible"
ref="tokenTooltipRef"
Expand Down Expand Up @@ -397,7 +397,7 @@
</Teleport>

<!-- Cost Tooltip Portal -->
<Teleport to="body">
<Teleport to="#overlay-root">
<div
v-if="tooltipVisible"
ref="tooltipRef"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/admin/user/UserApiKeysModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
</BaseDialog>

<!-- Group Selector Dropdown -->
<Teleport to="body">
<Teleport to="#overlay-root">
<div
v-if="groupSelectorKeyId !== null && dropdownPosition"
ref="dropdownRef"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/auth/LoginAgreementPrompt.vue
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
</div>
</div>

<Teleport to="body">
<Teleport to="#overlay-root">
<Transition name="agreement-fade">
<div
v-if="dialogVisible"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/channels/SupportedModelChip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<!-- Teleport to body so the popover is not clipped by card/overflow-hidden
ancestors. Fixed-position coords are computed from the trigger's
bounding rect; re-measured on enter / scroll / resize. -->
<Teleport to="body">
<Teleport to="#overlay-root">
<div
v-show="show"
ref="popoverEl"
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/components/common/AnnouncementBell.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
</button>

<!-- 公告列表 Modal -->
<Teleport to="body">
<Teleport to="#overlay-root">
<Transition name="modal-fade">
<div
v-if="isModalOpen"
Expand Down Expand Up @@ -181,7 +181,7 @@
</Teleport>

<!-- 公告详情 Modal -->
<Teleport to="body">
<Teleport to="#overlay-root">
<Transition name="modal-fade">
<div
v-if="detailModalOpen && selectedAnnouncement"
Expand Down Expand Up @@ -413,13 +413,14 @@ onMounted(() => {

onBeforeUnmount(() => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
document.body.removeAttribute('data-announcement-open')
})

watch(
[isModalOpen, detailModalOpen, () => announcementStore.currentPopup],
([modal, detail, popup]) => {
document.body.style.overflow = (modal || detail || popup) ? 'hidden' : ''
// body scroll lock (style.css); an attribute, not an inline style: that would restyle the whole page
document.body.toggleAttribute('data-announcement-open', Boolean(modal || detail || popup))
}
)
</script>
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/components/common/AnnouncementPopup.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<Teleport to="body">
<Teleport to="#overlay-root">
<Transition name="popup-fade">
<div
v-if="displayedAnnouncement"
Expand Down Expand Up @@ -138,22 +138,22 @@ function handleDismiss() {
announcementStore.dismissPopup()
}

// Manage body overflow — only set, never unset (bell component handles restore)
// Manage the body scroll lock (data-announcement-open, style.css) — only set, never unset (bell component handles restore)
watch(
displayedAnnouncement,
(popup) => {
if (popup) {
document.body.style.overflow = 'hidden'
document.body.setAttribute('data-announcement-open', '')
} else if (props.preview) {
document.body.style.overflow = ''
document.body.removeAttribute('data-announcement-open')
}
},
{ immediate: true },
)

onBeforeUnmount(() => {
if (props.preview) {
document.body.style.overflow = ''
document.body.removeAttribute('data-announcement-open')
}
})
</script>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/common/BaseDialog.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<Teleport to="body">
<Teleport to="#overlay-root">
<Transition name="modal">
<div
v-if="show"
Expand Down Expand Up @@ -166,7 +166,7 @@ watch(
if (isOpen) {
// 保存当前焦点元素
previousActiveElement = document.activeElement as HTMLElement
// 注册到共享对话框栈:body.modal-open 按引用计数管理,关闭嵌套子级时父级仍保持滚动锁定
// 注册到共享对话框栈:body[data-modal-open] 按引用计数管理,关闭嵌套子级时父级仍保持滚动锁定
registerDialog(dialogId, props.zIndex)

// 等待DOM更新后设置焦点到对话框(仅当自己仍是栈顶,避免抢走更晚打开的对话框的焦点)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/HelpTooltip.vue
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ onBeforeUnmount(() => {
</slot>

<!-- Teleport to body to escape modal overflow clipping -->
<Teleport to="body">
<Teleport to="#overlay-root">
<!-- before: 伪元素向下延伸一段透明区域,盖住提示框与触发图标之间的空隙,让指针能连续移入提示框。 -->
<div
ref="tooltip"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/InfoPopover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
>
<slot name="trigger" />
</button>
<Teleport to="body">
<Teleport to="#overlay-root">
<div
v-if="open"
:id="panelId"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
</button>

<!-- Teleport dropdown to body to escape stacking context -->
<Teleport to="body">
<Teleport to="#overlay-root">
<Transition name="select-dropdown">
<div
v-if="isOpen"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/common/Toast.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<Teleport to="body">
<Teleport to="#overlay-root">
<div
data-ui="toast-stack"
class="pointer-events-none fixed right-4 top-4 z-[9999] space-y-3"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('AnnouncementPopup', () => {

afterEach(() => {
document.body.innerHTML = ''
document.body.style.overflow = ''
document.body.removeAttribute('data-announcement-open')
})

it('renders mixed Markdown and HTML inside the shared styled container', async () => {
Expand Down Expand Up @@ -106,7 +106,7 @@ describe('AnnouncementPopup', () => {
expect(dismissPopup).not.toHaveBeenCalled()

await wrapper.setProps({ announcement: null })
expect(document.body.style.overflow).toBe('')
expect(document.body.hasAttribute('data-announcement-open')).toBe(false)
wrapper.unmount()
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ function dialog(show = true) {
return mount(BaseDialog, { props: { show, title: 'Details' }, global: { stubs: { Icon: true } } })
}

const locked = () => document.body.classList.contains('modal-open')
const locked = () => document.body.hasAttribute('data-modal-open')

describe('dialog body scroll lock', () => {
it('keeps the body locked when a hidden sibling mounts or unmounts', () => {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/common/__tests__/BaseDialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ vi.mock('vue-i18n', () => ({
describe('BaseDialog', () => {
afterEach(() => {
document.body.innerHTML = ''
document.body.classList.remove('modal-open')
document.body.removeAttribute('data-modal-open')
})

it('resets body scroll position when reopened', async () => {
Expand Down Expand Up @@ -54,7 +54,7 @@ describe('BaseDialog', () => {
expect(outer.emitted('close')).toBeUndefined()

await inner.setProps({ show: false })
expect(document.body.classList.contains('modal-open')).toBe(true)
expect(document.body.hasAttribute('data-modal-open')).toBe(true)

inner.unmount()
outer.unmount()
Expand Down
11 changes: 6 additions & 5 deletions frontend/src/components/common/dialogStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { shallowReactive } from 'vue'
*
* - Keyboard handling (Escape, Tab cycling) and auto-focus only act on the top-most dialog,
* so a confirm nested inside an editor no longer closes the editor as well.
* - The `body.modal-open` scroll lock is reference counted: added on the first registration,
* removed on the last unregistration, instead of every dialog toggling it on its own.
* - The `body[data-modal-open]` scroll lock is reference counted: added on the first registration,
* removed on the last unregistration, instead of every dialog toggling it on its own. It is an
* attribute no other selector uses: a class or inline-style change on <body> restyles the whole page.
* - `dialogLayer` stacks nested dialogs ten z-index steps above the previous one so a child
* always paints above its parent even when both use the default z-index.
*/
Expand All @@ -27,7 +28,7 @@ export const DEFAULT_DIALOG_Z_INDEX = 50
export const GATE_DIALOG_Z_INDEX = 80

const LAYER_STEP = 10
const BODY_LOCK_CLASS = 'modal-open'
const BODY_LOCK_ATTRIBUTE = 'data-modal-open'

const dialogs = shallowReactive<DialogEntry[]>([])
let sequence = 0
Expand All @@ -45,7 +46,7 @@ export function registerDialog(id: string, zIndex = DEFAULT_DIALOG_Z_INDEX): voi
} else {
dialogs.push({ id, zIndex })
}
document.body.classList.add(BODY_LOCK_CLASS)
document.body.setAttribute(BODY_LOCK_ATTRIBUTE, '')
}

export function unregisterDialog(id: string): void {
Expand All @@ -54,7 +55,7 @@ export function unregisterDialog(id: string): void {
dialogs.splice(index, 1)
}
if (dialogs.length === 0) {
document.body.classList.remove(BODY_LOCK_CLASS)
document.body.removeAttribute(BODY_LOCK_ATTRIBUTE)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
</span>
</button>

<Teleport to="body">
<Teleport to="#overlay-root">
<Transition name="select-dropdown">
<div
v-if="isOpen"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
</div>
</div>

<Teleport to="body">
<Teleport to="#overlay-root">
<div
v-if="floatingTooltip.visible"
class="matrix-floating-tooltip"
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,9 @@
@apply flex-shrink-0;
}

/* 防止body滚动的工具类 */
body.modal-open {
/* 对话框/公告弹窗打开时禁止 body 滚动(dialogStack 与公告组件设置的属性;<body> 的 class 或内联样式变化会让整页重算样式) */
body[data-modal-open],
body[data-announcement-open] {
overflow: hidden;
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/styles/console/controls.css
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@ html.flat-theme :is(a, button)[class*="underline"] {
checkbox squares and check badges (spans), steppers (divs) and the .btn recipe are not buttons of this kind; the
public pages keep their own rules. Kept at 0,3,1 (the page files refine; the account forms' 9.6 agrees).
(wave 2, reported by the accounts agent) */
html.flat-theme :where(:is(button, a):is(.bg-primary-500, .bg-primary-600, .bg-primary-700).text-white:not(.btn):not(:is([data-ui="home"], [data-ui="key-usage"], [data-ui="legal"], [data-ui="not-found"], [data-ui="auth-layout"], [data-testid="compact-home"], body > div.z-\[140\]) *))[class][class] {
html.flat-theme :where(:is(button, a):is(.bg-primary-500, .bg-primary-600, .bg-primary-700).text-white:not(.btn):not(:is([data-ui="home"], [data-ui="key-usage"], [data-ui="legal"], [data-ui="not-found"], [data-ui="auth-layout"], [data-testid="compact-home"], #overlay-root > div.z-\[140\]) *))[class][class] {
--theme-on-fill: var(--ui-on-ink);
border-color: rgb(var(--ui-ink));
background-color: rgb(var(--ui-ink));
Expand Down
Loading
Loading