fix(web): 完善统一认证恢复与重配交互
为处理中、失败和清理中的配对展示明确状态与恢复动作,支持放弃并清理、作用域内退役冲突连接,并在 Active 状态引导管理员先安全禁用再使用新接入码。同步修复运行时切换后的浏览器会话残留与确认弹窗焦点约束。 验证:pnpm test;pnpm lint;pnpm build。
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ConfirmDialog } from './confirm-dialog';
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
it('associates its title and destructive-action description', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConfirmDialog open title="放弃当前配对?" description="将清理临时资源" onCancel={() => undefined} onConfirm={() => undefined} />,
|
||||
);
|
||||
|
||||
const labelledBy = html.match(/aria-labelledby="([^"]+)"/)?.[1];
|
||||
const describedBy = html.match(/aria-describedby="([^"]+)"/)?.[1];
|
||||
expect(labelledBy).toBeTruthy();
|
||||
expect(describedBy).toBeTruthy();
|
||||
expect(html).toContain(`id="${labelledBy}"`);
|
||||
expect(html).toContain(`id="${describedBy}"`);
|
||||
expect(html).toContain('tabindex="-1"');
|
||||
});
|
||||
|
||||
it('keeps the modal container focusable while its actions are loading', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConfirmDialog open loading title="安全退役?" onCancel={() => undefined} onConfirm={() => undefined} />,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-busy="true"');
|
||||
expect(html).toContain('tabindex="-1"');
|
||||
expect(html.match(/disabled=""/g)?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -18,32 +18,79 @@ export interface ConfirmDialogProps {
|
||||
}
|
||||
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const { onCancel, open } = props;
|
||||
const { open } = props;
|
||||
const titleId = React.useId();
|
||||
const descriptionId = React.useId();
|
||||
const dialogRef = React.useRef<HTMLElement>(null);
|
||||
const cancelRef = React.useRef<HTMLButtonElement>(null);
|
||||
const loadingRef = React.useRef(props.loading);
|
||||
const onCancelRef = React.useRef(props.onCancel);
|
||||
loadingRef.current = props.loading;
|
||||
onCancelRef.current = props.onCancel;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const focusFrame = window.requestAnimationFrame(() => {
|
||||
if (cancelRef.current && !cancelRef.current.disabled) cancelRef.current.focus();
|
||||
else dialogRef.current?.focus();
|
||||
});
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onCancel();
|
||||
if (event.key === 'Escape' && !loadingRef.current) {
|
||||
event.preventDefault();
|
||||
onCancelRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab' || !dialogRef.current) return;
|
||||
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'));
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
dialogRef.current.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (document.activeElement === dialogRef.current) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
} else if (!dialogRef.current.contains(document.activeElement)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
} else if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onCancel, open]);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(focusFrame);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && props.loading) dialogRef.current?.focus();
|
||||
}, [open, props.loading]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="confirmDialogBackdrop" role="presentation">
|
||||
<section className={cn('confirmDialog', props.className)} role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
|
||||
<div className="confirmDialogIcon">
|
||||
<section ref={dialogRef} className={cn('confirmDialog', props.className)} role="alertdialog" tabIndex={-1} aria-busy={props.loading || undefined} aria-modal="true" aria-labelledby={titleId} aria-describedby={props.description ? descriptionId : undefined}>
|
||||
<div className="confirmDialogIcon" aria-hidden="true">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div className="confirmDialogBody">
|
||||
<strong id="confirm-dialog-title">{props.title}</strong>
|
||||
{props.description && <p>{props.description}</p>}
|
||||
<strong id={titleId}>{props.title}</strong>
|
||||
{props.description && <p id={descriptionId}>{props.description}</p>}
|
||||
{props.children}
|
||||
</div>
|
||||
<footer className="confirmDialogActions">
|
||||
<Button type="button" variant="outline" size="sm" disabled={props.loading} onClick={props.onCancel}>
|
||||
<Button ref={cancelRef} type="button" variant="outline" size="sm" disabled={props.loading} onClick={props.onCancel}>
|
||||
{props.cancelLabel ?? '取消'}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user