Tailwind's lg:hidden loses to PrimeVue's .p-button { display: inline-flex }
due to equal specificity but later cascade order. Resulted in the mobile
hamburger remaining visible on desktop, allowing the Drawer to open over
the already-visible permanent sidebar.
Fix: wrap mobile-only cluster (hamburger + title) in a plain <div lg:hidden>
so the wrapper owns the visibility toggle. The wrapper is not a PrimeVue
component, so no specificity competition.
The Drawer itself had the same anti-pattern (class="lg:hidden") and is
worse, because PrimeVue Drawer teleports to body — a wrapping div on the
parent does not isolate the teleported overlay, and a class on the Drawer
root loses to .p-drawer { display: flex } when visible. Converted to
v-if="!isLg" driven by useMediaQuery('(min-width: 1024px)'). Vue simply
does not render the component on lg+, so no display rule competes.
Audited all 5 layouts for the same anti-pattern:
- AppShell.vue — fixed (Button + Drawer described above)
- default.vue / OrganizerLayout.vue / PortalLayout.vue — delegate to
AppShell; no PrimeVue elements with responsive classes
- blank.vue — plain <div>, no PrimeVue
- PublicLayout.vue — plain <main>, no PrimeVue
useMediaQuery is auto-imported via unplugin-auto-import's @vueuse/core
entry in vite.config.ts; explicit imports get stripped by the post-edit
ESLint --fix hook as redundant.
F3-introduced bug (commit 43915501); surfaced during F3.5 testing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
279 lines
8.5 KiB
Vue
279 lines
8.5 KiB
Vue
<script setup lang="ts">
|
|
// AppShell — PrimeVue-only application chrome introduced in F3 per the
|
|
// RFC-WS-FRONTEND-PRIMEVUE AD-3 layout rewrite, B7-option-B (alongside
|
|
// the Vuexy carrier DefaultLayoutWithVerticalNav.vue).
|
|
//
|
|
// Hard constraint per F3 sprint plan: no Vuetify or @core/@layouts
|
|
// imports inside this component. Vuetify-based features that previously
|
|
// lived in the topbar (search, theme switcher, notifications,
|
|
// org switcher, context switcher, shortcuts, impersonation banner,
|
|
// rich user profile menu) are absent from AppShell — they migrate in
|
|
// F4 sub-packages and re-enter through this component then. See the
|
|
// B7 commit body for the explicit regression list.
|
|
//
|
|
// Layout: Tailwind CSS grid. Desktop (lg+) shows a permanent sidebar;
|
|
// mobile (<lg) hides it and shows a hamburger toggle that opens a
|
|
// PrimeVue Drawer overlay. Content area renders the default slot
|
|
// (a RouterView from the wrapping layout file).
|
|
|
|
import { computed, ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import Drawer from 'primevue/drawer'
|
|
import Button from 'primevue/button'
|
|
import { useToast } from 'primevue/usetoast'
|
|
import Icon from '@/components/Icon.vue'
|
|
import SidebarHeader from '@/layouts/components/SidebarHeader.vue'
|
|
import SidebarUserCard from '@/layouts/components/SidebarUserCard.vue'
|
|
import { useAuthStore } from '@/stores/useAuthStore'
|
|
|
|
interface NavHeading {
|
|
heading: string
|
|
}
|
|
interface NavLink {
|
|
title: string
|
|
to: { name: string }
|
|
icon: { icon: string }
|
|
}
|
|
type NavItem = NavHeading | NavLink
|
|
|
|
interface Props {
|
|
navItems: NavItem[]
|
|
title?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
title: 'Crewli',
|
|
})
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const toast = useToast()
|
|
const authStore = useAuthStore()
|
|
|
|
const mobileNavOpen = ref(false)
|
|
|
|
// Tailwind's lg breakpoint, mirrored in script so Vue can own the
|
|
// visibility of PrimeVue elements that would otherwise lose a CSS
|
|
// specificity duel to .p-button / .p-drawer / etc. See the wrapper
|
|
// `<div class="lg:hidden">` around the topbar mobile cluster and the
|
|
// `v-if="!isLg"` on the Drawer.
|
|
const isLg = useMediaQuery('(min-width: 1024px)')
|
|
|
|
function isHeading(item: NavItem): item is NavHeading {
|
|
return 'heading' in item
|
|
}
|
|
|
|
function navigate(item: NavLink) {
|
|
mobileNavOpen.value = false
|
|
router.push(item.to)
|
|
}
|
|
|
|
// Breadcrumb: "Organisation / Page title". Page title resolves from
|
|
// route.meta.title → matching navItems entry → humanized route name.
|
|
// Org name is omitted on portal / pre-org users (currentOrganisation
|
|
// is null) so the breadcrumb reads as just the page title there.
|
|
const orgName = computed(() => authStore.currentOrganisation?.name ?? '')
|
|
|
|
const pageTitle = computed(() => {
|
|
const metaTitle = route.meta.title
|
|
if (typeof metaTitle === 'string' && metaTitle.length > 0)
|
|
return metaTitle
|
|
|
|
const match = props.navItems.find(
|
|
(item): item is NavLink => 'to' in item && item.to.name === route.name,
|
|
)
|
|
|
|
if (match)
|
|
return match.title
|
|
|
|
const raw = String(route.name ?? '')
|
|
if (!raw)
|
|
return ''
|
|
|
|
return raw.charAt(0).toUpperCase() + raw.slice(1).replace(/-/g, ' ')
|
|
})
|
|
|
|
function onNotificationsClick() {
|
|
toast.add({
|
|
severity: 'info',
|
|
summary: 'Notificaties',
|
|
detail: 'Notificaties komen binnenkort beschikbaar.',
|
|
life: 3000,
|
|
})
|
|
}
|
|
|
|
// TODO(docs-url): https://docs.crewli.app currently serves with a TLS
|
|
// cert that does not cover the host (ERR_TLS_CERT_ALTNAME_INVALID),
|
|
// so a browser hit shows a security warning instead of the docs.
|
|
// When the cert is fixed, switch this handler to:
|
|
// window.open('https://docs.crewli.app', '_blank', 'noopener,noreferrer')
|
|
function onHelpClick() {
|
|
toast.add({
|
|
severity: 'info',
|
|
summary: 'Help',
|
|
detail: 'Documentatie komt binnenkort beschikbaar.',
|
|
life: 3000,
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="crewli-app-shell flex min-h-screen">
|
|
<!-- Desktop sidebar (lg+) -->
|
|
<aside class="hidden lg:flex w-72 flex-col border-r border-surface-200 bg-surface-0">
|
|
<SidebarHeader :title="title" />
|
|
<nav class="min-h-0 flex-1 overflow-y-auto p-3">
|
|
<template
|
|
v-for="(item, idx) in navItems"
|
|
:key="idx"
|
|
>
|
|
<div
|
|
v-if="isHeading(item)"
|
|
class="mt-6 mb-1 px-3 text-[11px] font-medium uppercase tracking-widest text-surface-400"
|
|
>
|
|
{{ item.heading }}
|
|
</div>
|
|
<button
|
|
v-else
|
|
type="button"
|
|
class="flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-surface-700 transition hover:bg-primary-50 hover:text-primary-600"
|
|
@click="navigate(item)"
|
|
>
|
|
<Icon
|
|
:name="item.icon.icon"
|
|
size="20"
|
|
/>
|
|
<span>{{ item.title }}</span>
|
|
</button>
|
|
</template>
|
|
</nav>
|
|
<SidebarUserCard />
|
|
</aside>
|
|
|
|
<!--
|
|
Mobile drawer (overlay, <lg). v-if (not lg:hidden class) because
|
|
PrimeVue Drawer teleports to body, so a wrapping div or class
|
|
on the Drawer root can't beat .p-drawer's display rule in the
|
|
cascade — Vue must simply not render the component on lg+.
|
|
-->
|
|
<Drawer
|
|
v-if="!isLg"
|
|
v-model:visible="mobileNavOpen"
|
|
position="left"
|
|
:pt="{
|
|
root: { style: { width: '18rem' } },
|
|
header: { class: 'p-0' },
|
|
content: { class: 'flex flex-col flex-1 min-h-0 p-0' },
|
|
}"
|
|
>
|
|
<template #header>
|
|
<SidebarHeader :title="title" />
|
|
</template>
|
|
<nav class="min-h-0 flex-1 overflow-y-auto p-3">
|
|
<template
|
|
v-for="(item, idx) in navItems"
|
|
:key="idx"
|
|
>
|
|
<div
|
|
v-if="isHeading(item)"
|
|
class="mt-6 mb-1 px-3 text-[11px] font-medium uppercase tracking-widest text-surface-400"
|
|
>
|
|
{{ item.heading }}
|
|
</div>
|
|
<button
|
|
v-else
|
|
type="button"
|
|
class="flex w-full items-center gap-3 rounded px-3 py-2 text-sm text-surface-700 transition hover:bg-primary-50 hover:text-primary-600"
|
|
@click="navigate(item)"
|
|
>
|
|
<Icon
|
|
:name="item.icon.icon"
|
|
size="20"
|
|
/>
|
|
<span>{{ item.title }}</span>
|
|
</button>
|
|
</template>
|
|
</nav>
|
|
<SidebarUserCard />
|
|
</Drawer>
|
|
|
|
<!-- Main column -->
|
|
<div class="flex flex-1 flex-col min-w-0">
|
|
<!-- Top bar -->
|
|
<header class="flex h-16 items-center justify-between border-b border-surface-200 bg-surface-0 px-4">
|
|
<div class="flex items-center gap-2">
|
|
<!--
|
|
Mobile cluster: visibility owned by this plain DIV. The
|
|
PrimeVue Button alone with lg:hidden loses to
|
|
.p-button { display: inline-flex } in the cascade — same
|
|
specificity, PrimeVue's stylesheet loads later.
|
|
-->
|
|
<div class="flex items-center gap-2 lg:hidden">
|
|
<Button
|
|
severity="secondary"
|
|
text
|
|
rounded
|
|
aria-label="Menu openen"
|
|
@click="mobileNavOpen = true"
|
|
>
|
|
<Icon
|
|
name="tabler-menu-2"
|
|
size="24"
|
|
/>
|
|
</Button>
|
|
<span class="text-base font-medium text-surface-700">{{ title }}</span>
|
|
</div>
|
|
<nav
|
|
class="hidden items-center gap-2 text-sm lg:flex"
|
|
aria-label="Kruimelpad"
|
|
>
|
|
<span
|
|
v-if="orgName"
|
|
class="text-surface-500"
|
|
>{{ orgName }}</span>
|
|
<Icon
|
|
v-if="orgName && pageTitle"
|
|
name="tabler-chevron-right"
|
|
size="14"
|
|
class="text-surface-400"
|
|
/>
|
|
<span class="font-medium text-surface-900">{{ pageTitle }}</span>
|
|
</nav>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-1">
|
|
<Button
|
|
severity="secondary"
|
|
text
|
|
rounded
|
|
aria-label="Notificaties"
|
|
@click="onNotificationsClick"
|
|
>
|
|
<Icon
|
|
name="tabler-bell"
|
|
size="22"
|
|
/>
|
|
</Button>
|
|
<Button
|
|
severity="secondary"
|
|
text
|
|
rounded
|
|
aria-label="Help"
|
|
@click="onHelpClick"
|
|
>
|
|
<Icon
|
|
name="tabler-help"
|
|
size="22"
|
|
/>
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Page content -->
|
|
<main class="flex-1 overflow-x-hidden p-4 lg:p-6">
|
|
<slot />
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</template>
|