UNPKG

@material/web

Version:
714 lines 26.2 kB
/** * @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 { requestUpdateOnAriaChange } from '../../internal/aria/delegate.js'; import { dispatchActivationClick, isActivationClick, redispatchEvent, } from '../../internal/controller/events.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 = 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.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, })}"> <div class="handleNub"><md-elevation></md-elevation></div> ${when(this.labeled, () => this.renderLabel(label))} <md-focus-ring part="focus-ring" for=${name}></md-focus-ring> <md-ripple for=${name} class=${name} ?disabled=${this.disabled}></md-ripple> </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; } } (() => { requestUpdateOnAriaChange(Slider); })(); /** @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