improvements

This commit is contained in:
Cory LaViska
2025-06-05 13:28:35 -04:00
parent 83bd02b613
commit 37fc1359de
4 changed files with 489 additions and 87 deletions

View File

@@ -7,7 +7,20 @@ icon: slider
---
```html {.example}
<wa-slider></wa-slider>
<wa-slider
label="Number of cats"
hint="Limit six per household"
name="value"
value="3"
min="0"
max="6"
with-markers
with-tooltip
with-references
>
<span slot="reference">Less</span>
<span slot="reference">More</span>
</wa-slider>
```
:::info
@@ -18,7 +31,7 @@ This component works with standard `<form>` elements. Please refer to the sectio
### Labels
Use the `label` attribute to give the range an accessible label. For labels that contain HTML, use the `label` slot instead.
Use the `label` attribute to give the slider an accessible label. For labels that contain HTML, use the `label` slot instead.
```html {.example}
<wa-slider label="Volume" min="0" max="100"></wa-slider>
@@ -26,18 +39,234 @@ Use the `label` attribute to give the range an accessible label. For labels that
### Hint
Add descriptive hint to a range with the `hint` attribute. For hints that contain HTML, use the `hint` slot instead.
Add descriptive hint to a slider with the `hint` attribute. For hints that contain HTML, use the `hint` slot instead.
```html {.example}
<wa-slider label="Volume" hint="Controls the volume of the current song." min="0" max="100"></wa-slider>
```
### Min, Max, and Step
### Showing tooltips
Use the `min` and `max` attributes to set the range's minimum and maximum values, respectively. The `step` attribute determines the value's interval when increasing and decreasing.
Use the `with-tooltip` attribute to display a tooltip with the current value when the slider is focused or being dragged.
```html {.example}
<wa-slider min="0" max="10" step="1"></wa-slider>
<wa-slider label="Quality" name="quality" min="0" max="100" value="50" with-tooltip></wa-slider>
```
### Setting min, max, and step
Use the `min` and `max` attributes to define the slider's range, and the `step` attribute to control the increment between values.
```html {.example}
<wa-slider label="Between zero and one" min="0" max="1" step="0.1" value="0.5" with-tooltip></wa-slider>
```
### Showing markers
Use the `with-markers` attribute to display visual indicators at each step increment. This works best with sliders that have a smaller range of values.
```html {.example}
<wa-slider label="Size" name="size" min="0" max="8" value="4" with-markers></wa-slider>
```
### Adding references
Use the `with-references` attribute along with the `reference` slot to add contextual labels below the slider. References are automatically spaced using `space-between`, making them easy to align with the start, center, and end positions.
```html {.example}
<wa-slider label="Speed" name="speed" min="1" max="5" value="3" with-markers with-references>
<span slot="reference">Slow</span>
<span slot="reference">Medium</span>
<span slot="reference">Fast</span>
</wa-slider>
```
:::info
If you want to show a reference next to a specific marker, you can add `position: absolute` to it and set the `left`, `right`, `top`, or `bottom` property to a percentage that corresponds to the marker's position.
:::
### Formatting the value
Customize how values are displayed in tooltips and announced to screen readers using the `valueFormatter` property. Set it to a function that accepts a number and returns a formatted string. The [`Intl.NumberFormat API`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat) is particularly useful for this.
```html {.example}
<!-- Percent -->
<wa-slider
id="slider__percent"
label="Percentage"
name="percentage"
value="0.5"
min="0"
max="1"
step=".01"
with-tooltip
></wa-slider
><br />
<script>
const slider = document.getElementById('slider__percent');
const formatter = new Intl.NumberFormat('en-US', { style: 'percent' });
customElements.whenDefined('wa-slider').then(() => {
slider.valueFormatter = value => formatter.format(value);
});
</script>
<!-- Duration -->
<wa-slider id="slider__duration" label="Duration" name="duration" value="12" min="0" max="24" with-tooltip></wa-slider
><br />
<script>
const slider = document.getElementById('slider__duration');
const formatter = new Intl.NumberFormat('en-US', { style: 'unit', unit: 'hour', unitDisplay: 'long' });
customElements.whenDefined('wa-slider').then(() => {
slider.valueFormatter = value => formatter.format(value);
});
</script>
<!-- Currency -->
<wa-slider id="slider__currency" label="Currency" name="currency" min="0" max="100" value="50" with-tooltip></wa-slider>
<script>
const slider = document.getElementById('slider__currency');
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
currencyDisplay: 'symbol',
maximumFractionDigits: 0,
});
customElements.whenDefined('wa-slider').then(() => {
slider.valueFormatter = value => formatter.format(value);
});
</script>
```
### Range selection
Use the `range` attribute to enable dual-thumb selection for choosing a range of values. Set the initial thumb positions with the `min-value` and `max-value` attributes.
```html {.example}
<wa-slider
label="Price Range"
hint="Select minimum and maximum price"
name="price"
range
min="0"
max="100"
min-value="20"
max-value="80"
with-tooltip
with-references
id="slider__range"
>
<span slot="reference">$0</span>
<span slot="reference">$50</span>
<span slot="reference">$100</span>
</wa-slider>
<script>
const slider = document.getElementById('slider__range');
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
customElements.whenDefined('wa-slider').then(() => {
slider.valueFormatter = value => formatter.format(value);
});
</script>
```
For range sliders, the `minValue` and `maxValue` properties represent the current positions of the thumbs. When the form is submitted, both values will be included as separate entries with the same name.
```ts
const slider = document.querySelector('wa-slider[range]');
// Get the current values
console.log(`Min value: ${slider.minValue}, Max value: ${slider.maxValue}`);
// Set the values programmatically
slider.minValue = 30;
slider.maxValue = 70;
```
### Changing the orientation
Set the `orientation` attribute to `vertical` to create a vertical slider. Vertical sliders automatically center themselves and fill the available vertical space.
```html {.example}
<div style="display: flex; gap: 1rem;">
<wa-slider orientation="vertical" label="Volume" name="volume" value="65" style="width: 80px"></wa-slider>
<wa-slider orientation="vertical" label="Bass" name="bass" value="50" style="width: 80px"></wa-slider>
<wa-slider orientation="vertical" label="Treble" name="treble" value="40" style="width: 80px"></wa-slider>
</div>
```
Range sliders can also be vertical.
```html {.example}
<div style="height: 300px; display: flex; align-items: center; gap: 2rem;">
<wa-slider
label="Temperature Range"
orientation="vertical"
range
min="0"
max="100"
min-value="30"
max-value="70"
with-tooltip
id="slider__vertical-range"
>
</wa-slider>
</div>
<script>
const slider = document.getElementById('slider__vertical-range');
slider.valueFormatter = value => {
return new Intl.NumberFormat('en', {
style: 'unit',
unit: 'fahrenheit',
unitDisplay: 'short',
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
};
</script>
```
### Changing the size
Control the slider's size using the `size` attribute with options ranging from `xs` to `xl`.
```html {.example}
<wa-slider size="xs" value="50" label="Extra small"></wa-slider><br />
<wa-slider size="sm" value="50" label="Small"></wa-slider><br />
<wa-slider size="md" value="50" label="Medium"></wa-slider><br />
<wa-slider size="lg" value="50" label="Large"></wa-slider><br />
<wa-slider size="xl" value="50" label="Extra large"></wa-slider>
```
### Changing the indicator's offset
By default, the filled indicator extends from the minimum value to the current position. Use the `indicator-offset` attribute to change the starting point of this visual indicator.
```html {.example}
<wa-slider
label="Cat playfulness"
hint="Energy level during playtime"
name="value"
value="0"
min="-5"
max="5"
indicator-offset="0"
with-markers
with-tooltip
with-references
>
<span slot="reference">Lazy</span>
<span slot="reference">Zoomies</span>
</wa-slider>
```
### Disabled
@@ -45,77 +274,136 @@ Use the `min` and `max` attributes to set the range's minimum and maximum values
Use the `disabled` attribute to disable a slider.
```html {.example}
<wa-slider disabled></wa-slider>
<wa-slider label="Disabled" value="50" disabled></wa-slider>
```
### Tooltip Placement
### Required
By default, the tooltip is shown on top. Set `tooltip` to `bottom` to show it below the slider.
Mark a slider as required using the `required` attribute. Users must interact with required sliders before the form can be submitted.
```html {.example}
<wa-slider tooltip="bottom"></wa-slider>
<form action="about:blank" target="_blank" method="get">
<wa-slider name="slide" label="Required slider" min="0" max="10" required></wa-slider>
<br />
<button type="submit">Submit</button>
</form>
```
### Disable the Tooltip
### Using custom validation
To disable the tooltip, set `tooltip` to `none`.
Set custom validation messages using the `setCustomValidity()` method. Pass an empty string to clear any custom validation errors.
```html {.example}
<wa-slider tooltip="none"></wa-slider>
```
<form action="about:blank" method="get" target="_blank" id="slider__custom-validation">
<wa-slider
name="value"
label="Select a value"
hint="This field will be invalid until custom validation is removed"
></wa-slider>
<br />
<button type="submit">Submit</button>
</form>
### Custom Track Colors
<script type="module">
import { allDefined } from '/dist/webawesome.js';
You can customize the active and inactive portions of the track using the `--track-color-active` and `--track-color-inactive` custom properties.
const form = document.getElementById('slider__custom-validation');
const slider = form.querySelector('wa-slider');
```html {.example}
<wa-slider
style="
--track-color-active: var(--wa-color-brand-fill-loud);
--track-color-inactive: var(--wa-color-brand-fill-normal);
"
></wa-slider>
```
await allDefined();
### Custom Track Offset
You can customize the initial offset of the active track using the `--track-active-offset` custom property.
```html {.example}
<wa-slider
min="-100"
max="100"
style="
--track-color-active: var(--wa-color-brand-fill-loud);
--track-color-inactive: var(--wa-color-brand-fill-normal);
--track-active-offset: 50%;
"
></wa-slider>
```
### Custom Tooltip Formatter
You can change the tooltip's content by setting the `tooltipFormatter` property to a function that accepts the range's value as an argument.
```html {.example}
<wa-slider min="0" max="100" step="1" class="range-with-custom-formatter"></wa-slider>
<script>
const range = document.querySelector('.range-with-custom-formatter');
range.tooltipFormatter = value => `Total - ${value}%`;
slider.setCustomValidity('Not so fast, bubba!');
</script>
```
### Right-to-Left languages
### Styling validation
The component adapts to right-to-left (RTL) languages as you would expect.
Apply custom styles to valid and invalid sliders using the `:valid` and `:invalid` pseudo-classes.
```html {.example}
<wa-slider
dir="rtl"
label="مقدار"
hint="التحكم في مستوى صوت الأغنية الحالية."
style="--track-color-active: var(--wa-color-brand-fill-loud)"
value="10"
></wa-slider>
<form action="about:blank" method="get" target="_blank" class="slider__validation-pseudo">
<wa-slider name="value" label="Select a value" min="0" max="5" value="0" with-markers with-tooltip></wa-slider>
<br />
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</form>
<style>
.slider__validation-pseudo {
wa-slider:valid {
outline: solid 2px var(--wa-color-success-border);
outline-offset: 1rem;
}
wa-slider:invalid {
outline: solid 2px var(--wa-color-danger-border);
outline-offset: 1rem;
}
}
</style>
<script type="module">
import { allDefined } from '/dist/webawesome.js';
const form = document.querySelector('.slider__validation-pseudo');
const slider = form.querySelector('wa-slider');
const validationMessage = 'Select a number greater than zero';
await allDefined();
slider.setCustomValidity(validationMessage);
async function updateValidity() {
await slider.updateComplete;
slider.setCustomValidity(slider.value > 0 ? '' : validationMessage);
}
slider.addEventListener('input', updateValidity);
form.addEventListener('reset', updateValidity);
</script>
```
However, these selectors will match even before the user has had a chance to fill out the form. More often than not, you'll want to use the `user-valid` and `user-invalid` [custom states](#custom-states) instead. This way, validation styles are only shown _after_ the user interacts with the form control or when the form is submitted.
```html {.example}
<form action="about:blank" method="get" target="_blank" class="slider__validation-custom">
<wa-slider name="value" label="Select a value" min="0" max="5" value="0" with-markers with-tooltip></wa-slider>
<br />
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</form>
<style>
.slider__validation-custom {
wa-slider:state(user-valid) {
outline: solid 2px var(--wa-color-success-border);
outline-offset: 1rem;
}
wa-slider:state(user-invalid) {
outline: solid 2px var(--wa-color-danger-border);
outline-offset: 1rem;
}
}
</style>
<script type="module">
import { allDefined } from '/dist/webawesome.js';
const form = document.querySelector('.slider__validation-custom');
const slider = form.querySelector('wa-slider');
const validationMessage = 'Select a number greater than zero';
await allDefined();
slider.setCustomValidity(validationMessage);
async function updateValidity() {
await slider.updateComplete;
slider.setCustomValidity(slider.value > 0 ? '' : validationMessage);
}
slider.addEventListener('input', updateValidity);
form.addEventListener('reset', updateValidity);
</script>
```

View File

@@ -161,7 +161,7 @@
width: var(--marker-width);
height: var(--marker-height);
border-radius: 50%;
background-color: tomato;
background-color: var(--wa-color-surface-default);
}
.marker:first-of-type,

View File

@@ -5,6 +5,7 @@ import { classMap } from 'lit/directives/class-map.js';
import { DraggableElement } from '../../internal/drag.js';
import { clamp } from '../../internal/math.js';
import { HasSlotController } from '../../internal/slot.js';
import { SliderValidator } from '../../internal/validators/slider-validator.js';
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-form-associated-element.js';
import formControlStyles from '../../styles/component/form-control.css';
import { LocalizeController } from '../../utilities/localize.js';
@@ -68,6 +69,10 @@ export default class WaSlider extends WebAwesomeFormAssociatedElement {
static observeSlots = true;
static css = [formControlStyles, styles];
static get validators() {
return [...super.validators, SliderValidator()];
}
private draggableTrack: DraggableElement;
private draggableThumbMin: DraggableElement | null = null;
private draggableThumbMax: DraggableElement | null = null;
@@ -77,10 +82,16 @@ export default class WaSlider extends WebAwesomeFormAssociatedElement {
private valueWhenDraggingStarted: number | undefined;
private activeThumb: 'min' | 'max' | null = null;
private lastTrackPosition: number | null = null; // Track last position for direction detection
protected get focusableAnchor() {
return this.isRange ? this.thumbMin || this.slider : this.slider;
}
/** Override validation target to point to the focusable element */
get validationTarget() {
return this.focusableAnchor;
}
@query('#slider') slider: HTMLElement;
@query('#thumb') thumb: HTMLElement;
@query('#thumb-min') thumbMin: HTMLElement;
@@ -151,6 +162,9 @@ export default class WaSlider extends WebAwesomeFormAssociatedElement {
/** The granularity the value must adhere to when incrementing and decrementing. */
@property({ type: Number }) step: number = 1;
/** Makes the slider a required field. */
@property({ type: Boolean, reflect: true }) required = false;
/** Tells the browser to focus the slider when the page loads or a dialog is shown. */
@property({ type: Boolean }) autofocus: boolean;
@@ -333,8 +347,7 @@ export default class WaSlider extends WebAwesomeFormAssociatedElement {
}
updated(changedProperties: PropertyValues<this>) {
// Always be updating
this.updateValidity();
super.updated(changedProperties);
// Handle range mode changes
if (changedProperties.has('range')) {
@@ -354,7 +367,7 @@ export default class WaSlider extends WebAwesomeFormAssociatedElement {
// Handle value for single thumb mode
if (changedProperties.has('value')) {
this.value = clamp(this.value, this.min, this.max);
this.internals.setFormValue(String(this.value));
this.setValue(String(this.value));
}
}
@@ -414,6 +427,7 @@ export default class WaSlider extends WebAwesomeFormAssociatedElement {
this.isInvalid = false;
this.hadUserInteraction = false;
this.wasSubmitted = false;
super.formResetCallback();
}
/** Clamps a number to min/max while ensuring it's a valid step interval. */
@@ -703,33 +717,10 @@ export default class WaSlider extends WebAwesomeFormAssociatedElement {
const formData = new FormData();
formData.append(this.name || '', String(this.minValue));
formData.append(this.name || '', String(this.maxValue));
this.internals.setFormValue(formData);
this.setValue(formData);
}
}
/** Sets the form control's validity */
private async updateValidity() {
await this.updateComplete;
const validationMessage = this.internals.validity.customError ? this.internals.validationMessage : '';
const hasCustomValidity = validationMessage.length > 0;
const flags: ValidityStateFlags = {
badInput: false,
customError: hasCustomValidity,
patternMismatch: false,
rangeOverflow: false,
rangeUnderflow: false,
stepMismatch: false,
tooLong: false,
tooShort: false,
typeMismatch: false,
valueMissing: false,
};
this.isInvalid = hasCustomValidity;
this.internals.setValidity(flags, validationMessage, this.focusableAnchor);
}
/** Sets focus to the slider. */
public focus() {
if (this.isRange) {

View File

@@ -0,0 +1,123 @@
import type WaSlider from '../../components/slider/slider.js';
import type { Validator } from '../webawesome-form-associated-element.js';
/**
* Comprehensive validator for sliders that handles required, range, and step validation
*/
export const SliderValidator = (): Validator<WaSlider> => {
// Create a native range input to get localized validation messages
const nativeRequiredRange = Object.assign(document.createElement('input'), {
type: 'range',
required: true,
});
return {
observedAttributes: ['required', 'min', 'max', 'step'],
checkValidity(element) {
const validity: ReturnType<Validator['checkValidity']> = {
message: '',
isValid: true,
invalidKeys: [],
};
// Create native range input to get localized validation messages
const createNativeRange = (value: number, min: number, max: number, step: number) => {
const input = document.createElement('input');
input.type = 'range';
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(value);
// Trigger validation
input.checkValidity();
return input.validationMessage;
};
// Check required validation first
if (element.required && !element.hadUserInteraction) {
validity.isValid = false;
validity.invalidKeys.push('valueMissing');
validity.message = nativeRequiredRange.validationMessage || 'Please fill out this field.';
return validity;
}
// For range sliders, validate both values
if (element.isRange) {
const minValue = element.minValue;
const maxValue = element.maxValue;
// Check range underflow for min value
if (minValue < element.min) {
validity.isValid = false;
validity.invalidKeys.push('rangeUnderflow');
validity.message =
createNativeRange(minValue, element.min, element.max, element.step) ||
`Value must be greater than or equal to ${element.min}.`;
return validity;
}
// Check range overflow for max value
if (maxValue > element.max) {
validity.isValid = false;
validity.invalidKeys.push('rangeOverflow');
validity.message =
createNativeRange(maxValue, element.min, element.max, element.step) ||
`Value must be less than or equal to ${element.max}.`;
return validity;
}
// Check step mismatch
if (element.step && element.step !== 1) {
const minStepMismatch = (minValue - element.min) % element.step !== 0;
const maxStepMismatch = (maxValue - element.min) % element.step !== 0;
if (minStepMismatch || maxStepMismatch) {
validity.isValid = false;
validity.invalidKeys.push('stepMismatch');
const testValue = minStepMismatch ? minValue : maxValue;
validity.message =
createNativeRange(testValue, element.min, element.max, element.step) ||
`Value must be a multiple of ${element.step}.`;
return validity;
}
}
} else {
// Single value validation
const value = element.value;
// Check range underflow
if (value < element.min) {
validity.isValid = false;
validity.invalidKeys.push('rangeUnderflow');
validity.message =
createNativeRange(value, element.min, element.max, element.step) ||
`Value must be greater than or equal to ${element.min}.`;
return validity;
}
// Check range overflow
if (value > element.max) {
validity.isValid = false;
validity.invalidKeys.push('rangeOverflow');
validity.message =
createNativeRange(value, element.min, element.max, element.step) ||
`Value must be less than or equal to ${element.max}.`;
return validity;
}
// Check step mismatch
if (element.step && element.step !== 1 && (value - element.min) % element.step !== 0) {
validity.isValid = false;
validity.invalidKeys.push('stepMismatch');
validity.message =
createNativeRange(value, element.min, element.max, element.step) ||
`Value must be a multiple of ${element.step}.`;
return validity;
}
}
return validity;
},
};
};