diff --git a/src/aria/private/accordion/accordion.ts b/src/aria/private/accordion/accordion.ts index 16cba8c90b5e..db6069399b61 100644 --- a/src/aria/private/accordion/accordion.ts +++ b/src/aria/private/accordion/accordion.ts @@ -74,8 +74,8 @@ export class AccordionGroupPattern { /** The keydown event manager for the accordion trigger. */ keydown = computed(() => { return new KeyboardEventManager() - .on(this.prevKey, () => this.navigationBehavior.prev()) - .on(this.nextKey, () => this.navigationBehavior.next()) + .on(this.prevKey, () => this.navigationBehavior.prev(), {handleRepeat: true}) + .on(this.nextKey, () => this.navigationBehavior.next(), {handleRepeat: true}) .on('Home', () => this.navigationBehavior.first()) .on('End', () => this.navigationBehavior.last()) .on(' ', () => this.toggle()) diff --git a/src/aria/private/behaviors/event-manager/event-manager.ts b/src/aria/private/behaviors/event-manager/event-manager.ts index f6718617cc8f..212bd3fcde17 100644 --- a/src/aria/private/behaviors/event-manager/event-manager.ts +++ b/src/aria/private/behaviors/event-manager/event-manager.ts @@ -24,6 +24,7 @@ export interface EventWithModifiers extends Event { * This library has not yet had a need for stopPropagationImmediate. */ export interface EventHandlerOptions { + handleRepeat?: boolean; stopPropagation: boolean; preventDefault: boolean; } diff --git a/src/aria/private/behaviors/event-manager/keyboard-event-manager.ts b/src/aria/private/behaviors/event-manager/keyboard-event-manager.ts index a088f744dda3..43cdc62d2a95 100644 --- a/src/aria/private/behaviors/event-manager/keyboard-event-manager.ts +++ b/src/aria/private/behaviors/event-manager/keyboard-event-manager.ts @@ -30,6 +30,7 @@ type KeyCode = string | SignalLike | RegExp; */ export class KeyboardEventManager extends EventManager { options: EventHandlerOptions = { + handleRepeat: false, preventDefault: true, stopPropagation: true, }; @@ -50,7 +51,7 @@ export class KeyboardEventManager extends EventManager< this.configs.push({ handler: handler, - matcher: event => this._isMatch(event, key, modifiers), + matcher: event => this._isMatch(event, key, modifiers, options), ...this.options, ...options, }); @@ -73,11 +74,20 @@ export class KeyboardEventManager extends EventManager< }; } - private _isMatch(event: T, key: KeyCode, modifiers: ModifierInputs) { + private _isMatch( + event: T, + key: KeyCode, + modifiers: ModifierInputs, + options?: Partial, + ): boolean { if (!hasModifiers(event, modifiers)) { return false; } + if (event.repeat && !options?.handleRepeat) { + return false; + } + if (key instanceof RegExp) { return key.test(event.key); } diff --git a/src/aria/private/combobox/combobox.ts b/src/aria/private/combobox/combobox.ts index 0e20884fe071..28cefc3a6109 100644 --- a/src/aria/private/combobox/combobox.ts +++ b/src/aria/private/combobox/combobox.ts @@ -250,8 +250,8 @@ export class ComboboxPattern, V> { } manager - .on('ArrowDown', () => this.next()) - .on('ArrowUp', () => this.prev()) + .on('ArrowDown', () => this.next(), {handleRepeat: true}) + .on('ArrowUp', () => this.prev(), {handleRepeat: true}) .on('Home', () => this.first()) .on('End', () => this.last()); diff --git a/src/aria/private/grid/cell.ts b/src/aria/private/grid/cell.ts index 90e28a8917f6..3be25d2a143c 100644 --- a/src/aria/private/grid/cell.ts +++ b/src/aria/private/grid/cell.ts @@ -181,11 +181,15 @@ export class GridCellPattern implements GridCell { // Start list navigation. manager .on('Escape', () => this.stopNavigation()) - .on(this.prevKey(), () => - this._advance(() => this.navigationBehavior.prev({focusElement: false})), + .on( + this.prevKey(), + () => this._advance(() => this.navigationBehavior.prev({focusElement: false})), + {handleRepeat: true}, ) - .on(this.nextKey(), () => - this._advance(() => this.navigationBehavior.next({focusElement: false})), + .on( + this.nextKey(), + () => this._advance(() => this.navigationBehavior.next({focusElement: false})), + {handleRepeat: true}, ) .on('Home', () => this._advance(() => this.navigationBehavior.next({focusElement: false}))) .on('End', () => this._advance(() => this.navigationBehavior.next({focusElement: false}))); diff --git a/src/aria/private/grid/grid.ts b/src/aria/private/grid/grid.ts index 1d68139819be..46c9ace39b9d 100644 --- a/src/aria/private/grid/grid.ts +++ b/src/aria/private/grid/grid.ts @@ -116,10 +116,10 @@ export class GridPattern { selectOne: this.inputs.enableSelection() && this.inputs.selectionMode() === 'follow', }; manager - .on('ArrowUp', () => this.gridBehavior.up(opts)) - .on('ArrowDown', () => this.gridBehavior.down(opts)) - .on(this.prevColKey(), () => this.gridBehavior.left(opts)) - .on(this.nextColKey(), () => this.gridBehavior.right(opts)) + .on('ArrowUp', () => this.gridBehavior.up(opts), {handleRepeat: true}) + .on('ArrowDown', () => this.gridBehavior.down(opts), {handleRepeat: true}) + .on(this.prevColKey(), () => this.gridBehavior.left(opts), {handleRepeat: true}) + .on(this.nextColKey(), () => this.gridBehavior.right(opts), {handleRepeat: true}) .on('Home', () => this.gridBehavior.firstInRow(opts)) .on('End', () => this.gridBehavior.lastInRow(opts)) .on([Modifier.Ctrl], 'Home', () => this.gridBehavior.first(opts)) diff --git a/src/aria/private/listbox/listbox.ts b/src/aria/private/listbox/listbox.ts index 081a3dd069c3..4670c7d0b01a 100644 --- a/src/aria/private/listbox/listbox.ts +++ b/src/aria/private/listbox/listbox.ts @@ -79,8 +79,8 @@ export class ListboxPattern { if (this.readonly()) { return manager - .on(this.prevKey, () => this.listBehavior.prev()) - .on(this.nextKey, () => this.listBehavior.next()) + .on(this.prevKey, () => this.listBehavior.prev(), {handleRepeat: true}) + .on(this.nextKey, () => this.listBehavior.next(), {handleRepeat: true}) .on('Home', () => this.listBehavior.first()) .on('End', () => this.listBehavior.last()) .on(this.typeaheadRegexp, e => this.listBehavior.search(e.key)); @@ -88,8 +88,8 @@ export class ListboxPattern { if (!this.followFocus()) { manager - .on(this.prevKey, () => this.listBehavior.prev()) - .on(this.nextKey, () => this.listBehavior.next()) + .on(this.prevKey, () => this.listBehavior.prev(), {handleRepeat: true}) + .on(this.nextKey, () => this.listBehavior.next(), {handleRepeat: true}) .on('Home', () => this.listBehavior.first()) .on('End', () => this.listBehavior.last()) .on(this.typeaheadRegexp, e => this.listBehavior.search(e.key)); @@ -97,8 +97,8 @@ export class ListboxPattern { if (this.followFocus()) { manager - .on(this.prevKey, () => this.listBehavior.prev({selectOne: true})) - .on(this.nextKey, () => this.listBehavior.next({selectOne: true})) + .on(this.prevKey, () => this.listBehavior.prev({selectOne: true}), {handleRepeat: true}) + .on(this.nextKey, () => this.listBehavior.next({selectOne: true}), {handleRepeat: true}) .on('Home', () => this.listBehavior.first({selectOne: true})) .on('End', () => this.listBehavior.last({selectOne: true})) .on(this.typeaheadRegexp, e => this.listBehavior.search(e.key, {selectOne: true})); @@ -107,8 +107,12 @@ export class ListboxPattern { if (this.inputs.multi()) { manager .on(Modifier.Any, 'Shift', () => this.listBehavior.anchor(this.listBehavior.activeIndex())) - .on(Modifier.Shift, this.prevKey, () => this.listBehavior.prev({selectRange: true})) - .on(Modifier.Shift, this.nextKey, () => this.listBehavior.next({selectRange: true})) + .on(Modifier.Shift, this.prevKey, () => this.listBehavior.prev({selectRange: true}), { + handleRepeat: true, + }) + .on(Modifier.Shift, this.nextKey, () => this.listBehavior.next({selectRange: true}), { + handleRepeat: true, + }) .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => this.listBehavior.first({selectRange: true, anchor: false}), ) @@ -137,8 +141,12 @@ export class ListboxPattern { if (this.inputs.multi() && this.followFocus()) { manager - .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => this.listBehavior.prev()) - .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => this.listBehavior.next()) + .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => this.listBehavior.prev(), { + handleRepeat: true, + }) + .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => this.listBehavior.next(), { + handleRepeat: true, + }) .on([Modifier.Ctrl, Modifier.Meta], ' ', () => this.listBehavior.toggle()) .on([Modifier.Ctrl, Modifier.Meta], 'Enter', () => this.listBehavior.toggle()) .on([Modifier.Ctrl, Modifier.Meta], 'Home', () => this.listBehavior.first()) diff --git a/src/aria/private/menu/menu.ts b/src/aria/private/menu/menu.ts index 056cff4279be..4704ddd38dfc 100644 --- a/src/aria/private/menu/menu.ts +++ b/src/aria/private/menu/menu.ts @@ -158,8 +158,8 @@ export class MenuPattern { /** Handles keyboard events for the menu. */ keydownManager = computed(() => { return new KeyboardEventManager() - .on('ArrowDown', () => this.next()) - .on('ArrowUp', () => this.prev()) + .on('ArrowDown', () => this.next(), {handleRepeat: true}) + .on('ArrowUp', () => this.prev(), {handleRepeat: true}) .on('Home', () => this.first()) .on('End', () => this.last()) .on('Enter', () => this.trigger()) @@ -485,8 +485,8 @@ export class MenuBarPattern { /** Handles keyboard events for the menu. */ keydownManager = computed(() => { return new KeyboardEventManager() - .on(this._nextKey, () => this.next()) - .on(this._previousKey, () => this.prev()) + .on(this._nextKey, () => this.next(), {handleRepeat: true}) + .on(this._previousKey, () => this.prev(), {handleRepeat: true}) .on('End', () => this.listBehavior.last()) .on('Home', () => this.listBehavior.first()) .on('Enter', () => this.inputs.activeItem()?.open({first: true})) diff --git a/src/aria/private/tabs/tabs.ts b/src/aria/private/tabs/tabs.ts index 22cf46091efd..a012f12e6227 100644 --- a/src/aria/private/tabs/tabs.ts +++ b/src/aria/private/tabs/tabs.ts @@ -184,11 +184,15 @@ export class TabListPattern { /** The keydown event manager for the tablist. */ readonly keydown = computed(() => { return new KeyboardEventManager() - .on(this.prevKey, () => - this._navigate(() => this.navigationBehavior.prev(), this.followFocus()), + .on( + this.prevKey, + () => this._navigate(() => this.navigationBehavior.prev(), this.followFocus()), + {handleRepeat: true}, ) - .on(this.nextKey, () => - this._navigate(() => this.navigationBehavior.next(), this.followFocus()), + .on( + this.nextKey, + () => this._navigate(() => this.navigationBehavior.next(), this.followFocus()), + {handleRepeat: true}, ) .on('Home', () => this._navigate(() => this.navigationBehavior.first(), this.followFocus())) .on('End', () => this._navigate(() => this.navigationBehavior.last(), this.followFocus())) diff --git a/src/aria/private/toolbar/toolbar.ts b/src/aria/private/toolbar/toolbar.ts index 25396ad6fe22..f0b384e6575d 100644 --- a/src/aria/private/toolbar/toolbar.ts +++ b/src/aria/private/toolbar/toolbar.ts @@ -80,10 +80,10 @@ export class ToolbarPattern { const manager = new KeyboardEventManager(); return manager - .on(this._nextKey, () => this.listBehavior.next()) - .on(this._prevKey, () => this.listBehavior.prev()) - .on(this._altNextKey, () => this._groupNext()) - .on(this._altPrevKey, () => this._groupPrev()) + .on(this._nextKey, () => this.listBehavior.next(), {handleRepeat: true}) + .on(this._prevKey, () => this.listBehavior.prev(), {handleRepeat: true}) + .on(this._altNextKey, () => this._groupNext(), {handleRepeat: true}) + .on(this._altPrevKey, () => this._groupPrev(), {handleRepeat: true}) .on(' ', () => this.select()) .on('Enter', () => this.select()) .on('Home', () => this.listBehavior.first()) @@ -179,7 +179,7 @@ export class ToolbarPattern { /** Handles click events for the toolbar. */ onClick(event: MouseEvent) { - if (this.disabled()) return; + if (this.disabled() || (event as PointerEvent).pointerType === '') return; this._goto(event); } diff --git a/src/aria/private/tree/tree.ts b/src/aria/private/tree/tree.ts index 1e4d7e8c5c15..3adbb59e40b9 100644 --- a/src/aria/private/tree/tree.ts +++ b/src/aria/private/tree/tree.ts @@ -216,8 +216,8 @@ export class TreePattern implements TreeInputs { const tree = this.treeBehavior; manager - .on(this.prevKey, () => tree.prev({selectOne: this.followFocus()})) - .on(this.nextKey, () => tree.next({selectOne: this.followFocus()})) + .on(this.prevKey, () => tree.prev({selectOne: this.followFocus()}), {handleRepeat: true}) + .on(this.nextKey, () => tree.next({selectOne: this.followFocus()}), {handleRepeat: true}) .on('Home', () => tree.first({selectOne: this.followFocus()})) .on('End', () => tree.last({selectOne: this.followFocus()})) .on(this.typeaheadRegexp, e => tree.search(e.key, {selectOne: this.followFocus()})) @@ -230,8 +230,12 @@ export class TreePattern implements TreeInputs { // TODO: Tracking the anchor by index can break if the // tree is expanded or collapsed causing the index to change. .on(Modifier.Any, 'Shift', () => tree.anchor(this.treeBehavior.activeIndex())) - .on(Modifier.Shift, this.prevKey, () => tree.prev({selectRange: true})) - .on(Modifier.Shift, this.nextKey, () => tree.next({selectRange: true})) + .on(Modifier.Shift, this.prevKey, () => tree.prev({selectRange: true}), { + handleRepeat: true, + }) + .on(Modifier.Shift, this.nextKey, () => tree.next({selectRange: true}), { + handleRepeat: true, + }) .on([Modifier.Ctrl | Modifier.Shift, Modifier.Meta | Modifier.Shift], 'Home', () => tree.first({selectRange: true, anchor: false}), ) @@ -258,8 +262,8 @@ export class TreePattern implements TreeInputs { if (this.inputs.multi() && this.followFocus()) { manager - .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => tree.prev()) - .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => tree.next()) + .on([Modifier.Ctrl, Modifier.Meta], this.prevKey, () => tree.prev(), {handleRepeat: true}) + .on([Modifier.Ctrl, Modifier.Meta], this.nextKey, () => tree.next(), {handleRepeat: true}) .on([Modifier.Ctrl, Modifier.Meta], this.expandKey, () => this._expandOrFirstChild()) .on([Modifier.Ctrl, Modifier.Meta], this.collapseKey, () => this._collapseOrParent()) .on([Modifier.Ctrl, Modifier.Meta], ' ', () => tree.toggle()) diff --git a/src/aria/toolbar/toolbar.spec.ts b/src/aria/toolbar/toolbar.spec.ts index 26c246a9351a..61e06a8915cb 100644 --- a/src/aria/toolbar/toolbar.spec.ts +++ b/src/aria/toolbar/toolbar.spec.ts @@ -23,7 +23,10 @@ describe('Toolbar', () => { }; const click = (element: HTMLElement, eventInit?: PointerEventInit) => { - element.dispatchEvent(new PointerEvent('click', {bubbles: true, ...eventInit})); + element.dispatchEvent( + // Include pointerType to better simulate a real mouse click v.s. enter keyboard event. + new PointerEvent('click', {bubbles: true, pointerType: 'mouse', ...eventInit}), + ); fixture.detectChanges(); };