feat(overflow): add overflow scroll button when using a pointer device

This commit is contained in:
alistair3149 2024-06-11 18:00:45 -04:00
parent abc8176638
commit 55d413eeda
No known key found for this signature in database
2 changed files with 190 additions and 40 deletions

View file

@ -6,11 +6,12 @@ const config = require( './config.json' );
* @class
*/
class OverflowElement {
constructor( element ) {
constructor( element, isPointerDevice ) {
this.element = element;
this.isPointerDevice = isPointerDevice;
this.elementWidth = 0;
this.wrapperScrollLeft = 0;
this.wrapperWidth = 0;
this.contentScrollLeft = 0;
this.contentWidth = 0;
this.onScroll = mw.util.throttle( this.onScroll.bind( this ), 250 );
this.updateState = this.updateState.bind( this );
}
@ -36,48 +37,48 @@ class OverflowElement {
}
/**
* Checks if the state of the overflow element has changed by comparing the current element width, wrapper scroll left,
* and wrapper width with the cached values. Returns true if any of the values have changed, otherwise returns false.
* Checks if the state of the overflow element has changed by comparing the current element width, content scroll left,
* and content width with the cached values. Returns true if any of the values have changed, otherwise returns false.
*
* @return {boolean} - True if the state has changed, false otherwise.
*/
hasStateChanged() {
return (
this.element.scrollWidth !== this.elementWidth ||
Math.round( this.wrapper.scrollLeft ) !== this.wrapperScrollLeft ||
this.wrapper.offsetWidth !== this.wrapperWidth
Math.round( this.content.scrollLeft ) !== this.contentScrollLeft ||
this.content.offsetWidth !== this.contentWidth
);
}
/**
* Checks if the element has overflowed horizontally by comparing the element width with the wrapper width.
* Checks if the element has overflowed horizontally by comparing the element width with the content width.
*
* @return {boolean} - True if the element has overflowed, false otherwise.
*/
hasOverflowed() {
return this.elementWidth > this.wrapperWidth;
return this.elementWidth > this.contentWidth;
}
/**
* Updates the state of the overflow element by calculating the element width, wrapper scroll left, and wrapper width.
* Updates the state of the overflow element by calculating the element width, content scroll left, and content width.
* If the width values are invalid, logs an error and returns.
* Compares the current state with the previous state and updates the cache if there is a change.
* Toggles classes on the wrapper element based on the overflow state (left or right).
* Toggles classes on the content element based on the overflow state (left or right).
*
* @return {void}
*/
updateState() {
const elementWidth = this.element.scrollWidth;
const wrapperScrollLeft = Math.round( this.wrapper.scrollLeft );
const wrapperWidth = this.wrapper.offsetWidth;
const contentScrollLeft = Math.round( this.content.scrollLeft );
const contentWidth = this.content.offsetWidth;
if ( !this.hasStateChanged() ) {
return;
}
this.elementWidth = elementWidth;
this.wrapperScrollLeft = wrapperScrollLeft;
this.wrapperWidth = wrapperWidth;
this.contentScrollLeft = contentScrollLeft;
this.contentWidth = contentWidth;
let isLeftOverflowing, isRightOverflowing;
@ -85,8 +86,9 @@ class OverflowElement {
isLeftOverflowing = false;
isRightOverflowing = false;
} else {
isLeftOverflowing = this.wrapperScrollLeft > 0;
isRightOverflowing = this.wrapperScrollLeft + this.wrapperWidth < this.elementWidth;
isLeftOverflowing = this.contentScrollLeft > 0;
isRightOverflowing =
this.contentScrollLeft + this.contentWidth < this.elementWidth;
}
window.requestAnimationFrame( () => {
@ -105,7 +107,8 @@ class OverflowElement {
*/
handleInheritedClasses() {
const inheritedClasses = config.wgCitizenOverflowInheritedClasses;
const filteredClasses = inheritedClasses.filter( ( cls ) => this.element.classList.contains( cls ) );
const filteredClasses = inheritedClasses.filter( ( cls ) => this.element.classList.contains( cls )
);
filteredClasses.forEach( ( cls ) => {
if ( !this.wrapper.classList.contains( cls ) ) {
@ -130,21 +133,88 @@ class OverflowElement {
*/
wrap() {
if ( !this.element || !this.element.parentNode ) {
mw.log.error( '[Citizen] Element or element.parentNode is null or undefined. Please check if the element or element.parentNode is null or undefined.' );
mw.log.error(
'[Citizen] Element or element.parentNode is null or undefined. Please check if the element or element.parentNode is null or undefined.'
);
return;
}
try {
const parentNode = this.element.parentNode;
const fragment = document.createDocumentFragment();
const wrapper = document.createElement( 'div' );
wrapper.className = 'citizen-overflow-wrapper';
this.handleInheritedClasses();
fragment.appendChild( wrapper );
const content = document.createElement( 'div' );
content.className = 'citizen-overflow-content';
wrapper.appendChild( content );
const parentNode = this.element.parentNode;
parentNode.insertBefore( fragment, this.element );
content.appendChild( this.element );
parentNode.insertBefore( wrapper, this.element );
wrapper.appendChild( this.element );
this.wrapper = wrapper;
this.content = content;
if ( this.isPointerDevice ) {
const nav = document.createElement( 'div' );
nav.className = 'citizen-overflow-nav';
const leftButton = document.createElement( 'button' );
leftButton.className =
'citizen-overflow-navButton citizen-overflow-navButton-left citizen-ui-icon mw-ui-icon-wikimedia-collapse';
nav.appendChild( leftButton );
const rightButton = document.createElement( 'button' );
rightButton.className =
'citizen-overflow-navButton citizen-overflow-navButton-right citizen-ui-icon mw-ui-icon-wikimedia-collapse';
nav.appendChild( rightButton );
wrapper.appendChild( nav );
this.nav = nav;
}
} catch ( error ) {
mw.log.error( `[Citizen] Error occurred while wrapping element: ${ error.message }` );
mw.log.error(
`[Citizen] Error occurred while wrapping element: ${ error.message }`
);
}
}
/**
* Scrolls the content element by the specified offset.
*
* @param {number} offset - The amount by which to scroll the content element.
* @return {void}
*/
scrollContent( offset ) {
const delta = this.content.scrollWidth - this.content.offsetWidth;
const scrollLeft = Math.ceil( this.content.scrollLeft ) + offset;
window.requestAnimationFrame( () => {
this.content.scrollLeft = Math.min( Math.max( scrollLeft, 0 ), delta );
} );
}
/**
* Handles the click event on the navigation buttons.
* Scrolls the content element left or right based on the button clicked.
*
* @param {Event} event - The click event object.
* @return {void}
*/
onClick( event ) {
const target = event.target;
if ( !target.classList.contains( 'citizen-overflow-navButton' ) ) {
return;
}
const offset = this.wrapper.offsetWidth / 2;
if ( target.classList.contains( 'citizen-overflow-navButton-left' ) ) {
this.scrollContent( -offset );
} else if (
target.classList.contains( 'citizen-overflow-navButton-right' )
) {
this.scrollContent( offset );
}
}
@ -160,27 +230,33 @@ class OverflowElement {
/**
* Resumes the functionality of the overflow element by updating its state, adding a scroll event listener, and observing element resize.
* Calls the 'updateState' method to update the state of the overflow element.
* Adds a scroll event listener to the wrapper element to handle scroll events by calling the 'onScroll' method.
* Adds a scroll event listener to the content element to handle scroll events by calling the 'onScroll' method.
* Observes the element for resize changes using the 'resizeObserver'.
*
* @return {void}
*/
resume() {
this.updateState();
this.wrapper.addEventListener( 'scroll', this.onScroll );
this.content.addEventListener( 'scroll', this.onScroll );
this.resizeObserver.observe( this.element );
if ( this.isPointerDevice ) {
this.nav.addEventListener( 'click', this.onClick.bind( this ) );
}
}
/**
* Pauses the functionality of the overflow element by removing the scroll event listener and stopping observation of element resize.
* Removes the scroll event listener from the wrapper element that triggers the 'onScroll' method.
* Removes the scroll event listener from the content element that triggers the 'onScroll' method.
* Stops observing resize changes of the element using the 'resizeObserver'.
*
* @return {void}
*/
pause() {
this.wrapper.removeEventListener( 'scroll', this.onScroll );
this.content.removeEventListener( 'scroll', this.onScroll );
this.resizeObserver.unobserve( this.element );
if ( this.isPointerDevice ) {
this.nav.removeEventListener( 'click', this.onClick );
}
}
/**
@ -238,21 +314,27 @@ class OverflowElement {
function init( bodyContent ) {
const nowrapClasses = config.wgCitizenOverflowNowrapClasses;
if ( !nowrapClasses || !Array.isArray( nowrapClasses ) ) {
mw.log.error( '[Citizen] Invalid or missing $wgCitizenOverflowNowrapClasses. Cannot proceed with wrapping element.' );
mw.log.error(
'[Citizen] Invalid or missing $wgCitizenOverflowNowrapClasses. Cannot proceed with wrapping element.'
);
return;
}
const overflowElements = bodyContent.querySelectorAll( '.citizen-overflow, .wikitable:not( .wikitable .wikitable )' );
const overflowElements = bodyContent.querySelectorAll(
'.citizen-overflow, .wikitable:not( .wikitable .wikitable )'
);
if ( !overflowElements.length ) {
return;
}
const isPointerDevice = window.matchMedia( '(hover: hover) and (pointer: fine)' ).matches;
overflowElements.forEach( ( el ) => {
if ( nowrapClasses.some( ( cls ) => el.classList.contains( cls ) ) ) {
return;
}
const overflowElement = new OverflowElement( el );
const overflowElement = new OverflowElement( el, isPointerDevice );
overflowElement.init();
} );
}

View file

@ -1,18 +1,34 @@
@overflow-affordnance-size: 2rem;
.mask-gradient(@direction, @color1, @color2, @color3: null , @color4: null) {
.mask-gradient(@direction, @color1, @color2, @color3: null, @color4: null) {
-webkit-mask-image: linear-gradient( @direction, @color1, @color2 );
mask-image: linear-gradient( @direction, @color1, @color2 );
& when not(@color3 = null), not(@color4 = null) {
& when not(@color3 =null),
not(@color4 =null) {
-webkit-mask-image: linear-gradient( @direction, @color1, @color2, @color3, @color4 );
mask-image: linear-gradient( @direction, @color1, @color2, @color3, @color4 );
}
}
.hideOverflowButton() {
pointer-events: none;
visibility: hidden;
}
.showOverflowButton() {
z-index: 1;
pointer-events: auto;
visibility: visible;
}
// Elements enhanced by overflowElements.js
.citizen-overflow {
&-wrapper {
position: relative;
}
&-content {
overflow-x: auto;
.wikitable {
@ -20,17 +36,69 @@
max-width: none;
overflow: initial;
}
}
&--left {
.citizen-overflow--left > & {
.mask-gradient(90deg, transparent, #000 @overflow-affordnance-size);
}
&--right {
.citizen-overflow--right > & {
.mask-gradient(270deg, transparent, #000 @overflow-affordnance-size);
}
&--left&--right {
.citizen-overflow--left.citizen-overflow--right > & {
.mask-gradient(90deg, transparent, #000 @overflow-affordnance-size, #000 ~'calc( 100% - @{overflow-affordnance-size} )', transparent);
}
}
&-nav {
position: absolute;
inset: 0;
display: flex;
justify-content: space-between;
margin-top: var( --space-md );
margin-bottom: var( --space-md );
}
&-navButton {
height: 100%;
padding: 0;
appearance: none;
cursor: pointer;
background: transparent;
border: 0;
border-radius: var( --border-radius--small );
.hideOverflowButton();
&-left {
.citizen-overflow--left & {
.showOverflowButton();
}
&::before {
transform: rotate( -90deg );
}
}
&-right {
.citizen-overflow--right & {
.showOverflowButton();
}
&::before {
transform: rotate( 90deg );
}
}
&:hover {
background-color: var( --background-color-quiet--hover );
}
&:active {
background-color: var( --background-color-quiet--active );
}
}
}
.citizen-animations-ready .citizen-overflow-content {
scroll-behavior: smooth;
}