补齐共享 UI 组件库与展示页交互
- 新增 shadcn canonical UI 源码、共享样式与组件导出 - 新增 /components 共享组件展示页及路由测试 - 补齐平台组件筛选、排序、上传预览和异步状态交互 - 同步网站与客户端构建别名、依赖和项目文档
This commit is contained in:
@@ -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';
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user