-
Notifications
You must be signed in to change notification settings - Fork 15
fix(core): restore tree keyboard navigation #273
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
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 |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ | |
|
|
||
| import type { ReactiveController, ReactiveElement } from 'lit'; | ||
| import type { LegacyDecoratorTarget } from '../types/index.js'; | ||
| import { focusElement, initializeKeyListItems, setActiveKeyListItem } from '../utils/focus.js'; | ||
| import { focusElement, initializeKeyListItems, isFocusable, setActiveKeyListItem } from '../utils/focus.js'; | ||
| import { KeynavCode, validKeyNavigationCode } from '../utils/dom.js'; | ||
|
|
||
| export interface KeynavListConfig { | ||
|
|
@@ -74,7 +74,7 @@ export class KeyNavigationListController<T extends ReactiveElement & KeynavListE | |
| } | ||
|
|
||
| #clickItem(e: PointerEvent) { | ||
| const item = this.#getActiveItem(e, this.#config.items); | ||
| const item = this.#getPointerActiveItem(e, this.#config.items); | ||
| if (item) { | ||
| this.#setActiveItem(e, item); | ||
| } | ||
|
|
@@ -83,7 +83,7 @@ export class KeyNavigationListController<T extends ReactiveElement & KeynavListE | |
| #focusItem(e: KeyboardEvent) { | ||
| if (validKeyNavigationCode(e) && !this.#keynavDisabled) { | ||
| const { loop, layout, dir, items } = this.#config; | ||
| const activeItem = this.#getActiveItem(e, items); | ||
| const activeItem = this.#getKeyboardActiveItem(e, items); | ||
| if (activeItem) { | ||
| const { next, previous } = getNextKeyListItem(activeItem, Array.from(items), { | ||
| loop, | ||
|
|
@@ -99,9 +99,22 @@ export class KeyNavigationListController<T extends ReactiveElement & KeynavListE | |
| } | ||
| } | ||
|
|
||
| #getActiveItem(e: Event, items: HTMLElement[]) { | ||
| const focusedElement = e.composedPath()[0] as HTMLElement; | ||
| return items.find(i => i === focusedElement) ? focusedElement : null; | ||
| #getKeyboardActiveItem(e: KeyboardEvent, items: HTMLElement[]) { | ||
| const target = e.composedPath()[0]; | ||
| return target instanceof HTMLElement && items.includes(target) ? target : null; | ||
| } | ||
|
|
||
| #getPointerActiveItem(e: PointerEvent, items: HTMLElement[]) { | ||
|
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. Pointer interaction may begin on nonfocusable slotted label content, so resolve the registered header through the composed path. Stop at a focusable descendant to preserve that control’s native focus. |
||
| const path = e.composedPath(); | ||
| const itemIndex = path.findIndex(item => item instanceof HTMLElement && items.includes(item)); | ||
| const item = path[itemIndex]; | ||
|
|
||
| if (!(item instanceof HTMLElement)) return null; | ||
|
|
||
| const hasFocusableDescendant = path | ||
| .slice(0, itemIndex) | ||
| .some(descendant => descendant instanceof Element && isFocusable(descendant)); | ||
| return hasFocusableDescendant ? null : item; | ||
| } | ||
|
|
||
| #setActiveItem(e: KeyboardEvent | PointerEvent, activeItem: HTMLElement, previousItem?: HTMLElement) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -167,7 +167,7 @@ export class TreeNode extends LitElement { | |
| : nothing | ||
| } | ||
| <div tabindex="0" part="_node-header"> | ||
| <slot tabindex="0" class="node-title" @click=${this.#nodeHeaderClick}></slot> | ||
| <slot class="node-title" @click=${this.#nodeHeaderClick}></slot> | ||
|
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. The node header is the canonical roving-focus target. Giving the label slot its own tab stop created a competing, unmanaged focus target and left keyboard navigation unable to identify the active tree item. |
||
| <slot name="content" part="_content"></slot> | ||
| </div> | ||
| </div> | ||
|
|
@@ -180,12 +180,14 @@ export class TreeNode extends LitElement { | |
| super.connectedCallback(); | ||
| attachInternals(this); | ||
| this._internals.role = 'treeitem'; | ||
| this.addEventListener('keydown', this.#onKeydown); | ||
| this.addEventListener('keyup', this.#onKeyup); | ||
| this.#nodeUpdate(); | ||
| } | ||
|
|
||
| disconnectedCallback() { | ||
| super.disconnectedCallback(); | ||
| this.removeEventListener('keydown', this.#onKeydown); | ||
| this.removeEventListener('keyup', this.#onKeyup); | ||
| } | ||
|
|
||
|
|
@@ -208,6 +210,15 @@ export class TreeNode extends LitElement { | |
| this.#isExpandable ? this._internals.states.add('is-expandable') : this._internals.states.delete('is-expandable'); | ||
| } | ||
|
|
||
| #onKeydown = (e: KeyboardEvent) => { | ||
|
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. Tree selection and expansion actions occur on keyup, but browser scrolling is a keydown default. Cancel the default here while allowing the event to continue bubbling for application listeners. |
||
| const isSelectionKey = this.selectable && e.code === 'Space'; | ||
| const isExpansionKey = this.#isExpandable && (e.code === 'ArrowLeft' || e.code === 'ArrowRight'); | ||
|
|
||
| if (e.target === this && (isSelectionKey || isExpansionKey)) { | ||
| e.preventDefault(); | ||
| } | ||
| }; | ||
|
|
||
| #onKeyup = (e: KeyboardEvent) => { | ||
| if (this.#isExpandable && e.code === 'ArrowLeft' && e.target === this) { | ||
| this.close(); | ||
|
|
@@ -218,7 +229,6 @@ export class TreeNode extends LitElement { | |
| } | ||
|
|
||
| if (e.code === 'Space' && e.target === this && this.selectable) { | ||
| e.preventDefault(); | ||
| this.#toggleSelection(); | ||
| } | ||
| }; | ||
|
|
||
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.
Keyboard navigation intentionally remains exact-target only. This prevents tree navigation from taking Arrow keys away from a focused nested control such as a link, button, or input.