diff --git a/apps/fabric-website/src/components/App/AppState.tsx b/apps/fabric-website/src/components/App/AppState.tsx index f74ad8aa2111e..f722bddac0cfe 100644 --- a/apps/fabric-website/src/components/App/AppState.tsx +++ b/apps/fabric-website/src/components/App/AppState.tsx @@ -302,6 +302,12 @@ export const AppState: IAppState = { component: () => , getComponent: cb => require.ensure([], (require) => cb(require('../../pages/Components/SpinnerComponentPage').SpinnerComponentPage)) }, + { + title: 'SpinButton', + url: '#/components/spinbutton', + component: () => , + getComponent: cb => require.ensure([], (require) => cb(require('../../pages/Components/SpinButtonComponentPage').SpinButtonComponentPage)) + }, { title: 'TextField', url: '#/components/textfield', diff --git a/apps/fabric-website/src/pages/Components/SpinButtonComponentPage.tsx b/apps/fabric-website/src/pages/Components/SpinButtonComponentPage.tsx new file mode 100644 index 0000000000000..9da1685a03e5f --- /dev/null +++ b/apps/fabric-website/src/pages/Components/SpinButtonComponentPage.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { SpinButtonPage } from 'office-ui-fabric-react/lib/components/SpinButton/SpinButtonPage'; +import { PageHeader } from '../../components/PageHeader/PageHeader'; +import { ComponentPage } from '../../components/ComponentPage/ComponentPage'; + +export class SpinButtonComponentPage extends React.Component { + public render() { + return ( +
+ + + + +
+ ); + } +} diff --git a/packages/office-ui-fabric-react/src/SpinButton.ts b/packages/office-ui-fabric-react/src/SpinButton.ts new file mode 100644 index 0000000000000..25568549f09b5 --- /dev/null +++ b/packages/office-ui-fabric-react/src/SpinButton.ts @@ -0,0 +1 @@ +export * from './components/SpinButton/index'; diff --git a/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.Props.ts b/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.Props.ts new file mode 100644 index 0000000000000..ac8a3233a54d5 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.Props.ts @@ -0,0 +1,107 @@ +import { Position } from '../../utilities/positioning'; +import { IIconProps } from '../../Icon'; + +export interface ISpinButtonProps { + + /** + * The initial value of the SpinButton. Use this if you intend for the SpinButton to be an uncontrolled component. + * This value is mutually exclusive to value. Use one or the other. + * @default 0 + */ + defaultValue?: string; + + /** + * The value of the SpinButton. Use this if you intend to pass in a new value as a result of onChange events. + * This value is mutually exclusive to defaultValue. Use one or the other. + */ + value?: string; + + /** + * The min value of the SpinButton. + * @default 0 + */ + min?: number; + + /** + * The max value of the SpinButton. + * @default 10 + */ + max?: number; + + /** + * The difference between the two adjacent values of the SpinButton. + * @default 1 + */ + step?: number; + + /** + * A description of the SpinButton for the benefit of screen readers. + */ + ariaLabel?: string; + + /** + * A title for the SpinButton used for a more descriptive name that's also visible on its tooltip. + */ + title?: string; + + /** + * Whether or not the SpinButton is disabled. + */ + disabled?: boolean; + + /** + * Optional className for SpinButton. + */ + className?: string; + + /** + * Descriptive label for the SpinButton. + */ + label: string; + + /** + * @default: Left + */ + labelPosition?: Position; + + /** + * Icon that goes along with the label for the whole SpinButton + */ + iconProps?: IIconProps; + + /** + * This callback is triggered when the value inside the SpinButton should be validated. + * @return {string | void} If a string is returned, it will be used as the value of the SpinButton. + */ + onValidate?: (value: string) => string | void; + + /** + * This callback is triggered when the increment button is pressed or if the user presses up arrow with focus on the input of the spinButton + * @return {string | void} If a string is returned, it will be used as the value of the SpinButton. + */ + onIncrement?: (value: string) => string | void; + + /** + * This callback is triggered when the decrement button is pressed or if the user presses down arrow with focus on the input of the spinButton + * @return {string | void} If a string is returned, it will be used as the value of the SpinButton. + */ + onDecrement?: (value: string) => string | void; + + /** + * Icon for the increment button of the spinButton + */ + incrementButtonIcon?: IIconProps; + + /** + * Icon for the decrement button of the spinButton + */ + decrementButtonIcon?: IIconProps; +} + +export interface ISpinButton { + /** + * The value of the SpinButton. Use this if you intend to pass in a new value as a result of onChange events. + * This value is mutually exclusive to defaultValue. Use one or the other. + */ + value?: string; +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.scss b/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.scss new file mode 100644 index 0000000000000..90ea34b50ff0e --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.scss @@ -0,0 +1,226 @@ +@import '../../common/common'; +@import '../Label/LabelMixins.scss'; + +.ArrowBox, .Input, +.UpButton, .DownButton, .SpinButtonContainer { + outline: none; + font-size: 12px; +} + +.SpinButtonLabel { + pointer-events: none; + padding: 2px 0px; +} + +.SpinButtonContainer { + width: 100%; + min-width: 86px; + padding: 2px; +} + + .ArrowBox { + display: block; + float: left; + height: 100%; + border: 1px solid $ms-color-neutralTertiaryAlt; + border-left-width: 0px; + cursor: default; + padding: 0px; + box-sizing: border-box; + } + + .SpinButtonWrapper:hover .ArrowBox, .SpinButtonWrapper:hover .Input { + border-color: $ms-color-neutralSecondaryAlt; + outline: 2px dashed transparent; + + @media screen and (-ms-high-contrast: active) { + border-color: $ms-color-contrastBlackSelected; + } + + @media screen and (-ms-high-contrast: black-on-white) { + border-color: $ms-color-contrastWhiteSelected; + } + } + + .labelWrapper { + display: inline-flex; + + &.start { + float: left; + margin-right: 10px; + } + + &.end { + float: right; + margin-left: 10px; + } + + &.top { + margin-bottom: 10px; + } + + &.bottom { + margin-top: 10px; + } + } + + .SpinButtonIcon { + padding: 2px 5px; + font-size: 20px; + } + + .SpinButtonWrapper { + display: flex; + height: 26px; + min-width: 86px; + } + + .SpinButtonWrapper.topBottom { + width: 100%; + } + + .Input:focus + .ArrowBox { + border-color: $ms-color-themePrimary; + outline: 2px dashed transparent; + + @media screen and (-ms-high-contrast: active) { + border-color: $ms-color-contrastBlackSelected; + } + + @media screen and (-ms-high-contrast: black-on-white) { + border-color: $ms-color-contrastWhiteSelected; + } + } + + .Input.disabled + .ArrowBox { + background-color: $ms-color-neutralLighter; + border-color: $ms-color-neutralLighter; + pointer-events: none; + cursor: default; + + @media screen and (-ms-high-contrast: active) { + color: $ms-color-contrastBlackDisabled; + } + + @media screen and (-ms-high-contrast: black-on-white) { + color: $ms-color-contrastWhiteDisabled; + } + } + + .SpinButtonWrapper:hover .Input:focus { + border-color: $ms-color-themePrimary; + } + + .Input { + @include ms-normalize; + border: 1px solid $ms-color-neutralTertiaryAlt; + border-radius: 0; + font-weight: $ms-font-weight-regular; + font-size: $ms-font-size-m; + color: $ms-color-neutralPrimary; + height: 100%; + @include padding(3px, 3px, 4px, 4px); + outline: 0; + text-overflow: ellipsis; + display: block; + float: left; + width: calc(100% - 14px); + min-width: 72px; + border-right-width: 0px; + overflow: hidden; + cursor: text; + user-select: text; + + &:focus { + border-color: $ms-color-themePrimary; + outline: 2px dashed transparent; + + @media screen and (-ms-high-contrast: active) { + border-color: $ms-color-contrastBlackSelected; + } + + @media screen and (-ms-high-contrast: black-on-white) { + border-color: $ms-color-contrastWhiteSelected; + } + } + + &::selection { + background-color: $ms-color-themePrimary; + color: $ms-color-white; + } + } + + .SpinButtonWrapper:hover .Input.disabled, + .Input.disabled { + background-color: $ms-color-neutralLighter; + border-color: $ms-color-neutralLighter; + pointer-events: none; + cursor: default; + color: $ms-color-neutralTertiaryAlt; + + @media screen and (-ms-high-contrast: active) { + color: $ms-color-contrastBlackDisabled; + } + + @media screen and (-ms-high-contrast: black-on-white) { + color: $ms-color-contrastWhiteDisabled; + } + } + + .Input:hover .UpButton, .Input:hover .DownButton { + background-color: $ms-color-neutralLighter; + } + + .SpinButtonWrapper .UpButton, .SpinButtonWrapper .DownButton { + display: block; + height: 50%; + width: 12px; + padding: 0px; + background-color: transparent; + text-align: center; + cursor: default; + font-size: 6px; + color: $ms-color-neutralPrimary; + + &:hover { + background-color: $ms-color-neutralLight; + } + + &:active { + background-color: $ms-color-themePrimary; + color: $ms-color-white; + + @media screen and (-ms-high-contrast: active) { + background-color: $ms-color-contrastBlackSelected; + } + + @media screen and (-ms-high-contrast: black-on-white) { + background-color: $ms-color-contrastWhiteSelected; + } + } + } + + .UpButton:disabled, .DownButton:disabled { + opacity: 0; + + @media screen and (-ms-high-contrast: active) { + color: $ms-color-contrastBlackDisabled; + } + + @media screen and (-ms-high-contrast: black-on-white) { + color: $ms-color-contrastWhiteDisabled; + } + } + + .SpinButtonWrapper .UpButton.active, .SpinButtonWrapper .DownButton.active { + background-color: $ms-color-themePrimary; + color: $ms-color-white; + + @media screen and (-ms-high-contrast: active) { + background-color: $ms-color-contrastBlackSelected; + } + + @media screen and (-ms-high-contrast: black-on-white) { + background-color: $ms-color-contrastWhiteSelected; + } + } diff --git a/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.test.tsx b/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.test.tsx new file mode 100644 index 0000000000000..64d48a3cf5c17 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.test.tsx @@ -0,0 +1,554 @@ +import { Promise } from 'es6-promise'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import * as ReactTestUtils from 'react-addons-test-utils'; +import { SpinButton } from './SpinButton'; +import { KeyCodes } from '../../Utilities'; + +const expect: Chai.ExpectStatic = chai.expect; + +describe('SpinButton', () => { + function renderIntoDocument(element: React.ReactElement): HTMLElement { + const component = ReactTestUtils.renderIntoDocument(element); + const renderedDOM: Element = ReactDOM.findDOMNode(component as React.ReactInstance); + return renderedDOM as HTMLElement; + } + + function mockEvent(targetValue: string = ''): ReactTestUtils.SyntheticEventData { + const target: EventTarget = { value: targetValue } as HTMLInputElement; + const event: ReactTestUtils.SyntheticEventData = { target }; + return event; + } + + it('should render a spinner with the default value on the input element', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + const labelDOM: HTMLLabelElement = renderedDOM.getElementsByTagName('label')[0]; + + expect(inputDOM.value).to.equal(exampleDefaultValue); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal(String(exampleDefaultValue)); + expect(inputDOM.getAttribute('aria-labelledby')).to.equals(labelDOM.id); + + // Assert on the label element. + expect(labelDOM.textContent).to.equal(exampleLabelValue); + expect(labelDOM.htmlFor).to.equal(inputDOM.id); + }); + + it('should increment the value in the spin button via the up button', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + const buttonDOM: Element = renderedDOM.getElementsByClassName('ms-UpButton')[0]; + + expect(buttonDOM.tagName).to.equal('BUTTON'); + + ReactTestUtils.Simulate.mouseDown(buttonDOM, + { + type: 'mousedown', + clientX: 0, + clientY: 0 + }); + + ReactTestUtils.Simulate.mouseUp(buttonDOM, + { + type: 'mouseup', + clientX: 0, + clientY: 0 + }); + + expect(inputDOM.value).to.equal('13'); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal('13'); + + }); + + it('should decrement the value in the spin button by the down button', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + const buttonDOM: Element = renderedDOM.getElementsByClassName('ms-DownButton')[0]; + + expect(buttonDOM.tagName).to.equal('BUTTON'); + + ReactTestUtils.Simulate.mouseDown(buttonDOM, + { + type: 'mousedown', + clientX: 0, + clientY: 0 + }); + + ReactTestUtils.Simulate.mouseUp(buttonDOM, + { + type: 'mouseup', + clientX: 0, + clientY: 0 + }); + + expect(inputDOM.value).to.equal('11'); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal('11'); + + }); + + it('should increment the value in the spin button by the up arrow', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + + ReactTestUtils.Simulate.keyDown(inputDOM, + { + which: KeyCodes.up + }); + + ReactTestUtils.Simulate.keyUp(inputDOM, + { + which: KeyCodes.up + }); + + expect(inputDOM.value).to.equal('13'); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal('13'); + + }); + + it('should decrement the value in the spin button by the down arrow', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + + ReactTestUtils.Simulate.keyDown(inputDOM, + { + which: KeyCodes.down + }); + + ReactTestUtils.Simulate.keyUp(inputDOM, + { + which: KeyCodes.down + }); + + expect(inputDOM.value).to.equal('11'); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal('11'); + + }); + + it('should increment the value in the spin button by a step value of 2', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + + ReactTestUtils.Simulate.keyDown(inputDOM, + { + which: KeyCodes.up + }); + + ReactTestUtils.Simulate.keyUp(inputDOM, + { + which: KeyCodes.up + }); + + expect(inputDOM.value).to.equal('14'); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal('14'); + + }); + + it('should decrement the value in the spin button by a step value of 2', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + + ReactTestUtils.Simulate.keyDown(inputDOM, + { + which: KeyCodes.down + }); + + ReactTestUtils.Simulate.keyUp(inputDOM, + { + which: KeyCodes.down + }); + + expect(inputDOM.value).to.equal('10'); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal('10'); + + }); + + it('should set the value of the spin button by manual entry', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + const exampleNewValue: string = '21'; + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + ReactTestUtils.Simulate.input(inputDOM, mockEvent(exampleNewValue)); + ReactTestUtils.Simulate.blur(inputDOM); + + expect(inputDOM.value).to.equal(exampleNewValue); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal(String(exampleNewValue)); + }); + + it('should reset the value of the spin button with invalid manual entry', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + const exampleNewValue: string = 'garbage'; + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + ReactTestUtils.Simulate.input(inputDOM, mockEvent(exampleNewValue)); + ReactTestUtils.Simulate.blur(inputDOM); + + expect(inputDOM.value).to.equal(exampleDefaultValue); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal(String(exampleDefaultValue)); + }); + + it('should revert to max value when input value is higher than the max of the spin button', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + const exampleNewValue: string = '23'; + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + ReactTestUtils.Simulate.input(inputDOM, mockEvent(exampleNewValue)); + ReactTestUtils.Simulate.blur(inputDOM); + + expect(inputDOM.value).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal(String(exampleMaxValue)); + }); + + it('should revert existing value when input value is lower than the min of the spin button', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + const exampleNewValue: string = '0'; + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + ReactTestUtils.Simulate.input(inputDOM, mockEvent(String(exampleNewValue))); + ReactTestUtils.Simulate.blur(inputDOM); + + expect(inputDOM.value).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal(String(exampleMinValue)); + }); + + it('should use validator passed to the spin button (with valid input)', () => { + const errorMessage: string = 'The value is invalid'; + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + const exampleNewValue: string = '21'; + + const renderedDOM: HTMLElement = renderIntoDocument( + { + let numberValue: number = +newValue; + return (!isNaN(numberValue) && numberValue >= exampleMinValue && numberValue <= exampleMaxValue) ? newValue : errorMessage; + } } + /> + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + ReactTestUtils.Simulate.input(inputDOM, mockEvent(String(exampleNewValue))); + ReactTestUtils.Simulate.blur(inputDOM); + + expect(inputDOM.value).to.equal(String(exampleNewValue)); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal(String(exampleNewValue)); + }); + + it('should use validator passed to the spin button', () => { + const errorMessage: string = 'The value is invalid'; + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + const exampleNewValue: string = '100'; + + const renderedDOM: HTMLElement = renderIntoDocument( + { + let numberValue: number = Number(newValue); + return (!isNaN(numberValue) && numberValue >= exampleMinValue && numberValue <= exampleMaxValue) ? newValue : errorMessage; + } } + /> + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + ReactTestUtils.Simulate.input(inputDOM, mockEvent(String(exampleNewValue))); + ReactTestUtils.Simulate.blur(inputDOM); + + expect(inputDOM.value).to.equal(String(errorMessage)); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal(String(errorMessage)); + }); + + it('should have correct value after increment and using defaultValue', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + const exampleStepValue: number = 2; + const exampleNewValue: string = String(Number(exampleDefaultValue) + exampleStepValue); + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + const upButtonDOM: HTMLButtonElement = renderedDOM.getElementsByClassName('ms-UpButton')[0] as HTMLButtonElement; + ReactTestUtils.Simulate.mouseDown(upButtonDOM); + ReactTestUtils.Simulate.mouseUp(upButtonDOM); + + expect(inputDOM.value).to.equal(String(exampleNewValue)); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal(String(exampleNewValue)); + }); + + it('should have correct value after decrement and using defaultValue', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + const exampleStepValue: number = 2; + const exampleNewValue: string = String(Number(exampleDefaultValue) - exampleStepValue); + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + const downButtonDOM: HTMLButtonElement = renderedDOM.getElementsByClassName('ms-DownButton')[0] as HTMLButtonElement; + ReactTestUtils.Simulate.mouseDown(downButtonDOM); + ReactTestUtils.Simulate.mouseUp(downButtonDOM); + + expect(inputDOM.value).to.equal(String(exampleNewValue)); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal(String(exampleNewValue)); + }); + + it('should stop spinning if text field is focused while actively spinning', () => { + const exampleLabelValue: string = 'SpinButton'; + const exampleMinValue: number = 2; + const exampleMaxValue: number = 22; + const exampleDefaultValue: string = '12'; + + function delay(millisecond: number): Promise { + return new Promise((resolve) => setTimeout(resolve, millisecond)); + } + + const renderedDOM: HTMLElement = renderIntoDocument( + + ); + + // Assert on the input element. + const inputDOM: HTMLInputElement = renderedDOM.getElementsByTagName('input')[0]; + const buttonDOM: Element = renderedDOM.getElementsByClassName('ms-UpButton')[0]; + + expect(buttonDOM.tagName).to.equal('BUTTON'); + + ReactTestUtils.Simulate.mouseDown(buttonDOM, + { + type: 'mousedown', + clientX: 0, + clientY: 0 + }); + + delay(500).then(() => ReactTestUtils.Simulate.focus(inputDOM)); + + let currentValue = inputDOM.value; + expect(currentValue).to.not.equal('2'); + expect(inputDOM.getAttribute('aria-valuemin')).to.equal(String(exampleMinValue)); + expect(inputDOM.getAttribute('aria-valuemax')).to.equal(String(exampleMaxValue)); + expect(inputDOM.getAttribute('aria-valuenow')).to.equal(currentValue); + + let newCurrentValue = inputDOM.value; + expect(currentValue).to.equal(newCurrentValue); + }); +}); diff --git a/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.tsx b/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.tsx new file mode 100644 index 0000000000000..4e29fefbe90fb --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/SpinButton/SpinButton.tsx @@ -0,0 +1,428 @@ +import * as React from 'react'; +import { IconButton } from '../../Button'; +import { Label } from '../../Label'; +import { Icon } from '../../Icon'; +import { + BaseComponent, + css, + getId, + KeyCodes, + autobind +} from '../../Utilities'; +import { + ISpinButton, + ISpinButtonProps +} from './SpinButton.Props'; +import { Position } from '../../utilities/positioning'; +import * as stylesImport from './SpinButton.scss'; +const styles: any = stylesImport; + +export enum KeyboardSpinDirection { + down = -1, + notSpinning = 0, + up = 1 +} + +export interface ISpinButtonState { + /** + * the value of the spin button + */ + value?: string; + + /** + * keyboard spin direction, used to style the up or down button + * as active when up/down arrow is pressed + */ + keyboardSpinDirection?: KeyboardSpinDirection; +} + +export class SpinButton extends BaseComponent implements ISpinButton { + + public static defaultProps: ISpinButtonProps = { + step: 1, + min: 0, + max: 100, + disabled: false, + labelPosition: Position.start, + label: null, + incrementButtonIcon: { iconName: 'ChevronUpSmall' }, + decrementButtonIcon: { iconName: 'ChevronDownSmall' } + }; + + private _input: HTMLInputElement; + private _inputId: string; + private _labelId: string; + private _lastValidValue: string; + private _spinningByMouse: boolean; + + private _onValidate?: (value: string) => string | void; + private _onIncrement?: (value: string) => string | void; + private _onDecrement?: (value: string) => string | void; + + private _currentStepFunctionHandle: number; + private _stepDelay = 100; + private _formattedValidUnitOptions: string[] = []; + private _arrowButtonStyle: React.CSSProperties = { + icon: { + fontSize: '6px', + } + }; + + constructor(props: ISpinButtonProps) { + super(props); + + this._warnMutuallyExclusive({ + 'value': 'defaultValue' + }); + + let value = props.value || props.defaultValue || String(props.min); + this._lastValidValue = value; + + this.state = { + value: value, + keyboardSpinDirection: KeyboardSpinDirection.notSpinning + }; + + this._currentStepFunctionHandle = -1; + this._labelId = getId('Label'); + this._inputId = getId('input'); + this._spinningByMouse = false; + + if (props.defaultValue) { + this._onValidate = this._defaultOnValidate; + this._onIncrement = this._defaultOnIncrement; + this._onDecrement = this._defaultOnDecrement; + } else { + this._onValidate = props.onValidate; + this._onIncrement = props.onIncrement; + this._onDecrement = props.onDecrement; + } + + this.focus = this.focus.bind(this); + } + + /** + * Invoked when a component is receiving new props. This method is not called for the initial render. + */ + public componentWillReceiveProps(newProps: ISpinButtonProps): void { + this._lastValidValue = this.state.value; + let value: string = newProps.value ? newProps.value : String(newProps.min); + if (newProps.defaultValue) { + value = String(Math.max(newProps.min, Math.min(newProps.max, Number(newProps.defaultValue)))); + } + + this.setState({ + value: value + }); + } + + public render() { + const { + disabled, + label, + min, + max, + labelPosition, + iconProps, + incrementButtonIcon, + decrementButtonIcon, + title, + ariaLabel + } = this.props; + + const { + value, + keyboardSpinDirection + } = this.state; + + return ( +
+ { labelPosition !== Position.bottom &&
+ { iconProps && } + { label && + + } +
} +
+ + + +
+ { labelPosition === Position.bottom &&
+ { iconProps && } + { label && + + } +
+ } +
+ ); + } + + /** + * OnFocus select the contents of the input + */ + public focus() { + if (this._spinningByMouse || this.state.keyboardSpinDirection !== KeyboardSpinDirection.notSpinning) { + this._stop(); + } + + this._input.focus(); + this._input.select(); + } + + /** + * Validate function to use if one is not passed in + */ + private _defaultOnValidate = (value: string) => { + if (isNaN(Number(value))) { + return this._lastValidValue; + } + const newValue = Math.min(this.props.max, Math.max(this.props.min, Number(value))); + return String(newValue); + } + + /** + * Increment function to use if one is not passed in + */ + private _defaultOnIncrement = (value: string) => { + let newValue = Math.min(Number(value) + this.props.step, this.props.max); + return String(newValue); + } + + /** + * Increment function to use if one is not passed in + */ + private _defaultOnDecrement = (value: string) => { + let newValue = Math.max(Number(value) - this.props.step, this.props.min); + return String(newValue); + } + + /** + * Returns the class name corresponding to the label position + */ + private _getClassNameForLabelPosition(labelPosition: Position): string { + let className: string = ''; + + switch (labelPosition) { + case Position.start: + className = styles.start; + break; + case Position.end: + className = styles.end; + break; + case Position.top: + className = styles.top; + break; + case Position.bottom: + className = styles.bottom; + } + + return className; + } + + private _onChange() { + /** + * A noop input change handler. + * https://github.com/facebook/react/issues/7027. + * Using the native onInput handler fixes the issue but onChange + * still need to be wired to avoid React console errors + * TODO: Check if issue is resolved when React 16 is available. + */ + } + + /** + * This is used when validating text entry + * in the input (not when changed via the buttons) + * @param event - the event that fired + */ + @autobind + private _validate(event: React.FocusEvent) { + const element: HTMLInputElement = event.target as HTMLInputElement; + const value: string = element.value; + if (this.state.value) { + const newValue = this._onValidate(value); + if (newValue) { + this._lastValidValue = newValue; + this.setState({ value: newValue }); + } + } + } + + /** + * The method is needed to ensure we are updating the actual input value. + * without this our value will never change (and validation will not have the correct number) + * @param event - the event that was fired + */ + @autobind + private _onInputChange(event: React.FormEvent): void { + const element: HTMLInputElement = event.target as HTMLInputElement; + const value: string = element.value; + + this.setState({ + value: value, + }); + } + + /** + * Update the value with the given stepFunction + * @param shouldSpin - should we fire off another updateValue when we are done here? This should be true + * when spinning in response to a mouseDown + * @param stepFunction - function to use to step by + */ + @autobind + private _updateValue(shouldSpin: boolean, stepFunction: (string) => string | void) { + const newValue = stepFunction(this.state.value); + if (newValue) { + this._lastValidValue = newValue; + this.setState({ value: newValue }); + } + + if (this._spinningByMouse !== shouldSpin) { + this._spinningByMouse = shouldSpin; + } + + if (shouldSpin) { + this._currentStepFunctionHandle = this._async.setTimeout(() => { this._updateValue(shouldSpin, stepFunction); }, this._stepDelay); + } + } + + /** + * Stop spinning (clear any currently pending update and set spinning to false) + */ + @autobind + private _stop() { + if (this._currentStepFunctionHandle >= 0) { + this._async.clearTimeout(this._currentStepFunctionHandle); + this._currentStepFunctionHandle = -1; + } + + if (this._spinningByMouse || this.state.keyboardSpinDirection !== KeyboardSpinDirection.notSpinning) { + this._spinningByMouse = false; + this.setState({ keyboardSpinDirection: KeyboardSpinDirection.notSpinning }); + } + } + + /** + * Handle keydown on the text field. We need to update + * the value when up or down arrow are depressed + * @param event - the keyboardEvent that was fired + */ + @autobind + private _handleKeyDown(event: React.KeyboardEvent) { + if (this.props.disabled) { + this._stop(); + + // eat the up and down arrow keys to keep the page from scrolling + if (event.which === KeyCodes.up || event.which === KeyCodes.down) { + event.preventDefault(); + event.stopPropagation(); + } + + return; + } + + let spinDirection = KeyboardSpinDirection.notSpinning; + + if (event.which === KeyCodes.up) { + + spinDirection = KeyboardSpinDirection.up; + this._updateValue(false /* shouldSpin */, this._onIncrement); + } else if (event.which === KeyCodes.down) { + + spinDirection = KeyboardSpinDirection.down; + this._updateValue(false /* shouldSpin */, this._onDecrement); + } else if (event.which === KeyCodes.enter) { + event.currentTarget.blur(); + this.focus(); + } else if (event.which === KeyCodes.escape) { + if (this.state.value !== this._lastValidValue) { + this.setState({ value: this._lastValidValue }); + } + } + + // style the increment/decrement button to look active + // when the corresponding up/down arrow keys trigger a step + if (this.state.keyboardSpinDirection !== spinDirection) { + this.setState({ keyboardSpinDirection: spinDirection }); + } + } + + /** + * Make sure that we have stopped spinning on keyUp + * if the up or down arrow fired this event + * @param event stop spinning if we + */ + @autobind + private _handleKeyUp(event: React.KeyboardEvent) { + + if (this.props.disabled || event.which === KeyCodes.up || event.which === KeyCodes.down) { + this._stop(); + return; + } + } + + @autobind + private _onIncrementMouseDown() { + this._updateValue(true /* shouldSpin */, this._onIncrement); + } + + @autobind + private _onDecrementMouseDown() { + this._updateValue(true /* shouldSpin */, this._onDecrement); + } + +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/SpinButton/SpinButtonPage.tsx b/packages/office-ui-fabric-react/src/components/SpinButton/SpinButtonPage.tsx new file mode 100644 index 0000000000000..37fd9c958c51a --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/SpinButton/SpinButtonPage.tsx @@ -0,0 +1,97 @@ +import * as React from 'react'; +import { + ExampleCard, + IComponentDemoPageProps, + ComponentPage, + PropertiesTableSet +} from '@uifabric/example-app-base'; +import { SpinButtonBasicExample } from './examples/SpinButton.Basic.Example'; +import { SpinButtonBasicDisabledExample } from './examples/SpinButton.BasicDisabled.Example'; +import { SpinButtonStatefulExample } from './examples/SpinButton.Stateful.Example'; +import { SpinButtonBasicWithIconExample } from './examples/SpinButton.BasicWithIcon.Example'; +import { SpinButtonBasicWithEndPositionExample } from './examples/SpinButton.BasicWithEndPosition.Example'; + +const SpinButtonBasicExampleCode = require('!raw-loader!office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.Basic.Example.tsx') as string; +const SpinButtonBasicDisabledExampleCode = require('!raw-loader!office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicDisabled.Example.tsx') as string; +const SpinButtonStatefulExampleCode = require('!raw-loader!office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.Stateful.Example.tsx') as string; +const SpinButtonBasicWithIconExampleCode = require('!raw-loader!office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicWithIcon.Example.tsx') as string; +const SpinButtonBasicWithEndPositionExampleCode = require('!raw-loader!office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicWithEndPosition.Example.tsx') as string; + +export class SpinButtonPage extends React.Component { + public render() { + return ( + + + + + + + + + + + + + + + + + + } + propertiesTables={ + ('!raw-loader!office-ui-fabric-react/src/components/SpinButton/SpinButton.Props.ts') + ] } + /> + } + overview={ +
+

+ A SpinButton allows the user to incrementaly adjust a value in small steps. It is mainly used for numeric values, but other values are supported too. +

+
+ } + bestPractices={ +
+ } + dos={ +
+
    +
  • Use a SpinButton when changing a value with precise control.
  • +
  • Use a SpinButton when values are tied to a unit.
  • +
  • Include a label indicating what value the SpinButton changes.
  • +
+
+ } + donts={ +
+
    +
  • Don’t use a SpinButton if the range of values is large.
  • +
  • Don’t use a SpinButton for binary settings.
  • +
  • Don't use a SpinButton for a range of three values or less.
  • +
+
+ } + related={ + Fabric JS + } + isHeaderVisible={ this.props.isHeaderVisible }> +
+ ); + } +} diff --git a/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.Basic.Example.tsx b/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.Basic.Example.tsx new file mode 100644 index 0000000000000..4999ef440bc24 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.Basic.Example.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { SpinButton } from 'office-ui-fabric-react/lib/SpinButton'; + +export class SpinButtonBasicExample extends React.Component { + public render() { + return ( +
+ +
+ ); + } +} diff --git a/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicDisabled.Example.tsx b/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicDisabled.Example.tsx new file mode 100644 index 0000000000000..b57b1d432db08 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicDisabled.Example.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { SpinButton } from 'office-ui-fabric-react/lib/SpinButton'; + +export class SpinButtonBasicDisabledExample extends React.Component { + public render() { + return ( +
+ +
+ ); + } +} diff --git a/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicWithEndPosition.Example.tsx b/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicWithEndPosition.Example.tsx new file mode 100644 index 0000000000000..31d1d77b14ab5 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicWithEndPosition.Example.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { SpinButton } from 'office-ui-fabric-react/lib/SpinButton'; +import { Position } from 'office-ui-fabric-react/lib/utilities/positioning'; + +export class SpinButtonBasicWithEndPositionExample extends React.Component { + public render() { + return ( +
+ +
+ ); + } +} diff --git a/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicWithIcon.Example.tsx b/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicWithIcon.Example.tsx new file mode 100644 index 0000000000000..056ba07096ba3 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.BasicWithIcon.Example.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { SpinButton } from 'office-ui-fabric-react/lib/SpinButton'; + +export class SpinButtonBasicWithIconExample extends React.Component { + public render() { + return ( +
+ +
+ ); + } +} diff --git a/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.Stateful.Example.tsx b/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.Stateful.Example.tsx new file mode 100644 index 0000000000000..205ddc1370100 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/SpinButton/examples/SpinButton.Stateful.Example.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { SpinButton, ISpinButtonState, ISpinButtonProps } from 'office-ui-fabric-react/lib/SpinButton'; + +export class SpinButtonStatefulExample extends React.Component { + public render() { + let suffix = ' cm'; + + return ( +
+ { + value = this.removeSuffix(value, suffix); + if (isNaN(+value)) { + return '0' + suffix; + } + + return String(value) + suffix; + } } + onIncrement={ (value: string) => { + value = this.removeSuffix(value, suffix); + return String(+value + 2) + suffix; + } } + onDecrement={ (value: string) => { + value = this.removeSuffix(value, suffix); + return String(+value - 2) + suffix; + } } + /> +
+ ); + } + + private hasSuffix(string: string, suffix: string): Boolean { + let subString = string.substr(string.length - suffix.length); + return subString === suffix; + } + + private removeSuffix(string: string, suffix: string): string { + if (!this.hasSuffix(string, suffix)) { + return string; + } + + return string.substr(0, string.length - suffix.length); + } +} diff --git a/packages/office-ui-fabric-react/src/components/SpinButton/index.ts b/packages/office-ui-fabric-react/src/components/SpinButton/index.ts new file mode 100644 index 0000000000000..08acda35da3bd --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/SpinButton/index.ts @@ -0,0 +1,2 @@ +export * from './SpinButton'; +export * from './SpinButton.Props'; diff --git a/packages/office-ui-fabric-react/src/demo/AppDefinition.tsx b/packages/office-ui-fabric-react/src/demo/AppDefinition.tsx index d77a7debd6edf..6a02d00c8537d 100644 --- a/packages/office-ui-fabric-react/src/demo/AppDefinition.tsx +++ b/packages/office-ui-fabric-react/src/demo/AppDefinition.tsx @@ -202,6 +202,12 @@ export const AppDefinition: IAppDefinition = { name: 'SearchBox', url: '#/examples/searchbox' }, + { + component: require('../components/SpinButton/SpinButtonPage').SpinButtonPage, + key: 'SpinButton', + name: 'SpinButton', + url: '#/examples/spinbutton' + }, { component: require('../components/Spinner/SpinnerPage').SpinnerPage, key: 'Spinner', diff --git a/packages/office-ui-fabric-react/src/index.ts b/packages/office-ui-fabric-react/src/index.ts index 2cfe405cb6c25..952bd1f6b4afa 100644 --- a/packages/office-ui-fabric-react/src/index.ts +++ b/packages/office-ui-fabric-react/src/index.ts @@ -40,6 +40,7 @@ export * from './ProgressIndicator'; export * from './Rating'; export * from './SearchBox'; export * from './Slider'; +export * from './SpinButton'; export * from './Spinner'; export * from './Styling'; export * from './TeachingBubble'; diff --git a/packages/office-ui-fabric-react/src/utilities/positioning.ts b/packages/office-ui-fabric-react/src/utilities/positioning.ts index 75baa2310b660..b3b857cfd435e 100644 --- a/packages/office-ui-fabric-react/src/utilities/positioning.ts +++ b/packages/office-ui-fabric-react/src/utilities/positioning.ts @@ -13,6 +13,13 @@ export enum RectangleEdge { right = 3 } +export enum Position { + top = 0, + bottom = 1, + start = 2, + end = 3 +} + let SLIDE_ANIMATIONS: { [key: number]: string; } = { [RectangleEdge.top]: 'slideUpIn20', [RectangleEdge.bottom]: 'slideDownIn20',