generated from boboko/starter
category page filters components and general ui - no functionality yet
This commit is contained in:
Generated
+985
-1550
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "./fonts.css";
|
@import "./fonts.css";
|
||||||
|
@import "./dropdown.css";
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
@source '../**/*.blade.php';
|
@source '../**/*.blade.php';
|
||||||
@@ -20,7 +21,7 @@ @theme {
|
|||||||
--text-h1: 60px;
|
--text-h1: 60px;
|
||||||
--text-h2: 48px;
|
--text-h2: 48px;
|
||||||
--text-h3: 36px;
|
--text-h3: 36px;
|
||||||
--text-h4: 27px;
|
--text-h4: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
/* ── Dropdown (Popover API) ──────────────────────────────────── */
|
||||||
|
/* The panel is a [popover] → it renders in the top layer, so its
|
||||||
|
containing block is the viewport, not the .dropdown wrapper, and
|
||||||
|
CSS alone can't tie it to the trigger (anchor positioning isn't
|
||||||
|
everywhere yet). The `dropdown` Stimulus controller measures the
|
||||||
|
trigger on open and writes --dropdown-top/left/width here; the
|
||||||
|
open/close animation below stays pure CSS. Opens on a click, so
|
||||||
|
animating transform + opacity is CLS-safe. */
|
||||||
|
.dropdown-panel {
|
||||||
|
top: var(--dropdown-top, 0);
|
||||||
|
left: var(--dropdown-left, 0);
|
||||||
|
min-width: var(--dropdown-width, 0);
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
transform 0.2s ease,
|
||||||
|
display 0.2s allow-discrete,
|
||||||
|
overlay 0.2s allow-discrete;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-panel:popover-open {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
.dropdown-panel:popover-open {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Caret flips while the panel is open — :has() is the only route
|
||||||
|
back up from the popover's :popover-open state to the caret. */
|
||||||
|
.dropdown:has(.dropdown-panel:popover-open) .dropdown-caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus'
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['menu']
|
|
||||||
|
|
||||||
open() {
|
|
||||||
this.menuTarget.classList.add('is-open')
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.menuTarget.classList.remove('is-open')
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle() {
|
|
||||||
this.menuTarget.classList.toggle('is-open')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
// Positions the popover panel directly under its trigger.
|
||||||
|
//
|
||||||
|
// A [popover] renders in the top layer, so its containing block is the
|
||||||
|
// viewport, not the .dropdown wrapper — CSS alone can't tie it to the trigger
|
||||||
|
// without anchor positioning, which isn't in every browser yet. So on each
|
||||||
|
// open we measure the trigger and write the geometry to CSS custom properties
|
||||||
|
// that .dropdown-panel consumes (top / left / min-width). The open/close
|
||||||
|
// animation stays entirely in CSS; the controller only feeds it three numbers.
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['trigger', 'panel']
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Wired to `click->dropdown#position` on the trigger, which also fires for
|
||||||
|
// keyboard activation (Enter/Space on a <button>), so this runs before the
|
||||||
|
// native popover toggle paints the panel.
|
||||||
|
position() {
|
||||||
|
const rect = this.triggerTarget.getBoundingClientRect()
|
||||||
|
const style = this.panelTarget.style
|
||||||
|
|
||||||
|
style.setProperty('--dropdown-top', `${rect.bottom + window.scrollY}px`)
|
||||||
|
style.setProperty('--dropdown-left', `${rect.left + window.scrollX}px`)
|
||||||
|
style.setProperty('--dropdown-width', `${rect.width}px`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@
|
|||||||
import AppearController from './appear-controller'
|
import AppearController from './appear-controller'
|
||||||
import BackToTopController from './back-to-top-controller'
|
import BackToTopController from './back-to-top-controller'
|
||||||
import CarouselController from './carousel-controller'
|
import CarouselController from './carousel-controller'
|
||||||
|
import DropdownController from './dropdown-controller'
|
||||||
import ProductFormController from './product-form-controller'
|
import ProductFormController from './product-form-controller'
|
||||||
import ProductGalleryController from './product-gallery-controller'
|
import ProductGalleryController from './product-gallery-controller'
|
||||||
import QuantityController from './quantity-controller'
|
import QuantityController from './quantity-controller'
|
||||||
|
import RangeSliderController from './range-slider-controller'
|
||||||
import StarRatingController from './star-rating-controller'
|
import StarRatingController from './star-rating-controller'
|
||||||
import TabsController from './tabs-controller'
|
import TabsController from './tabs-controller'
|
||||||
|
|
||||||
@@ -16,9 +18,11 @@ export function registerControllers(application) {
|
|||||||
application.register('appear', AppearController)
|
application.register('appear', AppearController)
|
||||||
application.register('back-to-top', BackToTopController)
|
application.register('back-to-top', BackToTopController)
|
||||||
application.register('carousel', CarouselController)
|
application.register('carousel', CarouselController)
|
||||||
|
application.register('dropdown', DropdownController)
|
||||||
application.register('product-form', ProductFormController)
|
application.register('product-form', ProductFormController)
|
||||||
application.register('product-gallery', ProductGalleryController)
|
application.register('product-gallery', ProductGalleryController)
|
||||||
application.register('quantity', QuantityController)
|
application.register('quantity', QuantityController)
|
||||||
|
application.register('range-slider', RangeSliderController)
|
||||||
application.register('star-rating', StarRatingController)
|
application.register('star-rating', StarRatingController)
|
||||||
application.register('tabs', TabsController)
|
application.register('tabs', TabsController)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
// Dual-thumb range slider.
|
||||||
|
//
|
||||||
|
// Two real <input type="range"> elements stay authoritative — they carry the
|
||||||
|
// value, the form data, native keyboard support and the no-JS fallback. On
|
||||||
|
// connect this controller hides their <label>s and mirrors their state onto a
|
||||||
|
// presentational track: a baseline, a filled span between the two carets, and
|
||||||
|
// the carets themselves, all positioned with the --min / --max percentage
|
||||||
|
// custom properties written on the track element.
|
||||||
|
//
|
||||||
|
// Pointer drag moves the carets (writing back to the inputs); the keyboard
|
||||||
|
// drives the inputs directly. Values can't cross — min stays one step below
|
||||||
|
// 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 values = {
|
||||||
|
min: Number,
|
||||||
|
max: Number,
|
||||||
|
step: { type: Number, default: 1 },
|
||||||
|
prefix: { type: String, default: '' },
|
||||||
|
suffix: { type: String, default: '' },
|
||||||
|
separator: { type: String, default: ' – ' },
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.#clamp()
|
||||||
|
this.defaults = { min: this.#lo, max: this.#hi }
|
||||||
|
this.fieldTargets.forEach((field) => field.classList.add('sr-only'))
|
||||||
|
this.#render()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.#stopDrag()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── keyboard / programmatic ──────────────────────────────────────
|
||||||
|
|
||||||
|
onInput(event) {
|
||||||
|
this.#clamp(this.#side(event.target))
|
||||||
|
this.#render()
|
||||||
|
this.#emit('input')
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(event) {
|
||||||
|
this.#clamp(this.#side(event.target))
|
||||||
|
this.#render()
|
||||||
|
this.#emit('change')
|
||||||
|
}
|
||||||
|
|
||||||
|
// The real inputs are visually hidden, so mirror their focus ring onto
|
||||||
|
// the matching caret to keep a visible focus indicator for keyboard use.
|
||||||
|
syncFocus(event) {
|
||||||
|
const thumb = event.target === this.minInputTarget ? this.minThumbTarget : this.maxThumbTarget
|
||||||
|
thumb.classList.toggle('ring-2', event.type === 'focus')
|
||||||
|
thumb.classList.toggle('ring-black', event.type === 'focus')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── pointer drag ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
thumbPointerDown(event) {
|
||||||
|
const input = event.currentTarget === this.minThumbTarget ? this.minInputTarget : this.maxInputTarget
|
||||||
|
this.#startDrag(event, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackPointerDown(event) {
|
||||||
|
if (event.target.closest('button')) return // a caret handles its own press
|
||||||
|
|
||||||
|
const value = this.#valueAt(event.clientX)
|
||||||
|
const input = Math.abs(value - this.#lo) <= Math.abs(value - this.#hi)
|
||||||
|
? this.minInputTarget
|
||||||
|
: this.maxInputTarget
|
||||||
|
|
||||||
|
input.value = value
|
||||||
|
this.#clamp(this.#side(input))
|
||||||
|
this.#render()
|
||||||
|
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) {
|
||||||
|
event.preventDefault()
|
||||||
|
this.#stopDrag()
|
||||||
|
const side = this.#side(input)
|
||||||
|
this.#onMove = (e) => {
|
||||||
|
input.value = this.#valueAt(e.clientX)
|
||||||
|
this.#clamp(side)
|
||||||
|
this.#render()
|
||||||
|
this.#emit('input')
|
||||||
|
}
|
||||||
|
this.#onUp = () => {
|
||||||
|
this.#stopDrag()
|
||||||
|
this.#emit('change')
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', this.#onMove)
|
||||||
|
window.addEventListener('pointerup', this.#onUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
#side(input) {
|
||||||
|
return input === this.maxInputTarget ? 'max' : 'min'
|
||||||
|
}
|
||||||
|
|
||||||
|
#stopDrag() {
|
||||||
|
if (this.#onMove) window.removeEventListener('pointermove', this.#onMove)
|
||||||
|
if (this.#onUp) window.removeEventListener('pointerup', this.#onUp)
|
||||||
|
this.#onMove = this.#onUp = null
|
||||||
|
}
|
||||||
|
|
||||||
|
get #lo() { return Number(this.minInputTarget.value) }
|
||||||
|
get #hi() { return Number(this.maxInputTarget.value) }
|
||||||
|
|
||||||
|
// Keep both thumbs inside the group bounds and stop them crossing. When a
|
||||||
|
// thumb is being moved (`side`), only that one gives way, so the other
|
||||||
|
// stays put instead of being dragged along.
|
||||||
|
#clamp(side = null) {
|
||||||
|
const gap = this.stepValue
|
||||||
|
let lo = Math.max(this.minValue, Math.min(this.maxValue, Number(this.minInputTarget.value)))
|
||||||
|
let hi = Math.max(this.minValue, Math.min(this.maxValue, Number(this.maxInputTarget.value)))
|
||||||
|
|
||||||
|
if (side === 'max') hi = Math.max(hi, lo + gap)
|
||||||
|
else if (side === 'min') lo = Math.min(lo, hi - gap)
|
||||||
|
else if (lo > hi - gap) lo = hi - gap
|
||||||
|
|
||||||
|
this.minInputTarget.value = lo
|
||||||
|
this.maxInputTarget.value = hi
|
||||||
|
}
|
||||||
|
|
||||||
|
#valueAt(clientX) {
|
||||||
|
const rect = this.trackTarget.getBoundingClientRect()
|
||||||
|
const ratio = rect.width ? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) : 0
|
||||||
|
const raw = this.minValue + ratio * (this.maxValue - this.minValue)
|
||||||
|
const step = this.stepValue
|
||||||
|
return Math.round(raw / step) * step
|
||||||
|
}
|
||||||
|
|
||||||
|
#percent(value) {
|
||||||
|
const span = this.maxValue - this.minValue
|
||||||
|
return span ? ((value - this.minValue) / span) * 100 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#render() {
|
||||||
|
const lo = this.#lo
|
||||||
|
const hi = this.#hi
|
||||||
|
|
||||||
|
this.trackTarget.style.setProperty('--min', `${this.#percent(lo)}%`)
|
||||||
|
this.trackTarget.style.setProperty('--max', `${this.#percent(hi)}%`)
|
||||||
|
|
||||||
|
if (this.hasOutputTarget) {
|
||||||
|
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) {
|
||||||
|
const detail = { min: this.#lo, max: this.#hi }
|
||||||
|
const key = `${detail.min},${detail.max}`
|
||||||
|
if (name === 'input' && key === this.#lastInputKey) return // no change since last frame
|
||||||
|
this.#lastInputKey = key
|
||||||
|
this.dispatch(name, { detail })
|
||||||
|
}
|
||||||
|
|
||||||
|
#onMove = null
|
||||||
|
#onUp = null
|
||||||
|
#lastInputKey = null
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
@section('description', strip_tags($collection['description'] ?? ''))
|
@section('description', strip_tags($collection['description'] ?? ''))
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-8 py-12">
|
<div class="max-w-5xl mx-auto pt-26 pb-12">
|
||||||
|
|
||||||
<div class="flex items-end justify-between gap-6 flex-wrap mb-10">
|
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
|
||||||
<h1 class="font-display font-extrabold text-h1">{{ $collection['name'] }}</h1>
|
<h1 class="font-medium text-h2">{{ $collection['name'] }}</h1>
|
||||||
|
|
||||||
<x-breadcrumb :items="[
|
<x-breadcrumb :items="[
|
||||||
['label' => __('storefront.nav.home'), 'href' => route('home')],
|
['label' => __('storefront.nav.home'), 'href' => route('home')],
|
||||||
@@ -15,33 +15,49 @@
|
|||||||
]" />
|
]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-6 flex-wrap border-b border-black pb-6 mb-10">
|
|
||||||
<p class="text-neutral-600">
|
|
||||||
{{ 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 --}}
|
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
|
||||||
<x-ui.select
|
|
||||||
:ariaLabel="__('storefront.shop.sort_label')"
|
<div class="flex items-center justify-between gap-6 flex-wrap mb-7">
|
||||||
:options="[
|
{{-- <p class="text-neutral-600"> --}}
|
||||||
['value' => 'default', 'label' => __('storefront.shop.sort_default')],
|
<p>
|
||||||
['value' => 'popularity', 'label' => __('storefront.shop.sort_popularity')],
|
{{ trans_choice('storefront.shop.showing_results', $products->total(), [
|
||||||
['value' => 'price-asc', 'label' => __('storefront.shop.sort_price_asc')],
|
'first' => $products->firstItem() ?? 0,
|
||||||
['value' => 'price-desc', 'label' => __('storefront.shop.sort_price_desc')],
|
'last' => $products->lastItem() ?? 0,
|
||||||
['value' => 'newest', 'label' => __('storefront.shop.sort_newest')],
|
'total' => $products->total(),
|
||||||
]"
|
]) }}
|
||||||
value="default"
|
</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>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-[1fr_230px] gap-14">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-12">
|
|
||||||
|
|
||||||
{{-- Products --}}
|
{{-- Products --}}
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
@if($products->isEmpty())
|
@if($products->isEmpty())
|
||||||
<p class="text-neutral-500">{{ __('storefront.shop.no_products') }}</p>
|
<p class="text-neutral-500">{{ __('storefront.shop.no_products') }}</p>
|
||||||
@else
|
@else
|
||||||
@@ -56,7 +72,7 @@
|
|||||||
{{-- Sidebar — search, price and availability filters are dummy for now --}}
|
{{-- Sidebar — search, price and availability filters are dummy for now --}}
|
||||||
<aside class="flex flex-col gap-10">
|
<aside class="flex flex-col gap-10">
|
||||||
|
|
||||||
<div>
|
<div class="-mt-2">
|
||||||
<label for="shop-search" class="sr-only">{{ __('storefront.shop.search_label') }}</label>
|
<label for="shop-search" class="sr-only">{{ __('storefront.shop.search_label') }}</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<x-ui.input
|
<x-ui.input
|
||||||
@@ -76,24 +92,23 @@ class="absolute right-0 top-1/2 -translate-y-1/2"
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<h2 class="font-display font-extrabold uppercase">{{ __('storefront.shop.filter_price') }}</h2>
|
<p class="font-extrabold text-h4">{{ __('storefront.shop.filter_price') }}</p>
|
||||||
|
|
||||||
<div class="flex items-center gap-2" aria-hidden="true">
|
{{-- Dummy — not wired to real price bounds or filtering yet. --}}
|
||||||
<x-ui.icon name="arrow-left" :size="20" />
|
<x-ui.range-slider
|
||||||
<span class="flex-1 h-px bg-black"></span>
|
:min="10"
|
||||||
<x-ui.icon name="arrow-right" :size="20" />
|
:max="50"
|
||||||
</div>
|
prefix="€"
|
||||||
|
separator=" - "
|
||||||
<div class="flex items-center justify-between gap-4">
|
:legend="__('storefront.shop.filter_price')"
|
||||||
<span class="text-sm">€10 - €50</span>
|
:min-label="__('storefront.shop.price_min')"
|
||||||
<button type="button" class="underline-slide [--slide-h:1px] font-display font-bold italic uppercase text-sm">
|
:max-label="__('storefront.shop.price_max')"
|
||||||
{{ __('storefront.shop.apply') }}
|
:reset-label="__('storefront.shop.reset')"
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4 [&_label]:text-base">
|
||||||
<h2 class="font-display font-extrabold uppercase">{{ __('storefront.shop.availability') }}</h2>
|
<p class="font-extrabold text-h4">{{ __('storefront.shop.availability') }}</p>
|
||||||
<x-ui.checkbox id="shop-in-stock" name="in_stock">
|
<x-ui.checkbox id="shop-in-stock" name="in_stock">
|
||||||
{{ __('storefront.shop.in_stock_only') }}
|
{{ __('storefront.shop.in_stock_only') }}
|
||||||
</x-ui.checkbox>
|
</x-ui.checkbox>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<h2 class="font-display font-extrabold text-h2 mb-10">{{ $title }}</h2>
|
<h2 class="font-display font-extrabold text-h2 mb-10">{{ $title }}</h2>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="grid {{ $gridCols }} gap-6">
|
<div class="grid {{ $gridCols }} gap-9">
|
||||||
@foreach($products as $product)
|
@foreach($products as $product)
|
||||||
<x-ui.product-card
|
<x-ui.product-card
|
||||||
:name="$product['name']"
|
:name="$product['name']"
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
@props([
|
||||||
|
'id',
|
||||||
|
'label' => null,
|
||||||
|
'ariaLabel' => null,
|
||||||
|
'triggerClass' => '',
|
||||||
|
])
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-controller="dropdown"
|
||||||
|
{{ $attributes->merge(['class' => 'dropdown relative inline-flex items-center']) }}
|
||||||
|
>
|
||||||
|
{{-- Popover invoker buttons get implicit aria-expanded / aria-details from
|
||||||
|
the browser, so only the accessible name needs setting here.
|
||||||
|
click->dropdown#position measures this button and feeds the panel's
|
||||||
|
position to CSS before the native popover toggle paints it. --}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
popovertarget="{{ $id }}"
|
||||||
|
popovertargetaction="toggle"
|
||||||
|
data-dropdown-target="trigger"
|
||||||
|
data-action="click->dropdown#position"
|
||||||
|
@if($ariaLabel) aria-label="{{ $ariaLabel }}" @endif
|
||||||
|
class="bg-transparent border-b border-black pr-10 py-2.5 cursor-pointer focus:outline-none text-left whitespace-nowrap {{ $triggerClass }}"
|
||||||
|
>
|
||||||
|
{{ $label }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<x-ui.icon
|
||||||
|
name="arrow-down"
|
||||||
|
:size="16"
|
||||||
|
class="dropdown-caret pointer-events-none absolute right-0 transition-transform duration-200"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="{{ $id }}"
|
||||||
|
popover
|
||||||
|
data-dropdown-target="panel"
|
||||||
|
class="dropdown-panel absolute w-max max-w-xs bg-neutral-200 border border-black"
|
||||||
|
>
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@aware(['id'])
|
||||||
|
|
||||||
|
@props([
|
||||||
|
'href' => null,
|
||||||
|
'current' => false,
|
||||||
|
'close' => true,
|
||||||
|
])
|
||||||
|
|
||||||
|
{{--
|
||||||
|
A single option inside <x-ui.dropdown>.
|
||||||
|
|
||||||
|
- Renders a <button> by default, or an <a> when :href is given.
|
||||||
|
- `close` (button only): also dismiss the panel on click via
|
||||||
|
popovertargetaction="hide". Any data-action on the same button still
|
||||||
|
fires — Stimulus handler runs AND the popover closes. Pass :close="false"
|
||||||
|
for options that shouldn't close the panel (e.g. a multi-select filter).
|
||||||
|
- `current`: marks the active option (aria-current + bold).
|
||||||
|
- Everything else (data-action, data-*-param, aria-*, class, …) is forwarded.
|
||||||
|
|
||||||
|
`id` comes from the parent <x-ui.dropdown> via @aware.
|
||||||
|
--}}
|
||||||
|
|
||||||
|
@php $tag = $href ? 'a' : 'button'; @endphp
|
||||||
|
|
||||||
|
<{{ $tag }}
|
||||||
|
@if($tag === 'button') type="button" @endif
|
||||||
|
@if($href) href="{{ $href }}" @endif
|
||||||
|
@if($close && $tag === 'button') popovertarget="{{ $id }}" popovertargetaction="hide" @endif
|
||||||
|
@if($current) aria-current="true" @endif
|
||||||
|
{{ $attributes->merge(['class' => 'block w-full text-left px-5 py-2.5 cursor-pointer transition-colors hover:bg-black hover:text-neutral-200 aria-[current=true]:font-bold']) }}
|
||||||
|
>
|
||||||
|
{{ $slot }}
|
||||||
|
</{{ $tag }}>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
@props([
|
||||||
|
'min',
|
||||||
|
'max',
|
||||||
|
'minValue' => null,
|
||||||
|
'maxValue' => null,
|
||||||
|
'step' => 1,
|
||||||
|
'name' => null,
|
||||||
|
'legend' => 'Range',
|
||||||
|
'minLabel' => 'Minimum',
|
||||||
|
'maxLabel' => 'Maximum',
|
||||||
|
'prefix' => '',
|
||||||
|
'suffix' => '',
|
||||||
|
'separator' => ' – ',
|
||||||
|
'resetLabel' => 'Reset',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$minValue ??= $min;
|
||||||
|
$maxValue ??= $max;
|
||||||
|
$uid = 'rs-' . uniqid();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-controller="range-slider"
|
||||||
|
data-range-slider-min-value="{{ $min }}"
|
||||||
|
data-range-slider-max-value="{{ $max }}"
|
||||||
|
data-range-slider-step-value="{{ $step }}"
|
||||||
|
data-range-slider-prefix-value="{{ $prefix }}"
|
||||||
|
data-range-slider-suffix-value="{{ $suffix }}"
|
||||||
|
data-range-slider-separator-value="{{ $separator }}"
|
||||||
|
{{ $attributes->merge(['class' => 'flex flex-col gap-4']) }}
|
||||||
|
>
|
||||||
|
<fieldset class="m-0 min-w-0 border-0 p-0">
|
||||||
|
<legend class="sr-only">{{ $legend }}</legend>
|
||||||
|
|
||||||
|
{{-- Real controls — keyboard, assistive tech, form values, no-JS
|
||||||
|
fallback. The controller adds `sr-only` to each on connect. --}}
|
||||||
|
<label data-range-slider-target="field" class="flex items-center gap-2 text-sm">
|
||||||
|
<span>{{ $minLabel }}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="{{ $uid }}-min"
|
||||||
|
min="{{ $min }}"
|
||||||
|
max="{{ $max }}"
|
||||||
|
step="{{ $step }}"
|
||||||
|
value="{{ $minValue }}"
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label data-range-slider-target="field" class="flex items-center gap-2 text-sm">
|
||||||
|
<span>{{ $maxLabel }}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
id="{{ $uid }}-max"
|
||||||
|
min="{{ $min }}"
|
||||||
|
max="{{ $max }}"
|
||||||
|
step="{{ $step }}"
|
||||||
|
value="{{ $maxValue }}"
|
||||||
|
@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"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{{-- Presentational track — pointer control + visual state only; the
|
||||||
|
real controls above own accessibility. --min / --max start at
|
||||||
|
the extremes so first paint has no jump. --}}
|
||||||
|
<div
|
||||||
|
data-range-slider-target="track"
|
||||||
|
data-action="pointerdown->range-slider#trackPointerDown"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="relative h-8 touch-none select-none [--min:0%] [--max:100%]"
|
||||||
|
>
|
||||||
|
<span class="pointer-events-none absolute inset-x-0 top-1/2 h-1.25 -translate-y-1/2 bg-neutral-500"></span>
|
||||||
|
<span class="pointer-events-none absolute top-1/2 left-[var(--min)] right-[calc(100%_-_var(--max))] h-1.25 -translate-y-1/2 bg-black"></span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex="-1"
|
||||||
|
data-range-slider-target="minThumb"
|
||||||
|
data-action="pointerdown->range-slider#thumbPointerDown"
|
||||||
|
class="absolute top-1/2 left-[var(--min)] grid h-8 w-6 -translate-x-1/4 -translate-y-1/2 cursor-grab place-items-center text-black active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 32" fill="none" aria-hidden="true" class="h-[32px] w-[22px]">
|
||||||
|
<path d="M17 4 L6 16 L17 28" stroke="currentColor" stroke-width="5" stroke-linecap="square" stroke-linejoin="miter" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex="-1"
|
||||||
|
data-range-slider-target="maxThumb"
|
||||||
|
data-action="pointerdown->range-slider#thumbPointerDown"
|
||||||
|
class="absolute top-1/2 left-[var(--max)] grid h-8 w-6 -translate-x-1/2 -translate-y-1/2 cursor-grab place-items-center text-black active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 32" fill="none" aria-hidden="true" class="h-[32px] w-[22px]">
|
||||||
|
<path d="M5 4 L16 16 L5 28" stroke="currentColor" stroke-width="5" stroke-linecap="square" stroke-linejoin="miter" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
{{-- Server-rendered so it's right on first paint and without JS; the
|
||||||
|
controller rewrites it as the values change. --}}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,21 +5,12 @@
|
|||||||
'ariaLabel' => null,
|
'ariaLabel' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
{{--
|
|
||||||
Native <select>, styled to match the site's bordered/uppercase look.
|
|
||||||
Usage:
|
|
||||||
<x-ui.select
|
|
||||||
:options="[['value' => 'a', 'label' => 'Option A'], ...]"
|
|
||||||
value="a"
|
|
||||||
ariaLabel="Sort products"
|
|
||||||
/>
|
|
||||||
--}}
|
|
||||||
|
|
||||||
<div class="relative inline-flex items-center">
|
<div class="relative inline-flex items-center">
|
||||||
<select
|
<select
|
||||||
@if($name) name="{{ $name }}" @endif
|
@if($name) name="{{ $name }}" @endif
|
||||||
@if($ariaLabel) aria-label="{{ $ariaLabel }}" @endif
|
@if($ariaLabel) aria-label="{{ $ariaLabel }}" @endif
|
||||||
{{ $attributes->merge(['class' => 'appearance-none bg-transparent border border-black pl-4 pr-10 py-2.5 font-display font-bold cursor-pointer focus:outline-none']) }}
|
{{ $attributes->merge(['class' => 'appearance-none bg-transparent border-b border-black pr-10 py-2.5 cursor-pointer focus:outline-none']) }}
|
||||||
>
|
>
|
||||||
@foreach($options as $option)
|
@foreach($options as $option)
|
||||||
<option value="{{ $option['value'] }}" @selected($value === $option['value'])>{{ $option['label'] }}</option>
|
<option value="{{ $option['value'] }}" @selected($value === $option['value'])>{{ $option['label'] }}</option>
|
||||||
|
|||||||
Reference in New Issue
Block a user