refactor(web): tighten button tone typing

This commit is contained in:
yyh
2026-04-14 22:54:36 +08:00
parent 381c518b23
commit cca408299b
11 changed files with 96 additions and 44 deletions

View File

@ -1,5 +1,4 @@
'use client'
import type { ButtonProps } from '@/app/components/base/ui/button'
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { SiteInfo } from '@/models/share'
import type { HumanInputFormError } from '@/service/use-share'
@ -263,7 +262,7 @@ const FormContent = () => {
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
variant={getButtonStyle(action.button_style)}
onClick={() => submit(action.id)}
>
{action.title}

View File

@ -1,6 +1,5 @@
'use client'
import type { HumanInputFormProps } from './type'
import type { ButtonProps } from '@/app/components/base/ui/button'
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
import * as React from 'react'
import { useCallback, useState } from 'react'
@ -47,7 +46,7 @@ const HumanInputForm = ({
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
variant={getButtonStyle(action.button_style)}
onClick={() => submit(formToken, action.id, inputs)}
data-testid="action-button"
>

View File

@ -1,3 +1,4 @@
import type { ButtonVariant } from '@/app/components/base/ui/button'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Locale } from '@/i18n-config'
import dayjs from 'dayjs'
@ -14,7 +15,7 @@ dayjs.extend(utc)
dayjs.extend(relativeTime)
dayjs.extend(isSameOrAfter)
export const getButtonStyle = (style: UserActionButtonType) => {
export const getButtonStyle = (style: UserActionButtonType): ButtonVariant | undefined => {
if (style === UserActionButtonType.Primary)
return 'primary'
if (style === UserActionButtonType.Default)

View File

@ -1,5 +1,5 @@
import type { Dayjs } from 'dayjs'
import type { ButtonProps } from '@/app/components/base/ui/button'
import type { ButtonSize, ButtonVariant } from '@/app/components/base/ui/button'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useChatContext } from '@/app/components/base/chat/chat/context'
@ -9,7 +9,7 @@ import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-time-picker/utils/dayjs'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { Button } from '@/app/components/base/ui/button'
import { Button, isButtonSize, isButtonVariant } from '@/app/components/base/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
enum DATA_FORMAT {
@ -48,17 +48,6 @@ function isSafeName(name: unknown): name is string {
&& !PROTOTYPE_POISON_KEYS.has(name)
}
const VALID_BUTTON_VARIANTS = new Set<string>([
'primary',
'warning',
'secondary',
'secondary-accent',
'ghost',
'ghost-accent',
'tertiary',
])
const VALID_BUTTON_SIZES = new Set<string>(['small', 'medium', 'large'])
type HastText = {
type: 'text'
value: string
@ -364,12 +353,8 @@ const MarkdownForm = ({ node }: { node: HastElement }) => {
if (child.tagName === SUPPORTED_TAGS.BUTTON) {
const rawVariant = str(child.properties.dataVariant)
const rawSize = str(child.properties.dataSize)
const variant = VALID_BUTTON_VARIANTS.has(rawVariant)
? rawVariant as ButtonProps['variant']
: undefined
const size = VALID_BUTTON_SIZES.has(rawSize)
? rawSize as ButtonProps['size']
: undefined
const variant: ButtonVariant | undefined = isButtonVariant(rawVariant) ? rawVariant : undefined
const size: ButtonSize | undefined = isButtonSize(rawSize) ? rawSize : undefined
return (
<Button

View File

@ -62,8 +62,9 @@ export function AlertDialogActions({ className, ...props }: AlertDialogActionsPr
)
}
type AlertDialogCancelButtonProps = Omit<ButtonProps, 'children'> & {
type AlertDialogCancelButtonProps = Omit<ButtonProps, 'children' | 'tone'> & {
children: React.ReactNode
tone?: 'default'
closeProps?: Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Close>, 'children' | 'render'>
}
@ -85,14 +86,17 @@ export function AlertDialogCancelButton({
type AlertDialogConfirmButtonProps = ButtonProps
export function AlertDialogConfirmButton({
variant = 'primary',
tone = 'destructive',
variant,
tone,
...props
}: AlertDialogConfirmButtonProps) {
if (variant === 'secondary-accent' || variant === 'ghost-accent')
return <Button variant={variant} tone="default" {...props} />
return (
<Button
variant={variant}
tone={tone}
tone={tone ?? 'destructive'}
{...props}
/>
)

View File

@ -1,3 +1,4 @@
import type { ButtonProps } from '../index'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { Button } from '../index'
@ -60,6 +61,12 @@ describe('Button', () => {
render(<Button variant="primary" tone="destructive">Click me</Button>)
expect(screen.getByRole('button').className).toContain('btn-destructive-primary')
})
it('keeps accent variants on the default tone path', () => {
render(<Button variant="secondary-accent">Click me</Button>)
expect(screen.getByRole('button').className).toContain('btn-secondary-accent')
expect(screen.getByRole('button').className).not.toContain('btn-destructive')
})
})
describe('sizes', () => {
@ -152,3 +159,9 @@ describe('Button', () => {
})
})
})
// @ts-expect-error accent variants do not support destructive tone
const _invalidSecondaryAccentTone: ButtonProps = { variant: 'secondary-accent', tone: 'destructive' }
// @ts-expect-error accent variants do not support destructive tone
const _invalidGhostAccentTone: ButtonProps = { variant: 'ghost-accent', tone: 'destructive' }

View File

@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { Button } from '.'
import { Button, buttonTones, buttonVariantsList } from '.'
const meta = {
title: 'Base/General/Button',
@ -13,12 +13,13 @@ const meta = {
loading: { control: 'boolean' },
tone: {
control: 'select',
options: ['default', 'destructive'],
options: buttonTones,
description: 'Destructive tone is only supported by primary, secondary, tertiary, and ghost variants.',
},
disabled: { control: 'boolean' },
variant: {
control: 'select',
options: ['primary', 'secondary', 'secondary-accent', 'ghost', 'ghost-accent', 'tertiary'],
options: buttonVariantsList,
},
size: {
control: 'select',
@ -53,6 +54,13 @@ export const SecondaryAccent: Story = {
variant: 'secondary-accent',
children: 'Secondary Accent Button',
},
parameters: {
docs: {
description: {
story: 'Accent variants only support the default tone.',
},
},
},
}
export const Ghost: Story = {
@ -67,6 +75,13 @@ export const GhostAccent: Story = {
variant: 'ghost-accent',
children: 'Ghost Accent Button',
},
parameters: {
docs: {
description: {
story: 'Accent variants only support the default tone.',
},
},
},
}
export const Tertiary: Story = {

View File

@ -1,9 +1,29 @@
import type { Button as BaseButtonNS } from '@base-ui/react/button'
import type { VariantProps } from 'class-variance-authority'
import { Button as BaseButton } from '@base-ui/react/button'
import { cva } from 'class-variance-authority'
import { cn } from '@/utils/classnames'
const destructiveToneVariants = ['primary', 'secondary', 'tertiary', 'ghost'] as const
const accentVariants = ['secondary-accent', 'ghost-accent'] as const
export const buttonVariantsList = [...destructiveToneVariants, ...accentVariants] as const
export const buttonTones = ['default', 'destructive'] as const
const buttonVariantSet = new Set<string>(buttonVariantsList)
const buttonSizeSet = new Set<string>(['small', 'medium', 'large'])
type DestructiveToneVariant = typeof destructiveToneVariants[number]
type AccentVariant = typeof accentVariants[number]
export type ButtonVariant = typeof buttonVariantsList[number]
export type ButtonSize = 'small' | 'medium' | 'large'
type ButtonTone = typeof buttonTones[number]
export const isButtonVariant = (value: unknown): value is ButtonVariant => {
return typeof value === 'string' && buttonVariantSet.has(value)
}
export const isButtonSize = (value: unknown): value is ButtonSize => {
return typeof value === 'string' && buttonSizeSet.has(value)
}
const buttonVariants = cva(
'btn',
{
@ -40,12 +60,30 @@ const buttonVariants = cva(
},
)
export type ButtonProps
= Omit<BaseButtonNS.Props, 'className'>
& VariantProps<typeof buttonVariants> & {
loading?: boolean
className?: string
}
type BaseButtonProps = Omit<BaseButtonNS.Props, 'className'> & {
size?: ButtonSize
className?: string
loading?: boolean
}
type DestructiveToneButtonProps = BaseButtonProps & {
variant?: DestructiveToneVariant
tone?: ButtonTone
}
type AccentButtonProps = BaseButtonProps & {
variant: AccentVariant
tone?: 'default'
}
export type ButtonProps = DestructiveToneButtonProps | AccentButtonProps
const resolveTone = (variant: ButtonVariant | undefined, tone: ButtonTone | undefined) => {
if (variant === 'secondary-accent' || variant === 'ghost-accent')
return 'default'
return tone
}
export function Button({
className,
@ -61,7 +99,7 @@ export function Button({
return (
<BaseButton
type={type}
className={cn(buttonVariants({ variant, size, tone, className }))}
className={cn(buttonVariants({ variant, size, tone: resolveTone(variant, tone), className }))}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}

View File

@ -8,7 +8,7 @@ import { TaskStatus } from '@/app/components/plugins/types'
import { useCheckInstalled, useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
type InstallPluginButtonProps = Omit<ComponentProps<typeof Button>, 'children' | 'loading'> & {
type InstallPluginButtonProps = Omit<ComponentProps<typeof Button>, 'children' | 'loading' | 'variant' | 'tone'> & {
uniqueIdentifier: string
extraIdentifiers?: string[]
onSuccess?: () => void

View File

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import type { FormInputItem, UserAction } from '../types'
import type { ButtonProps } from '@/app/components/base/ui/button'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
@ -86,7 +85,7 @@ const FormContentPreview: FC<FormContentPreviewProps> = ({
{userActions.map((action: UserAction) => (
<Button
key={action.id}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
variant={getButtonStyle(action.button_style)}
>
{action.title}
</Button>

View File

@ -1,5 +1,4 @@
'use client'
import type { ButtonProps } from '@/app/components/base/ui/button'
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import { RiArrowLeftLine } from '@remixicon/react'
@ -72,7 +71,7 @@ const FormContent = ({
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
variant={getButtonStyle(action.button_style)}
onClick={() => submit(action.id)}
>
{action.title}