From 9083b7d61b1dda2c5acefda6e8939870a358e98f Mon Sep 17 00:00:00 2001 From: Joy Zhong Date: Tue, 24 Nov 2020 13:03:32 -0800 Subject: [PATCH] feat(slider): Use input with type="range" to back slider component. This ensures that sliders can be adjusted with touch-based assistive technologies, as the current ARIA spec for sliders is not compatible with e.g. TalkBack/Android. This PR does the following: - When input is focused, adds focused style to slider thumb, and for discrete sliders, shows value indicator - On input `change` event (e.g. key [LEFT/RIGHT/UP/DOWN] events on input that adjust value), change internal slider value - On internal slider value change (e.g. via pointer events), sync input value attribute and property - On thumb drag start, focus input (such that users can use a mix of pointer and key events) BREAKING CHANGE: Slider is now backed by an input of type="range". Additionally, adapter methods (focusInput, isInputFocused, registerInputEventHandler, deregisterInputEventHandler) were added. PiperOrigin-RevId: 344116908 --- packages/mdc-slider/README.md | 76 ++-- packages/mdc-slider/_slider.scss | 14 +- packages/mdc-slider/adapter.ts | 42 +-- packages/mdc-slider/component.ts | 88 ++++- packages/mdc-slider/foundation.ts | 288 +++++++-------- packages/mdc-slider/test/component.test.ts | 146 ++------ packages/mdc-slider/test/foundation.test.ts | 382 +++----------------- 7 files changed, 348 insertions(+), 688 deletions(-) diff --git a/packages/mdc-slider/README.md b/packages/mdc-slider/README.md index f9958dbe7d5..3aee6aab5cd 100644 --- a/packages/mdc-slider/README.md +++ b/packages/mdc-slider/README.md @@ -13,11 +13,8 @@ path: /catalog/input-controls/sliders/ selections from a range of values. The MDC Slider implementation supports both single point sliders (one thumb) -and range sliders (two thumbs). It is modeled after the browser's -`` element. - -Sliders follow accessibility best practices per the [WAI-ARIA spec](https://www.w3.org/TR/wai-aria-practices/#slider) -and are fully RTL-aware. +and range sliders (two thumbs). It is backed by the browser +`` element, is fully accessible, and is RTL-aware. ## Contents @@ -56,22 +53,24 @@ information on how to import JavaScript. ### Making sliders accessible -Sliders follow the -[WAI-ARIA guidelines](https://www.w3.org/TR/wai-aria-practices/#slider). +Sliders are backed by an `` element, meaning that they are fully +accessible. Unlike the [ARIA-based slider](https://www.w3.org/TR/wai-aria-practices/#slider), +MDC sliders are adjustable using touch-based assistive technologies such as +TalkBack on Android. + Per the spec, ensure that the following attributes are added to the -`mdc-slider__thumb` element(s): +`input` element(s): -* `role="slider"` -* `aria-valuenow`: Value representing the current value. -* `aria-valuemin`: Value representing the minimum allowed value. -* `aria-valuemax`: Value representing the maximum allowed value. +* `value`: Value representing the current value. +* `min`: Value representing the minimum allowed value. +* `max`: Value representing the maximum allowed value. * `aria-label` or `aria-labelledby`: Accessible label for the slider. -If the value of `aria-valuenow` is not user-friendly (e.g. a number to +If the value is not user-friendly (e.g. a number to represent the day of the week), also set the following: -* `aria-valuetext`: Set to a string that makes the slider value -understandable, e.g. 'Monday'. +* `aria-valuetext`: Set this input attribute to a string that makes the slider +value understandable, e.g. 'Monday'. * Add a function to map the slider value to `aria-valuetext` via the `MDCSlider#setValueToAriaValueTextFn` method. @@ -95,15 +94,14 @@ element. ```html
- +
-
+
@@ -115,18 +113,18 @@ element. ```html
- - + +
-
+
-
+
@@ -147,14 +145,14 @@ To create a discrete slider, add the following: ```html
- +
-
+
@@ -184,7 +182,7 @@ To add tick marks to a discrete slider, add the following: ```html
- +
@@ -204,7 +202,7 @@ To add tick marks to a discrete slider, add the following:
-
+
@@ -221,15 +219,15 @@ To add tick marks to a discrete slider, add the following: ```html
- - + +
-
+
@@ -239,7 +237,7 @@ To add tick marks to a discrete slider, add the following:
-
+
@@ -264,14 +262,14 @@ To disable a slider, add the following: ```html
- +
-
+
@@ -281,8 +279,8 @@ To disable a slider, add the following: ### Initialization with custom ranges and values -When `MDCSlider` is initialized, it reads the thumb element's `aria-valuemin`, -`aria-valuemax`, and `aria-valuenow` attributes if present, using them to set +When `MDCSlider` is initialized, it reads the input element's `min`, +`max`, and `value` attributes if present, using them to set the component's internal `min`, `max`, and `value` properties. Use these attributes to initialize the slider with a custom range and values, @@ -290,10 +288,8 @@ as shown below: ```html
+ -
-
-
``` @@ -329,6 +325,8 @@ This is an example of a range slider with internal values of ```html
+ +
@@ -336,10 +334,10 @@ This is an example of a range slider with internal values of style="transform:scaleX(.4); left:30%">
-
+
-
+
diff --git a/packages/mdc-slider/_slider.scss b/packages/mdc-slider/_slider.scss index c3ae7da5228..e41012f0205 100644 --- a/packages/mdc-slider/_slider.scss +++ b/packages/mdc-slider/_slider.scss @@ -66,7 +66,7 @@ $_track-inactive-height: 4px; height: $_thumb-ripple-size; margin: 0 ($_thumb-ripple-size / 2); position: relative; - touch-action: none; + touch-action: pan-y; } &.mdc-slider--disabled { @@ -91,6 +91,18 @@ $_track-inactive-height: 4px; } } } + + .mdc-slider__input { + @include feature-targeting.targets($feat-structure) { + cursor: pointer; + left: 0; + margin: 0; + opacity: 0; + pointer-events: none; + position: absolute; + top: 0; + } + } } // This API is intended for use by frameworks that may want to separate the diff --git a/packages/mdc-slider/adapter.ts b/packages/mdc-slider/adapter.ts index 8d7311881fb..5cec88bb00b 100644 --- a/packages/mdc-slider/adapter.ts +++ b/packages/mdc-slider/adapter.ts @@ -63,22 +63,6 @@ export interface MDCSliderAdapter { */ removeThumbClass(className: string, thumb: Thumb): void; - /** - * - If thumb is `Thumb.START`, returns the value on the start thumb - * (for range slider variant). - * - If thumb is `Thumb.END`, returns the value on the end thumb (or - * only thumb for single point slider). - */ - getThumbAttribute(attribute: string, thumb: Thumb): string|null; - - /** - * - If thumb is `Thumb.START`, sets the attribute on the start thumb - * (for range slider variant). - * - If thumb is `Thumb.END`, sets the attribute on the end thumb (or - * only thumb for single point slider). - */ - setThumbAttribute(attribute: string, value: string, thumb: Thumb): void; - /** * - If thumb is `Thumb.START`, returns the value property on the start input * (for range slider variant). @@ -120,19 +104,21 @@ export interface MDCSliderAdapter { removeInputAttribute(attribute: string, thumb: Thumb): void; /** - * @return Returns the width of the given thumb knob. + * - If thumb is `Thumb.START`, focuses start input (range slider variant). + * - If thumb is `Thumb.END`, focuses end input (or only input for single + * point slider). */ - getThumbKnobWidth(thumb: Thumb): number; + focusInput(thumb: Thumb): void; /** - * @return Returns true if the given thumb is focused. + * @return Returns true if the given input is focused. */ - isThumbFocused(thumb: Thumb): boolean; + isInputFocused(thumb: Thumb): boolean; /** - * Adds browser focus to the given thumb. + * @return Returns the width of the given thumb knob. */ - focusThumb(thumb: Thumb): void; + getThumbKnobWidth(thumb: Thumb): number; /** * @return Returns the bounding client rect of the given thumb. @@ -260,6 +246,18 @@ export interface MDCSliderAdapter { deregisterThumbEventHandler( thumb: Thumb, evtType: K, handler: SpecificEventListener): void; + /** + * Registers an event listener on the given input element. + */ + registerInputEventHandler( + thumb: Thumb, evtType: K, handler: SpecificEventListener): void; + + /** + * Deregisters an event listener on the given input element. + */ + deregisterInputEventHandler( + thumb: Thumb, evtType: K, handler: SpecificEventListener): void; + /** * Registers an event listener on the body element. */ diff --git a/packages/mdc-slider/component.ts b/packages/mdc-slider/component.ts index b2d9fbfb06d..8cff1f607fc 100644 --- a/packages/mdc-slider/component.ts +++ b/packages/mdc-slider/component.ts @@ -22,7 +22,11 @@ */ import {MDCComponent} from '@material/base/component'; +import {EventType, SpecificEventListener} from '@material/base/types'; +import {matches} from '@material/dom/ponyfill'; +import {MDCRippleAdapter} from '@material/ripple/adapter'; import {MDCRipple} from '@material/ripple/component'; +import {MDCRippleFoundation} from '@material/ripple/foundation'; import {MDCSliderAdapter} from './adapter'; import {cssClasses, events} from './constants'; @@ -40,6 +44,7 @@ export class MDCSlider extends MDCComponent { private inputs!: HTMLInputElement[]; // Assigned in #initialize. private thumbs!: HTMLElement[]; // Assigned in #initialize. private trackActive!: HTMLElement; // Assigned in #initialize. + private ripples!: MDCRipple[]; // Assigned in #initialize. private skipInitialUIUpdate = false; // Function that maps a slider value to the value of the `aria-valuetext` @@ -64,11 +69,6 @@ export class MDCSlider extends MDCComponent { this.getThumbEl(thumb).classList.remove(className); }, getAttribute: (attribute) => this.root.getAttribute(attribute), - getThumbAttribute: (attribute, thumb: Thumb) => - this.getThumbEl(thumb).getAttribute(attribute), - setThumbAttribute: (attribute, value, thumb: Thumb) => { - this.getThumbEl(thumb).setAttribute(attribute, value); - }, getInputValue: (thumb: Thumb) => this.getInput(thumb).value, setInputValue: (value: string, thumb: Thumb) => { this.getInput(thumb).value = value; @@ -81,11 +81,11 @@ export class MDCSlider extends MDCComponent { removeInputAttribute: (attribute, thumb: Thumb) => { this.getInput(thumb).removeAttribute(attribute); }, - isThumbFocused: (thumb: Thumb) => - this.getThumbEl(thumb) === document.activeElement, - focusThumb: (thumb: Thumb) => { - this.getThumbEl(thumb).focus(); + focusInput: (thumb: Thumb) => { + this.getInput(thumb).focus(); }, + isInputFocused: (thumb: Thumb) => + this.getInput(thumb) === document.activeElement, getThumbKnobWidth: (thumb: Thumb) => { return this.getThumbEl(thumb) .querySelector(`.${cssClasses.THUMB_KNOB}`)! @@ -142,13 +142,17 @@ export class MDCSlider extends MDCComponent { emitInputEvent: (value, thumb: Thumb) => { this.emit(events.INPUT, {value, thumb}); }, - emitDragStartEvent: () => { - // Not yet implemented. See issue: + emitDragStartEvent: (_, thumb: Thumb) => { + // Emitting event is not yet implemented. See issue: // https://github.com/material-components/material-components-web/issues/6448 + + this.getRipple(thumb).activate(); }, - emitDragEndEvent: () => { - // Not yet implemented. See issue: + emitDragEndEvent: (_, thumb: Thumb) => { + // Emitting event is not yet implemented. See issue: // https://github.com/material-components/material-components-web/issues/6448 + + this.getRipple(thumb).deactivate(); }, registerEventHandler: (evtType, handler) => { this.listen(evtType, handler); @@ -162,6 +166,12 @@ export class MDCSlider extends MDCComponent { deregisterThumbEventHandler: (thumb, evtType, handler) => { this.getThumbEl(thumb).removeEventListener(evtType, handler); }, + registerInputEventHandler: (thumb, evtType, handler) => { + this.getInput(thumb).addEventListener(evtType, handler); + }, + deregisterInputEventHandler: (thumb, evtType, handler) => { + this.getInput(thumb).removeEventListener(evtType, handler); + }, registerBodyEventHandler: (evtType, handler) => { document.body.addEventListener(evtType, handler); }, @@ -194,6 +204,7 @@ export class MDCSlider extends MDCComponent { HTMLElement[]; this.trackActive = this.root.querySelector(`.${cssClasses.TRACK_ACTIVE}`) as HTMLElement; + this.ripples = this.createRipples(); if (skipInitialUIUpdate) { this.skipInitialUIUpdate = true; @@ -201,7 +212,6 @@ export class MDCSlider extends MDCComponent { } initialSyncWithDOM() { - this.createRipples(); this.foundation.layout({skipUpdateUI: this.skipInitialUIUpdate}); } @@ -254,6 +264,11 @@ export class MDCSlider extends MDCComponent { this.inputs[0]; } + private getRipple(thumb: Thumb) { + return thumb === Thumb.END ? this.ripples[this.ripples.length - 1] : + this.ripples[0]; + } + /** Adds tick mark elements to the given container. */ private addTickMarks(tickMarkContainer: HTMLElement, tickMarks: TickMark[]) { const fragment = document.createDocumentFragment(); @@ -284,13 +299,46 @@ export class MDCSlider extends MDCComponent { } /** Initializes thumb ripples. */ - private createRipples() { - const rippleSurfaces = - [].slice.call( - this.root.querySelectorAll(`.${cssClasses.THUMB}`)); - for (const rippleSurface of rippleSurfaces) { - const ripple = new MDCRipple(rippleSurface); + private createRipples(): MDCRipple[] { + const ripples = []; + const rippleSurfaces = [].slice.call( + this.root.querySelectorAll(`.${cssClasses.THUMB}`)); + for (let i = 0; i < rippleSurfaces.length; i++) { + const rippleSurface = rippleSurfaces[i] as HTMLElement; + // Use the corresponding input as the focus source for the ripple (i.e. + // when the input is focused, the ripple is in the focused state). + const input = this.inputs[i]; + + const adapter: MDCRippleAdapter = { + ...MDCRipple.createAdapter(this), + addClass: (className: string) => { + rippleSurface.classList.add(className); + }, + computeBoundingRect: () => rippleSurface.getBoundingClientRect(), + deregisterInteractionHandler: ( + evtType: K, handler: SpecificEventListener) => { + input.removeEventListener(evtType, handler); + }, + isSurfaceActive: () => matches(input, ':active'), + isUnbounded: () => true, + registerInteractionHandler: ( + evtType: K, handler: SpecificEventListener) => { + input.addEventListener(evtType, handler); + }, + removeClass: (className: string) => { + rippleSurface.classList.remove(className); + }, + updateCssVariable: (varName: string, value: string) => { + rippleSurface.style.setProperty(varName, value); + }, + }; + + const ripple = + new MDCRipple(rippleSurface, new MDCRippleFoundation(adapter)); ripple.unbounded = true; + ripples.push(ripple); } + + return ripples; } } diff --git a/packages/mdc-slider/foundation.ts b/packages/mdc-slider/foundation.ts index 852fb2a9c5e..3c1054fe1a7 100644 --- a/packages/mdc-slider/foundation.ts +++ b/packages/mdc-slider/foundation.ts @@ -24,7 +24,6 @@ import {getCorrectPropertyName} from '@material/animation/util'; import {MDCFoundation} from '@material/base/foundation'; import {SpecificEventListener} from '@material/base/types'; -import {KEY, normalizeKey} from '@material/dom/keyboard'; import {MDCSliderAdapter} from './adapter'; import {attributes, cssClasses, numbers} from './constants'; @@ -92,14 +91,22 @@ export class MDCSliderFoundation extends MDCFoundation { SpecificEventListener<'pointerdown'>; // Assigned in #initialize. private pointerupListener!: SpecificEventListener<'pointerup'>; // Assigned in #initialize. - private thumbStartKeydownListener!: - SpecificEventListener<'keydown'>; // Assigned in #initialize. - private thumbEndKeydownListener!: - SpecificEventListener<'keydown'>; // Assigned in #initialize. - private thumbFocusOrMouseenterListener!: - SpecificEventListener<'focus'|'mouseenter'>; // Assigned in #initialize. - private thumbBlurOrMouseleaveListener!: - SpecificEventListener<'blur'|'mouseleave'>; // Assigned in #initialize. + private thumbMouseenterListener!: + SpecificEventListener<'mouseenter'>; // Assigned in #initialize. + private thumbMouseleaveListener!: + SpecificEventListener<'mouseleave'>; // Assigned in #initialize. + private inputStartChangeListener!: + SpecificEventListener<'change'>; // Assigned in #initialize. + private inputEndChangeListener!: + SpecificEventListener<'change'>; // Assigned in #initialize. + private inputStartFocusListener!: + SpecificEventListener<'focus'>; // Assigned in #initialize. + private inputEndFocusListener!: + SpecificEventListener<'focus'>; // Assigned in #initialize. + private inputStartBlurListener!: + SpecificEventListener<'blur'>; // Assigned in #initialize. + private inputEndBlurListener!: + SpecificEventListener<'blur'>; // Assigned in #initialize. private resizeListener!: SpecificEventListener<'resize'>; // Assigned in #initialize. @@ -119,14 +126,12 @@ export class MDCSliderFoundation extends MDCFoundation { getAttribute: () => null, getInputValue: () => '', setInputValue: () => undefined, - getThumbAttribute: () => null, - setThumbAttribute: () => null, getInputAttribute: () => null, setInputAttribute: () => null, removeInputAttribute: () => null, + focusInput: () => undefined, + isInputFocused: () => false, getThumbKnobWidth: () => 0, - isThumbFocused: () => false, - focusThumb: () => undefined, getThumbBoundingClientRect: () => ({top: 0, right: 0, bottom: 0, left: 0, width: 0, height: 0}), getBoundingClientRect: () => @@ -148,6 +153,8 @@ export class MDCSliderFoundation extends MDCFoundation { deregisterEventHandler: () => undefined, registerThumbEventHandler: () => undefined, deregisterThumbEventHandler: () => undefined, + registerInputEventHandler: () => undefined, + deregisterInputEventHandler: () => undefined, registerBodyEventHandler: () => undefined, deregisterBodyEventHandler: () => undefined, registerWindowEventHandler: () => undefined, @@ -211,16 +218,26 @@ export class MDCSliderFoundation extends MDCFoundation { this.moveListener = this.handleMove.bind(this); this.pointerdownListener = this.handlePointerdown.bind(this); this.pointerupListener = this.handlePointerup.bind(this); - this.thumbStartKeydownListener = (event: KeyboardEvent) => { - this.handleThumbKeydown(event, Thumb.START); + this.thumbMouseenterListener = this.handleThumbMouseenter.bind(this); + this.thumbMouseleaveListener = this.handleThumbMouseleave.bind(this); + this.inputStartChangeListener = () => { + this.handleInputChange(Thumb.START); }; - this.thumbEndKeydownListener = (event: KeyboardEvent) => { - this.handleThumbKeydown(event, Thumb.END); + this.inputEndChangeListener = () => { + this.handleInputChange(Thumb.END); + }; + this.inputStartFocusListener = () => { + this.handleInputFocus(Thumb.START); + }; + this.inputEndFocusListener = () => { + this.handleInputFocus(Thumb.END); + }; + this.inputStartBlurListener = () => { + this.handleInputBlur(Thumb.START); + }; + this.inputEndBlurListener = () => { + this.handleInputBlur(Thumb.END); }; - this.thumbFocusOrMouseenterListener = - this.handleThumbFocusOrMouseenter.bind(this); - this.thumbBlurOrMouseleaveListener = - this.handleThumbBlurOrMouseleave.bind(this); this.resizeListener = this.handleResize.bind(this); this.registerEventHandlers(); } @@ -309,25 +326,17 @@ export class MDCSliderFoundation extends MDCFoundation { this.adapter.addClass(cssClasses.DISABLED); if (this.isRange) { - this.adapter.setThumbAttribute('tabindex', '-1', Thumb.START); - this.adapter.setThumbAttribute('aria-disabled', 'true', Thumb.START); this.adapter.setInputAttribute( attributes.INPUT_DISABLED, '', Thumb.START); } - this.adapter.setThumbAttribute('tabindex', '-1', Thumb.END); - this.adapter.setThumbAttribute('aria-disabled', 'true', Thumb.END); this.adapter.setInputAttribute(attributes.INPUT_DISABLED, '', Thumb.END); } else { this.adapter.removeClass(cssClasses.DISABLED); if (this.isRange) { - this.adapter.setThumbAttribute('tabindex', '0', Thumb.START); - this.adapter.setThumbAttribute('aria-disabled', 'false', Thumb.START); this.adapter.removeInputAttribute( attributes.INPUT_DISABLED, Thumb.START); } - this.adapter.setThumbAttribute('tabindex', '0', Thumb.END); - this.adapter.setThumbAttribute('aria-disabled', 'false', Thumb.END); this.adapter.removeInputAttribute(attributes.INPUT_DISABLED, Thumb.END); } } @@ -375,7 +384,7 @@ export class MDCSliderFoundation extends MDCFoundation { this.thumb = this.getThumbFromDownEvent(clientX, value); if (this.thumb === null) return; - this.adapter.emitDragStartEvent(value, this.thumb); + this.handleDragStart(event, value, this.thumb); // Presses within the range do not invoke slider updates. const newValueInCurrentRange = @@ -403,6 +412,7 @@ export class MDCSliderFoundation extends MDCFoundation { const value = this.mapClientXOnSliderScale(clientX); if (!dragAlreadyStarted) { + this.handleDragStart(event, value, this.thumb); this.adapter.emitDragStartEvent(value, this.thumb); } this.updateValue(value, this.thumb, {emitInputEvent: true}); @@ -427,65 +437,28 @@ export class MDCSliderFoundation extends MDCFoundation { } /** - * Handles keydown events on the slider thumbs. + * For range, discrete slider, shows the value indicator on both thumbs. */ - handleThumbKeydown(event: KeyboardEvent, thumb: Thumb) { - if (this.isDisabled) return; - - const key = normalizeKey(event); - if (key !== KEY.ARROW_LEFT && key !== KEY.ARROW_UP && - key !== KEY.ARROW_RIGHT && key !== KEY.ARROW_DOWN && key !== KEY.HOME && - key !== KEY.END && key !== KEY.PAGE_UP && key !== KEY.PAGE_DOWN) { - return; - } + handleThumbMouseenter() { + if (!this.isDiscrete || !this.isRange) return; - // Prevent scrolling. - event.preventDefault(); - - const value = this.getValueForKey(key, thumb); - const currentValue = thumb === Thumb.START ? this.valueStart : this.value; - if (value === currentValue) return; - - this.updateValue( - this.getValueForKey(key, thumb), thumb, - {emitChangeEvent: true, emitInputEvent: true}); + this.adapter.addThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.START); + this.adapter.addThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); } /** - * Shows the value indicator, as follows: - * - Range slider: Shows value indicator on both thumbs, on either hover or - * focus. - * - Single point slider: Shows value indicator on thumb, only on focus. + * For range, discrete slider, hides the value indicator on both thumbs. */ - handleThumbFocusOrMouseenter(event: FocusEvent|MouseEvent) { - if (!this.isDiscrete) return; - - if (this.isRange) { - this.adapter.addThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.START); - this.adapter.addThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); - } else if (event.type === 'focus') { - // If single point slider, only show value indicator on focus, not hover. - this.adapter.addThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); + handleThumbMouseleave() { + if (!this.isDiscrete || !this.isRange) return; + if (this.adapter.isInputFocused(Thumb.START) || + this.adapter.isInputFocused(Thumb.END)) { + // Leave value indicator shown if either input is focused. + return; } - } - - /** - * Hides the value indicator, as follows: - * - Range slider: Hides value indicator on both thumbs, on either blur - * or mouseleave, provided there is no thumb already focused. - * - Single point slider: Hides value indicator on thumb, on blur. - */ - handleThumbBlurOrMouseleave(event: FocusEvent|MouseEvent) { - if (!this.isDiscrete) return; - if (this.isRange && !this.adapter.isThumbFocused(Thumb.START) && - !this.adapter.isThumbFocused(Thumb.END)) { - this.adapter.removeThumbClass( - cssClasses.THUMB_WITH_INDICATOR, Thumb.START); - this.adapter.removeThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); - } else if (!this.isRange && event.type === 'blur') { - this.adapter.removeThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); - } + this.adapter.removeThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.START); + this.adapter.removeThumbClass(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); } handleMousedownOrTouchstart(event: MouseEvent|TouchEvent) { @@ -522,36 +495,53 @@ export class MDCSliderFoundation extends MDCFoundation { } /** - * @return Returns new value for the given thumb, based on key pressed. - * E.g. ARROW_DOWN on discrete slider decrements the current thumb - * value by the `step` value. + * Handles input `change` event by setting internal slider value to match + * input's new value. */ - private getValueForKey(key: string, thumb: Thumb): number { - const delta = this.step || (this.max - this.min) / 100; - const deltaBigStep = this.bigStep || (this.max - this.min) / 10; - const value = thumb === Thumb.START ? this.valueStart : this.value; - switch (key) { - case KEY.ARROW_LEFT: - return this.adapter.isRTL() ? value + delta : value - delta; - case KEY.ARROW_DOWN: - return value - delta; - case KEY.ARROW_RIGHT: - return this.adapter.isRTL() ? value - delta : value + delta; - case KEY.ARROW_UP: - return value + delta; - case KEY.HOME: - return this.min; - case KEY.END: - return this.max; - case KEY.PAGE_DOWN: - return value - deltaBigStep; - case KEY.PAGE_UP: - return value + deltaBigStep; - default: - return value; + handleInputChange(thumb: Thumb) { + const value = Number(this.adapter.getInputValue(thumb)); + if (thumb === Thumb.START) { + this.setValueStart(value); + } else { + this.setValue(value); } } + /** Shows value indicator on thumb(s). */ + handleInputFocus(thumb: Thumb) { + if (!this.isDiscrete) return; + + this.adapter.addThumbClass(cssClasses.THUMB_WITH_INDICATOR, thumb); + if (this.isRange) { + const otherThumb = thumb === Thumb.START ? Thumb.END : Thumb.START; + this.adapter.addThumbClass(cssClasses.THUMB_WITH_INDICATOR, otherThumb); + } + } + + /** Removes value indicator from thumb(s). */ + handleInputBlur(thumb: Thumb) { + if (!this.isDiscrete) return; + + this.adapter.removeThumbClass(cssClasses.THUMB_WITH_INDICATOR, thumb); + if (this.isRange) { + const otherThumb = thumb === Thumb.START ? Thumb.END : Thumb.START; + this.adapter.removeThumbClass( + cssClasses.THUMB_WITH_INDICATOR, otherThumb); + } + } + + /** + * Emits custom dragStart event, along with focusing the underlying input. + */ + private handleDragStart( + event: PointerEvent|MouseEvent|TouchEvent, value: number, thumb: Thumb) { + this.adapter.focusInput(thumb); + // Prevent the input (that we just focused) from losing focus. + event.preventDefault(); + + this.adapter.emitDragStartEvent(value, thumb); + } + /** * @return The thumb to be moved based on initial down event. */ @@ -654,11 +644,9 @@ export class MDCSliderFoundation extends MDCFoundation { this.adapter.setInputValue(valueStr, thumb); } - this.adapter.setThumbAttribute(attributes.ARIA_VALUENOW, valueStr, thumb); - const valueToAriaValueTextFn = this.adapter.getValueToAriaValueTextFn(); if (valueToAriaValueTextFn) { - this.adapter.setThumbAttribute( + this.adapter.setInputAttribute( attributes.ARIA_VALUETEXT, valueToAriaValueTextFn(value), thumb); } } @@ -828,7 +816,6 @@ export class MDCSliderFoundation extends MDCFoundation { this.removeInitialStyles(isRtl); this.updateOverlappingThumbsUI(thumbStartPos, thumbEndPos, thumb); - this.focusThumbIfDragging(thumb); }); } else { requestAnimationFrame(() => { @@ -839,7 +826,6 @@ export class MDCSliderFoundation extends MDCFoundation { transformProp, `scaleX(${pctComplete})`); this.removeInitialStyles(isRtl); - this.focusThumbIfDragging(thumb); }); } } @@ -934,16 +920,6 @@ export class MDCSliderFoundation extends MDCFoundation { } } - private focusThumbIfDragging(thumb?: Thumb) { - if (!thumb) return; - // Not dragging thumb via pointer interaction. - if (this.thumb === null) return; - - if (!this.adapter.isThumbFocused(thumb)) { - this.adapter.focusThumb(thumb); - } - } - /** * Converts attribute value to a number, e.g. '100' => 100. Throws errors * for invalid values. @@ -1018,27 +994,29 @@ export class MDCSliderFoundation extends MDCFoundation { if (this.isRange) { this.adapter.registerThumbEventHandler( - Thumb.START, 'keydown', this.thumbStartKeydownListener); - this.adapter.registerThumbEventHandler( - Thumb.START, 'focus', this.thumbFocusOrMouseenterListener); + Thumb.START, 'mouseenter', this.thumbMouseenterListener); this.adapter.registerThumbEventHandler( - Thumb.START, 'mouseenter', this.thumbFocusOrMouseenterListener); - this.adapter.registerThumbEventHandler( - Thumb.START, 'blur', this.thumbBlurOrMouseleaveListener); - this.adapter.registerThumbEventHandler( - Thumb.START, 'mouseleave', this.thumbBlurOrMouseleaveListener); + Thumb.START, 'mouseleave', this.thumbMouseleaveListener); + + this.adapter.registerInputEventHandler( + Thumb.START, 'change', this.inputStartChangeListener); + this.adapter.registerInputEventHandler( + Thumb.START, 'focus', this.inputStartFocusListener); + this.adapter.registerInputEventHandler( + Thumb.START, 'blur', this.inputStartBlurListener); } this.adapter.registerThumbEventHandler( - Thumb.END, 'keydown', this.thumbEndKeydownListener); - this.adapter.registerThumbEventHandler( - Thumb.END, 'focus', this.thumbFocusOrMouseenterListener); - this.adapter.registerThumbEventHandler( - Thumb.END, 'mouseenter', this.thumbFocusOrMouseenterListener); + Thumb.END, 'mouseenter', this.thumbMouseenterListener); this.adapter.registerThumbEventHandler( - Thumb.END, 'blur', this.thumbBlurOrMouseleaveListener); - this.adapter.registerThumbEventHandler( - Thumb.END, 'mouseleave', this.thumbBlurOrMouseleaveListener); + Thumb.END, 'mouseleave', this.thumbMouseleaveListener); + + this.adapter.registerInputEventHandler( + Thumb.END, 'change', this.inputEndChangeListener); + this.adapter.registerInputEventHandler( + Thumb.END, 'focus', this.inputEndFocusListener); + this.adapter.registerInputEventHandler( + Thumb.END, 'blur', this.inputEndBlurListener); } private deregisterEventHandlers() { @@ -1057,27 +1035,29 @@ export class MDCSliderFoundation extends MDCFoundation { if (this.isRange) { this.adapter.deregisterThumbEventHandler( - Thumb.START, 'keydown', this.thumbStartKeydownListener); - this.adapter.deregisterThumbEventHandler( - Thumb.START, 'focus', this.thumbFocusOrMouseenterListener); - this.adapter.deregisterThumbEventHandler( - Thumb.START, 'mouseenter', this.thumbFocusOrMouseenterListener); - this.adapter.deregisterThumbEventHandler( - Thumb.START, 'blur', this.thumbBlurOrMouseleaveListener); + Thumb.START, 'mouseenter', this.thumbMouseenterListener); this.adapter.deregisterThumbEventHandler( - Thumb.START, 'mouseleave', this.thumbBlurOrMouseleaveListener); + Thumb.START, 'mouseleave', this.thumbMouseleaveListener); + + this.adapter.deregisterInputEventHandler( + Thumb.START, 'change', this.inputStartChangeListener); + this.adapter.deregisterInputEventHandler( + Thumb.START, 'focus', this.inputStartFocusListener); + this.adapter.deregisterInputEventHandler( + Thumb.START, 'blur', this.inputStartBlurListener); } this.adapter.deregisterThumbEventHandler( - Thumb.END, 'keydown', this.thumbEndKeydownListener); - this.adapter.deregisterThumbEventHandler( - Thumb.END, 'focus', this.thumbFocusOrMouseenterListener); - this.adapter.deregisterThumbEventHandler( - Thumb.END, 'mouseenter', this.thumbFocusOrMouseenterListener); - this.adapter.deregisterThumbEventHandler( - Thumb.END, 'blur', this.thumbBlurOrMouseleaveListener); + Thumb.END, 'mouseenter', this.thumbMouseenterListener); this.adapter.deregisterThumbEventHandler( - Thumb.END, 'mouseleave', this.thumbBlurOrMouseleaveListener); + Thumb.END, 'mouseleave', this.thumbMouseleaveListener); + + this.adapter.deregisterInputEventHandler( + Thumb.END, 'change', this.inputEndChangeListener); + this.adapter.deregisterInputEventHandler( + Thumb.END, 'focus', this.inputEndFocusListener); + this.adapter.deregisterInputEventHandler( + Thumb.END, 'blur', this.inputEndBlurListener); } private handlePointerup() { diff --git a/packages/mdc-slider/test/component.test.ts b/packages/mdc-slider/test/component.test.ts index 21c354f5631..916e12464e4 100644 --- a/packages/mdc-slider/test/component.test.ts +++ b/packages/mdc-slider/test/component.test.ts @@ -21,10 +21,9 @@ * THE SOFTWARE. */ -import {KEY} from '../../mdc-dom/keyboard'; import {getFixture} from '../../../testing/dom'; import {html} from '../../../testing/dom'; -import {createKeyboardEvent, createMouseEvent, emitEvent} from '../../../testing/dom/events'; +import {createMouseEvent, emitEvent} from '../../../testing/dom/events'; import {setUpMdcTestEnvironment} from '../../../testing/helpers/setup'; import {attributes, cssClasses, events, MDCSlider, MDCSliderFoundation, Thumb} from '../index'; @@ -150,8 +149,7 @@ describe('MDCSlider', () => { 'touchstart', jasmine.any(Function), undefined); } - const thumbEvents = - ['keydown', 'focus', 'mouseenter', 'blur', 'mouseleave']; + const thumbEvents = ['mouseenter', 'mouseleave']; for (const event of thumbEvents) { expect(thumb.removeEventListener) .toHaveBeenCalledWith(event, jasmine.any(Function)); @@ -242,61 +240,6 @@ describe('MDCSlider', () => { expect(startThumb.style.transform) .toBe(`translateX(${initialValueStart}px)`); }); - - it('Thumb event listeners are destroyed when component is destroyed.', - () => { - spyOn(startThumb, 'removeEventListener').and.callThrough(); - spyOn(endThumb, 'removeEventListener').and.callThrough(); - - component.destroy(); - expect(startThumb.removeEventListener) - .toHaveBeenCalledWith('keydown', jasmine.any(Function)); - expect(endThumb.removeEventListener) - .toHaveBeenCalledWith('keydown', jasmine.any(Function)); - }); - }); - - describe('thumb states', () => { - it('single point slider: thumb is focused after value update', () => { - let thumb; - ({root, endThumb: thumb} = - setUpTest({isDiscrete: true, hasTickMarks: true})); - - const downEvent = createEventFrom('pointer', 'down', {clientX: 65.3}); - root.dispatchEvent(downEvent); - jasmine.clock().tick(1); // Tick for RAF. - expect(document.activeElement).toBe(thumb); - }); - - it('range slider: thumb is focused after value update', () => { - let startThumb, endThumb; - const valueStart = 10; - const value = 40; - ({root, startThumb, endThumb} = - setUpTest({isDiscrete: true, isRange: true, valueStart, value})); - - spyOn(startThumb as HTMLElement, 'getBoundingClientRect') - .and.returnValue({ - left: valueStart - 3, - right: valueStart + 3, - } as DOMRect); - spyOn(endThumb, 'getBoundingClientRect').and.returnValue({ - left: value - 3, - right: value + 3, - } as DOMRect); - - // Update start thumb value. - const downEventStart = createEventFrom('pointer', 'down', {clientX: 3}); - root.dispatchEvent(downEventStart); - jasmine.clock().tick(1); // Tick for RAF. - expect(document.activeElement).toBe(startThumb); - - // Update end thumb value. - const downEventEnd = createEventFrom('pointer', 'down', {clientX: 92}); - root.dispatchEvent(downEventEnd); - jasmine.clock().tick(1); // Tick for RAF. - expect(document.activeElement).toBe(endThumb); - }); }); describe('value indicator', () => { @@ -388,53 +331,23 @@ describe('MDCSlider', () => { }); describe('a11y support', () => { - let startThumb: HTMLElement|null, endThumb: HTMLElement; + let endInput: HTMLInputElement; - it('updates aria-valuenow on thumb value updates', () => { - ({root, endThumb} = setUpTest({isDiscrete: true, value: 30, step: 10})); - expect(endThumb.getAttribute(attributes.ARIA_VALUENOW)).toBe('30'); - - const downEvent = createEventFrom('pointer', 'down', {clientX: 90}); - root.dispatchEvent(downEvent); - expect(endThumb.getAttribute(attributes.ARIA_VALUENOW)).toBe('90'); - }); - - it('updates aria-valuetext on thumb value updates according to ' + + it('updates aria-valuetext on value updates according to ' + '`valueToAriaValueTextFn`', () => { let component: MDCSlider; - ({component, root, endThumb} = + ({component, root, endInput} = setUpTest({isDiscrete: true, value: 30, step: 10})); component.setValueToAriaValueTextFn( (value: number) => `${value} value`); - expect(endThumb.getAttribute(attributes.ARIA_VALUETEXT)).toBe(null); + expect(endInput.getAttribute(attributes.ARIA_VALUETEXT)).toBe(null); const downEvent = createEventFrom('pointer', 'down', {clientX: 90}); root.dispatchEvent(downEvent); - expect(endThumb.getAttribute(attributes.ARIA_VALUETEXT)) + expect(endInput.getAttribute(attributes.ARIA_VALUETEXT)) .toBe('90 value'); }); - - it('increments/decrements correct thumb value on keydown', () => { - ({root, startThumb, endThumb} = setUpTest({ - isDiscrete: true, - valueStart: 10, - value: 50, - isRange: true, - step: 10 - })); - expect(startThumb!.getAttribute(attributes.ARIA_VALUENOW)).toBe('10'); - expect(endThumb.getAttribute(attributes.ARIA_VALUENOW)).toBe('50'); - - const keyUpEvent = createKeyboardEvent('keydown', {key: KEY.ARROW_UP}); - startThumb!.dispatchEvent(keyUpEvent); - expect(startThumb!.getAttribute(attributes.ARIA_VALUENOW)).toBe('20'); - - const keyDownEvent = - createKeyboardEvent('keydown', {key: KEY.ARROW_DOWN}); - endThumb.dispatchEvent(keyDownEvent); - expect(endThumb.getAttribute(attributes.ARIA_VALUENOW)).toBe('40'); - }); }); describe('input synchronization: ', () => { @@ -458,10 +371,18 @@ describe('MDCSlider', () => { expect(endInput.getAttribute(attributes.INPUT_VALUE)).toBe('20'); expect(startInput!.getAttribute(attributes.INPUT_MAX)).toBe('20'); }); + + it('focuses input on thumb down event', () => { + ({root, endInput} = setUpTest({value: 30})); + const downEvent = createEventFrom('pointer', 'down', {clientX: 90}); + root.dispatchEvent(downEvent); + + expect(document.activeElement).toBe(endInput); + }); }); describe('disabled state', () => { - let startThumb: HTMLElement|null, endThumb: HTMLElement; + let startInput: HTMLInputElement|null, endInput: HTMLInputElement; it('updates disabled class when setting disabled state', () => { ({root, component} = setUpTest()); @@ -476,36 +397,27 @@ describe('MDCSlider', () => { expect(root.classList.contains(cssClasses.DISABLED)).toBe(false); }); - it('updates thumb attrs when setting disabled state', () => { - ({root, component, endThumb} = setUpTest()); - expect(endThumb.tabIndex).toBe(0); + it('updates input attrs when setting disabled state', () => { + ({root, component, endInput} = setUpTest()); component.setDisabled(true); - expect(endThumb.tabIndex).toBe(-1); - expect(endThumb.getAttribute('aria-disabled')).toBe('true'); + expect(endInput.getAttribute(attributes.INPUT_DISABLED)).toBe(''); component.setDisabled(false); - expect(endThumb.tabIndex).toBe(0); - expect(endThumb.getAttribute('aria-disabled')).toBe('false'); + expect(endInput.getAttribute(attributes.INPUT_DISABLED)).toBe(null); }); - it('range slider: updates thumbs\' attrs when setting disabled state', + it('range slider: updates inputs\' attrs when setting disabled state', () => { - ({root, component, startThumb, endThumb} = setUpTest({isRange: true})); - expect(startThumb!.tabIndex).toBe(0); - expect(endThumb.tabIndex).toBe(0); + ({root, component, startInput, endInput} = setUpTest({isRange: true})); component.setDisabled(true); - expect(startThumb!.tabIndex).toBe(-1); - expect(endThumb.tabIndex).toBe(-1); - expect(startThumb!.getAttribute('aria-disabled')).toBe('true'); - expect(endThumb.getAttribute('aria-disabled')).toBe('true'); + expect(startInput!.getAttribute(attributes.INPUT_DISABLED)).toBe(''); + expect(endInput.getAttribute(attributes.INPUT_DISABLED)).toBe(''); component.setDisabled(false); - expect(startThumb!.tabIndex).toBe(0); - expect(endThumb.tabIndex).toBe(0); - expect(startThumb!.getAttribute('aria-disabled')).toBe('false'); - expect(endThumb.getAttribute('aria-disabled')).toBe('false'); + expect(startInput!.getAttribute(attributes.INPUT_DISABLED)).toBe(null); + expect(endInput.getAttribute(attributes.INPUT_DISABLED)).toBe(null); }); }); @@ -732,8 +644,7 @@ function setUpTest( const valueIndicatorStart = isDiscrete ? valueIndicator(valueStart || 0) : ''; const valueIndicatorEnd = isDiscrete ? valueIndicator(valueStart || 0) : ''; const startThumbHtml = isRange ? html` -
+
${valueIndicatorStart}
` : @@ -750,8 +661,7 @@ function setUpTest(
${startThumbHtml} -
+
${valueIndicatorEnd}
diff --git a/packages/mdc-slider/test/foundation.test.ts b/packages/mdc-slider/test/foundation.test.ts index c440570ef6a..87696765525 100644 --- a/packages/mdc-slider/test/foundation.test.ts +++ b/packages/mdc-slider/test/foundation.test.ts @@ -21,8 +21,7 @@ * THE SOFTWARE. */ -import {KEY} from '../../mdc-dom/keyboard'; -import {createKeyboardEvent, createMouseEvent} from '../../../testing/dom/events'; +import {createMouseEvent} from '../../../testing/dom/events'; import {setUpFoundationTest, setUpMdcTestEnvironment} from '../../../testing/helpers/setup'; import {attributes, cssClasses, numbers} from '../constants'; import {MDCSliderFoundation} from '../foundation'; @@ -248,8 +247,7 @@ describe('MDCSliderFoundation', () => { expect(mockAdapter.deregisterEventHandler) .toHaveBeenCalledWith('pointerup', jasmine.any(Function)); - const thumbEvents = - ['keydown', 'focus', 'mouseenter', 'blur', 'mouseleave']; + const thumbEvents = ['mouseenter', 'mouseleave']; for (const event of thumbEvents) { expect(mockAdapter.deregisterThumbEventHandler) .toHaveBeenCalledWith(Thumb.END, event, jasmine.any(Function)); @@ -573,23 +571,6 @@ describe('MDCSliderFoundation', () => { expect(mockAdapter.setThumbStyleProperty).not.toHaveBeenCalled(); }); - it('focuses end thumb after updating end thumb value', () => { - const {foundation, mockAdapter} = setUpAndInit({ - valueStart: 10, - value: 50, - isRange: true, - }); - - // Down event on end thumb. - foundation.handleDown(createMouseEvent('mousedown', { - clientX: 70, - })); - jasmine.clock().tick(1); // Tick for RAF. - - expect(foundation.getValue()).toBe(70); - expect(mockAdapter.focusThumb).toHaveBeenCalledWith(Thumb.END); - }); - it('RTL, single point slider: updates track/thumb position with ' + 'reversed values', () => { @@ -720,6 +701,30 @@ describe('MDCSliderFoundation', () => { expect(mockAdapter.setInputAttribute) .toHaveBeenCalledWith(attributes.INPUT_MAX, '30', Thumb.START); }); + + it('updates values on input change', () => { + const {foundation, mockAdapter} = + setUpAndInit({valueStart: 10, value: 40, isRange: true}); + + mockAdapter.getInputValue.withArgs(Thumb.START).and.returnValue(5); + foundation.handleInputChange(Thumb.START); + expect(foundation.getValueStart()).toBe(5); + + mockAdapter.getInputValue.withArgs(Thumb.END).and.returnValue(45); + foundation.handleInputChange(Thumb.END); + expect(foundation.getValue()).toBe(45); + }); + + it('focuses input on thumb down event', () => { + const {foundation, mockAdapter} = setUpAndInit({ + value: 50, + }); + + foundation.handleDown(createMouseEvent('mousedown', { + clientX: 20, + })); + expect(mockAdapter.focusInput).toHaveBeenCalledWith(Thumb.END); + }); }); describe('value indicator', () => { @@ -773,23 +778,14 @@ describe('MDCSliderFoundation', () => { }); it('range slider: adds THUMB_WITH_INDICATOR class to both thumbs on ' + - 'thumb focus and mouseenter', + 'thumb mouseenter', () => { const {foundation, mockAdapter} = setUpAndInit({ isDiscrete: true, isRange: true, }); - foundation.handleThumbFocusOrMouseenter( - createMouseEvent('mouseenter')); - expect(mockAdapter.addThumbClass) - .toHaveBeenCalledWith( - cssClasses.THUMB_WITH_INDICATOR, Thumb.START); - expect(mockAdapter.addThumbClass) - .toHaveBeenCalledWith(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); - - mockAdapter.addThumbClass.calls.reset(); - foundation.handleThumbFocusOrMouseenter({type: 'focus'}); + foundation.handleThumbMouseenter(createMouseEvent('mouseenter')); expect(mockAdapter.addThumbClass) .toHaveBeenCalledWith( cssClasses.THUMB_WITH_INDICATOR, Thumb.START); @@ -797,40 +793,19 @@ describe('MDCSliderFoundation', () => { .toHaveBeenCalledWith(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); }); - it('adds THUMB_WITH_INDICATOR class to thumb on thumb focus, but not mouseenter', - () => { - const {foundation, mockAdapter} = setUpAndInit({ - isDiscrete: true, - }); - - foundation.handleThumbFocusOrMouseenter( - createMouseEvent('mouseenter')); - expect(mockAdapter.addThumbClass) - .not.toHaveBeenCalledWith( - cssClasses.THUMB_WITH_INDICATOR, Thumb.END); - - foundation.handleThumbFocusOrMouseenter({type: 'focus'}); - expect(mockAdapter.addThumbClass) - .toHaveBeenCalledWith(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); - }); - it('range slider: removes THUMB_WITH_INDICATOR class from both thumbs ' + - 'on thumb blur and mouseleave', + 'on thumb mouseleave', () => { const {foundation, mockAdapter} = setUpAndInit({ isDiscrete: true, isRange: true, }); - foundation.handleThumbBlurOrMouseleave(createMouseEvent('mouseleave')); - expect(mockAdapter.removeThumbClass) - .toHaveBeenCalledWith( - cssClasses.THUMB_WITH_INDICATOR, Thumb.START); - expect(mockAdapter.removeThumbClass) - .toHaveBeenCalledWith(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); + mockAdapter.isInputFocused.withArgs(Thumb.START) + .and.returnValue(false); + mockAdapter.isInputFocused.withArgs(Thumb.END).and.returnValue(false); - mockAdapter.removeThumbClass.calls.reset(); - foundation.handleThumbBlurOrMouseleave({type: 'blur'}); + foundation.handleThumbMouseleave(createMouseEvent('mouseleave')); expect(mockAdapter.removeThumbClass) .toHaveBeenCalledWith( cssClasses.THUMB_WITH_INDICATOR, Thumb.START); @@ -838,21 +813,23 @@ describe('MDCSliderFoundation', () => { .toHaveBeenCalledWith(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); }); - it('removes THUMB_WITH_INDICATOR class from thumb on thumb blur but ' + - 'not mouseleave', + it('range slider: does not remove THUMB_WITH_INDICATOR class on' + + 'thumb mouseleave if an input is still focused', () => { const {foundation, mockAdapter} = setUpAndInit({ isDiscrete: true, + isRange: true, }); - foundation.handleThumbBlurOrMouseleave(createMouseEvent('mouseleave')); + mockAdapter.isInputFocused.withArgs(Thumb.END).and.returnValue(true); + + foundation.handleThumbMouseleave(createMouseEvent('mouseleave')); expect(mockAdapter.removeThumbClass) .not.toHaveBeenCalledWith( - cssClasses.THUMB_WITH_INDICATOR, Thumb.END); - - foundation.handleThumbBlurOrMouseleave({type: 'blur'}); + cssClasses.THUMB_WITH_INDICATOR, Thumb.START); expect(mockAdapter.removeThumbClass) - .toHaveBeenCalledWith(cssClasses.THUMB_WITH_INDICATOR, Thumb.END); + .not.toHaveBeenCalledWith( + cssClasses.THUMB_WITH_INDICATOR, Thumb.END); }); }); @@ -1093,184 +1070,26 @@ describe('MDCSliderFoundation', () => { mockAdapter.getValueToAriaValueTextFn.and.returnValue( (value: string) => `${value} value`); - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_DOWN, - }), - Thumb.START); - expect(mockAdapter.setThumbAttribute) + foundation.setValueStart(11); + expect(mockAdapter.setInputAttribute) .toHaveBeenCalledWith( attributes.ARIA_VALUETEXT, '11 value', Thumb.START); - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_UP, - }), - Thumb.END); - expect(mockAdapter.setThumbAttribute) + foundation.setValue(16); + expect(mockAdapter.setInputAttribute) .toHaveBeenCalledWith( attributes.ARIA_VALUETEXT, '16 value', Thumb.END); }); - - it('updates aria-valuenow on thumb value updates', () => { - const {foundation, mockAdapter} = setUpAndInit({ - valueStart: 12, - value: 15, - isRange: true, - }); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_DOWN, - }), - Thumb.START); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith(attributes.ARIA_VALUENOW, '11', Thumb.START); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_UP, - }), - Thumb.END); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith(attributes.ARIA_VALUENOW, '16', Thumb.END); - }); - - it('increments value for ARROW_UP/ARROW_RIGHT/PAGE_UP keypresses', () => { - const {foundation} = setUpAndInit({ - valueStart: 8, - value: 80, - isRange: true, - isDiscrete: true, - step: 2, - bigStep: 10, - }); - expect(foundation.getValueStart()).toBe(8); - expect(foundation.getValue()).toBe(80); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_UP, - }), - Thumb.START); - expect(foundation.getValueStart()).toBe(10); - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_RIGHT, - }), - Thumb.START); - expect(foundation.getValueStart()).toBe(12); - }); - - it('decrements value for ARROW_DOWN/ARROW_LEFT/PAGE_DOWN keypresses', - () => { - const {foundation} = setUpAndInit({ - value: 50, - isDiscrete: true, - step: 1, - bigStep: 3, - }); - expect(foundation.getValue()).toBe(50); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_DOWN, - }), - Thumb.END); - expect(foundation.getValue()).toBe(49); - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_LEFT, - }), - Thumb.END); - expect(foundation.getValue()).toBe(48); - }); - - it('RTL: increments/decrements value for ARROW_LEFT/ARROW_RIGHT keypresses', - () => { - const {foundation} = setUpAndInit({ - value: 50, - isDiscrete: true, - step: 2, - isRTL: true, - }); - expect(foundation.getValue()).toBe(50); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_LEFT, - }), - Thumb.END); - expect(foundation.getValue()).toBe(52); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_RIGHT, - }), - Thumb.END); - expect(foundation.getValue()).toBe(50); - }); - - it('changes value by bigStep for PAGE_UP/PAGE_DOWN keypresses', () => { - const {foundation} = setUpAndInit({ - value: 50, - isDiscrete: true, - step: 1, - bigStep: 3, - }); - expect(foundation.getValue()).toBe(50); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.PAGE_UP, - }), - Thumb.END); - expect(foundation.getValue()).toBe(53); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.PAGE_DOWN, - }), - Thumb.END); - expect(foundation.getValue()).toBe(50); - }); - - it('sets value to min/max for HOME/END keypresses', () => { - const {foundation} = setUpAndInit({ - min: 100, - value: 138, - max: 1000, - }); - expect(foundation.getValue()).toBe(138); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.HOME, - }), - Thumb.END); - expect(foundation.getValue()).toBe(100); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.END, - }), - Thumb.END); - expect(foundation.getValue()).toBe(1000); - }); }); describe('disabled state', () => { - it('updates class and thumb/input attributes according to disabled state', + it('updates class and input attributes according to disabled state', () => { const {foundation, mockAdapter} = setUpAndInit(); expect(foundation.getDisabled()).toBe(false); foundation.setDisabled(true); expect(mockAdapter.addClass).toHaveBeenCalledWith(cssClasses.DISABLED); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('tabindex', '-1', Thumb.END); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('aria-disabled', 'true', Thumb.END); expect(mockAdapter.setInputAttribute) .toHaveBeenCalledWith(attributes.INPUT_DISABLED, '', Thumb.END); expect(foundation.getDisabled()).toBe(true); @@ -1278,43 +1097,22 @@ describe('MDCSliderFoundation', () => { foundation.setDisabled(false); expect(mockAdapter.removeClass) .toHaveBeenCalledWith(cssClasses.DISABLED); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('tabindex', '0', Thumb.END); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('aria-disabled', 'false', Thumb.END); expect(mockAdapter.removeInputAttribute) .toHaveBeenCalledWith(attributes.INPUT_DISABLED, Thumb.END); expect(foundation.getDisabled()).toBe(false); }); - it('range slider: updates both thumb and inputs\' attrs according ' + - 'to disabled state', + it('range slider: updates inputs\' attrs according to disabled state', () => { const {foundation, mockAdapter} = setUpAndInit({isRange: true}); foundation.setDisabled(true); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('tabindex', '-1', Thumb.END); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('tabindex', '-1', Thumb.START); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('aria-disabled', 'true', Thumb.END); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('aria-disabled', 'true', Thumb.START); expect(mockAdapter.setInputAttribute) .toHaveBeenCalledWith(attributes.INPUT_DISABLED, '', Thumb.END); expect(mockAdapter.setInputAttribute) .toHaveBeenCalledWith(attributes.INPUT_DISABLED, '', Thumb.START); foundation.setDisabled(false); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('tabindex', '0', Thumb.START); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('tabindex', '0', Thumb.END); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('aria-disabled', 'false', Thumb.START); - expect(mockAdapter.setThumbAttribute) - .toHaveBeenCalledWith('aria-disabled', 'false', Thumb.END); expect(mockAdapter.removeInputAttribute) .toHaveBeenCalledWith(attributes.INPUT_DISABLED, Thumb.START); expect(mockAdapter.removeInputAttribute) @@ -1333,13 +1131,6 @@ describe('MDCSliderFoundation', () => { clientX: 30, })); expect(foundation.getValue()).toBe(40); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_DOWN, - }), - Thumb.END); - expect(foundation.getValue()).toBe(40); }); }); @@ -1422,83 +1213,6 @@ describe('MDCSliderFoundation', () => { expect(mockAdapter.emitChangeEvent).toHaveBeenCalledWith(77, Thumb.END); }); - it('fires `input`/`change` events for key events', () => { - const {foundation, mockAdapter} = setUpAndInit({ - value: 15, - isDiscrete: true, - }); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_DOWN, - }), - Thumb.END); - expect(mockAdapter.emitInputEvent).toHaveBeenCalledWith(14, Thumb.END); - expect(mockAdapter.emitChangeEvent).toHaveBeenCalledWith(14, Thumb.END); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_UP, - }), - Thumb.END); - expect(mockAdapter.emitInputEvent).toHaveBeenCalledWith(15, Thumb.END); - expect(mockAdapter.emitChangeEvent).toHaveBeenCalledWith(15, Thumb.END); - }); - - it('does not fire `input`/`change` events for key events that do not ' + - 'change value', - () => { - const {foundation, mockAdapter} = setUpAndInit({ - min: 0, - value: 0, - isDiscrete: true, - }); - - foundation.handleThumbKeydown( - createKeyboardEvent('keydown', { - key: KEY.ARROW_DOWN, - }), - Thumb.END); - - expect(foundation.getValue()).toBe(0); - expect(mockAdapter.emitInputEvent).not.toHaveBeenCalled(); - expect(mockAdapter.emitChangeEvent).not.toHaveBeenCalled(); - }); - - it('does not fire `input`/`change` events for pointer events that do not ' + - 'change value', - () => { - const {foundation, mockAdapter} = setUpAndInit({ - value: 70, - isDiscrete: true, - step: 5, - }); - - foundation.handleDown(createMouseEvent('mousedown', { - clientX: 70, - })); - foundation.handleMove(createMouseEvent('mousemove', { - clientX: 72, - })); - expect(mockAdapter.emitInputEvent).not.toHaveBeenCalled(); - - // Move thumb to value 80... - foundation.handleMove(createMouseEvent('mousemove', { - clientX: 80, - })); - expect(foundation.getValue()).toBe(80); - // Move thumb back to value 70 without releasing. - foundation.handleMove(createMouseEvent('mousemove', { - clientX: 71, - })); - expect(foundation.getValue()).toBe(70); - - // `change` event should not have been called since the move to - // value 80 was not committed. - foundation.handleUp(createMouseEvent('mouseup')); - expect(mockAdapter.emitChangeEvent).not.toHaveBeenCalled(); - }); - it('fires `dragStart`/`dragEnd` events across drag interaction', () => { const {foundation, mockAdapter} = setUpAndInit({ valueStart: 20,