diff --git a/resources/js/components/back-to-top.js b/resources/js/components/back-to-top.js deleted file mode 100644 index 046e640d10a..00000000000 --- a/resources/js/components/back-to-top.js +++ /dev/null @@ -1,58 +0,0 @@ -import {Component} from './component'; - -export class BackToTop extends Component { - - setup() { - this.button = this.$el; - this.targetElem = document.getElementById('header'); - this.showing = false; - this.breakPoint = 1200; - - if (document.body.classList.contains('flexbox')) { - this.button.style.display = 'none'; - return; - } - - this.button.addEventListener('click', this.scrollToTop.bind(this)); - window.addEventListener('scroll', this.onPageScroll.bind(this)); - } - - onPageScroll() { - const scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0; - if (!this.showing && scrollTopPos > this.breakPoint) { - this.button.style.display = 'block'; - this.showing = true; - setTimeout(() => { - this.button.style.opacity = 0.4; - }, 1); - } else if (this.showing && scrollTopPos < this.breakPoint) { - this.button.style.opacity = 0; - this.showing = false; - setTimeout(() => { - this.button.style.display = 'none'; - }, 500); - } - } - - scrollToTop() { - const targetTop = this.targetElem.getBoundingClientRect().top; - const scrollElem = document.documentElement.scrollTop ? document.documentElement : document.body; - const duration = 300; - const start = Date.now(); - const scrollStart = this.targetElem.getBoundingClientRect().top; - - function setPos() { - const percentComplete = (1 - ((Date.now() - start) / duration)); - const target = Math.abs(percentComplete * scrollStart); - if (percentComplete > 0) { - scrollElem.scrollTop = target; - requestAnimationFrame(setPos.bind(this)); - } else { - scrollElem.scrollTop = targetTop; - } - } - - requestAnimationFrame(setPos.bind(this)); - } - -} diff --git a/resources/js/components/back-to-top.ts b/resources/js/components/back-to-top.ts new file mode 100644 index 00000000000..e47a0478acc --- /dev/null +++ b/resources/js/components/back-to-top.ts @@ -0,0 +1,132 @@ +import {Component} from './component'; + +export class BackToTop extends Component { + + private container!: HTMLElement; + private button!: HTMLElement; + private progress!: HTMLElement; + private progressPath!: SVGPathElement; + private targetElem!: HTMLElement; + + private showing: boolean = false; + private breakPoint: number = 1200; + private isAnimating: boolean = false; + + setup(): void { + this.container = this.$el; + this.button = this.$refs.button; + this.progress = this.$refs.progress; + this.progressPath = this.$refs.progressPath as unknown as SVGPathElement; + + this.targetElem = document.getElementById('header') as HTMLElement; + + if (document.body.classList.contains('flexbox')) { + this.container.style.display = 'none'; + return; + } + + this.button.addEventListener('click', this.scrollToTop.bind(this)); + window.addEventListener('scroll', this.onPageScroll.bind(this)); + + this.setupProgressBar(); + } + + private setupProgressBar(): void { + this.button.addEventListener('transitionstart', event => { + if (event.target !== this.button) return; + this.isAnimating = true; + this.renderProgressPath(); + }); + const stopAnimating = (event: TransitionEvent) => { + if (event.target !== this.button) return; + this.isAnimating = false; + }; + this.button.addEventListener('transitionend', stopAnimating); + this.button.addEventListener('transitioncancel', stopAnimating); + this.renderProgressPath(); + } + + private onPageScroll(): void { + const scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0; + if (!this.showing && scrollTopPos > this.breakPoint) { + this.container.style.display = 'block'; + this.showing = true; + setTimeout(() => { + this.container.style.opacity = '0.4'; + }, 1); + } else if (this.showing && scrollTopPos < this.breakPoint) { + this.container.style.opacity = '0'; + this.showing = false; + setTimeout(() => { + this.container.style.display = 'none'; + }, 500); + } + + if (this.showing) { + const maxScrollTop = document.documentElement.scrollHeight - window.innerHeight; + const scrollTopPercent = (scrollTopPos / maxScrollTop) * 100; + this.updateProgress(scrollTopPercent); + } + } + + private scrollToTop(): void { + const targetTop = this.targetElem.getBoundingClientRect().top; + const scrollElem = document.documentElement.scrollTop ? document.documentElement : document.body; + const duration = 300; + const start = Date.now(); + const scrollStart = this.targetElem.getBoundingClientRect().top; + + const setPos = () => { + const percentComplete = (1 - ((Date.now() - start) / duration)); + const target = Math.abs(percentComplete * scrollStart); + if (percentComplete > 0) { + scrollElem.scrollTop = target; + requestAnimationFrame(setPos); + } else { + scrollElem.scrollTop = targetTop; + } + }; + + requestAnimationFrame(setPos); + } + + private renderProgressPath(): void { + const bounds = this.button.getBoundingClientRect(); + const progressInset = window.getComputedStyle(this.progress).insetInlineStart; + const offset = Math.abs(Number(progressInset.replace('px', '') || 0)); + const path = this.roundedRectPath(bounds.width, bounds.height, bounds.height / 2, offset); + this.progressPath.setAttribute('d', path); + if (this.isAnimating) { + window.requestAnimationFrame(this.renderProgressPath.bind(this)); + } + } + + private updateProgress(percentComplete: number): void { + if (percentComplete < 5) { + percentComplete = 5; + } + this.progressPath.setAttribute('stroke-dasharray', `${Math.ceil(percentComplete)} 100`); + } + + private roundedRectPath(w: number, h: number, r: number, offset: number = 1.5): string { + // Expand dimensions outward by the offset + const W = w + 2 * offset; + const H = h + 2 * offset; + + // The corner radius also grows by the offset + const R = Math.min(r + offset, W / 2, H / 2); + + return [ + `M ${W / 2} 0`, // start at top center + `H ${W - R}`, // line to top-right (before arc) + `A ${R} ${R} 0 0 1 ${W} ${R}`, // arc: top-right corner + `V ${H - R}`, // line down right edge + `A ${R} ${R} 0 0 1 ${W - R} ${H}`, // arc: bottom-right corner + `H ${R}`, // line across bottom edge + `A ${R} ${R} 0 0 1 ${0} ${H - R}`, // arc: bottom-left corner + `V ${R}`, // line up left edge + `A ${R} ${R} 0 0 1 ${R} ${0}`, // arc: top-left corner + `Z` // close path back to start + ].join(' '); + } +} diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 8608427d8e8..bc7a0ed8823 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -1098,16 +1098,24 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { // Back to top link $btt-size: 40px; -.back-to-top { - background-color: var(--color-primary); +.back-to-top-container { position: fixed; bottom: vars.$m; right: vars.$l; + opacity: 0; + z-index: 999; + transition: opacity ease-in-out 180ms; + &:hover { + opacity: 1 !important; + } +} +.back-to-top { + background-color: var(--color-primary); padding: 5px 7px; cursor: pointer; color: #FFF; fill: #FFF; - svg { + .inner svg { width: math.div($btt-size, 1.5); height: math.div($btt-size, 1.5); margin-inline-end: 4px; @@ -1116,8 +1124,6 @@ $btt-size: 40px; height: $btt-size; border-radius: $btt-size; transition: all ease-in-out 180ms; - opacity: 0; - z-index: 999; overflow: hidden; &:hover { width: $btt-size*3.4; @@ -1125,6 +1131,7 @@ $btt-size: 40px; } .inner { width: $btt-size*3.4; + text-align: start; } span { position: relative; @@ -1133,6 +1140,23 @@ $btt-size: 40px; } } +.back-to-top-progress { + position: absolute; + top: -3px; + inset-inline-start: -3px; + width: 100%; + height: 100%; + pointer-events: none; + overflow: visible; + path { + fill: none; + stroke: var(--color-primary); + stroke-width: 2px; + stroke-linecap: butt; + transition: stroke-dasharray ease-in-out 120ms; + } +} + // Sortable scroll boxes .scroll-box { list-style: none; diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index b868cb88804..90d987ccb56 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -61,12 +61,7 @@ class="@stack('body-class')"> @include('layouts.parts.footer') - -