Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions projects/core/src/index.test.lighthouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(109.02);
expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(109.05);

// if sudden drop in size, check vite bundle config and bundle demo to ensure side effects are properly preserved
expect(report.payload.javascript.requests['index.js'].kb).toBeGreaterThan(100);
Expand Down Expand Up @@ -107,7 +107,7 @@ describe('lighthouse report', () => {
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
expect(report.payload.javascript.requests[Object.keys(report.payload.javascript.requests)[0]].kb).toBeLessThan(
89.5
89.56
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ describe('keynav-list.controller', () => {
expect(element.keynavListConfig.items[2].tabIndex).toBe(0);
});

it('should activate an item when a non-focusable descendant is clicked', async () => {
const item = element.keynavListConfig.items[2]!;
const nonFocusableLabel = document.createElement('span');
item.append(nonFocusableLabel);
nonFocusableLabel.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true }));
await elementIsStable(element);

expect(element.keynavListConfig.items[0].tabIndex).toBe(-1);
expect(item.tabIndex).toBe(0);
expect(item.matches(':focus')).toBe(true);
});

it('should not duplicate listeners after reconnect', async () => {
const listener = vi.fn();
element.addEventListener('nve-key-change', listener);
Expand Down Expand Up @@ -522,4 +534,15 @@ describe('nested interactive keynav-list.controller', () => {
expect(element.keynavListConfig.items[2].tabIndex).toBe(-1);
expect(element.keynavListConfig.items[3].tabIndex).toBe(-1);
});

it('should not activate a node when a nested interactive element is clicked', async () => {
const button = element.keynavListConfig.items[1]!.querySelector('button')!;
button.focus();
button.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true }));
await elementIsStable(element);

expect(button.matches(':focus')).toBe(true);
expect(element.keynavListConfig.items[0].tabIndex).toBe(0);
expect(element.keynavListConfig.items[1].tabIndex).toBe(-1);
});
});
25 changes: 19 additions & 6 deletions projects/core/src/internal/controllers/keynav-list.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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,
Expand All @@ -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[]) {

Copy link
Copy Markdown
Collaborator Author

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.

const target = e.composedPath()[0];
return target instanceof HTMLElement && items.includes(target) ? target : null;
}

#getPointerActiveItem(e: PointerEvent, items: HTMLElement[]) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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) {
Expand Down
9 changes: 6 additions & 3 deletions projects/core/src/menu/menu.test.lighthouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { lighthouseRunner } from '@internals/vite';

describe('menu lighthouse report', () => {
test('menu should meet lighthouse benchmarks', async () => {
const report = await lighthouseRunner.getReport('nve-menu', /* html */`
const report = await lighthouseRunner.getReport(
'nve-menu',
/* html */ `
<nve-menu>
<nve-menu-item>item 1</nve-menu-item>
<nve-menu-item>item 2</nve-menu-item>
Expand All @@ -16,11 +18,12 @@ describe('menu lighthouse report', () => {
<script type="module">
import '@nvidia-elements/core/menu/define.js';
</script>
`);
`
);

expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
expect(report.payload.javascript.kb).toBeLessThan(16.3);
expect(report.payload.javascript.kb).toBeLessThan(16.33);
});
});
9 changes: 6 additions & 3 deletions projects/core/src/pagination/pagination.test.lighthouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@ import { lighthouseRunner } from '@internals/vite';

describe('pagination lighthouse report', () => {
test('pagination should meet lighthouse benchmarks', async () => {
const report = await lighthouseRunner.getReport('nve-pagination', /* html */`
const report = await lighthouseRunner.getReport(
'nve-pagination',
/* html */ `
<nve-pagination name="page" value="1" step="10" items="100"></nve-pagination>
<script type="module">
import '@nvidia-elements/core/pagination/define.js';
</script>
`);
`
);

expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
expect(report.payload.javascript.kb).toBeLessThan(38.9);
expect(report.payload.javascript.kb).toBeLessThan(38.96);
});
});
70 changes: 70 additions & 0 deletions projects/core/src/tree/tree-node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,76 @@ describe(TreeNode.metadata.tag, () => {
expect(element.selected).toBe(false);
});

it('should focus the node header when its label is clicked', () => {
const nodeHeader = element.shadowRoot!.querySelector<HTMLElement>('[part="_node-header"]')!;
const nodeTitle = element.shadowRoot!.querySelector<HTMLElement>('.node-title')!;

expect(nodeTitle.tabIndex).toBe(-1);

nodeTitle.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true }));

expect(nodeHeader.matches(':focus')).toBe(true);
});

it('should preserve focus on an interactive node label descendant', async () => {
const anchor = document.createElement('a');
anchor.href = '#';
element.appendChild(anchor);
await elementIsStable(element);

const nodeHeader = element.shadowRoot!.querySelector<HTMLElement>('[part="_node-header"]')!;
anchor.focus();
anchor.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true }));

expect(anchor.matches(':focus')).toBe(true);
expect(nodeHeader.matches(':focus')).toBe(false);
});

it('should prevent Space from scrolling while toggling tree selection', async () => {
element.selectable = 'single';
element.behaviorSelect = true;
await elementIsStable(element);

const nodeHeader = element.shadowRoot!.querySelector<HTMLElement>('[part="_node-header"]')!;
const keydown = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code: 'Space', composed: true });
const keydownHandler = vi.fn();
tree.addEventListener('keydown', keydownHandler);

nodeHeader.dispatchEvent(keydown);
nodeHeader.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code: 'Space', composed: true }));
await elementIsStable(element);

expect(keydown.defaultPrevented).toBe(true);
expect(keydownHandler).toHaveBeenCalledOnce();
expect(element.selected).toBe(true);
});

it('should prevent expansion arrow keys from scrolling with application-managed expansion', async () => {
element.expandable = true;
await elementIsStable(element);

const nodeHeader = element.shadowRoot!.querySelector<HTMLElement>('[part="_node-header"]')!;
const keydownHandler = vi.fn();
const openHandler = vi.fn();
const closeHandler = vi.fn();
tree.addEventListener('keydown', keydownHandler);
element.addEventListener('open', openHandler);
element.addEventListener('close', closeHandler);

for (const code of ['ArrowLeft', 'ArrowRight']) {
const keydown = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, code, composed: true });
nodeHeader.dispatchEvent(keydown);
nodeHeader.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code, composed: true }));

expect(keydown.defaultPrevented).toBe(true);
}

expect(keydownHandler).toHaveBeenCalledTimes(2);
expect(closeHandler).toHaveBeenCalledOnce();
expect(openHandler).toHaveBeenCalledOnce();
expect(element.expanded).toBe(false);
});

it('should expand node if node header is clicked with behavior-expand and no interactive elements', async () => {
expect(nestedNodeElement.expanded).toBe(false);

Expand Down
14 changes: 12 additions & 2 deletions projects/core/src/tree/tree-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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>
Expand All @@ -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);
}

Expand All @@ -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) => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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();
Expand All @@ -218,7 +229,6 @@ export class TreeNode extends LitElement {
}

if (e.code === 'Space' && e.target === this && this.selectable) {
e.preventDefault();
this.#toggleSelection();
}
};
Expand Down
2 changes: 1 addition & 1 deletion projects/core/src/tree/tree.test.lighthouse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ describe('tree lighthouse report', () => {
expect(report.scores.performance).toBe(100);
expect(report.scores.accessibility).toBe(100);
expect(report.scores.bestPractices).toBe(100);
expect(report.payload.javascript.kb).toBeLessThan(30.41);
expect(report.payload.javascript.kb).toBeLessThan(30.42);
});
});
23 changes: 23 additions & 0 deletions projects/core/src/tree/tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,29 @@ describe(`${Tree.metadata.tag} - collapsed nodes`, () => {
expect(nodes[11].matches(':focus')).toBe(false);
});

it('should move focus with ArrowDown after a node label is clicked', async () => {
const currentNode = element.nodes[1]!;
const currentHeader = currentNode.shadowRoot!.querySelector<HTMLElement>('[part="_node-header"]')!;
const currentLabel = currentNode.shadowRoot!.querySelector<HTMLElement>('.node-title')!;
const nextHeader = element.nodes[2]!.shadowRoot!.querySelector<HTMLElement>('[part="_node-header"]')!;
let eventDetail: Record<string, unknown> | undefined;

element.addEventListener('nve-key-change', ((e: CustomEvent) => {
eventDetail = e.detail;
}) as EventListener);

currentLabel.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, composed: true }));
expect(currentHeader.matches(':focus')).toBe(true);
expect(element.nodes[0]!.shadowRoot!.querySelector<HTMLElement>('[part="_node-header"]')!.tabIndex).toBe(-1);
expect(currentHeader.tabIndex).toBe(0);

currentHeader.dispatchEvent(new KeyboardEvent('keydown', { code: 'ArrowDown', bubbles: true, composed: true }));
await elementIsStable(element);

expect(nextHeader.matches(':focus')).toBe(true);
expect(eventDetail).toMatchObject({ activeItem: nextHeader, code: 'ArrowDown' });
});

it('should keynav from a single level node to a expanded multi level node', async () => {
await elementIsStable(element);
nodes[1].focus();
Expand Down