Skip to content

Conversation

@jspurlin
Copy link
Contributor

@jspurlin jspurlin commented Jun 2, 2017

Pull request checklist

  • Addresses an existing issue: Fixes #0000
  • Include a change request file using $ npm run change

Description of changes

Add a new combobox component.

A few notes to how this differs from a dropdown:

  • ComboBox requires an <input> element for displaying the collapsed value to align more closely with the correct accessibility representation of a combobox
  • The keyboarding behavior and the ability to select available options varies greatly from dropdown
  • ComboBox is a "dropdown" that can be more specialize and in my mind will allow the dropdown to eventually be deprecated. Note, the combobox has the ability to autocomplete and/or allow free-form input (and the permutations of the two where autoComplete =="false" and allowFreeform=="false" aligns with the current behavior of the dropdown

comboboxgif

The gif shows:

  • The first combobox has autoComplete allowsFreeform. I type "time" which autoCompetes, then I expand the menu, move around to different options and finally press ESC to cancel the expatiation of the menu discarding the pending menu option, and then arrow up and down to move to different menu options which makes the new option the active selected option
  • The second combobox does not autoComplete and allowsFreeform. I type "time" which does not autoComplete, then I expand the menu, continue to type one of the options in which case it gives that option the selected look, and then Press ENTER to submit the value
  • The third combobox has autoComplete and does not allowFreeform. I type "time" and when the combobox is in this form typing goes to a matched option and allows for typing to continue to build a pending string to allow alternate matches as long as the changes happen within one second of each other (follow the default browser behavior); the menu is then expanded and an option is selected
  • The fourth combobox does not autoComplete and does not allowFreeform. I type a bunch of things and get no change in options, but if I expand the menu I can choose different options
  • The sixth combobox is disabled and typing nor arrowing or click will cause the combobox to respond

Another side note: if allowFreeform is false, clicking on the combobox will expand the combobox as opposed to when it is true, it places an IP. Also allowFreeform allows the user to enter new options and they will get added to the options, in the uncontrolled case, or will be passed back as the value in the onChanged callback

Focus areas to test

The input and selection of options with both mouse and keyboard (via text input and arrow keys) as well as getting the option on initial render or dynamically on focus

@msftclas
Copy link

msftclas commented Jun 2, 2017

@jspurlin,
Thanks for your contribution.
To ensure that the project team has proper rights to use your work, please complete the Contribution License Agreement at https://cla.microsoft.com.

It will cover your contributions to all Microsoft-managed open source projects.
Thanks,
Microsoft Pull Request Bot

@mikewheaton mikewheaton changed the title Add ComboBox functionality New Component: ComboBox Jun 2, 2017
Copy link

@jair-cazarin jair-cazarin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you check the code coverage of the UTs?

I believe we should move away from inheritance, if there is some code that need to be shared then we should extract and use through composition.

It also feels like ComboBox can be broken down in smaller stateless components for better maintainability and readability.

Regarding the promise, is that a common practice in Fabric? It feels the component should just receive new props when something is done async in the app.

@@ -1,398 +1,405 @@
import * as React from 'react';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's up with all the space changes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't make any space changes... not sure what that was

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the only thing I did add is the combobox piece

import { IDropdownOption, IDropdownProps } from '../../Dropdown';
import { IIconProps } from '../../Icon';

export interface IComboBox {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need an empty interface?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, it's not needed was just following the convention in the fabric codebase

buttonIconProps?: IIconProps;
}

export interface IComboBoxOption extends IDropdownOption {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This inheritance is fragile IMO, and some of the fields don't make sense to be inherited, DropdownMenuItemType for example.

Why not extract an ISelectableOption interface that both Dropdown and Combobox can use and have the both components decouple?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I'll rework this. It does make sense to have the contents of the DropdownMenuItemType although a better name would make it easier to think about

* 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?: () => IComboBoxOption[] | PromiseLike<IComboBoxOption[]>;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the difference between this function returning IComboBoxOption[] and setting options directly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One is handling the full state outside of the component (e.g. setting the options directly) opposed to the uncontrolled case where it may take some time to get the options back but we want the state to be stored inside of the component. The promise allows for the update without blocking


}

export interface IComboBoxProps extends IDropdownProps {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use composition instead of inheritance?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally a combo box is just a dropdown + input text so that should be reflected in the props as well, IMO

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm more conflicted now that the DropDown component is not even used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comboBox doesn't use a dropdown since dropdowns allow you to style the visible collapsed where comboBoxes enforce that the rendering is inside of a <input> element which should not allow for an arbitrary render functionality

private _inputElement: HTMLInputElement;
private _autoFillEnabled: boolean = true;
private _value: string;
protected _inputElement: HTMLInputElement;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The correct way to do this in React is through composition rather than exposing internals in a base class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll look into changing this to use composition

/**
* Font-family associated with this option.
*/
fontFamily?: string;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is interesting, isn't this specific to our font type combo box? Isn't there already an onRenderItem or similar that would allow the consumer to specify how to render each item?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but I felt like this aspect is scoped enough to expose it as well without making the creator create an onRenderOption

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question for you @jair-cazarin : Do you want to handle this with onRenderOption for our use case? If so I can remove the IComboBoxOption

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it feels very specific to that particular ComboBox IMO

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so I'd vote to remove it

* @param index - the index to check
* @returns {boolean} - true if the index is valid for the given options, false otherwise
*/
@autobind

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this also need to be autobind?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, removed

* allows freeform entry
* @param updatedValue - the input's newly changed value
*/
@autobind

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doesn't need autobind

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you make sure only the methods that need to be bound are decorated with autobind? there is a perf overhead otherwise.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

selectedIndex: newSelectedIndex
});

if (onChanged) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be called after setState is done?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same response as above

@micahgodbolt
Copy link
Member

Can you add a few images to the description so that we know what you're building here? @jspurlin

@betrue-final-final
Copy link
Member

I would assume it's just a dropdown that you can type in? It looks like we have this in SharePoint already. @yiminwu might be able to help.
image

@dzearing
Copy link
Member

dzearing commented Jun 6, 2017

@betrue-final-final We already have a dropdown...

@jspurlin So what is this component? Why would a customer use this vs dropdown?

Copy link
Member

@dzearing dzearing left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

block until we get some clarification on what dropdown isn't providing.

Copy link
Contributor Author

@jspurlin jspurlin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replying to the existing comments

@betrue-final-final
Copy link
Member

@dzearing @jspurlin , what they are looking for is the ability to type in a value not in the dropdown. I know we do have this in SharePoint, but I don't see the ability to do this in the Fabric component.

@dzearing
Copy link
Member

dzearing commented Jun 6, 2017

got it. screenshots would be handy

@jspurlin
Copy link
Contributor Author

jspurlin commented Jun 6, 2017

Added some more details to the description which lays out why this component ended up not either being a dropdown or being composed of a dropdown (as well as a gif showing off the functionality in the different states). My intention was to add those details earlier, but I was getting pulled other directions.

Take a look and I'd like to get your input after the update.

Note that the gif show performing similar actions across the different permutations of autoComplete and allowFreeform.

"@microsoft/web-library-build": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@microsoft/web-library-build/-/web-library-build-3.0.2.tgz",
"integrity": "sha1-DldYaol/ddjdDAKdKVtBapg4f5w=",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you actually need to change the ShrinkWrap file?

buttonIconProps
} = this.props;
let { isOpen, selectedIndex, focused, suggestedDisplayValue, currentOptions } = this.state;
let selectedOption = currentOptions[selectedIndex];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could selectedIndex be -1 when rendering?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, that's a valid state and means that none of the options are selected

return (
<div ref='root' className={ css('ms-ComboBox-container') }>
{ label && (
<Label className={ css('ms-ComboBox-label') } id={ id + '-label' } ref={ this._resolveRef('_comboBoxLabel') } required={ required } htmlFor={ id }>{ label }</Label>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need to capture the reference of the Label?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't, fixed

* @param {IBaseAutoFillProps} nextProps - the props that got passed in to the auto fill's componentWillReceiveProps
* @returns {string} - the updated value to set, if needed
*/
onComponentWillReceiveProps?: (nextProps: IBaseAutoFillProps) => string;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically lifecycle methods like this are used to update the component state in case of new props so exposing a callback with this name is quite weird. Can you actually rename these handlers to convey what they actually do?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and perhaps instead of exposing the entire set of props only the ones the ones you care about? It should be ideally only the ones that are in the local state (other props were set by the parent anyhow).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, we already have the props and we don't need to pass them back... removed the parameter and renamed the callbacks

private _inputElement: HTMLInputElement;
private _autoFillEnabled: boolean = true;
private _value: string;
protected _inputElement: HTMLInputElement;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these don't have to be protected anymore right?

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 !== DropdownMenuItemType.Header && option.itemType !== DropdownMenuItemType.Divider).filter((option) => option.text.toLocaleLowerCase().indexOf(updatedValue) === 0);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you need the two filters or can it be done with one?

} 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 !== DropdownMenuItemType.Header && option.itemType !== DropdownMenuItemType.Divider).filter((option) => option.text.toLocaleLowerCase() === updatedValue);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhas extract to a function so that it can be shared with the above?

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 !== DropdownMenuItemType.Header && option.itemType !== DropdownMenuItemType.Divider).filter((option) => option.text.toLocaleLowerCase().indexOf(updatedValue) === 0);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another case where we can reuse the same function

}

// If we get here, either autoComplete is on or we did not find a match with autoComplete on.
// Remeber we are not allowing freeform, so at this point, if we have a pending valid value index

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: Remember


let newIndex = index + searchDirection;

newIndex = Math.max(0, Math.min(currentOptions.length - 1, newIndex));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this get in an infinite loop?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, if you reach one of the ends of the array and are still at an option which shouldn't get focus, we won't make another call. Also, if the caller didn't tell us which way to move we won't try

@@ -1,428 +0,0 @@
import * as React from 'react';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mm this is weird, bad merge?

christiango and others added 3 commits June 12, 2017 11:16
* Enable no implicit any in the utilities package (microsoft#1970)

* Fix no implicit anys in utilities package.

* Rush change

* Reverse all shrinkwrap changes except the typings one.

* Fix bundle minification to exclude debug warnings. (microsoft#1973)

* Updating shrinkwrap, rush, and making minify build have production flag to remove debug code.

* Updates.

* removing lockfile.

* dropping to 7. Moving back to npm run build.

* Downgrading rush.

* Applying package updates.

* Website: Update dev.office.com header (microsoft#1966)

* Use Fabric Core 7.1.0 for the website

* Adjust position of caret's in header so that they spin around central axis

* Update to latest icon font from dev.office.com for header and move outside out :global{} to fix build issue

* Remove a u- prefix that was missed earlier

* Update dev.office.com header with the latest navigation links

* Add change file

* Make sure the quote rule is enabled for tsline (microsoft#1961)

* With responsive mode error (microsoft#1956)

* withResponsiveMode: Adding error handling around the case where window.innerWidth throws an exception

* adding change log file

* Create withResponsiveMode.tsx
* First step at stepper implementation.

* Add first implementation of stepper.

* Add functionality to stepper

* Refine the Stepper class and add tests

* let's make sure to put focus back on the text field when submitting via enter

* Added documentation to Stepper.

* Add flexibility to current stepper implementation.

* Modified example implementations.

* Add aria-valuemax.

* Change Stepper to SpinButton.

* Add example with unit.

* Implement color scheme in the ContextualMenu control to enable alternative theming.

* Improvements to SpinButton.

* Fix increment function calls.

* Add new width optional parameter.

* Add label direction.

* Fix border.

* Add Position enum.

* `defaultValue` is now the deciding prop for using the default implementation or not.

* onBlur is now onValidate.

* Fix tests.

* Fix warnings.

* Add implementation for labelGap.

* Put some polish on the styling, added some icon support, and added some more example spinButtons

* Implement the bar and unit tests and component page

* Add the ability for the spinButton buttons to look pressed when spinning via keyboard

* Revert "Implement color scheme in the ContextualMenu control to enable alternative theming."

This reverts commit 4f830cd.

* Don't render an empty icon for an icon-less header menu item.

* Revert "Implement Document Title Bar"

* update some CSS for high contrast in ff and use css utility instead of concatenating string classnames

* Fix quotation issue

* Fix Spin Button properties table.

* Fix Spin Button example code

* Use iconProps instead of string

* Extracted `spinning` out of state

* Add autobind instead of manually binding private functions to this

* Change `+` syntax for more explicit `Number()`

* Remove unnecessary cast

* Fix typos

* `incrementButtonIcon` and `decrementButtonIcon` are now IIconProps

* Add KeyboardSpinDirection enum

* Fix test description

* Fix SpinButton tests

* Remove unused onChange callback from SpinButton

* Revert onChange

* Remove old Stepper.ts file

* Use module css instead of global

* Fix missing word in comment

* Callback functions now allow for void return (state to be updated outside)

* Use `_async` instead of window

* Fix minor rendering issue with browser zoom

* Rename `_spinning` to `_spinningByMouse` for clarity

* Fix tests

* Fix extra space before label

* Remove width outside of SpinButton component and fix styling

* Add more tests to SpinButton

* Fix SpinButton documentation

* Fix typo

* Fix AppDefinition for SpinButton and Spinner

* Add missing documentation to SpinButton title prop

* Various SpinButton fixes

* Fix SpinButton path for properties

* Fix SpinButton styling issues

* Remove labelGap property from SpinButton
let newOptions: ISelectableOption[] | PromiseLike<ISelectableOption[]> =
this.props.onResolveOptions({ ...this.state.currentOptions });

// the options are either goingto be an array or a promise

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you remove this? I don't think it really adds anything to the code, it's pretty clear from the above it can be either type.

}

// Render Callout container and pass in list
@autobind

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think any of these render functions need @autoBind

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes they do need autobind since they reference "this" which won't be the expected this. I had narrowed down the autobind usages already every one that's there now needs to be there and would throw an error otherwise

it('Can change items in uncontrolled case', () => {
let comboBoxRoot;

try {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove the try/finally's? I don't think they are needed...if the test throws...it should fail and the finally is not doing any clean up or similar.

* in to the auto fill's componentWillReceiveProps
* @returns {string} - the updated value to set, if needed
*/
updateValueInWillReceiceProps?: () => string;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: there is a typo in here. Is it common to add the lifecycle function where these prop functions are used in Fabric? It looks to me as if the name itself is leaking already implementation details of the component.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it still feels weird that we are extending BaseAutoFill this way, would composition achieve the same in a more react idiomatic way? Looking at the code, doesn't seem to be that straightforward.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the typo. The name is being explicit about where the callback is being plugged in to. This functionality was created so that I could reuse the class instead of having to have almost duplicate functionality except for the changes in these lifecycle functions

* 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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is used above like: ISelectableDroppableTextProps, so the componentReference is really of type IComboBoxProps, is that the intention?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated so that the componentRef is really IComboBox instead

/**
* Collection of options for this ISelectableDroppableText
*/
options?: any;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the reason this became any?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To allow for the options to be generic enough so that different implementers can utilize this as they see fit


@autobind
private _onChanged(option: ISelectableOption, index: number, value: string) {
if (option != null) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be great to be consistent between the usages of != and !==

@christiango christiango merged commit f2fc34e into microsoft:master Jun 19, 2017
@nitishq
Copy link

nitishq commented Jul 7, 2017

So glad that this component is getting build. What's ETA on this, so that Microsoft Flow team can start utilizing it.

@christiango
Copy link
Member

This was merged several weeks ago. It is already available in the latest fabric releases

@stepsic-microsoft-com
Copy link

Forgive my ignorance, but we use this site: https://dev.office.com/fabric#/components to understand what components are available for consumption (as well as to understand best practices, etc...). Is there any sort of gate to ensure that as new components (such as ComboBox) are added they get put on that above page so that consumers can be aware of them? Thanks!

@microsoft microsoft locked as resolved and limited conversation to collaborators Aug 31, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants