@linhey/rhine-design 0.10.0 → 0.11.1
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/CHANGELOG.md +24 -0
- package/CONTRIBUTING.md +3 -1
- package/README.md +17 -14
- package/components/AdvancedForms.tsx +13 -5
- package/components/COMMON_COMPONENTS.md +5 -3
- package/components/Forms.tsx +66 -6
- package/components/README.md +13 -5
- package/components/SPACING.md +9 -0
- package/components/Toast.tsx +10 -4
- package/components/common.css +4 -0
- package/components/components.tsx +13 -7
- package/components/styles.css +12 -0
- package/components/useFloatingScale.ts +7 -3
- package/docs/APPLICATION_COMPONENTS.md +2 -2
- package/docs/DESIGN_SYSTEM.md +10 -1
- package/docs/DESIGN_SYSTEM_AUDIT.md +4 -4
- package/docs/DISCLOSURE_VERIFICATION.md +49 -0
- package/docs/FLOATING_OVERLAYS.md +16 -0
- package/docs/INTERACTION_PATTERNS.md +9 -1
- package/docs/REVIEW_TOOL.md +128 -0
- package/docs/REVIEW_VERIFICATION.md +56 -0
- package/docs/SUMMARY_TABS.md +77 -0
- package/docs/THEMING.md +1 -1
- package/docs/assets/disclosure/arknights-400.png +0 -0
- package/docs/assets/disclosure/neo-brutalism-400.png +0 -0
- package/docs/assets/disclosure/rhine-400.png +0 -0
- package/docs/assets/disclosure/rhine-native200.png +0 -0
- package/examples/review/http/App.tsx +46 -0
- package/examples/review/http/catalog.tsx +44 -0
- package/examples/review/http/details.ts +1 -0
- package/examples/review/http/vite.config.ts +8 -0
- package/examples/review/platform/Workspace.tsx +34 -0
- package/examples/review/platform/catalog.tsx +46 -0
- package/examples/review/platform/vite.config.ts +8 -0
- package/examples/review/product.css +3 -0
- package/package.json +17 -5
- package/preview/CommonGallery.tsx +1 -1
- package/preview/DenseFilterControlsGallery.tsx +5 -2
- package/preview/GalleryShell.tsx +3 -2
- package/preview/OrbitGallery.tsx +1 -1
- package/preview/SpacingGallery.tsx +9 -1
- package/preview/SummaryTabsGallery.tsx +37 -0
- package/preview/main.tsx +7 -3
- package/preview/packageInfo.ts +5 -0
- package/review/canvas.tsx +107 -0
- package/review/dev-client.mjs +17 -0
- package/review/feedback.ts +17 -0
- package/review/index.ts +2 -0
- package/review/isolation.ts +64 -0
- package/review/lifecycle.ts +59 -0
- package/review/protocol.ts +7 -0
- package/review/review.css +41 -0
- package/review/shell.tsx +165 -0
- package/review/state.ts +41 -0
- package/review/types.ts +59 -0
- package/review/vite.d.mts +10 -0
- package/review/vite.mjs +107 -0
- package/scripts/check-combobox-modal-focus.js +22 -0
- package/scripts/check-combobox-zoom.js +109 -0
- package/scripts/check-multiselect-completion.js +46 -0
- package/scripts/check-review-package.mjs +11 -0
- package/scripts/check-review-recovery.js +47 -0
- package/scripts/check-review.js +153 -0
- package/skills/rhine-design/references/API_MAP.md +4 -0
- package/skills/rhine-design/references/TASK_MAP.md +2 -0
- package/tests/CommonComponents.test.tsx +8 -0
- package/tests/DesignSystem.test.tsx +32 -0
- package/tests/FormParity.test.tsx +104 -2
- package/tests/Review.test.tsx +117 -0
- package/tests/ReviewBuild.test.tsx +59 -0
- package/tests/ReviewShell.test.tsx +63 -0
- package/tsconfig.json +5 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ReviewContext, ReviewScene, ReviewSession } from './types';
|
|
2
|
+
|
|
3
|
+
export class ReviewLifecycle {
|
|
4
|
+
private generation = 0;
|
|
5
|
+
private active?: { scene: string; abort: AbortController; cleanups: (() => void | Promise<void>)[]; session?: ReviewSession; disposed: boolean; disposal?: Promise<void> };
|
|
6
|
+
constructor(private readonly hooks: {
|
|
7
|
+
clear(): void;
|
|
8
|
+
ready(session: ReviewSession, scene: ReviewScene): void;
|
|
9
|
+
error(error: unknown, scene?: string): void;
|
|
10
|
+
failed?(scene: string): void;
|
|
11
|
+
context(signal: AbortSignal, scene: ReviewScene): Pick<ReviewContext, 'mockHttp' | 'report'>;
|
|
12
|
+
}) {}
|
|
13
|
+
private cleanup(run: NonNullable<ReviewLifecycle['active']>, reset: boolean): Promise<void> {
|
|
14
|
+
if (run.disposal) return run.disposal;
|
|
15
|
+
run.disposed = true;
|
|
16
|
+
run.abort.abort();
|
|
17
|
+
const operations = [...run.cleanups.reverse()];
|
|
18
|
+
if (run.session) {
|
|
19
|
+
if (reset && run.session.reset) operations.unshift(() => run.session!.reset!());
|
|
20
|
+
operations.push(() => run.session!.dispose());
|
|
21
|
+
}
|
|
22
|
+
run.disposal = (async () => {
|
|
23
|
+
for (const operation of operations) {
|
|
24
|
+
try { await operation(); } catch (error) { this.hooks.error(error, run.scene); }
|
|
25
|
+
}
|
|
26
|
+
})();
|
|
27
|
+
return run.disposal;
|
|
28
|
+
}
|
|
29
|
+
async select(scene: ReviewScene, dimensions: Record<string, string>, reason: 'select' | 'reset' = 'select') {
|
|
30
|
+
const generation = ++this.generation;
|
|
31
|
+
const previous = this.active;
|
|
32
|
+
try { this.hooks.clear(); } catch (error) { this.hooks.error(error, previous?.scene); }
|
|
33
|
+
if (previous) await this.cleanup(previous, reason === 'reset');
|
|
34
|
+
if (generation !== this.generation) return;
|
|
35
|
+
const run = { scene: scene.id, abort: new AbortController(), cleanups: [] as (() => void | Promise<void>)[], session: undefined as ReviewSession | undefined, disposed: false };
|
|
36
|
+
this.active = run;
|
|
37
|
+
const context: ReviewContext = {
|
|
38
|
+
...this.hooks.context(run.abort.signal, scene), dimensions: Object.freeze({ ...dimensions }), signal: run.abort.signal, reason,
|
|
39
|
+
onDispose: cleanup => {
|
|
40
|
+
if (run.disposed) void Promise.resolve().then(cleanup).catch(error => this.hooks.error(error, scene.id));
|
|
41
|
+
else run.cleanups.push(cleanup);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
try {
|
|
45
|
+
const session = await scene.setup(context);
|
|
46
|
+
if (generation !== this.generation || run.disposed) { await session.dispose(); return; }
|
|
47
|
+
run.session = session;
|
|
48
|
+
this.hooks.ready(session, scene);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (!run.abort.signal.aborted) { this.hooks.error(error, scene.id); this.hooks.failed?.(scene.id); }
|
|
51
|
+
await this.cleanup(run, false);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async dispose() {
|
|
55
|
+
++this.generation;
|
|
56
|
+
try { this.hooks.clear(); } catch (error) { this.hooks.error(error, this.active?.scene); }
|
|
57
|
+
if (this.active) await this.cleanup(this.active, false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const protocol = 'rhine-review/2';
|
|
2
|
+
export function isReviewMessage(event: MessageEvent, source: Window | null): boolean {
|
|
3
|
+
return event.origin === location.origin && event.source === source && event.data?.protocol === protocol;
|
|
4
|
+
}
|
|
5
|
+
export function sendReview(target: Window, data: Record<string, unknown>) {
|
|
6
|
+
target.postMessage({ ...data, protocol }, location.origin);
|
|
7
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
html, body, #root { margin: 0; min-height: 100%; }
|
|
2
|
+
.rhine-review-shell { min-height: 100vh; background: var(--rhine-bg); color: var(--rhine-ink); }
|
|
3
|
+
.rhine-review-header { display: flex; gap: 12px; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(--rhine-line); flex-wrap: wrap; }
|
|
4
|
+
.rhine-review-brand { display: flex; align-items: center; gap: 12px; margin-right: auto; }
|
|
5
|
+
.rhine-review-brand strong { display: block; font-size: 16px; line-height: 24px; font-weight: 600; }
|
|
6
|
+
.rhine-review-brand small { display: block; font-size: 12px; line-height: 18px; font-weight: 400; opacity: .7; }
|
|
7
|
+
.rhine-review-menu { display: none; }
|
|
8
|
+
.rhine-review-body { display: grid; grid-template-columns: 232px minmax(0, 1fr); }
|
|
9
|
+
.rhine-review-directory { padding: 20px; border-right: 1px solid var(--rhine-line); }
|
|
10
|
+
.rhine-review-directory h2 { margin-top: 24px; font-size: 12px; line-height: 18px; font-weight: 600; opacity: .7; }
|
|
11
|
+
.rhine-review-directory a { display: block; padding: 10px 12px; color: inherit; text-decoration: none; font-size: 14px; line-height: 22px; font-weight: 400; overflow-wrap: anywhere; }
|
|
12
|
+
.rhine-review-directory a[aria-current] { background: var(--rhine-selected); font-weight: 600; box-shadow: inset 3px 0 var(--rhine-green); }
|
|
13
|
+
.rhine-review-main { min-width: 0; }
|
|
14
|
+
.rhine-review-controls { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; padding: 20px; }
|
|
15
|
+
.rhine-review-controls > .rhine-field { flex: 1 1 140px; min-width: 0; }
|
|
16
|
+
.rhine-review-feedback, .rhine-review-notes { padding: 12px 20px; font-size: 13px; line-height: 20px; font-weight: 400; overflow-wrap: anywhere; }
|
|
17
|
+
.rhine-review-feedback[role=alert] { border-left: 3px solid var(--rhine-error); }
|
|
18
|
+
.rhine-review-feedback p { margin: 0 0 8px; }
|
|
19
|
+
.rhine-review-feedback [role=alert] { border-left: 3px solid var(--rhine-error); padding-left: 10px; }
|
|
20
|
+
.rhine-review-options { margin: 0 20px; padding: 0 0 8px; font-size: 13px; line-height: 20px; font-weight: 400; }
|
|
21
|
+
.rhine-review-options summary, .rhine-review-log summary { cursor: pointer; padding: 10px 0; }
|
|
22
|
+
.rhine-review-options .rhine-review-controls { padding: 12px 0; }
|
|
23
|
+
.rhine-review-options p { color: var(--rhine-muted); margin: 0; }
|
|
24
|
+
.rhine-review-recovery { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin: 12px 0; }
|
|
25
|
+
.rhine-review-log ol { max-height: 240px; overflow: auto; padding-left: 24px; }
|
|
26
|
+
.rhine-review-log li { padding: 8px 0; }
|
|
27
|
+
.rhine-review-log li strong, .rhine-review-log li span { display: block; }
|
|
28
|
+
.rhine-review-log li[data-kind=error] { color: var(--rhine-error); }
|
|
29
|
+
.rhine-review-size { margin: 0; padding: 8px 20px; color: var(--rhine-muted); font-size: 12px; line-height: 18px; font-weight: 400; }
|
|
30
|
+
.rhine-review-viewport { overflow: auto; padding: 16px; background: var(--rhine-surface); }
|
|
31
|
+
.rhine-review-viewport iframe { display: block; border: 1px solid var(--rhine-line); min-height: 650px; height: 72vh; margin: 0 auto; background: var(--rhine-bg); box-sizing: border-box; }
|
|
32
|
+
.rhine-review-notes summary { cursor: pointer; min-height: 32px; }
|
|
33
|
+
[data-rhine-motion=paused] *, [data-rhine-motion=paused] *::before, [data-rhine-motion=paused] *::after { animation-play-state: paused !important; }
|
|
34
|
+
@media (max-width: 760px) {
|
|
35
|
+
.rhine-review-menu { display: inline-flex; }
|
|
36
|
+
.rhine-review-body { grid-template-columns: minmax(0, 1fr); }
|
|
37
|
+
.rhine-review-directory { display: none; border-bottom: 1px solid var(--rhine-line); }
|
|
38
|
+
.rhine-review-directory[data-open=true] { display: block; }
|
|
39
|
+
.rhine-review-header { padding: 12px; }
|
|
40
|
+
.rhine-review-controls { padding: 12px; }
|
|
41
|
+
}
|
package/review/shell.tsx
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { Button, Logo, Select, TextField } from '../components';
|
|
4
|
+
import { ThemeProvider, themes } from '../components/theme';
|
|
5
|
+
import { defaultWidths, parseReviewState, serializeReviewState } from './state';
|
|
6
|
+
import { isReviewMessage, sendReview } from './protocol';
|
|
7
|
+
import { appendFeedback, emptyFeedback, type ReviewFeedback } from './feedback';
|
|
8
|
+
import type { ReviewCatalogInfo, ReviewSelection } from './types';
|
|
9
|
+
import './review.css';
|
|
10
|
+
|
|
11
|
+
type Phase = 'connecting' | 'loading' | 'ready' | 'failed' | 'timed-out';
|
|
12
|
+
export const reviewWaitMs = 10_000;
|
|
13
|
+
|
|
14
|
+
export function ReviewShell() {
|
|
15
|
+
const frame = useRef<HTMLIFrameElement>(null);
|
|
16
|
+
const [frameEpoch, setFrameEpoch] = useState(0);
|
|
17
|
+
const [catalog, setCatalog] = useState<ReviewCatalogInfo>();
|
|
18
|
+
const [state, setState] = useState<ReviewSelection>();
|
|
19
|
+
const [query, setQuery] = useState('');
|
|
20
|
+
const [log, setLog] = useState(emptyFeedback);
|
|
21
|
+
const [phase, setPhase] = useState<Phase>('connecting');
|
|
22
|
+
const [menu, setMenu] = useState(false);
|
|
23
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
24
|
+
const stateRef = useRef(state);
|
|
25
|
+
const connected = useRef(false);
|
|
26
|
+
const sequence = useRef(0);
|
|
27
|
+
const pendingRequest = useRef(0);
|
|
28
|
+
const feedbackSequence = useRef(0);
|
|
29
|
+
const timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
30
|
+
stateRef.current = state;
|
|
31
|
+
const record = useCallback((message: string, kind: ReviewFeedback['kind'] = 'info', scene = '画布') => {
|
|
32
|
+
const entry = { id: ++feedbackSequence.current, message, kind, scene };
|
|
33
|
+
setLog(previous => appendFeedback(previous, entry));
|
|
34
|
+
}, []);
|
|
35
|
+
const stopWaiting = useCallback(() => { clearTimeout(timeout.current); timeout.current = undefined; }, []);
|
|
36
|
+
const waitFor = useCallback((next: 'connecting' | 'loading') => {
|
|
37
|
+
stopWaiting(); setPhase(next);
|
|
38
|
+
timeout.current = setTimeout(() => {
|
|
39
|
+
setPhase('timed-out');
|
|
40
|
+
record(next === 'connecting' ? '连接画布超时,请检查资源加载后重新载入画布。' : '场景初始化或清理超过 10 秒,尚未确认完成。可继续等待,或重新载入画布。', 'error', next === 'connecting' ? '画布' : stateRef.current?.scene);
|
|
41
|
+
}, reviewWaitMs);
|
|
42
|
+
}, [record, stopWaiting]);
|
|
43
|
+
const select = useCallback((selection: ReviewSelection, reset = false) => {
|
|
44
|
+
if (!connected.current || !frame.current?.contentWindow) return;
|
|
45
|
+
pendingRequest.current = ++sequence.current;
|
|
46
|
+
waitFor('loading');
|
|
47
|
+
sendReview(frame.current.contentWindow, { type: 'select', hash: serializeReviewState(selection), requestId: pendingRequest.current, reset });
|
|
48
|
+
}, [waitFor]);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
connected.current = false; pendingRequest.current = 0;
|
|
51
|
+
waitFor('connecting');
|
|
52
|
+
const receive = (event: MessageEvent) => {
|
|
53
|
+
if (!isReviewMessage(event, frame.current?.contentWindow ?? null)) return;
|
|
54
|
+
if (event.data.type === 'ready') {
|
|
55
|
+
// Repeated hello acknowledgements must not restart a pending initialization.
|
|
56
|
+
if (connected.current) return;
|
|
57
|
+
connected.current = true;
|
|
58
|
+
const next = event.data.catalog as ReviewCatalogInfo;
|
|
59
|
+
setCatalog(next);
|
|
60
|
+
const selection = parseReviewState(location.hash, next);
|
|
61
|
+
history.replaceState(null, '', serializeReviewState(selection));
|
|
62
|
+
setState(selection);
|
|
63
|
+
document.title = `${next.title} · Rhine Review`;
|
|
64
|
+
} else if (event.data.type === 'feedback' && typeof event.data.message === 'string') {
|
|
65
|
+
record(event.data.message, event.data.kind === 'error' ? 'error' : 'info', typeof event.data.scene === 'string' ? event.data.scene : '画布');
|
|
66
|
+
} else if ((event.data.type === 'rendered' || event.data.type === 'failed') && event.data.requestId === pendingRequest.current) {
|
|
67
|
+
stopWaiting(); setPhase(event.data.type === 'rendered' ? 'ready' : 'failed');
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
window.addEventListener('message', receive);
|
|
71
|
+
const handshake = setInterval(() => {
|
|
72
|
+
if (!connected.current && frame.current?.contentWindow) sendReview(frame.current.contentWindow, { type: 'hello' });
|
|
73
|
+
}, 500);
|
|
74
|
+
return () => { window.removeEventListener('message', receive); clearInterval(handshake); stopWaiting(); };
|
|
75
|
+
}, [frameEpoch, record, stopWaiting, waitFor]);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!catalog) return;
|
|
78
|
+
const navigate = () => {
|
|
79
|
+
const next = parseReviewState(location.hash, catalog);
|
|
80
|
+
history.replaceState(null, '', serializeReviewState(next)); setState(next);
|
|
81
|
+
};
|
|
82
|
+
window.addEventListener('popstate', navigate); window.addEventListener('hashchange', navigate);
|
|
83
|
+
return () => { window.removeEventListener('popstate', navigate); window.removeEventListener('hashchange', navigate); };
|
|
84
|
+
}, [catalog]);
|
|
85
|
+
useEffect(() => { if (state) select(state); }, [state, select]);
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const element = frame.current;
|
|
88
|
+
if (!element) return;
|
|
89
|
+
const measure = () => setSize({ width: element.clientWidth, height: element.clientHeight });
|
|
90
|
+
measure();
|
|
91
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
92
|
+
const observer = new ResizeObserver(measure); observer.observe(element);
|
|
93
|
+
return () => observer.disconnect();
|
|
94
|
+
}, [frameEpoch]);
|
|
95
|
+
const update = (patch: Partial<ReviewSelection>) => {
|
|
96
|
+
if (!state || !catalog) return;
|
|
97
|
+
const next = parseReviewState(serializeReviewState({ ...state, ...patch }), catalog);
|
|
98
|
+
history.pushState(null, '', serializeReviewState(next)); setState(next);
|
|
99
|
+
};
|
|
100
|
+
const reload = () => {
|
|
101
|
+
// A new browsing context terminates noncooperative code; never start new setup in a stuck context.
|
|
102
|
+
stopWaiting(); connected.current = false;
|
|
103
|
+
record('已重新载入画布;旧会话与未保存草稿已清空,评审条件保留。', 'info', state?.scene);
|
|
104
|
+
setFrameEpoch(value => value + 1);
|
|
105
|
+
};
|
|
106
|
+
const scene = catalog?.scenes.find(item => item.id === state?.scene);
|
|
107
|
+
const matching = catalog?.scenes.filter(item => `${item.title} ${item.group ?? ''} ${item.id}`.toLocaleLowerCase().includes(query.toLocaleLowerCase())) ?? [];
|
|
108
|
+
const trouble = phase === 'failed' || phase === 'timed-out';
|
|
109
|
+
const lastError = [...log.entries].reverse().find(entry => entry.kind === 'error');
|
|
110
|
+
const phaseLabel = { connecting: '正在连接场景画布…', loading: '正在初始化或清理合成场景…', ready: '合成场景已就绪 · 无业务后端', failed: '场景未能完成,请查看反馈或重新载入画布。', 'timed-out': '画布响应较慢,尚未确认完成。' }[phase];
|
|
111
|
+
return <ThemeProvider theme={state?.theme ?? 'rhine'} className="rhine-review-shell">
|
|
112
|
+
<header className="rhine-review-header">
|
|
113
|
+
<div className="rhine-review-brand"><Logo size={28} /><div><strong>{catalog?.title ?? 'Rhine Review'}</strong><small>产品设计评审 · 合成数据</small></div></div>
|
|
114
|
+
<Button id="review-menu" className="rhine-review-menu" variant="secondary" aria-expanded={menu} aria-controls="review-directory" onClick={() => setMenu(!menu)}>场景目录</Button>
|
|
115
|
+
<Button variant="secondary" disabled={!state} onClick={async () => {
|
|
116
|
+
try { await navigator.clipboard.writeText(location.href); record('评审链接已复制'); }
|
|
117
|
+
catch { record('复制未完成,请复制浏览器地址', 'error'); }
|
|
118
|
+
}}>复制链接</Button>
|
|
119
|
+
</header>
|
|
120
|
+
<div className="rhine-review-body">
|
|
121
|
+
<aside id="review-directory" className="rhine-review-directory" data-open={menu} onKeyDown={event => {
|
|
122
|
+
if (event.key === 'Escape' && menu) { setMenu(false); document.getElementById('review-menu')?.focus(); }
|
|
123
|
+
}}>
|
|
124
|
+
<TextField label="搜索场景" value={query} onChange={event => setQuery(event.target.value)} />
|
|
125
|
+
<nav aria-label="评审场景">{[...new Set(matching.map(item => item.group ?? '场景'))].map(group => <section key={group}><h2>{group}</h2>{matching.filter(item => (item.group ?? '场景') === group).map(item => <a key={item.id} aria-current={item.id === scene?.id ? 'page' : undefined} href={state ? serializeReviewState({ ...state, scene: item.id, dimensions: {} }) : '#'} onClick={event => { event.preventDefault(); update({ scene: item.id, dimensions: {} }); setMenu(false); if (menu) document.getElementById('review-menu')?.focus(); }}>{item.title}</a>)}</section>)}</nav>
|
|
126
|
+
{catalog && !matching.length && <p>没有匹配场景</p>}
|
|
127
|
+
</aside>
|
|
128
|
+
<main className="rhine-review-main">
|
|
129
|
+
{state && <>
|
|
130
|
+
<div className="rhine-review-controls" aria-label="评审设置">
|
|
131
|
+
<Select label="界面主题" value={state.theme} onChange={event => update({ theme: event.target.value as ReviewSelection['theme'] })}>{themes.map(theme => <option key={theme.name} value={theme.name}>{theme.label}</option>)}</Select>
|
|
132
|
+
<Select label="画布宽度" value={state.width} onChange={event => update({ width: event.target.value === 'fit' ? 'fit' : Number(event.target.value) })}><option value="fit">适应容器</option>{(catalog?.widths ?? defaultWidths).map(width => <option key={width} value={width}>{width} px</option>)}</Select>
|
|
133
|
+
{scene?.dimensions?.map(dimension => <Select key={dimension.id} label={dimension.label} value={state.dimensions[dimension.id]} onChange={event => update({ dimensions: { ...state.dimensions, [dimension.id]: event.target.value } })}>{dimension.options.map(option => <option key={option.id} value={option.id}>{option.label}</option>)}</Select>)}
|
|
134
|
+
<Button variant="secondary" onClick={() => select(state, true)}>重置场景</Button>
|
|
135
|
+
</div>
|
|
136
|
+
<details className="rhine-review-options"><summary>更多设置 · {state.zoom * 100}% CSS zoom · {{ system: '跟随系统', full: '完整动态', reduced: '减少动态', paused: '暂停' }[state.motion]}</summary>
|
|
137
|
+
<div className="rhine-review-controls">
|
|
138
|
+
<Select label="CSS 缩放" value={state.zoom} onChange={event => update({ zoom: Number(event.target.value) as 1 | 2 })}><option value="1">100%</option><option value="2">200%(CSS zoom)</option></Select>
|
|
139
|
+
<Select label="动态偏好" value={state.motion} onChange={event => update({ motion: event.target.value as ReviewSelection['motion'] })}><option value="system">跟随系统</option>{scene?.motionControls?.includes('full') && <option value="full">完整动态</option>}<option value="reduced">减少动态</option>{scene?.motionControls?.includes('paused') && <option value="paused">暂停</option>}</Select>
|
|
140
|
+
{!trouble && <Button variant="secondary" onClick={reload}>重新载入画布</Button>}
|
|
141
|
+
</div><p>重新载入会清空未保存草稿,并保留当前评审条件。</p>
|
|
142
|
+
</details>
|
|
143
|
+
</>}
|
|
144
|
+
<div className="rhine-review-feedback">
|
|
145
|
+
<p role="status">{phaseLabel}</p>
|
|
146
|
+
{trouble && <div className="rhine-review-recovery"><Button variant="secondary" onClick={reload}>重新载入画布</Button><span>清空未保存草稿,保留当前评审条件。</span></div>}
|
|
147
|
+
{log.errors > 0 && <p role="alert">{log.errors} 条错误待确认{lastError ? ` · ${lastError.message}` : ''}</p>}
|
|
148
|
+
{log.entries.length > 0 && <details className="rhine-review-log"><summary>评审反馈 · {log.entries.length} 条记录{log.errors ? ` · ${log.errors} 条错误待确认` : ''}</summary>
|
|
149
|
+
<ol>{log.entries.map(entry => <li key={entry.id} data-kind={entry.kind}><strong>{catalog?.scenes.find(item => item.id === entry.scene)?.title ?? entry.scene}</strong><span>{entry.message}</span></li>)}</ol>
|
|
150
|
+
{log.omittedErrors > 0 && <p>另有 {log.omittedErrors} 条较早错误未展示,仍计入待确认数量。</p>}
|
|
151
|
+
<Button variant="secondary" onClick={() => setLog(emptyFeedback())}>确认并清除反馈</Button>
|
|
152
|
+
</details>}
|
|
153
|
+
</div>
|
|
154
|
+
<p className="rhine-review-size" aria-label="画布尺寸">画布 {size.width} × {size.height} px · {(state?.zoom ?? 1) * 100}% CSS zoom · 等效内容宽度约 {Math.round(size.width / (state?.zoom ?? 1))} CSS px</p>
|
|
155
|
+
<div className="rhine-review-viewport">
|
|
156
|
+
<iframe key={frameEpoch} ref={frame} title="产品场景画布" src={new URL('canvas.html', location.href).href} sandbox="allow-scripts allow-same-origin allow-forms" referrerPolicy="no-referrer" onLoad={() => {
|
|
157
|
+
if (frame.current?.contentWindow) sendReview(frame.current.contentWindow, { type: 'hello' });
|
|
158
|
+
}} style={{ width: state?.width === 'fit' || !state ? '100%' : state.width }} />
|
|
159
|
+
</div>
|
|
160
|
+
{scene && <details className="rhine-review-notes"><summary>场景说明与验证状态</summary><p>{scene.description ?? '此场景使用产品提供的合成数据。'}</p>{scene.source && <p>源码:<code>{scene.source}</code></p>}{scene.issue && /^https:\/\//.test(scene.issue) && <a href={scene.issue} target="_blank" rel="noreferrer">关联 issue</a>}<p>{scene.validation ?? '验证状态由产品维护;可交互预览不等于生产或原生验收。'}</p></details>}
|
|
161
|
+
</main>
|
|
162
|
+
</div>
|
|
163
|
+
</ThemeProvider>;
|
|
164
|
+
}
|
|
165
|
+
export function mountReviewShell() { createRoot(document.getElementById('root')!).render(<ReviewShell />); }
|
package/review/state.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { themes } from '../components/themeMetadata';
|
|
2
|
+
import type { ReviewCatalog, ReviewCatalogInfo, ReviewSelection } from './types';
|
|
3
|
+
|
|
4
|
+
export const defaultWidths = [400, 520, 880, 1280] as const;
|
|
5
|
+
const validId = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
6
|
+
export function defineReview(catalog: ReviewCatalog): ReviewCatalog {
|
|
7
|
+
if (!catalog.scenes.length) throw new Error('Review needs at least one scene.');
|
|
8
|
+
const unique = (ids: string[]) => ids.every(id => validId.test(id)) && new Set(ids).size === ids.length;
|
|
9
|
+
if (!unique(catalog.scenes.map(scene => scene.id))) throw new Error('Scene IDs must be unique stable identifiers.');
|
|
10
|
+
for (const scene of catalog.scenes) {
|
|
11
|
+
if (!unique((scene.dimensions ?? []).map(d => d.id))) throw new Error(`Invalid dimensions: ${scene.id}`);
|
|
12
|
+
for (const dimension of scene.dimensions ?? []) {
|
|
13
|
+
if (!dimension.options.length || !unique(dimension.options.map(o => o.id))) throw new Error(`Invalid options: ${dimension.id}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (catalog.widths && (!catalog.widths.length || catalog.widths.some(w => !Number.isInteger(w) || w < 240 || w > 3840))) throw new Error('Widths must be integers between 240 and 3840.');
|
|
17
|
+
return catalog;
|
|
18
|
+
}
|
|
19
|
+
export function catalogInfo(catalog: ReviewCatalog): ReviewCatalogInfo {
|
|
20
|
+
return { title: catalog.title, widths: catalog.widths, scenes: catalog.scenes.map(({ setup: _setup, ...info }) => info) };
|
|
21
|
+
}
|
|
22
|
+
export function parseReviewState(hash: string, catalog: ReviewCatalogInfo): ReviewSelection {
|
|
23
|
+
const params = new URLSearchParams(hash.replace(/^#/, ''));
|
|
24
|
+
const scene = catalog.scenes.find(s => s.id === params.get('scene')) ?? catalog.scenes[0];
|
|
25
|
+
if (!scene) throw new Error('Empty review catalog.');
|
|
26
|
+
const width = Number(params.get('width'));
|
|
27
|
+
const motion = params.get('motion');
|
|
28
|
+
return {
|
|
29
|
+
scene: scene.id,
|
|
30
|
+
dimensions: Object.fromEntries((scene.dimensions ?? []).map(d => [d.id, d.options.find(o => o.id === params.get(`d.${d.id}`))?.id ?? d.options[0].id])),
|
|
31
|
+
theme: themes.find(t => t.name === params.get('theme'))?.name ?? 'rhine',
|
|
32
|
+
width: (catalog.widths ?? defaultWidths).includes(width) ? width : 'fit',
|
|
33
|
+
zoom: params.get('zoom') === '2' ? 2 : 1,
|
|
34
|
+
motion: motion === 'reduced' ? motion : (motion === 'full' || motion === 'paused') && scene.motionControls?.includes(motion) ? motion : 'system',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function serializeReviewState(state: ReviewSelection): string {
|
|
38
|
+
const params = new URLSearchParams({ scene: state.scene, theme: state.theme, width: String(state.width), zoom: String(state.zoom), motion: state.motion });
|
|
39
|
+
for (const key of Object.keys(state.dimensions).sort()) params.set(`d.${key}`, state.dimensions[key]);
|
|
40
|
+
return `#${params}`;
|
|
41
|
+
}
|
package/review/types.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { ThemeName } from '../components/themeMetadata';
|
|
3
|
+
|
|
4
|
+
export type ReviewMotion = 'system' | 'full' | 'reduced' | 'paused';
|
|
5
|
+
export interface ReviewEnvironment {
|
|
6
|
+
theme: ThemeName;
|
|
7
|
+
motion: ReviewMotion;
|
|
8
|
+
reducedMotion: boolean;
|
|
9
|
+
paused: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface ReviewSelection {
|
|
12
|
+
scene: string;
|
|
13
|
+
dimensions: Record<string, string>;
|
|
14
|
+
theme: ThemeName;
|
|
15
|
+
width: number | 'fit';
|
|
16
|
+
zoom: 1 | 2;
|
|
17
|
+
motion: ReviewMotion;
|
|
18
|
+
}
|
|
19
|
+
export interface ReviewOption { id: string; label: string }
|
|
20
|
+
export interface ReviewDimension { id: string; label: string; options: readonly ReviewOption[] }
|
|
21
|
+
export interface ReviewSceneInfo {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
group?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
source?: string;
|
|
27
|
+
issue?: string;
|
|
28
|
+
validation?: string;
|
|
29
|
+
/** Host apps with an existing ThemeProvider own their single theme root. */
|
|
30
|
+
themeRoot?: 'runtime' | 'host';
|
|
31
|
+
/** Opt in only if every scene animation handles the corresponding environment flag. */
|
|
32
|
+
motionControls?: readonly ('full' | 'paused')[];
|
|
33
|
+
dimensions?: readonly ReviewDimension[];
|
|
34
|
+
}
|
|
35
|
+
export interface ReviewContext {
|
|
36
|
+
dimensions: Readonly<Record<string, string>>;
|
|
37
|
+
signal: AbortSignal;
|
|
38
|
+
reason: 'select' | 'reset';
|
|
39
|
+
/** Register immediately, including resources acquired before async setup finishes. */
|
|
40
|
+
onDispose(cleanup: () => void | Promise<void>): void;
|
|
41
|
+
/** Only synthetic requests are dispatched here. Returning undefined explicitly fails. */
|
|
42
|
+
mockHttp(handler: (request: Request) => Response | undefined | Promise<Response | undefined>): void;
|
|
43
|
+
report(message: string, kind?: 'info' | 'error'): void;
|
|
44
|
+
}
|
|
45
|
+
export interface ReviewSession {
|
|
46
|
+
render(environment: ReviewEnvironment): ReactNode;
|
|
47
|
+
/** Explicit reset notification; a fresh setup always follows disposal. */
|
|
48
|
+
reset?(): void | Promise<void>;
|
|
49
|
+
dispose(): void | Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export interface ReviewScene extends ReviewSceneInfo {
|
|
52
|
+
setup(context: ReviewContext): ReviewSession | Promise<ReviewSession>;
|
|
53
|
+
}
|
|
54
|
+
export interface ReviewCatalog {
|
|
55
|
+
title: string;
|
|
56
|
+
widths?: readonly number[];
|
|
57
|
+
scenes: readonly ReviewScene[];
|
|
58
|
+
}
|
|
59
|
+
export type ReviewCatalogInfo = Omit<ReviewCatalog, 'scenes'> & { scenes: readonly ReviewSceneInfo[] };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
export interface ReviewViteOptions {
|
|
3
|
+
/** Catalog module relative to the dedicated Vite root. */
|
|
4
|
+
catalog: string;
|
|
5
|
+
/** Product router base, separate from static asset base. */
|
|
6
|
+
routerBase?: string;
|
|
7
|
+
}
|
|
8
|
+
export function reviewPlugin(options: ReviewViteOptions): Plugin;
|
|
9
|
+
/** Forbidden paths are exact files/directories relative to the production Vite root. */
|
|
10
|
+
export function reviewProductionGuard(options?: { forbidden?: readonly string[] }): Plugin;
|
package/review/vite.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { resolve, dirname, relative } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
const directory = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const ids = { shell: '\0rhine-review:shell', canvas: '\0rhine-review:canvas' };
|
|
7
|
+
const csp = "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' blob:; connect-src 'none'; frame-src 'self'; worker-src 'none'; object-src 'none'; form-action 'none'; base-uri 'self'";
|
|
8
|
+
/** @param {string} script @param {string[]} styles */
|
|
9
|
+
function html(script, styles = []) {
|
|
10
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${csp}"><link rel="icon" href="data:,"><title>Rhine Review</title>${styles.map(style => `<link rel="stylesheet" href="${style}">`).join('')}</head><body><div id="root"></div><script type="module" src="${script}"></script></body></html>`;
|
|
11
|
+
}
|
|
12
|
+
/** Use in a dedicated config, never merge a production config containing secrets/proxies. */
|
|
13
|
+
/** @param {import('./vite.mjs').ReviewViteOptions} options @returns {import('vite').Plugin} */
|
|
14
|
+
export function reviewPlugin(options) {
|
|
15
|
+
/** @type {import('vite').ResolvedConfig} */
|
|
16
|
+
let config;
|
|
17
|
+
return {
|
|
18
|
+
name: 'rhine-review',
|
|
19
|
+
enforce: 'pre',
|
|
20
|
+
config(userConfig) {
|
|
21
|
+
const root = userConfig.root ?? process.cwd();
|
|
22
|
+
return {
|
|
23
|
+
appType: 'mpa', envDir: false, publicDir: false, envPrefix: 'RHINE_REVIEW_PUBLIC_',
|
|
24
|
+
server: { hmr: false },
|
|
25
|
+
cacheDir: resolve(root, 'node_modules/.vite-review'),
|
|
26
|
+
optimizeDeps: { entries: [resolve(directory, 'shell.tsx'), resolve(directory, 'canvas.tsx'), resolve(root, options.catalog)] },
|
|
27
|
+
resolve: { dedupe: ['react', 'react-dom'] },
|
|
28
|
+
define: options.routerBase ? { 'import.meta.env.BASE_URL': JSON.stringify(options.routerBase) } : {},
|
|
29
|
+
build: { cssCodeSplit: false, rollupOptions: { input: { review: ids.shell, canvas: ids.canvas } } },
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
configResolved(resolved) {
|
|
33
|
+
config = resolved;
|
|
34
|
+
if (resolved.envDir !== false || resolved.publicDir !== '' || Object.keys(resolved.server.proxy ?? {}).length || Object.keys(resolved.preview.proxy ?? {}).length) {
|
|
35
|
+
throw new Error('Review requires envDir:false, publicDir:false and no server/preview proxies.');
|
|
36
|
+
}
|
|
37
|
+
if (resolved.build.outDir === '.' || resolve(resolved.root, resolved.build.outDir) === resolved.root)
|
|
38
|
+
throw new Error('Review output must use a separate directory.');
|
|
39
|
+
},
|
|
40
|
+
resolveId(id) { if (Object.values(ids).includes(id))
|
|
41
|
+
return id; },
|
|
42
|
+
load(id) {
|
|
43
|
+
if (id === ids.shell)
|
|
44
|
+
return `import {mountReviewShell} from ${JSON.stringify(resolve(directory, 'shell.tsx'))}; mountReviewShell();`;
|
|
45
|
+
if (id === ids.canvas)
|
|
46
|
+
return `import {mountReviewCanvas} from ${JSON.stringify(resolve(directory, 'canvas.tsx'))}; mountReviewCanvas(() => import(${JSON.stringify(resolve(config.root, options.catalog))}));`;
|
|
47
|
+
},
|
|
48
|
+
configureServer(server) {
|
|
49
|
+
server.middlewares.use(async (request, response, next) => {
|
|
50
|
+
const requested = (request.url ?? '/').split('?')[0];
|
|
51
|
+
const pathname = config.base !== './' && requested.startsWith(config.base) ? `/${requested.slice(config.base.length)}` : requested;
|
|
52
|
+
if (pathname === '/@vite/client') {
|
|
53
|
+
response.setHeader('Content-Type', 'text/javascript');
|
|
54
|
+
response.end(readFileSync(resolve(directory, 'dev-client.mjs'), 'utf8'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!['/', '/index.html', '/canvas.html'].includes(pathname)) {
|
|
58
|
+
next();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const entry = pathname === '/canvas.html' ? ids.canvas : ids.shell;
|
|
62
|
+
try {
|
|
63
|
+
let document = await server.transformIndexHtml(pathname, html(`/@id/${entry.replace('\0', '__x00__')}`));
|
|
64
|
+
document = document.replace(/<script type="module" src="[^\"]*\/@vite\/client"><\/script>/g, '');
|
|
65
|
+
response.statusCode = 200;
|
|
66
|
+
response.setHeader('Content-Type', 'text/html');
|
|
67
|
+
response.end(document);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
next(error);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
generateBundle: { order: 'post', handler(_options, bundle) {
|
|
75
|
+
const assetBase = config.base === './' || config.base === '' ? './' : config.base;
|
|
76
|
+
const styles = Object.values(bundle).filter(asset => asset.type === 'asset' && asset.fileName.endsWith('.css')).map(asset => `${assetBase}${asset.fileName}`);
|
|
77
|
+
for (const [name, entryId] of Object.entries(ids)) {
|
|
78
|
+
const entry = Object.values(bundle).find(asset => asset.type === 'chunk' && asset.isEntry && asset.facadeModuleId === entryId);
|
|
79
|
+
if (!entry)
|
|
80
|
+
this.error(`Missing review entry: ${name}`);
|
|
81
|
+
this.emitFile({ type: 'asset', fileName: name === 'shell' ? 'index.html' : 'canvas.html', source: html(`${assetBase}${entry.fileName}`, styles) });
|
|
82
|
+
}
|
|
83
|
+
} },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Add to the actual production build. A build fails if fixtures or review code are imported. */
|
|
87
|
+
/** @param {{forbidden?: readonly string[]}} options @returns {import('vite').Plugin} */
|
|
88
|
+
export function reviewProductionGuard(options = {}) {
|
|
89
|
+
/** @type {string} */
|
|
90
|
+
let root;
|
|
91
|
+
return {
|
|
92
|
+
name: 'rhine-review-production-guard', apply: 'build',
|
|
93
|
+
configResolved(config) { root = config.root; },
|
|
94
|
+
generateBundle(_options, bundle) {
|
|
95
|
+
const forbidden = [directory, ...(options.forbidden ?? []).map(path => resolve(root, path))].map(path => path.replaceAll('\\', '/'));
|
|
96
|
+
for (const chunk of Object.values(bundle)) {
|
|
97
|
+
if (chunk.type !== 'chunk')
|
|
98
|
+
continue;
|
|
99
|
+
for (const module of Object.keys(chunk.modules)) {
|
|
100
|
+
const normalized = module.replaceAll('\\', '/').split('?')[0];
|
|
101
|
+
if (normalized.includes('rhine-review:') || forbidden.some(path => normalized === path || normalized.startsWith(`${path}/`)))
|
|
102
|
+
this.error(`Production bundle imports review runtime or forbidden fixtures: ${relative(root, normalized)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Regression for the interval between a nested popup closing and its modal scope resuming.
|
|
2
|
+
async page => {
|
|
3
|
+
const origin = new URL(page.url()).origin, cases = [], errors = [];
|
|
4
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
5
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) for (const width of [400, 880]) for (const zoom of [1, 2]) {
|
|
6
|
+
await page.setViewportSize({ width, height: 960 });
|
|
7
|
+
await page.goto(`${origin}/?theme=${theme}#compact-filters`); await page.reload();
|
|
8
|
+
await page.evaluate(value => { document.documentElement.style.zoom = String(value); }, zoom);
|
|
9
|
+
await page.getByRole('button', { name: '在弹窗内选择', exact: true }).click();
|
|
10
|
+
const input = page.getByRole('combobox', { name: '弹窗部门', exact: true });
|
|
11
|
+
await input.click(); await input.press('Shift+Tab');
|
|
12
|
+
await page.keyboard.press('Shift+Tab');
|
|
13
|
+
const immediateInside = await page.getByRole('dialog').evaluate(element => element.contains(document.activeElement));
|
|
14
|
+
// Assert the fast path before waiting; then separately verify normal navigation resumes.
|
|
15
|
+
await page.waitForTimeout(50);
|
|
16
|
+
const close = page.getByRole('button', { name: '关闭对话框', exact: true });
|
|
17
|
+
await close.focus(); await page.keyboard.press('Shift+Tab');
|
|
18
|
+
const resumed = await page.getByRole('dialog').evaluate(element => element.contains(document.activeElement) && document.activeElement?.getAttribute('aria-label') !== '关闭对话框');
|
|
19
|
+
cases.push({ theme, width, zoom, immediateInside, resumed });
|
|
20
|
+
}
|
|
21
|
+
return { pass: cases.every(item => item.immediateInside && item.resumed) && !errors.length, cases, errors };
|
|
22
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Open the built gallery, then run with Playwright CLI run-code --filename.
|
|
2
|
+
async page => {
|
|
3
|
+
const origin = new URL(page.url()).origin;
|
|
4
|
+
page.setDefaultTimeout(10000);
|
|
5
|
+
const failures = [], errors = [], cases = [];
|
|
6
|
+
page.on('pageerror', error => errors.push(String(error)));
|
|
7
|
+
const bounds = locator => locator.evaluate(element => {
|
|
8
|
+
const r = element.getBoundingClientRect();
|
|
9
|
+
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: innerWidth, height: innerHeight };
|
|
10
|
+
});
|
|
11
|
+
const contained = r => r.left >= -1 && r.top >= -1 && r.right <= r.width + 1 && r.bottom <= r.height + 1;
|
|
12
|
+
for (const theme of ['rhine', 'arknights', 'neo-brutalism']) {
|
|
13
|
+
for (const width of [400, 520, 880]) for (const zoom of [1, 2]) for (const modal of [false, true]) {
|
|
14
|
+
const spec = { theme, width, zoom, modal };
|
|
15
|
+
await page.setViewportSize({ width, height: 960 });
|
|
16
|
+
await page.goto(`${origin}/?theme=${theme}#compact-filters`);
|
|
17
|
+
await page.reload();
|
|
18
|
+
await page.getByRole('heading', { name: '密集筛选控件', exact: true }).waitFor();
|
|
19
|
+
await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom); }, zoom);
|
|
20
|
+
if (modal) await page.getByRole('button', { name: '在弹窗内选择', exact: true }).click();
|
|
21
|
+
const label = modal ? '弹窗部门' : '部门';
|
|
22
|
+
const input = page.getByRole('combobox', { name: label, exact: true });
|
|
23
|
+
await input.fill('合成部门 99');
|
|
24
|
+
const list = page.getByRole('listbox', { name: label, exact: true });
|
|
25
|
+
await list.getByRole('option', { name: '合成部门 99', exact: true }).waitFor();
|
|
26
|
+
await page.waitForTimeout(200);
|
|
27
|
+
// Native Tab targets differ with browser/platform keyboard preferences.
|
|
28
|
+
// Compare the exit with the same input's ordinary closed-popup Tab path.
|
|
29
|
+
await input.press('Escape'); await input.press('Tab');
|
|
30
|
+
const naturalNext = await page.evaluateHandle(() => document.activeElement);
|
|
31
|
+
await input.click(); await list.waitFor();
|
|
32
|
+
const popup = list.locator('..');
|
|
33
|
+
const completion = popup.getByRole('button', { name: '完成选择', exact: true });
|
|
34
|
+
await input.press('Tab');
|
|
35
|
+
const tabIn = await completion.evaluate(element => element === document.activeElement);
|
|
36
|
+
await completion.press('Shift+Tab');
|
|
37
|
+
const tabBack = await input.evaluate(element => element === document.activeElement);
|
|
38
|
+
await input.press('Tab'); await completion.press('Tab');
|
|
39
|
+
await list.waitFor({ state: 'detached' });
|
|
40
|
+
const tabOut = await page.waitForFunction(element => element === document.activeElement, naturalNext, { timeout: 2000 }).then(() => true, () => false);
|
|
41
|
+
await naturalNext.dispose();
|
|
42
|
+
await input.click(); await list.waitFor();
|
|
43
|
+
|
|
44
|
+
const rect = await bounds(popup);
|
|
45
|
+
await list.getByRole('option', { name: '合成部门 99', exact: true }).click();
|
|
46
|
+
const selected = await list.getByRole('option', { name: '合成部门 99', exact: true }).getAttribute('aria-selected');
|
|
47
|
+
await input.press('Escape');
|
|
48
|
+
await list.waitFor({ state: 'detached' });
|
|
49
|
+
const focusRetained = await input.evaluate(element => element === document.activeElement);
|
|
50
|
+
await input.fill('没有匹配的合成部门');
|
|
51
|
+
await list.getByText('暂无匹配结果', { exact: true }).waitFor();
|
|
52
|
+
await input.fill('合成部门 98');
|
|
53
|
+
await input.press('ArrowDown');
|
|
54
|
+
await input.press('Enter');
|
|
55
|
+
const keyboardSelected = await list.getByRole('option', { name: '合成部门 98', exact: true }).getAttribute('aria-selected');
|
|
56
|
+
const node = await input.elementHandle();
|
|
57
|
+
await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom === 1 ? 2 : 1); }, zoom);
|
|
58
|
+
await page.waitForTimeout(200);
|
|
59
|
+
const stable = await node.evaluate(element => element.isConnected && element.value === '合成部门 98');
|
|
60
|
+
const dynamic = await bounds(popup);
|
|
61
|
+
const option = list.getByRole('option', { name: '合成部门 98', exact: true });
|
|
62
|
+
await option.click();
|
|
63
|
+
const toggledOff = await option.getAttribute('aria-selected') === 'false';
|
|
64
|
+
await input.press('ArrowDown');
|
|
65
|
+
await input.press('Enter');
|
|
66
|
+
const toggledOn = await option.getAttribute('aria-selected') === 'true';
|
|
67
|
+
await input.fill('');
|
|
68
|
+
await list.getByRole('option').last().waitFor();
|
|
69
|
+
const done = popup.getByRole('button', { name: '完成选择', exact: true });
|
|
70
|
+
await done.scrollIntoViewIfNeeded();
|
|
71
|
+
const doneBounds = await bounds(done);
|
|
72
|
+
const longListBounds = await bounds(popup);
|
|
73
|
+
const boundedList = longListBounds.bottom - longListBounds.top <= Math.min(240 * (zoom === 1 ? 2 : 1), 936) + 1;
|
|
74
|
+
await done.click();
|
|
75
|
+
await list.waitFor({ state: 'detached' });
|
|
76
|
+
const completed = await input.evaluate(element => element === document.activeElement && element.value === '' && element.getAttribute('aria-expanded') === 'false');
|
|
77
|
+
const field = page.locator('.rhine-multi-select').filter({ has: input });
|
|
78
|
+
let cleared = true;
|
|
79
|
+
for (const initiallyOpen of [false, true]) {
|
|
80
|
+
await input.fill('合成部门 99');
|
|
81
|
+
await input.press('ArrowDown'); await input.press('Enter');
|
|
82
|
+
if (!initiallyOpen) await popup.getByRole('button', { name: '完成选择', exact: true }).click();
|
|
83
|
+
await field.getByRole('button', { name: '清空选择', exact: true }).click();
|
|
84
|
+
await list.waitFor({ state: 'detached' });
|
|
85
|
+
cleared = cleared && await input.evaluate(element => element === document.activeElement && element.value === '合成部门 99' && element.getAttribute('aria-expanded') === 'false') && await field.getByRole('button', { name: '清空选择', exact: true }).count() === 0;
|
|
86
|
+
}
|
|
87
|
+
for (const name of ['合成部门 99', '合成部门 98']) {
|
|
88
|
+
await input.fill(name); await input.press('ArrowDown');
|
|
89
|
+
await list.getByRole('option', { name, exact: true }).waitFor();
|
|
90
|
+
await input.press('Enter');
|
|
91
|
+
await page.waitForFunction(({ id, name }) => [...document.getElementById(id).querySelectorAll('[role=option]')].some(element => element.textContent.trim().replace(/^✓/, '').trim() === name && element.getAttribute('aria-selected') === 'true'), { id: await list.getAttribute('id'), name });
|
|
92
|
+
}
|
|
93
|
+
await popup.getByRole('button', { name: '完成选择', exact: true }).click();
|
|
94
|
+
await field.getByRole('button', { name: '移除合成部门 99', exact: true }).click();
|
|
95
|
+
const tagRemoved = await input.evaluate(element => element === document.activeElement && element.value === '合成部门 98' && element.getAttribute('aria-expanded') === 'false');
|
|
96
|
+
await input.fill(''); await input.press('Backspace');
|
|
97
|
+
const backspaceRetained = await input.getAttribute('aria-expanded') === 'true' && await field.getByRole('button', { name: '清空选择', exact: true }).count() === 0;
|
|
98
|
+
await input.press('Escape');
|
|
99
|
+
const pass = boundedList && tabIn && tabBack && tabOut && cleared && tagRemoved && backspaceRetained && contained(rect) && contained(dynamic) && selected === 'true' && keyboardSelected === 'true' && focusRetained && stable && toggledOff && toggledOn && contained(doneBounds) && completed;
|
|
100
|
+
cases.push({ ...spec, pass });
|
|
101
|
+
if (!pass) failures.push({ ...spec, rect, dynamic, selected, keyboardSelected, focusRetained, stable, toggledOff, toggledOn, doneBounds, completed, cleared, tagRemoved, backspaceRetained, tabIn, tabBack, tabOut, boundedList, longListBounds });
|
|
102
|
+
if (modal) {
|
|
103
|
+
await input.press('Escape');
|
|
104
|
+
await page.getByRole('dialog', { name: '搜索多选', exact: true }).waitFor({ state: 'detached' });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { pass: !failures.length && !errors.length, cases: cases.length, failures, errors };
|
|
109
|
+
}
|