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

相关文章

SpringBoot项目启动后自动加载系统配置的多种实现方式

《SpringBoot项目启动后自动加载系统配置的多种实现方式》:本文主要介绍SpringBoot项目启动后自动加载系统配置的多种实现方式,并通过代码示例讲解的非常详细,对大家的学习或工作有一定的... 目录1. 使用 CommandLineRunner实现方式:2. 使用 ApplicationRunne

vue解决子组件样式覆盖问题scoped deep

《vue解决子组件样式覆盖问题scopeddeep》文章主要介绍了在Vue项目中处理全局样式和局部样式的方法,包括使用scoped属性和深度选择器(/deep/)来覆盖子组件的样式,作者建议所有组件... 目录前言scoped分析deep分析使用总结所有组件必须加scoped父组件覆盖子组件使用deep前言

基于Qt Qml实现时间轴组件

《基于QtQml实现时间轴组件》时间轴组件是现代用户界面中常见的元素,用于按时间顺序展示事件,本文主要为大家详细介绍了如何使用Qml实现一个简单的时间轴组件,需要的可以参考下... 目录写在前面效果图组件概述实现细节1. 组件结构2. 属性定义3. 数据模型4. 事件项的添加和排序5. 事件项的渲染如何使用

SpringBoot项目删除Bean或者不加载Bean的问题解决

《SpringBoot项目删除Bean或者不加载Bean的问题解决》文章介绍了在SpringBoot项目中如何使用@ComponentScan注解和自定义过滤器实现不加载某些Bean的方法,本文通过实... 使用@ComponentScan注解中的@ComponentScan.Filter标记不加载。@C

Xshell远程连接失败以及解决方案

《Xshell远程连接失败以及解决方案》本文介绍了在Windows11家庭版和CentOS系统中解决Xshell无法连接远程服务器问题的步骤,在Windows11家庭版中,需要通过设置添加SSH功能并... 目录一.问题描述二.原因分析及解决办法2.1添加ssh功能2.2 在Windows中开启ssh服务2

springboot 加载本地jar到maven的实现方法

《springboot加载本地jar到maven的实现方法》如何在SpringBoot项目中加载本地jar到Maven本地仓库,使用Maven的install-file目标来实现,本文结合实例代码给... 在Spring Boothttp://www.chinasem.cn项目中,如果你想要加载一个本地的ja

最好用的WPF加载动画功能

《最好用的WPF加载动画功能》当开发应用程序时,提供良好的用户体验(UX)是至关重要的,加载动画作为一种有效的沟通工具,它不仅能告知用户系统正在工作,还能够通过视觉上的吸引力来增强整体用户体验,本文给... 目录前言需求分析高级用法综合案例总结最后前言当开发应用程序时,提供良好的用户体验(UX)是至关重要

Python实现局域网远程控制电脑

《Python实现局域网远程控制电脑》这篇文章主要为大家详细介绍了如何利用Python编写一个工具,可以实现远程控制局域网电脑关机,重启,注销等功能,感兴趣的小伙伴可以参考一下... 目录1.简介2. 运行效果3. 1.0版本相关源码服务端server.py客户端client.py4. 2.0版本相关源码1

MyBatis延迟加载的处理方案

《MyBatis延迟加载的处理方案》MyBatis支持延迟加载(LazyLoading),允许在需要数据时才从数据库加载,而不是在查询结果第一次返回时就立即加载所有数据,延迟加载的核心思想是,将关联对... 目录MyBATis如何处理延迟加载?延迟加载的原理1. 开启延迟加载2. 延迟加载的配置2.1 使用

Android WebView的加载超时处理方案

《AndroidWebView的加载超时处理方案》在Android开发中,WebView是一个常用的组件,用于在应用中嵌入网页,然而,当网络状况不佳或页面加载过慢时,用户可能会遇到加载超时的问题,本... 目录引言一、WebView加载超时的原因二、加载超时处理方案1. 使用Handler和Timer进行超