diff --git a/common/changes/office-ui-fabric-react/jspurlin-ComboBoxExpandOnTouch_2018-03-23-22-33.json b/common/changes/office-ui-fabric-react/jspurlin-ComboBoxExpandOnTouch_2018-03-23-22-33.json new file mode 100644 index 0000000000000..a71a47280e683 --- /dev/null +++ b/common/changes/office-ui-fabric-react/jspurlin-ComboBoxExpandOnTouch_2018-03-23-22-33.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "office-ui-fabric-react", + "comment": "SplitButton/ComboBox: added onTouch support for menu expansion.", + "type": "minor" + } + ], + "packageName": "office-ui-fabric-react", + "email": "chiechan@microsoft.com" +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/Button/BaseButton.tsx b/packages/office-ui-fabric-react/src/components/Button/BaseButton.tsx index 42edc086d6e54..b76784d5f153e 100644 --- a/packages/office-ui-fabric-react/src/components/Button/BaseButton.tsx +++ b/packages/office-ui-fabric-react/src/components/Button/BaseButton.tsx @@ -27,6 +27,8 @@ export interface IBaseButtonState { menuProps?: IContextualMenuProps | null; } +const TouchIdleDelay = 500; /* ms */ + export class BaseButton extends BaseComponent implements IButton { private get _isSplitButton(): boolean { @@ -52,6 +54,8 @@ export class BaseButton extends BaseComponent void = () => { + if (this._isSplitButton && this._splitButtonContainer.value && !('onpointerdown' in this._splitButtonContainer.value)) { + this._handleTouchAndPointerEvent(); + } + } + + private _onPointerDown(ev: PointerEvent) { + if (ev.pointerType === 'touch') { + this._handleTouchAndPointerEvent(); + + ev.preventDefault(); + ev.stopImmediatePropagation(); + } + } + + private _handleTouchAndPointerEvent() { + // If we already have an existing timeeout from a previous touch and pointer event + // cancel that timeout so we can set a nwe one. + if (this._lastTouchTimeoutId !== undefined) { + this._async.clearTimeout(this._lastTouchTimeoutId); + this._lastTouchTimeoutId = undefined; + } + this._processingTouch = true; + + this._lastTouchTimeoutId = this._async.setTimeout(() => { + this._processingTouch = false; + this._lastTouchTimeoutId = undefined; + }, TouchIdleDelay); + } + /** * Returns if the user hits a valid keyboard key to open the menu * @param ev - the keyboard event @@ -637,7 +684,7 @@ export class BaseButton extends BaseComponent) => { + private _onMenuClick = (ev: React.MouseEvent) => { const { onMenuClick } = this.props; if (onMenuClick) { onMenuClick(ev, this); diff --git a/packages/office-ui-fabric-react/src/components/Button/Button.test.tsx b/packages/office-ui-fabric-react/src/components/Button/Button.test.tsx index ed57a6ad8920b..1decc57feca5a 100644 --- a/packages/office-ui-fabric-react/src/components/Button/Button.test.tsx +++ b/packages/office-ui-fabric-react/src/components/Button/Button.test.tsx @@ -394,6 +394,40 @@ describe('Button', () => { expect(renderedDOM.getAttribute('aria-expanded')).toEqual('true'); }); + it('Touch Start on primary button of SplitButton expands menu', () => { + const button = ReactTestUtils.renderIntoDocument( + + ); + const renderedDOM = ReactDOM.findDOMNode(button as React.ReactInstance) as Element; + const primaryButtonDOM: HTMLButtonElement = renderedDOM.getElementsByTagName('button')[0] as HTMLButtonElement; + + // in a normal scenario, when we do a touchstart we would also cause a + // click event to fire. This doesn't happen in the simulator so we're + // manually adding this in. + ReactTestUtils.Simulate.touchStart(primaryButtonDOM); + ReactTestUtils.Simulate.click(primaryButtonDOM); + expect(renderedDOM.getAttribute('aria-expanded')).toEqual('true'); + }); + it('If menu trigger is disabled, pressing down does not trigger menu', () => { const button = ReactTestUtils.renderIntoDocument( { expect(returnUndefined.mock.calls.length).toBe(1); }); + it('Call onMenuOpened when touch start on the input', () => { + let comboBoxRoot; + let inputElement; + const returnUndefined = jest.fn(); + + const wrapper = mount( + ); + comboBoxRoot = wrapper.find('.ms-ComboBox'); + + inputElement = comboBoxRoot.find('input'); + + // in a normal scenario, when we do a touchstart we would also cause a + // click event to fire. This doesn't happen in the simulator so we're + // manually adding this in. + inputElement.simulate('touchstart'); + inputElement.simulate('click'); + + expect(wrapper.find('.is-open').length).toEqual(1); + }); + it('Can type a complete option with autocomplete and allowFreeform on and submit it', () => { let updatedOption; let updatedIndex; diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.tsx b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.tsx index 0c1ea5d31d339..d0db69b99d12e 100644 --- a/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.tsx +++ b/packages/office-ui-fabric-react/src/components/ComboBox/ComboBox.tsx @@ -81,6 +81,12 @@ enum HoverStatus { default = -1 } +const ScrollIdleDelay = 250 /* ms */; +const TouchIdleDelay = 500; /* ms */ + +// This is used to clear any pending autocomplete +// text (used when autocomplete is true and allowFreeform is false) +const ReadOnlyPendingAutoCompleteTimeout = 1000 /* ms */; interface IComboBoxOptionWrapperProps extends IComboBoxOption { // True if the option is currently selected isSelected: boolean; @@ -127,10 +133,6 @@ export class ComboBox extends BaseComponent { // 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 @@ -148,10 +150,12 @@ export class ComboBox extends BaseComponent { private _hasPendingValue: boolean; - private readonly _scrollIdleDelay: number = 250 /* ms */; - private _scrollIdleTimeoutId: number | undefined; + private _processingTouch: boolean; + + private _lastTouchTimeoutId: number | undefined; + // Determines if we should be setting // focus back to the input when the menu closes. // The general rule of thumb is if the menu was launched @@ -175,6 +179,7 @@ export class ComboBox extends BaseComponent { const selectedKeys: (string | number)[] = this._getSelectedKeys(props.defaultSelectedKey, props.selectedKey); this._isScrollIdle = true; + this._processingTouch = false; const initialSelectedIndices: number[] = this._getSelectedIndices(props.options, selectedKeys); @@ -191,8 +196,16 @@ export class ComboBox extends BaseComponent { } public componentDidMount(): void { - // hook up resolving the options if needed on focus - this._events.on(this._comboBoxWrapper.current, 'focus', this._onResolveOptions, true); + if (this._comboBoxWrapper.current) { + // hook up resolving the options if needed on focus + this._events.on(this._comboBoxWrapper.current, 'focus', this._onResolveOptions, true); + if ('onpointerdown' in this._comboBoxWrapper.current) { + // For ComboBoxes, touching anywhere in the combo box should drop the dropdown, including the input element. + // This gives more hit target space for touch environments. We're setting the onpointerdown here, because React + // does not support Pointer events yet. + this._events.on(this._comboBoxWrapper.value, 'pointerdown', this._onPointerDown, true); + } + } } public componentWillReceiveProps(newProps: IComboBoxProps): void { @@ -356,6 +369,7 @@ export class ComboBox extends BaseComponent { onKeyDown={ this._onInputKeyDown } onKeyUp={ this._onInputKeyUp } onClick={ this._onAutofillClick } + onTouchStart={ this._onTouchStart } onInputValueChange={ this._onInputChange } aria-expanded={ isOpen } aria-autocomplete={ this._getAriaAutoCompleteValue() } @@ -692,7 +706,7 @@ export class ComboBox extends BaseComponent { this._lastReadOnlyAutoCompleteChangeTimeoutId = this._async.setTimeout( () => { this._lastReadOnlyAutoCompleteChangeTimeoutId = undefined; }, - this._readOnlyPendingAutoCompleteTimeout + ReadOnlyPendingAutoCompleteTimeout ); return; } @@ -1191,7 +1205,7 @@ export class ComboBox extends BaseComponent { this._isScrollIdle = false; } - this._scrollIdleTimeoutId = this._async.setTimeout(() => { this._isScrollIdle = true; }, this._scrollIdleDelay); + this._scrollIdleTimeoutId = this._async.setTimeout(() => { this._isScrollIdle = true; }, ScrollIdleDelay); } /** @@ -1729,12 +1743,42 @@ export class ComboBox extends BaseComponent { */ private _onAutofillClick = (): void => { if (this.props.allowFreeform) { - this.focus(this.state.isOpen); + this.focus(this.state.isOpen || this._processingTouch); } else { this._onComboBoxClick(); } } + private _onTouchStart: () => void = () => { + if (this._comboBoxWrapper.value && !('onpointerdown' in this._comboBoxWrapper)) { + this._handleTouchAndPointerEvent(); + } + } + + private _onPointerDown = (ev: PointerEvent): void => { + if (ev.pointerType === 'touch') { + this._handleTouchAndPointerEvent(); + + ev.preventDefault(); + ev.stopImmediatePropagation(); + } + } + + private _handleTouchAndPointerEvent() { + // If we already have an existing timeeout from a previous touch and pointer event + // cancel that timeout so we can set a nwe one. + if (this._lastTouchTimeoutId !== undefined) { + this._async.clearTimeout(this._lastTouchTimeoutId); + this._lastTouchTimeoutId = undefined; + } + this._processingTouch = true; + + this._lastTouchTimeoutId = this._async.setTimeout(() => { + this._processingTouch = false; + this._lastTouchTimeoutId = undefined; + }, TouchIdleDelay); + } + /** * Get the styles for the current option. * @param item Item props for the current option diff --git a/packages/office-ui-fabric-react/src/components/ComboBox/__snapshots__/ComboBox.test.tsx.snap b/packages/office-ui-fabric-react/src/components/ComboBox/__snapshots__/ComboBox.test.tsx.snap index 784fc9d293c82..997815caba1c4 100644 --- a/packages/office-ui-fabric-react/src/components/ComboBox/__snapshots__/ComboBox.test.tsx.snap +++ b/packages/office-ui-fabric-react/src/components/ComboBox/__snapshots__/ComboBox.test.tsx.snap @@ -146,6 +146,7 @@ exports[`ComboBox Renders ComboBox correctly 1`] = ` onInput={[Function]} onKeyDown={[Function]} onKeyUp={[Function]} + onTouchStart={[Function]} readOnly={true} role="combobox" spellCheck={false} @@ -422,6 +423,7 @@ exports[`ComboBox renders a ComboBox with a Keytip correctly 1`] = ` onInput={[Function]} onKeyDown={[Function]} onKeyUp={[Function]} + onTouchStart={[Function]} readOnly={true} role="combobox" spellCheck={false} diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx index 48505643659b0..ea9b03ff896d1 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx @@ -176,6 +176,42 @@ describe('ContextualMenu', () => { expect(document.querySelector('.SubMenuClass')).toBeDefined(); }); + it('opens a splitbutton submenu item on touch start', () => { + const items: IContextualMenuItem[] = [ + { + name: 'TestText 1', + key: 'TestKey1', + split: true, + onClick: () => { alert('test'); }, + subMenuProps: { + items: [ + { + name: 'SubmenuText 1', + key: 'SubmenuKey1', + className: 'SubMenuClass' + } + ] + } + }, + ]; + + ReactTestUtils.renderIntoDocument( + + ); + + const menuItem = document.getElementsByName('TestText 1')[0] as HTMLButtonElement; + + // in a normal scenario, when we do a touchstart we would also cause a + // click event to fire. This doesn't happen in the simulator so we're + // manually adding this in. + ReactTestUtils.Simulate.touchStart(menuItem); + ReactTestUtils.Simulate.click(menuItem); + + expect(document.querySelector('.is-expanded')).toBeDefined(); + }); + it('sets the correct aria-owns attribute for the submenu', () => { const submenuId = 'testSubmenuId'; const items: IContextualMenuItem[] = [ diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.tsx index 29e3e65a1b4a1..6617366ba7739 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.tsx +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.tsx @@ -68,6 +68,8 @@ export function canAnyMenuItemsCheck(items: IContextualMenuItem[]): boolean { }); } +const NavigationIdleDelay = 250 /* ms */; + @customizable('ContextualMenu', ['theme']) @withResponsiveMode export class ContextualMenu extends BaseComponent { @@ -89,7 +91,6 @@ export class ContextualMenu extends BaseComponent ); } @@ -729,7 +728,7 @@ export class ContextualMenu extends BaseComponent { this._isScrollIdle = true; }, this._navigationIdleDelay); + this._scrollIdleTimeoutId = this._async.setTimeout(() => { this._isScrollIdle = true; }, NavigationIdleDelay); } private _onItemMouseEnter = (item: any, ev: React.MouseEvent): void => { @@ -793,7 +792,7 @@ export class ContextualMenu extends BaseComponent, target?: HTMLElement) { const targetElement = target ? target : ev.currentTarget as HTMLElement; - const { subMenuHoverDelay: timeoutDuration = this._navigationIdleDelay } = this.props; + const { subMenuHoverDelay: timeoutDuration = NavigationIdleDelay } = this.props; if (item.key === this.state.expandedMenuItemKey) { return; @@ -817,7 +816,7 @@ export class ContextualMenu extends BaseComponent { this._onSubMenuDismiss(ev); @@ -842,10 +841,8 @@ export class ContextualMenu extends BaseComponent { + if (this._enterTimerId !== undefined) { + this._async.clearTimeout(this._enterTimerId); + this._enterTimerId = undefined; + } + } + private _onItemSubMenuExpand(item: IContextualMenuItem, target: HTMLElement) { if (this.state.expandedMenuItemKey !== item.key) { @@ -997,4 +1003,8 @@ export class ContextualMenu extends BaseComponent | PointerEvent) => { + this._cancelSubMenuTimer(); + } } diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.tsx index 0a2975b67251d..29082a3d99cd1 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.tsx +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.tsx @@ -19,8 +19,18 @@ import { IContextualMenuSplitButtonProps } from './ContextualMenuSplitButton.typ export interface IContextualMenuSplitButtonState { } +const TouchIdleDelay = 500; /* ms */ + export class ContextualMenuSplitButton extends BaseComponent { private _splitButton: HTMLDivElement; + private _lastTouchTimeoutId: number | undefined; + private _processingTouch: boolean; + + public componentDidMount() { + if (this._splitButton && 'onpointerdown' in this._splitButton) { + this._events.on(this._splitButton, 'pointerdown', this._onPointerDown, true); + } + } public render(): JSX.Element | null { const { @@ -62,6 +72,7 @@ export class ContextualMenuSplitButton extends BaseComponent @@ -187,7 +198,10 @@ export class ContextualMenuSplitButton extends BaseComponent): void => { - const { item, onItemClickBase } = this.props; + const { + item, + onItemClickBase + } = this.props; if (onItemClickBase) { onItemClickBase(item, ev, (this._splitButton ? this._splitButton : ev.currentTarget) as HTMLElement); } @@ -196,13 +210,18 @@ export class ContextualMenuSplitButton extends BaseComponent | React.KeyboardEvent): void => { const { item, - executeItemClick + executeItemClick, + onItemClick } = this.props; if (item.disabled || item.isDisabled) { return; } + if (this._processingTouch && onItemClick) { + return onItemClick(item, ev); + } + if (executeItemClick) { executeItemClick(item, ev); } @@ -218,4 +237,39 @@ export class ContextualMenuSplitButton extends BaseComponent): void => { + if (this._splitButton && !('onpointerdown' in this._splitButton)) { + this._handleTouchAndPointerEvent(ev); + } + } + + private _onPointerDown = (ev: PointerEvent): void => { + if (ev.pointerType === 'touch') { + this._handleTouchAndPointerEvent(ev); + ev.preventDefault(); + ev.stopImmediatePropagation(); + } + } + + private _handleTouchAndPointerEvent(ev: React.TouchEvent | PointerEvent) { + const { + onTap + } = this.props; + + if (onTap) { + onTap(ev); + } + // If we already have an existing timeout from a previous touch/pointer event + // cancel that timeout so we can set a new one. + if (this._lastTouchTimeoutId) { + this._async.clearTimeout(this._lastTouchTimeoutId); + this._lastTouchTimeoutId = undefined; + } + this._processingTouch = true; + this._lastTouchTimeoutId = this._async.setTimeout(() => { + this._processingTouch = false; + this._lastTouchTimeoutId = undefined; + }, TouchIdleDelay); + } +} \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.types.ts b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.types.ts index 1e7ebdb801f2c..c13e199231c7b 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.types.ts +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenuSplitButton.types.ts @@ -91,4 +91,9 @@ export interface IContextualMenuSplitButtonProps extends React.Props) => void; + + /** + * Callback for touch/pointer events on the split button. + */ + onTap?: (ev: React.TouchEvent | PointerEvent) => void; } \ No newline at end of file diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/__snapshots__/ContextualMenuSplitButton.test.tsx.snap b/packages/office-ui-fabric-react/src/components/ContextualMenu/__snapshots__/ContextualMenuSplitButton.test.tsx.snap index d5648b6a5f8f2..40b7a74209afd 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/__snapshots__/ContextualMenuSplitButton.test.tsx.snap +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/__snapshots__/ContextualMenuSplitButton.test.tsx.snap @@ -17,6 +17,7 @@ exports[`ContextualMenuSplitButton creates a normal split button renders the con onMouseEnter={[Function]} onMouseLeave={undefined} onMouseMove={[Function]} + onTouchStart={[Function]} role="button" tabIndex={0} > diff --git a/scripts/package.json b/scripts/package.json index 602e98bd80708..c6ce5067e8edc 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -50,7 +50,7 @@ "bundlesize": [ { "path": "../apps/test-bundle-button/dist/test-bundle-button.min.js", - "maxSize": "49.4 kB" + "maxSize": "49.6 kB" } ] } \ No newline at end of file