@a13y/react 0.1.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.
@@ -0,0 +1,648 @@
1
+ import { useRef, useEffect, useCallback, useId, useState } from 'react';
2
+ import { announce } from '@a13y/core/runtime/announce';
3
+
4
+ var useAccessibleButton = (props) => {
5
+ const { label, onPress, isDisabled = false, role = "button", elementType = "button" } = props;
6
+ const buttonRef = useRef(null);
7
+ const isPressedRef = useRef(false);
8
+ useEffect(() => {
9
+ {
10
+ import('@a13y/devtools/runtime/invariants').then(
11
+ ({ assertHasAccessibleName, assertKeyboardAccessible }) => {
12
+ if (buttonRef.current) {
13
+ assertHasAccessibleName(buttonRef.current, "useAccessibleButton");
14
+ assertKeyboardAccessible(buttonRef.current, "useAccessibleButton");
15
+ }
16
+ }
17
+ );
18
+ }
19
+ }, []);
20
+ const handlePress = useCallback(
21
+ (event) => {
22
+ if (isDisabled) {
23
+ return;
24
+ }
25
+ onPress(event);
26
+ },
27
+ [onPress, isDisabled]
28
+ );
29
+ const handlePointerDown = useCallback(
30
+ (event) => {
31
+ if (isDisabled) {
32
+ event.preventDefault();
33
+ return;
34
+ }
35
+ isPressedRef.current = true;
36
+ handlePress({ type: "mouse" });
37
+ },
38
+ [handlePress, isDisabled]
39
+ );
40
+ const handleKeyDown = useCallback(
41
+ (event) => {
42
+ if (isDisabled) {
43
+ return;
44
+ }
45
+ if (event.key === "Enter" || event.key === " ") {
46
+ event.preventDefault();
47
+ handlePress({ type: "keyboard", key: event.key });
48
+ }
49
+ },
50
+ [handlePress, isDisabled]
51
+ );
52
+ const buttonProps = {
53
+ role,
54
+ tabIndex: isDisabled ? -1 : 0,
55
+ "aria-label": label,
56
+ "aria-disabled": isDisabled ? true : void 0,
57
+ disabled: elementType === "button" ? isDisabled : void 0,
58
+ onPointerDown: handlePointerDown,
59
+ onKeyDown: handleKeyDown
60
+ };
61
+ return {
62
+ buttonProps,
63
+ isPressed: isPressedRef.current
64
+ };
65
+ };
66
+ var useFocusTrap = (props) => {
67
+ const { isActive, onEscape, restoreFocus = true, autoFocus = true } = props;
68
+ const trapRef = useRef(null);
69
+ const focusTrapRef = useRef(null);
70
+ const previousFocusRef = useRef(null);
71
+ useEffect(() => {
72
+ if (!isActive || !trapRef.current) {
73
+ return;
74
+ }
75
+ if (restoreFocus) {
76
+ previousFocusRef.current = document.activeElement;
77
+ }
78
+ import('@a13y/core/runtime/focus').then(({ createFocusTrap }) => {
79
+ if (!trapRef.current) {
80
+ return;
81
+ }
82
+ const options = {
83
+ returnFocus: false,
84
+ onEscape
85
+ };
86
+ if (autoFocus) {
87
+ options.initialFocus = void 0;
88
+ }
89
+ const trap = createFocusTrap(trapRef.current, options);
90
+ trap.activate();
91
+ focusTrapRef.current = trap;
92
+ {
93
+ import('@a13y/devtools/runtime/validators').then(({ focusValidator }) => {
94
+ if (trapRef.current) {
95
+ focusValidator.validateFocusTrap(trapRef.current, true);
96
+ }
97
+ });
98
+ }
99
+ });
100
+ return () => {
101
+ if (focusTrapRef.current) {
102
+ focusTrapRef.current.deactivate();
103
+ focusTrapRef.current = null;
104
+ }
105
+ if (restoreFocus && previousFocusRef.current) {
106
+ previousFocusRef.current.focus();
107
+ {
108
+ import('@a13y/devtools/runtime/validators').then(({ focusValidator }) => {
109
+ if (previousFocusRef.current) {
110
+ focusValidator.expectFocusRestoration(
111
+ previousFocusRef.current,
112
+ "focus trap deactivation"
113
+ );
114
+ }
115
+ });
116
+ }
117
+ }
118
+ };
119
+ }, [isActive, onEscape, restoreFocus, autoFocus]);
120
+ return {
121
+ trapRef
122
+ };
123
+ };
124
+
125
+ // src/hooks/use-accessible-dialog.ts
126
+ var useAccessibleDialog = (props) => {
127
+ const {
128
+ isOpen,
129
+ onClose,
130
+ title,
131
+ description,
132
+ role = "dialog",
133
+ isModal = true,
134
+ closeOnBackdropClick = true
135
+ } = props;
136
+ {
137
+ if (!title || title.trim().length === 0) {
138
+ throw new Error(
139
+ '@a13y/react [useAccessibleDialog]: "title" prop is required for accessibility'
140
+ );
141
+ }
142
+ }
143
+ const dialogRef = useRef(null);
144
+ const titleId = useId();
145
+ const descriptionId = useId();
146
+ const { trapRef } = useFocusTrap({
147
+ isActive: isOpen,
148
+ onEscape: onClose,
149
+ restoreFocus: true,
150
+ autoFocus: true
151
+ });
152
+ useEffect(() => {
153
+ if (dialogRef.current && trapRef.current !== dialogRef.current) {
154
+ trapRef.current = dialogRef.current;
155
+ }
156
+ }, [trapRef]);
157
+ useEffect(() => {
158
+ if (!isOpen || !isModal) {
159
+ return;
160
+ }
161
+ const originalOverflow = document.body.style.overflow;
162
+ document.body.style.overflow = "hidden";
163
+ return () => {
164
+ document.body.style.overflow = originalOverflow;
165
+ };
166
+ }, [isOpen, isModal]);
167
+ useEffect(() => {
168
+ if (isOpen) {
169
+ import('@a13y/devtools/runtime/invariants').then(
170
+ ({ assertHasAccessibleName, assertValidAriaAttributes }) => {
171
+ if (dialogRef.current) {
172
+ assertHasAccessibleName(dialogRef.current, "useAccessibleDialog");
173
+ assertValidAriaAttributes(dialogRef.current);
174
+ }
175
+ }
176
+ );
177
+ }
178
+ }, [isOpen]);
179
+ const dialogProps = {
180
+ ref: dialogRef,
181
+ role,
182
+ "aria-labelledby": titleId,
183
+ "aria-describedby": description ? descriptionId : void 0,
184
+ "aria-modal": isModal,
185
+ tabIndex: -1
186
+ };
187
+ const titleProps = {
188
+ id: titleId
189
+ };
190
+ const descriptionProps = description ? { id: descriptionId } : null;
191
+ const backdropProps = closeOnBackdropClick && isModal ? {
192
+ onClick: onClose,
193
+ "aria-hidden": true
194
+ } : null;
195
+ return {
196
+ dialogProps,
197
+ titleProps,
198
+ descriptionProps,
199
+ backdropProps,
200
+ close: onClose
201
+ };
202
+ };
203
+ var useKeyboardNavigation = (props) => {
204
+ const {
205
+ orientation,
206
+ loop = false,
207
+ onNavigate,
208
+ defaultIndex = 0,
209
+ currentIndex: controlledIndex
210
+ } = props;
211
+ const isControlled = controlledIndex !== void 0;
212
+ const [uncontrolledIndex, setUncontrolledIndex] = useState(defaultIndex);
213
+ const currentIndex = isControlled ? controlledIndex : uncontrolledIndex;
214
+ const itemsRef = useRef(/* @__PURE__ */ new Map());
215
+ const containerRef = useRef(null);
216
+ const setCurrentIndex = useCallback(
217
+ (index) => {
218
+ if (!isControlled) {
219
+ setUncontrolledIndex(index);
220
+ }
221
+ onNavigate?.(index);
222
+ const element = itemsRef.current.get(index);
223
+ if (element) {
224
+ element.focus();
225
+ }
226
+ },
227
+ [isControlled, onNavigate]
228
+ );
229
+ const navigate = useCallback(
230
+ (direction) => {
231
+ const itemCount = itemsRef.current.size;
232
+ if (itemCount === 0) {
233
+ return;
234
+ }
235
+ let nextIndex = currentIndex;
236
+ switch (direction) {
237
+ case "forward":
238
+ nextIndex = currentIndex + 1;
239
+ if (nextIndex >= itemCount) {
240
+ nextIndex = loop ? 0 : itemCount - 1;
241
+ }
242
+ break;
243
+ case "backward":
244
+ nextIndex = currentIndex - 1;
245
+ if (nextIndex < 0) {
246
+ nextIndex = loop ? itemCount - 1 : 0;
247
+ }
248
+ break;
249
+ case "first":
250
+ nextIndex = 0;
251
+ break;
252
+ case "last":
253
+ nextIndex = itemCount - 1;
254
+ break;
255
+ }
256
+ if (nextIndex !== currentIndex) {
257
+ setCurrentIndex(nextIndex);
258
+ }
259
+ },
260
+ [currentIndex, loop, setCurrentIndex]
261
+ );
262
+ const handleKeyDown = useCallback(
263
+ (event) => {
264
+ const { key } = event;
265
+ let direction = null;
266
+ if (key === "ArrowRight") {
267
+ if (orientation === "horizontal" || orientation === "both") {
268
+ direction = "forward";
269
+ }
270
+ } else if (key === "ArrowLeft") {
271
+ if (orientation === "horizontal" || orientation === "both") {
272
+ direction = "backward";
273
+ }
274
+ } else if (key === "ArrowDown") {
275
+ if (orientation === "vertical" || orientation === "both") {
276
+ direction = "forward";
277
+ }
278
+ } else if (key === "ArrowUp") {
279
+ if (orientation === "vertical" || orientation === "both") {
280
+ direction = "backward";
281
+ }
282
+ } else if (key === "Home") {
283
+ direction = "first";
284
+ } else if (key === "End") {
285
+ direction = "last";
286
+ }
287
+ if (direction) {
288
+ event.preventDefault();
289
+ navigate(direction);
290
+ }
291
+ },
292
+ [orientation, navigate]
293
+ );
294
+ const getItemProps = useCallback(
295
+ (index) => {
296
+ return {
297
+ ref: (element) => {
298
+ if (element) {
299
+ itemsRef.current.set(index, element);
300
+ } else {
301
+ itemsRef.current.delete(index);
302
+ }
303
+ },
304
+ tabIndex: index === currentIndex ? 0 : -1,
305
+ onKeyDown: handleKeyDown,
306
+ "data-index": index
307
+ };
308
+ },
309
+ [currentIndex, handleKeyDown]
310
+ );
311
+ useEffect(() => {
312
+ {
313
+ import('@a13y/devtools/runtime/validators').then(({ keyboardValidator }) => {
314
+ if (containerRef.current) {
315
+ keyboardValidator.validateContainer(containerRef.current);
316
+ }
317
+ const container = Array.from(itemsRef.current.values())[0]?.parentElement;
318
+ if (container) {
319
+ keyboardValidator.validateRovingTabindex(container);
320
+ }
321
+ });
322
+ }
323
+ }, []);
324
+ const containerProps = {
325
+ role: "toolbar",
326
+ "aria-orientation": orientation
327
+ };
328
+ return {
329
+ currentIndex,
330
+ setCurrentIndex,
331
+ getItemProps,
332
+ containerProps
333
+ };
334
+ };
335
+ var useAccessibleForm = (config) => {
336
+ const {
337
+ fields,
338
+ validate: formValidator,
339
+ onSubmit,
340
+ autoFocusError = true,
341
+ announceErrors = true,
342
+ validateOnBlur = true,
343
+ validateOnChange = true
344
+ } = config;
345
+ const initialValues = Object.keys(fields).reduce((acc, key) => {
346
+ acc[key] = fields[key].initialValue;
347
+ return acc;
348
+ }, {});
349
+ const [values, setValues] = useState(initialValues);
350
+ const [errors, setErrors] = useState({});
351
+ const [touched, setTouched] = useState({});
352
+ const [isSubmitting, setIsSubmitting] = useState(false);
353
+ const [hasSubmitted, setHasSubmitted] = useState(false);
354
+ const fieldRefs = useRef(/* @__PURE__ */ new Map());
355
+ const validateField = useCallback(
356
+ (name) => {
357
+ const fieldConfig = fields[name];
358
+ const value = values[name];
359
+ if (fieldConfig.required) {
360
+ const isEmpty = value === "" || value === null || value === void 0 || Array.isArray(value) && value.length === 0;
361
+ if (isEmpty) {
362
+ const errorMessage = fieldConfig.requiredMessage || `${String(name)} is required`;
363
+ setErrors((prev) => ({ ...prev, [name]: errorMessage }));
364
+ return false;
365
+ }
366
+ }
367
+ if (fieldConfig.validate) {
368
+ const result = fieldConfig.validate(value);
369
+ if (result !== true) {
370
+ setErrors((prev) => ({ ...prev, [name]: result }));
371
+ return false;
372
+ }
373
+ }
374
+ setErrors((prev) => {
375
+ const newErrors = { ...prev };
376
+ delete newErrors[name];
377
+ return newErrors;
378
+ });
379
+ return true;
380
+ },
381
+ [fields, values]
382
+ );
383
+ const validateForm = useCallback(() => {
384
+ let isValid2 = true;
385
+ const newErrors = {};
386
+ for (const name of Object.keys(fields)) {
387
+ const fieldConfig = fields[name];
388
+ const value = values[name];
389
+ if (fieldConfig.required) {
390
+ const isEmpty = value === "" || value === null || value === void 0 || Array.isArray(value) && value.length === 0;
391
+ if (isEmpty) {
392
+ const errorMessage = fieldConfig.requiredMessage || `${String(name)} is required`;
393
+ newErrors[name] = errorMessage;
394
+ isValid2 = false;
395
+ continue;
396
+ }
397
+ }
398
+ if (fieldConfig.validate) {
399
+ const result = fieldConfig.validate(value);
400
+ if (result !== true) {
401
+ newErrors[name] = result;
402
+ isValid2 = false;
403
+ }
404
+ }
405
+ }
406
+ if (formValidator) {
407
+ const formErrors = formValidator(values);
408
+ if (formErrors) {
409
+ Object.assign(newErrors, formErrors);
410
+ isValid2 = false;
411
+ }
412
+ }
413
+ setErrors(newErrors);
414
+ return isValid2;
415
+ }, [fields, values, formValidator]);
416
+ const setFieldValue = useCallback((name, value) => {
417
+ setValues((prev) => ({ ...prev, [name]: value }));
418
+ }, []);
419
+ const setFieldError = useCallback((name, error) => {
420
+ setErrors((prev) => ({ ...prev, [name]: error }));
421
+ }, []);
422
+ const getFieldProps = useCallback(
423
+ (name, options) => {
424
+ const fieldConfig = fields[name];
425
+ const hasError = !!errors[name];
426
+ const errorId = `${String(name)}-error`;
427
+ return {
428
+ name: String(name),
429
+ value: values[name],
430
+ onChange: (value) => {
431
+ setFieldValue(name, value);
432
+ if (validateOnChange && touched[name]) {
433
+ validateField(name);
434
+ }
435
+ },
436
+ onBlur: () => {
437
+ setTouched((prev) => ({ ...prev, [name]: true }));
438
+ if (validateOnBlur) {
439
+ validateField(name);
440
+ }
441
+ },
442
+ "aria-invalid": hasError,
443
+ "aria-describedby": hasError ? options?.["aria-describedby"] ? `${errorId} ${options["aria-describedby"]}` : errorId : options?.["aria-describedby"],
444
+ "aria-required": fieldConfig.required
445
+ };
446
+ },
447
+ [
448
+ fields,
449
+ values,
450
+ errors,
451
+ touched,
452
+ setFieldValue,
453
+ validateField,
454
+ validateOnBlur,
455
+ validateOnChange
456
+ ]
457
+ );
458
+ const handleSubmit = useCallback(
459
+ async (e) => {
460
+ e?.preventDefault();
461
+ setHasSubmitted(true);
462
+ const isValid2 = validateForm();
463
+ if (!isValid2) {
464
+ const errorCount = Object.keys(errors).length;
465
+ if (announceErrors) {
466
+ const errorMessage = errorCount === 1 ? "Form has 1 error. Please correct it and try again." : `Form has ${errorCount} errors. Please correct them and try again.`;
467
+ announce(errorMessage, { politeness: "assertive" });
468
+ }
469
+ if (autoFocusError) {
470
+ const firstErrorField = Object.keys(errors)[0];
471
+ const fieldElement = fieldRefs.current.get(firstErrorField);
472
+ if (fieldElement) {
473
+ fieldElement.focus();
474
+ }
475
+ }
476
+ return;
477
+ }
478
+ setIsSubmitting(true);
479
+ try {
480
+ await onSubmit(values);
481
+ announce("Form submitted successfully", { politeness: "polite" });
482
+ } catch (error) {
483
+ if (announceErrors) {
484
+ announce("Form submission failed. Please try again.", {
485
+ politeness: "assertive"
486
+ });
487
+ }
488
+ throw error;
489
+ } finally {
490
+ setIsSubmitting(false);
491
+ }
492
+ },
493
+ [validateForm, errors, announceErrors, autoFocusError, onSubmit, values]
494
+ );
495
+ const reset = useCallback(() => {
496
+ setValues(initialValues);
497
+ setErrors({});
498
+ setTouched({});
499
+ setHasSubmitted(false);
500
+ }, [initialValues]);
501
+ const clearErrors = useCallback(() => {
502
+ setErrors({});
503
+ }, []);
504
+ const isValid = Object.keys(errors).length === 0;
505
+ return {
506
+ state: {
507
+ values,
508
+ errors,
509
+ touched,
510
+ isSubmitting,
511
+ isValid,
512
+ hasSubmitted
513
+ },
514
+ getFieldProps,
515
+ setFieldValue,
516
+ setFieldError,
517
+ setErrors,
518
+ validateField,
519
+ validateForm,
520
+ handleSubmit,
521
+ reset,
522
+ clearErrors,
523
+ fieldRefs: fieldRefs.current
524
+ };
525
+ };
526
+ var useFormField = (props) => {
527
+ const {
528
+ label,
529
+ initialValue = "",
530
+ validate: validator,
531
+ required = false,
532
+ requiredMessage,
533
+ helpText,
534
+ validateOnBlur = true,
535
+ validateOnChange = true,
536
+ announceErrors = true,
537
+ onChange,
538
+ onBlur
539
+ } = props;
540
+ {
541
+ if (!label || label.trim().length === 0) {
542
+ throw new Error('@a13y/react [useFormField]: "label" prop is required for accessibility');
543
+ }
544
+ }
545
+ const [value, setValue] = useState(initialValue);
546
+ const [error, setError] = useState(null);
547
+ const [isTouched, setIsTouched] = useState(false);
548
+ const fieldRef = useRef(null);
549
+ const id = useId();
550
+ const labelId = `${id}-label`;
551
+ const errorId = `${id}-error`;
552
+ const helpTextId = `${id}-help`;
553
+ const validate = useCallback(() => {
554
+ if (required) {
555
+ const isEmpty = value === "" || value === null || value === void 0 || Array.isArray(value) && value.length === 0;
556
+ if (isEmpty) {
557
+ const errorMessage = requiredMessage || `${label} is required`;
558
+ setError(errorMessage);
559
+ return false;
560
+ }
561
+ }
562
+ if (validator) {
563
+ const result = validator(value);
564
+ if (result !== true) {
565
+ setError(result);
566
+ return false;
567
+ }
568
+ }
569
+ setError(null);
570
+ return true;
571
+ }, [value, required, validator, label, requiredMessage]);
572
+ useEffect(() => {
573
+ if (error && isTouched && announceErrors) {
574
+ announce(error, { politeness: "assertive", delay: 100 });
575
+ }
576
+ }, [error, isTouched, announceErrors]);
577
+ const handleChange = useCallback(
578
+ (newValue) => {
579
+ setValue(newValue);
580
+ onChange?.(newValue);
581
+ if (validateOnChange && isTouched) {
582
+ setTimeout(() => validate(), 0);
583
+ }
584
+ },
585
+ [onChange, validateOnChange, isTouched, validate]
586
+ );
587
+ const handleBlur = useCallback(() => {
588
+ setIsTouched(true);
589
+ onBlur?.();
590
+ if (validateOnBlur) {
591
+ validate();
592
+ }
593
+ }, [onBlur, validateOnBlur, validate]);
594
+ const clearError = useCallback(() => {
595
+ setError(null);
596
+ }, []);
597
+ const reset = useCallback(() => {
598
+ setValue(initialValue);
599
+ setError(null);
600
+ setIsTouched(false);
601
+ }, [initialValue]);
602
+ const isValid = error === null;
603
+ const describedBy = [helpText ? helpTextId : null, error ? errorId : null].filter(Boolean).join(" ");
604
+ return {
605
+ id,
606
+ labelId,
607
+ errorId,
608
+ helpTextId,
609
+ value,
610
+ error,
611
+ isTouched,
612
+ isValid,
613
+ setValue: handleChange,
614
+ setError,
615
+ validate,
616
+ clearError,
617
+ reset,
618
+ labelProps: {
619
+ id: labelId,
620
+ htmlFor: id
621
+ },
622
+ inputProps: {
623
+ id,
624
+ name: id,
625
+ value,
626
+ onChange: handleChange,
627
+ onBlur: handleBlur,
628
+ "aria-labelledby": labelId,
629
+ "aria-describedby": describedBy || void 0,
630
+ "aria-invalid": !isValid,
631
+ "aria-required": required ? true : void 0,
632
+ ref: fieldRef
633
+ },
634
+ errorProps: {
635
+ id: errorId,
636
+ role: "alert",
637
+ "aria-live": "polite"
638
+ },
639
+ helpTextProps: {
640
+ id: helpTextId
641
+ },
642
+ fieldRef
643
+ };
644
+ };
645
+
646
+ export { useAccessibleButton, useAccessibleDialog, useAccessibleForm, useFocusTrap, useFormField, useKeyboardNavigation };
647
+ //# sourceMappingURL=index.js.map
648
+ //# sourceMappingURL=index.js.map