HOW - 支持防抖和远程加载的人员搜索组件(shadcn)

2024-09-05 16:04

本文主要是介绍HOW - 支持防抖和远程加载的人员搜索组件(shadcn),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

目录

  • 特性
  • 一、使用示例
  • 二、具体组件实现
  • 三、解释
    • 1. 属性定义
    • 2. 状态管理
    • 3. 功能实现
    • 4. 渲染逻辑

特性

  1. 支持 focus 时即发起请求获取用户列表
  2. 输入时 debounce 防抖
  3. 选中后以 tag 形式展示选项,这次点击 x 删除
  4. 搜索结果中若包含已选项会过滤,即隐藏已选中项
  5. 支持设置指定选项 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:一个包含选项的数组,每个选项有 labelvalue
  • 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)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1139421

相关文章

HTML5 搜索框Search Box详解

《HTML5搜索框SearchBox详解》HTML5的搜索框是一个强大的工具,能够有效提升用户体验,通过结合自动补全功能和适当的样式,可以创建出既美观又实用的搜索界面,这篇文章给大家介绍HTML5... html5 搜索框(Search Box)详解搜索框是一个用于输入查询内容的控件,通常用于网站或应用程

华为鸿蒙HarmonyOS 5.1官宣7月开启升级! 首批支持名单公布

《华为鸿蒙HarmonyOS5.1官宣7月开启升级!首批支持名单公布》在刚刚结束的华为Pura80系列及全场景新品发布会上,除了众多新品的发布,还有一个消息也点燃了所有鸿蒙用户的期待,那就是Ha... 在今日的华为 Pura 80 系列及全场景新品发布会上,华为宣布鸿蒙 HarmonyOS 5.1 将于 7

springboot加载不到nacos配置中心的配置问题处理

《springboot加载不到nacos配置中心的配置问题处理》:本文主要介绍springboot加载不到nacos配置中心的配置问题处理,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑... 目录springboot加载不到nacos配置中心的配置两种可能Spring Boot 版本Nacos

Python远程控制MySQL的完整指南

《Python远程控制MySQL的完整指南》MySQL是最流行的关系型数据库之一,Python通过多种方式可以与MySQL进行交互,下面小编就为大家详细介绍一下Python操作MySQL的常用方法和最... 目录1. 准备工作2. 连接mysql数据库使用mysql-connector使用PyMySQL3.

Linux使用scp进行远程目录文件复制的详细步骤和示例

《Linux使用scp进行远程目录文件复制的详细步骤和示例》在Linux系统中,scp(安全复制协议)是一个使用SSH(安全外壳协议)进行文件和目录安全传输的命令,它允许在远程主机之间复制文件和目录,... 目录1. 什么是scp?2. 语法3. 示例示例 1: 复制本地目录到远程主机示例 2: 复制远程主

Spring组件实例化扩展点之InstantiationAwareBeanPostProcessor使用场景解析

《Spring组件实例化扩展点之InstantiationAwareBeanPostProcessor使用场景解析》InstantiationAwareBeanPostProcessor是Spring... 目录一、什么是InstantiationAwareBeanPostProcessor?二、核心方法解

IDEA如何实现远程断点调试jar包

《IDEA如何实现远程断点调试jar包》:本文主要介绍IDEA如何实现远程断点调试jar包的问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录问题步骤总结问题以jar包的形式运行Spring Boot项目时报错,但是在IDEA开发环境javascript下编译

C++ RabbitMq消息队列组件详解

《C++RabbitMq消息队列组件详解》:本文主要介绍C++RabbitMq消息队列组件的相关知识,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧... 目录1. RabbitMq介绍2. 安装RabbitMQ3. 安装 RabbitMQ 的 C++客户端库4. A

使用Python获取JS加载的数据的多种实现方法

《使用Python获取JS加载的数据的多种实现方法》在当今的互联网时代,网页数据的动态加载已经成为一种常见的技术手段,许多现代网站通过JavaScript(JS)动态加载内容,这使得传统的静态网页爬取... 目录引言一、动态 网页与js加载数据的原理二、python爬取JS加载数据的方法(一)分析网络请求1

IDEA下"File is read-only"可能原因分析及"找不到或无法加载主类"的问题

《IDEA下Fileisread-only可能原因分析及找不到或无法加载主类的问题》:本文主要介绍IDEA下Fileisread-only可能原因分析及找不到或无法加载主类的问题,具有很好的参... 目录1.File is read-only”可能原因2.“找不到或无法加载主类”问题的解决总结1.File