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

相关文章

pycharm远程连接服务器运行pytorch的过程详解

《pycharm远程连接服务器运行pytorch的过程详解》:本文主要介绍在Linux环境下使用Anaconda管理不同版本的Python环境,并通过PyCharm远程连接服务器来运行PyTorc... 目录linux部署pytorch背景介绍Anaconda安装Linux安装pytorch虚拟环境安装cu

Vue项目的甘特图组件之dhtmlx-gantt使用教程和实现效果展示(推荐)

《Vue项目的甘特图组件之dhtmlx-gantt使用教程和实现效果展示(推荐)》文章介绍了如何使用dhtmlx-gantt组件来实现公司的甘特图需求,并提供了一个简单的Vue组件示例,文章还分享了一... 目录一、首先 npm 安装插件二、创建一个vue组件三、业务页面内 引用自定义组件:四、dhtmlx

Vue ElementUI中Upload组件批量上传的实现代码

《VueElementUI中Upload组件批量上传的实现代码》ElementUI中Upload组件批量上传通过获取upload组件的DOM、文件、上传地址和数据,封装uploadFiles方法,使... ElementUI中Upload组件如何批量上传首先就是upload组件 <el-upl

MobaXterm远程登录工具功能与应用小结

《MobaXterm远程登录工具功能与应用小结》MobaXterm是一款功能强大的远程终端软件,主要支持SSH登录,拥有多种远程协议,实现跨平台访问,它包括多会话管理、本地命令行执行、图形化界面集成和... 目录1. 远程终端软件概述1.1 远程终端软件的定义与用途1.2 远程终端软件的关键特性2. 支持的

Vue3中的动态组件详解

《Vue3中的动态组件详解》本文介绍了Vue3中的动态组件,通过`component:is=动态组件名或组件对象/component`来实现根据条件动态渲染不同的组件,此外,还提到了使用`markRa... 目录vue3动态组件动态组件的基本使用第一种写法第二种写法性能优化解决方法总结Vue3动态组件动态

spring-boot-starter-thymeleaf加载外部html文件方式

《spring-boot-starter-thymeleaf加载外部html文件方式》本文介绍了在SpringMVC中使用Thymeleaf模板引擎加载外部HTML文件的方法,以及在SpringBoo... 目录1.Thymeleaf介绍2.springboot使用thymeleaf2.1.引入spring

定价129元!支持双频 Wi-Fi 5的华为AX1路由器发布

《定价129元!支持双频Wi-Fi5的华为AX1路由器发布》华为上周推出了其最新的入门级Wi-Fi5路由器——华为路由AX1,建议零售价129元,这款路由器配置如何?详细请看下文介... 华为 Wi-Fi 5 路由 AX1 已正式开售,新品支持双频 1200 兆、配有四个千兆网口、提供可视化智能诊断功能,建

关于Spring @Bean 相同加载顺序不同结果不同的问题记录

《关于Spring@Bean相同加载顺序不同结果不同的问题记录》本文主要探讨了在Spring5.1.3.RELEASE版本下,当有两个全注解类定义相同类型的Bean时,由于加载顺序不同,最终生成的... 目录问题说明测试输出1测试输出2@Bean注解的BeanDefiChina编程nition加入时机总结问题说明

VScode连接远程Linux服务器环境配置图文教程

《VScode连接远程Linux服务器环境配置图文教程》:本文主要介绍如何安装和配置VSCode,包括安装步骤、环境配置(如汉化包、远程SSH连接)、语言包安装(如C/C++插件)等,文中给出了详... 目录一、安装vscode二、环境配置1.中文汉化包2.安装remote-ssh,用于远程连接2.1安装2

四种Flutter子页面向父组件传递数据的方法介绍

《四种Flutter子页面向父组件传递数据的方法介绍》在Flutter中,如果父组件需要调用子组件的方法,可以通过常用的四种方式实现,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录方法 1:使用 GlobalKey 和 State 调用子组件方法方法 2:通过回调函数(Callb