11import { tick } from 'svelte' ;
22import type { ActionReturn } from 'svelte/action' ;
33
4- import { focusElement , hasProperty } from '../utils' ;
5-
6- function getTabIndex ( element : unknown ) : number | undefined {
7- return hasProperty ( element , 'tabIndex' ) &&
8- typeof element . tabIndex === 'number'
9- ? element . tabIndex
10- : undefined ;
11- }
12-
13- /**
14- * Yields focusable elements within a container. The elements are yielded in DOM
15- * order (depth first, pre-order).
16- */
17- function * getFocusableElementsInDomOrder (
18- container : Element ,
19- ) : Generator < { element : Element ; tabIndex : number } > {
20- // Cast a wide net with selector for potentially focusable elements
21- const selectors = [
22- 'input' ,
23- 'button' ,
24- 'select' ,
25- 'textarea' ,
26- 'a[href]' ,
27- 'area[href]' ,
28- 'iframe' ,
29- 'object' ,
30- 'embed' ,
31- '[tabindex]' ,
32- '[contenteditable="true"]' ,
33- '[contenteditable=""]' ,
34- 'audio[controls]' ,
35- 'video[controls]' ,
36- 'details' ,
37- 'summary' ,
38- ] ;
39- const potentiallyFocusable = container . querySelectorAll ( selectors . join ( ', ' ) ) ;
40-
41- for ( const element of potentiallyFocusable ) {
42- // Narrow the net by checking additional properties of each element...
43-
44- const tabIndex = getTabIndex ( element ) ;
45-
46- // Filter out elements which don't have a tabIndex property at all
47- if ( tabIndex === undefined ) continue ;
48-
49- // Filter out elements with negative tabIndex, e.g. div
50- if ( tabIndex < 0 ) continue ;
51-
52- // Filter out disabled elements
53- if ( hasProperty ( element , 'disabled' ) && element . disabled ) continue ;
54-
55- // Filter out elements that are not visible
56- const { display, visibility } = getComputedStyle ( element ) ;
57- if ( display === 'none' || visibility === 'hidden' ) continue ;
58-
59- // Filter out hidden input elements
60- if ( hasProperty ( element , 'type' ) && element . type === 'hidden' ) continue ;
61-
62- // Yield most elements with a valid tabIndex
63- yield { element, tabIndex } ;
64- }
4+ import { focusElement , getFocusableDescendants , hasMethod } from '../utils' ;
5+
6+ interface FocusTrapOptions {
7+ /**
8+ * Automatically focus the first focusable element when opening. True by
9+ * default.
10+ */
11+ autoFocus ?: boolean ;
12+ /**
13+ * Automatically re-focus the last-focused element when closing. True by
14+ * default.
15+ */
16+ autoRestore ?: boolean ;
6517}
6618
67- /**
68- * Returns the focusable elements within a container. The elements are returned
69- * sorted in tab order (i.e. the order of their tabIndex values primarily, and
70- * then in DOM when their tabIndex values are equal).
71- */
72- function getFocusableElements ( container : Element ) : Element [ ] {
73- const elements = Array . from ( getFocusableElementsInDomOrder ( container ) ) ;
74-
75- // Sort elements by tabIndex only, assuming stable sort keeps DOM order
76- elements . sort ( ( a , b ) => a . tabIndex - b . tabIndex ) ;
19+ export default function focusTrap (
20+ container : HTMLElement ,
21+ options : FocusTrapOptions = { } ,
22+ ) : ActionReturn {
23+ let previouslyFocusedElement : Element | null ;
7724
78- return elements . map ( ( { element } ) => element ) ;
79- }
25+ const fullOptions = {
26+ autoFocus : true ,
27+ autoRestore : true ,
28+ ...options ,
29+ } ;
8030
81- export default function focusTrap ( container : HTMLElement ) : ActionReturn {
8231 function handleKeyDown ( event : KeyboardEvent ) {
8332 if ( event . key !== 'Tab' ) return ;
8433 if ( event . altKey || event . ctrlKey || event . metaKey ) return ;
8534
35+ event . preventDefault ( ) ;
36+
8637 // We are re-computing the list of focusable elements any time Tab or
8738 // Shift+Tab is pressed. This is a somewhat expensive operation. But it's
8839 // necessary because the list of focusable elements can change as state
8940 // changes (e.g. elements being added/removed from the DOM or state like
9041 // "disabled" being toggled).
91- const elements = getFocusableElements ( container ) ;
42+ const elements = getFocusableDescendants ( container ) ;
43+ if ( ! elements . length ) return ;
9244
9345 function wrap ( index : number ) : number {
9446 return ( ( index % elements . length ) + elements . length ) % elements . length ;
@@ -100,39 +52,56 @@ export default function focusTrap(container: HTMLElement): ActionReturn {
10052 const targetElement = elements . at ( targetIndex ) ;
10153 if ( ! targetElement ) return ;
10254
103- event . preventDefault ( ) ;
10455 focusElement ( targetElement ) ;
10556 }
10657
107- async function autoFocusFirstElement ( ) {
108- await tick ( ) ; // Wait, in case Svelte has more to render (e.g. children)
109- const firstElement = getFocusableElements ( container ) . at ( 0 ) ;
110- if ( firstElement ) {
111- /*
112- * When the element is immediately focused, keydown events seem to get triggered
113- * on the element. This results in cases like lightboxes closing immediately after
114- * being opened when opened via an 'enter' keydown event.
115- *
116- * My hunch is that this bug has to do something with the way transitions/animations
117- * are executed by svelte/transition.
118- *
119- * Moving the focus task to a macrotask such as setTimeout seems to prevent this
120- * bug from occurring.
121- *
122- * The exact root cause / race condition is not entirely figured out.
123- */
124- setTimeout ( ( ) => {
125- focusElement ( firstElement ) ;
126- } , 10 ) ;
58+ async function initializeFocus ( ) {
59+ // Wait, in case Svelte has more to render (e.g. children) or in case custom
60+ // imperative focus logic has been applied (e.g. focusing the first cell
61+ // when clicking on a column header)
62+ await tick ( ) ;
63+
64+ previouslyFocusedElement = document . activeElement ;
65+
66+ if ( fullOptions . autoFocus ) {
67+ const firstElement = getFocusableDescendants ( container ) . at ( 0 ) ;
68+ if ( firstElement ) {
69+ /*
70+ * When the element is immediately focused, keydown events seem to get
71+ * triggered on the element. This results in cases like the lightbox
72+ * closing immediately after being opened when opened via an 'enter'
73+ * keydown event.
74+ *
75+ * My hunch is that this bug has to do something with the way
76+ * transitions/animations are executed by svelte/transition.
77+ *
78+ * Moving the focus task to a macrotask such as setTimeout seems to
79+ * prevent this bug from occurring.
80+ *
81+ * The exact root cause / race condition is not entirely figured out.
82+ */
83+ setTimeout ( ( ) => {
84+ focusElement ( firstElement ) ;
85+ } , 10 ) ;
86+ }
87+ } else if (
88+ previouslyFocusedElement &&
89+ hasMethod ( previouslyFocusedElement , 'blur' )
90+ ) {
91+ previouslyFocusedElement . blur ( ) ;
12792 }
12893 }
12994
130- container . addEventListener ( 'keydown' , handleKeyDown ) ;
131- void autoFocusFirstElement ( ) ;
95+ void initializeFocus ( ) ;
96+
97+ window . addEventListener ( 'keydown' , handleKeyDown ) ;
13298
13399 return {
134100 destroy ( ) {
135- container . removeEventListener ( 'keydown' , handleKeyDown ) ;
101+ window . removeEventListener ( 'keydown' , handleKeyDown ) ;
102+ if ( fullOptions . autoRestore ) {
103+ focusElement ( previouslyFocusedElement ) ;
104+ }
136105 } ,
137106 } ;
138107}
0 commit comments