Skip to content

Commit 4ba2ff6

Browse files
committed
Deploying to gh-pages from @ 83d978f 🚀
1 parent 84e1209 commit 4ba2ff6

286 files changed

Lines changed: 612 additions & 417 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ScratchcardStatistics.styles.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
@import '_content/Microsoft.FluentUI.AspNetCore.Components/Microsoft.FluentUI.AspNetCore.Components.ewdlgswx1m.bundle.scp.css';
1+
@import '_content/Microsoft.FluentUI.AspNetCore.Components/Microsoft.FluentUI.AspNetCore.Components.lcdo7z9xd2.bundle.scp.css';
22

0 Bytes
Binary file not shown.
2 Bytes
Binary file not shown.

_content/Microsoft.FluentUI.AspNetCore.Components/Components/AnchoredRegion/FluentAnchoredRegion.razor.js

Lines changed: 161 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)