chromium/third_party/material_web_components/components-chromium/node_modules/@material/web/slider/internal/slider.js

/**
 * @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