@autional/ui 0.0.0-stage → 0.1.0-rc.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +88 -3
  3. package/src/antd/AntdThemeProvider.tsx +80 -0
  4. package/src/antd/Breadcrumb.tsx +118 -0
  5. package/src/antd/DataTable.tsx +82 -0
  6. package/src/antd/DateRangeFilter.tsx +64 -0
  7. package/src/antd/Drawer.tsx +39 -0
  8. package/src/antd/Modal.tsx +38 -0
  9. package/src/antd/PageStatus.tsx +68 -0
  10. package/src/antd/index.ts +27 -0
  11. package/src/atoms/Button.tsx +69 -0
  12. package/src/atoms/Input.tsx +19 -0
  13. package/src/atoms/Label.tsx +18 -0
  14. package/src/atoms/StatusBadge.tsx +27 -0
  15. package/src/atoms/Toggle.tsx +43 -0
  16. package/src/atoms/index.ts +9 -0
  17. package/src/context/ThemeProvider.tsx +78 -0
  18. package/src/i18n/index.ts +36 -0
  19. package/src/i18n/resources.ts +56 -0
  20. package/src/index.ts +7 -0
  21. package/src/internal/field-styles.ts +14 -0
  22. package/src/internal/semantic-styles.ts +28 -0
  23. package/src/internal/use-menu-button.ts +144 -0
  24. package/src/molecules/Alert.tsx +100 -0
  25. package/src/molecules/AppShell.tsx +122 -0
  26. package/src/molecules/CodeBlock.tsx +55 -0
  27. package/src/molecules/ConfirmDialog.tsx +101 -0
  28. package/src/molecules/ConsolePageHeader.tsx +45 -0
  29. package/src/molecules/EmptyState.tsx +24 -0
  30. package/src/molecules/ErrorBoundary.tsx +142 -0
  31. package/src/molecules/ErrorState.tsx +52 -0
  32. package/src/molecules/FormField.tsx +89 -0
  33. package/src/molecules/LanguageSwitcher.tsx +39 -0
  34. package/src/molecules/LoadingScreen.tsx +30 -0
  35. package/src/molecules/Modal.tsx +178 -0
  36. package/src/molecules/PageContainer.tsx +35 -0
  37. package/src/molecules/PageHeader.tsx +27 -0
  38. package/src/molecules/PortalSwitcher.tsx +132 -0
  39. package/src/molecules/Result.tsx +83 -0
  40. package/src/molecules/SectionCard.tsx +45 -0
  41. package/src/molecules/ThemeToggle.tsx +42 -0
  42. package/src/molecules/Toast.tsx +106 -0
  43. package/src/molecules/UserMenu.tsx +168 -0
  44. package/src/molecules/index.ts +31 -0
  45. package/src/rhf/FormInput.tsx +67 -0
  46. package/src/rhf/FormSelect.tsx +43 -0
  47. package/src/rhf/FormTextarea.tsx +42 -0
  48. package/src/rhf/index.ts +18 -0
  49. package/src/rhf/shared.tsx +63 -0
  50. package/src/styles/globals.css +19 -0
  51. package/src/test-setup.ts +37 -0
  52. package/README.md +0 -3
@@ -0,0 +1,168 @@
1
+ import type React from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { ChevronDown, LogOut, Settings, UserRound } from 'lucide-react';
4
+ import { useMenuButton } from '../internal/use-menu-button';
5
+
6
+ export interface UserMenuUserInfo {
7
+ displayName?: string | null;
8
+ username?: string | null;
9
+ email?: string | null;
10
+ avatarUrl?: string | null;
11
+ }
12
+
13
+ export interface UserMenuItemSpec {
14
+ key: string;
15
+ type?: 'settings' | 'profile' | 'logout' | 'custom';
16
+ label?: string;
17
+ icon?: React.ReactNode;
18
+ href?: string;
19
+ onClick?: () => void;
20
+ danger?: boolean;
21
+ }
22
+
23
+ export interface UserMenuLabels {
24
+ settings: string;
25
+ profile: string;
26
+ logout: string;
27
+ userMenu: string;
28
+ emptyUser: string;
29
+ }
30
+
31
+ export interface UserMenuProps {
32
+ user?: UserMenuUserInfo | null;
33
+ items: UserMenuItemSpec[];
34
+ align?: 'left' | 'right';
35
+ className?: string;
36
+ nameClassName?: string;
37
+ menuClassName?: string;
38
+ labels?: Partial<UserMenuLabels>;
39
+ }
40
+
41
+ export function deriveUserInitials(user?: UserMenuUserInfo | null): string {
42
+ const source =
43
+ user?.displayName?.trim() || user?.username?.trim() || user?.email?.trim() || '';
44
+ return source ? source[0]!.toUpperCase() : 'U';
45
+ }
46
+
47
+ const ITEM_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
48
+ settings: Settings,
49
+ profile: UserRound,
50
+ logout: LogOut,
51
+ };
52
+
53
+ export function UserMenu({
54
+ user,
55
+ items,
56
+ align = 'right',
57
+ className = '',
58
+ nameClassName = '',
59
+ menuClassName = '',
60
+ labels,
61
+ }: UserMenuProps) {
62
+ const { i18n } = useTranslation();
63
+ const isZh = (i18n.language || '').toLowerCase().startsWith('zh');
64
+ const defaults: UserMenuLabels = {
65
+ settings: isZh ? '个人设置' : 'Settings',
66
+ profile: isZh ? '个人资料' : 'Profile',
67
+ logout: isZh ? '退出登录' : 'Log out',
68
+ userMenu: isZh ? '用户菜单' : 'User menu',
69
+ emptyUser: isZh ? '未登录' : 'Not signed in',
70
+ };
71
+ const text: UserMenuLabels = { ...defaults, ...labels };
72
+
73
+ const { open, setOpen, triggerRef, menuRef, triggerProps, menuProps } = useMenuButton();
74
+
75
+ const displayName =
76
+ user?.displayName?.trim() || user?.username?.trim() || user?.email?.trim() || text.emptyUser;
77
+ const initials = deriveUserInitials(user);
78
+ // 统一口径:退出登录恒在末位(APG/NN-g 惯例),其余保持调用方顺序
79
+ const ordered = [...items].sort(
80
+ (a, b) => Number(a.type === 'logout') - Number(b.type === 'logout'),
81
+ );
82
+
83
+ const activate = (item: UserMenuItemSpec) => {
84
+ setOpen(false);
85
+ item.onClick?.();
86
+ };
87
+
88
+ return (
89
+ <div className="relative">
90
+ <button
91
+ {...triggerProps}
92
+ ref={triggerRef}
93
+ aria-label={text.userMenu}
94
+ className={`flex items-center gap-2 rounded-md p-1.5 transition-colors hover:bg-[var(--color-bg-muted)] ${className}`}
95
+ >
96
+ {user?.avatarUrl ? (
97
+ <img src={user.avatarUrl} alt="" className="h-8 w-8 rounded-full object-cover" />
98
+ ) : (
99
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--color-brand)] text-xs font-medium text-[var(--color-text-inverse)]">
100
+ {initials}
101
+ </span>
102
+ )}
103
+ <span
104
+ className={`max-w-[10rem] truncate text-sm text-[var(--color-text-primary)] ${nameClassName}`}
105
+ >
106
+ {displayName}
107
+ </span>
108
+ <ChevronDown className="h-4 w-4 shrink-0 text-[var(--color-text-muted)]" />
109
+ </button>
110
+ {open && (
111
+ <div
112
+ {...menuProps}
113
+ ref={menuRef}
114
+ className={`absolute top-full z-50 mt-2 w-56 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] py-1 shadow-lg ${
115
+ align === 'left' ? 'left-0' : 'right-0'
116
+ } ${menuClassName}`}
117
+ >
118
+ <div className="border-b border-[var(--color-border-subtle)] px-3 py-2">
119
+ <div className="truncate text-sm font-medium text-[var(--color-text-primary)]">
120
+ {displayName}
121
+ </div>
122
+ {user?.email && (
123
+ <div className="truncate text-xs text-[var(--color-text-muted)]">{user.email}</div>
124
+ )}
125
+ </div>
126
+ {ordered.map((item) => {
127
+ const Icon = ITEM_ICONS[item.type ?? ''] ?? null;
128
+ const danger = item.danger ?? item.type === 'logout';
129
+ const itemClass = `flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
130
+ danger
131
+ ? 'text-[var(--color-danger-text)] hover:bg-[var(--color-danger-soft)]'
132
+ : 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)]'
133
+ }`;
134
+ const content = (
135
+ <>
136
+ {item.icon ?? (Icon ? <Icon className="h-4 w-4 shrink-0" /> : null)}
137
+ <span className="truncate">{item.label ?? text[item.type as keyof UserMenuLabels] ?? item.key}</span>
138
+ </>
139
+ );
140
+ return item.href ? (
141
+ <a
142
+ key={item.key}
143
+ role="menuitem"
144
+ tabIndex={-1}
145
+ href={item.href}
146
+ className={itemClass}
147
+ onClick={() => activate(item)}
148
+ >
149
+ {content}
150
+ </a>
151
+ ) : (
152
+ <button
153
+ key={item.key}
154
+ role="menuitem"
155
+ tabIndex={-1}
156
+ type="button"
157
+ className={itemClass}
158
+ onClick={() => activate(item)}
159
+ >
160
+ {content}
161
+ </button>
162
+ );
163
+ })}
164
+ </div>
165
+ )}
166
+ </div>
167
+ );
168
+ }
@@ -0,0 +1,31 @@
1
+ export { PageHeader } from './PageHeader';
2
+ export { EmptyState } from './EmptyState';
3
+ export { SectionCard } from './SectionCard';
4
+ export { CodeBlock } from './CodeBlock';
5
+ export { ConfirmDialog } from './ConfirmDialog';
6
+ export { ToastProvider, useToast, showToast } from './Toast';
7
+ export { LoadingScreen } from './LoadingScreen';
8
+ export { ErrorState } from './ErrorState';
9
+ export { Modal } from './Modal';
10
+ export { ThemeToggle } from './ThemeToggle';
11
+ export { LanguageSwitcher } from './LanguageSwitcher';
12
+ export { UserMenu, deriveUserInitials } from './UserMenu';
13
+ export type { UserMenuProps, UserMenuUserInfo, UserMenuItemSpec, UserMenuLabels } from './UserMenu';
14
+ export { PortalSwitcher } from './PortalSwitcher';
15
+ export type { PortalSwitcherProps, PortalSwitcherLabels, PortalLink } from './PortalSwitcher';
16
+ export type { ToastType } from './Toast';
17
+ export type { LoadingScreenProps } from './LoadingScreen';
18
+ export type { ErrorStateProps } from './ErrorState';
19
+ export { ErrorBoundary } from './ErrorBoundary';
20
+ export { FormField, useFormFieldA11y } from './FormField';
21
+ export type { FormFieldProps } from './FormField';
22
+ export { PageContainer } from './PageContainer';
23
+ export type { PageContainerProps } from './PageContainer';
24
+ export { AppShell } from './AppShell';
25
+ export type { AppShellProps } from './AppShell';
26
+ export { ConsolePageHeader } from './ConsolePageHeader';
27
+ export type { ConsolePageHeaderProps } from './ConsolePageHeader';
28
+ export { Alert } from './Alert';
29
+ export type { AlertProps, AlertVariant } from './Alert';
30
+ export { Result } from './Result';
31
+ export type { ResultProps, ResultSurface } from './Result';
@@ -0,0 +1,67 @@
1
+ import * as React from 'react';
2
+ import type { FieldValues } from 'react-hook-form';
3
+ import { FormField } from '../molecules/FormField';
4
+ import { fieldControlClass, fieldControlHeight } from '../internal/field-styles';
5
+ import { ControlSlot, useBoundField, type FormControlBaseProps } from './shared';
6
+
7
+ export interface FormInputProps<T extends FieldValues>
8
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name' | 'defaultValue' | 'className' | 'prefix'>,
9
+ FormControlBaseProps<T> {
10
+ /**
11
+ * 控件内部的**前置装饰**(如金额框里的 ¥)。装饰是展示,不进表单值。
12
+ *
13
+ * 为什么收进 API 而不是让调用方自己拼:装饰必须贴在**输入框**上。
14
+ * 调用方若把装饰塞进 FormField 的控件槽,那一块的高度是整个控件块(含标签),
15
+ * `top-1/2` 会算到标签上,装饰就浮到输入框上沿去了 —— 实测两个页面都踩到,
16
+ * 于是各自复制了一份「把装饰和控件放进同一个定位块」的胶水代码。
17
+ */
18
+ leading?: React.ReactNode;
19
+ /** 控件内部的**后置装饰**(如显示/隐藏密码的按钮,可点,不是装饰而是控件)。 */
20
+ trailing?: React.ReactNode;
21
+ }
22
+
23
+ /** 文本输入(绑定 react-hook-form)。 */
24
+ export function FormInput<T extends FieldValues>({
25
+ name,
26
+ control,
27
+ rules,
28
+ error: errorOverride,
29
+ label,
30
+ hint,
31
+ required,
32
+ leading,
33
+ trailing,
34
+ className = '',
35
+ ...rest
36
+ }: FormInputProps<T>) {
37
+ const { field, error } = useBoundField(name, control, rules, errorOverride);
38
+ // 装饰占位:设计系统固定给前置 2rem / 后置 2.5rem。需要别的宽度时用 FormField + ControlSlot
39
+ // (同一个入口里导出的逃生口),而不是在这里加一个宽度 prop —— 那会长成配置表。
40
+ const padding = [leading ? 'pl-8' : '', trailing ? 'pr-10' : ''].filter(Boolean).join(' ');
41
+ return (
42
+ <FormField label={label} hint={hint} error={error} required={required}>
43
+ <ControlSlot
44
+ render={(a11y) => (
45
+ <div className={leading || trailing ? 'relative' : undefined}>
46
+ {leading && (
47
+ <span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-[var(--color-text-secondary)]">
48
+ {leading}
49
+ </span>
50
+ )}
51
+ <input
52
+ {...rest}
53
+ {...field}
54
+ id={a11y.id}
55
+ aria-invalid={a11y.invalid || undefined}
56
+ aria-describedby={a11y.describedBy}
57
+ className={fieldControlClass(a11y.invalid, [fieldControlHeight, padding, className].filter(Boolean).join(' '))}
58
+ />
59
+ {trailing && (
60
+ <span className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center">{trailing}</span>
61
+ )}
62
+ </div>
63
+ )}
64
+ />
65
+ </FormField>
66
+ );
67
+ }
@@ -0,0 +1,43 @@
1
+ import * as React from 'react';
2
+ import type { FieldValues } from 'react-hook-form';
3
+ import { FormField } from '../molecules/FormField';
4
+ import { fieldControlClass, fieldControlHeight } from '../internal/field-styles';
5
+ import { ControlSlot, useBoundField, type FormControlBaseProps } from './shared';
6
+
7
+ export interface FormSelectProps<T extends FieldValues>
8
+ extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'name' | 'defaultValue' | 'className'>,
9
+ FormControlBaseProps<T> {}
10
+
11
+ /** 下拉选择(绑定 react-hook-form,原生 select + 设计系统的控件外观)。 */
12
+ export function FormSelect<T extends FieldValues>({
13
+ name,
14
+ control,
15
+ rules,
16
+ error: errorOverride,
17
+ label,
18
+ hint,
19
+ required,
20
+ className = '',
21
+ children,
22
+ ...rest
23
+ }: FormSelectProps<T>) {
24
+ const { field, error } = useBoundField(name, control, rules, errorOverride);
25
+ return (
26
+ <FormField label={label} hint={hint} error={error} required={required}>
27
+ <ControlSlot
28
+ render={(a11y) => (
29
+ <select
30
+ {...rest}
31
+ {...field}
32
+ id={a11y.id}
33
+ aria-invalid={a11y.invalid || undefined}
34
+ aria-describedby={a11y.describedBy}
35
+ className={fieldControlClass(a11y.invalid, fieldControlHeight + ' ' + className)}
36
+ >
37
+ {children}
38
+ </select>
39
+ )}
40
+ />
41
+ </FormField>
42
+ );
43
+ }
@@ -0,0 +1,42 @@
1
+ import * as React from 'react';
2
+ import type { FieldValues } from 'react-hook-form';
3
+ import { FormField } from '../molecules/FormField';
4
+ import { fieldControlClass } from '../internal/field-styles';
5
+ import { ControlSlot, useBoundField, type FormControlBaseProps } from './shared';
6
+
7
+ export interface FormTextareaProps<T extends FieldValues>
8
+ extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'name' | 'defaultValue' | 'className'>,
9
+ FormControlBaseProps<T> {}
10
+
11
+ /** 多行文本(绑定 react-hook-form)。与单行控件的差别只有高度:不锁 h-10。 */
12
+ export function FormTextarea<T extends FieldValues>({
13
+ name,
14
+ control,
15
+ rules,
16
+ error: errorOverride,
17
+ label,
18
+ hint,
19
+ required,
20
+ className = '',
21
+ rows = 3,
22
+ ...rest
23
+ }: FormTextareaProps<T>) {
24
+ const { field, error } = useBoundField(name, control, rules, errorOverride);
25
+ return (
26
+ <FormField label={label} hint={hint} error={error} required={required}>
27
+ <ControlSlot
28
+ render={(a11y) => (
29
+ <textarea
30
+ {...rest}
31
+ {...field}
32
+ rows={rows}
33
+ id={a11y.id}
34
+ aria-invalid={a11y.invalid || undefined}
35
+ aria-describedby={a11y.describedBy}
36
+ className={fieldControlClass(a11y.invalid, className)}
37
+ />
38
+ )}
39
+ />
40
+ </FormField>
41
+ );
42
+ }
@@ -0,0 +1,18 @@
1
+ // react-hook-form 绑定入口 —— 与默认入口分离。
2
+ //
3
+ // 为什么单独一个子路径(与 ./antd 同一个理由):根入口的消费方里有不吃 react-hook-form 的
4
+ // (5 个 Astro 站、营销站),把绑定控件并进根入口等于让所有人替一个他们不用的库付包体积。
5
+ // 所以 react-hook-form 是 **optional peerDependency**,且默认入口 0 处 import 它(第 19 道闸门断言)。
6
+
7
+ export { FormInput } from './FormInput';
8
+ export type { FormInputProps } from './FormInput';
9
+ export { FormSelect } from './FormSelect';
10
+ export type { FormSelectProps } from './FormSelect';
11
+ export { FormTextarea } from './FormTextarea';
12
+ export type { FormTextareaProps } from './FormTextarea';
13
+ export type { FormControlBaseProps } from './shared';
14
+ // 自研控件(或需要自定义装饰/布局的字段)要接同一套 a11y 接线时用这两个:
15
+ // 实测两个页面为了「装饰贴着输入框」各自复制了一份 ControlSlot —— 导出它正是为了让这种复制没有理由发生。
16
+ export { useBoundField, ControlSlot } from './shared';
17
+ export { FormField, useFormFieldA11y } from '../molecules/FormField';
18
+ export type { FormFieldProps } from '../molecules/FormField';
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ // @autional/ui/rhf 的绑定控件:把 react-hook-form 的 useController 也包掉,
4
+ // 于是调用点只剩「字段名 + 标签 + 输入属性」,标签/错误/aria 接线一律由设计系统给。
5
+ //
6
+ // 为什么值得包:整个舰队有 50 处 react-hook-form 的 register( 调用点(user 10 / auth 34 / status 4 / trust 2),
7
+ // 每一处都在自己决定标签怎么写、错误显示成什么样、读屏器能不能念到那条错误。
8
+ //
9
+ // 为什么是**可选** peer:绑定控件在 ./rhf 子路径入口,不进默认入口 ——
10
+ // 不吃 react-hook-form 的站点(5 个 Astro 站、营销站)不会因为它多装一个包。
11
+
12
+ import * as React from 'react';
13
+ import { useController } from 'react-hook-form';
14
+ import type { Control, FieldValues, Path, UseControllerProps } from 'react-hook-form';
15
+ import { useFormFieldA11y } from '../molecules/FormField';
16
+
17
+ /** 绑定控件共有的 props。泛型 T 是表单值的类型,name 因此能校验到真实字段。 */
18
+ export interface FormControlBaseProps<T extends FieldValues> {
19
+ name: Path<T>;
20
+ /** 表单控制对象。给了就用它,不给则从最近的 FormProvider 上下文取。 */
21
+ control?: Control<T>;
22
+ label?: string;
23
+ hint?: string;
24
+ required?: boolean;
25
+ /**
26
+ * 字段级校验规则(透传给 react-hook-form)。
27
+ *
28
+ * 站点的 schema 校验走 zodResolver,用不到它;但**没有它,需要单字段规则的地方就只能退回 register()** ——
29
+ * 那正是这个入口想消灭的写法。所以照 RHF 的原义透传,而不是自创一套。
30
+ */
31
+ rules?: UseControllerProps<T>['rules'];
32
+ /**
33
+ * 覆盖错误文案。不给就用表单库给的那条。
34
+ *
35
+ * 为什么需要这个口子:本项目的 zod message 是 **i18n 键**(如 `mustMatch`),不是给人看的文案,
36
+ * 而页面今天按字段给的是自己的键(如 `security.deleteAccount.passwordRequired`)。
37
+ * 没有覆盖口子,接入绑定控件就会**悄悄换掉用户看到的报错文案** —— 那是收敛不该付的代价。
38
+ * 校验仍然归表单库,这里只决定「显示哪句话」。
39
+ */
40
+ error?: string;
41
+ className?: string;
42
+ }
43
+
44
+ /** 把 FormField 生成的 id / aria-invalid / aria-describedby 接到真实控件上。 */
45
+ export function ControlSlot({
46
+ render,
47
+ }: {
48
+ render: (a11y: { id: string; invalid: boolean; describedBy?: string }) => React.ReactNode;
49
+ }) {
50
+ const a11y = useFormFieldA11y();
51
+ // FormField 一定会提供上下文;这里的兜底只为类型收敛,不改变行为。
52
+ return <>{render(a11y ?? { id: '', invalid: false, describedBy: undefined })}</>;
53
+ }
54
+
55
+ export function useBoundField<T extends FieldValues>(
56
+ name: Path<T>,
57
+ control: Control<T> | undefined,
58
+ rules: UseControllerProps<T>['rules'],
59
+ override?: string,
60
+ ) {
61
+ const { field, fieldState } = useController({ name, control, rules });
62
+ return { field, error: override ?? fieldState.error?.message };
63
+ }
@@ -0,0 +1,19 @@
1
+ /* 令牌由各站点 <head> 里的 CDN <link> 提供(单一来源)。
2
+ 这份 CSS 被 packages/ui/src/index.ts 引入,若在这里再 @import 一份,
3
+ 令牌就会有两个来源(CDN + bundle),CDN 侧改动本地看不出来。 */
4
+
5
+ @tailwind base;
6
+ @tailwind components;
7
+ @tailwind utilities;
8
+
9
+ @layer base {
10
+ html {
11
+ font-family: var(--font-sans);
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+ }
15
+ body {
16
+ background-color: var(--color-bg-primary);
17
+ color: var(--color-text-primary);
18
+ }
19
+ }
@@ -0,0 +1,37 @@
1
+ // 测试环境的 jsdom 缺口 —— **由设计系统统一补这一份**。
2
+ //
3
+ // 为什么放在这里而不是各站各写一遍:实测三个控制台各自抄了一份 matchMedia + ResizeObserver,
4
+ // 而 user 只有 matchMedia。于是 user 接入 antd 的那一天,8 个既有用例一起红在
5
+ // 「ResizeObserver is not defined」——错误信息完全指不到「这是 jsdom 的缺口」。
6
+ // 抄了四份的东西必然有一份漏掉,这是「拷贝而非依赖」的教科书案例。
7
+ //
8
+ // 用法(站点 vitest 配置的 setupFiles 里):
9
+ // import '@testing-library/jest-dom/vitest';
10
+ // import '@autional/ui/test-setup';
11
+ //
12
+ // 这不是「为了让测试过而 mock」:matchMedia 与 ResizeObserver 是真实浏览器里就有的 API,
13
+ // jsdom 只是没实现。不补的后果实测过:antd 组件会在 rc-component 的 useLayoutEffect 里抛错,
14
+ // 报错落在与真正原因完全无关的位置。
15
+
16
+ if (typeof window !== 'undefined' && !window.matchMedia) {
17
+ window.matchMedia = ((query: string) => ({
18
+ matches: false,
19
+ media: query,
20
+ onchange: null,
21
+ addListener: () => {},
22
+ removeListener: () => {},
23
+ addEventListener: () => {},
24
+ removeEventListener: () => {},
25
+ dispatchEvent: () => false,
26
+ })) as unknown as typeof window.matchMedia;
27
+ }
28
+
29
+ if (typeof globalThis !== 'undefined' && !(globalThis as { ResizeObserver?: unknown }).ResizeObserver) {
30
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
31
+ observe() {}
32
+ unobserve() {}
33
+ disconnect() {}
34
+ };
35
+ }
36
+
37
+ export {};
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.