@autional/ui 0.0.0-stage → 0.1.0-rc.43
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.
- package/LICENSE +661 -0
- package/package.json +88 -3
- package/src/antd/AntdThemeProvider.tsx +80 -0
- package/src/antd/Breadcrumb.tsx +118 -0
- package/src/antd/DataTable.tsx +82 -0
- package/src/antd/DateRangeFilter.tsx +64 -0
- package/src/antd/Drawer.tsx +39 -0
- package/src/antd/Modal.tsx +38 -0
- package/src/antd/PageStatus.tsx +68 -0
- package/src/antd/index.ts +27 -0
- package/src/atoms/Button.tsx +69 -0
- package/src/atoms/Input.tsx +19 -0
- package/src/atoms/Label.tsx +18 -0
- package/src/atoms/StatusBadge.tsx +27 -0
- package/src/atoms/Toggle.tsx +43 -0
- package/src/atoms/index.ts +9 -0
- package/src/context/ThemeProvider.tsx +78 -0
- package/src/i18n/index.ts +36 -0
- package/src/i18n/resources.ts +56 -0
- package/src/index.ts +7 -0
- package/src/internal/field-styles.ts +14 -0
- package/src/internal/semantic-styles.ts +28 -0
- package/src/internal/use-menu-button.ts +144 -0
- package/src/molecules/Alert.tsx +100 -0
- package/src/molecules/AppShell.tsx +122 -0
- package/src/molecules/CodeBlock.tsx +55 -0
- package/src/molecules/ConfirmDialog.tsx +101 -0
- package/src/molecules/ConsolePageHeader.tsx +45 -0
- package/src/molecules/EmptyState.tsx +24 -0
- package/src/molecules/ErrorBoundary.tsx +142 -0
- package/src/molecules/ErrorState.tsx +52 -0
- package/src/molecules/FormField.tsx +89 -0
- package/src/molecules/LanguageSwitcher.tsx +39 -0
- package/src/molecules/LoadingScreen.tsx +30 -0
- package/src/molecules/Modal.tsx +178 -0
- package/src/molecules/PageContainer.tsx +35 -0
- package/src/molecules/PageHeader.tsx +27 -0
- package/src/molecules/PortalSwitcher.tsx +132 -0
- package/src/molecules/Result.tsx +83 -0
- package/src/molecules/SectionCard.tsx +45 -0
- package/src/molecules/ThemeToggle.tsx +42 -0
- package/src/molecules/Toast.tsx +106 -0
- package/src/molecules/UserMenu.tsx +168 -0
- package/src/molecules/index.ts +31 -0
- package/src/rhf/FormInput.tsx +67 -0
- package/src/rhf/FormSelect.tsx +43 -0
- package/src/rhf/FormTextarea.tsx +42 -0
- package/src/rhf/index.ts +18 -0
- package/src/rhf/shared.tsx +63 -0
- package/src/styles/globals.css +19 -0
- package/src/test-setup.ts +37 -0
- package/README.md +0 -3
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
4
|
+
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
|
5
|
+
size?: 'sm' | 'md' | 'lg';
|
|
6
|
+
isLoading?: boolean;
|
|
7
|
+
fullWidth?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
11
|
+
(
|
|
12
|
+
{
|
|
13
|
+
variant = 'primary',
|
|
14
|
+
size = 'md',
|
|
15
|
+
isLoading,
|
|
16
|
+
fullWidth,
|
|
17
|
+
children,
|
|
18
|
+
className = '',
|
|
19
|
+
disabled,
|
|
20
|
+
...props
|
|
21
|
+
},
|
|
22
|
+
ref,
|
|
23
|
+
) => {
|
|
24
|
+
const base =
|
|
25
|
+
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50';
|
|
26
|
+
|
|
27
|
+
const variants: Record<string, string> = {
|
|
28
|
+
primary: 'bg-[var(--color-brand)] text-[var(--color-on-brand)] hover:bg-[var(--color-brand-hover)]',
|
|
29
|
+
secondary: 'bg-[var(--color-bg-muted)] text-[var(--color-text-primary)] hover:opacity-80',
|
|
30
|
+
outline:
|
|
31
|
+
'border border-[var(--color-border-subtle)] bg-transparent hover:bg-[var(--color-bg-muted)] text-[var(--color-text-primary)]',
|
|
32
|
+
ghost: 'bg-transparent hover:bg-[var(--color-bg-muted)] text-[var(--color-text-primary)]',
|
|
33
|
+
danger: 'bg-[var(--color-danger)] text-white hover:opacity-80',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const sizes: Record<string, string> = {
|
|
37
|
+
sm: 'h-8 px-3 text-xs',
|
|
38
|
+
md: 'h-10 px-4 text-sm',
|
|
39
|
+
lg: 'h-12 px-6 text-base',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const width = fullWidth ? 'w-full' : '';
|
|
43
|
+
const cls = `${base} ${variants[variant]} ${sizes[size]} ${width} ${className}`;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<button ref={ref} className={cls} disabled={disabled || isLoading} {...props}>
|
|
47
|
+
{isLoading && (
|
|
48
|
+
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
49
|
+
<circle
|
|
50
|
+
className="opacity-25"
|
|
51
|
+
cx="12"
|
|
52
|
+
cy="12"
|
|
53
|
+
r="10"
|
|
54
|
+
stroke="currentColor"
|
|
55
|
+
strokeWidth="4"
|
|
56
|
+
/>
|
|
57
|
+
<path
|
|
58
|
+
className="opacity-75"
|
|
59
|
+
fill="currentColor"
|
|
60
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
61
|
+
/>
|
|
62
|
+
</svg>
|
|
63
|
+
)}
|
|
64
|
+
{children}
|
|
65
|
+
</button>
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
Button.displayName = 'Button';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { fieldControlClass, fieldControlHeight } from '../internal/field-styles';
|
|
3
|
+
|
|
4
|
+
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// 外观来自 ../internal/field-styles —— 与 @autional/ui/rhf 的绑定控件同一份,不各自维护。
|
|
9
|
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
10
|
+
({ className = '', error, ...props }, ref) => {
|
|
11
|
+
return (
|
|
12
|
+
<div className="w-full">
|
|
13
|
+
<input ref={ref} className={fieldControlClass(!!error, fieldControlHeight + ' ' + className)} {...props} />
|
|
14
|
+
{error && <p className="mt-1 text-xs text-[var(--color-danger)]">{error}</p>}
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
},
|
|
18
|
+
);
|
|
19
|
+
Input.displayName = 'Input';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
|
4
|
+
required?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
|
8
|
+
({ children, className = '', required, ...props }, ref) => {
|
|
9
|
+
const cls = `text-sm font-medium leading-none ${className}`;
|
|
10
|
+
return (
|
|
11
|
+
<label ref={ref} className={cls} {...props}>
|
|
12
|
+
{children}
|
|
13
|
+
{required && <span className="text-danger ml-0.5">*</span>}
|
|
14
|
+
</label>
|
|
15
|
+
);
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
Label.displayName = 'Label';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { SOFT_TEXT } from '../internal/semantic-styles';
|
|
3
|
+
|
|
4
|
+
export type StatusVariant = 'success' | 'warning' | 'danger' | 'info' | 'neutral';
|
|
5
|
+
|
|
6
|
+
interface StatusBadgeProps {
|
|
7
|
+
variant?: StatusVariant;
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// 配对的唯一来源在 internal/semantic-styles.ts(Alert 与它共用同一份)。
|
|
13
|
+
const variantStyles: Record<StatusVariant, string> = SOFT_TEXT;
|
|
14
|
+
|
|
15
|
+
export const StatusBadge = React.memo(function StatusBadge({
|
|
16
|
+
variant = 'neutral',
|
|
17
|
+
children,
|
|
18
|
+
className = '',
|
|
19
|
+
}: StatusBadgeProps) {
|
|
20
|
+
return (
|
|
21
|
+
<span
|
|
22
|
+
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${variantStyles[variant]} ${className}`}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface ToggleProps {
|
|
4
|
+
checked: boolean;
|
|
5
|
+
onChange: () => void;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
size?: 'sm' | 'md';
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const sizeClasses: Record<string, { track: string; circle: string; translate: string }> = {
|
|
12
|
+
md: { track: 'h-6 w-11', circle: 'h-5 w-5', translate: 'translate-x-5' },
|
|
13
|
+
sm: { track: 'h-5 w-9', circle: 'h-4 w-4', translate: 'translate-x-4' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const Toggle = React.memo(function Toggle({
|
|
17
|
+
checked,
|
|
18
|
+
onChange,
|
|
19
|
+
disabled = false,
|
|
20
|
+
size = 'md',
|
|
21
|
+
className = '',
|
|
22
|
+
}: ToggleProps) {
|
|
23
|
+
const s = sizeClasses[size];
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
role="switch"
|
|
29
|
+
aria-checked={checked}
|
|
30
|
+
disabled={disabled}
|
|
31
|
+
onClick={onChange}
|
|
32
|
+
className={`relative inline-flex shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
|
|
33
|
+
checked ? 'bg-primary-600' : 'bg-neutral-200 dark:bg-neutral-600'
|
|
34
|
+
} ${s.track} ${className}`}
|
|
35
|
+
>
|
|
36
|
+
<span
|
|
37
|
+
className={`inline-block rounded-full bg-white shadow-soft transition-transform ${
|
|
38
|
+
checked ? s.translate : 'translate-x-0.5'
|
|
39
|
+
} ${s.circle}`}
|
|
40
|
+
/>
|
|
41
|
+
</button>
|
|
42
|
+
);
|
|
43
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { Button } from './Button';
|
|
2
|
+
export type { ButtonProps } from './Button';
|
|
3
|
+
export { Input } from './Input';
|
|
4
|
+
export type { InputProps } from './Input';
|
|
5
|
+
export { Label } from './Label';
|
|
6
|
+
export type { LabelProps } from './Label';
|
|
7
|
+
export { StatusBadge } from './StatusBadge';
|
|
8
|
+
export type { StatusVariant } from './StatusBadge';
|
|
9
|
+
export { Toggle } from './Toggle';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
export type Theme = 'light' | 'dark';
|
|
4
|
+
|
|
5
|
+
interface ThemeContextValue {
|
|
6
|
+
theme: Theme;
|
|
7
|
+
toggle: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ThemeContext = createContext<ThemeContextValue>({
|
|
11
|
+
theme: 'light',
|
|
12
|
+
toggle: () => {},
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
interface ThemeProviderProps {
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
storageKey?: string;
|
|
18
|
+
/**
|
|
19
|
+
* 深色档写入 data-theme 的值(默认 'dark')。
|
|
20
|
+
* 站点若使用完整深色变体(如 authenticator 的 [data-theme="authenticator"]),
|
|
21
|
+
* 在这里传变体名;其余站点不传,行为不变。
|
|
22
|
+
*/
|
|
23
|
+
darkThemeName?: string;
|
|
24
|
+
/**
|
|
25
|
+
* 无存储值(首次访问)时的兜底档;缺省沿用 prefers-color-scheme → light 的既有行为。
|
|
26
|
+
* dark-first 的站点传 'dark'(与站点 boot script 的兜底保持一致)。
|
|
27
|
+
*/
|
|
28
|
+
defaultTheme?: Theme;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ThemeProvider({
|
|
32
|
+
children,
|
|
33
|
+
storageKey = 'autional-theme',
|
|
34
|
+
darkThemeName = 'dark',
|
|
35
|
+
defaultTheme,
|
|
36
|
+
}: ThemeProviderProps) {
|
|
37
|
+
const [theme, setTheme] = useState<Theme>(() => {
|
|
38
|
+
if (typeof window !== 'undefined') {
|
|
39
|
+
try {
|
|
40
|
+
const saved = localStorage.getItem(storageKey) as Theme;
|
|
41
|
+
if (saved === 'dark' || saved === 'light') return saved;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
// 存储不可用(隐私模式/被禁用):与站点 boot script 的兜底口径一致,继续走默认链
|
|
44
|
+
}
|
|
45
|
+
if (defaultTheme) return defaultTheme;
|
|
46
|
+
if (window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
|
|
47
|
+
}
|
|
48
|
+
return 'light';
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const root = document.documentElement;
|
|
53
|
+
if (theme === 'dark') {
|
|
54
|
+
root.classList.add('dark');
|
|
55
|
+
} else {
|
|
56
|
+
root.classList.remove('dark');
|
|
57
|
+
}
|
|
58
|
+
root.setAttribute('data-theme', theme === 'dark' ? darkThemeName : 'light');
|
|
59
|
+
}, [theme, darkThemeName]);
|
|
60
|
+
|
|
61
|
+
const toggle = useCallback(() => {
|
|
62
|
+
setTheme((prev) => {
|
|
63
|
+
const next = prev === 'dark' ? 'light' : 'dark';
|
|
64
|
+
try {
|
|
65
|
+
localStorage.setItem(storageKey, next);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
// 存储不可用(隐私模式/被禁用):本次切换仍生效,仅不持久化
|
|
68
|
+
}
|
|
69
|
+
return next;
|
|
70
|
+
});
|
|
71
|
+
}, [storageKey]);
|
|
72
|
+
|
|
73
|
+
return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function useTheme() {
|
|
77
|
+
return useContext(ThemeContext);
|
|
78
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { UI_I18N_NS, uiI18nResources } from './resources';
|
|
2
|
+
|
|
3
|
+
export { UI_I18N_NS, uiI18nResources };
|
|
4
|
+
|
|
5
|
+
/** 当前语言的内置文案;未收录的 key 返回 undefined(组件据此决定最终回落)。 */
|
|
6
|
+
export function uiText(lang: string | undefined, key: string): string | undefined {
|
|
7
|
+
const bundle = (lang ?? '').toLowerCase().startsWith('zh')
|
|
8
|
+
? uiI18nResources['zh-CN']
|
|
9
|
+
: uiI18nResources['en-US'];
|
|
10
|
+
return bundle[key];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UiI18nHost {
|
|
14
|
+
addResourceBundle(
|
|
15
|
+
lng: string,
|
|
16
|
+
ns: string,
|
|
17
|
+
resources: Record<string, unknown>,
|
|
18
|
+
deep?: boolean,
|
|
19
|
+
overwrite?: boolean,
|
|
20
|
+
): unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 消费站在自己的 i18n 初始化时调用一次,将组件文案注册进 i18next(ns 'autional-ui')。
|
|
25
|
+
* 注册后文案可经站点 i18next 覆盖/扩展(不注册也能工作——组件按站点语言回落内置表):
|
|
26
|
+
* i18n.addResourceBundle(lang, 'autional-ui', { 'portal.names.admin': '...' }, true, true)
|
|
27
|
+
* 短码 'zh'/'en' 一并注册:站点语言可能是裸码(如 navigator 检出的 'en')。
|
|
28
|
+
*/
|
|
29
|
+
export function registerUiI18n(instance: UiI18nHost): void {
|
|
30
|
+
for (const lng of ['zh-CN', 'zh']) {
|
|
31
|
+
instance.addResourceBundle(lng, UI_I18N_NS, uiI18nResources['zh-CN'], true, false);
|
|
32
|
+
}
|
|
33
|
+
for (const lng of ['en-US', 'en']) {
|
|
34
|
+
instance.addResourceBundle(lng, UI_I18N_NS, uiI18nResources['en-US'], true, false);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @autional/ui 组件文案的单一出处(i18n 资源形态,扁平点键——对齐消费站
|
|
3
|
+
* `keySeparator: false` 的扁平键约定)。
|
|
4
|
+
*
|
|
5
|
+
* 消费站在自己的 i18n 初始化时调用 `registerUiI18n(i18n)` 注册本表;未注册时
|
|
6
|
+
* 组件按站点当前语言直接回落本表(i18next defaultValue),行为一致。
|
|
7
|
+
* 门户名措辞对齐 auth dashboard 的 `dashboard.*` 口径(同一门户一处名字)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const UI_I18N_NS = 'autional-ui';
|
|
11
|
+
|
|
12
|
+
export const uiI18nResources: Record<'zh-CN' | 'en-US', Record<string, string>> = {
|
|
13
|
+
'zh-CN': {
|
|
14
|
+
'portal.switcher': '切换门户',
|
|
15
|
+
'portal.names.admin': '管理后台',
|
|
16
|
+
'portal.names.auth': '登录中心',
|
|
17
|
+
'portal.names.security': '安全中心',
|
|
18
|
+
'portal.names.user': '用户中心',
|
|
19
|
+
'portal.names.authenticator': '身份验证器应用',
|
|
20
|
+
'portal.names.status': '状态页',
|
|
21
|
+
'portal.names.trust': '信任中心',
|
|
22
|
+
'portal.names.platform': '平台控制台',
|
|
23
|
+
'portal.names.developer': '开发者门户',
|
|
24
|
+
'portal.names.landing': '门户首页',
|
|
25
|
+
'portal.names.brand': '品牌站',
|
|
26
|
+
// 整页占位(控制台的 PageLoading / PageError)。文案与 admin 站点里原有的
|
|
27
|
+
// pageStatus.* 三键**逐字一致** —— 合并只许改实现,不许顺手改用户看到的字。
|
|
28
|
+
'modal.close': '关闭',
|
|
29
|
+
'pageStatus.loading': '加载中…',
|
|
30
|
+
'pageStatus.loadError': '数据加载失败',
|
|
31
|
+
'pageStatus.retry': '重试',
|
|
32
|
+
// ThemeToggle 默认文案(UP-04:原为组件内硬编码中文,EN 站点顶栏出现中文标签)
|
|
33
|
+
'theme.toggleDark': '切换到深色模式',
|
|
34
|
+
'theme.toggleLight': '切换到浅色模式',
|
|
35
|
+
},
|
|
36
|
+
'en-US': {
|
|
37
|
+
'portal.switcher': 'Switch portal',
|
|
38
|
+
'portal.names.admin': 'Admin Console',
|
|
39
|
+
'portal.names.auth': 'Sign-in',
|
|
40
|
+
'portal.names.security': 'Security Dashboard',
|
|
41
|
+
'portal.names.user': 'End-User Portal',
|
|
42
|
+
'portal.names.authenticator': 'Authenticator App',
|
|
43
|
+
'portal.names.status': 'Status Page',
|
|
44
|
+
'portal.names.trust': 'Trust Center',
|
|
45
|
+
'portal.names.platform': 'Platform Console',
|
|
46
|
+
'portal.names.developer': 'Developer Portal',
|
|
47
|
+
'portal.names.landing': 'Home',
|
|
48
|
+
'portal.names.brand': 'Brand Site',
|
|
49
|
+
'modal.close': 'Close',
|
|
50
|
+
'pageStatus.loading': 'Loading...',
|
|
51
|
+
'pageStatus.loadError': 'Data loading failed',
|
|
52
|
+
'pageStatus.retry': 'Retry',
|
|
53
|
+
'theme.toggleDark': 'Switch to dark mode',
|
|
54
|
+
'theme.toggleLight': 'Switch to light mode',
|
|
55
|
+
},
|
|
56
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './atoms';
|
|
2
|
+
export * from './molecules';
|
|
3
|
+
export { ThemeProvider, useTheme } from './context/ThemeProvider';
|
|
4
|
+
export type { Theme } from './context/ThemeProvider';
|
|
5
|
+
export { UI_I18N_NS, uiI18nResources, registerUiI18n } from './i18n';
|
|
6
|
+
export type { UiI18nHost } from './i18n';
|
|
7
|
+
import './styles/globals.css';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// 表单控件的外观只有一份 —— Input atom 与 @autional/ui/rhf 的绑定控件都用它。
|
|
2
|
+
// 各自复制一份类名串的后果不是「看起来一样」,而是「改了一处、另一处没变」:
|
|
3
|
+
// 那正是这一层要消灭的东西(与 antd 组件级令牌同理,只是这里是一段 Tailwind 类)。
|
|
4
|
+
|
|
5
|
+
const BASE =
|
|
6
|
+
'flex w-full rounded-md border bg-[var(--color-bg-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50';
|
|
7
|
+
|
|
8
|
+
/** 单行控件的高度。textarea 不给(它要能长高)。 */
|
|
9
|
+
export const fieldControlHeight = 'h-10';
|
|
10
|
+
|
|
11
|
+
export function fieldControlClass(invalid?: boolean, extra = ''): string {
|
|
12
|
+
const border = invalid ? 'border-[var(--color-danger)]' : 'border-[var(--color-border-subtle)]';
|
|
13
|
+
return `${BASE} ${border} ${extra}`.trim();
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// 语义色的「浅底 + 深字」配对 —— **唯一一份**(第 31 轮从 StatusBadge 里提出来,Alert 与它共用)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么必须成对,两个理由(第二个才是关键):
|
|
4
|
+
// ① 机制:preset 把颜色定义成裸 var(--color-danger),没有 <alpha-value> 槽,
|
|
5
|
+
// Tailwind **生成不出** .bg-danger/10 —— 类写了、产物里没有规则、静默无底色。
|
|
6
|
+
// ② 设计:-soft 是**模式感知**的(light #fce4e4 / dark #490909),而且每一档都与对应的
|
|
7
|
+
// -text 配过对比度(success 5.51 / warning 4.85 / danger 4.65 / info 6.70)。
|
|
8
|
+
// 原来的 text-success #52c41a 铺白底只有 2.27:1 —— 即便底色能出来,文字也不达 AA。
|
|
9
|
+
//
|
|
10
|
+
// neutral 顺带带上 dark: 变体:slate 不在设计系统色阶里,那条类同样生成不出来。
|
|
11
|
+
export const SOFT_TEXT = {
|
|
12
|
+
success: 'bg-success-soft text-success-text',
|
|
13
|
+
warning: 'bg-warning-soft text-warning-text',
|
|
14
|
+
danger: 'bg-danger-soft text-danger-text',
|
|
15
|
+
info: 'bg-info-soft text-info-text',
|
|
16
|
+
neutral: 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300'
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export type SemanticVariant = keyof typeof SOFT_TEXT;
|
|
20
|
+
|
|
21
|
+
// 边框与浅底同族:这样「着色块」的边界也有唯一来源,而不是各站自己挑 border-neutral-200 / border-amber-200。
|
|
22
|
+
export const SOFT_BORDER: Record<SemanticVariant, string> = {
|
|
23
|
+
success: 'border-success-soft',
|
|
24
|
+
warning: 'border-warning-soft',
|
|
25
|
+
danger: 'border-danger-soft',
|
|
26
|
+
info: 'border-info-soft',
|
|
27
|
+
neutral: 'border-neutral-200 dark:border-neutral-700'
|
|
28
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import type React from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* W3C APG「menu button」最小实现:UserMenu / PortalSwitcher 共用。
|
|
6
|
+
* trigger 携带 aria-haspopup/aria-expanded/aria-controls;菜单内 Arrow/Home/End
|
|
7
|
+
* roving focus(条目 tabIndex=-1,由组件自行标注 role="menuitem")、Esc 关闭并还焦、
|
|
8
|
+
* Tab 关闭放行走位、焦点离开 trigger/菜单时关闭、document mousedown 外部点击关闭。
|
|
9
|
+
*/
|
|
10
|
+
export interface UseMenuButtonResult {
|
|
11
|
+
open: boolean;
|
|
12
|
+
setOpen: (next: boolean) => void;
|
|
13
|
+
triggerId: string;
|
|
14
|
+
menuId: string;
|
|
15
|
+
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
|
16
|
+
menuRef: React.RefObject<HTMLDivElement | null>;
|
|
17
|
+
triggerProps: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
18
|
+
menuProps: React.HTMLAttributes<HTMLDivElement>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useMenuButton(): UseMenuButtonResult {
|
|
22
|
+
const [open, setOpen] = useState(false);
|
|
23
|
+
const rawId = useId();
|
|
24
|
+
const triggerId = `${rawId}-trigger`;
|
|
25
|
+
const menuId = `${rawId}-menu`;
|
|
26
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
27
|
+
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
28
|
+
const focusOnOpenRef = useRef<'first' | 'last' | null>(null);
|
|
29
|
+
|
|
30
|
+
const getItems = () =>
|
|
31
|
+
Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
|
|
32
|
+
|
|
33
|
+
const openWith = (position: 'first' | 'last') => {
|
|
34
|
+
if (open) {
|
|
35
|
+
const items = getItems();
|
|
36
|
+
(position === 'first' ? items[0] : items[items.length - 1])?.focus();
|
|
37
|
+
} else {
|
|
38
|
+
focusOnOpenRef.current = position;
|
|
39
|
+
setOpen(true);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return;
|
|
45
|
+
const position = focusOnOpenRef.current;
|
|
46
|
+
if (!position) return;
|
|
47
|
+
focusOnOpenRef.current = null;
|
|
48
|
+
const items = getItems();
|
|
49
|
+
(position === 'first' ? items[0] : items[items.length - 1])?.focus();
|
|
50
|
+
}, [open]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!open) return;
|
|
54
|
+
const onMouseDown = (event: MouseEvent) => {
|
|
55
|
+
const target = event.target as Node;
|
|
56
|
+
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return;
|
|
57
|
+
setOpen(false);
|
|
58
|
+
};
|
|
59
|
+
document.addEventListener('mousedown', onMouseDown);
|
|
60
|
+
return () => document.removeEventListener('mousedown', onMouseDown);
|
|
61
|
+
}, [open]);
|
|
62
|
+
|
|
63
|
+
const toggle = useCallback(() => setOpen((value) => !value), []);
|
|
64
|
+
|
|
65
|
+
const triggerKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
66
|
+
if (event.key === 'ArrowDown') {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
openWith('first');
|
|
69
|
+
} else if (event.key === 'ArrowUp') {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
openWith('last');
|
|
72
|
+
} else if (event.key === 'Escape' && open) {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
setOpen(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// 焦点离开 widget(trigger + 菜单)即收起:覆盖「鼠标开菜单 → Tab 走位」时
|
|
79
|
+
// 面板仍悬浮、而菜单内 Tab 分支不触发的场景。焦点转入菜单项(键盘开菜单)除外。
|
|
80
|
+
const triggerBlur = (event: React.FocusEvent<HTMLButtonElement>) => {
|
|
81
|
+
if (!open) return;
|
|
82
|
+
const next = event.relatedTarget as Node | null;
|
|
83
|
+
if (next && menuRef.current?.contains(next)) return;
|
|
84
|
+
setOpen(false);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const menuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
88
|
+
const items = getItems();
|
|
89
|
+
if (items.length === 0) return;
|
|
90
|
+
const current = items.indexOf(document.activeElement as HTMLElement);
|
|
91
|
+
switch (event.key) {
|
|
92
|
+
case 'ArrowDown':
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
items[(current + 1) % items.length]?.focus();
|
|
95
|
+
break;
|
|
96
|
+
case 'ArrowUp':
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
items[(current - 1 + items.length) % items.length]?.focus();
|
|
99
|
+
break;
|
|
100
|
+
case 'Home':
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
items[0]?.focus();
|
|
103
|
+
break;
|
|
104
|
+
case 'End':
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
items[items.length - 1]?.focus();
|
|
107
|
+
break;
|
|
108
|
+
case 'Escape':
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
setOpen(false);
|
|
111
|
+
triggerRef.current?.focus();
|
|
112
|
+
break;
|
|
113
|
+
case 'Tab':
|
|
114
|
+
// 不拦截:收起菜单,放行浏览器默认走位
|
|
115
|
+
setOpen(false);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
open,
|
|
122
|
+
setOpen,
|
|
123
|
+
triggerId,
|
|
124
|
+
menuId,
|
|
125
|
+
triggerRef,
|
|
126
|
+
menuRef,
|
|
127
|
+
triggerProps: {
|
|
128
|
+
id: triggerId,
|
|
129
|
+
type: 'button',
|
|
130
|
+
'aria-haspopup': 'menu',
|
|
131
|
+
'aria-expanded': open,
|
|
132
|
+
'aria-controls': open ? menuId : undefined,
|
|
133
|
+
onClick: toggle,
|
|
134
|
+
onKeyDown: triggerKeyDown,
|
|
135
|
+
onBlur: triggerBlur,
|
|
136
|
+
},
|
|
137
|
+
menuProps: {
|
|
138
|
+
id: menuId,
|
|
139
|
+
role: 'menu',
|
|
140
|
+
'aria-labelledby': triggerId,
|
|
141
|
+
onKeyDown: menuKeyDown,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react';
|
|
5
|
+
import { SOFT_BORDER, SOFT_TEXT, type SemanticVariant } from '../internal/semantic-styles';
|
|
6
|
+
|
|
7
|
+
// Alert —— 区块级提示(着色块)。
|
|
8
|
+
//
|
|
9
|
+
// 为什么要有它(第 31 轮实测):
|
|
10
|
+
// · user 门户 **30 处手写**着色提示块,是 26 种排列组合(`bg-info-soft border-info-soft`、
|
|
11
|
+
// `bg-amber-50 border-amber-200 text-amber-800`、`bg-primary-50`…),
|
|
12
|
+
// 其中「警告」有三种写法(amber-50 / warning-soft / amber-800 文字);
|
|
13
|
+
// · 其余站在用 antd 的 `<Alert>`(security 14 / platform 19 / admin 9 / status 18 / authenticator 15 /
|
|
14
|
+
// trust 9)—— 那是**组件**,但它给的是 antd 出厂的四档配色,而不是设计系统自己的语义槽位。
|
|
15
|
+
// 两边合起来 = 同一个语义(一句提示)在舰队里有六七种长相。
|
|
16
|
+
//
|
|
17
|
+
// 配色只走 internal/semantic-styles.ts 那一份 `-soft` / `-text` 配对(与 StatusBadge 同源):
|
|
18
|
+
// 那一份是模式感知的、且每一档都做过对比度验证(success 5.51 / warning 4.85 / danger 4.65 / info 6.70)。
|
|
19
|
+
// 这也是为什么**不提供** `variant="brand"`:品牌色不是语义色,硬塞进来会让「提示的语义」和「品牌曝光」
|
|
20
|
+
// 混成一个属性,而且 brand 那一档没有配过 -text。
|
|
21
|
+
//
|
|
22
|
+
// 刻意不做的事:不做 `banner`(整条横幅)、不做 `action` 槽(antd 有,但 fleet 里 0 处用它)。
|
|
23
|
+
// 没有消费者的能力不进设计系统。
|
|
24
|
+
export type AlertVariant = SemanticVariant;
|
|
25
|
+
|
|
26
|
+
export interface AlertProps {
|
|
27
|
+
/** 语义档位,默认 info。amber(品牌色阶里的暖色)在语义上属于 warning。 */
|
|
28
|
+
variant?: AlertVariant;
|
|
29
|
+
/** 加粗的第一行(可选)。 */
|
|
30
|
+
title?: React.ReactNode;
|
|
31
|
+
/** 正文。 */
|
|
32
|
+
children?: React.ReactNode;
|
|
33
|
+
/** 图标:默认按档位给一个;显式传 `null` 可以不要图标。 */
|
|
34
|
+
icon?: React.ReactNode | null;
|
|
35
|
+
/**
|
|
36
|
+
* 右侧动作区(一个按钮/链接)。
|
|
37
|
+
*
|
|
38
|
+
* 第 39 轮补上:D18 当初写的是「`action` 在舰队里 0 处使用」,**那条证据是错的** ——
|
|
39
|
+
* 它来自「数 `<Alert` 出现次数」,而从没数过这些 Alert 用了哪些属性。
|
|
40
|
+
* 按属性数过之后:三个控制台里 `action` 有 **5 个**真实消费者(security 4 + admin 1),
|
|
41
|
+
* 那 5 处因此一直收不过来(它们不是配色不同,是形态不同)。够「≥2 份」的口径,所以补上。
|
|
42
|
+
*/
|
|
43
|
+
action?: React.ReactNode;
|
|
44
|
+
/** 是否可关闭(受控:真正的关闭动作在 onClose 里)。 */
|
|
45
|
+
closable?: boolean;
|
|
46
|
+
/** 点关闭按钮时调用。 */
|
|
47
|
+
onClose?: () => void;
|
|
48
|
+
/** 关闭按钮的无障碍标签。 */
|
|
49
|
+
closeLabel?: string;
|
|
50
|
+
/** 追加类名(间距归调用方,配色归组件)。 */
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_ICONS: Record<AlertVariant, React.ReactNode> = {
|
|
55
|
+
success: <CheckCircle2 className="h-4 w-4" />,
|
|
56
|
+
warning: <AlertTriangle className="h-4 w-4" />,
|
|
57
|
+
danger: <XCircle className="h-4 w-4" />,
|
|
58
|
+
info: <Info className="h-4 w-4" />,
|
|
59
|
+
neutral: <Info className="h-4 w-4" />
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const Alert = React.memo(function Alert({
|
|
63
|
+
variant = 'info',
|
|
64
|
+
title,
|
|
65
|
+
children,
|
|
66
|
+
icon,
|
|
67
|
+
action,
|
|
68
|
+
closable = false,
|
|
69
|
+
onClose,
|
|
70
|
+
closeLabel = '关闭',
|
|
71
|
+
className = ''
|
|
72
|
+
}: AlertProps) {
|
|
73
|
+
const glyph = icon === null ? null : (icon ?? DEFAULT_ICONS[variant]);
|
|
74
|
+
return (
|
|
75
|
+
// role="alert" 与 antd 的 Alert 一致:这些块绝大多数是**动作之后**才出现的(结果/失败原因),
|
|
76
|
+
// 屏幕阅读器应当主动播报;这与「静态的一行说明」不同,所以调用方若把它当纯装饰用,
|
|
77
|
+
// 应当显式传 role={undefined}。
|
|
78
|
+
<div
|
|
79
|
+
role="alert"
|
|
80
|
+
className={`flex items-start gap-3 rounded-lg border p-4 ${SOFT_TEXT[variant]} ${SOFT_BORDER[variant]} ${className}`}
|
|
81
|
+
>
|
|
82
|
+
{glyph ? <span className="mt-0.5 shrink-0">{glyph}</span> : null}
|
|
83
|
+
<div className="min-w-0 flex-1">
|
|
84
|
+
{title ? <p className="font-semibold">{title}</p> : null}
|
|
85
|
+
{children ? <div className={title ? 'mt-1' : ''}>{children}</div> : null}
|
|
86
|
+
</div>
|
|
87
|
+
{action ? <div className="ml-auto shrink-0 self-center pl-2">{action}</div> : null}
|
|
88
|
+
{closable ? (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={onClose}
|
|
92
|
+
aria-label={closeLabel}
|
|
93
|
+
className="-mr-1 -mt-1 shrink-0 rounded-md p-1 opacity-70 transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-2"
|
|
94
|
+
>
|
|
95
|
+
<X className="h-4 w-4" />
|
|
96
|
+
</button>
|
|
97
|
+
) : null}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
});
|