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:
@@ -19,7 +19,7 @@ export default React.memo(function EmptyState({
|
||||
}: EmptyStateProps) {
|
||||
const displayChar = icon || text.charAt(0);
|
||||
return (
|
||||
<View className='empty-state'>
|
||||
<View className='empty-state' role="status" aria-live="polite">
|
||||
<View className='empty-state-icon-wrap'>
|
||||
<Text className='empty-state-icon-char'>{displayChar}</Text>
|
||||
</View>
|
||||
|
||||
@@ -12,7 +12,7 @@ export default React.memo(function ErrorState({
|
||||
onRetry,
|
||||
}: ErrorStateProps) {
|
||||
return (
|
||||
<View className='error-state'>
|
||||
<View className='error-state' role="alert" aria-live="assertive">
|
||||
<Text className='error-state-icon'>⚠️</Text>
|
||||
<Text className='error-state-text'>{text}</Text>
|
||||
{onRetry && (
|
||||
|
||||
@@ -9,7 +9,7 @@ interface LoadingProps {
|
||||
export default React.memo(function Loading({ text = '加载中...' }: LoadingProps) {
|
||||
const isListEnd = text !== '加载中...' && !text.includes('加载');
|
||||
return (
|
||||
<View className={`loading-state ${isListEnd ? 'loading-state--end' : ''}`}>
|
||||
<View className={`loading-state ${isListEnd ? 'loading-state--end' : ''}`} role="status" aria-live="polite" aria-label="加载中">
|
||||
{!isListEnd && <View className='loading-spinner' />}
|
||||
<Text className='loading-state-text'>{text}</Text>
|
||||
</View>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
@include focus-ring;
|
||||
|
||||
&--active {
|
||||
.seg-tab__text {
|
||||
color: var(--tk-pri);
|
||||
@@ -54,6 +56,8 @@
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
@include focus-ring;
|
||||
|
||||
&--active {
|
||||
background: var(--tk-pri);
|
||||
box-shadow: var(--tk-shadow-tab);
|
||||
|
||||
@@ -21,11 +21,13 @@ export default React.memo(function SegmentTabs({
|
||||
variant = 'underline',
|
||||
}: SegmentTabsProps) {
|
||||
return (
|
||||
<View className={`seg-tabs seg-tabs--${variant}`}>
|
||||
<View className={`seg-tabs seg-tabs--${variant}`} role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`seg-tab ${activeKey === tab.key ? 'seg-tab--active' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={activeKey === tab.key}
|
||||
onClick={() => onChange(tab.key)}
|
||||
>
|
||||
<Text className='seg-tab__text'>{tab.label}</Text>
|
||||
|
||||
@@ -210,6 +210,8 @@ export default React.memo(function TrendChart({
|
||||
{tooltip && (
|
||||
<View
|
||||
className='trend-tooltip'
|
||||
role="tooltip"
|
||||
aria-live="polite"
|
||||
style={{ left: `${tooltip.x}px`, top: '8px' }}
|
||||
>
|
||||
<Text className='trend-tooltip-text'>
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
@include focus-ring;
|
||||
|
||||
&--active {
|
||||
.doctor-tabbar__icon {
|
||||
transform: scale(1.15);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@include focus-ring;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@include focus-ring;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user