From 82b1b064ac36719d0ac873e93fc16fdc5b6a8cde Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Fri, 3 Apr 2026 21:35:33 +0000 Subject: [PATCH 1/4] refactor(multiple): migrate Listbox and Tabs to ClickEventManager --- goldens/aria/private/index.api.md | 12 +-- src/aria/listbox/listbox.spec.ts | 9 +- src/aria/listbox/listbox.ts | 2 +- .../event-manager/click-event-manager.ts | 85 +++++++++++++++++++ src/aria/private/listbox/combobox-listbox.ts | 2 +- src/aria/private/listbox/listbox.spec.ts | 72 ++++++++-------- src/aria/private/listbox/listbox.ts | 13 +-- src/aria/private/tabs/tabs.ts | 15 ++-- src/aria/tabs/tab-list.ts | 2 +- src/aria/tabs/tabs.spec.ts | 23 +++-- src/components-examples/aria/tabs/index.ts | 1 + .../scrollable/tabs-scrollable-example.css | 7 ++ .../scrollable/tabs-scrollable-example.html | 13 +++ .../scrollable/tabs-scrollable-example.ts | 21 +++++ src/dev-app/aria-tabs/tabs-demo.html | 5 ++ src/dev-app/aria-tabs/tabs-demo.ts | 2 + 16 files changed, 220 insertions(+), 64 deletions(-) create mode 100644 src/aria/private/behaviors/event-manager/click-event-manager.ts create mode 100644 src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.css create mode 100644 src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.html create mode 100644 src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.ts diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index c8d4f271175d..b1b3712afc72 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -144,8 +144,8 @@ export class ComboboxListboxPattern extends ListboxPattern implements Comb last: () => void; multi: SignalLike; next: () => void; + onClick(_: PointerEvent): void; onKeydown(_: KeyboardEvent): void; - onPointerdown(_: PointerEvent): void; prev: () => void; role: SignalLike<"listbox">; select: (item?: OptionPattern) => void; @@ -450,6 +450,7 @@ export type ListboxInputs = ListInputs, V> & { export class ListboxPattern { constructor(inputs: ListboxInputs); activeDescendant: SignalLike; + clickManager: SignalLike>; disabled: SignalLike; dynamicSpaceKey: SignalLike<"" | " ">; followFocus: SignalLike; @@ -462,11 +463,10 @@ export class ListboxPattern { listBehavior: List, V>; multi: SignalLike; nextKey: SignalLike<"ArrowRight" | "ArrowLeft" | "ArrowDown">; - onKeydown(event: KeyboardEvent): void; // (undocumented) - onPointerdown(event: PointerEvent): void; + onClick(event: PointerEvent): void; + onKeydown(event: KeyboardEvent): void; orientation: SignalLike<'vertical' | 'horizontal'>; - pointerdown: SignalLike>; prevKey: SignalLike<"ArrowUp" | "ArrowRight" | "ArrowLeft">; readonly: SignalLike; setDefaultState(): void; @@ -680,6 +680,7 @@ export class TabListPattern { constructor(inputs: TabListInputs); readonly activeDescendant: SignalLike; readonly activeTab: SignalLike; + readonly clickManager: SignalLike>; readonly disabled: SignalLike; readonly expansionBehavior: ListExpansion; readonly focusBehavior: ListFocus; @@ -689,12 +690,11 @@ export class TabListPattern { readonly keydown: SignalLike>; readonly navigationBehavior: ListNavigation; readonly nextKey: SignalLike<"ArrowRight" | "ArrowLeft" | "ArrowDown">; + onClick(event: PointerEvent): void; onKeydown(event: KeyboardEvent): void; - onPointerdown(event: PointerEvent): void; open(value: string): boolean; open(tab?: TabPattern): boolean; readonly orientation: SignalLike<'vertical' | 'horizontal'>; - readonly pointerdown: SignalLike>; readonly prevKey: SignalLike<"ArrowUp" | "ArrowRight" | "ArrowLeft">; readonly selectedTab: WritableSignalLike; setDefaultState(): void; diff --git a/src/aria/listbox/listbox.spec.ts b/src/aria/listbox/listbox.spec.ts index 93bd45ad6619..a4dfe8d161e9 100644 --- a/src/aria/listbox/listbox.spec.ts +++ b/src/aria/listbox/listbox.spec.ts @@ -34,7 +34,14 @@ describe('Listbox', () => { const click = (index: number, eventInit?: PointerEventInit, targets?: HTMLElement[]) => { (targets || optionElements)[index].dispatchEvent( - new PointerEvent('pointerdown', {bubbles: true, ...eventInit}), + new PointerEvent('click', { + bubbles: true, + detail: 1, + pointerType: 'mouse', + clientX: 1, + clientY: 1, + ...eventInit, + }), ); fixture.detectChanges(); }; diff --git a/src/aria/listbox/listbox.ts b/src/aria/listbox/listbox.ts index c15dfc5ed4c1..255171b2ee60 100644 --- a/src/aria/listbox/listbox.ts +++ b/src/aria/listbox/listbox.ts @@ -63,7 +63,7 @@ import {LISTBOX} from './tokens'; '[attr.aria-multiselectable]': '_pattern.multi()', '[attr.aria-activedescendant]': '_pattern.activeDescendant()', '(keydown)': '_pattern.onKeydown($event)', - '(pointerdown)': '_pattern.onPointerdown($event)', + '(click)': '_pattern.onClick($event)', '(focusin)': '_onFocus()', }, hostDirectives: [ComboboxPopup], diff --git a/src/aria/private/behaviors/event-manager/click-event-manager.ts b/src/aria/private/behaviors/event-manager/click-event-manager.ts new file mode 100644 index 000000000000..090676952b8d --- /dev/null +++ b/src/aria/private/behaviors/event-manager/click-event-manager.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + EventHandler, + EventHandlerOptions, + EventManager, + hasModifiers, + ModifierInputs, + Modifier, +} from './event-manager'; + +/** + * An event manager that is specialized for handling click events. + * + * This manager should ONLY be used to handle pointer events. It explicitly + * filters out simulated click events generated by browsers when Enter or Space + * keys are pressed, to avoid concurrent logic or overwriting selection state + * when handling keyboard activation explicitly via KeyboardEventManager. + */ +export class ClickEventManager extends EventManager { + options: EventHandlerOptions = { + preventDefault: false, + stopPropagation: false, + }; + + /** + * Configures this event manager to handle events with a specific modifier combination. + */ + on(modifiers: ModifierInputs, handler: EventHandler): this; + + /** + * Configures this event manager to handle events with no modifiers. + * + * @param handler The handler function + */ + on(handler: EventHandler): this; + + on(...args: unknown[]) { + const {handler, modifiers} = this._normalizeInputs(...args); + + this.configs.push({ + handler, + matcher: event => this._isMatch(event, modifiers), + ...this.options, + }); + return this; + } + + private _normalizeInputs(...args: unknown[]) { + if (args.length === 2) { + return { + modifiers: args[0] as ModifierInputs, + handler: args[1] as EventHandler, + }; + } + + return { + modifiers: Modifier.None, + handler: args[0] as EventHandler, + }; + } + + _isMatch(event: T, modifiers: ModifierInputs) { + return this._isRealClick(event) && hasModifiers(event, modifiers); + } + + /** + * Checks if the event is a "real" pointer click. + * + * Real clicks typically have a non-zero detail count (click count) and + * a valid pointerType (e.g. 'mouse' or 'touch'). + */ + private _isRealClick(event: T): boolean { + if (event.detail === 0) return false; + if (event.clientX === 0 && event.clientY === 0) return false; + if (!event.pointerType) return false; + return true; + } +} diff --git a/src/aria/private/listbox/combobox-listbox.ts b/src/aria/private/listbox/combobox-listbox.ts index a23203408bce..9b78da330253 100644 --- a/src/aria/private/listbox/combobox-listbox.ts +++ b/src/aria/private/listbox/combobox-listbox.ts @@ -53,7 +53,7 @@ export class ComboboxListboxPattern override onKeydown(_: KeyboardEvent): void {} /** Noop. The combobox handles pointerdown events. */ - override onPointerdown(_: PointerEvent): void {} + override onClick(_: PointerEvent): void {} /** Noop. The combobox controls the open state. */ override setDefaultState(): void {} diff --git a/src/aria/private/listbox/listbox.spec.ts b/src/aria/private/listbox/listbox.spec.ts index d367648cffea..48b7dd5c13f9 100644 --- a/src/aria/private/listbox/listbox.spec.ts +++ b/src/aria/private/listbox/listbox.spec.ts @@ -569,6 +569,10 @@ describe('Listbox Pattern', () => { target: options[index].element(), shiftKey: mods?.shift, ctrlKey: mods?.control, + detail: 1, + pointerType: 'mouse', + clientX: 1, + clientY: 1, } as unknown as PointerEvent; } @@ -578,7 +582,7 @@ describe('Listbox Pattern', () => { multi: signal(false), selectionMode: signal('follow'), }); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual(['Apple']); }); }); @@ -589,7 +593,7 @@ describe('Listbox Pattern', () => { multi: signal(false), selectionMode: signal('explicit'), }); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual(['Apple']); }); @@ -599,7 +603,7 @@ describe('Listbox Pattern', () => { value: signal(['Apple']), selectionMode: signal('explicit'), }); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual([]); }); }); @@ -610,7 +614,7 @@ describe('Listbox Pattern', () => { multi: signal(true), selectionMode: signal('explicit'), }); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual(['Apple']); }); @@ -620,7 +624,7 @@ describe('Listbox Pattern', () => { value: signal(['Apple']), selectionMode: signal('explicit'), }); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual([]); }); @@ -629,9 +633,9 @@ describe('Listbox Pattern', () => { multi: signal(true), selectionMode: signal('explicit'), }); - listbox.onPointerdown(click(options, 2)); + listbox.onClick(click(options, 2)); listbox.onKeydown(shift()); - listbox.onPointerdown(click(options, 5, {shift: true})); + listbox.onClick(click(options, 5, {shift: true})); expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']); }); @@ -640,11 +644,11 @@ describe('Listbox Pattern', () => { multi: signal(true), selectionMode: signal('explicit'), }); - listbox.onPointerdown(click(options, 2)); + listbox.onClick(click(options, 2)); listbox.onKeydown(shift()); - listbox.onPointerdown(click(options, 5, {shift: true})); + listbox.onClick(click(options, 5, {shift: true})); expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']); - listbox.onPointerdown(click(options, 0, {shift: true})); + listbox.onClick(click(options, 0, {shift: true})); expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']); }); }); @@ -655,11 +659,11 @@ describe('Listbox Pattern', () => { multi: signal(true), selectionMode: signal('follow'), }); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual(['Apple']); - listbox.onPointerdown(click(options, 1)); + listbox.onClick(click(options, 1)); expect(listbox.inputs.value()).toEqual(['Apricot']); - listbox.onPointerdown(click(options, 2)); + listbox.onClick(click(options, 2)); expect(listbox.inputs.value()).toEqual(['Banana']); }); @@ -668,11 +672,11 @@ describe('Listbox Pattern', () => { multi: signal(true), selectionMode: signal('follow'), }); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual(['Apple']); - listbox.onPointerdown(click(options, 1, {control: true})); + listbox.onClick(click(options, 1, {control: true})); expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']); - listbox.onPointerdown(click(options, 2, {control: true})); + listbox.onClick(click(options, 2, {control: true})); expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana']); }); @@ -681,9 +685,9 @@ describe('Listbox Pattern', () => { multi: signal(true), selectionMode: signal('follow'), }); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual(['Apple']); - listbox.onPointerdown(click(options, 0, {control: true})); + listbox.onClick(click(options, 0, {control: true})); expect(listbox.inputs.value()).toEqual([]); }); @@ -692,9 +696,9 @@ describe('Listbox Pattern', () => { multi: signal(true), selectionMode: signal('follow'), }); - listbox.onPointerdown(click(options, 2)); + listbox.onClick(click(options, 2)); listbox.onKeydown(shift()); - listbox.onPointerdown(click(options, 5, {shift: true})); + listbox.onClick(click(options, 5, {shift: true})); expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']); }); @@ -703,11 +707,11 @@ describe('Listbox Pattern', () => { multi: signal(true), selectionMode: signal('follow'), }); - listbox.onPointerdown(click(options, 2)); + listbox.onClick(click(options, 2)); listbox.onKeydown(shift()); - listbox.onPointerdown(click(options, 5, {shift: true})); + listbox.onClick(click(options, 5, {shift: true})); expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']); - listbox.onPointerdown(click(options, 0, {shift: true})); + listbox.onClick(click(options, 0, {shift: true})); expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']); }); @@ -718,11 +722,11 @@ describe('Listbox Pattern', () => { selectionMode: signal('follow'), }); options[2].disabled.set(true); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual(['Apple']); listbox.onKeydown(shift()); - listbox.onPointerdown(click(options, 2, {shift: true})); + listbox.onClick(click(options, 2, {shift: true})); expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']); expect(listbox.inputs.activeItem()).toEqual(options[2]); }); @@ -734,11 +738,11 @@ describe('Listbox Pattern', () => { selectionMode: signal('follow'), }); options[2].disabled.set(true); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual(['Apple']); listbox.onKeydown(down({control: true})); expect(listbox.inputs.value()).toEqual(['Apple']); - listbox.onPointerdown(click(options, 2)); + listbox.onClick(click(options, 2)); expect(listbox.inputs.value()).toEqual(['Apple']); }); }); @@ -748,11 +752,11 @@ describe('Listbox Pattern', () => { readonly: signal(true), selectionMode: signal('follow'), }); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual([]); - listbox.onPointerdown(click(options, 1)); + listbox.onClick(click(options, 1)); expect(listbox.inputs.value()).toEqual([]); - listbox.onPointerdown(click(options, 2)); + listbox.onClick(click(options, 2)); expect(listbox.inputs.value()).toEqual([]); }); @@ -761,14 +765,14 @@ describe('Listbox Pattern', () => { multi: signal(true), selectionMode: signal('follow'), }); - listbox.onPointerdown(click(options, 2)); + listbox.onClick(click(options, 2)); listbox.onKeydown(down({control: true})); listbox.onKeydown(down({control: true})); listbox.onKeydown(shift()); listbox.onKeydown(space({shift: true})); expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry']); - listbox.onPointerdown(click(options, 0, {shift: true})); + listbox.onClick(click(options, 0, {shift: true})); expect(listbox.inputs.value()).toEqual(['Banana', 'Apricot', 'Apple']); }); @@ -777,12 +781,12 @@ describe('Listbox Pattern', () => { multi: signal(true), selectionMode: signal('follow'), }); - listbox.onPointerdown(click(options, 0)); + listbox.onClick(click(options, 0)); expect(listbox.inputs.value()).toEqual(['Apple']); listbox.onKeydown(down({control: true})); listbox.onKeydown(down({control: true})); listbox.onKeydown(shift()); - listbox.onPointerdown(click(options, 4, {shift: true})); + listbox.onClick(click(options, 4, {shift: true})); expect(listbox.inputs.value()).toEqual(['Apple', 'Banana', 'Blackberry', 'Blueberry']); }); }); diff --git a/src/aria/private/listbox/listbox.ts b/src/aria/private/listbox/listbox.ts index 9e8ebf6a7edf..13cc92228c20 100644 --- a/src/aria/private/listbox/listbox.ts +++ b/src/aria/private/listbox/listbox.ts @@ -7,7 +7,8 @@ */ import {OptionPattern} from './option'; -import {KeyboardEventManager, PointerEventManager, Modifier} from '../behaviors/event-manager'; +import {KeyboardEventManager, Modifier} from '../behaviors/event-manager'; +import {ClickEventManager} from '../behaviors/event-manager/click-event-manager'; import {computed, signal, SignalLike} from '../behaviors/signal-like/signal-like'; import {List, ListInputs} from '../behaviors/list/list'; @@ -160,9 +161,9 @@ export class ListboxPattern { return manager; }); - /** The pointerdown event manager for the listbox. */ - pointerdown = computed(() => { - const manager = new PointerEventManager(); + /** The click event manager for the listbox. */ + clickManager = computed(() => { + const manager = new ClickEventManager(); if (this.readonly()) { return manager.on(e => this.listBehavior.goto(this._getItem(e)!)); @@ -222,9 +223,9 @@ export class ListboxPattern { } } - onPointerdown(event: PointerEvent) { + onClick(event: PointerEvent) { if (!this.disabled()) { - this.pointerdown().handle(event); + this.clickManager().handle(event); } } diff --git a/src/aria/private/tabs/tabs.ts b/src/aria/private/tabs/tabs.ts index 044b3f68370a..8b93f6dfe0ac 100644 --- a/src/aria/private/tabs/tabs.ts +++ b/src/aria/private/tabs/tabs.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; +import {KeyboardEventManager} from '../behaviors/event-manager'; +import {ClickEventManager} from '../behaviors/event-manager/click-event-manager'; import {ExpansionItem, ListExpansionInputs, ListExpansion} from '../behaviors/expansion/expansion'; import { SignalLike, @@ -200,9 +201,9 @@ export class TabListPattern { .on('Enter', () => this.open()); }); - /** The pointerdown event manager for the tablist. */ - readonly pointerdown = computed(() => { - return new PointerEventManager().on(e => + /** The click event manager for the tablist. */ + readonly clickManager = computed(() => { + return new ClickEventManager().on(e => this._navigate(() => this.navigationBehavior.goto(this._getItem(e)!), true), ); }); @@ -256,10 +257,10 @@ export class TabListPattern { } } - /** The pointerdown event manager for the tablist. */ - onPointerdown(event: PointerEvent) { + /** The click event manager for the tablist. */ + onClick(event: PointerEvent) { if (!this.disabled()) { - this.pointerdown().handle(event); + this.clickManager().handle(event); } } diff --git a/src/aria/tabs/tab-list.ts b/src/aria/tabs/tab-list.ts index 7e9d4b12f413..08c8e41db3fc 100644 --- a/src/aria/tabs/tab-list.ts +++ b/src/aria/tabs/tab-list.ts @@ -52,7 +52,7 @@ import type {Tab} from './tab'; '[attr.aria-orientation]': '_pattern.orientation()', '[attr.aria-activedescendant]': '_pattern.activeDescendant()', '(keydown)': '_pattern.onKeydown($event)', - '(pointerdown)': '_pattern.onPointerdown($event)', + '(click)': '_pattern.onClick($event)', '(focusin)': '_onFocus()', }, }) diff --git a/src/aria/tabs/tabs.spec.ts b/src/aria/tabs/tabs.spec.ts index 39e25840f626..d1548aa0bb29 100644 --- a/src/aria/tabs/tabs.spec.ts +++ b/src/aria/tabs/tabs.spec.ts @@ -49,8 +49,17 @@ describe('Tabs', () => { defineTestVariables(); }; - const pointerDown = (target: HTMLElement, eventInit?: PointerEventInit) => { - target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, ...eventInit})); + const click = (target: HTMLElement, eventInit?: PointerEventInit) => { + target.dispatchEvent( + new PointerEvent('click', { + bubbles: true, + detail: 1, + pointerType: 'mouse', + clientX: 1, + clientY: 1, + ...eventInit, + }), + ); fixture.detectChanges(); defineTestVariables(); }; @@ -532,7 +541,7 @@ describe('Tabs', () => { it('should select tab on click', () => { updateTabs({selectedTab: 'tab1'}); expect(testComponent.selectedTab()).toBe('tab1'); - pointerDown(tabElements[1]); + click(tabElements[1]); expect(testComponent.selectedTab()).toBe('tab2'); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); }); @@ -599,7 +608,7 @@ describe('Tabs', () => { it('should select tab on click', () => { updateTabs({selectedTab: 'tab1'}); expect(testComponent.selectedTab()).toBe('tab1'); - pointerDown(tabElements[1]); + click(tabElements[1]); expect(testComponent.selectedTab()).toBe('tab2'); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); }); @@ -618,7 +627,7 @@ describe('Tabs', () => { enter(); expect(testComponent.selectedTab()).toBe('tab3'); - pointerDown(tabElements[0]); + click(tabElements[0]); expect(testComponent.selectedTab()).toBe('tab1'); }); @@ -645,7 +654,7 @@ describe('Tabs', () => { selectedTab: 'tab1', }); expect(testComponent.selectedTab()).toBe('tab1'); - pointerDown(tabElements[1]); + click(tabElements[1]); expect(testComponent.selectedTab()).toBe('tab1'); expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); }); @@ -672,7 +681,7 @@ describe('Tabs', () => { it('should not change selection if tablist is disabled', () => { updateTabs({selectedTab: 'tab1', disabled: true}); expect(testComponent.selectedTab()).toBe('tab1'); - pointerDown(tabElements[1]); + click(tabElements[1]); expect(testComponent.selectedTab()).toBe('tab1'); right(); expect(testComponent.selectedTab()).toBe('tab1'); diff --git a/src/components-examples/aria/tabs/index.ts b/src/components-examples/aria/tabs/index.ts index e0eb95b5a5e1..ed4d17e56227 100644 --- a/src/components-examples/aria/tabs/index.ts +++ b/src/components-examples/aria/tabs/index.ts @@ -7,3 +7,4 @@ export {TabsActiveDescendantExample} from './active-descendant/tabs-active-desce export {TabsDisabledFocusableExample} from './disabled-focusable/tabs-disabled-focusable-example'; export {TabsDisabledSkippedExample} from './disabled-skipped/tabs-disabled-skipped-example'; export {TabsDisabledExample} from './disabled/tabs-disabled-example'; +export {TabsScrollableExample} from './scrollable/tabs-scrollable-example'; diff --git a/src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.css b/src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.css new file mode 100644 index 000000000000..9ed26043f6a5 --- /dev/null +++ b/src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.css @@ -0,0 +1,7 @@ +/* Custom styles for the scrollable example */ + +/* Ensure the example container doesn't expand infinitely */ +tabs-scrollable-example { + display: block; + max-width: 100%; +} diff --git a/src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.html b/src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.html new file mode 100644 index 000000000000..05407c3d0773 --- /dev/null +++ b/src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.html @@ -0,0 +1,13 @@ +
+
+ @for (i of tabsList; track i) { +
Tab {{i}}
+ } +
+ + @for (i of tabsList; track i) { +
+ Content for Tab {{i}} +
+ } +
diff --git a/src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.ts b/src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.ts new file mode 100644 index 000000000000..01e4778cb651 --- /dev/null +++ b/src/components-examples/aria/tabs/scrollable/tabs-scrollable-example.ts @@ -0,0 +1,21 @@ +import {afterRenderEffect, Component, viewChildren} from '@angular/core'; +import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; + +/** @title Scrollable tabs */ +@Component({ + selector: 'tabs-scrollable-example', + templateUrl: 'tabs-scrollable-example.html', + styleUrls: ['../tabs-common.css', 'tabs-scrollable-example.css'], + imports: [TabList, Tab, Tabs, TabPanel, TabContent], +}) +export class TabsScrollableExample { + tabs = viewChildren(Tab); + tabsList = Array.from({length: 15}, (_, i) => i + 1); + + constructor() { + afterRenderEffect(() => { + const tab = this.tabs().find(t => t.active()); + tab?.element.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'}); + }); + } +} diff --git a/src/dev-app/aria-tabs/tabs-demo.html b/src/dev-app/aria-tabs/tabs-demo.html index 3fae6f69744d..f400eeaec13b 100644 --- a/src/dev-app/aria-tabs/tabs-demo.html +++ b/src/dev-app/aria-tabs/tabs-demo.html @@ -38,6 +38,11 @@

Disabled Tabs are Skipped

Disabled

+ +
+

Scrollable Tabs

+ +
diff --git a/src/dev-app/aria-tabs/tabs-demo.ts b/src/dev-app/aria-tabs/tabs-demo.ts index a1cb0705920e..c8e3476111c6 100644 --- a/src/dev-app/aria-tabs/tabs-demo.ts +++ b/src/dev-app/aria-tabs/tabs-demo.ts @@ -17,6 +17,7 @@ import { TabsDisabledFocusableExample, TabsDisabledSkippedExample, TabsDisabledExample, + TabsScrollableExample, } from '@angular/components-examples/aria/tabs'; @Component({ @@ -33,6 +34,7 @@ import { TabsDisabledFocusableExample, TabsDisabledSkippedExample, TabsDisabledExample, + TabsScrollableExample, ], changeDetection: ChangeDetectionStrategy.OnPush, }) From 691d4c5f1d02fe793175fa3880852a0a55762c52 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Sat, 4 Apr 2026 07:02:32 +0000 Subject: [PATCH 2/4] fixup! refactor(multiple): migrate Listbox and Tabs to ClickEventManager --- .../event-manager/click-event-manager.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/aria/private/behaviors/event-manager/click-event-manager.ts b/src/aria/private/behaviors/event-manager/click-event-manager.ts index 090676952b8d..4e9922345028 100644 --- a/src/aria/private/behaviors/event-manager/click-event-manager.ts +++ b/src/aria/private/behaviors/event-manager/click-event-manager.ts @@ -15,6 +15,20 @@ import { Modifier, } from './event-manager'; +/** + * Gets whether an event could be a faked click event dispatched by a screen reader + * or generated by the browser when Enter or Space keys are pressed. + */ +export function isFakeClick(event: PointerEvent): boolean { + // Some screen readers will dispatch a fake click event when pressing enter or space on + // a clickable element. We can distinguish these events when `event.buttons` is zero, or + // `event.detail` is zero depending on the browser: + // - `event.buttons` works on Firefox, but fails on Chrome. + // - `detail` works on Chrome, but fails on Firefox. + // Also, fake events might not have a valid pointerType. + return event.buttons === 0 || event.detail === 0 || !event.pointerType; +} + /** * An event manager that is specialized for handling click events. * @@ -67,19 +81,6 @@ export class ClickEventManager extends EventManager { } _isMatch(event: T, modifiers: ModifierInputs) { - return this._isRealClick(event) && hasModifiers(event, modifiers); - } - - /** - * Checks if the event is a "real" pointer click. - * - * Real clicks typically have a non-zero detail count (click count) and - * a valid pointerType (e.g. 'mouse' or 'touch'). - */ - private _isRealClick(event: T): boolean { - if (event.detail === 0) return false; - if (event.clientX === 0 && event.clientY === 0) return false; - if (!event.pointerType) return false; - return true; + return !isFakeClick(event) && hasModifiers(event, modifiers); } } From 19344ff9ca5395ff802c6e3adc11373be7730c9d Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Sat, 4 Apr 2026 07:23:44 +0000 Subject: [PATCH 3/4] fixup! refactor(multiple): migrate Listbox and Tabs to ClickEventManager --- .../event-manager/click-event-manager.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/aria/private/behaviors/event-manager/click-event-manager.ts b/src/aria/private/behaviors/event-manager/click-event-manager.ts index 4e9922345028..24985d5fa67b 100644 --- a/src/aria/private/behaviors/event-manager/click-event-manager.ts +++ b/src/aria/private/behaviors/event-manager/click-event-manager.ts @@ -16,23 +16,20 @@ import { } from './event-manager'; /** - * Gets whether an event could be a faked click event dispatched by a screen reader - * or generated by the browser when Enter or Space keys are pressed. + * Gets whether an event could be a simulated click event. + * + * Screen readers and keyboard activation (Enter/Space) often dispatch fake click + * events. We distinguish them by checking if `event.detail` is zero or if + * `event.pointerType` is missing. */ export function isFakeClick(event: PointerEvent): boolean { - // Some screen readers will dispatch a fake click event when pressing enter or space on - // a clickable element. We can distinguish these events when `event.buttons` is zero, or - // `event.detail` is zero depending on the browser: - // - `event.buttons` works on Firefox, but fails on Chrome. - // - `detail` works on Chrome, but fails on Firefox. - // Also, fake events might not have a valid pointerType. - return event.buttons === 0 || event.detail === 0 || !event.pointerType; + return event.detail === 0 || !event.pointerType; } /** * An event manager that is specialized for handling click events. * - * This manager should ONLY be used to handle pointer events. It explicitly + * This manager should ONLY be used to handle click events. It explicitly * filters out simulated click events generated by browsers when Enter or Space * keys are pressed, to avoid concurrent logic or overwriting selection state * when handling keyboard activation explicitly via KeyboardEventManager. From e4e7097dd6ec29a252b5b794aceaf90b3aa8fd23 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Sun, 5 Apr 2026 01:15:55 +0000 Subject: [PATCH 4/4] fixup! refactor(multiple): migrate Listbox and Tabs to ClickEventManager --- .../behaviors/event-manager/click-event-manager.ts | 11 ++++++++++- src/aria/tabs/tabs.spec.ts | 4 ---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/aria/private/behaviors/event-manager/click-event-manager.ts b/src/aria/private/behaviors/event-manager/click-event-manager.ts index 24985d5fa67b..03c6c2560008 100644 --- a/src/aria/private/behaviors/event-manager/click-event-manager.ts +++ b/src/aria/private/behaviors/event-manager/click-event-manager.ts @@ -26,6 +26,14 @@ export function isFakeClick(event: PointerEvent): boolean { return event.detail === 0 || !event.pointerType; } +/** + * Gets whether an event is a programmatic click (e.g. triggered by .click() or .dispatchEvent()). + * Programmatic events are untrusted. + */ +export function isProgrammaticClick(event: Event): boolean { + return !event.isTrusted; +} + /** * An event manager that is specialized for handling click events. * @@ -78,6 +86,7 @@ export class ClickEventManager extends EventManager { } _isMatch(event: T, modifiers: ModifierInputs) { - return !isFakeClick(event) && hasModifiers(event, modifiers); + const isAllowed = isProgrammaticClick(event) || !isFakeClick(event); + return isAllowed && hasModifiers(event, modifiers); } } diff --git a/src/aria/tabs/tabs.spec.ts b/src/aria/tabs/tabs.spec.ts index d1548aa0bb29..39c86b60afcf 100644 --- a/src/aria/tabs/tabs.spec.ts +++ b/src/aria/tabs/tabs.spec.ts @@ -53,10 +53,6 @@ describe('Tabs', () => { target.dispatchEvent( new PointerEvent('click', { bubbles: true, - detail: 1, - pointerType: 'mouse', - clientX: 1, - clientY: 1, ...eventInit, }), );