-
Notifications
You must be signed in to change notification settings - Fork 1
/
index.ts
747 lines (677 loc) · 27 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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
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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
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
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
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
/* eslint-disable functional/no-loop-statement */
/* eslint-disable no-case-declarations */
/* eslint-disable functional/immutable-data */
/* eslint-disable functional/functional-parameters */
/* eslint-disable functional/no-try-statement */
/* eslint-disable functional/no-expression-statement */
/* eslint-disable functional/no-conditional-statement */
/* eslint-disable functional/no-return-void */
// `ExcludeStrict` from https://github.com/pelotom/type-zoo
// Copyright (c) 2017 Thomas Crockett
// MIT License
/**
* Exclude from `T` those types that are assignable to `U`, where `U` must exist in `T`.
*
* Similar to `Exclude` but requires the exclusion list to be composed of valid members of `T`.
*
* @see https://github.com/pelotom/type-zoo/issues/37
*/
export type ExcludeStrict<T, U extends T> = T extends U ? never : T;
/**
* A CSS selector.
*/
export type Selector = string;
/**
* A hash fragment, including the leading # character, e.g. "#", "#top" or "#my-heading-id"
*/
export type Hash = string;
/**
* ARIA live region politeness values.
* See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions
*/
export type AriaLivePoliteness = "off" | "polite" | "assertive";
/**
* Specifies an element that is the target of a side effect (e.g. scroll into view, focus). This is
* either the element itself or a selector that will return the element when passed to querySelector().
*/
export type Target = Element | Selector;
/**
* The scroll position as x and y coordinates.
*/
export type ScrollPosition = { readonly x: number; readonly y: number };
export const targetToString = (target: Target): string =>
typeof target === "string" ? target : target.nodeName.toLowerCase();
/**
* Maps from a URL hash fragment to a target element.
*
* Supports "#", "#top" and element IDs. The empty string returns undefined.
*
* Useful for scrolling to the element referred to by the hash fragment
* in a URL (which browsers do natively, but single page apps often don't).
*
* See https://github.com/rafrex/react-router-hash-link (only manages scroll, not focus)
* See https://github.com/ReactTraining/react-router/issues/394
* See https://www.w3.org/TR/html5/single-page.html#scroll-to-the-fragment
*
* @param hash the hash fragment, including the leading # character, e.g. "#", "#top" or "#my-heading-id"
*/
export const elementFromHash = (hash: Hash): Element | undefined => {
if (typeof hash !== "string") {
return undefined;
}
switch (hash) {
case "#":
return (
// documentElement can in fact be undefined in some old browsers.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
document.documentElement ?? document.body.parentElement ?? document.body
);
case "":
return undefined;
default:
const element = document.getElementById(hash.substring(1));
if (element !== null) {
return element;
} else if (hash === "#top") {
return (
// documentElement can in fact be undefined in some old browsers.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
document.documentElement ??
document.body.parentElement ??
document.body
);
} else {
return undefined;
}
}
};
/**
* Set the document title.
* See https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-title.html
*
* @param title The new document title.
*/
export const setTitle = (title: string): void => {
if (typeof title !== "string" || title.trim() === "") {
console.error(
`Title [${title}] is invalid. See https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-title.html`,
);
}
document.title = title;
};
/**
* True if the specified element is within the viewport, false otherwise.
* See https://stackoverflow.com/questions/123999/how-to-tell-if-a-dom-element-is-visible-in-the-current-viewport/7557433#7557433
*
* @param element the element to test
*/
export const isInViewport = (element: Element): boolean => {
const rect = element.getBoundingClientRect();
// TODO: handle overflow-scroll in element's container?
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
// innerHeight can in fact be undefined in some old browsers.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(window.innerHeight ?? document.documentElement.clientHeight) &&
// innerHeight can in fact be undefined in some old browsers.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
rect.right <= (window.innerWidth ?? document.documentElement.clientWidth)
);
};
const querySelector = (
selectors: Selector,
parent: ParentNode = document,
): Element | undefined => {
try {
const result = parent.querySelector(selectors);
return result === null ? undefined : result;
} catch {
console.warn(`Syntax error in selector [${selectors}].`);
return undefined;
}
};
export const elementFromTarget = (
target: Target,
parent: ParentNode = document,
): Element | undefined => {
return target instanceof Element ? target : querySelector(target, parent);
};
export const getScrollPosition = (): ScrollPosition => {
// See https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollX#Notes
const documentElement =
// documentElement can in fact be undefined in some old browsers.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
document.documentElement ?? document.body.parentNode ?? document.body;
// scrollX can in fact be undefined in some old browsers.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const x = window.scrollX ?? window.pageXOffset ?? documentElement.scrollLeft;
// scrollY can in fact be undefined in some old browsers.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const y = window.scrollY ?? window.pageYOffset ?? documentElement.scrollTop;
return { x, y };
};
/**
* True if the user prefers reduced motion, false otherwise.
*
* See https://css-tricks.com/introduction-reduced-motion-media-query/
*/
export const prefersReducedMotion = (): boolean => {
// See https://caniuse.com/#feat=matchmedia
return (
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
};
/**
* Scrolls the window to the given scroll position.
*
* For smooth scrolling behavior you might want to use the smoothscroll
* polyfill http://iamdustan.com/smoothscroll/
*
* If the user has indicated that they prefer reduced motion, the smoothScroll value will be ignored.
*
* @param scrollPosition the scroll position to scroll to
* @param smoothScroll true for smooth scrolling, false otherwise
*/
export const setScrollPosition = (
scrollPosition: ScrollPosition,
smoothScroll = false,
): void => {
if (!smoothScroll || prefersReducedMotion()) {
// Use old form of scrollTo() (when we can) to maximize browser compatibility.
window.scrollTo(scrollPosition.x, scrollPosition.y);
} else {
try {
window.scrollTo({
behavior: "smooth",
left: scrollPosition.x,
top: scrollPosition.y,
});
} catch {
// If scrollTo with options throws, fall back on old form.
// See https://github.com/Fyrd/caniuse/issues/1760
// See https://github.com/frontarm/navi/issues/71
// See https://github.com/frontarm/navi/pull/84/files
window.scrollTo(scrollPosition.x, scrollPosition.y);
}
}
};
const getScrollPositionRestorer = (): (() => void) => {
const scrollPosition = getScrollPosition();
return () => {
setScrollPosition(scrollPosition);
};
};
/**
* Executes a function that may (undesirably) change the window's scroll position
* and then restores the window scroll position and scroll behavior.
* @param funcWithScrollSideEffect a function to execute that may (undesirably) change the window's scroll position
*/
const withRestoreScrollPosition = <T>(funcWithScrollSideEffect: () => T): T => {
const restoreScrollPosition = getScrollPositionRestorer();
const result = funcWithScrollSideEffect();
restoreScrollPosition();
return result;
};
/**
* Focuses an element, setting `tabindex="-1"` if necessary.
*
* @param target the element to focus.
* @param preventScroll true if the browser should not scroll the target element into view, false otherwise.
*/
export const focusElement = async (
target: Target,
preventScroll = false,
): Promise<boolean> => {
// See: https://developer.paciellogroup.com/blog/2014/08/using-the-tabindex-attribute/
// See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#Browser_compatibility
// See: https://github.com/whatwg/html/issues/834
// See: https://stackoverflow.com/questions/4963053/focus-to-input-without-scrolling/6610501
const element = elementFromTarget(target);
if (element === undefined) {
console.warn(
`Cannot focus element. Element [${targetToString(target)}] not found.`,
);
return Promise.resolve(false);
}
if (!(element instanceof HTMLElement || element instanceof SVGElement)) {
console.warn(
`Cannot focus element. Element [${targetToString(
target,
)}] is not an HTMLElement or SVGElement.`,
);
return Promise.resolve(false);
}
try {
// Set tabindex="-1" if necessary.
// TODO avoid setting tabindex when we're confident we don't need to?
if (!element.hasAttribute("tabindex")) {
element.setAttribute("tabindex", "-1");
// We remove tabindex after blur to avoid weird browser behavior
// where a mouse click can activate elements with tabindex="-1".
const blurListener = (): void => {
element.removeAttribute("tabindex");
element.removeEventListener("blur", blurListener);
};
element.addEventListener("blur", blurListener);
}
if (preventScroll) {
// preventScroll has poor browser support, so we restore scroll manually after setting focus.
// see https://caniuse.com/#feat=mdn-api_htmlelement_focus_preventscroll_option
// TODO detect if browser supports preventScroll and avoid `withRestoreScrollPosition`
// shenanigans if so.
withRestoreScrollPosition(() => {
try {
element.focus({ preventScroll: true });
} catch {
// If focus() with options throws, fall back on calling focus() without any arguments.
element.focus();
}
});
} else {
// Avoid passing anything to focus() (when we can) to maximize browser compatibility.
element.focus();
}
return document.activeElement === element;
} catch (e: unknown) {
// Apparently trying to focus a disabled element in IE can throw.
// See https://stackoverflow.com/a/1600194/2476884
console.error(e);
return false;
}
};
/**
* Scrolls an element into view.
*
* For smooth scrolling behavior you might want to use the smoothscroll
* polyfill http://iamdustan.com/smoothscroll/
*
* If the user has indicated that they prefer reduced motion, the smoothScroll value will be ignored.
*
* @param element the element to scroll into view
* @param smoothScroll true for smooth scrolling, false otherwise
*/
export const scrollIntoView = (
element: Element,
smoothScroll = false,
): void => {
// TODO support ScrollIntoViewOptions and respect block and inline even when not smooth scrolling.
// Scrolling to the document element or the body is problematic
// for a few reasons so we just scroll to `0, 0` instead.
// See e.g. https://github.com/iamdustan/smoothscroll/issues/138
if (element === document.documentElement || element === document.body) {
setScrollPosition({ x: 0, y: 0 }, smoothScroll);
} else {
if (!smoothScroll || prefersReducedMotion()) {
// Avoid passing anything to scrollIntoView() (when we can) to maximize browser compatibility.
element.scrollIntoView();
} else {
try {
element.scrollIntoView({ behavior: "smooth" });
} catch {
// If scrollIntoView with options throws, fall back on no options.
// See https://github.com/frontarm/navi/issues/71
element.scrollIntoView();
}
}
}
};
/**
* Scrolls an element into view if it is not currently visible.
*
* For smooth scrolling behavior you might want to use the smoothscroll
* polyfill http://iamdustan.com/smoothscroll/
*
* If the user has indicated that they prefer reduced motion, the smoothScroll value will be ignored.
*
* @param target the element to scroll into view
* @param smoothScroll true for smooth scrolling, false otherwise
*/
export const scrollIntoViewIfRequired = (
target: Target,
smoothScroll = false,
inViewport: typeof isInViewport = isInViewport,
): void => {
const element = elementFromTarget(target);
if (element !== undefined && !inViewport(element)) {
scrollIntoView(element, smoothScroll);
}
};
/**
* Focuses a specified element and then scrolls it (or another element) into view (if required).
*
* For smooth scrolling behavior you might want to use the smoothscroll
* polyfill http://iamdustan.com/smoothscroll/
*
* If the user has indicated that they prefer reduced motion, the smoothScroll value will be ignored.
*
* @param focusTarget the element to focus
* @param scrollIntoViewTarget the element to scroll into view
* @param smoothScroll true for smooth scrolling, false otherwise
*/
export const focusAndScrollIntoViewIfRequired = async (
focusTarget: Target,
scrollIntoViewTarget: Target,
smoothScroll = false,
): Promise<boolean> => {
const elementToFocus = elementFromTarget(focusTarget);
const elementToScrollIntoView =
elementFromTarget(scrollIntoViewTarget) ?? elementToFocus;
// See https://css-tricks.com/smooth-scrolling-accessibility/
// See https://github.com/whatwg/html/issues/834
// See https://stackoverflow.com/questions/4963053/focus-to-input-without-scrolling/6610501
// If we're not smooth scrolling and
// elementToFocus === elementToScrollIntoView then we can
// avoid preventScroll shenanigans.
const preventScroll =
smoothScroll || elementToFocus !== elementToScrollIntoView;
// Focus the element for keyboard users and users of assistive technology.
const result =
elementToFocus !== undefined &&
(await focusElement(elementToFocus, preventScroll));
if (elementToScrollIntoView !== undefined) {
// For screen users, scroll the element into view.
scrollIntoViewIfRequired(elementToScrollIntoView, smoothScroll);
}
return result;
};
/**
* Resets focus and scroll position after a SPA page navigation.
*
* Will attempt to move focus to the focusTarget, primaryFocusTarget,
* document element and finally document body, in that order. If any of
* those elements do not exist or cannot be focused, will attempt to
* focus the next fallback element.
*
* For smooth scrolling behavior you might want to use the smoothscroll
* polyfill http://iamdustan.com/smoothscroll/
*
* If the user has indicated that they prefer reduced motion, the smoothScroll value will be ignored.
*
* See: https://github.com/ReactTraining/react-router/issues/5210
*
* @param primaryFocusTarget a CSS selector for your primary focus target,
* e.g. `main h1`.
* @param focusTarget the element to focus, e.g. the element identified by
* the hash fragment of the URL.
* @param smoothScroll true for smooth scrolling, false otherwise
*/
export const resetFocus = async (
primaryFocusTarget: Selector,
focusTarget?: Target,
smoothScroll = false,
): Promise<boolean> => {
const elementToFocus =
focusTarget !== undefined ? elementFromTarget(focusTarget) : undefined;
const primaryFocusElement = elementFromTarget(primaryFocusTarget);
const targets: readonly (Element | undefined)[] = [
elementToFocus,
primaryFocusElement,
document.documentElement,
document.body,
];
for (const targetElement of targets) {
if (targetElement instanceof Element) {
try {
// eslint-disable-next-line no-await-in-loop
const didFocus = await focusAndScrollIntoViewIfRequired(
targetElement,
targetElement,
smoothScroll,
);
if (didFocus) {
return true;
}
} catch (e: unknown) {
console.error(e);
}
}
}
return false;
};
const createAnnounceDiv = (
id: string,
politeness: ExcludeStrict<AriaLivePoliteness, "off">,
): HTMLDivElement => {
const div = document.createElement("div");
div.setAttribute("id", id);
div.setAttribute("role", "status");
div.setAttribute("aria-live", politeness);
div.setAttribute("aria-atomic", "true");
// As per Bootstrap's visually-hidden styles.
// See: https://a11yproject.com/posts/how-to-hide-content/
// See: https://hugogiraudel.com/2016/10/13/css-hide-and-seek/
// See: https://github.com/twbs/bootstrap/blob/1df098361cac04217d6a464c80e890c4335ecb5c/scss/mixins/_visually-hidden.scss#L8-L18
div.style.position = "absolute";
div.style.width = "1px";
div.style.height = "1px";
div.style.padding = "0";
div.style.margin = "-1px";
div.style.overflow = "hidden";
div.style.clip = "rect(0, 0, 0, 0)";
div.style.whiteSpace = "nowrap";
div.style.border = "0";
document.body.appendChild(div);
return div;
};
/**
* Make an announcement to screen reader users. Useful for page navigation events.
*
* See https://almerosteyn.com/2017/03/accessible-react-navigation
* See https://getbootstrap.com/docs/4.3/utilities/screen-readers/
* See https://github.com/twbs/bootstrap/blob/ff29c1224c20b8fcf2d1e7c28426470f1dc3e40d/scss/mixins/_screen-reader.scss#L6
*
* @param message the message to announce to screen reader users, e.g. "navigated to about page".
* @param announcementsDivId a DOM ID of the visually hidden announcements element, e.g. "announcements".
*/
export const announce = (
message: string,
announcementsDivId = "announcements",
setMessageTimeout = 50,
clearMessageTimeout = 500,
politeness: ExcludeStrict<AriaLivePoliteness, "off"> = "polite",
): Promise<unknown> => {
const announceDiv =
document.getElementById(announcementsDivId) ??
createAnnounceDiv(announcementsDivId, politeness);
const p1 = new Promise((resolve) => {
setTimeout(() => {
announceDiv.innerText = message;
resolve(undefined);
}, setMessageTimeout);
});
const p2 = new Promise((resolve) => {
setTimeout(() => {
announceDiv.innerText = "";
resolve(undefined);
}, clearMessageTimeout);
});
return Promise.all([p1, p2]);
};
/* eslint-disable sonarjs/cognitive-complexity */
/**
* Hide the on-screen keyboard on touch devices like iOS and Android.
*
* It's useful to do this after a form submission that doesn't navigate away from the
* current page but does update some part of the current page (e.g. dynamically updated
* search results). If you weren't to do this the user might not be shown any feedback
* in response to their action (form submission), because it is obscured by the keyboard.
*
* To hide the keyboard we temporarily set the active input or textarea to readonly and
* disabled. To avoid a flash of readonly/disabled styles (often a gray background) you
* can hook into the [data-oaf-keyboard-hack] html attribute. For example:
*
* ```
* // Readonly/disabled styles shouldn't be applied when this attribute is present.
* [data-oaf-keyboard-hack] {
* background-color: $input-bg !important;
* }
* ```
*
* Note that lots of people simply `blur()` the focused input to achieve this result
* but doing that is hostile to keyboard users and users of other AT.
*
* Do you need to use this?
*
* 1. If your form submission triggers a full page reload, you don't need this.
* 2. If your form submission explicitly moves focus to some other element, you
* don't need this. For example you might move focus to some new content that
* was loaded as a result of the form submission or to a loading message.
* 3. If your form submission leaves focus where it is, you probably want this.
*/
export const hideOnscreenKeyboard = (): Promise<void> => {
// TODO: use inputmode="none"?
const activeElement = document.activeElement;
const inputType =
activeElement instanceof HTMLInputElement
? activeElement.getAttribute("type")
: undefined;
if (
activeElement !== null &&
activeElement instanceof HTMLElement &&
// Don't bother with input types that we know don't trigger an OSK.
inputType !== "checkbox" &&
inputType !== "radio" &&
inputType !== "submit" &&
inputType !== "reset" &&
inputType !== "button"
) {
// Blur the active element to dismiss the on-screen keyboard.
activeElement.blur();
// Set an attribute that allows users to override readonly/disabled styles via CSS.
// This input will be readonly/disabled for only a fraction of a second and we
// want to avoid the flash of readonly/disabled styles.
activeElement.setAttribute("data-oaf-keyboard-hack", "true");
// Some older Android browsers need extra encouragement.
// See https://stackoverflow.com/a/11160055/2476884
const originalReadonly = activeElement.getAttribute("readonly");
const originalDisabled = activeElement.getAttribute("disabled");
activeElement.setAttribute("readonly", "true");
if (activeElement instanceof HTMLTextAreaElement) {
activeElement.setAttribute("disabled", "true");
}
return new Promise((resolve) => {
setTimeout(() => {
// Put things back the way we found them.
originalReadonly !== null
? activeElement.setAttribute("readonly", originalReadonly)
: activeElement.removeAttribute("readonly");
if (activeElement instanceof HTMLTextAreaElement) {
originalDisabled !== null
? activeElement.setAttribute("disabled", originalDisabled)
: activeElement.removeAttribute("disabled");
}
activeElement.removeAttribute("data-oaf-keyboard-hack");
// Restore focus back to where it was. Lots of people forget to do this.
// Note that programmatically calling focus() will not trigger the
// on-screen keyboard to reemerge.
activeElement.focus();
resolve();
});
});
} else {
return Promise.resolve();
}
};
/* eslint-enable sonarjs/cognitive-complexity */
/**
* Like `closest()` but stops ascending the ancestor tree once it hits the specified form element.
*/
export const closestInsideForm = (
element: Element,
selector: Selector,
form: Element,
): Element | undefined => {
if (element === form || element.parentElement === null) {
return undefined;
} else if (element.matches(selector)) {
return element;
} else {
return closestInsideForm(element.parentElement, selector, form);
}
};
/**
* Focuses and scrolls into view the first invalid form element inside
* a given form. Attempts to hide the onscreen keyboard on touch devices so that
* the validation message near the first invalid form element is visible on screen.
*
* Call this function after you have validated a form and identified errors.
*
* See https://webaim.org/techniques/formvalidation/
*
* For smooth scrolling behavior you might want to use the smoothscroll
* polyfill http://iamdustan.com/smoothscroll/
*
* If the user has indicated that they prefer reduced motion, the smoothScroll value will be ignored.
*
* For IE support you might want to use the closest() polyfill from https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
*
* @param formTarget the form element to focus or a CSS selector that uniquely identifies the form to focus, e.g. `#search-form`.
* @param invalidElementSelector the CSS selector that is used to identify invalid elements within a form, e.g. `[aria-invalid="true"]`.
* @param elementWrapperSelector the CSS selector that matches the "wrapper" element--the closest ancestor of the form input--that contains
* both the form input and its label.
* This wrapper element will be scrolled into view so that both the invalid input and its label are visible.
* @param globalFormErrorSelector the CSS selector that matches the "global" form error, i.e. an element at the top of the form that contains
* an error message that isn't specific to a any given form input.
* @param smoothScroll true for smooth scrolling, false otherwise
*/
export const focusInvalidForm = async (
formTarget: Target,
invalidElementSelector: Selector,
elementWrapperSelector: Selector | undefined,
globalFormErrorSelector: Selector | undefined,
smoothScroll = false,
): Promise<boolean> => {
const form = elementFromTarget(formTarget);
if (form === undefined) {
console.warn(
`No form matching [${targetToString(
formTarget,
)}] found in document. Users of keyboards, screen readers and other assistive technology will have a degraded experience.`,
);
return Promise.resolve(false);
}
const firstInvalidElement = elementFromTarget(invalidElementSelector, form);
// Fall back on globalFormErrorElement if no firstInvalidElement found.
const globalFormErrorElement =
firstInvalidElement === undefined && globalFormErrorSelector !== undefined
? elementFromTarget(globalFormErrorSelector, form)
: undefined;
const elementToFocus = firstInvalidElement ?? globalFormErrorElement;
if (elementToFocus === undefined) {
// TODO: In this case should we focus and scroll to the form itself?
console.warn(
`No invalid form element matching [${targetToString(
invalidElementSelector,
)}] found inside form [${targetToString(
formTarget,
)}]. Users of keyboards, screen readers and other assistive technology will have a degraded experience.`,
);
return Promise.resolve(false);
}
// There's a common pitfall where focusing the invalid input scrolls the viewport back up
// only as far as the input, but the label remains just offscreen above the input (assuming the label
// is above the input and not beside it). At this point we're commonly below the input because
// we're at the bottom of the form where the submit button is, so this scenario is reasonably common.
// To avoid this we use a wrapper element that contains both the input and its label. We scroll the wrapper
// into view so the label is visible and then move focus to the input. If there is no wrapper we just
// scroll to the input itself.
const firstInvalidElementWrapper =
elementWrapperSelector !== undefined &&
firstInvalidElement !== undefined &&
typeof firstInvalidElement.matches === "function"
? closestInsideForm(firstInvalidElement, elementWrapperSelector, form)
: undefined;
// If the invalid element that we're about to focus _already_ has focus, then the onscreen
// keyboard is likely to remain visible, which risks obscuring the validation message displayed
// near the invalid input. We try to trick the browser into hiding the onscreen keyboard to
// avoid this situation and give the validation message the best chance of being visible.
await hideOnscreenKeyboard();
return focusAndScrollIntoViewIfRequired(
elementToFocus,
firstInvalidElementWrapper ?? elementToFocus,
smoothScroll,
);
};