@lytjs/common-a11y 6.4.0 → 6.6.0
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/dist/index.cjs +2 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +14 -14
- package/dist/index.d.ts +14 -14
- package/dist/index.mjs +2 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +550 -508
package/src/index.ts
CHANGED
|
@@ -1,508 +1,550 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @lytjs/common-a11y
|
|
3
|
-
* 轻量级无障碍访问工具
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
declare const __DEV__: boolean;
|
|
7
|
-
|
|
8
|
-
export interface FocusTrapOptions {
|
|
9
|
-
initialFocus?: HTMLElement;
|
|
10
|
-
escapeDeactivates?: boolean;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* 通用无障碍属性接口
|
|
15
|
-
*/
|
|
16
|
-
export interface A11yProps {
|
|
17
|
-
id?: string;
|
|
18
|
-
ariaLabel?: string;
|
|
19
|
-
ariaDescribedBy?: string;
|
|
20
|
-
ariaLabelledBy?: string;
|
|
21
|
-
ariaRequired?: boolean;
|
|
22
|
-
ariaInvalid?: boolean;
|
|
23
|
-
ariaDisabled?: boolean;
|
|
24
|
-
ariaHidden?: boolean;
|
|
25
|
-
ariaExpanded?: boolean;
|
|
26
|
-
ariaChecked?: boolean | 'mixed';
|
|
27
|
-
ariaSelected?: boolean;
|
|
28
|
-
ariaPressed?: boolean;
|
|
29
|
-
ariaHasPopup?: boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog';
|
|
30
|
-
ariaControls?: string;
|
|
31
|
-
ariaOwns?: string;
|
|
32
|
-
ariaLive?: 'off' | 'polite' | 'assertive';
|
|
33
|
-
ariaValuenow?: number | string;
|
|
34
|
-
ariaValuemax?: number;
|
|
35
|
-
ariaValuemin?: number;
|
|
36
|
-
ariaModal?: boolean;
|
|
37
|
-
tabIndex?: number;
|
|
38
|
-
role?: string;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* 生成 tabindex 属性值
|
|
43
|
-
*/
|
|
44
|
-
export function getTabIndex(disabled: boolean, customTabIndex?: number): number | undefined {
|
|
45
|
-
if (customTabIndex !== undefined) return customTabIndex;
|
|
46
|
-
return disabled ? -1 : 0;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* 为按钮组件生成 a11y 属性
|
|
51
|
-
*/
|
|
52
|
-
export function getButtonA11yProps(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
'
|
|
57
|
-
'aria-
|
|
58
|
-
'aria-
|
|
59
|
-
'aria-
|
|
60
|
-
'
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
'aria-
|
|
75
|
-
'aria-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
*
|
|
104
|
-
*/
|
|
105
|
-
export function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return {
|
|
133
|
-
role: '
|
|
134
|
-
'aria-
|
|
135
|
-
'aria-
|
|
136
|
-
'aria-
|
|
137
|
-
...getFormControlA11yProps(props),
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
*
|
|
143
|
-
*/
|
|
144
|
-
export function
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
'
|
|
149
|
-
'aria-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
]
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @lytjs/common-a11y
|
|
3
|
+
* 轻量级无障碍访问工具
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
declare const __DEV__: boolean;
|
|
7
|
+
|
|
8
|
+
export interface FocusTrapOptions {
|
|
9
|
+
initialFocus?: HTMLElement;
|
|
10
|
+
escapeDeactivates?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 通用无障碍属性接口
|
|
15
|
+
*/
|
|
16
|
+
export interface A11yProps {
|
|
17
|
+
id?: string;
|
|
18
|
+
ariaLabel?: string;
|
|
19
|
+
ariaDescribedBy?: string;
|
|
20
|
+
ariaLabelledBy?: string;
|
|
21
|
+
ariaRequired?: boolean;
|
|
22
|
+
ariaInvalid?: boolean;
|
|
23
|
+
ariaDisabled?: boolean;
|
|
24
|
+
ariaHidden?: boolean;
|
|
25
|
+
ariaExpanded?: boolean;
|
|
26
|
+
ariaChecked?: boolean | 'mixed';
|
|
27
|
+
ariaSelected?: boolean;
|
|
28
|
+
ariaPressed?: boolean;
|
|
29
|
+
ariaHasPopup?: boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog';
|
|
30
|
+
ariaControls?: string;
|
|
31
|
+
ariaOwns?: string;
|
|
32
|
+
ariaLive?: 'off' | 'polite' | 'assertive';
|
|
33
|
+
ariaValuenow?: number | string;
|
|
34
|
+
ariaValuemax?: number;
|
|
35
|
+
ariaValuemin?: number;
|
|
36
|
+
ariaModal?: boolean;
|
|
37
|
+
tabIndex?: number;
|
|
38
|
+
role?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 生成 tabindex 属性值
|
|
43
|
+
*/
|
|
44
|
+
export function getTabIndex(disabled: boolean, customTabIndex?: number): number | undefined {
|
|
45
|
+
if (customTabIndex !== undefined) return customTabIndex;
|
|
46
|
+
return disabled ? -1 : 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 为按钮组件生成 a11y 属性
|
|
51
|
+
*/
|
|
52
|
+
export function getButtonA11yProps(
|
|
53
|
+
props: A11yProps & { disabled?: boolean },
|
|
54
|
+
): Record<string, unknown> {
|
|
55
|
+
return {
|
|
56
|
+
role: 'button',
|
|
57
|
+
'aria-label': props.ariaLabel,
|
|
58
|
+
'aria-describedby': props.ariaDescribedBy,
|
|
59
|
+
'aria-labelledby': props.ariaLabelledBy,
|
|
60
|
+
'aria-disabled': props.ariaDisabled ?? props.disabled,
|
|
61
|
+
'aria-pressed': props.ariaPressed,
|
|
62
|
+
tabindex: getTabIndex(props.disabled ?? !!props.ariaDisabled, props.tabIndex),
|
|
63
|
+
id: props.id,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 为表单控件生成 a11y 属性
|
|
69
|
+
*/
|
|
70
|
+
export function getFormControlA11yProps(
|
|
71
|
+
props: A11yProps & { disabled?: boolean; required?: boolean; invalid?: boolean },
|
|
72
|
+
): Record<string, unknown> {
|
|
73
|
+
return {
|
|
74
|
+
'aria-label': props.ariaLabel,
|
|
75
|
+
'aria-describedby': props.ariaDescribedBy,
|
|
76
|
+
'aria-labelledby': props.ariaLabelledBy,
|
|
77
|
+
'aria-required': props.ariaRequired ?? props.required,
|
|
78
|
+
'aria-invalid': props.ariaInvalid ?? props.invalid,
|
|
79
|
+
'aria-disabled': props.ariaDisabled ?? props.disabled,
|
|
80
|
+
tabindex: getTabIndex(props.disabled ?? !!props.ariaDisabled, props.tabIndex),
|
|
81
|
+
id: props.id,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 为复选框/单选框生成 a11y 属性
|
|
87
|
+
*/
|
|
88
|
+
export function getInputControlA11yProps(
|
|
89
|
+
props: A11yProps & {
|
|
90
|
+
disabled?: boolean;
|
|
91
|
+
checked?: boolean | 'mixed';
|
|
92
|
+
required?: boolean;
|
|
93
|
+
invalid?: boolean;
|
|
94
|
+
},
|
|
95
|
+
): Record<string, unknown> {
|
|
96
|
+
return {
|
|
97
|
+
...getFormControlA11yProps(props),
|
|
98
|
+
'aria-checked': props.checked,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 为开关组件生成 a11y 属性
|
|
104
|
+
*/
|
|
105
|
+
export function getSwitchA11yProps(
|
|
106
|
+
props: A11yProps & {
|
|
107
|
+
disabled?: boolean;
|
|
108
|
+
checked?: boolean;
|
|
109
|
+
required?: boolean;
|
|
110
|
+
invalid?: boolean;
|
|
111
|
+
},
|
|
112
|
+
): Record<string, unknown> {
|
|
113
|
+
return {
|
|
114
|
+
role: 'switch',
|
|
115
|
+
'aria-checked': props.checked,
|
|
116
|
+
...getFormControlA11yProps(props),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 为下拉选择组件生成 a11y 属性
|
|
122
|
+
*/
|
|
123
|
+
export function getComboboxA11yProps(
|
|
124
|
+
props: A11yProps & {
|
|
125
|
+
disabled?: boolean;
|
|
126
|
+
expanded?: boolean;
|
|
127
|
+
controls?: string;
|
|
128
|
+
required?: boolean;
|
|
129
|
+
invalid?: boolean;
|
|
130
|
+
},
|
|
131
|
+
): Record<string, unknown> {
|
|
132
|
+
return {
|
|
133
|
+
role: 'combobox',
|
|
134
|
+
'aria-expanded': props.expanded,
|
|
135
|
+
'aria-controls': props.ariaControls ?? props.controls,
|
|
136
|
+
'aria-haspopup': 'listbox',
|
|
137
|
+
...getFormControlA11yProps(props),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 为列表框选项生成 a11y 属性
|
|
143
|
+
*/
|
|
144
|
+
export function getOptionA11yProps(
|
|
145
|
+
props: A11yProps & { selected?: boolean; disabled?: boolean },
|
|
146
|
+
): Record<string, unknown> {
|
|
147
|
+
return {
|
|
148
|
+
role: 'option',
|
|
149
|
+
'aria-selected': props.selected,
|
|
150
|
+
'aria-disabled': props.disabled,
|
|
151
|
+
tabindex: props.disabled ? -1 : 0,
|
|
152
|
+
id: props.id,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 为滑块组件生成 a11y 属性
|
|
158
|
+
*/
|
|
159
|
+
export function getSliderA11yProps(
|
|
160
|
+
props: A11yProps & { disabled?: boolean; value?: number | string; min?: number; max?: number },
|
|
161
|
+
): Record<string, unknown> {
|
|
162
|
+
return {
|
|
163
|
+
role: 'slider',
|
|
164
|
+
'aria-valuenow': props.value,
|
|
165
|
+
'aria-valuemin': props.min,
|
|
166
|
+
'aria-valuemax': props.max,
|
|
167
|
+
...getFormControlA11yProps(props),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 为数字输入组件生成 a11y 属性
|
|
173
|
+
*/
|
|
174
|
+
export function getSpinbuttonA11yProps(
|
|
175
|
+
props: A11yProps & { disabled?: boolean; value?: number | string; min?: number; max?: number },
|
|
176
|
+
): Record<string, unknown> {
|
|
177
|
+
return {
|
|
178
|
+
role: 'spinbutton',
|
|
179
|
+
'aria-valuenow': props.value,
|
|
180
|
+
'aria-valuemin': props.min,
|
|
181
|
+
'aria-valuemax': props.max,
|
|
182
|
+
...getFormControlA11yProps(props),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 为标签页列表生成 a11y 属性
|
|
188
|
+
*/
|
|
189
|
+
export function getTablistA11yProps(
|
|
190
|
+
props: A11yProps & { label?: string },
|
|
191
|
+
): Record<string, unknown> {
|
|
192
|
+
return {
|
|
193
|
+
role: 'tablist',
|
|
194
|
+
'aria-label': props.ariaLabel ?? props.label,
|
|
195
|
+
'aria-describedby': props.ariaDescribedBy,
|
|
196
|
+
id: props.id,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 为单个标签页生成 a11y 属性
|
|
202
|
+
*/
|
|
203
|
+
export function getTabA11yProps(
|
|
204
|
+
props: A11yProps & { selected?: boolean; disabled?: boolean; controls?: string },
|
|
205
|
+
): Record<string, unknown> {
|
|
206
|
+
return {
|
|
207
|
+
role: 'tab',
|
|
208
|
+
'aria-selected': props.selected,
|
|
209
|
+
'aria-disabled': props.disabled,
|
|
210
|
+
'aria-controls': props.ariaControls ?? props.controls,
|
|
211
|
+
tabindex: props.selected ? 0 : -1,
|
|
212
|
+
id: props.id,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 为标签面板生成 a11y 属性
|
|
218
|
+
*/
|
|
219
|
+
export function getTabpanelA11yProps(
|
|
220
|
+
props: A11yProps & { labelledBy?: string; hidden?: boolean },
|
|
221
|
+
): Record<string, unknown> {
|
|
222
|
+
return {
|
|
223
|
+
role: 'tabpanel',
|
|
224
|
+
'aria-labelledby': props.ariaLabelledBy ?? props.labelledBy,
|
|
225
|
+
'aria-hidden': props.hidden,
|
|
226
|
+
id: props.id,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 为对话框/模态框生成 a11y 属性
|
|
232
|
+
*/
|
|
233
|
+
export function getDialogA11yProps(
|
|
234
|
+
props: A11yProps & { labelledBy?: string; describedBy?: string; modal?: boolean },
|
|
235
|
+
): Record<string, unknown> {
|
|
236
|
+
return {
|
|
237
|
+
role: 'dialog',
|
|
238
|
+
'aria-modal': props.ariaModal ?? props.modal ?? true,
|
|
239
|
+
'aria-labelledby': props.ariaLabelledBy ?? props.labelledBy,
|
|
240
|
+
'aria-describedby': props.ariaDescribedBy ?? props.describedBy,
|
|
241
|
+
'aria-label': props.ariaLabel,
|
|
242
|
+
id: props.id,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 为分组组件(checkboxGroup/radioGroup)生成 a11y 属性
|
|
248
|
+
*/
|
|
249
|
+
export function getGroupA11yProps(
|
|
250
|
+
props: A11yProps & {
|
|
251
|
+
role?: 'radiogroup' | 'group' | 'listbox';
|
|
252
|
+
required?: boolean;
|
|
253
|
+
label?: string;
|
|
254
|
+
},
|
|
255
|
+
): Record<string, unknown> {
|
|
256
|
+
return {
|
|
257
|
+
role: props.role ?? 'group',
|
|
258
|
+
'aria-label': props.ariaLabel ?? props.label,
|
|
259
|
+
'aria-describedby': props.ariaDescribedBy,
|
|
260
|
+
'aria-required': props.ariaRequired ?? props.required,
|
|
261
|
+
id: props.id,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 合并多个 a11y 属性对象
|
|
267
|
+
*/
|
|
268
|
+
export function mergeA11yProps(
|
|
269
|
+
...propsList: Array<Record<string, unknown>>
|
|
270
|
+
): Record<string, unknown> {
|
|
271
|
+
const result: Record<string, unknown> = {};
|
|
272
|
+
for (const props of propsList) {
|
|
273
|
+
for (const [key, value] of Object.entries(props)) {
|
|
274
|
+
if (value !== undefined && value !== null) {
|
|
275
|
+
result[key] = value;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// 过滤 undefined 值
|
|
280
|
+
return Object.fromEntries(
|
|
281
|
+
Object.entries(result).filter(([_, v]) => v !== undefined && v !== null),
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** ARIA 角色到必需属性的映射 */
|
|
286
|
+
export const ARIA_ROLES: Record<string, string[]> = {
|
|
287
|
+
alert: ['aria-live'],
|
|
288
|
+
alertdialog: ['aria-labelledby', 'aria-describedby'],
|
|
289
|
+
button: [],
|
|
290
|
+
checkbox: ['aria-checked'],
|
|
291
|
+
combobox: ['aria-expanded', 'aria-controls'],
|
|
292
|
+
dialog: ['aria-labelledby', 'aria-describedby'],
|
|
293
|
+
grid: [],
|
|
294
|
+
gridcell: [],
|
|
295
|
+
link: [],
|
|
296
|
+
listbox: ['aria-label'],
|
|
297
|
+
menu: ['aria-label'],
|
|
298
|
+
menubar: [],
|
|
299
|
+
menuitem: [],
|
|
300
|
+
option: ['aria-selected'],
|
|
301
|
+
progressbar: ['aria-valuenow'],
|
|
302
|
+
radio: ['aria-checked'],
|
|
303
|
+
radiogroup: ['aria-label'],
|
|
304
|
+
slider: ['aria-valuenow'],
|
|
305
|
+
spinbutton: ['aria-valuenow'],
|
|
306
|
+
tab: ['aria-selected'],
|
|
307
|
+
tablist: [],
|
|
308
|
+
tabpanel: ['aria-labelledby'],
|
|
309
|
+
textbox: [],
|
|
310
|
+
tree: ['aria-label'],
|
|
311
|
+
treeitem: ['aria-selected'],
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/** 可聚焦元素选择器 */
|
|
315
|
+
const FOCUSABLE_SELECTOR = [
|
|
316
|
+
'a[href]',
|
|
317
|
+
'area[href]',
|
|
318
|
+
'button:not([disabled])',
|
|
319
|
+
'input:not([disabled])',
|
|
320
|
+
'select:not([disabled])',
|
|
321
|
+
'textarea:not([disabled])',
|
|
322
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
323
|
+
'[contenteditable="true"]',
|
|
324
|
+
].join(', ');
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 检查元素是否可聚焦
|
|
328
|
+
*/
|
|
329
|
+
export function isFocusable(element: Element): boolean {
|
|
330
|
+
if (!(element instanceof HTMLElement)) return false;
|
|
331
|
+
if ('disabled' in element && (element as { disabled: boolean }).disabled) return false;
|
|
332
|
+
if (element.getAttribute('tabindex') === '-1') return false;
|
|
333
|
+
if (element.getAttribute('aria-hidden') === 'true') return false;
|
|
334
|
+
|
|
335
|
+
const tag = element.tagName.toLowerCase();
|
|
336
|
+
const focusableTags = new Set([
|
|
337
|
+
'a',
|
|
338
|
+
'button',
|
|
339
|
+
'input',
|
|
340
|
+
'select',
|
|
341
|
+
'textarea',
|
|
342
|
+
'details',
|
|
343
|
+
'summary',
|
|
344
|
+
]);
|
|
345
|
+
|
|
346
|
+
if (focusableTags.has(tag)) return true;
|
|
347
|
+
if (element.getAttribute('tabindex') !== null) return true;
|
|
348
|
+
if (element.isContentEditable || element.getAttribute('contenteditable') === 'true') return true;
|
|
349
|
+
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 获取容器内所有可聚焦元素
|
|
355
|
+
*/
|
|
356
|
+
export function getFocusableElements(container: Element): HTMLElement[] {
|
|
357
|
+
const elements = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
|
|
358
|
+
return elements.filter((el): el is HTMLElement => el instanceof HTMLElement && isFocusable(el));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 在容器内创建焦点陷阱
|
|
363
|
+
*
|
|
364
|
+
* @param container - 陷阱容器
|
|
365
|
+
* @param options - 配置选项
|
|
366
|
+
* @returns 清理函数
|
|
367
|
+
*/
|
|
368
|
+
export function focusTrap(container: HTMLElement, options?: FocusTrapOptions): () => void {
|
|
369
|
+
const { initialFocus, escapeDeactivates = true } = options || {};
|
|
370
|
+
|
|
371
|
+
const focusableElements = getFocusableElements(container);
|
|
372
|
+
const firstElement = focusableElements[0] || container;
|
|
373
|
+
const lastElement = focusableElements[focusableElements.length - 1] || container;
|
|
374
|
+
|
|
375
|
+
// 设置初始焦点
|
|
376
|
+
if (initialFocus) {
|
|
377
|
+
initialFocus.focus();
|
|
378
|
+
} else {
|
|
379
|
+
firstElement.focus();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
383
|
+
if (event.key === 'Escape' && escapeDeactivates) {
|
|
384
|
+
cleanup();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (event.key !== 'Tab') return;
|
|
389
|
+
|
|
390
|
+
if (focusableElements.length === 0) {
|
|
391
|
+
event.preventDefault();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (event.shiftKey) {
|
|
396
|
+
if (document.activeElement === firstElement) {
|
|
397
|
+
event.preventDefault();
|
|
398
|
+
lastElement.focus();
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
if (document.activeElement === lastElement) {
|
|
402
|
+
event.preventDefault();
|
|
403
|
+
firstElement.focus();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
409
|
+
|
|
410
|
+
const cleanup = () => {
|
|
411
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
return cleanup;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* 管理焦点:保存之前的焦点,将焦点移入容器,返回恢复函数
|
|
419
|
+
*
|
|
420
|
+
* @param container - 目标容器
|
|
421
|
+
* @param triggerEl - 触发元素,恢复焦点时优先回到此元素
|
|
422
|
+
* @returns 恢复函数
|
|
423
|
+
*/
|
|
424
|
+
export function manageFocus(container: HTMLElement, triggerEl?: HTMLElement): () => void {
|
|
425
|
+
const previousFocus = document.activeElement as HTMLElement | null;
|
|
426
|
+
|
|
427
|
+
const focusableElements = getFocusableElements(container);
|
|
428
|
+
if (focusableElements.length > 0) {
|
|
429
|
+
focusableElements[0]!.focus();
|
|
430
|
+
} else {
|
|
431
|
+
container.setAttribute('tabindex', '-1');
|
|
432
|
+
container.focus();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return () => {
|
|
436
|
+
const target = triggerEl || previousFocus;
|
|
437
|
+
if (target && typeof target.focus === 'function') {
|
|
438
|
+
target.focus();
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* 获取元素上所有 aria-* 属性
|
|
445
|
+
*/
|
|
446
|
+
export function getAriaProps(element: Element): Record<string, string> {
|
|
447
|
+
const result: Record<string, string> = {};
|
|
448
|
+
const attrs = element.attributes;
|
|
449
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
450
|
+
const attr = attrs[i]!;
|
|
451
|
+
if (attr.name.startsWith('aria-')) {
|
|
452
|
+
result[attr.name] = attr.value!;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return result;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 批量设置 aria-* 属性
|
|
460
|
+
*/
|
|
461
|
+
export function setAriaProps(element: Element, props: Record<string, string>): void {
|
|
462
|
+
for (const key of Object.keys(props)) {
|
|
463
|
+
if (key.startsWith('aria-')) {
|
|
464
|
+
element.setAttribute(key, props[key]!);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* 检查给定元素是否是当前活动元素
|
|
471
|
+
*/
|
|
472
|
+
export function assertActiveElement(element: Element): boolean {
|
|
473
|
+
return document.activeElement === element;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* 键盘导航辅助函数 - 在启用的选项间循环
|
|
478
|
+
*/
|
|
479
|
+
export function getNextEnabledIndex(
|
|
480
|
+
currentIndex: number,
|
|
481
|
+
totalItems: number,
|
|
482
|
+
isEnabled: (index: number) => boolean,
|
|
483
|
+
direction: 'forward' | 'backward' = 'forward',
|
|
484
|
+
): number {
|
|
485
|
+
const step = direction === 'forward' ? 1 : -1;
|
|
486
|
+
let nextIndex = (currentIndex + step + totalItems) % totalItems;
|
|
487
|
+
|
|
488
|
+
for (let i = 0; i < totalItems; i++) {
|
|
489
|
+
if (isEnabled(nextIndex)) {
|
|
490
|
+
return nextIndex;
|
|
491
|
+
}
|
|
492
|
+
nextIndex = (nextIndex + step + totalItems) % totalItems;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return currentIndex;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* 处理列表组件的键盘导航
|
|
500
|
+
*/
|
|
501
|
+
export function handleListKeydown(
|
|
502
|
+
event: KeyboardEvent,
|
|
503
|
+
currentIndex: number,
|
|
504
|
+
totalItems: number,
|
|
505
|
+
isEnabled: (index: number) => boolean,
|
|
506
|
+
onSelect: (index: number) => void,
|
|
507
|
+
onClose?: () => void,
|
|
508
|
+
): void {
|
|
509
|
+
switch (event.key) {
|
|
510
|
+
case 'ArrowDown':
|
|
511
|
+
case 'ArrowRight':
|
|
512
|
+
event.preventDefault();
|
|
513
|
+
onSelect(getNextEnabledIndex(currentIndex, totalItems, isEnabled, 'forward'));
|
|
514
|
+
break;
|
|
515
|
+
case 'ArrowUp':
|
|
516
|
+
case 'ArrowLeft':
|
|
517
|
+
event.preventDefault();
|
|
518
|
+
onSelect(getNextEnabledIndex(currentIndex, totalItems, isEnabled, 'backward'));
|
|
519
|
+
break;
|
|
520
|
+
case 'Home':
|
|
521
|
+
event.preventDefault();
|
|
522
|
+
for (let i = 0; i < totalItems; i++) {
|
|
523
|
+
if (isEnabled(i)) {
|
|
524
|
+
onSelect(i);
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
break;
|
|
529
|
+
case 'End':
|
|
530
|
+
event.preventDefault();
|
|
531
|
+
for (let i = totalItems - 1; i >= 0; i--) {
|
|
532
|
+
if (isEnabled(i)) {
|
|
533
|
+
onSelect(i);
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
break;
|
|
538
|
+
case 'Enter':
|
|
539
|
+
case ' ':
|
|
540
|
+
event.preventDefault();
|
|
541
|
+
if (isEnabled(currentIndex)) {
|
|
542
|
+
onSelect(currentIndex);
|
|
543
|
+
}
|
|
544
|
+
break;
|
|
545
|
+
case 'Escape':
|
|
546
|
+
event.preventDefault();
|
|
547
|
+
onClose?.();
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
}
|