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 "./fonts.css";
|
||||
@import "./dropdown.css";
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@@ -20,7 +21,7 @@ @theme {
|
||||
--text-h1: 60px;
|
||||
--text-h2: 48px;
|
||||
--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 BackToTopController from './back-to-top-controller'
|
||||
import CarouselController from './carousel-controller'
|
||||
import DropdownController from './dropdown-controller'
|
||||
import ProductFormController from './product-form-controller'
|
||||
import ProductGalleryController from './product-gallery-controller'
|
||||
import QuantityController from './quantity-controller'
|
||||
import RangeSliderController from './range-slider-controller'
|
||||
import StarRatingController from './star-rating-controller'
|
||||
import TabsController from './tabs-controller'
|
||||
|
||||
@@ -16,9 +18,11 @@ export function registerControllers(application) {
|
||||
application.register('appear', AppearController)
|
||||
application.register('back-to-top', BackToTopController)
|
||||
application.register('carousel', CarouselController)
|
||||
application.register('dropdown', DropdownController)
|
||||
application.register('product-form', ProductFormController)
|
||||
application.register('product-gallery', ProductGalleryController)
|
||||
application.register('quantity', QuantityController)
|
||||
application.register('range-slider', RangeSliderController)
|
||||
application.register('star-rating', StarRatingController)
|
||||
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('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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user