Files
Genarrative/packages/shared/src/components/ui.tsx
T
kdletters c5200c7d45
Project CI / Backend tests (push) Successful in 8m31s
Project CI / Repository checks (push) Successful in 14m14s
Project CI / Frontend tests (push) Successful in 13m2s
Project CI / Native shell tests (push) Successful in 21m30s
补齐共享 UI 组件库与展示页交互 (#202)
## 变更内容

- 新增基于 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>
2026-08-31 14:10:44 +08:00

592 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)}
/>
);
}