diff --git a/components/auto-complete/auto-complete-context.ts b/components/auto-complete/auto-complete-context.ts index 5ba965f..c7acc6e 100644 --- a/components/auto-complete/auto-complete-context.ts +++ b/components/auto-complete/auto-complete-context.ts @@ -1,22 +1,20 @@ import React, { MutableRefObject } from 'react' import { NormalSizes } from '../utils/prop-types' -export interface SelectConfig { +export interface AutoCompleteConfig { value?: string updateValue?: Function visible?: boolean updateVisible?: Function size?: NormalSizes - disableAll?: boolean ref?: MutableRefObject } const defaultContext = { visible: false, size: 'medium' as NormalSizes, - disableAll: false, } -export const SelectContext = React.createContext(defaultContext) +export const AutoCompleteContext = React.createContext(defaultContext) -export const useSelectContext = (): SelectConfig => React.useContext(SelectContext) +export const useAutoCompleteContext = (): AutoCompleteConfig => React.useContext(AutoCompleteContext) diff --git a/components/auto-complete/auto-complete-dropdown.tsx b/components/auto-complete/auto-complete-dropdown.tsx index 35822b4..07de10c 100644 --- a/components/auto-complete/auto-complete-dropdown.tsx +++ b/components/auto-complete/auto-complete-dropdown.tsx @@ -1,11 +1,37 @@ import React from 'react' +import useTheme from '../styles/use-theme' +import { useAutoCompleteContext } from './auto-complete-context' +import Dropdown from '../shared/dropdown' -const AutoComplete = () => { +interface Props { + visible: boolean +} + +const AutoCompleteDropdown: React.FC> = ({ + children, visible +}) => { + const theme = useTheme() + const { ref } = useAutoCompleteContext() + const clickHandler = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + } + return ( -
- -
+ +
+ {children} + +
+
) } -export default AutoComplete +export default AutoCompleteDropdown diff --git a/components/auto-complete/auto-complete-empty.tsx b/components/auto-complete/auto-complete-empty.tsx index ec2a88e..de5272b 100644 --- a/components/auto-complete/auto-complete-empty.tsx +++ b/components/auto-complete/auto-complete-empty.tsx @@ -1,6 +1,6 @@ import React from 'react' import withDefaults from '../utils/with-defaults' -import useTheme from '../styles/use-theme' +import AutoCompleteSearch from './auto-complete-searching' interface Props { className?: string @@ -10,36 +10,13 @@ const defaultProps = { className: '', } -export type AutoCompleteSearchProps = Props & typeof defaultProps & React.HTMLAttributes +export type AutoCompleteEmptyProps = Props & typeof defaultProps & React.HTMLAttributes -const AutoCompleteSearch: React.FC> = ({ +const AutoCompleteEmpty: React.FC> = ({ children, className, }) => { - const theme = useTheme() - return ( -
- {children} - -
- ) + return {children} } -export default withDefaults(AutoCompleteSearch, defaultProps) +export default withDefaults(AutoCompleteEmpty, defaultProps) diff --git a/components/auto-complete/auto-complete-item.tsx b/components/auto-complete/auto-complete-item.tsx index 35822b4..68dbb03 100644 --- a/components/auto-complete/auto-complete-item.tsx +++ b/components/auto-complete/auto-complete-item.tsx @@ -1,11 +1,84 @@ -import React from 'react' +import React, { useMemo } from 'react' +import withDefaults from '../utils/with-defaults' +import useTheme from '../styles/use-theme' +import { useAutoCompleteContext } from './auto-complete-context' +import { NormalSizes } from 'components/utils/prop-types' -const AutoComplete = () => { +interface Props { + value: string + disabled?: boolean +} + +const defaultProps = { + disabled: false, +} + +export type AutoCompleteItemProps = Props & typeof defaultProps & React.HTMLAttributes + +const getSizes = (size?: NormalSizes) => { + const fontSizes: { [key in NormalSizes]: string } = { + mini: '.7rem', + small: '.75rem', + medium: '.875rem', + large: '1rem', + } + return size ? fontSizes[size] : fontSizes.medium +} + +const AutoCompleteItem: React.FC> = ({ + value: identValue, children, disabled, +}) => { + const theme = useTheme() + const { value, updateValue, size } = useAutoCompleteContext() + const selectHandler = () => { + updateValue && updateValue(identValue) + } + + const isActive = useMemo(() => value === identValue, [identValue, value]) + const fontSize = useMemo(() => getSizes(size), [size]) + return ( -
- +
+ {children} +
) } -export default AutoComplete +export default withDefaults(AutoCompleteItem, defaultProps) diff --git a/components/auto-complete/auto-complete-searching.tsx b/components/auto-complete/auto-complete-searching.tsx index ddae453..ec2a88e 100644 --- a/components/auto-complete/auto-complete-searching.tsx +++ b/components/auto-complete/auto-complete-searching.tsx @@ -1,37 +1,30 @@ -import React, { useMemo } from 'react' +import React from 'react' import withDefaults from '../utils/with-defaults' import useTheme from '../styles/use-theme' -import { useAutoCompleteContext } from './auto-complete-context' interface Props { - value: string - disabled?: boolean + className?: string } const defaultProps = { - disabled: false, + className: '', } -export type AutoCompleteItemProps = Props & typeof defaultProps & React.HTMLAttributes +export type AutoCompleteSearchProps = Props & typeof defaultProps & React.HTMLAttributes -const AutoCompleteItem: React.FC> = ({ - value: identValue, children, disabled, +const AutoCompleteSearch: React.FC> = ({ + children, className, }) => { const theme = useTheme() - const { value, updateValue } = useAutoCompleteContext() - const selectHandler = () => { - updateValue && updateValue(identValue) - } - - const isActive = useMemo(() => value === identValue, [identValue, value]) - + return ( -
+
{children}
) } -export default withDefaults(AutoCompleteItem, defaultProps) +export default withDefaults(AutoCompleteSearch, defaultProps) diff --git a/components/auto-complete/auto-complete.tsx b/components/auto-complete/auto-complete.tsx index e69de29..43eb4b8 100644 --- a/components/auto-complete/auto-complete.tsx +++ b/components/auto-complete/auto-complete.tsx @@ -0,0 +1,166 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import Input from '../input' +import AutoCompleteItem from './auto-complete-item' +import AutoCompleteDropdown from './auto-complete-dropdown' +import AutoCompleteSearching from './auto-complete-searching' +import AutoCompleteEmpty from './auto-complete-empty' +import { AutoCompleteContext, AutoCompleteConfig } from './auto-complete-context' +import { NormalSizes, NormalTypes } from '../utils/prop-types' +import ButtonLoading from '../button/button.loading' +import { pickChild } from 'components/utils/collections' + +export type AutoCompleteOption = { + label: string + value: string +} + +export type AutoCompleteOptions = Array + +interface Props { + options: AutoCompleteOptions + size?: NormalSizes + status?: NormalTypes + initialValue?: string + value?: string + width?: string + onChange?: (value: string) => void + onSearch?: (value: string) => void + onSelect?: (value: string) => void + searching?: boolean | undefined + clearable?: boolean + className?: string +} + +const defaultProps = { + options: [], + initialValue: '', + disabled: false, + clearable: false, + size: 'medium' as NormalSizes, + className: '', +} + +export type AutoCompleteProps = Props & typeof defaultProps & React.InputHTMLAttributes + +const childrenToOptionsNode = (options: AutoCompleteOptions) => { + if (options.length === 0) return null + + return options.map((item, index) => { + const key = `auto-complete-item-${index}` + if (React.isValidElement(item)) return React.cloneElement(item, { key }) + const validItem = item as AutoCompleteOption + return ( + + {validItem.label} + + ) + }) +} + +// When the search is not set, the "clearable" icon can be displayed in the original location. +// When the search is seted, at least one element should exist to avoid re-render. +const getSearchIcon = (searching?: boolean) => { + if (searching === undefined) return null + return searching ? : +} + +const AutoComplete: React.FC> = ({ + options, initialValue: customInitialValue, onSelect, onSearch, onChange, + searching, children, size, status, value, width, clearable, ...props +}) => { + const ref = useRef(null) + const [state, setState] = useState(customInitialValue) + const [visible, setVisible] = useState(false) + const [, searchChild] = pickChild(children, AutoCompleteSearching) + const [, emptyChild] = pickChild(children, AutoCompleteEmpty) + const autoCompleteItems = useMemo(() => { + const hasSearchChild = searchChild && React.Children.count(searchChild) > 0 + const hasEmptyChild = emptyChild && React.Children.count(emptyChild) > 0 + if (searching) { + return hasSearchChild ? searchChild : Searching... + } + if (options.length === 0) { + if (state === '') return null + return hasEmptyChild ? emptyChild : No Options + } + return childrenToOptionsNode(options) + }, [searching, options]) + const showClearIcon = useMemo( + () => clearable && searching === undefined, + [clearable, searching], + ) + + const updateValue = (val: string) => { + onSelect && onSelect(val) + setState(val) + } + const updateVisible = (next: boolean) => setVisible(next) + const onInputChange = (event: React.ChangeEvent) => { + onSearch && onSearch(event.target.value) + setState(event.target.value) + } + + useEffect(() => onChange && onChange(state), [state]) + useEffect(() => { + if (value === undefined) return + setState(value) + }, [value]) + + const initialValue = useMemo(() => ({ + ref, size, + value: state, + updateValue, + visible, + updateVisible, + }), [state, visible, size]) + + const toggleFocusHandler = (next: boolean) => { + setVisible(next) + if (next) { + onSearch && onSearch(state) + } + } + + const inputProps = { + ...props, + width, + value: state, + } + + return ( + +
+ toggleFocusHandler(true)} + onBlur={() => toggleFocusHandler(false)} + clearable={showClearIcon} + iconRight={getSearchIcon(searching)} + {...inputProps} /> + + {autoCompleteItems} + + + +
+
+ ) +} + +type AutoCompleteComponent

= React.FC

& { + Item: typeof AutoCompleteItem + Option: typeof AutoCompleteItem + Searching: typeof AutoCompleteSearching + Empty: typeof AutoCompleteEmpty +} + +type ComponentProps = Partial & Omit + +(AutoComplete as AutoCompleteComponent).defaultProps = defaultProps + +export default AutoComplete as AutoCompleteComponent diff --git a/components/auto-complete/index.ts b/components/auto-complete/index.ts index e69de29..32f953d 100644 --- a/components/auto-complete/index.ts +++ b/components/auto-complete/index.ts @@ -0,0 +1,11 @@ +import AutoComplete from './auto-complete' +import AutoCompleteItem from './auto-complete-item' +import AutoCompleteSearching from './auto-complete-searching' +import AutoCompleteEmpty from './auto-complete-empty' + +AutoComplete.Item = AutoCompleteItem +AutoComplete.Option = AutoCompleteItem +AutoComplete.Searching = AutoCompleteSearching +AutoComplete.Empty = AutoCompleteEmpty + +export default AutoComplete diff --git a/components/button/button.loading.tsx b/components/button/button.loading.tsx index 1815d95..7003112 100644 --- a/components/button/button.loading.tsx +++ b/components/button/button.loading.tsx @@ -1,7 +1,13 @@ import React from 'react' import useTheme from '../styles/use-theme' -const ButtonLoading: React.FC<{}> = React.memo(() => { +interface Props { + bgColor?: string +} + +const ButtonLoading: React.FC = React.memo(({ + bgColor, +}) => { const theme = useTheme() return ( @@ -21,7 +27,7 @@ const ButtonLoading: React.FC<{}> = React.memo(() => { display: flex; justify-content: center; align-items: center; - background-color: ${theme.palette.accents_1}; + background-color: ${bgColor || theme.palette.accents_1}; } i { diff --git a/components/index.ts b/components/index.ts index 28fdca9..ff42210 100644 --- a/components/index.ts +++ b/components/index.ts @@ -37,3 +37,4 @@ export { default as Tabs } from './tabs' export { default as Progress } from './progress' export { default as Tree } from './file-tree' export { default as Badge } from './badge' +export { default as AutoComplete } from './auto-complete' diff --git a/pages/docs/components/auto-complete.mdx b/pages/docs/components/auto-complete.mdx index 6bedad2..80a7466 100644 --- a/pages/docs/components/auto-complete.mdx +++ b/pages/docs/components/auto-complete.mdx @@ -1,99 +1,256 @@ import { Layout, Playground, Attributes } from 'lib/components' -import { Avatar, Spacer } from 'components' +import { AutoComplete, Spacer, Badge, Row } from 'components' +import { useState, useRef, useEffect } from 'react' export const meta = { - title: 'avatar', - description: 'avatar', + title: 'AutoComplete', + description: 'auto-complete', } -## Avatar - -Avatars represent a user or a team. Stacked avatars represent a group of people. +## Auto Complete { - const url = 'https://zeit.co/api/www/avatar/?u=evilrabbit&s=160' + const options = [ + { label: 'London', value: 'london' }, + { label: 'Sydney', value: 'sydney' }, + { label: 'Shanghai', value: 'shanghai' }, + ] + return +} +`} /> + + { + const options = [ + { label: 'London', value: 'london' }, + { label: 'Sydney', value: 'sydney' }, + { label: 'Shanghai', value: 'shanghai' }, + ] + return +} +`} /> + + { + const allOptions = [ + { label: 'London', value: 'london' }, + { label: 'Sydney', value: 'sydney' }, + { label: 'Shanghai', value: 'shanghai' }, + ] + const [options, setOptions] = useState() + const searchHandler = (currentValue) => { + if (!currentValue) return setOptions([]) + const relatedOptions = allOptions.filter(item => item.value.includes(currentValue)) + setOptions(relatedOptions) + } + return +} +`} /> + + { + const allOptions = [ + { label: 'London', value: 'london' }, + { label: 'Sydney', value: 'sydney' }, + { label: 'Shanghai', value: 'shanghai' }, + ] + const [options, setOptions] = useState() + const [searching, setSearching] = useState(false) + const timer = useRef() + // triggered every time input + const searchHandler = (currentValue) => { + if (!currentValue) return setOptions([]) + setSearching(true) + const relatedOptions = allOptions.filter(item => item.value.includes(currentValue)) + // this is mock async request + // you can get data in any way + timer.current && clearTimeout(timer.current) + timer.current = setTimeout(() => { + setOptions(relatedOptions) + setSearching(false) + clearTimeout(timer.current) + }, 1000) + } + return ( + + ) +} +`} /> + + + + waiting... + + +`} /> + + { + const allOptions = [ + { label: 'London', value: 'london' }, + { label: 'Sydney', value: 'sydney' }, + { label: 'Shanghai', value: 'shanghai' }, + ] + const [options, setOptions] = useState() + const searchHandler = (currentValue) => { + if (!currentValue) return setOptions([]) + const relatedOptions = allOptions.filter(item => item.value.includes(currentValue)) + setOptions(relatedOptions) + } + return ( + + + no options... + + + ) +} +`} /> + + { + const makeOption = (label, value) => ( + +

+
+

Recent search results

+ Recommended +
+ + {label} +
+ + ) + const allOptions = [ + { label: 'London', value: 'london' }, + { label: 'Sydney', value: 'sydney' }, + { label: 'Shanghai', value: 'shanghai' }, + ] + const [options, setOptions] = useState() + const searchHandler = (currentValue) => { + if (!currentValue) return setOptions([]) + const relatedOptions = allOptions.filter(item => item.value.includes(currentValue)) + const customOptions = relatedOptions.map(({ label, value }) => makeOption(label, value)) + setOptions(customOptions) + } + return ( + + ) +} +`} /> + + { + const options = [ + { label: 'London', value: 'london' }, + { label: 'Sydney', value: 'sydney' }, + { label: 'Shanghai', value: 'shanghai' }, + ] return ( <> - - - - + - - - - - - ) -} -`} /> - - - { - const url = 'https://zeit.co/api/www/avatar/?u=evilrabbit&s=160' - return ( - <> - - - - + + + + + ) } `} /> - - - - - -`} /> - - - { - const url = 'https://zeit.co/api/www/avatar/?u=evilrabbit&s=160' - return ( - <> - - - - - - ) + const options = [ + { label: 'London', value: 'london' }, + { label: 'Sydney', value: 'sydney' }, + { label: 'Shanghai', value: 'shanghai' }, + ] + return } `} /> - -Avatar.Props + +AutoComplete.Props | Attribute | Description | Type | Accepted values | Default | ---------- | ---------- | ---- | -------------- | ------ | -| **src** | image src | `string` | - | - | -| **stacked** | stacked display group | `boolean` | - | `false` | -| **text** | display text when image is missing | `string` | - | - | -| **size** | avatar size | `string` / `number` | `'mini', 'small', 'medium', 'large', number` | `medium` | -| **isSquare** | avatar shape | `boolean` | - | `false` | -| ... | native props | `ImgHTMLAttributes` | `'alt', 'crossOrigin', 'className', ...` | - | +| **options** | options of input | `AutoCompleteOptions` | - | - | +| **status** | input type | `NormalTypes` | `'default', 'secondary', 'success', 'warning', 'error'` | `default` | +| **size** | input size | `NormalSizes` | `'mini', 'small', 'medium', 'large'` | `medium` | +| **initialValue** | initial value | `string` | - | - | +| **value** | current value | `string` | - | - | +| **width** | container width | `string` | - | - | +| **clearable** | show clear icon | `boolean` | - | `false` | +| **searching** | show loading icon for search | `boolean` | - | `false` | +| **onChange** | value of input is changed | `(value: string) => void` | - | - | +| **onSearch** | called when searching items | `(value: string) => void` | - | - | +| **onSelect** | called when a option is selected | `(value: string) => void` | - | - | +| ... | native props | `InputHTMLAttributes` | `'autoComplete', 'type', 'className', ...` | - | + +AutoComplete.Item + +| Attribute | Description | Type | Accepted values | Default +| ---------- | ---------- | ---- | -------------- | ------ | +| **value** | a unique ident value | `string` | - | - | +| ... | native props | `HTMLAttributes` | `'id', 'className', ...` | - | + +AutoComplete.Searching + +| Attribute | Description | Type | Accepted values | Default +| ---------- | ---------- | ---- | -------------- | ------ | +| ... | native props | `HTMLAttributes` | `'id', 'className', ...` | - | + +AutoComplete.Empty + +| Attribute | Description | Type | Accepted values | Default +| ---------- | ---------- | ---- | -------------- | ------ | +| ... | native props | `HTMLAttributes` | `'id', 'className', ...` | - | + +type AutoCompleteOptions + +```ts +Array<{ + label: string + value: string +} | AutoComplete.Item> +```