本文主要是介绍HOW - 支持防抖和远程加载的人员搜索组件(shadcn),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
目录
- 特性
- 一、使用示例
- 二、具体组件实现
- 三、解释
- 1. 属性定义
- 2. 状态管理
- 3. 功能实现
- 4. 渲染逻辑
特性
- 支持 focus 时即发起请求获取用户列表
- 输入时 debounce 防抖
- 选中后以 tag 形式展示选项,这次点击 x 删除
- 搜索结果中若包含已选项会过滤,即隐藏已选中项
- 支持设置指定选项 disabled
一、使用示例
shadcn - multiple-selector - Async Search with Debounce
'use client';
import React from 'react';
import MultipleSelector, { Option } from '@/components/ui/multiple-selector';
import { InlineCode } from '@/components/ui/inline-code';const OPTIONS: Option[] = [{ label: 'nextjs', value: 'Nextjs' },{ label: 'React', value: 'react' },{ label: 'Remix', value: 'remix' },{ label: 'Vite', value: 'vite' },{ label: 'Nuxt', value: 'nuxt' },{ label: 'Vue', value: 'vue' },{ label: 'Svelte', value: 'svelte' },{ label: 'Angular', value: 'angular' },{ label: 'Ember', value: 'ember' },{ label: 'Gatsby', value: 'gatsby' },{ label: 'Astro', value: 'astro' },
];const mockSearch = async (value: string): Promise<Option[]> => {return new Promise((resolve) => {setTimeout(() => {const res = OPTIONS.filter((option) => option.value.includes(value));resolve(res);}, 1000);});
};const MultipleSelectorWithAsyncSearch = () => {const [isTriggered, setIsTriggered] = React.useState(false);return (<div className="flex w-full flex-col gap-5 px-10"><p>Is request been triggered? <InlineCode>{String(isTriggered)}</InlineCode></p><MultipleSelectoronSearch={async (value) => {setIsTriggered(true);const res = await mockSearch(value);setIsTriggered(false);return res;}}placeholder="trying to search 'a' to get more options..."loadingIndicator={<p className="py-2 text-center text-lg leading-10 text-muted-foreground">loading...</p>}emptyIndicator={<p className="w-full text-center text-lg leading-10 text-muted-foreground">no results found.</p>}/></div>);
};export default MultipleSelectorWithAsyncSearch;
在示例代码中,我们展示了如何使用 MultipleSelector
组件来创建一个带有异步搜索功能的多选下拉选择器。
OPTIONS
:一个包含选项的数组,每个选项有label
和value
。mockSearch
:一个模拟的异步搜索函数,根据输入的值返回匹配的选项。MultipleSelectorWithAsyncSearch
:一个使用MultipleSelector
组件的示例,展示了如何在搜索时显示加载指示器和空结果指示器,并且在搜索过程中显示触发状态。
二、具体组件实现
"use client"import * as React from "react"
import { forwardRef, useEffect } from "react"
import { Command as CommandPrimitive, useCommandState } from "cmdk"
import { X } from "lucide-react"import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import {Command,CommandGroup,CommandItem,CommandList,
} from "@/components/ui/command"export interface Option {value: stringlabel: stringdisable?: boolean/** fixed option that can't be removed. */fixed?: boolean/** Group the options by providing key. */[key: string]: string | boolean | undefined
}
interface GroupOption {[key: string]: Option[]
}interface MultipleSelectorProps {value?: Option[]defaultOptions?: Option[]/** manually controlled options */options?: Option[]placeholder?: string/** Loading component. */loadingIndicator?: React.ReactNode/** Empty component. */emptyIndicator?: React.ReactNode/** Debounce time for async search. Only work with `onSearch`. */delay?: number/*** Only work with `onSearch` prop. Trigger search when `onFocus`.* For example, when user click on the input, it will trigger the search to get initial options.**/triggerSearchOnFocus?: boolean/** async search */onSearch?: (value: string) => Promise<Option[]>onChange?: (options: Option[]) => void/** Limit the maximum number of selected options. */maxSelected?: number/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */onMaxSelected?: (maxLimit: number) => void/** Hide the placeholder when there are options selected. */hidePlaceholderWhenSelected?: booleandisabled?: boolean/** Group the options base on provided key. */groupBy?: stringclassName?: stringbadgeClassName?: string/*** First item selected is a default behavior by cmdk. That is why the default is true.* This is a workaround solution by add a dummy item.** @reference: https://github.com/pacocoursey/cmdk/issues/171*/selectFirstItem?: boolean/** Allow user to create option when there is no option matched. */creatable?: boolean/** Props of `Command` */commandProps?: React.ComponentPropsWithoutRef<typeof Command>/** Props of `CommandInput` */inputProps?: Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,"value" | "placeholder" | "disabled">
}export interface MultipleSelectorRef {selectedValue: Option[]input: HTMLInputElement
}function useDebounce<T>(value: T, delay?: number): T {const [debouncedValue, setDebouncedValue] = React.useState<T>(value)useEffect(() => {const timer = setTimeout(() => setDebouncedValue(value), delay || 500)return () => {clearTimeout(timer)}}, [value, delay])return debouncedValue
}function transToGroupOption(options: Option[], groupBy?: string) {if (options.length === 0) {return {}}if (!groupBy) {return {"": options,}}const groupOption: GroupOption = {}options.forEach((option) => {const key = (option[groupBy] as string) || ""if (!groupOption[key]) {groupOption[key] = []}groupOption[key].push(option)})return groupOption
}function removePickedOption(groupOption: GroupOption, picked: Option[]) {const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOptionfor (const [key, value] of Object.entries(cloneOption)) {cloneOption[key] = value.filter((val) => !picked.find((p) => p.value === val.value),)}return cloneOption
}function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {for (const [key, value] of Object.entries(groupOption)) {if (value.some((option) =>targetOption.find((p) => p.value === option.value),)) {return true}}return false
}/*** The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.* So we create one and copy the `Empty` implementation from `cmdk`.** @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607**/
const CommandEmpty = forwardRef<HTMLDivElement,React.ComponentProps<typeof CommandPrimitive.Empty>
>(({ className, ...props }, forwardedRef) => {const render = useCommandState((state) => state.filtered.count === 0)if (!render) return nullreturn (<divref={forwardedRef}className={cn("py-6 text-center text-sm", className)}cmdk-empty=""role="presentation"{...props}/>)
})CommandEmpty.displayName = "CommandEmpty"const MultipleSelector = React.forwardRef<MultipleSelectorRef,MultipleSelectorProps
>(({value,onChange,placeholder,defaultOptions: arrayDefaultOptions = [],options: arrayOptions,delay,onSearch,loadingIndicator,emptyIndicator,maxSelected = Number.MAX_SAFE_INTEGER,onMaxSelected,hidePlaceholderWhenSelected = true,disabled,groupBy,className,badgeClassName,selectFirstItem = true,creatable = false,triggerSearchOnFocus = false,commandProps,inputProps,}: MultipleSelectorProps,ref: React.Ref<MultipleSelectorRef>,) => {const inputRef = React.useRef<HTMLInputElement>(null)const [open, setOpen] = React.useState(false)const [isLoading, setIsLoading] = React.useState(false)const [selected, setSelected] = React.useState<Option[]>(value || [])const [options, setOptions] = React.useState<GroupOption>(transToGroupOption(arrayDefaultOptions, groupBy),)const [inputValue, setInputValue] = React.useState("")const debouncedSearchTerm = useDebounce(inputValue, delay || 500)React.useImperativeHandle(ref,() => ({selectedValue: [...selected],input: inputRef.current as HTMLInputElement,focus: () => inputRef.current?.focus(),}),[selected],)const handleUnselect = React.useCallback((option: Option) => {const newOptions = selected.filter((s) => s.value !== option.value,)setSelected(newOptions)onChange?.(newOptions)},[onChange, selected],)const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {const input = inputRef.currentif (input) {if (e.key === "Delete" || e.key === "Backspace") {if (input.value === "" && selected.length > 0) {const lastSelectOption =selected[selected.length - 1]// If last item is fixed, we should not remove it.if (!lastSelectOption.fixed) {handleUnselect(selected[selected.length - 1])}}}// This is not a default behavior of the <input /> fieldif (e.key === "Escape") {input.blur()}}},[handleUnselect, selected],)useEffect(() => {if (value) {setSelected(value)}}, [value])useEffect(() => {/** If `onSearch` is provided, do not trigger options updated. */if (!arrayOptions || onSearch) {return}const newOption = transToGroupOption(arrayOptions || [], groupBy)if (JSON.stringify(newOption) !== JSON.stringify(options)) {setOptions(newOption)}}, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options])useEffect(() => {const doSearch = async () => {setIsLoading(true)const res = await onSearch?.(debouncedSearchTerm)setOptions(transToGroupOption(res || [], groupBy))setIsLoading(false)}const exec = async () => {if (!onSearch || !open) returnif (triggerSearchOnFocus) {await doSearch()}if (debouncedSearchTerm) {await doSearch()}}void exec()// eslint-disable-next-line react-hooks/exhaustive-deps}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus])const CreatableItem = () => {if (!creatable) return undefinedif (isOptionsExist(options, [{ value: inputValue, label: inputValue },]) ||selected.find((s) => s.value === inputValue)) {return undefined}const Item = (<CommandItemvalue={inputValue}className="cursor-pointer"onMouseDown={(e) => {e.preventDefault()e.stopPropagation()}}onSelect={(value: string) => {if (selected.length >= maxSelected) {onMaxSelected?.(selected.length)return}setInputValue("")const newOptions = [...selected,{ value, label: value },]setSelected(newOptions)onChange?.(newOptions)}}>{`Create "${inputValue}"`}</CommandItem>)// For normal creatableif (!onSearch && inputValue.length > 0) {return Item}// For async search creatable. avoid showing creatable item before loading at first.if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {return Item}return undefined}const EmptyItem = React.useCallback(() => {if (!emptyIndicator) return undefined// For async search that showing emptyIndicatorif (onSearch && !creatable && Object.keys(options).length === 0) {return (<CommandItem value="-" disabled>{emptyIndicator}</CommandItem>)}return <CommandEmpty>{emptyIndicator}</CommandEmpty>}, [creatable, emptyIndicator, onSearch, options])const selectables = React.useMemo<GroupOption>(() => removePickedOption(options, selected),[options, selected],)/** Avoid Creatable Selector freezing or lagging when paste a long string. */const commandFilter = React.useCallback(() => {if (commandProps?.filter) {return commandProps.filter}if (creatable) {return (value: string, search: string) => {return value.toLowerCase().includes(search.toLowerCase())? 1: -1}}// Using default filter in `cmdk`. We don't have to provide it.return undefined}, [creatable, commandProps?.filter])return (<Command{...commandProps}onKeyDown={(e) => {handleKeyDown(e)commandProps?.onKeyDown?.(e)}}className={cn("h-auto overflow-visible bg-transparent",commandProps?.className,)}shouldFilter={commandProps?.shouldFilter !== undefined? commandProps.shouldFilter: !onSearch} // When onSearch is provided, we don't want to filter the options. You can still override it.filter={commandFilter()}><divclassName={cn("min-h-9 bg-accent rounded-md text-sm ring-offset-background focus-within:ring-1 focus-within:ring-ring focus-within:ring-offset-1 focus-within:bg-background",{// 'px-3 py-2': selected.length !== 0,"flex items-center px-1": selected.length !== 0,"cursor-text": !disabled && selected.length !== 0,},className,)}onClick={() => {if (disabled) returninputRef.current?.focus()}}><div className="flex flex-wrap gap-1">{selected.map((option) => {return (<Badgekey={option.value}className={cn("data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground","data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground",badgeClassName,)}data-fixed={option.fixed}data-disabled={disabled || undefined}>{option.label}<buttonclassName={cn("ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",(disabled || option.fixed) &&"hidden",)}onKeyDown={(e) => {if (e.key === "Enter") {handleUnselect(option)}}}onMouseDown={(e) => {e.preventDefault()e.stopPropagation()}}onClick={() => handleUnselect(option)}><X className="h-3 w-3 text-muted-foreground hover:text-foreground" /></button></Badge>)})}{/* Avoid having the "Search" Icon */}<CommandPrimitive.Input{...inputProps}ref={inputRef}value={inputValue}disabled={disabled}onValueChange={(value) => {setInputValue(value)inputProps?.onValueChange?.(value)}}onBlur={(event) => {setOpen(false)inputProps?.onBlur?.(event)}}onFocus={(event) => {setOpen(true)triggerSearchOnFocus &&onSearch?.(debouncedSearchTerm)inputProps?.onFocus?.(event)}}placeholder={hidePlaceholderWhenSelected &&selected.length !== 0? "": placeholder}className={cn("flex-1 bg-transparent outline-none placeholder:text-muted-foreground",{"w-full": hidePlaceholderWhenSelected,"px-3 py-2": selected.length === 0,"ml-1": selected.length !== 0,},inputProps?.className,)}/></div></div><div className="relative">{open && (<CommandList className="absolute top-1 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">{isLoading ? (<>{loadingIndicator}</>) : (<>{EmptyItem()}{CreatableItem()}{!selectFirstItem && (<CommandItemvalue="-"className="hidden"/>)}{Object.entries(selectables).map(([key, dropdowns]) => (<CommandGroupkey={key}heading={key}className="h-full overflow-auto"><>{dropdowns.map((option) => {return (<CommandItemkey={option.value}value={option.value}disabled={option.disable}onMouseDown={(e,) => {e.preventDefault()e.stopPropagation()}}onSelect={() => {if (selected.length >=maxSelected) {onMaxSelected?.(selected.length,)return}setInputValue("",)const newOptions =[...selected,option,]setSelected(newOptions,)onChange?.(newOptions,)}}className={cn("cursor-pointer",option.disable &&"cursor-default text-muted-foreground",)}>{option.label}</CommandItem>)})}</></CommandGroup>),)}</>)}</CommandList>)}</div></Command>)},
)MultipleSelector.displayName = "MultipleSelector"
export { MultipleSelector }
三、解释
这个 MultipleSelector
组件是一个多选下拉选择器,支持异步搜索、分组选项、可创建新选项等功能。以下是对这个组件及其使用的详细解释:
1. 属性定义
组件的属性 (MultipleSelectorProps
) 定义了组件的功能和外观:
value
:当前选中的选项。options
:提供给组件的选项列表。onSearch
:一个异步函数,用于搜索选项。loadingIndicator
:加载时显示的内容。emptyIndicator
:没有结果时显示的内容。maxSelected
:允许选择的最大选项数。onMaxSelected
:当选择的选项数量超过maxSelected
时的回调。creatable
:是否允许创建新的选项。groupBy
:用于分组选项的字段。inputProps
:传递给输入框的属性。
2. 状态管理
组件使用多个状态来管理其内部逻辑:
open
:控制下拉列表的打开和关闭状态。isLoading
:指示是否正在加载选项。selected
:当前选中的选项。options
:可供选择的选项列表。inputValue
:输入框的值。
3. 功能实现
useDebounce
:防止在用户输入时频繁触发搜索。将输入值在一定延迟后更新。transToGroupOption
:根据groupBy
属性将选项分组。removePickedOption
:从选项中移除已选中的选项。isOptionsExist
:检查选项是否存在于当前选项中。CreatableItem
:如果creatable
属性为真,允许用户创建新的选项。EmptyItem
:显示空结果提示或自定义的空提示组件。
4. 渲染逻辑
- 输入框:用户可以在输入框中输入文本来过滤选项。
- 选择的项:已选中的项以徽章形式显示,用户可以点击徽章上的删除按钮来取消选择。
- 下拉列表:显示所有可选项、创建新选项的选项、加载指示器和空结果提示。
这篇关于HOW - 支持防抖和远程加载的人员搜索组件(shadcn)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!