product category page: front-end sorting and filtering using turboframes

This commit is contained in:
elvira
2026-08-31 22:56:40 +03:00
parent b070a7d1e6
commit 8a07c772f8
20 changed files with 428 additions and 148 deletions
+7
View File
@@ -1,5 +1,12 @@
import "./bootstrap";
import "./utils/strip-accents";
import "./utils/refresh-scroll";
// Frames only — no site-wide Turbo Drive. <turbo-frame> navigations still work
// (that's how the category listing reloads); every other link and form on the
// site keeps its normal full-page browser behaviour.
import "@hotwired/turbo";
window.Turbo.session.drive = false;
import { Application } from "@hotwired/stimulus";
import { registerControllers } from "./stimulus/index";
@@ -0,0 +1,27 @@
import { Controller } from '@hotwired/stimulus'
// Submits the host <form> a short beat after a control inside it changes,
// coalescing a burst — rapid slider nudges, or holding an arrow key on a range
// input — into a single submit. Wire it on the <form>:
//
// <form data-controller="auto-submit"
// data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
// data-auto-submit-delay-value="300"> (delay optional, ms)
//
// `change` covers native inputs (checkbox, select); the range slider emits its
// own `range-slider:change` on commit. Uses requestSubmit() (not submit()) so a
// <turbo-frame> around the form still captures the navigation and validation runs.
export default class extends Controller {
static values = { delay: { type: Number, default: 300 } }
submit() {
clearTimeout(this.#timer)
this.#timer = setTimeout(() => this.element.requestSubmit(), this.delayValue)
}
disconnect() {
clearTimeout(this.#timer)
}
#timer
}
+2
View File
@@ -4,6 +4,7 @@
// application.register('hello', HelloController);
import AppearController from './appear-controller'
import AutoSubmitController from './auto-submit-controller'
import BackToTopController from './back-to-top-controller'
import CarouselController from './carousel-controller'
import DropdownController from './dropdown-controller'
@@ -16,6 +17,7 @@ import TabsController from './tabs-controller'
export function registerControllers(application) {
application.register('appear', AppearController)
application.register('auto-submit', AutoSubmitController)
application.register('back-to-top', BackToTopController)
application.register('carousel', CarouselController)
application.register('dropdown', DropdownController)
@@ -14,7 +14,7 @@ import { Controller } from '@hotwired/stimulus'
// max and vice versa. Emits `range-slider:input` while dragging and
// `range-slider:change` on commit, both with { min, max }.
export default class extends Controller {
static targets = ['minInput', 'maxInput', 'field', 'track', 'minThumb', 'maxThumb', 'output', 'reset']
static targets = ['minInput', 'maxInput', 'field', 'track', 'minThumb', 'maxThumb', 'output']
static values = {
min: Number,
max: Number,
@@ -26,7 +26,6 @@ export default class extends Controller {
connect() {
this.#clamp()
this.defaults = { min: this.#lo, max: this.#hi }
this.fieldTargets.forEach((field) => field.classList.add('sr-only'))
this.#render()
}
@@ -78,13 +77,6 @@ export default class extends Controller {
this.#startDrag(event, input)
}
reset() {
this.minInputTarget.value = this.defaults.min
this.maxInputTarget.value = this.defaults.max
this.#render()
this.#emit('change')
}
// ── internals ──────────────────────────────────────────────────────
#startDrag(event, input) {
@@ -158,10 +150,6 @@ export default class extends Controller {
const fmt = (v) => `${this.prefixValue}${v}${this.suffixValue}`
this.outputTarget.textContent = fmt(lo) + this.separatorValue + fmt(hi)
}
if (this.hasResetTarget) {
this.resetTarget.hidden = lo === this.defaults.min && hi === this.defaults.max
}
}
#emit(name) {
+64
View File
@@ -0,0 +1,64 @@
// Make a refresh land back where you were — accurately.
//
// Turbo Drive is off site-wide (see app.js), so a refresh is a full browser
// load. The browser restores the scroll position early in that load — before
// the Manrope web fonts swap in and reflow the header, <h1> and result count
// above the product grid — so it settles a bit too low. We record the position
// ourselves and re-apply it once the layout has actually stopped moving.
//
// Separately: drop focus on the way out. Otherwise the browser re-focuses
// whatever filter control was active and scrolls it into view on reload, and
// that sidebar stacks below the grid on narrow screens — hence the jump to the
// bottom.
//
// The real fix for the drift is preloading the above-the-fold font weights so
// there's no reflow to chase; this keeps the restore correct until then, and
// harmless after.
const key = 'scrollY:' + location.pathname + location.search
let frame = 0
window.addEventListener(
'scroll',
() => {
if (frame) return
frame = requestAnimationFrame(() => {
frame = 0
try {
sessionStorage.setItem(key, String(Math.round(window.scrollY)))
} catch {}
})
},
{ passive: true },
)
window.addEventListener('pagehide', () => {
const el = document.activeElement
if (el && el !== document.body) el.blur()
})
// Only reloads and back/forward should resume a position; a fresh visit to the
// page starts where it naturally would.
const [nav] = performance.getEntriesByType('navigation')
if (nav && (nav.type === 'reload' || nav.type === 'back_forward')) {
let saved = null
try {
saved = sessionStorage.getItem(key)
} catch {}
if (saved !== null) {
const y = Number(saved)
const apply = () => window.scrollTo(0, y)
window.addEventListener(
'load',
() => {
apply()
// Fonts (and any late above-the-fold image) can still nudge
// layout a frame or two after load — re-apply once they settle.
document.fonts?.ready.then(() => requestAnimationFrame(apply))
},
{ once: true },
)
}
}