diff --git a/mu-plugins/blocks/local-navigation-bar/postcss/style.pcss b/mu-plugins/blocks/local-navigation-bar/postcss/style.pcss index 0a4b69567..cc94af9ad 100644 --- a/mu-plugins/blocks/local-navigation-bar/postcss/style.pcss +++ b/mu-plugins/blocks/local-navigation-bar/postcss/style.pcss @@ -123,6 +123,13 @@ } } +@media (min-width: 890px) { + /* stylelint-disable selector-id-pattern */ + #wp--skip-link--target { + scroll-margin-top: var(--wp--custom--local-navigation-bar--spacing--height, 0); + } +} + /* Set up the custom properties. These can be overridden by settings in theme.json. */ :where(body) { --wp--custom--local-navigation-bar--spacing--height: 60px; diff --git a/mu-plugins/blocks/sidebar-container/index.php b/mu-plugins/blocks/sidebar-container/index.php index 248c386b2..9e126cfd8 100644 --- a/mu-plugins/blocks/sidebar-container/index.php +++ b/mu-plugins/blocks/sidebar-container/index.php @@ -39,15 +39,19 @@ function init() { * @return string Returns the block markup. */ function render( $attributes, $content, $block ) { - $back_to_top = sprintf( - '', - esc_html__( '↑ Back to top', 'wporg' ) - ); - $wrapper_attributes = get_block_wrapper_attributes(); + $inline_breakpoint = $attributes['inlineBreakpoint']; + $back_to_top = $attributes['hasBackToTop'] + ? sprintf( + '', + esc_html__( '↑ Back to top', 'wporg' ) + ) + : ''; + return sprintf( - '
%2$s%3$s
', + '
%3$s%4$s
', $wrapper_attributes, + esc_attr( $inline_breakpoint ), $content, $back_to_top ); diff --git a/mu-plugins/blocks/sidebar-container/postcss/style.pcss b/mu-plugins/blocks/sidebar-container/postcss/style.pcss index 20d7aedfb..bf19be0f1 100644 --- a/mu-plugins/blocks/sidebar-container/postcss/style.pcss +++ b/mu-plugins/blocks/sidebar-container/postcss/style.pcss @@ -1,62 +1,103 @@ -.wp-block-wporg-sidebar-container .is-link-to-top { - display: none; +.wp-block-wporg-sidebar-container { + /* stylelint-disable-next-line length-zero-no-unit */ + --local--offset-top: var(--wp-admin--admin-bar--height, 0px); - & a { - text-decoration-line: none; + /* These vars are used in JS calcs */ + --local--nav--offset: var(--wp--custom--local-navigation-bar--spacing--height, 60px); + --local--padding: var(--wp--preset--spacing--20); - &:hover { - text-decoration-line: underline; - } + /* Account for local nav height on larger screens where it becomes fixed. */ + @media (min-width: 890px) { + --local--nav--offset: 0; + --local--offset-top: calc(var(--wp-admin--admin-bar--height, 0px) + var(--wp--custom--local-navigation-bar--spacing--height, 60px)); } -} -/* Slot the search & table of contents into a floating sidebar on large screens. */ -@media (min-width: 1200px) { - .wp-block-wporg-sidebar-container { - --local--block-end-sidebar--width: 340px; + & .is-link-to-top { + display: none; - position: absolute; - top: calc(var(--wp-global-header-offset, 90px) + var(--wp--custom--local-navigation-bar--spacing--height, 60px)); + & a { + text-decoration-line: none; + + &:hover { + text-decoration-line: underline; + } + } + } + + /* Slot the search & table of contents into a floating sidebar on large screens. */ + &.is-floating-sidebar { + --local--block-end-sidebar--width: 356px; - /* Right offset should be "edge spacing" at minimum, otherwise calculate it to be centered. */ - right: max(var(--wp--preset--spacing--edge-space), calc((100% - var(--wp--style--global--wide-size)) / 2)); width: var(--local--block-end-sidebar--width); - margin-top: var(--wp--custom--wporg-sidebar-container--spacing--margin--top); - margin-bottom: 0 !important; + padding-bottom: var(--local--padding); + overflow-y: scroll; + overscroll-behavior: contain; + scrollbar-color: var(--wp--preset--color--charcoal-5) transparent; - &.is-fixed-sidebar { - position: fixed; - top: 0; + /* Custom scrollbar so that it can be made visible on hover */ + &::-webkit-scrollbar, + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &::-webkit-scrollbar-thumb { + &:active, + &:hover { + background-color: var(--wp--preset--color--charcoal-4) !important; + } + } - /* Make the space above the sidebar the same as the height of the local nav. */ - margin-top: calc(var(--wp-admin--admin-bar--height, 0px) + var(--wp--custom--local-navigation-bar--spacing--height, 60px) * 2); + &:active, + &:focus-within, + &:focus, + &:hover { + &::-webkit-scrollbar-thumb { + background-color: var(--wp--preset--color--charcoal-5); + border: 4px solid transparent; + background-clip: content-box; + border-radius: 10px; + } } - &.is-bottom-sidebar { - position: absolute; + & > * { + padding-right: 16px; } - &.is-fixed-sidebar .is-link-to-top, - &.is-bottom-sidebar .is-link-to-top { - display: block; - margin-top: 0; + &.is-fixed-sidebar { + position: fixed; + top: 0; + height: calc(100vh - var(--local--offset-top)); + margin-top: var(--local--offset-top) !important; + padding: var(--local--padding) 0; + + & .is-link-to-top { + display: block; - & a { - color: var(--wp--preset--color--charcoal-4); + & a { + color: var(--wp--preset--color--charcoal-4); + } } } - .wp-block-wporg-table-of-contents + .is-link-to-top { - border-top: 1px solid var(--wp--preset--color--light-grey-1); + & * + .is-link-to-top { padding-top: var(--wp--preset--spacing--20); + border-top: 1px solid var(--wp--preset--color--light-grey-1); } } } -@media (min-width: 890px) { - /* stylelint-disable selector-id-pattern */ - #wp--skip-link--target { - scroll-margin-top: var(--wp--custom--local-navigation-bar--spacing--height, 0); +main .wp-block-wporg-sidebar-container { + + /* Hide the main sidebar until layout classes have been applied, to avoid FOUC */ + display: none; + + &.is-floating-sidebar { + position: absolute; + top: calc(var(--wp-global-header-offset, 90px) + var(--wp--custom--local-navigation-bar--spacing--height, 60px)); + margin-top: var(--wp--custom--wporg-sidebar-container--spacing--margin--top); + + /* Right offset should be "edge spacing" at minimum, otherwise calculate it to be centered. */ + right: max(var(--wp--preset--spacing--edge-space), calc((100% - var(--wp--style--global--wide-size)) / 2)); } } diff --git a/mu-plugins/blocks/sidebar-container/src/block.json b/mu-plugins/blocks/sidebar-container/src/block.json index 90d9b5ede..557d1652f 100644 --- a/mu-plugins/blocks/sidebar-container/src/block.json +++ b/mu-plugins/blocks/sidebar-container/src/block.json @@ -7,7 +7,16 @@ "category": "layout", "description": "A sticky container to be used in 2-column layouts.", "textdomain": "wporg", - "attributes": {}, + "attributes": { + "hasBackToTop": { + "type": "boolean", + "default": true + }, + "inlineBreakpoint": { + "type": "string", + "default": "1200px" + } + }, "supports": { "inserter": false, "__experimentalLayout": true, diff --git a/mu-plugins/blocks/sidebar-container/src/view.js b/mu-plugins/blocks/sidebar-container/src/view.js index 04f7ff705..f2540f312 100644 --- a/mu-plugins/blocks/sidebar-container/src/view.js +++ b/mu-plugins/blocks/sidebar-container/src/view.js @@ -1,20 +1,13 @@ /** * Fallback values for custom properties match CSS defaults. */ -const globalNavHeight = 90; - -const LOCAL_NAV_HEIGHT = getCustomPropValue( '--wp--custom--local-navigation-bar--spacing--height' ) || 60; -const ADMIN_BAR_HEIGHT = parseInt( - window.getComputedStyle( document.documentElement ).getPropertyValue( 'margin-top' ), - 10 -); -const SPACE_FROM_BOTTOM = getCustomPropValue( '--wp--preset--spacing--edge-space' ) || 80; const SPACE_TO_TOP = getCustomPropValue( '--wp--custom--wporg-sidebar-container--spacing--margin--top' ) || 80; -const FIXED_HEADER_HEIGHT = globalNavHeight + LOCAL_NAV_HEIGHT + ADMIN_BAR_HEIGHT; -const SCROLL_POSITION_TO_FIX = globalNavHeight + SPACE_TO_TOP - LOCAL_NAV_HEIGHT - ADMIN_BAR_HEIGHT; -let container; +let containers; let mainEl; +let adminBarHeight; +let globalNavHeight; +const scrollHandlers = []; /** * Get the value of a CSS custom property. @@ -26,6 +19,9 @@ let mainEl; */ function getCustomPropValue( name, element = document.body ) { const value = window.getComputedStyle( element ).getPropertyValue( name ); + if ( '0' === value ) { + return 0; + } if ( 'px' === value.slice( -2 ) ) { return Number( value.replace( 'px', '' ) ); } @@ -33,113 +29,82 @@ function getCustomPropValue( name, element = document.body ) { } /** - * Check the position of the sidebar vs the height of the viewport & page - * container, and toggle the "bottom" class to position the sidebar without - * overlapping the footer. + * Check the position of the sidebar relative to the scroll position, + * and toggle the "fixed" class at a certain point. + * Reduce the height of the sidebar to stop it overlapping the footer. * - * @return {boolean} True if the sidebar is at the bottom of the page. + * @param {HTMLElement} container The sidebar container. + * @return {Function} onScroll The sidebar scroll handler. */ -function onScroll() { - // Only run the scroll code if the sidebar is floating on a wide screen. - if ( ! mainEl || ! container || ! window.matchMedia( '(min-width: 1200px)' ).matches ) { - return false; - } - - const scrollPosition = window.scrollY - ADMIN_BAR_HEIGHT; - - if ( ! container.classList.contains( 'is-bottom-sidebar' ) ) { - const footerStart = mainEl.offsetTop + mainEl.offsetHeight; - // The pixel location of the bottom of the sidebar, relative to the top of the page. - const sidebarBottom = scrollPosition + container.offsetHeight + container.offsetTop - ADMIN_BAR_HEIGHT; +function createScrollHandler( container ) { + return function onScroll() { + // Only run the scroll code if the sidebar is floating. + if ( ! container.classList.contains( 'is-floating-sidebar' ) ) { + return false; + } - // Is the sidebar bottom crashing into the footer? - if ( footerStart - SPACE_FROM_BOTTOM < sidebarBottom ) { - container.classList.add( 'is-bottom-sidebar' ); + const { scrollY, innerHeight: windowHeight } = window; + const scrollPosition = scrollY - adminBarHeight; + const localNavOffset = getCustomPropValue( '--local--nav--offset', container ); + const paddingTop = getCustomPropValue( '--local--padding', container ); - // Bottom sidebar is absolutely positioned, so we need to set the top relative to the page origin. - // The pixel location of the top of the sidebar, relative to the footer. - const sidebarTop = - footerStart - container.offsetHeight - LOCAL_NAV_HEIGHT * 2 + ADMIN_BAR_HEIGHT - SPACE_FROM_BOTTOM; - container.style.setProperty( 'top', `${ sidebarTop }px` ); + // Toggle the fixed position based on whether the scrollPosition is greater than the + // initial gap from the top minus the padding applied when fixed. + const shouldFix = + scrollPosition > SPACE_TO_TOP + globalNavHeight + localNavOffset - adminBarHeight - paddingTop; + container.classList.toggle( 'is-fixed-sidebar', shouldFix ); - return true; + // If the sidebar is fixed and the footer is visible in the viewport, reduce the height to stop overlap. + const footerStart = mainEl.offsetTop + mainEl.offsetHeight; + if ( shouldFix && footerStart < scrollPosition + windowHeight ) { + container.style.setProperty( 'height', `${ footerStart - scrollPosition - container.offsetTop }px` ); + } else { + container.style.removeProperty( 'height' ); } - } else if ( container.getBoundingClientRect().top > LOCAL_NAV_HEIGHT * 2 + ADMIN_BAR_HEIGHT ) { - // If the top of the sidebar is above the top fixing position, switch back to just a fixed sidebar. - container.classList.remove( 'is-bottom-sidebar' ); - container.style.removeProperty( 'top' ); - } - - // Toggle the fixed position based on whether the scrollPosition is greater than the initial gap from the top. - container.classList.toggle( 'is-fixed-sidebar', scrollPosition > SCROLL_POSITION_TO_FIX ); - - return false; + }; } -function isSidebarWithinViewport() { - if ( ! container ) { - return false; - } - // Usable viewport height. - const viewHeight = window.innerHeight - LOCAL_NAV_HEIGHT + ADMIN_BAR_HEIGHT; - // Get the height of the sidebar, plus the top offset and 60px for the - // "Back to top" link, which isn't visible until `is-fixed-sidebar` is - // added, therefore not included in the offsetHeight value. - const sidebarHeight = container.offsetHeight + LOCAL_NAV_HEIGHT + 60; - // If the sidebar is shorter than the view area, apply the class so - // that it's fixed and scrolls with the page content. - return sidebarHeight < viewHeight; +/** + * Set the height for the admin bar and global nav vars. + * Set the floating sidebar class on each container based on its breakpoint. + * Show hidden containers after layout. + */ +function onResize() { + adminBarHeight = parseInt( + window.getComputedStyle( document.documentElement ).getPropertyValue( 'margin-top' ), + 10 + ); + globalNavHeight = getCustomPropValue( '--wp-global-header-height' ) || 90; + + containers.forEach( ( container ) => { + // Toggle the floating class based on the configured breakpoint. + const shouldFloat = window.matchMedia( `(min-width: ${ container.dataset.breakpoint })` ).matches; + container.classList.toggle( 'is-floating-sidebar', shouldFloat ); + // Show the sidebar after layout, if it has been hidden to avoid FOUC. + if ( 'none' === window.getComputedStyle( container ).display ) { + container.style.setProperty( 'display', 'revert' ); + } + } ); + + scrollHandlers.forEach( ( handler ) => handler() ); } function init() { - container = document.querySelector( '.wp-block-wporg-sidebar-container' ); + containers = document.querySelectorAll( '.wp-block-wporg-sidebar-container' ); mainEl = document.getElementById( 'wp--skip-link--target' ); - const toggleButton = container?.querySelector( '.wporg-table-of-contents__toggle' ); - const list = container?.querySelector( '.wporg-table-of-contents__list' ); - if ( toggleButton && list ) { - toggleButton.addEventListener( 'click', function () { - if ( toggleButton.getAttribute( 'aria-expanded' ) === 'true' ) { - toggleButton.setAttribute( 'aria-expanded', false ); - list.removeAttribute( 'style' ); - } else { - toggleButton.setAttribute( 'aria-expanded', true ); - list.setAttribute( 'style', 'display:block;' ); - } + if ( mainEl && containers.length ) { + containers.forEach( ( container ) => { + const scrollHandler = createScrollHandler( container ); + scrollHandlers.push( scrollHandler ); + window.addEventListener( 'scroll', scrollHandler ); } ); } - if ( isSidebarWithinViewport() ) { - onScroll(); // Run once to avoid footer collisions on load (ex, when linked to #reply-title). - window.addEventListener( 'scroll', onScroll ); - - const observer = new window.ResizeObserver( () => { - // If the sidebar is positioned at the bottom and mainEl resizes, - // it will remain fixed at the previous bottom position, leading to a broken page layout. - // In this case manually trigger the scroll handler to reposition. - if ( container.classList.contains( 'is-bottom-sidebar' ) ) { - container.classList.remove( 'is-bottom-sidebar' ); - container.style.removeProperty( 'top' ); - const isBottom = onScroll(); - // After the sidebar is repositioned, also adjusts the scroll position - // to a point where the sidebar is visible. - if ( isBottom ) { - window.scrollTo( { - top: container.offsetTop - FIXED_HEADER_HEIGHT, - behavior: 'instant', - } ); - } - } - } ); - - observer.observe( mainEl ); - } - - // If there is no table of contents, hide the heading. - if ( ! document.querySelector( '.wp-block-wporg-table-of-contents' ) ) { - const heading = document.querySelector( '.wp-block-wporg-sidebar-container h2' ); - heading?.style.setProperty( 'display', 'none' ); - } + // Run once to set height vars and position elements on load. + // Avoids footer collisions (ex, when linked to #reply-title). + onResize(); + window.addEventListener( 'resize', onResize ); } window.addEventListener( 'load', init ); diff --git a/mu-plugins/blocks/table-of-contents/src/block.json b/mu-plugins/blocks/table-of-contents/src/block.json index 5cfe99478..77e164600 100644 --- a/mu-plugins/blocks/table-of-contents/src/block.json +++ b/mu-plugins/blocks/table-of-contents/src/block.json @@ -26,5 +26,6 @@ } }, "editorScript": "file:./index.js", - "style": "file:./style.css" + "style": "file:./style.css", + "viewScript": "file:./view.js" } diff --git a/mu-plugins/blocks/table-of-contents/src/view.js b/mu-plugins/blocks/table-of-contents/src/view.js new file mode 100644 index 000000000..67ac55992 --- /dev/null +++ b/mu-plugins/blocks/table-of-contents/src/view.js @@ -0,0 +1,24 @@ +function init() { + const container = document.querySelector( '.wp-block-wporg-table-of-contents' ); + + if ( ! container ) { + return; + } + + const toggleButton = container.querySelector( '.wporg-table-of-contents__toggle' ); + const list = container.querySelector( '.wporg-table-of-contents__list' ); + + if ( toggleButton && list ) { + toggleButton.addEventListener( 'click', function () { + if ( toggleButton.getAttribute( 'aria-expanded' ) === 'true' ) { + toggleButton.setAttribute( 'aria-expanded', false ); + list.removeAttribute( 'style' ); + } else { + toggleButton.setAttribute( 'aria-expanded', true ); + list.setAttribute( 'style', 'display:block;' ); + } + } ); + } +} + +window.addEventListener( 'load', init );