Select.tsx 7.8 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 25 26
import styled from 'styled-components';
import useClickOutside from '~/hooks/useClickOutside';
import {useTranslation} from '~/utils/i18n';
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 40
    ${sameBorder({radius: true})}
    ${props => (props.opened ? borderRadiusShortHand('bottom', '0') : '')}
    ${transitionProps('border-color')}
41 42 43 44 45 46 47 48 49

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

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

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

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

const List = styled.div<{opened?: boolean; empty?: boolean}>`
    position: absolute;
    top: 100%;
    width: calc(100% + 2px);
76 77 78
    max-height: ${math(`4.35 * ${height} + 2 * ${padding}`)};
    overflow-x: hidden;
    overflow-y: auto;
79 80 81 82
    left: -1px;
    padding: ${padding} 0;
    border: inherit;
    border-top-color: ${borderColor};
83
    ${borderRadiusShortHand('bottom', borderRadius)}
84 85 86 87 88 89 90
    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
91 92 93 94
            ? {
                  color: textLighterColor,
                  textAlign: 'center'
              }
95 96 97 98 99 100 101
            : ''}
`;

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

106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127
    &: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;
};

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

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

147
const Select = <T extends unknown>({
148 149 150 151 152 153
    list: propList,
    value: propValue,
    placeholder,
    multiple,
    className,
    onChange
154
}: SelectProps<T> & WithStyled): ReturnType<FunctionComponent> => {
155 156 157 158 159 160 161
    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);
162 163 164 165 166 167
    useEffect(() => setValue(multiple ? (Array.isArray(propValue) ? propValue : []) : propValue), [
        multiple,
        propValue,
        setValue
    ]);

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

    const ref = useClickOutside(setIsOpenedFalse);

200 201 202 203 204 205 206
    const list = useMemo<SelectListItem<T>[]>(
        () =>
            propList?.map(item =>
                ['string', 'number'].includes(typeof item)
                    ? {value: item as T, label: item + ''}
                    : (item as SelectListItem<T>)
            ) ?? [],
207 208 209
        [propList]
    );
    const isListEmpty = useMemo(() => list.length === 0, [list]);
210

211
    const findLabelByValue = useCallback((v: T) => list.find(item => item.value === v)?.label ?? '', [list]);
212 213 214 215
    const label = useMemo(
        () =>
            isSelected
                ? multiple
216 217
                    ? (value as T[]).map(findLabelByValue).join(' / ')
                    : findLabelByValue(value as T)
218 219 220
                : placeholder || t('select'),
        [multiple, value, findLabelByValue, isSelected, placeholder, t]
    );
221 222 223 224 225 226 227 228 229 230 231 232 233 234

    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
                    ? t('empty')
                    : list.map((item, index) => {
                          if (multiple) {
                              return (
                                  <MultipleListItem
235
                                      value={(value as T[]).includes(item.value)}
236
                                      key={index}
P
Peter Pan 已提交
237
                                      title={item.label}
238
                                      size="small"
239
                                      onChange={checked => changeMultipleValue(item.value, checked)}
240 241 242 243 244 245 246 247 248
                                  >
                                      {item.label}
                                  </MultipleListItem>
                              );
                          }
                          return (
                              <ListItem
                                  selected={item.value === value}
                                  key={index}
P
Peter Pan 已提交
249
                                  title={item.label}
250 251 252 253 254 255 256 257 258 259 260 261
                                  onClick={() => changeValue(item.value)}
                              >
                                  {item.label}
                              </ListItem>
                          );
                      })}
            </List>
        </Wrapper>
    );
};

export default Select;