SplitButton 分裂按钮

将一个常用操作与附加菜单组合在一起,两侧分别响应点击。

文章准备就绪

'use client'

import { useState } from 'react'
import { Clock, FilePen, Send } from 'lucide-react'
import { DropdownMenuItem, SplitButton, Stack, Text } from '@hina-ui/react'

export default function Demo() {
  const [result, setResult] = useState('文章准备就绪')

  return (
    <Stack align="center" gap="sm">
      <SplitButton
        label="文章操作"
        menuLabel="其他发布方式"
        icon={<Send />}
        onClick={() => setResult('已执行:立即发布')}
        renderContent={() => (
          <>
            <DropdownMenuItem icon={<FilePen />} onSelect={() => setResult('已执行:保存草稿')}>
              保存草稿
            </DropdownMenuItem>
            <DropdownMenuItem icon={<Clock />} onSelect={() => setResult('已执行:定时发布')}>
              定时发布
            </DropdownMenuItem>
          </>
        )}
      >
        立即发布
      </SplitButton>
      <Text role="status" size="sm" tone="muted">
        {result}
      </Text>
    </Stack>
  )
}
tsx

用法

import { SplitButton, DropdownMenuItem } from '@hina-ui/react'
ts

主按钮点击调用 onClick,尾部箭头打开菜单。将附加操作放进 renderContent,通过 DropdownMenuItem 的 onSelect 处理选择。适合“立即发布 / 保存草稿”“导出 / 选择格式”等有明确默认操作的场景。

<SplitButton
  menuLabel="其他保存方式"
  onClick={save}
  renderContent={() => <DropdownMenuItem onSelect={saveCopy}>另存副本</DropdownMenuItem>}
>
  保存
</SplitButton>
tsx

如果几项操作同样常用,用 ButtonGroup;只有菜单而没有默认操作,用 DropdownMenu。菜单选择不会自动改变主按钮,是否记住所选操作由调用方决定。

示例

外观与色调

两侧共享 variant 和 tone,连接处不重复圆角。实底、浅底和无底色形态带有分隔线,描边形态共用一条边框。

每种外观都保留独立的主操作和菜单

'use client'

import { useState } from 'react'
import { DropdownMenuItem, Flex, SplitButton, Stack, Text } from '@hina-ui/react'

const variants = ['solid', 'soft', 'outline', 'ghost'] as const

export default function Demo() {
  const [result, setResult] = useState('每种外观都保留独立的主操作和菜单')

  return (
    <Stack align="center" gap="lg">
      <Flex wrap justify="center" gap="lg">
        {variants.map(variant => (
          <SplitButton
            key={variant}
            variant={variant}
            menuLabel={`${variant} 的更多操作`}
            onClick={() => setResult(`${variant}:已保存`)}
            renderContent={() => (
              <DropdownMenuItem onSelect={() => setResult(`${variant}:另存副本`)}>
                另存副本
              </DropdownMenuItem>
            )}
          >
            {variant}
          </SplitButton>
        ))}
      </Flex>
      <Flex wrap justify="center" gap="lg">
        <SplitButton
          tone="neutral"
          menuLabel="下载方式"
          onClick={() => setResult('已执行:下载')}
          renderContent={() => (
            <DropdownMenuItem onSelect={() => setResult('已执行:复制链接')}>
              复制链接
            </DropdownMenuItem>
          )}
        >
          下载
        </SplitButton>
        <SplitButton
          tone="danger"
          variant="soft"
          menuLabel="归档方式"
          onClick={() => setResult('已执行:归档')}
          renderContent={() => (
            <DropdownMenuItem onSelect={() => setResult('已执行:移至回收站')}>
              移至回收站
            </DropdownMenuItem>
          )}
        >
          归档
        </SplitButton>
      </Flex>
      <Text role="status" size="sm" tone="muted">
        {result}
      </Text>
    </Stack>
  )
}
tsx

尺寸与圆角

size 同时控制主按钮和菜单按钮的高度,pill 仅改变整组外侧的圆角。拼接的两侧均不使用按压缩放。

菜单按钮始终与主操作等高

'use client'

import { useState } from 'react'
import { DropdownMenuItem, Flex, SplitButton, Stack, Text } from '@hina-ui/react'

const sizes = ['sm', 'md', 'lg'] as const

export default function Demo() {
  const [result, setResult] = useState('菜单按钮始终与主操作等高')

  return (
    <Stack align="center">
      <Flex wrap align="center" justify="center" gap="lg">
        {sizes.map(size => (
          <SplitButton
            key={size}
            size={size}
            menuLabel={`${size} 的更多操作`}
            onClick={() => setResult(`${size}:已保存`)}
            renderContent={() => (
              <DropdownMenuItem onSelect={() => setResult(`${size}:另存副本`)}>
                另存副本
              </DropdownMenuItem>
            )}
          >
            保存
          </SplitButton>
        ))}
        <SplitButton
          pill
          variant="outline"
          menuLabel="圆角按钮的更多操作"
          onClick={() => setResult('已执行:保存')}
          renderContent={() => (
            <DropdownMenuItem onSelect={() => setResult('已执行:另存副本')}>
              另存副本
            </DropdownMenuItem>
          )}
        >
          圆角按钮
        </SplitButton>
      </Flex>
      <Text role="status" size="sm" tone="muted">
        {result}
      </Text>
    </Stack>
  )
}
tsx

加载与禁用

loading 会显示加载指示、禁用两侧并关闭已打开的菜单。disabled 禁用整组;primaryDisabled 和 menuDisabled 可以单独禁用一侧,例如不能发布时仍可保存草稿。

可以分别禁用主操作和菜单

'use client'

import { useState } from 'react'
import { DropdownMenuItem, FormField, SplitButton, Stack, Switch, Text } from '@hina-ui/react'

export default function Demo() {
  const [loading, setLoading] = useState(false)
  const [disabled, setDisabled] = useState(false)
  const [primaryDisabled, setPrimaryDisabled] = useState(false)
  const [menuDisabled, setMenuDisabled] = useState(false)
  const [result, setResult] = useState('可以分别禁用主操作和菜单')

  return (
    <Stack className="w-full max-w-xs" gap="lg">
      <Stack gap="sm">
        <FormField label="加载中" orientation="horizontal">
          <Switch checked={loading} onCheckedChange={setLoading} />
        </FormField>
        <FormField label="全部禁用" orientation="horizontal">
          <Switch checked={disabled} onCheckedChange={setDisabled} />
        </FormField>
        <FormField label="禁用主操作" orientation="horizontal">
          <Switch checked={primaryDisabled} onCheckedChange={setPrimaryDisabled} />
        </FormField>
        <FormField label="禁用菜单" orientation="horizontal">
          <Switch checked={menuDisabled} onCheckedChange={setMenuDisabled} />
        </FormField>
      </Stack>
      <Stack align="center" gap="sm">
        <SplitButton
          loading={loading}
          disabled={disabled}
          primaryDisabled={primaryDisabled}
          menuDisabled={menuDisabled}
          menuLabel="保存方式"
          onClick={() => setResult('已执行:保存')}
          renderContent={() => (
            <DropdownMenuItem onSelect={() => setResult('已执行:另存副本')}>
              另存副本
            </DropdownMenuItem>
          )}
        >
          保存修改
        </SplitButton>
        <Text role="status" size="sm" tone="muted">
          {result}
        </Text>
      </Stack>
    </Stack>
  )
}
tsx

记住所选格式

菜单直接使用现有的 DropdownMenu 子组件,支持分组、单选、勾选项和子菜单。本例由应用持有导出格式,选择后更新主按钮的文字和操作参数。

选择格式后,主操作使用新的格式

'use client'

import { useState } from 'react'
import { Download } from 'lucide-react'
import {
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  SplitButton,
  Stack,
  Text,
} from '@hina-ui/react'

export default function Demo() {
  const [format, setFormat] = useState('CSV')
  const [result, setResult] = useState('选择格式后,主操作使用新的格式')

  return (
    <Stack align="center" gap="sm">
      <SplitButton
        variant="outline"
        menuLabel="导出格式"
        icon={<Download />}
        onClick={() => setResult(`已执行:导出 ${format}`)}
        renderContent={() => (
          <>
            <DropdownMenuLabel>文件格式</DropdownMenuLabel>
            <DropdownMenuRadioGroup value={format} onValueChange={setFormat}>
              <DropdownMenuRadioItem value="CSV">CSV</DropdownMenuRadioItem>
              <DropdownMenuRadioItem value="JSON">JSON</DropdownMenuRadioItem>
              <DropdownMenuRadioItem value="XLSX">Excel (.xlsx)</DropdownMenuRadioItem>
            </DropdownMenuRadioGroup>
          </>
        )}
      >
        {`导出 ${format}`}
      </SplitButton>
      <Text role="status" size="sm" tone="muted">
        {result}
      </Text>
    </Stack>
  )
}
tsx

占满宽度

block 让主按钮填满剩余宽度,箭头仍保持方形;较长的默认文案会截断。className 与 style 都作用于组容器。

主操作伸展,菜单按钮保持方形

'use client'

import { useState } from 'react'
import { Download } from 'lucide-react'
import { DropdownMenuItem, SplitButton, Stack, Text } from '@hina-ui/react'

export default function Demo() {
  const [result, setResult] = useState('主操作伸展,菜单按钮保持方形')

  return (
    <Stack className="w-full max-w-xs" gap="sm">
      <SplitButton
        block
        menuLabel="报表的其他操作"
        icon={<Download />}
        onClick={() => setResult('已执行:导出完整报表')}
        renderContent={() => (
          <>
            <DropdownMenuItem onSelect={() => setResult('已执行:仅导出选中项')}>
              仅导出选中项
            </DropdownMenuItem>
            <DropdownMenuItem onSelect={() => setResult('已执行:复制报表链接')}>
              复制报表链接
            </DropdownMenuItem>
          </>
        )}
      >
        导出本月所有项目的完整运行报表
      </SplitButton>
      <Text role="status" size="sm" tone="muted">
        {result}
      </Text>
    </Stack>
  )
}
tsx

表单提交

type="submit" 仅作用于主按钮。菜单触发器始终为 type="button",打开菜单和选择菜单项都不会意外提交表单。name、value、form 等原生属性透传到主按钮。

本例模拟异步发布。发布经过 Form 校验,保存草稿由菜单单独处理,可以保留未完成的标题。

发布前校验标题,保存草稿可以保留未完成内容。

'use client'

import { useState } from 'react'
import * as v from 'valibot'
import { DropdownMenuItem, Form, FormField, Input, SplitButton, Text } from '@hina-ui/react'

const schema = v.object({
  title: v.pipe(v.string(), v.trim(), v.minLength(2, '标题至少需要 2 个字')),
})

export default function Demo() {
  const [values, setValues] = useState({ title: '' })
  const [result, setResult] = useState('')

  async function publish(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 800))
    setResult(`已发布:${(data as { title: string }).title}`)
  }
  function saveDraft() {
    setResult(`已保存草稿:${values.title.trim() || '未命名文章'}`)
  }

  return (
    <Form values={values} rules={schema} className="w-full max-w-sm" onSubmit={publish}>
      {({ submitting }) => (
        <>
          <FormField
            name="title"
            label="文章标题"
            description="发布前校验标题,保存草稿可以保留未完成内容。"
            required
          >
            <Input
              value={values.title}
              onValueChange={title => setValues({ ...values, title })}
              placeholder="填写标题"
            />
          </FormField>
          <SplitButton
            type="submit"
            loading={submitting}
            menuLabel="其他保存方式"
            className="self-start"
            renderContent={() => <DropdownMenuItem onSelect={saveDraft}>保存草稿</DropdownMenuItem>}
          >
            发布文章
          </SplitButton>
          {result && (
            <Text role="status" size="sm" tone="muted">
              {result}
            </Text>
          )}
        </>
      )}
    </Form>
  )
}
tsx

从右向左排列

dir="rtl" 同时控制按钮顺序、连接圆角和菜单方向。菜单对齐整组按钮的逻辑末端。

菜单按钮随书写方向排列

'use client'

import { useState } from 'react'
import { DropdownMenuItem, SplitButton, Stack, Text } from '@hina-ui/react'

export default function Demo() {
  const [result, setResult] = useState('菜单按钮随书写方向排列')

  return (
    <Stack align="center" gap="sm">
      <SplitButton
        dir="rtl"
        menuLabel="خيارات النشر"
        onClick={() => setResult('已执行:发布')}
        renderContent={() => (
          <>
            <DropdownMenuItem onSelect={() => setResult('已执行:保存草稿')}>
              حفظ المسودة
            </DropdownMenuItem>
            <DropdownMenuItem onSelect={() => setResult('已执行:定时发布')}>
              جدولة النشر
            </DropdownMenuItem>
          </>
        )}
      >
        نشر المقال
      </SplitButton>
      <Text role="status" size="sm" tone="muted">
        {result}
      </Text>
    </Stack>
  )
}
tsx

行为与无障碍

  • Tab 分别访问主按钮与菜单按钮。主按钮按 Enter 或空格执行操作,按 ArrowDown 打开菜单;链接形态遵循链接的键盘行为。
  • 菜单按钮按 Enter、空格或 ArrowDown 打开菜单,键盘打开时聚焦首个可用项。菜单沿用 DropdownMenu 的方向键、首尾跳转和字符搜索。
  • Escape 关闭菜单并将焦点返回菜单按钮。点选普通菜单项后同样关闭;需要保留菜单时在该项的 onSelect 中调用 event.preventDefault()。
  • menuLabel 应描述附加操作,默认使用当前语言的“更多操作”。label 为整组提供可选的无障碍名称。
  • icon 和 trailing 只属于主操作;菜单箭头使用 DisclosureIcon,随打开状态变化。

API

Props

属性
类型
默认值
说明
variant
'solid' | 'soft' | 'outline' | 'ghost'
'solid'
两侧共用的外观
tone
'accent' | 'neutral' | 'danger'
'accent'
两侧共用的色调
size
'sm' | 'md' | 'lg'
'md'
按钮尺寸
block
boolean
false
占满容器宽度
pill
boolean
false
整组外侧使用全圆角
ripple
boolean
true
启用涟漪反馈
loading
boolean
false
显示加载指示并禁用两侧
disabled
boolean
false
禁用两侧
primaryDisabled
boolean
false
仅禁用主操作
menuDisabled
boolean
false
仅禁用菜单
as
string | Component
'button'
主按钮的元素或组件
type
'button' | 'submit' | 'reset'
'button'
主按钮类型
label
string
—
按钮组的无障碍名称
menuLabel
string
当前语言的“更多操作”
菜单按钮及菜单的无障碍名称
modal
boolean
true
菜单打开时限制外部交互
dir
'ltr' | 'rtl'
继承
按钮和菜单的书写方向
side
'top' | 'right' | 'bottom' | 'left'
'bottom'
菜单的首选方向
align
'start' | 'center' | 'end'
'end'
菜单相对于整组的对齐方式
sideOffset
number
8
菜单与按钮组的间距
className
string
—
组容器的类名
style
StyleValue
—
组容器的样式
menuClass
string
—
菜单内容的类名

其他属性和事件处理函数(例如 id、aria-label、href、target、onKeyDown)透传到主按钮。

受控状态

模型
类型
默认值
说明
open / onOpenChange
boolean
false
菜单打开状态;加载或禁用菜单时关闭

回调

回调
参数
说明
onClick
MouseEvent
主操作点击;菜单操作不会调用它
onOpenChange
boolean
菜单打开状态变化

内容属性

属性
参数
说明
children
—
主按钮文案
icon
—
主按钮前置图标
trailing
—
主按钮尾部内容
renderContent
{ close: () => void }
菜单内容,通常由 DropdownMenuItem 等子组件组成

Ref

方法
类型
说明
focus
() => void
聚焦可用的主按钮
openMenu
() => Promise<void>
聚焦菜单触发器并打开菜单;菜单不可用时无操作
closeMenu
() => void
关闭菜单