修复维护通知自适应布局与导航重复焦点
This commit is contained in:
1 parent
a6b2bfd4dc
commit
b4915d0ae5
12 files changed
+438
-49
No files matched your search
@@ -2,7 +2,7 @@
|
||||
/* 导入方:app/components/OperationToasts.vue、nuxt.config.ts。 */
|
||||
.operation-toasts.p-toast {
|
||||
width: min(380px, 100% - 24px);
|
||||
top: 76px !important;
|
||||
top: calc(var(--console-top-offset, 56px) + 20px) !important;
|
||||
right: 20px !important;
|
||||
}
|
||||
.operation-toasts .p-toast-message {
|
||||
@@ -64,7 +64,7 @@
|
||||
/* 视口条件 (width<=600px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */
|
||||
@media (width<=600px) {
|
||||
.operation-toasts.p-toast {
|
||||
top: 68px !important;
|
||||
top: calc(var(--console-top-offset, 56px) + 12px) !important;
|
||||
right: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@
|
||||
gap: 18px;
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
top: calc(var(--console-top-offset, 56px) + 20px);
|
||||
}
|
||||
.cart-foot h3[data-v-11d8a39a] {
|
||||
margin: 0;
|
||||
|
||||
@@ -326,7 +326,7 @@ summary[data-v-e16eb167]:focus-visible {
|
||||
@media (width>=1024px) {
|
||||
.catalog-lines[data-v-e16eb167] {
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
top: calc(var(--console-top-offset, 56px) + 20px);
|
||||
}
|
||||
}
|
||||
/* 视口条件 (width<=1199px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */
|
||||
|
||||
@@ -4,23 +4,30 @@
|
||||
background: var(--console-page-bg);
|
||||
min-height: 100dvh;
|
||||
color: var(--p-text-color, #334155);
|
||||
padding-top: 56px;
|
||||
}
|
||||
.console-layout.has-banner[data-v-1a9c3b51] {
|
||||
padding-top: 94px;
|
||||
padding-top: var(--console-top-offset, 56px);
|
||||
}
|
||||
.maintenance-banner[data-v-1a9c3b51] {
|
||||
z-index: 150;
|
||||
color: #92400e;
|
||||
background: #fef3c7;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
min-height: 38px;
|
||||
max-height: 40dvh;
|
||||
padding: 8px 14px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
}
|
||||
.maintenance-banner > span[data-v-1a9c3b51] {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.maintenance-banner > i[data-v-1a9c3b51] {
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
/* 桌面侧栏与移动端导航共享的外壳;断点覆盖会改变其宽度/可见性。 */
|
||||
.sidebar[data-v-1a9c3b51] {
|
||||
z-index: 30;
|
||||
@@ -28,7 +35,7 @@
|
||||
border-right: 1px solid var(--console-panel-border);
|
||||
width: 210px;
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
top: var(--console-top-offset, 56px);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
@@ -146,22 +153,20 @@
|
||||
.main[data-v-1a9c3b51] {
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: calc(100dvh - 56px);
|
||||
min-height: calc(100dvh - var(--console-top-offset, 56px));
|
||||
margin-left: 210px;
|
||||
display: flex;
|
||||
}
|
||||
.has-banner .main[data-v-1a9c3b51] {
|
||||
min-height: calc(100dvh - 94px);
|
||||
}
|
||||
/* 顶栏与维护提示、主内容定位配合,避免固定元素遮挡可操作内容。 */
|
||||
.topbar[data-v-1a9c3b51] {
|
||||
z-index: 35;
|
||||
background: var(--console-panel-bg);
|
||||
border-bottom: 1px solid var(--console-panel-border);
|
||||
gap: 0;
|
||||
height: 56px;
|
||||
height: var(--console-header-height, 56px);
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
top: var(--console-banner-height, 0px);
|
||||
}
|
||||
.brand-group[data-v-1a9c3b51],
|
||||
.topbar[data-v-1a9c3b51] {
|
||||
@@ -435,12 +440,6 @@
|
||||
.skip-link[data-v-1a9c3b51]:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.has-banner .topbar[data-v-1a9c3b51] {
|
||||
top: 38px;
|
||||
}
|
||||
.has-banner .sidebar[data-v-1a9c3b51] {
|
||||
top: 94px;
|
||||
}
|
||||
/* 视口条件 (width<=1535px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */
|
||||
@media (width<=1535px) {
|
||||
.main[data-v-1a9c3b51] {
|
||||
@@ -451,7 +450,7 @@
|
||||
border-right: 0;
|
||||
width: 280px;
|
||||
transition: transform 0.2s;
|
||||
top: 0;
|
||||
top: var(--console-banner-height, 0px);
|
||||
transform: translate(-100%);
|
||||
}
|
||||
.sidebar.open[data-v-1a9c3b51] {
|
||||
@@ -466,9 +465,6 @@
|
||||
.brand-group[data-v-1a9c3b51] {
|
||||
flex: none;
|
||||
}
|
||||
.has-banner .sidebar[data-v-1a9c3b51] {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
/* 视口条件 (width<=767px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */
|
||||
@media (width<=767px) {
|
||||
|
||||
@@ -1472,7 +1472,7 @@ video {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding: 16px 14px;
|
||||
scroll-margin-top: 75px;
|
||||
scroll-margin-top: calc(var(--console-top-offset, 56px) + 19px);
|
||||
box-shadow: 0 1px 3px #0000001a;
|
||||
}
|
||||
.config-checkout .section-title {
|
||||
@@ -1690,7 +1690,7 @@ video {
|
||||
@media (width>=1024px) {
|
||||
.config-summary-wrap {
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
top: calc(var(--console-top-offset, 56px) + 20px);
|
||||
}
|
||||
}
|
||||
/* 视口条件 (width<=1199px) 下的响应式覆盖;保留相对基础规则的层叠顺序。 */
|
||||
|
||||
@@ -40,11 +40,11 @@
|
||||
.help-nav[data-v-2e1fbf8d] {
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
max-height: calc(100dvh - 104px);
|
||||
max-height: calc(100dvh - var(--console-top-offset, 56px) - 48px);
|
||||
padding: 20px 14px 16px;
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 78px;
|
||||
top: calc(var(--console-top-offset, 56px) + 22px);
|
||||
}
|
||||
.nav-heading[data-v-2e1fbf8d] {
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -777,6 +777,7 @@ var ce = {
|
||||
{
|
||||
default: withCtx(() => [
|
||||
createVNode(o, {
|
||||
as: `span`,
|
||||
label: `前往安全组管理`,
|
||||
size: `small`,
|
||||
severity: `secondary`,
|
||||
@@ -857,6 +858,7 @@ var ce = {
|
||||
{
|
||||
default: withCtx(() => [
|
||||
createVNode(o, {
|
||||
as: `span`,
|
||||
label: `前往域名白名单`,
|
||||
size: `small`,
|
||||
severity: `secondary`,
|
||||
|
||||
+53
-18
@@ -137,6 +137,28 @@ var Ce = {
|
||||
O = useRoute(),
|
||||
k = useTheme_t(),
|
||||
A = useSiteConfig_t();
|
||||
// 固定层共用实际维护条高度,避免换行、字体加载或窗口变化后仍按单行常量占位。
|
||||
const maintenanceBanner = ref(null);
|
||||
const bannerHeight = ref(0);
|
||||
let bannerObserver;
|
||||
watch(
|
||||
maintenanceBanner,
|
||||
(element) => {
|
||||
bannerObserver?.disconnect();
|
||||
const measure = () => {
|
||||
bannerHeight.value = element
|
||||
? Math.ceil(element.getBoundingClientRect().height)
|
||||
: 0;
|
||||
};
|
||||
measure();
|
||||
if (element) {
|
||||
bannerObserver = new ResizeObserver(measure);
|
||||
bannerObserver.observe(element);
|
||||
}
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
onBeforeUnmount(() => bannerObserver?.disconnect());
|
||||
onMounted(() => {
|
||||
(A.load().catch(() => {}),
|
||||
w.isLoggedIn && !w.overview && w.fetchOverview().catch(() => {}),
|
||||
@@ -548,6 +570,15 @@ var Ce = {
|
||||
return (
|
||||
useHead(() => ({
|
||||
title: `${$.value.at(-1) || `会员中心`} - ${A.siteName.value}`,
|
||||
// 通知 Teleport 在 body,变量发布到 html;Unhead 随布局卸载逐属性清理,不影响主题样式。
|
||||
htmlAttrs: {
|
||||
style: {
|
||||
"--console-header-height": "56px",
|
||||
"--console-banner-height": `${A.maintenance.value ? bannerHeight.value : 0}px`,
|
||||
"--console-top-offset":
|
||||
"calc(var(--console-header-height) + var(--console-banner-height))",
|
||||
},
|
||||
},
|
||||
})),
|
||||
// 导航、面包屑和账户/消息浮层来自同一布局状态;移动侧栏需保留焦点约束与滚动锁清理。
|
||||
(e, r) => {
|
||||
@@ -591,25 +622,29 @@ var Ce = {
|
||||
: createCommentVNode(``, !0),
|
||||
unref(A).maintenance.value
|
||||
? (openBlock(),
|
||||
createElementBlock(`div`, Ce, [
|
||||
(r[6] ||= createElementVNode(
|
||||
`i`,
|
||||
{
|
||||
class: `pi pi-exclamation-triangle`,
|
||||
},
|
||||
null,
|
||||
-1,
|
||||
)),
|
||||
createElementVNode(
|
||||
`span`,
|
||||
null,
|
||||
toDisplayString(
|
||||
unref(A).config.value?.maintenance_mode_message ||
|
||||
`站点维护中,部分功能可能不可用。`,
|
||||
createElementBlock(
|
||||
`div`,
|
||||
{ ...Ce, ref: maintenanceBanner },
|
||||
[
|
||||
(r[6] ||= createElementVNode(
|
||||
`i`,
|
||||
{
|
||||
class: `pi pi-exclamation-triangle`,
|
||||
},
|
||||
null,
|
||||
-1,
|
||||
)),
|
||||
createElementVNode(
|
||||
`span`,
|
||||
null,
|
||||
toDisplayString(
|
||||
unref(A).config.value?.maintenance_mode_message ||
|
||||
`站点维护中,部分功能可能不可用。`,
|
||||
),
|
||||
1,
|
||||
),
|
||||
1,
|
||||
),
|
||||
]))
|
||||
],
|
||||
))
|
||||
: createCommentVNode(``, !0),
|
||||
createElementVNode(
|
||||
`aside`,
|
||||
|
||||
@@ -75,6 +75,8 @@ var m = defineComponent({
|
||||
{
|
||||
default: withCtx(() => [
|
||||
createVNode(g, {
|
||||
// 导航只保留外层链接的焦点;内层保留按钮外观,不再生成第二个交互元素。
|
||||
as: `span`,
|
||||
label: `订购云服务器`,
|
||||
icon: `pi pi-plus`,
|
||||
}),
|
||||
|
||||
@@ -75,6 +75,8 @@ var m = defineComponent({
|
||||
{
|
||||
default: withCtx(() => [
|
||||
createVNode(g, {
|
||||
// 外层 NuxtLink 独占导航语义,避免链接里再次嵌套按钮焦点。
|
||||
as: `span`,
|
||||
label: `订购物理服务器`,
|
||||
icon: `pi pi-plus`,
|
||||
}),
|
||||
|
||||
@@ -227,6 +227,7 @@ var ie = {
|
||||
{
|
||||
default: withCtx(() => [
|
||||
createVNode(f, {
|
||||
as: `span`,
|
||||
label: `创建工单`,
|
||||
icon: `pi pi-plus`,
|
||||
}),
|
||||
@@ -398,6 +399,7 @@ var ie = {
|
||||
{
|
||||
default: withCtx(() => [
|
||||
createVNode(f, {
|
||||
as: `span`,
|
||||
label: `创建工单`,
|
||||
icon: `pi pi-plus`,
|
||||
size: `small`,
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 布局回归补齐旧业务 E2E 的视觉盲区:长维护文案、吸顶避让、导航的单一焦点与布局切换清理。
|
||||
* 所有内容来自生产已有的本地 HTTP 边界;尺寸断言读取实际 DOM,不检查源码形状。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
appOrigin,
|
||||
backendOrigin,
|
||||
eventually,
|
||||
login,
|
||||
scenario,
|
||||
} from "./helpers/console-browser.mjs";
|
||||
|
||||
const maintenanceMessage =
|
||||
"系统正在升级维护,部分业务暂时不可用,请稍后再试。如有紧急问题请联系客服,我们正在尽快恢复服务。";
|
||||
|
||||
async function layoutBoundary(
|
||||
page,
|
||||
{ maintenance = true, content = "help" } = {},
|
||||
) {
|
||||
await page
|
||||
.context()
|
||||
.route(`${backendOrigin}/console/v1/**`, async (route) => {
|
||||
const path = new URL(route.request().url()).pathname.replace(
|
||||
/^\/console\/v1/,
|
||||
"",
|
||||
);
|
||||
const reply = (data) =>
|
||||
route.fulfill({ json: { status: 200, msg: "", data } });
|
||||
if (path === "/common") {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
Object.assign(body.data, {
|
||||
maintenance_mode: Number(maintenance),
|
||||
maintenance_mode_message: maintenanceMessage,
|
||||
});
|
||||
return route.fulfill({ response, json: body });
|
||||
}
|
||||
if (path === "/menu")
|
||||
return reply({
|
||||
menu: [
|
||||
{
|
||||
id: 10,
|
||||
name: "查看官网",
|
||||
menu_type: "custom",
|
||||
url: "/official-site",
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === "/official_site_cms/content") return reply({ content: {} });
|
||||
if (path === "/help")
|
||||
return reply({
|
||||
list: [
|
||||
{
|
||||
id: 1,
|
||||
name: "产品帮助",
|
||||
helps: [{ id: 1, title: "维护期间的使用说明" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === "/help/1")
|
||||
return reply({
|
||||
help: {
|
||||
id: 1,
|
||||
title: "维护期间的使用说明",
|
||||
content:
|
||||
"<p>这是一段正常帮助正文,用于验证长文章滚动后的布局。</p>".repeat(
|
||||
80,
|
||||
),
|
||||
create_time: 1700000000,
|
||||
},
|
||||
});
|
||||
if (path === "/cart")
|
||||
return reply({
|
||||
list: Array.from({ length: 14 }, (_, index) => ({
|
||||
product_id: 123,
|
||||
qty: 1,
|
||||
name: `云产品 ${index + 1}`,
|
||||
config_options: { cpu: index + 1 },
|
||||
customfield: {},
|
||||
self_defined_field: {},
|
||||
})),
|
||||
});
|
||||
if (path === "/product/123/config_option")
|
||||
return reply({
|
||||
price_total: "10.00",
|
||||
preview: [{ name: "CPU", value: "2 核" }],
|
||||
});
|
||||
if (path === "/purchase_limit/check")
|
||||
return reply({ allowed: true, message: "" });
|
||||
if (path === "/product/123")
|
||||
return reply({
|
||||
product: {
|
||||
id: 123,
|
||||
name: "可配置产品",
|
||||
module: "idcsmart_common",
|
||||
pay_type: "free",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
if (path === "/idcsmart_common/product/123/configoption")
|
||||
return reply({
|
||||
common_product: { pay_type: "free" },
|
||||
configoptions: Array.from({ length: 18 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
option_name: `扩展资源 ${index + 1}`,
|
||||
option_type: "quantity",
|
||||
is_show: 1,
|
||||
qty_min: 1,
|
||||
qty_max: 10,
|
||||
qty_change: 1,
|
||||
})),
|
||||
});
|
||||
if (path === "/product/123/self_defined_field/order_page")
|
||||
return reply({ data: [] });
|
||||
if (path === "/product" && content === "dedicated")
|
||||
return reply({
|
||||
list: [
|
||||
{
|
||||
id: 123,
|
||||
name: "物理服务器",
|
||||
module: "mf_dcim",
|
||||
price: "100.00",
|
||||
stock_control: 0,
|
||||
},
|
||||
],
|
||||
count: 1,
|
||||
});
|
||||
if (path === "/product/123/mf_dcim/order_page")
|
||||
return reply({
|
||||
model_config: Array.from({ length: 12 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `机型 ${index + 1}`,
|
||||
cpu: "Xeon",
|
||||
memory: "32GB",
|
||||
disk: "1TB",
|
||||
})),
|
||||
data_center: [
|
||||
{
|
||||
name: "中国",
|
||||
city: [
|
||||
{
|
||||
name: "上海",
|
||||
area: [{ name: "一区", line: [{ id: 1, name: "BGP" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (path === "/product/123/mf_dcim/line/1") return reply({});
|
||||
return route.fallback();
|
||||
});
|
||||
}
|
||||
|
||||
async function bannerGeometry(page) {
|
||||
return page.locator(".maintenance-banner").evaluate((banner) => {
|
||||
const box = banner.getBoundingClientRect(),
|
||||
range = document.createRange();
|
||||
range.selectNodeContents(banner.querySelector("span"));
|
||||
const text = range.getBoundingClientRect(),
|
||||
bar = document.querySelector(".topbar").getBoundingClientRect();
|
||||
return {
|
||||
top: box.top,
|
||||
bottom: box.bottom,
|
||||
height: box.height,
|
||||
textTop: text.top,
|
||||
textBottom: text.bottom,
|
||||
topbarTop: bar.top,
|
||||
topbarBottom: bar.bottom,
|
||||
viewport: innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
"maintenance text and page offsets follow content height and viewport changes",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.setViewportSize({ width: 320, height: 740 });
|
||||
await layoutBoundary(page);
|
||||
await login(page, "/security/ssh-keys");
|
||||
await page.locator(".maintenance-banner").waitFor();
|
||||
const sizes = [];
|
||||
for (const width of [320, 375, 1440, 320]) {
|
||||
await page.setViewportSize({ width, height: 740 });
|
||||
const state = await eventually(
|
||||
() => bannerGeometry(page),
|
||||
(state) =>
|
||||
state.textTop >= state.top - 0.5 &&
|
||||
state.textBottom <= state.bottom + 0.5 &&
|
||||
state.topbarTop >= state.bottom - 0.5,
|
||||
"The full notice and topbar must occupy separate visible regions",
|
||||
);
|
||||
assert.equal(
|
||||
state.scrollWidth,
|
||||
state.viewport,
|
||||
"A maintenance notice must not widen the document",
|
||||
);
|
||||
sizes.push({ width, noticeFits: true, headerBelowNotice: true });
|
||||
}
|
||||
await page.getByRole("button", { name: "打开菜单", exact: true }).click();
|
||||
const menuVisible = await page
|
||||
.getByRole("button", { name: "关闭菜单", exact: true })
|
||||
.evaluate((button) => {
|
||||
const r = button.getBoundingClientRect(),
|
||||
hit = document.elementFromPoint(
|
||||
r.left + r.width / 2,
|
||||
r.top + r.height / 2,
|
||||
);
|
||||
return button.contains(hit);
|
||||
});
|
||||
assert.ok(
|
||||
menuVisible,
|
||||
"The notice must not cover the drawer close button",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
return { sizes, drawerCloseReachable: true };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [name, path, selector] of [
|
||||
["help", "/help/article?id=1", ".help-nav"],
|
||||
["cart", "/cart", ".cart-foot"],
|
||||
["dedicated", "/products/dedicated-server", ".catalog-lines"],
|
||||
["configuration", "/products/123", ".config-summary-wrap"],
|
||||
]) {
|
||||
test(
|
||||
`maintenance sticky ${name} clears the actual fixed header`,
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.setViewportSize({ width: 1440, height: 800 });
|
||||
await layoutBoundary(page, { content: name });
|
||||
await login(page, path);
|
||||
if (name === "dedicated")
|
||||
await page
|
||||
.getByRole("button", { name: "选择线路", exact: true })
|
||||
.first()
|
||||
.click();
|
||||
await page.locator(selector).waitFor();
|
||||
await page.evaluate(() => window.scrollTo(0, 500));
|
||||
const state = await eventually(
|
||||
() =>
|
||||
page.locator(selector).evaluate((el) => {
|
||||
const r = el.getBoundingClientRect(),
|
||||
bar = document.querySelector(".topbar").getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(r.left + 10, r.top + 5);
|
||||
return {
|
||||
scrollY,
|
||||
top: r.top,
|
||||
headerBottom: bar.bottom,
|
||||
unobscured: el.contains(hit),
|
||||
sticky: getComputedStyle(el).position,
|
||||
};
|
||||
}),
|
||||
(state) =>
|
||||
state.scrollY > 0 &&
|
||||
state.top >= state.headerBottom - 0.5 &&
|
||||
state.unobscured,
|
||||
"Sticky content must remain below, not behind, the header",
|
||||
);
|
||||
assert.equal(
|
||||
state.sticky,
|
||||
"sticky",
|
||||
"The test must exercise the sticky state",
|
||||
);
|
||||
return {
|
||||
stickyReached: true,
|
||||
headerCleared: true,
|
||||
hitTestUnobscured: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
"console layout releases its measured offsets when navigating to the official site",
|
||||
{ timeout: 30000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await page.setViewportSize({ width: 1680, height: 900 });
|
||||
await layoutBoundary(page);
|
||||
await page.goto(`${appOrigin}/official-site`);
|
||||
await page.locator(".official-site").waitFor();
|
||||
const toastTop = () =>
|
||||
page
|
||||
.locator(".operation-toasts")
|
||||
.evaluate((el) => parseFloat(getComputedStyle(el).top));
|
||||
const outsideConsoleTop = await toastTop();
|
||||
await login(page, "/help/article?id=1");
|
||||
const consoleTop = await eventually(
|
||||
toastTop,
|
||||
(top) => top > outsideConsoleTop,
|
||||
"Global notifications must clear the maintenance header",
|
||||
);
|
||||
await page.getByRole("link", { name: "查看官网", exact: true }).click();
|
||||
await page.locator(".official-site").waitFor();
|
||||
await eventually(
|
||||
toastTop,
|
||||
(top) => Math.abs(top - outsideConsoleTop) < 0.5,
|
||||
"Console geometry must not leak after the console layout unmounts",
|
||||
);
|
||||
return {
|
||||
notificationClearedConsole: consoleTop > outsideConsoleTop,
|
||||
officialLayoutRestored: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [path, name] of [
|
||||
["/cloud-servers", "订购云服务器"],
|
||||
["/dedicated-servers", "订购物理服务器"],
|
||||
["/tickets", "创建工单"],
|
||||
]) {
|
||||
test(
|
||||
`navigation action has one keyboard stop on ${path}`,
|
||||
{ timeout: 25000 },
|
||||
(t) =>
|
||||
scenario(t, {}, async (page) => {
|
||||
await login(page, path);
|
||||
const links = page.getByRole("link", { name, exact: true });
|
||||
await links.first().waitFor();
|
||||
for (const link of await links.all()) {
|
||||
assert.equal(
|
||||
await link
|
||||
.locator("button,a[href],input,select,textarea,[tabindex='0']")
|
||||
.count(),
|
||||
0,
|
||||
"A navigation action must not contain another focusable control",
|
||||
);
|
||||
await link.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
assert.equal(
|
||||
await link.evaluate((el) => el.contains(document.activeElement)),
|
||||
false,
|
||||
"Tab must move on from the action, not enter it a second time",
|
||||
);
|
||||
}
|
||||
return {
|
||||
navigationActions: await links.count(),
|
||||
singleKeyboardStop: true,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user