Merge branch 'next' into dropdown-rework

This commit is contained in:
Cory LaViska
2025-06-06 13:27:38 -04:00
16 changed files with 1639 additions and 543 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,233 @@ 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;
```
### Vertical Sliders
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
tooltip-placement="right"
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>
```
### Size
Control the slider's size using the `size` attribute. Valid options include `small`, `medium`, and `large`.
```html {.example}
<wa-slider size="small" value="50" label="Small"></wa-slider><br />
<wa-slider size="medium" value="50" label="Medium"></wa-slider><br />
<wa-slider size="large" value="50" label="Large"></wa-slider>
```
### Indicator 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,74 +273,17 @@ 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>
```
### Disable the Tooltip
To disable the tooltip, set `tooltip` to `none`.
```html {.example}
<wa-slider tooltip="none"></wa-slider>
```
### Custom Track Colors
You can customize the active and inactive portions of the track using the `--track-color-active` and `--track-color-inactive` custom properties.
```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>
```
### 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}%`;
</script>
```
### Right-to-Left languages
The component adapts to right-to-left (RTL) languages as you would expect.
```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" 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>
```

View File

@@ -38,6 +38,16 @@ During the alpha period, things might break! We take breaking changes very serio
- Added convenience tokens for `--wa-font-size-smaller` and `--wa-font-size-larger`
- Updated components to use relative `em` values for internal padding and margin wherever appropriate
- 🚨 BREAKING: removed the `hint` property and slot from `<wa-radio>`; please apply hints directly to `<wa-radio-group>` instead
- 🚨 BREAKING: redesigned `<wa-slider>` with extensive new functionality
- Added support for range sliders with dual thumbs using the `range` attribute
- Added vertical orientation support with `orientation="vertical"`
- Added visual markers at each step with `with-markers`
- Added contextual reference labels with `with-references` and the `reference` slot
- Added tooltips showing current values with `with-tooltip`
- Added customizable indicator offset with `indicator-offset` attribute
- Added value formatting support with the `valueFormatter` property
- Improved the styling API to be consistent and more powerful (no more browser-specific selectors and pseudo elements to style)
- Updated to use consistent `with-*` attribute naming pattern
- 🚨 BREAKING: removed `<wa-icon-button>`; use `<wa-button><wa-icon name="..." label="..."></wa-icon></wa-button>` instead
- 🚨 BREAKING: completely reworked `<wa-dropdown>` to be easier to use
- Added `<wa-dropdown-item>`, greatly simplifying the dropdown's markup structure

View File

@@ -5,6 +5,7 @@ import { ifDefined } from 'lit/directives/if-defined.js';
import { live } from 'lit/directives/live.js';
import { WaClearEvent } from '../../events/clear.js';
import { HasSlotController } from '../../internal/slot.js';
import { submitOnEnter } from '../../internal/submit-on-enter.js';
import { MirrorValidator } from '../../internal/validators/mirror-validator.js';
import { watch } from '../../internal/watch.js';
import { WebAwesomeFormAssociatedElement } from '../../internal/webawesome-form-associated-element.js';
@@ -12,7 +13,6 @@ import formControlStyles from '../../styles/component/form-control.css';
import appearanceStyles from '../../styles/utilities/appearance.css';
import sizeStyles from '../../styles/utilities/size.css';
import { LocalizeController } from '../../utilities/localize.js';
import type WaButton from '../button/button.js';
import '../icon/icon.js';
import styles from './input.css';
@@ -245,51 +245,7 @@ export default class WaInput extends WebAwesomeFormAssociatedElement {
}
private handleKeyDown(event: KeyboardEvent) {
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
// Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before
// submitting to allow users to cancel the keydown event if they need to
if (event.key === 'Enter' && !hasModifier) {
setTimeout(() => {
//
// When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
// to check for this is to look at event.isComposing, which will be true when the IME is open.
//
// See https://github.com/shoelace-style/shoelace/pull/988
//
if (!event.defaultPrevented && !event.isComposing) {
const form = this.getForm();
if (!form) {
return;
}
const formElements = [...form.elements];
// If we're the only formElement, we submit like a native input.
if (formElements.length === 1) {
form.requestSubmit(null);
return;
}
const button = formElements.find(
(el: HTMLButtonElement) => el.type === 'submit' && !el.matches(':disabled'),
) as undefined | HTMLButtonElement | WaButton;
// No button found, don't submit.
if (!button) {
return;
}
if (button.tagName.toLowerCase() === 'button') {
form.requestSubmit(button);
} else {
// requestSubmit() wont work with `<wa-button>`
button.click();
}
}
});
}
submitOnEnter(event, this);
}
private handlePasswordToggle() {

View File

@@ -1,214 +1,227 @@
:host {
--thumb-color: var(--wa-form-control-activated-color);
--thumb-gap: calc(var(--thumb-size) * 0.125);
--thumb-shadow: initial;
--thumb-size: calc(1em * var(--wa-form-control-value-line-height));
--track-color-active: var(--wa-color-neutral-fill-normal);
--track-color-inactive: var(--wa-color-neutral-fill-normal);
--track-active-offset: 0%;
--track-height: calc(var(--thumb-size) * 0.25);
--tooltip-offset: calc(var(--wa-tooltip-arrow-size) * 1.375);
display: flex;
flex-direction: column;
position: relative;
min-height: max(var(--thumb-size), var(--track-height));
--track-size: 0.5em;
--thumb-width: 1.4em;
--thumb-height: 1.4em;
--marker-width: 0.1875em;
--marker-height: 0.1875em;
}
input[type='range'] {
--percent: 0%;
-webkit-appearance: none;
border-radius: calc(var(--track-height) / 2);
width: 100%;
height: var(--track-height);
font-size: inherit;
line-height: var(--wa-form-control-height);
vertical-align: middle;
margin: 0;
--dir: right;
:host([orientation='vertical']) {
width: auto;
}
background-image: linear-gradient(
to var(--dir),
var(--track-color-inactive) min(var(--percent), var(--track-active-offset)),
var(--track-color-active) min(var(--percent), var(--track-active-offset)),
var(--track-color-active) max(var(--percent), var(--track-active-offset)),
var(--track-color-inactive) max(var(--percent), var(--track-active-offset))
);
#label:has(~ .vertical) {
display: block;
order: 2;
max-width: none;
text-align: center;
}
#description:has(~ .vertical) {
order: 3;
text-align: center;
}
/* Add extra space between slider and label, when present */
#label:has(*:not(:empty)) ~ #slider {
&.horizontal {
margin-block-start: 0.5em;
}
&.vertical {
margin-block-end: 0.5em;
}
}
#slider {
&:focus {
outline: none;
}
&:focus-visible:not(.disabled) #thumb,
&:focus-visible:not(.disabled) #thumb-min,
&:focus-visible:not(.disabled) #thumb-max {
outline: var(--wa-focus-ring);
/* intentionally no offset due to border */
}
}
#track {
position: relative;
border-radius: 9999px;
background: var(--wa-color-neutral-fill-normal);
isolation: isolate;
}
/* Orientation */
.horizontal #track {
height: var(--track-size);
}
.vertical #track {
order: 1;
width: var(--track-size);
height: 200px;
}
/* Disabled */
.disabled #track {
cursor: not-allowed;
opacity: 0.5;
}
/* Indicator */
#indicator {
position: absolute;
border-radius: inherit;
background-color: var(--wa-form-control-activated-color);
&:dir(ltr) {
right: calc(100% - max(var(--start), var(--end)));
left: min(var(--start), var(--end));
}
&:dir(rtl) {
--dir: left;
}
&::-webkit-slider-runnable-track {
width: 100%;
height: var(--track-height);
border-radius: 3px;
border: none;
}
&::-webkit-slider-thumb {
width: var(--thumb-size);
height: var(--thumb-size);
border-radius: 50%;
background-color: var(--thumb-color);
box-shadow:
var(--thumb-shadow, 0 0 transparent),
0 0 0 var(--thumb-gap) var(--wa-color-surface-default);
-webkit-appearance: none;
margin-top: calc(var(--thumb-size) / -2 + var(--track-height) / 2);
transition: var(--wa-transition-fast);
transition-property: width, height;
}
&:enabled {
&:focus-visible::-webkit-slider-thumb {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
&::-webkit-slider-thumb {
cursor: pointer;
}
&::-webkit-slider-thumb:active {
cursor: grabbing;
}
}
&::-moz-focus-outer {
border: 0;
}
&::-moz-range-progress {
background-color: var(--track-color-active);
border-radius: 3px;
height: var(--track-height);
}
&::-moz-range-track {
width: 100%;
height: var(--track-height);
background-color: var(--track-color-inactive);
border-radius: 3px;
border: none;
}
&::-moz-range-thumb {
height: var(--thumb-size);
width: var(--thumb-size);
border-radius: 50%;
background-color: var(--thumb-color);
box-shadow:
var(--thumb-shadow, 0 0 transparent),
0 0 0 var(--thumb-gap) var(--wa-color-surface-default);
transition-property: background-color, border-color, box-shadow, color;
transition-duration: var(--wa-transition-normal);
transition-timing-function: var(--wa-transition-easing);
}
&:enabled {
&:focus-visible::-moz-range-thumb {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
&::-moz-range-thumb {
cursor: pointer;
}
&::-moz-range-thumb:active {
cursor: grabbing;
}
right: min(var(--start), var(--end));
left: calc(100% - max(var(--start), var(--end)));
}
}
/* States */
/* nesting these styles yields broken results in Safari */
input[type='range']:focus {
outline: none;
.horizontal #indicator {
top: 0;
height: 100%;
}
:host :has(:disabled) input[type='range'] {
opacity: 0.5;
cursor: not-allowed;
&::-moz-range-thumb,
&::-webkit-slider-thumb {
cursor: not-allowed;
}
.vertical #indicator {
top: calc(100% - var(--end));
bottom: var(--start);
left: 0;
width: 100%;
}
/* Tooltip output */
.tooltip {
/* Thumbs */
#thumb,
#thumb-min,
#thumb-max {
z-index: 3;
position: absolute;
z-index: 1000;
inset-inline-start: 0;
width: var(--thumb-width);
height: var(--thumb-height);
border: solid 0.125em var(--wa-color-surface-default);
border-radius: 50%;
background-color: var(--wa-form-control-activated-color);
cursor: pointer;
}
inset-block-end: calc(50% + (var(--thumb-size) / 2) + var(--tooltip-offset));
border-radius: var(--wa-tooltip-border-radius);
background-color: var(--wa-tooltip-background-color);
font-family: inherit;
font-size: var(--wa-tooltip-font-size);
line-height: var(--wa-tooltip-line-height);
color: var(--wa-tooltip-content-color);
opacity: 0;
padding: 0.25em 0.5em;
transition: var(--wa-transition-normal) opacity;
.disabled #thumb,
.disabled #thumb-min,
.disabled #thumb-max {
cursor: inherit;
}
.horizontal #thumb,
.horizontal #thumb-min,
.horizontal #thumb-max {
top: calc(50% - var(--thumb-height) / 2);
&:dir(ltr) {
right: auto;
left: calc(var(--position) - var(--thumb-width) / 2);
}
&:dir(rtl) {
right: calc(var(--position) - var(--thumb-width) / 2);
left: auto;
}
}
.vertical #thumb,
.vertical #thumb-min,
.vertical #thumb-max {
bottom: calc(var(--position) - var(--thumb-height) / 2);
left: calc(50% - var(--thumb-width) / 2);
}
/* Range-specific thumb styles */
:host([range]) {
#thumb-min:focus-visible,
#thumb-max:focus-visible {
z-index: 4; /* Ensure focused thumb appears on top */
outline: var(--wa-focus-ring);
/* intentionally no offset due to border */
}
}
/* Markers */
#markers {
pointer-events: none;
}
&::after {
content: '';
position: absolute;
width: 0;
height: 0;
inset-inline-start: 50%;
inset-block-start: 100%;
translate: calc(-1 * var(--wa-tooltip-arrow-size));
border-inline: var(--wa-tooltip-arrow-size) solid transparent;
border-block-start: var(--border-block);
.marker {
z-index: 2;
position: absolute;
width: var(--marker-width);
height: var(--marker-height);
border-radius: 50%;
background-color: var(--wa-color-surface-default);
}
.marker:first-of-type,
.marker:last-of-type {
display: none;
}
.horizontal .marker {
top: calc(50% - var(--marker-height) / 2);
left: calc(var(--position) - var(--marker-width) / 2);
}
.vertical .marker {
top: calc(var(--position) - var(--marker-height) / 2);
left: calc(50% - var(--marker-width) / 2);
}
/* Marker labels */
#references {
position: relative;
slot {
display: flex;
justify-content: space-between;
height: 100%;
}
&:dir(rtl)::after {
translate: var(--wa-tooltip-arrow-size);
::slotted(*) {
color: var(--wa-color-text-quiet);
font-size: 0.875em;
line-height: 1;
}
}
.horizontal {
#references {
margin-block-start: 0.5em;
}
}
.vertical {
display: flex;
margin-inline: auto;
#track {
order: 1;
}
&.visible {
opacity: 1;
}
#references {
order: 2;
width: min-content;
margin-inline-start: 0.75em;
--inset-block: calc(50% + (var(--thumb-size) / 2) + var(--tooltip-offset));
--border-block: var(--wa-tooltip-arrow-size) solid var(--wa-tooltip-background-color);
@media (forced-colors: active) {
border: solid 1px transparent;
&::after {
display: none;
slot {
flex-direction: column;
}
}
}
/* RTL tooltip positioning */
:host(:dir(rtl)) .tooltip {
inset-inline-start: auto;
inset-inline-end: 0;
}
/* Tooltip on bottom */
:host([tooltip='bottom']) .tooltip {
inset-block-end: auto;
inset-block-start: calc(50% + (var(--thumb-size) / 2) + var(--tooltip-offset));
&::after {
border-block-end: var(--border-block);
inset-block-start: auto;
inset-block-end: 100%;
}
}
/* Bottom tooltip RTL fix */
:host([tooltip='bottom']:dir(rtl)) .tooltip {
inset-inline-start: auto;
inset-inline-end: 0;
.vertical #references slot {
flex-direction: column;
}

View File

@@ -21,9 +21,8 @@ describe('<wa-slider>', () => {
it('default properties', async () => {
const el = await fixture<WaSlider>(html` <wa-slider></wa-slider> `);
expect(el.name).to.equal('');
expect(el.name).to.equal(null);
expect(el.value).to.equal(0);
expect(el.title).to.equal('');
expect(el.label).to.equal('');
expect(el.hint).to.equal('');
expect(el.disabled).to.be.false;
@@ -31,22 +30,16 @@ describe('<wa-slider>', () => {
expect(el.min).to.equal(0);
expect(el.max).to.equal(100);
expect(el.step).to.equal(1);
expect(el.tooltip).to.equal('top');
expect(el.tooltipPlacement).to.equal('top');
expect(el.defaultValue).to.equal(0);
});
it('should have title if title attribute is set', async () => {
const el = await fixture<WaSlider>(html` <wa-slider title="Test"></wa-slider> `);
const input = el.shadowRoot!.querySelector('input')!;
expect(input.title).to.equal('Test');
});
it('should be disabled with the disabled attribute', async () => {
const el = await fixture<WaSlider>(html` <wa-slider disabled></wa-slider> `);
const input = el.shadowRoot!.querySelector<HTMLInputElement>('.control')!;
const input = el.shadowRoot!.querySelector<HTMLElement>("[role='slider']")!;
expect(input.disabled).to.be.true;
expect(el.matches(':disabled')).to.be.true;
expect(input.getAttribute('aria-disabled')).to.equal('true');
});
describe('when the value changes', () => {

File diff suppressed because it is too large Load Diff

View File

@@ -43,3 +43,132 @@ export function drag(container: HTMLElement, options?: Partial<DragOptions>) {
move(options.initialEvent);
}
}
const supportsTouch = typeof window !== 'undefined' && 'ontouchstart' in window;
/**
* Attaches the necessary events to make an element draggable.
*
* This by itself will not make the element draggable, but it provides the events and callbacks necessary to facilitate
* dragging. Use the `clientX` and `clientY` arguments of each callback to update the UI as desired when dragging.
*
* Drag functionality will be enabled as soon as the constructor is called. A `start()` and `stop()` method can be used
* to start and stop it, if needed.
*
* @usage
*
* const draggable = new DraggableElement(element, {
* start: (clientX, clientY) => { ... },
* move: (clientX, clientY) => { ... },
* stop: (clientX, clientY) => { ... }
* });
*/
export class DraggableElement {
private element: Element;
private isActive = false;
private isDragging = false;
private options: DraggableElementOptions;
constructor(el: Element, options: Partial<DraggableElementOptions>) {
this.element = el;
this.options = {
start: () => undefined,
stop: () => undefined,
move: () => undefined,
...options,
};
this.start();
}
private handleDragStart = (event: PointerEvent | TouchEvent) => {
const clientX = supportsTouch && 'touches' in event ? event.touches[0].clientX : (event as PointerEvent).clientX;
const clientY = supportsTouch && 'touches' in event ? event.touches[0].clientY : (event as PointerEvent).clientY;
// Prevent scrolling while dragging
event.preventDefault();
if (
this.isDragging ||
// Prevent right-clicks from triggering drags
(!supportsTouch && (event as PointerEvent).buttons > 1)
) {
return;
}
this.isDragging = true;
document.addEventListener('pointerup', this.handleDragStop);
document.addEventListener('pointermove', this.handleDragMove);
document.addEventListener('touchend', this.handleDragStop);
document.addEventListener('touchmove', this.handleDragMove);
this.options.start(clientX, clientY);
};
private handleDragStop = (event: PointerEvent | TouchEvent) => {
const clientX = supportsTouch && 'touches' in event ? event.touches[0].clientX : (event as PointerEvent).clientX;
const clientY = supportsTouch && 'touches' in event ? event.touches[0].clientY : (event as PointerEvent).clientY;
this.isDragging = false;
document.removeEventListener('pointerup', this.handleDragStop);
document.removeEventListener('pointermove', this.handleDragMove);
document.removeEventListener('touchend', this.handleDragStop);
document.removeEventListener('touchmove', this.handleDragMove);
this.options.stop(clientX, clientY);
};
private handleDragMove = (event: PointerEvent | TouchEvent) => {
const clientX = supportsTouch && 'touches' in event ? event.touches[0].clientX : (event as PointerEvent).clientX;
const clientY = supportsTouch && 'touches' in event ? event.touches[0].clientY : (event as PointerEvent).clientY;
// Prevent text selection while dragging
window.getSelection()?.removeAllRanges();
this.options.move(clientX, clientY);
};
/** Start listening to drags. */
public start() {
if (!this.isActive) {
this.element.addEventListener('pointerdown', this.handleDragStart);
if (supportsTouch) {
this.element.addEventListener('touchstart', this.handleDragStart);
}
this.isActive = true;
}
}
/** Stop listening to drags. */
public stop() {
document.removeEventListener('pointerup', this.handleDragStop);
document.removeEventListener('pointermove', this.handleDragMove);
document.removeEventListener('touchend', this.handleDragStop);
document.removeEventListener('touchmove', this.handleDragMove);
this.element.removeEventListener('pointerdown', this.handleDragStart);
if (supportsTouch) {
this.element.removeEventListener('touchstart', this.handleDragStart);
}
this.isActive = false;
this.isDragging = false;
}
/** Starts or stops the drag listeners. */
public toggle(isActive?: boolean) {
const isGoingToBeActive = isActive !== undefined ? isActive : !this.isActive;
if (isGoingToBeActive) {
this.start();
} else {
this.stop();
}
}
}
export interface DraggableElementOptions {
/** Runs when dragging starts. */
start: (clientX: number, clientY: number) => void;
/** Runs as the user is dragging. This may execute often, so avoid expensive operations. */
move: (clientX: number, clientY: number) => void;
/** Runs when dragging ends. */
stop: (clientX: number, clientY: number) => void;
}

View File

@@ -0,0 +1,64 @@
import type WaButton from '../components/button/button.js';
import type { WebAwesomeFormAssociatedElement } from './webawesome-form-associated-element.js';
export function submitOnEnter<T extends HTMLElement>(event: KeyboardEvent, el: T) {
const hasModifier = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
// Pressing enter when focused on an input should submit the form like a native input, but we wait a tick before
// submitting to allow users to cancel the keydown event if they need to
if (event.key === 'Enter' && !hasModifier) {
// setTimeout in case the event is caught higher up in the tree and defaultPrevented
setTimeout(() => {
//
// When using an Input Method Editor (IME), pressing enter will cause the form to submit unexpectedly. One way
// to check for this is to look at event.isComposing, which will be true when the IME is open.
//
// See https://github.com/shoelace-style/shoelace/pull/988
//
if (!event.defaultPrevented && !event.isComposing) {
submitForm(el);
}
});
}
}
export function submitForm(el: HTMLElement | WebAwesomeFormAssociatedElement) {
let form: HTMLFormElement | null = null;
if ('form' in el) {
form = el.form as HTMLFormElement | null;
}
if (!form && 'getForm' in el) {
form = el.getForm();
}
if (!form) {
return;
}
const formElements = [...form.elements];
// If we're the only formElement, we submit like a native input.
if (formElements.length === 1) {
form.requestSubmit(null);
return;
}
const button = formElements.find((el: HTMLButtonElement) => el.type === 'submit' && !el.matches(':disabled')) as
| undefined
| HTMLButtonElement
| WaButton;
// No button found, don't submit.
if (!button) {
return;
}
if (['input', 'button'].includes(button.localName)) {
form.requestSubmit(button);
} else {
// requestSubmit() wont work with `<wa-button>`, so trigger a manual click.
button.click();
}
}

View File

@@ -162,6 +162,7 @@ function runAllValidityTests(
const form = await fixture(html`<form id="${formId}"></form>`);
const control = await createControl();
expect(control.getForm()).to.equal(null);
// control.setAttribute("form", 'test-form');
control.form = 'test-form';
await control.updateComplete;
expect(control.getForm()).to.equal(form);

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.hasInteracted) {
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;
},
};
};

View File

@@ -48,10 +48,10 @@
--box-shadow: inset var(--wa-shadow-s);
}
}
input[type='range'],
wa-slider,
wa-switch {
--thumb-shadow: var(--wa-theme-active-shadow-pop-out);
wa-slider::part(thumb),
wa-switch::part(thumb) {
box-shadow: var(--wa-theme-active-shadow-pop-out);
}
wa-progress-bar {

View File

@@ -45,7 +45,8 @@
wa-carousel::part(pagination-item),
wa-comparison::part(handle),
wa-progress-bar::part(base),
wa-slider::part(base),
wa-slider::part(track),
wa-slider::part(thumb),
input[type='range'],
wa-switch::part(control),
wa-switch::part(thumb) {

View File

@@ -97,10 +97,9 @@
}
}
input[type='range'],
wa-slider,
wa-switch {
--thumb-shadow:
wa-slider::part(thumb),
wa-switch::part(thumb) {
box-shadow:
var(--wa-theme-glossy-inner-shine), var(--wa-theme-glossy-top-highlight), var(--wa-theme-glossy-bottom-shadow);
}

View File

@@ -206,9 +206,8 @@
}
@media (hover: hover) {
input[type='range']:hover,
wa-slider:hover {
--thumb-shadow: 0 0 0 0.5em color-mix(in oklab, var(--thumb-color), transparent 85%);
wa-slider:hover::part(thumb) {
box-shadow: 0 0 0 0.5em color-mix(in oklab, var(--wa-form-control-activated-color), transparent 85%);
}
}

View File

@@ -110,23 +110,22 @@
);
}
input[type='range'],
wa-progress-bar,
wa-slider {
--shadow-lower: inset 0 -0.125em 0.5em
oklab(from var(--indicator-color, var(--wa-form-control-activated-color)) calc(l - 0.2) a b);
--shadow-upper: inset 0 0.125em 0.5em
oklab(from var(--indicator-color, var(--wa-form-control-activated-color)) calc(l + 0.4) a b);
--thumb-shadow: var(--wa-shadow-s), var(--shadow-lower), var(--shadow-upper);
&::part(indicator) {
box-shadow: var(--shadow-lower), var(--shadow-upper);
}
--shadow-lower: inset 0 -0.125em 0.5em oklab(from var(--wa-form-control-activated-color) calc(l - 0.2) a b);
--shadow-upper: inset 0 0.125em 0.5em oklab(from var(--wa-form-control-activated-color) calc(l + 0.4) a b);
}
wa-switch[checked] {
--thumb-shadow: var(--wa-shadow-s);
wa-slider::part(thumb) {
box-shadow: var(--wa-shadow-s), var(--shadow-lower), var(--shadow-upper);
}
wa-progress-bar::part(indicator) {
box-shadow: var(--shadow-lower), var(--shadow-upper);
}
wa-switch[checked]::part(thumb) {
box-shadow: var(--wa-shadow-s);
}
}
}

View File

@@ -94,9 +94,8 @@
--checked-icon-scale: 0.4;
}
input[type='range'],
wa-slider {
--thumb-gap: 0;
wa-slider::part(thumb) {
border: none;
}
wa-switch {