diff --git a/nx/blocks/nav/nav.js b/nx/blocks/nav/nav.js index 01e84e81..34905439 100644 --- a/nx/blocks/nav/nav.js +++ b/nx/blocks/nav/nav.js @@ -5,10 +5,17 @@ import getSvg from '../../utils/svg.js'; const { nxBase } = getConfig(); const ICONS = [ - `${nxBase}/img/logos/aec.svg`, `${nxBase}/img/icons/S2IconHelp20N-icon.svg`, ]; +async function loadBrandLogoSvg() { + const resp = await fetch(`${nxBase}/img/logos/adobe-branding.svg`); + if (!resp.ok) return null; + const text = await resp.text(); + const doc = new DOMParser().parseFromString(text, 'image/svg+xml'); + return doc.querySelector('svg'); +} + function getDefaultPath() { const { origin } = new URL(import.meta.url); return `${origin}/fragments/nx-nav`; @@ -34,11 +41,17 @@ class Nav extends HTMLElement { await loadArea(doc.body); const sections = doc.querySelectorAll('body > .section'); - // Grab the first link as it will be the main branding const brandLink = doc.querySelector('a'); - brandLink.innerHTML = `${brandLink.innerHTML}`; brandLink.classList.add('nx-nav-brand'); - brandLink.insertAdjacentHTML('afterbegin', ''); + brandLink.setAttribute('aria-label', 'Home'); + brandLink.textContent = ''; + const brandSvg = await loadBrandLogoSvg(); + if (brandSvg) { + const svg = brandSvg.cloneNode(true); + svg.classList.add('icon'); + svg.setAttribute('aria-hidden', 'true'); + brandLink.append(svg); + } const inner = document.createElement('div'); inner.className = 'nx-nav-inner'; diff --git a/nx/img/logos/adobe-branding.svg b/nx/img/logos/adobe-branding.svg new file mode 100644 index 00000000..c6c5a461 --- /dev/null +++ b/nx/img/logos/adobe-branding.svg @@ -0,0 +1,4 @@ + + + + diff --git a/nx2/blocks/action-button/action-button.js b/nx2/blocks/action-button/action-button.js index 13966cf7..0991ec48 100644 --- a/nx2/blocks/action-button/action-button.js +++ b/nx2/blocks/action-button/action-button.js @@ -13,7 +13,11 @@ function decoratePanel(a, hash) { else panel.hidePanel(existing); return; } - await panel.openPanelWithFragment({ width: '400px', beforeMain, fragment: value }); + await panel.openPanelWithFragment({ + width: panel.getDefaultPanelWidthCss(), + beforeMain, + fragment: value, + }); }); } diff --git a/nx2/blocks/canvas-actions/canvas-actions.css b/nx2/blocks/canvas-actions/canvas-actions.css index baf4b248..581a7e30 100644 --- a/nx2/blocks/canvas-actions/canvas-actions.css +++ b/nx2/blocks/canvas-actions/canvas-actions.css @@ -6,7 +6,14 @@ color: var(--s2-gray-800); } -.canvas-actions button { +.canvas-actions { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; +} + +.canvas-actions .publish-btn { display: inline-flex; align-items: center; justify-content: center; @@ -25,22 +32,21 @@ cursor: pointer; } -.canvas-actions button:focus-visible { +.canvas-actions .publish-btn:focus-visible { outline: 2px solid var(--s2-blue-800); outline-offset: 2px; } -.canvas-actions button:disabled { +.canvas-actions .publish-btn:disabled { color: var(--s2-gray-400); background-color: var(--s2-gray-200); cursor: not-allowed; } -.canvas-actions button:hover:not(:disabled) { +.canvas-actions .publish-btn:hover:not(:disabled) { background-color: var(--s2-blue-1000); } -.canvas-actions button:active:not(:disabled) { +.canvas-actions .publish-btn:active:not(:disabled) { background-color: var(--s2-blue-1100); } - diff --git a/nx2/blocks/canvas-actions/canvas-actions.js b/nx2/blocks/canvas-actions/canvas-actions.js index 87ac73a3..20e59e54 100644 --- a/nx2/blocks/canvas-actions/canvas-actions.js +++ b/nx2/blocks/canvas-actions/canvas-actions.js @@ -13,7 +13,7 @@ class NXCanvasActions extends LitElement { render() { return html`
- +
`; } diff --git a/nx2/blocks/canvas/canvas.js b/nx2/blocks/canvas/canvas.js index 656e263b..73e79ebc 100644 --- a/nx2/blocks/canvas/canvas.js +++ b/nx2/blocks/canvas/canvas.js @@ -1,5 +1,10 @@ import { loadStyle } from '../../utils/utils.js'; -import { hidePanel, unhidePanel, openPanelWithFragment } from '../../utils/panel.js'; +import { + getDefaultPanelWidthCss, + hidePanel, + unhidePanel, + openPanelWithFragment, +} from '../../utils/panel.js'; import './nx-canvas-header/nx-canvas-header.js'; const style = await loadStyle(import.meta.url); @@ -30,7 +35,7 @@ async function openCanvasPanel(position) { // Case 3: Panel does not exist yet const aside = await openPanelWithFragment({ - width: '400px', + width: getDefaultPanelWidthCss(), beforeMain: position === 'before', fragment: FRAGMENTS[position], }); diff --git a/nx2/blocks/nav/nav.css b/nx2/blocks/nav/nav.css index e74ec930..3c5dfa88 100644 --- a/nx2/blocks/nav/nav.css +++ b/nx2/blocks/nav/nav.css @@ -16,21 +16,9 @@ } } -.brand-area { - a { - display: block; - height: 100%; - color: inherit; - text-decoration: none; - } - - svg { - height: 100%; - } -} - .action-area { display: none; + flex-shrink: 0; ul { height: 100%; @@ -84,3 +72,138 @@ display: unset; } } + +.brand-area { + display: flex; + align-items: center; + min-width: 0; + flex: 1; + gap: 0; + + a { + display: flex; + align-items: center; + height: 100%; + color: inherit; + text-decoration: none; + } + + /* Logo link — 12px to breadcrumb */ + > a { + flex-shrink: 0; + margin-inline-end: 12px; + } + + a > svg { + width: 24px; + height: 24px; + max-width: 24px; + max-height: 24px; + flex-shrink: 0; + display: block; + box-sizing: border-box; + } + + .nav-home-btn { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-sizing: border-box; + width: 32px; + height: 32px; + padding: 0; + margin: 0; + margin-inline: 16px 12px; + border: none; + border-radius: var(--s2-corner-radius-500); + background: transparent; + color: var(--s2-gray-800); + cursor: pointer; + } + + .nav-home-btn:hover { + background-color: var(--s2-gray-100); + } + + .nav-home-btn:focus-visible { + outline: 2px solid var(--s2-blue-800); + outline-offset: 2px; + } + + .nav-home-btn svg { + display: block; + } +} + +.workspace-breadcrumb { + min-width: 0; + overflow: hidden; + + /* Typography: Component M, Regular (S2 component tokens) */ + + /* Color: content / neutral-subdued — default --s2-gray-700, hover --s2-gray-800 */ + ol { + display: flex; + align-items: center; + flex-wrap: nowrap; + list-style: none; + margin: 0; + padding: 0; + gap: 0; + font-family: var(--s2-font-family); + font-size: var(--s2-component-m-regular-font-size); + line-height: var(--s2-component-m-regular-line-height); + font-weight: var(--s2-component-m-regular-font-weight); + color: var(--s2-gray-700); + } + + .crumb { + display: flex; + align-items: center; + min-width: 0; + } + + .crumb-separator { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-inline: 4px 6px; + list-style: none; + + /* design #505050 — matches --s2-gray-700 in light; token in dark */ + color: var(--s2-gray-700); + pointer-events: none; + user-select: none; + } + + .crumb-chevron { + display: block; + transform: translateY(1px); + } + + .crumb-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; + font-weight: inherit; + color: var(--s2-gray-700); + text-decoration: none; + } + + a.crumb-label:hover { + color: var(--s2-gray-800); + } + + .crumb-label.current { + cursor: default; + color: var(--s2-gray-800); + font-weight: var(--s2-component-m-bold-font-weight); + } + +} diff --git a/nx2/blocks/nav/nav.js b/nx2/blocks/nav/nav.js index f019970f..7d9830d3 100644 --- a/nx2/blocks/nav/nav.js +++ b/nx2/blocks/nav/nav.js @@ -1,19 +1,65 @@ -import { LitElement, html } from 'da-lit'; +import { LitElement, html, nothing } from 'da-lit'; import { getMetadata } from '../../scripts/nx.js'; -import { loadStyle } from '../../utils/utils.js'; +import { loadStyle, HashController } from '../../utils/utils.js'; import { loadFragment } from '../fragment/fragment.js'; -import { loadHrefSvg } from '../../utils/svg.js'; +import { loadHrefSvg, ICONS_BASE } from '../../utils/svg.js'; + +const HOME_ICON_HREF = `${ICONS_BASE}S2_Icon_Home_20_N.svg`; const DEFAULT_NAV_PATH = '/nx/fragments/nav'; const style = await loadStyle(import.meta.url); +/** Resolve against this module so ?nx=local fetches from the Nexter dev origin, not da.live. */ +function getBrandLogoHref() { + return new URL('../../../nx/img/logos/adobe-branding.svg', import.meta.url).href; +} + +function normalizeBrandSvg(svg) { + if (!svg) return; + svg.setAttribute('width', '24'); + svg.setAttribute('height', '24'); + svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); +} + +/** Hash segments after `#/`: org, site, then path parts → Folder > Folder > Page */ +function workspaceBreadcrumbSegments(state) { + if (!state?.org) return []; + const segments = [state.org]; + if (state.site) segments.push(state.site); + if (state.path) { + segments.push(...state.path.split('/').filter(Boolean)); + } + return segments; +} + +function formatBreadcrumbLabel(raw, isLast) { + try { + const decoded = decodeURIComponent(raw); + if (isLast && decoded.endsWith('.html')) return decoded.slice(0, -5); + return decoded; + } catch { + return raw; + } +} + +/** Chevron between breadcrumb segments; fill uses currentColor (see `.crumb-separator`). */ +const BREADCRUMB_CHEVRON = html` + +`; + class NXNav extends LitElement { + details = new HashController(this); + static properties = { path: { attribute: false }, _brand: { state: true }, _actions: { state: true }, + _homeIcon: { state: true }, }; connectedCallback() { @@ -22,6 +68,16 @@ class NXNav extends LitElement { this.loadNav(); } + async firstUpdated() { + const svg = await loadHrefSvg(HOME_ICON_HREF); + if (svg) { + svg.setAttribute('width', '20'); + svg.setAttribute('height', '20'); + svg.setAttribute('aria-hidden', 'true'); + this._homeIcon = svg; + } + } + change(props) { if (props.has('path') && this.path) { this.loadNav(); @@ -37,19 +93,18 @@ class NXNav extends LitElement { } async decorateBrand(brandSection) { - // The first link will always be at least an icon const brandLink = brandSection.querySelector('a'); if (!brandLink) return null; - const { href, textContent } = brandLink; - - // Attempt to find a lockup svg - const hasLockup = href.includes('.svg'); - if (hasLockup) { - brandLink.setAttribute('aria-label', textContent); - brandLink.textContent = ''; - const lockup = await loadHrefSvg(href); - brandLink.append(lockup); + + brandLink.setAttribute('aria-label', 'Adobe'); + brandLink.textContent = ''; + + const graphic = await loadHrefSvg(getBrandLogoHref()); + if (graphic) { + normalizeBrandSvg(graphic); + brandLink.append(graphic); } + brandLink.href = '/'; return brandLink; @@ -73,10 +128,64 @@ class NXNav extends LitElement { return getMetadata('nav-path') || this.path || DEFAULT_NAV_PATH; } + async _onHomeClick() { + const { setPanelsGrid } = await import('../../utils/panel.js'); + if (document.body.classList.contains('sidenav-collapsed')) { + document.body.classList.remove('sidenav-collapsed'); + sessionStorage.setItem('nx-sidenav-visible', 'true'); + } else { + document.body.classList.add('sidenav-collapsed'); + sessionStorage.removeItem('nx-sidenav-visible'); + } + setPanelsGrid(); + this.requestUpdate(); + } + + _renderWorkspaceBreadcrumb() { + const segments = workspaceBreadcrumbSegments(this.details.value); + if (segments.length < 2) return nothing; + + return html` + + `; + } + render() { return html`
+ ${this._brand} + ${this._renderWorkspaceBreadcrumb()}
${this._actions} diff --git a/nx2/scripts/nx.js b/nx2/scripts/nx.js index d010e89c..ce5ee563 100644 --- a/nx2/scripts/nx.js +++ b/nx2/scripts/nx.js @@ -260,6 +260,15 @@ async function decorateDoc() { const template = getMetadata('template'); if (template) document.body.classList.add(template); + if (template === 'app-frame') { + if (sessionStorage.getItem('nx-sidenav-visible') !== 'true') { + document.body.classList.add('sidenav-collapsed'); + } + const { setPanelsGrid, ensureAppFrameSurfaceElevation } = await import('../utils/panel.js'); + setPanelsGrid(); + ensureAppFrameSurfaceElevation(); + } + const scheme = localStorage.getItem('color-scheme'); if (scheme) document.body.classList.add(scheme); diff --git a/nx2/styles/styles.css b/nx2/styles/styles.css index baa5062a..830eda9c 100644 --- a/nx2/styles/styles.css +++ b/nx2/styles/styles.css @@ -208,6 +208,14 @@ --s2-corner-radius-700: 10px; --s2-corner-radius-800: 16px; + /* Drop shadow / elevated — layered (alias/drop-shadow: ambient, transition, elevated-key) */ + --nx-drop-shadow-ambient: 0 4px 12px 0 rgb(0 0 0 / 8%); + --nx-drop-shadow-transition: 0 2px 6px 0 rgb(0 0 0 / 4%); + --nx-drop-shadow-elevated-key: 0 0 2px 0 rgb(0 0 0 / 12%); + + /* App-frame `main` + side panels (desktop + mobile shell) */ + --nx-app-shell-radius: 12px; + /* heading */ --s2-heading-size-xs: 18px; --s2-heading-size-s: 20px; @@ -422,7 +430,11 @@ html:has(meta[content="edge-delivery"]) { grid-template: "header" var(--s2-nav-height) "main" 1fr; - background-color: light-dark(rgb(240 240 240), rgb(17 17 17)); + background-color: var(--s2-gray-50); + + &.nx-panel-resize-active { + cursor: ew-resize; + } header { grid-area: header; @@ -436,24 +448,22 @@ html:has(meta[content="edge-delivery"]) { grid-area: main; background-color: light-dark(#fff, #000); max-height: 100%; + border-radius: var(--nx-app-shell-radius) var(--nx-app-shell-radius) 0 0; } - .section { - &.container { - .default-content, .block-content { - max-width: var(--section-container-width); - margin-inline: auto; - } - } + /* Elevated surface: center `main` or side panels (pointer-activated; see panel.js) */ + main, + aside.panel .panel-wrapper { + transition: box-shadow 0.15s ease; } - &:has(aside.panel:not([hidden]))::before { - content: ''; - position: fixed; - inset: 0; - z-index: 50; - background-color: light-dark(rgb(0 0 0 / 40%), rgb(0 0 0 / 55%)); - pointer-events: auto; + main.nx-main-raised { + position: relative; + z-index: 1; + box-shadow: + var(--nx-drop-shadow-ambient), + var(--nx-drop-shadow-transition), + var(--nx-drop-shadow-elevated-key); } aside.panel { @@ -471,10 +481,16 @@ html:has(meta[content="edge-delivery"]) { display: block; position: relative; box-sizing: border-box; + + /* Programmatic / click focus; visual is layered shadow via :focus-within */ + &:focus { + outline: none; + } + height: 100%; max-height: 100%; margin: 0; - border-radius: 24px; + border-radius: var(--nx-app-shell-radius); background-color: light-dark(#fff, #000); .panel-shell { @@ -505,12 +521,11 @@ html:has(meta[content="edge-delivery"]) { .panel-resize-handle { display: none; position: absolute; - top: 50%; - translate: 0 -50%; + top: 4px; + bottom: 4px; z-index: 2; box-sizing: border-box; - width: 12px; - height: 56px; + width: 16px; padding: 0; margin: 0; border: none; @@ -520,38 +535,95 @@ html:has(meta[content="edge-delivery"]) { color: inherit; } + /* Gutter pill: gray-400, only when pointer is in the resize zone (or dragging / touch) */ .panel-resize-handle::before { content: ''; position: absolute; left: 50%; - top: 50%; - translate: -50% -50%; - width: 4px; - height: 48px; + top: 0; + bottom: 0; + translate: -50% 0; + width: 2px; border-radius: 999px; - background-color: var(--s2-gray-600); + background-color: var(--s2-gray-400); + opacity: 0; + transition: opacity 0.12s ease 0s; + } + + .panel-resize-handle:focus-visible { + outline: none; } - .panel-resize-handle:hover::before, .panel-resize-handle:focus-visible::before { - background-color: var(--s2-gray-900); + background-color: var(--s2-gray-400); + opacity: 1; } - .panel-resize-handle:focus-visible { - outline: 2px solid var(--s2-blue-800); - outline-offset: 2px; + .panel-resize-handle:active::before { + background-color: var(--s2-gray-400); + opacity: 1; + } + + /* Show gutter pill only when pointer is in the handle hit zone (between main & panel) */ + @media (hover: hover) { + /* Short dwell so the pill doesn’t flash on fast pointer moves across the hit zone */ + .panel-resize-handle:hover::before { + opacity: 1; + background-color: var(--s2-gray-400); + transition: opacity 0.12s ease 0.2s; + } + } + + @media (hover: none) { + .panel-resize-handle::before { + opacity: 1; + } } .panel-resize-handle-trailing { - right: -12px; + right: -14px; } .panel-resize-handle-leading { - left: -12px; + left: -14px; } } } + /* During drag, only the handle being dragged stays visible (see panel.js) */ + aside.panel.nx-panel-resizing .panel-wrapper .panel-resize-handle::before { + opacity: 1; + } + + /* Side panels: `.nx-panel-raised` matches main behavior (see panel.js) */ + aside.panel .panel-wrapper:focus-within, + aside.panel .panel-wrapper.nx-panel-raised { + position: relative; + z-index: 2; + box-shadow: + var(--nx-drop-shadow-ambient), + var(--nx-drop-shadow-transition), + var(--nx-drop-shadow-elevated-key); + } + + .section { + &.container { + .default-content, .block-content { + max-width: var(--section-container-width); + margin-inline: auto; + } + } + } + + &:has(aside.panel:not([hidden]))::before { + content: ''; + position: fixed; + inset: 0; + z-index: 50; + background-color: light-dark(rgb(0 0 0 / 40%), rgb(0 0 0 / 55%)); + pointer-events: auto; + } + footer { display: none; } @@ -580,15 +652,31 @@ html:has(meta[content="edge-delivery"]) { display: unset; } + &.sidenav-collapsed > nav { + visibility: hidden; + overflow: hidden; + width: 0; + min-width: 0; + pointer-events: none; + } + main { display: grid; - margin: 0 12px 0 0; - border-radius: var(--s2-corner-radius-800) var(--s2-corner-radius-800) 0 0; + margin: 0 12px; max-height: 100%; overflow-y: auto; } + /* Single 12px gutter: `.panel-wrapper` already uses margin-inline 12px */ + &:has(aside.panel[data-position="before"]:not([hidden])) main { + margin-left: 0; + } + + &:has(aside.panel[data-position="after"]:not([hidden])) main { + margin-right: 0; + } + &:has(aside.panel:not([hidden]))::before { display: none; pointer-events: none; @@ -597,11 +685,15 @@ html:has(meta[content="edge-delivery"]) { aside.panel { position: static; + /* Match panel.js: 240px surface + 12px `.panel-wrapper` margin each side */ + min-width: calc(240px + 24px); + .panel-wrapper { height: calc(100vh - var(--s2-nav-height) - var(--panel-bottom-margin)); max-height: none; - margin: 0 12px var(--panel-bottom-margin) 0; - border-radius: var(--s2-corner-radius-800); + margin-block: 0 var(--panel-bottom-margin); + margin-inline: 12px; + border-radius: var(--nx-app-shell-radius); .panel-resize-handle { display: block; diff --git a/nx2/utils/panel.js b/nx2/utils/panel.js index a4ecf412..be4be730 100644 --- a/nx2/utils/panel.js +++ b/nx2/utils/panel.js @@ -1,7 +1,49 @@ import { getMetadata } from '../scripts/nx.js'; -const PANEL_WIDTH_MIN = 120; -const PANEL_WIDTH_MAX = () => Math.min(1600, window.innerWidth * 0.4); +/** Default painted panel surface width (the white column users see inside wrapper margins). */ +const PANEL_SURFACE_DEFAULT_PX = 400; +/** Minimum width of the painted panel surface (inside `.panel-wrapper` margins). */ +const PANEL_SURFACE_MIN_PX = 240; +/** + * Desktop `.panel-wrapper` uses `margin-inline: 12px`; the grid sizes `aside`, so + * track min = surface min + those insets (otherwise ~216px “looks” like the minimum). + */ +const PANEL_WRAPPER_MARGIN_INLINE_PX = 12; + +/** + * Default grid track width (`aside` column): on desktop includes wrapper side margins so + * the painted surface matches `PANEL_SURFACE_DEFAULT_PX`; on small viewports margins are 0. + */ +export function getDefaultPanelTrackWidthPx() { + if (typeof window === 'undefined') { + return PANEL_SURFACE_DEFAULT_PX + 2 * PANEL_WRAPPER_MARGIN_INLINE_PX; + } + return window.matchMedia('(min-width: 600px)').matches + ? PANEL_SURFACE_DEFAULT_PX + 2 * PANEL_WRAPPER_MARGIN_INLINE_PX + : PANEL_SURFACE_DEFAULT_PX; +} + +export function getDefaultPanelWidthCss() { + return `${getDefaultPanelTrackWidthPx()}px`; +} +/** Desktop grid track min so the painted surface is still `PANEL_SURFACE_MIN_PX` after margins. */ +const DESKTOP_PANEL_TRACK_MIN_PX = PANEL_SURFACE_MIN_PX + 2 * PANEL_WRAPPER_MARGIN_INLINE_PX; +/** `main` and any peer side panel **track** each stay at least this wide when you resize. */ +const REGION_MIN_WIDTH_PX = PANEL_SURFACE_MIN_PX; +/** + * Approximate horizontal margin budget (sidenav gap, main/panel `margin-inline`, gutters). + * Kept in sync-ish with app-frame `styles.css` (~12px rhythm). + */ +const APP_FRAME_WIDTH_MARGIN_BUDGET = 72; + +const NAV_WIDTH_PX = 56; + +/** Below 600px the shell uses full-bleed panel chrome (`margin: 0` on `.panel-wrapper`). */ +function panelTrackMinPx() { + return window.matchMedia('(min-width: 600px)').matches + ? DESKTOP_PANEL_TRACK_MIN_PX + : PANEL_SURFACE_MIN_PX; +} function parsePanelWidth(aside) { const w = aside.dataset.width?.trim(); @@ -9,12 +51,49 @@ function parsePanelWidth(aside) { return aside.getBoundingClientRect().width; } +/** + * Max width for this panel’s column: user can grow until `main` and the peer panel (if any) + * would go below REGION_MIN_WIDTH_PX. No arbitrary hard cap beyond the viewport budget. + */ +function getPanelWidthMaxPx(aside) { + const inner = window.innerWidth; + const sidenav = document.body.classList.contains('sidenav-collapsed') ? 0 : NAV_WIDTH_PX; + const before = document.body.querySelector('aside.panel[data-position="before"]:not([hidden])'); + const after = document.body.querySelector('aside.panel[data-position="after"]:not([hidden])'); + const isBefore = aside.dataset.position === 'before'; + + const trackMin = panelTrackMinPx(); + + let peerOtherPx = 0; + if (isBefore) { + if (after && after !== aside) { + peerOtherPx = Math.max(trackMin, parsePanelWidth(after)); + } + } else if (before && before !== aside) { + peerOtherPx = Math.max(trackMin, parsePanelWidth(before)); + } + + const reserved = sidenav + peerOtherPx + REGION_MIN_WIDTH_PX + APP_FRAME_WIDTH_MARGIN_BUDGET; + const fromLayout = inner - reserved; + return Math.max(trackMin, fromLayout); +} + function applyPanelWidth(aside, px) { - const clamped = `${Math.max(PANEL_WIDTH_MIN, Math.min(PANEL_WIDTH_MAX(), Math.round(px)))}px`; + const max = getPanelWidthMaxPx(aside); + const min = panelTrackMinPx(); + const clamped = `${Math.max(min, Math.min(max, Math.round(px)))}px`; aside.dataset.width = clamped; aside.style.width = clamped; } +/** If viewport or peer panels changed, clamp stored widths so layout stays valid. */ +export function clampAllPanelWidthsToLayout() { + document.body.querySelectorAll('aside.panel:not([hidden])').forEach((aside) => { + const el = /** @type {HTMLElement} */ (aside); + applyPanelWidth(el, parsePanelWidth(el)); + }); +} + const PANEL_STORAGE_KEY = 'nx-panels'; function getPanelStore() { @@ -43,15 +122,16 @@ export function setPanelsGrid() { const before = body.querySelector('aside.panel[data-position="before"]:not([hidden])'); const after = body.querySelector('aside.panel[data-position="after"]:not([hidden])'); + const sidenavCollapsed = body.classList.contains('sidenav-collapsed'); const getWidth = (el) => { const w = el?.dataset.width?.trim(); - return w ? `min(${w}, 40vw)` : 'minmax(0, auto)'; + return w && /^\d+(\.\d+)?px$/i.test(w) ? w : 'minmax(0, auto)'; }; const header = ['header']; const content = ['sidenav']; - const columns = ['var(--s2-nav-width)']; + const columns = [sidenavCollapsed ? '0px' : 'var(--s2-nav-width)']; if (before) { before.style.gridArea = 'panel-before'; @@ -75,10 +155,16 @@ export function setPanelsGrid() { body.style.setProperty('--app-frame-columns', columns.join(' ')); } +const RESIZE_ACTIVE_CLASS = 'nx-panel-resize-active'; +const PANEL_RESIZING_CLASS = 'nx-panel-resizing'; + function resizePointerDown(downEvent) { - const handle = downEvent.currentTarget; + const handle = /** @type {HTMLButtonElement} */ (downEvent.currentTarget); const aside = handle.closest('aside.panel'); if (!aside || downEvent.button !== 0) return; + + downEvent.preventDefault(); + const deltaSign = aside.dataset.position === 'before' ? 1 : -1; handle.setPointerCapture(downEvent.pointerId); @@ -86,28 +172,195 @@ function resizePointerDown(downEvent) { const startW = parsePanelWidth(aside); const prevUserSelect = document.body.style.userSelect; document.body.style.userSelect = 'none'; + document.body.classList.add(RESIZE_ACTIVE_CLASS); + aside.classList.add(PANEL_RESIZING_CLASS); - const onPointerMove = (moveEvent) => { - const dx = moveEvent.clientX - startX; - applyPanelWidth(aside, startW + deltaSign * dx); + let rafId = 0; + let pendingDx = 0; + + const flushMove = () => { + rafId = 0; + applyPanelWidth(aside, startW + deltaSign * pendingDx); setPanelsGrid(); }; - const onPointerUp = (upEvent) => { - handle.releasePointerCapture(upEvent.pointerId); + const onPointerMove = (moveEvent) => { + pendingDx = moveEvent.clientX - startX; + if (!rafId) { + rafId = requestAnimationFrame(flushMove); + } + }; + + const captureId = downEvent.pointerId; + + let ended = false; + function endResize() { + if (ended) return; + ended = true; + if (rafId) cancelAnimationFrame(rafId); + rafId = 0; + flushMove(); document.body.style.userSelect = prevUserSelect; + document.body.classList.remove(RESIZE_ACTIVE_CLASS); + aside.classList.remove(PANEL_RESIZING_CLASS); handle.removeEventListener('pointermove', onPointerMove); - handle.removeEventListener('pointerup', onPointerUp); - handle.removeEventListener('pointercancel', onPointerUp); + handle.removeEventListener('pointerup', endResize); + handle.removeEventListener('pointercancel', endResize); + handle.removeEventListener('lostpointercapture', endResize); + try { + handle.releasePointerCapture(captureId); + } catch { + /* already released */ + } savePanelState(aside.dataset.position, { width: aside.dataset.width, fragment: aside.dataset.fragment, }); - }; + } handle.addEventListener('pointermove', onPointerMove); - handle.addEventListener('pointerup', onPointerUp); - handle.addEventListener('pointercancel', onPointerUp); + handle.addEventListener('pointerup', endResize); + handle.addEventListener('pointercancel', endResize); + handle.addEventListener('lostpointercapture', endResize); +} + +function resetPanelTrackToDefaultWidth(aside) { + applyPanelWidth(aside, getDefaultPanelTrackWidthPx()); + setPanelsGrid(); + savePanelState(aside.dataset.position, { + width: aside.dataset.width, + fragment: aside.dataset.fragment, + }); +} + +const PANEL_RAISED_CLASS = 'nx-panel-raised'; +const MAIN_RAISED_CLASS = 'nx-main-raised'; + +function panelWrapperFromAside(aside) { + return aside?.querySelector(':scope > .panel-wrapper') ?? null; +} + +function lowerAppFrameMain() { + document.querySelector('main')?.classList.remove(MAIN_RAISED_CLASS); +} + +function raiseAppFrameMain() { + document.querySelector('main')?.classList.add(MAIN_RAISED_CLASS); +} + +/** If no side panel is elevated, treat the canvas `main` as active (center column). */ +function syncMainRaisedIfNoPanelRaised() { + if (getMetadata('template') !== 'app-frame') return; + const anyPanelRaised = document.querySelector( + `aside.panel:not([hidden]) .panel-wrapper.${PANEL_RAISED_CLASS}`, + ); + if (!anyPanelRaised) { + raiseAppFrameMain(); + } +} + +/** Set elevated shadow on one panel only (or clear all when `panelAside` is null). */ +function setActivePanelAside(panelAside) { + if (panelAside) { + lowerAppFrameMain(); + } + document.querySelectorAll('aside.panel:not([hidden])').forEach((aside) => { + const w = panelWrapperFromAside(aside); + if (!w) return; + if (panelAside && aside === panelAside) { + w.classList.add(PANEL_RAISED_CLASS); + } else { + w.classList.remove(PANEL_RAISED_CLASS); + } + }); +} + +function findPanelAsideInComposedPath(path) { + for (const node of path) { + if ( + node instanceof Element + && node.localName === 'aside' + && node.classList.contains('panel') + && !node.hidden + ) { + return /** @type {HTMLElement} */ (node); + } + } + return null; +} + +function composedPathIncludesMain(path) { + for (const node of path) { + if (node instanceof Element && node.localName === 'main') return true; + } + return false; +} + +function onDocumentPointerDownForSurfaceRaise(event) { + if (event.button !== 0) return; + if (getMetadata('template') !== 'app-frame') return; + const path = event.composedPath(); + const hitPanel = findPanelAsideInComposedPath(path); + if (hitPanel) { + setActivePanelAside(hitPanel); + return; + } + if (composedPathIncludesMain(path)) { + setActivePanelAside(null); + raiseAppFrameMain(); + } +} + +let panelRaisePointerHookInstalled = false; +let panelLayoutResizeHookInstalled = false; + +function ensurePanelLayoutClampOnResize() { + if (panelLayoutResizeHookInstalled || typeof window === 'undefined') return; + panelLayoutResizeHookInstalled = true; + window.addEventListener('resize', () => { + if (getMetadata('template') !== 'app-frame') return; + clampAllPanelWidthsToLayout(); + setPanelsGrid(); + }); +} + +function ensurePanelRaisePointerHook() { + if (panelRaisePointerHookInstalled || typeof document === 'undefined') return; + panelRaisePointerHookInstalled = true; + document.addEventListener('pointerdown', onDocumentPointerDownForSurfaceRaise, true); +} + +/** Default: center `main` is elevated until a side panel is clicked (app-frame only). */ +export function ensureAppFrameSurfaceElevation() { + if (getMetadata('template') !== 'app-frame') return; + ensurePanelRaisePointerHook(); + ensurePanelLayoutClampOnResize(); + syncMainRaisedIfNoPanelRaised(); +} + +/** Walk composedPath up to `wrapper`; if we hit a real control first, skip panel focus. */ +function panelPointerShouldFocusWrapper(event, wrapper) { + const path = event.composedPath(); + const wrapIdx = path.indexOf(wrapper); + if (wrapIdx === -1) return false; + for (let i = 0; i < wrapIdx; i += 1) { + const node = path[i]; + if (node.nodeType === Node.ELEMENT_NODE) { + const el = /** @type {Element} */ (node); + if ( + el.matches( + 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [contenteditable="true"]', + ) + ) { + return false; + } + if (el !== wrapper && el.matches('[tabindex]')) { + const ti = Number.parseInt(el.getAttribute('tabindex') ?? '', 10); + if (!Number.isNaN(ti) && ti >= 0) return false; + } + } + } + return true; } function buildPanelDOM(aside) { @@ -115,6 +368,13 @@ function buildPanelDOM(aside) { const wrapper = document.createElement('div'); wrapper.className = 'panel-wrapper'; + wrapper.tabIndex = 0; + wrapper.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + if (!panelPointerShouldFocusWrapper(e, wrapper)) return; + if (document.activeElement === wrapper) return; + wrapper.focus({ preventScroll: true }); + }); const shell = document.createElement('div'); shell.className = 'panel-shell'; @@ -125,15 +385,26 @@ function buildPanelDOM(aside) { const handle = document.createElement('button'); handle.type = 'button'; handle.className = `panel-resize-handle panel-resize-handle-${edge}`; - handle.setAttribute('aria-label', 'Resize panel'); + handle.setAttribute('aria-label', 'Resize panel. Double-click to restore default width.'); handle.addEventListener('pointerdown', resizePointerDown); + handle.addEventListener('dblclick', (e) => { + e.preventDefault(); + const panelAside = handle.closest('aside.panel'); + if (!panelAside) return; + resetPanelTrackToDefaultWidth(panelAside); + }); shell.append(body); wrapper.append(shell, handle); aside.append(wrapper); } -export function createPanel({ width = '400px', beforeMain = false, content, fragment } = {}) { +export function createPanel({ + width = getDefaultPanelWidthCss(), + beforeMain = false, + content, + fragment, +} = {}) { const aside = document.createElement('aside'); aside.classList.add('panel'); aside.dataset.width = width; @@ -146,38 +417,51 @@ export function createPanel({ width = '400px', beforeMain = false, content, frag if (content) aside.querySelector('.panel-body').append(content); - savePanelState(position, { width, fragment }); - if (beforeMain) { document.querySelector('main').before(aside); } else { document.querySelector('main').after(aside); } + ensurePanelRaisePointerHook(); + ensurePanelLayoutClampOnResize(); + applyPanelWidth(aside, parsePanelWidth(aside)); + setPanelsGrid(); + setActivePanelAside(aside); + + savePanelState(position, { + width: aside.dataset.width, + fragment: fragment ?? aside.dataset.fragment, + }); + return aside; } export function hidePanel(aside) { + panelWrapperFromAside(aside)?.classList.remove(PANEL_RAISED_CLASS); removePanelState(aside.dataset.position); aside.hidden = true; setPanelsGrid(); + syncMainRaisedIfNoPanelRaised(); } export function unhidePanel(aside) { aside.hidden = false; + ensurePanelRaisePointerHook(); + ensurePanelLayoutClampOnResize(); + applyPanelWidth(aside, parsePanelWidth(aside)); + setPanelsGrid(); savePanelState(aside.dataset.position, { width: aside.dataset.width, fragment: aside.dataset.fragment, }); - setPanelsGrid(); + setActivePanelAside(aside); } export { getPanelStore }; export function showPanel(opts) { - const aside = createPanel(opts); - setPanelsGrid(); - return aside; + return createPanel(opts); } export async function loadPanelContent(value) { @@ -191,7 +475,11 @@ export async function loadPanelContent(value) { return { content: await mod.getPanel(), fragment: undefined }; } -export async function openPanelWithFragment({ width = '400px', beforeMain = false, fragment } = {}) { +export async function openPanelWithFragment({ + width = getDefaultPanelWidthCss(), + beforeMain = false, + fragment, +} = {}) { const { content, fragment: persistedFragment } = await loadPanelContent(fragment); if (!content) return undefined; return showPanel({ width, beforeMain, content, fragment: persistedFragment });