diff --git a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml index 21341ace91..085c0da078 100644 --- a/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml +++ b/examples/Demo/Shared/Microsoft.FluentUI.AspNetCore.Components.xml @@ -109,6 +109,78 @@ Gets or sets the content to be rendered inside the component. + + + + + + + + + Prompts the user to save the linked URL. See a element for more information. + + + + + Gets or sets the URL the hyperlink references. See a element for more information. + Use Target parameter to specify where. + + + + + Hints at the language of the referenced resource. See a element for more information. + + + + + See a element for more information. + + + + + See a element for more information. + + + + + See a element for more information. + + + + + Gets or sets the target attribute that specifies where to open the link, if Href is specified. + Possible values: _blank | _self | _parent | _top. + + + + + See a element for more information. + + + + + Gets or sets the visual appearance. See + Defaults to + + + + + Gets or sets the displayed at the start of anchor content. + + + + + Gets or sets the displayed at the end of anchor content. + + + + + Gets or sets the content to be rendered inside the component. + + + + + @@ -244,78 +316,6 @@ - - - - - - - - - Prompts the user to save the linked URL. See a element for more information. - - - - - Gets or sets the URL the hyperlink references. See a element for more information. - Use Target parameter to specify where. - - - - - Hints at the language of the referenced resource. See a element for more information. - - - - - See a element for more information. - - - - - See a element for more information. - - - - - See a element for more information. - - - - - Gets or sets the target attribute that specifies where to open the link, if Href is specified. - Possible values: _blank | _self | _parent | _top. - - - - - See a element for more information. - - - - - Gets or sets the visual appearance. See - Defaults to - - - - - Gets or sets the displayed at the start of anchor content. - - - - - Gets or sets the displayed at the end of anchor content. - - - - - Gets or sets the content to be rendered inside the component. - - - - - @@ -6799,6 +6799,16 @@ Gets or sets the list of items to display. + + + Gets or sets the height of the header (in pixels). + + + + + Gets or sets the content to be rendered inside the component. + + Gets or sets the header content. @@ -6834,83 +6844,6 @@ Gets or sets the content of the body. - - - Gets or sets the height of the header (in pixels). - - - - - Gets or sets the content to be rendered inside the component. - - - - - Gets or sets a reference to the button. - - - - - Gets or sets the button appearance. - - - - - The content to be rendered inside the button. This parameter should be supplied if you do not want to render a chevron - on the menu button. - If both and ButtonContent are provided, ButtonContent will be used. - - - - - Gets or sets a reference to the menu. - - - - - Use IMenuService to create the menu, if this service was injected. - This value must be defined before the component is rendered (you can't change it during the component lifecycle). - Default, true. - - - - - Gets or sets the texts shown on the button. - If both Text and are provided, ButtonContent will be used. - - - - - Gets or sets the displayed at the start of button content. - - - - - Gets or sets the button style. - - - - - Gets or sets the menu style. - - - - - Gets or sets the items to show in the menu. - - - - - Gets or sets the content to be shown in the menu. - Should consist of components. - - - - - The callback to invoke when a menu item is chosen. - Using this event prevents the execution of any OnClick event on an included FluentMenuItem. - - @@ -7215,32 +7148,99 @@ - - - - - - - - - - + - Gets or sets the type of message bar. - Default is MessageType.MessageBar. See for more details. + Gets or sets a reference to the button. - + - Gets or sets the actual message instance shown in the message bar. + Gets or sets the button appearance. - + - Gets or sets the message to be shown when not using the MessageService methods. + The content to be rendered inside the button. This parameter should be supplied if you do not want to render a chevron + on the menu button. + If both and ButtonContent are provided, ButtonContent will be used. - + + + Gets or sets a reference to the menu. + + + + + Use IMenuService to create the menu, if this service was injected. + This value must be defined before the component is rendered (you can't change it during the component lifecycle). + Default, true. + + + + + Gets or sets the texts shown on the button. + If both Text and are provided, ButtonContent will be used. + + + + + Gets or sets the displayed at the start of button content. + + + + + Gets or sets the button style. + + + + + Gets or sets the menu style. + + + + + Gets or sets the items to show in the menu. + + + + + Gets or sets the content to be shown in the menu. + Should consist of components. + + + + + The callback to invoke when a menu item is chosen. + Using this event prevents the execution of any OnClick event on an included FluentMenuItem. + + + + + + + + + + + + + + Gets or sets the type of message bar. + Default is MessageType.MessageBar. See for more details. + + + + + Gets or sets the actual message instance shown in the message bar. + + + + + Gets or sets the message to be shown when not using the MessageService methods. + + + Gets or sets the intent of the message bar. Default is MessageIntent.Info. See for more details. @@ -7702,408 +7702,408 @@ Optional section int - + - Returns if the group is expanded, - and if collapsed. + Base class for and . - + - Gets or sets a callback that is triggered whenever changes. + Gets or sets the URL for the group. - + - If set to then the tree will - expand when it is created. + Gets or sets the target attribute that specifies where to open the group, if Href is specified. + Possible values: _blank | _self | _parent | _top. - + - Returns if the group is collapsed, - and if expanded. + Gets or sets the Icon to use if set. - + - Base class for and . + Gets or sets the color of the icon. + It supports the theme colors, default value uses the themes drawer icon color. - + - Gets or sets the content to be rendered inside the component. + Gets or sets the icon drawing and fill color to a custom value. + Needs to be formatted as an HTML hex color string (#rrggbb or #rgb) or CSS variable. + ⚠️ Only available when Color is set to Color.Custom. - + - Gets or sets whether the link is disabled. + If true, the button will be disabled. - + - Gets or sets the destination of the link. + Gets or sets the content to be shown. - + - Gets or sets the icon to display with the link - Use a constant value from the class + Gets or sets the class names to use to indicate the item is active, separated by space. - + - Called when the user attempts to execute the default action of a menu item. + Gets or sets how the link should be matched. + Defaults to . - + - Gets or sets a value indicating whether the item is selected. + Gets or sets the tooltip to display when the mouse is placed over the item. + For the Title is used as fallback. - + - Event callback for when changes. + The callback to invoke when the item is clicked. - + - Gets or sets the text of the link. + If true, force browser to redirect outside component router-space. - + - Gets or sets the width of the link (in pixels). + Gets or sets the id of the custom toggle element + Defaults to navmenu-toggle - + - Returns if the item has an set. + Returns if the item has an set. - + - Gets or sets the tree item associated with this menu item. + If a custom toggle is being used to hide/show the menu, this defines the 'onclick' code + Uses the as the id of the element that will be clicked - + - Called when the user attempts to action a menu item + Gets or sets the text to display for the group. - Information about the menu item - - + - Gets or sets the content to be rendered inside the component. + If true, expands the nav group, otherwise collapse it. + Two-way bindable - + - Gets or sets the content to be rendered for the expander icon when the menu is collapsible. - The default icon will be used if this is not specified. + If true, hides expand button at the end of the NavGroup. - + - Gets or sets the title of the navigation menu. - Default to "Navigation menu". + Explicitly sets the height for the Collapse element to override the css default. - + - Gets or sets the width of the menu (in pixels). + Defines the vertical spacing between the NavGroup and adjacent items. + Needs to be a valid CSS value. - + - Gets or sets whether or not the menu can be collapsed. + If set, overrides the default expand icon. - - - - + - Event callback for when the property changes. + Allows for specific markup and styling to be applied for the group title + When using this, the contained s and s need to be placed in a ChildContent tag. + When specifying both Title and TitleTemplate, both will be rendered. - + - Called when the user attempts to execute the default action of a menu item. + Gets or sets a callback that is triggered whenever changes. - - - If set to then the tree will expand when it is created. - + + - + - If true, the menu will re-navigate to the current page when the user clicks on the currently selected menu item. + Determines whether the current URI should match the link. + The absolute URI of the current location. + True if the link should be highlighted as active; otherwise, false. - - + + - + - Navigation manager + Gets or sets the content to be rendered for the collapse icon when the menu is collapsible. + The default icon will be used if this is not specified. - - - - - - - - - - + - An interface for supporting the ownership of . + Gets or sets the title of the navigation menu using the aria-label attribute. + Defaults to "Navigation menu". - + - . + Gets or sets the width of the menu (in pixels). - + - Returns if the implementing component - is collapsed, and if expanded. + Gets or sets whether or not the menu can be collapsed. - + - Returns if the implementing component - is expanded, and if collapsed. + Gets or sets whether a menu with all child links is shown for s when the navigation menu is collapsed. - + + + + - Returns if the result of - contains any items, otherwise returns . + Event callback for when the property changes. - + - Gets all items directly - parented by the implementing object. + Adjust the vertical spacing between navlinks. - - + - Called by a direct child to register itself with its owner - when it is created. + Gets or sets if a custom toggle for showing/hiding the menu is used. + This is primarily intended to be used in a mobile view - - + - Called by a direct child to unregister itself from its owner - when it is being disposed. + Navigation manager - - + - Base class for and . + Returns if the group is expanded, + and if collapsed. - + - Gets or sets the URL for the group. + Gets or sets a callback that is triggered whenever changes. - + - Gets or sets the target attribute that specifies where to open the group, if Href is specified. - Possible values: _blank | _self | _parent | _top. + If set to then the tree will + expand when it is created. - + - Gets or sets the Icon to use if set. + Returns if the group is collapsed, + and if expanded. - + - Gets or sets the color of the icon. - It supports the theme colors, default value uses the themes drawer icon color. + Base class for and . - + - Gets or sets the icon drawing and fill color to a custom value. - Needs to be formatted as an HTML hex color string (#rrggbb or #rgb) or CSS variable. - ⚠️ Only available when Color is set to Color.Custom. + Gets or sets the content to be rendered inside the component. - + - If true, the button will be disabled. + Gets or sets whether the link is disabled. - + - Gets or sets the content to be shown. + Gets or sets the destination of the link. - + - Gets or sets the class names to use to indicate the item is active, separated by space. + Gets or sets the icon to display with the link + Use a constant value from the class - + - Gets or sets how the link should be matched. - Defaults to . + Called when the user attempts to execute the default action of a menu item. - + - Gets or sets the tooltip to display when the mouse is placed over the item. - For the Title is used as fallback. + Gets or sets a value indicating whether the item is selected. - + - The callback to invoke when the item is clicked. + Event callback for when changes. - + - If true, force browser to redirect outside component router-space. + Gets or sets the text of the link. - + - Gets or sets the id of the custom toggle element - Defaults to navmenu-toggle + Gets or sets the width of the link (in pixels). - + - Returns if the item has an set. + Returns if the item has an set. - + - If a custom toggle is being used to hide/show the menu, this defines the 'onclick' code - Uses the as the id of the element that will be clicked + Gets or sets the tree item associated with this menu item. - + - Gets or sets the text to display for the group. + Called when the user attempts to action a menu item + Information about the menu item + - + - If true, expands the nav group, otherwise collapse it. - Two-way bindable + Gets or sets the content to be rendered inside the component. - + - If true, hides expand button at the end of the NavGroup. + Gets or sets the content to be rendered for the expander icon when the menu is collapsible. + The default icon will be used if this is not specified. - + - Explicitly sets the height for the Collapse element to override the css default. + Gets or sets the title of the navigation menu. + Default to "Navigation menu". - + - Defines the vertical spacing between the NavGroup and adjacent items. - Needs to be a valid CSS value. + Gets or sets the width of the menu (in pixels). - + - If set, overrides the default expand icon. + Gets or sets whether or not the menu can be collapsed. - + + + + - Allows for specific markup and styling to be applied for the group title - When using this, the contained s and s need to be placed in a ChildContent tag. - When specifying both Title and TitleTemplate, both will be rendered. + Event callback for when the property changes. - + - Gets or sets a callback that is triggered whenever changes. + Called when the user attempts to execute the default action of a menu item. - - + + + If set to then the tree will expand when it is created. + - + - Determines whether the current URI should match the link. + If true, the menu will re-navigate to the current page when the user clicks on the currently selected menu item. - The absolute URI of the current location. - True if the link should be highlighted as active; otherwise, false. - - + + - + - Gets or sets the content to be rendered for the collapse icon when the menu is collapsible. - The default icon will be used if this is not specified. + Navigation manager - + + + + + + + + + + - Gets or sets the title of the navigation menu using the aria-label attribute. - Defaults to "Navigation menu". + An interface for supporting the ownership of . - + - Gets or sets the width of the menu (in pixels). + . - + - Gets or sets whether or not the menu can be collapsed. + Returns if the implementing component + is collapsed, and if expanded. - + - Gets or sets whether a menu with all child links is shown for s when the navigation menu is collapsed. + Returns if the implementing component + is expanded, and if collapsed. - - - - + - Event callback for when the property changes. + Returns if the result of + contains any items, otherwise returns . - + - Adjust the vertical spacing between navlinks. + Gets all items directly + parented by the implementing object. + - + - Gets or sets if a custom toggle for showing/hiding the menu is used. - This is primarily intended to be used in a mobile view + Called by a direct child to register itself with its owner + when it is created. + - + - Navigation manager + Called by a direct child to unregister itself from its owner + when it is being disposed. + diff --git a/examples/Demo/Shared/Pages/Popover/Examples/PopoverKeyboardNavigation.razor b/examples/Demo/Shared/Pages/Popover/Examples/PopoverKeyboardNavigation.razor new file mode 100644 index 0000000000..bf31365340 --- /dev/null +++ b/examples/Demo/Shared/Pages/Popover/Examples/PopoverKeyboardNavigation.razor @@ -0,0 +1,25 @@ +
+ + Open Callout + + + @* Declared between its anchor and the next control so Tab has to skip the open callout. *@ + +
Keyboard navigation
+ + + +
+ Save +
+
+ + + + Next control +
+ +@code { + private bool _visible; + private string? _name; +} diff --git a/examples/Demo/Shared/Pages/Popover/PopoverPage.razor b/examples/Demo/Shared/Pages/Popover/PopoverPage.razor index d9b5501aae..e9c2848f9f 100644 --- a/examples/Demo/Shared/Pages/Popover/PopoverPage.razor +++ b/examples/Demo/Shared/Pages/Popover/PopoverPage.razor @@ -15,6 +15,16 @@ + + +

+ Tab and Shift+Tab move through the open callout. Tabbing past its last element closes the callout and + moves focus to the next control after the anchor, and Shift+Tab from its first element returns focus to + the anchor. Escape closes the callout and returns focus to the anchor. +

+
+
+

Documentation

diff --git a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js index 39184bd29f..110479857d 100644 --- a/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js +++ b/src/Core/Components/AnchoredRegion/FluentAnchoredRegion.razor.js @@ -45,6 +45,57 @@ export function goToNextFocusableElement(forContainer, toOriginal, delay) { const keyboardNavigationState = new Map(); +function getDeepActiveElement(root = document) { + let activeElement = root.activeElement; + + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + + return activeElement; +} + +function containsComposedElement(container, element) { + let current = element; + + while (current) { + if (container?.contains?.(current)) { + return true; + } + + const root = current.getRootNode(); + current = root instanceof ShadowRoot ? root.host : null; + } + + return false; +} + +function focusElementOrDescendant(element) { + if (element.tabIndex !== -1) { + element.focus(); + + const activeElement = getDeepActiveElement(); + if (activeElement !== element && containsComposedElement(element, activeElement)) { + return; + } + } + + const focusTarget = new FocusableElement(element).findNextFocusableElement(); + (focusTarget ?? element).focus(); +} + +function findNextPageElementAfterAnchor(anchorElement, popupElement) { + const focusable = new FocusableElement(anchorElement.getRootNode()); + + // The popup stays in the DOM until CloseAsync completes, so skip it when looking for the next page control. + const candidates = focusable.getFocusableElements() + .filter(element => !containsComposedElement(popupElement, element)); + + const anchorIndex = focusable.getFocusableElementIndex(anchorElement, candidates); + + return anchorIndex === -1 ? null : candidates[anchorIndex + 1] ?? null; +} + /** * Attaches keyboard navigation listeners to an anchor+popup pair. * @param {string} anchorId - Id of the anchor element. @@ -73,7 +124,7 @@ export function initializeKeyboardNavigation(anchorId, popupId, dotNetHelper, cl // Case 4: close key → return focus to anchor, close ev.preventDefault(); ev.stopPropagation(); - anchorElement.focus(); + focusElementOrDescendant(anchorElement); dotNetHelper.invokeMethodAsync('CloseAsync'); return; } @@ -85,41 +136,30 @@ export function initializeKeyboardNavigation(anchorId, popupId, dotNetHelper, cl ev.stopPropagation(); if (!ev.shiftKey) { // Case 3: move to element after anchor in page - let startFrom; - if (anchorElement.tagName.startsWith("FLUENT-") && anchorElement.shadowRoot?.children.length > 0) { - startFrom = anchorElement.shadowRoot.children[0]; - } else { - startFrom = anchorElement; - } - new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(startFrom)?.focus(); + findNextPageElementAfterAnchor(anchorElement, popupElement)?.focus(); } else { // Case 2: Shift+Tab → focus anchor - anchorElement.focus(); + focusElementOrDescendant(anchorElement); } dotNetHelper.invokeMethodAsync('CloseAsync'); } else { // Popover pattern: only intercept Tab at the first/last boundary; // let the browser handle Tab naturally for elements in between. - const focusables = new FocusableElement(popupElement).getFocusableElements(); - const activeIndex = focusables.indexOf(document.activeElement); + const popupFocus = new FocusableElement(popupElement); + const focusables = popupFocus.getFocusableElements(); + const activeIndex = popupFocus.getFocusableElementIndex(getDeepActiveElement(), focusables); if (!ev.shiftKey && (focusables.length === 0 || activeIndex === focusables.length - 1)) { // Case 3: Tab on last element → next page element after anchor, close ev.preventDefault(); ev.stopPropagation(); - let startFrom; - if (anchorElement.tagName.startsWith("FLUENT-") && anchorElement.shadowRoot?.children.length > 0) { - startFrom = anchorElement.shadowRoot.children[0]; - } else { - startFrom = anchorElement; - } - new FocusableElement(anchorElement.getRootNode()).findNextFocusableElement(startFrom)?.focus(); + findNextPageElementAfterAnchor(anchorElement, popupElement)?.focus(); dotNetHelper.invokeMethodAsync('CloseAsync'); } else if (ev.shiftKey && (focusables.length === 0 || activeIndex === 0)) { // Case 2: Shift+Tab on first element → focus anchor, close ev.preventDefault(); ev.stopPropagation(); - anchorElement.focus(); + focusElementOrDescendant(anchorElement); dotNetHelper.invokeMethodAsync('CloseAsync'); } // Otherwise: middle element — let browser handle Tab/Shift+Tab naturally @@ -198,24 +238,62 @@ export class FocusableElement { * @returns {Element[]} */ getFocusableElements() { - const queriedElements = Array.from(this._container.querySelectorAll("*")).filter(el => { - return el.matches(this.FOCUSABLE_SELECTORS) || el.tagName.toLowerCase().startsWith("fluent-"); - }); - const focusableElements = []; - queriedElements.forEach(el => { - if (el.tagName.toLowerCase().startsWith("fluent-") && el.tabIndex === -1 && !!el.shadowRoot) { - Array.from(el.shadowRoot.children).forEach(child => { - if (child.tabIndex !== -1 && child.checkVisibility()) { - focusableElements.push(child); - } - }); - } else { - focusableElements.push(el); + + const collectFocusableElements = container => { + Array.from(container.children).forEach(element => { + const isFocusable = element.matches(this.FOCUSABLE_SELECTORS) + && element.tabIndex !== -1 + && element.checkVisibility(); + + if (isFocusable) { + focusableElements.push(element); + } else if (element.shadowRoot) { + collectFocusableElements(element.shadowRoot); + } + + collectFocusableElements(element); + }); + }; + + if (this._container.shadowRoot) { + collectFocusableElements(this._container.shadowRoot); + } + + collectFocusableElements(this._container); + return focusableElements; + } + + /** + * Gets the position of an element or its shadow/composite representation in a focusable element list. + * @param currentElement + * @param focusableElements + * @param reverse - If true, use the first focusable descendant instead of the last. + * @returns + */ + getFocusableElementIndex(currentElement, focusableElements = this.getFocusableElements(), reverse = false) { + let current = currentElement; + + while (current) { + const currentIndex = focusableElements.indexOf(current); + if (currentIndex !== -1) { + return currentIndex; } - }); - return focusableElements.filter(el => !!el && el.tabIndex !== -1 && el.checkVisibility()); + const root = current.getRootNode(); + current = root instanceof ShadowRoot ? root.host : null; + } + + const descendantIndexes = focusableElements + .map((element, index) => ({ element, index })) + .filter(({ element }) => containsComposedElement(currentElement, element)) + .map(({ index }) => index); + + if (descendantIndexes.length === 0) { + return -1; + } + + return reverse ? descendantIndexes[0] : descendantIndexes[descendantIndexes.length - 1]; } /** @@ -234,7 +312,11 @@ export class FocusableElement { // Find the index of the current element const current = currentElement ?? document.activeElement; if (current != null) { - const currentIndex = filteredElements.indexOf(current); + const currentIndex = this.getFocusableElementIndex(current, filteredElements, reverse); + + if (currentIndex === -1) { + return currentElement === undefined && !reverse ? filteredElements[0] : null; + } // Calculate the index of the next (or previous) element const nextIndex = reverse