@@ -33,6 +33,143 @@ export function goToNextFocusableElement(forContainer, toOriginal, delay) {
3333}
3434
3535
36+ // ---------------------------------------------------------------------------
37+ // Shared keyboard navigation for anchor+popup pairs (popovers, menus, etc.)
38+ // Implements the standard 4-case ARIA keyboard pattern:
39+ // 1. Tab on anchor → focus first element in popup (stay open)
40+ // 2. Shift+Tab on popup → focus anchor, close popup
41+ // 3. Tab on popup → focus next page element after anchor, close popup
42+ // 4. Escape on popup → focus anchor, close popup
43+ // (Shift+Tab on anchor while popup open → close popup, browser handles focus)
44+ // ---------------------------------------------------------------------------
45+
46+ const keyboardNavigationState = new Map ( ) ;
47+
48+ /**
49+ * Attaches keyboard navigation listeners to an anchor+popup pair.
50+ * @param {string } anchorId - Id of the anchor element.
51+ * @param {string } popupId - Id of the popup/overlay element.
52+ * @param {object } dotNetHelper - DotNetObjectReference; must expose CloseAsync().
53+ * @param {number[] } closeKeyCodes - Additional key codes (besides Tab) that close the popup. Defaults to [27] (Escape).
54+ */
55+ export function initializeKeyboardNavigation ( anchorId , popupId , dotNetHelper , closeKeyCodes = [ 27 ] , tabExitsAlways = false ) {
56+ disposeKeyboardNavigation ( anchorId ) ;
57+
58+ const popupElement = document . getElementById ( popupId ) ;
59+ const anchorElement = document . getElementById ( anchorId ) ;
60+
61+ if ( ! popupElement || ! anchorElement ) {
62+ return ;
63+ }
64+
65+ // Listeners on the popup content (cases 2, 3, 4)
66+ const popupKeydownListener = function ( ev ) {
67+ const keyCode = ev . which || ev . keyCode ;
68+ const isCloseKey = closeKeyCodes . includes ( keyCode ) ;
69+
70+ if ( ev . key !== "Tab" && ! isCloseKey ) return ;
71+
72+ if ( isCloseKey ) {
73+ // Case 4: close key → return focus to anchor, close
74+ ev . preventDefault ( ) ;
75+ ev . stopPropagation ( ) ;
76+ anchorElement . focus ( ) ;
77+ dotNetHelper . invokeMethodAsync ( 'CloseAsync' ) ;
78+ return ;
79+ }
80+
81+ // Tab / Shift+Tab
82+ if ( tabExitsAlways ) {
83+ // Menu pattern: Tab on any element exits immediately
84+ ev . preventDefault ( ) ;
85+ ev . stopPropagation ( ) ;
86+ if ( ! ev . shiftKey ) {
87+ // Case 3: move to element after anchor in page
88+ let startFrom ;
89+ if ( anchorElement . tagName . startsWith ( "FLUENT-" ) && anchorElement . shadowRoot ?. children . length > 0 ) {
90+ startFrom = anchorElement . shadowRoot . children [ 0 ] ;
91+ } else {
92+ startFrom = anchorElement ;
93+ }
94+ new FocusableElement ( anchorElement . getRootNode ( ) ) . findNextFocusableElement ( startFrom ) ?. focus ( ) ;
95+ } else {
96+ // Case 2: Shift+Tab → focus anchor
97+ anchorElement . focus ( ) ;
98+ }
99+ dotNetHelper . invokeMethodAsync ( 'CloseAsync' ) ;
100+ } else {
101+ // Popover pattern: only intercept Tab at the first/last boundary;
102+ // let the browser handle Tab naturally for elements in between.
103+ const focusables = new FocusableElement ( popupElement ) . getFocusableElements ( ) ;
104+ const activeIndex = focusables . indexOf ( document . activeElement ) ;
105+
106+ if ( ! ev . shiftKey && ( focusables . length === 0 || activeIndex === focusables . length - 1 ) ) {
107+ // Case 3: Tab on last element → next page element after anchor, close
108+ ev . preventDefault ( ) ;
109+ ev . stopPropagation ( ) ;
110+ let startFrom ;
111+ if ( anchorElement . tagName . startsWith ( "FLUENT-" ) && anchorElement . shadowRoot ?. children . length > 0 ) {
112+ startFrom = anchorElement . shadowRoot . children [ 0 ] ;
113+ } else {
114+ startFrom = anchorElement ;
115+ }
116+ new FocusableElement ( anchorElement . getRootNode ( ) ) . findNextFocusableElement ( startFrom ) ?. focus ( ) ;
117+ dotNetHelper . invokeMethodAsync ( 'CloseAsync' ) ;
118+ } else if ( ev . shiftKey && ( focusables . length === 0 || activeIndex === 0 ) ) {
119+ // Case 2: Shift+Tab on first element → focus anchor, close
120+ ev . preventDefault ( ) ;
121+ ev . stopPropagation ( ) ;
122+ anchorElement . focus ( ) ;
123+ dotNetHelper . invokeMethodAsync ( 'CloseAsync' ) ;
124+ }
125+ // Otherwise: middle element — let browser handle Tab/Shift+Tab naturally
126+ }
127+ } ;
128+
129+ // Listener on the anchor (case 1 and Shift+Tab-while-open)
130+ const anchorKeydownListener = function ( ev ) {
131+ if ( ev . key !== "Tab" ) return ;
132+
133+ if ( ! ev . shiftKey ) {
134+ // Case 1: Tab on anchor → focus first focusable element in popup
135+ const firstFocusable = new FocusableElement ( popupElement ) . findNextFocusableElement ( ) ;
136+ if ( ! firstFocusable ) return ;
137+
138+ firstFocusable . focus ( ) ;
139+ ev . preventDefault ( ) ;
140+ ev . stopPropagation ( ) ;
141+ } else {
142+ // Shift+Tab on anchor while popup open → close, browser handles focus naturally
143+ dotNetHelper . invokeMethodAsync ( 'CloseAsync' ) ;
144+ }
145+ } ;
146+
147+ popupElement . addEventListener ( "keydown" , popupKeydownListener ) ;
148+ anchorElement . addEventListener ( "keydown" , anchorKeydownListener ) ;
149+
150+ keyboardNavigationState . set ( anchorId , {
151+ popupKeydownListener,
152+ anchorKeydownListener,
153+ popupElement,
154+ anchorElement
155+ } ) ;
156+ }
157+
158+ /**
159+ * Removes keyboard navigation listeners registered by initializeKeyboardNavigation.
160+ * @param {string } anchorId
161+ */
162+ export function disposeKeyboardNavigation ( anchorId ) {
163+ const state = keyboardNavigationState . get ( anchorId ) ;
164+ if ( ! state ) return ;
165+
166+ const { popupKeydownListener, anchorKeydownListener, popupElement, anchorElement } = state ;
167+ popupElement . removeEventListener ( "keydown" , popupKeydownListener ) ;
168+ anchorElement . removeEventListener ( "keydown" , anchorKeydownListener ) ;
169+
170+ keyboardNavigationState . delete ( anchorId ) ;
171+ }
172+
36173/**
37174 * Focusable Element
38175 */
@@ -57,46 +194,52 @@ export class FocusableElement {
57194 }
58195
59196 /**
60- * Find the next focusable element, after the optional current element, in the specified container.
61- * @param container
62- * @param currentElement
63- * @returns
197+ * Returns all focusable elements within the container, resolving Fluent web component shadow roots.
198+ * @returns {Element[] }
64199 */
65- findNextFocusableElement ( currentElement ) {
66- // Fluent web components may have children that are focusable, but they are not
67- // focusable themselves. Thus, we unfortunately need to query every element or provide
68- // a list of all fluent elements that have focusable children.
200+ getFocusableElements ( ) {
69201 const queriedElements = Array . from ( this . _container . querySelectorAll ( "*" ) ) . filter ( el => {
70202 return el . matches ( this . FOCUSABLE_SELECTORS ) || el . tagName . toLowerCase ( ) . startsWith ( "fluent-" ) ;
71203 } ) ;
72204
73205 const focusableElements = [ ] ;
74-
75- // If an element is a fluent web component and is not focusable, replace with its inner focusable element
76- // if one exists.
77- queriedElements . forEach ( ( el , index ) => {
206+ queriedElements . forEach ( el => {
78207 if ( el . tagName . toLowerCase ( ) . startsWith ( "fluent-" ) && el . tabIndex === - 1 && ! ! el . shadowRoot ) {
79208 Array . from ( el . shadowRoot . children ) . forEach ( child => {
80209 if ( child . tabIndex !== - 1 && child . checkVisibility ( ) ) {
81210 focusableElements . push ( child ) ;
82211 }
83212 } ) ;
84- }
85- else {
213+ } else {
86214 focusableElements . push ( el ) ;
87215 }
88216 } ) ;
89217
90- // Filter out elements with tabindex="-1" and elements that are not visible
91- const filteredElements = focusableElements . filter ( el => ! ! el && el . tabIndex !== - 1 && el . checkVisibility ( ) ) ;
218+ return focusableElements . filter ( el => ! ! el && el . tabIndex !== - 1 && el . checkVisibility ( ) ) ;
219+ }
220+
221+ /**
222+ * Find the next focusable element, after the optional current element, in the specified container.
223+ * @param currentElement
224+ * @param reverse - If true, find the previous focusable element instead of the next.
225+ * @returns
226+ */
227+ findNextFocusableElement ( currentElement , reverse = false ) {
228+ const filteredElements = this . getFocusableElements ( ) ;
229+
230+ if ( filteredElements . length === 0 ) {
231+ return null ;
232+ }
92233
93234 // Find the index of the current element
94235 const current = currentElement ?? document . activeElement ;
95236 if ( current != null ) {
96237 const currentIndex = filteredElements . indexOf ( current ) ;
97238
98- // Calculate the index of the next element
99- const nextIndex = ( currentIndex + 1 ) % filteredElements . length ;
239+ // Calculate the index of the next (or previous) element
240+ const nextIndex = reverse
241+ ? ( currentIndex - 1 + filteredElements . length ) % filteredElements . length
242+ : ( currentIndex + 1 ) % filteredElements . length ;
100243
101244 // Return the next focusable element
102245 return filteredElements [ nextIndex ] ;
0 commit comments