功能(充值弹层): 会员周期切换支持键盘方向键

- 月付/年付 radiogroup 原先无 roving tabindex,方向键无法移动选项
- 复用 resolveRovingIndex 与 ref,方向键同步移动选中与焦点;锁定时不响应方向键
- 补一条周期切换键盘导航测试
This commit is contained in:
2026-10-05 14:03:48 +08:00
parent 25bca13c31
commit 3a948c5f58
2 changed files with 74 additions and 0 deletions
@@ -197,6 +197,52 @@ describe('PlatformProfileRechargeModal', () => {
);
});
test('周期切换按钮支持方向键与 roving tabindex', async () => {
const user = userEvent.setup();
render(
<PlatformProfileRechargeModal
center={{
mudPointBalance: buildMudPointBalance(29),
membership: buildNormalMembership(),
pointProducts: buildPointProducts(),
membershipPlans: [STARTER_PLAN, PLUS_PLAN],
latestOrder: null,
hasPointsRecharged: false,
}}
isLoading={false}
error={null}
submittingProductId={null}
nativePayment={null}
membershipQuote={null}
isLoadingMembershipQuote={false}
membershipQuoteError={null}
onClose={vi.fn()}
onRetry={vi.fn()}
onBuy={vi.fn()}
onRequestMembershipQuote={vi.fn()}
onSubmitMembershipCheckout={vi.fn()}
onConfirmNativePayment={vi.fn()}
onCloseNativePayment={vi.fn()}
/>,
);
const group = screen.getByRole('radiogroup', { name: '付费周期' });
const monthly = within(group).getByRole('radio', { name: '月付' });
const yearly = within(group).getByRole('radio', { name: '年付' });
expect(monthly.getAttribute('tabindex')).toBe('0');
expect(yearly.getAttribute('tabindex')).toBe('-1');
monthly.focus();
await user.keyboard('{ArrowRight}');
expect(yearly.getAttribute('aria-checked')).toBe('true');
expect(monthly.getAttribute('aria-checked')).toBe('false');
expect(yearly.getAttribute('tabindex')).toBe('0');
expect(monthly.getAttribute('tabindex')).toBe('-1');
expect(document.activeElement).toBe(yearly);
});
test('requests the server quote and shows the upgrade amount', async () => {
const user = userEvent.setup();
const onRequestMembershipQuote = vi.fn();
@@ -641,6 +641,8 @@ function PlatformMembershipCycleSwitch({
{ id: 'monthly', label: '月付' },
{ id: 'yearly', label: '年付' },
];
// 与泥点档位一致:roving tabindex + 方向键移动焦点,保证键盘可切换周期。
const optionRefs = useRef<Record<string, HTMLButtonElement | null>>({});
return (
<div className="flex items-center justify-between gap-3">
@@ -648,15 +650,41 @@ function PlatformMembershipCycleSwitch({
role="radiogroup"
aria-label="付费周期"
className="platform-subpanel flex gap-1 rounded-full p-1"
onKeyDown={(event) => {
if (locked) {
return;
}
const currentIndex = options.findIndex(
(option) => option.id === cycleKind,
);
const nextIndex = resolveRovingIndex(
event.key,
currentIndex,
options.length,
);
if (nextIndex === null) {
return;
}
event.preventDefault();
const nextOption = options[nextIndex];
if (nextOption) {
onChange(nextOption.id);
optionRefs.current[nextOption.id]?.focus();
}
}}
>
{options.map((option) => {
const selected = option.id === cycleKind;
return (
<button
key={option.id}
ref={(node) => {
optionRefs.current[option.id] = node;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
disabled={locked && !selected}
onClick={() => onChange(option.id)}
className={joinClassNames(