mui / material-ui

Material UI: Comprehensive React component library that implements Google's Material Design. Free forever.
https://mui.com/material-ui/
MIT License
93.08k stars 32.05k forks source link

Capability of using Downshift for Select component #15078

Closed raymondsze closed 5 years ago

raymondsze commented 5 years ago

Apart from AutoComplete, downshift could be used to implement a dropdown component with getToggleProps.

I would like to use downshift instead of the original implementation of Select (TextField) component.

The main purpose I would like to have this feature is to implement

  1. infinte-scroll dropdown (with react-virtualized or similar tools)
  2. modal select (which is more user friendly to mobile device)

What I need is to keep the ui looks & feel of what Select have.

I have tried to set the open and onOpen of SelectProps to make the open controlled by myself. But Select force me to put at least one child to the component.

Also, if I click on the TextField, the TextField is highlighted as usual (focused state) but never get blur if I mark the open prop as false.

Another attempt is I try to implement a component looks similar to Select but without Menu and any dropdown related event callback. But I found it is too hard to do except clone the codes from the @material-ui/core package.

Any advices / workaround on doing this? Or should we have a pure Select like component without any dropdown behaviour or pass a props to Select indicate that we would implement the dropdown ourself?

And just a question. What is the design principle of Select and RadioGroup, since these component make use of React.cloneElement heavily and somehow assume the children must be MenuItem or FormControlLabel/Radio. Select is more complicated as it grab the children of MenuItem as default "renderValue", the "renderValue" behaviour could be different if multiple is specified as true due to .join(','). And... we have RadioGroup but we don't have CheckboxGroup. If array value is rare, why we have Select that with "multiple" prop.....

Thanks.

Expected Behavior 🤔

Current Behavior 😯

Examples 🌈

Context 🔦

raymondsze commented 5 years ago

Here is what i did right now to make a Select like component. PS: I didn't extract the style to makeStyle, but rough idea.. Not sure whether it is right way to go.

const InputComponent = ({ className, 'aria-invalid': _, inputRef, ...props }: any) => (
  <div tabIndex={0} ref={inputRef} className={className} style={{ height: '1.1875em', cursor: 'pointer' }}>
    Option {props.value + 1}
    <input {...props} type="hidden" />
    <ArrowDownIcon
      style={{ position: 'absolute', right: 0, top: 'calc(50% - 12px)' }}
      color="action"
    />
  </div>
);

const Dropdown = () => (
  <Downshift>
            {({
              getToggleButtonProps,
              getMenuProps,
              getItemProps,
              isOpen,
              highlightedIndex,
              selectedItem,
              closeMenu,
            }) => (
              <div>
                <MuiTextField
                  variant="filled"
                  label="Mock Dropdown"
                  value={selectedItem}
                  style={{ width: 160, cursor: 'pointer' }}
                  {...getToggleButtonProps()}
                  InputLabelProps={{ shrink: true }}
                  InputProps={{
                    inputRef,
                    inputComponent: InputComponent,
                  }}
                />
                <Popper open={isOpen} anchorEl={inputRef.current} style={{ zIndex: 1300 }}>
                  <Grow in={isOpen}>
                    <Paper elevation={8} style={{ width: inputRef.current ? inputRef.current.clientWidth : undefined }}>
                      <List {...getMenuProps({}, { suppressRefError: true })}>
                        {new Array(2).fill(null).map((_, i) => (
                          <ListItem
                            {...getItemProps({ key: i, item: i, index: i })}
                            style={{ fontWeight: selectedItem === i ? 500 : 300 }}
                            selected={highlightedIndex === i}
                            button={true}
                          >
                            Option {i + 1}
                          </ListItem>
                        ))}
                      </List>
                    </Paper>
                  </Grow>
                </Popper>
              </div>
            )}
          </Downshift>
);
hungrymonkey commented 5 years ago

A few things. Downshift is a render prop library. Downshift is telling you to draw itself.

I would highly recommend you bind onSelect, onStateChange, and stateReducer to grok Downshift internal states. I believe have downshift has around 15 states. You can fine grain control over state change with stateReducer.

https://github.com/downshift-js/downshift/blob/master/src/stateChangeTypes.js

/*
 * All possible downshift states
 * 0: "unknown"
 * 1: "mouseUp"
 * 2: "itemMouseEnter"
 * 3: "keyDownArrowUp"
 * 4: "keyDownArrowDown"
 * 5: "keyDownEscape"
 * 6: "keyDownEnter"
 * 7: "clickItem"
 * 8: "blurInput"
 * 9: "changeInput"
 * 10: "keyDownSpaceButton"
 * 11: "clickButton"
 * 12: "blurButton"
 * 13: "controlledPropUpdatedSelectedItem"
 * 14: "touchStart"
 */

On the other note: I would recommend you warp your drop down in a normal div tag. https://github.com/downshift-js/downshift/issues/443 Material UI breaks downshift expected ref behavior. You cannot select it on IOS.

raymondsze commented 5 years ago

Actually I would like to ask the ability to make the Select component Menu controllable. Here is the problems: Select ask me to pass the children, the children props is mandatory. But if I would like to use Downshift, the menu is rendered externally, I don't need any children there. Even the open and onOpen props are passed, this behaviour is still cannot be changed.

Here is the codes I finally work out to make it works. It's long but hope you get the idea what I trying to do.

  1. Convert downshift to hook version (It is optional, but hook is easier to me)
    
    import React, { useContext } from 'react';
    import Downshift, { DownshiftProps, ControllerStateAndHelpers } from 'downshift';
    import { StyledComponentProps } from '@material-ui/core/styles';
    import { createStyles, withStyles, WithStyles } from '@material-ui/styles';

export * from 'downshift';

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

const styles = createStyles({ root: { display: 'inline' }, });

export type DownshiftClassKey = keyof WithStyles['classes'];

interface DownshiftWrapperProps extends Omit<DownshiftProps, 'children'>, StyledComponentProps { id?: string; children: React.ReactNode; };

export interface DownshiftWrapperInnerProps extends Omit<DownshiftWrapperProps, 'classes'>, WithStyles {};

const Context = React.createContext<ControllerStateAndHelpers | null>(null);

export const useDownshift = () => useContext(Context);

const stateReducer: DownshiftProps['stateReducer'] = (state, changes) => { switch (changes.type) { case Downshift.stateChangeTypes.itemMouseEnter: return state; } return changes; }

const DownshiftWrapper = (props: DownshiftWrapperInnerProps) => { const { id, children, classes, ...rest } = props; return ( <Downshift id={id} labelId={${id}-label} inputId={${id}-input} menuId={${id}-menu} stateReducer={stateReducer} {...rest}

{(stateAndHelpers) => (

{children}

)} ); };

export type DownshiftProps = DownshiftWrapperProps; export default withStyles(styles)(DownshiftWrapper) as React.ComponentType;


2. Clone a SelectInput like component but children is not necessary (Here I added some custom styling to make it like MWC)

import React from 'react'; import clsx from 'clsx'; import { Theme, StyledComponentProps } from '@material-ui/core/styles'; import { createStyles, withStyles, WithStyles } from '@material-ui/styles'; import ArrowDropdown from '../internal/svg-icons/ArrowDropdown';

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

const styles = (theme: Theme) => createStyles({ / Styles applied to the Input component root class. / root: { position: 'relative', width: '100%', outline: 'none', }, select: { '-moz-appearance': 'none', // Reset '-webkit-appearance': 'none', // Reset // When interacting quickly, the text can end up selected. // Native select can't be selected either. userSelect: 'none', paddingRight: 32, borderRadius: 0, // Reset height: '1.1875em', // Reset (19px), match the native input line-height width: 'calc(100% - 32px)', minWidth: 16, // So it doesn't collapse. cursor: 'pointer', '&:focus': { // Show that it's not an text input backgroundColor: theme.palette.type === 'light' ? 'rgba(0, 0, 0, 0.05)' : 'rgba(255, 255, 255, 0.05)', borderRadius: 0, // Reset Chrome style }, // Remove IE 11 arrow '&::-ms-expand': { display: 'none', }, '&$disabled': { cursor: 'default', }, '&$open': { backgroundColor: 'inherit', cursor: 'default', }, }, / Styles applied to the Input component if variant="filled". / filled: { width: 'calc(100% - 44px)', }, / Styles applied to the Input component if variant="outlined". / outlined: { width: 'calc(100% - 46px)', borderRadius: theme.shape.borderRadius, }, / Styles applied to the Input component selectMenu class. / selectMenu: { width: 'auto', // Fix Safari textOverflow height: 'auto', // Reset textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', minHeight: '1.1875em', // Reset (19px), match the native input line-height }, / Styles applied to the Input component disabled class. / disabled: {}, icon: { transition: theme.transitions.create('transform', { duration: theme.transitions.duration.shortest, }), // We use a position absolute over a flexbox in order to forward the pointer events // to the input. position: 'absolute', right: 8, top: 'calc(50% - 12px)', // Center vertically color: theme.palette.action.active, 'pointer-events': 'none', // Don't block pointer events on the select under the icon. '&$open': { color: theme.palette.primary.main, transform: 'rotate(180deg) translateY(-5px)', }, }, open: {}, });

export type SelectInputClassKey = keyof WithStyles['classes'];

export interface SelectInputProps extends Omit<React.InputHTMLAttributes, 'value'>, StyledComponentProps { inputRef?: React.Ref | React.RefObject; value?: Array<string | number | boolean> | string | number | boolean; disabled?: boolean; open?: boolean; variant?: 'filled' | 'outlined' | 'standard'; renderValue(value?: Array<string | number | boolean> | string | number | boolean): React.ReactNode; SelectDisplayProps?: React.HTMLAttributes; };

export interface SelectInputInnerProps extends Omit<SelectInputProps, 'classes'>, WithStyles {};

const SelectInput = React.forwardRef<HTMLDivElement, SelectInputInnerProps>( (function SelectInput(props: SelectInputInnerProps, ref: React.Ref) { const { open, inputRef, className, classes, name, value, disabled, variant, readOnly, renderValue, required, placeholder, type, SelectDisplayProps, onClick, onFocus, onBlur, onKeyDown, onKeyUp, ...rest } = props;

const buttonProps = {
  className: clsx(
    classes.select,
    classes.selectMenu,
    {
      [classes.disabled]: disabled,
      [classes.filled]: variant === 'filled',
      [classes.outlined]: variant === 'outlined',
      [classes.open]: open,
    },
    className,
  ),
  onFocus,
  onBlur,
  onKeyDown,
  onKeyUp,
  disabled,
  ...SelectDisplayProps,
} as any;

return (
  <div ref={ref} className={classes.root}>
    <div
      id={name ? `select-${name}` : undefined}
      tabIndex={0}
      ref={inputRef}
      {...buttonProps}
    >
      {renderValue(value)}
      <input
        name={name}
        value={Array.isArray(value) ? value.join(',') : value}
        type="hidden"
        {...rest as any}
      />
      <ArrowDropdown
        className={clsx(classes.icon, { [classes.open]: open })}
      />
    </div>
  </div>
)

}) as React.SFC, );

export default withStyles(styles)(SelectInput) as React.ComponentType;


3. Write a TextFIeld that integrated with downshift hook, and make the inputComponent to SelectInput

import React from 'react'; import { SelectProps } from '@material-ui/core/Select'; import TextField, { TextFieldClassKey, StandardTextFieldProps, FilledTextFieldProps, OutlinedTextFieldProps, } from '@material-ui/core/TextField'; import { useDownshift } from '../Downshift'; import SelectInput from './SelectInput';

export type SelectFieldClassKey = TextFieldClassKey;

interface SelectBaseProps { renderValue: SelectProps['renderValue']; SelectDisplayProps?: React.HTMLAttributes; };

export interface StandardSelectFieldProps extends StandardTextFieldProps, SelectBaseProps {}; export interface OutlinedSelectFieldProps extends OutlinedTextFieldProps, SelectBaseProps {}; export interface FilledSelectFieldProps extends FilledTextFieldProps, SelectBaseProps {};

export type SelectFieldProps = StandardSelectFieldProps | OutlinedSelectFieldProps | FilledSelectFieldProps;

const SelectField: React.SFC = props => { const { open: openProp, value: valueProp, variant, renderValue, SelectDisplayProps, ...rest } = props; const downShift = useDownshift();

const open = (typeof openProp !== 'undefined') ? openProp : (downShift && downShift.isOpen); const value = (typeof valueProp !== 'undefined') ? valueProp : (downShift && downShift.selectedItem); const getToggleButtonProps = (downShift && downShift.getToggleButtonProps);

const handleKeyDown = (event: React.KeyboardEvent) => { (event.key === 'Enter') && downShift && downShift.toggleMenu(); }

return ( <TextField value={value} variant={variant as any} {...getToggleButtonProps && getToggleButtonProps({ onKeyDown: handleKeyDown, })} {...rest as any} InputProps={{ inputComponent: SelectInput, ...rest.InputProps, inputProps: { open, variant, renderValue, SelectDisplayProps, ...rest.InputProps && rest.InputProps.inputProps, }, }} /> ); };

export default SelectField;


4. Write a Select Popper that integrated with downshift hook

import React from 'react'; import Popper, { PopperProps } from '@material-ui/core/Popper'; import { PopoverProps } from '@material-ui/core/Popover'; import Paper, { PaperProps } from '@material-ui/core/Paper'; import Grow from '@material-ui/core/Grow'; import { StyledComponentProps } from '@material-ui/core/styles'; import { createStyles, withStyles, WithStyles } from '@material-ui/styles';

import { useDownshift } from '../Downshift';

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

export const styles = createStyles({ root: { zIndex: 1300, }, });

export type SelectPopperClassKey = keyof WithStyles['classes'];

export interface SelectPopperProps extends Partial, StyledComponentProps { open?: boolean; anchorEl: HTMLElement | null; children: React.ReactNode; PaperProps?: Partial; TransitionComponent?: PopoverProps['TransitionComponent']; transitionDuration?: PopoverProps['transitionDuration']; TransitionProps?: PopoverProps['TransitionProps']; };

export interface SelectPopperInnerProps extends Omit<SelectPopperProps, 'classes'>, WithStyles {};

const SelectPopper: React.SFC = ({ classes, open: openProp, anchorEl, children, PaperProps, TransitionComponent, transitionDuration, TransitionProps, ...rest }) => { const Transition = TransitionComponent || Grow; const downShift = useDownshift(); const open = (typeof openProp !== 'undefined') ? openProp : (downShift ? downShift.isOpen : false); const getMenuProps = downShift && downShift.getMenuProps;

return ( <Popper className={classes.root} open={!!open} anchorEl={anchorEl} {...rest}> <Transition in={open} timeout={transitionDuration} {...TransitionProps}

<Paper elevation={8} style={{ minWidth: anchorEl ? anchorEl.clientWidth : undefined, ...PaperProps && PaperProps.style }} {...getMenuProps && getMenuProps({}, { suppressRefError: true })} {...PaperProps}

{children} ); };

export default withStyles(styles)(SelectPopper) as React.ComponentType;


5. Write a Leaf Menu Item

import React, { useEffect } from 'react'; import clsx from 'clsx'; import MenuItem, { MenuItemProps, MenuItemClassKey } from '@material-ui/core/MenuItem'; import { Theme, StyledComponentProps } from '@material-ui/core/styles'; import { createStyles, withStyles, WithStyles } from '@material-ui/styles'; import { useDownshift } from '../Downshift';

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

export const styles = (theme: Theme) => createStyles({ root: {}, gutters: {}, selected: {}, });

export type SelectItemClassKey = keyof WithStyles['classes'] | MenuItemClassKey;

export interface SelectItemProps extends Omit<MenuItemProps, 'classes'> { classes?: StyledComponentProps['classes']; highlighted?: boolean; index: number; }

export interface SelectItemInnerProps extends Omit<SelectItemProps, 'classes'>, WithStyles {};

const SelectItem: React.SFC = props => { const { index, color, selected: selectedProp, highlighted: highlightedProp, value, className, classes, ...rest } = props;

const downShift = useDownshift(); const highlighted = (typeof highlightedProp !== 'undefined') ? highlightedProp : (downShift && (downShift.highlightedIndex === index)); const selected = (typeof selectedProp !== 'undefined') ? selectedProp : (downShift && (downShift.selectedItem === value)); const getItemProps = downShift && downShift.getItemProps;

useEffect(() => { if (downShift && selected) downShift.setHighlightedIndex(index); }, [selected]);

const itemProps = { index, value, item: value, className, ...rest, };

return ( <MenuItem classes={classes} selected={highlighted} {...getItemProps && getItemProps({ index, item: value })} {...itemProps} /> ); }

export default withStyles(styles)(SelectItem) as React.ComponentType;


6. Now I can compose my Select Component as I want

import React from 'react'; import List from '@material-ui/core/List'; import RootRef from '@material-ui/core/RootRef'; import SelectField, { SelectFieldClassKey, SelectFieldProps, } from '../SelectField'; import Downshift, { DownshiftProps } from '../Downshift/Downshift'; import SelectPopper, { SelectPopperProps } from '../SelectPopper';

export type DownshiftSelectClassKey = SelectFieldClassKey;

export type DownshiftSelectFieldProps = SelectFieldProps & { value: any; DownshiftProps?: DownshiftProps; SelectPopperProps?: SelectPopperProps; };

const DownshiftSelectField: React.SFC = props => { const [popperNode, setPopperNode] = React.useState<HTMLDivElement | null>(null);

const { id, value, DownshiftProps, SelectPopperProps, children: childrenProp, ...other } = props; const handleRef = (node: HTMLDivElement) => setPopperNode(node);

return ( <Downshift id={id} initialSelectedItem={value} {...DownshiftProps}

<SelectPopper anchorEl={popperNode} {...SelectPopperProps}>

{childrenProp}

); }

export default DownshiftSelectField;



And what I would like to get rid of is the `SelectInput` component.
I think `Context + Hook` combo would be the trend that I could prevent the problems introduced by RenderProps and Hoc especially the props drilling issue. Just like that current `material-ui` v4 `Radio` did.

Currently the `Select` component main issue is it assuming the children must contain a prop called `value`, and `Menu` also relying on children to make the keyboard event works.
It is not possible to integrate the `Select` component with other library like `Downshift`, `react-virtualized`, etc.

Actually if I use third party library like `Downshift`, the extra behaviour of `Select` component is mean nothing to me (it may be meaningful to others). So what I need is a static component that looks similar to what `Select` component but without any event binding (or optional). The closest solution is to pass a `endAdornment` with value `DropdownIcon` and mark the component prop to `button`. 
cvanem commented 5 years ago

@raymondsze Have you seen mui-downshift package yet? This is what I use.

raymondsze commented 5 years ago

@cvanem mui-downshift is good for general usage, but actually what I want is a generic solution that I can apply either the Downshift or Material-UI current MenuItem behaviour (Hook is one of possible solution) to any item I want. For example, modal select or even more complex selections ui component. Downshift is a library could achieve that, but not easy to integrate with MaterialUI "Select" component. Other component are fine, like the AutoComplete example shown in Material UI website.

eps1lon commented 5 years ago

Is this a proposal for different implementation of Select, are features lacking or is the integration with downshift lacking? Did you have a look at https://next.material-ui.com/components/autocomplete/#downshift?

raymondsze commented 5 years ago

@eps1lon Material UI have example to integrate with Downshift using TextField or equivalent.

However, Select requires Menu and MenuItem to work. As I remember, the children is required for Select Component. Let's say what if I don't want the Menu, but a popup Dialog with MenuItem inside the Dialog? I would like to preserve the Look & Feel of Select component (Material Design) but with different behaviour (for example, integrate with Downshift).

You may say inputAdornment could do the Dropdown Icon, but I found that they are totally different and not easy to do the same.

So, I think it should be a proposal for different implementation of Select (Maybe?).

eps1lon commented 5 years ago

Let's say what if I don't want the Menu, but a popup Dialog with MenuItem inside the Dialog? I would like to preserve the Look & Feel of Select component (Material Design) but with different behaviour (for example, integrate with Downshift).

That sounds like bad UX. Dialogs are usually very disruptive elements and should be used as rarely as possible. Do you have some mock implementation that I can play with? Right now I don't understand what issue you're trying to solve.

raymondsze commented 5 years ago

Let's say what if I don't want the Menu, but a popup Dialog with MenuItem inside the Dialog? I would like to preserve the Look & Feel of Select component (Material Design) but with different behaviour (for example, integrate with Downshift).

That sounds like bad UX. Dialogs are usually very disruptive elements and should be used as rarely as possible. Do you have some mock implementation that I can play with? Right now I don't understand what issue you're trying to solve.

Dialog is bad UX for desktop, but reasonable for Mobile device (What I mean here is 'full-screen' Dialog). You could simplify my issue as "How could we integrate with downshift to make a dropdown component with same looks and feel as Select component?".

eps1lon commented 5 years ago

So this is more of a usage question? Sounds like this is more appropriate for StackOverflow or Spectrum unless you have a conrete proposal for an integration demo. Otherwise I don't think this should be part of the Select component which has a pretty clear definition on the web. Making it work like a fullscreen dialog on mobile is not necessarily part of a select widget.

raymondsze commented 5 years ago

Nope, from my point of view. The current implementation of Select does not allow me to integrate with Downshift. Full screen dialog is just an example of usecase.

Basically, here is what I need.

  1. I would like to use the Downshift behaviour with Material UI TextField (select=true) or Material UI Select Component. The reason behind is the default Select behaviour does not fit my need. We could integrate downshift with TextField for auto complete. Why we cant do the same with Select? I think its common as well for react-select or other libraries.

  2. I tried to mark the MenuProps open to false without children for the Select component, the error I got was children is required.

  3. Do with another approach, I remove select=true and just use TextField with endAdornment with dropdown icon, but extra css is needed to make it like Material Design. And I feel this is a bit hacky.

I will provide an example when I have time to do it.

oliviertassinari commented 5 years ago

I believe we should close for #13863.

joshwooding commented 5 years ago

@oliviertassinari I agree