Add the M3 Expressive slider
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m5s
tests / feature (8.5) (push) Successful in 1m5s
tests / browser (chrome, chromium) (push) Successful in 3m17s
tests / browser (firefox, firefox) (push) Successful in 4m1s
tests / browser (safari, webkit) (push) Failing after 5m38s
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m5s
tests / feature (8.5) (push) Successful in 1m5s
tests / browser (chrome, chromium) (push) Successful in 3m17s
tests / browser (firefox, firefox) (push) Successful in 4m1s
tests / browser (safari, webkit) (push) Failing after 5m38s
Standard, centered and range sliders on native range inputs, drawn as Compose draws them, in five sizes with ticks, value labels and inset icons. Completes Phase 6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
0fee2a0952
commit
bf00e4c40a
@@ -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.
|
||||
- **`<x-chip>` and `<x-slider>` 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.
|
||||
- **`<x-choices>` 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.
|
||||
- **`<x-search>` 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
|
||||
|
||||
@@ -436,6 +436,33 @@ M3 selection controls on native inputs; the whole row is the label.
|
||||
<x-toggle label="Notify me on download" wire:model.live="notify" right />
|
||||
```
|
||||
|
||||
### `<x-slider>`
|
||||
|
||||
M3 Expressive's slider on native `<input type="range">`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
|
||||
<x-slider label="Volume" wire:model.live="volume" hint="Applies at once" />
|
||||
<x-slider label="Price" wire:model="price" range :max="500" :step="10" ticks />
|
||||
<x-slider label="Balance" x-model="balance" name="balance" :min="-50" :max="50" centered />
|
||||
<x-slider label="Brightness" name="brightness" value="60" size="lg" icon="light_mode" />
|
||||
```
|
||||
|
||||
| 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.
|
||||
|
||||
### `<x-choices>`
|
||||
|
||||
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.
|
||||
|
||||
@@ -20,3 +20,4 @@ import './carousel.js'
|
||||
import './chips.js'
|
||||
import './field.js'
|
||||
import './search.js'
|
||||
import './slider.js'
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* `materialSlider`: draws `<x-slider>`, 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<string, {from: number, to: number, startRadius: number, endRadius: number}>, stops: Object<string, number>, 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)
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,391 @@
|
||||
{{-- An M3 Expressive slider: a value, or with `range` a span of values, picked along a track.
|
||||
|
||||
<x-slider label="Volume" wire:model.live="volume" />
|
||||
<x-slider label="Price" wire:model="price" range :min="0" :max="500" :step="10" />
|
||||
<x-slider label="Balance" name="balance" :min="-50" :max="50" centered ticks :step="10" />
|
||||
|
||||
Built on native `<input type="range">`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
|
||||
|
||||
<div {{ $attributes->only(['class', 'style', 'wire:key'])->class(['min-w-0', 'w-full' => ! $sized]) }}>
|
||||
@if (filled($label))
|
||||
@if ($range)
|
||||
<span id="{{ $id }}-label" class="mb-2 block type-label-lg text-on-surface-variant">{{ $label }}</span>
|
||||
@else
|
||||
<label for="{{ $id }}" id="{{ $id }}-label" class="mb-2 block type-label-lg text-on-surface-variant">{{ $label }}</label>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<div
|
||||
x-data="materialSlider"
|
||||
x-on:pointerdown="press($event)"
|
||||
data-slider
|
||||
data-size="{{ $size }}"
|
||||
@if ($centered) data-centered @endif
|
||||
@if ($range) role="group" @if (filled($label)) aria-labelledby="{{ $id }}-label" @endif @endif
|
||||
@class([
|
||||
'group/slider relative cursor-pointer touch-pan-y select-none has-disabled:cursor-not-allowed',
|
||||
'h-12' => 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',
|
||||
])
|
||||
>
|
||||
<div data-slider-drawing wire:ignore aria-hidden="true" class="pointer-events-none absolute inset-y-0 inset-x-0.5 rtl:-scale-x-100 noscript:hidden">
|
||||
<div @class(['absolute inset-x-0 top-1/2 -translate-y-1/2', $trackHeight])>
|
||||
@foreach (['start' => $inactiveInk[$color], 'active' => $activeInk[$color], 'end' => $inactiveInk[$color]] as $part => $ink)
|
||||
<span
|
||||
data-segment="{{ $part }}"
|
||||
@if (! isset($segments[$part])) hidden @endif
|
||||
@class([
|
||||
'absolute inset-y-0',
|
||||
$ink,
|
||||
'group-has-disabled/slider:bg-on-surface/38' => $part === 'active',
|
||||
'group-has-disabled/slider:bg-on-surface/12' => $part !== 'active',
|
||||
])
|
||||
style="{{ $segmentStyle($segments[$part] ?? null) }}"
|
||||
></span>
|
||||
@endforeach
|
||||
|
||||
@foreach ($tickMarks as $mark)
|
||||
<span
|
||||
data-tick="{{ $format($mark['fraction']) }}"
|
||||
@if ($mark['hidden']) hidden @endif
|
||||
@if ($mark['active']) data-active @endif
|
||||
@class(['absolute top-1/2 size-1 -translate-1/2 rounded-full', $tickInk[$color], 'group-has-disabled/slider:bg-on-surface/38 group-has-disabled/slider:data-active:bg-on-surface/12'])
|
||||
style="left: {{ $calc($mark['at']) }}"
|
||||
></span>
|
||||
@endforeach
|
||||
|
||||
@foreach (['start', 'end'] as $part)
|
||||
<span
|
||||
data-stop="{{ $part }}"
|
||||
@if (! isset($stops[$part])) hidden @endif
|
||||
@class(['absolute top-1/2 size-1 -translate-1/2 rounded-full', $activeInk[$color], 'group-has-disabled/slider:bg-on-surface/38'])
|
||||
style="{{ isset($stops[$part]) ? 'left: '.$calc($stops[$part]) : '' }}"
|
||||
></span>
|
||||
@endforeach
|
||||
|
||||
@if ($icon !== null)
|
||||
<span
|
||||
data-track-icon
|
||||
@if (! $iconInActive && ! $iconInInactive) hidden @endif
|
||||
@if ($iconInActive) data-active @endif
|
||||
@class(['absolute top-1/2 flex -translate-y-1/2 rtl:-scale-x-100', $iconInk[$color], 'group-has-disabled/slider:text-on-surface/38'])
|
||||
style="left: {{ $calc($iconAt) }}"
|
||||
>
|
||||
<x-icon :name="$icon" :class="$iconSize === 32 ? 'size-8' : 'size-6'" />
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@foreach ($thumbs as $index => $thumb)
|
||||
<span data-handle="{{ $thumb }}" class="group/thumb absolute inset-y-0 w-0" style="left: {{ $calc($place($fractions[$index])) }}">
|
||||
<span @class([
|
||||
'absolute top-1/2 left-0 w-1 -translate-1/2 rounded-full',
|
||||
'transition-[width,height] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
'group-data-pressed/thumb:w-0.5 group-data-focused/thumb:w-0.5',
|
||||
'outline-offset-2 outline-secondary group-data-focused/thumb:outline-3',
|
||||
$handleHeight,
|
||||
$activeInk[$color],
|
||||
'group-has-disabled/slider:bg-on-surface/38',
|
||||
])></span>
|
||||
|
||||
@if ($valueLabel !== 'never')
|
||||
<span @class(['absolute left-0 z-10 flex -translate-x-1/2 justify-center rtl:-scale-x-100', $labelBottom])>
|
||||
<span
|
||||
data-value-label
|
||||
@class([
|
||||
'flex h-8 min-w-9 origin-bottom items-center justify-center whitespace-nowrap rounded-corner-full bg-inverse-surface px-2.5 text-inverse-on-surface type-label-lg',
|
||||
'transition-[opacity,scale] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
|
||||
'opacity-0 scale-75 group-data-pressed/thumb:opacity-100 group-data-pressed/thumb:scale-100 group-data-focused/thumb:opacity-100 group-data-focused/thumb:scale-100' => $valueLabel === 'drag',
|
||||
])
|
||||
>{{ $format($values[$index]) }}</span>
|
||||
</span>
|
||||
@endif
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@foreach ($thumbs as $index => $thumb)
|
||||
<input
|
||||
type="range"
|
||||
id="{{ $index === 0 ? $id : $id.'-end' }}"
|
||||
@if (filled($name)) name="{{ $range ? $name.'[]' : $name }}" @endif
|
||||
min="{{ $format($minimum) }}"
|
||||
max="{{ $format($maximum) }}"
|
||||
step="{{ $continuous ? 'any' : $format($interval) }}"
|
||||
value="{{ $format($values[$index]) }}"
|
||||
data-thumb="{{ $thumb }}"
|
||||
@if ($range) aria-label="{{ $thumb === 'start' ? __('Range start') : __('Range end') }}" @endif
|
||||
@if ($described) aria-describedby="{{ $described }}" @endif
|
||||
@if ($messages !== []) aria-invalid="true" @endif
|
||||
@disabled($disabled)
|
||||
{{ new \Illuminate\View\ComponentAttributeBag($bindingsFor($index)) }}
|
||||
{{ $inputAttributes }}
|
||||
@class([
|
||||
'pointer-events-none absolute inset-0 m-0 size-full appearance-none bg-transparent opacity-0',
|
||||
'noscript:pointer-events-auto noscript:static noscript:block noscript:h-6 noscript:w-full noscript:appearance-auto noscript:opacity-100',
|
||||
$accent[$color],
|
||||
])
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($messages !== [])
|
||||
<div id="{{ $id }}-hint">
|
||||
@foreach ($messages as $message)
|
||||
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
@elseif (filled($hint))
|
||||
<p id="{{ $id }}-hint" class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@@ -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')
|
||||
</main>
|
||||
@endsection
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
|
||||
|
||||
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips'] as $anchor => $section)
|
||||
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel', 'fields' => 'Fields', 'chips' => 'Chips', 'sliders' => 'Sliders'] as $anchor => $section)
|
||||
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
@php
|
||||
$examples = [
|
||||
'Standard, discrete and disabled' => <<<'BLADE'
|
||||
<div class="grid w-full gap-8 md:grid-cols-3">
|
||||
<x-slider label="Volume" value="40" hint="Drag the handle or use the arrow keys" />
|
||||
<x-slider label="Quality" value="6" :max="10" ticks />
|
||||
<x-slider label="Locked" value="70" disabled />
|
||||
</div>
|
||||
BLADE,
|
||||
'Sizes, with an inset icon from md' => <<<'BLADE'
|
||||
<div class="grid w-full gap-6">
|
||||
<x-slider label="Extra small (16px track)" value="30" />
|
||||
<x-slider label="Small (24px)" value="45" size="sm" />
|
||||
<x-slider label="Medium (40px)" value="60" size="md" icon="volume_up" />
|
||||
<x-slider label="Large (56px)" value="12" size="lg" icon="light_mode" />
|
||||
<x-slider label="Extra large (96px)" value="75" size="xl" icon="brightness_medium" />
|
||||
</div>
|
||||
BLADE,
|
||||
'Range and centred' => <<<'BLADE'
|
||||
<div class="grid w-full gap-8 md:grid-cols-2">
|
||||
<x-slider label="Price" :value="[20, 60]" range ticks :step="10" hint="Handles never cross" />
|
||||
<x-slider label="Balance" value="15" :min="-50" :max="50" centered />
|
||||
</div>
|
||||
BLADE,
|
||||
'Colours and value labels' => <<<'BLADE'
|
||||
<div class="grid w-full gap-8 md:grid-cols-3">
|
||||
<x-slider label="Always labelled" value="55" color="secondary" value-label="always" size="sm" />
|
||||
<x-slider label="Tertiary, no label" value="35" color="tertiary" value-label="never" size="sm" />
|
||||
<x-slider label="Error" :value="[30, 70]" color="error" range size="sm" />
|
||||
</div>
|
||||
BLADE,
|
||||
'Bound with x-model' => <<<'BLADE'
|
||||
<div x-data="{ volume: 25, price: [100, 300] }" class="grid w-full gap-8 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<x-slider label="Bound volume" x-model="volume" />
|
||||
<p class="type-body-md text-on-surface-variant">volume: <span data-bound="volume" x-text="volume"></span> <x-button label="Set 80" x-on:click="volume = 80" /></p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<x-slider label="Bound price" x-model="price" range :max="500" :step="25" ticks />
|
||||
<p class="type-body-md text-on-surface-variant">price: <span data-bound="price" x-text="JSON.stringify(price)"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
BLADE,
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section id="sliders" class="scroll-mt-24 space-y-6">
|
||||
<h2 class="type-headline-md">Sliders</h2>
|
||||
|
||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||
<code><x-slider></code>: M3 Expressive's slider on native range inputs — standard, range and centred, five sizes, ticks, value labels and an inset icon.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
<x-showcase::example :$title :$code :stack="true" />
|
||||
@endforeach
|
||||
</section>
|
||||
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
/**
|
||||
* A Livewire component bound live to a slider and a range, whose values the server can also set.
|
||||
*/
|
||||
class SliderProbe extends Component
|
||||
{
|
||||
public int $volume = 30;
|
||||
|
||||
/** @var array<int, int|string> */
|
||||
public array $price = [20, 80];
|
||||
|
||||
public function maximise(): void
|
||||
{
|
||||
$this->volume = 90;
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div style="width: 420px; padding: 4rem 2rem">
|
||||
<x-slider label="Volume" wire:model.live="volume" />
|
||||
<p>volume: <span id="volume">{{ $volume }}</span></p>
|
||||
<x-slider label="Price" wire:model.live="price" range />
|
||||
<p>price: <span id="price">{{ implode(',', $price) }}</span></p>
|
||||
<button type="button" wire:click="maximise">Maximise</button>
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
}
|
||||
|
||||
function sliderProbe()
|
||||
{
|
||||
Livewire::component('slider-probe', SliderProbe::class);
|
||||
|
||||
Route::middleware('web')->get('/slider-probe', fn () => Blade::render(<<<'BLADE'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(config('livewire-material.showcase.vite'))
|
||||
@livewireStyles
|
||||
</head>
|
||||
<body class="bg-surface">
|
||||
<livewire:slider-probe />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
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 <<<JS
|
||||
(async () => {
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\MessageBag;
|
||||
use Illuminate\Support\ViewErrorBag;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('is a labelled native range input under a drawing only the script touches', function () {
|
||||
$html = (string) $this->blade('<x-slider label="Volume" name="volume" value="40" :min="0" :max="200" :step="5" id="volume" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('<label for="volume" id="volume-label"')
|
||||
->toContain('type="range"')
|
||||
->toContain('id="volume"')
|
||||
->toContain('name="volume"')
|
||||
->toContain('min="0"')
|
||||
->toContain('max="200"')
|
||||
->toContain('step="5"')
|
||||
->toContain('value="40"')
|
||||
->toContain('x-data="materialSlider"')
|
||||
->toContain('x-on:pointerdown="press($event)"')
|
||||
->toContain('data-slider-drawing wire:ignore aria-hidden="true"')
|
||||
->toContain('noscript:appearance-auto')
|
||||
->not->toContain('role="group"');
|
||||
});
|
||||
|
||||
it('draws the first frame on the server: the track split around the handle, and the stop', function () {
|
||||
$html = (string) $this->blade('<x-slider value="40" />');
|
||||
|
||||
expect($html)
|
||||
->toMatch('/data-segment="active"\s+class="[^"]*bg-primary[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/')
|
||||
->toMatch('/data-segment="end"\s+class="[^"]*bg-secondary-container[^"]*"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/')
|
||||
->toMatch('/data-segment="start"\s+hidden/')
|
||||
->toContain('style="left: calc(100% - 8px)"')
|
||||
->toContain('<span data-handle="start" class="group/thumb absolute inset-y-0 w-0" style="left: calc(40% + 0px)">')
|
||||
->toMatch('/data-value-label\s+class="[^"]*opacity-0[^"]*"\s*>40<\/span>/');
|
||||
});
|
||||
|
||||
it('snaps and clamps the value to the step grid', function (string $template, string $value) {
|
||||
expect((string) $this->blade($template))->toContain("value=\"{$value}\"");
|
||||
})->with([
|
||||
'between steps' => ['<x-slider value="23" :step="5" />', '25'],
|
||||
'below the minimum' => ['<x-slider :value="-20" />', '0'],
|
||||
'past the last step' => ['<x-slider value="10" :max="10" :step="3" />', '9'],
|
||||
'a fraction' => ['<x-slider value="0.34" :max="1" step="0.1" />', '0.3'],
|
||||
'continuous' => ['<x-slider value="12.345" step="any" />', '12.345'],
|
||||
'centred without a value' => ['<x-slider :min="-50" :max="50" centered />', '0'],
|
||||
]);
|
||||
|
||||
it('binds wire:model and x-model to the input as numbers, and names it after the property', function () {
|
||||
expect((string) $this->blade('<x-slider wire:model.live.debounce.250ms="volume" />'))
|
||||
->toContain('wire:model.number.live.debounce.250ms="volume"')
|
||||
->toContain('name="volume"');
|
||||
|
||||
expect((string) $this->blade('<x-slider x-model="volume" name="volume" x-on:change="saved = true" />'))
|
||||
->toContain('x-model.number="volume"')
|
||||
->toContain('x-on:change="saved = true"');
|
||||
|
||||
expect((string) $this->blade('<x-slider x-model.number="volume" />'))->toContain('x-model.number="volume"');
|
||||
});
|
||||
|
||||
it('gives a range two inputs bound to the ends of an array, in a named group', function () {
|
||||
$html = (string) $this->blade('<x-slider label="Price" wire:model="price" range :value="[80, 20]" id="price" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('role="group"')
|
||||
->toContain('aria-labelledby="price-label"')
|
||||
->toContain('wire:model.number="price.0"')
|
||||
->toContain('wire:model.number="price.1"')
|
||||
->toContain('aria-label="Range start"')
|
||||
->toContain('aria-label="Range end"')
|
||||
->toContain('id="price-end"')
|
||||
->toContain('data-handle="end"')
|
||||
->not->toContain('<label for=');
|
||||
|
||||
expect(substr_count($html, 'name="price[]"'))->toBe(2)
|
||||
// Out of order, the ends are sorted, as Compose sorts them.
|
||||
->and(strpos($html, 'value="20"'))->toBeLessThan(strpos($html, 'value="80"'));
|
||||
|
||||
expect((string) $this->blade('<x-slider x-model="price" range />'))
|
||||
->toContain('x-model.number="price[0]"')
|
||||
->toContain('x-model.number="price[1]"');
|
||||
});
|
||||
|
||||
it('draws the value of the bound Livewire property', function () {
|
||||
$component = new class extends Component
|
||||
{
|
||||
public int $volume = 70;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public array $price = [100, 400];
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div>
|
||||
<x-slider wire:model.live="volume" />
|
||||
<x-slider wire:model="price" range :max="500" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
};
|
||||
|
||||
Livewire::test($component)
|
||||
->assertSeeHtml('value="70"')
|
||||
->assertSeeHtml('style="left: calc(70% + 0px)"')
|
||||
->assertSeeHtml('value="100"')
|
||||
->assertSeeHtml('value="400"');
|
||||
});
|
||||
|
||||
it('replaces the hint with the validation message and marks the input invalid', function () {
|
||||
$errors = (new ViewErrorBag)->put('default', new MessageBag(['volume' => ['Too loud.'], 'price.1' => ['Too expensive.']]));
|
||||
|
||||
$this->withViewErrors([]);
|
||||
view()->share('errors', $errors);
|
||||
|
||||
expect((string) $this->blade('<x-slider name="volume" hint="Quietly" id="volume" />'))
|
||||
->toContain('Too loud.')
|
||||
->toContain('text-error')
|
||||
->toContain('aria-invalid="true"')
|
||||
->toContain('aria-describedby="volume-hint"')
|
||||
->not->toContain('Quietly');
|
||||
|
||||
expect((string) $this->blade('<x-slider name="price" range hint="Per night" />'))
|
||||
->toContain('Too expensive.')
|
||||
->not->toContain('Per night');
|
||||
|
||||
expect((string) $this->blade('<x-slider name="balance" hint="Centre is even" />'))
|
||||
->toContain('Centre is even')
|
||||
->not->toContain('aria-invalid');
|
||||
});
|
||||
|
||||
it('sizes the track and the handle by Expressive\'s tokens', function (string $size, string $track, string $handle, string $body) {
|
||||
expect((string) $this->blade('<x-slider :size="$size" />', ['size' => $size]))
|
||||
->toContain("data-size=\"{$size}\"")
|
||||
->toContain("-translate-y-1/2 {$track}\"")
|
||||
->toContain($handle)
|
||||
->toMatch("/group\\/slider [^\"]* {$body} /");
|
||||
})->with([
|
||||
'xs' => ['xs', 'h-4', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'],
|
||||
'sm' => ['sm', 'h-6', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'],
|
||||
'md' => ['md', 'h-10', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'],
|
||||
'lg' => ['lg', 'h-14', 'h-17 group-data-focused/thumb:h-15.5', 'h-17'],
|
||||
'xl' => ['xl', 'h-24', 'h-27 group-data-focused/thumb:h-25.5', 'h-27'],
|
||||
]);
|
||||
|
||||
it('insets an icon in the track from md, but not on a small, range or centred slider', function () {
|
||||
expect((string) $this->blade('<x-slider size="md" icon="volume_up" value="60" />'))
|
||||
->toContain('data-track-icon')
|
||||
->toMatch('/data-track-icon\s+data-active/')
|
||||
->toContain('text-primary data-active:text-secondary-container');
|
||||
|
||||
expect((string) $this->blade('<x-slider size="xl" icon="volume_up" value="2" />'))
|
||||
->toMatch('/data-track-icon\s+class/')
|
||||
->toContain('size-8');
|
||||
|
||||
foreach (['<x-slider size="sm" icon="volume_up" />', '<x-slider size="lg" icon="volume_up" range />', '<x-slider size="lg" icon="volume_up" centered />'] as $template) {
|
||||
expect((string) $this->blade($template))->not->toContain('data-track-icon');
|
||||
}
|
||||
});
|
||||
|
||||
it('fills a centred slider from the middle', function () {
|
||||
expect((string) $this->blade('<x-slider :min="-50" :max="50" value="25" centered />'))
|
||||
->toContain('data-centered')
|
||||
->toMatch('/data-segment="start"\s+class="[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/')
|
||||
->toMatch('/data-segment="active"\s+class="[^"]*"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 8px\)/')
|
||||
->toMatch('/data-segment="end"\s+class="[^"]*"\s+style="left: calc\(75% \+ 8px\)/');
|
||||
});
|
||||
|
||||
it('marks every step when asked, and no more than 200 of them', function () {
|
||||
$html = (string) $this->blade('<x-slider ticks :max="10" value="5" />');
|
||||
|
||||
expect(substr_count($html, 'data-tick="'))->toBe(11)
|
||||
->and($html)->toContain('data-tick="0.5"')
|
||||
->and($html)->toContain('bg-primary data-active:bg-secondary-container');
|
||||
|
||||
expect((string) $this->blade('<x-slider ticks :max="1000" />'))->not->toContain('data-tick=')
|
||||
->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-tick=')
|
||||
->and((string) $this->blade('<x-slider :max="10" />'))->not->toContain('data-tick=');
|
||||
});
|
||||
|
||||
it('shows the value label on drag, always, or never', function () {
|
||||
expect((string) $this->blade('<x-slider />'))->toContain('opacity-0 scale-75 group-data-pressed/thumb:opacity-100');
|
||||
|
||||
expect((string) $this->blade('<x-slider value-label="always" value="30" />'))
|
||||
->toContain('data-value-label')
|
||||
->toContain('mt-9')
|
||||
->not->toContain('opacity-0 scale-75');
|
||||
|
||||
expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-value-label');
|
||||
});
|
||||
|
||||
it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) {
|
||||
expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color]))
|
||||
->toContain("absolute inset-y-0 {$active} group-has-disabled/slider:bg-on-surface/38")
|
||||
->toContain("absolute inset-y-0 {$inactive} group-has-disabled/slider:bg-on-surface/12")
|
||||
->toMatch('/<input[^>]*\sdisabled[\s>]/');
|
||||
})->with([
|
||||
'primary' => ['primary', 'bg-primary', 'bg-secondary-container'],
|
||||
'tertiary' => ['tertiary', 'bg-tertiary', 'bg-tertiary-container'],
|
||||
'error' => ['error', 'bg-error', 'bg-error-container'],
|
||||
'unknown' => ['pink', 'bg-primary', 'bg-secondary-container'],
|
||||
]);
|
||||
Reference in New Issue
Block a user