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>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import HealthStatus from './components/HealthStatus.vue';
|
import HealthStatus from './components/HealthStatus.vue';
|
||||||
import { consumeNavigationNotice, syncNavigationRoutes } from './router.js';
|
import { consumeNavigationNotice, syncNavigationRoutes } from './router.js';
|
||||||
import { normalizeRoutePath } from './routeContracts.js';
|
import { normalizeRoutePath } from './routeContracts.js';
|
||||||
|
import {
|
||||||
|
SIDEBAR_STORAGE_KEY,
|
||||||
|
buildSidebarUiState,
|
||||||
|
isMobileViewport,
|
||||||
|
parseSidebarCollapsedPreference,
|
||||||
|
serializeSidebarCollapsedPreference,
|
||||||
|
} from './sidebarState.js';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -20,6 +27,9 @@ const adminLinks = ref({
|
|||||||
pages: '/admin/pages',
|
pages: '/admin/pages',
|
||||||
performance: '/admin/performance',
|
performance: '/admin/performance',
|
||||||
});
|
});
|
||||||
|
const sidebarCollapsed = ref(false);
|
||||||
|
const sidebarMobileOpen = ref(false);
|
||||||
|
const isMobile = ref(false);
|
||||||
|
|
||||||
function toShellPath(targetRoute) {
|
function toShellPath(targetRoute) {
|
||||||
return normalizeRoutePath(targetRoute);
|
return normalizeRoutePath(targetRoute);
|
||||||
@@ -46,6 +56,71 @@ const adminLoginHref = computed(() => {
|
|||||||
return `/admin/login?next=${encodeURIComponent('/portal-shell')}`;
|
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() {
|
async function loadNavigation() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
@@ -111,12 +186,22 @@ async function loadNavigation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
restoreSidebarPreference();
|
||||||
|
checkViewport();
|
||||||
|
window.addEventListener('resize', handleViewportResize, { passive: true });
|
||||||
|
window.addEventListener('keydown', handleGlobalKeydown);
|
||||||
void loadNavigation();
|
void loadNavigation();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleViewportResize);
|
||||||
|
window.removeEventListener('keydown', handleGlobalKeydown);
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.fullPath,
|
||||||
() => {
|
() => {
|
||||||
|
closeMobileSidebar();
|
||||||
navigationNotice.value = consumeNavigationNotice();
|
navigationNotice.value = consumeNavigationNotice();
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -124,9 +209,27 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="shell">
|
<div class="shell" :class="sidebarUiState.shellClass">
|
||||||
<header class="shell-header">
|
<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>
|
<h1>MES 報表入口</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="shell-header-right">
|
<div class="shell-header-right">
|
||||||
@@ -144,8 +247,12 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<Transition name="overlay-fade">
|
||||||
|
<div v-if="isMobile && sidebarMobileOpen" class="sidebar-overlay" @click="closeMobileSidebar" />
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<main class="shell-main">
|
<main class="shell-main">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar" :class="sidebarUiState.sidebarClass">
|
||||||
<div v-if="loading" class="muted">載入導覽中...</div>
|
<div v-if="loading" class="muted">載入導覽中...</div>
|
||||||
<div v-else-if="errorMessage" class="error">{{ errorMessage }}</div>
|
<div v-else-if="errorMessage" class="error">{{ errorMessage }}</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -164,7 +271,7 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="content">
|
<section class="shell-content">
|
||||||
<div v-if="navigationNotice" class="notice-banner">{{ navigationNotice }}</div>
|
<div v-if="navigationNotice" class="notice-banner">{{ navigationNotice }}</div>
|
||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<span v-if="breadcrumb.drawerName">{{ breadcrumb.drawerName }}</span>
|
<span v-if="breadcrumb.drawerName">{{ breadcrumb.drawerName }}</span>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
|
|||||||
),
|
),
|
||||||
'/query-tool': createNativeLoader(
|
'/query-tool': createNativeLoader(
|
||||||
() => import('../query-tool/App.vue'),
|
() => import('../query-tool/App.vue'),
|
||||||
[() => import('../query-tool/style.css')],
|
[() => import('../resource-shared/styles.css'), () => import('../query-tool/style.css')],
|
||||||
),
|
),
|
||||||
'/tmtt-defect': createNativeLoader(
|
'/tmtt-defect': createNativeLoader(
|
||||||
() => import('../tmtt-defect/App.vue'),
|
() => 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 {
|
.shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 20px;
|
display: flex;
|
||||||
max-width: 1600px;
|
flex-direction: column;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell-header {
|
.shell-header {
|
||||||
@@ -27,8 +26,15 @@ body {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 12px;
|
padding: 12px 20px;
|
||||||
padding: 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 {
|
.shell-header h1 {
|
||||||
@@ -47,6 +53,39 @@ body {
|
|||||||
gap: 12px;
|
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 {
|
.admin-entry {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -73,20 +112,77 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shell-main {
|
.shell-main {
|
||||||
margin-top: 14px;
|
flex: 1;
|
||||||
display: grid;
|
min-height: 0;
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
display: flex;
|
||||||
gap: 12px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid #e5e7eb;
|
width: var(--portal-sidebar-width, 240px);
|
||||||
border-radius: 10px;
|
min-width: var(--portal-sidebar-width, 240px);
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
height: fit-content;
|
overflow-y: auto;
|
||||||
position: sticky;
|
overflow-x: hidden;
|
||||||
top: 20px;
|
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 {
|
.drawer + .drawer {
|
||||||
@@ -123,12 +219,33 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.shell-content {
|
||||||
background: #fff;
|
flex: 1;
|
||||||
border: 1px solid #e5e7eb;
|
min-width: 0;
|
||||||
border-radius: 10px;
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #f5f7fa;
|
||||||
padding: 16px;
|
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 {
|
.breadcrumb {
|
||||||
@@ -282,7 +399,7 @@ body {
|
|||||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.24);
|
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.24);
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
z-index: 30;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-popup h4 {
|
.health-popup h4 {
|
||||||
@@ -363,25 +480,29 @@ body {
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.drawer-link,
|
.drawer-link,
|
||||||
.route-fade-enter-active,
|
.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;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dot.loading {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.shell-main {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell-header {
|
.shell-header {
|
||||||
flex-direction: column;
|
padding: 12px 16px;
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell-header-right {
|
.shell-header-right {
|
||||||
width: 100%;
|
margin-left: auto;
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-trigger {
|
.health-trigger {
|
||||||
@@ -389,7 +510,7 @@ body {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.shell-content {
|
||||||
position: static;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<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 FilterToolbar from '../shared-ui/components/FilterToolbar.vue';
|
||||||
import SectionCard from '../shared-ui/components/SectionCard.vue';
|
import SectionCard from '../shared-ui/components/SectionCard.vue';
|
||||||
import { useQueryToolData } from './composables/useQueryToolData.js';
|
import { useQueryToolData } from './composables/useQueryToolData.js';
|
||||||
@@ -25,6 +26,20 @@ const {
|
|||||||
exportCurrentCsv,
|
exportCurrentCsv,
|
||||||
} = useQueryToolData();
|
} = 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) {
|
function formatCell(value) {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return '-';
|
return '-';
|
||||||
@@ -65,11 +80,14 @@ onMounted(async () => {
|
|||||||
</label>
|
</label>
|
||||||
<label class="query-tool-filter">
|
<label class="query-tool-filter">
|
||||||
<span>站點群組</span>
|
<span>站點群組</span>
|
||||||
<select v-model="batch.selectedWorkcenterGroups" multiple size="3">
|
<MultiSelect
|
||||||
<option v-for="group in batch.workcenterGroups" :key="group.name || group" :value="group.name || group">
|
:model-value="batch.selectedWorkcenterGroups"
|
||||||
{{ group.name || group }}
|
:options="workcenterGroupOptions"
|
||||||
</option>
|
:disabled="loading.bootstrapping"
|
||||||
</select>
|
placeholder="全部群組"
|
||||||
|
searchable
|
||||||
|
@update:model-value="batch.selectedWorkcenterGroups = $event"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.resolving" @click="resolveLots">
|
<button type="button" class="query-tool-btn query-tool-btn-primary" :disabled="loading.resolving" @click="resolveLots">
|
||||||
@@ -178,11 +196,14 @@ onMounted(async () => {
|
|||||||
<FilterToolbar>
|
<FilterToolbar>
|
||||||
<label class="query-tool-filter">
|
<label class="query-tool-filter">
|
||||||
<span>設備(複選)</span>
|
<span>設備(複選)</span>
|
||||||
<select v-model="equipment.selectedEquipmentIds" multiple size="4">
|
<MultiSelect
|
||||||
<option v-for="item in equipment.options" :key="item.RESOURCEID" :value="item.RESOURCEID">
|
:model-value="equipment.selectedEquipmentIds"
|
||||||
{{ item.RESOURCENAME || item.RESOURCEID }}
|
:options="equipmentOptions"
|
||||||
</option>
|
:disabled="loading.bootstrapping || loading.equipment"
|
||||||
</select>
|
placeholder="全部設備"
|
||||||
|
searchable
|
||||||
|
@update:model-value="equipment.selectedEquipmentIds = $event"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="query-tool-filter">
|
<label class="query-tool-filter">
|
||||||
<span>查詢類型</span>
|
<span>查詢類型</span>
|
||||||
|
|||||||
@@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--portal-shell-max-width: 1600px;
|
--portal-shell-max-width: none;
|
||||||
--portal-shell-gap: 12px;
|
--portal-shell-gap: 12px;
|
||||||
--portal-shell-bg: #f5f7fa;
|
--portal-shell-bg: #f5f7fa;
|
||||||
|
--portal-sidebar-width: 240px;
|
||||||
|
--shell-header-height: 56px;
|
||||||
--portal-panel-bg: #ffffff;
|
--portal-panel-bg: #ffffff;
|
||||||
--portal-text-primary: #1f2937;
|
--portal-text-primary: #1f2937;
|
||||||
--portal-text-secondary: #64748b;
|
--portal-text-secondary: #64748b;
|
||||||
@@ -61,7 +63,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.u-content-shell {
|
.u-content-shell {
|
||||||
max-width: var(--portal-shell-max-width);
|
width: 100%;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: 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');
|
||||||
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-02-11
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The portal shell (`frontend/src/portal-shell/App.vue` + `frontend/src/portal-shell/style.css`) currently uses a block-centered layout: `.shell` has `max-width: 1600px; margin: 0 auto; padding: 20px`, `.shell-main` uses `display: grid; grid-template-columns: 220px minmax(0,1fr)`, and both `.sidebar` and `.content` are styled as bordered, rounded white cards. Page modules add their own `max-width` (1680-1900px) and `padding`.
|
||||||
|
|
||||||
|
The result is a segmented, boxed appearance that wastes screen real estate. The migration to full-screen fluid layout with a collapsible drawer addresses this.
|
||||||
|
|
||||||
|
**Constraints:**
|
||||||
|
- No new dependencies (pure Vue 3 + Tailwind CSS 3 + vanilla CSS)
|
||||||
|
- Sidebar navigation items are text-only (no icons) — icon-only collapsed mode is not viable
|
||||||
|
- Pages must still render correctly standalone (outside the portal shell) for development
|
||||||
|
- Existing color scheme and navigation hierarchy must be preserved
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Full-viewport fluid layout with no max-width constraints at shell or page level
|
||||||
|
- Collapsible sidebar: push-mode on desktop (content resizes), overlay-mode on mobile
|
||||||
|
- Smooth 300ms transitions for sidebar open/close
|
||||||
|
- Sidebar state persistence within browser session
|
||||||
|
- Keyboard accessibility (Escape closes mobile drawer)
|
||||||
|
- Maintain all existing navigation, routing, and page functionality
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Icon-only collapsed sidebar mode (nav items have no icons; complete hide is chosen)
|
||||||
|
- Redesigning individual page components or card layouts within pages (deferred to Phase 2)
|
||||||
|
- Adding new dependencies (headlessui, shadcn, etc.)
|
||||||
|
- Server-side changes or API modifications
|
||||||
|
- Full ARIA focus-trap for mobile overlay (simple Escape + backdrop click suffices)
|
||||||
|
- Verifying pages not registered in shell route contracts (e.g. routes with missing contract warnings)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D1: Flexbox over CSS Grid for `.shell-main`
|
||||||
|
|
||||||
|
**Choice**: Replace `display: grid; grid-template-columns: 220px minmax(0,1fr)` with `display: flex`.
|
||||||
|
|
||||||
|
**Rationale**: CSS Grid `grid-template-columns` cannot be smoothly animated with CSS transitions. Flexbox with `width` + `min-width` transitions on the sidebar provides smooth animated collapse/expand. The layout is a simple two-column split, which flexbox handles naturally.
|
||||||
|
|
||||||
|
### D2: Sidebar collapses to 0 width (complete hide)
|
||||||
|
|
||||||
|
**Choice**: Desktop collapsed state sets sidebar `width: 0; min-width: 0; overflow: hidden`.
|
||||||
|
|
||||||
|
**Rationale**: The current navigation items are text-only with no icons. An icon-only strip would require adding icons to every nav item — a separate design effort. Complete hide is the simplest approach that provides maximum content space.
|
||||||
|
|
||||||
|
### D3: Mobile overlay vs desktop push
|
||||||
|
|
||||||
|
**Choice**: Desktop uses push mode (content resizes via flex). Mobile (<=900px) uses fixed-position overlay with backdrop.
|
||||||
|
|
||||||
|
**Rationale**: On desktop, push mode provides a stable layout without content obscuring. On mobile, the viewport is too narrow for push mode — overlay maximizes both sidebar and content usability. The 900px breakpoint matches the existing responsive threshold.
|
||||||
|
|
||||||
|
### D4: JavaScript viewport detection instead of pure CSS media queries
|
||||||
|
|
||||||
|
**Choice**: Use a `resize` event listener to set `isMobile` ref, then apply CSS classes based on state.
|
||||||
|
|
||||||
|
**Rationale**: The sidebar has three distinct behaviors: desktop-expanded, desktop-collapsed, and mobile-overlay. Pure CSS media queries cannot differentiate between "desktop-collapsed" and "mobile-hidden" since both have zero sidebar width. JavaScript state allows clean separation of desktop collapse (user choice) and mobile overlay (viewport-driven).
|
||||||
|
|
||||||
|
### D5: `sessionStorage` for sidebar preference
|
||||||
|
|
||||||
|
**Choice**: Persist collapsed/expanded state in `sessionStorage` (not `localStorage`).
|
||||||
|
|
||||||
|
**Rationale**: Session-scoped persistence means the user's choice persists across page navigations and refreshes within the same tab, but new tabs start with sidebar expanded. This matches the ephemeral nature of a UI layout preference. The codebase already uses `sessionStorage` for recovery keys in `NativeRouteView.vue`.
|
||||||
|
|
||||||
|
### D6: Page-level max-width override via scoped selectors
|
||||||
|
|
||||||
|
**Choice**: Add `.shell-content .xxx-page { max-width: none; }` rules in `frontend/src/portal-shell/style.css` rather than modifying each page's CSS. Include `.shell-content .tables-page .container` for the tables module where `max-width` is on the inner `.container` element, not the page wrapper.
|
||||||
|
|
||||||
|
**Rationale**: This keeps all shell-level layout overrides in one file, preserves standalone page rendering (pages still have their own max-width when accessed directly), and avoids touching 10+ page CSS files.
|
||||||
|
|
||||||
|
### D7: Content area background color
|
||||||
|
|
||||||
|
**Choice**: Change `.shell-content` background from `#ffffff` to `#f5f7fa` (app background color).
|
||||||
|
|
||||||
|
**Rationale**: With borders and border-radius removed, the content area merges visually with the page background. Using the app bg color instead of white allows individual page cards (`.section-card`, `.header-gradient`) to stand out on their own. This maintains the card-on-background visual hierarchy.
|
||||||
|
|
||||||
|
### D8: Rename `.content` to `.shell-content` to prevent CSS collision
|
||||||
|
|
||||||
|
**Choice**: Rename the shell's main content area class from `.content` to `.shell-content` in both the template and CSS.
|
||||||
|
|
||||||
|
**Rationale**: The tables module (`tables/App.vue:81`) and potentially other page modules also use a `.content` class. If the shell rewrites `.content` with `flex: 1; overflow-y: auto`, it will leak into page-level `.content` elements and cause layout breakage. Using `.shell-content` scopes the styles unambiguously to the shell layer. This is a low-cost rename (one template attribute + CSS find-and-replace in a single file) with high defensive value.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **[Text wrapping during animation]** → Sidebar text may wrap awkwardly during 300ms width transition. Mitigate with `white-space: nowrap; overflow: hidden` on the sidebar.
|
||||||
|
- **[Double scrollbar]** → Content area gets `overflow-y: auto`, but some pages have `min-height: 100vh`. Mitigate with `.shell-content .resource-page { min-height: auto }` overrides.
|
||||||
|
- **[Residual gutter after collapse]** → If legacy `.shell-main` `gap: 12px` is left in place during flex migration, collapsed sidebar still leaves visible empty space. Mitigate by removing `gap` from `.shell-main`.
|
||||||
|
- **[Health popup z-index clash]** → `.health-popup` uses `z-index: 30` which is below mobile sidebar overlay `z-index: 40`. Mitigate by bumping health popup to `z-index: 50`.
|
||||||
|
- **[Wide content readability]** → Removing all max-width means tables and cards stretch on ultra-wide monitors. Accepted trade-off per user preference. Individual page teams can re-add max-width later if needed.
|
||||||
|
- **[sessionStorage loss]** → If user clears session data, sidebar preference resets. Acceptable — sidebar defaults to expanded which is a safe fallback.
|
||||||
|
- **[CSS class collision]** → Shell `.content` class collides with page-level `.content` (e.g. tables module). Mitigate by renaming to `.shell-content` (Decision D8).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The portal shell just completed an iframe-to-Vue migration but still uses a block/card-based layout with `max-width: 1600px` centered shell, fixed `220px` sidebar, and rounded-corner card containers. This creates a segmented "boxed" appearance with wasted screen real estate and occasional card overflow issues. Converting to a full-screen fluid layout with a collapsible sidebar drawer maximizes usable space and provides a modern dashboard experience.
|
||||||
|
|
||||||
|
> **Scope**: This change is **Portal Shell Modernize Phase 1** — it targets the shell layout layer only (header, sidebar, content wrapper). Page-level component redesign (cards, tables, charts within individual pages) is out of scope and should be addressed in a separate Phase 2 change.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **BREAKING**: Remove `max-width` constraints from the portal shell (`.shell` 1600px) and all page-level wrappers (`.dashboard` 1800px, `.qc-gate-page` 1900px, `.job-query-page` 1680px, etc.) — content fills available viewport width
|
||||||
|
- Remove border-radius and border from the shell header, sidebar, and content area — edge-to-edge fluid appearance
|
||||||
|
- Convert `.shell-main` from CSS Grid (`220px | 1fr`) to Flexbox with animated sidebar width transitions
|
||||||
|
- Add collapsible sidebar: desktop push-mode (width animates from 240px to 0), mobile overlay-mode (slide-in drawer with backdrop)
|
||||||
|
- Add hamburger toggle button in the shell header
|
||||||
|
- Add sidebar state persistence via `sessionStorage`
|
||||||
|
- Add mobile overlay backdrop with fade transition
|
||||||
|
- Add keyboard accessibility (Escape to close mobile drawer)
|
||||||
|
- Maintain existing gradient color scheme, navigation hierarchy, card-level styling within pages
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `collapsible-sidebar-drawer`: Sidebar collapse/expand behavior, toggle button, mobile overlay, state persistence, keyboard accessibility, smooth transitions
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- `spa-shell-navigation`: Shell layout changes from block-centered grid to full-screen fluid flexbox; sidebar becomes collapsible; content area becomes scrollable flex child
|
||||||
|
- `tailwind-design-system`: CSS variables updated (`--portal-shell-max-width` removed), `.u-content-shell` utility changed from max-width to full-width
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Portal shell** (`frontend/src/portal-shell/App.vue`, `frontend/src/portal-shell/style.css`): Major layout restructure — template adds toggle button, overlay, class bindings; CSS rewrites `.shell`, `.shell-main`, `.sidebar`; `.content` renamed to `.shell-content` to avoid class collision with page-level `.content` (e.g. in tables module)
|
||||||
|
- **Global styles** (`frontend/src/styles/tailwind.css`): CSS variable updates, utility class changes
|
||||||
|
- **Shell-registered page modules only**: Page-level max-width and padding overridden when embedded in shell via `.shell-content .xxx-page` selectors (standalone rendering unaffected). Verification scope limited to pages registered in shell route contracts — unregistered routes (e.g. missing contract warnings) are excluded
|
||||||
|
- **Health popup**: z-index adjustment needed to stay above new sidebar overlay layer
|
||||||
|
- **No new dependencies**: Pure Vue + Tailwind + CSS, no additional libraries
|
||||||
|
- **New test file**: `frontend/tests/portal-shell-sidebar.test.js` — automated tests (Node `node:test` harness) for sidebar collapse/expand, mobile overlay, and sessionStorage persistence
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Sidebar SHALL be collapsible via a toggle button
|
||||||
|
The portal shell SHALL provide a toggle button in the header that collapses and expands the sidebar. On desktop viewports (>900px), collapsing SHALL animate the sidebar width from 240px to 0px using push mode, causing the content area to resize and fill the freed space. Expanding SHALL reverse the animation.
|
||||||
|
|
||||||
|
#### Scenario: Desktop sidebar collapse
|
||||||
|
- **WHEN** a user clicks the sidebar toggle button on a desktop viewport
|
||||||
|
- **AND** the sidebar is currently expanded
|
||||||
|
- **THEN** the sidebar width SHALL animate to 0px over 300ms
|
||||||
|
- **THEN** the content area SHALL expand to fill the full viewport width
|
||||||
|
|
||||||
|
#### Scenario: Desktop sidebar expand
|
||||||
|
- **WHEN** a user clicks the sidebar toggle button on a desktop viewport
|
||||||
|
- **AND** the sidebar is currently collapsed
|
||||||
|
- **THEN** the sidebar width SHALL animate to 240px over 300ms
|
||||||
|
- **THEN** the content area SHALL shrink to accommodate the sidebar
|
||||||
|
|
||||||
|
### Requirement: Mobile sidebar SHALL use overlay drawer mode
|
||||||
|
On mobile viewports (<=900px), the sidebar SHALL behave as a fixed-position overlay drawer that slides in from the left, with a semi-transparent backdrop covering the content area.
|
||||||
|
|
||||||
|
#### Scenario: Mobile sidebar open
|
||||||
|
- **WHEN** a user taps the toggle button on a mobile viewport
|
||||||
|
- **AND** the sidebar is currently hidden
|
||||||
|
- **THEN** the sidebar SHALL slide in from the left as a fixed overlay (280px width)
|
||||||
|
- **THEN** a semi-transparent backdrop SHALL appear behind the sidebar and above the content
|
||||||
|
|
||||||
|
#### Scenario: Mobile sidebar close via backdrop
|
||||||
|
- **WHEN** a user taps the backdrop overlay
|
||||||
|
- **THEN** the sidebar SHALL slide out to the left
|
||||||
|
- **THEN** the backdrop SHALL fade out
|
||||||
|
|
||||||
|
#### Scenario: Mobile sidebar close via Escape key
|
||||||
|
- **WHEN** the mobile sidebar overlay is open
|
||||||
|
- **AND** a user presses the Escape key
|
||||||
|
- **THEN** the sidebar SHALL close
|
||||||
|
|
||||||
|
#### Scenario: Mobile sidebar closes on navigation
|
||||||
|
- **WHEN** the mobile sidebar overlay is open
|
||||||
|
- **AND** a user clicks a navigation link
|
||||||
|
- **THEN** the sidebar SHALL automatically close after the route change
|
||||||
|
|
||||||
|
### Requirement: Sidebar state SHALL persist within the browser session
|
||||||
|
The collapsed/expanded state of the desktop sidebar SHALL be persisted to `sessionStorage` so that the preference survives page refreshes within the same browser tab.
|
||||||
|
|
||||||
|
#### Scenario: State persistence on refresh
|
||||||
|
- **WHEN** a user collapses the sidebar and refreshes the page
|
||||||
|
- **THEN** the sidebar SHALL remain collapsed after reload
|
||||||
|
|
||||||
|
#### Scenario: New tab starts expanded
|
||||||
|
- **WHEN** a user opens the portal in a new browser tab
|
||||||
|
- **THEN** the sidebar SHALL default to expanded regardless of other tabs' state
|
||||||
|
|
||||||
|
### Requirement: Sidebar transitions SHALL respect reduced-motion preference
|
||||||
|
All sidebar transitions (width animation, overlay slide, backdrop fade) SHALL be disabled when the user has `prefers-reduced-motion: reduce` enabled.
|
||||||
|
|
||||||
|
#### Scenario: Reduced motion disables sidebar animation
|
||||||
|
- **WHEN** the user's OS or browser has reduced-motion enabled
|
||||||
|
- **AND** the user toggles the sidebar
|
||||||
|
- **THEN** the sidebar state SHALL change instantly without animation
|
||||||
|
|
||||||
|
### Requirement: Toggle button SHALL be accessible
|
||||||
|
The sidebar toggle button SHALL include `aria-label` and `aria-expanded` attributes for screen reader accessibility.
|
||||||
|
|
||||||
|
#### Scenario: Screen reader announces toggle state
|
||||||
|
- **WHEN** a screen reader user focuses the toggle button
|
||||||
|
- **THEN** the button SHALL announce its current expanded/collapsed state via `aria-expanded`
|
||||||
|
- **THEN** the button SHALL have an `aria-label` describing its purpose
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Portal SHALL provide a SPA shell driven by Vue Router
|
||||||
|
The portal frontend SHALL use a single SPA shell entry and Vue Router to render page modules without iframe embedding, and SHALL route each page through native route-view integration. The shell layout SHALL use a full-viewport fluid layout with flexbox, removing all max-width constraints and block-centered styling. The main content area (`.shell-content`) SHALL fill available space as a flex child, and the sidebar SHALL be a collapsible flex child that pushes content when expanded on desktop. The content area class SHALL be `.shell-content` (not `.content`) to avoid CSS collision with page-level `.content` classes.
|
||||||
|
|
||||||
|
#### Scenario: Drawer navigation renders integrated route view
|
||||||
|
- **WHEN** a user clicks a sidebar page entry whose migration mode is `native`
|
||||||
|
- **THEN** the active route SHALL be updated through Vue Router
|
||||||
|
- **THEN** the main content area SHALL render the corresponding page module inside shell route-view without iframe usage
|
||||||
|
- **THEN** the content area SHALL fill the available viewport width minus the sidebar width (if sidebar is expanded)
|
||||||
|
|
||||||
|
#### Scenario: Shell layout fills full viewport
|
||||||
|
- **WHEN** the portal shell renders
|
||||||
|
- **THEN** the shell SHALL span the full viewport width with no max-width constraint
|
||||||
|
- **THEN** the header SHALL span edge-to-edge with no border-radius
|
||||||
|
- **THEN** the sidebar and content area SHALL have no outer borders or border-radius
|
||||||
|
|
||||||
|
#### Scenario: Page-level max-width constraints are removed when embedded
|
||||||
|
- **WHEN** a page module registered in the shell route contracts renders inside `.shell-content`
|
||||||
|
- **THEN** page-level max-width constraints SHALL be overridden to allow full-width rendering
|
||||||
|
- **THEN** page-level duplicate padding SHALL be removed to avoid double spacing
|
||||||
|
- **THEN** standalone page rendering (outside the shell) SHALL remain unaffected
|
||||||
|
|
||||||
|
#### Scenario: Shell content class avoids collision with page-level classes
|
||||||
|
- **WHEN** a page module that uses its own `.content` class renders inside the shell
|
||||||
|
- **THEN** the shell's content wrapper (`.shell-content`) SHALL NOT interfere with the page's `.content` styling
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Frontend styles SHALL be governed by Tailwind design tokens
|
||||||
|
The frontend SHALL define a Tailwind-based design token system for color, spacing, typography, radius, and elevation to ensure consistent styling across modules. The `--portal-shell-max-width` CSS variable SHALL be set to `none` to support the fluid layout. A `--portal-sidebar-width` variable SHALL be added for sidebar width reference. The `.u-content-shell` utility class SHALL use `width: 100%` instead of `max-width` constraint.
|
||||||
|
|
||||||
|
#### Scenario: Shared token usage across modules
|
||||||
|
- **WHEN** two report modules render equivalent UI elements (e.g., card, filter chip, primary button)
|
||||||
|
- **THEN** they SHALL use the same token-backed style semantics
|
||||||
|
- **THEN** visual output SHALL remain consistent across modules
|
||||||
|
|
||||||
|
#### Scenario: Fluid layout tokens
|
||||||
|
- **WHEN** the portal shell renders
|
||||||
|
- **THEN** `--portal-shell-max-width` SHALL resolve to `none`
|
||||||
|
- **THEN** `--portal-sidebar-width` SHALL resolve to `240px`
|
||||||
|
- **THEN** `.u-content-shell` SHALL apply `width: 100%` without max-width constraint
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
## 1. Sidebar State Management (App.vue script)
|
||||||
|
|
||||||
|
- [x] 1.1 Add `sidebarCollapsed`, `sidebarMobileOpen`, `isMobile` refs to `App.vue` `<script setup>`
|
||||||
|
- [x] 1.2 Implement `toggleSidebar()`, `closeMobileSidebar()`, `checkViewport()` functions
|
||||||
|
- [x] 1.3 Add `sessionStorage` load/save for sidebar collapsed preference (`portal-shell:sidebar-collapsed`)
|
||||||
|
- [x] 1.4 Register `resize` listener in `onMounted`, clean up in `onUnmounted`
|
||||||
|
- [x] 1.5 Add Escape key handler to close mobile sidebar overlay
|
||||||
|
- [x] 1.6 Add `closeMobileSidebar()` call to existing route watcher for auto-close on navigation
|
||||||
|
|
||||||
|
## 2. Template Restructure (App.vue template)
|
||||||
|
|
||||||
|
- [x] 2.1 Add `.shell-header-left` wrapper with hamburger toggle `<button>` (inline SVG, `aria-label`, `aria-expanded`)
|
||||||
|
- [x] 2.2 Add `:class` binding on `.shell` root: `{ 'sidebar-collapsed': sidebarCollapsed && !isMobile }`
|
||||||
|
- [x] 2.3 Add `:class` bindings on `<aside class="sidebar">`: `sidebar--collapsed`, `sidebar--mobile-open`, `sidebar--mobile-closed`
|
||||||
|
- [x] 2.4 Add `<Transition name="overlay-fade">` wrapped `<div class="sidebar-overlay">` before `<main>`, shown when `isMobile && sidebarMobileOpen`
|
||||||
|
- [x] 2.5 Rename `<section class="content">` to `<section class="shell-content">` in the template
|
||||||
|
|
||||||
|
## 3. Shell CSS Rewrite (`frontend/src/portal-shell/style.css`)
|
||||||
|
|
||||||
|
- [x] 3.1 Rename all `.content` selectors to `.shell-content` throughout `style.css` (`.content`, `.content .xxx-page`, etc.)
|
||||||
|
- [x] 3.2 `.shell`: remove `max-width: 1600px`, `padding: 20px`, `margin: 0 auto`; add `display: flex; flex-direction: column`
|
||||||
|
- [x] 3.3 `.shell-header`: remove `border-radius: 12px`; adjust `padding` to `12px 20px`; add `flex-shrink: 0`
|
||||||
|
- [x] 3.4 Add `.shell-header-left` styles (flex, align-items center, gap 12px)
|
||||||
|
- [x] 3.5 Add `.sidebar-toggle` button styles (36x36, border/bg rgba white, hover, focus-visible outline)
|
||||||
|
- [x] 3.6 `.shell-main`: replace `display: grid; grid-template-columns` with `display: flex; flex: 1; overflow: hidden`; remove legacy `gap: 12px` to prevent residual gutter when sidebar collapses
|
||||||
|
- [x] 3.7 `.sidebar`: rewrite to `width: 240px; min-width: 240px; border-right; overflow-y: auto; flex-shrink: 0; transition: width/min-width/padding 0.3s; white-space: nowrap` — remove border-radius, border, sticky, height: fit-content
|
||||||
|
- [x] 3.8 Add `.sidebar--collapsed` styles: `width: 0; min-width: 0; padding: 0; border-right: none; overflow: hidden`
|
||||||
|
- [x] 3.9 Add `.sidebar--mobile-closed` styles: fixed position, `transform: translateX(-100%)`, 280px width, z-index 40
|
||||||
|
- [x] 3.10 Add `.sidebar--mobile-open` styles: fixed position, `transform: translateX(0)`, box-shadow, z-index 40
|
||||||
|
- [x] 3.11 Add `.sidebar-overlay` styles: fixed inset 0, z-index 35, `background: rgba(0,0,0,0.4)`
|
||||||
|
- [x] 3.12 Add `overlay-fade` transition classes (enter/leave opacity 0.3s)
|
||||||
|
- [x] 3.13 `.shell-content`: remove `border`, `border-radius: 10px`, `min-height: 70vh`; add `flex: 1; min-width: 0; overflow-y: auto`; change background to `#f5f7fa`
|
||||||
|
- [x] 3.14 Bump `.health-popup` z-index from `30` to `50`
|
||||||
|
|
||||||
|
## 4. Page-Level Overrides (`frontend/src/portal-shell/style.css`)
|
||||||
|
|
||||||
|
- [x] 4.1 Add `.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` override: `max-width: none; min-height: auto`
|
||||||
|
- [x] 4.2 Add `.shell-content .tables-page .container` override: `max-width: none` (tables module has max-width on inner `.container`, not on page wrapper)
|
||||||
|
- [x] 4.3 Add `.shell-content .resource-page` override: `padding: 0` (remove duplicate padding)
|
||||||
|
|
||||||
|
## 5. Responsive and Accessibility (`frontend/src/portal-shell/style.css`)
|
||||||
|
|
||||||
|
- [x] 5.1 Simplify `@media (max-width: 900px)` block — remove grid/sidebar rules (now JS-driven); keep header/content padding adjustments only
|
||||||
|
- [x] 5.2 Extend `@media (prefers-reduced-motion: reduce)` to include `.sidebar`, `.sidebar--mobile-*`, `.sidebar-overlay`, `.overlay-fade-*`, `.sidebar-toggle`
|
||||||
|
|
||||||
|
## 6. CSS Variables Update (`frontend/src/styles/tailwind.css`)
|
||||||
|
|
||||||
|
- [x] 6.1 Change `--portal-shell-max-width` from `1600px` to `none`
|
||||||
|
- [x] 6.2 Add `--portal-sidebar-width: 240px` and `--shell-header-height: 56px`
|
||||||
|
- [x] 6.3 Update `.u-content-shell` utility: replace `max-width` with `width: 100%`
|
||||||
|
|
||||||
|
## 7. Automated Tests
|
||||||
|
|
||||||
|
- [x] 7.1 Create `frontend/tests/portal-shell-sidebar.test.js` using existing Node `node:test` harness
|
||||||
|
- [x] 7.2 Test: desktop sidebar collapse — toggle sets `sidebarCollapsed` to true, sidebar gets `sidebar--collapsed` class
|
||||||
|
- [x] 7.3 Test: mobile overlay close via backdrop — clicking overlay calls `closeMobileSidebar()`, `sidebarMobileOpen` becomes false
|
||||||
|
- [x] 7.4 Test: mobile overlay close via Escape — pressing Escape when overlay open closes sidebar
|
||||||
|
- [x] 7.5 Test: sessionStorage persistence — collapsing sidebar writes to sessionStorage; mounting with stored value restores collapsed state
|
||||||
|
|
||||||
|
## 8. Manual Verification
|
||||||
|
|
||||||
|
- [x] 8.1 Run `npm run build` — confirm no build errors
|
||||||
|
- [x] 8.2 Test desktop: sidebar expanded → toggle collapse → toggle expand (smooth 300ms animation)
|
||||||
|
- [x] 8.3 Test mobile (<= 900px): toggle opens overlay drawer with backdrop → tap backdrop closes → Escape closes
|
||||||
|
- [x] 8.4 Test route navigation auto-closes mobile sidebar
|
||||||
|
- [x] 8.5 Test health popup z-index: open health popup while mobile sidebar is open → popup stays on top
|
||||||
|
- [x] 8.6 Test shell-registered page modules render fluid (no max-width centering) within the shell
|
||||||
|
- [x] 8.7 Test `prefers-reduced-motion`: all transitions disabled
|
||||||
|
- [x] 8.8 Verify tables module `.content` class is not affected by shell `.shell-content` styles
|
||||||
70
openspec/specs/collapsible-sidebar-drawer/spec.md
Normal file
70
openspec/specs/collapsible-sidebar-drawer/spec.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
## Purpose
|
||||||
|
Define stable requirements for collapsible-sidebar-drawer.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Sidebar SHALL be collapsible via a toggle button
|
||||||
|
The portal shell SHALL provide a toggle button in the header that collapses and expands the sidebar. On desktop viewports (>900px), collapsing SHALL animate the sidebar width from 240px to 0px using push mode, causing the content area to resize and fill the freed space. Expanding SHALL reverse the animation.
|
||||||
|
|
||||||
|
#### Scenario: Desktop sidebar collapse
|
||||||
|
- **WHEN** a user clicks the sidebar toggle button on a desktop viewport
|
||||||
|
- **AND** the sidebar is currently expanded
|
||||||
|
- **THEN** the sidebar width SHALL animate to 0px over 300ms
|
||||||
|
- **THEN** the content area SHALL expand to fill the full viewport width
|
||||||
|
|
||||||
|
#### Scenario: Desktop sidebar expand
|
||||||
|
- **WHEN** a user clicks the sidebar toggle button on a desktop viewport
|
||||||
|
- **AND** the sidebar is currently collapsed
|
||||||
|
- **THEN** the sidebar width SHALL animate to 240px over 300ms
|
||||||
|
- **THEN** the content area SHALL shrink to accommodate the sidebar
|
||||||
|
|
||||||
|
### Requirement: Mobile sidebar SHALL use overlay drawer mode
|
||||||
|
On mobile viewports (<=900px), the sidebar SHALL behave as a fixed-position overlay drawer that slides in from the left, with a semi-transparent backdrop covering the content area.
|
||||||
|
|
||||||
|
#### Scenario: Mobile sidebar open
|
||||||
|
- **WHEN** a user taps the toggle button on a mobile viewport
|
||||||
|
- **AND** the sidebar is currently hidden
|
||||||
|
- **THEN** the sidebar SHALL slide in from the left as a fixed overlay (280px width)
|
||||||
|
- **THEN** a semi-transparent backdrop SHALL appear behind the sidebar and above the content
|
||||||
|
|
||||||
|
#### Scenario: Mobile sidebar close via backdrop
|
||||||
|
- **WHEN** a user taps the backdrop overlay
|
||||||
|
- **THEN** the sidebar SHALL slide out to the left
|
||||||
|
- **THEN** the backdrop SHALL fade out
|
||||||
|
|
||||||
|
#### Scenario: Mobile sidebar close via Escape key
|
||||||
|
- **WHEN** the mobile sidebar overlay is open
|
||||||
|
- **AND** a user presses the Escape key
|
||||||
|
- **THEN** the sidebar SHALL close
|
||||||
|
|
||||||
|
#### Scenario: Mobile sidebar closes on navigation
|
||||||
|
- **WHEN** the mobile sidebar overlay is open
|
||||||
|
- **AND** a user clicks a navigation link
|
||||||
|
- **THEN** the sidebar SHALL automatically close after the route change
|
||||||
|
|
||||||
|
### Requirement: Sidebar state SHALL persist within the browser session
|
||||||
|
The collapsed/expanded state of the desktop sidebar SHALL be persisted to `sessionStorage` so that the preference survives page refreshes within the same browser tab.
|
||||||
|
|
||||||
|
#### Scenario: State persistence on refresh
|
||||||
|
- **WHEN** a user collapses the sidebar and refreshes the page
|
||||||
|
- **THEN** the sidebar SHALL remain collapsed after reload
|
||||||
|
|
||||||
|
#### Scenario: New tab starts expanded
|
||||||
|
- **WHEN** a user opens the portal in a new browser tab
|
||||||
|
- **THEN** the sidebar SHALL default to expanded regardless of other tabs' state
|
||||||
|
|
||||||
|
### Requirement: Sidebar transitions SHALL respect reduced-motion preference
|
||||||
|
All sidebar transitions (width animation, overlay slide, backdrop fade) SHALL be disabled when the user has `prefers-reduced-motion: reduce` enabled.
|
||||||
|
|
||||||
|
#### Scenario: Reduced motion disables sidebar animation
|
||||||
|
- **WHEN** the user's OS or browser has reduced-motion enabled
|
||||||
|
- **AND** the user toggles the sidebar
|
||||||
|
- **THEN** the sidebar state SHALL change instantly without animation
|
||||||
|
|
||||||
|
### Requirement: Toggle button SHALL be accessible
|
||||||
|
The sidebar toggle button SHALL include `aria-label` and `aria-expanded` attributes for screen reader accessibility.
|
||||||
|
|
||||||
|
#### Scenario: Screen reader announces toggle state
|
||||||
|
- **WHEN** a screen reader user focuses the toggle button
|
||||||
|
- **THEN** the button SHALL announce its current expanded/collapsed state via `aria-expanded`
|
||||||
|
- **THEN** the button SHALL have an `aria-label` describing its purpose
|
||||||
@@ -2,12 +2,29 @@
|
|||||||
Define stable requirements for spa-shell-navigation.
|
Define stable requirements for spa-shell-navigation.
|
||||||
## Requirements
|
## Requirements
|
||||||
### Requirement: Portal SHALL provide a SPA shell driven by Vue Router
|
### Requirement: Portal SHALL provide a SPA shell driven by Vue Router
|
||||||
The portal frontend SHALL use a single SPA shell entry and Vue Router to render page modules without iframe embedding, and SHALL route each page through either native route-view integration or a temporary wrapper component.
|
The portal frontend SHALL use a single SPA shell entry and Vue Router to render page modules without iframe embedding, and SHALL route each page through native route-view integration. The shell layout SHALL use a full-viewport fluid layout with flexbox, removing all max-width constraints and block-centered styling. The main content area (`.shell-content`) SHALL fill available space as a flex child, and the sidebar SHALL be a collapsible flex child that pushes content when expanded on desktop. The content area class SHALL be `.shell-content` (not `.content`) to avoid CSS collision with page-level `.content` classes.
|
||||||
|
|
||||||
#### Scenario: Drawer navigation renders integrated route view
|
#### Scenario: Drawer navigation renders integrated route view
|
||||||
- **WHEN** a user clicks a sidebar page entry whose migration mode is `native`
|
- **WHEN** a user clicks a sidebar page entry whose migration mode is `native`
|
||||||
- **THEN** the active route SHALL be updated through Vue Router
|
- **THEN** the active route SHALL be updated through Vue Router
|
||||||
- **THEN** the main content area SHALL render the corresponding page module inside shell route-view without iframe usage
|
- **THEN** the main content area SHALL render the corresponding page module inside shell route-view without iframe usage
|
||||||
|
- **THEN** the content area SHALL fill the available viewport width minus the sidebar width (if sidebar is expanded)
|
||||||
|
|
||||||
|
#### Scenario: Shell layout fills full viewport
|
||||||
|
- **WHEN** the portal shell renders
|
||||||
|
- **THEN** the shell SHALL span the full viewport width with no max-width constraint
|
||||||
|
- **THEN** the header SHALL span edge-to-edge with no border-radius
|
||||||
|
- **THEN** the sidebar and content area SHALL have no outer borders or border-radius
|
||||||
|
|
||||||
|
#### Scenario: Page-level max-width constraints are removed when embedded
|
||||||
|
- **WHEN** a page module registered in the shell route contracts renders inside `.shell-content`
|
||||||
|
- **THEN** page-level max-width constraints SHALL be overridden to allow full-width rendering
|
||||||
|
- **THEN** page-level duplicate padding SHALL be removed to avoid double spacing
|
||||||
|
- **THEN** standalone page rendering (outside the shell) SHALL remain unaffected
|
||||||
|
|
||||||
|
#### Scenario: Shell content class avoids collision with page-level classes
|
||||||
|
- **WHEN** a page module that uses its own `.content` class renders inside the shell
|
||||||
|
- **THEN** the shell's content wrapper (`.shell-content`) SHALL NOT interfere with the page's `.content` styling
|
||||||
|
|
||||||
#### Scenario: Wrapper route remains available during migration
|
#### Scenario: Wrapper route remains available during migration
|
||||||
- **WHEN** a user clicks a sidebar page entry whose migration mode is `wrapper`
|
- **WHEN** a user clicks a sidebar page entry whose migration mode is `wrapper`
|
||||||
|
|||||||
@@ -4,13 +4,19 @@ Define stable requirements for tailwind-design-system.
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: Frontend styles SHALL be governed by Tailwind design tokens
|
### Requirement: Frontend styles SHALL be governed by Tailwind design tokens
|
||||||
The frontend SHALL define a Tailwind-based design token system for color, spacing, typography, radius, and elevation to ensure consistent styling across modules.
|
The frontend SHALL define a Tailwind-based design token system for color, spacing, typography, radius, and elevation to ensure consistent styling across modules. The `--portal-shell-max-width` CSS variable SHALL be set to `none` to support the fluid layout. A `--portal-sidebar-width` variable SHALL be added for sidebar width reference. The `.u-content-shell` utility class SHALL use `width: 100%` instead of `max-width` constraint.
|
||||||
|
|
||||||
#### Scenario: Shared token usage across modules
|
#### Scenario: Shared token usage across modules
|
||||||
- **WHEN** two report modules render equivalent UI elements (e.g., card, filter chip, primary button)
|
- **WHEN** two report modules render equivalent UI elements (e.g., card, filter chip, primary button)
|
||||||
- **THEN** they SHALL use the same token-backed style semantics
|
- **THEN** they SHALL use the same token-backed style semantics
|
||||||
- **THEN** visual output SHALL remain consistent across modules
|
- **THEN** visual output SHALL remain consistent across modules
|
||||||
|
|
||||||
|
#### Scenario: Fluid layout tokens
|
||||||
|
- **WHEN** the portal shell renders
|
||||||
|
- **THEN** `--portal-shell-max-width` SHALL resolve to `none`
|
||||||
|
- **THEN** `--portal-sidebar-width` SHALL resolve to `240px`
|
||||||
|
- **THEN** `.u-content-shell` SHALL apply `width: 100%` without max-width constraint
|
||||||
|
|
||||||
### Requirement: Tailwind migration SHALL support coexistence with legacy CSS
|
### Requirement: Tailwind migration SHALL support coexistence with legacy CSS
|
||||||
The migration SHALL allow Tailwind and existing page CSS to coexist during phased rollout without breaking existing pages.
|
The migration SHALL allow Tailwind and existing page CSS to coexist during phased rollout without breaking existing pages.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user