diff --git a/apps/fabric-website/src/components/App/AppState.tsx b/apps/fabric-website/src/components/App/AppState.tsx index f722bddac0cfea..0dd406fae4d741 100644 --- a/apps/fabric-website/src/components/App/AppState.tsx +++ b/apps/fabric-website/src/components/App/AppState.tsx @@ -129,6 +129,13 @@ export const AppState: IAppState = { component: () => , getComponent: cb => require.ensure([], (require) => cb(require('../../pages/Components/ColorPickerComponentPage').ColorPickerComponentPage)) }, + { + title: 'ComboBox', + url: '#/components/ComboBox', + component: () => , + getComponent: cb => require.ensure([], (require) => cb(require('../../pages/Components/ComboBoxComponentPage').ComboBoxComponentPage)) + + }, { title: 'CommandBar', url: '#/components/commandbar', @@ -401,4 +408,4 @@ export const AppState: IAppState = { getComponent: cb => require.ensure([], (require) => cb(require('../../pages/Interstitials/FabricIOSPage').FabricIOSPage)) } ] -}; +}; \ No newline at end of file diff --git a/apps/fabric-website/src/pages/Components/ComboBoxComponentPage.tsx b/apps/fabric-website/src/pages/Components/ComboBoxComponentPage.tsx new file mode 100644 index 00000000000000..dd4467690c1130 --- /dev/null +++ b/apps/fabric-website/src/pages/Components/ComboBoxComponentPage.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { ComboBoxPage } from 'office-ui-fabric-react/lib/components/ComboBox/ComboBoxPage'; +import { PageHeader } from '../../components/PageHeader/PageHeader'; +import { ComponentPage } from '../../components/ComponentPage/ComponentPage'; + +export class ComboBoxComponentPage extends React.Component { + public render() { + return ( +
+ + + + +
+ ); + } +} \ No newline at end of file diff --git a/common/changes/@uifabric/fabric-website/miwhea-website-update-header_2017-06-09-19-15.json~HEAD b/common/changes/@uifabric/fabric-website/miwhea-website-update-header_2017-06-09-19-15.json~HEAD new file mode 100644 index 00000000000000..ca46d03d328836 --- /dev/null +++ b/common/changes/@uifabric/fabric-website/miwhea-website-update-header_2017-06-09-19-15.json~HEAD @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@uifabric/fabric-website", + "comment": "Fix issues with dev.office.com header", + "type": "patch" + } + ], + "packageName": "@uifabric/fabric-website", + "email": "miwhea@microsoft.com" +} \ No newline at end of file diff --git a/common/changes/@uifabric/fabric-website/miwhea-website-update-header_2017-06-09-19-15.json~b0ee1f6c9e5f0d9b31c060d92998e0c43af504d4 b/common/changes/@uifabric/fabric-website/miwhea-website-update-header_2017-06-09-19-15.json~b0ee1f6c9e5f0d9b31c060d92998e0c43af504d4 new file mode 100644 index 00000000000000..ca46d03d328836 --- /dev/null +++ b/common/changes/@uifabric/fabric-website/miwhea-website-update-header_2017-06-09-19-15.json~b0ee1f6c9e5f0d9b31c060d92998e0c43af504d4 @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@uifabric/fabric-website", + "comment": "Fix issues with dev.office.com header", + "type": "patch" + } + ], + "packageName": "@uifabric/fabric-website", + "email": "miwhea@microsoft.com" +} \ No newline at end of file diff --git a/common/config/rush/npm-shrinkwrap.json b/common/config/rush/npm-shrinkwrap.json index 3ee4a00cd0fa4e..0067e2978da41e 100644 --- a/common/config/rush/npm-shrinkwrap.json +++ b/common/config/rush/npm-shrinkwrap.json @@ -570,9 +570,9 @@ "integrity": "sha1-DldYaol/ddjdDAKdKVtBapg4f5w=", "dependencies": { "@microsoft/api-extractor": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-2.0.7.tgz", - "integrity": "sha1-zmAg7skaKVy1JlmXu58gIqmBPWk=" + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-2.0.8.tgz", + "integrity": "sha1-MbnfuwRtx2MYLgVQ+1shCD40nXA=" }, "@microsoft/gulp-core-build-sass": { "version": "3.1.2", @@ -605,9 +605,9 @@ "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=" }, "clean-css": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.1.3.tgz", - "integrity": "sha1-B8/omA7bINRV3cI6rc8eBMblCc4=" + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.1.4.tgz", + "integrity": "sha1-7siBHbJ0V+AHjYypIfqBty+oK/Q=" }, "configstore": { "version": "3.1.0", @@ -1648,14 +1648,14 @@ } }, "@uifabric/styling": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@uifabric/styling/-/styling-0.7.4.tgz", - "integrity": "sha512-MzIyq7vk6crRTWATfj8ryESGwZApt9RlFIScPkAFy8MBIvLhWP3Rc0H3/TrgjEYgIjDph+DybjmW/9aXlq3jLg==" + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@uifabric/styling/-/styling-0.8.1.tgz", + "integrity": "sha512-7uldSHBNLP8dNfYg7W54WKTvLLqL9phUNkKUCxAIsXDpqx405ffk3QDVb2hos77Rc8bbNdujfk8IaXuUV/8Oyw==" }, "@uifabric/utilities": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@uifabric/utilities/-/utilities-4.2.0.tgz", - "integrity": "sha512-eB9YLGf1hDFXV0Uq8+FoaHi13aLOvDAVHFSnqoIMnlAa1Q+3bODmpifwGcxRiP8WzqhRKlZKt4boK6iF6uQnOg==" + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@uifabric/utilities/-/utilities-4.3.0.tgz", + "integrity": "sha512-/4/xTLAJBtdYb+ohwWYPeusSiNlUCigPWlD5nkq+uAjGW/vkT/TDJXuCQasrb9QaV6wmgF6GDLzzZMykz5w0Tg==" }, "abbrev": { "version": "1.0.9", @@ -2302,9 +2302,9 @@ "integrity": "sha1-tTTnxzTE+B7F++isoq0kNUuWLGw=" }, "caniuse-db": { - "version": "1.0.30000684", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000684.tgz", - "integrity": "sha1-maywEYuP0f3WAaFeDA8t/BWoFoA=" + "version": "1.0.30000686", + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000686.tgz", + "integrity": "sha1-1VtHntbmQCwf0/H9j0bmlNhupGQ=" }, "capture-stack-trace": { "version": "1.0.0", @@ -2443,9 +2443,16 @@ "integrity": "sha1-4+JbIHrE5wGvch4staFnksrD3Fg=" }, "clone-deep": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", - "integrity": "sha1-TnPdCen7lxzDhnDF3O2cGJZIHMY=" + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.3.0.tgz", + "integrity": "sha1-NIxhrpzb4O3+BT2R/0zFIdeQ7eg=", + "dependencies": { + "for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=" + } + } }, "clone-stats": { "version": "0.0.1", @@ -5551,14 +5558,14 @@ "integrity": "sha1-1zvD/0SJQkCIGM5gm/P7DqfvTrc=", "dependencies": { "clean-css": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.1.3.tgz", - "integrity": "sha1-B8/omA7bINRV3cI6rc8eBMblCc4=" + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.1.4.tgz", + "integrity": "sha1-7siBHbJ0V+AHjYypIfqBty+oK/Q=" }, "uglify-js": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.0.15.tgz", - "integrity": "sha1-qssyOoRrI0YCJw3q2KMkQaiAb0I=" + "version": "3.0.17", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.0.17.tgz", + "integrity": "sha1-0ijNVcLfmz0vU/FHVoy0zEpyzAY=" } } }, @@ -7353,9 +7360,9 @@ "integrity": "sha1-6mJvqUURirroSrsZc45KCLCHPx8=" }, "office-ui-fabric-react": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/office-ui-fabric-react/-/office-ui-fabric-react-4.5.1.tgz", - "integrity": "sha512-lPeE/wgI9C/ryKpaQGTQRvo/1txgoFs5/g6i5kBEMc41DDVj7ZMXW2kgPER6dtV9Ku1IYqcJSNwi/KP1a07KvA==" + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/office-ui-fabric-react/-/office-ui-fabric-react-4.7.0.tgz", + "integrity": "sha512-X/FHLjmUEgipp/BucZVFmw3x59DV3clKS5SDenFK9J4uQErHiXpS4OBRLs/gUgyxnrS5Xzu/CJk8hyp+I+LuEQ==" }, "on-finished": { "version": "2.3.0", @@ -8601,9 +8608,9 @@ } }, "sass-loader": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-6.0.5.tgz", - "integrity": "sha1-qEeRDzZEKqVsWYWHnVTrUZ4koyg=", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-6.0.6.tgz", + "integrity": "sha512-c3/Zc+iW+qqDip6kXPYLEgsAu2lf4xz0EZDplB7EmSUMda12U1sGJPetH55B/j9eu0bTtKzKlNPWWyYC7wFNyQ==", "dependencies": { "async": { "version": "2.4.1", @@ -8614,6 +8621,11 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.1.0.tgz", "integrity": "sha1-yYrvSIvM7aL/teLeZG1qdUQp9c0=" + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=" } } }, @@ -9596,9 +9608,9 @@ "integrity": "sha1-BMgamb3V3FImPqKdJMa/jUgYpLs=" }, "uglify-js": { - "version": "2.8.28", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.28.tgz", - "integrity": "sha512-WqKNbmNJKzIdIEQu/U2ytgGBbhCy2PVks94GoetczOAJ/zCgVu2CuO7gguI5KPFGPtUtI1dmPQl6h0D4cPzypA==", + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", + "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", "dependencies": { "camelcase": { "version": "1.2.1", diff --git a/packages/office-ui-fabric-react/src/ComboBox.ts b/packages/office-ui-fabric-react/src/ComboBox.ts new file mode 100644 index 00000000000000..c9b81e057c5f2b --- /dev/null +++ b/packages/office-ui-fabric-react/src/ComboBox.ts @@ -0,0 +1 @@ +export * from './components/ComboBox/index'; diff --git a/packages/office-ui-fabric-react/src/SelectableOption.ts b/packages/office-ui-fabric-react/src/SelectableOption.ts new file mode 100644 index 00000000000000..ec2ae3d50cbd76 --- /dev/null +++ b/packages/office-ui-fabric-react/src/SelectableOption.ts @@ -0,0 +1 @@ +export * from './utilities/selectableOption/index'; diff --git a/packages/office-ui-fabric-react/src/VisualTestState.ts b/packages/office-ui-fabric-react/src/VisualTestState.ts index 9dde96a3b28710..e434f34dfe53a0 100644 --- a/packages/office-ui-fabric-react/src/VisualTestState.ts +++ b/packages/office-ui-fabric-react/src/VisualTestState.ts @@ -8,6 +8,7 @@ export const VisualTestState: IVisualTestState = { './Button/ButtonPage.visualtest', './Checkbox/CheckboxPage.visualtest', './ChoiceGroup/ChoiceGroupPage.visualtest', + './ComboBox/ComboBoxPage.visualtest', './CommandBar/CommandBarPage.visualtest', './ContextualMenu/ContextualMenuPage.visualtest', './DetailsList/DetailsListPage.visualtest', diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.Props.ts b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.Props.ts new file mode 100644 index 00000000000000..2a56e8860f64b6 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.Props.ts @@ -0,0 +1,59 @@ +import { IIconProps } from '../../Icon'; +import { ISelectableOption } from '../../utilities/selectableOption/SelectableOption.Props'; +import { ISelectableDroppableTextProps } from '../../utilities/selectableOption/SelectableDroppableText.Props'; + +export interface IComboBox { + /** + * Sets focus to the input in the comboBox + * @returns True if focus could be set, false if no operation was taken. + */ + focus(): boolean; +} + +export interface IComboBoxProps extends ISelectableDroppableTextProps { + /** + * Optional callback to access the IComboBox interface. Use this instead of ref for accessing + * the public methods and properties of the component. + */ + componentRef?: (component: IComboBox) => void; + + /** + * Collection of options for this ComboBox + */ + options?: ISelectableOption[]; + + /** + * Callback issues when either: + * 1) the selected option changes + * 2) a manually edited value is submitted. In this case there may not be a matched option if allowFreeform is also true + * (and hence only value would be true, the other parameter would be null in this case) + */ + onChanged?: (option?: ISelectableOption, index?: number, value?: string) => void; + + /** + * Callback issued when the options should be resolved, if they have been updated or + * if they need to be passed in the first time + */ + onResolveOptions?: (options: ISelectableOption[]) => ISelectableOption[] | PromiseLike; + + /** + * Whether the ComboBox is free form, meaning that the user input is not bound to provided items. Defaults to false. + */ + allowFreeform?: boolean; + + /** + * Whether the ComboBox auto completes. As the user is inputing text, it will be suggested potential matches from the list of items. If + * the combo box is expanded, this will also scroll to the suggested item, and give it a selected style. Defaults to false. + */ + autoComplete?: boolean; + + /** + * Value to show in the input, does not have to map to a combobox option + */ + value?: string; + + /** + * The IconProps to use for the button aspect of the combobox + */ + buttonIconProps?: IIconProps; +} diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.scss b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.scss new file mode 100644 index 00000000000000..f0d015f3c55ad3 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.scss @@ -0,0 +1,255 @@ +@import '../../common/common'; + +// Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See LICENSE in the project root for license information. + +// +// Office UI Fabric +// -------------------------------------------------- +// ComboBox styles + +$ComboBox-selectedItem-bg: $ms-color-neutralQuaternaryAlt; +$ComboBox-selectedItem-hover-bg: $ms-color-neutralLighter; +$ComboBox-height: 32px; +$ComboBox-caretDown-width: 32px; +$ComboBox-item-height: 36px; + +// Mixin for high contrast mode link states +@mixin highContrastListItemState { + @media screen and (-ms-high-contrast: active) { + background-color: $ms-color-contrastBlackSelected; + border-color: $ms-color-contrastBlackSelected; + color: $ms-color-black; + + &:focus { + border-color: $ms-color-black; + } + } + + @media screen and (-ms-high-contrast: black-on-white) { + background-color: $ms-color-contrastWhiteSelected; + border-color: $ms-color-contrastWhiteSelected; + color: $ms-color-white; + } + + @include highContrastAdjust(); +} + +.root { + @include ms-normalize; + @include ms-font-m; + color: $ms-color-neutralPrimary; + + margin-bottom: 10px; + position: relative; + outline: 0; + user-select: none; + background: $ms-color-white; + border: 1px solid $ms-color-neutralTertiaryAlt; + cursor: text ; + display: block; + height: $ComboBox-height; + line-height: $ComboBox-height - 2px; // height minus the border + @include padding(0, $ComboBox-caretDown-width, 0, 0); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + + + &:hover, + &.focused { + &.wrapper + { + border-color: $ms-color-themePrimary; + + @media screen and (-ms-high-contrast: active) { + color: $ms-color-contrastBlackSelected; + } + + @media screen and (-ms-high-contrast: black-on-white) { + color: $ms-color-contrastWhiteSelected; + } + } + + &.readOnly { + .caretDown { + background-color: inherit; + } + } + } + + &.wrapperForError { + border-color: $ms-color-error; + } + + :global(.ms-Label) { + display: inline-block; + margin-bottom: 8px; + } +} + +//== State: A disabled ComboBox +.root.rootIsDisabled { + &.wrapper, + .input, + .caretDown { + background-color: $ms-color-neutralLighter; + border-color: $ms-color-neutralLighter; + color: $ms-color-neutralTertiary; + cursor: default; + + @media screen and (-ms-high-contrast: active) { + border-color: $ms-color-contrastBlackDisabled; + color: $ms-color-contrastBlackDisabled; + } + + @media screen and (-ms-high-contrast: black-on-white) { + border-color: $ms-color-contrastWhiteDisabled; + color: $ms-color-contrastWhiteDisabled; + } + } +} + +.input { + box-sizing: border-box; + width: 100%; + height: 100%; + border: none; + outline: none; + font: inherit; + text-overflow: ellipsis; + padding-left: 12px; + +} + +.caretDown { + color: $ms-color-neutralDark; + font-size: $ms-icon-size-s; + position: absolute; + height: $ComboBox-height; + line-height: $ComboBox-height - 2px; // height minus the border + width: $ComboBox-caretDown-width; + text-align: center; + cursor: default; + + &:hover{ + background-color: $ms-color-neutralQuaternaryAlt; + } + + &:active{ + background-color: $ms-color-neutralTertiaryAlt; + } +} + +.callout { + box-shadow: 0 0px 5px 0px rgba(0, 0, 0, 0.4); + border: 1px solid $ms-color-neutralLight; +} + +.errorMessage{ + color: $ms-color-error; + &::before { + content: '* '; + } +} + +.items { + display: block; +} + +// Container for the ComboBox items, displayed as a panel on small screens. +.item { + background: transparent; + box-sizing: border-box; + cursor: pointer; + display: block; + width: 100%; + height: auto; + min-height: $ComboBox-item-height; + line-height: 20px; + padding: 5px 16px; + position: relative; + border: 1px solid transparent; + word-wrap: break-word; + overflow-wrap: break-word; + text-align: left; + + @media screen and (-ms-high-contrast: active) { + border-color: $ms-color-black; + } + + @media screen and (-ms-high-contrast: black-on-white) { + border-color: $ms-color-white; + } + + &:hover { + background-color: $ComboBox-selectedItem-hover-bg; + color: $ms-color-black; + + @include highContrastListItemState; + } + + @include focus-border(); + + &:focus { + background-color: $ms-color-neutralLighter; + } + + &:active { + background-color: $ComboBox-selectedItem-hover-bg; + color: $ms-color-black; + } + + &.itemIsDisabled { + background: $ms-color-white; + color: $ms-color-neutralTertiary; + cursor: default; + } + :global(.ms-Button-flexContainer) { + justify-content: flex-start; + } +} + +// A selected ComboBox item +.item.itemIsSelected { + background-color: $ComboBox-selectedItem-bg; + color: $ms-color-black; + + &:hover { + background-color: $ComboBox-selectedItem-bg; + } + + @include focus-border(); + + @include highContrastListItemState; +} + +.header { + @include ms-font-m; + font-weight: $ms-font-weight-semibold; + color: $ms-color-themePrimary; + background: none; + border: none; + height: $ComboBox-item-height; + line-height: $ComboBox-item-height; + cursor: default; + padding: 0px 16px; + user-select: none; + @include text-align(left); +} + +.divider { + height: 1px; + background-color: $dividerColor; +} + +.optionText { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0px; + max-width: 100%; + word-wrap: break-word; + overflow-wrap: break-word; + margin: 1px; +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.test.tsx b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.test.tsx new file mode 100644 index 00000000000000..e9be6876e1ad2d --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.test.tsx @@ -0,0 +1,284 @@ +/* tslint:disable:no-unused-variable */ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +/* tslint:enable:no-unused-variable */ +import * as ReactTestUtils from 'react-addons-test-utils'; +import { mount, shallow } from 'enzyme'; +import { KeyCodes } from '../../Utilities'; +let { expect } = chai; + +import { ComboBox } from './ComboBox'; +import { ISelectableOption } from '../../utilities/selectableOption/SelectableOption.Props'; + +const DEFAULT_OPTIONS: ISelectableOption[] = [ + { key: '1', text: '1' }, + { key: '2', text: '2' }, + { key: '3', text: '3' } +]; + +const DEFAULT_OPTIONS2: ISelectableOption[] = [ + { key: '1', text: 'One' }, + { key: '2', text: 'Foo' }, + { key: '3', text: 'Bar' } +]; + +describe('ComboBox', () => { + + it('Can flip between enabled and disabled.', () => { + let wrapper = shallow( + ); + let comboBoxRoot = wrapper.find('.ms-ComboBox'); + + expect(comboBoxRoot.find('.is-disabled').length).equals(0, `shouldn't be disabled`); + expect(comboBoxRoot.find('[data-is-interactable=true]').length).equals(1, 'data-is-focusable="true"'); + + wrapper = shallow( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + + expect(comboBoxRoot.find('.is-disabled').length).equals(1, `should be disabled`); + expect(comboBoxRoot.find('[data-is-interactable=false]').length).equals(1, 'data-is-focusable="false"'); + }); + + it('Renders no selected item in default case', () => { + + let wrapper = mount( + ); + let comboBoxRoot = wrapper.find('.ms-ComboBox'); + let inputElement = comboBoxRoot.find('[role="combobox"]'); + + expect(inputElement.text()).equals(''); + }); + + it('Renders a selected item in uncontrolled case', () => { + let wrapper = mount( + ); + let comboBoxRoot = wrapper.find('.ms-ComboBox'); + let inputElement = comboBoxRoot.find('input'); + + expect(inputElement.props().value).equals('1'); + }); + + it('Renders a selected item in controlled case', () => { + let wrapper = mount( + ); + let comboBoxRoot = wrapper.find('.ms-ComboBox'); + let inputElement = comboBoxRoot.find('input'); + + expect(inputElement.props().value).equals('1'); + }); + + it('Renders a default value with options', () => { + let wrapper = mount( + ); + let comboBoxRoot = wrapper.find('.ms-ComboBox'); + let inputElement = comboBoxRoot.find('input'); + + expect(inputElement.props().value).equals('1'); + }); + + it('Renders a default value with no options', () => { + let wrapper = mount( + ); + let comboBoxRoot = wrapper.find('.ms-ComboBox'); + let inputElement = comboBoxRoot.find('input'); + + expect(inputElement.props().value).equals('1'); + }); + + it('Can change items in uncontrolled case', () => { + let comboBoxRoot; + let wrapper = mount( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + let buttonElement = comboBoxRoot.find('button'); + buttonElement.simulate('click'); + let secondItemElement = wrapper.getDOMNode().ownerDocument.querySelector('.ms-ComboBox-item[data-index="1"]'); + ReactTestUtils.Simulate.click(secondItemElement); + let inputElement = comboBoxRoot.find('input'); + expect(inputElement.props().value).equals('2'); + }); + + it('Can insert text in uncontrolled case with autoComplete and allowFreeform on', () => { + let comboBoxRoot; + let inputElement; + let wrapper = mount( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + inputElement = comboBoxRoot.find('input'); + inputElement.simulate('change', { target: { value: 'f' } }); + inputElement = comboBoxRoot.find('input'); + expect(inputElement.props().value).equals('Foo'); + }); + + it('Can insert text in uncontrolled case with autoComplete on and allowFreeform off', () => { + let comboBoxRoot; + let inputElement; + let wrapper = mount( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + inputElement = comboBoxRoot.find('input'); + inputElement.simulate('change', { target: { value: 'f' } }); + inputElement = comboBoxRoot.find('input'); + expect(inputElement.props().value).equals('Foo'); + }); + + it('Can insert text in uncontrolled case with autoComplete off and allowFreeform on', () => { + let comboBoxRoot; + let inputElement; + let wrapper = mount( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + inputElement = comboBoxRoot.find('input'); + inputElement.simulate('change', { target: { value: 'f' } }); + inputElement = comboBoxRoot.find('input'); + expect(inputElement.props().value).equals('f'); + }); + + it('Can insert text in uncontrolled case with autoComplete and allowFreeform off', () => { + let comboBoxRoot; + let inputElement; + let wrapper = mount( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + inputElement = comboBoxRoot.find('input'); + inputElement.simulate('keydown', { which: 'f' }); + inputElement = comboBoxRoot.find('input'); + expect(inputElement.props().value).equals('One'); + }); + + it('Can change selected option with keyboard', () => { + let comboBoxRoot; + let inputElement; + let wrapper = mount( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + inputElement = comboBoxRoot.find('input'); + inputElement.simulate('keydown', { which: KeyCodes.down }); + inputElement = comboBoxRoot.find('input'); + expect(inputElement.props().value).equals('Foo'); + }); + + it('Cannot insert text while disabled', () => { + let comboBoxRoot; + let inputElement; + let wrapper = mount( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + inputElement = comboBoxRoot.find('input'); + inputElement.simulate('keydown', { which: KeyCodes.a }); + inputElement = comboBoxRoot.find('input'); + expect(inputElement.props().value).equals('One'); + }); + + it('Cannot change selected option with keyboard while disabled', () => { + let comboBoxRoot; + let inputElement; + let wrapper = mount( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + inputElement = comboBoxRoot.find('input'); + inputElement.simulate('keydown', { which: KeyCodes.down }); + inputElement = comboBoxRoot.find('input'); + expect(inputElement.props().value).equals('One'); + }); + + it('Cannot expand the menu when clicking on the input while disabled', () => { + let comboBoxRoot; + let inputElement; + let wrapper = mount( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + inputElement = comboBoxRoot.find('input'); + inputElement.simulate('click'); + expect(comboBoxRoot.find('.is-opened').length).equals(0, `shouldn't be opened`); + }); + + it('Cannot expand the menu when clicking on the button while disabled', () => { + let comboBoxRoot; + let buttonElement; + let wrapper = mount( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + buttonElement = comboBoxRoot.find('button'); + buttonElement.simulate('click'); + expect(comboBoxRoot.find('.is-opened').length).equals(0, `shouldn't be opened`); + }); +}); diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.tsx b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.tsx new file mode 100644 index 00000000000000..bc7e5e8748c477 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.tsx @@ -0,0 +1,1214 @@ +import * as React from 'react'; +import { IComboBoxProps } from './ComboBox.Props'; +import { DirectionalHint } from '../../common/DirectionalHint'; +import { Callout } from '../../Callout'; +import { Label } from '../../Label'; +import { + CommandButton, + IconButton +} from '../../Button'; +import { BaseAutoFill } from '../pickers/AutoFill/BaseAutoFill'; +import { IBaseAutoFillProps } from '../pickers/AutoFill/BaseAutoFill.Props'; +import { + autobind, + BaseComponent, + css, + divProperties, + findIndex, + getId, + getNativeProps, + KeyCodes +} from '../../Utilities'; +import { ISelectableOption, SelectableOptionMenuItemType } from '../../utilities/selectableOption/SelectableOption.Props'; +import * as stylesImport from './ComboBox.scss'; +const styles: any = stylesImport; + +export interface IComboBoxState { + + // The open state + isOpen?: boolean; + + // The currently selected index (-1 if no index is selected) + selectedIndex?: number; + + // The focused state of the comboBox + focused?: boolean; + + // This value is used for the autocomplete hint value + suggestedDisplayValue?: string; + + // The options currently available for the callout + currentOptions?: ISelectableOption[]; + + // when taking input, this will store the index the + // that the options input matches (-1 if no input or match) + currentPendingValueValidIndex?: number; + + // when taking input, this will store + // the actual text that is being entered + currentPendingValue: string; +} + +enum SearchDirection { + backward = -1, + none = 0, + forward = 1 +} + +export class ComboBox extends BaseComponent { + + public static defaultProps = { + options: [], + allowFreeform: false, + autoComplete: true, + buttonIconProps: { iconName: 'chevronDown' } + }; + + public refs: { + [key: string]: React.ReactInstance, + root: HTMLElement + }; + + // The input aspect of the comboBox + private _comboBox: BaseAutoFill; + + // The wrapping div of the input and button + private _comboBoxWrapper: HTMLDivElement; + + // The callout element + private _comboBoxMenu: HTMLElement; + + // The menu item element that is currently selected + private _selectedElement: HTMLElement; + + // The base id for the comboBox + private _id: string; + + // This is used to clear any pending autocomplete + // text (used when autocomplete is true and allowFreeform is false) + private readonly _readOnlyPendingAutoCompleteTimeout: number = 1000 /* ms */; + + // After a character is inserted when autocomplete is true and + // allowFreeform is false, remember the task that will clear + // the pending string of characters + private _lastReadOnlyAutoCompleteChangeTimeoutId: number; + + // Promise used when resolving the comboBox options + private _currentPromise: PromiseLike; + + // The current visible value sent to the auto fill on render + private _currentVisibleValue; + + constructor(props?: IComboBoxProps) { + super(props); + + this._warnMutuallyExclusive({ + 'defaultSelectedKey': 'selectedKey', + 'value': 'defaultSelectedKey', + 'selectedKey': 'value' + }); + + this._id = props.id || getId('ComboBox'); + + let selectedKey = props.defaultSelectedKey !== undefined ? props.defaultSelectedKey : props.selectedKey; + this._lastReadOnlyAutoCompleteChangeTimeoutId = -1; + + let index: number = this._getSelectedIndex(props.options, selectedKey); + + this.state = { + isOpen: false, + selectedIndex: index, + focused: false, + suggestedDisplayValue: '', + currentOptions: this.props.options, + currentPendingValueValidIndex: -1, + currentPendingValue: '' + }; + } + + public componentDidMount() { + // hook up resolving the options if needed on focus + this._events.on(this._comboBoxWrapper, 'focus', this._onResolveOptions, true); + } + + public componentWillReceiveProps(newProps: IComboBoxProps) { + // In controlled component usage where selectedKey is provided, update the selectedIndex + // and currentOptions state if the key or options change + if (newProps.selectedKey !== undefined && + (newProps.selectedKey !== this.props.selectedKey || newProps.options !== this.props.options)) { + let index: number = this._getSelectedIndex(newProps.options, newProps.selectedKey); + this.setState({ + selectedIndex: index, + currentOptions: newProps.options + }); + } + } + + public componentDidUpdate(prevProps: IComboBoxProps, prevState: IComboBoxState) { + let { + allowFreeform + } = this.props; + let { + isOpen, + focused, + selectedIndex + } = this.state; + + // If we are open, make sure the currently + // selected option is scrolled into view + if (isOpen) { + this._scrollIntoView(); + } + + // If we are open or we are focused but are not the activeElement, + // set focus on the input + if (isOpen || (focused && document.activeElement !== this._comboBox.inputElement)) { + this.focus(); + } + + // If we just opened/closed the menu OR + // updated the selectedIndex with the menu closed OR + // we are focused and are not allowing freeform + // we need to fix up focus and set selection + if (prevState.isOpen !== isOpen || + (!isOpen && prevState.selectedIndex !== selectedIndex) || + (!allowFreeform && focused)) { + this._select(); + } + } + + public componentWillUnmount() { + super.componentWillUnmount(); + + // remove the eventHanlder that was added in componentDidMount + this._events.off(this._comboBoxWrapper); + } + + // Primary Render + public render() { + let id = this._id; + let { + className, + label, + disabled, + ariaLabel, + required, + errorMessage, + onRenderContainer = this._onRenderContainer, + allowFreeform, + autoComplete, + buttonIconProps + } = this.props; + let { isOpen, selectedIndex, focused, suggestedDisplayValue } = this.state; + this._currentVisibleValue = this._getVisibleValue(); + + let divProps = getNativeProps(this.props, divProperties); + + return ( +
+ { label && ( + + ) } +
0 ? styles.wrapperForError : null), + styles.root, className, { + 'is-open': isOpen, + ['is-disabled ' + styles.rootIsDisabled]: disabled, + 'is-required ': required, + [styles.focused]: focused, + [styles.readOnly]: !allowFreeform + } + ) + } > + = 0 ? (id + '-list' + selectedIndex) : null) } + aria-disabled={ disabled } + aria-owns={ (id + '-list') } + spellCheck={ false } + defaultVisibleValue={ this._currentVisibleValue } + suggestedDisplayValue={ suggestedDisplayValue } + updateValueInWillReceiveProps={ this._onUpdateValueInAutoFillWillReceiveProps } + shouldSelectFullInputValueInComponentDidUpdate={ this._onShouldSelectFullInputValueInAutoFillComponentDidUpdate } /> +
+ + { isOpen && ( + onRenderContainer({ ...this.props }, this._onRenderContainer) + ) } + { + errorMessage && +
+ { errorMessage } +
+ } +
+ ); + } + + /** + * Set focus on the input + */ + @autobind + public focus() { + if (this._comboBox) { + this._comboBox.focus(); + } + } + + /** + * componentWillReceiveProps handler for the auto fill component + * Checks/updates the iput value to set, if needed + * @param {IBaseAutoFillProps} defaultVisibleValue - the defaultVisibleValue that got passed + * in to the auto fill's componentWillReceiveProps + * @returns {string} - the updated value to set, if needed + */ + @autobind + private _onUpdateValueInAutoFillWillReceiveProps(): string { + if (this._comboBox === null || this._comboBox === undefined) { + return null; + } + + if (this._currentVisibleValue && this._currentVisibleValue !== '' && this._comboBox.value !== this._currentVisibleValue) { + return this._currentVisibleValue; + } + + return this._comboBox.value; + } + + /** + * componentDidUpdate handler for the auto fill component + * + * @param { string } defaultVisibleValue - the current defaultVisibleValue in the auto fill's componentDidUpdate + * @param { string } suggestedDisplayValue - the current suggestedDisplayValue in the auto fill's componentDidUpdate + * @returns {boolean} - should the full value of the input be selected? + * True if the defaultVisibleValue equals the suggestedDisplayValue, false otherwise + */ + @autobind + private _onShouldSelectFullInputValueInAutoFillComponentDidUpdate(): boolean { + return this._currentVisibleValue === this.state.suggestedDisplayValue; + } + + /** + * Get the correct value to pass to the input + * to show to the user based off of the current props and state + * @returns {string} the value to pass to the input + */ + @autobind + private _getVisibleValue(): string { + let { + value, + allowFreeform, + autoComplete + } = this.props; + let { + selectedIndex, + currentPendingValueValidIndex, + currentOptions, + currentPendingValue, + suggestedDisplayValue + } = this.state; + + // If the user passed is a value prop, use that + if (value) { + return value; + } + + let index = selectedIndex; + + if (allowFreeform) { + + // If we are allowing freeform and autocomplete is also true + // and we've got a pending value that matches an option, remember + // the matched option's index + if (autoComplete && this._indexWithinBounds(currentOptions, currentPendingValueValidIndex)) { + index = currentPendingValueValidIndex; + } + + // Since we are allowing freeform, if there is currently a nonempty pending value, use that + // otherwise use the index determined above (falling back to '' if we did not get a valid index) + return currentPendingValue !== '' ? currentPendingValue : + (this._indexWithinBounds(currentOptions, index) ? currentOptions[index].text : ''); + } else { + + // If we are not allowing freeform and have a + // valid index that matches the pending value, + // we know we will need some version of the pending value + if (this._indexWithinBounds(currentOptions, currentPendingValueValidIndex)) { + + // If autoComplete is on, return the + // raw pending value, otherwise remember + // the matched option's index + if (autoComplete) { + return currentPendingValue; + } + + index = currentPendingValueValidIndex; + } + + // If we have a valid index then return the text value of that option, + // otherwise return the suggestedDisplayValue + return this._indexWithinBounds(currentOptions, index) ? currentOptions[index].text : suggestedDisplayValue; + } + } + + /** + * Is the index within the bounds of the array? + * @param options - options to check if the index is valid for + * @param index - the index to check + * @returns {boolean} - true if the index is valid for the given options, false otherwise + */ + private _indexWithinBounds(options: ISelectableOption[], index: number): boolean { + return index >= 0 && index < options.length; + } + + /** + * Handler for typing changes on the input + * @param updatedValue - the newly changed value + */ + @autobind + private _onInputChange(updatedValue: string) { + if (this.props.disabled) { + this._handleInputWhenDisabled(null /* event */); + return; + } + + this.props.allowFreeform ? + this._processInputChangeWithFreeform(updatedValue) : + this._processInputChangeWithoutFreeform(updatedValue); + } + + /** + * Process the new input's new value when the comboBox + * allows freeform entry + * @param updatedValue - the input's newly changed value + */ + private _processInputChangeWithFreeform(updatedValue: string) { + let { + currentOptions + } = this.state; + + // if the new value is empty, nothing needs to be done + if (updatedValue === '') { + return; + } + + // Remember the original value and then, + // make the value lowercase for comparison + let originalUpdatedValue: string = updatedValue; + updatedValue = updatedValue.toLocaleLowerCase(); + + let newSuggestedDisplayValue = ''; + let newCurrentPendingValueValidIndex = -1; + + // If autoComplete is on, attempt to find a match from the available options + if (this.props.autoComplete) { + + // If autoComplete is on, attempt to find a match where the text of an option starts with the updated value + let items = currentOptions.map((item, index) => { return { ...item, index }; }).filter((option) => option.itemType !== SelectableOptionMenuItemType.Header && option.itemType !== SelectableOptionMenuItemType.Divider).filter((option) => option.text.toLocaleLowerCase().indexOf(updatedValue) === 0); + if (items.length > 0) { + // If the user typed out the complete option text, we don't need any suggested display text anymore + newSuggestedDisplayValue = items[0].text.toLocaleLowerCase() !== updatedValue ? items[0].text : ''; + + // remember the index of the match we found + newCurrentPendingValueValidIndex = items[0].index; + } + } else { + + // If autoComplete is off, attempt to find a match only when the value is exactly equal to the text of an option + let items = currentOptions.map((item, index) => { return { ...item, index }; }).filter((option) => option.itemType !== SelectableOptionMenuItemType.Header && option.itemType !== SelectableOptionMenuItemType.Divider).filter((option) => option.text.toLocaleLowerCase() === updatedValue); + + // if we fould a match remember the index + if (items.length === 1) { + newCurrentPendingValueValidIndex = items[0].index; + } + } + + // Set the updated state + this._setPendingInfo(originalUpdatedValue, newCurrentPendingValueValidIndex, newSuggestedDisplayValue); + } + + /** + * Process the new input's new value when the comboBox + * does not allow freeform entry + * @param updatedValue - the input's newly changed value + */ + private _processInputChangeWithoutFreeform(updatedValue: string) { + let { + currentPendingValue, + currentPendingValueValidIndex, + currentOptions, + selectedIndex + } = this.state; + + if (this.props.autoComplete) { + + // If autoComplete is on while allow freeform is off, + // we will remember the keypresses and build up a string to attempt to match + // as long as characters are typed within a the timeout span of each other, + // otherwise we will clear the string and start building a new one on the next keypress. + // Also, only do this processing if we have a non-empty value + if (updatedValue !== '') { + + // If we have a pending autocomplete clearing task, + // we know that the user is typing with keypresses happening + // within the timeout of each other so remove the clearing task + // and continue building the pending value with the udpated value + if (this._lastReadOnlyAutoCompleteChangeTimeoutId > 0) { + this._async.clearTimeout(this._lastReadOnlyAutoCompleteChangeTimeoutId); + this._lastReadOnlyAutoCompleteChangeTimeoutId = -1; + updatedValue = currentPendingValue + updatedValue; + } + + let originalUpdatedValue: string = updatedValue; + updatedValue = updatedValue.toLocaleLowerCase(); + + // If autoComplete is on, attempt to find a match where the text of an option starts with the updated value + let items = currentOptions.map((item, index) => { return { ...item, index }; }).filter((option) => option.itemType !== SelectableOptionMenuItemType.Header && option.itemType !== SelectableOptionMenuItemType.Divider).filter((option) => option.text.toLocaleLowerCase().indexOf(updatedValue) === 0); + + // If we found a match, udpdate the state + if (items.length > 0) { + this._setPendingInfo(originalUpdatedValue, items[0].index, items[0].text); + } + + // Schedule a timeout to clear the pending value after the timeout span + this._lastReadOnlyAutoCompleteChangeTimeoutId = + this._async.setTimeout( + () => { this._lastReadOnlyAutoCompleteChangeTimeoutId = -1; }, + this._readOnlyPendingAutoCompleteTimeout + ); + return; + } + } + + // If we get here, either autoComplete is on or we did not find a match with autoComplete on. + // Remember we are not allowing freeform, so at this point, if we have a pending valid value index + // use that; otherwise use the selectedIndex + let index = currentPendingValueValidIndex >= 0 ? currentPendingValueValidIndex : selectedIndex; + + // Since we are not allowing freeform, we need to + // set both the pending and suggested values/index + // to allow us to select all content in the input to + // give the illusion that we are readonly (e.g. freeform off) + this._setPendingInfoFromIndex(index); + } + + /** + * Walk along the options starting at the index, stepping by the delta (positive or negative) + * looking for the next valid selectable index (e.g. skipping headings and dividers) + * @param index - the index to get the next selectable index from + * @param delta - optional delta to step by when finding the next index, defaults to 0 + * @returns {number} - the next valid selectable index. If the new index is outside of the bounds, + * it will snap to the edge of the options array. If delta == 0 and the given index is not selectable + */ + private _getNextSelectableIndex(index: number, searchDirection: SearchDirection): number { + let { currentOptions } = this.state; + + let newIndex = index + searchDirection; + + newIndex = Math.max(0, Math.min(currentOptions.length - 1, newIndex)); + + let option: ISelectableOption = currentOptions[newIndex]; + + // attempt to skip headers and dividers + if ((option.itemType === SelectableOptionMenuItemType.Header || + option.itemType === SelectableOptionMenuItemType.Divider)) { + + // Should we continue looking for an index to select? + if (searchDirection !== SearchDirection.none && + ((newIndex !== 0 && searchDirection < SearchDirection.none) || + (newIndex !== currentOptions.length - 1 && searchDirection > SearchDirection.none))) { + newIndex = this._getNextSelectableIndex(newIndex, searchDirection); + } else { + // If we cannot perform a useful search just return the index we were given + return index; + } + } + + // We have the next valid selectable index, return it + return newIndex; + } + + /** + * Set the selected index. Note, this is + * the "real" selected index, not the pending selected index + * @param index - the index to set (or the index to set from if a search direction is provided) + * @param searchDirection - the direction to search along the options from the given index + */ + private _setSelectedIndex(index: number, searchDirection: SearchDirection = SearchDirection.none) { + let { onChanged } = this.props; + let { selectedIndex, currentOptions } = this.state; + + // Find the next selectable index, if searchDirection is none + // we will get our starting index back + index = this._getNextSelectableIndex(index, searchDirection); + + // Are we at a new index? If so, update the state, otherwise + // there is nothing to do + if (index !== selectedIndex) { + let option: ISelectableOption = currentOptions[index]; + + // Set the selected option + this.setState({ + selectedIndex: index + }); + + // Did the creator give us an onChanged callback? + if (onChanged) { + onChanged(option, index); + } + + // if we have a new selected index, + // clear all of the pending info + this._clearPendingInfo(); + } + } + + /** + * Focus (and select) the content of the input + * and set the focused state + */ + @autobind + private _select() { + this._comboBox.inputElement.select(); + + if (!this.state.focused) { + this.setState({ focused: true }); + } + } + + /** + * Callback issued when the options should be resolved, if they have been updated or + * if they need to be passed in the first time. This only does work if an onResolveOptions + * callback was passed in + */ + @autobind + private _onResolveOptions() { + if (this.props.onResolveOptions) { + + // get the options + let newOptions = this.props.onResolveOptions({ ...this.state.currentOptions }); + + // Check to see if the returned value is an array, if it is update the state + // If the returned value is not an array then check to see if it's a promise or PromiseLike. If it is then resolve it asynchronously. + if (Array.isArray(newOptions)) { + this.setState({ + currentOptions: newOptions + }); + } else if (newOptions && newOptions.then) { + + // Ensure that the promise will only use the callback if it was the most recent one + // and update the state when the promise returns + let promise: PromiseLike = this._currentPromise = newOptions; + promise.then((newOptionsFromPromise: ISelectableOption[]) => { + if (promise === this._currentPromise) { + this.setState({ + currentOptions: newOptionsFromPromise + }); + } + }); + } + } + } + + /** + * OnBlur handler. Set the focused state to false + * and submit any pending value + */ + @autobind + private _onBlur() { + if (this.state.focused) { + this.setState({ focused: false }); + this._submitPendingValue(); + } + } + + /** + * Submit a pending value if there is one + */ + private _submitPendingValue() { + let { + onChanged, + allowFreeform + } = this.props; + let { + currentPendingValue, + currentPendingValueValidIndex, + currentOptions + } = this.state; + + // If we allow freeform and we have a pending value, we + // need to handle that + if (allowFreeform && currentPendingValue !== '') { + + // Check to see if the user typed an exact match + if (currentPendingValueValidIndex >= 0) { + let pendingOptionText: string = currentOptions[currentPendingValueValidIndex].text.toLocaleLowerCase(); + + // By exact match, that means: our pending value is the same as the the pending option text OR + // the peding option starts with the pending value and we have an "autoComplete" selection + // where the total lenght is equal to pending option length; update the state + if (currentPendingValue.toLocaleLowerCase() === pendingOptionText || + (pendingOptionText.indexOf(currentPendingValue.toLocaleLowerCase()) === 0 && + this._comboBox.isValueSelected && + currentPendingValue.length + (this._comboBox.selectionEnd - this._comboBox.selectionStart) === pendingOptionText.length)) { + this._setSelectedIndex(currentPendingValueValidIndex); + this._clearPendingInfo(); + return; + } + } + + // Create a new option + let newOption: ISelectableOption = { key: currentPendingValue, text: currentPendingValue }; + let newOptions: ISelectableOption[] = [...currentOptions, newOption]; + let newSelectedIndex: number = this._getSelectedIndex(newOptions, currentPendingValue); + + this.setState({ + currentOptions: newOptions, + selectedIndex: newSelectedIndex + }); + + if (onChanged) { + onChanged(null, null, currentPendingValue); + } + } else if (currentPendingValueValidIndex >= 0) { + // Since we are not allowing freeform, we must have a matching + // to be able to update state + this._setSelectedIndex(currentPendingValueValidIndex); + } + + // Finally, clear the pending info + this._clearPendingInfo(); + } + + // Render Callout container and pass in list + @autobind + private _onRenderContainer(props: IComboBoxProps): JSX.Element { + let { + onRenderList = this._onRenderList, + calloutProps + } = props; + + return ( + +
+ { onRenderList({ ...props }, this._onRenderList) } +
+
+ ); + } + + // Render List of items + @autobind + private _onRenderList(props: IComboBoxProps): JSX.Element { + let { + onRenderItem = this._onRenderItem + } = this.props; + + let id = this._id; + let { selectedIndex } = this.state; + + return ( +
+ { this.state.currentOptions.map((item, index) => onRenderItem({ ...item, index }, this._onRenderItem)) } +
+ ); + } + + // Render items + @autobind + private _onRenderItem(item: ISelectableOption): JSX.Element { + switch (item.itemType) { + case SelectableOptionMenuItemType.Divider: + return this._renderSeparator(item); + case SelectableOptionMenuItemType.Header: + return this._renderHeader(item); + default: + return this._renderOption(item); + } + } + + // Render separator + private _renderSeparator(item: ISelectableOption): JSX.Element { + let { index, key } = item; + if (index > 0) { + return
; + } + return null; + } + + private _renderHeader(item: ISelectableOption): JSX.Element { + let { onRenderOption = this._onRenderOption } = this.props; + return ( +
+ { onRenderOption(item, this._onRenderOption) } +
); + } + + // Render menu item + @autobind + private _renderOption(item: ISelectableOption): JSX.Element { + let { onRenderOption = this._onRenderOption } = this.props; + let id = this._id; + let isSelected: boolean = this._isOptionSelected(item.index); + return ( + this._onItemClick(item.index) } + role='option' + aria-selected={ isSelected ? 'true' : 'false' } + ariaLabel={ item.text } + > { + { onRenderOption(item, this._onRenderOption) } + + } + + ); + } + + /** + * Use the current valid pending index if it exists OR + * we do not have a valid index and we currently have a pending input value, + * otherwise use the selected index + * */ + private _isOptionSelected(index: number): boolean { + let { + currentPendingValueValidIndex, + currentPendingValue, + selectedIndex + } = this.state; + return ((currentPendingValueValidIndex >= 0 || currentPendingValue !== '') ? + currentPendingValueValidIndex === index : selectedIndex === index); + } + + /** + * Scroll the selected element into view + */ + private _scrollIntoView() { + if (this._selectedElement) { + let alignToTop = true; + if (this._comboBoxMenu.offsetParent) { + let scrollableParentRect = this._comboBoxMenu.offsetParent.getBoundingClientRect(); + let selectedElementRect = this._selectedElement.offsetParent.getBoundingClientRect(); + + if (scrollableParentRect.top + scrollableParentRect.height <= selectedElementRect.top) { + alignToTop = false; + } + } + + this._selectedElement.offsetParent.scrollIntoView(alignToTop); + } + } + + // Render content of item + @autobind + private _onRenderOption(item: ISelectableOption): JSX.Element { + return { item.text }; + } + + /** + * Click handler for the menu items + * to select the item and also close the menu + * @param index - the index of the item that was clicked + */ + private _onItemClick(index) { + this._setSelectedIndex(index); + this.setState({ + isOpen: false + }); + } + + /** + * Handles dismissing (cancelling) the menu + */ + @autobind + private _onDismiss() { + + // reset the selected index + // to the last valud state + this._resetSelectedIndex(); + + // close the menu and focus the input + this.setState({ isOpen: false }); + this._comboBox.focus(); + } + + /** + * Get the index of the option that is marked as selected + * @param options - the comboBox options + * @param selectedKey - the known selected key to find + * @returns {number} - the index of the selected option, -1 if not found + */ + private _getSelectedIndex(options: ISelectableOption[], selectedKey: string | number): number { + return findIndex(options, (option => (option.isSelected || option.selected || (selectedKey != null) && option.key === selectedKey))); + } + + /** + * Reset the selected index by clearing the + * input (of any pending text), clearing the pending state, + * and setting the suggested display value to the last + * selected state text + */ + private _resetSelectedIndex() { + let { + selectedIndex, + currentOptions + } = this.state; + this._comboBox.clear(); + this._clearPendingInfo(); + + if (selectedIndex > 0 && selectedIndex < currentOptions.length) { + this.setState({ + suggestedDisplayValue: currentOptions[selectedIndex].text + }); + } + } + + /** + * Clears the pending info state + */ + private _clearPendingInfo() { + this._setPendingInfo('' /* suggestedDisplayValue */, -1 /* currentPendingValueValidIndex */, '' /* currentPendingValue */); + } + + /** + * Set the pending info + * @param currentPendingValue - new pending value to set + * @param currentPendingValueValidIndex - new pending value index to set + * @param suggestedDisplayValue - new suggest display value to set + */ + private _setPendingInfo(currentPendingValue: string, currentPendingValueValidIndex: number, suggestedDisplayValue) { + this.setState({ + currentPendingValue: currentPendingValue, + currentPendingValueValidIndex: currentPendingValueValidIndex, + suggestedDisplayValue: suggestedDisplayValue + }); + } + + /** + * Set the pending info from the given index + * @param index - the index to set the pending info from + */ + private _setPendingInfoFromIndex(index: number) { + let { + currentOptions + } = this.state; + + if (index > 0 && index < currentOptions.length) { + let option = currentOptions[index]; + this._setPendingInfo(option.text, index, option.text); + } else { + this._clearPendingInfo(); + } + + } + + /** + * Sets either the pending info or the + * selected index depending of if the comboBox is open + * @param index - the index to search from + * @param searchDirection - the direction to search + */ + private _setInfoForIndexAndDirection(index: number, searchDirection: SearchDirection) { + let { + isOpen, + selectedIndex + } = this.state; + + if (isOpen) { + index = this._getNextSelectableIndex(index, searchDirection); + this._setPendingInfoFromIndex(index); + } else { + this._setSelectedIndex(selectedIndex, searchDirection); + } + } + + /** + * Handle keydown on the input + * @param ev - The keyboard event that was fired + */ + @autobind + private _onInputKeyDown(ev: React.KeyboardEvent) { + let { + disabled, + allowFreeform, + autoComplete + } = this.props; + let { + isOpen, + currentPendingValueValidIndex, + selectedIndex, + currentOptions + } = this.state; + + if (disabled) { + this._handleInputWhenDisabled(ev); + return; + } + + let index = currentPendingValueValidIndex >= 0 ? currentPendingValueValidIndex : selectedIndex; + + switch (ev.which) { + case KeyCodes.enter: + // On enter submit the pending value + this._submitPendingValue(); + + // if we are open or + // if we are not allowing freeform or + // our we have no pending value + // and no valid pending index + // flip the open state + if ((isOpen || + ((!allowFreeform || + this.state.currentPendingValue === undefined || + this.state.currentPendingValue === null || + this.state.currentPendingValue.length <= 0) && + this.state.currentPendingValueValidIndex < 0))) { + this.setState({ + isOpen: !isOpen + }); + } + + // Allow TAB to propigate + if (ev.which === KeyCodes.tab) { + return; + } + break; + + case KeyCodes.tab: + // On enter submit the pending value + this._submitPendingValue(); + + // If we are not allowing freeform + // or the comboBox is open, flip the open state + if (isOpen) { + this.setState({ + isOpen: !isOpen + }); + } + + // Allow TAB to propigate + return; + + case KeyCodes.escape: + // reset the selected index + this._resetSelectedIndex(); + + // Close the menu if opened + if (isOpen) { + this.setState({ + isOpen: false + }); + } + break; + + case KeyCodes.up: + // Go to the previous option + this._setInfoForIndexAndDirection(index, SearchDirection.backward); + break; + + case KeyCodes.down: + // Expand the comboBox on ALT + DownArrow + if (ev.altKey || ev.metaKey) { + this.setState({ isOpen: true }); + } else { + // Got to the next option + this._setInfoForIndexAndDirection(index, SearchDirection.forward); + } + break; + + case KeyCodes.home: + case KeyCodes.end: + if (allowFreeform) { + return; + } + + // Set the initial values to respond to HOME + // which goes to the first selectable option + index = -1; + let directionToSearch = SearchDirection.forward; + + // If end, update the values to respond to END + // which goes to the last selectable option + if (ev.which === KeyCodes.end) { + index = currentOptions.length; + directionToSearch = SearchDirection.backward; + } + + this._setInfoForIndexAndDirection(index, directionToSearch); + break; + + case KeyCodes.space: + // event handled in _onComboBoxKeyUp + if (!allowFreeform && !autoComplete) { + break; + } + + default: + + // are we processing a function key? if so bail out + if (ev.which >= 112 /* F1 */ && ev.which <= 123 /* F12 */) { + return; + } + + // If we get here and we got either and ALT key + // or meta key and we are current open, let's close the menu + if ((ev.altKey || ev.metaKey) && isOpen) { + this.setState({ isOpen: !isOpen }); + } + + // If we are not allowing freeform and + // allowing autoComplete, handle the input here + // since we have marked the input as readonly + if (!allowFreeform && autoComplete) { + this._onInputChange(String.fromCharCode(ev.which)); + break; + } + + // allow the key to propigate by default + return; + } + + ev.stopPropagation(); + ev.preventDefault(); + } + + /** + * Handle keyup on the input + * @param ev - the keyboard event that was fired + */ + @autobind + private _onInputKeyUp(ev: React.KeyboardEvent) { + let { + disabled, + allowFreeform, + autoComplete + } = this.props; + + if (disabled) { + this._handleInputWhenDisabled(ev); + return; + } + + switch (ev.which) { + case KeyCodes.space: + // If we are not allowing freeform and are not autoComplete + // make space expand/collapse the comboBox + // and allow the event to propagate + if (!allowFreeform && !autoComplete) { + this.setState({ + isOpen: !this.state.isOpen + }); + return; + } + break; + + default: + return; + } + + ev.stopPropagation(); + ev.preventDefault(); + } + + /** + * Handle dismissing the menu and + * eating the required key event when disabled + * @param ev - the keyboard event that was fired + */ + private _handleInputWhenDisabled(ev: React.KeyboardEvent) { + // If we are disabled, close the menu (if needed) + // and eat all keystokes other than TAB or ESC + if (this.props.disabled) { + if (this.state.isOpen) { + this.setState({ isOpen: false }); + } + + // When disabled stop propagation and prevent default + // of the event unless we have a tab, escape, or function key + if (ev !== null && + ev.which !== KeyCodes.tab && + ev.which !== KeyCodes.escape && + (ev.which < 112 /* F1 */ || ev.which > 123 /* F12 */)) { + ev.stopPropagation(); + ev.preventDefault(); + } + } + } + + /** + * Click handler for the button of the comboBox + * and the input when not allowing freeform. This + * toggles the expand/collapse state of the comboBox (if enbled) + */ + @autobind + private _onComboBoxClick() { + let { disabled } = this.props; + let { isOpen } = this.state; + + if (!disabled) { + this.setState({ + isOpen: !isOpen + }); + } + } +} diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.visualtest.ts b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.visualtest.ts new file mode 100644 index 00000000000000..300231665fe215 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.visualtest.ts @@ -0,0 +1,29 @@ +import { Casper } from '../../visualtest/PhantomCssInterface'; +import { baseUrl } from '../../common/VisualTest'; +import { defaultScreenshot, testRunner, mouseClickScreenshot, mouseMoveScreenshot } from '../../visualtest/RunVisualTest'; +import { IRunVisualTest } from '../../visualtest/IRunVisualTest'; +declare var casper: Casper; +let componentIds: IRunVisualTest[] = []; + +componentIds.push( + { + selector: '.' + 'ms-ComboBox-Input', + fileName: 'comboBox_input', + imageSelector: '.' + 'ms-ComboBox-Container', + commands: [defaultScreenshot, mouseClickScreenshot, mouseMoveScreenshot] + }, + { + selector: '.' + 'ms-ComboBox-Button', + fileName: 'comboBox_button', + imageSelector: '.' + 'ms-comboBox-Container', + commands: [defaultScreenshot, mouseClickScreenshot, mouseMoveScreenshot] + }, +); + +casper. + start(baseUrl + 'comboBox'). + then(() => { + testRunner(componentIds); + }); + +casper.run(() => { casper.test.done(); }); \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/ComboBoxPage.tsx b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBoxPage.tsx new file mode 100644 index 00000000000000..2179418e30cd43 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBoxPage.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { + ExampleCard, + IComponentDemoPageProps, + ComponentPage, + PropertiesTableSet +} from '@uifabric/example-app-base'; +import { ComboBoxBasicExample } from './examples/ComboBox.Basic.Example'; + +const ComboBoxBasicExampleCode = require('!raw-loader!office-ui-fabric-react/src/components/ComboBox/examples/ComboBox.Basic.Example.tsx') as string; + +export class ComboBoxPage extends React.Component { + public render() { + return ( + + + + } + propertiesTables={ + ('!raw-loader!office-ui-fabric-react/src/components/ComboBox/ComboBox.Props.ts') + ] } + /> + } + overview={ +
+

+ A ComboBox is a list in which the selected item is always visible, and the others are visible on demand by clicking a drop-down button or by typing in the input (unless allowFreeform and autoComplete are both false). They are used to simplify the design and make a choice within the UI. When closed, only the selected item is visible. When users click the drop-down button, all the options become visible. To change the value, users open the list and click another value or use the arrow keys (up and down) to select a new value. When collapsed if autoComplete and/or allowFreeform are true, the user can select a new value by typing. +

+
+ } + bestPractices={ +
+ } + dos={ +
+
    +
  • Use a ComboBox when there are multiple choices that can be collapsed under one title. Or if the list of items is long or when space is constrained.
  • +
  • ComboBoxs contain shortened statements or words.
  • +
  • Use a ComboBox when the selected option is more important than the alternatives (in contrast to radio buttons where all the choices are visible putting more emphasis on the other options).
  • +
+
+ } + donts={ +
+ } + related={ + Fabric JS + } + isHeaderVisible={ this.props.isHeaderVisible }> +
+ ); + } +} diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/ComboBoxPage.visualtest.tsx b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBoxPage.visualtest.tsx new file mode 100644 index 00000000000000..b7e35f4416d958 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBoxPage.visualtest.tsx @@ -0,0 +1,29 @@ +import { ComboBox } from './index'; +/* tslint:disable:no-unused-variable */ +import * as React from 'react'; +/* tslint:enable:no-unused-variable */ +export default class ComboBoxVPage extends React.Component { + public render() { + return
+ +
; + } +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/examples/ComboBox.Basic.Example.scss b/packages/office-ui-fabric-react/src/components/ComboBox/examples/ComboBox.Basic.Example.scss new file mode 100644 index 00000000000000..f71fcb3e8911cd --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ComboBox/examples/ComboBox.Basic.Example.scss @@ -0,0 +1,15 @@ +:global { + .ms-ComboBoxBasicExample { + max-width: 300px; + } + .ms-ComboBox-optionText { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0px; + max-width: 100%; + word-wrap: break-word; + overflow-wrap: break-word; + margin: 1px; + } +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/examples/ComboBox.Basic.Example.tsx b/packages/office-ui-fabric-react/src/components/ComboBox/examples/ComboBox.Basic.Example.tsx new file mode 100644 index 00000000000000..dc221e04c6a93c --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ComboBox/examples/ComboBox.Basic.Example.tsx @@ -0,0 +1,234 @@ +import * as React from 'react'; +import { ComboBox } from 'office-ui-fabric-react/lib/ComboBox'; +import './ComboBox.Basic.Example.scss'; +import { + assign, + autobind +} from 'office-ui-fabric-react/lib/Utilities'; +import { ISelectableOption, SelectableOptionMenuItemType } from 'office-ui-fabric-react/lib/utilities/selectableOption/SelectableOption.Props'; + +export class ComboBoxBasicExample extends React.Component { + private _testOptions = + [{ key: 'Header', text: 'Theme Fonts', itemType: SelectableOptionMenuItemType.Header }, + { key: 'A', text: 'Arial Black' }, + { key: 'B', text: 'Time New Roman' }, + { key: 'C', text: 'Comic Sans MS' }, + { key: 'divider_2', text: '-', itemType: SelectableOptionMenuItemType.Divider }, + { key: 'Header1', text: 'Other Options', itemType: SelectableOptionMenuItemType.Header }, + { key: 'D', text: 'Option d' }, + { key: 'E', text: 'Option e' }, + { key: 'F', text: 'Option f' }, + { key: 'G', text: 'Option g' }, + { key: 'H', text: 'Option h' }, + { key: 'I', text: 'Option i' }, + { key: 'J', text: 'Option j' }, + ]; + + private _fontMapping: { [key: string]: string } = { + ['Arial Black']: '"Arial Black", "Arial Black_MSFontService", sans-serif', + ['Time New Roman']: '"Times New Roman", "Times New Roman_MSFontService", serif', + ['Comic Sans MS']: '"Comic Sans MS", "Comic Sans MS_MSFontService", fantasy', + ['Calibri']: 'Calibri, Calibri_MSFontService, sans-serif' + }; + + constructor() { + super(); + this.state = { + options: [], + selectedOptionKey: null, + value: 'Calibri' + }; + } + + public render() { + let { options, selectedOptionKey, value } = this.state; + + return ( +
+ + + + + + + + + + + + + + { value ? + + : + + } + +
+ + ); + } + + // Render content of item + @autobind + private _onRenderFontOption(item: ISelectableOption): JSX.Element { + + if (item.itemType === SelectableOptionMenuItemType.Header || + item.itemType === SelectableOptionMenuItemType.Divider) { + return { item.text }; + } + + let fontFamily = this._fontMapping[item.text]; + + if (fontFamily === null || fontFamily === undefined) { + let newFontFamily: string = item.text; + if (newFontFamily.indexOf(' ') > -1) { + newFontFamily = '"' + newFontFamily + '"'; + } + + // add a default fallback font + newFontFamily += ',"Segoe UI",Tahoma,Sans-Serif'; + + this._fontMapping = assign({}, this._fontMapping, { [fontFamily]: newFontFamily }); + fontFamily = newFontFamily; + } + + return { item.text }; + } + + @autobind + private _getOptions(currentOptions: ISelectableOption[]): ISelectableOption[] { + + if (this.state.options.length > 0) { + return this.state.options; + } + + let newOptions = + [ + { key: 'Header', text: 'Theme Fonts', itemType: SelectableOptionMenuItemType.Header }, + { key: 'A', text: 'Arial Black', fontFamily: '"Arial Black", "Arial Black_MSFontService", sans-serif' }, + { key: 'B', text: 'Time New Roman', fontFamily: '"Times New Roman", "Times New Roman_MSFontService", serif' }, + { key: 'C', text: 'Comic Sans MS', fontFamily: '"Comic Sans MS", "Comic Sans MS_MSFontService", fantasy' }, + { key: 'C1', text: 'Calibri', fontFamily: 'Calibri, Calibri_MSFontService, sans-serif' }, + { key: 'divider_2', text: '-', itemType: SelectableOptionMenuItemType.Divider }, + { key: 'Header1', text: 'Other Options', itemType: SelectableOptionMenuItemType.Header }, + { key: 'D', text: 'Option d' }, + { key: 'E', text: 'Option e' }, + { key: 'F', text: 'Option f' }, + { key: 'G', text: 'Option g' }, + { key: 'H', text: 'Option h' }, + { key: 'I', text: 'Option i' }, + { key: 'J', text: 'Option j' } + ]; + this.setState({ + options: newOptions, + selectedOptionKey: 'C1', + value: null + }); + + return newOptions; + } + + @autobind + private _onChanged(option: ISelectableOption, index: number, value: string) { + if (option !== null) { + this.setState({ + selectedOptionKey: option.key, + value: null + }); + } else if (index !== null && index >= 0 && index < this.state.options.length) { + this.setState({ + selectedOptionKey: this.state.options[index].key, + value: null + }); + } else if (value !== null) { + let newOption: ISelectableOption = { key: value, text: value }; + + this.setState({ + options: [...this.state.options, newOption], + selectedOptionKey: newOption.key, + value: null + }); + } + } +} diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/index.ts b/packages/office-ui-fabric-react/src/components/ComboBox/index.ts new file mode 100644 index 00000000000000..fddc1b1ca5ba75 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/ComboBox/index.ts @@ -0,0 +1,2 @@ +export * from './ComboBox'; +export * from './ComboBox.Props'; diff --git a/packages/office-ui-fabric-react/src/components/Dropdown/Dropdown.Props.ts b/packages/office-ui-fabric-react/src/components/Dropdown/Dropdown.Props.ts index a28fafc3d552f6..2f62833c2eec0c 100644 --- a/packages/office-ui-fabric-react/src/components/Dropdown/Dropdown.Props.ts +++ b/packages/office-ui-fabric-react/src/components/Dropdown/Dropdown.Props.ts @@ -1,66 +1,20 @@ -import * as React from 'react'; import { IRenderFunction } from '../../Utilities'; import { Dropdown } from './Dropdown'; -import { ICalloutProps } from '../../Callout'; +import { ISelectableOption } from '../../utilities/selectableOption/SelectableOption.Props'; +import { ISelectableDroppableTextProps } from '../../utilities/selectableOption/SelectableDroppableText.Props'; -export enum DropdownMenuItemType { - Normal = 0, - Divider = 1, - Header = 2 -} +export { SelectableOptionMenuItemType as DropdownMenuItemType } from '../../utilities/selectableOption/SelectableOption.Props'; export interface IDropdown { } -export interface IDropdownProps extends React.Props { - /** - * Optional callback to access the IDropdown interface. Use this instead of ref for accessing - * the public methods and properties of the component. - */ - componentRef?: (component: IDropdown) => void; - - /** - * Descriptive label for the Dropdown - */ - label?: string; - +export interface IDropdownProps extends ISelectableDroppableTextProps { /** * Input placeholder text. Displayed until option is selected. */ placeHolder?: string; - /** - * Aria Label for the Dropdown for screen reader users. - */ - ariaLabel?: string; - - /** - * Id of the drop down - */ - id?: string; - - /** - * If provided, additional class name to provide on the root element. - */ - className?: string; - - /** - * The key that will be initially used to set a selected item. - */ - defaultSelectedKey?: string | number; - - /** - * The key of the selected item. If you provide this, you must maintain selection - * state by observing onChange events and passing a new value in when changed. - */ - selectedKey?: string | number; - - /** - * Collection of options for this Dropdown - */ - options?: IDropdownOption[]; - /** * Callback issues when the selected option changes */ @@ -76,82 +30,14 @@ export interface IDropdownProps extends React.Props { */ onRenderTitle?: IRenderFunction; - /** - * Optional custom renderer for the dropdown container - */ - onRenderContainer?: IRenderFunction; - - /** - * Optional custom renderer for the dropdown list - */ - onRenderList?: IRenderFunction; - - /** - * Optional custom renderer for the dropdown options - */ - onRenderItem?: IRenderFunction; - - /** - * Optional custom renderer for the dropdown option content - */ - onRenderOption?: IRenderFunction; - - /** - * Whether or not the Dropdown is disabled. - */ - disabled?: boolean; - - /** - * Whether or not the Dropdown is required. - */ - required?: boolean; - - /** - * Custom properties for Dropdown's Callout used to render options. - */ - calloutProps?: ICalloutProps; - /** * Deprecated at v0.52.0, use 'disabled' instead. * @deprecated */ isDisabled?: boolean; - - /** - * Descriptive label for the Dropdown Error Message - */ - errorMessage?: string; } -export interface IDropdownOption { - /** - * Arbitrary string associated with this option. - */ - key: string | number; - - /** - * Text to render for this option - */ - text: string; - - /** - * Text to render for this option - */ - itemType?: DropdownMenuItemType; - - /** - * Index for this option - */ - index?: number; - - /** - * The aria label for the dropdown option. If not present, the `text` will be used. - */ - ariaLabel?: string; - - /** If option is selected. */ - selected?: boolean; - +export interface IDropdownOption extends ISelectableOption { /** * Data available to custom onRender functions. */ diff --git a/packages/office-ui-fabric-react/src/components/Dropdown/Dropdown.tsx b/packages/office-ui-fabric-react/src/components/Dropdown/Dropdown.tsx index e818ad5583f0a4..96f673c8760462 100644 --- a/packages/office-ui-fabric-react/src/components/Dropdown/Dropdown.tsx +++ b/packages/office-ui-fabric-react/src/components/Dropdown/Dropdown.tsx @@ -17,6 +17,7 @@ import { findIndex, getId } from '../../Utilities'; +import { SelectableOptionMenuItemType } from '../../utilities/selectableOption/SelectableOption.Props'; import * as stylesImport from './Dropdown.scss'; const styles: any = stylesImport; @@ -330,9 +331,9 @@ export class Dropdown extends BaseComponent string; + + /** + * Handler for checking if the full value of the input should + * be seleced in componentDidUpdate + * + * @returns {boolean} - should the full value of the input be selected? + */ + shouldSelectFullInputValueInComponentDidUpdate?: () => boolean; + } \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/pickers/AutoFill/BaseAutoFill.tsx b/packages/office-ui-fabric-react/src/components/pickers/AutoFill/BaseAutoFill.tsx index 0ee0b1865f67dc..a05af2ce803e2a 100644 --- a/packages/office-ui-fabric-react/src/components/pickers/AutoFill/BaseAutoFill.tsx +++ b/packages/office-ui-fabric-react/src/components/pickers/AutoFill/BaseAutoFill.tsx @@ -29,7 +29,7 @@ export class BaseAutoFill extends BaseComponent 0) { - this._inputElement.setSelectionRange(differenceIndex, suggestedDisplayValue.length, SELECTION_BACKWARD); + + if (shouldSelectFullRange) { + this._inputElement.setSelectionRange(0, suggestedDisplayValue.length, SELECTION_BACKWARD); + } else { + while (differenceIndex < value.length && value[differenceIndex].toLocaleLowerCase() === suggestedDisplayValue[differenceIndex].toLocaleLowerCase()) { + differenceIndex++; + } + if (differenceIndex > 0) { + this._inputElement.setSelectionRange(differenceIndex, suggestedDisplayValue.length, SELECTION_BACKWARD); + } } } } @@ -99,7 +121,7 @@ export class BaseAutoFill extends BaseComponent; } @@ -121,7 +143,11 @@ export class BaseAutoFill extends BaseComponent) { + private _onKeyDown(ev: React.KeyboardEvent) { + if (this.props.onKeyDown) { + this.props.onKeyDown(ev); + } + switch (ev.which) { case KeyCodes.backspace: this._autoFillEnabled = false; diff --git a/packages/office-ui-fabric-react/src/demo/AppDefinition.tsx b/packages/office-ui-fabric-react/src/demo/AppDefinition.tsx index 6a02d00c8537d4..f7deda39eb3fe0 100644 --- a/packages/office-ui-fabric-react/src/demo/AppDefinition.tsx +++ b/packages/office-ui-fabric-react/src/demo/AppDefinition.tsx @@ -52,6 +52,12 @@ export const AppDefinition: IAppDefinition = { name: 'ChoiceGroup', url: '#/examples/choicegroup' }, + { + component: require('../components/ComboBox/ComboBoxPage').ComboBoxPage, + key: 'ComboBox', + name: 'ComboBox', + url: '#/examples/ComboBox' + }, { component: require('../components/CommandBar/CommandBarPage').CommandBarPage, key: 'CommandBar', diff --git a/packages/office-ui-fabric-react/src/index.ts b/packages/office-ui-fabric-react/src/index.ts index 952bd1f6b4afaa..7bc84c48df80ff 100644 --- a/packages/office-ui-fabric-react/src/index.ts +++ b/packages/office-ui-fabric-react/src/index.ts @@ -10,6 +10,7 @@ export * from './Callout'; export * from './Checkbox'; export * from './ChoiceGroup'; export * from './ColorPicker'; +export * from './ComboBox'; export * from './CommandBar'; export * from './ContextualMenu'; export * from './DatePicker'; diff --git a/packages/office-ui-fabric-react/src/utilities/selectableOption/SelectableDroppableText.Props.ts b/packages/office-ui-fabric-react/src/utilities/selectableOption/SelectableDroppableText.Props.ts new file mode 100644 index 00000000000000..633a04cd68d90d --- /dev/null +++ b/packages/office-ui-fabric-react/src/utilities/selectableOption/SelectableDroppableText.Props.ts @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { IRenderFunction } from '../../Utilities'; +import { ICalloutProps } from '../../Callout'; +import { ISelectableOption } from '../../utilities/selectableOption/SelectableOption.Props'; + +export interface ISelectableDroppableTextProps extends React.Props { + /** + * Optional callback to access the ISelectableDroppableText interface. Use this instead of ref for accessing + * the public methods and properties of the component. + */ + componentRef?: (component: T) => void; + + /** + * Descriptive label for the ISelectableDroppableText + */ + label?: string; + + /** + * Aria Label for the ISelectableDroppableText for screen reader users. + */ + ariaLabel?: string; + + /** + * Id of the ISelectableDroppableText + */ + id?: string; + + /** + * If provided, additional class name to provide on the root element. + */ + className?: string; + + /** + * The key that will be initially used to set a selected item. + */ + defaultSelectedKey?: string | number; + + /** + * The key of the selected item. If you provide this, you must maintain selection + * state by observing onChange events and passing a new value in when changed. + */ + selectedKey?: string | number; + + /** + * Collection of options for this ISelectableDroppableText + */ + options?: any; + + /** + * Callback issues when the selected option changes + */ + onChanged?: (option: ISelectableOption, index?: number) => void; + + /** + * Optional custom renderer for the ISelectableDroppableText container + */ + onRenderContainer?: IRenderFunction>; + + /** + * Optional custom renderer for the ISelectableDroppableText list + */ + onRenderList?: IRenderFunction>; + + /** + * Optional custom renderer for the ISelectableDroppableText options + */ + onRenderItem?: IRenderFunction; + + /** + * Optional custom renderer for the ISelectableDroppableText option content + */ + onRenderOption?: IRenderFunction; + + /** + * Whether or not the ISelectableDroppableText is disabled. + */ + disabled?: boolean; + + /** + * Whether or not the ISelectableDroppableText is required. + */ + required?: boolean; + + /** + * Custom properties for ISelectableDroppableText's Callout used to render options. + */ + calloutProps?: ICalloutProps; + + /** + * Descriptive label for the ISelectableDroppableText Error Message + */ + errorMessage?: string; +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/utilities/selectableOption/SelectableOption.Props.ts b/packages/office-ui-fabric-react/src/utilities/selectableOption/SelectableOption.Props.ts new file mode 100644 index 00000000000000..c58caa9c512cec --- /dev/null +++ b/packages/office-ui-fabric-react/src/utilities/selectableOption/SelectableOption.Props.ts @@ -0,0 +1,35 @@ +export interface ISelectableOption { + /** + * Arbitrary string associated with this option. + */ + key: string | number; + + /** + * Text to render for this option + */ + text: string; + + /** + * Text to render for this option + */ + itemType?: SelectableOptionMenuItemType; + + /** + * Index for this option + */ + index?: number; + + /** + * The aria label for the dropdown option. If not present, the `text` will be used. + */ + ariaLabel?: string; + + /** If option is selected. */ + selected?: boolean; +} + +export enum SelectableOptionMenuItemType { + Normal = 0, + Divider = 1, + Header = 2 +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/utilities/selectableOption/index.ts b/packages/office-ui-fabric-react/src/utilities/selectableOption/index.ts new file mode 100644 index 00000000000000..4c2fe6688b9930 --- /dev/null +++ b/packages/office-ui-fabric-react/src/utilities/selectableOption/index.ts @@ -0,0 +1,2 @@ +export * from './SelectableOption.Props'; +export * from './SelectableDroppableText.Props'; \ No newline at end of file