diff --git a/demos/index.html b/demos/index.html index 3346aa78c05..1b3281120d8 100644 --- a/demos/index.html +++ b/demos/index.html @@ -39,6 +39,7 @@
  • Layout grid
  • List
  • Select
  • +
  • Slider
  • Menu (simple)
  • Switch
  • Radio
  • diff --git a/demos/slider.html b/demos/slider.html new file mode 100644 index 00000000000..a9d8eb4a605 --- /dev/null +++ b/demos/slider.html @@ -0,0 +1,99 @@ + + + + + + + MDC Slider Demo + + + + + + +
    +

    MDC slider

    + +
    +

    Continuous slider

    +
    + +
    +
    +
    +
    +
    +

    Value

    +
    + +
    +

    Continuous slider (accent color)

    +
    + +
    +
    +
    +
    +
    +
    +
    + + + + + diff --git a/package.json b/package.json index c1567c13c32..3978c51801a 100644 --- a/package.json +++ b/package.json @@ -126,6 +126,7 @@ "ripple", "rtl", "select", + "slider", "snackbar", "switch", "textfield", diff --git a/packages/material-components-web/index.js b/packages/material-components-web/index.js index a8d7f2b4291..14f0c4ad613 100644 --- a/packages/material-components-web/index.js +++ b/packages/material-components-web/index.js @@ -27,6 +27,7 @@ import * as textfield from '@material/textfield'; import * as snackbar from '@material/snackbar'; import * as menu from '@material/menu'; import * as select from '@material/select'; +import * as slider from '@material/slider'; import autoInit from '@material/auto-init'; // Register all components @@ -41,6 +42,7 @@ autoInit.register('MDCSnackbar', snackbar.MDCSnackbar); autoInit.register('MDCTextfield', textfield.MDCTextfield); autoInit.register('MDCSimpleMenu', menu.MDCSimpleMenu); autoInit.register('MDCSelect', select.MDCSelect); +// autoInit.register('MDCSelect', select.MDCSelect); // Export all components. export { @@ -57,5 +59,6 @@ export { textfield, menu, select, + slider, autoInit, }; diff --git a/packages/material-components-web/material-components-web.scss b/packages/material-components-web/material-components-web.scss index 1529bec68a2..0cda615e042 100644 --- a/packages/material-components-web/material-components-web.scss +++ b/packages/material-components-web/material-components-web.scss @@ -31,6 +31,7 @@ @import "@material/radio/mdc-radio"; @import "@material/ripple/mdc-ripple"; @import "@material/select/mdc-select"; +@import "@material/slider/mdc-slider"; @import "@material/snackbar/mdc-snackbar"; @import "@material/switch/mdc-switch"; @import "@material/textfield/mdc-textfield"; diff --git a/packages/material-components-web/package.json b/packages/material-components-web/package.json index cd7db7dfce1..fe057858c91 100644 --- a/packages/material-components-web/package.json +++ b/packages/material-components-web/package.json @@ -32,6 +32,7 @@ "@material/radio": "^0.1.6", "@material/ripple": "^0.5.0", "@material/select": "^0.3.1", + "@material/slider": "^0.1.0", "@material/snackbar": "^0.1.6", "@material/switch": "^0.1.3", "@material/textfield": "^0.2.4", diff --git a/packages/mdc-slider/README.md b/packages/mdc-slider/README.md new file mode 100644 index 00000000000..597fdd2e81d --- /dev/null +++ b/packages/mdc-slider/README.md @@ -0,0 +1,173 @@ +# MDC Slider + +The MDC Slider component provides a range input field adhering to the [Material Design Specification]( https://material.google.com/components/sliders.html). + +It handles cross-browser differences in how the native input[range] element is styled and handles mouse and touch events. +It provides an `MDCSlider:change` custom event giving access to the current range value in a uniform event. +It requires JavaScript to handle the lower and upper track shading but includes a degraded version that does +not require any javascript for basic operation. + +## Installation + +``` +npm install --save @material/slider +``` + +## Continuous slider usage + +```html +
    + +
    +
    +
    +
    +
    +``` + +The slider component is driven by an underlying native input[range] element. This element is sized and positioned the same way as the slider component itself, allowing for proper behavior of assistive devices. + +CSS classes: + +| Class | Description | +| -------------------------------------- | -------------------------------------------------------------------------- | +| `mdc-slider` | Mandatory. Needs to be set on the root element of the component. | +| `mdc-mdc-slider__input` | Mandatory. Needs to be set on the input element. | +| `mdc-mdc-slider__background` | Mandatory. Needs to be set on the background div element. | +| `mdc-mdc-slider__background-lower` | Mandatory. Needs to be set on the background lower track div element. | +| `mdc-mdc-slider__background-upper` | Mandatory. Needs to be set on the background upper track div element. | +| `mdc-slider--accent` | Optional. Colors the slider with the theme accent color. | + + +> _NOTE_: _if you plan on using CSS-only_. The slider will not shade the lower and upper parts +> of the track on most browsers except IE. + +### Using the JS component + +MDC Slider ships with Component / Foundation classes which are used to provide a full-fidelity +Material Design text field component. + +#### Including in code + +##### ES2015 + +```javascript +import {MDCSlider, MDCSliderFoundation} from 'mdc-slider'; +``` + +##### CommonJS + +```javascript +const MDCSlider = require('mdc-slider'); +const MDCSlider = MDCSlider.MDCSlider; +const MDCSliderFoundation = MDCSlider.MDCSliderFoundation; +``` + +##### AMD + +```javascript +require(['path/to/mdc-slider'], MDCSlider => { + const MDCSlider = MDCSlider.MDCSlider; + const MDCSliderFoundation = MDCSlider.MDCSliderFoundation; +}); +``` + +##### Global + +```javascript +const MDCSlider = mdc.slider.MDCSlider; +const MDCSliderFoundation = mdc.slider.MDCSliderFoundation; +``` + +#### Automatic Instantiation + +```javascript +mdc.slider.MDCSlider.attachTo(document.querySelector('.mdc-slider')); +``` + +#### Manual Instantiation + +```javascript +import {MDCSlider} from 'mdc-slider'; + +const slider = new MDCSlider(document.querySelector('.mdc-slider')); +``` + + +#### Using the slider component +```js +var slider = new mdc.slider.MDCSlider(document.querySelector('#mdc-slider-default')); + +slider.listen('MDCSlider:change', function(evt) { + console.log(`value: {evt.detail.value}`); +}) +``` + +### Slider component API + +##### MDCSlider.disabled + +Boolean. Proxies to the foundation's `isDisabled/setDisabled` methods when retrieved/set +respectively. + +#### MDCSlider.destroy() => void + +Cleans up handlers when slider is destroyed + +##### MDCCheckbox.value + +String. Returns the slider's value. Setting this property will update the underlying input control. +element. + + +### Slider Events + +#### MDCSlider:change + +Broadcast when a user actions on the `.mdc-slider__input` element. + + +### Using the foundation class + + +| Method Signature | Description | +| --- | --- | +| addClass(className: string) => void | Adds a class to the root element | +| removeClass(className: string) => void | Removes a class from the root element | +| `hasClass(className: string) => boolean` | Returns boolean indicating whether element has a given class. | +| addInputClass(className: string) => void | Adds a class to the input element | +| removeInputClass(className: string) => void | Removes a class from the inp[ut] element | +| setAttr(name: string, value: string) => void | Sets an attribute on the input element | +| registerHandler(type: string, handler: EventListener) => void | Registers an event listener on the native input element for the type | +| deregisterHandler(type: string, handler: EventListener) => void | Un-registers an event listener on the native input element for the type | +| registerRootHandler(type: string, handler: EventListener) => void | Registers an event listener on the root element for the type | +| deregisterRootHandler(type: string, handler: EventListener) => void | Un-registers an event listener on the root element for the type | +| getNativeInput() => {value: string, disabled: boolean} | Returns an object representing the native input element, with a similar API shape. The object returned should include the `value` and `disabled` properties. We _never_ alter the value within our code, however we _do_ update the disabled property, so if you choose to duck-type the return value for this method in your implementation it's important to keep this in mind. Also note that this method can return null, which the foundation will handle gracefully. | +| `hasNecessaryDom() => boolean` | Returns boolean indicating whether the necessary DOM is present (namely, the `mdc-slider__background` container). | +| `setLowerStyle(name: string, value: string) => void` | Sets the style `name` to `value` on the background-lower element. | +| `setUpperStyle(name: string, value: string) => void` | Sets the style `name` to `value` on the background-upper element. | +| `notifyChange(evtData: {value: number}) => void` | Broadcasts a change notification, passing along the `evtData` to the environment's event handling system. In our vanilla implementation, Custom Events are used for this. | + +#### The full foundation API + +##### MDCSliderFoundation.isDisabled() => boolean + +Returns a boolean specifying whether or not the input is disabled. + +##### MDCSliderFoundation.setDisabled(disabled: boolean) + +Updates the input's disabled state. + +##### MDCCheckboxFoundation.getValue() => string + +Returns the value of `adapter.getNativeControl().value`. Returns `null` if `getNativeControl()` +does not return an object. + +##### MDCCheckboxFoundation.setValue(value: string) => void + +Sets the value of `adapter.getNativeControl().value`. Does nothing if `getNativeControl()` does +not return an object. + +### Theming + +MDC Slider components use the configured theme's primary color for its thumb and track. diff --git a/packages/mdc-slider/continuous/constants.js b/packages/mdc-slider/continuous/constants.js new file mode 100644 index 00000000000..60e16192632 --- /dev/null +++ b/packages/mdc-slider/continuous/constants.js @@ -0,0 +1,31 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const ROOT = 'mdc-slider'; + +export const cssClasses = { + ROOT, + LOWEST_VALUE: `${ROOT}--lowest-value`, + UPGRADED: `${ROOT}--upgraded`, +}; + +export const strings = { + INPUT_SELECTOR: `.${ROOT}__input`, + BACKGROUND_SELECTOR: `.${ROOT}__background`, + BACKGROUND_LOWER_SELECTOR: `.${ROOT}__background-lower`, + BACKGROUND_UPPER_SELECTOR: `.${ROOT}__background-upper`, + ARIA_VALUENOW: 'aria-valuenow', +}; diff --git a/packages/mdc-slider/continuous/foundation.js b/packages/mdc-slider/continuous/foundation.js new file mode 100644 index 00000000000..e2bcdedcb4e --- /dev/null +++ b/packages/mdc-slider/continuous/foundation.js @@ -0,0 +1,233 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint object-curly-spacing: [error, always, { "objectsInObjects": false }], arrow-parens: [error, as-needed] */ + +import { MDCFoundation } from '@material/base'; +import { cssClasses, strings } from './constants'; + +export default class MDCSliderFoundation extends MDCFoundation { + static get cssClasses() { + return cssClasses; + } + + static get strings() { + return strings; + } + + static get defaultAdapter() { + return { + addClass: (/* className: string */) => {}, + removeClass: (/* className: string */) => {}, + hasClass: (/* className: string */) => {}, + addInputClass: (/* className: string */) => {}, + removeInputClass: (/* className: string */) => {}, + getNativeInput: (/* HTMLInputElement */) => {}, + registerHandler: (/* type: string, handler: EventListener */) => {}, + deregisterHandler: (/* type: string, handler: EventListener */) => {}, + registerRootHandler: (/* type: string, handler: EventListener */) => {}, + deregisterRootHandler: (/* type: string, handler: EventListener */) => {}, + setAttr: (/* name: string, value: string */) => {}, + setLowerStyle: (/* name: string, value: number */) => {}, + setUpperStyle: (/* name: string, value: number */) => {}, + hasNecessaryDom: (/* boolean */) => false, + notifyChange: (/* evtData: {value: number} */) => {}, + detectIsIE: (/* boolean */) => {}, + }; + } + + constructor(adapter) { + super(Object.assign(MDCSliderFoundation.defaultAdapter, adapter)); + + this.touchMoveHandler_ = evt => this.handleTouchMove_(evt); + this.inputHandler = evt => this.onInput_(evt); + this.changeHandler = evt => this.onChange_(evt); + this.mouseUpHandler = evt => this.onMouseUp_(evt); + this.containerMouseDownHandler = evt => this.onContainerMouseDown_(evt); + } + + init() { + const { ROOT, UPGRADED } = MDCSliderFoundation.cssClasses; + + if (!this.adapter_.hasClass(ROOT)) { + throw new Error(`${ROOT} class required in root element.`); + } + + if (!this.adapter_.hasNecessaryDom()) { + throw new Error(`Required DOM nodes missing in ${ROOT} component.`); + } + + // Browser feature detection. + this.isIE_ = this.adapter_.detectIsIE(); + + this.adapter_.addClass(UPGRADED); + this.adapter_.registerHandler('input', this.inputHandler); + this.adapter_.registerHandler('change', this.changeHandler); + this.adapter_.registerHandler('mouseup', this.mouseUpHandler); + this.adapter_.registerHandler('touchmove', this.touchMoveHandler_); + this.adapter_.registerHandler('touchstart', this.touchMoveHandler_); + this.adapter_.registerRootHandler('mousedown', this.containerMouseDownHandler); + this.updateValueStyles_(); + } + + destroy() { + this.adapter_.deregisterHandler('input', this.inputHandler); + this.adapter_.deregisterHandler('change', this.changeHandler); + this.adapter_.deregisterHandler('mouseup', this.mouseUpHandler); + this.adapter_.deregisterHandler('touchmove', this.touchMoveHandler_); + this.adapter_.deregisterHandler('touchstart', this.touchMoveHandler_); + this.adapter_.deregisterRootHandler('mousedown', this.containerMouseDownHandler); + this.adapter_.removeClass(MDCSliderFoundation.cssClasses.UPGRADED); + } + + onInput_(event) { + this.updateValueStyles_(); + } + + onChange_(event) { + this.updateValueStyles_(); + } + + onMouseUp_(event) { + event.target.blur(); + } + + handleTouchMove_(event) { + // IE handles events on input[range] so no additional help is needed here + if (this.isIE_ || (event.pointerType && event.pointerType !== 'touch')) { + return; + } + + const input_ = this.getNativeInput(); + const rect = input_.getBoundingClientRect(); + const eventclientX = event.touches[0].clientX; + + const value = input_.max / rect.width * (eventclientX - rect.left); + input_.value = value; + + event.preventDefault(); + + // create a new event on the slider element to ensure + // listeners receive the input event + let newEvent; + + // Only if browser supports new Event + if (typeof Event === 'function') { + newEvent = new Event('input', { + target: event.target, + buttons: event.buttons, + clientX: eventclientX, + clientY: rect.top, + }); + } + + input_.dispatchEvent(newEvent); + } + + onContainerMouseDown_(event) { + const input_ = this.getNativeInput(); + // If this click is not on the parent element (but rather some child) + // ignore. It may still bubble up. + if (event.target !== input_.parentElement) { + return; + } + + // Discard the original event and create a new event that + // is on the slider element. + event.preventDefault(); + + let newEvent; + const newclientY = input_.getBoundingClientRect().top; + + if (typeof MouseEvent === 'function') { + newEvent = new MouseEvent('mousedown', { + target: event.target, + buttons: event.buttons, + clientX: event.clientX, + clientY: newclientY, + }); + } else { + newEvent = document.createEvent('MouseEvent'); + newEvent.initMouseEvent( + 'mousedown', + false, + false, + event.view, + 1, + event.screenX, + event.screenY, + event.clientX, + newclientY, + false, + false, + false, + false, + 0, + null + ); + } + + input_.dispatchEvent(newEvent); + } + + updateValueStyles_() { + const { ARIA_VALUENOW } = MDCSliderFoundation.strings; + + // Calculate and apply percentages to div structure behind slider. + const element = this.getNativeInput(); + const fraction = (element.value - element.min) / (element.max - element.min); + if (fraction === 0) { + this.adapter_.addInputClass(MDCSliderFoundation.cssClasses.LOWEST_VALUE); + } else { + this.adapter_.removeInputClass(MDCSliderFoundation.cssClasses.LOWEST_VALUE); + } + + if (!this.isIE_) { + this.adapter_.setLowerStyle('flex', fraction); + this.adapter_.setLowerStyle('webkitFlex', fraction); + this.adapter_.setUpperStyle('flex', 1 - fraction); + this.adapter_.setUpperStyle('webkitFlex', 1 - fraction); + } + + const { value } = element; + this.adapter_.setAttr(ARIA_VALUENOW, value); + this.adapter_.notifyChange({ value }); + } + + isDisabled() { + return this.getNativeInput().disabled; + } + + setDisabled(disabled) { + this.getNativeInput().disabled = disabled; + } + + getNativeInput() { + return this.adapter_.getNativeInput() || { + disabled: false, + value: null, + }; + } + + getValue() { + return this.getNativeInput().value; + } + + setValue(value) { + this.getNativeInput().value = value; + this.updateValueStyles_(); + } +} diff --git a/packages/mdc-slider/continuous/index.js b/packages/mdc-slider/continuous/index.js new file mode 100644 index 00000000000..e74ac284da9 --- /dev/null +++ b/packages/mdc-slider/continuous/index.js @@ -0,0 +1,86 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint object-curly-spacing: [error, always, { "objectsInObjects": false }], arrow-parens: [error, as-needed] */ + +import { MDCComponent } from '@material/base'; +import MDCSliderFoundation from './foundation'; + +export { MDCSliderFoundation }; + +export class MDCSlider extends MDCComponent { + static attachTo(root) { + return new MDCSlider(root); + } + + get inputControl_() { + const { INPUT_SELECTOR } = MDCSliderFoundation.strings; + return this.root_.querySelector(INPUT_SELECTOR); + } + /* Return the input element inside the component. */ + get input_() { + return this.root_.querySelector(MDCSliderFoundation.strings.INPUT_SELECTOR); + } + + get lower_() { + return this.root_.querySelector(MDCSliderFoundation.strings.BACKGROUND_LOWER_SELECTOR); + } + + get upper_() { + return this.root_.querySelector(MDCSliderFoundation.strings.BACKGROUND_UPPER_SELECTOR); + } + + getDefaultFoundation() { + const input_ = this.input_; + const lower_ = this.lower_; + const upper_ = this.upper_; + + return new MDCSliderFoundation({ + addClass: className => this.root_.classList.add(className), + removeClass: className => this.root_.classList.remove(className), + hasClass: className => this.root_.classList.contains(className), + addInputClass: className => input_.classList.add(className), + registerHandler: (eventName, handler) => this.inputControl_.addEventListener(eventName, handler, false), + deregisterHandler: (eventName, handler) => this.inputControl_.removeEventListener(eventName, handler, false), + registerRootHandler: (eventName, handler) => this.root_.addEventListener(eventName, handler, false), + deregisterRootHandler: (eventName, handler) => this.root_.removeEventListener(eventName, handler, false), + removeInputClass: className => input_.classList.remove(className), + setAttr: (name, value) => input_.setAttribute(name, value), + setLowerStyle: (name, value) => lower_.style[name] = value, + setUpperStyle: (name, value) => upper_.style[name] = value, + getNativeInput: () => this.inputControl_, + hasNecessaryDom: () => Boolean(input_) && Boolean(lower_) && Boolean(upper_), + notifyChange: evtData => this.emit('MDCSlider:change', evtData), + detectIsIE: () => window.navigator.msPointerEnabled, + }); + } + + get disabled() { + return this.foundation_.isDisabled(); + } + + set disabled(disabled) { + this.foundation_.setDisabled(disabled); + } + + get value() { + return this.foundation_.getValue(); + } + + set value(value) { + this.foundation_.setValue(value); + } +} diff --git a/packages/mdc-slider/continuous/mdc-slider.scss b/packages/mdc-slider/continuous/mdc-slider.scss new file mode 100644 index 00000000000..605cf200791 --- /dev/null +++ b/packages/mdc-slider/continuous/mdc-slider.scss @@ -0,0 +1,382 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@import "@material/animation/variables"; +@import "@material/theme/variables"; +@import "@material/theme/mixins"; // IE11 and Edge use their built-in support for the lower/upper portions of the +// track, so the slider background is turned off for these browsers. +// custom css to target IE11+ browser only +// https://jeffclayton.wordpress.com/2014/07/22/internet-explorer-css-hacks-collection/ + +$range-bg: rgba(0, 0, 0, 0.26); +$range-bg-focus: rgba(0, 0, 0, 0.12); + +:root .mdc-slider__background, +_:-ms-input-placeholder { + -ms-appearance: none; + display: none; +} +// custom css to target Edge 12+ only +// https://jeffclayton.wordpress.com/2015/04/07/css-hacks-for-windows-10-and-spartan-browser-preview/ +@supports (-ms-ime-align:auto) { + :root .mdc-slider__background { + display: none; + } +} + +.mdc-slider { + height: 18px; + position: relative; + // background: none; + display: flex; + flex-direction: row; + user-select: none; + + &--accent { + .mdc-slider__input { + @include mdc-theme-prop(color, accent); + + &::-webkit-slider-thumb { + @include mdc-theme-prop(background, accent); + } + + &:active::-webkit-slider-thumb { + @include mdc-theme-prop(background, accent); + } + + &::-moz-range-thumb { + @include mdc-theme-prop(background, accent); + } + + &:active::-moz-range-thumb { + @include mdc-theme-prop(background, accent); + } + + &::-ms-fill-lower { + background: linear-gradient(to right, transparent, transparent 16px, $mdc-theme-accent 16px, $mdc-theme-accent 0); + } + + &::-ms-thumb { + @include mdc-theme-prop(background, accent); + } + + &:focus:not(:active)::-ms-thumb { + background: radial-gradient(circle closest-side, $mdc-theme-accent 0%, $mdc-theme-accent 37.5%, transparent 37.5%, transparent 100%); + } + &:active::-ms-thumb { + @include mdc-theme-prop(background, accent); + } + } + + .mdc-slider__background-lower { + @include mdc-theme-prop(background, accent); + } + } +} + +.mdc-slider__input { + width: calc(100% - 40px); + margin: 0 20px; + appearance: none; + // height: 2px; + background: transparent; + user-select: none; + outline: 0; + padding: 0; + @include mdc-theme-prop(color, primary); + align-self: center; + z-index: 1; + cursor: pointer; + /**************************** webkit ****************************/ + &::-webkit-slider-runnable-track { + background: transparent; + user-select: none; + // height: 2px; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + box-sizing: border-box; + border-radius: 50%; + @include mdc-theme-prop(background, primary); + border: none; + transition: transform 0.18s $mdc-animation-fast-out-slow-in-timing-function, border 0.18s $mdc-animation-fast-out-slow-in-timing-function, box-shadow 0.18s $mdc-animation-fast-out-slow-in-timing-function, background 0.28s $mdc-animation-fast-out-slow-in-timing-function; + } + + &:focus:not(:active)::-webkit-slider-thumb { + box-shadow: 0 0 0 10px unquote("rgba(#{$mdc-theme-primary}, 0.26)"); + } + + &:active::-webkit-slider-thumb { + background-image: none; + @include mdc-theme-prop(background, primary); + transform: scale(1.5); + } + + &::-moz-range-track { + background: transparent; + border: none; + } + + &::-moz-range-thumb { + -moz-appearance: none; + width: 12px; + height: 12px; + box-sizing: border-box; + border-radius: 50%; + background-image: none; + @include mdc-theme-prop(background, primary); + border: none; + // -moz-range-thumb doesn't currently support transitions. + } + + &:focus:not(:active)::-moz-range-thumb { + box-shadow: 0 0 0 10px unquote("rgba(#{$mdc-theme-primary}, 0.26)"); + } + + &:active::-moz-range-thumb { + background-image: none; + @include mdc-theme-prop(background, primary); + transform: scale(1.5); + } + // Disable default focus on Firefox. + &::-moz-focus-outer { + border: 0; + } + /**************************** IE/Edge ****************************/ + // Disable tooltip on IE. + &::-ms-tooltip { + display: none; + } + + &::-ms-track { + background-color: transparent; + color: transparent; + height: 2px; + width: 100%; + border-width: 16px 0; + border-color: transparent; + overflow: visible; + } + + &::-ms-fill-lower { + padding: 0; + // Margin on -ms-track doesn't work right, so we use gradients on the + // fills. + background: linear-gradient(to right, transparent, transparent 16px, $mdc-theme-primary 16px, $mdc-theme-primary 0); + } + + &::-ms-fill-upper { + padding: 0; + // Margin on -ms-track doesn't work right, so we use gradients on the + // fills. + background: linear-gradient(to left, transparent, transparent 16px, $range-bg 16px, $range-bg 0); + } + + &::-ms-thumb { + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + @include mdc-theme-prop(background, primary); + overflow: visible; + transform: scale(0.375); + // -ms-thumb doesn't currently support transitions, but leaving this here + // in case support ever gets added. + transition: transform 0.18s $mdc-animation-fast-out-slow-in-timing-function, background 0.28s $mdc-animation-fast-out-slow-in-timing-function; + } + + &:focus:not(:active)::-ms-thumb { + background: radial-gradient(circle closest-side, $mdc-theme-primary 0%, $mdc-theme-primary 37.5%, transparent 37.5%, transparent 100%); + transform: scale(1); + } + + &:active::-ms-thumb { + @include mdc-theme-prop(background, primary); + transform: scale(.5625); + } + /**************************** 0-value ****************************/ + &.mdc-slider--lowest-value::-webkit-slider-thumb { + border: 2px solid $range-bg; + background: transparent; + } + + &.mdc-slider--lowest-value + .mdc-slider__background > .mdc-slider__background-upper { + left: 6px; + } + + &.mdc-slider--lowest-value:focus:not(:active)::-webkit-slider-thumb { + box-shadow: 0 0 0 10px $range-bg-focus; + background: $range-bg-focus; + } + + &.mdc-slider--lowest-value:active::-webkit-slider-thumb { + border: 1.6px solid $range-bg; + transform: scale(1.5); + } + + &.mdc-slider--lowest-value:active + .mdc-slider__background > .mdc-slider__background-upper { + left: 9px; + } + + &.mdc-slider--lowest-value::-moz-range-thumb { + border: 2px solid $range-bg; + background: transparent; + } + + &.mdc-slider--lowest-value:focus:not(:active)::-moz-range-thumb { + box-shadow: 0 0 0 10px $range-bg-focus; + background: $range-bg-focus; + } + + &.mdc-slider--lowest-value:active::-moz-range-thumb { + border: 1.5px solid $range-bg; + transform: scale(1.5); + } + + &.mdc-slider--lowest-value::-ms-thumb { + background: radial-gradient(circle closest-side, transparent 0%, transparent 66.67%, $range-bg 66.67%, $range-bg 100%); + } + + &.mdc-slider--lowest-value:focus:not(:active)::-ms-thumb { + background: radial-gradient(circle closest-side, $range-bg-focus 0%, $range-bg-focus 25%, $range-bg 25%, $range-bg 37.5%, $range-bg-focus 37.5%, $range-bg-focus 100%); + transform: scale(1); + } + + &.mdc-slider--lowest-value:active::-ms-thumb { + transform: scale(0.5625); + background: radial-gradient(circle closest-side, transparent 0%, transparent 77.78%, $range-bg 77.78%, $range-bg 100%); + } + + &.mdc-slider--lowest-value::-ms-fill-lower { + background: transparent; + } + + &.mdc-slider--lowest-value::-ms-fill-upper { + margin-left: 6px; + } + + &.mdc-slider--lowest-value:active::-ms-fill-upper { + margin-left: 9px; + } + /**************************** Disabled ****************************/ + &:disabled::-webkit-slider-thumb, + &:disabled:active::-webkit-slider-thumb, + &:disabled:focus::-webkit-slider-thumb { + transform: scale(0.667); + background: $range-bg; + } + + &:disabled + .mdc-slider__background > .mdc-slider__background-lower { + background-color: $range-bg; + left: -6px; + } + + &:disabled + .mdc-slider__background > .mdc-slider__background-upper { + left: 6px; + } + + &.mdc-slider--lowest-value:disabled::-webkit-slider-thumb, + &.mdc-slider--lowest-value:disabled:active::-webkit-slider-thumb, + &.mdc-slider--lowest-value:disabled:focus::-webkit-slider-thumb { + border: 3px solid $range-bg; + background: transparent; + transform: scale(0.667); + } + + &.mdc-slider--lowest-value:disabled:active + .mdc-slider__background > .mdc-slider__background-upper { + left: 6px; + } + + &:disabled::-moz-range-thumb, + &:disabled:active::-moz-range-thumb, + &:disabled:focus::-moz-range-thumb { + transform: scale(0.667); + background: $range-bg; + } + + &.mdc-slider--lowest-value:disabled::-moz-range-thumb, + &.mdc-slider--lowest-value:disabled:active::-moz-range-thumb, + &.mdc-slider--lowest-value:disabled:focus::-moz-range-thumb { + border: 3px solid $range-bg; + background: transparent; + transform: scale(0.667); + } + + &:disabled::-ms-thumb, + &:disabled:active::-ms-thumb, + &:disabled:focus::-ms-thumb { + transform: scale(0.25); + background: $range-bg; + } + + &.mdc-slider--lowest-value:disabled::-ms-thumb, + &.mdc-slider--lowest-value:disabled:active::-ms-thumb, + &.mdc-slider--lowest-value:disabled:focus::-ms-thumb { + transform: scale(0.25); + background: radial-gradient(circle closest-side, transparent 0%, transparent 50%, $range-bg 50%, $range-bg 100%); + } + + &:disabled::-ms-fill-lower { + margin-right: 6px; + background: linear-gradient(to right, transparent, transparent 25px, $range-bg 25px, $range-bg 0); + } + + &:disabled::-ms-fill-upper { + margin-left: 6px; + } + + &.mdc-slider--lowest-value:disabled:active::-ms-fill-upper { + margin-left: 6px; + } +} +// Set up a flex box for the styled upper and lower portions of the +// the slider track (not used in IE/Edge). + +.mdc-slider__background { + background: transparent; + position: absolute; + height: 2px; + width: calc(100% - 52px); + top: 50%; + left: 0; + margin: 0 26px; + display: flex; + overflow: hidden; + border: 0; + padding: 0; + transform: translate(0, -1px); +} +// The lower part of the slider track. + +.mdc-slider__background-lower { + @include mdc-theme-prop(background, primary); + flex: 0; + position: relative; + border: 0; + padding: 0; +} +// The upper part of the slider track. + +.mdc-slider__background-upper { + background: $range-bg; + flex: 1; + position: relative; + border: 0; + padding: 0; + transition: left 0.18s $mdc-animation-fast-out-slow-in-timing-function; +} diff --git a/packages/mdc-slider/index.js b/packages/mdc-slider/index.js new file mode 100644 index 00000000000..8880bfc1b96 --- /dev/null +++ b/packages/mdc-slider/index.js @@ -0,0 +1,17 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {MDCSlider, MDCSliderFoundation} from './continuous'; diff --git a/packages/mdc-slider/mdc-slider.scss b/packages/mdc-slider/mdc-slider.scss new file mode 100644 index 00000000000..221a51b475f --- /dev/null +++ b/packages/mdc-slider/mdc-slider.scss @@ -0,0 +1,17 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "./continuous/mdc-slider"; diff --git a/packages/mdc-slider/package.json b/packages/mdc-slider/package.json new file mode 100644 index 00000000000..4abc16df12c --- /dev/null +++ b/packages/mdc-slider/package.json @@ -0,0 +1,22 @@ +{ + "name": "@material/slider", + "description": "The Material Components for the web slider component", + "version": "0.1.0", + "license": "Apache-2.0", + "keywords": [ + "material components", + "material design", + "slider" + ], + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/material-components/material-components-web.git" + }, + "dependencies": { + "@material/base": "^0.1.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/test/unit/mdc-slider/foundation.test.js b/test/unit/mdc-slider/foundation.test.js new file mode 100644 index 00000000000..572c30cde04 --- /dev/null +++ b/test/unit/mdc-slider/foundation.test.js @@ -0,0 +1,453 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint object-curly-spacing: [error, always, { "objectsInObjects": false }], arrow-parens: [error, as-needed] */ + +import { assert } from 'chai'; +import td from 'testdouble'; + +import { verifyDefaultAdapter, captureHandlers } from '../helpers/foundation'; +import { setupFoundationTest } from '../helpers/setup'; +import MDCSliderFoundation from '../../../packages/mdc-slider/continuous/foundation'; + +const { cssClasses } = MDCSliderFoundation; + +suite('MDCSliderFoundation'); + +test('exports strings', () => { + assert.isOk('strings' in MDCSliderFoundation); +}); + +test('exports cssClasses', () => { + assert.isOk('cssClasses' in MDCSliderFoundation); +}); + +test('defaultAdapter returns a complete adapter implementation', () => { + verifyDefaultAdapter(MDCSliderFoundation, [ + 'addClass', + 'removeClass', + 'hasClass', + 'addInputClass', + 'removeInputClass', + 'getNativeInput', + 'registerHandler', + 'deregisterHandler', + 'registerRootHandler', + 'deregisterRootHandler', + 'setAttr', + 'setLowerStyle', + 'setUpperStyle', + 'hasNecessaryDom', + 'notifyChange', + 'detectIsIE', + ]); +}); + +function setupTest() { + const { foundation, mockAdapter } = setupFoundationTest(MDCSliderFoundation); + td.when(mockAdapter.hasClass('mdc-slider')).thenReturn(true); + td.when(mockAdapter.hasNecessaryDom()).thenReturn(true); + + return { foundation, mockAdapter }; +} + +test('#constructor sets disabled to false', () => { + const { foundation } = setupTest(); + assert.isNotOk(foundation.isDisabled()); +}); + +test('#setDisabled flips disabled when a native input is given', () => { + const { foundation, mockAdapter } = setupTest(); + const nativeInput = { disabled: false }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeInput); + foundation.setDisabled(true); + assert.isOk(foundation.isDisabled()); +}); + +test('#setDisabled has no effect when no native input is provided', () => { + const { foundation } = setupTest(); + foundation.setDisabled(true); + assert.isNotOk(foundation.isDisabled()); +}); + +test('#setDisabled set the disabled property on the native input when there is one', () => { + const { foundation, mockAdapter } = setupTest(); + const nativeInput = { disabled: false }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeInput); + foundation.setDisabled(true); + assert.isOk(nativeInput.disabled); +}); + +test('#setDisabled handles no native input being returned gracefully', () => { + const { foundation, mockAdapter } = setupTest(); + td.when(mockAdapter.getNativeInput()).thenReturn(null); + assert.doesNotThrow(() => foundation.setDisabled(true)); +}); + +test('#init throws error when the root class is not present', () => { + const mockAdapter = td.object(MDCSliderFoundation.defaultAdapter); + td.when(mockAdapter.hasClass('mdc-slider')).thenReturn(false); + + const foundation = new MDCSliderFoundation(mockAdapter); + assert.throws(() => foundation.init()); +}); + +test('#init throws error when the necessary DOM is not present', () => { + const mockAdapter = td.object(MDCSliderFoundation.defaultAdapter); + td.when(mockAdapter.hasClass('mdc-slider')).thenReturn(true); + td.when(mockAdapter.hasNecessaryDom()).thenReturn(false); + + const foundation = new MDCSliderFoundation(mockAdapter); + assert.throws(() => foundation.init()); +}); + +test('#init detects browser', () => { + const { foundation, mockAdapter } = setupTest(); + td.when(mockAdapter.detectIsIE()).thenReturn(456); + foundation.init(); + assert.strictEqual(foundation.isIE_, 456); +}); + +test('#init adds mdc-slider--upgraded class', () => { + const { foundation, mockAdapter } = setupTest(); + foundation.init(); + td.verify(mockAdapter.addClass(cssClasses.UPGRADED)); +}); + +test('#destroy removes mdc-slider--upgraded class', () => { + const { foundation, mockAdapter } = setupTest(); + foundation.destroy(); + td.verify(mockAdapter.removeClass(cssClasses.UPGRADED)); +}); + +test('#destroy deregisters input handler', () => { + const { foundation, mockAdapter } = setupTest(); + foundation.destroy(); + td.verify(mockAdapter.deregisterHandler('input', td.matchers.isA(Function))); +}); + +test('#destroy deregisters change handler', () => { + const { foundation, mockAdapter } = setupTest(); + foundation.destroy(); + td.verify(mockAdapter.deregisterHandler('change', td.matchers.isA(Function))); +}); + +test('#destroy deregisters mouseup handler', () => { + const { foundation, mockAdapter } = setupTest(); + foundation.destroy(); + td.verify(mockAdapter.deregisterHandler('mouseup', td.matchers.isA(Function))); +}); + +test('#destroy deregisters touchmove handler', () => { + const { foundation, mockAdapter } = setupTest(); + foundation.destroy(); + td.verify(mockAdapter.deregisterHandler('touchmove', td.matchers.isA(Function))); +}); + +test('#destroy deregisters touchstart handler', () => { + const { foundation, mockAdapter } = setupTest(); + foundation.destroy(); + td.verify(mockAdapter.deregisterHandler('touchstart', td.matchers.isA(Function))); +}); + +test('#destroy deregisters mousedown handler', () => { + const { foundation, mockAdapter } = setupTest(); + foundation.destroy(); + td.verify(mockAdapter.deregisterRootHandler('mousedown', td.matchers.isA(Function))); +}); + +test('on input sets valuenow', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerHandler'); + const evt = { + stopPropagation: () => {}, + target: {}, + }; + + foundation.init(); + + handlers.input(evt); + td.verify(mockAdapter.setAttr('aria-valuenow', td.matchers.anything())); +}); + +test('on change sets valuenow', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerHandler'); + const evt = { + stopPropagation: () => {}, + target: {}, + }; + + foundation.init(); + + handlers.change(evt); + td.verify(mockAdapter.setAttr('aria-valuenow', td.matchers.anything())); +}); + +test('on change adapter adds LOWEST_VALUE for fraction 0', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerHandler'); + const evt = { + stopPropagation: () => {}, + target: {}, + }; + + const nativeInput = { + value: 0, + min: 0, + max: 100, + }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeInput); + + foundation.init(); + handlers.change(evt); + td.verify(mockAdapter.addInputClass(cssClasses.LOWEST_VALUE)); +}); + +test('on change adapter removes LOWEST_VALUE for fraction != 0', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerHandler'); + const evt = { + stopPropagation: () => {}, + target: {}, + }; + + const nativeInput = { + value: 50, + min: 0, + max: 100, + }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeInput); + + foundation.init(); + handlers.change(evt); + td.verify(mockAdapter.removeInputClass(cssClasses.LOWEST_VALUE)); +}); + +test('on touchmove calls preventDefault', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerHandler'); + const evt = { + preventDefault: td.func('evt.preventDefault'), + target: {}, + touches: [{ clientX: 50 }], + }; + + const nativeInput = { + max: 100, + getBoundingClientRect: () => ({ width: 100, left: 0 }), + dispatchEvent: () => undefined, + }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeInput); + foundation.init(); + handlers.touchmove(evt); + td.verify(evt.preventDefault()); +}); + +test('on touchmove calls dispatchEvent', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerHandler'); + const evt = { + preventDefault: td.func('evt.preventDefault'), + target: {}, + touches: [{ clientX: 50 }], + }; + + const nativeInput = { + max: 100, + getBoundingClientRect: () => ({ width: 100, left: 0, top: 10 }), + dispatchEvent: td.func('input.dispatchEvent'), + }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeInput); + foundation.init(); + handlers.touchmove(evt); + td.verify(nativeInput.dispatchEvent(td.matchers.anything())); +}); + +test('on touchstart calls preventDefault', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerHandler'); + const evt = { + preventDefault: td.func('evt.preventDefault'), + target: {}, + touches: [{ clientX: 50 }], + }; + + const nativeInput = { + max: 100, + getBoundingClientRect: () => ({ width: 100, left: 0 }), + dispatchEvent: () => undefined, + }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeInput); + foundation.init(); + handlers.touchstart(evt); + td.verify(evt.preventDefault()); +}); + +test('on touchmove call returns if is IE', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerHandler'); + const evt = { + preventDefault: td.func('evt.preventDefault'), + }; + + td.when(mockAdapter.detectIsIE()).thenReturn(true); + foundation.init(); + handlers.touchmove(evt); + td.verify(evt.preventDefault(), { times: 0 }); +}); + +test('on touchmove call returns if pointerType not touch', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerHandler'); + const evt = { + preventDefault: td.func('evt.preventDefault'), + pointerType: 'other', + }; + + foundation.init(); + handlers.touchmove(evt); + td.verify(evt.preventDefault(), { times: 0 }); +}); + +test('on mousedown calls preventDefault', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerRootHandler'); + const target = {}; + const evt = { + preventDefault: td.func('evt.preventDefault'), + target, + }; + + const nativeInput = { + max: 100, + parentElement: target, + getBoundingClientRect: () => ({ width: 100, left: 0, top: 10 }), + dispatchEvent: () => undefined, + }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeInput); + foundation.init(); + handlers.mousedown(evt); + td.verify(evt.preventDefault()); +}); + +test('on mousedown returns if target not parentElement', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerRootHandler'); + const target = {}; + const evt = { + preventDefault: td.func('evt.preventDefault'), + target, + }; + + const nativeInput = { + max: 100, + parentElement: {}, + getBoundingClientRect: () => ({ width: 100, left: 0, top: 10 }), + dispatchEvent: () => undefined, + }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeInput); + foundation.init(); + handlers.mousedown(evt); + td.verify(evt.preventDefault(), { times: 0 }); +}); + +test('on mousedown calls dispatchEvent', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerRootHandler'); + const target = {}; + const evt = { + preventDefault: td.func('evt.preventDefault'), + target, + }; + + const nativeInput = { + max: 100, + parentElement: target, + getBoundingClientRect: () => ({ width: 100, left: 0, top: 10 }), + dispatchEvent: td.func('input.dispatchEvent'), + }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeInput); + foundation.init(); + handlers.mousedown(evt); + td.verify(nativeInput.dispatchEvent(td.matchers.anything())); +}); + +test("#on mousedown dispatches a mouse event with the supplied data where MouseEvent isn't available", () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerRootHandler'); + const target = {}; + const evt = { + preventDefault: td.func('evt.preventDefault'), + target, + }; + + const nativeInput = { + max: 100, + parentElement: target, + getBoundingClientRect: () => ({ width: 100, left: 0, top: 10 }), + dispatchEvent: td.func('input.dispatchEvent'), + }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeInput); + foundation.init(); + const { MouseEvent } = window; + window.MouseEvent = undefined; + try { + handlers.mousedown(evt); + } finally { + window.MouseEvent = MouseEvent; + } + td.verify(nativeInput.dispatchEvent(td.matchers.anything())); +}); + +test('on mouseup calls target.blur()', () => { + const { foundation, mockAdapter } = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerHandler'); + const evt = { + preventDefault: td.func('evt.preventDefault'), + target: { blur: td.func('evt.target.blur') }, + }; + + foundation.init(); + handlers.mouseup(evt); + td.verify(evt.target.blur()); +}); + +test('#getValue returns the value of getNativeInput.value', () => { + const { foundation, mockAdapter } = setupTest(); + td.when(mockAdapter.getNativeInput()).thenReturn({ value: 'value' }); + assert.equal(foundation.getValue(), 'value'); +}); + +test('#getValue returns null if getNativeInput() does not return anything', () => { + const { foundation, mockAdapter } = setupTest(); + td.when(mockAdapter.getNativeInput()).thenReturn(null); + assert.isNull(foundation.getValue()); +}); + +test('#setValue sets the value of getNativeInput.value', () => { + const { foundation, mockAdapter } = setupTest(); + const nativeControl = { value: null, max: 100, min: 0 }; + td.when(mockAdapter.getNativeInput()).thenReturn(nativeControl); + foundation.setValue('49'); + assert.equal(nativeControl.value, '49'); +}); + +test('#setValue exits gracefully if getNativeInput() does not return anything', () => { + const { foundation, mockAdapter } = setupTest(); + td.when(mockAdapter.getNativeInput()).thenReturn(null); + assert.doesNotThrow(() => foundation.setValue('new value')); +}); diff --git a/test/unit/mdc-slider/mdc-slider.test.js b/test/unit/mdc-slider/mdc-slider.test.js new file mode 100644 index 00000000000..01f06ce5892 --- /dev/null +++ b/test/unit/mdc-slider/mdc-slider.test.js @@ -0,0 +1,170 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint object-curly-spacing: [error, always, { "objectsInObjects": false }], arrow-parens: [error, as-needed] */ + +import { assert } from 'chai'; +import bel from 'bel'; +import domEvents from 'dom-events'; +import td from 'testdouble'; +import { strings } from '../../../packages/mdc-slider/continuous/constants'; +import { MDCSlider } from '../../../packages/mdc-slider'; + +function getFixture() { + return bel` +
    + +
    +
    +
    +
    +
    + `; +} + +suite('MDCSlider'); + +test('attachTo returns a component instance', () => { + assert.isOk(MDCSlider.attachTo(getFixture()) instanceof MDCSlider); +}); + +function setupTest() { + const root = getFixture(); + const component = new MDCSlider(root); + return { root, component }; +} + +test('get/set disabled updates the input element', () => { + const { root, component } = setupTest(); + const input = root.querySelector('.mdc-slider__input'); + component.disabled = true; + assert.isOk(input.disabled); + component.disabled = false; + assert.isNotOk(input.disabled); +}); + +test('get disabled gets state', () => { + const { component } = setupTest(); + component.disabled = true; + assert.isOk(component.disabled); + component.disabled = false; + assert.isNotOk(component.disabled); +}); + +test('#adapter.addClass adds a class to the root element', () => { + const { root, component } = setupTest(); + component.getDefaultFoundation().adapter_.addClass('foo'); + assert.isOk(root.classList.contains('foo')); +}); + +test('#adapter.removeClass removes a class from the root element', () => { + const { root, component } = setupTest(); + root.classList.add('foo'); + component.getDefaultFoundation().adapter_.removeClass('foo'); + assert.isNotOk(root.classList.contains('foo')); +}); + +test('#adapter.addInputClass adds a class to the input element', () => { + const { root, component } = setupTest(); + const input = root.querySelector('.mdc-slider__input'); + component.getDefaultFoundation().adapter_.addInputClass('foo'); + assert.isOk(input.classList.contains('foo')); +}); + +test('#adapter.removeInputClass removes a class from the input element', () => { + const { root, component } = setupTest(); + const input = root.querySelector('.mdc-slider__input'); + input.classList.add('foo'); + component.getDefaultFoundation().adapter_.removeInputClass('foo'); + assert.isNotOk(input.classList.contains('foo')); +}); + +test('#adapter.registerHandler adds an event handler on the input element', () => { + const { root, component } = setupTest(); + const handler = td.func('fooHandler'); + component.getDefaultFoundation().adapter_.registerHandler('foo', handler); + domEvents.emit(root.querySelector('.mdc-slider__input'), 'foo'); + td.verify(handler(td.matchers.anything())); +}); + +test('#adapter.deregisterHandler removes an event handler from the input element', () => { + const { root, component } = setupTest(); + const input = root.querySelector('.mdc-slider__input'); + const handler = td.func('fooHandler'); + input.addEventListener('foo', handler); + component.getDefaultFoundation().adapter_.deregisterHandler('foo', handler); + domEvents.emit(input, 'foo'); + td.verify(handler(td.matchers.anything()), { times: 0 }); +}); + +test('#adapter.registerRootHandler adds an event handler on the root element', () => { + const { root, component } = setupTest(); + const handler = td.func('fooHandler'); + component.getDefaultFoundation().adapter_.registerRootHandler('foo', handler); + domEvents.emit(root, 'foo'); + td.verify(handler(td.matchers.anything())); +}); + +test('#adapter.deregisterRootHandler removes an event handler from the root element', () => { + const { root, component } = setupTest(); + const handler = td.func('fooHandler'); + root.addEventListener('foo', handler); + component.getDefaultFoundation().adapter_.deregisterRootHandler('foo', handler); + domEvents.emit(root, 'foo'); + td.verify(handler(td.matchers.anything()), { times: 0 }); +}); + +test('#adapter.getNativeInput returns the component input element', () => { + const { root, component } = setupTest(); + assert.equal(component.getDefaultFoundation().adapter_.getNativeInput(), root.querySelector('.mdc-slider__input')); +}); + +test('#adapter.setAttr sets an attribute to a certain value on the input element', () => { + const { root, component } = setupTest(); + const input = root.querySelector('.mdc-slider__input'); + component.getDefaultFoundation().adapter_.setAttr('aria-valuenow', 'foo'); + assert.equal(input.getAttribute('aria-valuenow'), 'foo'); +}); + +test('adapter#setLowerStyle sets the given style propertyName to the given value', () => { + const { component, root } = setupTest(); + const lower = root.querySelector('.mdc-slider__background-lower'); + component.getDefaultFoundation().adapter_.setLowerStyle('flex', '0.5 1 0%'); + assert.equal(lower.style.getPropertyValue('flex'), '0.5 1 0%'); +}); + +test('adapter#setUpperStyle sets the given style propertyName to the given value', () => { + const { component, root } = setupTest(); + const upper = root.querySelector('.mdc-slider__background-upper'); + component.getDefaultFoundation().adapter_.setUpperStyle('flex', '0.5 1 0%'); + assert.equal(upper.style.getPropertyValue('flex'), '0.5 1 0%'); +}); + +test('#adapter.notifyChange broadcasts a "MDCSlider:change" custom event', () => { + const { root, component } = setupTest(); + const handler = td.func('custom event handler'); + root.addEventListener('MDCSlider:change', handler); + component.getDefaultFoundation().adapter_.notifyChange({}); + td.verify(handler(td.matchers.anything())); +}); + +test('get/set value updates the value of the native checkbox element', () => { + const { root, component } = setupTest(); + const cb = root.querySelector(strings.INPUT_SELECTOR); + component.value = '59'; + assert.equal(cb.value, '59'); + assert.equal(component.value, cb.value); +}); diff --git a/webpack.config.js b/webpack.config.js index f1ebdc7c5ba..16a68c459cb 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -84,6 +84,7 @@ module.exports = [{ radio: [path.resolve('./packages/mdc-radio/index.js')], ripple: [path.resolve('./packages/mdc-ripple/index.js')], select: [path.resolve('./packages/mdc-select/index.js')], + slider: [path.resolve('./packages/mdc-slider/index.js')], snackbar: [path.resolve('./packages/mdc-snackbar/index.js')], textfield: [path.resolve('./packages/mdc-textfield/index.js')], }, @@ -154,6 +155,7 @@ module.exports = [{ 'mdc.radio': path.resolve('./packages/mdc-radio/mdc-radio.scss'), 'mdc.ripple': path.resolve('./packages/mdc-ripple/mdc-ripple.scss'), 'mdc.select': path.resolve('./packages/mdc-select/mdc-select.scss'), + 'mdc.slider': path.resolve('./packages/mdc-slider/mdc-slider.scss'), 'mdc.snackbar': path.resolve('./packages/mdc-snackbar/mdc-snackbar.scss'), 'mdc.switch': path.resolve('./packages/mdc-switch/mdc-switch.scss'), 'mdc.textfield': path.resolve('./packages/mdc-textfield/mdc-textfield.scss'),