feat(shell): fluid layout with collapsible sidebar drawer + fix query-tool MultiSelect
Convert portal shell from block-centered (max-width 1600px) layout to full-viewport fluid flexbox with collapsible sidebar: desktop push-mode (240px → 0), mobile overlay drawer with backdrop. Rename .content → .shell-content to avoid CSS collision with page-level classes. Override page-level max-width constraints when embedded in shell. Also replace native <select multiple> in query-tool with shared MultiSelect component for equipment and workcenter group filters, matching resource-status/history UX. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,17 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import HealthStatus from './components/HealthStatus.vue';
|
||||
import { consumeNavigationNotice, syncNavigationRoutes } from './router.js';
|
||||
import { normalizeRoutePath } from './routeContracts.js';
|
||||
import {
|
||||
SIDEBAR_STORAGE_KEY,
|
||||
buildSidebarUiState,
|
||||
isMobileViewport,
|
||||
parseSidebarCollapsedPreference,
|
||||
serializeSidebarCollapsedPreference,
|
||||
} from './sidebarState.js';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -20,6 +27,9 @@ const adminLinks = ref({
|
||||
pages: '/admin/pages',
|
||||
performance: '/admin/performance',
|
||||
});
|
||||
const sidebarCollapsed = ref(false);
|
||||
const sidebarMobileOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
|
||||
function toShellPath(targetRoute) {
|
||||
return normalizeRoutePath(targetRoute);
|
||||
@@ -46,6 +56,71 @@ const adminLoginHref = computed(() => {
|
||||
return `/admin/login?next=${encodeURIComponent('/portal-shell')}`;
|
||||
});
|
||||
|
||||
const sidebarUiState = computed(() =>
|
||||
buildSidebarUiState({
|
||||
isMobile: isMobile.value,
|
||||
sidebarCollapsed: sidebarCollapsed.value,
|
||||
sidebarMobileOpen: sidebarMobileOpen.value,
|
||||
}),
|
||||
);
|
||||
|
||||
const sidebarToggleLabel = computed(() => {
|
||||
if (isMobile.value) {
|
||||
return sidebarMobileOpen.value ? '關閉側邊欄' : '開啟側邊欄';
|
||||
}
|
||||
return sidebarCollapsed.value ? '展開側邊欄' : '收合側邊欄';
|
||||
});
|
||||
|
||||
function restoreSidebarPreference() {
|
||||
try {
|
||||
const stored = window.sessionStorage.getItem(SIDEBAR_STORAGE_KEY);
|
||||
sidebarCollapsed.value = parseSidebarCollapsedPreference(stored);
|
||||
} catch {
|
||||
sidebarCollapsed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSidebarPreference() {
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
SIDEBAR_STORAGE_KEY,
|
||||
serializeSidebarCollapsedPreference(sidebarCollapsed.value),
|
||||
);
|
||||
} catch {
|
||||
// Keep UI behavior deterministic even if storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function checkViewport() {
|
||||
isMobile.value = isMobileViewport(window.innerWidth);
|
||||
if (!isMobile.value) {
|
||||
sidebarMobileOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeMobileSidebar() {
|
||||
sidebarMobileOpen.value = false;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
if (isMobile.value) {
|
||||
sidebarMobileOpen.value = !sidebarMobileOpen.value;
|
||||
return;
|
||||
}
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
persistSidebarPreference();
|
||||
}
|
||||
|
||||
function handleViewportResize() {
|
||||
checkViewport();
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeMobileSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNavigation() {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
@@ -111,12 +186,22 @@ async function loadNavigation() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
restoreSidebarPreference();
|
||||
checkViewport();
|
||||
window.addEventListener('resize', handleViewportResize, { passive: true });
|
||||
window.addEventListener('keydown', handleGlobalKeydown);
|
||||
void loadNavigation();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleViewportResize);
|
||||
window.removeEventListener('keydown', handleGlobalKeydown);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
closeMobileSidebar();
|
||||
navigationNotice.value = consumeNavigationNotice();
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -124,9 +209,27 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<div class="shell" :class="sidebarUiState.shellClass">
|
||||
<header class="shell-header">
|
||||
<div>
|
||||
<div class="shell-header-left">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-toggle"
|
||||
:aria-expanded="sidebarUiState.ariaExpanded"
|
||||
:aria-label="sidebarToggleLabel"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1>MES 報表入口</h1>
|
||||
</div>
|
||||
<div class="shell-header-right">
|
||||
@@ -144,8 +247,12 @@ watch(
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Transition name="overlay-fade">
|
||||
<div v-if="isMobile && sidebarMobileOpen" class="sidebar-overlay" @click="closeMobileSidebar" />
|
||||
</Transition>
|
||||
|
||||
<main class="shell-main">
|
||||
<aside class="sidebar">
|
||||
<aside class="sidebar" :class="sidebarUiState.sidebarClass">
|
||||
<div v-if="loading" class="muted">載入導覽中...</div>
|
||||
<div v-else-if="errorMessage" class="error">{{ errorMessage }}</div>
|
||||
<template v-else>
|
||||
@@ -164,7 +271,7 @@ watch(
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<section class="content">
|
||||
<section class="shell-content">
|
||||
<div v-if="navigationNotice" class="notice-banner">{{ navigationNotice }}</div>
|
||||
<div class="breadcrumb">
|
||||
<span v-if="breadcrumb.drawerName">{{ breadcrumb.drawerName }}</span>
|
||||
|
||||
@@ -56,7 +56,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
|
||||
),
|
||||
'/query-tool': createNativeLoader(
|
||||
() => import('../query-tool/App.vue'),
|
||||
[() => import('../query-tool/style.css')],
|
||||
[() => import('../resource-shared/styles.css'), () => import('../query-tool/style.css')],
|
||||
),
|
||||
'/tmtt-defect': createNativeLoader(
|
||||
() => import('../tmtt-defect/App.vue'),
|
||||
|
||||
34
frontend/src/portal-shell/sidebarState.js
Normal file
34
frontend/src/portal-shell/sidebarState.js
Normal file
@@ -0,0 +1,34 @@
|
||||
export const SIDEBAR_STORAGE_KEY = 'portal-shell:sidebar-collapsed';
|
||||
export const MOBILE_BREAKPOINT = 900;
|
||||
|
||||
export function isMobileViewport(width, breakpoint = MOBILE_BREAKPOINT) {
|
||||
return Number(width) <= breakpoint;
|
||||
}
|
||||
|
||||
export function parseSidebarCollapsedPreference(value) {
|
||||
return String(value).trim() === 'true';
|
||||
}
|
||||
|
||||
export function serializeSidebarCollapsedPreference(collapsed) {
|
||||
return collapsed ? 'true' : 'false';
|
||||
}
|
||||
|
||||
export function buildSidebarUiState({ isMobile, sidebarCollapsed, sidebarMobileOpen }) {
|
||||
const mobile = Boolean(isMobile);
|
||||
const desktopCollapsed = !mobile && Boolean(sidebarCollapsed);
|
||||
const mobileOpen = mobile && Boolean(sidebarMobileOpen);
|
||||
const sidebarVisible = mobile ? mobileOpen : !desktopCollapsed;
|
||||
|
||||
return {
|
||||
shellClass: {
|
||||
'sidebar-collapsed': desktopCollapsed,
|
||||
},
|
||||
sidebarClass: {
|
||||
'sidebar--collapsed': desktopCollapsed,
|
||||
'sidebar--mobile-open': mobileOpen,
|
||||
'sidebar--mobile-closed': mobile && !mobileOpen,
|
||||
},
|
||||
sidebarVisible,
|
||||
ariaExpanded: sidebarVisible ? 'true' : 'false',
|
||||
};
|
||||
}
|
||||
@@ -15,9 +15,8 @@ body {
|
||||
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
@@ -27,8 +26,15 @@ body {
|
||||
gap: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
padding: 12px 20px;
|
||||
min-height: var(--shell-header-height, 56px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shell-header-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shell-header h1 {
|
||||
@@ -47,6 +53,39 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.sidebar-toggle:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.sidebar-toggle:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.admin-entry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -73,20 +112,77 @@ body {
|
||||
}
|
||||
|
||||
.shell-main {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
width: var(--portal-sidebar-width, 240px);
|
||||
min-width: var(--portal-sidebar-width, 240px);
|
||||
border-right: 1px solid #e5e7eb;
|
||||
padding: 10px;
|
||||
height: fit-content;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
width 0.3s ease,
|
||||
min-width 0.3s ease,
|
||||
padding 0.3s ease,
|
||||
transform 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar--collapsed {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border-right: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar--collapsed * {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.sidebar--mobile-open,
|
||||
.sidebar--mobile-closed {
|
||||
position: fixed;
|
||||
top: var(--shell-header-height, 56px);
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(280px, calc(100vw - 40px));
|
||||
min-width: min(280px, calc(100vw - 40px));
|
||||
z-index: 40;
|
||||
box-shadow: 8px 0 24px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.sidebar--mobile-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar--mobile-closed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 35;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
.overlay-fade-enter-active,
|
||||
.overlay-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.overlay-fade-enter-from,
|
||||
.overlay-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer + .drawer {
|
||||
@@ -123,12 +219,33 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
.shell-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background: #f5f7fa;
|
||||
padding: 16px;
|
||||
min-height: 70vh;
|
||||
}
|
||||
|
||||
.shell-content .resource-page,
|
||||
.shell-content .dashboard,
|
||||
.shell-content .qc-gate-page,
|
||||
.shell-content .job-query-page,
|
||||
.shell-content .excel-query-page,
|
||||
.shell-content .query-tool-page,
|
||||
.shell-content .tmtt-page,
|
||||
.shell-content .tables-page {
|
||||
max-width: none;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.shell-content .tables-page .container {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.shell-content .resource-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
@@ -282,7 +399,7 @@ body {
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.24);
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 14px;
|
||||
z-index: 30;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.health-popup h4 {
|
||||
@@ -363,25 +480,29 @@ body {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.drawer-link,
|
||||
.route-fade-enter-active,
|
||||
.route-fade-leave-active {
|
||||
.route-fade-leave-active,
|
||||
.sidebar,
|
||||
.sidebar--mobile-open,
|
||||
.sidebar--mobile-closed,
|
||||
.sidebar-overlay,
|
||||
.overlay-fade-enter-active,
|
||||
.overlay-fade-leave-active,
|
||||
.sidebar-toggle {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.dot.loading {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shell-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shell-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.shell-header-right {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.health-trigger {
|
||||
@@ -389,7 +510,7 @@ body {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
.shell-content {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
|
||||
import MultiSelect from '../resource-shared/components/MultiSelect.vue';
|
||||
import FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
|
||||
import SectionCard from '../shared-ui/components/SectionCard.vue';
|
||||
import { useQueryToolData } from './composables/useQueryToolData.js';
|
||||
@@ -25,6 +26,20 @@ const {
|
||||
exportCurrentCsv,
|
||||
} = useQueryToolData();
|
||||
|
||||
const equipmentOptions = computed(() =>
|
||||
equipment.options.map((item) => ({
|
||||
value: item.RESOURCEID,
|
||||
label: item.RESOURCENAME || item.RESOURCEID,
|
||||
})),
|
||||
);
|
||||
|
||||
const workcenterGroupOptions = computed(() =>
|
||||
batch.workcenterGroups.map((group) => ({
|
||||
value: group.name || group,
|
||||
label: group.name || group,
|
||||
})),
|
||||
);
|
||||
|
||||
function formatCell(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '-';
|
||||
@@ -65,11 +80,14 @@ onMounted(async () => {
|
||||
</label>
|
||||
<label class="query-tool-filter">
|
||||
<span>站點群組</span>
|
||||
<select v-model="batch.selectedWorkcenterGroups" multiple size="3">
|
||||
<option v-for="group in batch.workcenterGroups" :key="group.name || group" :value="group.name || group">
|
||||
{{ group.name || group }}
|
||||
</option>
|
||||
</select>
|
||||
<MultiSelect
|
||||
:model-value="batch.selectedWorkcenterGroups"
|
||||
:options="workcenterGroupOptions"
|
||||
:disabled="loading.bootstrapping"
|
||||
placeholder="全部群組"
|
||||
searchable
|
||||
@update:model-value="batch.selectedWorkcenterGroups = $event"
|
||||
/>
|
||||
</label>
|
||||
<template #actions>
|
||||
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.resolving" @click="resolveLots">
|
||||
@@ -178,11 +196,14 @@ onMounted(async () => {
|
||||
<FilterToolbar>
|
||||
<label class="query-tool-filter">
|
||||
<span>設備(複選)</span>
|
||||
<select v-model="equipment.selectedEquipmentIds" multiple size="4">
|
||||
<option v-for="item in equipment.options" :key="item.RESOURCEID" :value="item.RESOURCEID">
|
||||
{{ item.RESOURCENAME || item.RESOURCEID }}
|
||||
</option>
|
||||
</select>
|
||||
<MultiSelect
|
||||
:model-value="equipment.selectedEquipmentIds"
|
||||
:options="equipmentOptions"
|
||||
:disabled="loading.bootstrapping || loading.equipment"
|
||||
placeholder="全部設備"
|
||||
searchable
|
||||
@update:model-value="equipment.selectedEquipmentIds = $event"
|
||||
/>
|
||||
</label>
|
||||
<label class="query-tool-filter">
|
||||
<span>查詢類型</span>
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--portal-shell-max-width: 1600px;
|
||||
--portal-shell-max-width: none;
|
||||
--portal-shell-gap: 12px;
|
||||
--portal-shell-bg: #f5f7fa;
|
||||
--portal-sidebar-width: 240px;
|
||||
--shell-header-height: 56px;
|
||||
--portal-panel-bg: #ffffff;
|
||||
--portal-text-primary: #1f2937;
|
||||
--portal-text-secondary: #64748b;
|
||||
@@ -61,7 +63,7 @@
|
||||
}
|
||||
|
||||
.u-content-shell {
|
||||
max-width: var(--portal-shell-max-width);
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
92
frontend/tests/portal-shell-sidebar.test.js
Normal file
92
frontend/tests/portal-shell-sidebar.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
SIDEBAR_STORAGE_KEY,
|
||||
buildSidebarUiState,
|
||||
parseSidebarCollapsedPreference,
|
||||
serializeSidebarCollapsedPreference,
|
||||
} from '../src/portal-shell/sidebarState.js';
|
||||
|
||||
function readSource(relativePath) {
|
||||
return readFileSync(resolve(process.cwd(), relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('buildSidebarUiState marks desktop collapse correctly', () => {
|
||||
const expanded = buildSidebarUiState({
|
||||
isMobile: false,
|
||||
sidebarCollapsed: false,
|
||||
sidebarMobileOpen: false,
|
||||
});
|
||||
assert.equal(expanded.sidebarClass['sidebar--collapsed'], false);
|
||||
assert.equal(expanded.ariaExpanded, 'true');
|
||||
|
||||
const collapsed = buildSidebarUiState({
|
||||
isMobile: false,
|
||||
sidebarCollapsed: true,
|
||||
sidebarMobileOpen: false,
|
||||
});
|
||||
assert.equal(collapsed.sidebarClass['sidebar--collapsed'], true);
|
||||
assert.equal(collapsed.sidebarClass['sidebar--mobile-open'], false);
|
||||
assert.equal(collapsed.ariaExpanded, 'false');
|
||||
});
|
||||
|
||||
test('buildSidebarUiState marks mobile overlay states correctly', () => {
|
||||
const closed = buildSidebarUiState({
|
||||
isMobile: true,
|
||||
sidebarCollapsed: true,
|
||||
sidebarMobileOpen: false,
|
||||
});
|
||||
assert.equal(closed.sidebarClass['sidebar--mobile-closed'], true);
|
||||
assert.equal(closed.sidebarClass['sidebar--collapsed'], false);
|
||||
assert.equal(closed.ariaExpanded, 'false');
|
||||
|
||||
const open = buildSidebarUiState({
|
||||
isMobile: true,
|
||||
sidebarCollapsed: true,
|
||||
sidebarMobileOpen: true,
|
||||
});
|
||||
assert.equal(open.sidebarClass['sidebar--mobile-open'], true);
|
||||
assert.equal(open.sidebarClass['sidebar--mobile-closed'], false);
|
||||
assert.equal(open.ariaExpanded, 'true');
|
||||
});
|
||||
|
||||
test('sidebar collapsed preference serializes and parses as expected', () => {
|
||||
assert.equal(serializeSidebarCollapsedPreference(true), 'true');
|
||||
assert.equal(serializeSidebarCollapsedPreference(false), 'false');
|
||||
assert.equal(parseSidebarCollapsedPreference('true'), true);
|
||||
assert.equal(parseSidebarCollapsedPreference('false'), false);
|
||||
assert.equal(parseSidebarCollapsedPreference(null), false);
|
||||
});
|
||||
|
||||
test('portal shell template uses shell-content and overlay close wiring', () => {
|
||||
const source = readSource('src/portal-shell/App.vue');
|
||||
|
||||
assert.match(source, /<section class="shell-content">/);
|
||||
assert.doesNotMatch(source, /<section class="content">/);
|
||||
assert.match(source, /class="sidebar-overlay"/);
|
||||
assert.match(source, /@click="closeMobileSidebar"/);
|
||||
assert.match(source, /event\.key === 'Escape'/);
|
||||
assert.match(source, /SIDEBAR_STORAGE_KEY/);
|
||||
});
|
||||
|
||||
test('toggleSidebar keeps desktop collapse and persistence wiring', () => {
|
||||
const source = readSource('src/portal-shell/App.vue');
|
||||
|
||||
assert.match(source, /function toggleSidebar\(\)/);
|
||||
assert.match(source, /sidebarCollapsed\.value = !sidebarCollapsed\.value/);
|
||||
assert.match(source, /persistSidebarPreference\(\)/);
|
||||
});
|
||||
|
||||
test('sessionStorage preference restore wiring exists', () => {
|
||||
const source = readSource('src/portal-shell/App.vue');
|
||||
|
||||
assert.match(source, /function restoreSidebarPreference\(\)/);
|
||||
assert.match(source, /window\.sessionStorage\.getItem\(SIDEBAR_STORAGE_KEY\)/);
|
||||
});
|
||||
|
||||
test('sidebar sessionStorage key remains stable', () => {
|
||||
assert.equal(SIDEBAR_STORAGE_KEY, 'portal-shell:sidebar-collapsed');
|
||||
});
|
||||
Reference in New Issue
Block a user