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