Skip to content
63 changes: 51 additions & 12 deletions resources/css/core/layout.css
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
:root {
--nav-width: 11.5rem;
}

/* GROUP THE MAIN NAV (LEFT SIDEBAR)
=================================================== */
.nav-main {
@apply flex flex-col gap-6 py-6 px-2 sm:px-3 text-sm antialiased select-none;
.cp-sidebar-start {
@apply flex flex-col gap-6 py-6 px-2 sm:px-3 sm:pe-1 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 overflow-y-auto fixed top-14 start-0 w-48;
@apply overflow-y-auto fixed top-14 start-0;
width: var(--nav-width);
@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) */
.page-fully-loaded & {
Expand Down Expand Up @@ -46,7 +51,7 @@

/* GROUP THE MAIN NAV (LEFT SIDEBAR) / ACTIVE STATES
=================================================== */
.nav-main {
.cp-sidebar-start {
@supports not (anchor-name: --my-anchor) {
ul li ul li {
a.active {
Expand Down Expand Up @@ -109,34 +114,35 @@
}
}




/* Mobile nav behavior */
/* GROUP THE MAIN NAV (LEFT SIDEBAR) / MOBILE BEHAVIOR
=================================================== */
@media (width < theme(--breakpoint-lg)) {
.nav-main {
.cp-sidebar-start {
/* Always visible but off-screen by default */
@apply flex;
@apply bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700;
}

.nav-open .nav-main {
.nav-open .cp-sidebar-start {
/* Slide in from the left */
@apply start-0 shadow-2xl;
}
}


main {
@apply ps-0 lg:ps-46;
padding-inline-start: 0;
@media (width >= theme(--breakpoint-lg)) {
padding-inline-start: var(--nav-width);
}
/* 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) */
.page-fully-loaded & {
/* Only padding because we don't wand to transition the color when we switch between light/dark mode */
transition: padding 0.3s ease-in-out;
}
}

.nav-closed .nav-main {
.nav-closed .cp-sidebar-start {
/* Start off-screen to the left */
@apply -start-50;
}
Expand All @@ -146,6 +152,39 @@ main.nav-closed {
@apply lg:ps-0;
}

/* GROUP RESIZABLE NAVS / RESIZE HANDLE
=================================================== */
.content-card-resize-handle {
--resize-width: 10px;

position: absolute;
z-index: var(--z-index-above);
top: 0;
inset-inline-start: calc(0% - var(--resize-width) / 2);
width: var(--resize-width);
height: 100%;
cursor: col-resize;
@apply hidden lg:block;
}

.nav-resizing {
cursor: col-resize;
/* Prevents any text selection while dragging (otherwise you could end up selecting menu text while dragging the resize handle). */
user-select: none;

& * {
/* Ensures that if you move the pointer over a child element inside the nav (icons, links, spans), the cursor doesn't revert back to the default pointer/hand. */
cursor: col-resize;
}

& main,
& .cp-sidebar-start,
& .cp-sidebar-end {
/* Disables transitions while dragging, specifically to prevent jank/animated layout changes from the existing sidebar + main transitions in layout.css (the sidebar open/close transition and the main padding transition). */
transition: none;
}
}

/* ==========================================================================
DRAGGABLE MIRRORS
========================================================================== */
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/nav/Nav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ Statamic.$events.$on('nav.toggle', toggle);
</script>

<template>
<nav ref="navRef" class="nav-main">
<nav ref="navRef" class="cp-sidebar-start">
<div v-for="(section, i) in nav" :key="i">
<div
class="section-title"
Expand Down
119 changes: 114 additions & 5 deletions resources/js/pages/layout/Layout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@ import SessionExpiry from '@/components/SessionExpiry.vue';
import LicensingAlert from '@/components/LicensingAlert.vue';
import PortalTargets from '@/components/portals/PortalTargets.vue';
import Tooltips from '@/components/Tooltips.vue';
import { provide, watch, ref, onMounted, onUnmounted, nextTick } from 'vue';
import { router } from '@inertiajs/vue3';
import { provide, watch, ref, onMounted, onUnmounted, nextTick, computed, useTemplateRef } from 'vue';
import { router, usePage } from '@inertiajs/vue3';
import useBodyClasses from './body-classes.js';
import useStatamicPageProps from '@/composables/page-props.js';
import useMaxWidthToggle from '@/composables/use-max-width-toggle.js';

useBodyClasses('bg-global-header-bg font-sans leading-normal text-gray-900 dark:text-white');
Expand All @@ -32,6 +31,108 @@ provide('layout', {
// Focus management: focus main element if no input has auto-focus
let navigationListener = null;

// Resizable sidebar (handle lives on the left edge of the content card)
const page = usePage();
// const isEditingForm = computed(() => page.url.includes('/forms/'));
const isEditingForm = computed(() => false);

const navWidthStorageKey = computed(() => isEditingForm.value ? 'statamic.nav.width.forms' : 'statamic.nav.width');
const minNavWidth = computed(() => isEditingForm.value ? 480 : 150);
const maxNavWidth = computed(() => isEditingForm.value ? 1000 : 300);
const mainContentRef = useTemplateRef('mainContent');
const contentCardRef = useTemplateRef('contentCard');

watch(navWidthStorageKey, restoreSavedNavWidth);

let isResizing = false;
let currentWidthPx = null;
let contentInsetPx = 0;
let pointerMoveListener = null;
let pointerUpListener = null;

function clampNavWidthPx(widthPx) {
return Math.min(Math.max(widthPx, minNavWidth.value), maxNavWidth.value);
}

function setNavWidthPx(widthPx) {
document.documentElement.style.setProperty('--nav-width', `${widthPx}px`);
}

function restoreSavedNavWidth() {
const saved = localStorage.getItem(navWidthStorageKey.value);

if (!saved) {
setNavWidthPx(minNavWidth.value);
return;
}

const widthPx = Number(saved);

if (!Number.isFinite(widthPx)) {
setNavWidthPx(minNavWidth.value);
return;
}

setNavWidthPx(clampNavWidthPx(widthPx));
}

function stopResize({ persist = true } = {}) {
if (!isResizing) return;

isResizing = false;
document.documentElement.classList.remove('nav-resizing');

if (pointerMoveListener) document.removeEventListener('pointermove', pointerMoveListener);
if (pointerUpListener) document.removeEventListener('pointerup', pointerUpListener);
pointerMoveListener = null;
pointerUpListener = null;

if (persist && currentWidthPx !== null) {
localStorage.setItem(navWidthStorageKey.value, Math.round(currentWidthPx));
}

currentWidthPx = null;
}

function startResize(event) {
if (isResizing || !mainContentRef.value || !contentCardRef.value) return;

isResizing = true;
document.documentElement.classList.add('nav-resizing');

// Prevent losing the drag if the pointer leaves the handle.
event?.currentTarget?.setPointerCapture?.(event.pointerId);

const dir = getComputedStyle(document.documentElement).direction;
const isRtl = dir === 'rtl';

const mainContentRect = mainContentRef.value.getBoundingClientRect();
const contentCardRect = contentCardRef.value.getBoundingClientRect();
contentInsetPx = isRtl
? (mainContentRect.right - contentCardRect.right)
: (contentCardRect.left - mainContentRect.left);

pointerMoveListener = (e) => {
const proposedWidth = isRtl
? (window.innerWidth - e.clientX - contentInsetPx)
: (e.clientX - contentInsetPx);

currentWidthPx = clampNavWidthPx(proposedWidth);
setNavWidthPx(currentWidthPx);
};

pointerUpListener = () => stopResize({ persist: true });

document.addEventListener('pointermove', pointerMoveListener);
document.addEventListener('pointerup', pointerUpListener);
}

function resetNavWidth() {
stopResize({ persist: false });
localStorage.removeItem(navWidthStorageKey.value);
setNavWidthPx(minNavWidth.value);
}

function focusMain() {
// Wait for components to mount and autofocus to process
nextTick(() => {
Expand Down Expand Up @@ -63,13 +164,16 @@ function focusMain() {

onMounted(() => {
navigationListener = router.on('success', focusMain);
restoreSavedNavWidth();
focusMain();
});

onUnmounted(() => {
if (navigationListener) {
navigationListener();
}

stopResize({ persist: false });
});
</script>

Expand All @@ -82,8 +186,13 @@ onUnmounted(() => {
<main id="main" class="flex bg-body-bg dark:border-t dark:border-body-border rounded-t-2xl fixed top-14 inset-x-0 bottom-0 min-h-[calc(100vh-3.5rem)]">
<Nav />
<!-- The data attribute allows CSS to target elements when max-width is disabled. -->
<div id="main-content" class="main-content sm:p-2 h-full flex-1 overflow-y-auto focus:outline-none rounded-t-2xl" :data-max-width-enabled="isMaxWidthEnabled">
<div id="content-card" tabindex="-1" class="focus:outline-none relative content-card grid min-h-full mx-auto">
<div ref="mainContent" id="main-content" class="main-content sm:p-2 h-full flex-1 overflow-y-auto focus:outline-none rounded-t-2xl" :data-max-width-enabled="isMaxWidthEnabled">
<div ref="contentCard" id="content-card" tabindex="-1" class="focus:outline-none relative content-card grid min-h-full mx-auto">
<div
class="content-card-resize-handle"
@pointerdown.prevent="startResize"
@dblclick="resetNavWidth"
/>
<!-- Data attribute used by the CSS style tag below to override max-width when disabled.-->
<div class="w-full min-w-0 mx-auto max-w-page" data-max-width-wrapper>
<slot />
Expand Down
Loading