diff --git a/docs/plans/livewire-material.md b/docs/plans/livewire-material.md index ad64f62c..4386ea78 100644 --- a/docs/plans/livewire-material.md +++ b/docs/plans/livewire-material.md @@ -459,6 +459,34 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`, inputs, value label), `search` (search bar and search view, results through a Livewire property). +**Phase 6 is done (2026-09-13).** What changed from the steps above: + +- **The field family is ReStride's, extended with M3's filled text field** (`variant`, default from + `config('livewire-material.fields.variant')`): a surface-container-highest box, extra-small top + corners, the outline turned into an indicator line and the label floating inside. A textarea's + top offset is half margin, so text scrolled up in a full textarea disappears under the edge + instead of running through the label; it grows with `field-sizing: content` from `rows` to + `max-rows`, with a script fallback (`field.js`) that no current engine needs. +- **Checkbox, radio and switch are CSS on native inputs** (`components/selection.css`), hooked by + `data-checkbox`, `data-radio`, `data-switch`: `checkbox`, `radio` and `toggle` are daisyUI class + names the design guard rejects. The indeterminate checkbox is `data-indeterminate`, kept in step + with the property by a MutationObserver, since HTML has no attribute for it. +- **`` and `` were built by separate agents in their own worktrees.** Chips: one + component for four types; a filter chip is a native checkbox when bound and a toggle button + otherwise; removable input chips hand focus on by `wire:key` after a morph. Slider: native range + inputs under a drawing ported from Compose (`wire:ignore`); `.number` is added to the binding, + because a string sent and an integer returned made Livewire move the handle back mid-drag. +- **`` keeps typed values in Alpine** (filter chips as toggle buttons, or a searchable + combobox whose list is a `popover="manual"` placed by CSS anchor positioning, so a card's + `overflow-hidden` never clips it). Selecting the text on focus has to happen on the `click` that + follows the press: Chromium collapses a selection made in the focus handler. +- **`` is the search bar and view**: docked from `sm`, full screen and trapped below it. + It closes on `pointerdown` outside, not `click` (the bar moves under the pointer as it goes full + screen, and the click then lands outside), and focus returned to the input within 250ms of a close + does not reopen it (Escape, and the trap letting go). +- **A slot rendered by a `@foreach` with no items is not empty** to `isNotEmpty()`; use + `hasActualContent()`. + ### Phase 7 — Pickers 27. `datepicker` (docked, modal, modal input; `Intl` month/day names and week start from the diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 088cb690..9b2833d4 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -436,6 +436,33 @@ M3 selection controls on native inputs; the whole row is the label. ``` +### `` + +M3 Expressive's slider on native ``s (one per handle), so the arrow keys, Home, End, forms and screen readers work as on a plain range; PageUp and PageDown move a tenth of the steps (1 to 10). A press anywhere on the slider moves the nearest handle there. Without JavaScript the native range shows, and posts. + +```blade + + + + +``` + +| Prop | Default | | +|---|---|---| +| `label`, `hint` | | the label above names the input (with `range`, the group); a validation error for the bound property or `name` replaces the hint | +| `value`, `min`, `max`, `step` | `null`, `0`, `100`, `1` | as on a range input; `step="any"` is continuous. With `wire:model` the property's value is drawn | +| `name` | the `wire:model` property | with `range` it posts `name[]` twice, from first | +| `range` | `false` | two handles that never cross; binds an array `[from, to]` (`wire:model="price"` binds `price.0` and `price.1`, `x-model="price"` binds `price[0]` and `price[1]`) | +| `centered` | `false` | fills from the middle of the track, for values that go below zero | +| `size` | `xs` | track `xs` 16px, `sm` 24px, `md` 40px, `lg` 56px, `xl` 96px | +| `icon` | `null` | a Material Symbol inside the track, `md` and up, standard sliders only | +| `ticks` | `false` | a mark per step (up to 200, hidden while closer than 8px); the handle sits on the marks | +| `value-label` | `drag` | `drag` (while pressed, dragged or keyboard-focused), `always`, `never` | +| `color` | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` | +| `disabled` | `false` | | + +Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed. + ### `` Choosing from a list, with typed values (an array of integers stays integers). `options` (`id`, `name`, `disabled`; `option-value`, `option-label`), `label`, `hint`, `single`. Errors for the property and its items replace the hint. diff --git a/resources/js/material.js b/resources/js/material.js index 4efe2321..6b6d1cb7 100644 --- a/resources/js/material.js +++ b/resources/js/material.js @@ -20,3 +20,4 @@ import './carousel.js' import './chips.js' import './field.js' import './search.js' +import './slider.js' diff --git a/resources/js/slider.js b/resources/js/slider.js new file mode 100644 index 00000000..43a6fca9 --- /dev/null +++ b/resources/js/slider.js @@ -0,0 +1,573 @@ +/** + * `materialSlider`: draws ``, M3 Expressive's slider, over its native range inputs — + * the track, split around each handle, the stop indicators, the tick marks, the handle and its + * value label, and the inset icon — the way androidx Compose Material 3 draws them. + * + * The inputs stay the control. Keyboard, forms, `wire:model`, `x-model` and assistive technology + * all talk to them; this only reads their values and paints. A pointer is the one thing handled + * here rather than natively, so that a press lands where Compose puts it in every engine (a + * native range thumb is sized, hit-tested and dragged differently in each): a press moves the + * nearest handle to the pointer, snapped to the step, a drag follows it, and every change is an + * `input` event on the input, with `change` on release — what a native drag sends. A touch waits + * for a sideways move, or a tap, so scrolling past a slider never changes it. + * + * The server draws the first frame. The drawing is `wire:ignore`: a value that Livewire or + * Alpine writes into an input (`input.value = …`, which fires no event) is caught on the input's + * own `value` setter, and attribute changes by a MutationObserver. + * + * - Range: the handles never cross; an input that would pass the other is held at its value + * before any listener (x-model, wire:model) reads it. + * - PageUp and PageDown move by Compose's page: a tenth of the steps, at least one and at most + * ten (arrows, Home and End are the inputs' own). + * - Ticks hide while they would sit closer than 8px (Material Components' tick_min_spacing). + * + * --------------------------------------------------------------------------------------- + * Ported from androidx (https://github.com/androidx/androidx), commit + * 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: + * + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Slider.kt + * (SliderImpl and RangeSliderImpl layout, SliderDefaults.drawTrack, ThumbContent, + * slideOnKeyEvents, RangeSliderLogic.captureThumb) + * compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/SliderTokens.kt + * + * with the Expressive sizes from Material Components for Android's slider tokens + * (md.comp.slider.xsmall … xlarge, lib/java/com/google/android/material/slider/res/values/tokens.xml). + * + * Copyright 2023-2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * --------------------------------------------------------------------------------------- + */ + +// Tokens --------------------------------------------------------------------------------- + +/** + * Track height and corner per size (md.comp.slider.*). XS is Compose's default track, whose + * corner is half its height and whose segments vanish rather than shrink; the larger sizes pass a + * `trackCornerSize`, which shrinks a segment's corners as it narrows (enableCornerShrinking). + */ +const SIZES = { + xs: { corner: 8, shrink: false, icon: 0 }, + sm: { corner: 8, shrink: true, icon: 0 }, + md: { corner: 12, shrink: true, icon: 24 }, + lg: { corner: 16, shrink: true, icon: 24 }, + xl: { corner: 28, shrink: true, icon: 32 }, +} + +/** SliderTokens.HandleWidth: the handle's box, which the gap is measured from even while it narrows. */ +const HANDLE_WIDTH = 4 +/** SliderTokens.ActiveHandleLeadingSpace: ThumbTrackGapSize. */ +const GAP = 6 +/** TrackInsideCornerSize. */ +const INSIDE_CORNER = 2 +/** The inset icon's padding from its segment's end (m3_slider_track_icon_padding, SliderWithTrackIconsSample). */ +const ICON_PADDING = 10 +/** Material Components' tick_min_spacing. */ +const TICK_MIN_SPACING = 8 +/** How far a touch moves sideways before it is a drag rather than the start of a scroll. */ +const TOUCH_SLOP = 8 + +const VALUE = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value') +const VALUE_AS_NUMBER = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'valueAsNumber') + +const clamp = (value, min, max) => Math.min(Math.max(value, min), max) +const near = (a, b) => Math.abs(a - b) < 1e-6 +const within = (x, [from, to]) => x >= from && x <= to + +/** + * Where the track's parts go, in pixels from the start of a track `width` wide: SliderImpl's and + * RangeSliderImpl's handle placement and SliderDefaults.drawTrack, for a horizontal, left-to-right + * slider (right-to-left mirrors the drawing as a whole). + * + * @returns {{ segments: Object, stops: Object, ticks: Array<{x: number, active: boolean}|null>, handles: number[], activeTrack: number[], endTrackStart: number|null }} + */ +function sliderGeometry({ width, size, fractions, centered = false, range = false, tickFractions = [] }) { + const { corner, shrink } = SIZES[size] ?? SIZES.xs + const discrete = tickFractions.length > 0 + const onFirstOrLastStep = (fraction) => near(fraction, tickFractions[0]) || near(fraction, tickFractions.at(-1)) + + // SliderImpl: a discrete handle off the ends sits between the corners, where the ticks are. + const place = (fraction) => (discrete && !onFirstOrLastStep(fraction) ? (width - corner * 2) * fraction + corner : width * fraction) + + const activeRangeStart = range ? fractions[0] : 0 + const activeRangeEnd = range ? fractions[1] : fractions[0] + const sliderValueStart = place(activeRangeStart) + const sliderValueEnd = place(activeRangeEnd) + + // TrackImpl: a centred track has a handle on only one side of its centre. + let startThumbWidth = HANDLE_WIDTH + let endThumbWidth = HANDLE_WIDTH + let startGapSize = GAP + + if (!range) { + const before = fractions[0] < 0.5 + const after = fractions[0] > 0.5 + + startThumbWidth = centered && !after ? HANDLE_WIDTH : 0 + endThumbWidth = centered ? (before ? 0 : HANDLE_WIDTH) : HANDLE_WIDTH + startGapSize = centered ? GAP : 0 + } + + const startGap = startGapSize > 0 ? startThumbWidth / 2 + startGapSize : 0 + const endGap = endThumbWidth / 2 + GAP + const centerAxis = width / 2 + const cornerThreshold = !shrink || discrete ? corner : 0 + + const segments = {} + const stops = {} + + // The inactive track before the active one (centred and range sliders). + const adjustedSliderValueEnd = centered ? Math.min(sliderValueEnd, centerAxis) : sliderValueStart + + if ((centered || range) && adjustedSliderValueEnd > startGap + cornerThreshold) { + segments.start = { from: 0, to: adjustedSliderValueEnd - startGap, startRadius: corner, endRadius: INSIDE_CORNER } + stops.start = corner + } + + // The inactive track after it. + const adjustedSliderValueStart = centered ? Math.max(sliderValueEnd, centerAxis) : sliderValueEnd + + if (adjustedSliderValueStart < width - endGap - cornerThreshold) { + segments.end = { from: adjustedSliderValueStart + endGap, to: width, startRadius: INSIDE_CORNER, endRadius: corner } + stops.end = width - corner + } + + // The active track. + const activeTrackStart = centered + ? adjustedSliderValueEnd + (adjustedSliderValueEnd < centerAxis ? startGap : 0) + : range + ? sliderValueStart + startGap + : 0 + const activeTrackEnd = centered ? adjustedSliderValueStart - (adjustedSliderValueStart > centerAxis ? endGap : 0) : sliderValueEnd - endGap + const activeStartRadius = centered || range ? INSIDE_CORNER : corner + + if (activeTrackEnd - activeTrackStart > (!shrink || discrete ? activeStartRadius : 0)) { + segments.active = { from: activeTrackStart, to: activeTrackEnd, startRadius: activeStartRadius, endRadius: INSIDE_CORNER } + } + + // Ticks, between the corners; none on a stop indicator or in a gap. + const activeTrack = [activeTrackStart, activeTrackEnd] + const centreGap = sliderValueEnd > centerAxis ? startGap : endGap + const valueGap = centered ? (sliderValueEnd > centerAxis ? endGap : startGap) : endGap + const tickCenterGap = [centerAxis - centreGap, centerAxis + centreGap] + const tickStartGap = [sliderValueStart - startGap, sliderValueStart + startGap] + const tickEndGap = [sliderValueEnd - valueGap, sliderValueEnd + valueGap] + + const ticks = tickFractions.map((tick, index) => { + if (((centered || range) && index === 0) || index === tickFractions.length - 1) { + return null + } + + const x = corner + (width - corner * 2) * tick + + if ((centered && within(x, tickCenterGap)) || (range && within(x, tickStartGap)) || within(x, tickEndGap)) { + return null + } + + return { x, active: within(x, activeTrack) } + }) + + return { + segments, + stops, + ticks, + handles: (range ? fractions : [fractions[0]]).map(place), + activeTrack, + endTrackStart: segments.end ? segments.end.from : null, + } +} + +// The component ---------------------------------------------------------------------------- + +/** + * One slider's behaviour, bound to its root (the element with `x-data`). Everything it keeps is + * a local here rather than Alpine data: x-model writes an input's value from inside a reactive + * effect, and anything reactive read or written on the way to the drawing would re-run it. + */ +function slider(root) { + const inputs = [...root.querySelectorAll(':scope > input[type="range"]')] + const drawing = root.querySelector('[data-slider-drawing]') + const range = inputs.length > 1 + const cleanups = [] + let queued = false + let release = null + // Focus this script gave an input on a press, which draws no ring until a key is pressed. + let focusedByPointer = null + + const listen = (target, type, handler, capture = false) => { + target.addEventListener(type, handler, capture) + cleanups.push(() => target.removeEventListener(type, handler, capture)) + } + + /** The inputs' min, max and step, as a native range reads them; `step` is null for `any`. */ + const bounds = () => { + const input = inputs[0] + const parsed = (attribute, fallback) => (Number.isFinite(Number.parseFloat(input[attribute])) ? Number.parseFloat(input[attribute]) : fallback) + const min = parsed('min', 0) + const max = parsed('max', 100) + const step = parsed('step', 1) + + return { min, max: max > min ? max : min + 100, step: input.step === 'any' ? null : step > 0 ? step : 1 } + } + + const values = () => inputs.map((input) => Number.parseFloat(input.value)) + const thumbOf = (input) => drawing?.querySelector(`[data-handle="${inputs.indexOf(input) === 1 ? 'end' : 'start'}"]`) + + const tickFractions = () => [...(drawing?.querySelectorAll('[data-tick]') ?? [])].map((tick) => Number.parseFloat(tick.dataset.tick)) + + const geometry = (width) => { + const { min, max } = bounds() + + return sliderGeometry({ + width, + size: root.dataset.size, + fractions: values().map((value) => (Number.isFinite(value) ? clamp((value - min) / (max - min), 0, 1) : 0)), + centered: root.hasAttribute('data-centered'), + range, + tickFractions: tickFractions(), + }) + } + + const draw = () => { + if (!drawing) { + return + } + + const width = Math.max(root.clientWidth - HANDLE_WIDTH, 0) + const size = SIZES[root.dataset.size] ?? SIZES.xs + const { segments, stops, ticks, handles, activeTrack, endTrackStart } = geometry(width) + const part = (selector) => drawing.querySelector(selector) + const px = (number) => `${Math.round(number * 100) / 100}px` + + for (const name of ['start', 'active', 'end']) { + const element = part(`[data-segment="${name}"]`) + const segment = segments[name] + + if (element) { + element.hidden = !segment + + if (segment) { + const { from, to, startRadius: start, endRadius: end } = segment + element.style.cssText = `left: ${px(from)}; width: ${px(to - from)}; border-radius: ${px(start)} ${px(end)} ${px(end)} ${px(start)}` + } + } + } + + for (const name of ['start', 'end']) { + const element = part(`[data-stop="${name}"]`) + + if (element) { + element.hidden = stops[name] === undefined + element.style.left = stops[name] === undefined ? '' : px(stops[name]) + } + } + + const fractions = tickFractions() + const spacing = fractions.length > 1 ? (width - size.corner * 2) * Math.abs(fractions[1] - fractions[0]) : Infinity + + drawing.querySelectorAll('[data-tick]').forEach((element, i) => { + const tick = ticks[i] + + element.hidden = !tick || spacing < TICK_MIN_SPACING + + if (tick) { + element.style.left = px(tick.x) + element.toggleAttribute('data-active', tick.active) + } + }) + + handles.forEach((x, i) => { + const thumb = part(`[data-handle="${i === 1 ? 'end' : 'start'}"]`) + const label = thumb?.querySelector('[data-value-label]') + + if (thumb) { + thumb.style.left = px(x) + } + + if (label && label.textContent !== inputs[i].value) { + label.textContent = inputs[i].value + } + }) + + // The inset icon: at the start of the active track while it fits, else of the inactive one. + const icon = part('[data-track-icon]') + + if (icon) { + const room = size.icon + ICON_PADDING * 2 + const inActive = Boolean(segments.active) && activeTrack[1] - activeTrack[0] >= room + const inInactive = !inActive && endTrackStart !== null && width - endTrackStart >= room + + icon.hidden = !inActive && !inInactive + icon.toggleAttribute('data-active', inActive) + icon.style.left = px((inActive ? activeTrack[0] : (endTrackStart ?? 0)) + ICON_PADDING) + } + } + + const schedule = () => { + if (!queued) { + queued = true + queueMicrotask(() => { + queued = false + draw() + }) + } + } + + /** A value on the step grid, inside the bounds. */ + const snap = (raw) => { + const { min, max, step } = bounds() + + if (step === null) { + return clamp(raw, min, max) + } + + const decimals = Math.max(...[step, min].map((number) => (String(number).split('.')[1] ?? '').length)) + const top = min + Math.floor((max - min) / step + 1e-9) * step + + return Number(clamp(min + Math.round((raw - min) / step) * step, min, top).toFixed(decimals)) + } + + /** Set one input and tell its listeners, as a native change would. */ + const commit = (index, raw) => { + const input = inputs[index] + let value = snap(raw) + + if (range) { + const other = Number.parseFloat(inputs[1 - index].value) + value = index === 0 ? Math.min(value, other) : Math.max(value, other) + } + + if (Number.parseFloat(input.value) === value) { + return false + } + + input.value = String(value) + input.dispatchEvent(new Event('input', { bubbles: true })) + + return true + } + + /** Whether a handle shows keyboard focus: narrowed, ringed and labelled. */ + const showFocus = (input, shown) => thumbOf(input)?.toggleAttribute('data-focused', shown) + + /** Compose narrows a handle and shows its label while it is pressed or dragged. */ + const pressed = (index) => drawing?.querySelectorAll('[data-handle]').forEach((thumb, i) => thumb.toggleAttribute('data-pressed', i === index)) + + inputs.forEach((input) => { + // A value written by script fires no event: catch it on the input's own setters. + for (const [property, descriptor] of [['value', VALUE], ['valueAsNumber', VALUE_AS_NUMBER]]) { + Object.defineProperty(input, property, { + configurable: true, + get() { + return descriptor.get.call(this) + }, + set(value) { + descriptor.set.call(this, value) + schedule() + }, + }) + } + + cleanups.push(() => { + delete input.value + delete input.valueAsNumber + }) + + listen(input, 'focus', () => showFocus(input, focusedByPointer !== input && input.matches(':focus-visible'))) + listen(input, 'blur', () => { + focusedByPointer = null + showFocus(input, false) + }) + + // slideOnKeyEvents: PageUp and PageDown move a tenth of the steps, one to ten of them. + listen(input, 'keydown', (event) => { + focusedByPointer = null + showFocus(input, true) + + if (!['PageUp', 'PageDown'].includes(event.key) || input.disabled) { + return + } + + event.preventDefault() + + const { min, max, step } = bounds() + const steps = step === null ? 100 : Math.max(Math.round((max - min) / step), 1) + const delta = ((max - min) / steps) * clamp(Math.floor(steps / 10), 1, 10) + + if (commit(inputs.indexOf(input), Number.parseFloat(input.value) + (event.key === 'PageUp' ? delta : -delta))) { + input.dispatchEvent(new Event('change', { bubbles: true })) + } + }) + }) + + // Before x-model or wire:model reads it, hold a range input at its neighbour instead of passing it. + listen( + root, + 'input', + (event) => { + const index = inputs.indexOf(event.target) + const [from, to] = values() + + if (range && index !== -1 && from > to) { + VALUE.set.call(event.target, String(index === 0 ? to : from)) + } + }, + true, + ) + listen(root, 'input', schedule) + + const mutations = new MutationObserver(schedule) + inputs.forEach((input) => mutations.observe(input, { attributes: true, attributeFilter: ['min', 'max', 'step', 'value', 'disabled'] })) + mutations.observe(root, { attributes: true, attributeFilter: ['data-size', 'data-centered', 'dir'] }) + + const resizes = new ResizeObserver(schedule) + resizes.observe(root) + + cleanups.push( + () => mutations.disconnect(), + () => resizes.disconnect(), + ) + + draw() + + return { + destroy() { + release?.() + cleanups.forEach((cleanup) => cleanup()) + }, + + /** A press on the slider: sliderTapModifier, draggable and RangeSliderLogic.captureThumb. */ + press(event) { + if ((event.pointerType === 'mouse' && event.button !== 0) || inputs.some((input) => input.disabled) || release) { + return + } + + event.preventDefault() + + const box = root.getBoundingClientRect() + const width = Math.max(box.width - HANDLE_WIDTH, 0) + const rtl = getComputedStyle(root).direction === 'rtl' + const { min, max } = bounds() + const handles = geometry(width).handles + + const offset = (clientX) => clamp(rtl ? box.right - HANDLE_WIDTH / 2 - clientX : clientX - box.left - HANDLE_WIDTH / 2, 0, width) + const valueAt = (clientX) => min + (width > 0 ? offset(clientX) / width : 0) * (max - min) + + const startX = event.clientX + const before = values() + let dragging = event.pointerType !== 'touch' + let index = null + + // The nearest handle; handles on top of each other wait for the first move's direction. + const choose = (clientX) => { + if (!range) { + return 0 + } + + const x = offset(clientX) + const [start, end] = handles.map((handle) => Math.abs(handle - x)) + + if (start !== end) { + return start < end ? 0 : 1 + } + + const moved = rtl ? startX - clientX : clientX - startX + + return moved === 0 ? null : moved < 0 ? 0 : 1 + } + + const follow = (clientX) => { + index ??= choose(clientX) + + if (index === null) { + return + } + + if (document.activeElement !== inputs[index]) { + focusedByPointer = inputs[index] + inputs[index].focus({ preventScroll: true }) + } + + pressed(index) + commit(index, valueAt(clientX)) + } + + const move = (moveEvent) => { + if (moveEvent.pointerId !== event.pointerId) { + return + } + + if (!dragging && Math.abs(moveEvent.clientX - startX) < TOUCH_SLOP) { + return + } + + dragging = true + follow(moveEvent.clientX) + } + + const end = (endEvent) => { + if (endEvent.pointerId !== event.pointerId) { + return + } + + if (endEvent.type === 'pointerup' && !dragging) { + follow(endEvent.clientX) + } + + release() + + values().forEach((value, i) => { + if (value !== before[i]) { + inputs[i].dispatchEvent(new Event('change', { bubbles: true })) + } + }) + } + + release = () => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', end) + window.removeEventListener('pointercancel', end) + pressed(null) + release = null + } + + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', end) + window.addEventListener('pointercancel', end) + + if (dragging) { + follow(event.clientX) + } + }, + } +} + +document.addEventListener('alpine:init', () => { + window.Alpine.data('materialSlider', () => { + let behaviour = null + + return { + init() { + behaviour = slider(this.$el) + }, + + destroy() { + behaviour?.destroy() + }, + + press(event) { + behaviour?.press(event) + }, + } + }) +}) diff --git a/resources/views/components/slider.blade.php b/resources/views/components/slider.blade.php new file mode 100644 index 00000000..fe0f48b1 --- /dev/null +++ b/resources/views/components/slider.blade.php @@ -0,0 +1,391 @@ +{{-- An M3 Expressive slider: a value, or with `range` a span of values, picked along a track. + + + + + + Built on native ``s, one per handle, which stay the control: the arrows, + Home and End, forms, `wire:model` and `x-model`, and screen readers all work as they do on a + plain range. M3's drawing lies under them (resources/js/slider.js); the inputs are invisible + and a press on the slider moves the nearest handle to the pointer. Without JavaScript the + inputs show as native ranges in the colour, and still post. + + `min` (0), `max` (100), `step` (1, or `any`) and `value` as on a range input, or the bound + property's value. The binding gets `.number`, so values arrive as numbers: Livewire compares + what it sent with what came back, and a string that returns as an int would count as a + change and move a handle back while it is dragged. `range` binds an array `[from, to]` — + `wire:model="price"` binds `price.0` and `price.1`, `x-model="price"` binds `price[0]` and + `price[1]` — and posts `name[]` twice, from first; the handles never cross. `centered` fills from the middle of the track, for a + value that goes below zero. `ticks` marks every step (up to 200 of them, and only while they + stand 8px apart) and places the handle on the marks, as Compose does for a discrete slider. + `value-label` is `drag` (the default: while pressed, dragged or focused from the keyboard), + `always` or `never`. `size` is Expressive's `xs` (the default: a 16px track), `sm` 24px, + `md` 40px, `lg` 56px or `xl` 96px; `icon` puts a Material Symbol inside the track of an `md`, + `lg` or `xl` slider, at the start of the active track while it fits and of the inactive + track once it does not (not on a range or centred slider). `color`: `primary` (the default), + `secondary`, `tertiary`, `error`, `success`, `warning`, `info` — the active track and the + handle; the inactive track is its container (secondary-container for primary). + + `label` is shown above and names the input (with `range`, the group; its handles are "Range + start" and "Range end"); `hint` goes below, and a validation message for the bound property + (or `name`) replaces it. Other attributes go to the input(s). + + From androidx Compose Material 3 at commit 27cf9a7d5788aa0f5f2d8b6699ce279560daf326 + (Apache-2.0): Slider.kt — SliderImpl, RangeSliderImpl, SliderDefaults.Track, CenteredTrack + and drawTrack, ThumbContent — and SliderTokens.kt: a 4px handle, narrowed to 2px while + pressed or focused, in a 6px gap that splits the track, 2px inside corners, a 4px stop + indicator at each open end of the track, active track and handle in primary, inactive track + in secondary-container, ticks in the other part's colour, disabled parts in on-surface at 38% + and 12%. Compose leaves the value label to the app; this one is M3's as Flutter draws it + (RoundedRectSliderValueIndicatorShape): a 32px inverse-surface pill with label-large, 10px + padding, 4px above the handle, which is 6px shorter while its focus ring shows (Material + Components' m3_slider_focus_ring_thumb_height_decrease). The Expressive sizes are + Material Components for Android's md.comp.slider.xsmall … xlarge tokens: tracks of 16, 24, + 40, 56 and 96px with 8, 8, 12, 16 and 28px corners, handles of 44, 44, 44, 68 and 108px, + icons of 24, 24 and 32px, 10px from their segment's end. + + The server draws the first frame; the drawing is `wire:ignore`, so a Livewire render never + fights a handle being dragged, and a value the server changes moves the handle after the + morph. --}} + +@props([ + 'label' => null, + 'hint' => null, + 'name' => null, + 'value' => null, + 'min' => 0, + 'max' => 100, + 'step' => 1, + 'range' => false, + 'centered' => false, + 'size' => 'xs', + 'ticks' => false, + 'valueLabel' => 'drag', + 'icon' => null, + 'color' => 'primary', + 'disabled' => false, +]) + +@php + $format = fn (float $number): string => rtrim(rtrim(number_format(round($number, 6) + 0.0, 6, '.', ''), '0'), '.'); + + $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'xs'; + $color = in_array($color, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? $color : 'primary'; + $valueLabel = in_array($valueLabel, ['always', 'drag', 'never'], true) ? $valueLabel : 'drag'; + $centered = $centered && ! $range; + + $minimum = is_numeric($min) ? (float) $min : 0.0; + $maximum = is_numeric($max) && (float) $max > $minimum ? (float) $max : $minimum + 100; + $continuous = $step === 'any'; + $interval = ! $continuous && is_numeric($step) && (float) $step > 0 ? (float) $step : 1.0; + // The highest value on the step grid, which is where a native range stops. + $top = $continuous ? $maximum : $minimum + floor(($maximum - $minimum) / $interval + 1e-9) * $interval; + $snap = fn (mixed $number, float $fallback): float => ! is_numeric($number) + ? $fallback + : ($continuous + ? min(max((float) $number, $minimum), $maximum) + : min(max($minimum + round(((float) $number - $minimum) / $interval) * $interval, $minimum), $top)); + + // The bound property's value, when the slider renders inside a Livewire component. + $model = $attributes->wire('model')->value() ?: null; + $livewire = $model !== null ? \Livewire\Livewire::current() : null; + $source = ($livewire !== null ? data_get($livewire, $model) : null) ?? $value; + + if ($range) { + $pair = is_array($source) ? array_values($source) : (is_string($source) ? explode(',', $source) : []); + $values = [$snap($pair[0] ?? null, $minimum), $snap($pair[1] ?? null, $top)]; + sort($values); + } else { + $values = [$snap($source, $centered ? $snap(($minimum + $maximum) / 2, $minimum) : $minimum)]; + } + + $fractions = array_map(fn (float $number): float => ($number - $minimum) / ($maximum - $minimum), $values); + + // Bound as numbers: a string sent for an int property comes back "changed", and Livewire + // would write the server's copy over a handle still being dragged. + $bindingsFor = fn (int $index): array => collect($attributes->whereStartsWith(['wire:model', 'x-model'])->getAttributes()) + ->mapWithKeys(fn (string $expression, string $attribute): array => [ + (str_contains($attribute, '.number') ? $attribute : preg_replace('/^(wire:model|x-model)/', '$1.number', $attribute)) => match (true) { + ! $range => $expression, + str_starts_with($attribute, 'wire:model') => "{$expression}.{$index}", + default => "{$expression}[{$index}]", + }, + ]) + ->all(); + + $name ??= $model; + $key = $model ?? ($name !== null ? str_replace(['[', ']'], ['.', ''], $name) : null); + $messages = $key !== null && isset($errors) + ? \Illuminate\Support\Arr::flatten([$errors->get($key), $range ? $errors->get($key.'.*') : []]) + : []; + + $id = $attributes->get('id') ?? 'material-slider-'.substr(md5(($model ?? $name ?? '').'|'.$label.'|'.($model ?? $name ?? \Illuminate\Support\Str::random(10))), 0, 10); + $described = $messages !== [] || filled($hint) ? "{$id}-hint" : null; + + $sized = preg_match('/(^|\s)w-/', (string) $attributes->get('class')) === 1; + $inputAttributes = $attributes->whereDoesntStartWith(['wire:model', 'x-model'])->except(['class', 'style', 'wire:key', 'id']); + + // The first frame: Compose's layout and drawTrack, with every position a percentage of the + // track plus pixels. What only a measured width can decide — whether a segment has room, the + // icon's place, a tick in a gap — is decided for a 320px track; the script redraws exactly. + $nominal = 320.0; + $corner = ['xs' => 8, 'sm' => 8, 'md' => 12, 'lg' => 16, 'xl' => 28][$size]; + $shrink = $size !== 'xs'; + $iconSize = ['md' => 24, 'lg' => 24, 'xl' => 32][$size] ?? null; + $icon = filled($icon) && $iconSize !== null && ! $range && ! $centered ? $icon : null; + + $tickFractions = []; + + if ($ticks && ! $continuous && ($top - $minimum) / $interval <= 200) { + for ($index = 0; $minimum + $index * $interval <= $top + 1e-9; $index++) { + $tickFractions[] = $index * $interval / ($maximum - $minimum); + } + } + + $discrete = $tickFractions !== []; + $length = fn (array $at): float => $at[0] / 100 * $nominal + $at[1]; + $plus = fn (array $at, float $pixels): array => [$at[0], $at[1] + $pixels]; + $onEdge = fn (float $fraction): bool => $discrete && (abs($fraction - $tickFractions[0]) < 1e-6 || abs($fraction - end($tickFractions)) < 1e-6); + $place = fn (float $fraction): array => $discrete && ! $onEdge($fraction) ? [$fraction * 100, $corner * (1 - 2 * $fraction)] : [$fraction * 100, 0.0]; + + $valueStart = $place($range ? $fractions[0] : 0.0); + $valueEnd = $place($range ? $fractions[1] : $fractions[0]); + $startThumb = $range || ($centered && $fractions[0] <= 0.5) ? 4 : 0; + $endThumb = ! $centered || $fractions[0] >= 0.5 ? 4 : 0; + $startGap = $range || $centered ? $startThumb / 2 + 6 : 0; + $endGap = $endThumb / 2 + 6; + $threshold = ! $shrink || $discrete ? $corner : 0; + $centre = [50.0, 0.0]; + + $segments = []; + $stops = []; + + $adjustedEnd = $centered ? ($length($valueEnd) <= $length($centre) ? $valueEnd : $centre) : $valueStart; + + if (($centered || $range) && $length($adjustedEnd) > $startGap + $threshold) { + $segments['start'] = [[0.0, 0.0], $plus($adjustedEnd, -$startGap), $corner, 2]; + $stops['start'] = [0.0, (float) $corner]; + } + + $adjustedStart = $centered ? ($length($valueEnd) >= $length($centre) ? $valueEnd : $centre) : $valueEnd; + + if ($length($adjustedStart) < $nominal - $endGap - $threshold) { + $segments['end'] = [$plus($adjustedStart, $endGap), [100.0, 0.0], 2, $corner]; + $stops['end'] = [100.0, (float) -$corner]; + } + + $activeFrom = match (true) { + $centered => $plus($adjustedEnd, $length($adjustedEnd) < $length($centre) ? $startGap : 0), + $range => $plus($valueStart, $startGap), + default => [0.0, 0.0], + }; + $activeTo = $centered ? $plus($adjustedStart, $length($adjustedStart) > $length($centre) ? -$endGap : 0) : $plus($valueEnd, -$endGap); + $activeStartRadius = $centered || $range ? 2 : $corner; + + if ($length($activeTo) - $length($activeFrom) > (! $shrink || $discrete ? $activeStartRadius : 0)) { + $segments['active'] = [$activeFrom, $activeTo, $activeStartRadius, 2]; + } + + $spacing = count($tickFractions) > 1 ? ($nominal - $corner * 2) * $tickFractions[1] : INF; + $centreGap = $length($valueEnd) > $nominal / 2 ? $startGap : $endGap; + $valueGap = $centered && $length($valueEnd) <= $nominal / 2 ? $startGap : $endGap; + + $tickMarks = array_map(function (float $tick, int $index) use ($tickFractions, $corner, $length, $centered, $range, $nominal, $centreGap, $valueStart, $valueEnd, $startGap, $valueGap, $spacing, $segments, $activeFrom, $activeTo): array { + $at = [$tick * 100, $corner * (1 - 2 * $tick)]; + $x = $length($at); + + return [ + 'fraction' => $tick, + 'at' => $at, + 'hidden' => (($centered || $range) && $index === 0) || $index === count($tickFractions) - 1 + || ($centered && abs($x - $nominal / 2) <= $centreGap) + || ($range && abs($x - $length($valueStart)) <= $startGap) + || abs($x - $length($valueEnd)) <= $valueGap + || $spacing < 8, + 'active' => $x >= $length($activeFrom) && $x <= $length($activeTo), + ]; + }, $tickFractions, array_keys($tickFractions)); + + $iconInActive = $icon !== null && isset($segments['active']) && $length($activeTo) - $length($activeFrom) >= $iconSize + 20; + $iconInInactive = $icon !== null && ! $iconInActive && isset($segments['end']) && $nominal - $length($segments['end'][0]) >= $iconSize + 20; + $iconAt = $iconInInactive ? $plus($segments['end'][0], 10) : [0.0, 10.0]; + + $calc = fn (array $at): string => 'calc('.$format($at[0]).'% '.($at[1] < 0 ? '-' : '+').' '.$format(abs($at[1])).'px)'; + $segmentStyle = fn (?array $segment): string => $segment === null ? '' : 'left: '.$calc($segment[0]).'; width: '.$calc([$segment[1][0] - $segment[0][0], $segment[1][1] - $segment[0][1]]) + .'; border-radius: '.$segment[2].'px '.$segment[3].'px '.$segment[3].'px '.$segment[2].'px'; + + // Every colour class written out whole, so Tailwind compiles it. + $activeInk = [ + 'primary' => 'bg-primary', 'secondary' => 'bg-secondary', 'tertiary' => 'bg-tertiary', 'error' => 'bg-error', + 'success' => 'bg-success', 'warning' => 'bg-warning', 'info' => 'bg-info', + ]; + $inactiveInk = [ + 'primary' => 'bg-secondary-container', 'secondary' => 'bg-secondary-container', 'tertiary' => 'bg-tertiary-container', + 'error' => 'bg-error-container', 'success' => 'bg-success-container', 'warning' => 'bg-warning-container', 'info' => 'bg-info-container', + ]; + // A tick, like the icon, takes the colour of the part of the track it is not on. + $tickInk = [ + 'primary' => 'bg-primary data-active:bg-secondary-container', 'secondary' => 'bg-secondary data-active:bg-secondary-container', + 'tertiary' => 'bg-tertiary data-active:bg-tertiary-container', 'error' => 'bg-error data-active:bg-error-container', + 'success' => 'bg-success data-active:bg-success-container', 'warning' => 'bg-warning data-active:bg-warning-container', + 'info' => 'bg-info data-active:bg-info-container', + ]; + $iconInk = [ + 'primary' => 'text-primary data-active:text-secondary-container', 'secondary' => 'text-secondary data-active:text-secondary-container', + 'tertiary' => 'text-tertiary data-active:text-tertiary-container', 'error' => 'text-error data-active:text-error-container', + 'success' => 'text-success data-active:text-success-container', 'warning' => 'text-warning data-active:text-warning-container', + 'info' => 'text-info data-active:text-info-container', + ]; + $accent = [ + 'primary' => 'noscript:accent-primary', 'secondary' => 'noscript:accent-secondary', 'tertiary' => 'noscript:accent-tertiary', + 'error' => 'noscript:accent-error', 'success' => 'noscript:accent-success', 'warning' => 'noscript:accent-warning', 'info' => 'noscript:accent-info', + ]; + + $trackHeight = ['xs' => 'h-4', 'sm' => 'h-6', 'md' => 'h-10', 'lg' => 'h-14', 'xl' => 'h-24'][$size]; + // A focused handle is 6px shorter, so its ring stays clear of the label (Material Components' + // m3_slider_focus_ring_thumb_height_decrease). + $handleHeight = [ + 'xs' => 'h-11 group-data-focused/thumb:h-9.5', 'sm' => 'h-11 group-data-focused/thumb:h-9.5', 'md' => 'h-11 group-data-focused/thumb:h-9.5', + 'lg' => 'h-17 group-data-focused/thumb:h-15.5', 'xl' => 'h-27 group-data-focused/thumb:h-25.5', + ][$size]; + // The label's bottom sits 4px above the handle. + $labelBottom = ['xs' => 'bottom-[calc(50%+1.625rem)]', 'sm' => 'bottom-[calc(50%+1.625rem)]', 'md' => 'bottom-[calc(50%+1.625rem)]', 'lg' => 'bottom-[calc(50%+2.375rem)]', 'xl' => 'bottom-[calc(50%+3.625rem)]'][$size]; + $thumbs = $range ? ['start', 'end'] : ['start']; +@endphp + +
only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized]) }}> + @if (filled($label)) + @if ($range) + {{ $label }} + @else + + @endif + @endif + +
in_array($size, ['xs', 'sm', 'md'], true), + 'h-17' => $size === 'lg', + 'h-27' => $size === 'xl', + 'mt-9' => $valueLabel === 'always', + 'noscript:h-auto noscript:cursor-auto noscript:space-y-2', + ]) + > + + + @foreach ($thumbs as $index => $thumb) + + @endforeach +
+ + @if ($messages !== []) +
+ @foreach ($messages as $message) +

{{ $message }}

+ @endforeach +
+ @elseif (filled($hint)) +

{{ $hint }}

+ @endif +
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index c3e18434..84e7023d 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -20,5 +20,6 @@ @include('livewire-material::showcase.sections.carousel') @include('livewire-material::showcase.sections.fields') @include('livewire-material::showcase.sections.chips') + @include('livewire-material::showcase.sections.sliders') @endsection diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index 28f67f42..5728194f 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -18,7 +18,7 @@ Livewire Material diff --git a/resources/views/showcase/sections/sliders.blade.php b/resources/views/showcase/sections/sliders.blade.php new file mode 100644 index 00000000..c1153bfb --- /dev/null +++ b/resources/views/showcase/sections/sliders.blade.php @@ -0,0 +1,57 @@ +@php + $examples = [ + 'Standard, discrete and disabled' => <<<'BLADE' +
+ + + +
+ BLADE, + 'Sizes, with an inset icon from md' => <<<'BLADE' +
+ + + + + +
+ BLADE, + 'Range and centred' => <<<'BLADE' +
+ + +
+ BLADE, + 'Colours and value labels' => <<<'BLADE' +
+ + + +
+ BLADE, + 'Bound with x-model' => <<<'BLADE' +
+
+ +

volume:

+
+
+ +

price:

+
+
+ BLADE, + ]; +@endphp + +
+

Sliders

+ +

+ <x-slider>: M3 Expressive's slider on native range inputs — standard, range and centred, five sizes, ticks, value labels and an inset icon. +

+ + @foreach ($examples as $title => $code) + + @endforeach +
diff --git a/tests/Browser/SliderTest.php b/tests/Browser/SliderTest.php new file mode 100644 index 00000000..59ce8164 --- /dev/null +++ b/tests/Browser/SliderTest.php @@ -0,0 +1,254 @@ + */ + public array $price = [20, 80]; + + public function maximise(): void + { + $this->volume = 90; + } + + public function render(): string + { + return <<<'BLADE' +
+ +

volume: {{ $volume }}

+ +

price: {{ implode(',', $price) }}

+ +
+ BLADE; + } +} + +function sliderProbe() +{ + Livewire::component('slider-probe', SliderProbe::class); + + Route::middleware('web')->get('/slider-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE)); + + return visit('/slider-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); +} + +function sliderShowcase() +{ + return visit('/material')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); +} + +/** + * A script run against the first slider labelled `$label`, scrolled into view. In scope: `root` + * (the element with x-data), `inputs`, `width` (the track's), `left(selector)` (a drawn part's + * position), `segment(name)` (a track segment's {from, to}, or null while hidden), `near()`, + * `pause(ms)` and `pointer(type, fraction)`, which sends a mouse pointer event at that fraction of + * the track. Pest retries a failing assertion: scripts that change something go through + * `script()`, and the assertions after them only read. + */ +function onSlider(string $label, string $body): string +{ + return << { + const root = [...document.querySelectorAll('[data-slider]')] + .find((slider) => slider.parentElement.querySelector(':scope > label, :scope > span')?.textContent.trim() === '{$label}') + root.scrollIntoView({ block: 'center' }) + const inputs = [...root.querySelectorAll('input[type="range"]')] + const width = root.clientWidth - 4 + const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + const near = (a, b, tolerance = 1) => Math.abs(a - b) <= tolerance + const left = (selector) => Number.parseFloat(root.querySelector(selector).style.left) + const segment = (name) => { + const element = root.querySelector(`[data-segment="\${name}"]`) + const from = Number.parseFloat(element.style.left) + return element.hidden ? null : { from, to: from + Number.parseFloat(element.style.width) } + } + const pointer = (type, fraction) => { + const box = root.getBoundingClientRect() + const target = type === 'pointerdown' ? root : window + target.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, clientX: box.left + 2 + width * fraction, clientY: box.top + box.height / 2, button: 0, pointerId: 1, pointerType: 'mouse' })) + } + {$body} + })() + JS; +} + +it('moves with the keyboard, and the drawing follows', function () { + $page = sliderShowcase()->assertNoJavaScriptErrors(); + + $page->script(onSlider('Volume', 'inputs[0].focus()')); + + $page->keys(':focus', ['ArrowRight', 'ArrowRight']) + ->assertScript(onSlider('Volume', <<<'JS' + return inputs[0].value === '42' + && near(left('[data-handle="start"]'), width * 0.42) + && near(segment('active').to, width * 0.42 - 8) + && near(segment('end').from, width * 0.42 + 8) + && root.querySelector('[data-handle="start"]').hasAttribute('data-focused') + && root.querySelector('[data-value-label]').textContent === '42' + JS)); + + $page->keys(':focus', 'PageUp') + ->assertScript(onSlider('Volume', "return inputs[0].value === '52' && near(left('[data-handle=\"start\"]'), width * 0.52)")); + + $page->keys(':focus', 'End') + ->assertScript(onSlider('Volume', "return inputs[0].value === '100' && segment('end') === null && ! root.querySelector('[data-stop=\"end\"]').checkVisibility()")); + + $page->keys(':focus', 'Home') + ->assertScript(onSlider('Volume', "return inputs[0].value === '0' && segment('active') === null && near(segment('end').from, 8)")); +}); + +it('follows a pointer drag with a narrowed handle and its value label', function () { + $page = sliderShowcase(); + + $page->script(onSlider('Volume', "pointer('pointerdown', 0.4); pointer('pointermove', 0.75)")); + + $page->assertScript(onSlider('Volume', <<<'JS' + await pause(450) + const thumb = root.querySelector('[data-handle="start"]') + const label = thumb.querySelector('[data-value-label]') + return inputs[0].value === '75' + && label.textContent === '75' + && getComputedStyle(label).opacity === '1' + && thumb.hasAttribute('data-pressed') + && thumb.firstElementChild.offsetWidth === 2 + && near(left('[data-handle="start"]'), width * 0.75) + && document.activeElement === inputs[0] + JS)); + + $page->script(onSlider('Volume', "window.__changed = 0; inputs[0].addEventListener('change', () => window.__changed++); pointer('pointerup', 0.75)")); + + $page->assertScript(onSlider('Volume', <<<'JS' + await pause(450) + const thumb = root.querySelector('[data-handle="start"]') + return inputs[0].value === '75' + && window.__changed === 1 + && ! thumb.hasAttribute('data-pressed') + && ! thumb.hasAttribute('data-focused') + && thumb.firstElementChild.offsetWidth === 4 + && getComputedStyle(thumb.querySelector('[data-value-label]')).opacity === '0' + JS)); +}); + +it('keeps a range\'s handles from crossing, by pointer and by keyboard', function () { + $page = sliderShowcase(); + + // The start handle, from 20 dragged past the end at 60, stops on it. + $page->script(onSlider('Price', "pointer('pointerdown', 0.2); pointer('pointermove', 0.9); pointer('pointerup', 0.9)")); + + $page->assertScript(onSlider('Price', <<<'JS' + return inputs[0].value === '60' && inputs[1].value === '60' + && near(left('[data-handle="start"]'), left('[data-handle="end"]')) + && segment('active') === null + JS)); + + $page->script(onSlider('Price', 'inputs[1].focus()')); + + $page->keys(':focus', 'Home') + ->assertScript(onSlider('Price', "return inputs[0].value === '60' && inputs[1].value === '60'")); + + // Bound with x-model to an array: the model never holds crossed ends either. + $page->script(onSlider('Bound price', 'inputs[0].focus()')); + + $page->keys(':focus', 'End') + ->assertScript("document.querySelector('[data-bound=\"price\"]').textContent === '[300,300]'"); +}); + +it('fills a centred slider from the middle', function () { + $page = sliderShowcase(); + + $page->assertScript(onSlider('Balance', <<<'JS' + const handle = left('[data-handle="start"]') + return inputs[0].value === '15' + && near(handle, width * 0.65) + && near(segment('start').to, width / 2 - 6) + && near(segment('active').from, width / 2) + && near(segment('active').to, handle - 8) + && near(segment('end').from, handle + 8) + JS)); + + $page->script(onSlider('Balance', 'inputs[0].focus()')); + + $page->keys(':focus', 'Home') + ->assertScript(onSlider('Balance', <<<'JS' + return inputs[0].value === '-50' + && segment('start') === null + && near(segment('active').from, 8) + && near(segment('active').to, width / 2) + && near(segment('end').from, width / 2 + 6) + JS)); +}); + +it('follows a value x-model sets from outside', function () { + $page = sliderShowcase(); + + $page->script("[...document.querySelectorAll('#sliders button')].find((button) => button.textContent.trim() === 'Set 80').click()"); + + $page->assertScript(onSlider('Bound volume', "return inputs[0].value === '80' && near(left('[data-handle=\"start\"]'), width * 0.8)")); +}); + +it('sends live values to Livewire, is not moved back by a render mid-drag, and moves when the server sets a value', function () { + $page = sliderProbe(); + + $page->script(onSlider('Volume', 'inputs[0].focus()')); + + $page->keys(':focus', ['ArrowRight', 'ArrowRight', 'ArrowRight']) + ->assertSeeIn('#volume', '33'); + + // A drag that outruns a round trip: while the request for 50 is on its way, the handle moves + // on to 70, and the render for 50 must not pull it back. + $page->script(onSlider('Volume', <<<'JS' + window.__renders = 0 + let moved = false + window.Livewire.hook('commit', ({ succeed }) => { + if (! moved) { + moved = true + queueMicrotask(() => pointer('pointermove', 0.7)) + } + succeed(() => window.__renders++) + }) + pointer('pointerdown', 0.5) + JS)); + + // Both round trips back: the handle is where it was dragged, and so is the property. + $page->assertScript(onSlider('Volume', <<<'JS' + return window.__renders >= 2 && inputs[0].value === '70' && near(left('[data-handle="start"]'), width * 0.7) + JS)) + ->assertSeeIn('#volume', '70'); + + $page->script(onSlider('Volume', "pointer('pointerup', 0.7)")); + + $page->click('button:has-text("Maximise")') + ->assertSeeIn('#volume', '90') + ->assertScript(onSlider('Volume', "return inputs[0].value === '90' && near(left('[data-handle=\"start\"]'), width * 0.9) && root.querySelector('[data-value-label]').textContent === '90'")); + + $page->script(onSlider('Price', 'inputs[1].focus()')); + + $page->keys(':focus', 'ArrowLeft') + ->assertSeeIn('#price', '20,79'); +}); diff --git a/tests/Feature/Components/SliderTest.php b/tests/Feature/Components/SliderTest.php new file mode 100644 index 00000000..26edd92c --- /dev/null +++ b/tests/Feature/Components/SliderTest.php @@ -0,0 +1,203 @@ +blade(''); + + expect($html) + ->toContain('