mirror of
https://github.com/dataelement/bisheng.git
synced 2026-09-21 12:43:36 +08:00
feat(client): dual-axis Button design-system component + btn-* tokens
New color×variant×size Button API (solid/outlined/filled/text/link) with legacy shadcn variants auto-mapped; btn-* semantic tokens + touch hit-area in style.css/tailwind; app-wide hoverOnlyWhenSupported; gallery ButtonSection; migrate KnowledgeSpaceSidebar create button to the new API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
324f299ffd
commit
9a9e9331e7
@@ -1,57 +1,335 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
||||
/**
|
||||
* Button — design-system base component (docs-ui-refactor/组件-Button按钮.md).
|
||||
*
|
||||
* New API is the antd-style dual axis: `color` (primary/default/danger) ×
|
||||
* `variant` (solid/outlined/filled/text/link) × `size` (small/medium/large),
|
||||
* plus `iconOnly` for icon buttons and `shape` (square/circle, circle being
|
||||
* icon-only). All colors go through semantic
|
||||
* tokens (`btn-*` in tailwind.config / style.css, brand via `blue-*`); hover
|
||||
* states are disabled on touch (§5.5) and disabled/loading are uniform (§5.2).
|
||||
*
|
||||
* The legacy shadcn API (`variant="outline" | "ghost" | ...`, `size="sm" |
|
||||
* "icon" | ...`) still works through an automatic mapping (§6.3) so existing
|
||||
* call sites keep rendering; they will be migrated batch-by-batch and the
|
||||
* mapping removed afterwards.
|
||||
*/
|
||||
|
||||
type ButtonColor = 'primary' | 'default' | 'danger';
|
||||
type ButtonVariant = 'solid' | 'outlined' | 'filled' | 'text' | 'link';
|
||||
type ButtonSize = 'small' | 'medium' | 'large';
|
||||
type ButtonShape = 'square' | 'circle';
|
||||
|
||||
/** @deprecated Legacy single-axis variants — auto-mapped to color×variant (§6.3). */
|
||||
type LegacyVariant =
|
||||
| 'default'
|
||||
| 'destructive'
|
||||
| 'outline'
|
||||
| 'secondary'
|
||||
| 'secondaryBrand'
|
||||
| 'ghost'
|
||||
| 'submit';
|
||||
/** @deprecated Legacy sizes — auto-mapped (`icon` → medium + iconOnly). */
|
||||
type LegacySize = 'default' | 'sm' | 'lg' | 'icon';
|
||||
|
||||
const buttonStyles = cva(
|
||||
// Disabled is uniform across every combo (§5.2) and must beat both the
|
||||
// combo colors and legacy className overrides, hence the `!` importants.
|
||||
// `relative` anchors the .btn-touch-hit ::after hot zone (style.css).
|
||||
// Weight 400 across all sizes/types (§3.1) — heavier weights are not a knob.
|
||||
'relative inline-flex items-center justify-center whitespace-nowrap font-normal transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:!border-btn-disabled-border disabled:!bg-black/[0.04] disabled:!text-black/25 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// `btn-brand-primary` applies the green-theme lime override (see style.css).
|
||||
// Blue theme keeps the shared --primary color; other primary-token usages unaffected.
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 btn-brand-primary',
|
||||
destructive:
|
||||
'bg-surface-destructive text-destructive-foreground hover:bg-surface-destructive-hover',
|
||||
outline:
|
||||
'text-text-primary border border-border-light bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
// Brand-tinted secondary: light brand bg + dark brand text. The blue-* utilities
|
||||
// are re-pointed to the --brand-* vars, so this follows the blue⇄green theme.
|
||||
secondaryBrand: 'bg-blue-50 text-blue-600 hover:bg-blue-100',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
// hardcoded text color because of WCAG contrast issues (text-white)
|
||||
submit: 'bg-surface-submit text-white hover:bg-surface-submit-hover',
|
||||
// Color axis only carries what is combo-independent (focus ring, §5.2).
|
||||
color: {
|
||||
primary: 'focus-visible:ring-blue-500/40',
|
||||
default: 'focus-visible:ring-blue-500/40',
|
||||
danger: 'focus-visible:ring-btn-danger/40',
|
||||
},
|
||||
variant: {
|
||||
solid: '',
|
||||
outlined: 'border bg-white',
|
||||
filled: '',
|
||||
text: '',
|
||||
link: 'underline-offset-4',
|
||||
},
|
||||
// Heights/radii per §2 (24/32/40, 4/6/8px). Font sizes reference the
|
||||
// PRIMITIVE type scale vars on purpose: the semantic --text-body remaps
|
||||
// 14→16 under 768px, but control text must not follow (§5.5 / 适配原则 §3).
|
||||
// Icon size is one 14/16/18 ladder for BOTH icon-only and text+icon (§3.2/3.3).
|
||||
// Horizontal padding here is the borderless value (8/16/16); bordered
|
||||
// variants override to 7/15/15 in compoundVariants (visual-width parity).
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-9 rounded-lg px-3',
|
||||
lg: 'h-11 rounded-lg px-8',
|
||||
icon: 'size-9',
|
||||
small:
|
||||
'h-6 gap-1 rounded px-2 text-[length:var(--font-size-3)] leading-[var(--line-height-3)] [&_svg]:size-3.5',
|
||||
medium:
|
||||
'btn-touch-hit h-8 gap-2 rounded-[6px] px-4 text-[length:var(--font-size-3)] leading-[var(--line-height-3)] [&_svg]:size-4',
|
||||
large:
|
||||
'h-10 gap-2 rounded-[8px] px-4 text-[length:var(--font-size-4)] leading-[var(--line-height-4)] [&_svg]:size-[18px]',
|
||||
},
|
||||
// `circle` is declared AFTER `size` so its rounded-full wins the merge
|
||||
// over the per-size radius; resolveVariants restricts it to icon-only (§1).
|
||||
shape: {
|
||||
square: '',
|
||||
circle: 'rounded-full',
|
||||
},
|
||||
iconOnly: {
|
||||
true: '',
|
||||
false: '',
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
/* ---- color × variant matrix (§5.2; combos the spec leaves implicit
|
||||
follow the same ramp logic: hover one step, active one deeper) ---- */
|
||||
{
|
||||
color: 'primary',
|
||||
variant: 'solid',
|
||||
// btn-brand-primary = green-theme !important override (style.css) —
|
||||
// kept as agreed tech debt until the theme mechanism is reworked (§6.2).
|
||||
class:
|
||||
'btn-brand-primary bg-blue-500 text-white hover:bg-blue-400 active:bg-blue-600',
|
||||
},
|
||||
{
|
||||
color: 'primary',
|
||||
variant: 'outlined',
|
||||
class:
|
||||
'border-blue-500 text-blue-500 hover:border-blue-400 hover:text-blue-400 active:border-blue-600 active:text-blue-600',
|
||||
},
|
||||
{
|
||||
color: 'primary',
|
||||
variant: 'filled',
|
||||
class: 'bg-blue-50 text-blue-600 hover:bg-blue-100 active:bg-blue-200',
|
||||
},
|
||||
{
|
||||
color: 'primary',
|
||||
variant: 'text',
|
||||
class: 'text-blue-500 hover:bg-blue-50 active:bg-blue-100',
|
||||
},
|
||||
{
|
||||
color: 'primary',
|
||||
variant: 'link',
|
||||
class:
|
||||
'text-blue-500 hover:text-blue-400 hover:underline active:text-blue-600',
|
||||
},
|
||||
{
|
||||
color: 'default',
|
||||
variant: 'solid',
|
||||
class:
|
||||
'bg-btn-gray-text text-white hover:bg-btn-gray-text/90 active:bg-btn-gray-text/80',
|
||||
},
|
||||
{
|
||||
color: 'default',
|
||||
variant: 'outlined',
|
||||
class:
|
||||
'border-btn-gray-border text-btn-gray-text hover:bg-btn-fill-1 active:bg-btn-fill-2',
|
||||
},
|
||||
{
|
||||
color: 'default',
|
||||
variant: 'filled',
|
||||
class:
|
||||
'bg-btn-fill-2 text-btn-gray-text hover:bg-btn-fill-3 active:bg-btn-fill-4',
|
||||
},
|
||||
{
|
||||
color: 'default',
|
||||
variant: 'text',
|
||||
class:
|
||||
'text-btn-gray-text hover:bg-btn-fill-1 active:bg-btn-fill-2',
|
||||
},
|
||||
{
|
||||
color: 'default',
|
||||
variant: 'link',
|
||||
class:
|
||||
'text-btn-gray-text hover:text-btn-gray-text/80 hover:underline active:text-btn-gray-text',
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
variant: 'solid',
|
||||
class:
|
||||
'bg-btn-danger text-white hover:bg-btn-danger-hover active:bg-btn-danger-active',
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
variant: 'outlined',
|
||||
class:
|
||||
'border-btn-danger text-btn-danger hover:border-btn-danger-hover hover:text-btn-danger-hover active:border-btn-danger-active active:text-btn-danger-active',
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
variant: 'filled',
|
||||
class:
|
||||
'bg-btn-danger/10 text-btn-danger hover:bg-btn-danger/[0.15] active:bg-btn-danger/20',
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
variant: 'text',
|
||||
class:
|
||||
'text-btn-danger hover:bg-btn-danger/10 active:bg-btn-danger/[0.15]',
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
variant: 'link',
|
||||
class:
|
||||
'text-btn-danger hover:text-btn-danger-hover hover:underline active:text-btn-danger-active',
|
||||
},
|
||||
/* ---- bordered padding 7/15/15 incl. 1px border (§2 visual parity) ---- */
|
||||
{ variant: 'outlined', size: 'small', class: 'px-[7px]' },
|
||||
{ variant: 'outlined', size: ['medium', 'large'], class: 'px-[15px]' },
|
||||
/* ---- icon-only squares 24/32/40 (§3.2, icon ladder shared with the
|
||||
size axis); every size gets the ≥44px touch hot zone (§5.5) ---- */
|
||||
{ iconOnly: true, size: 'small', class: 'btn-touch-hit w-6 px-0' },
|
||||
{ iconOnly: true, size: 'medium', class: 'w-8 px-0' },
|
||||
{ iconOnly: true, size: 'large', class: 'btn-touch-hit w-10 px-0' },
|
||||
],
|
||||
// Bare <Button> keeps its historical primary-solid look (§6.3).
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
color: 'primary',
|
||||
variant: 'solid',
|
||||
size: 'medium',
|
||||
shape: 'square',
|
||||
iconOnly: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonStyleProps {
|
||||
// `(string & {})` keeps the three literals in autocomplete while still
|
||||
// accepting `{...props}` spreads that carry the native HTML `color` attr
|
||||
// (e.g. TooltipAnchor render props); non-axis strings are ignored at runtime.
|
||||
color?: ButtonColor | (string & {});
|
||||
variant?: ButtonVariant | LegacyVariant;
|
||||
size?: ButtonSize | LegacySize;
|
||||
/** `circle` renders a full circle — icon-only buttons ONLY (§1); ignored otherwise. */
|
||||
shape?: ButtonShape;
|
||||
/** Square icon-only button (§3.2) — must ship an `aria-label` + Tooltip. */
|
||||
iconOnly?: boolean;
|
||||
}
|
||||
|
||||
function isButtonColor(value: unknown): value is ButtonColor {
|
||||
return value === 'primary' || value === 'default' || value === 'danger';
|
||||
}
|
||||
|
||||
const LEGACY_VARIANT_MAP: Record<string, { color: ButtonColor; variant: ButtonVariant }> = {
|
||||
default: { color: 'primary', variant: 'solid' },
|
||||
submit: { color: 'primary', variant: 'solid' },
|
||||
destructive: { color: 'danger', variant: 'solid' },
|
||||
outline: { color: 'default', variant: 'outlined' },
|
||||
secondary: { color: 'default', variant: 'filled' },
|
||||
secondaryBrand: { color: 'primary', variant: 'filled' },
|
||||
ghost: { color: 'default', variant: 'text' },
|
||||
// Bare `variant="link"` predates the color axis — keep its primary look.
|
||||
link: { color: 'primary', variant: 'link' },
|
||||
};
|
||||
|
||||
function resolveVariants({ color: rawColor, variant, size, shape, iconOnly }: ButtonStyleProps) {
|
||||
const color = isButtonColor(rawColor) ? rawColor : undefined;
|
||||
let resolvedColor = color;
|
||||
let resolvedVariant = variant as ButtonVariant | undefined;
|
||||
// Legacy values only kick in while the new `color` axis is absent — any
|
||||
// explicit `color` means the caller is on the new dual-axis API.
|
||||
if (color === undefined && variant !== undefined && variant in LEGACY_VARIANT_MAP) {
|
||||
({ color: resolvedColor, variant: resolvedVariant } = LEGACY_VARIANT_MAP[variant]);
|
||||
} else if (color !== undefined && variant === undefined) {
|
||||
// New-API ergonomics matching the §1 named types: <Button color="default">
|
||||
// is THE default button (outlined), primary/danger default to solid.
|
||||
resolvedVariant = color === 'default' ? 'outlined' : 'solid';
|
||||
}
|
||||
|
||||
let resolvedSize: ButtonSize | undefined;
|
||||
let resolvedIconOnly = iconOnly;
|
||||
switch (size) {
|
||||
case 'default':
|
||||
case 'sm':
|
||||
resolvedSize = 'medium';
|
||||
break;
|
||||
case 'lg':
|
||||
resolvedSize = 'large';
|
||||
break;
|
||||
case 'icon':
|
||||
resolvedSize = 'medium';
|
||||
resolvedIconOnly = iconOnly ?? true;
|
||||
break;
|
||||
default:
|
||||
resolvedSize = size;
|
||||
}
|
||||
|
||||
return {
|
||||
color: resolvedColor,
|
||||
variant: resolvedVariant,
|
||||
size: resolvedSize,
|
||||
// Circle is an icon-only privilege (§1) — text buttons fall back to square.
|
||||
shape: shape === 'circle' && resolvedIconOnly ? ('circle' as const) : ('square' as const),
|
||||
iconOnly: resolvedIconOnly,
|
||||
};
|
||||
}
|
||||
|
||||
/** Class-only entry point (for <a>/Slot call sites); accepts both APIs. */
|
||||
export function buttonVariants(props: ButtonStyleProps & { className?: string } = {}) {
|
||||
const { className, ...styleProps } = props;
|
||||
return buttonStyles({ ...resolveVariants(styleProps), className });
|
||||
}
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
// Native `color` attr is shadowed by the color axis.
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'color'>,
|
||||
ButtonStyleProps {
|
||||
asChild?: boolean;
|
||||
/** Single leading icon (§3.3, one icon max); replaced by the spinner while loading. */
|
||||
icon?: React.ReactNode;
|
||||
/**
|
||||
* Built-in loading state (§5.2): spinner takes the icon slot, whole button
|
||||
* at opacity .65 and not clickable. Do NOT pass your own Spinner.
|
||||
*/
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
color,
|
||||
variant,
|
||||
size,
|
||||
shape,
|
||||
iconOnly,
|
||||
icon,
|
||||
loading = false,
|
||||
asChild = false,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />
|
||||
<Comp
|
||||
ref={ref}
|
||||
aria-busy={loading || undefined}
|
||||
className={cn(
|
||||
buttonVariants({ color, variant, size, shape, iconOnly }),
|
||||
loading && 'pointer-events-none opacity-65',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{asChild ? (
|
||||
// Slot requires a single element child — icon/spinner injection is
|
||||
// skipped; asChild callers render their own content.
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{loading ? <Outlined.Loading className="animate-spin" /> : icon}
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button };
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
/**
|
||||
* Button gallery — DEV-ONLY.
|
||||
* Button.tsx is already cva-based; it serves as the reference pattern for how other
|
||||
* components should expose variants/sizes. See docs-ui-refactor/01-设计规范.md.
|
||||
* Standard-usage documentation for the refactored dual-axis Button
|
||||
* (docs-ui-refactor/组件-Button按钮.md v1): color × variant × size matrix,
|
||||
* states, content forms, plus the legacy-API mapping ledger for migration.
|
||||
*/
|
||||
import { Outlined } from 'bisheng-icons';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { ComponentPage, ExampleGroup, ExampleGrid, ExampleCard } from '../components/kit';
|
||||
import {
|
||||
ComponentPage,
|
||||
ExampleGroup,
|
||||
ExampleGrid,
|
||||
ExampleCard,
|
||||
CompareTable,
|
||||
} from '../components/kit';
|
||||
|
||||
const VARIANTS = [
|
||||
'default',
|
||||
'secondary',
|
||||
'secondaryBrand',
|
||||
'outline',
|
||||
'ghost',
|
||||
'destructive',
|
||||
'submit',
|
||||
'link',
|
||||
] as const;
|
||||
const COLORS = ['primary', 'default', 'danger'] as const;
|
||||
const VARIANTS = ['solid', 'outlined', 'filled', 'text', 'link'] as const;
|
||||
const SIZES = ['small', 'medium', 'large'] as const;
|
||||
|
||||
const SIZES = ['sm', 'default', 'lg', 'icon'] as const;
|
||||
const SIZE_META: Record<(typeof SIZES)[number], string> = {
|
||||
small: '高 24px · 字号 14/22 · 圆角 4px — 表格行内、紧凑工具条',
|
||||
medium: '高 32px · 字号 14/22 · 圆角 6px — 默认,绝大多数场景',
|
||||
large: '高 40px · 字号 16/24 · 圆角 8px — 登录页、大表单提交',
|
||||
};
|
||||
|
||||
export function ButtonSection() {
|
||||
return (
|
||||
@@ -26,39 +31,318 @@ export function ButtonSection() {
|
||||
eng="Button"
|
||||
description={
|
||||
<>
|
||||
已是 <code>cva</code> 变体写法,作为其它组件"留改动余地"的范本:预设档位 +
|
||||
允许 <code>className</code> 覆盖。
|
||||
antd 式 <code>color × variant</code> 双轴:color 管颜色(primary 品牌 / default
|
||||
中性 / danger 危险),variant 管画法(solid / outlined / filled / text /
|
||||
link),3×5 组合自动成立。颜色全部走 token(品牌随蓝⇄绿主题,危险红固定),
|
||||
触屏下自动禁用 hover 态并扩 44px 热区。规范见 docs-ui-refactor/组件-Button按钮.md。
|
||||
</>
|
||||
}
|
||||
whenToUse={[
|
||||
<>主操作用 <code>default</code>(已跟随蓝⇄绿主题);危险操作用 <code>destructive</code>。</>,
|
||||
<>次要操作用 <code>outline</code> / <code>secondary</code>;弱操作用 <code>ghost</code> / <code>link</code>。</>,
|
||||
<>尺寸只用 <code>sm / default / lg / icon</code> 四档,不要手写高度/内边距。</>,
|
||||
<>特殊一次性样式用 <code>className</code> 覆盖,不要新增变体。</>,
|
||||
<>
|
||||
一个操作区域只放一个 <code>primary solid</code> 主按钮;次级操作用默认按钮(
|
||||
<code>color="default"</code>,白底灰描边)。
|
||||
</>,
|
||||
<>
|
||||
尺寸只用 <code>small / medium / large</code> 三档(24/32/40px),不要手写高度、
|
||||
内边距、圆角;同一视图内相邻按钮必须同尺寸。
|
||||
</>,
|
||||
<>
|
||||
弹窗 footer 右对齐、主按钮在最右(间距 12px);页面级操作区主按钮在左首位(间距
|
||||
8px)。
|
||||
</>,
|
||||
<>
|
||||
纯 icon 按钮必须带 Tooltip 与 <code>aria-label</code>;正圆形(
|
||||
<code>shape="circle"</code>)仅限纯 icon 按钮;图标 bisheng-icons
|
||||
优先,lucide 兜底。
|
||||
</>,
|
||||
<>
|
||||
loading 用组件内置的 <code>loading</code> 属性,禁止业务页自塞 Spinner;两个汉字的
|
||||
按钮不加中间空格。
|
||||
</>,
|
||||
<>
|
||||
特殊一次性样式用 <code>className</code> 覆盖,不要新增变体;旧
|
||||
API(outline/ghost/submit…)已自动映射为新双轴,逐批迁移后删除。
|
||||
</>,
|
||||
]}
|
||||
>
|
||||
<ExampleGroup title="变体 variant" subtitle="每格含常态与 disabled 态">
|
||||
<ExampleGrid cols={4}>
|
||||
{VARIANTS.map((v) => (
|
||||
<ExampleCard key={v} title={`variant="${v}"`}>
|
||||
<Button variant={v}>{v}</Button>
|
||||
<Button variant={v} disabled>
|
||||
disabled
|
||||
<ExampleGroup
|
||||
title="常用类型(§1)"
|
||||
subtitle="双轴组合的六个常用别名;其余组合按双轴自然推导。"
|
||||
>
|
||||
<ExampleGrid cols={3}>
|
||||
<ExampleCard title="Primary 主按钮" description="primary × solid — 主行动点,一个区域只放一个">
|
||||
<Button color="primary" variant="solid">
|
||||
主按钮
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
<ExampleCard title="Secondary 次强调" description="primary × filled — 品牌浅底,弱于主按钮">
|
||||
<Button color="primary" variant="filled">
|
||||
次强调
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
<ExampleCard title="Default 默认按钮" description="default × outlined — 最常用的次级按钮(取消/返回)">
|
||||
<Button color="default" variant="outlined">
|
||||
取消
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
<ExampleCard title="Text 文字按钮" description="default × text — 最次级、表格行内、工具栏">
|
||||
<Button color="default" variant="text">
|
||||
文字按钮
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
<ExampleCard title="Link 链接按钮" description="primary × link — 导航型操作,hover 不加底">
|
||||
<Button color="primary" variant="link">
|
||||
链接按钮
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
<ExampleCard title="Danger 危险按钮" description="danger × solid / outlined / text — 一般配二次确认">
|
||||
<Button color="danger" variant="solid">
|
||||
删除
|
||||
</Button>
|
||||
<Button color="danger" variant="outlined">
|
||||
删除
|
||||
</Button>
|
||||
<Button color="danger" variant="text">
|
||||
删除
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
</ExampleGrid>
|
||||
</ExampleGroup>
|
||||
|
||||
<ExampleGroup
|
||||
title="color × variant 全矩阵"
|
||||
subtitle="15 种组合全部成立;直接在此悬停/按下体验 hover、active 状态色板(§5.2)。"
|
||||
>
|
||||
<CompareTable
|
||||
head={['variant \\ color', 'primary 品牌', 'default 中性', 'danger 危险']}
|
||||
rows={VARIANTS.map((v) => [
|
||||
<code key="v">{v}</code>,
|
||||
...COLORS.map((c) => (
|
||||
<Button key={c} color={c} variant={v}>
|
||||
按钮
|
||||
</Button>
|
||||
)),
|
||||
])}
|
||||
/>
|
||||
</ExampleGroup>
|
||||
|
||||
<ExampleGroup
|
||||
title="尺寸 size(§2)"
|
||||
subtitle="三档:24 / 32 / 40px,圆角 4 / 6 / 8px;描边与无边框变体水平 padding 视觉等宽。"
|
||||
>
|
||||
<ExampleGrid cols={3}>
|
||||
{SIZES.map((s) => (
|
||||
<ExampleCard key={s} title={`size="${s}"`} description={SIZE_META[s]}>
|
||||
<Button color="primary" size={s}>
|
||||
按钮
|
||||
</Button>
|
||||
<Button color="default" size={s}>
|
||||
按钮
|
||||
</Button>
|
||||
<Button color="default" size={s} iconOnly aria-label="搜索">
|
||||
<Outlined.Search />
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
))}
|
||||
</ExampleGrid>
|
||||
</ExampleGroup>
|
||||
|
||||
<ExampleGroup title="尺寸 size">
|
||||
<ExampleGrid cols={4}>
|
||||
{SIZES.map((s) => (
|
||||
<ExampleCard key={s} title={`size="${s}"`}>
|
||||
<Button size={s}>{s === 'icon' ? '★' : s}</Button>
|
||||
</ExampleCard>
|
||||
))}
|
||||
<ExampleGroup
|
||||
title="内容形态(§3)"
|
||||
subtitle="纯文字 / 纯 icon / 文字 + icon;一个按钮最多一个 icon。"
|
||||
>
|
||||
<ExampleGrid cols={2}>
|
||||
<ExampleCard
|
||||
title="纯文字"
|
||||
description="不换行不省略;两个汉字不加中间空格;字重 400(全尺寸全类型一致)"
|
||||
>
|
||||
<Button>确定</Button>
|
||||
<Button color="default">取消</Button>
|
||||
</ExampleCard>
|
||||
<ExampleCard
|
||||
title="纯 icon(shape square / circle)"
|
||||
description="24/32/40,icon 14/16/18px;circle 正圆仅限纯 icon 按钮;必须带 Tooltip + aria-label,触屏热区自动扩到 ≥44px"
|
||||
>
|
||||
<Button color="default" variant="outlined" size="small" iconOnly aria-label="编辑">
|
||||
<Outlined.Edit />
|
||||
</Button>
|
||||
<Button color="default" variant="outlined" size="medium" iconOnly aria-label="编辑">
|
||||
<Outlined.Edit />
|
||||
</Button>
|
||||
<Button color="default" variant="outlined" size="large" iconOnly aria-label="编辑">
|
||||
<Outlined.Edit />
|
||||
</Button>
|
||||
<Button
|
||||
color="default"
|
||||
variant="outlined"
|
||||
shape="circle"
|
||||
iconOnly
|
||||
aria-label="搜索"
|
||||
>
|
||||
<Outlined.Search />
|
||||
</Button>
|
||||
<Button shape="circle" iconOnly aria-label="发送">
|
||||
<Outlined.Send />
|
||||
</Button>
|
||||
<Button color="default" variant="text" iconOnly aria-label="删除">
|
||||
<Outlined.Delete />
|
||||
</Button>
|
||||
<Button color="danger" variant="text" iconOnly aria-label="删除">
|
||||
<Outlined.Delete />
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
<ExampleCard
|
||||
title="文字 + icon(icon 属性,默认在左)"
|
||||
description="icon 与纯 icon 同一套 14/16/18px,间距 8px(small 4px)"
|
||||
>
|
||||
<Button icon={<Outlined.Plus />}>新建</Button>
|
||||
<Button color="default" icon={<Outlined.Download />}>
|
||||
下载
|
||||
</Button>
|
||||
<Button color="danger" variant="outlined" icon={<Outlined.Delete />}>
|
||||
删除
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
<ExampleCard
|
||||
title="方向语义 icon 在右"
|
||||
description="“下一步 →”类方向语义可放右侧:icon 走 children 尾部"
|
||||
>
|
||||
<Button>
|
||||
下一步
|
||||
<Outlined.ArrowRight />
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
</ExampleGrid>
|
||||
</ExampleGroup>
|
||||
|
||||
<ExampleGroup
|
||||
title="状态 state(§5)"
|
||||
subtitle="hover / active 请在上方矩阵直接体验;disabled 与 loading 全类型统一;focus 环仅键盘(Tab)可见,环色随 color。"
|
||||
>
|
||||
<ExampleGrid cols={2}>
|
||||
<ExampleCard
|
||||
title="disabled(全类型统一)"
|
||||
description="灰底 rgba(0,0,0,.04) + 字 rgba(0,0,0,.25) + 边 #d9d9d9,cursor: not-allowed"
|
||||
>
|
||||
<Button disabled>主按钮</Button>
|
||||
<Button color="primary" variant="filled" disabled>
|
||||
次强调
|
||||
</Button>
|
||||
<Button color="default" disabled>
|
||||
默认
|
||||
</Button>
|
||||
<Button color="default" variant="text" disabled>
|
||||
文字
|
||||
</Button>
|
||||
<Button color="danger" disabled>
|
||||
删除
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
<ExampleCard
|
||||
title="loading(内置 spinner)"
|
||||
description="spinner 顶替 icon 位、整体 opacity .65、期间不可点;禁止业务页自塞 Spinner"
|
||||
>
|
||||
<Button loading>提交中</Button>
|
||||
<Button color="default" loading>
|
||||
提交中
|
||||
</Button>
|
||||
<Button color="danger" loading>
|
||||
删除中
|
||||
</Button>
|
||||
<Button loading icon={<Outlined.Plus />}>
|
||||
新建
|
||||
</Button>
|
||||
</ExampleCard>
|
||||
</ExampleGrid>
|
||||
</ExampleGroup>
|
||||
|
||||
<ExampleGroup
|
||||
title="旧 API 兼容映射(迁移台账,§6.3)"
|
||||
subtitle="旧入参自动映射为新双轴(已标 deprecated),下方按钮全部用旧 API 渲染以验证映射;业务迁完即删。缺省高度 h-9(36) 已归入 medium(32),全站矮 4px,迁移各批带目检回归(§6.6)。"
|
||||
>
|
||||
<CompareTable
|
||||
head={['旧写法(用量)', '映射为', '旧 API 实渲染']}
|
||||
rows={[
|
||||
[
|
||||
<code key="o">缺省 / variant="default"(116 处)</code>,
|
||||
<code key="n">primary solid</code>,
|
||||
<Button key="b" variant="default">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">variant="submit"(11 处)</code>,
|
||||
<code key="n">primary solid(原写死 ChatGPT 绿,随迁移废除)</code>,
|
||||
<Button key="b" variant="submit">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">variant="outline"(78 处)</code>,
|
||||
<code key="n">default outlined</code>,
|
||||
<Button key="b" variant="outline">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">variant="secondary"(17 处,原 18:知识空间侧栏“创建知识空间”已迁 primary filled)</code>,
|
||||
<code key="n">default filled</code>,
|
||||
<Button key="b" variant="secondary">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">variant="secondaryBrand"(0 处)</code>,
|
||||
<code key="n">primary filled</code>,
|
||||
<Button key="b" variant="secondaryBrand">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">variant="ghost"(40 处)</code>,
|
||||
<code key="n">default text</code>,
|
||||
<Button key="b" variant="ghost">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">variant="destructive"(6 处)</code>,
|
||||
<code key="n">danger solid</code>,
|
||||
<Button key="b" variant="destructive">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">variant="link"(0 处)</code>,
|
||||
<code key="n">primary link</code>,
|
||||
<Button key="b" variant="link">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">size 缺省 / "sm"(249 处,旧 h-9)</code>,
|
||||
<code key="n">medium(32px)</code>,
|
||||
<Button key="b" variant="outline" size="sm">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">size="lg"(2 处)</code>,
|
||||
<code key="n">large(40px)</code>,
|
||||
<Button key="b" size="lg">
|
||||
按钮
|
||||
</Button>,
|
||||
],
|
||||
[
|
||||
<code key="o">size="icon"(18 处)</code>,
|
||||
<code key="n">medium + iconOnly</code>,
|
||||
<Button key="b" variant="outline" size="icon" aria-label="搜索">
|
||||
<Outlined.Search />
|
||||
</Button>,
|
||||
],
|
||||
]}
|
||||
/>
|
||||
</ExampleGroup>
|
||||
</ComponentPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -582,11 +582,12 @@ export function KnowledgeSpaceSidebar({
|
||||
{localize("com_knowledge.go_to_knowledge_square")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
color="primary"
|
||||
variant="filled"
|
||||
icon={<Outlined.Plus />}
|
||||
onClick={onCreateSpace}
|
||||
className="h-8 w-full gap-1 rounded-[6px] bg-blue-100 px-3 py-[5px] text-sm font-normal leading-[22px] text-blue-main hover:bg-blue-200"
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{localize("com_knowledge.create_knowledge_space")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -175,6 +175,21 @@ html {
|
||||
--brand-main: 22 93 255; /* #165DFF */
|
||||
--brand-muted: 87 115 180; /* #5773B4 — muted/low-sat brand accent (e.g. pin) */
|
||||
|
||||
/* Button semantic tokens (docs-ui-refactor/组件-Button按钮.md §5.1).
|
||||
Neutral grays follow the Arco ramp; danger red is FIXED — never themed.
|
||||
RGB channels so the Tailwind `btn-*` colors keep `/<alpha>` working.
|
||||
Light-mode values only for now (dark mode inherits them — known debt). */
|
||||
--btn-gray-text: 78 89 105; /* #4E5969 — default-color button text */
|
||||
--btn-gray-border: 229 230 235; /* #E5E6EB — default-color button border */
|
||||
--btn-fill-1: 247 248 250; /* #F7F8FA — neutral fill ramp: hover bg */
|
||||
--btn-fill-2: 242 243 245; /* #F2F3F5 — active bg / filled base */
|
||||
--btn-fill-3: 229 230 235; /* #E5E6EB — filled hover */
|
||||
--btn-fill-4: 201 205 212; /* #C9CDD4 — filled active */
|
||||
--btn-danger: 245 63 63; /* #F53F3F */
|
||||
--btn-danger-hover: 214 55 58; /* #D6373A */
|
||||
--btn-danger-active: 208 47 51; /* #D02F33 */
|
||||
--btn-disabled-border: 217 217 217; /* #D9D9D9 — bordered variants, disabled */
|
||||
|
||||
/* Illustration palette — empty-state SVG illustrations use --illus-* instead
|
||||
of --brand-* so the green theme can render the illustrations' own vivid
|
||||
green ramp (#19B476 / #BDE6D3 / #7CD0B1) rather than the darker UI brand
|
||||
@@ -261,6 +276,22 @@ html {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Touch hit-area expansion (组件-Button按钮.md §5.5): medium and icon-only
|
||||
buttons get an invisible ≥44×44 hot zone on touch devices — "跟手不跟屏"
|
||||
(基础-多端适配原则.md §0/§2) — while the visual size stays unchanged.
|
||||
The base Button sets `relative` + this class; never hand-roll per page. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.btn-touch-hit::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: max(100%, 44px);
|
||||
height: max(100%, 44px);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--presentation: var(--gray-800);
|
||||
--text-primary: var(--gray-100);
|
||||
|
||||
@@ -3,6 +3,14 @@ const plugin = require('tailwindcss/plugin');
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
// 基础-多端适配原则.md §1: hover states are disabled on touch APP-WIDE — every
|
||||
// `hover:` utility compiles wrapped in a hover-capable media query. Press
|
||||
// feedback on touch comes from `active:` styles instead (no sticky hover).
|
||||
// NOTE: components must keep using plain `hover:` (never a custom variant),
|
||||
// so tailwind-merge can still dedupe business-page hover overrides.
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
content: ['./src/**/*.{js,jsx,ts,tsx}'],
|
||||
// darkMode: 'class',
|
||||
darkMode: ['class'],
|
||||
@@ -159,6 +167,19 @@ module.exports = {
|
||||
900: 'rgb(var(--brand-900) / <alpha-value>)',
|
||||
},
|
||||
'brand-purple': '#ab68ff',
|
||||
// Button semantic tokens (docs-ui-refactor/组件-Button按钮.md §5.1) —
|
||||
// RGB-channel vars defined in src/style.css :root; channel form keeps
|
||||
// `/<alpha>` modifiers working. Neutral fill ramp is shared Arco grays.
|
||||
'btn-gray-text': 'rgb(var(--btn-gray-text) / <alpha-value>)',
|
||||
'btn-gray-border': 'rgb(var(--btn-gray-border) / <alpha-value>)',
|
||||
'btn-fill-1': 'rgb(var(--btn-fill-1) / <alpha-value>)',
|
||||
'btn-fill-2': 'rgb(var(--btn-fill-2) / <alpha-value>)',
|
||||
'btn-fill-3': 'rgb(var(--btn-fill-3) / <alpha-value>)',
|
||||
'btn-fill-4': 'rgb(var(--btn-fill-4) / <alpha-value>)',
|
||||
'btn-danger': 'rgb(var(--btn-danger) / <alpha-value>)',
|
||||
'btn-danger-hover': 'rgb(var(--btn-danger-hover) / <alpha-value>)',
|
||||
'btn-danger-active': 'rgb(var(--btn-danger-active) / <alpha-value>)',
|
||||
'btn-disabled-border': 'rgb(var(--btn-disabled-border) / <alpha-value>)',
|
||||
'presentation': 'var(--presentation)',
|
||||
'text-primary': 'var(--text-primary)',
|
||||
'text-secondary': 'var(--text-secondary)',
|
||||
|
||||
Reference in New Issue
Block a user