diff --git a/.circleci/config.yml b/.circleci/config.yml index bec616bd9f..513c5bd155 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,7 +8,7 @@ executors: parameters: current_golden_images_hash: type: string - default: af2206f0506c49d7127e6ae9070d8f2359b7e12e + default: 26ae0c0f5ea0cd3952923e8c2a0d06cb387ba439 commands: downstream: steps: diff --git a/packages/bundle/elements.ts b/packages/bundle/elements.ts index f7837fb134..6c481021f3 100644 --- a/packages/bundle/elements.ts +++ b/packages/bundle/elements.ts @@ -60,6 +60,7 @@ import '@spectrum-web-components/sidenav/sp-sidenav.js'; import '@spectrum-web-components/sidenav/sp-sidenav-heading.js'; import '@spectrum-web-components/sidenav/sp-sidenav-item.js'; import '@spectrum-web-components/slider/sp-slider.js'; +import '@spectrum-web-components/slider/sp-slider-handle.js'; import '@spectrum-web-components/split-button/sp-split-button.js'; import '@spectrum-web-components/split-view/sp-split-view.js'; import '@spectrum-web-components/status-light/sp-status-light.js'; diff --git a/packages/slider/README.md b/packages/slider/README.md index 9a0c420476..55af0a3120 100644 --- a/packages/slider/README.md +++ b/packages/slider/README.md @@ -81,6 +81,45 @@ import { Slider } from '@spectrum-web-components/slider'; ``` +## Advanced normalization + +By default, `sp-slider` assumes a linear scale between the `min` and `max` values. +For advanced applications, it is sometimes necessary to specify a custom +"normalization." + +Normalization is the process of converting a slider to a value between 0 and 1 where +0 represents the minimum and 1 represents the maximum. See the "Three Handles Complex" example in the playground. + +## Labels and Formatting + +An `` or `` element will process its numeric value with `new Intl.NumberFormat(navigator.language, this.formatOptions).format(this.value)` in order to prepare it for visual delivery in the input. In order to customize this processing supply your own `Intl.NumberFormatOptions` via the `formatOptions` property, or `format-options` attribute as follows. + +```html + +``` + +More advanced formatting is avialable by specifying a formatting function to +the `getAriaHandleText` property on an `sp-slider` or `sp-slider-handle`. Or, +for a multi-handle slider, you can format the combined value label for all +handles by passing a formatting function to the `getAriaValueText` property +on the parent `sp-slider`. + +You can suppress the value label altogether using the `hide-value-label` +attribute. + +```html + +``` + ## Events Like the `` element after which the `` is fashioned it will dispatch `input` events in a stream culminating with a `change` event (representing the final comit of the `value` to the element) once the user has discontinued with the element. Both other these events can access the `value` of their dispatching target via `event.target.value`. In this way a steaming listener patters similar to the following can prove useful: diff --git a/packages/slider/package.json b/packages/slider/package.json index ba4de9edc0..095455f906 100644 --- a/packages/slider/package.json +++ b/packages/slider/package.json @@ -24,7 +24,9 @@ "./src/*": "./src/*", "./package.json": "./package.json", "./sp-slider": "./sp-slider.js", - "./sp-slider.js": "./sp-slider.js" + "./sp-slider.js": "./sp-slider.js", + "./sp-slider-handle": "./sp-slider-handle.js", + "./sp-slider-handle.js": "./sp-slider-handle.js" }, "scripts": { "test": "echo \"Error: run tests from mono-repo root.\" && exit 1" @@ -43,6 +45,7 @@ "lit-html" ], "dependencies": { + "@internationalized/number": "^3.0.0", "@spectrum-web-components/base": "^0.4.3", "@spectrum-web-components/shared": "^0.12.4", "tslib": "^2.0.0" diff --git a/packages/slider/slider-handle.md b/packages/slider/slider-handle.md new file mode 100644 index 0000000000..5c27bae2de --- /dev/null +++ b/packages/slider/slider-handle.md @@ -0,0 +1,77 @@ +## Description + +Some advanced slider uses require more than one handle. One example of this is the +range slider above. `sp-slider` supports an arbitrary number of handles via the `` sub-component, although it would be very rare to ever require more than two handles. + +### Usage + +[![See it on NPM!](https://img.shields.io/npm/v/@spectrum-web-components/slider?style=for-the-badge)](https://www.npmjs.com/package/@spectrum-web-components/slider) +[![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/@spectrum-web-components/slider?style=for-the-badge)](https://bundlephobia.com/result?p=@spectrum-web-components/slider) +[![Try it on webcomponents.dev](https://img.shields.io/badge/Try%20it%20on-webcomponents.dev-green?style=for-the-badge)](https://webcomponents.dev/edit/collection/fO75441E1Q5ZlI0e9pgq/U7LQv7LsAVBwJayJXG3B/src/index.ts) + +``` +yarn add @spectrum-web-components/slider +``` + +Import the side effectful registration of `` and `` via: + +``` +import '@spectrum-web-components/slider/sp-slider.js'; +import '@spectrum-web-components/slider/sp-slider-handle.js'; +``` + +## Examples + +### Range Slider + +This examples uses the `"range"` variant along with two handles to create a range slider. + +```html + + Output Levels + + + +``` + +## Multi-handle Slider with Ordered Handles + +For slider handles that have the same numeric range, you can specify `min="previous"` or `max="next"` to constrain handles by the values of their neighbours. + +```html + + Output Levels + + + + +``` diff --git a/packages/slider/sp-slider-handle.ts b/packages/slider/sp-slider-handle.ts new file mode 100644 index 0000000000..93613efd2c --- /dev/null +++ b/packages/slider/sp-slider-handle.ts @@ -0,0 +1,20 @@ +/* +Copyright 2021 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { SliderHandle } from './src/SliderHandle.js'; + +customElements.define('sp-slider-handle', SliderHandle); + +declare global { + interface HTMLElementTagNameMap { + 'sp-slider-handle': SliderHandle; + } +} diff --git a/packages/slider/src/HandleController.ts b/packages/slider/src/HandleController.ts new file mode 100644 index 0000000000..c334d12ff4 --- /dev/null +++ b/packages/slider/src/HandleController.ts @@ -0,0 +1,578 @@ +/* +Copyright 2021 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import { + html, + TemplateResult, + ifDefined, + classMap, + styleMap, +} from '@spectrum-web-components/base'; +import { streamingListener } from '@spectrum-web-components/base/src/streaming-listener.js'; +import { Slider } from './Slider.js'; +import { + SliderHandle, + SliderNormalization, + Controller, +} from './SliderHandle.js'; + +interface HandleReference { + handle: HTMLElement; + input: HTMLInputElement; +} + +interface HandleComponents extends HandleReference { + model: SliderHandle; +} + +interface RangeAndClamp { + range: { min: number; max: number }; + clamp: { min: number; max: number }; +} +interface ModelValue extends RangeAndClamp { + name: string; + value: number; + normalizedValue: number; + step: number; + highlight: boolean; + ariaLabel?: string; + normalization: SliderNormalization; + handle: SliderHandle; +} + +interface InputWithModel extends HTMLInputElement { + model: ModelValue; +} + +export interface HandleValueDictionary { + [key: string]: number; +} + +export class HandleController implements Controller { + private observer!: MutationObserver; + private host!: Slider; + private handles: Map = new Map(); + private model: ModelValue[] = []; + private handleOrder: string[] = []; + private draggingHandle?: SliderHandle; + private handleRefMap?: WeakMap; + + constructor(host: Slider) { + this.host = host; + } + + public get values(): HandleValueDictionary { + const result: HandleValueDictionary = {}; + for (const model of this.handles.values()) { + result[model.handleName] = model.value; + } + return result; + } + + public inputForHandle(handle: SliderHandle): HTMLInputElement | undefined { + if (this.handles.has(handle.handleName)) { + const { input } = this.getHandleElements(handle); + return input; + } + /* c8 ignore next 2 */ + throw new Error(`No input for handle "${handle.name}"`); + } + + public requestUpdate(): void { + this.host.requestUpdate(); + } + + public get language(): string { + return this.host.language; + } + + /** + * It is possible for value attributes to be set programmatically. The + * for a particular slider needs to have an opportunity to validate any such + * values + * + * @param handle Handle who's value needs validation + */ + public setValueFromHandle(handle: SliderHandle): void { + const elements = this.getHandleElements(handle); + /* c8 ignore next */ + if (!elements) return; + + const { input } = elements; + if (input.valueAsNumber === handle.value) { + handle.dispatchInputEvent(); + } else { + input.valueAsNumber = handle.value; + handle.value = input.valueAsNumber; + this.requestUpdate(); + } + handle.value = input.valueAsNumber; + } + + public handleHasChanged(handle: SliderHandle): void { + if (handle !== this.host) { + this.requestUpdate(); + } + } + + public formattedValueForHandle(model: ModelValue): string { + const { handle } = model; + const numberFormat = handle.numberFormat ?? this.host.numberFormat; + return handle.getAriaHandleText(model.value, numberFormat); + } + + public get formattedValues(): Map { + const result = new Map(); + for (const model of this.model) { + result.set(model.name, this.formattedValueForHandle(model)); + } + return result; + } + + public get focusElement(): HTMLElement { + const { input } = this.getActiveHandleElements(); + return input; + } + + public hostConnected(): void { + if (!this.observer) { + this.observer = new MutationObserver(this.extractModelFromLightDom); + } + this.observer.observe(this.host, { subtree: true, childList: true }); + this.extractModelFromLightDom(); + } + + public hostDisconnected(): void { + this.observer.disconnect(); + } + + public hostUpdate(): void { + this.updateModel(); + } + + private extractModelFromLightDom = (): void => { + let handles = [ + ...this.host.querySelectorAll('[slot="handle"]'), + ] as SliderHandle[]; + if (handles.length === 0) { + handles = [this.host as SliderHandle]; + } + this.handles = new Map(); + this.handleOrder = []; + handles.forEach((handle, index) => { + /* c8 ignore next */ + if (!handle.handleName?.length) { + handle.name = `handle${index + 1}`; + } + this.handles.set(handle.handleName, handle); + this.handleOrder.push(handle.handleName); + handle.handleController = this; + }); + this.requestUpdate(); + }; + + public get activeHandle(): string { + return this.handleOrder[this.handleOrder.length - 1]; + } + + public get activeHandleInputId(): string { + const active = this.activeHandle; + const index = this.model.findIndex((model) => model.name === active); + return `input-${index}`; + } + + public activateHandle(name: string): void { + const index = this.handleOrder.findIndex((item) => item === name); + if (index >= 0) { + this.handleOrder.splice(index, 1); + } + this.handleOrder.push(name); + } + + private getActiveHandleElements(): HandleComponents { + const name = this.activeHandle; + const handleSlider = this.handles.get(name) as SliderHandle; + const elements = this.getHandleElements( + handleSlider + ) as HandleReference; + return { model: handleSlider, ...elements }; + } + + private getHandleElements(sliderHandle: SliderHandle): HandleReference { + if (!this.handleRefMap) { + this.handleRefMap = new WeakMap(); + + const inputNodes = this.host.shadowRoot.querySelectorAll( + '.handle > input' + ); + for (const inputNode of inputNodes) { + const input = inputNode as HTMLInputElement; + const handle = input.parentElement as HTMLElement; + const model = this.handles.get( + handle.getAttribute('name') as string + ); + if (model) { + this.handleRefMap.set(model, { input, handle }); + } + } + } + + const components = this.handleRefMap.get( + sliderHandle + ) as HandleReference; + return components; + } + + private clearHandleComponentCache(): void { + delete this.handleRefMap; + } + + private get boundingClientRect(): DOMRect { + if (!this._boundingClientRect) { + this._boundingClientRect = this.host.getBoundingClientRect(); + } + return this._boundingClientRect; + } + + private updateBoundingRect(): void { + delete this._boundingClientRect; + } + + private _boundingClientRect?: DOMRect; + + /** + * Receives an event from a track click and turns it into a drag + * of the active handle + * @param event Track click event + */ + public beginTrackDrag(event: PointerEvent): void { + const { handle } = this.getActiveHandleElements(); + const model = this.model.find( + (item) => item.name === this.activeHandle + ); + /* c8 ignore next */ + if (!model) return; + + event.stopPropagation(); + event.preventDefault(); + const applyDefault = handle.dispatchEvent( + new PointerEvent('pointerdown', event) + ); + if (applyDefault) { + const model = this.model.find( + (model) => model.name === this.activeHandle + ); + if (model) { + this.handlePointermove(event, model); + } + } + } + + private handlePointerdown(event: PointerEvent, model: ModelValue): void { + const handle = event.target as HTMLDivElement; + if (this.host.disabled || event.button !== 0) { + event.preventDefault(); + return; + } + this.updateBoundingRect(); + this.host.labelEl.click(); + this.draggingHandle = model.handle; + model.handle.dragging = true; + this.activateHandle(model.name); + handle.setPointerCapture(event.pointerId); + this.host.requestUpdate(); + } + + private handlePointerup(event: PointerEvent, model: ModelValue): void { + // Retain focus on input element after mouse up to enable keyboard interactions + const handle = event.target as HTMLDivElement; + const input = handle.querySelector('input') as HTMLInputElement; + this.host.labelEl.click(); + model.handle.highlight = false; + delete this.draggingHandle; + model.handle.dragging = false; + this.requestUpdate(); + handle.releasePointerCapture(event.pointerId); + this.dispatchChangeEvent(input, model.handle); + } + + private handlePointermove(event: PointerEvent, model: ModelValue): void { + /* c8 ignore next 3 */ + if (!this.draggingHandle) { + return; + } + event.stopPropagation(); + const { input } = this.getHandleElements(model.handle); + input.value = this.calculateHandlePosition(event, model).toString(); + model.handle.value = parseFloat(input.value); + this.requestUpdate(); + } + + /** + * Keep the slider value property in sync with the input element's value + */ + private onInputChange = (event: Event): void => { + const input = event.target as InputWithModel; + input.model.handle.value = input.valueAsNumber; + + this.requestUpdate(); + this.dispatchChangeEvent(input, input.model.handle); + }; + + private onInputFocus = (event: Event): void => { + const input = event.target as InputWithModel; + let isFocusVisible; + try { + isFocusVisible = + input.matches(':focus-visible') || + this.host.matches('.focus-visible'); + /* c8 ignore next 3 */ + } catch (error) { + isFocusVisible = this.host.matches('.focus-visible'); + } + input.model.handle.highlight = isFocusVisible; + this.requestUpdate(); + }; + + private onInputBlur = (event: Event): void => { + const input = event.target as InputWithModel; + input.model.handle.highlight = false; + this.requestUpdate(); + }; + + private dispatchChangeEvent( + input: HTMLInputElement, + handle: SliderHandle + ): void { + input.valueAsNumber = handle.value; + + const changeEvent = new Event('change', { + bubbles: true, + composed: true, + }); + + handle.dispatchEvent(changeEvent); + } + + /** + * Returns the value under the cursor + * @param: PointerEvent on slider + * @return: Slider value that correlates to the position under the pointer + */ + private calculateHandlePosition( + event: PointerEvent | MouseEvent, + model: ModelValue + ): number { + const rect = this.boundingClientRect; + const minOffset = rect.left; + const offset = event.clientX; + const size = rect.width; + + const normalized = (offset - minOffset) / size; + const value = model.normalization.fromNormalized( + normalized, + model.range.min, + model.range.max + ); + + /* c8 ignore next */ + return this.host.isLTR ? value : model.range.max - value; + } + + public renderHandle( + model: ModelValue, + index: number, + zIndex: number, + isMultiHandle: boolean + ): TemplateResult { + const classes = { + handle: true, + dragging: this.draggingHandle?.handleName === model.name, + 'handle-highlight': model.highlight, + }; + const style = { + [this.host.isLTR ? 'left' : 'right']: `${ + model.normalizedValue * 100 + }%`, + 'z-index': zIndex.toString(), + // Allow setting background per-handle + 'background-color': `var(--spectrum-slider-handle-background-color-${index}, var(--spectrum-slider-handle-default-background-color))`, + 'border-color': `var(--spectrum-slider-handle-border-color-${index}, var(-spectrum-slider-handle-default-border-color))`, + }; + const ariaLabelledBy = isMultiHandle ? `label input-${index}` : 'label'; + return html` +
this.handlePointerdown(event, model), + }, + { + type: 'pointermove', + fn: (event) => this.handlePointermove(event, model), + }, + { + type: ['pointerup', 'pointercancel'], + fn: (event) => this.handlePointerup(event, model), + } + )} + role="presentation" + > + +
+ `; + } + + public render(): TemplateResult[] { + this.clearHandleComponentCache(); + return this.model.map((model, index) => { + const zIndex = this.handleOrder.indexOf(model.name) + 1; + return this.renderHandle( + model, + index, + zIndex, + this.model.length > 1 + ); + }); + } + + /** + * Returns a list of track segment [start, end] tuples where the values are + * normalized to be between 0 and 1. + * @returns A list of track segment tuples [start, end] + */ + public trackSegments(): [number, number][] { + const values = this.model.map((model) => model.normalizedValue); + values.sort((a, b) => a - b); + + // The first segment always starts at 0 + values.unshift(0); + return values.map((value, index, array) => [ + value, + array[index + 1] ?? 1, + ]); + } + + private updateModel(): void { + const handles = [...this.handles.values()]; + + const getRangeAndClamp = (index: number): RangeAndClamp => { + const handle = handles[index]; + const previous = handles[index - 1]; + const next = handles[index + 1]; + + const min = + typeof handle.min === 'number' + ? handle.min + : (this.host.min as number); + const max = + typeof handle.max === 'number' + ? handle.max + : (this.host.max as number); + + const result: RangeAndClamp = { + range: { min: min, max: max }, + clamp: { min: min, max: max }, + }; + + if (handle.min === 'previous') { + if (previous) { + for (let j = index - 1; j >= 0; j--) { + const item = handles[j]; + if (typeof item.min === 'number') { + result.range.min = item.min; + break; + } + } + result.clamp.min = Math.max( + previous.value, + result.range.min + ); + /* c8 ignore next 5 */ + } else { + console.warn( + 'First slider handle cannot have attribute min="previous"' + ); + } + } + if (handle.max === 'next') { + if (next) { + for (let j = index + 1; j < handles.length; j++) { + const item = handles[j]; + if (typeof item.max === 'number') { + result.range.max = item.max; + break; + } + } + result.clamp.max = Math.min(next.value, result.range.max); + /* c8 ignore next 5 */ + } else { + console.warn( + 'Last slider handle cannot have attribute max="next"' + ); + } + } + return result; + }; + + const modelValues = handles.map((handle, index) => { + const rangeAndClamp = getRangeAndClamp(index); + const { toNormalized } = handle.normalization; + const clampedValue = Math.max( + Math.min(handle.value, rangeAndClamp.clamp.max), + rangeAndClamp.clamp.min + ); + const normalizedValue = toNormalized( + clampedValue, + rangeAndClamp.range.min, + rangeAndClamp.range.max + ); + const model = { + name: handle.handleName, + value: clampedValue, + normalizedValue, + highlight: handle.highlight, + step: handle.step ?? this.host.step, + normalization: handle.normalization, + handle, + ariaLabel: + handle !== this.host && handle?.label.length > 0 + ? handle.label + : undefined, + ...rangeAndClamp, + }; + return model; + }); + + this.model = modelValues; + } +} diff --git a/packages/slider/src/Slider.ts b/packages/slider/src/Slider.ts index 406fa7fe71..a1411a3742 100644 --- a/packages/slider/src/Slider.ts +++ b/packages/slider/src/Slider.ts @@ -16,30 +16,30 @@ import { CSSResultArray, TemplateResult, query, - PropertyValues, styleMap, ifDefined, + repeat, + classMap, } from '@spectrum-web-components/base'; -import { streamingListener } from '@spectrum-web-components/base/src/streaming-listener.js'; import sliderStyles from './slider.css.js'; import { ObserveSlotText } from '@spectrum-web-components/shared/src/observe-slot-text.js'; -import { Focusable } from '@spectrum-web-components/shared/src/focusable.js'; import { StyleInfo } from 'lit-html/directives/style-map'; +import { HandleController, HandleValueDictionary } from './HandleController.js'; +import { SliderHandle } from './SliderHandle.js'; export const variants = ['filled', 'ramp', 'range', 'tick']; -export class Slider extends ObserveSlotText(Focusable, '') { +export class Slider extends ObserveSlotText(SliderHandle, '') { public static get styles(): CSSResultArray { return [sliderStyles]; } + public handleController: HandleController = new HandleController(this); + @property() public type = ''; - @property({ type: Number, reflect: true }) - public value = 10; - @property({ type: String }) public set variant(variant: string) { const oldVariant = this.variant; @@ -60,32 +60,46 @@ export class Slider extends ObserveSlotText(Focusable, '') { return this._variant; } + public get values(): HandleValueDictionary { + return this.handleController.values; + } + + public get handleName(): string { + return 'value'; + } + /* Ensure that a '' value for `variant` removes the attribute instead of a blank value */ private _variant = ''; - @property({ attribute: false }) - public getAriaValueText: (value: number) => string = (value) => `${value}`; + @property({ type: String }) + public language: string = navigator.language; @property({ attribute: false }) + public getAriaValueText: (values: Map) => string = ( + values + ) => { + const valueArray = [...values.values()]; + if (valueArray.length === 2) + return `${valueArray[0]} - ${valueArray[1]}`; + return valueArray.join(', '); + }; + private get ariaValueText(): string { if (!this.getAriaValueText) { return `${this.value}`; } - return this.getAriaValueText(this.value); + return this.getAriaValueText(this.handleController.formattedValues); } - @property() - public label = ''; + @property({ type: Boolean, reflect: true, attribute: 'hide-value-label' }) + public hideValueLabel = false; - @property({ reflect: true, attribute: 'aria-label' }) - public ariaLabel?: string; + @property({ type: Number, reflect: true }) + public min = 0; - @property({ type: Number }) + @property({ type: Number, reflect: true }) public max = 100; - @property({ type: Number }) - public min = 0; - @property({ type: Number }) public step = 1; @@ -98,25 +112,15 @@ export class Slider extends ObserveSlotText(Focusable, '') { @property({ type: Boolean, reflect: true }) public disabled = false; - @property({ type: Boolean, reflect: true }) - public dragging = false; - - @property({ type: Boolean, reflect: true, attribute: 'handle-highlight' }) - public handleHighlight = false; - - @query('#handle') - private handle!: HTMLDivElement; - - @query('#input') - private input!: HTMLInputElement; - @query('#label') - private labelEl!: HTMLLabelElement; + public labelEl!: HTMLLabelElement; - private boundingClientRect?: DOMRect; + public get numberFormat(): Intl.NumberFormat { + return this.getNumberFormat(); + } public get focusElement(): HTMLElement { - return this.input; + return this.handleController.focusElement; } protected render(): TemplateResult { @@ -125,58 +129,45 @@ export class Slider extends ObserveSlotText(Focusable, '') { `; } - protected updated(changedProperties: PropertyValues): void { - if (changedProperties.has('value')) { - if (this.value === this.input.valueAsNumber) { - this.dispatchInputEvent(); - } else { - this.value = this.input.valueAsNumber; - } - } + public connectedCallback(): void { + super.connectedCallback(); + this.handleController.hostConnected(); + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + this.handleController.hostDisconnected(); + } + + public update(changedProperties: Map): void { + this.handleController.hostUpdate(); + super.update(changedProperties); } private renderLabel(): TemplateResult { return html`
-
`; } - private renderTrackLeft(): TemplateResult { - if (this.variant === 'ramp') { - return html``; - } - return html` - - `; - } - - private renderTrackRight(): TemplateResult { - if (this.variant === 'ramp') { - return html``; - } - return html` - - `; - } - private renderRamp(): TemplateResult { if (this.variant !== 'ramp') { return html``; @@ -202,7 +193,8 @@ export class Slider extends ObserveSlotText(Focusable, '') { return html``; } const tickStep = this.tickStep || this.step; - const tickCount = (this.max - this.min) / tickStep; + const tickCount = + ((this.max as number) - (this.min as number)) / tickStep; const partialFit = tickCount % 1 !== 0; const ticks = new Array(Math.floor(tickCount + 1)); ticks.fill(0, 0, tickCount + 1); @@ -232,194 +224,65 @@ export class Slider extends ObserveSlotText(Focusable, '') { `; } - private renderHandle(): TemplateResult { + private renderTrackSegment(start: number, end: number): TemplateResult { + if (this.variant === 'ramp') { + return html``; + } return html` + > `; } private renderTrack(): TemplateResult { + const segments = this.handleController.trackSegments(); + + const trackItems = [ + { id: 'track0', html: this.renderTrackSegment(...segments[0]) }, + { id: 'ramp', html: this.renderRamp() }, + { id: 'ticks', html: this.renderTicks() }, + { id: 'handles', html: this.handleController.render() }, + ...segments.slice(1).map(([start, end], index) => ({ + id: `track${index + 1}`, + html: this.renderTrackSegment(start, end), + })), + ]; + return html`
- ${this.renderTrackLeft()} ${this.renderRamp()} - ${this.renderTicks()} ${this.renderHandle()} - ${this.renderTrackRight()} + ${repeat( + trackItems, + (item) => item.id, + (item) => item.html + )}
`; } - private handlePointerdown(event: PointerEvent): void { - if (this.disabled || event.button !== 0) { - event.preventDefault(); - return; - } - this.boundingClientRect = this.getBoundingClientRect(); - this.labelEl.click(); - this.dragging = true; - this.handle.setPointerCapture(event.pointerId); - } - - private handlePointerup(event: PointerEvent): void { - // Retain focus on input element after mouse up to enable keyboard interactions - this.labelEl.click(); - this.handleHighlight = false; - this.dragging = false; - this.handle.releasePointerCapture(event.pointerId); - this.dispatchChangeEvent(); - } - - private handlePointermove(event: PointerEvent): void { - if (!this.dragging) { - return; - } - this.value = this.calculateHandlePosition(event); - } - /** * Move the handle under the cursor and begin start a pointer capture when the track * is moused down */ private handleTrackPointerdown(event: PointerEvent): void { - if (event.target === this.handle) { - return; - } - - event.stopPropagation(); - event.preventDefault(); - const applyDefault = this.handle.dispatchEvent( - new PointerEvent('pointerdown', event) - ); - if (applyDefault) { - this.handlePointermove(event); - } - } - - /** - * Keep the slider value property in sync with the input element's value - */ - private onInputChange(): void { - const inputValue = parseFloat(this.input.value); - this.value = inputValue; - - this.dispatchChangeEvent(); - } - - private onInputFocus(): void { - let isFocusVisible; - try { - isFocusVisible = - this.input.matches(':focus-visible') || - this.matches('.focus-visible'); - } catch (error) { - isFocusVisible = this.matches('.focus-visible'); - } - this.handleHighlight = isFocusVisible; - } - - private onInputBlur(): void { - this.handleHighlight = false; - } - - /** - * Returns the value under the cursor - * @param: PointerEvent on slider - * @return: Slider value that correlates to the position under the pointer - */ - private calculateHandlePosition(event: PointerEvent | MouseEvent): number { - if (!this.boundingClientRect) { - return this.value; - } - const rect = this.boundingClientRect; - const minOffset = rect.left; - const offset = event.clientX; - const size = rect.width; - - const percent = (offset - minOffset) / size; - const value = this.min + (this.max - this.min) * percent; - - return this.isLTR ? value : this.max - value; - } - - private dispatchInputEvent(): void { - if (!this.dragging) { + const target = event.target as HTMLElement; + if (target.classList.contains('handle')) { return; } - const inputEvent = new Event('input', { - bubbles: true, - composed: true, - }); - - this.dispatchEvent(inputEvent); + this.handleController.beginTrackDrag(event); } - private dispatchChangeEvent(): void { - this.input.value = this.value.toString(); - - const changeEvent = new Event('change', { - bubbles: true, - composed: true, - }); - - this.dispatchEvent(changeEvent); - } - - /** - * Ratio representing the slider's position on the track - */ - private get trackProgress(): number { - const range = this.max - this.min; - const progress = this.value - this.min; - - return progress / range; - } - - private get trackStartStyles(): StyleInfo { - return { - width: `${this.trackProgress * 100}%`, - '--spectrum-slider-track-background-size': `calc(100% / ${this.trackProgress})`, + private trackSegmentStyles(start: number, end: number): StyleInfo { + const size = end - start; + const styles: StyleInfo = { + width: `${size * 100}%`, + '--spectrum-slider-track-background-size': `${(1 / size) * 100}%`, + '--spectrum-slider-track-segment-position': `${start * 100}%`, }; - } - - private get trackEndStyles(): StyleInfo { - return { - width: `${100 - this.trackProgress * 100}%`, - '--spectrum-slider-track-background-size': `calc(100% / ${ - 1 - this.trackProgress - })`, - }; - } - - private get handleStyle(): string { - return `${this.isLTR ? 'left' : 'right'}: ${this.trackProgress * 100}%`; + return styles; } } diff --git a/packages/slider/src/SliderHandle.ts b/packages/slider/src/SliderHandle.ts new file mode 100644 index 0000000000..9a8893f265 --- /dev/null +++ b/packages/slider/src/SliderHandle.ts @@ -0,0 +1,173 @@ +/* +Copyright 2021 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { property, PropertyValues } from '@spectrum-web-components/base'; +import { Focusable } from '@spectrum-web-components/shared/src/focusable.js'; +import { + NumberFormatter, + NumberFormatOptions, +} from '@internationalized/number'; + +export type HandleMin = number | 'previous'; +export type HandleMax = number | 'next'; + +export type HandleValues = { + name: string; + value: number; +}[]; + +export interface Controller { + inputForHandle(handle: SliderHandle): HTMLInputElement | undefined; + requestUpdate(): void; + setValueFromHandle(handle: SliderHandle): void; + handleHasChanged(handle: SliderHandle): void; + language: string; +} + +export type SliderNormalization = { + toNormalized: (value: number, min: number, max: number) => number; + fromNormalized: (value: number, min: number, max: number) => number; +}; + +export const defaultNormalization: SliderNormalization = { + toNormalized(value: number, min: number, max: number) { + return (value - min) / (max - min); + }, + fromNormalized(value: number, min: number, max: number) { + return value * (max - min) + min; + }, +}; + +const MinConverter = { + fromAttribute: (value: string): number | 'previous' => { + if (value === 'previous') return value; + return parseFloat(value); + }, + toAttribute: (value: 'previous' | number): string => { + return value.toString(); + }, +}; + +const MaxConverter = { + fromAttribute: (value: string): number | 'next' => { + if (value === 'next') return value; + return parseFloat(value); + }, + toAttribute: (value: 'next' | number): string => { + return value.toString(); + }, +}; + +export class SliderHandle extends Focusable { + public handleController?: Controller; + + public get handleName(): string { + return this.name; + } + + public get focusElement(): HTMLElement { + /* c8 ignore next */ + return this.handleController?.inputForHandle(this) ?? this; + } + + @property({ type: Number }) + value = 10; + + @property({ type: Boolean, reflect: true }) + public dragging = false; + + @property({ type: Boolean }) + public highlight = false; + + @property({ type: String }) + public name = ''; + + @property({ reflect: true, converter: MinConverter }) + public min?: number | 'previous'; + + @property({ reflect: true, converter: MaxConverter }) + public max?: number | 'next'; + + @property({ type: Number, reflect: true }) + public step?: number; + + @property({ type: Object, attribute: 'format-options' }) + public formatOptions?: NumberFormatOptions; + + @property({ type: String }) + public label = ''; + + @property({ attribute: false }) + public getAriaHandleText: ( + value: number, + numberFormat: NumberFormatter + ) => string = (value, numberFormat) => { + return numberFormat.format(value); + }; + + protected updated(changedProperties: PropertyValues): void { + if (changedProperties.has('value')) { + const oldValue = changedProperties.get('value'); + if (oldValue != null) { + this.handleController /* c8 ignore next */ + ?.setValueFromHandle(this); + } + } + if ( + changedProperties.has('numberFormat') || + changedProperties.has('language') + ) { + delete this._numberFormatCache; + } + this.handleController?.handleHasChanged(this); + super.updated(changedProperties); + } + + @property({ attribute: false }) + public normalization: SliderNormalization = defaultNormalization; + + public dispatchInputEvent(): void { + if (!this.dragging) { + return; + } + const inputEvent = new Event('input', { + bubbles: true, + composed: true, + }); + + this.dispatchEvent(inputEvent); + } + + protected _numberFormatCache: + | { numberFormat: NumberFormatter; language: string } + | undefined; + protected getNumberFormat(): Intl.NumberFormat { + /* c8 ignore next */ + const language = this.handleController?.language ?? navigator.language; + if ( + !this._numberFormatCache || + language !== this._numberFormatCache.language + ) { + this._numberFormatCache = { + language, + numberFormat: new NumberFormatter(language, this.formatOptions), + }; + } + /* c8 ignore next */ + return this._numberFormatCache?.numberFormat; + } + + public get numberFormat(): NumberFormatter | undefined { + if (!this.formatOptions) return; + return this.getNumberFormat(); + } +} diff --git a/packages/slider/src/index.ts b/packages/slider/src/index.ts index bab2557acf..976b7056c8 100644 --- a/packages/slider/src/index.ts +++ b/packages/slider/src/index.ts @@ -10,3 +10,5 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ export * from './Slider.js'; +export * from './SliderHandle.js'; +export * from './HandleController.js'; diff --git a/packages/slider/src/slider.css b/packages/slider/src/slider.css index 58dd517119..12eb1c738c 100644 --- a/packages/slider/src/slider.css +++ b/packages/slider/src/slider.css @@ -12,10 +12,21 @@ governing permissions and limitations under the License. @import './spectrum-slider.css'; -/* +:host { + --spectrum-slider-handle-default-background-color: var( + --spectrum-slider-m-handle-background-color, + var(--spectrum-alias-background-color-transparent) + ); + --spectrum-slider-handle-default-border-color: var( + --spectrum-slider-m-handle-border-color, + var(--spectrum-global-color-gray-700) + ); +} + +/* * Removes blue outline from :host when it is being focused. * This situation is not addressed in spectrum-css because the slider element itself - * is not intended to receive focus. + * is not intended to receive focus. * This is not the case with web-components. The :host will receive focus when * interacting with the slider via the mouse. */ @@ -62,14 +73,6 @@ governing permissions and limitations under the License. background-size: var(--spectrum-slider-track-background-size) !important; } -:host([dir='ltr']) #track-right:before { - background-position: 100%; -} - -:host([dir='rtl']) #track-left:before { - background-position: 100%; -} - :host([dir='ltr']) .track:before { background: var( --spectrum-slider-track-color, @@ -87,3 +90,40 @@ governing permissions and limitations under the License. ) ); } + +:host([dir='ltr']) .track:last-of-type:before { + background-position: 100%; +} + +:host([dir='rtl']) .track:first-of-type:before { + background-position: 100%; +} + +.track:not(:first-of-type):not(:last-of-type) { + padding-left: calc( + var(--spectrum-slider-handle-width) / 2 + + var(--spectrum-slider-track-handleoffset) + ) !important; + padding-right: calc( + var(--spectrum-slider-handle-width) / 2 + + var(--spectrum-slider-track-handleoffset) + ) !important; +} + +:host([dir='ltr']) .track:not(:first-of-type):not(:last-of-type) { + left: var(--spectrum-slider-track-segment-position); +} + +:host([dir='rtl']) .track:not(:first-of-type):not(:last-of-type) { + right: var(--spectrum-slider-track-segment-position); +} + +.visually-hidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} diff --git a/packages/slider/src/spectrum-config.js b/packages/slider/src/spectrum-config.js index 315ef8b659..8c1e2dd46f 100644 --- a/packages/slider/src/spectrum-config.js +++ b/packages/slider/src/spectrum-config.js @@ -22,16 +22,6 @@ const config = { name: 'disabled', selector: '.is-disabled', }, - { - type: 'boolean', - selector: '.is-focused', - name: 'handle-highlight', - }, - { - type: 'boolean', - selector: '.is-dragged', - name: 'dragging', - }, { type: 'enum', name: 'variant', @@ -49,18 +39,10 @@ const config = { selector: '.spectrum-Slider-buffer', name: 'buffer', }, - { - selector: '.spectrum-Slider-handle', - name: 'handle', - }, { selector: '.spectrum-Slider-ramp', name: 'ramp', }, - { - selector: '.spectrum-Slider-input', - name: 'input', - }, { selector: '.spectrum-Slider-controls', name: 'controls', @@ -83,6 +65,22 @@ const config = { }, ], classes: [ + { + selector: '.spectrum-Slider-handle', + name: 'handle', + }, + { + selector: '.is-focused', + name: 'handle-highlight', + }, + { + selector: '.is-dragged', + name: 'dragging', + }, + { + selector: '.spectrum-Slider-input', + name: 'input', + }, { selector: '.spectrum-Slider-track', name: 'track', diff --git a/packages/slider/src/spectrum-slider.css b/packages/slider/src/spectrum-slider.css index 22093ae8a4..d889315f6a 100644 --- a/packages/slider/src/spectrum-slider.css +++ b/packages/slider/src/spectrum-slider.css @@ -463,25 +463,25 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ width: 100%; height: 100%; } -:host([dir='ltr']) #handle { +:host([dir='ltr']) .handle { /* [dir=ltr] .spectrum-Slider-handle */ left: 0; } -:host([dir='rtl']) #handle { +:host([dir='rtl']) .handle { /* [dir=rtl] .spectrum-Slider-handle */ right: 0; } -:host([dir='ltr']) #handle { +:host([dir='ltr']) .handle { /* [dir=ltr] .spectrum-Slider-handle */ margin-left: calc(var(--spectrum-slider-handle-width) / -2); margin-right: 0; } -:host([dir='rtl']) #handle { +:host([dir='rtl']) .handle { /* [dir=rtl] .spectrum-Slider-handle */ margin-right: calc(var(--spectrum-slider-handle-width) / -2); margin-left: 0; } -#handle { +.handle { /* .spectrum-Slider-handle */ position: absolute; top: calc(var(--spectrum-slider-height) / 2); @@ -499,25 +499,25 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ ease-in-out; outline: none; } -:host([dragging]) #handle, -:host([handle-highlight]) #handle, -#handle:active { +.handle.dragging, +.handle.handle-highlight, +.handle:active { /* .spectrum-Slider-handle.is-dragged, * .spectrum-Slider-handle.is-focused, * .spectrum-Slider-handle:active */ border-width: var(--spectrum-slider-handle-border-size-down); } -:host([dragging]) #handle, -:host([handle-highlight]) #handle, -#handle.is-tophandle, -#handle:active { +.handle.dragging, +.handle.handle-highlight, +.handle.is-tophandle, +.handle:active { /* .spectrum-Slider-handle.is-dragged, * .spectrum-Slider-handle.is-focused, * .spectrum-Slider-handle.is-tophandle, * .spectrum-Slider-handle:active */ z-index: 3; } -#handle:before { +.handle:before { /* .spectrum-Slider-handle:before */ content: ' '; display: block; @@ -533,7 +533,7 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ transform: translate(-50%, -50%); border-radius: 100%; } -:host([handle-highlight]) #handle:before { +.handle.handle-highlight:before { /* .spectrum-Slider-handle.is-focused:before */ width: calc( var(--spectrum-slider-handle-width) + @@ -550,15 +550,15 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ ) * 2 ); } -:host([dir='ltr']) #input { +:host([dir='ltr']) .input { /* [dir=ltr] .spectrum-Slider-input */ left: var(--spectrum-slider-input-left); } -:host([dir='rtl']) #input { +:host([dir='rtl']) .input { /* [dir=rtl] .spectrum-Slider-input */ right: var(--spectrum-slider-input-left); } -#input { +.input { /* .spectrum-Slider-input */ margin: 0; width: var(--spectrum-slider-handle-width); @@ -573,7 +573,7 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ border: 0; pointer-events: none; } -#input:focus { +.input:focus { /* .spectrum-Slider-input:focus */ outline: none; } @@ -709,7 +709,7 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ /* .spectrum-Slider.is-disabled */ cursor: default; } -:host([disabled]) #handle { +:host([disabled]) .handle { /* .spectrum-Slider.is-disabled .spectrum-Slider-handle */ cursor: default; pointer-events: none; @@ -768,7 +768,7 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ var(--spectrum-global-color-gray-400) ); } -#handle { +.handle { /* .spectrum-Slider-handle */ border-color: var( --spectrum-slider-m-handle-border-color, @@ -779,21 +779,21 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ var(--spectrum-alias-background-color-transparent) ); } -#handle:hover { +.handle:hover { /* .spectrum-Slider-handle:hover */ border-color: var( --spectrum-slider-m-handle-border-color-hover, var(--spectrum-global-color-gray-800) ); } -:host([handle-highlight]) #handle { +.handle.handle-highlight { /* .spectrum-Slider-handle.is-focused */ border-color: var( --spectrum-slider-m-handle-border-color-key-focus, var(--spectrum-global-color-gray-800) ); } -:host([handle-highlight]) #handle:before { +.handle.handle-highlight:before { /* .spectrum-Slider-handle.is-focused:before */ box-shadow: 0 0 0 var( @@ -802,8 +802,8 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ ) var(--spectrum-slider-m-handle-focus-ring-color-key-focus); } -:host([dragging]) #handle, -#handle:active { +.handle.dragging, +.handle:active { /* .spectrum-Slider-handle.is-dragged, * .spectrum-Slider-handle:active */ border-color: var( @@ -811,7 +811,7 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ var(--spectrum-global-color-gray-800) ); } -:host([variant='ramp']) #handle { +:host([variant='ramp']) .handle { /* .spectrum-Slider--ramp .spectrum-Slider-handle */ box-shadow: 0 0 0 4px var( @@ -819,7 +819,7 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ var(--spectrum-global-color-gray-100) ); } -#input { +.input { /* .spectrum-Slider-input */ background: transparent; } @@ -830,7 +830,7 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ var(--spectrum-alias-track-color-default) ); } -:host([dragging]) #handle { +.handle.dragging { /* .spectrum-Slider-handle.is-dragged */ border-color: var( --spectrum-slider-m-handle-border-color-down, @@ -852,7 +852,7 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ /* .spectrum-Slider.is-disabled .spectrum-Slider-labelContainer */ color: var(--spectrum-slider-m-label-text-color-disabled); } -:host([disabled]) #handle { +:host([disabled]) .handle { /* .spectrum-Slider.is-disabled .spectrum-Slider-handle */ border-color: var( --spectrum-slider-m-handle-border-color-disabled, @@ -863,8 +863,8 @@ THIS FILE IS MACHINE GENERATED. DO NOT EDIT */ var(--spectrum-global-color-gray-100) ); } -:host([disabled]) #handle:active, -:host([disabled]) #handle:hover { +:host([disabled]) .handle:active, +:host([disabled]) .handle:hover { /* .spectrum-Slider.is-disabled .spectrum-Slider-handle:active, * .spectrum-Slider.is-disabled .spectrum-Slider-handle:hover */ border-color: var( diff --git a/packages/slider/stories/slider.stories.ts b/packages/slider/stories/slider.stories.ts index 3649ec3139..bf82161edd 100644 --- a/packages/slider/stories/slider.stories.ts +++ b/packages/slider/stories/slider.stories.ts @@ -12,21 +12,62 @@ governing permissions and limitations under the License. import { html } from 'lit-html'; import '../sp-slider.js'; -import { Slider } from '../'; +import '../sp-slider-handle.js'; +import { Slider, SliderHandle, HandleValues, variants } from '../'; import { TemplateResult } from '@spectrum-web-components/base'; +import { spreadProps } from '@open-wc/lit-helpers'; -const action = (msg1: string) => (msg2: string | number): void => - console.log(msg1, msg2); +const action = (msg1: string) => (msg2: string | HandleValues): void => { + const message = + typeof msg2 === 'string' ? msg2 : JSON.stringify(msg2, null, 2); + console.log(msg1, message); +}; export default { component: 'sp-slider', title: 'Slider', + argTypes: { + variant: { + name: 'Variant', + description: 'Determines the style of slider.', + table: { + type: { summary: 'string' }, + defaultValue: { summary: undefined }, + }, + control: { + type: 'inline-radio', + options: [undefined, ...variants], + }, + }, + tickStep: { + name: 'Tick Step', + description: 'Tick spacing on slider.', + table: { + type: { summary: 'number' }, + defaultValue: { summary: 0.1 }, + }, + control: { + type: 'number', + }, + }, + }, + args: { + variant: undefined, + tickStep: 0.1, + }, }; -export const Default = (): TemplateResult => { +interface StoryArgs { + variant?: string; + tickStep?: number; +} + +export const Default = (args: StoryArgs): TemplateResult => { const handleEvent = (event: Event): void => { const target = event.target as Slider; - action(event.type)(target.value); + if (target.value != null) { + action(event.type)(target.value.toString()); + } }; return html`
@@ -37,10 +78,8 @@ export const Default = (): TemplateResult => { step="0.01" @input=${handleEvent} @change=${handleEvent} - .getAriaValueText=${(value: number) => - new Intl.NumberFormat('en-US', { style: 'percent' }).format( - value - )} + .formatOptions=${{ style: 'percent' }} + ...=${spreadProps(args)} > Opacity @@ -48,10 +87,12 @@ export const Default = (): TemplateResult => { `; }; -export const Gradient = (): TemplateResult => { +export const Gradient = (args: StoryArgs): TemplateResult => { const handleEvent = (event: Event): void => { const target = event.target as Slider; - action(event.type)(target.value); + if (target.value != null) { + action(event.type)(target.value.toString()); + } }; return html`
{ id="opacity-slider" @input=${handleEvent} @change=${handleEvent} + ...=${spreadProps(args)} >
`; }; +Gradient.args = { + variant: undefined, +}; -export const tick = (): TemplateResult => { +export const tick = (args: StoryArgs): TemplateResult => { return html` `; }; +tick.args = { + variant: 'tick', + tickStep: 5, +}; -export const Disabled = (): TemplateResult => { +export const Disabled = (args: StoryArgs): TemplateResult => { return html`
{ min="0" max="20" label="Intensity" + ...=${spreadProps(args)} >
`; }; -export const focusTabDemo = (): TemplateResult => { +export const ExplicitHandle = (args: StoryArgs): TemplateResult => { + const handleEvent = (event: Event): void => { + const target = event.target as SliderHandle; + if (target.value != null) { + if (typeof target.value === 'object') { + action(event.type)(target.value); + } else { + action(event.type)(`${target.name}: ${target.value}`); + } + } + }; + return html` +
+ + Intensity + + +
+ `; +}; + +export const TwoHandles = (args: StoryArgs): TemplateResult => { + const handleEvent = (event: Event): void => { + const target = event.target as SliderHandle; + if (target.value != null) { + if (typeof target.value === 'object') { + action(event.type)(target.value); + } else { + action(event.type)(`${target.name}: ${target.value}`); + } + } + }; + return html` +
+ + Output Levels + + + +
+ `; +}; +TwoHandles.args = { + variant: 'range', + tickStep: 10, +}; + +export const ThreeHandlesOrdered = (args: StoryArgs): TemplateResult => { + const handleEvent = (event: Event): void => { + const target = event.target as SliderHandle; + if (target.value != null) { + if (typeof target.value === 'object') { + action(event.type)(target.value); + } else { + action(event.type)(`${target.name}: ${target.value}`); + } + } + }; + return html` +
+ + Output Levels + + + + +
+ `; +}; +ThreeHandlesOrdered.args = { + tickStep: 10, +}; + +// This is a very complex example from an actual application. +// +// The first and last handles go from 0 to 255 in a linear fashion. +// The last and first handles are ordered so that the last handle +// must be greater than or equal to the first handle. +// +// The middle handle's range goes from 9.99 to 0.01, counting down. +// the middle handle's limits are set by the outer handles such that +// the position of the left handle is the staring value (9.99) for the +// middle handle. And the position of the right handle is the end +// value (0.01). That means that the middle handle will move +// proportionally as you move the outer handles. +// +// The two other interesting features of the middle handle are that +// it counts down, and that it does so exponentially for the first +// half of its range. +// +// Because the specification for the tag in HTML says that the +// min should be less than the max, we do a double normalization to make +// this work. The middle handle is considered to go between 0 and 1, +// where 0 is the left handle's position and 1 is the right handle's +// position. We then do the appropriate calculation to convert that +// value into one between 9.99 and 0.01 for display to the user. +// +// One iteresting thing to note is that the normalization function +// can also be used to enforce clamping. +// +export const ThreeHandlesComplex = (args: StoryArgs): TemplateResult => { + const values: { [key: string]: number } = { + black: 50, + gray: 4.98, + white: 225, + }; + const handleEvent = (event: Event): void => { + const target = event.target as SliderHandle; + if (target.value != null) { + if (typeof target.value === 'object') { + action(event.type)(target.value); + } else { + action(event.type)(`${target.name}: ${target.value}`); + } + values[target.name] = target.value; + } + }; + const grayNormalization = { + toNormalized(value: number) { + const normalizedBlack = values.black / 255; + const normalizedWhite = values.white / 255; + const clamped = Math.max(Math.min(value, 1), 0); + return ( + clamped * (normalizedWhite - normalizedBlack) + normalizedBlack + ); + }, + fromNormalized(value: number) { + const normalizedBlack = values.black / 255; + const normalizedWhite = values.white / 255; + const clamped = Math.max( + Math.min(value, normalizedWhite), + normalizedBlack + ); + + return ( + (clamped - normalizedBlack) / + (normalizedWhite - normalizedBlack) + ); + }, + }; + const blackNormalization = { + toNormalized(value: number) { + const clamped = Math.min(value, values.white); + return clamped / 255; + }, + fromNormalized(value: number) { + const denormalized = value * 255; + return Math.min(denormalized, values.white); + }, + }; + const whiteNormalization = { + toNormalized(value: number) { + const clamped = Math.max(value, values.black); + return clamped / 255; + }, + fromNormalized(value: number) { + const denormalized = value * 255; + return Math.max(denormalized, values.black); + }, + }; + const computeGray = (value: number): string => { + let result = 1.0; + if (value > 0.5) { + result = Math.max(2 * (1 - value), 0.01); + } else if (value < 0.5) { + result = ((1 - 2 * value) * (Math.sqrt(9.99) - 1) + 1) ** 2; + } + const formatOptions = { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }; + return new Intl.NumberFormat(navigator.language, formatOptions).format( + result + ); + }; + return html` +
+ + Output Levels + + + + +
+ `; +}; +ThreeHandlesOrdered.args = { + tickStep: 10, +}; + +export const focusTabDemo = (args: StoryArgs): TemplateResult => { const value = 50; const min = 0; const max = 100; @@ -116,6 +428,7 @@ export const focusTabDemo = (): TemplateResult => { max="${max}" label="Opacity" id="opacity-slider-opacity" + ...=${spreadProps(args)} >
@@ -126,6 +439,7 @@ export const focusTabDemo = (): TemplateResult => { max="${max}" label="Lightness" id="opacity-slider-lightness" + ...=${spreadProps(args)} >
@@ -136,6 +450,7 @@ export const focusTabDemo = (): TemplateResult => { max="${max}" label="Saturation" id="opacity-slider-saturation" + ...=${spreadProps(args)} >
`; diff --git a/packages/slider/test/slider.test.ts b/packages/slider/test/slider.test.ts index 7ace49057f..3e511e2816 100644 --- a/packages/slider/test/slider.test.ts +++ b/packages/slider/test/slider.test.ts @@ -11,7 +11,8 @@ governing permissions and limitations under the License. */ import '../sp-slider.js'; -import { Slider } from '../'; +import '../sp-slider-handle.js'; +import { Slider, SliderHandle } from '../'; import { tick } from '../stories/slider.stories.js'; import { fixture, elementUpdated, html, expect } from '@open-wc/testing'; import { sendKeys, executeServerCommand } from '@web/test-runner-commands'; @@ -80,12 +81,17 @@ describe('Slider', () => { expect(el.value).to.equal(20); }); it('accepts keyboard events', async () => { - const el = await fixture(tick()); + const el = await fixture( + tick({ + variant: 'tick', + tickStep: 5, + }) + ); await elementUpdated(el); expect(el.value).to.equal(10); - expect(el.handleHighlight).to.be.false; + expect(el.highlight).to.be.false; el.focus(); await sendKeys({ @@ -94,14 +100,14 @@ describe('Slider', () => { await elementUpdated(el); expect(el.value).to.equal(9); - expect(el.handleHighlight).to.be.true; + expect(el.highlight).to.be.true; await sendKeys({ press: 'ArrowUp', }); await elementUpdated(el); expect(el.value).to.equal(10); - expect(el.handleHighlight).to.be.true; + expect(el.highlight).to.be.true; }); it('accepts pointer events', async () => { let pointerId = -1; @@ -114,10 +120,10 @@ describe('Slider', () => { await elementUpdated(el); expect(el.dragging).to.be.false; - expect(el.handleHighlight).to.be.false; + expect(el.highlight).to.be.false; expect(pointerId).to.equal(-1); - const handle = el.shadowRoot.querySelector('#handle') as HTMLDivElement; + const handle = el.shadowRoot.querySelector('.handle') as HTMLDivElement; handle.setPointerCapture = (id: number) => (pointerId = id); handle.releasePointerCapture = (id: number) => (pointerId = id); handle.dispatchEvent( @@ -153,7 +159,7 @@ describe('Slider', () => { await elementUpdated(el); expect(el.dragging).to.be.false; - expect(el.handleHighlight).to.be.false; + expect(el.highlight).to.be.false; expect(pointerId, '3').to.equal(2); handle.dispatchEvent( @@ -194,7 +200,7 @@ describe('Slider', () => { const controls = el.shadowRoot.querySelector( '#controls' ) as HTMLDivElement; - const handle = el.shadowRoot.querySelector('#handle') as HTMLDivElement; + const handle = el.shadowRoot.querySelector('.handle') as HTMLDivElement; handle.setPointerCapture = (id: number) => (pointerId = id); handle.releasePointerCapture = (id: number) => (pointerId = id); @@ -275,7 +281,7 @@ describe('Slider', () => { expect(pointerId).to.equal(-1); expect(el.value).to.equal(10); - const handle = el.shadowRoot.querySelector('#handle') as HTMLDivElement; + const handle = el.shadowRoot.querySelector('.handle') as HTMLDivElement; handle.setPointerCapture = (id: number) => (pointerId = id); handle.dispatchEvent( @@ -317,7 +323,7 @@ describe('Slider', () => { expect(el.value).to.equal(10); - const handle = el.shadowRoot.querySelector('#handle') as HTMLDivElement; + const handle = el.shadowRoot.querySelector('.handle') as HTMLDivElement; await executeServerCommand('send-mouse', { steps: [ { @@ -332,7 +338,7 @@ describe('Slider', () => { await elementUpdated(el); expect(el.dragging, 'is dragging').to.be.true; - expect(el.handleHighlight, 'not highlighted').to.be.false; + expect(el.highlight, 'not highlighted').to.be.false; handle.dispatchEvent( new PointerEvent('pointermove', { @@ -355,7 +361,7 @@ describe('Slider', () => { expect(el.value, 'initial').to.equal(10); - const handle = el.shadowRoot.querySelector('#handle') as HTMLDivElement; + const handle = el.shadowRoot.querySelector('.handle') as HTMLDivElement; handle.setPointerCapture = (id: number) => (pointerId = id); handle.releasePointerCapture = (id: number) => (pointerId = id); handle.dispatchEvent( @@ -377,7 +383,8 @@ describe('Slider', () => { expect(el.value, 'first pointerdown').to.equal(50); expect(el.dragging, 'is dragging').to.be.true; - expect(el.handleHighlight, 'not highlighted').to.be.false; + expect(el.classList.contains('handle-highlight'), 'not highlighted').to + .be.false; expect(pointerId).to.equal(100); handle.dispatchEvent( @@ -418,7 +425,8 @@ describe('Slider', () => { expect(el.value, 'second pointerdown').to.equal(50); expect(el.dragging, 'is dragging').to.be.true; - expect(el.handleHighlight, 'not highlighted').to.be.false; + expect(el.classList.contains('handle-highlight'), 'not highlighted').to + .be.false; handle.dispatchEvent( new PointerEvent('pointermove', { @@ -460,7 +468,7 @@ describe('Slider', () => { expect(el.value).to.equal(10); expect(inputSpy.callCount).to.equal(0); - const handle = el.shadowRoot.querySelector('#handle') as HTMLDivElement; + const handle = el.shadowRoot.querySelector('.handle') as HTMLDivElement; handle.setPointerCapture = (id: number) => (pointerId = id); handle.releasePointerCapture = (id: number) => (pointerId = id); handle.dispatchEvent( @@ -474,7 +482,7 @@ describe('Slider', () => { await elementUpdated(el); expect(el.dragging).to.be.true; - expect(el.handleHighlight).to.be.false; + expect(el.highlight).to.be.false; expect(pointerId, 'pointer id').to.equal(1); handle.dispatchEvent( @@ -511,7 +519,7 @@ describe('Slider', () => { expect(el.value).to.equal(10); expect(el.dragging).to.be.false; - const handle = el.shadowRoot.querySelector('#handle') as HTMLDivElement; + const handle = el.shadowRoot.querySelector('.handle') as HTMLDivElement; handle.dispatchEvent( new PointerEvent('pointermove', { @@ -534,7 +542,7 @@ describe('Slider', () => { expect(el.value).to.equal(10); - const input = el.shadowRoot.querySelector('#input') as HTMLInputElement; + const input = el.shadowRoot.querySelector('.input') as HTMLInputElement; input.value = '0'; input.dispatchEvent(new Event('change')); @@ -620,7 +628,7 @@ describe('Slider', () => { value="50" min="0" max="100" - .getAriaValueText=${(value: number) => `${value}%`} + .getAriaHandleText=${(value: number) => `${value}%`} >
` ); @@ -635,6 +643,75 @@ describe('Slider', () => { expect(input.getAttribute('aria-valuetext')).to.equal('100%'); }); + it('displays Intl.formatNumber results', async () => { + const el = await fixture( + html` + + ` + ); + + await elementUpdated(el); + + const input = el.focusElement as HTMLInputElement; + expect(input.getAttribute('aria-valuetext')).to.equal('50%'); + + el.value = 100; + await elementUpdated(el); + + expect(input.getAttribute('aria-valuetext')).to.equal('100%'); + }); + it('obeys lnaguage property', async () => { + let el = await fixture( + html` + + ` + ); + + await elementUpdated(el); + + let input = el.focusElement as HTMLInputElement; + expect(input.getAttribute('aria-valuetext')).to.equal('2,44'); + + el.language = 'en'; + await elementUpdated(el); + + expect(input.getAttribute('aria-valuetext')).to.equal('2.44'); + + el = await fixture( + html` + + + + ` + ); + + await elementUpdated(el); + + input = el.focusElement as HTMLInputElement; + expect(input.getAttribute('aria-valuetext')).to.equal('2,44'); + + el.language = 'en'; + await elementUpdated(el); + + expect(input.getAttribute('aria-valuetext')).to.equal('2.44'); + }); it('uses fallback ariaValueText', async () => { const el = await fixture( html` @@ -687,4 +764,296 @@ describe('Slider', () => { expect(el.value).to.equal(-100); }); + it('returns values for handles', async () => { + let el = await fixture( + html` + + + + + + ` + ); + + await elementUpdated(el); + + expect(el.values).to.deep.equal({ a: 10, b: 20, c: 30 }); + + const middleHandle = el.querySelector('#middle-handle') as SliderHandle; + middleHandle.value = 22; + + await elementUpdated(el); + + expect(el.values).to.deep.equal({ a: 10, b: 22, c: 30 }); + + el = await fixture( + html` + + ` + ); + expect(el.values).to.deep.equal({ value: 10 }); + + el = await fixture( + html` + + + + ` + ); + expect(el.values).to.deep.equal({ handle1: 10 }); + }); + it('clamps values for multi-handle slider', async () => { + const el = await fixture( + html` + + + + + + ` + ); + + await elementUpdated(el); + + expect(el.values).to.deep.equal({ a: 10, b: 20, c: 30 }); + + const firstHandle = el.querySelector('#first-handle') as SliderHandle; + const middleHandle = el.querySelector('#middle-handle') as SliderHandle; + const lastHandle = el.querySelector('#last-handle') as SliderHandle; + + firstHandle.value = 25; + await elementUpdated(el); + expect(el.values).to.deep.equal({ a: 20, b: 20, c: 30 }); + + firstHandle.value = 10; + await elementUpdated(el); + middleHandle.value = 5; + await elementUpdated(el); + expect(el.values).to.deep.equal({ a: 10, b: 10, c: 30 }); + + lastHandle.value = 11; + await elementUpdated(el); + expect(el.values).to.deep.equal({ a: 10, b: 10, c: 11 }); + + lastHandle.value = 7; + await elementUpdated(el); + expect(el.values).to.deep.equal({ a: 10, b: 10, c: 10 }); + }); + it('enforces next/previous max/min', async () => { + let el = await fixture( + html` + + + + + + ` + ); + + await elementUpdated(el); + + expect(el.values).to.deep.equal({ a: 10, b: 20, c: 30 }); + + let firstHandle = el.querySelector('#first-handle') as SliderHandle; + let lastHandle = el.querySelector('#last-handle') as SliderHandle; + + let firstInput = el.shadowRoot.querySelector( + '.handle[name="a"] > input' + ) as HTMLInputElement; + let middleInput = el.shadowRoot.querySelector( + '.handle[name="b"] > input' + ) as HTMLInputElement; + let lastInput = el.shadowRoot.querySelector( + '.handle[name="c"] > input' + ) as HTMLInputElement; + + expect(firstInput.min).to.equal('0'); + expect(firstInput.max).to.equal('20'); + + expect(middleInput.min).to.equal('10'); + expect(middleInput.max).to.equal('30'); + + expect(lastInput.min).to.equal('20'); + expect(lastInput.max).to.equal('100'); + + firstHandle.value = 15; + lastHandle.value = 85; + + await elementUpdated(el); + await elementUpdated(el); + + expect(firstInput.min).to.equal('0'); + expect(firstInput.max).to.equal('20'); + + expect(middleInput.min).to.equal('15'); + expect(middleInput.max).to.equal('85'); + + expect(lastInput.min).to.equal('20'); + expect(lastInput.max).to.equal('100'); + + el = await fixture( + html` + + + + + + ` + ); + + firstInput = el.shadowRoot.querySelector( + '.handle[name="a"] > input' + ) as HTMLInputElement; + middleInput = el.shadowRoot.querySelector( + '.handle[name="b"] > input' + ) as HTMLInputElement; + lastInput = el.shadowRoot.querySelector( + '.handle[name="c"] > input' + ) as HTMLInputElement; + + expect(firstInput.min).to.equal('0'); + expect(firstInput.max).to.equal('20'); + + expect(middleInput.min).to.equal('10'); + expect(middleInput.max).to.equal('30'); + + expect(lastInput.min).to.equal('20'); + expect(lastInput.max).to.equal('100'); + + firstHandle = el.querySelector('#first-handle') as SliderHandle; + lastHandle = el.querySelector('#last-handle') as SliderHandle; + + firstHandle.min = 5; + lastHandle.max = 95; + + await elementUpdated(el); + await elementUpdated(el); + + expect(firstInput.min).to.equal('5'); + expect(firstInput.max).to.equal('20'); + + expect(lastInput.min).to.equal('20'); + expect(lastInput.max).to.equal('95'); + }); + it('sends keyboard events to active handle', async () => { + // let pointerId = -1; + + const el = await fixture( + html` + + + + + + ` + ); + + await elementUpdated(el); + expect(el.values).to.deep.equal({ a: 10, b: 20, c: 30 }); + + const lastHandle = el.querySelector('#last-handle') as SliderHandle; + lastHandle.focus(); + + await sendKeys({ + press: 'ArrowDown', + }); + await elementUpdated(el); + expect(el.values).to.deep.equal({ a: 10, b: 20, c: 29 }); + }); });