unnamed-ui
按钮

Toggle Button

Toggle button component for selection and mode switching

"use client";

import { useState } from "react";
import { ToggleButton } from "@/components/composed/toggle-button/toggle-button";

/**
 * 默认单选示例
 */
export function ToggleButtonDefault() {
  const [selected, setSelected] = useState<string | undefined>();

  const options = [
    { id: "harmful", label: "有害/不安全" },
    { id: "false", label: "信息虚假" },
    { id: "inappropriate", label: "内容不当" },
  ];

  return (
    <div className="space-y-4">
      <ToggleButton
        options={options}
        value={selected}
        onChange={setSelected}
        variant="default"
      />
      {selected && (
        <div className="text-sm text-muted-foreground">
          当前选择:{options.find((opt) => opt.id === selected)?.label}
        </div>
      )}
    </div>
  );
}

Toggle Button 组件提供了用于选择和多选场景的开关按钮样式,支持单选和多选模式,适用于反馈选项选择、模式切换(如深度思考、联网搜索)等场景。

概述

  • 两种变体样式:default(默认,高度 32px)和 compact(紧凑,使用 CSS 变量)
  • 单选和多选模式:支持单选按钮组和多选按钮组
  • 图标支持:支持 icon 选项,设置后仅显示图标不显示 label,适用于工具栏等紧凑场景
  • 清晰的状态反馈:选中/未选中视觉反馈明确
  • 禁用选项支持:可单独禁用某些选项
  • 类型安全:完整的 TypeScript 类型定义
  • 灵活组合:基于原语组件构建,可自由组合使用

快速开始

import { ToggleButton } from "@/registry/wuhan/composed/toggle-button";

export function Example() {
  const [selected, setSelected] = useState<string>();
  
  return (
    <ToggleButton
      options={[
        { id: "harmful", label: "有害/不安全" },
        { id: "false", label: "信息虚假" },
        { id: "inappropriate", label: "内容不当" },
      ]}
      value={selected}
      onChange={setSelected}
    />
  );
}

特性

  • 两种变体样式:default 适用于反馈组件等场景,compact 适用于 sender 组件等场景
  • Tooltip 支持:通过 option.tooltip 由组件内部正确包裹,DOM 结构为 Tooltip > button
  • 单选模式:点击选中,再次点击取消选择
  • 多选模式:可同时选中多个选项
  • 禁用状态:支持单独禁用某些选项,禁用的选项无法被选中
  • 状态管理:内置状态管理逻辑,简化使用
  • 样式定制:支持自定义按钮和容器样式

安装

pnpm dlx shadcn@latest add http://localhost:3000/r/wuhan/toggle-button.json

代码演示

默认样式

默认样式的开关按钮,适用于反馈组件等场景。

"use client";

import { useState } from "react";
import { ToggleButton } from "@/components/composed/toggle-button/toggle-button";

/**
 * 默认单选示例
 */
export function ToggleButtonDefault() {
  const [selected, setSelected] = useState<string | undefined>();

  const options = [
    { id: "harmful", label: "有害/不安全" },
    { id: "false", label: "信息虚假" },
    { id: "inappropriate", label: "内容不当" },
  ];

  return (
    <div className="space-y-4">
      <ToggleButton
        options={options}
        value={selected}
        onChange={setSelected}
        variant="default"
      />
      {selected && (
        <div className="text-sm text-muted-foreground">
          当前选择:{options.find((opt) => opt.id === selected)?.label}
        </div>
      )}
    </div>
  );
}

紧凑样式

紧凑样式的开关按钮,适用于 sender 组件等场景。

"use client";

import { useState } from "react";
import { ToggleButton } from "@/components/composed/toggle-button/toggle-button";

/**
 * 紧凑样式示例(用于 sender 组件等场景)
 */
export function ToggleButtonCompact() {
  const [modes, setModes] = useState<string[]>([]);

  const modeOptions = [
    { id: "web-search", label: "联网搜索" },
    { id: "deep-think", label: "深度思考" },
  ];

  return (
    <div className="space-y-4">
      <ToggleButton
        options={modeOptions}
        values={modes}
        onValuesChange={setModes}
        multiple
        variant="compact"
      />
      {modes.length > 0 && (
        <div className="text-sm text-muted-foreground">
          已启用模式:
          {modes
            .map((id) => modeOptions.find((opt) => opt.id === id)?.label)
            .join("、")}
        </div>
      )}
    </div>
  );
}

多选模式

多选模式示例。

选择你的兴趣(可多选)
"use client";

import { useState } from "react";
import { ToggleButton } from "@/components/composed/toggle-button/toggle-button";

/**
 * 多选模式示例
 */
export function ToggleButtonMultiple() {
  const [interests, setInterests] = useState<string[]>([]);

  const interestOptions = [
    { id: "tech", label: "科技" },
    { id: "sports", label: "体育" },
    { id: "music", label: "音乐" },
    { id: "travel", label: "旅行" },
    { id: "food", label: "美食" },
  ];

  return (
    <div className="space-y-4">
      <div className="space-y-2">
        <div className="text-sm font-medium">选择你的兴趣(可多选)</div>
        <ToggleButton
          options={interestOptions}
          values={interests}
          onValuesChange={setInterests}
          multiple
          variant="default"
        />
      </div>
      {interests.length > 0 && (
        <div className="text-sm text-muted-foreground">
          已选择:
          {interests
            .map((id) => interestOptions.find((opt) => opt.id === id)?.label)
            .join("、")}
        </div>
      )}
    </div>
  );
}

禁用选项

带禁用选项的开关按钮。

"use client";

import { useState } from "react";
import { ToggleButton } from "@/components/composed/toggle-button/toggle-button";

/**
 * 禁用状态示例
 */
export function ToggleButtonDisabled() {
  const [selected, setSelected] = useState<string | undefined>();

  const options = [
    { id: "option1", label: "可选项 1" },
    { id: "option2", label: "可选项 2" },
    { id: "option3", label: "禁用项", disabled: true },
    { id: "option4", label: "可选项 4" },
  ];

  return (
    <div className="space-y-4">
      <ToggleButton options={options} value={selected} onChange={setSelected} />
      {selected && (
        <div className="text-sm text-muted-foreground">
          当前选择:{options.find((opt) => opt.id === selected)?.label}
        </div>
      )}
    </div>
  );
}

API

ToggleButton

开关按钮主组件,支持单选和多选模式。

Props

PropTypeDefaultDescription
optionsToggleOption[]-按钮选项列表(必填)
valuestring-当前选中的选项 ID(单选模式,受控)
valuesstring[]-当前选中的选项 ID 列表(多选模式,受控)
defaultValuestring-单选模式默认值(非受控)
defaultValuesstring[]-多选模式默认值(非受控)
onChange(value: string | undefined) => void-选项变化回调(单选模式)
onValuesChange(values: string[]) => void-选项变化回调(多选模式)
multiplebooleanfalse是否为多选模式
variant"default" | "compact""default"按钮变体样式
ariaLabelstring-按钮组的 aria-label(无障碍)
classNamestring-按钮自定义类名
groupClassNamestring-按钮容器自定义类名
renderOption(option, context) => ReactNode-自定义选项渲染,复杂场景使用

ToggleOption Type

interface ToggleOption {
  id: string;              // 选项唯一标识
  label: string;           // 选项显示文本(兼作 aria-label)
  icon?: React.ReactNode;  // 图标(设置后仅显示图标,不显示 label)
  tooltip?: React.ReactNode; // 悬停提示(由组件内部正确包裹 Tooltip)
  disabled?: boolean;     // 是否禁用
  className?: string;      // 单个选项的类名
}

Example

import { ToggleButton } from "@/registry/wuhan/composed/toggle-button";

function FeedbackSelector() {
  const [feedback, setFeedback] = useState<string | undefined>();
  
  return (
    <div>
      {/* 单选模式 */}
      <ToggleButton
        options={[
          { id: "harmful", label: "有害/不安全" },
          { id: "false", label: "信息虚假" },
          { id: "inappropriate", label: "内容不当" },
        ]}
        value={feedback}
        onChange={setFeedback}
        variant="default"
      />
    </div>
  );
}

function ModeSelector() {
  const [modes, setModes] = useState<string[]>([]);
  
  return (
    <div>
      {/* 多选模式 */}
      <ToggleButton
        options={[
          { id: "web-search", label: "联网搜索" },
          { id: "deep-think", label: "深度思考" },
        ]}
        values={modes}
        onValuesChange={setModes}
        multiple
        variant="compact"
      />
    </div>
  );
}

变体样式

Default Variant

  • 高度:32px (h-8)
  • 内边距:使用 CSS 变量 var(--Padding-padding-com-md)
  • 未选中状态:中性边框和背景
  • 选中状态:品牌色边框和文字
  • 适用场景:反馈组件、问卷选择等

Compact Variant

  • 高度:使用 CSS 变量 var(--size-com-md)
  • 内边距:px-3
  • 未选中状态:透明背景,中性边框
  • 选中状态:品牌色浅背景和边框(单选)或透明背景(多选)
  • 适用场景:Sender 组件、工具栏等

使用场景

  • 反馈选择:用户反馈选项,如"有害/不安全"、"信息虚假"等
  • 模式切换:启用/禁用某些功能模式,如"联网搜索"、"深度思考"
  • 兴趣选择:多选用户兴趣标签
  • 筛选器:内容分类筛选,可多选
  • 偏好设置:用户偏好选项的开关
  • 表单选择:问卷调查、表单中的选择题

最佳实践

  1. 选择合适的变体:反馈场景使用 default,工具栏场景使用 compact
  2. 选择合适的模式:互斥选择用单选,非互斥选择用多选
  3. 选项数量控制:单行建议不超过 5 个选项,过多时考虑换行或其他组件
  4. 文本简洁:选项文本应简短明了,通常 2-6 个字
  5. 状态反馈:在单选模式下,可以通过再次点击取消选择
  6. 禁用状态:合理使用禁用状态,并提供必要的提示信息

注意事项

  • 单选模式使用 value/onChange,多选模式使用 values/onValuesChange,不能混用
  • 不传 value/values 时可使用 defaultValue/defaultValues 实现非受控模式
  • 单选模式下点击已选中的选项会取消选择(value 变为 undefined)
  • 多选模式下,选中状态的背景色根据 multiple 属性自动调整
  • 禁用的选项无法被选中或取消选中
  • 建议在使用多选模式时,至少保留一个选项可选(如果需要的话)

原语组件

Toggle Button 基于以下原语组件构建:

  • ToggleButtonPrimitive - 单个开关按钮原语
  • ToggleButtonGroupPrimitive - 开关按钮组容器原语

原语组件提供了基础的样式和结构,可以在 registry/wuhan/blocks/toggle-button/toggle-button-01.tsx 中找到。

样式定制

组件使用 Tailwind CSS,可以通过以下方式定制:

// 通过 className 定制按钮样式
<ToggleButton
  options={options}
  value={selected}
  onChange={setSelected}
  className="font-bold"
/>

// 通过 groupClassName 定制容器样式
<ToggleButton
  options={options}
  value={selected}
  onChange={setSelected}
  groupClassName="gap-4"
/>

扩展示例

Feedback Scene

完整的反馈场景示例,包含提交逻辑。

这个回答有什么问题?
"use client";

import { useState } from "react";
import { ToggleButton } from "@/components/composed/toggle-button/toggle-button";

/**
 * 反馈场景示例
 */
export function ToggleButtonFeedback() {
  const [feedback, setFeedback] = useState<string | undefined>();
  const [submitted, setSubmitted] = useState(false);

  const feedbackOptions = [
    { id: "harmful", label: "有害/不安全" },
    { id: "false", label: "信息虚假" },
    { id: "inappropriate", label: "内容不当" },
    { id: "unhelpful", label: "无用信息" },
    { id: "other", label: "其他问题" },
  ];

  const handleSubmit = () => {
    if (feedback) {
      setSubmitted(true);
      setTimeout(() => {
        setSubmitted(false);
        setFeedback(undefined);
      }, 2000);
    }
  };

  return (
    <div className="space-y-4 p-4 border rounded-lg">
      <div className="space-y-2">
        <div className="text-sm font-medium">这个回答有什么问题?</div>
        <ToggleButton
          options={feedbackOptions}
          value={feedback}
          onChange={setFeedback}
          variant="default"
        />
      </div>
      <button
        onClick={handleSubmit}
        disabled={!feedback || submitted}
        className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
      >
        {submitted ? "已提交" : "提交反馈"}
      </button>
    </div>
  );
}

Sender Mode Switching

Sender 组件中的模式切换示例。

"use client";

import { useState } from "react";
import { ToggleButton } from "@/components/composed/toggle-button/toggle-button";
import { Search, Brain } from "lucide-react";

/**
 * Sender 模式切换示例
 */
export function ToggleButtonSender() {
  const [modes, setModes] = useState<string[]>([]);
  const [input, setInput] = useState("");

  const modeOptions = [
    { id: "web-search", label: "联网搜索" },
    { id: "deep-think", label: "深度思考" },
  ];

  const handleSend = () => {
    console.log("发送消息:", input);
    console.log("已启用模式:", modes);
    setInput("");
  };

  return (
    <div className="space-y-4 p-4 border rounded-lg max-w-2xl">
      <div className="space-y-2">
        <textarea
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="输入你的问题..."
          className="w-full min-h-[100px] p-3 border rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-primary"
        />
      </div>
      <div className="flex items-center justify-between">
        <ToggleButton
          options={modeOptions}
          values={modes}
          onValuesChange={setModes}
          multiple
          variant="compact"
        />
        <button
          onClick={handleSend}
          disabled={!input.trim()}
          className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
        >
          发送
        </button>
      </div>
      {modes.length > 0 && (
        <div className="text-xs text-muted-foreground flex items-center gap-2">
          {modes.includes("web-search") && (
            <span className="flex items-center gap-1">
              <Search className="w-3 h-3" />
              联网搜索已启用
            </span>
          )}
          {modes.includes("deep-think") && (
            <span className="flex items-center gap-1">
              <Brain className="w-3 h-3" />
              深度思考已启用
            </span>
          )}
        </div>
      )}
    </div>
  );
}

Content Filter

内容筛选器示例,支持"全部"选项的特殊逻辑。

选择类别
显示所有类别的内容
"use client";

import { useState } from "react";
import { ToggleButton } from "@/components/composed/toggle-button/toggle-button";

/**
 * 筛选器示例
 */
export function ToggleButtonFilter() {
  const [categories, setCategories] = useState<string[]>(["all"]);

  const categoryOptions = [
    { id: "all", label: "全部" },
    { id: "tech", label: "科技" },
    { id: "design", label: "设计" },
    { id: "business", label: "商业" },
    { id: "lifestyle", label: "生活" },
  ];

  // 处理全部选项的特殊逻辑
  const handleCategoryChange = (newValues: string[]) => {
    // 如果选择了 "全部",则清空其他选项
    if (newValues.includes("all") && !categories.includes("all")) {
      setCategories(["all"]);
    }
    // 如果有其他选项被选中,则移除 "全部"
    else if (newValues.length > 1 && newValues.includes("all")) {
      setCategories(newValues.filter((id) => id !== "all"));
    }
    // 如果取消所有选项,默认选中 "全部"
    else if (newValues.length === 0) {
      setCategories(["all"]);
    } else {
      setCategories(newValues);
    }
  };

  return (
    <div className="space-y-4">
      <div className="space-y-2">
        <div className="text-sm font-medium">选择类别</div>
        <ToggleButton
          options={categoryOptions}
          values={categories}
          onValuesChange={handleCategoryChange}
          multiple
          variant="default"
        />
      </div>
      <div className="p-4 border rounded-lg bg-muted/50">
        <div className="text-sm text-muted-foreground">
          {categories.includes("all") ? (
            <span>显示所有类别的内容</span>
          ) : (
            <span>
              显示以下类别的内容:
              {categories
                .map(
                  (id) => categoryOptions.find((opt) => opt.id === id)?.label,
                )
                .join("、")}
            </span>
          )}
        </div>
      </div>
    </div>
  );
}

仅图标的开关按钮

使用 option.tooltip 由组件内部正确包裹 Tooltip(Tooltip > button),适用于工具栏等紧凑场景。

"use client";

import { useState } from "react";
import { Globe } from "lucide-react";
import { ToggleButton } from "@/components/composed/toggle-button/toggle-button";

/**
 * 仅图标的开关按钮示例
 * 使用 option.tooltip 由组件内部正确包裹 Tooltip(Tooltip > button)
 */
export function ToggleButtonIcon() {
  const [webSearchEnabled, setWebSearchEnabled] = useState<
    string | undefined
  >();

  return (
    <ToggleButton
      options={[
        {
          id: "web-search",
          label: "联网搜索",
          icon: <Globe className="size-4" />,
          tooltip: "联网搜索",
        },
      ]}
      value={webSearchEnabled}
      onChange={setWebSearchEnabled}
      variant="compact"
      className="p-2"
    />
  );
}
import { ToggleButton } from "@/registry/wuhan/composed/toggle-button";
import { Globe } from "lucide-react";

function IconOnlyToggleButton() {
  const [webSearchEnabled, setWebSearchEnabled] = useState<string | undefined>();

  return (
    <ToggleButton
      options={[
        {
          id: "web-search",
          label: "联网搜索",
          icon: <Globe className="size-4" />,
          tooltip: "联网搜索",
        },
      ]}
      value={webSearchEnabled}
      onChange={setWebSearchEnabled}
      variant="compact"
      className="p-2"
    />
  );
}

带图标的开关按钮(图标+文本)

import { ToggleButton } from "@/registry/wuhan/composed/toggle-button";
import { Globe, Brain } from "lucide-react";

function IconToggleButton() {
  const [modes, setModes] = useState<string[]>([]);
  
  return (
    <div className="space-y-4">
      <ToggleButton
        options={[
          { id: "web-search", label: "联网搜索" },
          { id: "deep-think", label: "深度思考" },
        ]}
        values={modes}
        onValuesChange={setModes}
        multiple
        variant="compact"
      />
      
      {/* 显示已启用的模式图标 */}
      <div className="flex gap-2">
        {modes.includes("web-search") && (
          <span className="flex items-center gap-1 text-sm text-primary">
            <Globe className="w-4 h-4" />
            联网搜索已启用
          </span>
        )}
        {modes.includes("deep-think") && (
          <span className="flex items-center gap-1 text-sm text-primary">
            <Brain className="w-4 h-4" />
            深度思考已启用
          </span>
        )}
      </div>
    </div>
  );
}

动态选项加载

import { ToggleButton } from "@/registry/wuhan/composed/toggle-button";

function DynamicOptions() {
  const [options, setOptions] = useState<ToggleOption[]>([]);
  const [selected, setSelected] = useState<string>();
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    // 模拟从 API 加载选项
    setTimeout(() => {
      setOptions([
        { id: "opt1", label: "选项 1" },
        { id: "opt2", label: "选项 2" },
        { id: "opt3", label: "选项 3" },
      ]);
      setLoading(false);
    }, 1000);
  }, []);
  
  if (loading) {
    return <div>加载中...</div>;
  }
  
  return (
    <ToggleButton
      options={options}
      value={selected}
      onChange={setSelected}
    />
  );
}

条件禁用

import { ToggleButton } from "@/registry/wuhan/composed/toggle-button";

function ConditionalDisable() {
  const [selected, setSelected] = useState<string>();
  const isPremiumUser = false; // 假设这是从用户状态获取的
  
  const options = [
    { id: "basic", label: "基础功能" },
    { id: "advanced", label: "高级功能", disabled: !isPremiumUser },
    { id: "pro", label: "专业功能", disabled: !isPremiumUser },
  ];
  
  return (
    <div className="space-y-2">
      <ToggleButton
        options={options}
        value={selected}
        onChange={setSelected}
      />
      {!isPremiumUser && (
        <p className="text-sm text-muted-foreground">
          部分功能需要升级为高级用户
        </p>
      )}
    </div>
  );
}

表单集成

import { ToggleButton } from "@/registry/wuhan/composed/toggle-button";
import { useForm } from "react-hook-form";

function FormIntegration() {
  const { register, setValue, watch } = useForm({
    defaultValues: {
      feedback: undefined,
      interests: [],
    },
  });
  
  const feedback = watch("feedback");
  const interests = watch("interests");
  
  return (
    <form className="space-y-4">
      {/* 单选字段 */}
      <div>
        <label className="text-sm font-medium mb-2 block">反馈类型</label>
        <ToggleButton
          options={[
            { id: "helpful", label: "有帮助" },
            { id: "unhelpful", label: "没帮助" },
          ]}
          value={feedback}
          onChange={(value) => setValue("feedback", value)}
        />
      </div>
      
      {/* 多选字段 */}
      <div>
        <label className="text-sm font-medium mb-2 block">感兴趣的主题</label>
        <ToggleButton
          options={[
            { id: "tech", label: "科技" },
            { id: "sports", label: "体育" },
            { id: "music", label: "音乐" },
          ]}
          values={interests}
          onValuesChange={(values) => setValue("interests", values)}
          multiple
        />
      </div>
    </form>
  );
}