diff --git a/resources/skins.citizen.scripts/overflowElements.js b/resources/skins.citizen.scripts/overflowElements.js index a19bef42..886862b1 100644 --- a/resources/skins.citizen.scripts/overflowElements.js +++ b/resources/skins.citizen.scripts/overflowElements.js @@ -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(); } ); } diff --git a/resources/skins.citizen.styles/components/OverflowElements.less b/resources/skins.citizen.styles/components/OverflowElements.less index f12a5d74..d2e361a0 100644 --- a/resources/skins.citizen.styles/components/OverflowElements.less +++ b/resources/skins.citizen.styles/components/OverflowElements.less @@ -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; } + + .citizen-overflow--left > & { + .mask-gradient(90deg, transparent, #000 @overflow-affordnance-size); + } + + .citizen-overflow--right > & { + .mask-gradient(270deg, transparent, #000 @overflow-affordnance-size); + } + + .citizen-overflow--left.citizen-overflow--right > & { + .mask-gradient(90deg, transparent, #000 @overflow-affordnance-size, #000 ~'calc( 100% - @{overflow-affordnance-size} )', transparent); + } } - &--left { - .mask-gradient(90deg, transparent, #000 @overflow-affordnance-size); + &-nav { + position: absolute; + inset: 0; + display: flex; + justify-content: space-between; + margin-top: var( --space-md ); + margin-bottom: var( --space-md ); } - &--right { - .mask-gradient(270deg, transparent, #000 @overflow-affordnance-size); - } + &-navButton { + height: 100%; + padding: 0; + appearance: none; + cursor: pointer; + background: transparent; + border: 0; + border-radius: var( --border-radius--small ); + .hideOverflowButton(); - &--left&--right { - .mask-gradient(90deg, transparent, #000 @overflow-affordnance-size, #000 ~'calc( 100% - @{overflow-affordnance-size} )', transparent); + &-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; +}