mirror of
https://github.com/zhigang1992/react.git
synced 2026-04-28 20:25:29 +08:00
feat(auto-complete): add component
This commit is contained in:
@@ -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<HTMLElement | null>
|
||||
}
|
||||
|
||||
const defaultContext = {
|
||||
visible: false,
|
||||
size: 'medium' as NormalSizes,
|
||||
disableAll: false,
|
||||
}
|
||||
|
||||
export const SelectContext = React.createContext<SelectConfig>(defaultContext)
|
||||
export const AutoCompleteContext = React.createContext<AutoCompleteConfig>(defaultContext)
|
||||
|
||||
export const useSelectContext = (): SelectConfig => React.useContext<SelectConfig>(SelectContext)
|
||||
export const useAutoCompleteContext = (): AutoCompleteConfig => React.useContext<AutoCompleteConfig>(AutoCompleteContext)
|
||||
|
||||
@@ -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<React.PropsWithChildren<Props>> = ({
|
||||
children, visible
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
const { ref } = useAutoCompleteContext()
|
||||
const clickHandler = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
</div>
|
||||
<Dropdown parent={ref} visible={visible}>
|
||||
<div className="auto-dropdown-dropdown" onClick={clickHandler}>
|
||||
{children}
|
||||
<style jsx>{`
|
||||
.auto-dropdown-dropdown {
|
||||
border-radius: ${theme.layout.radius};
|
||||
box-shadow: ${theme.expressiveness.shadowLarge};
|
||||
background-color: ${theme.palette.background};
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default AutoComplete
|
||||
export default AutoCompleteDropdown
|
||||
|
||||
@@ -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<any>
|
||||
export type AutoCompleteEmptyProps = Props & typeof defaultProps & React.HTMLAttributes<any>
|
||||
|
||||
const AutoCompleteSearch: React.FC<React.PropsWithChildren<AutoCompleteSearchProps>> = ({
|
||||
const AutoCompleteEmpty: React.FC<React.PropsWithChildren<AutoCompleteEmptyProps>> = ({
|
||||
children, className,
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{children}
|
||||
<style jsx>{`
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
white-space: pre;
|
||||
font-size: .875rem;
|
||||
padding: ${theme.layout.gapHalf};
|
||||
line-height: 1;
|
||||
background-color: ${theme.palette.background};
|
||||
color: ${theme.palette.accents_5};
|
||||
user-select: none;
|
||||
border: 0;
|
||||
border-radius: ${theme.layout.radius};
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
return <AutoCompleteSearch className={className}>{children}</AutoCompleteSearch>
|
||||
}
|
||||
|
||||
export default withDefaults(AutoCompleteSearch, defaultProps)
|
||||
export default withDefaults(AutoCompleteEmpty, defaultProps)
|
||||
|
||||
@@ -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<any>
|
||||
|
||||
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<React.PropsWithChildren<AutoCompleteItemProps>> = ({
|
||||
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 (
|
||||
<div>
|
||||
|
||||
<div className={`item ${isActive ? 'active' : ''}`} onClick={selectHandler}>
|
||||
{children}
|
||||
<style jsx>{`
|
||||
.item {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
white-space: pre;
|
||||
font-size: ${fontSize};
|
||||
padding: ${theme.layout.gapHalf};
|
||||
line-height: 1.2;
|
||||
background-color: ${theme.palette.background};
|
||||
color: ${theme.palette.foreground};
|
||||
user-select: none;
|
||||
border: 0;
|
||||
cursor: ${disabled ? 'not-allowed' : 'pointer'};
|
||||
transition: background 0.2s ease 0s, border-color 0.2s ease 0s;
|
||||
}
|
||||
|
||||
.item:first-of-type {
|
||||
border-top-left-radius: ${theme.layout.radius};
|
||||
border-top-right-radius: ${theme.layout.radius};
|
||||
}
|
||||
|
||||
.item:last-of-type {
|
||||
border-bottom-left-radius: ${theme.layout.radius};
|
||||
border-bottom-right-radius: ${theme.layout.radius};
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: ${theme.palette.accents_1};
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background-color: ${theme.palette.accents_1};
|
||||
color: ${theme.palette.success};
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AutoComplete
|
||||
export default withDefaults(AutoCompleteItem, defaultProps)
|
||||
|
||||
@@ -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<any>
|
||||
export type AutoCompleteSearchProps = Props & typeof defaultProps & React.HTMLAttributes<any>
|
||||
|
||||
const AutoCompleteItem: React.FC<React.PropsWithChildren<AutoCompleteItemProps>> = ({
|
||||
value: identValue, children, disabled,
|
||||
const AutoCompleteSearch: React.FC<React.PropsWithChildren<AutoCompleteSearchProps>> = ({
|
||||
children, className,
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
const { value, updateValue } = useAutoCompleteContext()
|
||||
const selectHandler = () => {
|
||||
updateValue && updateValue(identValue)
|
||||
}
|
||||
|
||||
const isActive = useMemo(() => value === identValue, [identValue, value])
|
||||
|
||||
|
||||
return (
|
||||
<div className={`item ${isActive ? 'active' : ''}`} onClick={selectHandler}>
|
||||
<div className={className}>
|
||||
{children}
|
||||
<style jsx>{`
|
||||
.item {
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
white-space: pre;
|
||||
@@ -39,34 +32,14 @@ const AutoCompleteItem: React.FC<React.PropsWithChildren<AutoCompleteItemProps>>
|
||||
padding: ${theme.layout.gapHalf};
|
||||
line-height: 1;
|
||||
background-color: ${theme.palette.background};
|
||||
color: ${theme.palette.foreground};
|
||||
color: ${theme.palette.accents_5};
|
||||
user-select: none;
|
||||
border: 0;
|
||||
cursor: ${disabled ? 'not-allowed' : 'pointer'};
|
||||
transition: background 0.2s ease 0s, border-color 0.2s ease 0s;
|
||||
}
|
||||
|
||||
.item:first-of-type {
|
||||
border-top-left-radius: ${theme.layout.radius};
|
||||
border-top-right-radius: ${theme.layout.radius};
|
||||
}
|
||||
|
||||
.item:last-of-type {
|
||||
border-bottom-left-radius: ${theme.layout.radius};
|
||||
border-bottom-right-radius: ${theme.layout.radius};
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: ${theme.palette.accents_1};
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background-color: ${theme.palette.accents_1};
|
||||
color: ${theme.palette.success};
|
||||
border-radius: ${theme.layout.radius};
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withDefaults(AutoCompleteItem, defaultProps)
|
||||
export default withDefaults(AutoCompleteSearch, defaultProps)
|
||||
|
||||
@@ -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<AutoCompleteOption | typeof AutoCompleteItem>
|
||||
|
||||
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<any>
|
||||
|
||||
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 (
|
||||
<AutoCompleteItem key={key}
|
||||
value={validItem.value}>
|
||||
{validItem.label}
|
||||
</AutoCompleteItem>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 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 ? <ButtonLoading bgColor="transparent" /> : <span />
|
||||
}
|
||||
|
||||
const AutoComplete: React.FC<React.PropsWithChildren<AutoCompleteProps>> = ({
|
||||
options, initialValue: customInitialValue, onSelect, onSearch, onChange,
|
||||
searching, children, size, status, value, width, clearable, ...props
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [state, setState] = useState<string>(customInitialValue)
|
||||
const [visible, setVisible] = useState<boolean>(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 : <AutoCompleteSearching>Searching...</AutoCompleteSearching>
|
||||
}
|
||||
if (options.length === 0) {
|
||||
if (state === '') return null
|
||||
return hasEmptyChild ? emptyChild : <AutoCompleteEmpty>No Options</AutoCompleteEmpty>
|
||||
}
|
||||
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<HTMLInputElement>) => {
|
||||
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<AutoCompleteConfig>(() => ({
|
||||
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 (
|
||||
<AutoCompleteContext.Provider value={initialValue}>
|
||||
<div ref={ref} className="auto-complete">
|
||||
<Input size={size} status={status}
|
||||
onChange={onInputChange}
|
||||
onFocus={() => toggleFocusHandler(true)}
|
||||
onBlur={() => toggleFocusHandler(false)}
|
||||
clearable={showClearIcon}
|
||||
iconRight={getSearchIcon(searching)}
|
||||
{...inputProps} />
|
||||
<AutoCompleteDropdown visible={visible}>
|
||||
{autoCompleteItems}
|
||||
</AutoCompleteDropdown>
|
||||
|
||||
<style jsx>{`
|
||||
.auto-complete {
|
||||
width: ${width || 'max-content'};
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</AutoCompleteContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
type AutoCompleteComponent<P = {}> = React.FC<P> & {
|
||||
Item: typeof AutoCompleteItem
|
||||
Option: typeof AutoCompleteItem
|
||||
Searching: typeof AutoCompleteSearching
|
||||
Empty: typeof AutoCompleteEmpty
|
||||
}
|
||||
|
||||
type ComponentProps = Partial<typeof defaultProps> & Omit<Props, keyof typeof defaultProps>
|
||||
|
||||
(AutoComplete as AutoCompleteComponent<ComponentProps>).defaultProps = defaultProps
|
||||
|
||||
export default AutoComplete as AutoCompleteComponent<ComponentProps>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Props> = React.memo(({
|
||||
bgColor,
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<span className="loading">
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user