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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions resources/css/core/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
.nav-main {
@apply flex flex-col gap-6 py-6 px-2 sm:px-3 text-sm antialiased select-none;
/* Same as the main element, accounting for the header with a class of h-14, which is the same as 3.5rem */
@apply h-[calc(100vh-3.5rem)];
@apply h-[calc(100dvh-3.5rem)];
@apply overflow-y-auto fixed top-14 start-0 w-48;
@apply [&_svg]:text-gray-500 dark:[&_svg]:text-gray-500/85;
/* Wait for the full page to load before allowing this transition otherwise you see the Sidebar animate in/out on load in Firefox (and sometimes Safari) */
Expand Down Expand Up @@ -115,8 +115,9 @@
/* Mobile nav behavior */
@media (width < theme(--breakpoint-lg)) {
.nav-main {
/* Always visible but off-screen by default */
/* Always visible but off-screen until opened (do not rely on JS adding .nav-closed before first paint). */
@apply flex;
@apply -start-50;
@apply bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700;
}

Expand Down
21 changes: 18 additions & 3 deletions resources/js/components/global-header/Logo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import StatamicLogo from '@/../svg/statamic-mark-lime.svg';
import ProBadge from './ProBadge.vue';
import { Link } from '@inertiajs/vue3';
import useStatamicPageProps from '@/composables/page-props.js';
import { invokeCpNavToggle } from '@/components/nav/nav-toggle-channel.js';

const { logos, isPro, cmsName, version } = useStatamicPageProps();
const customLogoImage = computed(() => {
Expand All @@ -15,14 +16,21 @@ const customLogoText = computed(() => logos?.text);
const customLogo = computed(() => customLogoImage.value || customLogoText.value);

function toggleNav() {
Statamic.$events.$emit('nav.toggle');
invokeCpNavToggle();
}
</script>

<template>
<template v-if="customLogo">
<div class="flex items-center gap-3 relative rounded-xs">
<button class="flex items-center group rounded-xs cursor-pointer" type="button" @click="toggleNav" :aria-label="__('Toggle Nav')" style="--focus-outline-offset: 0.2rem;">
<button
type="button"
class="flex items-center group rounded-xs cursor-pointer"
data-cp-global-nav-toggle
:aria-label="__('Toggle Nav')"
style="--focus-outline-offset: 0.2rem;"
@click.stop="toggleNav"
>
<div class="p-1 max-sm:ps-2 size-5 flex items-center justify-center lg:inset-0">
<Icon name="burger-menu-no-border" class="size-3.5! sm:size-3.25! opacity-75 hover:opacity-100" />
</div>
Expand All @@ -35,7 +43,14 @@ function toggleNav() {
</template>
<template v-else>
<div class="flex items-center gap-1.5 sm:gap-2.5 relative">
<button class="flex items-center group rounded-xs cursor-pointer" type="button" @click="toggleNav" :aria-label="__('Toggle Nav')" style="--focus-outline-offset: 0.2rem;">
<button
type="button"
class="flex items-center group rounded-xs cursor-pointer"
data-cp-global-nav-toggle
:aria-label="__('Toggle Nav')"
style="--focus-outline-offset: 0.2rem;"
@click.stop="toggleNav"
>
<div class="p-1 max-sm:ps-2 size-5 flex items-center justify-center lg:inset-0">
<Icon name="burger-menu-no-border" class="size-3.5! sm:size-3.25! opacity-75 hover:opacity-100" />
</div>
Expand Down
120 changes: 81 additions & 39 deletions resources/js/components/nav/Nav.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,80 @@
<script setup>
import { Link, usePage, router } from '@inertiajs/vue3';
import { Link, router } from '@inertiajs/vue3';
import { Badge, Icon } from '@ui';
import useNavigation from './navigation.js';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import DynamicHtmlRenderer from '@/components/DynamicHtmlRenderer.vue';
import { setCpNavToggleHandler, clearCpNavToggleHandler } from './nav-toggle-channel.js';

const { nav, setParentActive, setChildActive } = useNavigation();
const localStorageKey = 'statamic.nav';
const isOpen = ref(localStorage.getItem(localStorageKey) !== 'closed');

function readInitialNavOpen() {
const stored = localStorage.getItem(localStorageKey);
const mobile = window.matchMedia('(width < 1024px)').matches;
// Desktop: preserve legacy default (open unless user chose "closed").
// Mobile: default to closed on load so the overlay does not cover the CP; only reopen if the user left it explicitly "open".
if (mobile) {
return stored === 'open';
}
return stored !== 'closed';
}

const isOpen = ref(readInitialNavOpen());
const navRef = ref(null);
const isMobile = ref(false);
const isMobile = ref(typeof window !== 'undefined' && window.matchMedia('(width < 1024px)').matches);
const collapsedByViewport = ref(false);
let clickListenerActive = false;
let navigateEventListener = null;
let clickOutsideEnableTimer = null;

function toggle() {
isOpen.value = !isOpen.value;
// Reset viewport flag since user is explicitly toggling, so we should respect their preference
// even when viewport size changes (don't auto-expand if user manually closed it)
collapsedByViewport.value = false;
localStorage.setItem(localStorageKey, isOpen.value ? 'open' : 'closed');
}

setCpNavToggleHandler(toggle);

onUnmounted(() => {
clearCpNavToggleHandler(toggle);
});

watch(
isOpen,
(open) => {
const el = document.getElementById('main');
el?.classList.toggle('nav-closed', !open);
el?.classList.toggle('nav-open', open);

if (clickOutsideEnableTimer !== null) {
clearTimeout(clickOutsideEnableTimer);
clickOutsideEnableTimer = null;
}

// Delay enabling the click-outside listener to avoid catching the toggle click.
// Clear any pending timer so a fast close cannot leave clickListenerActive stuck true.
if (open) {
clickListenerActive = false;
clickOutsideEnableTimer = setTimeout(() => {
clickOutsideEnableTimer = null;
clickListenerActive = true;
}, 100);
} else {
clickListenerActive = false;
}
},
{ flush: 'post', immediate: true },
);

onMounted(() => {
const keyBinding = Statamic.$keys.bind(['command+\\', ['[']], (e) => {
e.preventDefault();
toggle();
});

// Check if screen is less than lg breakpoint (1024px)
const mediaQuery = window.matchMedia('(width < 1024px)');
isMobile.value = mediaQuery.matches;
Expand All @@ -40,31 +100,15 @@ onMounted(() => {
};

mediaQuery.addEventListener('change', handleMediaChange);

nextTick(() => {
watch(isOpen, (isOpen) => {
const el = document.getElementById('main');
el.classList.toggle('nav-closed', !isOpen);
el.classList.toggle('nav-open', isOpen);

// Delay enabling the click-outside listener to avoid catching the toggle click
if (isOpen) {
setTimeout(() => {
clickListenerActive = true;
}, 100);
} else {
clickListenerActive = false;
}
}, { immediate: true });
});

// Mark page as fully loaded after all resources are loaded
if (document.readyState === 'complete') {
const onWindowLoad = () => {
document.documentElement.classList.add('page-fully-loaded');
};
if (document.readyState === 'complete') {
onWindowLoad();
} else {
window.addEventListener('load', () => {
document.documentElement.classList.add('page-fully-loaded');
});
window.addEventListener('load', onWindowLoad);
}

// Close nav when clicking outside (only on mobile)
Expand All @@ -81,29 +125,33 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
mediaQuery.removeEventListener('change', handleMediaChange);
if (document.readyState !== 'complete') {
window.removeEventListener('load', onWindowLoad);
}
if (navigateEventListener) {
navigateEventListener();
}
if (clickOutsideEnableTimer !== null) {
clearTimeout(clickOutsideEnableTimer);
clickOutsideEnableTimer = null;
}
keyBinding.destroy();
});
});

function handleClickOutside(event) {
// Only handle click-outside on mobile (less than lg breakpoint)
if (!isOpen.value || !clickListenerActive || !isMobile.value) return;
// Never treat the global header burger as an "outside" click — same gesture can race
// with document bubble listeners and cancel the toggle.
const target = event.target;
if (target instanceof Element && target.closest('[data-cp-global-nav-toggle]')) return;
if (navRef.value && !navRef.value.contains(event.target)) {
isOpen.value = false;
localStorage.setItem(localStorageKey, 'closed');
}
}

function toggle() {
isOpen.value = !isOpen.value;
// Reset viewport flag since user is explicitly toggling, so we should respect their preference
// even when viewport size changes (don't auto-expand if user manually closed it)
collapsedByViewport.value = false;
localStorage.setItem(localStorageKey, isOpen.value ? 'open' : 'closed');
}

function handleParentClick(event, item) {
if (event.defaultPrevented) return;

Expand Down Expand Up @@ -145,12 +193,6 @@ function shouldRenderAsInertiaLink(item) {
return isUrlWithinControlPanel(item.url);
}

Statamic.$keys.bind(['command+\\', ['[']], (e) => {
e.preventDefault();
toggle();
});

Statamic.$events.$on('nav.toggle', toggle);
</script>

<template>
Expand Down
19 changes: 19 additions & 0 deletions resources/js/components/nav/nav-toggle-channel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Direct wiring from the global header burger to Nav.toggle().
* Avoids Statamic.$events (tiny-emitter) and document-level click ordering races.
*/
let handler = null;

export function setCpNavToggleHandler(fn) {
handler = fn;
}

export function clearCpNavToggleHandler(fn) {
if (handler === fn) {
handler = null;
}
}

export function invokeCpNavToggle() {
handler?.();
}
Loading