-
Notifications
You must be signed in to change notification settings - Fork 2.9k
ComboBox/SplitButton Expand on Touch #4353
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
07b16f9
3502f3a
1deccdd
4f36e14
06806a1
e6e0276
78ee211
aea2641
020ed81
c60caca
8b888d8
fbb8857
4c5720e
90c6ac1
b8b07c9
fab1029
0293d75
fa7671e
c284b76
d6be2f4
30a2933
1fe7db7
ad563fe
1702de4
f20265a
ec0bd53
51e730f
f8fea3b
dd096d8
acce342
dbaa322
e3ebdf7
e57e6e9
dd13382
089233c
a94b7c5
71ade9a
b68bf62
16a88a7
cec47d0
6d3810e
1f9fd86
8ce9bc7
9b45fef
fb63fb8
e7d0229
9d247be
d3b1d13
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "changes": [ | ||
| { | ||
| "packageName": "office-ui-fabric-react", | ||
| "comment": "Added ontouch support to expand menus for split buttons and combo boxes", | ||
| "type": "minor" | ||
| } | ||
| ], | ||
| "packageName": "office-ui-fabric-react", | ||
| "email": "chiechan@microsoft.com" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,6 +49,7 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState | |
| private _descriptionId: string; | ||
| private _ariaDescriptionId: string; | ||
| private _classNames: IButtonClassNames; | ||
| private _processingTouch: boolean; | ||
|
|
||
| constructor(props: IBaseButtonProps, rootClassName: string) { | ||
| super(props); | ||
|
|
@@ -192,13 +193,23 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState | |
| return this._onRenderContent(tag, buttonProps); | ||
| } | ||
|
|
||
| public componentDidMount() { | ||
| if (this._isSplitButton && this._splitButtonContainer) { | ||
| this._events.on(this._splitButtonContainer.value, 'pointerdown', this._onPointerDown, true); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can't you use react eventing? You should avoid using native events when possible.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. React does not support this yet, you could potentially mix it in with a |
||
| } | ||
| } | ||
|
|
||
| public componentDidUpdate(prevProps: IBaseButtonProps, prevState: IBaseButtonState) { | ||
| // If Button's menu was closed, run onAfterMenuDismiss | ||
| if (this.props.onAfterMenuDismiss && prevState.menuProps && !this.state.menuProps) { | ||
| this.props.onAfterMenuDismiss(); | ||
| } | ||
| } | ||
|
|
||
| public componentWillUnmount() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why? |
||
| super.componentWillUnmount(); | ||
| } | ||
|
|
||
| public focus(): void { | ||
| if (this._isSplitButton && this._splitButtonContainer.value) { | ||
| this._splitButtonContainer.value.focus(); | ||
|
|
@@ -425,7 +436,6 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState | |
| disabled, | ||
| checked, | ||
| getSplitButtonClassNames, | ||
| onClick, | ||
| primaryDisabled | ||
| } = this.props; | ||
|
|
||
|
|
@@ -460,7 +470,7 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState | |
| onKeyDown={ this._onSplitButtonContainerKeyDown } | ||
| ref={ this._splitButtonContainer } | ||
| data-is-focusable={ true } | ||
| onClick={ !disabled && !primaryDisabled ? onClick : undefined } | ||
| onClick={ !disabled && !primaryDisabled ? this._onSplitButtonClick : undefined } | ||
| tabIndex={ !disabled ? 0 : undefined } | ||
| > | ||
| <span | ||
|
|
@@ -511,6 +521,14 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState | |
| return <BaseButton {...splitButtonProps} onMouseDown={ this._onMouseDown } tabIndex={ -1 } />; | ||
| } | ||
|
|
||
| private _onSplitButtonClick = (ev: React.MouseEvent<HTMLDivElement | HTMLAnchorElement>) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method isn't used anywhere, how is it getting called?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right, it's not this was old code that came from a merge conflict. I'll get rid of it. |
||
| if (!this._processingTouch && this.props.onClick) { | ||
| this.props.onClick(ev); | ||
| } else { | ||
| this._onMenuClick(ev); | ||
| } | ||
| } | ||
|
|
||
| private _onMouseDown = (ev: React.MouseEvent<BaseButton>) => { | ||
| if (this.props.onMouseDown) { | ||
| this.props.onMouseDown(ev); | ||
|
|
@@ -554,6 +572,19 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState | |
| } | ||
| } | ||
|
|
||
| private _onPointerDown(ev: PointerEvent) { | ||
| if (ev.pointerType === 'touch') { | ||
| this._processingTouch = true; | ||
|
|
||
| ev.preventDefault(); | ||
| ev.stopImmediatePropagation(); | ||
|
|
||
| this._async.setTimeout(() => { | ||
| this._processingTouch = false; | ||
| }, 500); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Returns if the user hits a valid keyboard key to open the menu | ||
| * @param ev - the keyboard event | ||
|
|
@@ -567,7 +598,7 @@ export class BaseButton extends BaseComponent<IBaseButtonProps, IBaseButtonState | |
| } | ||
| } | ||
|
|
||
| private _onMenuClick = (ev: React.MouseEvent<HTMLAnchorElement>) => { | ||
| private _onMenuClick = (ev: React.MouseEvent<HTMLDivElement | HTMLAnchorElement>) => { | ||
| const { onMenuClick } = this.props; | ||
| if (onMenuClick) { | ||
| onMenuClick(ev, this); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -129,6 +129,8 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
|
|
||
| private _scrollIdleTimeoutId: number | undefined; | ||
|
|
||
| private _processingTouch: boolean; | ||
|
|
||
| // 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 | ||
|
|
@@ -152,6 +154,7 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
| const selectedKeys: (string | number)[] = this._getSelectedKeys(props.defaultSelectedKey, props.selectedKey); | ||
|
|
||
| this._isScrollIdle = true; | ||
| this._processingTouch = false; | ||
|
|
||
| const initialSelectedIndices: number[] = this._getSelectedIndices(props.options, selectedKeys); | ||
|
|
||
|
|
@@ -170,6 +173,8 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
| public componentDidMount() { | ||
| // hook up resolving the options if needed on focus | ||
| this._events.on(this._comboBoxWrapper.value, 'focus', this._onResolveOptions, true); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you mean to remove this line? |
||
|
|
||
| this._events.on(this._comboBoxWrapper.value, 'pointerdown', this._onPointerDown, true); | ||
| } | ||
|
|
||
| public componentWillReceiveProps(newProps: IComboBoxProps) { | ||
|
|
@@ -346,8 +351,8 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
| title={ title } | ||
| /> | ||
| <IconButton | ||
| className={'ms-ComboBox-CaretDown-button'} | ||
| styles={this._getCaretButtonStyles()} | ||
| className={ 'ms-ComboBox-CaretDown-button' } | ||
| styles={ this._getCaretButtonStyles() } | ||
| role='presentation' | ||
| aria-hidden={ isButtonAriaHidden } | ||
| data-is-focusable={ false } | ||
|
|
@@ -359,7 +364,7 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
| /> | ||
| </div> | ||
|
|
||
| {isOpen && ( | ||
| { isOpen && ( | ||
| (onRenderContainer as any)({ | ||
| ...this.props, | ||
| onRenderList, | ||
|
|
@@ -368,13 +373,13 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
| options: this.state.currentOptions.map((item, index) => ({ ...item, index: index })) | ||
| }, | ||
| this._onRenderContainer) | ||
| )} | ||
| ) } | ||
| { | ||
| errorMessage && | ||
| <div | ||
| className={this._classNames.errorMessage} | ||
| className={ this._classNames.errorMessage } | ||
| > | ||
| {errorMessage} | ||
| { errorMessage } | ||
| </div> | ||
| } | ||
| </div> | ||
|
|
@@ -481,7 +486,7 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
| } | ||
| displayValues.push(currentPendingValue !== '' ? currentPendingValue : (this._indexWithinBounds(currentOptions, index) ? currentOptions[index].text : '')); | ||
| } else { | ||
| for (let idx = 0; selectedIndices && (idx < selectedIndices.length); idx ++) { | ||
| for (let idx = 0; selectedIndices && (idx < selectedIndices.length); idx++) { | ||
| const index: number = selectedIndices[idx]; | ||
| displayValues.push(this._indexWithinBounds(currentOptions, index) ? currentOptions[index].text : suggestedDisplayValue); | ||
| } | ||
|
|
@@ -933,11 +938,11 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
|
|
||
| return ( | ||
| <Callout | ||
| isBeakVisible={false} | ||
| gapSpace={0} | ||
| doNotLayer={false} | ||
| directionalHint={DirectionalHint.bottomLeftEdge} | ||
| directionalHintFixed={true} | ||
| isBeakVisible={ false } | ||
| gapSpace={ 0 } | ||
| doNotLayer={ false } | ||
| directionalHint={ DirectionalHint.bottomLeftEdge } | ||
| directionalHintFixed={ true } | ||
| { ...calloutProps } | ||
| className={ css(this._classNames.callout, calloutProps ? calloutProps.className : undefined) } | ||
| target={ this._comboBoxWrapper.value } | ||
|
|
@@ -952,7 +957,7 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
| <div className={ this._classNames.optionsContainerWrapper } ref={ this._comboBoxMenu }> | ||
| { (onRenderList as any)({ ...props }, this._onRenderList) } | ||
| </div> | ||
| {onRenderLowerContent(this.props, this._onRenderLowerContent)} | ||
| { onRenderLowerContent(this.props, this._onRenderLowerContent) } | ||
| </Callout> | ||
| ); | ||
| } | ||
|
|
@@ -967,12 +972,12 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
| const id = this._id; | ||
| return ( | ||
| <div | ||
| id={id + '-list'} | ||
| className={this._classNames.optionsContainer} | ||
| aria-labelledby={id + '-label'} | ||
| id={ id + '-list' } | ||
| className={ this._classNames.optionsContainer } | ||
| aria-labelledby={ id + '-label' } | ||
| role='listbox' | ||
| > | ||
| {options.map((item) => (onRenderItem as any)(item, this._onRenderItem))} | ||
| { options.map((item) => (onRenderItem as any)(item, this._onRenderItem)) } | ||
| </div> | ||
| ); | ||
| } | ||
|
|
@@ -1002,8 +1007,8 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
| return ( | ||
| <div | ||
| role='separator' | ||
| key={key} | ||
| className={this._classNames.divider} | ||
| key={ key } | ||
| className={ this._classNames.divider } | ||
| /> | ||
| ); | ||
| } | ||
|
|
@@ -1039,32 +1044,32 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
| onMouseMove={ this._onOptionMouseMove.bind(this, item.index) } | ||
| onMouseLeave={ this._onOptionMouseLeave } | ||
| role='option' | ||
| aria-selected={ isSelected ? 'true' : 'false'} | ||
| ariaLabel= {item.text } | ||
| aria-selected={ isSelected ? 'true' : 'false' } | ||
| ariaLabel={ item.text } | ||
| disabled={ item.disabled } | ||
| > { <span ref={ this._selectedElement }> | ||
| { onRenderOption(item, this._onRenderOptionContent) } | ||
| </span> | ||
| } | ||
| </CommandButton> | ||
| ) : ( | ||
| <Checkbox | ||
| id={id + '-list' + item.index} | ||
| ref={'option' + item.index} | ||
| key={item.key} | ||
| data-index={item.index} | ||
| styles={optionStyles} | ||
| className={'ms-ComboBox-option'} | ||
| data-is-focusable={true} | ||
| onChange={this._onItemClick(item.index!)} | ||
| label={item.text} | ||
| role='option' | ||
| aria-selected={ isSelected ? 'true' : 'false' } | ||
| checked={isSelected} | ||
| > | ||
| {onRenderOption(item, this._onRenderOptionContent)} | ||
| </Checkbox> | ||
| ) | ||
| <Checkbox | ||
| id={ id + '-list' + item.index } | ||
| ref={ 'option' + item.index } | ||
| key={ item.key } | ||
| data-index={ item.index } | ||
| styles={ optionStyles } | ||
| className={ 'ms-ComboBox-option' } | ||
| data-is-focusable={ true } | ||
| onChange={ this._onItemClick(item.index!) } | ||
| label={ item.text } | ||
| role='option' | ||
| aria-selected={ isSelected ? 'true' : 'false' } | ||
| checked={ isSelected } | ||
| > | ||
| { onRenderOption(item, this._onRenderOptionContent) } | ||
| </Checkbox> | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -1187,7 +1192,7 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
|
|
||
| private _onRenderOptionContent = (item: IComboBoxOption): JSX.Element => { | ||
| const optionClassNames = getComboBoxOptionClassNames(this._getCurrentOptionStyles(item)); | ||
| return <span className={optionClassNames.optionText}>{item.text}</span>; | ||
| return <span className={ optionClassNames.optionText }>{ item.text }</span>; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -1650,12 +1655,25 @@ export class ComboBox extends BaseComponent<IComboBoxProps, IComboBoxState> { | |
| */ | ||
| private _onAutofillClick = (): void => { | ||
| if (this.props.allowFreeform) { | ||
| this.focus(this.state.isOpen); | ||
| this.focus(this.state.isOpen || this._processingTouch); | ||
| } else { | ||
| this._onComboBoxClick(); | ||
| } | ||
| } | ||
|
|
||
| private _onPointerDown = (ev: PointerEvent): void => { | ||
| if (ev.pointerType === 'touch') { | ||
| this._processingTouch = true; | ||
|
|
||
| ev.preventDefault(); | ||
| ev.stopImmediatePropagation(); | ||
|
|
||
| this._async.setTimeout(() => { | ||
| this._processingTouch = false; | ||
| }, 500); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Get the styles for the current option. | ||
| * @param item Item props for the current option | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -93,6 +93,7 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| private _isScrollIdle: boolean; | ||
| private readonly _navigationIdleDelay: number = 250 /* ms */; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should just use const at the top of the file for constant numbers. /** Comment explaining what its used for. */
const NavigationIdleDelay = 250; |
||
| private _scrollIdleTimeoutId: number | undefined; | ||
| private _processingTouch: boolean; | ||
|
|
||
| private _splitButtonContainers: Map<string, HTMLDivElement>; | ||
|
|
||
|
|
@@ -149,6 +150,7 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
|
|
||
| // Invoked immediately before a component is unmounted from the DOM. | ||
| public componentWillUnmount() { | ||
| super.componentWillUnmount(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmmm. This feels unneeded? |
||
| if (this._isFocusingPreviousElement && this._previousActiveElement) { | ||
|
|
||
| // This slight delay is required so that we can unwind the stack, const react try to mess with focus, and then | ||
|
|
@@ -160,9 +162,6 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| if (this.props.onMenuDismissed) { | ||
| this.props.onMenuDismissed(this.props); | ||
| } | ||
|
|
||
| this._events.dispose(); | ||
| this._async.dispose(); | ||
| } | ||
|
|
||
| public render(): JSX.Element | null { | ||
|
|
@@ -592,8 +591,10 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
|
|
||
| return ( | ||
| <div | ||
| ref={ (el: HTMLDivElement) => | ||
| this._splitButtonContainers.set(item.key, el) | ||
| ref={ (el: HTMLDivElement) => { | ||
| this._splitButtonContainers.set(item.key, el); | ||
| el && this._events.on(el, 'pointerdown', this._onPointerDown, true); | ||
| } | ||
| } | ||
| role={ 'button' } | ||
| aria-labelledby={ item.ariaLabel } | ||
|
|
@@ -705,6 +706,19 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| } | ||
| } | ||
|
|
||
| private _onPointerDown(ev: PointerEvent) { | ||
| if (ev.pointerType === 'touch') { | ||
| this._processingTouch = true; | ||
|
|
||
| ev.preventDefault(); | ||
| ev.stopImmediatePropagation(); | ||
|
|
||
| this._async.setTimeout(() => { | ||
| this._processingTouch = false; | ||
| }, 500); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Checks if the submenu should be closed | ||
| */ | ||
|
|
@@ -849,7 +863,7 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| } | ||
| } | ||
|
|
||
| private _onItemClick(item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement>) { | ||
| private _onItemClick(item: IContextualMenuItem, ev: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) { | ||
| const items = getSubmenuItems(item); | ||
|
|
||
| if (!hasSubmenu(item) && (!items || !items.length)) { // This is an item without a menu. Click it. | ||
|
|
@@ -875,6 +889,10 @@ export class ContextualMenu extends BaseComponent<IContextualMenuProps, IContext | |
| if (item.disabled || item.isDisabled) { | ||
| return; | ||
| } | ||
| if (this._processingTouch) { | ||
| return this._onItemClick(item, ev); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can you add unit tests to validate the things you're changing?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will do!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added unit tests, with some caveats if you look at another one of my comments. |
||
| } | ||
|
|
||
| let dismiss = false; | ||
| if (item.onClick) { | ||
| dismiss = !!item.onClick(ev, item); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SplitButton/ComboBox: added
onTouchsupport for menu expansion.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated!