/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { __decorate } from "tslib";
import '../../elevation/elevation.js';
import '../../focus/md-focus-ring.js';
import '../../ripple/ripple.js';
import { html, isServer, LitElement, nothing } from 'lit';
import { property, query, queryAsync, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { when } from 'lit/directives/when.js';
import { mixinDelegatesAria } from '../../internal/aria/delegate.js';
import { dispatchActivationClick, isActivationClick, } from '../../internal/events/form-label-activation.js';
import { redispatchEvent } from '../../internal/events/redispatch-event.js';
import { mixinElementInternals } from '../../labs/behaviors/element-internals.js';
import { getFormValue, mixinFormAssociated, } from '../../labs/behaviors/form-associated.js';
// Disable warning for classMap with destructuring
// tslint:disable:no-implicit-dictionary-conversion
// Separate variable needed for closure.
const sliderBaseClass = mixinDelegatesAria(mixinFormAssociated(mixinElementInternals(LitElement)));
/**
* Slider component.
*
*
* @fires change {Event} The native `change` event on
* [`<input>`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/change_event)
* --bubbles
* @fires input {InputEvent} The native `input` event on
* [`<input>`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event)
* --bubbles --composed
*/
export class Slider extends sliderBaseClass {
/**
* The HTML name to use in form submission for a range slider's starting
* value. Use `name` instead if both the start and end values should use the
* same name.
*/
get nameStart() {
return this.getAttribute('name-start') ?? this.name;
}
set nameStart(name) {
this.setAttribute('name-start', name);
}
/**
* The HTML name to use in form submission for a range slider's ending value.
* Use `name` instead if both the start and end values should use the same
* name.
*/
get nameEnd() {
return this.getAttribute('name-end') ?? this.nameStart;
}
set nameEnd(name) {
this.setAttribute('name-end', name);
}
// Note: start aria-* properties are only applied when range=true, which is
// why they do not need to handle both cases.
get renderAriaLabelStart() {
// Needed for closure conformance
const { ariaLabel } = this;
return (this.ariaLabelStart ||
(ariaLabel && `${ariaLabel} start`) ||
this.valueLabelStart ||
String(this.valueStart));
}
get renderAriaValueTextStart() {
return (this.ariaValueTextStart || this.valueLabelStart || String(this.valueStart));
}
// Note: end aria-* properties are applied for single and range sliders, which
// is why it needs to handle `this.range` (while start aria-* properties do
// not).
get renderAriaLabelEnd() {
// Needed for closure conformance
const { ariaLabel } = this;
if (this.range) {
return (this.ariaLabelEnd ||
(ariaLabel && `${ariaLabel} end`) ||
this.valueLabelEnd ||
String(this.valueEnd));
}
return ariaLabel || this.valueLabel || String(this.value);
}
get renderAriaValueTextEnd() {
if (this.range) {
return (this.ariaValueTextEnd || this.valueLabelEnd || String(this.valueEnd));
}
// Needed for conformance
const { ariaValueText } = this;
return ariaValueText || this.valueLabel || String(this.value);
}
constructor() {
super();
/**
* The slider minimum value
*/
this.min = 0;
/**
* The slider maximum value
*/
this.max = 100;
/**
* An optional label for the slider's value displayed when range is
* false; if not set, the label is the value itself.
*/
this.valueLabel = '';
/**
* An optional label for the slider's start value displayed when
* range is true; if not set, the label is the valueStart itself.
*/
this.valueLabelStart = '';
/**
* An optional label for the slider's end value displayed when
* range is true; if not set, the label is the valueEnd itself.
*/
this.valueLabelEnd = '';
/**
* Aria label for the slider's start handle displayed when
* range is true.
*/
this.ariaLabelStart = '';
/**
* Aria value text for the slider's start value displayed when
* range is true.
*/
this.ariaValueTextStart = '';
/**
* Aria label for the slider's end handle displayed when
* range is true.
*/
this.ariaLabelEnd = '';
/**
* Aria value text for the slider's end value displayed when
* range is true.
*/
this.ariaValueTextEnd = '';
/**
* The step between values.
*/
this.step = 1;
/**
* Whether or not to show tick marks.
*/
this.ticks = false;
/**
* Whether or not to show a value label when activated.
*/
this.labeled = false;
/**
* Whether or not to show a value range. When false, the slider displays
* a slideable handle for the value property; when true, it displays
* slideable handles for the valueStart and valueEnd properties.
*/
this.range = false;
// handle hover/pressed states are set manually since the handle
// does not receive pointer events so that the native inputs are
// interaction targets.
this.handleStartHover = false;
this.handleEndHover = false;
this.startOnTop = false;
this.handlesOverlapping = false;
// used in synthetic events generated to control ripple hover state.
this.ripplePointerId = 1;
// flag to prevent processing of re-dispatched input event.
this.isRedispatchingEvent = false;
if (!isServer) {
this.addEventListener('click', (event) => {
if (!isActivationClick(event) || !this.inputEnd) {
return;
}
this.focus();
dispatchActivationClick(this.inputEnd);
});
}
}
focus() {
this.inputEnd?.focus();
}
willUpdate(changed) {
this.renderValueStart = changed.has('valueStart')
? this.valueStart
: this.inputStart?.valueAsNumber;
const endValueChanged = (changed.has('valueEnd') && this.range) || changed.has('value');
this.renderValueEnd = endValueChanged
? this.range
? this.valueEnd
: this.value
: this.inputEnd?.valueAsNumber;
// manually handle ripple hover state since the handle is pointer events
// none.
if (changed.get('handleStartHover') !== undefined) {
this.toggleRippleHover(this.rippleStart, this.handleStartHover);
}
else if (changed.get('handleEndHover') !== undefined) {
this.toggleRippleHover(this.rippleEnd, this.handleEndHover);
}
}
updated(changed) {
// Validate input rendered value and re-render if necessary. This ensures
// the rendred handle stays in sync with the input thumb which is used for
// interaction. These can get out of sync if a supplied value does not
// map to an exactly stepped value between min and max.
if (this.range) {
this.renderValueStart = this.inputStart.valueAsNumber;
}
this.renderValueEnd = this.inputEnd.valueAsNumber;
// update values if they are unset
// when using a range, default to equi-distant between
// min - valueStart - valueEnd - max
if (this.range) {
const segment = (this.max - this.min) / 3;
if (this.valueStart === undefined) {
this.inputStart.valueAsNumber = this.min + segment;
// read actual value from input
const v = this.inputStart.valueAsNumber;
this.valueStart = this.renderValueStart = v;
}
if (this.valueEnd === undefined) {
this.inputEnd.valueAsNumber = this.min + 2 * segment;
// read actual value from input
const v = this.inputEnd.valueAsNumber;
this.valueEnd = this.renderValueEnd = v;
}
}
else {
this.value ??= this.renderValueEnd;
}
if (changed.has('range') ||
changed.has('renderValueStart') ||
changed.has('renderValueEnd') ||
this.isUpdatePending) {
// Only check if the handle nubs are overlapping, as the ripple touch
// target extends subtantially beyond the boundary of the handle nub.
const startNub = this.handleStart?.querySelector('.handleNub');
const endNub = this.handleEnd?.querySelector('.handleNub');
this.handlesOverlapping = isOverlapping(startNub, endNub);
}
// called to finish the update imediately;
// note, this is a no-op unless an update is scheduled
this.performUpdate();
}
render() {
const step = this.step === 0 ? 1 : this.step;
const range = Math.max(this.max - this.min, step);
const startFraction = this.range
? ((this.renderValueStart ?? this.min) - this.min) / range
: 0;
const endFraction = ((this.renderValueEnd ?? this.min) - this.min) / range;
const containerStyles = {
// for clipping inputs and active track.
'--_start-fraction': String(startFraction),
'--_end-fraction': String(endFraction),
// for generating tick marks
'--_tick-count': String(range / step),
};
const containerClasses = { ranged: this.range };
// optional label values to show in place of the value.
const labelStart = this.valueLabelStart || String(this.renderValueStart);
const labelEnd = (this.range ? this.valueLabelEnd : this.valueLabel) ||
String(this.renderValueEnd);
const inputStartProps = {
start: true,
value: this.renderValueStart,
ariaLabel: this.renderAriaLabelStart,
ariaValueText: this.renderAriaValueTextStart,
ariaMin: this.min,
ariaMax: this.valueEnd ?? this.max,
};
const inputEndProps = {
start: false,
value: this.renderValueEnd,
ariaLabel: this.renderAriaLabelEnd,
ariaValueText: this.renderAriaValueTextEnd,
ariaMin: this.range ? this.valueStart ?? this.min : this.min,
ariaMax: this.max,
};
const handleStartProps = {
start: true,
hover: this.handleStartHover,
label: labelStart,
};
const handleEndProps = {
start: false,
hover: this.handleEndHover,
label: labelEnd,
};
const handleContainerClasses = {
hover: this.handleStartHover || this.handleEndHover,
};
return html ` <div
class="container ${classMap(containerClasses)}"
style=${styleMap(containerStyles)}>
${when(this.range, () => this.renderInput(inputStartProps))}
${this.renderInput(inputEndProps)} ${this.renderTrack()}
<div class="handleContainerPadded">
<div class="handleContainerBlock">
<div class="handleContainer ${classMap(handleContainerClasses)}">
${when(this.range, () => this.renderHandle(handleStartProps))}
${this.renderHandle(handleEndProps)}
</div>
</div>
</div>
</div>`;
}
renderTrack() {
return html `
<div class="track"></div>
${this.ticks ? html `<div class="tickmarks"></div>` : nothing}
`;
}
renderLabel(value) {
return html `<div class="label" aria-hidden="true">
<span class="labelContent" part="label">${value}</span>
</div>`;
}
renderHandle({ start, hover, label, }) {
const onTop = !this.disabled && start === this.startOnTop;
const isOverlapping = !this.disabled && this.handlesOverlapping;
const name = start ? 'start' : 'end';
return html `<div
class="handle ${classMap({
[name]: true,
hover,
onTop,
isOverlapping,
})}">
<md-focus-ring part="focus-ring" for=${name}></md-focus-ring>
<md-ripple
for=${name}
class=${name}
?disabled=${this.disabled}></md-ripple>
<div class="handleNub">
<md-elevation part="elevation"></md-elevation>
</div>
${when(this.labeled, () => this.renderLabel(label))}
</div>`;
}
renderInput({ start, value, ariaLabel, ariaValueText, ariaMin, ariaMax, }) {
// Slider requires min/max set to the overall min/max for both inputs.
// This is reported to screen readers, which is why we need aria-valuemin
// and aria-valuemax.
const name = start ? `start` : `end`;
return html `<input
type="range"
class="${classMap({
start,
end: !start,
})}"
@focus=${this.handleFocus}
@pointerdown=${this.handleDown}
@pointerup=${this.handleUp}
@pointerenter=${this.handleEnter}
@pointermove=${this.handleMove}
@pointerleave=${this.handleLeave}
@keydown=${this.handleKeydown}
@keyup=${this.handleKeyup}
@input=${this.handleInput}
@change=${this.handleChange}
id=${name}
.disabled=${this.disabled}
.min=${String(this.min)}
aria-valuemin=${ariaMin}
.max=${String(this.max)}
aria-valuemax=${ariaMax}
.step=${String(this.step)}
.value=${String(value)}
.tabIndex=${start ? 1 : 0}
aria-label=${ariaLabel || nothing}
aria-valuetext=${ariaValueText} />`;
}
async toggleRippleHover(ripple, hovering) {
const rippleEl = await ripple;
if (!rippleEl) {
return;
}
// TODO(b/269799771): improve slider ripple connection
if (hovering) {
rippleEl.handlePointerenter(new PointerEvent('pointerenter', {
isPrimary: true,
pointerId: this.ripplePointerId,
}));
}
else {
rippleEl.handlePointerleave(new PointerEvent('pointerleave', {
isPrimary: true,
pointerId: this.ripplePointerId,
}));
}
}
handleFocus(event) {
this.updateOnTop(event.target);
}
startAction(event) {
const target = event.target;
const fixed = target === this.inputStart ? this.inputEnd : this.inputStart;
this.action = {
canFlip: event.type === 'pointerdown',
flipped: false,
target,
fixed,
values: new Map([
[target, target.valueAsNumber],
[fixed, fixed?.valueAsNumber],
]),
};
}
finishAction(event) {
this.action = undefined;
}
handleKeydown(event) {
this.startAction(event);
}
handleKeyup(event) {
this.finishAction(event);
}
handleDown(event) {
this.startAction(event);
this.ripplePointerId = event.pointerId;
const isStart = event.target === this.inputStart;
// Since handle moves to pointer on down and there may not be a move,
// it needs to be considered hovered..
this.handleStartHover =
!this.disabled && isStart && Boolean(this.handleStart);
this.handleEndHover = !this.disabled && !isStart && Boolean(this.handleEnd);
}
async handleUp(event) {
if (!this.action) {
return;
}
const { target, values, flipped } = this.action;
// Async here for Firefox because input can be after pointerup
// when value is calmped.
await new Promise(requestAnimationFrame);
if (target !== undefined) {
// Ensure Safari focuses input so label renders.
// Ensure any flipped input is focused so the tab order is right.
target.focus();
// When action is flipped, change must be fired manually since the
// real event target did not change.
if (flipped && target.valueAsNumber !== values.get(target)) {
target.dispatchEvent(new Event('change', { bubbles: true }));
}
}
this.finishAction(event);
}
/**
* The move handler tracks handle hovering to facilitate proper ripple
* behavior on the slider handle. This is needed because user interaction with
* the native input is leveraged to position the handle. Because the separate
* displayed handle element has pointer events disabled (to allow interaction
* with the input) and the input's handle is a pseudo-element, neither can be
* the ripple's interactive element. Therefore the input is the ripple's
* interactive element and has a `ripple` directive; however the ripple
* is gated on the handle being hovered. In addition, because the ripple
* hover state is being specially handled, it must be triggered independent
* of the directive. This is done based on the hover state when the
* slider is updated.
*/
handleMove(event) {
this.handleStartHover = !this.disabled && inBounds(event, this.handleStart);
this.handleEndHover = !this.disabled && inBounds(event, this.handleEnd);
}
handleEnter(event) {
this.handleMove(event);
}
handleLeave() {
this.handleStartHover = false;
this.handleEndHover = false;
}
updateOnTop(input) {
this.startOnTop = input.classList.contains('start');
}
needsClamping() {
if (!this.action) {
return false;
}
const { target, fixed } = this.action;
const isStart = target === this.inputStart;
return isStart
? target.valueAsNumber > fixed.valueAsNumber
: target.valueAsNumber < fixed.valueAsNumber;
}
// if start/end start coincident and the first drag input would e.g. move
// start > end, avoid clamping and "flip" to use the other input
// as the action target.
isActionFlipped() {
const { action } = this;
if (!action) {
return false;
}
const { target, fixed, values } = action;
if (action.canFlip) {
const coincident = values.get(target) === values.get(fixed);
if (coincident && this.needsClamping()) {
action.canFlip = false;
action.flipped = true;
action.target = fixed;
action.fixed = target;
}
}
return action.flipped;
}
// when flipped, apply the drag input to the flipped target and reset
// the actual target.
flipAction() {
if (!this.action) {
return false;
}
const { target, fixed, values } = this.action;
const changed = target.valueAsNumber !== fixed.valueAsNumber;
target.valueAsNumber = fixed.valueAsNumber;
fixed.valueAsNumber = values.get(fixed);
return changed;
}
// clamp such that start does not move beyond end and visa versa.
clampAction() {
if (!this.needsClamping() || !this.action) {
return false;
}
const { target, fixed } = this.action;
target.valueAsNumber = fixed.valueAsNumber;
return true;
}
handleInput(event) {
// avoid processing a re-dispatched event
if (this.isRedispatchingEvent) {
return;
}
let stopPropagation = false;
let redispatch = false;
if (this.range) {
if (this.isActionFlipped()) {
stopPropagation = true;
redispatch = this.flipAction();
}
if (this.clampAction()) {
stopPropagation = true;
redispatch = false;
}
}
const target = event.target;
this.updateOnTop(target);
// update value only on interaction
if (this.range) {
this.valueStart = this.inputStart.valueAsNumber;
this.valueEnd = this.inputEnd.valueAsNumber;
}
else {
this.value = this.inputEnd.valueAsNumber;
}
// control external visibility of input event
if (stopPropagation) {
event.stopPropagation();
}
// ensure event path is correct when flipped.
if (redispatch) {
this.isRedispatchingEvent = true;
redispatchEvent(target, event);
this.isRedispatchingEvent = false;
}
}
handleChange(event) {
// prevent keyboard triggered changes from dispatching for
// clamped values; note, this only occurs for keyboard
const changeTarget = event.target;
const { target, values } = this.action ?? {};
const squelch = target && target.valueAsNumber === values.get(changeTarget);
if (!squelch) {
redispatchEvent(this, event);
}
// ensure keyboard triggered change clears action.
this.finishAction(event);
}
[getFormValue]() {
if (this.range) {
const data = new FormData();
data.append(this.nameStart, String(this.valueStart));
data.append(this.nameEnd, String(this.valueEnd));
return data;
}
return String(this.value);
}
formResetCallback() {
if (this.range) {
const valueStart = this.getAttribute('value-start');
this.valueStart = valueStart !== null ? Number(valueStart) : undefined;
const valueEnd = this.getAttribute('value-end');
this.valueEnd = valueEnd !== null ? Number(valueEnd) : undefined;
return;
}
const value = this.getAttribute('value');
this.value = value !== null ? Number(value) : undefined;
}
formStateRestoreCallback(state) {
if (Array.isArray(state)) {
const [[, valueStart], [, valueEnd]] = state;
this.valueStart = Number(valueStart);
this.valueEnd = Number(valueEnd);
this.range = true;
return;
}
this.value = Number(state);
this.range = false;
}
}
/** @nocollapse */
Slider.shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
__decorate([
property({ type: Number })
], Slider.prototype, "min", void 0);
__decorate([
property({ type: Number })
], Slider.prototype, "max", void 0);
__decorate([
property({ type: Number })
], Slider.prototype, "value", void 0);
__decorate([
property({ type: Number, attribute: 'value-start' })
], Slider.prototype, "valueStart", void 0);
__decorate([
property({ type: Number, attribute: 'value-end' })
], Slider.prototype, "valueEnd", void 0);
__decorate([
property({ attribute: 'value-label' })
], Slider.prototype, "valueLabel", void 0);
__decorate([
property({ attribute: 'value-label-start' })
], Slider.prototype, "valueLabelStart", void 0);
__decorate([
property({ attribute: 'value-label-end' })
], Slider.prototype, "valueLabelEnd", void 0);
__decorate([
property({ attribute: 'aria-label-start' })
], Slider.prototype, "ariaLabelStart", void 0);
__decorate([
property({ attribute: 'aria-valuetext-start' })
], Slider.prototype, "ariaValueTextStart", void 0);
__decorate([
property({ attribute: 'aria-label-end' })
], Slider.prototype, "ariaLabelEnd", void 0);
__decorate([
property({ attribute: 'aria-valuetext-end' })
], Slider.prototype, "ariaValueTextEnd", void 0);
__decorate([
property({ type: Number })
], Slider.prototype, "step", void 0);
__decorate([
property({ type: Boolean })
], Slider.prototype, "ticks", void 0);
__decorate([
property({ type: Boolean })
], Slider.prototype, "labeled", void 0);
__decorate([
property({ type: Boolean })
], Slider.prototype, "range", void 0);
__decorate([
query('input.start')
], Slider.prototype, "inputStart", void 0);
__decorate([
query('.handle.start')
], Slider.prototype, "handleStart", void 0);
__decorate([
queryAsync('md-ripple.start')
], Slider.prototype, "rippleStart", void 0);
__decorate([
query('input.end')
], Slider.prototype, "inputEnd", void 0);
__decorate([
query('.handle.end')
], Slider.prototype, "handleEnd", void 0);
__decorate([
queryAsync('md-ripple.end')
], Slider.prototype, "rippleEnd", void 0);
__decorate([
state()
], Slider.prototype, "handleStartHover", void 0);
__decorate([
state()
], Slider.prototype, "handleEndHover", void 0);
__decorate([
state()
], Slider.prototype, "startOnTop", void 0);
__decorate([
state()
], Slider.prototype, "handlesOverlapping", void 0);
__decorate([
state()
], Slider.prototype, "renderValueStart", void 0);
__decorate([
state()
], Slider.prototype, "renderValueEnd", void 0);
function inBounds({ x, y }, element) {
if (!element) {
return false;
}
const { top, left, bottom, right } = element.getBoundingClientRect();
return x >= left && x <= right && y >= top && y <= bottom;
}
function isOverlapping(elA, elB) {
if (!(elA && elB)) {
return false;
}
const a = elA.getBoundingClientRect();
const b = elB.getBoundingClientRect();
return !(a.top > b.bottom ||
a.right < b.left ||
a.bottom < b.top ||
a.left > b.right);
}
//# sourceMappingURL=slider.js.map