From d21d829c299be9e6b66b14d2e6c089e6ae7b349d Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Fri, 6 Jun 2025 10:38:18 -0400 Subject: [PATCH] Slider rework (#1034) * initial rework * improvements * add size styles * update changelog * update changelog * fix hint aria * whitespace * slider test fixes (#1036) * prettier * More slider fixes 2 (#1037) * more fixes * more fixes * prettier * fix theme slider styles * only add extra space around slider when label is present --------- Co-authored-by: Konnor Rogers Co-authored-by: lindsaym-fa --- .../webawesome/docs/docs/components/slider.md | 315 +++-- .../docs/docs/resources/changelog.md | 10 + .../webawesome/src/components/input/input.ts | 48 +- .../src/components/slider/slider.css | 387 +++--- .../src/components/slider/slider.test.ts | 17 +- .../src/components/slider/slider.ts | 1035 +++++++++++++---- packages/webawesome/src/internal/drag.ts | 129 ++ .../src/internal/submit-on-enter.ts | 64 + .../internal/test/form-control-base-tests.ts | 1 + .../internal/validators/slider-validator.ts | 123 ++ .../src/styles/themes/active/dimension.css | 8 +- .../src/styles/themes/brutalist/overrides.css | 3 +- .../src/styles/themes/glossy/dimension.css | 7 +- .../src/styles/themes/matter/overrides.css | 5 +- .../src/styles/themes/playful/dimension.css | 25 +- .../src/styles/themes/shoelace/overrides.css | 5 +- 16 files changed, 1639 insertions(+), 543 deletions(-) create mode 100644 packages/webawesome/src/internal/submit-on-enter.ts create mode 100644 packages/webawesome/src/internal/validators/slider-validator.ts diff --git a/packages/webawesome/docs/docs/components/slider.md b/packages/webawesome/docs/docs/components/slider.md index 9400458b0..f4f7cda6d 100644 --- a/packages/webawesome/docs/docs/components/slider.md +++ b/packages/webawesome/docs/docs/components/slider.md @@ -7,7 +7,20 @@ icon: slider --- ```html {.example} - + + Less + More + ``` :::info @@ -18,7 +31,7 @@ This component works with standard `
` 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} @@ -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} ``` -### 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} - + +``` + +### 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} + +``` + +### 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} + +``` + +### 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} + + Slow + Medium + Fast + +``` + +:::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} + +
+ + + + +
+ + + + + + + +``` + +### 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} + + $0 + $50 + $100 + + + +``` + +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} +
+ + + + + +
+``` + +Range sliders can also be vertical. + +```html {.example} +
+ + +
+ + +``` + +### Size + +Control the slider's size using the `size` attribute. Valid options include `small`, `medium`, and `large`. + +```html {.example} +
+
+ +``` + +### 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} + + Lazy + Zoomies + ``` ### 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} - + ``` -### 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} - -``` - -### Disable the Tooltip - -To disable the tooltip, set `tooltip` to `none`. - -```html {.example} - -``` - -### 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} - -``` - -### Custom Track Offset - -You can customize the initial offset of the active track using the `--track-active-offset` custom property. - -```html {.example} - -``` - -### 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} - - - -``` - -### Right-to-Left languages - -The component adapts to right-to-left (RTL) languages as you would expect. - -```html {.example} - -``` + + +
+ +
+``` \ No newline at end of file diff --git a/packages/webawesome/docs/docs/resources/changelog.md b/packages/webawesome/docs/docs/resources/changelog.md index 77ce2f1f0..12641b9dc 100644 --- a/packages/webawesome/docs/docs/resources/changelog.md +++ b/packages/webawesome/docs/docs/resources/changelog.md @@ -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 ``; please apply hints directly to `` instead +- 🚨 BREAKING: redesigned `` 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 ``; use `` instead - Added a new free component: `` (#2 of 14 per stretch goals) - Added a new free component: `` (#3 of 14 per stretch goals) diff --git a/packages/webawesome/src/components/input/input.ts b/packages/webawesome/src/components/input/input.ts index 5ffe73c8f..e615f0e8d 100644 --- a/packages/webawesome/src/components/input/input.ts +++ b/packages/webawesome/src/components/input/input.ts @@ -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 `` - button.click(); - } - } - }); - } + submitOnEnter(event, this); } private handlePasswordToggle() { diff --git a/packages/webawesome/src/components/slider/slider.css b/packages/webawesome/src/components/slider/slider.css index 13eee9922..f0ee323ac 100644 --- a/packages/webawesome/src/components/slider/slider.css +++ b/packages/webawesome/src/components/slider/slider.css @@ -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; } diff --git a/packages/webawesome/src/components/slider/slider.test.ts b/packages/webawesome/src/components/slider/slider.test.ts index 99c92c9c2..3594c26e3 100644 --- a/packages/webawesome/src/components/slider/slider.test.ts +++ b/packages/webawesome/src/components/slider/slider.test.ts @@ -21,9 +21,8 @@ describe('', () => { it('default properties', async () => { const el = await fixture(html` `); - 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('', () => { 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(html` `); - 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(html` `); - const input = el.shadowRoot!.querySelector('.control')!; + const input = el.shadowRoot!.querySelector("[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', () => { diff --git a/packages/webawesome/src/components/slider/slider.ts b/packages/webawesome/src/components/slider/slider.ts index 00a724fe2..b0cc5dc4b 100644 --- a/packages/webawesome/src/components/slider/slider.ts +++ b/packages/webawesome/src/components/slider/slider.ts @@ -1,24 +1,34 @@ +import type { PropertyValues } from 'lit'; import { html } from 'lit'; -import { customElement, eventOptions, property, query, state } from 'lit/decorators.js'; +import { customElement, property, query, queryAll, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; -import { live } from 'lit/directives/live.js'; +import { DraggableElement } from '../../internal/drag.js'; +import { clamp } from '../../internal/math.js'; import { HasSlotController } from '../../internal/slot.js'; -import { MirrorValidator } from '../../internal/validators/mirror-validator.js'; -import { watch } from '../../internal/watch.js'; +import { submitOnEnter } from '../../internal/submit-on-enter.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 sizeStyles from '../../styles/utilities/size.css'; import { LocalizeController } from '../../utilities/localize.js'; +import '../tooltip/tooltip.js'; +import type WaTooltip from '../tooltip/tooltip.js'; import styles from './slider.css'; /** + * + * * @summary Ranges allow the user to select a single value within a given range using a slider. * @documentation https://backers.webawesome.com/docs/components/range * @status stable * @since 2.0 * + * @dependency wa-tooltip + * * @slot label - The slider label. Alternatively, you can use the `label` attribute. * @slot hint - Text that describes how to use the input. Alternatively, you can use the `hint` attribute. + * instead. + * @slot reference - One or more reference labels to show visually below the slider. * * @event blur - Emitted when the control loses focus. * @event change - Emitted when an alteration to the control's value is committed by the user. @@ -26,61 +36,107 @@ import styles from './slider.css'; * @event input - Emitted when the control receives input. * @event wa-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied. * - * @csspart form-control - The form control that wraps the label, input, and hint. - * @csspart form-control-label - The input's label. - * @csspart form-control-input - The input's wrapper. - * @csspart hint - The hint's wrapper. - * @csspart base - The internal `` element. - * @csspart tooltip - The slider tooltip. + * @csspart label - The element that contains the sliders's label. + * @csspart hint - The element that contains the slider's description. + * @csspart slider - The focusable element with `role="slider"`. Contains the track and reference slot. + * @csspart track - The slider's track. + * @csspart indicator - The colored indicator that shows from the start of the slider to the current value. + * @csspart markers - The container that holds all the markers when `with-markers` is used. + * @csspart marker - The individual markers that are shown when `with-markers` is used. + * @csspart references - The container that holds references that get slotted in. + * @csspart thumb - The slider's thumb. + * @csspart thumb-min - The min value thumb in a range slider. + * @csspart thumb-max - The max value thumb in a range slider. + * @csspart tooltip - The tooltip, a `` element. + * @csspart tooltip__tooltip - The tooltip's `tooltip` part. + * @csspart tooltip__content - The tooltip's `content` part. + * @csspart tooltip__arrow - The tooltip's `arrow` part. * - * @cssproperty --thumb-color - The color of the thumb. - * @cssproperty --thumb-gap - The visual gap between the edges of the thumb and the track. - * @cssproperty --thumb-shadow - The shadow effects around the edges of the thumb. - * @cssproperty --thumb-size - The size of the thumb. - * @cssproperty --tooltip-offset - The vertical distance the tooltip is offset from the thumb. - * @cssproperty --track-color-active - The color of the portion of the track that represents the current value. - * @cssproperty --track-color-inactive - The of the portion of the track that represents the remaining value. - * @cssproperty --track-height - The height of the track. - * @cssproperty --track-active-offset - The point of origin of the active track. + * @cssstate disabled - Applied when the slider is disabled. + * @cssstate dragging - Applied when the slider is being dragged. + * @cssstate focused - Applied when the slider has focus. + * @cssstate user-valid - Applied when the slider is valid and the user has sufficiently interacted with it. + * @cssstate user-invalid - Applied when the slider is invalid and the user has sufficiently interacted with it. + * + * @cssproperty [--track-size=0.75em] - The height or width of the slider's track. + * @cssproperty [--marker-width=0.1875em] - The width of each individual marker. + * @cssproperty [--marker-height=0.1875em] - The height of each individual marker. + * @cssproperty [--thumb-width=1.25em] - The width of the thumb. + * @cssproperty [--thumb-height=1.25em] - The height of the thumb. */ @customElement('wa-slider') export default class WaSlider extends WebAwesomeFormAssociatedElement { - static css = [formControlStyles, styles]; + static formAssociated = true; + static observeSlots = true; + static css = [sizeStyles, formControlStyles, styles]; static get validators() { - return [...super.validators, MirrorValidator()]; + return [...super.validators, SliderValidator()]; } + private draggableTrack: DraggableElement; + private draggableThumbMin: DraggableElement | null = null; + private draggableThumbMax: DraggableElement | null = null; private readonly hasSlotController = new HasSlotController(this, 'hint', 'label'); private readonly localize = new LocalizeController(this); - private resizeObserver: ResizeObserver; + private trackBoundingClientRect: DOMRect; + private valueWhenDraggingStarted: number | undefined | null; + private activeThumb: 'min' | 'max' | null = null; + private lastTrackPosition: number | null = null; // Track last position for direction detection - @query('.control') input: HTMLInputElement; - @query('.tooltip') output: HTMLOutputElement | null; + protected get focusableAnchor() { + return this.isRange ? this.thumbMin || this.slider : this.slider; + } - @state() private hasTooltip = false; - @property() title = ''; // make reactive to pass through + /** Override validation target to point to the focusable element */ + get validationTarget() { + return this.focusableAnchor; + } - /** The name of the slider, submitted as a name/value pair with form data. */ - @property() name: string = ''; + @query('#slider') slider: HTMLElement; + @query('#thumb') thumb: HTMLElement; + @query('#thumb-min') thumbMin: HTMLElement; + @query('#thumb-max') thumbMax: HTMLElement; + @query('#track') track: HTMLElement; + @query('#tooltip') tooltip: WaTooltip; + @queryAll('wa-tooltip') tooltips: NodeListOf; + + /** + * The slider's label. If you need to provide HTML in the label, use the `label` slot instead. + */ + @property() label: string = ''; + + /** The slider hint. If you need to display HTML, use the hint slot instead. */ + @property({ attribute: 'hint' }) hint = ''; + + /** The name of the slider. This will be submitted with the form as a name/value pair. */ + @property({ reflect: true }) name: string; + + /** The minimum value of a range selection. Used only when range attribute is set. */ + @property({ type: Number, attribute: 'min-value' }) minValue = 0; + + /** The maximum value of a range selection. Used only when range attribute is set. */ + @property({ type: Number, attribute: 'max-value' }) maxValue = 50; /** The default value of the form control. Primarily used for resetting the form control. */ - @property({ type: Number, attribute: 'value', reflect: true }) defaultValue: number = - Number(this.getAttribute('value')) || 0; + @property({ attribute: 'value', reflect: true, type: Number }) defaultValue: number = + this.getAttribute('value') == null ? this.minValue : Number(this.getAttribute('value')); - private _value: number | null = null; + private _value: number = this.defaultValue; /** The current value of the slider, submitted as a name/value pair with form data. */ get value(): number { if (this.valueHasChanged) { - return this._value || 0; + return this._value; } - return this._value ?? (this.defaultValue || 0); + return this._value ?? this.defaultValue; } @state() set value(val: number | null) { + val = Number(val) ?? this.minValue; + if (this._value === val) { return; } @@ -89,234 +145,817 @@ export default class WaSlider extends WebAwesomeFormAssociatedElement { this._value = val; } - /** The slider label. If you need to display HTML, use the `label` slot instead. */ - @property() label = ''; + /** Converts the slider to a range slider with two thumbs. */ + @property({ type: Boolean, reflect: true }) range = false; - /** The slider hint. If you need to display HTML, use the hint slot instead. */ - @property({ attribute: 'hint' }) hint = ''; + /** Get if this is a range slider */ + get isRange(): boolean { + return this.range; + } /** Disables the slider. */ @property({ type: Boolean }) disabled = false; - /** The minimum acceptable value of the slider. */ - @property({ type: Number }) min = 0; + /** Makes the slider a read-only field. */ + @property({ type: Boolean, reflect: true }) readonly = false; - /** The maximum acceptable value of the slider. */ - @property({ type: Number }) max = 100; + /** The orientation of the slider. */ + @property({ reflect: true }) orientation: 'horizontal' | 'vertical' = 'horizontal'; - /** The interval at which the slider will increase and decrease. */ - @property({ type: Number }) step = 1; + /** The slider's size. */ + @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; - /** The preferred placement of the slider tooltip. */ - @property() tooltip: 'top' | 'bottom' | 'none' = 'top'; + /** The starting value from which to draw the slider's fill, which is based on its current value. */ + @property({ attribute: 'indicator-offset', type: Number }) indicatorOffset: number; /** - * A function used to format the tooltip's value. The slider value is passed as the first and only argument. The - * function should return a string to display in the tooltip. + * The form to associate this control with. If omitted, the closest containing `
` will be used. The value of + * this attribute must be an ID of a form in the same document or shadow root. */ - @property({ attribute: false }) tooltipFormatter: (value: number) => string = (value: number) => value.toString(); + @property({ reflect: true }) form = null; + + /** The minimum value allowed. */ + @property({ type: Number }) min: number = 0; + + /** The maximum value allowed. */ + @property({ type: Number }) max: number = 100; + + /** 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; + + /** The distance of the tooltip from the slider's thumb. */ + @property({ attribute: 'tooltip-distance', type: Number }) tooltipDistance = 8; + + /** The placement of the tooltip in reference to the slider's thumb. */ + @property({ attribute: 'tooltip-placement', reflect: true }) tooltipPlacement: 'top' | 'right' | 'bottom' | 'left' = + 'top'; + + /** Draws markers at each step along the slider. */ + @property({ attribute: 'with-markers', type: Boolean }) withMarkers = false; + + /** Renders the slider with the `references` slot. */ + @property({ attribute: 'with-references', type: Boolean, reflect: true }) withReferences = false; + + /** Draws a tooltip above the thumb when the control has focus or is dragged. */ + @property({ attribute: 'with-tooltip', type: Boolean }) withTooltip = false; /** - * By default, form controls are associated with the nearest containing `` element. This attribute allows you - * to place the form control outside of a form and associate it with the form that has this `id`. The form must be in - * the same document or shadow root for this to work. + * A custom formatting function to apply to the value. This will be shown in the tooltip and announced by screen + * readers. Must be set with JavaScript. Property only. */ - @property({ reflect: true }) form: string | null = null; + @property({ attribute: false }) valueFormatter: (value: number) => string; - /** - * Used for SSR to render slotted labels. If true, will render slotted label content on first paint. - */ - @property({ attribute: 'with-label', reflect: true, type: Boolean }) withLabel = false; + firstUpdated() { + // Setup dragging based on range or single thumb mode + if (this.isRange) { + // Enable dragging on both thumbs for range slider + this.draggableThumbMin = new DraggableElement(this.thumbMin, { + start: () => { + this.activeThumb = 'min'; + this.trackBoundingClientRect = this.track.getBoundingClientRect(); + this.valueWhenDraggingStarted = this.minValue; + this.customStates.set('dragging', true); + this.showRangeTooltips(); + }, + move: (x, y) => { + this.setThumbValueFromCoordinates(x, y, 'min'); + }, + stop: () => { + if (this.minValue !== this.valueWhenDraggingStarted) { + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + this.hasInteracted = true; + } + this.hideRangeTooltips(); + this.customStates.set('dragging', false); + this.valueWhenDraggingStarted = undefined; + this.activeThumb = null; + }, + }); - /** - * Used for SSR to render slotted labels. If true, will render slotted hint content on first paint. - */ - @property({ attribute: 'with-hint', reflect: true, type: Boolean }) withHint = false; + this.draggableThumbMax = new DraggableElement(this.thumbMax, { + start: () => { + this.activeThumb = 'max'; + this.trackBoundingClientRect = this.track.getBoundingClientRect(); + this.valueWhenDraggingStarted = this.maxValue; + this.customStates.set('dragging', true); + this.showRangeTooltips(); + }, + move: (x, y) => { + this.setThumbValueFromCoordinates(x, y, 'max'); + }, + stop: () => { + if (this.maxValue !== this.valueWhenDraggingStarted) { + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + this.hasInteracted = true; + } + this.hideRangeTooltips(); + this.customStates.set('dragging', false); + this.valueWhenDraggingStarted = undefined; + this.activeThumb = null; + }, + }); - connectedCallback() { - super.connectedCallback(); - this.resizeObserver = new ResizeObserver(() => this.syncRange()); + // Enable track dragging for finding the closest thumb + this.draggableTrack = new DraggableElement(this.track, { + start: (x, y) => { + this.trackBoundingClientRect = this.track.getBoundingClientRect(); - if (this.value < this.min) { - this.value = this.min; + // When a drag starts, we need to determine which thumb to move + // If the thumbs are in nearly the same position, we prioritize the one that's already active + // or the one that received focus most recently + if (this.activeThumb) { + // Keep using the already active thumb (useful for keyboard interactions) + this.valueWhenDraggingStarted = this.activeThumb === 'min' ? this.minValue : this.maxValue; + } else { + // Otherwise select by closest distance + const value = this.getValueFromCoordinates(x, y); + const minDistance = Math.abs(value - this.minValue); + const maxDistance = Math.abs(value - this.maxValue); + + if (minDistance === maxDistance) { + // If distances are equal, prioritize the max thumb when value is higher than both thumbs + // and min thumb when value is lower than both thumbs + if (value > this.maxValue) { + this.activeThumb = 'max'; + } else if (value < this.minValue) { + this.activeThumb = 'min'; + } else { + // If the value is between the thumbs and they're at the same distance, + // prioritize the thumb that's in the direction of movement + const isRtl = this.localize.dir() === 'rtl'; + const isVertical = this.orientation === 'vertical'; + const position = isVertical ? y : x; + const previousPosition = this.lastTrackPosition || position; + this.lastTrackPosition = position; + + // Determine direction of movement + const movingForward = + (position > previousPosition !== isRtl && !isVertical) || (position < previousPosition && isVertical); + + this.activeThumb = movingForward ? 'max' : 'min'; + } + } else { + // Select the closest thumb + this.activeThumb = minDistance <= maxDistance ? 'min' : 'max'; + } + + this.valueWhenDraggingStarted = this.activeThumb === 'min' ? this.minValue : this.maxValue; + } + + this.customStates.set('dragging', true); + this.setThumbValueFromCoordinates(x, y, this.activeThumb); + this.showRangeTooltips(); + }, + move: (x, y) => { + if (this.activeThumb) { + this.setThumbValueFromCoordinates(x, y, this.activeThumb); + } + }, + stop: () => { + if (this.activeThumb) { + const currentValue = this.activeThumb === 'min' ? this.minValue : this.maxValue; + if (currentValue !== this.valueWhenDraggingStarted) { + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + this.hasInteracted = true; + } + } + this.hideRangeTooltips(); + this.customStates.set('dragging', false); + this.valueWhenDraggingStarted = undefined; + this.activeThumb = null; + }, + }); + } else { + // Single thumb mode - original behavior + this.draggableTrack = new DraggableElement(this.slider, { + start: (x, y) => { + this.trackBoundingClientRect = this.track.getBoundingClientRect(); + this.valueWhenDraggingStarted = this.value; + this.customStates.set('dragging', true); + this.setValueFromCoordinates(x, y); + this.showTooltip(); + }, + move: (x, y) => { + this.setValueFromCoordinates(x, y); + }, + stop: () => { + if (this.value !== this.valueWhenDraggingStarted) { + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + this.hasInteracted = true; + } + this.hideTooltip(); + this.customStates.set('dragging', false); + this.valueWhenDraggingStarted = undefined; + }, + }); } - if (this.value > this.max) { - this.value = this.max; + } + + updated(changedProperties: PropertyValues) { + // Handle range mode changes + if (changedProperties.has('range')) { + this.requestUpdate(); } - this.updateComplete.then(() => { - this.syncRange(); - this.resizeObserver.observe(this.input); - }); + if (this.isRange) { + // Handle min/max values for range mode + if (changedProperties.has('minValue') || changedProperties.has('maxValue')) { + // Ensure min doesn't exceed max + this.minValue = clamp(this.minValue, this.min, this.maxValue); + this.maxValue = clamp(this.maxValue, this.minValue, this.max); + // Update form value + this.updateFormValue(); + } + } else { + // Handle value for single thumb mode + if (changedProperties.has('value')) { + this.value = clamp(this.value, this.min, this.max); + this.setValue(String(this.value)); + } + } + + // Handle min/max + if (changedProperties.has('min') || changedProperties.has('max')) { + if (this.isRange) { + this.minValue = clamp(this.minValue, this.min, this.max); + this.maxValue = clamp(this.maxValue, this.min, this.max); + } else { + this.value = clamp(this.value, this.min, this.max); + } + } + + // Handle disabled + if (changedProperties.has('disabled')) { + this.customStates.set('disabled', this.disabled); + } + + // Disable dragging when disabled or readonly + if (changedProperties.has('disabled') || changedProperties.has('readonly')) { + const enabled = !(this.disabled || this.readonly); + + if (this.isRange) { + if (this.draggableThumbMin) this.draggableThumbMin.toggle(enabled); + if (this.draggableThumbMax) this.draggableThumbMax.toggle(enabled); + } + + if (this.draggableTrack) { + this.draggableTrack.toggle(enabled); + } + } + + super.updated(changedProperties); } - disconnectedCallback() { - super.disconnectedCallback(); - this.resizeObserver?.unobserve(this.input); + /** @internal Called when a containing fieldset is disabled. */ + formDisabledCallback(isDisabled: boolean) { + this.disabled = isDisabled; } - private handleChange(event: Event) { - this.relayNativeEvent(event, { bubbles: true, composed: true }); + /** @internal Called when the form is reset. */ + formResetCallback() { + if (this.isRange) { + this.minValue = parseFloat(this.getAttribute('min-value') ?? String(this.min)); + this.maxValue = parseFloat(this.getAttribute('max-value') ?? String(this.max)); + } else { + this.value = parseFloat(this.getAttribute('value') ?? String(this.min)); + } + this.hasInteracted = false; + super.formResetCallback(); } - private handleInput() { - this.value = parseFloat(this.input.value); - this.syncRange(); + /** Clamps a number to min/max while ensuring it's a valid step interval. */ + private clampAndRoundToStep(value: number) { + const stepPrecision = (String(this.step).split('.')[1] || '').replace(/0+$/g, '').length; + value = Math.round(value / this.step) * this.step; + value = clamp(value, this.min, this.max); + + return parseFloat(value.toFixed(stepPrecision)); + } + + /** Given a value, returns its percentage within a range of min/max. */ + private getPercentageFromValue(value: number) { + return ((value - this.min) / (this.max - this.min)) * 100; + } + + /** Converts coordinates to slider value */ + private getValueFromCoordinates(x: number, y: number) { + const isRtl = this.localize.dir() === 'rtl'; + const isVertical = this.orientation === 'vertical'; + const { top, right, bottom, left, height, width } = this.trackBoundingClientRect; + const pointerPosition = isVertical ? y : x; + const sliderCoords = isVertical + ? { start: top, end: bottom, size: height } + : { start: left, end: right, size: width }; + const relativePosition = isVertical + ? sliderCoords.end - pointerPosition + : isRtl + ? sliderCoords.end - pointerPosition + : pointerPosition - sliderCoords.start; + const percentage = relativePosition / sliderCoords.size; + return this.clampAndRoundToStep(this.min + (this.max - this.min) * percentage); } private handleBlur() { - this.hasTooltip = false; + // Only hide tooltips if neither thumb has focus + if (this.isRange) { + // Allow a subsequent focus event to fire on the other thumb if the user is tabbing + requestAnimationFrame(() => { + const focusedElement = this.shadowRoot?.activeElement; + const thumbHasFocus = focusedElement === this.thumbMin || focusedElement === this.thumbMax; + if (!thumbHasFocus) { + this.hideRangeTooltips(); + } + }); + } else { + this.hideTooltip(); + } + this.customStates.set('focused', false); + this.dispatchEvent(new FocusEvent('blur', { bubbles: true, composed: true })); } - private handleFocus() { - this.hasTooltip = true; + private handleFocus(event: FocusEvent) { + const target = event.target as HTMLElement; + + // Handle focus for specific thumbs in range mode + if (this.isRange) { + if (target === this.thumbMin) { + this.activeThumb = 'min'; + } else if (target === this.thumbMax) { + this.activeThumb = 'max'; + } + this.showRangeTooltips(); + } else { + this.showTooltip(); + } + + this.customStates.set('focused', true); + this.dispatchEvent(new FocusEvent('focus', { bubbles: true, composed: true })); } - @eventOptions({ passive: true }) - private handleThumbDragStart() { - this.hasTooltip = true; - } + private handleKeyDown(event: KeyboardEvent) { + const isRtl = this.localize.dir() === 'rtl'; + const target = event.target as HTMLElement; - private handleThumbDragEnd() { - this.hasTooltip = false; - } + if (this.disabled || this.readonly) return; - private syncProgress(percent: number) { - this.input.style.setProperty('--percent', `${percent * 100}%`); - } + // For range slider, determine which thumb is active + if (this.isRange) { + if (target === this.thumbMin) { + this.activeThumb = 'min'; + } else if (target === this.thumbMax) { + this.activeThumb = 'max'; + } - private syncTooltip(percent: number) { - if (this.output !== null) { - const inputWidth = this.input.offsetWidth; - const tooltipWidth = this.output.offsetWidth; - const thumbSize = getComputedStyle(this.input).getPropertyValue('--thumb-size'); - const isRtl = this.localize.dir() === 'rtl'; - const percentAsWidth = inputWidth * percent; + if (!this.activeThumb) return; + } - // The calculations are used to "guess" where the thumb is located. Since we're using the native range control - // under the hood, we don't have access to the thumb's true coordinates. These measurements can be a pixel or two - // off depending on the size of the control, thumb, and tooltip dimensions. - if (isRtl) { - const x = `${inputWidth - percentAsWidth}px + ${percent} * ${thumbSize}`; - this.output.style.translate = `calc((${x} - ${tooltipWidth / 2}px - ${thumbSize} / 2))`; + // Get current value based on slider mode + const current = this.isRange ? (this.activeThumb === 'min' ? this.minValue : this.maxValue) : this.value; + + let newValue = current; + + // Handle key presses + switch (event.key) { + // Increase + case 'ArrowUp': + case isRtl ? 'ArrowLeft' : 'ArrowRight': + event.preventDefault(); + newValue = this.clampAndRoundToStep(current + this.step); + break; + + // Decrease + case 'ArrowDown': + case isRtl ? 'ArrowRight' : 'ArrowLeft': + event.preventDefault(); + newValue = this.clampAndRoundToStep(current - this.step); + break; + + // Minimum value + case 'Home': + event.preventDefault(); + newValue = this.isRange && this.activeThumb === 'min' ? this.min : this.isRange ? this.minValue : this.min; + break; + + // Maximum value + case 'End': + event.preventDefault(); + newValue = this.isRange && this.activeThumb === 'max' ? this.max : this.isRange ? this.maxValue : this.max; + break; + + // Move up 10% + case 'PageUp': + event.preventDefault(); + const stepUp = Math.max( + current + (this.max - this.min) / 10, + current + this.step, // make sure we at least move up to the next step + ); + newValue = this.clampAndRoundToStep(stepUp); + break; + + // Move down 10% + case 'PageDown': + event.preventDefault(); + const stepDown = Math.min( + current - (this.max - this.min) / 10, + current - this.step, // make sure we at least move down to the previous step + ); + newValue = this.clampAndRoundToStep(stepDown); + break; + + // Handle form submission on Enter + case 'Enter': + submitOnEnter(event, this); + return; + } + + // If no value change, exit early + if (newValue === current) return; + + // Apply the new value with appropriate constraints + if (this.isRange) { + if (this.activeThumb === 'min') { + if (newValue > this.maxValue) { + // If min thumb exceeds max thumb, move both + this.maxValue = newValue; + this.minValue = newValue; + } else { + this.minValue = Math.max(this.min, newValue); + } } else { - const x = `${percentAsWidth}px - ${percent} * ${thumbSize}`; - this.output.style.translate = `calc(${x} - ${tooltipWidth / 2}px + ${thumbSize} / 2)`; + if (newValue < this.minValue) { + // If max thumb goes below min thumb, move both + this.minValue = newValue; + this.maxValue = newValue; + } else { + this.maxValue = Math.min(this.max, newValue); + } + } + this.updateFormValue(); + } else { + this.value = clamp(newValue, this.min, this.max); + } + + // Dispatch events + this.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + this.hasInteracted = true; + } + + private handleLabelPointerDown(event: PointerEvent) { + event.preventDefault(); + + if (!this.disabled) { + if (this.isRange) { + this.thumbMin?.focus(); + } else { + this.slider.focus(); } } } - @watch('value', { waitUntilFirstUpdate: true }) - handleValueChange() { - // The value may have constraints, so we set the native control's value and sync it back to ensure it adhere's to - // min, max, and step properly - this.input.value = this.value.toString(); - this.value = parseFloat(this.input.value); - this.updateValidity(); + private setValueFromCoordinates(x: number, y: number) { + const oldValue = this.value; + this.value = this.getValueFromCoordinates(x, y); - this.syncRange(); - } - - @watch('hasTooltip', { waitUntilFirstUpdate: true }) - syncRange() { - const percent = Math.max(0, (this.value - this.min) / (this.max - this.min)); - - this.syncProgress(percent); - - if (this.tooltip !== 'none') { - // Ensure updates are drawn before we sync the tooltip - this.updateComplete.then(() => this.syncTooltip(percent)); + // Dispatch input events when the value changes by dragging + if (this.value !== oldValue) { + this.dispatchEvent(new InputEvent('input')); } } - /** Sets focus on the slider. */ - focus(options?: FocusOptions) { - this.input.focus(options); + private setThumbValueFromCoordinates(x: number, y: number, thumb: 'min' | 'max') { + const value = this.getValueFromCoordinates(x, y); + const oldValue = thumb === 'min' ? this.minValue : this.maxValue; + + if (thumb === 'min') { + // If min thumb is being dragged and would exceed max thumb + if (value > this.maxValue) { + // Move both thumbs, keeping their distance at 0 + this.maxValue = value; + this.minValue = value; + } else { + // Normal case - just move min thumb + this.minValue = Math.max(this.min, value); + } + } else { + // thumb === 'max' + // If max thumb is being dragged and would go below min thumb + if (value < this.minValue) { + // Move both thumbs, keeping their distance at 0 + this.minValue = value; + this.maxValue = value; + } else { + // Normal case - just move max thumb + this.maxValue = Math.min(this.max, value); + } + } + + // Dispatch input events + if (oldValue !== (thumb === 'min' ? this.minValue : this.maxValue)) { + this.dispatchEvent(new InputEvent('input')); + this.updateFormValue(); + } + } + + private showTooltip() { + if (this.withTooltip && this.tooltip) { + this.tooltip.open = true; + } + } + + private hideTooltip() { + if (this.withTooltip && this.tooltip) { + this.tooltip.open = false; + } + } + + private showRangeTooltips() { + if (this.withTooltip) { + this.tooltips.forEach(tooltip => { + tooltip.open = true; + }); + } + } + + private hideRangeTooltips() { + if (this.withTooltip) { + this.tooltips.forEach(tooltip => { + tooltip.open = false; + }); + } + } + + /** Updates the form value submission for range sliders */ + private updateFormValue() { + if (this.isRange) { + // Submit both values using FormData for range sliders + const formData = new FormData(); + formData.append(this.name || '', String(this.minValue)); + formData.append(this.name || '', String(this.maxValue)); + this.setValue(formData); + } + } + + /** Sets focus to the slider. */ + public focus() { + if (this.isRange) { + this.thumbMin?.focus(); + } else { + this.slider.focus(); + } } /** Removes focus from the slider. */ - blur() { - this.input.blur(); - } - - /** Increments the value of the slider by the value of the step attribute. */ - stepUp() { - this.input.stepUp(); - if (this.value !== Number(this.input.value)) { - this.value = Number(this.input.value); + public blur() { + if (this.isRange) { + if (document.activeElement === this.thumbMin) { + this.thumbMin.blur(); + } else if (document.activeElement === this.thumbMax) { + this.thumbMax.blur(); + } + } else { + this.slider.blur(); } } - /** Decrements the value of the slider by the value of the step attribute. */ - stepDown() { - this.input.stepDown(); - if (this.value !== Number(this.input.value)) { - this.value = Number(this.input.value); + /** + * Decreases the slider's value by `step`. This is a programmatic change, so `input` and `change` events will not be + * emitted when this is called. + */ + public stepDown() { + if (this.isRange) { + // If in range mode, default to stepping down the min value + const newValue = this.clampAndRoundToStep(this.minValue - this.step); + this.minValue = clamp(newValue, this.min, this.maxValue); + this.updateFormValue(); + } else { + const newValue = this.clampAndRoundToStep(this.value - this.step); + this.value = newValue; } } - formResetCallback() { - this.value = this.defaultValue; - - super.formResetCallback(); + /** + * Increases the slider's value by `step`. This is a programmatic change, so `input` and `change` events will not be + * emitted when this is called. + */ + public stepUp() { + if (this.isRange) { + // If in range mode, default to stepping up the max value + const newValue = this.clampAndRoundToStep(this.maxValue + this.step); + this.maxValue = clamp(newValue, this.minValue, this.max); + this.updateFormValue(); + } else { + const newValue = this.clampAndRoundToStep(this.value + this.step); + this.value = newValue; + } } render() { - const hasLabelSlot = this.hasUpdated ? this.hasSlotController.test('label') : this.withLabel; - const hasHintSlot = this.hasUpdated ? this.hasSlotController.test('hint') : this.withHint; - const hasLabel = this.label ? true : !!hasLabelSlot; - const hasHint = this.hint ? true : !!hasHintSlot; + const hasLabel = this.hasSlotController.test('label'); + const hasHint = this.hasSlotController.test('hint'); - // NOTE - always bind value after min/max, otherwise it will be clamped - return html` - ${hasLabel - ? html`` - : ''} + const sliderClasses = classMap({ + small: this.size === 'small', + medium: this.size === 'medium', + large: this.size === 'large', + horizontal: this.orientation === 'horizontal', + vertical: this.orientation === 'vertical', + disabled: this.disabled, + }); -
- - ${this.tooltip !== 'none' && !this.disabled - ? html` - - ${typeof this.tooltipFormatter === 'function' ? this.tooltipFormatter(this.value) : this.value} - - ` - : ''} -
+ // Calculate marker positions + const markers = []; + if (this.withMarkers) { + for (let i = this.min; i <= this.max; i += this.step) { + markers.push(this.getPercentageFromValue(i)); + } + } - ${this.hint} + ${this.label} + + +
+ ${this.hint} +
`; + + const markersTemplate = this.withMarkers + ? html` +
+ ${markers.map(marker => html``)} +
+ ` + : ''; + + const referencesTemplate = this.withReferences + ? html` + + ` + : ''; + + // Create tooltip template function + const createTooltip = (thumbId: string, value: number) => + this.withTooltip + ? html` + + + + ` + : ''; + + // Render based on mode + if (this.isRange) { + // Range slider mode + const minThumbPosition = clamp(this.getPercentageFromValue(this.minValue), 0, 100); + const maxThumbPosition = clamp(this.getPercentageFromValue(this.maxValue), 0, 100); + + return html` + ${labelAndHint} + +
+
+
+ + ${markersTemplate} + + + + +
+ + ${referencesTemplate} +
+ + ${createTooltip('thumb-min', this.minValue)} ${createTooltip('thumb-max', this.maxValue)} + `; + } else { + // Single thumb mode + const thumbPosition = clamp(this.getPercentageFromValue(this.value), 0, 100); + const indicatorOffsetPosition = clamp( + this.getPercentageFromValue(typeof this.indicatorOffset === 'number' ? this.indicatorOffset : this.min), + 0, + 100, + ); + + return html` + ${labelAndHint} + +
+
+
+ + ${markersTemplate} + +
+ + ${referencesTemplate} +
+ + ${createTooltip('thumb', this.value)} + `; + } } } diff --git a/packages/webawesome/src/internal/drag.ts b/packages/webawesome/src/internal/drag.ts index 5a22f33d1..0b7ae61e9 100644 --- a/packages/webawesome/src/internal/drag.ts +++ b/packages/webawesome/src/internal/drag.ts @@ -43,3 +43,132 @@ export function drag(container: HTMLElement, options?: Partial) { 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) { + 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; +} diff --git a/packages/webawesome/src/internal/submit-on-enter.ts b/packages/webawesome/src/internal/submit-on-enter.ts new file mode 100644 index 000000000..1f87ccdf8 --- /dev/null +++ b/packages/webawesome/src/internal/submit-on-enter.ts @@ -0,0 +1,64 @@ +import type WaButton from '../components/button/button.js'; +import type { WebAwesomeFormAssociatedElement } from './webawesome-form-associated-element.js'; + +export function submitOnEnter(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 ``, so trigger a manual click. + button.click(); + } +} diff --git a/packages/webawesome/src/internal/test/form-control-base-tests.ts b/packages/webawesome/src/internal/test/form-control-base-tests.ts index 51c6daa8a..97a91b3a4 100644 --- a/packages/webawesome/src/internal/test/form-control-base-tests.ts +++ b/packages/webawesome/src/internal/test/form-control-base-tests.ts @@ -162,6 +162,7 @@ function runAllValidityTests( const form = await fixture(html``); 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); diff --git a/packages/webawesome/src/internal/validators/slider-validator.ts b/packages/webawesome/src/internal/validators/slider-validator.ts new file mode 100644 index 000000000..44ea1d0dc --- /dev/null +++ b/packages/webawesome/src/internal/validators/slider-validator.ts @@ -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 => { + // 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 = { + 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; + }, + }; +}; diff --git a/packages/webawesome/src/styles/themes/active/dimension.css b/packages/webawesome/src/styles/themes/active/dimension.css index f18c7cfb7..793d4950a 100644 --- a/packages/webawesome/src/styles/themes/active/dimension.css +++ b/packages/webawesome/src/styles/themes/active/dimension.css @@ -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 { diff --git a/packages/webawesome/src/styles/themes/brutalist/overrides.css b/packages/webawesome/src/styles/themes/brutalist/overrides.css index 4e659fbd4..a27508544 100644 --- a/packages/webawesome/src/styles/themes/brutalist/overrides.css +++ b/packages/webawesome/src/styles/themes/brutalist/overrides.css @@ -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) { diff --git a/packages/webawesome/src/styles/themes/glossy/dimension.css b/packages/webawesome/src/styles/themes/glossy/dimension.css index 8cc603c24..212d4ab51 100644 --- a/packages/webawesome/src/styles/themes/glossy/dimension.css +++ b/packages/webawesome/src/styles/themes/glossy/dimension.css @@ -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); } diff --git a/packages/webawesome/src/styles/themes/matter/overrides.css b/packages/webawesome/src/styles/themes/matter/overrides.css index 84dbacb12..43fd05150 100644 --- a/packages/webawesome/src/styles/themes/matter/overrides.css +++ b/packages/webawesome/src/styles/themes/matter/overrides.css @@ -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%); } } diff --git a/packages/webawesome/src/styles/themes/playful/dimension.css b/packages/webawesome/src/styles/themes/playful/dimension.css index 83dc276f1..9e8fa9adb 100644 --- a/packages/webawesome/src/styles/themes/playful/dimension.css +++ b/packages/webawesome/src/styles/themes/playful/dimension.css @@ -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); } } } diff --git a/packages/webawesome/src/styles/themes/shoelace/overrides.css b/packages/webawesome/src/styles/themes/shoelace/overrides.css index 6cf23234e..9b2b7657c 100644 --- a/packages/webawesome/src/styles/themes/shoelace/overrides.css +++ b/packages/webawesome/src/styles/themes/shoelace/overrides.css @@ -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 {