c5200c7d45
## 变更内容 - 新增基于 shadcn open-code 模式的共享 UI canonical 组件、样式与导出。 - 新增 `/components` 共享组件展示页,并覆盖平台组件、Token、状态和移动端布局。 - 补齐平台展示页的筛选、排序、上传预览、标签、开关和异步状态交互。 - 修复排序导致预览主题变化、筛选弹窗误关闭和入口状态不更新的问题。 - 同步网站与客户端构建别名、依赖、路由测试和项目文档。 ## 验证 - `npm run test -- src/components/shared-components/SharedComponentsShowcasePage.test.tsx` - `npm run typecheck` - `npm run check:encoding` - `npm run build:raw` - `git diff --check` - pre-push 门禁通过 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/202 Co-authored-by: kdletters <kdletters@qq.com> Co-committed-by: kdletters <kdletters@qq.com>
592 lines
14 KiB
TypeScript
592 lines
14 KiB
TypeScript
import {
|
||
type ButtonHTMLAttributes,
|
||
forwardRef,
|
||
type HTMLAttributes,
|
||
type InputHTMLAttributes,
|
||
type ReactNode,
|
||
type SelectHTMLAttributes,
|
||
type TextareaHTMLAttributes,
|
||
useId,
|
||
} from 'react';
|
||
|
||
import { Badge as ShadcnBadge } from './ui/badge';
|
||
import {
|
||
Button as ShadcnButton,
|
||
type ButtonProps as ShadcnButtonProps,
|
||
} from './ui/button';
|
||
import { Card, CardActions, CardHeader, CardTitle } from './ui/card';
|
||
import { SharedDialog, type SharedDialogProps } from './ui/dialog';
|
||
import { Input } from './ui/input';
|
||
import {
|
||
SharedSwitch as ShadcnSwitch,
|
||
type SharedSwitchProps,
|
||
} from './ui/switch';
|
||
import {
|
||
type SegmentedTabItem as ShadcnSegmentedTabItem,
|
||
SegmentedTabs as ShadcnSegmentedTabs,
|
||
type SegmentedTabsProps as ShadcnSegmentedTabsProps,
|
||
} from './ui/tabs';
|
||
import { Textarea } from './ui/textarea';
|
||
|
||
export type { CheckboxProps } from './ui/checkbox';
|
||
export { Checkbox } from './ui/checkbox';
|
||
export type { LabelProps } from './ui/label';
|
||
export { Label } from './ui/label';
|
||
export type { SkeletonProps } from './ui/skeleton';
|
||
export { Skeleton } from './ui/skeleton';
|
||
export type { TableProps } from './ui/table';
|
||
export {
|
||
Table,
|
||
TableBody,
|
||
TableCaption,
|
||
TableCell,
|
||
TableFooter,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from './ui/table';
|
||
|
||
/**
|
||
* The shared UI layer is deliberately headless with regard to product data.
|
||
* Components accept native props and slots only, so a web or Tauri host can
|
||
* provide its own business state and event handlers.
|
||
*/
|
||
|
||
export type SharedButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||
export type SharedButtonSize = 'sm' | 'md' | 'lg';
|
||
|
||
export type ButtonProps = Omit<
|
||
ShadcnButtonProps,
|
||
'children' | 'variant' | 'size'
|
||
> & {
|
||
children?: ReactNode;
|
||
variant?: SharedButtonVariant;
|
||
size?: SharedButtonSize;
|
||
};
|
||
|
||
function joinClassNames(
|
||
...classNames: Array<string | false | null | undefined>
|
||
) {
|
||
return classNames.filter(Boolean).join(' ');
|
||
}
|
||
|
||
export function Button({
|
||
variant = 'primary',
|
||
size = 'md',
|
||
...props
|
||
}: ButtonProps) {
|
||
return <ShadcnButton {...props} variant={variant} size={size} />;
|
||
}
|
||
|
||
export type IconButtonVariant = 'default' | 'quiet' | 'danger';
|
||
export type IconButtonSize = 'sm' | 'md' | 'lg';
|
||
|
||
export type IconButtonProps = Omit<
|
||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||
'aria-label'
|
||
> & {
|
||
label: string;
|
||
icon: ReactNode;
|
||
children?: ReactNode;
|
||
variant?: IconButtonVariant;
|
||
size?: IconButtonSize;
|
||
};
|
||
|
||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||
function IconButton(
|
||
{
|
||
label,
|
||
icon,
|
||
children,
|
||
variant = 'default',
|
||
size = 'md',
|
||
className,
|
||
type = 'button',
|
||
...buttonProps
|
||
},
|
||
ref,
|
||
) {
|
||
return (
|
||
<button
|
||
{...buttonProps}
|
||
ref={ref}
|
||
type={type}
|
||
aria-label={label}
|
||
title={buttonProps.title ?? label}
|
||
className={joinClassNames(
|
||
'genarrative-ui-icon-button',
|
||
`genarrative-ui-icon-button--${variant}`,
|
||
`genarrative-ui-icon-button--${size}`,
|
||
className,
|
||
)}
|
||
>
|
||
{icon}
|
||
{children}
|
||
</button>
|
||
);
|
||
},
|
||
);
|
||
|
||
export type TextFieldProps =
|
||
| (Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> & {
|
||
multiline?: false;
|
||
label?: ReactNode;
|
||
hint?: ReactNode;
|
||
error?: ReactNode;
|
||
leading?: ReactNode;
|
||
trailing?: ReactNode;
|
||
size?: SharedButtonSize;
|
||
variant?: 'input' | 'textarea';
|
||
})
|
||
| (TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||
multiline: true;
|
||
label?: ReactNode;
|
||
hint?: ReactNode;
|
||
error?: ReactNode;
|
||
leading?: ReactNode;
|
||
trailing?: ReactNode;
|
||
size?: SharedButtonSize;
|
||
variant?: 'input' | 'textarea';
|
||
});
|
||
|
||
export function TextField({
|
||
label,
|
||
hint,
|
||
error,
|
||
leading,
|
||
trailing,
|
||
className,
|
||
id: providedId,
|
||
'aria-label': providedAriaLabel,
|
||
'aria-labelledby': providedAriaLabelledBy,
|
||
'aria-describedby': providedAriaDescribedBy,
|
||
size = 'md',
|
||
multiline = false,
|
||
variant = 'input',
|
||
...fieldProps
|
||
}: TextFieldProps) {
|
||
const generatedId = useId();
|
||
const id = providedId ?? `genarrative-text-field-${generatedId}`;
|
||
const accessibleLabel = typeof label === 'string' ? label : undefined;
|
||
const hintId = hint ? `${id}-hint` : undefined;
|
||
const errorId = error ? `${id}-error` : undefined;
|
||
const describedBy =
|
||
[providedAriaDescribedBy, hintId, errorId].filter(Boolean).join(' ') ||
|
||
undefined;
|
||
const isMultiline = multiline || variant === 'textarea';
|
||
const inputClassName = joinClassNames(
|
||
'genarrative-ui-text-field__control',
|
||
`genarrative-ui-text-field__control--${size}`,
|
||
Boolean(error) && 'genarrative-ui-text-field__control--error',
|
||
className,
|
||
);
|
||
|
||
const control = isMultiline ? (
|
||
<Textarea
|
||
{...(fieldProps as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
||
id={id}
|
||
aria-label={accessibleLabel ?? providedAriaLabel}
|
||
aria-labelledby={providedAriaLabelledBy}
|
||
aria-invalid={Boolean(error) || undefined}
|
||
aria-describedby={describedBy}
|
||
size={size}
|
||
error={Boolean(error)}
|
||
className={inputClassName}
|
||
/>
|
||
) : (
|
||
<Input
|
||
{...(fieldProps as InputHTMLAttributes<HTMLInputElement>)}
|
||
id={id}
|
||
aria-label={accessibleLabel ?? providedAriaLabel}
|
||
aria-labelledby={providedAriaLabelledBy}
|
||
aria-invalid={Boolean(error) || undefined}
|
||
aria-describedby={describedBy}
|
||
size={size}
|
||
error={Boolean(error)}
|
||
className={inputClassName}
|
||
/>
|
||
);
|
||
|
||
return (
|
||
<label className="genarrative-ui-text-field" htmlFor={id}>
|
||
{label ? (
|
||
<span className="genarrative-ui-text-field__label">{label}</span>
|
||
) : null}
|
||
<span className="genarrative-ui-text-field__control-wrap">
|
||
{leading ? (
|
||
<span className="genarrative-ui-text-field__slot">{leading}</span>
|
||
) : null}
|
||
{control}
|
||
{trailing ? (
|
||
<span className="genarrative-ui-text-field__slot">{trailing}</span>
|
||
) : null}
|
||
</span>
|
||
{hint ? (
|
||
<span id={hintId} className="genarrative-ui-text-field__hint">
|
||
{hint}
|
||
</span>
|
||
) : null}
|
||
{error ? (
|
||
<span
|
||
id={errorId}
|
||
className="genarrative-ui-text-field__error"
|
||
role="alert"
|
||
>
|
||
{error}
|
||
</span>
|
||
) : null}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export type SelectFieldProps = Omit<
|
||
SelectHTMLAttributes<HTMLSelectElement>,
|
||
'size'
|
||
> & {
|
||
label?: ReactNode;
|
||
hint?: ReactNode;
|
||
error?: ReactNode;
|
||
size?: SharedButtonSize;
|
||
};
|
||
|
||
export function SelectField({
|
||
label,
|
||
hint,
|
||
error,
|
||
className,
|
||
id: providedId,
|
||
'aria-label': providedAriaLabel,
|
||
'aria-labelledby': providedAriaLabelledBy,
|
||
'aria-describedby': providedAriaDescribedBy,
|
||
size = 'md',
|
||
...selectProps
|
||
}: SelectFieldProps) {
|
||
const generatedId = useId();
|
||
const id = providedId ?? `genarrative-select-field-${generatedId}`;
|
||
const accessibleLabel = typeof label === 'string' ? label : undefined;
|
||
const hintId = hint ? `${id}-hint` : undefined;
|
||
const errorId = error ? `${id}-error` : undefined;
|
||
return (
|
||
<label className="genarrative-ui-text-field" htmlFor={id}>
|
||
{label ? (
|
||
<span className="genarrative-ui-text-field__label">{label}</span>
|
||
) : null}
|
||
<select
|
||
{...selectProps}
|
||
id={id}
|
||
aria-label={accessibleLabel ?? providedAriaLabel}
|
||
aria-labelledby={providedAriaLabelledBy}
|
||
aria-invalid={Boolean(error) || undefined}
|
||
aria-describedby={
|
||
[providedAriaDescribedBy, hintId, errorId]
|
||
.filter(Boolean)
|
||
.join(' ') || undefined
|
||
}
|
||
className={joinClassNames(
|
||
'genarrative-ui-text-field__control',
|
||
`genarrative-ui-text-field__control--${size}`,
|
||
Boolean(error) && 'genarrative-ui-text-field__control--error',
|
||
className,
|
||
)}
|
||
/>
|
||
{hint ? (
|
||
<span id={hintId} className="genarrative-ui-text-field__hint">
|
||
{hint}
|
||
</span>
|
||
) : null}
|
||
{error ? (
|
||
<span
|
||
id={errorId}
|
||
className="genarrative-ui-text-field__error"
|
||
role="alert"
|
||
>
|
||
{error}
|
||
</span>
|
||
) : null}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export type SubpanelProps = Omit<HTMLAttributes<HTMLElement>, 'title'> & {
|
||
title?: ReactNode;
|
||
actions?: ReactNode;
|
||
children?: ReactNode;
|
||
as?: 'section' | 'div' | 'article' | 'aside';
|
||
tone?: 'default' | 'soft' | 'contrast';
|
||
padding?: 'sm' | 'md' | 'lg' | 'none';
|
||
};
|
||
|
||
export function Subpanel({
|
||
title,
|
||
actions,
|
||
children,
|
||
as: Component = 'section',
|
||
tone = 'default',
|
||
padding = 'md',
|
||
className,
|
||
...elementProps
|
||
}: SubpanelProps) {
|
||
return (
|
||
<Card
|
||
{...elementProps}
|
||
as={Component}
|
||
tone={tone}
|
||
padding={padding}
|
||
className={className}
|
||
>
|
||
{title || actions ? (
|
||
<CardHeader>
|
||
{title ? <CardTitle>{title}</CardTitle> : <span />}
|
||
{actions ? <CardActions>{actions}</CardActions> : null}
|
||
</CardHeader>
|
||
) : null}
|
||
{children}
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export type ModalProps = Omit<SharedDialogProps, 'closeButton'>;
|
||
|
||
export function Modal({
|
||
open,
|
||
title,
|
||
description,
|
||
children,
|
||
footer,
|
||
onClose,
|
||
closeLabel = '关闭弹窗',
|
||
closeOnBackdrop = true,
|
||
closeOnEscape = true,
|
||
size = 'md',
|
||
portal = true,
|
||
className,
|
||
}: ModalProps) {
|
||
return (
|
||
<SharedDialog
|
||
{...{
|
||
open,
|
||
title,
|
||
description,
|
||
children,
|
||
footer,
|
||
onClose,
|
||
closeLabel,
|
||
closeOnBackdrop,
|
||
closeOnEscape,
|
||
size,
|
||
portal,
|
||
className,
|
||
}}
|
||
closeButton={
|
||
<IconButton
|
||
label={closeLabel}
|
||
icon="×"
|
||
variant="quiet"
|
||
size="sm"
|
||
onClick={onClose}
|
||
/>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
|
||
export type StatusProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
|
||
tone?: StatusTone;
|
||
children?: ReactNode;
|
||
icon?: ReactNode;
|
||
};
|
||
|
||
export function Status({
|
||
tone = 'neutral',
|
||
icon,
|
||
children,
|
||
className,
|
||
...props
|
||
}: StatusProps) {
|
||
return (
|
||
<div
|
||
{...props}
|
||
role={props.role ?? 'status'}
|
||
className={joinClassNames(
|
||
'genarrative-ui-status',
|
||
`genarrative-ui-status--${tone}`,
|
||
className,
|
||
)}
|
||
>
|
||
{icon ? (
|
||
<span className="genarrative-ui-status__icon" aria-hidden="true">
|
||
{icon}
|
||
</span>
|
||
) : null}
|
||
<span>{children}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export type EmptyStateProps = Omit<
|
||
HTMLAttributes<HTMLDivElement>,
|
||
'children' | 'title'
|
||
> & {
|
||
title: ReactNode;
|
||
description?: ReactNode;
|
||
icon?: ReactNode;
|
||
action?: ReactNode;
|
||
};
|
||
|
||
export function EmptyState({
|
||
title,
|
||
description,
|
||
icon,
|
||
action,
|
||
className,
|
||
...props
|
||
}: EmptyStateProps) {
|
||
return (
|
||
<div
|
||
{...props}
|
||
className={joinClassNames('genarrative-ui-empty-state', className)}
|
||
>
|
||
{icon ? (
|
||
<div className="genarrative-ui-empty-state__icon" aria-hidden="true">
|
||
{icon}
|
||
</div>
|
||
) : null}
|
||
<h2>{title}</h2>
|
||
{description ? <p>{description}</p> : null}
|
||
{action ? (
|
||
<div className="genarrative-ui-empty-state__action">{action}</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export type BadgeTone =
|
||
| 'neutral'
|
||
| 'accent'
|
||
| 'success'
|
||
| 'warning'
|
||
| 'danger'
|
||
| 'info';
|
||
export type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children'> & {
|
||
children?: ReactNode;
|
||
tone?: BadgeTone;
|
||
size?: 'sm' | 'md';
|
||
icon?: ReactNode;
|
||
};
|
||
|
||
export function Badge({
|
||
children,
|
||
tone = 'neutral',
|
||
size = 'sm',
|
||
icon,
|
||
className,
|
||
...props
|
||
}: BadgeProps) {
|
||
return (
|
||
<ShadcnBadge
|
||
{...props}
|
||
variant={tone}
|
||
size={size}
|
||
icon={icon}
|
||
className={className}
|
||
>
|
||
{children}
|
||
</ShadcnBadge>
|
||
);
|
||
}
|
||
|
||
export type ProgressBarProps = Omit<
|
||
HTMLAttributes<HTMLDivElement>,
|
||
'children'
|
||
> & {
|
||
value?: number;
|
||
label?: string;
|
||
indeterminate?: boolean;
|
||
children?: ReactNode;
|
||
};
|
||
|
||
export function ProgressBar({
|
||
value = 0,
|
||
label,
|
||
indeterminate = false,
|
||
children,
|
||
className,
|
||
...props
|
||
}: ProgressBarProps) {
|
||
const progress = Number.isFinite(value)
|
||
? Math.min(100, Math.max(0, value))
|
||
: 0;
|
||
return (
|
||
<div
|
||
{...props}
|
||
className={joinClassNames('genarrative-ui-progress', className)}
|
||
>
|
||
<div
|
||
role="progressbar"
|
||
aria-label={label ?? '进度'}
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
aria-valuenow={indeterminate ? undefined : progress}
|
||
className={joinClassNames(
|
||
'genarrative-ui-progress__track',
|
||
indeterminate && 'genarrative-ui-progress__track--indeterminate',
|
||
)}
|
||
>
|
||
<span
|
||
className="genarrative-ui-progress__fill"
|
||
style={indeterminate ? undefined : { width: `${progress}%` }}
|
||
/>
|
||
</div>
|
||
{children ? (
|
||
<span className="genarrative-ui-progress__label">{children}</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export type SegmentedTabItem<TId extends string = string> =
|
||
ShadcnSegmentedTabItem<TId>;
|
||
export type SegmentedTabsProps<TId extends string = string> =
|
||
ShadcnSegmentedTabsProps<TId>;
|
||
|
||
export function SegmentedTabs<TId extends string>(
|
||
props: SegmentedTabsProps<TId>,
|
||
) {
|
||
return <ShadcnSegmentedTabs {...props} />;
|
||
}
|
||
|
||
export type SwitchProps = SharedSwitchProps;
|
||
|
||
export function Switch(props: SwitchProps) {
|
||
return <ShadcnSwitch {...props} />;
|
||
}
|
||
|
||
export function Spinner({
|
||
label = '加载中',
|
||
className,
|
||
}: {
|
||
label?: string;
|
||
className?: string;
|
||
}) {
|
||
return (
|
||
<span
|
||
className={joinClassNames('genarrative-ui-spinner', className)}
|
||
role="status"
|
||
aria-label={label}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function Divider({
|
||
className,
|
||
...props
|
||
}: HTMLAttributes<HTMLHRElement>) {
|
||
return (
|
||
<hr
|
||
{...props}
|
||
className={joinClassNames('genarrative-ui-divider', className)}
|
||
/>
|
||
);
|
||
}
|