补齐共享 UI 组件库与展示页交互
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 3m21s
Project CI / Native shell tests (pull_request) Successful in 12m36s

- 新增 shadcn canonical UI 源码、共享样式与组件导出
- 新增 /components 共享组件展示页及路由测试
- 补齐平台组件筛选、排序、上传预览和异步状态交互
- 同步网站与客户端构建别名、依赖和项目文档
This commit is contained in:
2026-08-27 15:12:50 +08:00
parent d41aaddd0e
commit bbd895d7c2
48 changed files with 5032 additions and 12 deletions
@@ -0,0 +1,131 @@
import type {
ButtonHTMLAttributes,
LabelHTMLAttributes,
ReactNode,
} from 'react';
import { Button, type ButtonProps, type SharedButtonSize } from './ui';
export type PlatformActionButtonTone =
| 'primary'
| 'secondary'
| 'ghost'
| 'danger'
| 'success'
| 'warning'
| 'accent'
| 'accentSoft';
export type PlatformActionButtonSurface = 'platform' | 'profile' | 'editorDark';
export type PlatformActionButtonSize = 'xxs' | 'xs' | SharedButtonSize;
export type PlatformActionButtonShape = 'default' | 'pill';
export type PlatformActionButtonAlign = 'center' | 'start';
type PlatformActionButtonBaseProps = {
children?: ReactNode;
tone?: PlatformActionButtonTone;
surface?: PlatformActionButtonSurface;
size?: PlatformActionButtonSize;
shape?: PlatformActionButtonShape;
align?: PlatformActionButtonAlign;
fullWidth?: boolean;
};
type PlatformActionButtonButtonProps = Omit<
ButtonProps,
'children' | 'variant' | 'size' | 'fullWidth'
> &
PlatformActionButtonBaseProps & {
asChild?: false;
};
type PlatformActionButtonLabelProps = Omit<
LabelHTMLAttributes<HTMLLabelElement>,
'children'
> &
PlatformActionButtonBaseProps & {
asChild: 'label';
};
export type PlatformActionButtonProps =
| PlatformActionButtonButtonProps
| PlatformActionButtonLabelProps;
const toneVariant: Record<PlatformActionButtonTone, ButtonProps['variant']> = {
primary: 'primary',
secondary: 'secondary',
ghost: 'ghost',
danger: 'danger',
success: 'primary',
warning: 'secondary',
accent: 'primary',
accentSoft: 'secondary',
};
function toneClassName(tone: PlatformActionButtonTone) {
return tone.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);
}
/**
* Product-neutral compatibility wrapper for the existing platform action
* button naming. It keeps the optional label host useful for file inputs while
* leaving all product state and event handling with the caller.
*/
export function PlatformActionButton({
tone = 'primary',
surface = 'platform',
size = 'sm',
shape = 'default',
align = 'center',
fullWidth = false,
className,
children,
asChild,
...props
}: PlatformActionButtonProps) {
const resolvedSize: SharedButtonSize =
size === 'xxs' || size === 'xs' ? 'sm' : size;
const modifierClassName = [
shape === 'pill' ? 'genarrative-ui-button--pill' : null,
align === 'start' ? 'genarrative-ui-button--start' : null,
fullWidth ? 'genarrative-ui-button--full-width' : null,
`genarrative-ui-button--surface-${surface}`,
`genarrative-ui-button--tone-${toneClassName(tone)}`,
className,
]
.filter(Boolean)
.join(' ');
const actionClassName = [
'genarrative-ui-button',
`genarrative-ui-button--${toneVariant[tone]}`,
`genarrative-ui-button--${resolvedSize}`,
modifierClassName,
]
.filter(Boolean)
.join(' ');
if (asChild === 'label') {
return (
<label
{...(props as LabelHTMLAttributes<HTMLLabelElement>)}
className={actionClassName}
>
{children}
</label>
);
}
const { type = 'button', ...buttonProps } =
props as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<Button
{...buttonProps}
type={type}
fullWidth={fullWidth}
size={resolvedSize}
variant={toneVariant[tone]}
className={modifierClassName}
>
{children}
</Button>
);
}
@@ -0,0 +1,5 @@
export type {
BadgeProps as PlatformBadgeProps,
BadgeTone as PlatformBadgeTone,
} from './ui';
export { Badge as PlatformBadge } from './ui';
@@ -0,0 +1,2 @@
export type { EmptyStateProps as PlatformEmptyStateProps } from './ui';
export { EmptyState as PlatformEmptyState } from './ui';
@@ -0,0 +1,151 @@
import type {
ButtonHTMLAttributes,
HTMLAttributes,
KeyboardEvent,
LabelHTMLAttributes,
ReactNode,
Ref,
} from 'react';
import { forwardRef } from 'react';
import { IconButton, type IconButtonProps, type IconButtonSize } from './ui';
export type PlatformIconButtonVariant =
| 'platformIcon'
| 'surfaceFloating'
| 'darkMini';
export type PlatformIconButtonSize = IconButtonSize;
type PlatformIconButtonBaseProps = {
label: string;
icon: ReactNode;
children?: ReactNode;
variant?: PlatformIconButtonVariant;
};
type PlatformIconButtonButtonProps = Omit<
IconButtonProps,
'variant' | 'children'
> &
PlatformIconButtonBaseProps & {
asChild?: false;
};
type PlatformIconButtonLabelProps = Omit<
LabelHTMLAttributes<HTMLLabelElement>,
'aria-label' | 'children'
> &
PlatformIconButtonBaseProps & {
asChild: 'label';
};
type PlatformIconButtonSpanButtonProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'aria-label' | 'children' | 'role'
> &
PlatformIconButtonBaseProps & {
asChild: 'spanButton';
disabled?: boolean;
};
export type PlatformIconButtonProps =
| PlatformIconButtonButtonProps
| PlatformIconButtonLabelProps
| PlatformIconButtonSpanButtonProps;
const variantClassName: Record<PlatformIconButtonVariant, string> = {
platformIcon: 'genarrative-ui-icon-button--surface-platform',
surfaceFloating: 'genarrative-ui-icon-button--surface-floating',
darkMini: 'genarrative-ui-icon-button--surface-dark-mini',
};
export const PlatformIconButton = forwardRef<
HTMLButtonElement | HTMLLabelElement | HTMLSpanElement,
PlatformIconButtonProps
>(function PlatformIconButton(
{
label,
icon,
children,
variant = 'platformIcon',
title,
className,
asChild,
...actionProps
},
ref,
) {
const resolvedVariant = variant === 'darkMini' ? 'quiet' : 'default';
const actionClassName = [
'genarrative-ui-icon-button',
`genarrative-ui-icon-button--${resolvedVariant}`,
variantClassName[variant],
className,
]
.filter(Boolean)
.join(' ');
if (asChild === 'label') {
return (
<label
{...(actionProps as LabelHTMLAttributes<HTMLLabelElement>)}
ref={ref as Ref<HTMLLabelElement>}
title={title}
className={`${actionClassName} genarrative-ui-icon-button--md`}
>
<span className="genarrative-ui-visually-hidden">{label}</span>
{icon}
{children}
</label>
);
}
if (asChild === 'spanButton') {
const { disabled, onClick, onKeyDown, tabIndex, ...spanProps } =
actionProps as HTMLAttributes<HTMLSpanElement> & {
disabled?: boolean;
};
const handleKeyDown = (event: KeyboardEvent<HTMLSpanElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.currentTarget.click();
}
};
return (
<span
{...spanProps}
ref={ref as Ref<HTMLSpanElement>}
aria-disabled={disabled || undefined}
aria-label={label}
className={`${actionClassName} genarrative-ui-icon-button--md`}
onClick={disabled ? undefined : onClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex={disabled ? -1 : (tabIndex ?? 0)}
title={title ?? label}
>
{icon}
{children}
</span>
);
}
const { type = 'button', ...buttonProps } =
actionProps as ButtonHTMLAttributes<HTMLButtonElement>;
return (
<IconButton
{...buttonProps}
ref={ref as Ref<HTMLButtonElement>}
type={type}
label={label}
icon={icon}
children={children}
variant={resolvedVariant}
className={actionClassName}
title={title ?? label}
/>
);
});
@@ -0,0 +1,2 @@
export type { ModalProps as PlatformModalProps } from './ui';
export { Modal as PlatformModal } from './ui';
@@ -0,0 +1,5 @@
export type {
BadgeProps as PlatformPillBadgeProps,
BadgeTone as PlatformPillBadgeTone,
} from './ui';
export { Badge as PlatformPillBadge } from './ui';
@@ -0,0 +1,5 @@
export type {
StatusProps as PlatformStatusProps,
StatusTone as PlatformStatusTone,
} from './ui';
export { Status as PlatformStatus } from './ui';
@@ -0,0 +1,5 @@
export type {
StatusProps as PlatformStatusMessageProps,
StatusTone as PlatformStatusMessageTone,
} from './ui';
export { Status as PlatformStatusMessage } from './ui';
@@ -0,0 +1,2 @@
export type { SubpanelProps as PlatformSubpanelProps } from './ui';
export { Subpanel as PlatformSubpanel } from './ui';
@@ -0,0 +1,8 @@
export type {
SelectFieldProps as PlatformSelectFieldProps,
TextFieldProps as PlatformTextFieldProps,
} from './ui';
export {
SelectField as PlatformSelectField,
TextField as PlatformTextField,
} from './ui';
@@ -0,0 +1,5 @@
// Account surfaces are kept in a separate barrel because they accept account
// DTOs. They are shared adapters, not part of the business-neutral UI chrome.
export * from './PlatformMudPointWalletEntry';
export * from './PlatformProfileRechargeModal';
export * from './PlatformProfileWalletLedgerModal';
+125
View File
@@ -0,0 +1,125 @@
export type {
PlatformActionButtonAlign,
PlatformActionButtonProps,
PlatformActionButtonShape,
PlatformActionButtonSize,
PlatformActionButtonSurface,
PlatformActionButtonTone,
} from './PlatformActionButton';
export { PlatformActionButton } from './PlatformActionButton';
export type { PlatformBadgeProps, PlatformBadgeTone } from './PlatformBadge';
export { PlatformBadge } from './PlatformBadge';
export type { PlatformEmptyStateProps } from './PlatformEmptyState';
export { PlatformEmptyState } from './PlatformEmptyState';
export type {
PlatformIconButtonProps,
PlatformIconButtonSize,
PlatformIconButtonVariant,
} from './PlatformIconButton';
export { PlatformIconButton } from './PlatformIconButton';
export type { PlatformModalProps } from './PlatformModal';
export { PlatformModal } from './PlatformModal';
export type {
PlatformPillBadgeProps,
PlatformPillBadgeTone,
} from './PlatformPillBadge';
export { PlatformPillBadge } from './PlatformPillBadge';
export type { PlatformStatusProps, PlatformStatusTone } from './PlatformStatus';
export { PlatformStatus } from './PlatformStatus';
export type {
PlatformStatusMessageProps,
PlatformStatusMessageTone,
} from './PlatformStatusMessage';
export { PlatformStatusMessage } from './PlatformStatusMessage';
export type { PlatformSubpanelProps } from './PlatformSubpanel';
export { PlatformSubpanel } from './PlatformSubpanel';
export type {
PlatformSelectFieldProps,
PlatformTextFieldProps,
} from './PlatformTextField';
export { PlatformSelectField, PlatformTextField } from './PlatformTextField';
export type {
BadgeProps,
BadgeTone,
ButtonProps,
EmptyStateProps,
IconButtonProps,
IconButtonSize,
IconButtonVariant,
ModalProps,
ProgressBarProps,
SegmentedTabItem,
SegmentedTabsProps,
SelectFieldProps,
SharedButtonSize,
SharedButtonVariant,
StatusProps,
StatusTone,
SubpanelProps,
SwitchProps,
TextFieldProps,
} from './ui';
export {
Badge,
Button,
Divider,
EmptyState,
IconButton,
Modal,
ProgressBar,
SegmentedTabs,
SelectField,
Spinner,
Status,
Subpanel,
Switch,
TextField,
} from './ui';
export type { BadgeProps as ShadcnBadgeProps } from './ui/badge';
export { badgeVariants, Badge as ShadcnBadge } from './ui/badge';
export type {
ButtonProps as ShadcnButtonProps,
ShadcnButtonSize,
ShadcnButtonVariant,
} from './ui/button';
export { buttonVariants, Button as ShadcnButton } from './ui/button';
export type { CardProps } from './ui/card';
export { Card, CardActions, CardHeader, CardTitle } from './ui/card';
export type { SharedDialogProps } from './ui/dialog';
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from './ui/dialog';
export type { InputProps } from './ui/input';
export { Input, inputVariants } from './ui/input';
export type { SharedSwitchProps } from './ui/switch';
export { SwitchRoot, SwitchThumb } from './ui/switch';
export { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
export type { TextareaProps } from './ui/textarea';
export { Textarea } from './ui/textarea';
// Additional platform-prefixed aliases make the package discoverable beside
// existing application adapters while keeping the public API product-neutral.
export type {
ButtonProps as PlatformButtonProps,
ProgressBarProps as PlatformProgressBarProps,
SegmentedTabItem as PlatformSegmentedTabItem,
SegmentedTabsProps as PlatformSegmentedTabsProps,
SwitchProps as PlatformSwitchProps,
} from './ui';
export {
Button as PlatformButton,
Divider as PlatformDivider,
ProgressBar as PlatformProgressBar,
SegmentedTabs as PlatformSegmentedTabs,
Spinner as PlatformSpinner,
Switch as PlatformSwitch,
} from './ui';
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import {
Badge,
Button,
EmptyState,
IconButton,
Modal,
PlatformActionButton,
PlatformIconButton,
ProgressBar,
SegmentedTabs,
SelectField,
Status,
Subpanel,
Switch,
TextField,
} from './index';
describe('shared UI components', () => {
test('keeps native button semantics and states', () => {
const onClick = vi.fn();
render(
<div className="genarrative-ui">
<Button onClick={onClick}></Button>
<Button loading></Button>
<IconButton label="关闭" icon="×" />
</div>,
);
const button = screen.getByRole('button', { name: '保存' });
expect(button.getAttribute('type')).toBe('button');
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
expect(
(screen.getByRole('button', { name: '稍等' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(
screen.getByRole('button', { name: '关闭' }).getAttribute('title'),
).toBe('关闭');
});
test('connects field labels and descriptions to native controls', () => {
render(
<div className="genarrative-ui">
<TextField label="名称" hint="最多 20 字" error="名称已存在" />
<TextField label="描述" multiline />
<SelectField label="主题" hint="选择颜色">
<option value="warm"></option>
</SelectField>
</div>,
);
const input = screen.getByLabelText('名称');
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(input.getAttribute('aria-describedby')).toContain('hint');
expect(screen.getByLabelText('描述').tagName).toBe('TEXTAREA');
expect(screen.getByLabelText('主题').tagName).toBe('SELECT');
expect(screen.getByRole('alert').textContent).toContain('名称已存在');
});
test('renders slots, selected tabs and semantic status content', () => {
const onChange = vi.fn();
render(
<div className="genarrative-ui">
<Subpanel title="设置" actions={<Badge></Badge>}>
</Subpanel>
<Status tone="success" icon="✓">
</Status>
<EmptyState title="暂无内容" />
<ProgressBar value={140} label="进度" />
<SegmentedTabs
items={[
{ id: 'a', label: 'A' },
{ id: 'b', label: 'B' },
]}
activeId="a"
onChange={onChange}
label="分区"
/>
<Switch checked label="启用" />
</div>,
);
expect(screen.getByRole('heading', { name: '设置' })).toBeTruthy();
expect(screen.getByRole('status').textContent).toContain('完成');
expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe(
'100',
);
expect(
screen.getByRole('tab', { name: 'A' }).getAttribute('aria-selected'),
).toBe('true');
fireEvent.click(screen.getByRole('tab', { name: 'B' }));
expect(onChange).toHaveBeenCalledWith('b');
expect(
screen.getByRole('switch', { name: '启用' }).getAttribute('aria-checked'),
).toBe('true');
});
test('closes modal on escape and backdrop click', () => {
const onClose = vi.fn();
render(
<Modal open title="确认" onClose={onClose}>
</Modal>,
);
expect(screen.getByRole('dialog', { name: '确认' })).toBeTruthy();
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
fireEvent.mouseDown(
document.querySelector('.genarrative-ui-modal__backdrop')!,
);
expect(onClose).toHaveBeenCalledTimes(2);
});
test('can keep modal open when backdrop and escape closing are disabled', () => {
const onClose = vi.fn();
render(
<Modal
open
title="锁定"
onClose={onClose}
closeOnBackdrop={false}
closeOnEscape={false}
>
</Modal>,
);
fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.mouseDown(
document.querySelector('.genarrative-ui-modal__backdrop')!,
);
expect(onClose).not.toHaveBeenCalled();
});
test('keeps platform wrappers generic and supports label/span hosts', () => {
const onClick = vi.fn();
render(
<div className="genarrative-ui">
<PlatformActionButton asChild="label" htmlFor="upload" tone="accent">
</PlatformActionButton>
<PlatformIconButton
asChild="spanButton"
label="打开"
icon="+"
onClick={onClick}
/>
</div>,
);
expect(screen.getByText('上传').closest('label')?.htmlFor).toBe('upload');
const spanButton = screen.getByRole('button', { name: '打开' });
fireEvent.keyDown(spanButton, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(1);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
/* eslint-disable react-refresh/only-export-components -- shadcn co-locates variants with their source */
import { cva, type VariantProps } from 'class-variance-authority';
import { type HTMLAttributes, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
export const badgeVariants = cva('genarrative-ui-badge', {
variants: {
variant: {
neutral: 'genarrative-ui-badge--neutral',
accent: 'genarrative-ui-badge--accent',
success: 'genarrative-ui-badge--success',
warning: 'genarrative-ui-badge--warning',
danger: 'genarrative-ui-badge--danger',
info: 'genarrative-ui-badge--info',
},
size: {
sm: 'genarrative-ui-badge--sm',
md: 'genarrative-ui-badge--md',
},
},
defaultVariants: {
variant: 'neutral',
size: 'sm',
},
});
export type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> &
VariantProps<typeof badgeVariants> & {
children?: ReactNode;
icon?: ReactNode;
};
export function Badge({
children,
icon,
variant,
size,
className,
...props
}: BadgeProps) {
return (
<span
{...props}
data-slot="badge"
className={cn(badgeVariants({ variant, size }), className)}
>
{icon ? <span aria-hidden="true">{icon}</span> : null}
{children}
</span>
);
}
@@ -0,0 +1,45 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { Button, buttonVariants } from './button';
describe('shadcn button source', () => {
test('exposes CVA variants while preserving native button behavior', () => {
const onClick = vi.fn();
render(
<Button variant="destructive" size="sm" onClick={onClick}>
</Button>,
);
const button = screen.getByRole('button', { name: '删除' });
expect(button.dataset.slot).toBe('button');
expect(button.className).toContain('genarrative-ui-button--danger');
expect(button.className).toContain('genarrative-ui-button--sm');
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
test('keeps loading buttons disabled and merges caller classes', () => {
render(
<Button loading fullWidth className="w-auto">
</Button>,
);
const button = screen.getByRole('button', { name: '保存' });
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(button.getAttribute('aria-busy')).toBe('true');
expect(button.className).toContain('genarrative-ui-button--full-width');
expect(button.className).toContain('w-auto');
expect(button.className).not.toContain('w-full');
});
test('can generate a standalone shadcn variant class', () => {
expect(buttonVariants({ variant: 'outline', size: 'lg' })).toContain(
'genarrative-ui-button--secondary',
);
});
});
@@ -0,0 +1,97 @@
/* eslint-disable react-refresh/only-export-components -- shadcn co-locates variants with their source */
import { cva, type VariantProps } from 'class-variance-authority';
import { type ButtonHTMLAttributes, forwardRef, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
/**
* Project-owned shadcn-style button source. Existing genarrative classes keep
* the platform token contract intact while CVA provides the migration point
* for future shared component variants.
*/
export const buttonVariants = cva(
'genarrative-ui-button inline-flex items-center justify-center',
{
variants: {
variant: {
default: 'genarrative-ui-button--primary',
primary: 'genarrative-ui-button--primary',
secondary: 'genarrative-ui-button--secondary',
outline: 'genarrative-ui-button--secondary',
ghost: 'genarrative-ui-button--ghost',
destructive: 'genarrative-ui-button--danger',
danger: 'genarrative-ui-button--danger',
link: 'genarrative-ui-button--ghost underline-offset-4',
},
size: {
default: 'genarrative-ui-button--md',
sm: 'genarrative-ui-button--sm',
md: 'genarrative-ui-button--md',
lg: 'genarrative-ui-button--lg',
icon: 'genarrative-ui-button--sm p-0',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export type ShadcnButtonVariant = NonNullable<
VariantProps<typeof buttonVariants>['variant']
>;
export type ShadcnButtonSize = NonNullable<
VariantProps<typeof buttonVariants>['size']
>;
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
children?: ReactNode;
fullWidth?: boolean;
loading?: boolean;
icon?: ReactNode;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
children,
variant,
size,
fullWidth = false,
loading = false,
icon,
className,
disabled,
type = 'button',
...buttonProps
},
ref,
) {
return (
<button
{...buttonProps}
ref={ref}
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
data-slot="button"
className={cn(
buttonVariants({ variant, size }),
fullWidth && 'w-full genarrative-ui-button--full-width',
className,
)}
>
{loading ? (
<span className="genarrative-ui-spinner" aria-hidden="true" />
) : (
icon
)}
{children}
</button>
);
},
);
Button.displayName = 'Button';
@@ -0,0 +1,73 @@
import { type HTMLAttributes, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
export type CardProps = HTMLAttributes<HTMLElement> & {
as?: 'section' | 'div' | 'article' | 'aside';
tone?: 'default' | 'soft' | 'contrast';
padding?: 'sm' | 'md' | 'lg' | 'none';
children?: ReactNode;
};
export function Card({
as: Component = 'div',
tone = 'default',
padding = 'md',
className,
...props
}: CardProps) {
return (
<Component
{...props}
data-slot="card"
className={cn(
'genarrative-ui-subpanel',
`genarrative-ui-subpanel--${tone}`,
`genarrative-ui-subpanel--padding-${padding}`,
className,
)}
/>
);
}
export function CardHeader({
className,
...props
}: HTMLAttributes<HTMLElement>) {
return (
<header
{...props}
data-slot="card-header"
className={cn('genarrative-ui-subpanel__header', className)}
/>
);
}
export function CardTitle({
className,
children,
...props
}: HTMLAttributes<HTMLHeadingElement> & { children?: ReactNode }) {
return (
<h2
{...props}
data-slot="card-title"
className={cn('genarrative-ui-subpanel__title', className)}
>
{children}
</h2>
);
}
export function CardActions({
className,
...props
}: HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
data-slot="card-actions"
className={cn('genarrative-ui-subpanel__actions', className)}
/>
);
}
@@ -0,0 +1,163 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { forwardRef, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
export const DialogPortal = DialogPrimitive.Portal;
export const DialogOverlay = forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(function DialogOverlay({ className, ...props }, ref) {
return (
<DialogPrimitive.Overlay
{...props}
ref={ref}
data-slot="dialog-overlay"
className={cn('genarrative-ui-modal__backdrop', className)}
/>
);
});
export const DialogContent = forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(function DialogContent({ className, ...props }, ref) {
return (
<DialogPrimitive.Content
{...props}
ref={ref}
data-slot="dialog-content"
className={cn('genarrative-ui-modal__panel', className)}
/>
);
});
export const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
{...props}
data-slot="dialog-header"
className={cn('genarrative-ui-modal__header', className)}
/>
);
export const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
{...props}
data-slot="dialog-footer"
className={cn('genarrative-ui-modal__footer', className)}
/>
);
export const DialogTitle = forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(function DialogTitle({ className, ...props }, ref) {
return (
<DialogPrimitive.Title
{...props}
ref={ref}
data-slot="dialog-title"
className={cn('genarrative-ui-modal__title', className)}
/>
);
});
export const DialogDescription = forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(function DialogDescription({ className, ...props }, ref) {
return (
<DialogPrimitive.Description
{...props}
ref={ref}
data-slot="dialog-description"
className={cn('genarrative-ui-modal__description', className)}
/>
);
});
export type SharedDialogProps = {
open: boolean;
title: ReactNode;
description?: ReactNode;
children?: ReactNode;
footer?: ReactNode;
onClose: () => void;
closeLabel?: string;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
size?: 'sm' | 'md' | 'lg';
portal?: boolean;
className?: string;
closeButton: ReactNode;
};
/** Compatibility adapter for the existing Modal API. */
export function SharedDialog({
open,
title,
description,
children,
footer,
onClose,
closeOnBackdrop = true,
closeOnEscape = true,
size = 'md',
portal = true,
className,
closeButton,
}: SharedDialogProps) {
const content = (
<DialogOverlay
onMouseDown={(event) => {
if (closeOnBackdrop && event.target === event.currentTarget) {
onClose();
}
}}
>
<DialogContent
onEscapeKeyDown={(event) => {
if (!closeOnEscape) event.preventDefault();
}}
onPointerDownOutside={(event) => {
// The compatibility contract closes on the backdrop's mouse-down
// event (also used by existing tests). Prevent Radix from invoking
// onOpenChange a second time for the same pointer gesture.
event.preventDefault();
}}
className={cn(`genarrative-ui-modal__panel--${size}`, className)}
>
<DialogHeader>
<div>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</div>
{closeButton}
</DialogHeader>
{children ? (
<div className="genarrative-ui-modal__body">{children}</div>
) : null}
{footer ? <DialogFooter>{footer}</DialogFooter> : null}
</DialogContent>
</DialogOverlay>
);
return (
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
{portal ? <DialogPortal>{content}</DialogPortal> : content}
</Dialog>
);
}
@@ -0,0 +1,31 @@
export type { BadgeProps } from './badge';
export { Badge, badgeVariants } from './badge';
export type {
ButtonProps,
ShadcnButtonSize,
ShadcnButtonVariant,
} from './button';
export { Button, buttonVariants } from './button';
export type { CardProps } from './card';
export { Card, CardActions, CardHeader, CardTitle } from './card';
export type { SharedDialogProps } from './dialog';
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from './dialog';
export type { InputProps } from './input';
export { Input, inputVariants } from './input';
export type { SharedSwitchProps } from './switch';
export { SwitchRoot, SwitchThumb } from './switch';
export type { SegmentedTabItem, SegmentedTabsProps } from './tabs';
export { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs';
export type { TextareaProps } from './textarea';
export { Textarea } from './textarea';
@@ -0,0 +1,42 @@
/* eslint-disable react-refresh/only-export-components -- shadcn co-locates variants with their source */
import { cva, type VariantProps } from 'class-variance-authority';
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
export const inputVariants = cva('genarrative-ui-text-field__control', {
variants: {
size: {
sm: 'genarrative-ui-text-field__control--sm',
md: 'genarrative-ui-text-field__control--md',
lg: 'genarrative-ui-text-field__control--lg',
},
error: {
true: 'genarrative-ui-text-field__control--error',
false: '',
},
},
defaultVariants: {
size: 'md',
error: false,
},
});
export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> &
VariantProps<typeof inputVariants>;
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ className, size, error, ...props },
ref,
) {
return (
<input
{...props}
ref={ref}
data-slot="input"
className={cn(inputVariants({ size, error }), className)}
/>
);
});
Input.displayName = 'Input';
@@ -0,0 +1,38 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { Badge } from './badge';
import { Card, CardTitle } from './card';
import { Input } from './input';
import { Textarea } from './textarea';
describe('shadcn form, badge and card sources', () => {
test('expose project-owned slots while retaining platform classes', () => {
render(
<>
<Input size="lg" error aria-label="名称" />
<Textarea size="sm" aria-label="描述" />
<Badge variant="success"></Badge>
<Card tone="soft">
<CardTitle></CardTitle>
</Card>
</>,
);
expect(screen.getByRole('textbox', { name: '名称' }).dataset.slot).toBe(
'input',
);
expect(screen.getByRole('textbox', { name: '描述' }).dataset.slot).toBe(
'textarea',
);
expect(screen.getByText('完成').dataset.slot).toBe('badge');
expect(screen.getByRole('heading', { name: '设置' }).dataset.slot).toBe(
'card-title',
);
expect(screen.getByText('完成').className).toContain(
'genarrative-ui-badge--success',
);
});
});
@@ -0,0 +1,41 @@
import * as SwitchPrimitive from '@radix-ui/react-switch';
import { type ReactNode } from 'react';
import { cn } from '../../lib/utils';
export const SwitchRoot = SwitchPrimitive.Root;
export const SwitchThumb = SwitchPrimitive.Thumb;
export type SharedSwitchProps = Omit<
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>,
'checked' | 'children'
> & {
checked: boolean;
label: ReactNode;
};
/** Compatibility adapter for the existing switch API. */
export function SharedSwitch({
checked,
label,
className,
...props
}: SharedSwitchProps) {
return (
<SwitchPrimitive.Root
{...props}
checked={checked}
data-slot="switch"
className={cn(
'genarrative-ui-switch',
checked && 'genarrative-ui-switch--checked',
className,
)}
>
<SwitchPrimitive.Thumb className="genarrative-ui-switch__track">
<span className="genarrative-ui-switch__thumb" />
</SwitchPrimitive.Thumb>
<span>{label}</span>
</SwitchPrimitive.Root>
);
}
@@ -0,0 +1,30 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { SegmentedTabs } from './tabs';
describe('shadcn tabs source', () => {
test('does not duplicate changes across pointer and click events', () => {
const onChange = vi.fn();
render(
<SegmentedTabs
items={[
{ id: 'overview', label: '概览' },
{ id: 'states', label: '状态' },
]}
activeId="overview"
onChange={onChange}
label="展示页分区"
/>,
);
const tab = screen.getByRole('tab', { name: '状态' });
fireEvent.mouseDown(tab, { button: 0 });
fireEvent.click(tab);
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith('states');
});
});

Some files were not shown because too many files have changed in this diff Show More