category page filters components and general ui - no functionality yet

This commit is contained in:
elvira
2026-08-31 21:52:47 +03:00
parent 74e554884f
commit b070a7d1e6
13 changed files with 1491 additions and 1620 deletions
+56 -41
View File
@@ -4,10 +4,10 @@
@section('description', strip_tags($collection['description'] ?? ''))
@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">
<h1 class="font-display font-extrabold text-h1">{{ $collection['name'] }}</h1>
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
<h1 class="font-medium text-h2">{{ $collection['name'] }}</h1>
<x-breadcrumb :items="[
['label' => __('storefront.nav.home'), 'href' => route('home')],
@@ -15,33 +15,49 @@
]" />
</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 --}}
<x-ui.select
:ariaLabel="__('storefront.shop.sort_label')"
:options="[
['value' => 'default', 'label' => __('storefront.shop.sort_default')],
['value' => 'popularity', 'label' => __('storefront.shop.sort_popularity')],
['value' => 'price-asc', 'label' => __('storefront.shop.sort_price_asc')],
['value' => 'price-desc', 'label' => __('storefront.shop.sort_price_desc')],
['value' => 'newest', 'label' => __('storefront.shop.sort_newest')],
]"
value="default"
/>
<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_300px] gap-12">
<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
@@ -56,7 +72,7 @@
{{-- Sidebar — search, price and availability filters are dummy for now --}}
<aside class="flex flex-col gap-10">
<div>
<div class="-mt-2">
<label for="shop-search" class="sr-only">{{ __('storefront.shop.search_label') }}</label>
<div class="relative">
<x-ui.input
@@ -76,24 +92,23 @@ class="absolute right-0 top-1/2 -translate-y-1/2"
</div>
<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">
<x-ui.icon name="arrow-left" :size="20" />
<span class="flex-1 h-px bg-black"></span>
<x-ui.icon name="arrow-right" :size="20" />
</div>
<div class="flex items-center justify-between gap-4">
<span class="text-sm">€10 - €50</span>
<button type="button" class="underline-slide [--slide-h:1px] font-display font-bold italic uppercase text-sm">
{{ __('storefront.shop.apply') }}
</button>
</div>
{{-- 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">
<h2 class="font-display font-extrabold uppercase">{{ __('storefront.shop.availability') }}</h2>
<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>
@@ -17,7 +17,7 @@
<h2 class="font-display font-extrabold text-h2 mb-10">{{ $title }}</h2>
@endif
<div class="grid {{ $gridCols }} gap-6">
<div class="grid {{ $gridCols }} gap-9">
@foreach($products as $product)
<x-ui.product-card
: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>
+1 -10
View File
@@ -5,21 +5,12 @@
'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">
<select
@if($name) name="{{ $name }}" @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)
<option value="{{ $option['value'] }}" @selected($value === $option['value'])>{{ $option['label'] }}</option>