From cca408299b9bc0df8a244ba51b1748267b09cec7 Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 14 Apr 2026 22:54:36 +0800 Subject: [PATCH] refactor(web): tighten button tone typing --- .../(humanInputLayout)/form/[token]/form.tsx | 3 +- .../human-input-content/human-input-form.tsx | 3 +- .../chat/answer/human-input-content/utils.ts | 3 +- .../components/base/markdown-blocks/form.tsx | 23 ++------ .../components/base/ui/alert-dialog/index.tsx | 12 +++-- .../base/ui/button/__tests__/index.spec.tsx | 13 +++++ .../base/ui/button/index.stories.tsx | 21 ++++++-- web/app/components/base/ui/button/index.tsx | 54 ++++++++++++++++--- .../components/install-plugin-button.tsx | 2 +- .../components/form-content-preview.tsx | 3 +- .../components/single-run-form.tsx | 3 +- 11 files changed, 96 insertions(+), 44 deletions(-) diff --git a/web/app/(humanInputLayout)/form/[token]/form.tsx b/web/app/(humanInputLayout)/form/[token]/form.tsx index 898dab8f4a..c6e38e9844 100644 --- a/web/app/(humanInputLayout)/form/[token]/form.tsx +++ b/web/app/(humanInputLayout)/form/[token]/form.tsx @@ -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 = () => { ) expect(screen.getByRole('button').className).toContain('btn-destructive-primary') }) + + it('keeps accent variants on the default tone path', () => { + render() + 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' } diff --git a/web/app/components/base/ui/button/index.stories.tsx b/web/app/components/base/ui/button/index.stories.tsx index 9552452a22..2b4e5869a8 100644 --- a/web/app/components/base/ui/button/index.stories.tsx +++ b/web/app/components/base/ui/button/index.stories.tsx @@ -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 = { diff --git a/web/app/components/base/ui/button/index.tsx b/web/app/components/base/ui/button/index.tsx index 00bbe3c8b7..80a0f16512 100644 --- a/web/app/components/base/ui/button/index.tsx +++ b/web/app/components/base/ui/button/index.tsx @@ -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(buttonVariantsList) +const buttonSizeSet = new Set(['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 - & VariantProps & { - loading?: boolean - className?: string - } +type BaseButtonProps = Omit & { + 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 ( , 'children' | 'loading'> & { +type InstallPluginButtonProps = Omit, 'children' | 'loading' | 'variant' | 'tone'> & { uniqueIdentifier: string extraIdentifiers?: string[] onSuccess?: () => void diff --git a/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx index 1364941edf..01666a4396 100644 --- a/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx +++ b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx @@ -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 = ({ {userActions.map((action: UserAction) => ( diff --git a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx index dc735830c6..0833fe67a1 100644 --- a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx +++ b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx @@ -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 = ({