Skip to content

Commit 47ea94d

Browse files
Merge pull request #44 from schaefersoft/feat/flyout-touch-controls
Flyout touch controls
2 parents 1c24faf + 9b46fbb commit 47ea94d

7 files changed

Lines changed: 602 additions & 53 deletions

File tree

dist/js/hui.js

Lines changed: 165 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -346,41 +346,41 @@ function E(e = document) {
346346
typeof window < "u" && typeof document < "u" && (document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", () => E()) : E());
347347
//#endregion
348348
//#region resources/js/flyout/flyout.ts
349-
var D = [
349+
var re = [
350350
"a[href]",
351351
"button:not([disabled])",
352352
"input:not([disabled]):not([type=\"hidden\"])",
353353
"select:not([disabled])",
354354
"textarea:not([disabled])",
355355
"[tabindex]:not([tabindex=\"-1\"])"
356356
].join(",");
357-
function O(e) {
358-
return Array.from(e.querySelectorAll(D));
357+
function D(e) {
358+
return Array.from(e.querySelectorAll(re));
359359
}
360-
function re(e, t) {
360+
function ie(e, t) {
361361
if (t.key !== "Tab") return;
362-
let n = O(e);
362+
let n = D(e);
363363
if (n.length === 0) {
364364
t.preventDefault();
365365
return;
366366
}
367367
let r = n[0], i = n[n.length - 1];
368368
t.shiftKey ? document.activeElement === r && (t.preventDefault(), i.focus()) : document.activeElement === i && (t.preventDefault(), r.focus());
369369
}
370-
function k(e, t) {
370+
function O(e, t) {
371371
return {
372372
base: (e.getAttribute(t) || "").split(/\s+/).filter(Boolean),
373373
from: (e.getAttribute(`${t}-from`) || "").split(/\s+/).filter(Boolean),
374374
to: (e.getAttribute(`${t}-to`) || "").split(/\s+/).filter(Boolean)
375375
};
376376
}
377-
function A(e, t) {
377+
function k(e, t) {
378378
return e.hasAttribute(t) || e.hasAttribute(`${t}-from`) || e.hasAttribute(`${t}-to`);
379379
}
380-
function j() {
380+
function A() {
381381
return new Promise((e) => requestAnimationFrame(() => requestAnimationFrame(() => e())));
382382
}
383-
function M(e) {
383+
function j(e) {
384384
return new Promise((t) => {
385385
let n = getComputedStyle(e), r = (parseFloat(n.transitionDuration || "0") + parseFloat(n.transitionDelay || "0")) * 1e3;
386386
if (r <= 0) {
@@ -393,69 +393,119 @@ function M(e) {
393393
e.addEventListener("transitionend", a, { once: !0 }), setTimeout(a, r + 50);
394394
});
395395
}
396-
function N(e, t) {
397-
let { base: n, from: r } = k(e, t);
396+
function M(e, t) {
397+
let { base: n, from: r } = O(e, t);
398398
(n.length > 0 || r.length > 0) && e.classList.add(...n, ...r);
399399
}
400-
function P(e, t) {
401-
let { base: n, from: r, to: i } = k(e, t);
402-
return n.length === 0 && r.length === 0 && i.length === 0 ? Promise.resolve() : j().then(() => (e.classList.remove(...r), e.classList.add(...i), M(e))).then(() => {
400+
function ae(e, t) {
401+
let { base: n, from: r, to: i } = O(e, t);
402+
return n.length === 0 && r.length === 0 && i.length === 0 ? Promise.resolve() : A().then(() => (e.classList.remove(...r), e.classList.add(...i), j(e))).then(() => {
403403
e.classList.remove(...n, ...i);
404404
});
405405
}
406-
function F(e, t) {
407-
let { base: n, from: r, to: i } = k(e, t);
408-
return n.length === 0 && r.length === 0 && i.length === 0 ? Promise.resolve() : (e.classList.add(...n, ...r), j().then(() => (e.classList.remove(...r), e.classList.add(...i), M(e))));
406+
function oe(e, t) {
407+
let { base: n, from: r, to: i } = O(e, t);
408+
return n.length === 0 && r.length === 0 && i.length === 0 ? Promise.resolve() : (e.classList.add(...n, ...r), A().then(() => (e.classList.remove(...r), e.classList.add(...i), j(e))));
409409
}
410-
function I(e, t) {
411-
let { base: n, to: r } = k(e, t);
410+
function se(e, t) {
411+
let { base: n, to: r } = O(e, t);
412412
e.classList.remove(...n, ...r);
413413
}
414-
function L(e) {
414+
function N(e) {
415415
let t = [];
416-
return (A(e, "data-hui-flyout-enter") || A(e, "data-hui-flyout-leave")) && t.push(e), t.push(...Array.from(e.querySelectorAll("[data-hui-flyout-enter], [data-hui-flyout-leave]"))), t;
416+
return (k(e, "data-hui-flyout-enter") || k(e, "data-hui-flyout-leave")) && t.push(e), t.push(...Array.from(e.querySelectorAll("[data-hui-flyout-enter], [data-hui-flyout-leave]"))), t;
417417
}
418-
function R(e) {
418+
function P(e) {
419419
let t = e.getAttribute("data-hui-flyout-inline");
420420
if (!t) return null;
421421
let n = parseInt(t, 10);
422422
return Number.isFinite(n) && n > 0 ? n : null;
423423
}
424-
function z(e) {
425-
let t = R(e);
424+
function F(e) {
425+
let t = P(e);
426426
return t === null ? !1 : window.innerWidth >= t;
427427
}
428-
function B(e) {
428+
var ce = {
429+
left: "x",
430+
right: "x",
431+
top: "y",
432+
bottom: "y"
433+
}, le = {
434+
left: -1,
435+
right: 1,
436+
top: -1,
437+
bottom: 1
438+
};
439+
function I(e) {
440+
let t = e.getAttribute("data-hui-flyout-position");
441+
return t === "left" || t === "top" || t === "bottom" ? t : "right";
442+
}
443+
function L(e) {
444+
let t = e.getAttribute("data-hui-flyout-swipe");
445+
return t === "open" || t === "close" || t === "both" ? t : null;
446+
}
447+
function R(e, t) {
448+
return t === "left" || t === "right" ? !0 : t === "bottom" ? e.scrollTop <= 0 : e.scrollTop + e.clientHeight >= e.scrollHeight - 1;
449+
}
450+
function z(e, t, n) {
451+
switch (e) {
452+
case "left": return {
453+
inward: t,
454+
cross: n
455+
};
456+
case "right": return {
457+
inward: -t,
458+
cross: n
459+
};
460+
case "top": return {
461+
inward: n,
462+
cross: t
463+
};
464+
case "bottom": return {
465+
inward: -n,
466+
cross: t
467+
};
468+
}
469+
}
470+
function B(e, t, n, r) {
471+
switch (n) {
472+
case "left": return e <= r;
473+
case "right": return e >= window.innerWidth - r;
474+
case "top": return t <= r;
475+
case "bottom": return t >= window.innerHeight - r;
476+
}
477+
}
478+
function V(e) {
429479
if (e.hasAttribute("data-hui-flyout-initialized")) return;
430480
e.setAttribute("data-hui-flyout-initialized", "");
431481
let t = null, n = !1, r = e.hasAttribute("data-hui-flyout-no-escape"), i = e.hasAttribute("data-hui-flyout-no-backdrop-close"), a = e.hasAttribute("data-hui-flyout-scroll-lock");
432482
function o() {
433-
R(e) !== null && (z(e) ? (e.setAttribute("data-hui-flyout-mode", "inline"), e.open && e.hasAttribute("data-hui-flyout-open") && e.close(), e.removeAttribute("data-hui-flyout-open")) : e.setAttribute("data-hui-flyout-mode", "flyout"));
483+
P(e) !== null && (F(e) ? (e.setAttribute("data-hui-flyout-mode", "inline"), e.open && e.hasAttribute("data-hui-flyout-open") && e.close(), e.removeAttribute("data-hui-flyout-open")) : e.setAttribute("data-hui-flyout-mode", "flyout"));
434484
}
435485
function s() {
436-
if (z(e) || e.open || n) return;
486+
if (F(e) || e.open || n) return;
437487
t = document.activeElement;
438-
let r = L(e).filter((e) => A(e, "data-hui-flyout-enter"));
439-
r.forEach((e) => N(e, "data-hui-flyout-enter")), e.showModal(), e.setAttribute("data-hui-flyout-open", ""), a && (document.body.style.overflow = "hidden");
440-
let i = O(e);
441-
i.length > 0 && i[0].focus(), r.length > 0 && Promise.all(r.map((e) => P(e, "data-hui-flyout-enter"))), e.dispatchEvent(new CustomEvent("hui:flyout:open", { bubbles: !0 }));
488+
let r = N(e).filter((e) => k(e, "data-hui-flyout-enter"));
489+
r.forEach((e) => M(e, "data-hui-flyout-enter")), e.showModal(), e.setAttribute("data-hui-flyout-open", ""), a && (document.body.style.overflow = "hidden");
490+
let i = D(e);
491+
i.length > 0 && i[0].focus(), r.length > 0 && Promise.all(r.map((e) => ae(e, "data-hui-flyout-enter"))), e.dispatchEvent(new CustomEvent("hui:flyout:open", { bubbles: !0 }));
442492
}
443-
function c() {
444-
if (z(e) || !e.open || n) return;
445-
let r = L(e).filter((e) => A(e, "data-hui-flyout-leave"));
446-
function i() {
493+
function c(r = {}) {
494+
if (F(e) || !e.open || n) return;
495+
let i = N(e), o = r.immediate ? [] : i.filter((e) => k(e, "data-hui-flyout-leave"));
496+
function s() {
447497
e.close(), e.removeAttribute("data-hui-flyout-open"), a && (document.querySelector("dialog[data-hui-flyout][data-hui-flyout-scroll-lock][open]") || (document.body.style.overflow = "")), t && t.focus && t.focus(), t = null, n = !1, e.dispatchEvent(new CustomEvent("hui:flyout:close", { bubbles: !0 }));
448498
}
449-
if (r.length > 0) {
499+
if (o.length > 0) {
450500
n = !0;
451-
let e = r.map((e) => F(e, "data-hui-flyout-leave"));
501+
let e = o.map((e) => oe(e, "data-hui-flyout-leave"));
452502
Promise.all(e).then(() => {
453-
i(), r.forEach((e) => I(e, "data-hui-flyout-leave"));
503+
s(), o.forEach((e) => se(e, "data-hui-flyout-leave"));
454504
});
455-
} else i();
505+
} else s();
456506
}
457507
e.addEventListener("keydown", (t) => {
458-
re(e, t);
508+
ie(e, t);
459509
}), e.addEventListener("cancel", (e) => {
460510
e.preventDefault(), r || c();
461511
}), e.addEventListener("click", (t) => {
@@ -466,18 +516,83 @@ function B(e) {
466516
e.target.closest("[data-hui-flyout-close]") && c();
467517
});
468518
let l = e.querySelector("[data-hui-flyout-title]"), u = e.querySelector("[data-hui-flyout-description]");
469-
l && (l.id ||= `hui-flyout-title-${H()}`, e.setAttribute("aria-labelledby", l.id)), u && (u.id ||= `hui-flyout-desc-${H()}`, e.setAttribute("aria-describedby", u.id));
470-
let d = R(e);
471-
d !== null && (window.matchMedia(`(min-width: ${d}px)`).addEventListener("change", o), o()), e._hui = {
519+
l && (l.id ||= `hui-flyout-title-${U()}`, e.setAttribute("aria-labelledby", l.id)), u && (u.id ||= `hui-flyout-desc-${U()}`, e.setAttribute("aria-describedby", u.id));
520+
let d = P(e);
521+
d !== null && (window.matchMedia(`(min-width: ${d}px)`).addEventListener("change", o), o());
522+
function f(t) {
523+
let r = ce[t], i = le[t];
524+
Array.from(e.querySelectorAll("[data-hui-flyout-panel]")).forEach((a) => {
525+
let o = 0, s = 0, l = 0, u = 0, d = 0, f = 0, p = !1, m = !1;
526+
function h() {
527+
p = !1, m = !1, u = 0, d = 0;
528+
}
529+
function g(e, t) {
530+
a.style.transition = "transform 0.2s cubic-bezier(0.3, 0, 0.2, 1)", a.style.transform = e, j(a).then(() => {
531+
t && t(), a.style.transition = "", a.style.transform = "";
532+
});
533+
}
534+
a.addEventListener("touchstart", (t) => {
535+
if (t.touches.length !== 1 || !e.open || F(e) || n) return;
536+
let i = t.touches[0];
537+
o = i.clientX, s = i.clientY, l = t.timeStamp, f = r === "x" ? a.getBoundingClientRect().width : a.getBoundingClientRect().height, h();
538+
}, { passive: !0 }), a.addEventListener("touchmove", (n) => {
539+
if (n.touches.length !== 1 || !e.open || F(e)) return;
540+
let c = n.touches[0], f = r === "x" ? c.clientX - o : c.clientY - s, h = r === "x" ? c.clientY - s : c.clientX - o, g = f * i;
541+
if (!p) {
542+
if (Math.abs(f) < 8 && Math.abs(h) < 8) return;
543+
p = !0, m = Math.abs(f) > Math.abs(h) && g > 0 && R(a, t), m && (a.style.transition = "none");
544+
}
545+
if (!m) return;
546+
n.preventDefault();
547+
let _ = Math.max(0, g), v = n.timeStamp - l;
548+
v > 0 && (d = (_ - u) / v), u = _, l = n.timeStamp, a.style.transform = r === "x" ? `translateX(${i * _}px)` : `translateY(${i * _}px)`;
549+
}, { passive: !1 });
550+
function _() {
551+
if (!m) {
552+
h();
553+
return;
554+
}
555+
let e = f > 0 && u > f * .4 || d > .5;
556+
h(), n = !0, e ? g(r === "x" ? `translateX(${i * 100}%)` : `translateY(${i * 100}%)`, () => {
557+
n = !1, c({ immediate: !0 });
558+
}) : g(r === "x" ? "translateX(0)" : "translateY(0)", () => {
559+
n = !1;
560+
});
561+
}
562+
a.addEventListener("touchend", _, { passive: !0 }), a.addEventListener("touchcancel", _, { passive: !0 });
563+
});
564+
}
565+
function p(t) {
566+
let r = !1, i = 0, a = 0;
567+
document.addEventListener("touchstart", (o) => {
568+
if (!e.isConnected || o.touches.length !== 1 || e.open || F(e) || n) return;
569+
let s = o.touches[0];
570+
B(s.clientX, s.clientY, t, 24) && (r = !0, i = s.clientX, a = s.clientY);
571+
}, { passive: !0 }), document.addEventListener("touchmove", (e) => {
572+
if (!r || e.touches.length !== 1) return;
573+
let n = e.touches[0], { inward: o, cross: c } = z(t, n.clientX - i, n.clientY - a);
574+
o > 48 && o > Math.abs(c) && (r = !1, s());
575+
}, { passive: !0 });
576+
let o = () => {
577+
r = !1;
578+
};
579+
document.addEventListener("touchend", o, { passive: !0 }), document.addEventListener("touchcancel", o, { passive: !0 });
580+
}
581+
let m = L(e);
582+
if (m) {
583+
let t = I(e);
584+
(m === "close" || m === "both") && f(t), (m === "open" || m === "both") && p(t);
585+
}
586+
e._hui = {
472587
open: s,
473588
close: c
474589
}, e.hasAttribute("data-hui-flyout-open") && (e.removeAttribute("data-hui-flyout-open"), s());
475590
}
476-
var V = 0;
477-
function H() {
478-
return `hui-${++V}-${Date.now()}`;
591+
var H = 0;
592+
function U() {
593+
return `hui-${++H}-${Date.now()}`;
479594
}
480-
function U(e) {
595+
function ue(e) {
481596
Array.from(e.querySelectorAll("[data-hui-flyout-trigger]")).forEach((e) => {
482597
e.hasAttribute("data-hui-flyout-trigger-bound") || (e.setAttribute("data-hui-flyout-trigger-bound", ""), e.addEventListener("click", () => {
483598
let t = e.getAttribute("data-hui-flyout-trigger");
@@ -488,7 +603,7 @@ function U(e) {
488603
});
489604
}
490605
function W(e = document) {
491-
Array.from(e.querySelectorAll("[data-hui-flyout]")).forEach(B), U(e);
606+
Array.from(e.querySelectorAll("[data-hui-flyout]")).forEach(V), ue(e);
492607
}
493608
typeof window < "u" && typeof document < "u" && (document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", () => W()) : W());
494609
//#endregion
@@ -865,8 +980,8 @@ function $(e = document) {
865980
typeof window < "u" && typeof document < "u" && (document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", () => $()) : $());
866981
//#endregion
867982
//#region resources/js/hui.ts
868-
function ie(...e) {
983+
function de(...e) {
869984
return e.filter(Boolean).join(" ");
870985
}
871986
//#endregion
872-
export { ie as cn };
987+
export { de as cn };

docs/flyout.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ The flyout can be closed by:
4747
- Clicking a `data-hui-flyout-close` element inside the flyout
4848
- Pressing `Escape`
4949
- Clicking the backdrop (outside the panel)
50+
- Swiping the panel toward its edge (when [`swipe`](#swipe-gestures-mobile) is enabled)
5051
- Calling `closeFlyout('id')` from JavaScript
5152

5253
### Preventing close
@@ -98,6 +99,52 @@ In inline mode:
9899
- The trigger button can be hidden with responsive utilities (e.g. `lg:hidden`)
99100
- Opening via trigger/JS API is ignored
100101

102+
### Swipe gestures (mobile)
103+
104+
The `swipe` prop enables touch swipe gestures. The direction is derived from `position` — you drag the panel toward
105+
its own edge to close it, and swipe inward from that edge to open it.
106+
107+
```bladehtml
108+
109+
{{-- Both directions (swipe to open from the edge, swipe the panel away to close) --}}
110+
<x-hui::flyout id="menu" position="left" swipe>...</x-hui::flyout>
111+
112+
{{-- Close only — the safe default for most drawers and bottom sheets --}}
113+
<x-hui::flyout id="cart" position="right" swipe="close">...</x-hui::flyout>
114+
115+
{{-- Open only --}}
116+
<x-hui::flyout id="nav" position="left" swipe="open">...</x-hui::flyout>
117+
```
118+
119+
| `swipe` value | Gestures enabled |
120+
|--------------------|---------------------------|
121+
| `false` (default) | None |
122+
| `true` | Swipe to open **and** close |
123+
| `"close"` | Swipe to close only |
124+
| `"open"` | Swipe to open only |
125+
| `"both"` | Same as `true` |
126+
127+
**Swipe to close.** While open, dragging the panel toward its edge makes it follow your finger. Releasing past ~40% of
128+
the panel's size (or with a quick flick) animates it the rest of the way out and closes; otherwise it snaps back. For
129+
`top`/`bottom` panels the gesture only engages when the panel's content is scrolled to the relevant edge, so it never
130+
hijacks normal scrolling.
131+
132+
| Position | Swipe to close | Swipe to open (from edge) |
133+
|----------|----------------|---------------------------|
134+
| `right` | swipe right | swipe left from right edge |
135+
| `left` | swipe left | swipe right from left edge |
136+
| `top` | swipe up | swipe down from top edge |
137+
| `bottom` | swipe down | swipe up from bottom edge |
138+
139+
> [!NOTE]
140+
> Swipe is touch-only and has no effect in [inline mode](#responsive-inline-mode-sidebar). Swipe-to-close works with or
141+
> without [transitions](#transitions) — it runs its own panel animation.
142+
143+
> [!WARNING]
144+
> Edge **swipe-to-open** can conflict with native browser/OS edge gestures (e.g. iOS Safari's left-edge back swipe, or
145+
> the bottom home indicator). For these positions, prefer `swipe="close"` and open via a trigger button. Register at most
146+
> one swipe-to-open flyout per screen edge.
147+
101148
### Nested flyouts
102149

103150
Flyouts can be nested. Each opens in the browser's top layer, stacking naturally. Escape closes only the topmost flyout,
@@ -183,6 +230,7 @@ Add transition attributes to the background and/or panel for animated open/close
183230
| `close-on-backdrop-click` | `boolean` | `true` | Whether backdrop click closes the flyout. |
184231
| `scroll-lock` | `boolean` | `false` | Locks body scroll while open. |
185232
| `inline` | `int` | `null` | Breakpoint (px) above which the flyout is a sidebar. |
233+
| `swipe` | `bool\|string` | `false` | Touch swipe gestures: `true`/`"both"`, `"open"`, or `"close"`. |
186234

187235
### Panel
188236

0 commit comments

Comments
 (0)