Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/lm sticky sidebar #6304

Open
wants to merge 10 commits into
base: trunk
Choose a base branch
from
1 change: 1 addition & 0 deletions assets/course-theme/learning-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import './scroll-direction';
import './adminbar-layout';
import './featured-video-size';
import './sidebar';
import { toggleFocusMode } from './focus-mode';
import { submitContactTeacher } from './contact-teacher';
import { initCompleteLessonTransition } from './complete-lesson-button';
Expand Down
228 changes: 228 additions & 0 deletions assets/course-theme/sidebar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/**
* External dependencies
*/
import debounce from 'lodash/debounce';

/**
* The last scroll top value.
*
* @member {number}
*/
let lastScrollTop = 0;

/**
* Calculates the scroll delta.
*/
const getScrollDelta = () => {
const { scrollTop } = document.documentElement;
const delta = scrollTop - lastScrollTop;
lastScrollTop = Math.max( 0, scrollTop );
return delta;
};

/**
* Tells if the sidebar is supposed to be sticky.
*
* @return {boolean} True if it is sticky. False otherwise.
*/
const isStickySidebar = () =>
!! document.querySelectorAll( '.sensei-course-theme__sidebar--is-sticky' )
.length;

/**
* The sidebar DOM element.
*
* @member {HTMLElement}
*/
let sidebar = null;

/**
* The header DOM element.
*
* @member {HTMLElement}
*/
let header = null;

/**
* A placeholder for the sidebar.
*
* @member {HTMLElement}
*/
let sidebarPlaceholder = null;

/**
* The featured video DOM element.
*
* @member {HTMLElement}
*/
let featuredVideo = null;

/**
* Populates the DOM elements that we need.
*/
const queryDomElements = () => {
sidebar = document.querySelector( '.sensei-course-theme__sidebar' );
header = document.querySelector( '.sensei-course-theme__header' );
featuredVideo = document.querySelector(
'.sensei-course-theme-lesson-video'
);
};

/**
* Sets 'position: fixed' for the sidebar and puts a placeholder in it's original
* place so the original layout is preserved. We also use the placeholder for sticky
* sidebar position calculation to determine where to put it in any given time.
*/
function preparestickySidebar() {
if ( ! sidebarPlaceholder ) {
sidebarPlaceholder = sidebar.cloneNode();
sidebarPlaceholder.style.visibility = 'hidden';
sidebarPlaceholder.setAttribute( 'aria-hidden', 'true' );
sidebar.style.transition = 'none';
sidebar.style.position = 'fixed';
sidebar.style.marginTop = '0';
sidebar.parentElement.prepend( sidebarPlaceholder );
}
const sidebarRect = sidebarPlaceholder.getBoundingClientRect();
sidebar.style.top = `0`;
sidebar.style.left = `${ sidebarRect.left }px`;
sidebar.style.width = `${ sidebarRect.right - sidebarRect.left }px`;
sidebar.style.transform = `translateY(${ sidebarRect.top }px)`;
}

/**
* Sidebar bottom margin.
*
* @member {number}
*/
const SIDEBAR_BOTTOM_MARGIN = 32;

/**
* Updates the stickySidebar position. The position of the stickySidebar
* is relative to the Learning Mode header block. It assumes the header is
* fixed.
*
* @param {boolean} initialPosition True if the sidebar should be positioned
* for it's initial position given the current
* state of the scrollbar. Used when user opens
* the page and the page is scrolled into the middle.
*/
function updateSidebarPosition( initialPosition = false ) {
if ( ! sidebar ) {
return;
}

// Get the current dimensions of the elements.
const headerRect = header.getBoundingClientRect();
const sidebarPlaceholderRect = sidebarPlaceholder.getBoundingClientRect();
const sidebarRect = sidebar.getBoundingClientRect();

// Calculate required values.
const delta = getScrollDelta();
const sidebarHeight = sidebarRect.bottom - sidebarRect.top;
const sidebarIsTallerThanViewport =
sidebarHeight >
window.innerHeight - ( headerRect.bottom + SIDEBAR_BOTTOM_MARGIN );
let sidebarNewTop = sidebarPlaceholderRect.top;

// If the sidebar is very tall and does not fit into the viewport vertically
// we scroll the sticky sidebar up until the bottom is reached. Or we scroll
// the sticky sidebar down until the top of the sidebar is reached.
if ( sidebarIsTallerThanViewport && ! initialPosition ) {
sidebarNewTop = sidebarRect.top - delta;
const sidebarNewBottom = sidebarRect.bottom - delta;
const sidebarMinTop = sidebarPlaceholderRect.top;
const sidebarMinBottom = window.innerHeight - SIDEBAR_BOTTOM_MARGIN;

// The sidebar is moving upwards.
if ( delta >= 0 ) {
if ( sidebarNewBottom < sidebarMinBottom ) {
sidebarNewTop = sidebarMinBottom - sidebarHeight;
}

// The sidebar is moving downwards.
} else {
if ( sidebarNewTop > headerRect.bottom ) {
sidebarNewTop = headerRect.bottom;
}
if ( sidebarNewTop < sidebarMinTop ) {
sidebarNewTop = sidebarMinTop;
}
}

// If the sidebar fits into the viewport vertically
// then we simply stick it below the header when user
// scrolls it up above the header.
} else if ( sidebarPlaceholderRect.top <= headerRect.bottom ) {
sidebarNewTop = headerRect.bottom;

// By default we position the sticky sidebar on top
// of the original sidebar.
} else {
sidebarNewTop = sidebarPlaceholderRect.top;
}

// Need to subtract the sidebar top margin because fixed positioned elements
// are pushed down by css top margin.

sidebar.style.transform = `translateY(${ sidebarNewTop }px)`;
}

/**
* Reinitializes the sticky sideber
*/
const reinitializeSidebar = debounce( () => {
preparestickySidebar();
updateSidebarPosition( true );
}, 500 );

/**
* Makes sure the height of the sidebar is at least the height
* of the featured video in 'modern' LM template.
*/
function syncSidebarSizeWithVideo() {
if ( featuredVideo && sidebar ) {
new window.ResizeObserver( () => {
const videoHeight = featuredVideo.offsetHeight;
const sidebarHeight = sidebar.offsetHeight;
if (
! videoHeight ||
! sidebarHeight ||
sidebarHeight >= videoHeight
) {
return;
}
sidebar.style.height = `${ videoHeight }px`;
reinitializeSidebar();
} ).observe( featuredVideo );
}
}

/**
* Makes the sidebar sticky for relevant LM templates.
*/
function setupStickySidebar() {
if ( ! isStickySidebar() ) {
return;
}

queryDomElements();

document.defaultView.addEventListener( 'scroll', () =>
updateSidebarPosition()
);

// eslint-disable-next-line @wordpress/no-global-event-listener
window.addEventListener( 'resize', reinitializeSidebar );

// Make sure sidebar height is not shorter than the video height
// for `moderm` lm template.
if ( document.body.classList.contains( 'learning-mode--modern' ) ) {
syncSidebarSizeWithVideo();
}
yscik marked this conversation as resolved.
Show resolved Hide resolved

reinitializeSidebar();
}

// eslint-disable-next-line @wordpress/no-global-event-listener
window.addEventListener( 'DOMContentLoaded', setupStickySidebar );
13 changes: 12 additions & 1 deletion includes/course-theme/class-sensei-course-theme-templates.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public function init() {
add_filter( 'pre_get_block_file_template', [ $this, 'get_single_block_template' ], 10, 3 );
add_filter( 'theme_lesson_templates', [ $this, 'add_learning_mode_template' ], 10, 4 );
add_filter( 'theme_quiz_templates', [ $this, 'add_learning_mode_template' ], 10, 4 );

add_filter( 'body_class', [ $this, 'add_body_class' ], 10, 2 );
}


Expand Down Expand Up @@ -499,5 +499,16 @@ private function should_hide_lesson_template( $post_type ) {
return false;
}

/**
* Adds the active template class to body tag.
*
* @param array $classes The list of body class names.
* @param array $class The list of additional class names added to the body.
*/
public function add_body_class( array $classes, array $class ): array {
$active_template_name = Sensei_Course_Theme_Template_Selection::get_active_template_name();
$classes[] = "learning-mode--{$active_template_name}";
return $classes;
}

}