feat(mp): Phase 1 测试覆盖 + UX 无障碍 — 106 tests PASS + ARIA + focus ring

测试:
- secure-storage: 26 tests (AES 加解密/明文 fallback/迁移/Base64 边界)
- request.ts: 16 tests (扩展 ResponseCache/patientId 隔离/requestUnlimited)
- mock-api: 修复 getCachedPatientId 缺失导致 health 测试失败

UX 无障碍 (10 组件):
- SegmentTabs/DoctorTabBar: role=tablist/tab + aria-selected
- PrimaryButton/SecondaryButton: role=button + aria-disabled/aria-busy
- Loading/LoadingCard: role=status + aria-live=polite
- EmptyState: role=status + aria-live=polite
- ErrorState: role=alert + aria-live=assertive
- TrendChart tooltip: role=tooltip + aria-live=polite
- FormInput: aria-invalid + aria-label

焦点管理:
- 新增 _focus-ring.scss mixin (focus + focus-visible)
- 5 组件 SCSS 应用 focus-ring
This commit is contained in:
iven
2026-05-22 00:24:06 +08:00
parent 02a96682f6
commit 898e22c715
20 changed files with 363 additions and 9 deletions

View File

@@ -25,6 +25,8 @@
cursor: pointer;
-webkit-tap-highlight-color: transparent;
@include focus-ring;
&--active {
.doctor-tabbar__icon {
transform: scale(1.15);

View File

@@ -31,11 +31,13 @@ export default function DoctorTabBar({ active }: DoctorTabBarProps) {
};
return (
<View className="doctor-tabbar">
<View className="doctor-tabbar" role="tablist">
{DOCTOR_TABS.map((tab) => (
<View
key={tab.key}
className={`doctor-tabbar__item ${tab.key === activeKey ? 'doctor-tabbar__item--active' : ''}`}
role="tab"
aria-selected={tab.key === activeKey}
onClick={() => handleTab(tab)}
>
<Text className="doctor-tabbar__icon">{tab.key === activeKey ? tab.activeIcon : tab.icon}</Text>

View File

@@ -17,6 +17,12 @@
display: flex;
align-items: center;
transition: border-color 0.2s;
&:focus-within {
outline: $focus-ring-width solid $focus-ring-color;
outline-offset: $focus-ring-offset;
border-color: var(--tk-pri);
}
}
&__control {

View File

@@ -45,6 +45,8 @@ const FormInput: React.FC<FormInputProps> = ({
type={type}
maxlength={maxLength}
disabled={disabled}
aria-invalid={!!error}
aria-label={label || placeholder}
/>
</View>
{error && <Text className='form-input__error'>{error}</Text>}

View File

@@ -12,7 +12,7 @@ const LoadingCard: React.FC<LoadingCardProps> = ({
layout = 'card',
}) => {
return (
<View className={`loading-card-group loading-card-group--${layout}`}>
<View className={`loading-card-group loading-card-group--${layout}`} role="status" aria-label="内容加载中">
{Array.from({ length: count }, (_, i) => (
<View key={i} className="loading-card">
{layout === 'card' && (

View File

@@ -29,6 +29,8 @@
transform: scale(0.98);
}
@include focus-ring;
&--disabled {
opacity: 0.5;
box-shadow: none;

View File

@@ -28,7 +28,7 @@ const PrimaryButton: React.FC<PrimaryButtonProps> = ({
].filter(Boolean).join(' ');
return (
<View className={cls} onClick={!disabled && !loading ? onClick : undefined}>
<View className={cls} role="button" aria-disabled={disabled} aria-busy={loading} onClick={!disabled && !loading ? onClick : undefined}>
{loading && <View className='primary-btn__spinner' />}
<Text className='primary-btn__text'>{children}</Text>
</View>

View File

@@ -18,6 +18,8 @@
transform: scale(0.98);
}
@include focus-ring;
&--disabled {
opacity: 0.5;
}

View File

@@ -22,7 +22,7 @@ const SecondaryButton: React.FC<SecondaryButtonProps> = ({
].filter(Boolean).join(' ');
return (
<View className={cls} onClick={!disabled ? onClick : undefined}>
<View className={cls} role="button" aria-disabled={disabled} onClick={!disabled ? onClick : undefined}>
<Text className='secondary-btn__text'>{children}</Text>
</View>
);