2026.camp.carte/js/components/bidi-panel.js

274 lines
6.2 KiB
JavaScript

const TEMPLATE = document.createElement("template");
TEMPLATE.innerHTML = `
<style>
:host {
display: block;
scroll-behavior: smooth;
}
#navigation {
position: sticky;
bottom: 0;
left: 0;
width: 100%;
background: inherit;
color: inherit;
font-weight: bold;
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
padding: 0.5ex;
box-sizing: border-box;
}
:host(.single-panel) #navigation {
display: none;
}
#navigation .progress-container {
flex: 1;
flex-shrink: 100;
}
#navigation button {
flex-shrink: 0;
width: 3em;
height: 3em;
background: none;
border: none;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
cursor: pointer;
}
#navigation button img {
height: 1.5em;
}
.progress-container {
position: relative;
}
.progress-container progress {
width: 100%;
box-sizing: border-box;
position: absolute;
top: 0;
left: 0;
width: 100%;
opacity: 0;
z-index: -1;
}
#progress-dots {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
overflow-x: auto;
gap: 0.5ex;
height: 100%;
}
#progress-dots .dot {
width: 0.75ex;
height: 0.75ex;
border-radius: 100%;
background: currentColor;
}
#progress-dots .dot.active {
transform: scale(1.5);
}
#content {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-behavior: inherit;
}
#content ::slotted(*) {
width: 100%;
flex-shrink: 0;
scroll-snap-align: start;
box-sizing: border-box;
}
</style>
<section id="content"><slot></slot></section>
<nav id="navigation" part="navigation-panel">
<button id="nav-left-panel" title="Aller au panneau de gauche" part="nav-button-left">
<img alt="Fleche vers la gauche" src="${new URL("../../icons/arrow-left.svg", import.meta.url)}"/>
</button>
<div class="progress-container">
<progress id="panel-progress-bar" value="0"></progress>
<div id="progress-dots" aria-hidden="true"></div>
</div>
<button id="nav-right-panel" title="Aller au panneau de droite" part="nav-button-right" >
<img alt="Fleche vers la droite" src="${new URL("../../icons/arrow-right.svg", import.meta.url)}"/>
</button>
</nav>
`
export class BidiPanelElement extends HTMLElement {
shadow
mutationObserver
#lastActive
#currentIndex
get contentContainer(){
return this.shadow.getElementById("content")
}
get progressBar(){
return this.shadow.getElementById("panel-progress-bar")
}
get progressDotsContainer(){
return this.shadow.getElementById("progress-dots")
}
get activeChildrenIndex(){
let thisGeometry = this.getBoundingClientRect()
let progress = this.contentContainer.scrollLeft / (this.contentContainer.scrollWidth - thisGeometry.width)
let focusedElementIndex = Math.floor(progress * this.children.length)
if(Number.isFinite(focusedElementIndex)){
if(focusedElementIndex == this.children.length){
return this.children.length - 1
} else {
return focusedElementIndex
}
} else {
return null
}
}
set activeChildrenIndex(newIndex){
this.setActiveChildrenIndex(newIndex, {behavior: "auto"})
}
setActiveChildrenIndex(newIndex, options){
let newPanel = this.children[newIndex]
let newPanelRect = newPanel.getBoundingClientRect()
let selfRect = this.getBoundingClientRect()
this.contentContainer.scrollTo({
left: this.contentContainer.scrollLeft + ( newPanelRect.left - selfRect.left ),
behavior: options?.behavior || "auto"
})
this.#currentIndex = newIndex
}
get activeChildren(){
return this.children[this.activeChildrenIndex]
}
connectedCallback(){
if(!this.shadow){
this.shadow = this.attachShadow({ mode: "open" })
this.shadow.append(TEMPLATE.content.cloneNode(true))
}
this.contentContainer.addEventListener("scroll", this.handleContentScroll.bind(this))
this.shadow.getElementById("nav-left-panel").addEventListener("click", () => this.previous());
this.shadow.getElementById("nav-right-panel").addEventListener("click", () => this.next());
this.mutationObserver = (new MutationObserver(this.handleMutations.bind(this)))
this.mutationObserver.observe(this, {childList:true})
this.updateProgress()
}
handleMutations(mutation){
let newIndex = this.#currentIndex
this.updateProgress()
if(Number.isFinite(newIndex)){
this.setActiveChildrenIndex(newIndex, {behavior: "instant"})
}
this.requestDispatchChangeEvent()
}
handleContentScroll(e){
this.updateProgress()
this.#currentIndex = this.activeChildrenIndex
this.requestDispatchChangeEvent()
}
requestDispatchChangeEvent(){
let currentPanel = this.activeChildren
if(currentPanel != this.#lastActive){
this.dispatchEvent(new ActivePanelChangeEvent("activePanelChange", this.#currentIndex || this.activeChildrenIndex))
this.#lastActive = currentPanel
}
}
updateProgress(){
let focusedElementIndex = this.activeChildrenIndex;
let progressBar = this.progressBar;
progressBar.value = focusedElementIndex;
progressBar.min = 0;
progressBar.max = this.children.length-1;
this.classList.toggle("single-panel", this.children.length == 1)
this.classList.toggle("empty", this.children.length == 0)
let dotsContainer = this.progressDotsContainer;
if(dotsContainer.children.length != parseInt(progressBar.max)+1){
let dots = (new Array(this.children.length))
.fill(0)
.map(() => {
let el = document.createElement("div")
el.classList.add("dot")
el.part = "dot"
return el
});
dotsContainer.replaceChildren(...dots)
}
for(let inactiveDot of dotsContainer.querySelectorAll(".active")){
inactiveDot.part = "dot"
inactiveDot.classList.remove("active")
}
let activeDot = dotsContainer.children[parseInt(progressBar.value)]
if(activeDot){
activeDot.part = "dot-active"
activeDot.classList.add("active")
}
}
next(){
let nextItem = this.activeChildrenIndex + 1;
if(nextItem < this.children.length){
this.activeChildrenIndex = nextItem
}
}
previous(){
let nextItem = this.activeChildrenIndex - 1;
if(nextItem >= 0){
this.activeChildrenIndex = nextItem
}
}
}
export class ActivePanelChangeEvent extends Event {
constructor(name, activePanelIndex){
super(name)
this.activePanelIndex = activePanelIndex
}
}
customElements.define("camp-bidi-panel", BidiPanelElement);