From e1a488175d2bf92e5b10c63074f4e6237fac83ad Mon Sep 17 00:00:00 2001 From: unix Date: Sun, 24 May 2020 01:54:12 +0800 Subject: [PATCH] feat(select): handle multiple selections --- .../__snapshots__/index.test.tsx.snap | 159 +++++++++++++++++- .../__snapshots__/multiple.test.tsx.snap | 150 +++++++++++++++++ components/select/__tests__/index.test.tsx | 13 ++ components/select/__tests__/multiple.test.tsx | 55 ++++++ components/select/select-context.ts | 2 +- components/select/select-multiple-value.tsx | 46 +++++ components/select/select-option.tsx | 64 +++++-- components/select/select.tsx | 81 ++++++--- 8 files changed, 526 insertions(+), 44 deletions(-) create mode 100644 components/select/__tests__/__snapshots__/multiple.test.tsx.snap create mode 100644 components/select/__tests__/multiple.test.tsx create mode 100644 components/select/select-multiple-value.tsx diff --git a/components/select/__tests__/__snapshots__/index.test.tsx.snap b/components/select/__tests__/__snapshots__/index.test.tsx.snap index d26a3e5..0af4cc3 100644 --- a/components/select/__tests__/__snapshots__/index.test.tsx.snap +++ b/components/select/__tests__/__snapshots__/index.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Select should render correctly 1`] = ` -"
" `; +exports[`Select should work correctly with labels 1`] = ` +"
" +`; + exports[`Select should work with different icons 1`] = ` initialize { "0": Object { "attribs": Object { - "class": "select ", + "class": "select ", }, "children": Array [ Object { @@ -334,6 +446,13 @@ initialize { background-color: #fff; } + .multiple { + height: auto; + min-height: calc(1.688 * 16pt); + padding: 4pt calc(.875rem * 2) + 4pt 8pt; + } + .select:hover { border-color: #000; } @@ -431,6 +550,13 @@ initialize { background-color: #fff; } + .multiple { + height: auto; + min-height: calc(1.688 * 16pt); + padding: 4pt calc(.875rem * 2) + 4pt 8pt; + } + .select:hover { border-color: #000; } @@ -554,7 +680,7 @@ exports[`Select should work with different icons 2`] = ` initialize { "0": Object { "attribs": Object { - "class": "select ", + "class": "select ", }, "children": Array [ Object { @@ -616,6 +742,13 @@ initialize { background-color: #fff; } + .multiple { + height: auto; + min-height: calc(1.688 * 16pt); + padding: 4pt calc(.875rem * 2) + 4pt 8pt; + } + .select:hover { border-color: #000; } @@ -751,6 +884,13 @@ initialize { background-color: #fff; } + .multiple { + height: auto; + min-height: calc(1.688 * 16pt); + padding: 4pt calc(.875rem * 2) + 4pt 8pt; + } + .select:hover { border-color: #000; } @@ -865,6 +1005,13 @@ initialize { background-color: #fff; } + .multiple { + height: auto; + min-height: calc(1.688 * 16pt); + padding: 4pt calc(.875rem * 2) + 4pt 8pt; + } + .select:hover { border-color: #000; } diff --git a/components/select/__tests__/__snapshots__/multiple.test.tsx.snap b/components/select/__tests__/__snapshots__/multiple.test.tsx.snap new file mode 100644 index 0000000..94b950f --- /dev/null +++ b/components/select/__tests__/__snapshots__/multiple.test.tsx.snap @@ -0,0 +1,150 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Select Multiple should render correctly 1`] = ` +"
" +`; diff --git a/components/select/__tests__/index.test.tsx b/components/select/__tests__/index.test.tsx index cb759ce..eedb141 100644 --- a/components/select/__tests__/index.test.tsx +++ b/components/select/__tests__/index.test.tsx @@ -17,6 +17,19 @@ describe('Select', () => { expect(() => wrapper.unmount()).not.toThrow() }) + it('should work correctly with labels', () => { + const wrapper = mount( + , + ) + expect(wrapper.html()).toMatchSnapshot() + expect(() => wrapper.unmount()).not.toThrow() + }) + it('should work with different icons', () => { const MockIcon = () => icon const pure = render( + 1 + Option 2 + , + ) + expect(wrapper.html()).toMatchSnapshot() + expect(() => wrapper.unmount()).not.toThrow() + }) + + it('should render value with initial-value', () => { + const wrapper = mount( + , + ) + expect(wrapper.find('.option').length).toBeGreaterThan(1) + const text = wrapper.find('.option').map(item => item.text()) + expect(text.includes('test-1')).toBeTruthy() + expect(text.includes('test-2')).toBeTruthy() + expect(text.includes('test-3')).not.toBeTruthy() + }) + + it('should trigger events when option changed', async () => { + let value = '' + const changeHandler = jest.fn().mockImplementation(val => (value = val)) + const wrapper = mount( + , + ) + wrapper.find('.select').simulate('click', nativeEvent) + wrapper.find('.select-dropdown').find('.option').at(0).simulate('click', nativeEvent) + await updateWrapper(wrapper, 350) + expect(changeHandler).toHaveBeenCalled() + expect(Array.isArray(value)).toBeTruthy() + expect(value.includes('1')).toBeTruthy() + + wrapper.find('.select-dropdown').find('.option').at(0).simulate('click', nativeEvent) + await updateWrapper(wrapper, 350) + expect(Array.isArray(value)).toBeTruthy() + expect(value.includes('1')).not.toBeTruthy() + changeHandler.mockRestore() + }) +}) diff --git a/components/select/select-context.ts b/components/select/select-context.ts index 5ba965f..e67c132 100644 --- a/components/select/select-context.ts +++ b/components/select/select-context.ts @@ -2,7 +2,7 @@ import React, { MutableRefObject } from 'react' import { NormalSizes } from '../utils/prop-types' export interface SelectConfig { - value?: string + value?: string | string[] updateValue?: Function visible?: boolean updateVisible?: Function diff --git a/components/select/select-multiple-value.tsx b/components/select/select-multiple-value.tsx new file mode 100644 index 0000000..b74aa99 --- /dev/null +++ b/components/select/select-multiple-value.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import useTheme from '../styles/use-theme' +import Grid from '../grid' + +interface Props { + disabled: boolean + size: string +} + +const SelectMultipleValue: React.FC> = ({ + disabled, + size, + children, +}) => { + const theme = useTheme() + + return ( + +
{children}
+ +
+ ) +} + +export default SelectMultipleValue diff --git a/components/select/select-option.tsx b/components/select/select-option.tsx index a385601..60be23c 100644 --- a/components/select/select-option.tsx +++ b/components/select/select-option.tsx @@ -5,14 +5,18 @@ import { useSelectContext } from './select-context' import useWarning from '../utils/use-warning' interface Props { - value: string + value?: string disabled?: boolean className?: string + divider?: boolean + label?: boolean preventAllEvents?: boolean } const defaultProps = { disabled: false, + divider: false, + label: false, className: '', preventAllEvents: false, } @@ -25,23 +29,37 @@ const SelectOption: React.FC> = ({ className, children, disabled, + divider, + label, preventAllEvents, ...props }) => { const theme = useTheme() const { updateValue, value, disableAll } = useSelectContext() const isDisabled = useMemo(() => disabled || disableAll, [disabled, disableAll]) - if (identValue === undefined) { + const isLabel = useMemo(() => label || divider, [label, divider]) + if (!isLabel && identValue === undefined) { useWarning('The props "value" is required.', 'Select Option') } - const selected = useMemo(() => (value ? identValue === value : false), [identValue, value]) + const selected = useMemo(() => { + if (!value) return false + if (typeof value === 'string') { + return identValue === value + } + return value.includes(`${identValue}`) + }, [identValue, value]) const bgColor = useMemo(() => { if (isDisabled) return theme.palette.accents_1 - return selected ? theme.palette.accents_1 : theme.palette.background + return selected ? theme.palette.accents_2 : theme.palette.background }, [selected, isDisabled, theme.palette]) + const hoverBgColor = useMemo(() => { + if (isDisabled || isLabel || selected) return bgColor + return theme.palette.accents_1 + }, [selected, isDisabled, theme.palette, isLabel, bgColor]) + const color = useMemo(() => { if (isDisabled) return theme.palette.accents_4 return selected ? theme.palette.foreground : theme.palette.accents_5 @@ -52,13 +70,16 @@ const SelectOption: React.FC> = ({ event.stopPropagation() event.nativeEvent.stopImmediatePropagation() event.preventDefault() - if (isDisabled) return + if (isDisabled || isLabel) return updateValue && updateValue(identValue) } return ( <> -
+
{children}
@@ -80,18 +101,27 @@ const SelectOption: React.FC> = ({ transition: background 0.2s ease 0s, border-color 0.2s ease 0s; } - .option:first-of-type { - border-top-left-radius: ${theme.layout.radius}; - border-top-right-radius: ${theme.layout.radius}; - } - - .option:last-of-type { - border-bottom-left-radius: ${theme.layout.radius}; - border-bottom-right-radius: ${theme.layout.radius}; - } - .option:hover { - background-color: ${theme.palette.accents_1}; + background-color: ${hoverBgColor}; + color: ${theme.palette.accents_7}; + } + + .divider { + line-height: 0; + height: 0; + padding: 0; + overflow: hidden; + border-top: 1px solid ${theme.palette.border}; + margin: 0.5rem 0; + width: 100%; + } + + .label { + font-size: 0.875rem; + color: ${theme.palette.accents_7}; + border-bottom: 1px solid ${theme.palette.border}; + text-transform: capitalize; + cursor: default; } `} diff --git a/components/select/select.tsx b/components/select/select.tsx index 1f51adb..7fd8103 100644 --- a/components/select/select.tsx +++ b/components/select/select.tsx @@ -1,24 +1,29 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { NormalSizes } from '../utils/prop-types' +import useTheme from '../styles/use-theme' import useClickAway from '../utils/use-click-away' -import { pickChildByProps, pickChildrenFirst } from '../utils/collections' +import useCurrentState from '../utils/use-current-state' +import { pickChildByProps } from '../utils/collections' import SelectIcon from './select-icon' import SelectOption from './select-option' -import useTheme from '../styles/use-theme' import SelectDropdown from './select-dropdown' +import SelectMultipleValue from './select-multiple-value' +import Grid from '../grid' import { SelectContext, SelectConfig } from './select-context' import { getSizes } from './styles' interface Props { disabled?: boolean size?: NormalSizes - value?: string - initialValue?: string + value?: string | string[] + initialValue?: string | string[] placeholder?: React.ReactNode | string icon?: React.ComponentType - onChange?: (value: string) => void + onChange?: (value: string | string[]) => void pure?: boolean + multiple?: boolean className?: string + width?: string dropdownClassName?: string dropdownStyle?: object } @@ -28,6 +33,8 @@ const defaultProps = { size: 'medium' as NormalSizes, icon: SelectIcon as React.ComponentType, pure: false, + multiple: false, + width: 'initial', className: '', } @@ -42,9 +49,11 @@ const Select: React.FC> = ({ value: customValue, icon: Icon, onChange, - className, pure, + multiple, placeholder, + width, + className, dropdownClassName, dropdownStyle, ...props @@ -52,14 +61,28 @@ const Select: React.FC> = ({ const theme = useTheme() const ref = useRef(null) const [visible, setVisible] = useState(false) - const [value, setValue] = useState(init) + const [value, setValue, valueRef] = useCurrentState(() => { + if (!multiple) return init + if (Array.isArray(init)) return init + return typeof init === 'undefined' ? [] : [init] + }) + const isEmpty = useMemo(() => { + if (!Array.isArray(value)) return !value + return value.length === 0 + }, [value]) const sizes = useMemo(() => getSizes(theme, size), [theme, size]) const updateVisible = (next: boolean) => setVisible(next) const updateValue = (next: string) => { - setValue(next) - onChange && onChange(next) - setVisible(false) + setValue(last => { + if (!Array.isArray(last)) return next + if (!last.includes(next)) return [...last, next] + return last.filter(item => item !== next) + }) + onChange && onChange(valueRef.current as string | string[]) + if (!multiple) { + setVisible(false) + } } const initialValue: SelectConfig = useMemo( @@ -72,7 +95,7 @@ const Select: React.FC> = ({ ref, disableAll: disabled, }), - [visible, size, disabled, ref], + [visible, size, disabled, ref, value, multiple], ) const clickHandler = (event: React.MouseEvent) => { @@ -84,7 +107,6 @@ const Select: React.FC> = ({ } useClickAway(ref, () => setVisible(false)) - useEffect(() => { if (customValue === undefined) return setValue(customValue) @@ -92,16 +114,28 @@ const Select: React.FC> = ({ const selectedChild = useMemo(() => { const [, optionChildren] = pickChildByProps(children, 'value', value) - const child = pickChildrenFirst(optionChildren) - if (!React.isValidElement(child)) return optionChildren - return React.cloneElement(child, { preventAllEvents: true }) - }, [value, children]) + return React.Children.map(optionChildren, child => { + if (!React.isValidElement(child)) return null + const el = React.cloneElement(child, { preventAllEvents: true }) + if (!multiple) return el + return ( + + {el} + + ) + }) + }, [value, children, multiple]) return ( -
- {!value && {placeholder}} - {value && {selectedChild}} +
+ {isEmpty && {placeholder}} + {value && !multiple && {selectedChild}} + {value && multiple && {selectedChild}} > = ({ position: relative; cursor: ${disabled ? 'not-allowed' : 'pointer'}; max-width: 80vw; - width: initial; + width: ${width}; overflow: hidden; transition: border 0.2s ease 0s, color 0.2s ease-out 0s, box-shadow 0.2s ease 0s; border: 1px solid ${theme.palette.border}; @@ -133,6 +167,13 @@ const Select: React.FC> = ({ background-color: ${disabled ? theme.palette.accents_1 : theme.palette.background}; } + .multiple { + height: auto; + min-height: ${sizes.height}; + padding: ${theme.layout.gapQuarter} calc(${sizes.fontSize} * 2) + ${theme.layout.gapQuarter} ${theme.layout.gapHalf}; + } + .select:hover { border-color: ${disabled ? theme.palette.border : theme.palette.foreground}; }