-
Notifications
You must be signed in to change notification settings - Fork 285
feat(ui5-bar): add arrow left/arrow right/home/end navigation #13977
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,19 @@ | ||
| import UI5Element from "@ui5/webcomponents-base/dist/UI5Element.js"; | ||
| import UI5Element, { instanceOfUI5Element } from "@ui5/webcomponents-base/dist/UI5Element.js"; | ||
| import type { DefaultSlot, Slot } from "@ui5/webcomponents-base/dist/UI5Element.js"; | ||
| import customElement from "@ui5/webcomponents-base/dist/decorators/customElement.js"; | ||
| import property from "@ui5/webcomponents-base/dist/decorators/property.js"; | ||
| import slot from "@ui5/webcomponents-base/dist/decorators/slot-strict.js"; | ||
| import jsxRenderer from "@ui5/webcomponents-base/dist/renderer/JsxRenderer.js"; | ||
| import ResizeHandler from "@ui5/webcomponents-base/dist/delegate/ResizeHandler.js"; | ||
| import { getEffectiveAriaLabelText } from "@ui5/webcomponents-base/dist/util/AccessibilityTextsHelper.js"; | ||
| import isElementHidden from "@ui5/webcomponents-base/dist/util/isElementHidden.js"; | ||
| import getActiveElement from "@ui5/webcomponents-base/dist/util/getActiveElement.js"; | ||
| import { | ||
| isLeft, | ||
| isRight, | ||
| isHome, | ||
| isEnd, | ||
| } from "@ui5/webcomponents-base/dist/Keys.js"; | ||
| import type BarDesign from "./types/BarDesign.js"; | ||
| import type BarAccessibleRole from "./types/BarAccessibleRole.js"; | ||
|
|
||
|
|
@@ -36,6 +44,13 @@ import type { AriaRole } from "@ui5/webcomponents-base/dist/types.js"; | |
| * | ||
| * ### Keyboard Handling | ||
| * | ||
| * The `ui5-bar` provides advanced keyboard handling among interactive components inside it, no matter in which slot they are placed. | ||
| * | ||
| * #### Regular Navigation | ||
| * - [Left] / [Right] - navigate backward/forward among interactive components | ||
| * - [Home] / [End] - move to first/last interactive components | ||
| * - [Tab] / [Shift]+[Tab] - navigate forward/backward among interactive components | ||
| * | ||
| * #### Fast Navigation | ||
| * This component provides a build in fast navigation group which can be used via [F6] / [Shift] + [F6] / [Ctrl] + [Alt/Option] / [Down] or [Ctrl] + [Alt/Option] + [Up]. | ||
| * In order to use this functionality, you need to import the following module: | ||
|
|
@@ -76,9 +91,9 @@ class Bar extends UI5Element { | |
| * | ||
| * - By default, accessibleRole is set to "Toolbar", which renders the ARIA role "toolbar". | ||
| * | ||
| * - Use the default accessibleRole value "Toolbar" only when the component contains two or more active, interactive elements (such as buttons, links, or input fields) within the bar. | ||
| * - Use the default accessibleRole value "Toolbar" only when the component contains three or more active, interactive elements (such as buttons, links, or input fields) within the bar. | ||
| * | ||
| * - If there is only one or no active element, set accessibleRole to "None" to avoid rendering the ARIA role "toolbar", as that role implies a grouping of multiple interactive controls. | ||
| * - If there is only one, two or no active element, set accessibleRole to "None" to avoid rendering the ARIA role "toolbar", as that role implies a grouping of multiple interactive controls. | ||
| * | ||
| * @public | ||
| * @default "Toolbar" | ||
|
|
@@ -128,6 +143,7 @@ class Bar extends UI5Element { | |
| endContent!: Slot<HTMLElement>; | ||
|
|
||
| _handleResizeBound: () => void; | ||
| _onKeyDownBound: (e: KeyboardEvent) => void; | ||
|
|
||
| get accInfo() { | ||
| return { | ||
|
|
@@ -148,6 +164,7 @@ class Bar extends UI5Element { | |
| super(); | ||
|
|
||
| this._handleResizeBound = this.handleResize.bind(this); | ||
| this._onKeyDownBound = this._onKeyDown.bind(this); | ||
| } | ||
|
|
||
| handleResize() { | ||
|
|
@@ -166,6 +183,8 @@ class Bar extends UI5Element { | |
| this.getDomRef()!.querySelectorAll(".ui5-bar-content-container").forEach(child => { | ||
| ResizeHandler.register(child as HTMLElement, this._handleResizeBound); | ||
| }, this); | ||
|
|
||
| this.addEventListener("keydown", this._onKeyDownBound, true); | ||
| } | ||
|
|
||
| onExitDOM() { | ||
|
|
@@ -174,11 +193,151 @@ class Bar extends UI5Element { | |
| this.getDomRef()!.querySelectorAll(".ui5-bar-content-container").forEach(child => { | ||
| ResizeHandler.deregister(child as HTMLElement, this._handleResizeBound); | ||
| }, this); | ||
|
|
||
| this.removeEventListener("keydown", this._onKeyDownBound, true); | ||
| } | ||
|
|
||
| get effectiveRole() { | ||
| return this.accessibleRole.toLowerCase() === "toolbar" ? "toolbar" as AriaRole : undefined; | ||
| } | ||
|
|
||
| _collectFocusableElements(): Array<HTMLElement> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we use
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nope, because getTabbableElements return ALL tabbable elements (for example each segmented button item, and using it to focus an element later would break default behaviour of the components that use item navigation or similar mechanism. |
||
| const slotSelectors = [ | ||
| "slot[name=\"startContent\"]", | ||
| "slot:not([name])", | ||
| "slot[name=\"endContent\"]", | ||
| ]; | ||
| const result: Array<HTMLElement> = []; | ||
|
|
||
| slotSelectors.forEach(sel => { | ||
| const slotEl = this.shadowRoot!.querySelector<HTMLSlotElement>(sel); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. something minor, but let's use more descriptive names here: sel → slottedElement / slotElement
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok |
||
| if (!slotEl) { | ||
| return; | ||
| } | ||
| (slotEl.assignedElements({ flatten: true }) as HTMLElement[]).forEach(el => { | ||
| result.push(...this._getFocusableFromElement(el)); | ||
| }); | ||
| }); | ||
| return result; | ||
| } | ||
|
|
||
| _getFocusableFromElement(el: HTMLElement): Array<HTMLElement> { | ||
| if (isElementHidden(el)) { | ||
| return []; | ||
| } | ||
|
|
||
| if (instanceOfUI5Element(el)) { | ||
| const focusRef = el.getFocusDomRef(); | ||
| if (focusRef && focusRef.tabIndex >= 0 && !isElementHidden(focusRef) && !(focusRef as HTMLInputElement).disabled) { | ||
| return [focusRef]; | ||
| } | ||
| return []; | ||
| } | ||
|
|
||
| if (el.tabIndex >= 0 && !(el as HTMLInputElement).disabled) { | ||
| return [el]; | ||
| } | ||
|
|
||
| // Non-focusable container: recurse into children | ||
| const nested: Array<HTMLElement> = []; | ||
| Array.from(el.children).forEach(child => { | ||
| nested.push(...this._getFocusableFromElement(child as HTMLElement)); | ||
| }); | ||
| return nested; | ||
| } | ||
|
|
||
| _hasCaretNavigation(el: EventTarget | null): el is HTMLInputElement | HTMLTextAreaElement { | ||
| if (!(el instanceof HTMLElement)) { | ||
| return false; | ||
| } | ||
| const tag = el.tagName.toLowerCase(); | ||
| if (tag === "textarea") { | ||
| return true; | ||
| } | ||
| if (tag !== "input") { | ||
| return false; | ||
| } | ||
| const type = (el as HTMLInputElement).type.toLowerCase(); | ||
| return ["text", "search", "url", "tel", "password", ""].includes(type); | ||
| } | ||
|
|
||
| _onKeyDown(e: KeyboardEvent) { | ||
| if (this.effectiveRole !== "toolbar") { | ||
| return; | ||
| } | ||
|
|
||
| const isForward = this.effectiveDir === "rtl" ? isLeft(e) : isRight(e); | ||
| const isBackward = this.effectiveDir === "rtl" ? isRight(e) : isLeft(e); | ||
| const isHomeKey = isHome(e); | ||
| const isEndKey = isEnd(e); | ||
|
|
||
| if (!isForward && !isBackward && !isHomeKey && !isEndKey) { | ||
| return; | ||
| } | ||
|
|
||
| const items = this._collectFocusableElements(); | ||
| if (items.length === 0) { | ||
| return; | ||
| } | ||
|
|
||
| const active = getActiveElement() as HTMLElement | null; | ||
| if (!active) { | ||
| return; | ||
| } | ||
|
|
||
| const currentIndex = items.findIndex(item => this._isNodeInsideElement(active, item)); | ||
| if (currentIndex === -1) { | ||
| return; | ||
| } | ||
|
|
||
| if (this._hasCaretNavigation(active)) { | ||
| const input = active as HTMLInputElement; | ||
| if (isHomeKey || isEndKey) { | ||
| return; | ||
| } | ||
| if (isForward && input.selectionStart !== input.value.length) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For inputs where
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
| return; | ||
| } | ||
| if (isBackward && input.selectionStart !== 0) { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| let nextIndex: number; | ||
| if (isHomeKey) { | ||
| nextIndex = 0; | ||
| } else if (isEndKey) { | ||
| nextIndex = items.length - 1; | ||
| } else if (isForward) { | ||
| nextIndex = Math.min(currentIndex + 1, items.length - 1); | ||
| } else { | ||
| nextIndex = Math.max(currentIndex - 1, 0); | ||
| } | ||
|
|
||
| if (nextIndex === currentIndex) { | ||
| return; | ||
| } | ||
|
|
||
| items[nextIndex].focus(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it supposed to be changed? In my opinion - NO! |
||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| } | ||
|
|
||
| _isNodeInsideElement(node: Node, element: HTMLElement): boolean { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is absolutely identical with the same method in the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, we can think on it. |
||
| let current: Node | null = node; | ||
| while (current) { | ||
| if (current === element) { | ||
| return true; | ||
| } | ||
| const root = current.getRootNode?.(); | ||
| if (root instanceof ShadowRoot) { | ||
| current = root.host; | ||
| } else { | ||
| current = current.parentNode; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| Bar.define(); | ||
|
|
||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This listener steals keys if we have slotted self-navigating child elems. For example slotted slider, segmented button, or breadcrumb loses their own Left/Right/Home/End at the bar boundary.
I think in the
Toolbarthey use this util, to go around it -getArrowNavState(), please double-check.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, good point. It steals kbd navigation and wouldn't work if there is such component there. The problem is that getArrowNavState should be implemented in all components that have its own arrow handling.