From 9b0f2ac4fba4e221a010b85d2b5bbc3b38311cc6 Mon Sep 17 00:00:00 2001 From: Yiran Mao Date: Thu, 15 Jun 2017 14:32:14 -0400 Subject: [PATCH] feat(slider): Implement discrete slider and discrete slider with marker Closes #25 --- demos/slider.html | 229 +++++++++++++----- package.json | 2 +- packages/mdc-slider/README.md | 81 +++++-- packages/mdc-slider/constants.js | 5 + packages/mdc-slider/foundation.js | 48 ++++ packages/mdc-slider/index.js | 26 ++ packages/mdc-slider/mdc-slider.scss | 115 +++++++++ .../foundation-pointer-events.test.js | 38 +++ test/unit/mdc-slider/foundation.test.js | 182 +++++++++++++- test/unit/mdc-slider/mdc-slider.test.js | 56 +++++ 10 files changed, 700 insertions(+), 82 deletions(-) diff --git a/demos/slider.html b/demos/slider.html index 4fc71d431b1..3c3e326ac1d 100644 --- a/demos/slider.html +++ b/demos/slider.html @@ -88,13 +88,13 @@
Note that in browsers that support custom properties, we alter theme's primary color when using the dark theme toggle so that the slider appears more visible
-
+

Continuous Slider

-
+

Select Value:

-
@@ -109,48 +109,114 @@

Continuous Slider

-
- -
-
- -
-
- -
-
- -
-
- -
-
- +

+ Value from MDCSlider:input event: 0 +

+

+ Value from MDCSlider:change event: 0 +

+
+ + +

Discrete Slider

+
+

Select Value:

+ +
+
+
+
+
+
+
+ 30 +
+ + + +
+
+
-
- + +

+ Value from MDCSlider:input event: 0 +

+

+ Value from MDCSlider:change event: 0 +

+
+ +

Discrete Slider with markers

+
+

Select Value:

+ +
+
+
+
+
+
+
+
+
+ 30 +
+ + + +
+
+
+

- Value from MDCSlider:input event: 0 + Value from MDCSlider:input event: 0

- Value from MDCSlider:change event: 0 + Value from MDCSlider:change event: 0

+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
@@ -158,7 +224,7 @@

Continuous Slider

(function() { setTimeout(function() { mdc.slider.MDCSlider.attachTo(document.getElementById('hero-slider')); - initDemo(document.getElementById('continuous-slider-example')); + initDemo(document.getElementById('slider-example')); }, 80); function initDemo(demoRoot) { @@ -169,55 +235,90 @@

Continuous Slider

var disabled = demoRoot.querySelector('[name="disabled"]'); var useCustomColor = demoRoot.querySelector('[name="use-custom-color"]'); var rtl = demoRoot.querySelector('[name="rtl"]'); - var value = demoRoot.querySelector('.value'); - var committedValue = demoRoot.querySelector('.committed-value'); - var sliderEl = demoRoot.querySelector('.mdc-slider'); - var slider = new mdc.slider.MDCSlider(sliderEl); - slider.listen('MDCSlider:input', function() { - value.textContent = slider.value; + var continuousValue = demoRoot.querySelector('#continuous-slider-value'); + var continuousCommittedValue = demoRoot.querySelector('#continuous-slider-committed-value'); + var continuousSliderEl = demoRoot.querySelector('#continuous-mdc-slider'); + var continuousSlider = new mdc.slider.MDCSlider(continuousSliderEl); + continuousSlider.listen('MDCSlider:input', function() { + continuousValue.textContent = continuousSlider.value; + }); + continuousSlider.listen('MDCSlider:change', function() { + continuousCommittedValue.textContent = continuousSlider.value; }); - slider.listen('MDCSlider:change', function() { - committedValue.textContent = slider.value; + var discreteValue = demoRoot.querySelector('#discrete-slider-value'); + var discreteCommittedValue = demoRoot.querySelector('#discrete-slider-committed-value'); + var discreteSliderEl = demoRoot.querySelector('#discrete-mdc-slider'); + var discreteSlider = new mdc.slider.MDCSlider(discreteSliderEl); + discreteSlider.listen('MDCSlider:input', function() { + discreteValue.textContent = discreteSlider.value; + }); + discreteSlider.listen('MDCSlider:change', function() { + discreteCommittedValue.textContent = discreteSlider.value; + }); + + var discreteWMarkerValue = demoRoot.querySelector('#discrete-slider-w-marker-value'); + var discreteWMarkerCommittedValue = demoRoot.querySelector('#discrete-slider-w-marker-committed-value'); + var discreteWMarkerSliderEl = demoRoot.querySelector('#discrete-mdc-slider-w-marker'); + var discreteWMarkerSlider = new mdc.slider.MDCSlider(discreteWMarkerSliderEl); + discreteWMarkerSlider.listen('MDCSlider:input', function() { + discreteWMarkerValue.textContent = discreteWMarkerSlider.value; + }); + discreteWMarkerSlider.listen('MDCSlider:change', function() { + discreteWMarkerCommittedValue.textContent = discreteWMarkerSlider.value; }); min.addEventListener('input', function() { - slider.min = parseFloat(min.value); + continuousSlider.min = parseFloat(min.value); + discreteSlider.min = parseFloat(min.value); + discreteWMarkerSlider.min = parseFloat(min.value); }); max.addEventListener('input', function() { - slider.max = parseFloat(max.value); + continuousSlider.max = parseFloat(max.value); + discreteSlider.max = parseFloat(max.value); + discreteWMarkerSlider.max = parseFloat(max.value); }); step.addEventListener('input', function() { - slider.step = parseFloat(step.value); + continuousSlider.step = parseFloat(step.value); + discreteSlider.step = parseFloat(step.value); + discreteWMarkerSlider.step = parseFloat(step.value); }); darkTheme.addEventListener('change', function() { - demoRoot - .querySelector('.example-slider-wrapper') - .classList[ darkTheme.checked ? 'add' : 'remove']('mdc-theme--dark'); + var examples = demoRoot.querySelectorAll('.example-slider-wrapper'); + examples.forEach((example) => { + example.classList[ darkTheme.checked ? 'add' : 'remove']('mdc-theme--dark'); + }); }); disabled.addEventListener('change', function() { - slider.disabled = disabled.checked; + continuousSlider.disabled = disabled.checked; + discreteSlider.disabled = disabled.checked; + discreteWMarkerSlider.disabled = disabled.checked; }); useCustomColor.addEventListener('change', function() { - demoRoot - .querySelector('.example-slider-wrapper') - .classList[ useCustomColor.checked ? 'add' : 'remove' ]('custom-bg'); + var examples = demoRoot.querySelectorAll('.example-slider-wrapper'); + examples.forEach((example) => { + example.classList[ useCustomColor.checked ? 'add' : 'remove' ]('custom-bg'); + }); }); rtl.addEventListener('change', function() { - var wrapper = demoRoot.querySelector('.example-slider-wrapper'); - if (rtl.checked) { - wrapper.setAttribute('dir', 'rtl'); - } else { - wrapper.removeAttribute('dir'); - } - slider.layout(); + var examples = demoRoot.querySelectorAll('.example-slider-wrapper'); + examples.forEach((example) => { + if (rtl.checked) { + example.setAttribute('dir', 'rtl'); + } else { + example.removeAttribute('dir'); + } + }); + continuousSlider.layout(); + discreteSlider.layout(); + discreteWMarkerSlider.layout(); }); } })(); diff --git a/package.json b/package.json index 76ef866a6e4..7c6b1e6f0f2 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "stylelint-order": "^0.5.0", "stylelint-scss": "^1.4.1", "stylelint-selector-bem-pattern": "^1.0.0", - "testdouble": "^3.0.0", + "testdouble": "3.0.0", "to-slug-case": "^1.0.0", "validate-commit-msg": "^2.6.1", "webpack": "^2.2.1", diff --git a/packages/mdc-slider/README.md b/packages/mdc-slider/README.md index 84070f4d7df..7d574954ef4 100644 --- a/packages/mdc-slider/README.md +++ b/packages/mdc-slider/README.md @@ -15,10 +15,6 @@ path: /catalog/input-controls/sliders/ --> -> **Status**: -> - [x] Continuous Sliders -> - [ ] Discrete Sliders - MDC Slider provides an implementation of the Material Design slider component. It is modeled after the browser's `` element. Sliders are fully RTL-aware, and conform to the WAI-ARIA [slider authoring practices](https://www.w3.org/TR/wai-aria-practices-1.1/#slider). @@ -48,6 +44,8 @@ npm i --save @material/slider ## Usage +### Continuous Slider + ```html
``` +### Discrete Slider + +```html +
+
+
+
+
+
+ +
+ + + +
+
+
+``` + Then in JS ```js @@ -123,7 +142,33 @@ When a step value is given, the slider will quantize all values to match that st for the minimum and maximum values, which can always be set. This is to ensure consistent behavior. The step value can be any positive floating-point number, or `0`. When the step value is `0`, the -slider is considered to not have any step. +slider is considered to not have any step. A error will be thrown if you are trying to set step +value to be a negative number. + +Discrete sliders are required to have a positive step value other than 0. If a step value of 0 is +provided, or no value is provided, the step value will default to 1. + +### Display tracker markers (discrete slider only) + +Discrete sliders support display markers on their tracks by adding the `mdc-slider--display-markers` +modifier class to `mdc-slider`, and `
` to the +track container. + +```html +
+
+
+
+
+ +
+``` + +> **NOTE**: When the provided step is indivisble to distance between max and min, +> we place the secondary to last marker proportionally at where thumb could reach and +> place the last marker at max value. ### Disabled sliders @@ -182,25 +227,30 @@ use to build a custom MDCSlider component for their framework. | Method Signature | Description | | --- | --- | +| `hasClass(className: string) => boolean` | Checks if `className` exists on the root element | | `addClass(className: string) => void` | Adds a class `className` to the root element | | `removeClass(className: string) => void` | Removes a class `className` from the root element | | `getAttribute(name: string) => string?` | Returns the value of the attribute `name` on the root element, or `null` if that attribute is not present on the root element. | | `setAttribute(name: string, value: string) => void` | Sets an attribute `name` to the value `value` on the root element. | -| `removeAttribute(name: string) => void` | Removes an attribute `name` from the root element | +| `removeAttribute(name: string) => void` | Removes an attribute `name` from the root element. | | `computeBoundingRect() => ClientRect` | Computes and returns the bounding client rect for the root element. Our implementations calls `getBoundingClientRect()`` for this. | -| `getTabIndex() => number` | Returns the value of the `tabIndex` property on the root element | -| `registerInteractionHandler(type: string, handler: EventListener) => void` | Adds an event listener `handler` for event type `type` to the slider's root element | -| `deregisterInteractionHandler(type: string, handler: EventListener) => void` | Removes an event listener `handler` for event type `type` from the slider's root element | -| `registerThumbContainerInteractionHandler(type: string, handler: EventListener) => void` | Adds an event listener `handler` for event type `type` to the slider's thumb container element | -| `deregisterThumbContainerInteractionHandler(type: string, handler: EventListener) => void` | Removes an event listener `handler` for event type `type` from the slider's thumb container element | -| `registerBodyInteractionHandler(type: string, handler: EventListener) => void` | Adds an event listener `handler` for event type `type` to the `` element of the slider's document | -| `deregisterBodyInteractionHandler(type: string, handler: EventListener) => void` | Removes an event listener `handler` for event type `type` from the `` element of the slider's document | +| `getTabIndex() => number` | Returns the value of the `tabIndex` property on the root element. | +| `registerInteractionHandler(type: string, handler: EventListener) => void` | Adds an event listener `handler` for event type `type` to the slider's root element. | +| `deregisterInteractionHandler(type: string, handler: EventListener) => void` | Removes an event listener `handler` for event type `type` from the slider's root element. | +| `registerThumbContainerInteractionHandler(type: string, handler: EventListener) => void` | Adds an event listener `handler` for event type `type` to the slider's thumb container element. | +| `deregisterThumbContainerInteractionHandler(type: string, handler: EventListener) => void` | Removes an event listener `handler` for event type `type` from the slider's thumb container element. | +| `registerBodyInteractionHandler(type: string, handler: EventListener) => void` | Adds an event listener `handler` for event type `type` to the `` element of the slider's document. | +| `deregisterBodyInteractionHandler(type: string, handler: EventListener) => void` | Removes an event listener `handler` for event type `type` from the `` element of the slider's document. | | `registerResizeHandler(handler: EventListener) => void` | Adds an event listener `handler` that is called when the component's viewport resizes, e.g. `window.onresize`. | -| `deregisterResizeHandler(handler: EventListener) => void` | Removes an event listener `handler` that was attached via `registerResizeHandler` | +| `deregisterResizeHandler(handler: EventListener) => void` | Removes an event listener `handler` that was attached via `registerResizeHandler`. | | `notifyInput() => void` | Broadcasts an "input" event notifying clients that the slider's value is currently being changed. The implementation should choose to pass along any relevant information pertaining to this event. In our case we pass along the instance of the component for which the event is triggered for. | | `notifyChange() => void` | Broadcasts a "change" event notifying clients that a change to the slider's value has been committed by the user. Similar guidance applies here as for `notifyInput()`. | | `setThumbContainerStyleProperty(propertyName: string, value: string) => void` | Sets a dash-cased style property `propertyName` to the given `value` on the thumb container element. | -| `setTrackStyleProperty(propertyName: string, value: string) => void` | Sets a dash-cased style property `propertyName` to the given `value` on the track element | +| `setTrackStyleProperty(propertyName: string, value: string) => void` | Sets a dash-cased style property `propertyName` to the given `value` on the track element. | +| `setMarkerValue(value: number) => void` | Sets pin value marker's value when discrete slider thumb moves. | +| `appendTrackMarkers(numMarkers: number) => void` | Appends track marker element to track container. | +| `removeTrackMarkers() => void` | Removes existing marker elements to track container. | +| `setLastTrackMarkersStyleProperty(propertyName: string, value: string) => void` | Sets a dash-cased style property `propertyName` to the given `value` on the last element of the track markers. | | `isRTL() => boolean` | True if the slider is within an RTL context, false otherwise. | #### MDCSliderFoundation API @@ -218,6 +268,7 @@ use to build a custom MDCSlider component for their framework. | `setStep(step: number) => void` | Sets the step value of the slider | | `isDisabled() => boolean` | Returns whether or not the slider is disabled | | `setDisabled(disabled: boolean) => void` | Disables the slider when given true, enables it otherwise. | +| `setupTrackMarker() => void` | Put correct number of markers in track for discrete slider that display track markers. No-op if it doesn't meet those criteria. | ### Theming diff --git a/packages/mdc-slider/constants.js b/packages/mdc-slider/constants.js index b2bdd6e1f45..7c9d755cca0 100644 --- a/packages/mdc-slider/constants.js +++ b/packages/mdc-slider/constants.js @@ -21,11 +21,16 @@ export const cssClasses = { FOCUS: 'mdc-slider--focus', IN_TRANSIT: 'mdc-slider--in-transit', OFF: 'mdc-slider--off', + IS_DISCRETE: 'mdc-slider--discrete', + HAS_TRACK_MARKER: 'mdc-slider--display-markers', }; export const strings = { TRACK_SELECTOR: '.mdc-slider__track', + TRACK_MARKER_CONTAINER_SELECTOR: '.mdc-slider__track-marker-container', + LAST_TRACK_MARKER_SELECTOR: '.mdc-slider__track-marker:last-child', THUMB_CONTAINER_SELECTOR: '.mdc-slider__thumb-container', + PIN_VALUE_MARKER_SELECTOR: '.mdc-slider__pin-value-marker', ARIA_VALUEMIN: 'aria-valuemin', ARIA_VALUEMAX: 'aria-valuemax', ARIA_VALUENOW: 'aria-valuenow', diff --git a/packages/mdc-slider/foundation.js b/packages/mdc-slider/foundation.js index 78acc8d26f0..9c72af25d02 100644 --- a/packages/mdc-slider/foundation.js +++ b/packages/mdc-slider/foundation.js @@ -45,6 +45,7 @@ export default class MDCSliderFoundation extends MDCFoundation { static get defaultAdapter() { return { + hasClass: (/* className: string */) => /* boolean */ false, addClass: (/* className: string */) => {}, removeClass: (/* className: string */) => {}, getAttribute: (/* name: string */) => /* string|null */ null, @@ -66,6 +67,10 @@ export default class MDCSliderFoundation extends MDCFoundation { notifyChange: () => {}, setThumbContainerStyleProperty: (/* propertyName: string, value: string */) => {}, setTrackStyleProperty: (/* propertyName: string, value: string */) => {}, + setMarkerValue: (/* value: number */) => {}, + appendTrackMarkers: (/* numMarkers: number */) => {}, + removeTrackMarkers: () => {}, + setLastTrackMarkersStyleProperty: (/* propertyName: string, value: string */) => {}, isRTL: () => /* boolean */ false, }; } @@ -79,6 +84,8 @@ export default class MDCSliderFoundation extends MDCFoundation { this.off_ = false; this.active_ = false; this.inTransit_ = false; + this.isDiscrete_ = false; + this.hasTrackMarker_ = false; this.handlingThumbTargetEvt_ = false; this.min_ = 0; this.max_ = 100; @@ -101,6 +108,8 @@ export default class MDCSliderFoundation extends MDCFoundation { } init() { + this.isDiscrete_ = this.adapter_.hasClass(cssClasses.IS_DISCRETE); + this.hasTrackMarker_ = this.adapter_.hasClass(cssClasses.HAS_TRACK_MARKER); this.adapter_.registerInteractionHandler('mousedown', this.mousedownHandler_); this.adapter_.registerInteractionHandler('pointerdown', this.pointerdownHandler_); this.adapter_.registerInteractionHandler('touchstart', this.touchstartHandler_); @@ -112,6 +121,10 @@ export default class MDCSliderFoundation extends MDCFoundation { }); this.adapter_.registerResizeHandler(this.resizeHandler_); this.layout(); + // At last step, provide a reasonable default value to discrete slider + if (this.isDiscrete_ && this.getStep() == 0) { + this.setStep(1); + } } destroy() { @@ -127,6 +140,32 @@ export default class MDCSliderFoundation extends MDCFoundation { this.adapter_.deregisterResizeHandler(this.resizeHandler_); } + setupTrackMarker() { + if (this.isDiscrete_ && this.hasTrackMarker_&& this.getStep() != 0) { + const min = this.getMin(); + const max = this.getMax(); + const step = this.getStep(); + let numMarkers = (max - min) / step; + + // In case distance between max & min is indivisible to step, + // we place the secondary to last marker proportionally at where thumb + // could reach and place the last marker at max value + const indivisible = Math.ceil(numMarkers) !== numMarkers; + if (indivisible) { + numMarkers = Math.ceil(numMarkers); + } + + this.adapter_.removeTrackMarkers(); + this.adapter_.appendTrackMarkers(numMarkers); + + if (indivisible) { + const lastStepRatio = (max - numMarkers * step) / step + 1; + const flex = getCorrectPropertyName(window, 'flex'); + this.adapter_.setLastTrackMarkersStyleProperty(flex, lastStepRatio); + } + } + } + layout() { this.rect_ = this.adapter_.computeBoundingRect(); this.updateUIForCurrentValue_(); @@ -151,6 +190,7 @@ export default class MDCSliderFoundation extends MDCFoundation { this.max_ = max; this.setValue_(this.value_, false, true); this.adapter_.setAttribute(strings.ARIA_VALUEMAX, String(this.max_)); + this.setupTrackMarker(); } getMin() { @@ -164,6 +204,7 @@ export default class MDCSliderFoundation extends MDCFoundation { this.min_ = min; this.setValue_(this.value_, false, true); this.adapter_.setAttribute(strings.ARIA_VALUEMIN, String(this.min_)); + this.setupTrackMarker(); } getStep() { @@ -174,8 +215,12 @@ export default class MDCSliderFoundation extends MDCFoundation { if (step < 0) { throw new Error('Step cannot be set to a negative number'); } + if (this.isDiscrete_ && (typeof(step) !== 'number' || step < 1)) { + step = 1; + } this.step_ = step; this.setValue_(this.value_, false, true); + this.setupTrackMarker(); } isDisabled() { @@ -356,6 +401,9 @@ export default class MDCSliderFoundation extends MDCFoundation { if (shouldFireInput) { this.adapter_.notifyInput(); + if (this.isDiscrete_) { + this.adapter_.setMarkerValue(value); + } } } diff --git a/packages/mdc-slider/index.js b/packages/mdc-slider/index.js index 7e3b63a0bd8..f4d8e5e2db7 100644 --- a/packages/mdc-slider/index.js +++ b/packages/mdc-slider/index.js @@ -69,10 +69,13 @@ export class MDCSlider extends MDCComponent { initialize() { this.thumbContainer_ = this.root_.querySelector(strings.THUMB_CONTAINER_SELECTOR); this.track_ = this.root_.querySelector(strings.TRACK_SELECTOR); + this.pinValueMarker_ = this.root_.querySelector(strings.PIN_VALUE_MARKER_SELECTOR); + this.trackMarkerContainer_ = this.root_.querySelector(strings.TRACK_MARKER_CONTAINER_SELECTOR); } getDefaultFoundation() { return new MDCSliderFoundation({ + hasClass: (className) => this.root_.classList.contains(className), addClass: (className) => this.root_.classList.add(className), removeClass: (className) => this.root_.classList.remove(className), getAttribute: (name) => this.root_.getAttribute(name), @@ -116,6 +119,28 @@ export class MDCSlider extends MDCComponent { setTrackStyleProperty: (propertyName, value) => { this.track_.style.setProperty(propertyName, value); }, + setMarkerValue: (value) => { + this.pinValueMarker_.innerText = value; + }, + appendTrackMarkers: (numMarkers) => { + const frag = document.createDocumentFragment(); + for (let i = 0; i < numMarkers; i++) { + const marker = document.createElement('div'); + marker.classList.add('mdc-slider__track-marker'); + frag.appendChild(marker); + } + this.trackMarkerContainer_.appendChild(frag); + }, + removeTrackMarkers: () => { + while (this.trackMarkerContainer_.firstChild) { + this.trackMarkerContainer_.removeChild(this.trackMarkerContainer_.firstChild); + } + }, + setLastTrackMarkersStyleProperty: (propertyName, value) => { + // We remove and append new nodes, thus, the last track marker must be dynamically found. + const lastTrackMarker = this.root_.querySelector(strings.LAST_TRACK_MARKER_SELECTOR); + lastTrackMarker.style.setProperty(propertyName, value); + }, isRTL: () => getComputedStyle(this.root_).direction === 'rtl', }); } @@ -130,6 +155,7 @@ export class MDCSlider extends MDCComponent { this.root_.hasAttribute(strings.ARIA_DISABLED) && this.root_.getAttribute(strings.ARIA_DISABLED) !== 'false' ); + this.foundation_.setupTrackMarker(); } layout() { diff --git a/packages/mdc-slider/mdc-slider.scss b/packages/mdc-slider/mdc-slider.scss index b84d0bcf253..1e1f6fa419c 100644 --- a/packages/mdc-slider/mdc-slider.scss +++ b/packages/mdc-slider/mdc-slider.scss @@ -15,6 +15,7 @@ // @import "@material/theme/mixins"; +@import "@material/typography/mixins"; @import "@material/rtl/mixins"; @import "./variables"; @@ -77,6 +78,50 @@ $mdc-slider-dark-theme-grey: #5c5c5c; } } + &__track-marker-container { + display: flex; + margin-right: 0; + margin-left: -1px; + visibility: hidden; + + @include mdc-rtl(".mdc-slider") { + margin-right: -1px; + margin-left: 0; + } + + &::after { + display: block; + width: 2px; + height: 2px; + background-color: $mdc-slider-dark-theme-grey; + content: ""; + + @include mdc-theme-dark(".mdc-slider", true) { + background-color: $mdc-slider-main-grey; + } + } + } + + &__track-marker { + flex: 1; + + &::after { + display: block; + width: 2px; + height: 2px; + background-color: $mdc-slider-dark-theme-grey; + content: ""; + + @include mdc-theme-dark(".mdc-slider", true) { + background-color: $mdc-slider-main-grey; + } + } + + &:first-child::after { + width: 3px; + } + } + &__thumb-container { position: absolute; top: 15px; @@ -109,6 +154,37 @@ $mdc-slider-dark-theme-grey: #5c5c5c; border-radius: 50%; opacity: 0; } + + &__pin { + @include mdc-theme-prop(background-color, primary); + @include mdc-theme-prop(color, text-primary-on-primary); + + display: flex; + position: absolute; + top: 0; + left: 0; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + margin-top: -2px; + margin-left: -2px; + transform: rotate(-45deg) scale(0) translate(0, 0); + transition: transform 100ms ease-out; + border-radius: 50% 50% 50% 0%; + + /** + * Ensuring that the pin is higher than the thumb in the stacking order + * removes some rendering jank observed in Chrome. + */ + z-index: 1; + } + + &__pin-value-marker { + @include mdc-typography(caption); + + transform: rotate(45deg); + } } .mdc-slider--active { @@ -172,6 +248,14 @@ $mdc-slider-dark-theme-grey: #5c5c5c; } } + .mdc-slider__pin { + background-color: $mdc-slider-main-grey; + + @include mdc-theme-dark(".mdc-slider", true) { + background-color: $mdc-slider-dark-theme-grey; + } + } + // stylelint-disable plugin/selector-bem-pattern &.mdc-slider--focus { .mdc-slider__thumb { @@ -223,4 +307,35 @@ $mdc-slider-dark-theme-grey: #5c5c5c; } } +.mdc-slider--discrete { + // stylelint-disable plugin/selector-bem-pattern + &.mdc-slider--active { + .mdc-slider__thumb { + transform: scale(calc(12 / 21)); + } + + .mdc-slider__pin { + transform: rotate(-45deg) scale(1) translate(19px, -20px); + } + } + + &.mdc-slider--focus { + .mdc-slider__thumb { + animation: none; + } + + .mdc-slider__focus-ring { + transform: none; + opacity: 0; + } + } + + &.mdc-slider--display-markers { + .mdc-slider__track-marker-container { + visibility: visible; + } + } + // stylelint-enable plugin/selector-bem-pattern +} + // postcss-bem-linter: end diff --git a/test/unit/mdc-slider/foundation-pointer-events.test.js b/test/unit/mdc-slider/foundation-pointer-events.test.js index cc180e7bfc6..020c9017821 100644 --- a/test/unit/mdc-slider/foundation-pointer-events.test.js +++ b/test/unit/mdc-slider/foundation-pointer-events.test.js @@ -165,6 +165,23 @@ function createTestSuiteForPointerEvents(downEvt, moveEvt, upEvt, pageXObj = (pa raf.restore(); }); + test(`on ${downEvt} notifies discrete slider pin value marker to change value`, () => { + const {foundation, mockAdapter, raf, rootHandlers} = setupTest(); + const {isA} = td.matchers; + + td.when(mockAdapter.computeBoundingRect()).thenReturn({left: 0, width: 100}); + td.when(mockAdapter.hasClass(cssClasses.IS_DISCRETE)).thenReturn(true); + foundation.init(); + raf.flush(); + + rootHandlers[downEvt](pageXObj(50)); + raf.flush(); + + td.verify(mockAdapter.setMarkerValue(isA(Number))); + + raf.restore(); + }); + test(`on ${downEvt} attaches event handlers for ${moveEvt} and ${upEvt} events to the document body`, () => { const {foundation, mockAdapter, raf, rootHandlers} = setupTest(); const {isA} = td.matchers; @@ -263,6 +280,27 @@ function createTestSuiteForPointerEvents(downEvt, moveEvt, upEvt, pageXObj = (pa raf.restore(); }); + test(`on body ${moveEvt} notifies discrete slider pin value marker to change value`, () => { + const {foundation, mockAdapter, raf, rootHandlers, bodyHandlers} = setupTest(); + const {isA} = td.matchers; + + td.when(mockAdapter.computeBoundingRect()).thenReturn({left: 0, width: 100}); + td.when(mockAdapter.hasClass(cssClasses.IS_DISCRETE)).thenReturn(true); + foundation.init(); + raf.flush(); + + rootHandlers[downEvt](pageXObj(49)); + bodyHandlers[moveEvt](Object.assign({ + preventDefault: () => {}, + }, pageXObj(50))); + raf.flush(); + + // Once on mousedown, once on mousemove + td.verify(mockAdapter.setMarkerValue(isA(Number)), {times: 2}); + + raf.restore(); + }); + test(`on body ${upEvt} removes the mdc-slider--active class from the component`, () => { const {foundation, mockAdapter, raf, rootHandlers, bodyHandlers} = setupTest(); diff --git a/test/unit/mdc-slider/foundation.test.js b/test/unit/mdc-slider/foundation.test.js index e656f0878a1..fa8b06945bc 100644 --- a/test/unit/mdc-slider/foundation.test.js +++ b/test/unit/mdc-slider/foundation.test.js @@ -43,12 +43,13 @@ test('exports numbers', () => { test('default adapter returns a complete adapter implementation', () => { verifyDefaultAdapter(MDCSliderFoundation, [ - 'addClass', 'removeClass', 'getAttribute', 'setAttribute', 'removeAttribute', + 'hasClass', 'addClass', 'removeClass', 'getAttribute', 'setAttribute', 'removeAttribute', 'computeBoundingRect', 'getTabIndex', 'registerInteractionHandler', 'deregisterInteractionHandler', 'registerThumbContainerInteractionHandler', 'deregisterThumbContainerInteractionHandler', 'registerBodyInteractionHandler', 'deregisterBodyInteractionHandler', 'registerResizeHandler', 'deregisterResizeHandler', 'notifyInput', 'notifyChange', 'setThumbContainerStyleProperty', - 'setTrackStyleProperty', 'isRTL', + 'setTrackStyleProperty', 'setMarkerValue', 'appendTrackMarkers', 'removeTrackMarkers', + 'setLastTrackMarkersStyleProperty', 'isRTL', ]); }); @@ -101,6 +102,21 @@ test('#init registers all necessary event handlers for the component', () => { raf.restore(); }); +test('#init checks if slider is discrete and if display track markers', () => { + const {foundation, mockAdapter} = setupTest(); + const raf = createMockRaf(); + + td.when(mockAdapter.computeBoundingRect()).thenReturn({width: 100, left: 200}); + foundation.init(); + + raf.flush(); + + td.verify(mockAdapter.hasClass(cssClasses.IS_DISCRETE)); + td.verify(mockAdapter.hasClass(cssClasses.HAS_TRACK_MARKER)); + + raf.restore(); +}); + test('#init performs an initial layout of the component', () => { const {foundation, mockAdapter} = setupTest(); const raf = createMockRaf(); @@ -135,6 +151,94 @@ test('#destroy deregisters all component event handlers registered during init() td.verify(mockAdapter.deregisterResizeHandler(isA(Function))); }); +test('#setupTrackMarker appends correct number of markers to discrete slider with markers', () => { + const {foundation, mockAdapter} = setupTest(); + const raf = createMockRaf(); + + td.when(mockAdapter.computeBoundingRect()).thenReturn({left: 0, width: 100}); + td.when(mockAdapter.hasClass(cssClasses.IS_DISCRETE)).thenReturn(true); + td.when(mockAdapter.hasClass(cssClasses.HAS_TRACK_MARKER)).thenReturn(true); + foundation.init(); + raf.flush(); + + const numMarkers = 10; + foundation.setMax(100); + foundation.setMin(0); + foundation.setStep(10); + foundation.setupTrackMarker(); + + td.verify(mockAdapter.removeTrackMarkers()); + td.verify(mockAdapter.appendTrackMarkers(numMarkers)); + + raf.restore(); +}); + +test('#setupTrackMarker append one excessive marker if distance is indivisible to step', () => { + const {foundation, mockAdapter} = setupTest(); + const raf = createMockRaf(); + + td.when(mockAdapter.computeBoundingRect()).thenReturn({left: 0, width: 100}); + td.when(mockAdapter.hasClass(cssClasses.IS_DISCRETE)).thenReturn(true); + td.when(mockAdapter.hasClass(cssClasses.HAS_TRACK_MARKER)).thenReturn(true); + foundation.init(); + raf.flush(); + + const numMarkers = 12; + foundation.setMax(100); + foundation.setMin(0); + foundation.setStep(9); + foundation.setupTrackMarker(); + + td.verify(mockAdapter.removeTrackMarkers()); + td.verify(mockAdapter.appendTrackMarkers(numMarkers)); + + raf.restore(); +}); + +test('#setupTrackMarker does execute if it is continuous slider', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + const raf = createMockRaf(); + + td.when(mockAdapter.computeBoundingRect()).thenReturn({left: 0, width: 100}); + td.when(mockAdapter.hasClass(cssClasses.IS_DISCRETE)).thenReturn(false); + td.when(mockAdapter.hasClass(cssClasses.HAS_TRACK_MARKER)).thenReturn(true); + foundation.init(); + raf.flush(); + + foundation.setMax(100); + foundation.setMin(0); + foundation.setStep(10); + foundation.setupTrackMarker(); + + td.verify(mockAdapter.removeTrackMarkers(), {times: 0}); + td.verify(mockAdapter.appendTrackMarkers(isA(Number)), {times: 0}); + + raf.restore(); +}); + +test('#setupTrackMarker does execute if discrete slider does not display markers', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + const raf = createMockRaf(); + + td.when(mockAdapter.computeBoundingRect()).thenReturn({left: 0, width: 100}); + td.when(mockAdapter.hasClass(cssClasses.IS_DISCRETE)).thenReturn(true); + td.when(mockAdapter.hasClass(cssClasses.HAS_TRACK_MARKER)).thenReturn(false); + foundation.init(); + raf.flush(); + + foundation.setMax(100); + foundation.setMin(0); + foundation.setStep(10); + foundation.setupTrackMarker(); + + td.verify(mockAdapter.removeTrackMarkers(), {times: 0}); + td.verify(mockAdapter.appendTrackMarkers(isA(Number)), {times: 0}); + + raf.restore(); +}); + test('#layout re-computes the bounding rect for the component on each call', () => { const {foundation, mockAdapter} = setupTest(); const raf = createMockRaf(); @@ -518,6 +622,26 @@ test('#setMax updates "aria-valuemax" to the new maximum', () => { raf.restore(); }); +test('#setMax re-place track markers if slider is discrete and displays markers', () => { + const {foundation, mockAdapter} = setupTest(); + const raf = createMockRaf(); + const {isA} = td.matchers; + + td.when(mockAdapter.computeBoundingRect()).thenReturn({left: 0, width: 100}); + td.when(mockAdapter.hasClass(cssClasses.IS_DISCRETE)).thenReturn(true); + td.when(mockAdapter.hasClass(cssClasses.HAS_TRACK_MARKER)).thenReturn(true); + foundation.init(); + raf.flush(); + + foundation.setMax(50); + + td.verify(mockAdapter.removeTrackMarkers()); + td.verify(mockAdapter.appendTrackMarkers(isA(Number))); + + raf.restore(); +}); + + test('#getMin/#setMin retrieves / sets the minimum value, respectively', () => { const {foundation, mockAdapter} = setupTest(); const raf = createMockRaf(); @@ -600,6 +724,25 @@ test('#setMin updates "aria-valuemin" to the new minimum', () => { raf.restore(); }); +test('#setMin re-place track markers if slider is discrete and displays markers', () => { + const {foundation, mockAdapter} = setupTest(); + const raf = createMockRaf(); + const {isA} = td.matchers; + + td.when(mockAdapter.computeBoundingRect()).thenReturn({left: 0, width: 100}); + td.when(mockAdapter.hasClass(cssClasses.IS_DISCRETE)).thenReturn(true); + td.when(mockAdapter.hasClass(cssClasses.HAS_TRACK_MARKER)).thenReturn(true); + foundation.init(); + raf.flush(); + + foundation.setMin(10); + + td.verify(mockAdapter.removeTrackMarkers()); + td.verify(mockAdapter.appendTrackMarkers(isA(Number))); + + raf.restore(); +}); + test('#getStep/#setStep retrieves / sets the step value, respectively', () => { const {foundation, mockAdapter} = setupTest(); const raf = createMockRaf(); @@ -646,6 +789,22 @@ test('#setStep throws if the step value given is less than 0', () => { raf.restore(); }); +test('#setStep set discrete slider step to 1 if the provided step is invalid', () => { + const {foundation, mockAdapter} = setupTest(); + const raf = createMockRaf(); + + td.when(mockAdapter.computeBoundingRect()).thenReturn({left: 0, width: 0}); + td.when(mockAdapter.hasClass(cssClasses.IS_DISCRETE)).thenReturn(true); + foundation.init(); + raf.flush(); + + foundation.setStep(0.5); + + assert.equal(foundation.getStep(), 1); + + raf.restore(); +}); + test('#setStep updates the slider\'s UI when altering the step value', () => { const {foundation, mockAdapter} = setupTest(); const raf = createMockRaf(); @@ -668,6 +827,25 @@ test('#setStep updates the slider\'s UI when altering the step value', () => { raf.restore(); }); +test('#setStep re-place track markers if slider is discrete and displays markers', () => { + const {foundation, mockAdapter} = setupTest(); + const raf = createMockRaf(); + const {isA} = td.matchers; + + td.when(mockAdapter.computeBoundingRect()).thenReturn({left: 0, width: 100}); + td.when(mockAdapter.hasClass(cssClasses.IS_DISCRETE)).thenReturn(true); + td.when(mockAdapter.hasClass(cssClasses.HAS_TRACK_MARKER)).thenReturn(true); + foundation.init(); + raf.flush(); + + foundation.setStep(10); + + td.verify(mockAdapter.removeTrackMarkers()); + td.verify(mockAdapter.appendTrackMarkers(isA(Number))); + + raf.restore(); +}); + test('#isDisabled/#setDisabled retrieves / sets the disabled state, respectively', () => { const {foundation} = setupTest(); foundation.setDisabled(true); diff --git a/test/unit/mdc-slider/mdc-slider.test.js b/test/unit/mdc-slider/mdc-slider.test.js index ff2d6598b69..f8246a1f466 100644 --- a/test/unit/mdc-slider/mdc-slider.test.js +++ b/test/unit/mdc-slider/mdc-slider.test.js @@ -32,8 +32,12 @@ function getFixture() {
+
+
+ 30 +
@@ -232,6 +236,13 @@ test('#stepDown decrements the slider by the step value if no value given and a assert.equal(component.value, 10); }); +test('adapter#hasClass checks if a class exists on root element', () => { + const {root, component} = setupTest(); + root.classList.add('foo'); + + assert.isTrue(component.getDefaultFoundation().adapter_.hasClass('foo')); +}); + test('adapter#addClass adds a class to the root element', () => { const {root, component} = setupTest(); component.getDefaultFoundation().adapter_.addClass('foo'); @@ -421,6 +432,51 @@ test('adapter#setTrackStyleProperty sets a style property on the track element', assert.equal(track.style.backgroundColor, div.style.backgroundColor); }); +test('adapter#setMarkerValue changes the value on pin value markers', () => { + const {root, component} = setupTest(); + const pinValueMarker = root.querySelector(strings.PIN_VALUE_MARKER_SELECTOR); + + component.getDefaultFoundation().adapter_.setMarkerValue(10); + + assert.equal(pinValueMarker.innerHTML, 10); +}); + +test('adapter#appendTrackMarkers appends correct number of markers to track', () => { + const {root, component} = setupTest(); + const markerContainer = root.querySelector(strings.TRACK_MARKER_CONTAINER_SELECTOR); + + component.getDefaultFoundation().adapter_.appendTrackMarkers(1); + + assert.equal(markerContainer.firstChild.className, 'mdc-slider__track-marker'); + assert.equal(markerContainer.childNodes.length, 1); +}); + +test('adapter#removeTrackMarkers all markers from track', () => { + const {root, component} = setupTest(); + const markerContainer = root.querySelector(strings.TRACK_MARKER_CONTAINER_SELECTOR); + + component.getDefaultFoundation().adapter_.appendTrackMarkers(1); + assert.equal(markerContainer.childNodes.length, 1); + + component.getDefaultFoundation().adapter_.removeTrackMarkers(); + assert.equal(markerContainer.childNodes.length, 0); +}); + +test('adapter#setLastTrackMarkersStyleProperty all markers from track', () => { + const {root, component} = setupTest(); + + // We need to first append one marker to the container + component.getDefaultFoundation().adapter_.appendTrackMarkers(1); + const lastMarker = root.querySelector(strings.LAST_TRACK_MARKER_SELECTOR); + + const div = bel`
`; + div.style.flex = 0.5; + + component.getDefaultFoundation().adapter_.setLastTrackMarkersStyleProperty('flex', 0.5); + + assert.equal(lastMarker.style.flex, div.style.flex); +}); + test('adapter#isRTL returns true when component is in an RTL context', () => { const wrapper = bel`
`; const {root, component} = setupTest();