Select.tsx 8.1 KB
Newer Older
1
import React, {FunctionComponent, useCallback, useEffect, useMemo, useState} from 'react';
2 3 4 5 6 7 8
import {
    WithStyled,
    backgroundColor,
    backgroundFocusedColor,
    borderColor,
    borderFocusedColor,
    borderRadius,
9 10
    borderRadiusShortHand,
    css,
11
    ellipsis,
12
    em,
13
    math,
14 15 16 17 18
    sameBorder,
    selectedColor,
    size,
    textLighterColor,
    transitionProps
19
} from '~/utils/style';
20

21 22
import Checkbox from '~/components/Checkbox';
import Icon from '~/components/Icon';
23 24
import styled from 'styled-components';
import useClickOutside from '~/hooks/useClickOutside';
25
import {useTranslation} from 'react-i18next';
26
import without from 'lodash/without';
27 28 29 30 31 32 33

export const padding = em(10);
export const height = em(36);

const Wrapper = styled.div<{opened?: boolean}>`
    height: ${height};
    line-height: calc(${height} - 2px);
P
Peter Pan 已提交
34
    max-width: 100%;
35 36 37
    display: inline-block;
    position: relative;
    background-color: ${backgroundColor};
38 39
    ${sameBorder({radius: true})}
    ${props => (props.opened ? borderRadiusShortHand('bottom', '0') : '')}
40 41 42
    ${transitionProps(
        'border-color'
    )}
43 44 45 46 47 48 49 50 51

    &:hover {
        border-color: ${borderFocusedColor};
    }
`;

const Trigger = styled.div<{selected?: boolean}>`
    padding: ${padding};
    display: inline-flex;
52
    ${size('100%')}
53 54 55 56 57 58 59
    justify-content: space-between;
    align-items: center;
    cursor: pointer;
    ${props => (props.selected ? '' : `color: ${textLighterColor}`)}
`;

const TriggerIcon = styled(Icon)<{opened?: boolean}>`
60
    ${size(em(14))}
61 62 63 64
    text-align: center;
    display: block;
    flex-shrink: 0;
    transform: rotate(${props => (props.opened ? '180' : '0')}deg) scale(${10 / 14});
65
    ${transitionProps('transform')}
66 67 68 69
`;

const Label = styled.span`
    flex-grow: 1;
P
Peter Pan 已提交
70 71
    padding-right: ${em(10)};
    ${ellipsis()}
72 73 74 75 76 77
`;

const List = styled.div<{opened?: boolean; empty?: boolean}>`
    position: absolute;
    top: 100%;
    width: calc(100% + 2px);
78 79 80
    max-height: ${math(`4.35 * ${height} + 2 * ${padding}`)};
    overflow-x: hidden;
    overflow-y: auto;
81 82 83 84
    left: -1px;
    padding: ${padding} 0;
    border: inherit;
    border-top-color: ${borderColor};
85
    ${borderRadiusShortHand('bottom', borderRadius)}
86 87 88 89 90 91 92
    display: ${props => (props.opened ? 'block' : 'none')};
    z-index: 9999;
    line-height: 1;
    background-color: inherit;
    box-shadow: 0 5px 6px 0 rgba(0, 0, 0, 0.05);
    ${props =>
        props.empty
93 94 95 96
            ? {
                  color: textLighterColor,
                  textAlign: 'center'
              }
97 98 99 100 101 102 103
            : ''}
`;

const listItem = css`
    display: block;
    cursor: pointer;
    padding: 0 ${padding};
104
    ${size(height, '100%')}
105
    line-height: ${height};
106 107
    ${transitionProps(['color', 'background-color'])}

108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129
    &:hover {
        background-color: ${backgroundFocusedColor};
    }
`;

const ListItem = styled.div<{selected?: boolean}>`
    ${ellipsis()}
    ${listItem}
    ${props => (props.selected ? `color: ${selectedColor};` : '')}
`;

const MultipleListItem = styled(Checkbox)<{selected?: boolean}>`
    ${listItem}
    display: flex;
    align-items: center;
`;

type SelectListItem<T> = {
    value: T;
    label: string;
};

130 131 132 133 134
type OnSingleChange<T> = (value: T) => unknown;
type OnMultipleChange<T> = (value: T[]) => unknown;

export type SelectProps<T> = {
    list?: (SelectListItem<T> | T)[];
135
    placeholder?: string;
136 137 138 139 140 141 142 143 144 145 146 147
} & (
    | {
          value?: T;
          onChange?: OnSingleChange<T>;
          multiple?: false;
      }
    | {
          value?: T[];
          onChange?: OnMultipleChange<T>;
          multiple: true;
      }
);
148

149
const Select = <T extends unknown>({
150 151 152 153 154 155
    list: propList,
    value: propValue,
    placeholder,
    multiple,
    className,
    onChange
156
}: SelectProps<T> & WithStyled): ReturnType<FunctionComponent> => {
157 158 159 160 161 162 163
    const {t} = useTranslation('common');

    const [isOpened, setIsOpened] = useState(false);
    const toggleOpened = useCallback(() => setIsOpened(!isOpened), [isOpened]);
    const setIsOpenedFalse = useCallback(() => setIsOpened(false), []);

    const [value, setValue] = useState(multiple ? (Array.isArray(propValue) ? propValue : []) : propValue);
164 165 166 167 168 169
    useEffect(() => setValue(multiple ? (Array.isArray(propValue) ? propValue : []) : propValue), [
        multiple,
        propValue,
        setValue
    ]);

170
    // eslint-disable-next-line react-hooks/exhaustive-deps
171 172 173 174
    const isSelected = useMemo(() => !!(multiple ? (value as T[]) && (value as T[]).length !== 0 : (value as T)), [
        multiple,
        value
    ]);
175
    const changeValue = useCallback(
176 177 178 179 180
        (mutateValue: T) => {
            setValue(mutateValue);
            (onChange as OnSingleChange<T>)?.(mutateValue);
            setIsOpenedFalse();
        },
181
        [setIsOpenedFalse, onChange] // eslint-disable-line react-hooks/exhaustive-deps
182 183 184 185 186 187 188
    );
    const changeMultipleValue = useCallback(
        (mutateValue: T, checked: boolean) => {
            let newValue = value as T[];
            if (checked) {
                if (!newValue.includes(mutateValue)) {
                    newValue = [...newValue, mutateValue];
189 190
                }
            } else {
191 192 193
                if (newValue.includes(mutateValue)) {
                    newValue = without(newValue, mutateValue);
                }
194 195
            }
            setValue(newValue);
196
            (onChange as OnMultipleChange<T>)?.(newValue);
197
        },
198
        [value, onChange] // eslint-disable-line react-hooks/exhaustive-deps
199
    );
200

P
Peter Pan 已提交
201
    const ref = useClickOutside<HTMLDivElement>(setIsOpenedFalse);
202

203 204 205 206 207 208 209
    const list = useMemo<SelectListItem<T>[]>(
        () =>
            propList?.map(item =>
                ['string', 'number'].includes(typeof item)
                    ? {value: item as T, label: item + ''}
                    : (item as SelectListItem<T>)
            ) ?? [],
210
        [propList] // eslint-disable-line react-hooks/exhaustive-deps
211 212
    );
    const isListEmpty = useMemo(() => list.length === 0, [list]);
213

214
    // eslint-disable-next-line react-hooks/exhaustive-deps
215
    const findLabelByValue = useCallback((v: T) => list.find(item => item.value === v)?.label ?? '', [list]);
216 217 218 219
    const label = useMemo(
        () =>
            isSelected
                ? multiple
220 221
                    ? (value as T[]).map(findLabelByValue).join(' / ')
                    : findLabelByValue(value as T)
P
Peter Pan 已提交
222
                : placeholder || t('common:select'),
223
        [multiple, value, findLabelByValue, isSelected, placeholder, t] // eslint-disable-line react-hooks/exhaustive-deps
224
    );
225 226 227 228 229 230 231 232 233

    return (
        <Wrapper ref={ref} opened={isOpened} className={className}>
            <Trigger onClick={toggleOpened} selected={isSelected} title={isSelected && label ? String(label) : ''}>
                <Label>{label}</Label>
                <TriggerIcon opened={isOpened} type="chevron-down" />
            </Trigger>
            <List opened={isOpened} empty={isListEmpty}>
                {isListEmpty
P
Peter Pan 已提交
234
                    ? t('common:empty')
235 236 237 238
                    : list.map((item, index) => {
                          if (multiple) {
                              return (
                                  <MultipleListItem
239
                                      value={(value as T[]).includes(item.value)}
240
                                      key={index}
P
Peter Pan 已提交
241
                                      title={item.label}
242
                                      size="small"
243
                                      onChange={checked => changeMultipleValue(item.value, checked)}
244 245 246 247 248 249 250 251 252
                                  >
                                      {item.label}
                                  </MultipleListItem>
                              );
                          }
                          return (
                              <ListItem
                                  selected={item.value === value}
                                  key={index}
P
Peter Pan 已提交
253
                                  title={item.label}
254 255 256 257 258 259 260 261 262 263 264 265
                                  onClick={() => changeValue(item.value)}
                              >
                                  {item.label}
                              </ListItem>
                          );
                      })}
            </List>
        </Wrapper>
    );
};

export default Select;