diff --git a/components/auto-complete/auto-complete-context.ts b/components/auto-complete/auto-complete-context.ts new file mode 100644 index 0000000..c7acc6e --- /dev/null +++ b/components/auto-complete/auto-complete-context.ts @@ -0,0 +1,20 @@ +import React, { MutableRefObject } from 'react' +import { NormalSizes } from '../utils/prop-types' + +export interface AutoCompleteConfig { + value?: string + updateValue?: Function + visible?: boolean + updateVisible?: Function + size?: NormalSizes + ref?: MutableRefObject +} + +const defaultContext = { + visible: false, + size: 'medium' as NormalSizes, +} + +export const AutoCompleteContext = React.createContext(defaultContext) + +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 new file mode 100644 index 0000000..07de10c --- /dev/null +++ b/components/auto-complete/auto-complete-dropdown.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import useTheme from '../styles/use-theme' +import { useAutoCompleteContext } from './auto-complete-context' +import Dropdown from '../shared/dropdown' + +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 AutoCompleteDropdown diff --git a/components/auto-complete/auto-complete-empty.tsx b/components/auto-complete/auto-complete-empty.tsx new file mode 100644 index 0000000..de5272b --- /dev/null +++ b/components/auto-complete/auto-complete-empty.tsx @@ -0,0 +1,22 @@ +import React from 'react' +import withDefaults from '../utils/with-defaults' +import AutoCompleteSearch from './auto-complete-searching' + +interface Props { + className?: string +} + +const defaultProps = { + className: '', +} + +export type AutoCompleteEmptyProps = Props & typeof defaultProps & React.HTMLAttributes + +const AutoCompleteEmpty: React.FC> = ({ + children, className, +}) => { + + return {children} +} + +export default withDefaults(AutoCompleteEmpty, defaultProps) diff --git a/components/auto-complete/auto-complete-item.tsx b/components/auto-complete/auto-complete-item.tsx new file mode 100644 index 0000000..68dbb03 --- /dev/null +++ b/components/auto-complete/auto-complete-item.tsx @@ -0,0 +1,84 @@ +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' + +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 withDefaults(AutoCompleteItem, defaultProps) diff --git a/components/auto-complete/auto-complete-searching.tsx b/components/auto-complete/auto-complete-searching.tsx new file mode 100644 index 0000000..ec2a88e --- /dev/null +++ b/components/auto-complete/auto-complete-searching.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import withDefaults from '../utils/with-defaults' +import useTheme from '../styles/use-theme' + +interface Props { + className?: string +} + +const defaultProps = { + className: '', +} + +export type AutoCompleteSearchProps = Props & typeof defaultProps & React.HTMLAttributes + +const AutoCompleteSearch: React.FC> = ({ + children, className, +}) => { + const theme = useTheme() + + return ( +
+ {children} + +
+ ) +} + +export default withDefaults(AutoCompleteSearch, defaultProps) diff --git a/components/auto-complete/auto-complete.tsx b/components/auto-complete/auto-complete.tsx new file mode 100644 index 0000000..43eb4b8 --- /dev/null +++ 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 new file mode 100644 index 0000000..32f953d --- /dev/null +++ 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/components/input/input-icon-clear.tsx b/components/input/input-icon-clear.tsx index ded07ae..05f4d9b 100644 --- a/components/input/input-icon-clear.tsx +++ b/components/input/input-icon-clear.tsx @@ -2,13 +2,14 @@ import React, { useMemo } from 'react' import useTheme from '../styles/use-theme' interface Props { + visibale: boolean onClick?: (event: React.MouseEvent) => void heightRatio?: string | undefined disabled?: boolean } const InputIconClear: React.FC = ({ - onClick, heightRatio, disabled, + onClick, heightRatio, disabled, visibale, }) => { const theme = useTheme() const width = useMemo(() => { @@ -21,7 +22,7 @@ const InputIconClear: React.FC = ({ onClick && onClick(event) } return ( -

+
@@ -39,6 +40,13 @@ const InputIconClear: React.FC = ({ box-sizing: border-box; transition: color 150ms ease 0s; color: ${theme.palette.accents_3}; + visibility: hidden; + opacity: 0; + } + + .visibale { + visibility: visible; + opacity: 1; } div:hover { diff --git a/components/input/input-icon.tsx b/components/input/input-icon.tsx index 6756862..a9fa558 100644 --- a/components/input/input-icon.tsx +++ b/components/input/input-icon.tsx @@ -32,6 +32,7 @@ const InputIcon: React.FC = React.memo(({ margin: 0; padding: 0 ${padding}; line-height: 1; + position: relative; } `} diff --git a/components/input/input.tsx b/components/input/input.tsx index 3d4344b..716a173 100644 --- a/components/input/input.tsx +++ b/components/input/input.tsx @@ -24,6 +24,9 @@ interface Props { clearable?: boolean onChange?: (e: React.ChangeEvent) => void onClearClick?: (e: React.MouseEvent) => void + onFocus?: (e: React.FocusEvent) => void + onBlur?: (e: React.FocusEvent) => void + autoComplete: string } const defaultProps = { @@ -33,6 +36,7 @@ const defaultProps = { width: 'initial', size: 'medium', status: 'default', + autoComplete: 'off', className: '', placeholder: '', initialValue: '', @@ -43,12 +47,14 @@ export type InputProps = Props & typeof defaultProps & React.InputHTMLAttributes const Input: React.FC = ({ placeholder, label, labelRight, size, status, disabled, icon, iconRight, initialValue, onChange, readOnly, value, - onClearClick, clearable, width, className, ...props + onClearClick, clearable, width, className, onBlur, onFocus, + autoComplete, ...props }) => { const theme = useTheme() const [selfValue, setSelfValue] = useState(initialValue) const [hover, setHover] = useState(false) const { heightRatio, fontSize } = useMemo(() => getSizes(size),[size]) + const showClearIcon = useMemo(() => clearable && selfValue !== '', [selfValue, clearable]) const labelClasses = useMemo( () => labelRight ? 'right-label' : (label ? 'left-label' : ''), [label, labelRight], @@ -63,15 +69,23 @@ const Input: React.FC = ({ ) const changeHandler = (event: React.ChangeEvent) => { if (disabled || readOnly) return - console.log(123, event.target.value) setSelfValue(event.target.value) onChange && onChange(event) } - + const clearHandler = (event: React.MouseEvent) => { setSelfValue('') onClearClick && onClearClick(event) } + + const focusHandler = (e: React.FocusEvent) => { + setHover(true) + onFocus && onFocus(e) + } + const blurHandler = (e: React.FocusEvent) => { + setHover(false) + onBlur && onBlur(e) + } useEffect(() => { if (value === undefined) return @@ -88,12 +102,15 @@ const Input: React.FC = ({ placeholder={placeholder} disabled={disabled} readOnly={readOnly} - onFocus={() => setHover(true)} - onBlur={() => setHover(false)} + onFocus={focusHandler} + onBlur={blurHandler} onChange={changeHandler} + autoComplete={autoComplete} {...props} /> - {clearable && } {iconRight && } diff --git a/pages/docs/components/auto-complete.mdx b/pages/docs/components/auto-complete.mdx new file mode 100644 index 0000000..80a7466 --- /dev/null +++ b/pages/docs/components/auto-complete.mdx @@ -0,0 +1,257 @@ +import { Layout, Playground, Attributes } from 'lib/components' +import { AutoComplete, Spacer, Badge, Row } from 'components' +import { useState, useRef, useEffect } from 'react' + +export const meta = { + title: 'AutoComplete', + description: 'auto-complete', +} + +## Auto Complete + + + { + 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 options = [ + { label: 'London', value: 'london' }, + { label: 'Sydney', value: 'sydney' }, + { label: 'Shanghai', value: 'shanghai' }, + ] + return +} +`} /> + + +AutoComplete.Props + +| Attribute | Description | Type | Accepted values | Default +| ---------- | ---------- | ---- | -------------- | ------ | +| **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> +``` + + + +export default ({ children }) => {children}