wui
组件

提及输入 Mention

用于协同评论、讨论区或 AI 输入框中键入 @、# 或 / 快速匹配并插入人员、话题或指令。

第三方依赖 · class-variance-authority第三方依赖 · lucide-react第三方依赖 · motion

基础示例 · Basic example

Loading…
pnpm dlx @wui-design/cli@latest add @wui/mention
components/ui/mention.tsx
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { AtSignIcon, HashIcon, SlashIcon } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { cn } from "@/lib/utils"

export interface MentionOption {
  /** 选项唯一标识值。 */
  id: string
  /** 选项展示主文本。 */
  label: string
  /** 补充描述信息。 */
  description?: string
  /** 自定义头像或图标。 */
  icon?: React.ReactNode
  /** 分组标签。 */
  group?: string
  /** 徽标标签。 */
  badge?: React.ReactNode
}

const mentionVariants = cva(
  "relative flex w-full flex-col rounded-md border transition-[border-color,box-shadow,background-color] duration-200 ease-out focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/30 motion-reduce:transition-none",
  {
    variants: {
      variant: {
        default: "border-input bg-background shadow-xs",
        ghost: "border-transparent bg-muted/50 focus-within:bg-background",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

export interface MentionProps
  extends Omit<React.ComponentProps<"div">, "onChange">,
    VariantProps<typeof mentionVariants> {
  /** 触发提及菜单的前缀字符(如 "@", "#", "/")。 @default "@" */
  trigger?: string
  /** 可供匹配选择的候选提及列表。 */
  options?: MentionOption[]
  /** 当前输入框文本(受控模式)。 */
  value?: string
  /** 默认输入框文本(非受控模式)。 */
  defaultValue?: string
  /** 输入文本发生变动时的回调。 */
  onValueChange?: (value: string) => void
  /** 选中某项提及项时的回调函数。 */
  onSelectOption?: (option: MentionOption) => void
  /** 输入框占位提示文案。 */
  placeholder?: string
  /** 外观样式变体。 @default "default" */
  variant?: "default" | "ghost"
}

/** 提及输入组件,支持键入特定前缀(如 @ 或 #)快速检索并插入成员、标签或快捷指令。 */
function Mention({
  className,
  variant = "default",
  trigger = "@",
  options = [],
  value: controlledValue,
  defaultValue = "",
  onValueChange,
  onSelectOption,
  placeholder = "输入 @ 提及成员或技能...",
  ...props
}: MentionProps) {
  const reduceMotion = useReducedMotion()
  const baseId = React.useId()
  const listId = `${baseId}-list`
  const [internalValue, setInternalValue] = React.useState(defaultValue)
  const [isOpen, setIsOpen] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const [selectedIndex, setSelectedIndex] = React.useState(0)
  const inputRef = React.useRef<HTMLTextAreaElement>(null)
  const listRef = React.useRef<HTMLDivElement>(null)

  const value = controlledValue !== undefined ? controlledValue : internalValue

  const filteredOptions = React.useMemo(() => {
    if (!query) return options
    const q = query.toLowerCase()
    return options.filter(
      (opt) =>
        opt.label.toLowerCase().includes(q) ||
        (opt.description && opt.description.toLowerCase().includes(q))
    )
  }, [options, query])

  const showList = isOpen && filteredOptions.length > 0
  const activeOption = showList ? filteredOptions[selectedIndex] : undefined
  const optionId = (option: MentionOption) => `${baseId}-option-${option.id}`

  React.useEffect(() => {
    if (!activeOption) return
    listRef.current
      ?.querySelector(`[id="${CSS.escape(optionId(activeOption))}"]`)
      ?.scrollIntoView({ block: "nearest" })
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [activeOption?.id])

  const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    const nextValue = e.target.value
    const cursor = e.target.selectionStart ?? 0
    const textBeforeCursor = nextValue.slice(0, cursor)
    const triggerIndex = textBeforeCursor.lastIndexOf(trigger)

    if (triggerIndex !== -1 && (triggerIndex === 0 || /\s/.test(textBeforeCursor[triggerIndex - 1]))) {
      const currentQuery = textBeforeCursor.slice(triggerIndex + 1)
      if (!/\s/.test(currentQuery)) {
        setQuery(currentQuery)
        setIsOpen(true)
        setSelectedIndex(0)
      } else {
        setIsOpen(false)
      }
    } else {
      setIsOpen(false)
    }

    if (controlledValue === undefined) {
      setInternalValue(nextValue)
    }
    onValueChange?.(nextValue)
  }

  const handleSelect = (option: MentionOption) => {
    const cursor = inputRef.current?.selectionStart ?? value.length
    const textBeforeCursor = value.slice(0, cursor)
    const triggerIndex = textBeforeCursor.lastIndexOf(trigger)
    const textAfterCursor = value.slice(cursor)

    const insertText = `${trigger}${option.label} `
    const newValue =
      triggerIndex !== -1
        ? value.slice(0, triggerIndex) + insertText + textAfterCursor
        : value + insertText

    if (controlledValue === undefined) {
      setInternalValue(newValue)
    }
    onValueChange?.(newValue)
    onSelectOption?.(option)
    setIsOpen(false)

    // Focus back to input
    setTimeout(() => {
      if (inputRef.current) {
        inputRef.current.focus()
        const newPos = (triggerIndex !== -1 ? triggerIndex : cursor) + insertText.length
        inputRef.current.setSelectionRange(newPos, newPos)
      }
    }, 0)
  }

  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (!isOpen || filteredOptions.length === 0) return

    if (e.key === "ArrowDown") {
      e.preventDefault()
      setSelectedIndex((prev) => (prev + 1) % filteredOptions.length)
    } else if (e.key === "ArrowUp") {
      e.preventDefault()
      setSelectedIndex((prev) => (prev - 1 + filteredOptions.length) % filteredOptions.length)
    } else if (e.key === "Enter" || e.key === "Tab") {
      e.preventDefault()
      const selected = filteredOptions[selectedIndex]
      if (selected) {
        handleSelect(selected)
      }
    } else if (e.key === "Escape") {
      e.preventDefault()
      setIsOpen(false)
    }
  }

  return (
    <div
      data-slot="mention"
      className={cn(mentionVariants({ variant }), className)}
      {...props}
    >
      <textarea
        ref={inputRef}
        value={value}
        onChange={handleInputChange}
        onKeyDown={handleKeyDown}
        onBlur={() => setIsOpen(false)}
        placeholder={placeholder}
        rows={3}
        role="combobox"
        aria-autocomplete="list"
        aria-expanded={showList}
        aria-controls={showList ? listId : undefined}
        aria-activedescendant={activeOption ? optionId(activeOption) : undefined}
        className="text-foreground placeholder:text-muted-foreground w-full resize-none bg-transparent px-3 py-2.5 text-sm leading-6 outline-none"
      />

      <AnimatePresence>
        {showList ? (
          <motion.div
            ref={listRef}
            id={listId}
            role="listbox"
            data-slot="mention-list"
            className="bg-popover text-popover-foreground absolute bottom-full left-2 z-50 mb-2 max-h-60 w-64 origin-bottom-left overflow-y-auto rounded-lg border p-1 shadow-md"
            initial={reduceMotion ? false : { opacity: 0, y: 6, scale: 0.97 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.98 }}
            transition={
              reduceMotion
                ? { duration: 0 }
                : { type: "spring", stiffness: 520, damping: 38, mass: 0.7 }
            }
            // Keep focus in the textarea while picking with the pointer.
            onMouseDown={(event) => event.preventDefault()}
          >
          <div className="flex flex-col gap-0.5">
            {filteredOptions.map((opt, idx) => {
              const isSelected = idx === selectedIndex
              return (
                <div
                  key={opt.id}
                  id={optionId(opt)}
                  role="option"
                  aria-selected={isSelected}
                  data-slot="mention-item"
                  data-selected={isSelected ? "true" : "false"}
                  onMouseMove={() => {
                    if (!isSelected) setSelectedIndex(idx)
                  }}
                  onClick={() => handleSelect(opt)}
                  className={cn(
                    "relative isolate flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-xs outline-none transition-colors",
                    isSelected ? "text-foreground" : "text-muted-foreground"
                  )}
                >
                  {isSelected ? (
                    <motion.span
                      aria-hidden="true"
                      layoutId={`${baseId}-highlight`}
                      className="bg-accent absolute inset-0 -z-10 rounded-md"
                      transition={
                        reduceMotion
                          ? { duration: 0 }
                          : { type: "spring", stiffness: 520, damping: 38, mass: 0.7 }
                      }
                    />
                  ) : null}
                  <span className="flex size-6 shrink-0 items-center justify-center text-muted-foreground">
                    {opt.icon ?? (
                      trigger === "@" ? (
                        <AtSignIcon className="size-3.5" />
                      ) : trigger === "#" ? (
                        <HashIcon className="size-3.5" />
                      ) : (
                        <SlashIcon className="size-3.5" />
                      )
                    )}
                  </span>
                  <div className="min-w-0 flex-1">
                    <div className="flex items-center justify-between gap-1">
                      <span className="truncate text-foreground font-medium">{opt.label}</span>
                      {opt.badge}
                    </div>
                    {opt.description && (
                      <p className="line-clamp-1 text-[11px] text-muted-foreground">
                        {opt.description}
                      </p>
                    )}
                  </div>
                </div>
              )
            })}
          </div>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  )
}

export interface MentionBadgeProps extends React.ComponentProps<"span"> {
  /** 提及前缀符号。 @default "@" */
  prefix?: string
}

function MentionBadge({
  className,
  prefix = "@",
  children,
  ...props
}: MentionBadgeProps) {
  return (
    <span
      data-slot="mention-badge"
      className={cn(
        "inline-flex items-center gap-0.5 rounded-md bg-primary/10 px-1.5 py-0.5 font-medium text-primary text-xs",
        className
      )}
      {...props}
    >
      <span className="opacity-70">{prefix}</span>
      <span>{children}</span>
    </span>
  )
}

export { Mention, MentionBadge, mentionVariants }

组件作用 · What it's for

Mention 专用于支持富交互提及场景,通过键盘打字实时捕获前缀并呈现建议浮层。

适用场景

  • 协作任务与评论区:键入 @ 快速提醒团队成员或关联特定负责人。
  • AI 助手与角色指派:在提示词中键入 @bot 指定协同 Agent。
  • 键盘导航支持:原生支持上下方向键高亮、Enter/Tab 快捷上屏与 Esc 取消。

何时不建议使用

  • 如果仅仅是普通无特殊触发符号的单行表单输入,使用 Input 即可。

组件属性 · Props

属性类型默认值说明
triggerstring@触发提及菜单的前缀字符(如 "@", "#", "/")。
optionsMentionOption[][]可供匹配选择的候选提及列表。
valuestring—当前输入框文本(受控模式)。
defaultValuestring—默认输入框文本(非受控模式)。
onValueChange((value: string) => void)—输入文本发生变动时的回调。
onSelectOption((option: MentionOption) => void)—选中某项提及项时的回调函数。
placeholderstring输入 @ 提及成员或技能...输入框占位提示文案。
variant"default" | "ghost"default外观样式变体。

事件 · Events

  • onValueChange?: (value: string) => void:文本内容变化时的回调。
  • onSelectOption?: (option: MentionOption) => void:选中某一匹配项时的回调。

拓展使用 · Extended usage

可通过 trigger 属性自定义触发符号,例如传入 # 实现话题标签自动补全,或传入 / 实现斜杠快捷指令。配合 onSelectOption 可以把选中的话题同步到外部,用 MentionBadge 展示:

Loading…

交互与无障碍 · Accessibility

  • 候选浮层以轻微的上移与缩放进入、退出;键盘或鼠标移动高亮时,高亮底色会在选项之间滑动,并自动滚动到可视区域。
  • 输入框声明为 role="combobox",浮层为 role="listbox",通过 aria-activedescendant 指向当前高亮项,屏幕阅读器能够读出候选内容。
  • 点击候选项不会让输入框失焦;输入框失焦或按 Esc 时浮层关闭。
  • 所有过渡在 prefers-reduced-motion 下关闭。