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
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Catalog;
use Illuminate\Http\Request;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Enums\ProductSort;
/**
* The parsed state of a category listing request. The query string is the single
* source of truth for sort / filters / page — build one of these from the
* request, read the applied values off it, and use query() to build links (sort
* options, pagination, "clear filter") that carry the rest of the state along.
*
* A param is only ever emitted when it differs from its default, so a pristine
* listing is just `/category/{id}` with no query string.
*/
final class CategoryListing
{
private function __construct(
public readonly ?ProductSort $sort,
public readonly ?int $minPrice,
public readonly ?int $maxPrice,
public readonly bool $inStockOnly,
public readonly int $page,
) {}
public static function fromRequest(Request $request): self
{
return new self(
sort: ProductSort::tryFrom((string) $request->query('sort')),
minPrice: self::intOrNull($request->query('price_min')),
maxPrice: self::intOrNull($request->query('price_max')),
inStockOnly: $request->boolean('in_stock'),
page: max(1, (int) $request->query('page', 1)),
);
}
public function filters(int $collectionId): ProductFilters
{
return new ProductFilters(
collectionId: $collectionId,
minPrice: $this->minPrice,
maxPrice: $this->maxPrice,
inStockOnly: $this->inStockOnly,
);
}
/**
* The applied params as a clean array (defaults omitted), with `$overrides`
* merged on top — pass `['key' => null]` to drop one. Feeds straight into
* route('category.show', ['id' => $id] + $listing->query([...])).
*
* @param array<string, string|int|null> $overrides
* @return array<string, string|int>
*/
public function query(array $overrides = []): array
{
return array_filter([
'sort' => $this->sort?->value,
'price_min' => $this->minPrice,
'price_max' => $this->maxPrice,
'in_stock' => $this->inStockOnly ? 1 : null,
'page' => $this->page > 1 ? $this->page : null,
...$overrides,
], fn ($value) => $value !== null);
}
/**
* Whether the listing is reordered/narrowed enough that it shouldn't be
* indexed as its own page (the canonical still points at the bare category
* URL either way). A plain in-stock toggle is left indexable.
*/
public function isRefined(): bool
{
return $this->sort !== null
|| $this->minPrice !== null
|| $this->maxPrice !== null
|| $this->page > 1;
}
private static function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
}
+25 -6
View File
@@ -2,8 +2,8 @@
namespace App\Http\Controllers;
use App\Catalog\CategoryListing;
use Illuminate\Http\Response;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Services\CollectionService;
use Modules\Core\Catalog\Services\ProductService;
@@ -19,27 +19,46 @@ public function show(string $locale, int $collection)
$collectionData = $this->collections->getById($collection);
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
$listing = CategoryListing::fromRequest(request());
$filters = $listing->filters($collectionData['id']);
$perPage = 12;
$page = (int) request('page', 1);
// Listing/filtering reads from the Meilisearch index via ProductService,
// not Eloquent — see Modules\Core\Catalog\Services\ProductService. list() returns a
// real LengthAwarePaginator of plain arrays (already localized/flattened),
// not Product models.
// not Product models. Sort/filter/page all come from the query string via
// CategoryListing, which is the single source of truth for that state.
$products = $this->products->list(
filters: new ProductFilters(collectionId: $collectionData['id']),
filters: $filters,
perPage: $perPage,
page: $page,
page: $listing->page,
sort: $listing->sort,
)->through(fn (array $product) => [
'name' => $product['name'],
'price' => $product['price'],
'image' => $product['media'][0]['url'] ?? null,
'href' => route('product.show', ['id' => $product['id']]),
]);
])->appends($listing->query(['page' => null]));
// Slider bounds — the price span of everything matching the *other*
// filters (priceRange() drops the price filter itself, so the handles
// don't collapse to whatever's already selected). Whole euros.
$priceRange = $this->products->priceRange($filters);
$priceFloor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
$priceCeil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
// A price param is only a real filter if it's tighter than the bounds —
// drives whether the "clear" link shows.
$priceFiltered = ($listing->minPrice !== null && $listing->minPrice > ($priceFloor ?? PHP_INT_MIN))
|| ($listing->maxPrice !== null && $listing->maxPrice < ($priceCeil ?? PHP_INT_MAX));
return view('category.show', [
'collection' => $collectionData,
'products' => $products,
'listing' => $listing,
'priceFloor' => $priceFloor,
'priceCeil' => $priceCeil,
'priceFiltered' => $priceFiltered,
]);
}
}
+6
View File
@@ -44,6 +44,12 @@ @layer base {
button:not(:disabled) {
cursor: pointer;
}
/* <turbo-frame> is a custom element — inline by default. Give it a box so
the grid it wraps on the category page lays out normally. */
turbo-frame {
display: block;
}
}
@keyframes spin {
+6 -6
View File
@@ -4,7 +4,7 @@ @font-face {
font-family: "Manrope";
font-style: normal;
font-weight: 300;
src: url("../fonts/manrope/manrope-v20-greek_latin-300.woff2")
src: url("/fonts/manrope/manrope-v20-greek_latin-300.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* manrope-regular - greek_latin */
@@ -13,7 +13,7 @@ @font-face {
font-family: "Manrope";
font-style: normal;
font-weight: 400;
src: url("../fonts/manrope/manrope-v20-greek_latin-regular.woff2")
src: url("/fonts/manrope/manrope-v20-greek_latin-regular.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* manrope-500 - greek_latin */
@@ -22,7 +22,7 @@ @font-face {
font-family: "Manrope";
font-style: normal;
font-weight: 500;
src: url("../fonts/manrope/manrope-v20-greek_latin-500.woff2")
src: url("/fonts/manrope/manrope-v20-greek_latin-500.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* manrope-600 - greek_latin */
@@ -31,7 +31,7 @@ @font-face {
font-family: "Manrope";
font-style: normal;
font-weight: 600;
src: url("../fonts/manrope/manrope-v20-greek_latin-600.woff2")
src: url("/fonts/manrope/manrope-v20-greek_latin-600.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* manrope-700 - greek_latin */
@@ -40,7 +40,7 @@ @font-face {
font-family: "Manrope";
font-style: normal;
font-weight: 700;
src: url("../fonts/manrope/manrope-v20-greek_latin-700.woff2")
src: url("/fonts/manrope/manrope-v20-greek_latin-700.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* manrope-800 - greek_latin */
@@ -49,6 +49,6 @@ @font-face {
font-family: "Manrope";
font-style: normal;
font-weight: 800;
src: url("../fonts/manrope/manrope-v20-greek_latin-800.woff2")
src: url("/fonts/manrope/manrope-v20-greek_latin-800.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
+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 },
)
}
}
@@ -0,0 +1,154 @@
{{--
The reloadable body of the category listing — count + sort, product grid +
pagination, and the filter sidebar. Rendered both on full page load and on
every <turbo-frame id="category-listing"> navigation, always straight from
the query string ($listing). Anything that must reflect the applied
sort/filter/page state belongs in here.
Vars: $collection, $products (LengthAwarePaginator), $listing (App\Catalog\CategoryListing)
--}}
@php
// Keys are the `sort` URL param values — the ProductSort enum cases, plus
// "popularity" for the default (no param). App\Catalog\CategoryListing is
// the single place that validates them back into the enum.
$sortLabels = [
'popularity' => __('storefront.shop.sort_popularity'),
'price_asc' => __('storefront.shop.sort_price_asc'),
'price_desc' => __('storefront.shop.sort_price_desc'),
'newest' => __('storefront.shop.sort_newest'),
];
$currentSort = $listing->sort?->value ?? 'popularity';
@endphp
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
<div class="flex items-center justify-between gap-6 flex-wrap mb-7">
<p>
{{ trans_choice('storefront.shop.showing_results', $products->total(), [
'first' => $products->firstItem() ?? 0,
'last' => $products->lastItem() ?? 0,
'total' => $products->total(),
]) }}
</p>
{{-- Sort options are plain links carrying the rest of the applied state;
following one navigates the frame and advances the URL. Changing
sort drops back to page 1. --}}
<x-ui.dropdown
id="category-sort"
:label="$sortLabels[$currentSort]"
:ariaLabel="__('storefront.shop.sort_label')"
triggerClass="min-w-48"
>
@foreach ($sortLabels as $value => $label)
<x-ui.dropdown.item
:href="route('category.show', ['id' => $collection['id']] + $listing->query([
'sort' => $value === 'popularity' ? null : $value,
'page' => null,
]))"
:current="$value === $currentSort"
>{{ $label }}</x-ui.dropdown.item>
@endforeach
</x-ui.dropdown>
</div>
<div></div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
{{-- Products --}}
<div>
@if($products->isEmpty())
<p class="text-neutral-500">{{ __('storefront.shop.no_products') }}</p>
@else
<x-product-grid :products="$products->items()" cols="3" />
<div class="mt-12">
<x-ui.pagination :paginator="$products" />
</div>
@endif
</div>
{{-- Sidebar — sort, price and in-stock are wired to the back-end; the
search box is still a placeholder. --}}
<aside class="flex flex-col gap-10">
{{-- Placeholder — not wired yet. --}}
<div class="-mt-2">
<label for="shop-search" class="sr-only">{{ __('storefront.shop.search_label') }}</label>
<div class="relative">
<x-ui.input
type="search"
id="shop-search"
:placeholder="__('storefront.shop.search_placeholder')"
class="pr-10"
/>
<button
type="button"
class="absolute right-0 top-1/2 -translate-y-1/2"
aria-label="{{ __('storefront.shop.search_label') }}"
>
<x-ui.icon name="search" :size="20" />
</button>
</div>
</div>
{{-- Filter form: a plain GET form whose fields ARE the state. Changing
any control auto-submits (auto-submit controller); Turbo captures
the GET, navigates the frame, and advances the URL. `sort` rides
along as a hidden field so it survives a filter change; `page` is
deliberately absent, so filtering drops back to page 1. Without JS
the sr-only submit button applies the range inputs. --}}
<form
method="get"
action="{{ route('category.show', ['id' => $collection['id']]) }}"
data-controller="auto-submit"
data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
class="contents"
>
@if ($listing->sort)
<input type="hidden" name="sort" value="{{ $listing->sort->value }}">
@endif
@if ($priceFloor !== null && $priceCeil !== null && $priceCeil > $priceFloor)
<div class="flex flex-col gap-4">
<p class="font-extrabold text-h4">{{ __('storefront.shop.filter_price') }}</p>
<x-ui.range-slider
name="price"
:min="$priceFloor"
:max="$priceCeil"
:min-value="max($priceFloor, $listing->minPrice ?? $priceFloor)"
:max-value="min($priceCeil, $listing->maxPrice ?? $priceCeil)"
prefix="€"
separator=" - "
:legend="__('storefront.shop.filter_price')"
:min-label="__('storefront.shop.price_min')"
:max-label="__('storefront.shop.price_max')"
>
{{-- Clear the price filter — a plain link back to the URL
without price_*; only shown when actually filtered. --}}
@if ($priceFiltered)
<a
href="{{ route('category.show', ['id' => $collection['id']] + $listing->query(['price_min' => null, 'price_max' => null, 'page' => null])) }}"
class="underline-slide [--slide-h:1px] font-display text-sm font-bold uppercase italic"
>{{ __('storefront.shop.reset') }}</a>
@endif
</x-ui.range-slider>
</div>
@endif
<div class="flex flex-col gap-4 [&_label]:text-base">
<p class="font-extrabold text-h4">{{ __('storefront.shop.availability') }}</p>
<x-ui.checkbox id="shop-in-stock" name="in_stock" :checked="$listing->inStockOnly">
{{ __('storefront.shop.in_stock_only') }}
</x-ui.checkbox>
</div>
<button type="submit" class="sr-only">{{ __('storefront.shop.apply') }}</button>
</form>
</aside>
</div>
+20 -102
View File
@@ -3,6 +3,16 @@
@section('title', $collection['name'] . ' — ' . config('app.name'))
@section('description', strip_tags($collection['description'] ?? ''))
@push('seo')
{{-- Filtered / sorted / paged variants all consolidate onto the bare
category URL; the noindex keeps the near-duplicate variants out of the
index while still letting crawlers follow through to the products. --}}
<link rel="canonical" href="{{ route('category.show', ['id' => $collection['id']]) }}">
@if($listing->isRefined())
<meta name="robots" content="noindex,follow">
@endif
@endpush
@section('content')
<div class="max-w-5xl mx-auto pt-26 pb-12">
@@ -15,108 +25,16 @@
]" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
<div class="flex items-center justify-between gap-6 flex-wrap mb-7">
{{-- <p class="text-neutral-600"> --}}
<p>
{{ trans_choice('storefront.shop.showing_results', $products->total(), [
'first' => $products->firstItem() ?? 0,
'last' => $products->lastItem() ?? 0,
'total' => $products->total(),
]) }}
</p>
{{-- Dummy — not wired to real sorting yet. Options carry data-action
for a future `category` Stimulus controller; no-op until that
controller exists and a [data-controller="category"] wraps this. --}}
<x-ui.dropdown
id="category-sort"
:label="__('storefront.shop.sort_popularity')"
:ariaLabel="__('storefront.shop.sort_label')"
triggerClass="min-w-48"
>
@foreach ([
'popularity' => __('storefront.shop.sort_popularity'),
'price-asc' => __('storefront.shop.sort_price_asc'),
'price-desc' => __('storefront.shop.sort_price_desc'),
'newest' => __('storefront.shop.sort_newest'),
] as $value => $label)
<x-ui.dropdown.item
data-action="category#sort"
data-category-sort-param="{{ $value }}"
:current="$value === 'popularity'"
>{{ $label }}</x-ui.dropdown.item>
@endforeach
</x-ui.dropdown>
</div>
<div></div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
{{-- Products --}}
<div>
@if($products->isEmpty())
<p class="text-neutral-500">{{ __('storefront.shop.no_products') }}</p>
@else
<x-product-grid :products="$products->items()" cols="3" />
<div class="mt-12">
<x-ui.pagination :paginator="$products" />
</div>
@endif
</div>
{{-- Sidebar — search, price and availability filters are dummy for now --}}
<aside class="flex flex-col gap-10">
<div class="-mt-2">
<label for="shop-search" class="sr-only">{{ __('storefront.shop.search_label') }}</label>
<div class="relative">
<x-ui.input
type="search"
id="shop-search"
:placeholder="__('storefront.shop.search_placeholder')"
class="pr-10"
/>
<button
type="button"
class="absolute right-0 top-1/2 -translate-y-1/2"
aria-label="{{ __('storefront.shop.search_label') }}"
>
<x-ui.icon name="search" :size="20" />
</button>
</div>
</div>
<div class="flex flex-col gap-4">
<p class="font-extrabold text-h4">{{ __('storefront.shop.filter_price') }}</p>
{{-- Dummy — not wired to real price bounds or filtering yet. --}}
<x-ui.range-slider
:min="10"
:max="50"
prefix="€"
separator=" - "
:legend="__('storefront.shop.filter_price')"
:min-label="__('storefront.shop.price_min')"
:max-label="__('storefront.shop.price_max')"
:reset-label="__('storefront.shop.reset')"
/>
</div>
<div class="flex flex-col gap-4 [&_label]:text-base">
<p class="font-extrabold text-h4">{{ __('storefront.shop.availability') }}</p>
<x-ui.checkbox id="shop-in-stock" name="in_stock">
{{ __('storefront.shop.in_stock_only') }}
</x-ui.checkbox>
</div>
</aside>
</div>
{{-- Everything that reflects sort / filter / page state lives in this
frame. A sort link or filter submit inside it navigates the frame;
the controller re-renders it straight from the query string, and
data-turbo-action="advance" keeps the address bar in sync so a
refresh or bookmark reproduces the exact same view. With no JS the
frame is just a block and the links do full-page navigations to the
same URLs. --}}
<turbo-frame id="category-listing" data-turbo-action="advance">
@include('category.partials.listing')
</turbo-frame>
</div>
@endsection
@@ -1,23 +1,28 @@
@props([
'min',
'max',
'minValue' => null,
'maxValue' => null,
'step' => 1,
'name' => null,
'legend' => 'Range',
'minLabel' => 'Minimum',
'maxLabel' => 'Maximum',
'prefix' => '',
'suffix' => '',
'separator' => ' – ',
'resetLabel' => 'Reset',
'minValue' => null,
'maxValue' => null,
'step' => 1,
'name' => null,
'legend' => 'Range',
'minLabel' => 'Minimum',
'maxLabel' => 'Maximum',
'prefix' => '',
'suffix' => '',
'separator' => ' – ',
])
{{-- The default slot sits on the readout row, to the right of the min–max
text — use it for a "clear"/"reset" control when the slider is filtered.
Left empty otherwise. --}}
@php
$minValue ??= $min;
$maxValue ??= $max;
$uid = 'rs-' . uniqid();
// Stable when a name is given so focus can survive a re-render; random
// otherwise, just to keep the label/input association unique on the page.
$uid = 'rs-' . ($name ?: uniqid());
@endphp
@@ -46,7 +51,7 @@
max="{{ $max }}"
step="{{ $step }}"
value="{{ $minValue }}"
@if($name) name="{{ $name }}[min]" @endif
@if($name) name="{{ $name }}_min" @endif
data-range-slider-target="minInput"
data-action="input->range-slider#onInput change->range-slider#onChange focus->range-slider#syncFocus blur->range-slider#syncFocus"
>
@@ -61,7 +66,7 @@
max="{{ $max }}"
step="{{ $step }}"
value="{{ $maxValue }}"
@if($name) name="{{ $name }}[max]" @endif
@if($name) name="{{ $name }}_max" @endif
data-range-slider-target="maxInput"
data-action="input->range-slider#onInput change->range-slider#onChange focus->range-slider#syncFocus blur->range-slider#syncFocus"
>
@@ -111,12 +116,6 @@ class="absolute top-1/2 left-[var(--max)] grid h-8 w-6 -translate-x-1/2 -transla
<span data-range-slider-target="output" aria-hidden="true" class="text-sm tabular-nums"
>{{ $prefix }}{{ $minValue }}{{ $suffix }}{{ $separator }}{{ $prefix }}{{ $maxValue }}{{ $suffix }}</span>
<button
type="button"
hidden
data-range-slider-target="reset"
data-action="range-slider#reset"
class="underline-slide [--slide-h:1px] font-display text-sm font-bold uppercase italic"
>{{ $resetLabel }}</button>
{{ $slot }}
</div>
</div>
+10
View File
@@ -26,6 +26,16 @@
@stack('seo')
{{-- Preload the Manrope weights used in above-the-fold text (body 400,
category <h1> 500, generic headings 700, nav 800) so the swap-in
doesn't reflow the header and headings on load — which otherwise
throws off scroll restoration on refresh. Same URLs as the @font-face
rules in fonts.css, so each is fetched once. --}}
<link rel="preload" as="font" type="font/woff2" crossorigin href="/fonts/manrope/manrope-v20-greek_latin-regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/fonts/manrope/manrope-v20-greek_latin-500.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/fonts/manrope/manrope-v20-greek_latin-700.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/fonts/manrope/manrope-v20-greek_latin-800.woff2">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@auth('staff')