mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Pulling the mail list down from its top, with a finger below md, runs the topbar's sync. A shared touch-gesture module decides what a touch is and who owns it, for the row swipes and long-press to come. Mouse, trackpad and wider windows are unchanged. Takes over #115. Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
6.0 KiB
JavaScript
172 lines
6.0 KiB
JavaScript
// assets/controllers/sidebar_drawer_controller.js
|
|
//
|
|
// Manages the two ways of hiding the sidebar:
|
|
// • below md — a slide-in drawer over a backdrop. The sidebar is always
|
|
// rendered in the DOM, translated off-screen and brought back via a CSS
|
|
// transition when open.
|
|
// • md and up — an icon rail. A `sidebar-rail` class on <html> narrows the
|
|
// inline sidebar and hides its text (see app.css); the state is kept in
|
|
// localStorage and re-applied before paint by the script in app.html.twig.
|
|
//
|
|
// One burger drives both — which one depends on the viewport.
|
|
//
|
|
// A closed drawer is `inert`, not merely off-screen. Translated out of view it
|
|
// was still in the tab order: a keyboard user on a phone tabbed through the
|
|
// whole sidebar, invisibly, before reaching the page — and a screen reader
|
|
// read it out as though it were open. Opening moves focus into it, closing puts
|
|
// it back on the burger, and the burger's aria-expanded says which it is.
|
|
|
|
import { Controller } from "@hotwired/stimulus";
|
|
import { registerSurface, unregisterSurface } from "../../touch_gesture.js";
|
|
|
|
const DESKTOP = "(min-width: 768px)";
|
|
const RAIL_KEY = "plmail:sidebarRail";
|
|
|
|
export default class extends Controller {
|
|
static targets = ["drawer", "backdrop", "burger"];
|
|
static values = { open: { type: Boolean, default: false } };
|
|
|
|
connect() {
|
|
this._onKeydown = this._handleKeydown.bind(this);
|
|
|
|
// An open drawer is a surface no touch gesture may start over. Read from
|
|
// its own inert target, so it cannot disagree with what is on screen.
|
|
registerSurface("drawer", () => this.hasDrawerTarget && this.drawerTarget.inert === false);
|
|
|
|
// A drawer left open while rotating an iPad to landscape would otherwise
|
|
// stay stuck over the desktop layout, backdrop and all.
|
|
this._desktop = window.matchMedia(DESKTOP);
|
|
this._onBreakpoint = (event) => {
|
|
if (event.matches === true) {
|
|
this.close();
|
|
}
|
|
};
|
|
this._desktop.addEventListener("change", this._onBreakpoint);
|
|
this._desktop.addEventListener("change", this._onExpandedChange = () => this._syncExpanded());
|
|
|
|
this._syncExpanded();
|
|
}
|
|
|
|
disconnect() {
|
|
unregisterSurface("drawer");
|
|
document.removeEventListener("keydown", this._onKeydown);
|
|
this._desktop.removeEventListener("change", this._onBreakpoint);
|
|
this._desktop.removeEventListener("change", this._onExpandedChange);
|
|
document.body.style.overflow = "";
|
|
}
|
|
|
|
toggle() {
|
|
if (this._desktop.matches === true) {
|
|
this.toggleRail();
|
|
|
|
return;
|
|
}
|
|
|
|
this.openValue = !this.openValue;
|
|
}
|
|
|
|
toggleRail() {
|
|
const railed = document.documentElement.classList.toggle("sidebar-rail");
|
|
|
|
try {
|
|
localStorage.setItem(RAIL_KEY, railed ? "1" : "0");
|
|
} catch (error) {
|
|
// Safari in private mode throws on write; the rail still works,
|
|
// it just won't survive a reload.
|
|
}
|
|
|
|
this._syncExpanded();
|
|
}
|
|
|
|
open() {
|
|
this.openValue = true;
|
|
}
|
|
|
|
close() {
|
|
this.openValue = false;
|
|
}
|
|
|
|
backdropClick(event) {
|
|
if (event.target === event.currentTarget) {
|
|
this.close();
|
|
}
|
|
}
|
|
|
|
openValueChanged() {
|
|
const open = this.openValue;
|
|
|
|
if (this.hasDrawerTarget) {
|
|
// Slide in/out
|
|
this.drawerTarget.classList.toggle("-translate-x-full", !open);
|
|
this.drawerTarget.classList.toggle("translate-x-0", open);
|
|
this.drawerTarget.inert = !open;
|
|
}
|
|
|
|
if (this.hasBackdropTarget) {
|
|
this.backdropTarget.classList.toggle("opacity-0", !open);
|
|
this.backdropTarget.classList.toggle("pointer-events-none", !open);
|
|
this.backdropTarget.classList.toggle("opacity-100", open);
|
|
this.backdropTarget.classList.toggle("pointer-events-auto", open);
|
|
}
|
|
|
|
// Lock body scroll while drawer is open
|
|
document.body.style.overflow = open ? "hidden" : "";
|
|
|
|
if (open) {
|
|
document.addEventListener("keydown", this._onKeydown);
|
|
} else {
|
|
document.removeEventListener("keydown", this._onKeydown);
|
|
}
|
|
|
|
this._syncExpanded();
|
|
|
|
// Focus follows the drawer, but only on a real change — this callback
|
|
// also runs once on connect, where nothing was opened or closed.
|
|
if (this._wasOpen === undefined) {
|
|
this._wasOpen = open;
|
|
|
|
return;
|
|
}
|
|
|
|
if (open && !this._wasOpen && this.hasDrawerTarget) {
|
|
this.drawerTarget.querySelector("a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex='-1'])")?.focus();
|
|
} else if (!open && this._wasOpen && this.hasBurgerTarget && this._focusWasInDrawer()) {
|
|
this.burgerTarget.focus();
|
|
}
|
|
|
|
this._wasOpen = open;
|
|
}
|
|
|
|
/**
|
|
* Focus inside the drawer, or already dropped to <body> — `inert` blurs
|
|
* whatever it contains, so by the time this runs the keyboard may already
|
|
* have fallen out of it.
|
|
*/
|
|
_focusWasInDrawer() {
|
|
const active = document.activeElement;
|
|
|
|
return active === null || active === document.body || this.drawerTarget.contains(active);
|
|
}
|
|
|
|
/** The drawer's state below md; the sidebar's (not railed) above it. */
|
|
_syncExpanded() {
|
|
// Stimulus runs openValueChanged before connect(), so the first call
|
|
// can arrive with no media query yet; connect() makes the real one.
|
|
if (!this.hasBurgerTarget || this._desktop === undefined) {
|
|
return;
|
|
}
|
|
|
|
const expanded = this._desktop.matches === true
|
|
? !document.documentElement.classList.contains("sidebar-rail")
|
|
: this.openValue;
|
|
|
|
this.burgerTarget.setAttribute("aria-expanded", String(expanded));
|
|
}
|
|
|
|
_handleKeydown(event) {
|
|
if (event.key === "Escape") {
|
|
this.close();
|
|
}
|
|
}
|
|
}
|