-
Notifications
You must be signed in to change notification settings - Fork 117
feat(core): refactor keyboard navigation #2268
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 1 commit
8e433e2
cc35d1c
5d6ffdb
3e470b5
4d9ce58
19dbd82
5c7f55f
6d0e3ba
2136ccc
df279b0
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 |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| /* | ||
| * SPDX-FileCopyrightText: 2025 Siemens AG | ||
| * | ||
| * SPDX-License-Identifier: MIT | ||
| * | ||
| * This source code is licensed under the MIT license found in the | ||
| * LICENSE file in the root directory of this source tree. | ||
| */ | ||
| @mixin ix-focus-visible() { | ||
| outline: 1px solid var(--theme-color-focus-bdr) !important; | ||
| outline-offset: var(--theme-btn--focus--outline-offset) !important; | ||
|
Comment on lines
+10
to
+11
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. |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,9 +19,7 @@ | |
| border-top-right-radius: var(--ix-button-border-radius-right); | ||
| border-bottom-right-radius: var(--ix-button-border-radius-right); | ||
|
|
||
| &, | ||
| &.focus, | ||
| &:focus-visible { | ||
| & { | ||
| background-color: var(--theme-btn-#{$name}--background); | ||
| color: var(--theme-btn-#{$name}--color); | ||
|
|
||
|
|
@@ -32,10 +30,10 @@ | |
| border-style: solid; | ||
| } | ||
|
|
||
| @include hover.focus-visible { | ||
| outline: 1px solid var(--theme-color-focus-bdr); | ||
| outline-offset: var(--theme-btn--focus--outline-offset); | ||
| } | ||
| // @include hover.focus-visible { | ||
| // outline: 1px solid var(--theme-color-focus-bdr); | ||
| // outline-offset: var(--theme-btn--focus--outline-offset); | ||
| // } | ||
|
|
||
| &.selected { | ||
| background-color: var(--theme-btn-#{$name}--background--pressed); | ||
|
|
@@ -160,6 +158,21 @@ | |
| :host(.disabled) { | ||
| cursor: default; | ||
| } | ||
|
|
||
| :host(.ix-focused) { | ||
| .btn { | ||
| outline: 1px solid var(--theme-color-focus-bdr) !important; | ||
| outline-offset: var(--theme-btn--focus--outline-offset) !important; | ||
|
Comment on lines
+164
to
+165
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. |
||
| } | ||
| } | ||
|
|
||
| :host(:focus-visible) { | ||
| outline: none; | ||
|
|
||
| .btn { | ||
| outline: none; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @mixin all-btn-variants { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,159 @@ | ||||||||||||||||
| /* | ||||||||||||||||
| * SPDX-FileCopyrightText: 2025 Siemens AG | ||||||||||||||||
| * | ||||||||||||||||
| * SPDX-License-Identifier: MIT | ||||||||||||||||
| * | ||||||||||||||||
| * This source code is licensed under the MIT license found in the | ||||||||||||||||
| * LICENSE file in the root directory of this source tree. | ||||||||||||||||
| */ | ||||||||||||||||
|
|
||||||||||||||||
| import { requestAnimationFrameNoNgZone } from '../utils/requestAnimationFrame'; | ||||||||||||||||
|
|
||||||||||||||||
| export const getIndexOfDropdownItem = ( | ||||||||||||||||
| items: HTMLElement[], | ||||||||||||||||
| item: HTMLElement | null | ||||||||||||||||
| ) => { | ||||||||||||||||
| if (!item || item.tagName !== 'IX-DROPDOWN-ITEM') { | ||||||||||||||||
| return -1; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| return items.findIndex((el) => el === item); | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export const getNextFocusableDropdownItem = ( | ||||||||||||||||
| items: HTMLElement[], | ||||||||||||||||
| currentItem: HTMLElement | null | ||||||||||||||||
| ) => { | ||||||||||||||||
| const currentItemIndex = getIndexOfDropdownItem(items, currentItem); | ||||||||||||||||
| return items[currentItemIndex + 1]; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export const getPreviousFocusableItem = ( | ||||||||||||||||
| items: HTMLElement[], | ||||||||||||||||
| currentItem: HTMLElement | null | ||||||||||||||||
| ) => { | ||||||||||||||||
| const currentItemIndex = getIndexOfDropdownItem(items, currentItem); | ||||||||||||||||
| return items[currentItemIndex - 1]; | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| const focusItem = (item: HTMLElement) => { | ||||||||||||||||
| requestAnimationFrameNoNgZone(() => | ||||||||||||||||
| item.shadowRoot!.querySelector('button')!.focus() | ||||||||||||||||
| ); | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| export const isTriggerElement = (element: HTMLElement) => | ||||||||||||||||
| element.hasAttribute('data-ix-dropdown-trigger'); | ||||||||||||||||
|
|
||||||||||||||||
| export const configureKeyboardInteraction = (dropdownElement: HTMLElement) => { | ||||||||||||||||
| const callback = async (ev: KeyboardEvent) => { | ||||||||||||||||
| const activeElement = document.activeElement as HTMLElement | null; | ||||||||||||||||
| let items: HTMLElement[] = []; | ||||||||||||||||
|
|
||||||||||||||||
| const targetTagName = (ev.target as HTMLElement)?.tagName; | ||||||||||||||||
| if ( | ||||||||||||||||
| targetTagName !== 'IX-DROPDOWN' && | ||||||||||||||||
| targetTagName !== 'IX-DROPDOWN-ITEM' | ||||||||||||||||
| ) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| try { | ||||||||||||||||
| const query = | ||||||||||||||||
| 'ix-dropdown-item:not(ix-dropdown ix-dropdown *):not([disabled])'; | ||||||||||||||||
|
|
||||||||||||||||
| // Collect items from slots if they exist | ||||||||||||||||
| if (dropdownElement.querySelectorAll('slot').length > 0) { | ||||||||||||||||
| const slotElements = Array.from( | ||||||||||||||||
| dropdownElement.querySelectorAll('slot') | ||||||||||||||||
| ); | ||||||||||||||||
| items = slotElements.flatMap((slot) => | ||||||||||||||||
| Array.from( | ||||||||||||||||
| slot.assignedElements({ flatten: true }) as HTMLElement[] | ||||||||||||||||
| ).flatMap((el) => { | ||||||||||||||||
| // Check if the assigned element itself matches the query | ||||||||||||||||
| if (el.matches && el.matches(query)) { | ||||||||||||||||
| return [el]; | ||||||||||||||||
| } | ||||||||||||||||
| // Otherwise, query its children | ||||||||||||||||
| return Array.from( | ||||||||||||||||
| el.querySelectorAll(query) as NodeListOf<HTMLElement> | ||||||||||||||||
| ); | ||||||||||||||||
| }) | ||||||||||||||||
| ); | ||||||||||||||||
| } else { | ||||||||||||||||
| // No slots, query directly on popoverEl | ||||||||||||||||
| items = Array.from( | ||||||||||||||||
| dropdownElement.querySelectorAll(query) as NodeListOf<HTMLElement> | ||||||||||||||||
| ); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /* eslint-disable-next-line */ | ||||||||||||||||
| } catch {} | ||||||||||||||||
|
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.
Collaborator
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. @gemini-code-assist I do not agree, i do not want that the user see any error message in the console
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. I understand your concern about not wanting end-users to see error messages in the console. However, an empty To address this, I recommend logging errors conditionally. This approach ensures that errors are visible to developers during testing and development, which is crucial for identifying and fixing issues, without exposing potentially sensitive or confusing error messages to end-users in a production environment. This can be achieved by checking the /* eslint-disable-next-line */
} catch (e) {
if (process.env.NODE_ENV === 'development') {
console.error('Error during dropdown item collection:', e);
}
// Optionally, you could send errors to an internal logging service in production here.
}Silently swallowing errors can lead to unexpected behavior in the UI that is very difficult to trace back to its origin. Even if not visible to the end-user, having a record of these errors is crucial for maintaining the stability and reliability of the component.
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| switch (ev.key) { | ||||||||||||||||
| case 'ArrowLeft': | ||||||||||||||||
| dropdownElement.dispatchEvent( | ||||||||||||||||
| new CustomEvent('ix-close-submenu', { | ||||||||||||||||
| bubbles: true, | ||||||||||||||||
| cancelable: true, | ||||||||||||||||
| }) | ||||||||||||||||
| ); | ||||||||||||||||
| break; | ||||||||||||||||
|
|
||||||||||||||||
| case 'ArrowDown': | ||||||||||||||||
| // Disable movement/scroll with keyboard | ||||||||||||||||
| ev.preventDefault(); | ||||||||||||||||
| const nextItem = getNextFocusableDropdownItem(items, activeElement); | ||||||||||||||||
| if (nextItem !== undefined) { | ||||||||||||||||
| focusItem(nextItem); | ||||||||||||||||
| } | ||||||||||||||||
| break; | ||||||||||||||||
|
|
||||||||||||||||
| case 'ArrowUp': | ||||||||||||||||
| // Disable movement/scroll with keyboard | ||||||||||||||||
| ev.preventDefault(); | ||||||||||||||||
| const prevItem = getPreviousFocusableItem(items, activeElement); | ||||||||||||||||
| if (prevItem !== undefined) { | ||||||||||||||||
| focusItem(prevItem); | ||||||||||||||||
| } | ||||||||||||||||
| break; | ||||||||||||||||
|
|
||||||||||||||||
| case 'Home': | ||||||||||||||||
| ev.preventDefault(); | ||||||||||||||||
| const firstItem = items[0]; | ||||||||||||||||
| if (firstItem !== undefined) { | ||||||||||||||||
| focusItem(firstItem); | ||||||||||||||||
| } | ||||||||||||||||
| break; | ||||||||||||||||
|
|
||||||||||||||||
| case 'End': | ||||||||||||||||
| ev.preventDefault(); | ||||||||||||||||
| const lastItem = items[items.length - 1]; | ||||||||||||||||
| if (lastItem !== undefined) { | ||||||||||||||||
| focusItem(lastItem); | ||||||||||||||||
| } | ||||||||||||||||
| break; | ||||||||||||||||
|
|
||||||||||||||||
| case 'ArrowRight': | ||||||||||||||||
| case ' ': | ||||||||||||||||
| case 'Enter': | ||||||||||||||||
| if (activeElement && isTriggerElement(activeElement)) { | ||||||||||||||||
| const triggerEvent = new CustomEvent('ix-open-submenu', { | ||||||||||||||||
| bubbles: true, | ||||||||||||||||
| cancelable: true, | ||||||||||||||||
| detail: { | ||||||||||||||||
| activeElement: activeElement, | ||||||||||||||||
| }, | ||||||||||||||||
| }); | ||||||||||||||||
| activeElement.dispatchEvent(triggerEvent); | ||||||||||||||||
| } | ||||||||||||||||
| break; | ||||||||||||||||
| default: | ||||||||||||||||
| break; | ||||||||||||||||
| } | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| dropdownElement.addEventListener('keydown', callback); | ||||||||||||||||
| return () => dropdownElement.removeEventListener('keydown', callback); | ||||||||||||||||
| }; | ||||||||||||||||
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.
The phrase "preventing event will prevent nothing" is a bit confusing. It could be rephrased for better clarity, for example: "This event is not preventable." or "Calling
preventDefault()on this event has no effect." This comment also applies to other generated files where this documentation appears.