diff --git a/src/cdk-experimental/combobox/combobox-popup.ts b/src/cdk-experimental/combobox/combobox-popup.ts index 90aa3e99d069..99b390ae1000 100644 --- a/src/cdk-experimental/combobox/combobox-popup.ts +++ b/src/cdk-experimental/combobox/combobox-popup.ts @@ -6,11 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, ElementRef, Inject, Input, OnInit} from '@angular/core'; +import {IdGenerator} from '@angular/cdk/a11y'; +import {Directive, ElementRef, inject, Inject, Input, OnInit} from '@angular/core'; import {AriaHasPopupValue, CDK_COMBOBOX, CdkCombobox} from './combobox'; -let nextId = 0; - @Directive({ selector: '[cdkComboboxPopup]', exportAs: 'cdkComboboxPopup', @@ -24,6 +23,9 @@ let nextId = 0; standalone: true, }) export class CdkComboboxPopup implements OnInit { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + @Input() get role(): AriaHasPopupValue { return this._role; @@ -42,7 +44,7 @@ export class CdkComboboxPopup implements OnInit { } private _firstFocusElement: HTMLElement; - @Input() id = `cdk-combobox-popup-${nextId++}`; + @Input() id = this._idGenerator.getId('cdk-combobox-popup-'); constructor( private readonly _elementRef: ElementRef, diff --git a/src/cdk-experimental/table-scroll-container/BUILD.bazel b/src/cdk-experimental/table-scroll-container/BUILD.bazel index 9db3cec310c4..e624e99e819f 100644 --- a/src/cdk-experimental/table-scroll-container/BUILD.bazel +++ b/src/cdk-experimental/table-scroll-container/BUILD.bazel @@ -14,6 +14,7 @@ ng_module( exclude = ["**/*.spec.ts"], ), deps = [ + "//src/cdk/a11y", "//src/cdk/bidi", "//src/cdk/platform", "//src/cdk/table", diff --git a/src/cdk-experimental/table-scroll-container/table-scroll-container.ts b/src/cdk-experimental/table-scroll-container/table-scroll-container.ts index 89d164f57956..937635ed72c9 100644 --- a/src/cdk-experimental/table-scroll-container/table-scroll-container.ts +++ b/src/cdk-experimental/table-scroll-container/table-scroll-container.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {CSP_NONCE, Directive, ElementRef, Inject, OnDestroy, OnInit, Optional} from '@angular/core'; -import {DOCUMENT} from '@angular/common'; +import {IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {_getShadowRoot} from '@angular/cdk/platform'; import { @@ -16,8 +15,17 @@ import { StickySize, StickyUpdate, } from '@angular/cdk/table'; - -let nextId = 0; +import {DOCUMENT} from '@angular/common'; +import { + CSP_NONCE, + Directive, + ElementRef, + inject, + Inject, + OnDestroy, + OnInit, + Optional, +} from '@angular/core'; /** * Applies styles to the host element that make its scrollbars match up with @@ -39,6 +47,9 @@ let nextId = 0; standalone: true, }) export class CdkTableScrollContainer implements StickyPositioningListener, OnDestroy, OnInit { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private readonly _uniqueClassName: string; private _styleRoot!: Node; private _styleElement?: HTMLStyleElement; @@ -55,7 +66,7 @@ export class CdkTableScrollContainer implements StickyPositioningListener, OnDes @Optional() private readonly _directionality?: Directionality, @Optional() @Inject(CSP_NONCE) private readonly _nonce?: string | null, ) { - this._uniqueClassName = `cdk-table-scroll-container-${++nextId}`; + this._uniqueClassName = this._idGenerator.getId('cdk-table-scroll-container-'); _elementRef.nativeElement.classList.add(this._uniqueClassName); } diff --git a/src/cdk/a11y/id-generator/id-generator.ts b/src/cdk/a11y/id-generator/id-generator.ts new file mode 100644 index 000000000000..b1e9adcf4c4b --- /dev/null +++ b/src/cdk/a11y/id-generator/id-generator.ts @@ -0,0 +1,40 @@ +/** + * @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.io/license + */ +import {APP_ID, inject, Injectable} from '@angular/core'; + +let nextId = 0; + +/** + * Class that generates unique *enough* IDs for DOM elements. IDs + * are based on an incrementing number starting at zero and the + * Angular application's APP_ID. + */ +@Injectable({providedIn: 'root'}) +export class IdGenerator { + private _appId = inject(APP_ID); + + /** + * Gets an ID for a DOM element based on a given prefix, the application's APP_ID, and + * an incrementing number. Generated IDs are non-deterministic. Code should never depend on this + * service producing a specific ID. + * + * @param prefix Prefix for the ID. Use this to make the ID specific to a specific use-case. + * For example, if you are generating an ID for a checkbox element, you might specify + * "my-checkbox". + */ + getId(prefix: string): string { + // In dev mode, introduce some entropy to the generated IDs in order to prevent people from + // hard-coding specific IDs. + let entropy = ''; + if (typeof ngDevMode === 'undefined' || ngDevMode) { + entropy = `${Math.floor(Math.random() * 100000000)}` + } + + return `${prefix}${entropy}${this._appId}${nextId++}`; + } +} diff --git a/src/cdk/a11y/live-announcer/live-announcer.ts b/src/cdk/a11y/live-announcer/live-announcer.ts index b9ec9e6fe652..5b96817e1221 100644 --- a/src/cdk/a11y/live-announcer/live-announcer.ts +++ b/src/cdk/a11y/live-announcer/live-announcer.ts @@ -11,6 +11,7 @@ import {DOCUMENT} from '@angular/common'; import { Directive, ElementRef, + inject, Inject, Injectable, Input, @@ -19,17 +20,19 @@ import { Optional, } from '@angular/core'; import {Subscription} from 'rxjs'; +import {IdGenerator} from '../id-generator/id-generator'; import { AriaLivePoliteness, - LiveAnnouncerDefaultOptions, - LIVE_ANNOUNCER_ELEMENT_TOKEN, LIVE_ANNOUNCER_DEFAULT_OPTIONS, + LIVE_ANNOUNCER_ELEMENT_TOKEN, + LiveAnnouncerDefaultOptions, } from './live-announcer-tokens'; -let uniqueIds = 0; - @Injectable({providedIn: 'root'}) export class LiveAnnouncer implements OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _liveElement: HTMLElement; private _document: Document; private _previousTimeout: number; @@ -179,7 +182,7 @@ export class LiveAnnouncer implements OnDestroy { liveEl.setAttribute('aria-atomic', 'true'); liveEl.setAttribute('aria-live', 'polite'); - liveEl.id = `cdk-live-announcer-${uniqueIds++}`; + liveEl.id = this._idGenerator.getId('cdk-live-announcer-'); this._document.body.appendChild(liveEl); diff --git a/src/cdk/a11y/public-api.ts b/src/cdk/a11y/public-api.ts index f0c600b4016d..97e0bbab5585 100644 --- a/src/cdk/a11y/public-api.ts +++ b/src/cdk/a11y/public-api.ts @@ -36,3 +36,4 @@ export { HighContrastModeDetector, HighContrastMode, } from './high-contrast-mode/high-contrast-mode-detector'; +export * from './id-generator/id-generator'; diff --git a/src/cdk/accordion/BUILD.bazel b/src/cdk/accordion/BUILD.bazel index 919a0b06faab..7b1065d33951 100644 --- a/src/cdk/accordion/BUILD.bazel +++ b/src/cdk/accordion/BUILD.bazel @@ -15,6 +15,7 @@ ng_module( exclude = ["**/*.spec.ts"], ), deps = [ + "//src/cdk/a11y", "//src/cdk/collections", "@npm//@angular/core", "@npm//rxjs", diff --git a/src/cdk/accordion/accordion-item.ts b/src/cdk/accordion/accordion-item.ts index 64bb79bfd60b..529cbe0d5da2 100644 --- a/src/cdk/accordion/accordion-item.ts +++ b/src/cdk/accordion/accordion-item.ts @@ -6,24 +6,23 @@ * found in the LICENSE file at https://angular.io/license */ +import {IdGenerator} from '@angular/cdk/a11y'; +import {UniqueSelectionDispatcher} from '@angular/cdk/collections'; import { - Output, + booleanAttribute, + ChangeDetectorRef, Directive, EventEmitter, + Inject, + inject, Input, OnDestroy, Optional, - ChangeDetectorRef, + Output, SkipSelf, - Inject, - booleanAttribute, } from '@angular/core'; -import {UniqueSelectionDispatcher} from '@angular/cdk/collections'; -import {CDK_ACCORDION, CdkAccordion} from './accordion'; import {Subscription} from 'rxjs'; - -/** Used to generate unique ID for each accordion item. */ -let nextId = 0; +import {CDK_ACCORDION, CdkAccordion} from './accordion'; /** * An basic directive expected to be extended and decorated as a component. Sets up all @@ -40,6 +39,8 @@ let nextId = 0; standalone: true, }) export class CdkAccordionItem implements OnDestroy { + protected _idGenerator = inject(IdGenerator); + /** Subscription to openAll/closeAll events. */ private _openCloseAllSubscription = Subscription.EMPTY; /** Event emitted every time the AccordionItem is closed. */ @@ -57,7 +58,7 @@ export class CdkAccordionItem implements OnDestroy { @Output() readonly expandedChange: EventEmitter = new EventEmitter(); /** The unique AccordionItem id. */ - readonly id: string = `cdk-accordion-child-${nextId++}`; + readonly id: string = this._idGenerator.getId('cdk-accordion-child-'); /** Whether the AccordionItem is expanded. */ @Input({transform: booleanAttribute}) diff --git a/src/cdk/accordion/accordion.ts b/src/cdk/accordion/accordion.ts index 294e7ba6fdfc..eea2bb6cf033 100644 --- a/src/cdk/accordion/accordion.ts +++ b/src/cdk/accordion/accordion.ts @@ -6,20 +6,19 @@ * found in the LICENSE file at https://angular.io/license */ +import {IdGenerator} from '@angular/cdk/a11y'; import { + booleanAttribute, Directive, + inject, InjectionToken, Input, OnChanges, OnDestroy, SimpleChanges, - booleanAttribute, } from '@angular/core'; import {Subject} from 'rxjs'; -/** Used to generate unique ID for each accordion. */ -let nextId = 0; - /** * Injection token that can be used to reference instances of `CdkAccordion`. It serves * as alternative token to the actual `CdkAccordion` class which could cause unnecessary @@ -37,6 +36,9 @@ export const CDK_ACCORDION = new InjectionToken('CdkAccordion'); standalone: true, }) export class CdkAccordion implements OnDestroy, OnChanges { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** Emits when the state of the accordion changes */ readonly _stateChanges = new Subject(); @@ -44,7 +46,7 @@ export class CdkAccordion implements OnDestroy, OnChanges { readonly _openCloseAllActions: Subject = new Subject(); /** A readonly id value to use for unique selection coordination. */ - readonly id: string = `cdk-accordion-${nextId++}`; + readonly id: string = this._idGenerator.getId('cdk-accordion-'); /** Whether the accordion should allow multiple expanded accordion items simultaneously. */ @Input({transform: booleanAttribute}) multi: boolean = false; diff --git a/src/cdk/dialog/dialog.ts b/src/cdk/dialog/dialog.ts index 25c1aa51a825..a1685a6c21c4 100644 --- a/src/cdk/dialog/dialog.ts +++ b/src/cdk/dialog/dialog.ts @@ -6,41 +6,43 @@ * found in the LICENSE file at https://angular.io/license */ -import { - TemplateRef, - Injectable, - Injector, - OnDestroy, - Type, - StaticProvider, - Inject, - Optional, - SkipSelf, - ComponentRef, -} from '@angular/core'; -import {BasePortalOutlet, ComponentPortal, TemplatePortal} from '@angular/cdk/portal'; -import {of as observableOf, Observable, Subject, defer} from 'rxjs'; -import {DialogRef} from './dialog-ref'; -import {DialogConfig} from './dialog-config'; +import {IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import { ComponentType, Overlay, - OverlayRef, OverlayConfig, - ScrollStrategy, OverlayContainer, + OverlayRef, + ScrollStrategy, } from '@angular/cdk/overlay'; +import {BasePortalOutlet, ComponentPortal, TemplatePortal} from '@angular/cdk/portal'; +import { + ComponentRef, + Inject, + inject, + Injectable, + Injector, + OnDestroy, + Optional, + SkipSelf, + StaticProvider, + TemplateRef, + Type, +} from '@angular/core'; +import {defer, Observable, of as observableOf, Subject} from 'rxjs'; import {startWith} from 'rxjs/operators'; - -import {DEFAULT_DIALOG_CONFIG, DIALOG_DATA, DIALOG_SCROLL_STRATEGY} from './dialog-injectors'; +import {DialogConfig} from './dialog-config'; import {CdkDialogContainer} from './dialog-container'; -/** Unique id for the created dialog. */ -let uniqueId = 0; +import {DEFAULT_DIALOG_CONFIG, DIALOG_DATA, DIALOG_SCROLL_STRATEGY} from './dialog-injectors'; +import {DialogRef} from './dialog-ref'; @Injectable({providedIn: 'root'}) export class Dialog implements OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _openDialogsAtThisLevel: DialogRef[] = []; private readonly _afterAllClosedAtThisLevel = new Subject(); private readonly _afterOpenedAtThisLevel = new Subject(); @@ -114,7 +116,7 @@ export class Dialog implements OnDestroy { DialogRef >; config = {...defaults, ...config}; - config.id = config.id || `cdk-dialog-${uniqueId++}`; + config.id = config.id || this._idGenerator.getId('cdk-dialog-'); if ( config.id && diff --git a/src/cdk/drag-drop/directives/drop-list.ts b/src/cdk/drag-drop/directives/drop-list.ts index db13704b368b..a2e9642e4759 100644 --- a/src/cdk/drag-drop/directives/drop-list.ts +++ b/src/cdk/drag-drop/directives/drop-list.ts @@ -6,35 +6,34 @@ * found in the LICENSE file at https://angular.io/license */ -import {NumberInput, coerceArray, coerceNumberProperty} from '@angular/cdk/coercion'; +import {IdGenerator} from '@angular/cdk/a11y'; +import {Directionality} from '@angular/cdk/bidi'; +import {coerceArray, coerceNumberProperty, NumberInput} from '@angular/cdk/coercion'; +import {ScrollDispatcher} from '@angular/cdk/scrolling'; import { + booleanAttribute, + ChangeDetectorRef, + Directive, ElementRef, EventEmitter, + Inject, + inject, Input, OnDestroy, - Output, Optional, - Directive, - ChangeDetectorRef, + Output, SkipSelf, - Inject, - booleanAttribute, } from '@angular/core'; -import {Directionality} from '@angular/cdk/bidi'; -import {ScrollDispatcher} from '@angular/cdk/scrolling'; -import {CDK_DROP_LIST, CdkDrag} from './drag'; -import {CdkDragDrop, CdkDragEnter, CdkDragExit, CdkDragSortEvent} from '../drag-events'; -import {CDK_DROP_LIST_GROUP, CdkDropListGroup} from './drop-list-group'; -import {DropListRef} from '../drop-list-ref'; -import {DragRef} from '../drag-ref'; -import {DragDrop} from '../drag-drop'; -import {DropListOrientation, DragAxis, DragDropConfig, CDK_DRAG_CONFIG} from './config'; import {merge, Subject} from 'rxjs'; import {startWith, takeUntil} from 'rxjs/operators'; +import {DragDrop} from '../drag-drop'; +import {CdkDragDrop, CdkDragEnter, CdkDragExit, CdkDragSortEvent} from '../drag-events'; +import {DragRef} from '../drag-ref'; +import {DropListRef} from '../drop-list-ref'; import {assertElementNode} from './assertions'; - -/** Counter used to generate unique ids for drop zones. */ -let _uniqueIdCounter = 0; +import {CDK_DRAG_CONFIG, DragAxis, DragDropConfig, DropListOrientation} from './config'; +import {CDK_DROP_LIST, CdkDrag} from './drag'; +import {CDK_DROP_LIST_GROUP, CdkDropListGroup} from './drop-list-group'; /** Container that wraps a set of draggable items. */ @Directive({ @@ -55,6 +54,9 @@ let _uniqueIdCounter = 0; }, }) export class CdkDropList implements OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** Emits when the list has been destroyed. */ private readonly _destroyed = new Subject(); @@ -85,7 +87,7 @@ export class CdkDropList implements OnDestroy { * Unique ID for the drop zone. Can be used as a reference * in the `connectedTo` of another `CdkDropList`. */ - @Input() id: string = `cdk-drop-list-${_uniqueIdCounter++}`; + @Input() id: string = this._idGenerator.getId('cdk-drop-list-'); /** Locks the position of the draggable elements inside the container along the specified axis. */ @Input('cdkDropListLockAxis') lockAxis: DragAxis; diff --git a/src/cdk/listbox/listbox.spec.ts b/src/cdk/listbox/listbox.spec.ts index e49c7951d14a..9cfba4985b27 100644 --- a/src/cdk/listbox/listbox.spec.ts +++ b/src/cdk/listbox/listbox.spec.ts @@ -46,10 +46,10 @@ describe('CdkOption and CdkListbox', () => { expect(optionIds.size).toBe(options.length); for (let i = 0; i < options.length; i++) { expect(options[i].id).toBe(optionEls[i].id); - expect(options[i].id).toMatch(/cdk-option-\d+/); + expect(options[i].id).toMatch(/cdk-option-\w+/); } expect(listbox.id).toEqual(listboxEl.id); - expect(listbox.id).toMatch(/cdk-listbox-\d+/); + expect(listbox.id).toMatch(/cdk-listbox-\w+/); }); it('should not overwrite user given ids', () => { diff --git a/src/cdk/listbox/listbox.ts b/src/cdk/listbox/listbox.ts index 213d1f31adef..0e2b2e4efa13 100644 --- a/src/cdk/listbox/listbox.ts +++ b/src/cdk/listbox/listbox.ts @@ -6,7 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ -import {ActiveDescendantKeyManager, Highlightable, ListKeyManagerOption} from '@angular/cdk/a11y'; +import { + ActiveDescendantKeyManager, + Highlightable, + IdGenerator, + ListKeyManagerOption, +} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {coerceArray} from '@angular/cdk/coercion'; import {SelectionModel} from '@angular/cdk/collections'; @@ -42,9 +47,6 @@ import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {defer, fromEvent, merge, Observable, Subject} from 'rxjs'; import {filter, map, startWith, switchMap, takeUntil} from 'rxjs/operators'; -/** The next id to use for creating unique DOM IDs. */ -let nextId = 0; - /** * An implementation of SelectionModel that internally always represents the selection as a * multi-selection. This is necessary so that we can recover the full selection if the user @@ -96,6 +98,9 @@ class ListboxSelectionModel extends SelectionModel { }, }) export class CdkOption implements ListKeyManagerOption, Highlightable, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** The id of the option's host element. */ @Input() get id() { @@ -105,7 +110,7 @@ export class CdkOption implements ListKeyManagerOption, Highlightab this._id = value; } private _id: string; - private _generatedId = `cdk-option-${nextId++}`; + private _generatedId = this._idGenerator.getId('cdk-option-'); /** The value of this option. */ @Input('cdkOption') value: T; @@ -249,6 +254,9 @@ export class CdkOption implements ListKeyManagerOption, Highlightab ], }) export class CdkListbox implements AfterContentInit, OnDestroy, ControlValueAccessor { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** The id of the option's host element. */ @Input() get id() { @@ -258,7 +266,7 @@ export class CdkListbox implements AfterContentInit, OnDestroy, Con this._id = value; } private _id: string; - private _generatedId = `cdk-listbox-${nextId++}`; + private _generatedId = this._idGenerator.getId('cdk-listbox-'); /** The tabindex to use when the listbox is enabled. */ @Input('tabindex') diff --git a/src/cdk/menu/menu-base.ts b/src/cdk/menu/menu-base.ts index 031e323abec3..17a7b1dff56a 100644 --- a/src/cdk/menu/menu-base.ts +++ b/src/cdk/menu/menu-base.ts @@ -6,22 +6,22 @@ * found in the LICENSE file at https://angular.io/license */ -import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y'; +import {FocusKeyManager, FocusOrigin, IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import { AfterContentInit, + computed, ContentChildren, Directive, ElementRef, + inject, Input, NgZone, OnDestroy, QueryList, - computed, - inject, signal, } from '@angular/core'; -import {Subject, merge} from 'rxjs'; +import {merge, Subject} from 'rxjs'; import {mapTo, mergeAll, mergeMap, startWith, switchMap, takeUntil} from 'rxjs/operators'; import {MENU_AIM} from './menu-aim'; import {CdkMenuGroup} from './menu-group'; @@ -30,9 +30,6 @@ import {CdkMenuItem} from './menu-item'; import {MENU_STACK, MenuStack, MenuStackItem} from './menu-stack'; import {PointerFocusTracker} from './pointer-focus-tracker'; -/** Counter used to create unique IDs for menus. */ -let nextId = 0; - /** * Abstract directive that implements shared logic common to all menus. * This class can be extended to create custom menu types. @@ -55,6 +52,9 @@ export abstract class CdkMenuBase extends CdkMenuGroup implements Menu, AfterContentInit, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** The menu's native DOM host element. */ readonly nativeElement: HTMLElement = inject(ElementRef).nativeElement; @@ -71,7 +71,7 @@ export abstract class CdkMenuBase protected readonly dir = inject(Directionality, {optional: true}); /** The id of the menu's host element. */ - @Input() id = `cdk-menu-${nextId++}`; + @Input() id = this._idGenerator.getId('cdk-menu-'); /** All child MenuItem elements nested in this Menu. */ @ContentChildren(CdkMenuItem, {descendants: true}) diff --git a/src/cdk/overlay/BUILD.bazel b/src/cdk/overlay/BUILD.bazel index 55713dc87df7..1a48335bcd86 100644 --- a/src/cdk/overlay/BUILD.bazel +++ b/src/cdk/overlay/BUILD.bazel @@ -20,6 +20,7 @@ ng_module( ), deps = [ "//src:dev_mode_types", + "//src/cdk/a11y", "//src/cdk/bidi", "//src/cdk/coercion", "//src/cdk/keycodes", diff --git a/src/cdk/overlay/overlay.ts b/src/cdk/overlay/overlay.ts index be660242b437..ee7e2f05d639 100644 --- a/src/cdk/overlay/overlay.ts +++ b/src/cdk/overlay/overlay.ts @@ -6,19 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ +import {IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {DomPortalOutlet} from '@angular/cdk/portal'; import {DOCUMENT, Location} from '@angular/common'; import { + ANIMATION_MODULE_TYPE, ApplicationRef, ComponentFactoryResolver, + EnvironmentInjector, Inject, + inject, Injectable, Injector, NgZone, - ANIMATION_MODULE_TYPE, Optional, - EnvironmentInjector, } from '@angular/core'; import {OverlayKeyboardDispatcher} from './dispatchers/overlay-keyboard-dispatcher'; import {OverlayOutsideClickDispatcher} from './dispatchers/overlay-outside-click-dispatcher'; @@ -28,9 +30,6 @@ import {OverlayRef} from './overlay-ref'; import {OverlayPositionBuilder} from './position/overlay-position-builder'; import {ScrollStrategyOptions} from './scroll/index'; -/** Next overlay unique ID. */ -let nextUniqueId = 0; - // Note that Overlay is *not* scoped to the app root because of the ComponentFactoryResolver // which needs to be different depending on where OverlayModule is imported. @@ -44,6 +43,9 @@ let nextUniqueId = 0; */ @Injectable({providedIn: 'root'}) export class Overlay { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _appRef: ApplicationRef; constructor( @@ -106,7 +108,7 @@ export class Overlay { private _createPaneElement(host: HTMLElement): HTMLElement { const pane = this._document.createElement('div'); - pane.id = `cdk-overlay-${nextUniqueId++}`; + pane.id = this._idGenerator.getId('cdk-overlay-'); pane.classList.add('cdk-overlay-pane'); host.appendChild(pane); diff --git a/src/cdk/stepper/stepper.ts b/src/cdk/stepper/stepper.ts index 1a7d7851965e..94001b9a889c 100644 --- a/src/cdk/stepper/stepper.ts +++ b/src/cdk/stepper/stepper.ts @@ -9,8 +9,12 @@ import {FocusableOption, FocusKeyManager} from '@angular/cdk/a11y'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {ENTER, hasModifierKey, SPACE} from '@angular/cdk/keycodes'; +import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform'; import { + AfterContentInit, AfterViewInit, + APP_ID, + booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -21,8 +25,10 @@ import { EventEmitter, forwardRef, Inject, + inject, InjectionToken, Input, + numberAttribute, OnChanges, OnDestroy, Optional, @@ -31,11 +37,7 @@ import { TemplateRef, ViewChild, ViewEncapsulation, - AfterContentInit, - booleanAttribute, - numberAttribute, } from '@angular/core'; -import {_getFocusedElementPierceShadowDom} from '@angular/cdk/platform'; import {Observable, of as observableOf, Subject} from 'rxjs'; import {startWith, takeUntil} from 'rxjs/operators'; @@ -236,6 +238,9 @@ export class CdkStep implements OnChanges { standalone: true, }) export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _appId = inject(APP_ID); + /** Emits when the component is destroyed. */ protected readonly _destroyed = new Subject(); @@ -420,7 +425,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { /** Returns unique id for each step content element. */ _getStepContentId(i: number): string { - return `cdk-step-content-${this._groupId}-${i}`; + return `cdk-step-content-${this._appId}${this._groupId}-${i}`; } /** Marks the component to be change detected. */ diff --git a/src/material/autocomplete/autocomplete.ts b/src/material/autocomplete/autocomplete.ts index bfa8227258f5..53940978c189 100644 --- a/src/material/autocomplete/autocomplete.ts +++ b/src/material/autocomplete/autocomplete.ts @@ -6,8 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ +import {AnimationEvent} from '@angular/animations'; +import {ActiveDescendantKeyManager, IdGenerator} from '@angular/cdk/a11y'; +import {Platform} from '@angular/cdk/platform'; import { AfterContentInit, + booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -15,6 +19,7 @@ import { ElementRef, EventEmitter, Inject, + inject, InjectionToken, Input, OnDestroy, @@ -23,9 +28,7 @@ import { TemplateRef, ViewChild, ViewEncapsulation, - booleanAttribute, } from '@angular/core'; -import {AnimationEvent} from '@angular/animations'; import { MAT_OPTGROUP, MAT_OPTION_PARENT_COMPONENT, @@ -33,16 +36,8 @@ import { MatOption, ThemePalette, } from '@angular/material/core'; -import {ActiveDescendantKeyManager} from '@angular/cdk/a11y'; -import {Platform} from '@angular/cdk/platform'; -import {panelAnimation} from './animations'; import {Subscription} from 'rxjs'; - -/** - * Autocomplete IDs need to be unique across components, so this counter exists outside of - * the component definition. - */ -let _uniqueAutocompleteIdCounter = 0; +import {panelAnimation} from './animations'; /** Event object that is emitted when an autocomplete option is selected. */ export class MatAutocompleteSelectedEvent { @@ -119,6 +114,9 @@ export function MAT_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY(): MatAutocompleteDefau standalone: true, }) export class MatAutocomplete implements AfterContentInit, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _activeOptionChanges = Subscription.EMPTY; /** Emits when the panel animation is done. Null if the panel doesn't animate. */ @@ -244,7 +242,7 @@ export class MatAutocomplete implements AfterContentInit, OnDestroy { } /** Unique ID to be used by autocomplete trigger's "aria-owns" property. */ - id: string = `mat-autocomplete-${_uniqueAutocompleteIdCounter++}`; + id: string = this._idGenerator.getId('mat-autocomplete-'); /** * Tells any descendant `mat-optgroup` to use the inert a11y pattern. diff --git a/src/material/badge/badge.ts b/src/material/badge/badge.ts index 74789e31d8cc..170f032b1f09 100644 --- a/src/material/badge/badge.ts +++ b/src/material/badge/badge.ts @@ -6,9 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import {AriaDescriber, InteractivityChecker} from '@angular/cdk/a11y'; +import {AriaDescriber, IdGenerator, InteractivityChecker} from '@angular/cdk/a11y'; import {DOCUMENT} from '@angular/common'; import { + ANIMATION_MODULE_TYPE, ApplicationRef, booleanAttribute, ChangeDetectionStrategy, @@ -26,7 +27,6 @@ import { Optional, Renderer2, ViewEncapsulation, - ANIMATION_MODULE_TYPE, } from '@angular/core'; import {ThemePalette} from '@angular/material/core'; @@ -83,6 +83,9 @@ export class _MatBadgeStyleLoader {} standalone: true, }) export class MatBadge implements OnInit, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** * Theme color of the badge. This API is supported in M2 themes only, it * has no effect in M3 themes. @@ -259,7 +262,7 @@ export class MatBadge implements OnInit, OnDestroy { const badgeElement = this._renderer.createElement('span'); const activeClass = 'mat-badge-active'; - badgeElement.setAttribute('id', `mat-badge-content-${this._id}`); + badgeElement.setAttribute('id', this._idGenerator.getId('mat-badge-content-')); // The badge is aria-hidden because we don't want it to appear in the page's navigation // flow. Instead, we use the badge to describe the decorated element with aria-describedby. diff --git a/src/material/button-toggle/button-toggle.ts b/src/material/button-toggle/button-toggle.ts index c6290fbe2770..b48e4405b81a 100644 --- a/src/material/button-toggle/button-toggle.ts +++ b/src/material/button-toggle/button-toggle.ts @@ -6,12 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import {FocusMonitor} from '@angular/cdk/a11y'; +import {FocusMonitor, IdGenerator} from '@angular/cdk/a11y'; +import {Direction, Directionality} from '@angular/cdk/bidi'; import {SelectionModel} from '@angular/cdk/collections'; -import {DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, UP_ARROW, SPACE, ENTER} from '@angular/cdk/keycodes'; +import {DOWN_ARROW, ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, UP_ARROW} from '@angular/cdk/keycodes'; import { AfterContentInit, + AfterViewInit, Attribute, + booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -20,6 +23,9 @@ import { ElementRef, EventEmitter, forwardRef, + Inject, + inject, + InjectionToken, Input, OnDestroy, OnInit, @@ -28,14 +34,9 @@ import { QueryList, ViewChild, ViewEncapsulation, - InjectionToken, - Inject, - AfterViewInit, - booleanAttribute, } from '@angular/core'; -import {Direction, Directionality} from '@angular/cdk/bidi'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; -import {MatRipple, MatPseudoCheckbox} from '@angular/material/core'; +import {MatPseudoCheckbox, MatRipple} from '@angular/material/core'; /** * @deprecated No longer used. @@ -134,6 +135,9 @@ export class MatButtonToggleChange { standalone: true, }) export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, AfterContentInit { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _multiple = false; private _disabled = false; private _selectionModel: SelectionModel; @@ -175,7 +179,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After this._name = value; this._markButtonsForCheck(); } - private _name = `mat-button-toggle-group-${uniqueIdCounter++}`; + private _name = this._idGenerator.getId('mat-button-toggle-group-'); /** Whether the toggle group is vertical. */ @Input({transform: booleanAttribute}) vertical: boolean; diff --git a/src/material/checkbox/checkbox.spec.ts b/src/material/checkbox/checkbox.spec.ts index dc751b271d7f..cd3bab7c1002 100644 --- a/src/material/checkbox/checkbox.spec.ts +++ b/src/material/checkbox/checkbox.spec.ts @@ -302,7 +302,7 @@ describe('MDC-based MatCheckbox', () => { fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); - expect(checkboxInstance.inputId).toMatch(/mat-mdc-checkbox-\d+/); + expect(checkboxInstance.inputId).toMatch(/mat-mdc-checkbox-\w+/); expect(inputElement.id).toBe(checkboxInstance.inputId); })); @@ -881,8 +881,8 @@ describe('MDC-based MatCheckbox', () => { .queryAll(By.directive(MatCheckbox)) .map(debugElement => debugElement.nativeElement.querySelector('input').id); - expect(firstId).toMatch(/mat-mdc-checkbox-\d+-input/); - expect(secondId).toMatch(/mat-mdc-checkbox-\d+-input/); + expect(firstId).toMatch(/mat-mdc-checkbox-\w+-input/); + expect(secondId).toMatch(/mat-mdc-checkbox-\w+-input/); expect(firstId).not.toEqual(secondId); })); }); diff --git a/src/material/checkbox/checkbox.ts b/src/material/checkbox/checkbox.ts index aee9aaac1016..dcb42352ec00 100644 --- a/src/material/checkbox/checkbox.ts +++ b/src/material/checkbox/checkbox.ts @@ -6,28 +6,29 @@ * found in the LICENSE file at https://angular.io/license */ -import {FocusableOption} from '@angular/cdk/a11y'; +import {FocusableOption, IdGenerator} from '@angular/cdk/a11y'; import { - ANIMATION_MODULE_TYPE, AfterViewInit, + ANIMATION_MODULE_TYPE, Attribute, + booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, + forwardRef, Inject, + inject, Input, NgZone, + numberAttribute, OnChanges, Optional, Output, SimpleChanges, ViewChild, ViewEncapsulation, - booleanAttribute, - forwardRef, - numberAttribute, } from '@angular/core'; import { AbstractControl, @@ -37,7 +38,7 @@ import { ValidationErrors, Validator, } from '@angular/forms'; -import {MatRipple, _MatInternalFormField} from '@angular/material/core'; +import {_MatInternalFormField, MatRipple} from '@angular/material/core'; import { MAT_CHECKBOX_DEFAULT_OPTIONS, MAT_CHECKBOX_DEFAULT_OPTIONS_FACTORY, @@ -77,9 +78,6 @@ export class MatCheckboxChange { checked: boolean; } -// Increasing integer for generating unique ids for checkbox components. -let nextUniqueId = 0; - // Default checkbox configuration. const defaults = MAT_CHECKBOX_DEFAULT_OPTIONS_FACTORY(); @@ -118,6 +116,9 @@ const defaults = MAT_CHECKBOX_DEFAULT_OPTIONS_FACTORY(); export class MatCheckbox implements AfterViewInit, OnChanges, ControlValueAccessor, Validator, FocusableOption { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** Focuses the checkbox. */ focus() { this._inputElement.nativeElement.focus(); @@ -245,7 +246,7 @@ export class MatCheckbox this._options = this._options || defaults; this.color = this._options.color || defaults.color; this.tabIndex = parseInt(tabIndex) || 0; - this.id = this._uniqueId = `mat-mdc-checkbox-${++nextUniqueId}`; + this.id = this._uniqueId = this._idGenerator.getId('mat-mdc-checkbox-'); this.disabledInteractive = _options?.disabledInteractive ?? false; } diff --git a/src/material/chips/BUILD.bazel b/src/material/chips/BUILD.bazel index 8b4f66c3e473..c45f033b92eb 100644 --- a/src/material/chips/BUILD.bazel +++ b/src/material/chips/BUILD.bazel @@ -24,6 +24,7 @@ ng_module( ] + glob(["**/*.html"]), deps = [ "//src:dev_mode_types", + "//src/cdk/a11y", "//src/material/core", "//src/material/form-field", "@npm//@angular/animations", diff --git a/src/material/chips/chip-input.ts b/src/material/chips/chip-input.ts index 6f39cbdb906c..4af7492783b5 100644 --- a/src/material/chips/chip-input.ts +++ b/src/material/chips/chip-input.ts @@ -6,23 +6,25 @@ * found in the LICENSE file at https://angular.io/license */ +import {IdGenerator} from '@angular/cdk/a11y'; import {BACKSPACE, hasModifierKey} from '@angular/cdk/keycodes'; import { + booleanAttribute, Directive, ElementRef, EventEmitter, Inject, + inject, Input, OnChanges, OnDestroy, Optional, Output, - booleanAttribute, } from '@angular/core'; -import {MatFormField, MAT_FORM_FIELD} from '@angular/material/form-field'; -import {MatChipsDefaultOptions, MAT_CHIPS_DEFAULT_OPTIONS} from './tokens'; +import {MAT_FORM_FIELD, MatFormField} from '@angular/material/form-field'; import {MatChipGrid} from './chip-grid'; import {MatChipTextControl} from './chip-text-control'; +import {MAT_CHIPS_DEFAULT_OPTIONS, MatChipsDefaultOptions} from './tokens'; /** Represents an input event on a `matChipInput`. */ export interface MatChipInputEvent { @@ -41,7 +43,6 @@ export interface MatChipInputEvent { } // Increasing integer for generating unique ids. -let nextUniqueId = 0; /** * Directive that adds chip-specific behaviors to an input element inside ``. @@ -69,6 +70,9 @@ let nextUniqueId = 0; standalone: true, }) export class MatChipInput implements MatChipTextControl, OnChanges, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** Whether the control is focused. */ focused: boolean = false; @@ -107,7 +111,7 @@ export class MatChipInput implements MatChipTextControl, OnChanges, OnDestroy { @Input() placeholder: string = ''; /** Unique id for the input. */ - @Input() id: string = `mat-mdc-chip-list-input-${nextUniqueId++}`; + @Input() id: string = this._idGenerator.getId('mat-mdc-chip-list-input-'); /** Whether the input is disabled. */ @Input({transform: booleanAttribute}) diff --git a/src/material/chips/chip.ts b/src/material/chips/chip.ts index d489f9766168..62554e2d6d86 100644 --- a/src/material/chips/chip.ts +++ b/src/material/chips/chip.ts @@ -6,13 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ -import {FocusMonitor} from '@angular/cdk/a11y'; +import {FocusMonitor, IdGenerator} from '@angular/cdk/a11y'; import {BACKSPACE, DELETE} from '@angular/cdk/keycodes'; import {DOCUMENT} from '@angular/common'; import { - ANIMATION_MODULE_TYPE, AfterContentInit, + afterNextRender, AfterViewInit, + ANIMATION_MODULE_TYPE, + booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -22,6 +24,7 @@ import { ElementRef, EventEmitter, Inject, + inject, Injector, Input, NgZone, @@ -32,9 +35,6 @@ import { QueryList, ViewChild, ViewEncapsulation, - afterNextRender, - booleanAttribute, - inject, } from '@angular/core'; import { MAT_RIPPLE_GLOBAL_OPTIONS, @@ -42,13 +42,11 @@ import { MatRippleLoader, RippleGlobalOptions, } from '@angular/material/core'; -import {Subject, Subscription, merge} from 'rxjs'; +import {merge, Subject, Subscription} from 'rxjs'; import {MatChipAction} from './chip-action'; import {MatChipAvatar, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; import {MAT_CHIP, MAT_CHIP_AVATAR, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON} from './tokens'; -let uid = 0; - /** Represents an event fired on an individual `mat-chip`. */ export interface MatChipEvent { /** The chip the event was fired on. */ @@ -93,6 +91,9 @@ export interface MatChipEvent { imports: [MatChipAction], }) export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + protected _document: Document; /** Emits when the chip is focused. */ @@ -136,7 +137,7 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck } /** A unique id for the chip. If none is supplied, it will be auto-generated. */ - @Input() id: string = `mat-mdc-chip-${uid++}`; + @Input() id: string = this._idGenerator.getId('mat-mdc-chip-'); // TODO(#26104): Consider deprecating and using `_computeAriaAccessibleName` instead. // `ariaLabel` may be unnecessary, and `_computeAriaAccessibleName` only supports diff --git a/src/material/core/option/optgroup.ts b/src/material/core/option/optgroup.ts index dc5e75eeed6f..bbfcc139f180 100644 --- a/src/material/core/option/optgroup.ts +++ b/src/material/core/option/optgroup.ts @@ -6,17 +6,19 @@ * found in the LICENSE file at https://angular.io/license */ +import {IdGenerator} from '@angular/cdk/a11y'; import { - Component, - ViewEncapsulation, + booleanAttribute, ChangeDetectionStrategy, - Input, + Component, Inject, - Optional, + inject, InjectionToken, - booleanAttribute, + Input, + Optional, + ViewEncapsulation, } from '@angular/core'; -import {MatOptionParentComponent, MAT_OPTION_PARENT_COMPONENT} from './option-parent'; +import {MAT_OPTION_PARENT_COMPONENT, MatOptionParentComponent} from './option-parent'; // Notes on the accessibility pattern used for `mat-optgroup`. // The option group has two different "modes": regular and inert. The regular mode uses the @@ -39,7 +41,6 @@ import {MatOptionParentComponent, MAT_OPTION_PARENT_COMPONENT} from './option-pa // doesn't read out the text at all. Furthermore, on // Counter for unique group ids. -let _uniqueOptgroupIdCounter = 0; /** * Injection token that can be used to reference instances of `MatOptgroup`. It serves as @@ -68,6 +69,9 @@ export const MAT_OPTGROUP = new InjectionToken('MatOptgroup'); standalone: true, }) export class MatOptgroup { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** Label for the option group. */ @Input() label: string; @@ -75,7 +79,7 @@ export class MatOptgroup { @Input({transform: booleanAttribute}) disabled: boolean = false; /** Unique id for the underlying label. */ - _labelId: string = `mat-optgroup-label-${_uniqueOptgroupIdCounter++}`; + _labelId: string = this._idGenerator.getId('mat-optgroup-label-'); /** Whether the group is in inert a11y mode. */ _inert: boolean; diff --git a/src/material/core/option/option.ts b/src/material/core/option/option.ts index 9c28ddd1cf7c..dfc9e75a49c8 100644 --- a/src/material/core/option/option.ts +++ b/src/material/core/option/option.ts @@ -6,36 +6,31 @@ * found in the LICENSE file at https://angular.io/license */ -import {FocusableOption, FocusOrigin} from '@angular/cdk/a11y'; +import {FocusableOption, FocusOrigin, IdGenerator} from '@angular/cdk/a11y'; import {ENTER, hasModifierKey, SPACE} from '@angular/cdk/keycodes'; import { - Component, - ViewEncapsulation, + AfterViewChecked, + booleanAttribute, ChangeDetectionStrategy, - ElementRef, ChangeDetectorRef, - Optional, + Component, + ElementRef, + EventEmitter, Inject, - AfterViewChecked, - OnDestroy, + inject, Input, + OnDestroy, + Optional, Output, - EventEmitter, QueryList, ViewChild, - booleanAttribute, + ViewEncapsulation, } from '@angular/core'; import {Subject} from 'rxjs'; -import {MAT_OPTGROUP, MatOptgroup} from './optgroup'; -import {MatOptionParentComponent, MAT_OPTION_PARENT_COMPONENT} from './option-parent'; import {MatRipple} from '../ripple/ripple'; import {MatPseudoCheckbox} from '../selection/pseudo-checkbox/pseudo-checkbox'; - -/** - * Option IDs need to be unique across components, so this counter exists outside of - * the component definition. - */ -let _uniqueIdCounter = 0; +import {MAT_OPTGROUP, MatOptgroup} from './optgroup'; +import {MAT_OPTION_PARENT_COMPONENT, MatOptionParentComponent} from './option-parent'; /** Event object emitted by MatOption when selected or deselected. */ export class MatOptionSelectionChange { @@ -83,6 +78,9 @@ export class MatOptionSelectionChange { imports: [MatPseudoCheckbox, MatRipple], }) export class MatOption implements FocusableOption, AfterViewChecked, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _selected = false; private _active = false; private _disabled = false; @@ -102,7 +100,7 @@ export class MatOption implements FocusableOption, AfterViewChecked, On @Input() value: T; /** The unique ID of the option. */ - @Input() id: string = `mat-option-${_uniqueIdCounter++}`; + @Input() id: string = this._idGenerator.getId('mat-option-'); /** Whether the option is disabled. */ @Input({transform: booleanAttribute}) diff --git a/src/material/datepicker/calendar-body.ts b/src/material/datepicker/calendar-body.ts index 1e4cdadc220f..31c26a3e8798 100644 --- a/src/material/datepicker/calendar-body.ts +++ b/src/material/datepicker/calendar-body.ts @@ -6,25 +6,26 @@ * found in the LICENSE file at https://angular.io/license */ -import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; +import {IdGenerator} from '@angular/cdk/a11y'; +import {normalizePassiveListenerOptions, Platform} from '@angular/cdk/platform'; +import {NgClass} from '@angular/common'; import { + afterNextRender, + AfterViewChecked, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, + inject, + Injector, Input, - Output, - ViewEncapsulation, NgZone, OnChanges, - SimpleChanges, OnDestroy, - AfterViewChecked, - inject, - afterNextRender, - Injector, + Output, + SimpleChanges, + ViewEncapsulation, } from '@angular/core'; -import {NgClass} from '@angular/common'; /** Extra CSS classes that can be associated with a calendar cell. */ export type MatCalendarCellCssClasses = string | string[] | Set | {[key: string]: any}; @@ -61,8 +62,6 @@ export interface MatCalendarUserEvent { event: Event; } -let calendarBodyId = 1; - /** Event options that can be used to bind an active, capturing event. */ const activeCapturingEventOptions = normalizePassiveListenerOptions({ passive: false, @@ -96,6 +95,9 @@ const passiveEventOptions = normalizePassiveListenerOptions({passive: true}); imports: [NgClass], }) export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _platform = inject(Platform); /** @@ -595,7 +597,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterView return null; } - private _id = `mat-calendar-body-${calendarBodyId++}`; + private _id = this._idGenerator.getId('mat-calendar-body-'); _startDateLabelId = `${this._id}-start-date`; diff --git a/src/material/datepicker/calendar-header.spec.ts b/src/material/datepicker/calendar-header.spec.ts index 6c25947d4835..03da9ff5dc1b 100644 --- a/src/material/datepicker/calendar-header.spec.ts +++ b/src/material/datepicker/calendar-header.spec.ts @@ -201,7 +201,7 @@ describe('MatCalendarHeader', () => { expect(periodButton.hasAttribute('aria-label')).toBe(true); expect(periodButton.getAttribute('aria-label')).toMatch(/^[a-z0-9\s]+$/i); expect(periodButton.hasAttribute('aria-describedby')).toBe(true); - expect(periodButton.getAttribute('aria-describedby')).toMatch(/mat-calendar-header-[0-9]+/i); + expect(periodButton.getAttribute('aria-describedby')).toMatch(/mat-calendar-header-\w+/i); }); }); diff --git a/src/material/datepicker/calendar.ts b/src/material/datepicker/calendar.ts index 44aa938c3147..90e43c0f1093 100644 --- a/src/material/datepicker/calendar.ts +++ b/src/material/datepicker/calendar.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {CdkMonitorFocus, IdGenerator} from '@angular/cdk/a11y'; import {CdkPortalOutlet, ComponentPortal, ComponentType, Portal} from '@angular/cdk/portal'; import { AfterContentInit, @@ -15,6 +16,7 @@ import { Component, EventEmitter, forwardRef, + inject, Inject, Input, OnChanges, @@ -26,9 +28,11 @@ import { ViewChild, ViewEncapsulation, } from '@angular/core'; +import {MatButton, MatIconButton} from '@angular/material/button'; import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core'; import {Subject, Subscription} from 'rxjs'; -import {MatCalendarUserEvent, MatCalendarCellClassFunction} from './calendar-body'; +import {MatCalendarCellClassFunction, MatCalendarUserEvent} from './calendar-body'; +import {DateRange, MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER} from './date-selection-model'; import {createMissingDateImplError} from './datepicker-errors'; import {MatDatepickerIntl} from './datepicker-intl'; import {MatMonthView} from './month-view'; @@ -39,11 +43,6 @@ import { yearsPerPage, } from './multi-year-view'; import {MatYearView} from './year-view'; -import {MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER, DateRange} from './date-selection-model'; -import {MatIconButton, MatButton} from '@angular/material/button'; -import {CdkMonitorFocus} from '@angular/cdk/a11y'; - -let calendarHeaderId = 1; /** * Possible views for the calendar. @@ -62,6 +61,9 @@ export type MatCalendarView = 'month' | 'year' | 'multi-year'; imports: [MatButton, MatIconButton], }) export class MatCalendarHeader { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + constructor( private _intl: MatDatepickerIntl, @Inject(forwardRef(() => MatCalendar)) public calendar: MatCalendar, @@ -221,7 +223,7 @@ export class MatCalendarHeader { return [minYearLabel, maxYearLabel]; } - private _id = `mat-calendar-header-${calendarHeaderId++}`; + private _id = this._idGenerator.getId('mat-calendar-header-'); _periodButtonLabelId = `${this._id}-period-label`; } diff --git a/src/material/datepicker/date-range-input.ts b/src/material/datepicker/date-range-input.ts index a48fdc877739..80827c62034d 100644 --- a/src/material/datepicker/date-range-input.ts +++ b/src/material/datepicker/date-range-input.ts @@ -6,29 +6,30 @@ * found in the LICENSE file at https://angular.io/license */ -import {CdkMonitorFocus, FocusOrigin} from '@angular/cdk/a11y'; +import {CdkMonitorFocus, FocusOrigin, IdGenerator} from '@angular/cdk/a11y'; import { AfterContentInit, + booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, Inject, + inject, Input, OnChanges, OnDestroy, Optional, Self, + signal, SimpleChanges, ViewEncapsulation, - booleanAttribute, - signal, } from '@angular/core'; import {ControlContainer, NgControl, Validators} from '@angular/forms'; import {DateAdapter, ThemePalette} from '@angular/material/core'; import {MAT_FORM_FIELD, MatFormFieldControl} from '@angular/material/form-field'; -import {Subject, Subscription, merge} from 'rxjs'; +import {merge, Subject, Subscription} from 'rxjs'; import { MAT_DATE_RANGE_INPUT_PARENT, MatDateRangeInputParent, @@ -39,9 +40,7 @@ import {MatDateRangePickerInput} from './date-range-picker'; import {DateRange, MatDateSelectionModel} from './date-selection-model'; import {MatDatepickerControl, MatDatepickerPanel} from './datepicker-base'; import {createMissingDateImplError} from './datepicker-errors'; -import {DateFilterFn, _MatFormFieldPartial, dateInputsHaveChanged} from './datepicker-input-base'; - -let nextUniqueId = 0; +import {_MatFormFieldPartial, DateFilterFn, dateInputsHaveChanged} from './datepicker-input-base'; @Component({ selector: 'mat-date-range-input', @@ -79,6 +78,9 @@ export class MatDateRangeInput OnChanges, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _closedSubscription = Subscription.EMPTY; private _openedSubscription = Subscription.EMPTY; @@ -88,7 +90,7 @@ export class MatDateRangeInput } /** Unique ID for the group. */ - id = `mat-date-range-input-${nextUniqueId++}`; + id = this._idGenerator.getId('mat-date-range-input-'); /** Whether the control is focused. */ focused = false; diff --git a/src/material/datepicker/datepicker-base.ts b/src/material/datepicker/datepicker-base.ts index 449d08d068fc..0cb543727953 100644 --- a/src/material/datepicker/datepicker-base.ts +++ b/src/material/datepicker/datepicker-base.ts @@ -7,7 +7,7 @@ */ import {AnimationEvent} from '@angular/animations'; -import {CdkTrapFocus} from '@angular/cdk/a11y'; +import {CdkTrapFocus, IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {coerceStringArray} from '@angular/cdk/coercion'; import { @@ -78,9 +78,6 @@ import {createMissingDateImplError} from './datepicker-errors'; import {DateFilterFn} from './datepicker-input-base'; import {MatDatepickerIntl} from './datepicker-intl'; -/** Used to generate a unique ID for each datepicker instance. */ -let datepickerUid = 0; - /** Injection token that determines the scroll handling while the calendar is open. */ export const MAT_DATEPICKER_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>( 'mat-datepicker-scroll-strategy', @@ -358,6 +355,9 @@ export abstract class MatDatepickerBase< > implements MatDatepickerPanel, OnDestroy, OnChanges { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _scrollStrategy: () => ScrollStrategy; private _inputStateChanges = Subscription.EMPTY; private _document = inject(DOCUMENT); @@ -489,7 +489,7 @@ export abstract class MatDatepickerBase< private _opened = false; /** The id for the datepicker calendar. */ - id: string = `mat-datepicker-${datepickerUid++}`; + id: string = this._idGenerator.getId('mat-datepicker-'); /** The minimum selectable date. */ _getMinDate(): D | null { diff --git a/src/material/dialog/dialog-content-directives.ts b/src/material/dialog/dialog-content-directives.ts index dac9c74ccf66..f73633bddacb 100644 --- a/src/material/dialog/dialog-content-directives.ts +++ b/src/material/dialog/dialog-content-directives.ts @@ -6,9 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ +import {IdGenerator} from '@angular/cdk/a11y'; +import {CdkScrollable} from '@angular/cdk/scrolling'; import { Directive, ElementRef, + inject, Input, OnChanges, OnDestroy, @@ -16,14 +19,10 @@ import { Optional, SimpleChanges, } from '@angular/core'; -import {CdkScrollable} from '@angular/cdk/scrolling'; import {MatDialog} from './dialog'; import {_closeDialogVia, MatDialogRef} from './dialog-ref'; -/** Counter used to generate unique IDs for dialog elements. */ -let dialogElementUid = 0; - /** * Button that will close the current dialog. */ @@ -140,7 +139,10 @@ export abstract class MatDialogLayoutSection implements OnInit, OnDestroy { }, }) export class MatDialogTitle extends MatDialogLayoutSection { - @Input() id: string = `mat-mdc-dialog-title-${dialogElementUid++}`; + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + + @Input() id: string = this._idGenerator.getId('mat-mdc-dialog-title-'); protected _onAdd() { // Note: we null check the queue, because there are some internal diff --git a/src/material/dialog/dialog.ts b/src/material/dialog/dialog.ts index f85a26a40055..74a8dc6962e6 100644 --- a/src/material/dialog/dialog.ts +++ b/src/material/dialog/dialog.ts @@ -6,12 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ +import {IdGenerator} from '@angular/cdk/a11y'; +import {Dialog, DialogConfig} from '@angular/cdk/dialog'; import {ComponentType, Overlay, OverlayContainer, ScrollStrategy} from '@angular/cdk/overlay'; import {Location} from '@angular/common'; import { ANIMATION_MODULE_TYPE, ComponentRef, Inject, + inject, Injectable, InjectionToken, Injector, @@ -20,14 +23,12 @@ import { SkipSelf, TemplateRef, Type, - inject, } from '@angular/core'; +import {defer, Observable, Subject} from 'rxjs'; +import {startWith} from 'rxjs/operators'; import {MatDialogConfig} from './dialog-config'; import {MatDialogContainer} from './dialog-container'; import {MatDialogRef} from './dialog-ref'; -import {defer, Observable, Subject} from 'rxjs'; -import {Dialog, DialogConfig} from '@angular/cdk/dialog'; -import {startWith} from 'rxjs/operators'; /** Injection token that can be used to access the data that was passed in to a dialog. */ export const MAT_DIALOG_DATA = new InjectionToken('MatMdcDialogData'); @@ -71,14 +72,14 @@ export const MAT_DIALOG_SCROLL_STRATEGY_PROVIDER = { useFactory: MAT_DIALOG_SCROLL_STRATEGY_PROVIDER_FACTORY, }; -// Counter for unique dialog ids. -let uniqueId = 0; - /** * Service to open Material Design modal dialogs. */ @Injectable({providedIn: 'root'}) export class MatDialog implements OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private readonly _openDialogsAtThisLevel: MatDialogRef[] = []; private readonly _afterAllClosedAtThisLevel = new Subject(); private readonly _afterOpenedAtThisLevel = new Subject>(); @@ -178,7 +179,7 @@ export class MatDialog implements OnDestroy { ): MatDialogRef { let dialogRef: MatDialogRef; config = {...(this._defaultOptions || new MatDialogConfig()), ...config}; - config.id = config.id || `mat-mdc-dialog-${uniqueId++}`; + config.id = config.id || this._idGenerator.getId('mat-mdc-dialog-'); config.scrollStrategy = config.scrollStrategy || this._scrollStrategy(); const cdkRef = this._dialog.open(componentOrTemplateRef, { diff --git a/src/material/dialog/testing/dialog-harness.spec.ts b/src/material/dialog/testing/dialog-harness.spec.ts index ddd3de3dad79..b63201c6610e 100644 --- a/src/material/dialog/testing/dialog-harness.spec.ts +++ b/src/material/dialog/testing/dialog-harness.spec.ts @@ -72,7 +72,7 @@ describe('MatDialogHarness', () => { fixture.componentInstance.open(); fixture.componentInstance.open({ariaLabelledBy: 'dialog-label'}); const dialogs = await loader.getAllHarnesses(MatDialogHarness); - expect(await dialogs[0].getAriaLabelledby()).toMatch(/-dialog-title-\d+/); + expect(await dialogs[0].getAriaLabelledby()).toMatch(/-dialog-title-\w+/); expect(await dialogs[1].getAriaLabelledby()).toBe('dialog-label'); }); diff --git a/src/material/expansion/expansion-panel.ts b/src/material/expansion/expansion-panel.ts index d2a26cdbec4a..4760f3ffcfaa 100644 --- a/src/material/expansion/expansion-panel.ts +++ b/src/material/expansion/expansion-panel.ts @@ -13,6 +13,8 @@ import {CdkPortalOutlet, TemplatePortal} from '@angular/cdk/portal'; import {DOCUMENT} from '@angular/common'; import { AfterContentInit, + ANIMATION_MODULE_TYPE, + booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -32,12 +34,10 @@ import { ViewChild, ViewContainerRef, ViewEncapsulation, - booleanAttribute, - ANIMATION_MODULE_TYPE, } from '@angular/core'; import {Subject} from 'rxjs'; import {filter, startWith, take} from 'rxjs/operators'; -import {MatAccordionBase, MatAccordionTogglePosition, MAT_ACCORDION} from './accordion-base'; +import {MAT_ACCORDION, MatAccordionBase, MatAccordionTogglePosition} from './accordion-base'; import {matExpansionAnimations} from './expansion-animations'; import {MAT_EXPANSION_PANEL} from './expansion-panel-base'; import {MatExpansionPanelContent} from './expansion-panel-content'; @@ -45,9 +45,6 @@ import {MatExpansionPanelContent} from './expansion-panel-content'; /** MatExpansionPanel's states. */ export type MatExpansionPanelState = 'expanded' | 'collapsed'; -/** Counter for generating unique element ids. */ -let uniqueId = 0; - /** * Object that can be used to override the default options * for all of the expansion panels in a module. @@ -146,7 +143,7 @@ export class MatExpansionPanel _portal: TemplatePortal; /** ID for the associated header element. Used for a11y labelling. */ - _headerId = `mat-expansion-panel-header-${uniqueId++}`; + _headerId = this._idGenerator.getId('mat-expansion-panel-header-'); constructor( @Optional() @SkipSelf() @Inject(MAT_ACCORDION) accordion: MatAccordionBase, diff --git a/src/material/form-field/directives/error.ts b/src/material/form-field/directives/error.ts index 59800fd68392..ff875cad45bc 100644 --- a/src/material/form-field/directives/error.ts +++ b/src/material/form-field/directives/error.ts @@ -6,9 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {Attribute, Directive, ElementRef, InjectionToken, Input} from '@angular/core'; - -let nextUniqueId = 0; +import {IdGenerator} from '@angular/cdk/a11y'; +import {Attribute, Directive, ElementRef, inject, InjectionToken, Input} from '@angular/core'; /** * Injection token that can be used to reference instances of `MatError`. It serves as @@ -29,7 +28,10 @@ export const MAT_ERROR = new InjectionToken('MatError'); standalone: true, }) export class MatError { - @Input() id: string = `mat-mdc-error-${nextUniqueId++}`; + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + + @Input() id: string = this._idGenerator.getId('mat-mdc-error-'); constructor(@Attribute('aria-live') ariaLive: string, elementRef: ElementRef) { // If no aria-live value is set add 'polite' as a default. This is preferred over setting diff --git a/src/material/form-field/directives/hint.ts b/src/material/form-field/directives/hint.ts index be63b37f5990..66ea3641f4e4 100644 --- a/src/material/form-field/directives/hint.ts +++ b/src/material/form-field/directives/hint.ts @@ -6,9 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, Input} from '@angular/core'; - -let nextUniqueId = 0; +import {IdGenerator} from '@angular/cdk/a11y'; +import {Directive, inject, Input} from '@angular/core'; /** Hint text to be shown underneath the form field control. */ @Directive({ @@ -23,9 +22,12 @@ let nextUniqueId = 0; standalone: true, }) export class MatHint { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** Whether to align the hint label at the start or end of the line. */ @Input() align: 'start' | 'end' = 'start'; /** Unique ID for the hint. Used for the aria-describedby on the form field control. */ - @Input() id: string = `mat-mdc-hint-${nextUniqueId++}`; + @Input() id: string = this._idGenerator.getId('mat-mdc-hint-'); } diff --git a/src/material/form-field/form-field.ts b/src/material/form-field/form-field.ts index 914f89a32d2e..a017b11d6693 100644 --- a/src/material/form-field/form-field.ts +++ b/src/material/form-field/form-field.ts @@ -5,22 +5,27 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ +import {IdGenerator} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {Platform} from '@angular/cdk/platform'; import {DOCUMENT, NgTemplateOutlet} from '@angular/common'; import { - ANIMATION_MODULE_TYPE, AfterContentChecked, AfterContentInit, + afterRender, AfterViewInit, + ANIMATION_MODULE_TYPE, ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, ContentChild, + contentChild, ContentChildren, ElementRef, Inject, + inject, InjectionToken, Injector, Input, @@ -30,14 +35,10 @@ import { QueryList, ViewChild, ViewEncapsulation, - afterRender, - computed, - contentChild, - inject, } from '@angular/core'; import {AbstractControlDirective} from '@angular/forms'; import {ThemePalette} from '@angular/material/core'; -import {Subject, merge} from 'rxjs'; +import {merge, Subject} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; import {MAT_ERROR, MatError} from './directives/error'; import { @@ -108,8 +109,6 @@ export const MAT_FORM_FIELD_DEFAULT_OPTIONS = new InjectionToken extends _MatFormFieldControl {} export class MatFormField implements FloatingLabelParent, AfterContentInit, AfterContentChecked, AfterViewInit, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + @ViewChild('textField') _textField: ElementRef; @ViewChild('iconPrefixContainer') _iconPrefixContainer: ElementRef; @ViewChild('textPrefixContainer') _textPrefixContainer: ElementRef; @@ -298,10 +300,10 @@ export class MatFormField _hasTextSuffix = false; // Unique id for the internal form field label. - readonly _labelId = `mat-mdc-form-field-label-${nextUniqueId++}`; + readonly _labelId = this._idGenerator.getId('mat-mdc-form-field-label-'); // Unique id for the hint label. - readonly _hintLabelId = `mat-mdc-hint-${nextUniqueId++}`; + readonly _hintLabelId = this._idGenerator.getId('mat-mdc-hint-'); /** State of the mat-hint and mat-error animations. */ _subscriptAnimationState = ''; diff --git a/src/material/input/input.spec.ts b/src/material/input/input.spec.ts index 4158c0162001..4b668a75c4ff 100644 --- a/src/material/input/input.spec.ts +++ b/src/material/input/input.spec.ts @@ -548,7 +548,7 @@ describe('MatMdcInput without forms', () => { fixture.componentInstance.formControl.markAsTouched(); fixture.componentInstance.formControl.setErrors({invalid: true}); fixture.detectChanges(); - expect(input.getAttribute('aria-describedby')).toMatch(/^custom-error mat-mdc-error-\d+$/); + expect(input.getAttribute('aria-describedby')).toMatch(/^custom-error mat-mdc-error-\w+$/); fixture.componentInstance.label = ''; fixture.componentInstance.userDescribedByValue = ''; diff --git a/src/material/input/input.ts b/src/material/input/input.ts index ad0a96b58a3f..2a96e77d4b9f 100644 --- a/src/material/input/input.ts +++ b/src/material/input/input.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {IdGenerator} from '@angular/cdk/a11y'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {getSupportedInputTypes, Platform} from '@angular/cdk/platform'; import {AutofillMonitor} from '@angular/cdk/text-field'; @@ -14,6 +15,7 @@ import { Directive, DoCheck, ElementRef, + inject, Inject, Input, NgZone, @@ -23,8 +25,8 @@ import { Self, } from '@angular/core'; import {FormGroupDirective, NgControl, NgForm, Validators} from '@angular/forms'; -import {ErrorStateMatcher, _ErrorStateTracker} from '@angular/material/core'; -import {MatFormFieldControl, MatFormField, MAT_FORM_FIELD} from '@angular/material/form-field'; +import {_ErrorStateTracker, ErrorStateMatcher} from '@angular/material/core'; +import {MAT_FORM_FIELD, MatFormField, MatFormFieldControl} from '@angular/material/form-field'; import {Subject} from 'rxjs'; import {getMatInputUnsupportedTypeError} from './input-errors'; import {MAT_INPUT_VALUE_ACCESSOR} from './input-value-accessor'; @@ -42,8 +44,6 @@ const MAT_INPUT_INVALID_TYPES = [ 'submit', ]; -let nextUniqueId = 0; - @Directive({ selector: `input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]`, @@ -82,7 +82,10 @@ let nextUniqueId = 0; export class MatInput implements MatFormFieldControl, OnChanges, OnDestroy, AfterViewInit, DoCheck { - protected _uid = `mat-input-${nextUniqueId++}`; + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + + protected _uid = this._idGenerator.getId('mat-input-'); protected _previousNativeValue: any; private _inputValueAccessor: {value: any}; private _previousPlaceholder: string | null; diff --git a/src/material/input/testing/input-harness.spec.ts b/src/material/input/testing/input-harness.spec.ts index c455d6310284..cc93d04d28bd 100644 --- a/src/material/input/testing/input-harness.spec.ts +++ b/src/material/input/testing/input-harness.spec.ts @@ -66,11 +66,11 @@ describe('MatInputHarness', () => { it('should be able to get id of input', async () => { const inputs = await loader.getAllHarnesses(MatInputHarness); expect(inputs.length).toBe(7); - expect(await inputs[0].getId()).toMatch(/mat-input-\d+/); - expect(await inputs[1].getId()).toMatch(/mat-input-\d+/); + expect(await inputs[0].getId()).toMatch(/mat-input-\w+/); + expect(await inputs[1].getId()).toMatch(/mat-input-\w+/); expect(await inputs[2].getId()).toBe('myTextarea'); expect(await inputs[3].getId()).toBe('nativeControl'); - expect(await inputs[4].getId()).toMatch(/mat-input-\d+/); + expect(await inputs[4].getId()).toMatch(/mat-input-\w+/); expect(await inputs[5].getId()).toBe('has-ng-model'); }); diff --git a/src/material/menu/menu.ts b/src/material/menu/menu.ts index b625f64727e2..72df820fe77d 100644 --- a/src/material/menu/menu.ts +++ b/src/material/menu/menu.ts @@ -6,53 +6,51 @@ * found in the LICENSE file at https://angular.io/license */ +import {AnimationEvent} from '@angular/animations'; +import {FocusKeyManager, FocusOrigin, IdGenerator} from '@angular/cdk/a11y'; +import {Direction} from '@angular/cdk/bidi'; +import { + DOWN_ARROW, + ESCAPE, + hasModifierKey, + LEFT_ARROW, + RIGHT_ARROW, + UP_ARROW, +} from '@angular/cdk/keycodes'; import { AfterContentInit, + afterNextRender, + AfterRenderRef, + booleanAttribute, ChangeDetectionStrategy, + ChangeDetectorRef, Component, ContentChild, ContentChildren, ElementRef, EventEmitter, Inject, + inject, InjectionToken, + Injector, Input, NgZone, OnDestroy, + OnInit, Output, - TemplateRef, QueryList, + TemplateRef, ViewChild, ViewEncapsulation, - OnInit, - ChangeDetectorRef, - booleanAttribute, - afterNextRender, - AfterRenderRef, - inject, - Injector, } from '@angular/core'; -import {AnimationEvent} from '@angular/animations'; -import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y'; -import {Direction} from '@angular/cdk/bidi'; -import { - ESCAPE, - LEFT_ARROW, - RIGHT_ARROW, - DOWN_ARROW, - UP_ARROW, - hasModifierKey, -} from '@angular/cdk/keycodes'; import {merge, Observable, Subject} from 'rxjs'; import {startWith, switchMap} from 'rxjs/operators'; +import {matMenuAnimations} from './menu-animations'; +import {MAT_MENU_CONTENT, MatMenuContent} from './menu-content'; +import {throwMatMenuInvalidPositionX, throwMatMenuInvalidPositionY} from './menu-errors'; import {MatMenuItem} from './menu-item'; -import {MatMenuPanel, MAT_MENU_PANEL} from './menu-panel'; +import {MAT_MENU_PANEL, MatMenuPanel} from './menu-panel'; import {MenuPositionX, MenuPositionY} from './menu-positions'; -import {throwMatMenuInvalidPositionX, throwMatMenuInvalidPositionY} from './menu-errors'; -import {MatMenuContent, MAT_MENU_CONTENT} from './menu-content'; -import {matMenuAnimations} from './menu-animations'; - -let menuPanelUid = 0; /** Reason why the menu was closed. */ export type MenuCloseReason = void | 'click' | 'keydown' | 'tab'; @@ -114,6 +112,9 @@ export function MAT_MENU_DEFAULT_OPTIONS_FACTORY(): MatMenuDefaultOptions { standalone: true, }) export class MatMenu implements AfterContentInit, MatMenuPanel, OnInit, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _keyManager: FocusKeyManager; private _xPosition: MenuPositionX; private _yPosition: MenuPositionY; @@ -270,7 +271,7 @@ export class MatMenu implements AfterContentInit, MatMenuPanel, OnI */ @Output() readonly close: EventEmitter = this.closed; - readonly panelId = `mat-menu-panel-${menuPanelUid++}`; + readonly panelId = this._idGenerator.getId('mat-menu-panel-'); private _injector = inject(Injector); diff --git a/src/material/paginator/paginator.ts b/src/material/paginator/paginator.ts index 0e589fdb4b5d..eb0312f0d196 100644 --- a/src/material/paginator/paginator.ts +++ b/src/material/paginator/paginator.ts @@ -6,27 +6,29 @@ * found in the LICENSE file at https://angular.io/license */ +import {IdGenerator} from '@angular/cdk/a11y'; import { + booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Inject, + inject, InjectionToken, Input, + numberAttribute, OnDestroy, OnInit, Optional, Output, ViewEncapsulation, - booleanAttribute, - numberAttribute, } from '@angular/core'; +import {MatIconButton} from '@angular/material/button'; import {MatOption, ThemePalette} from '@angular/material/core'; +import {MatFormField, MatFormFieldAppearance} from '@angular/material/form-field'; import {MatSelect} from '@angular/material/select'; -import {MatIconButton} from '@angular/material/button'; import {MatTooltip} from '@angular/material/tooltip'; -import {MatFormField, MatFormFieldAppearance} from '@angular/material/form-field'; import {Observable, ReplaySubject, Subscription} from 'rxjs'; import {MatPaginatorIntl} from './paginator-intl'; @@ -90,8 +92,6 @@ export const MAT_PAGINATOR_DEFAULT_OPTIONS = new InjectionToken { fixture.detectChanges(); const hint = fixture.debugElement.query(By.css('mat-hint')).nativeElement; expect(select.getAttribute('aria-describedby')).toBe(hint.getAttribute('id')); - expect(select.getAttribute('aria-describedby')).toMatch(/^mat-mdc-hint-\d+$/); + expect(select.getAttribute('aria-describedby')).toMatch(/^mat-mdc-hint-\w+$/); })); it('should support user binding to `aria-describedby`', fakeAsync(() => { diff --git a/src/material/select/select.ts b/src/material/select/select.ts index 05ac5fa6896d..3129d35c611b 100644 --- a/src/material/select/select.ts +++ b/src/material/select/select.ts @@ -9,6 +9,7 @@ import { ActiveDescendantKeyManager, addAriaReferencedId, + IdGenerator, LiveAnnouncer, removeAriaReferencedId, } from '@angular/cdk/a11y'; @@ -32,6 +33,7 @@ import { ScrollStrategy, } from '@angular/cdk/overlay'; import {ViewportRuler} from '@angular/cdk/scrolling'; +import {NgClass} from '@angular/common'; import { AfterContentInit, Attribute, @@ -98,9 +100,6 @@ import { getMatSelectNonArrayValueError, getMatSelectNonFunctionValueError, } from './select-errors'; -import {NgClass} from '@angular/common'; - -let nextUniqueId = 0; /** Injection token that determines the scroll handling while a select is open. */ export const MAT_SELECT_SCROLL_STRATEGY = new InjectionToken<() => ScrollStrategy>( @@ -217,6 +216,9 @@ export class MatSelect ControlValueAccessor, MatFormFieldControl { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** All of the defined select options. */ @ContentChildren(MatOption, {descendants: true}) options: QueryList; @@ -308,7 +310,7 @@ export class MatSelect private _compareWith = (o1: any, o2: any) => o1 === o2; /** Unique id for this input. */ - private _uid = `mat-select-${nextUniqueId++}`; + private _uid = this._idGenerator.getId('mat-select-'); /** Current `aria-labelledby` value for the select trigger. */ private _triggerAriaLabelledBy: string | null = null; @@ -363,7 +365,7 @@ export class MatSelect _onTouched = () => {}; /** ID for the DOM node containing the select's value. */ - _valueId = `mat-select-value-${nextUniqueId++}`; + _valueId = this._idGenerator.getId('mat-select-value-'); /** Emits when the panel element is finished transforming in. */ readonly _panelDoneAnimatingStream = new Subject(); diff --git a/src/material/slide-toggle/slide-toggle.spec.ts b/src/material/slide-toggle/slide-toggle.spec.ts index 1cf3b83a6973..6a441b550ebb 100644 --- a/src/material/slide-toggle/slide-toggle.spec.ts +++ b/src/material/slide-toggle/slide-toggle.spec.ts @@ -175,7 +175,7 @@ describe('MDC-based MatSlideToggle without forms', () => { fixture.detectChanges(); // Once the id binding is set to null, the id property should auto-generate a unique id. - expect(buttonElement.id).toMatch(/mat-mdc-slide-toggle-\d+-button/); + expect(buttonElement.id).toMatch(/mat-mdc-slide-toggle-\w+-button/); })); it('should forward the tabIndex to the underlying element', fakeAsync(() => { @@ -237,7 +237,7 @@ describe('MDC-based MatSlideToggle without forms', () => { // We fall back to pointing to the label if a value isn't provided. expect(buttonElement.getAttribute('aria-labelledby')).toMatch( - /mat-mdc-slide-toggle-\d+-label/, + /mat-mdc-slide-toggle-\w+-label/, ); })); diff --git a/src/material/slide-toggle/slide-toggle.ts b/src/material/slide-toggle/slide-toggle.ts index 28837bcf85c9..88e3edf9d297 100644 --- a/src/material/slide-toggle/slide-toggle.ts +++ b/src/material/slide-toggle/slide-toggle.ts @@ -6,8 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ +import {FocusMonitor, IdGenerator} from '@angular/cdk/a11y'; import { AfterContentInit, + ANIMATION_MODULE_TYPE, Attribute, booleanAttribute, ChangeDetectionStrategy, @@ -17,6 +19,7 @@ import { EventEmitter, forwardRef, Inject, + inject, Input, numberAttribute, OnChanges, @@ -26,7 +29,6 @@ import { SimpleChanges, ViewChild, ViewEncapsulation, - ANIMATION_MODULE_TYPE, } from '@angular/core'; import { AbstractControl, @@ -36,12 +38,11 @@ import { ValidationErrors, Validator, } from '@angular/forms'; -import {FocusMonitor} from '@angular/cdk/a11y'; +import {_MatInternalFormField, MatRipple} from '@angular/material/core'; import { MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS, MatSlideToggleDefaultOptions, } from './slide-toggle-config'; -import {_MatInternalFormField, MatRipple} from '@angular/material/core'; /** * @deprecated Will stop being exported. @@ -64,7 +65,6 @@ export class MatSlideToggleChange { } // Increasing integer for generating unique ids for slide-toggle components. -let nextUniqueId = 0; @Component({ selector: 'mat-slide-toggle', @@ -100,6 +100,9 @@ let nextUniqueId = 0; export class MatSlideToggle implements OnDestroy, AfterContentInit, OnChanges, ControlValueAccessor, Validator { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _onChange = (_: any) => {}; private _onTouched = () => {}; private _validatorOnChange = () => {}; @@ -216,7 +219,7 @@ export class MatSlideToggle this.tabIndex = parseInt(tabIndex) || 0; this.color = defaults.color || 'accent'; this._noopAnimations = animationMode === 'NoopAnimations'; - this.id = this._uniqueId = `mat-mdc-slide-toggle-${++nextUniqueId}`; + this.id = this._uniqueId = this._idGenerator.getId('mat-mdc-slide-toggle-'); this.hideIcon = defaults.hideIcon ?? false; this.disabledInteractive = defaults.disabledInteractive ?? false; this._labelId = this._uniqueId + '-label'; diff --git a/src/material/snack-bar/snack-bar-container.ts b/src/material/snack-bar/snack-bar-container.ts index 163b2969f4c2..f74a7753cdbb 100644 --- a/src/material/snack-bar/snack-bar-container.ts +++ b/src/material/snack-bar/snack-bar-container.ts @@ -6,6 +6,17 @@ * found in the LICENSE file at https://angular.io/license */ +import {AnimationEvent} from '@angular/animations'; +import {AriaLivePoliteness, IdGenerator} from '@angular/cdk/a11y'; +import {Platform} from '@angular/cdk/platform'; +import { + BasePortalOutlet, + CdkPortalOutlet, + ComponentPortal, + DomPortal, + TemplatePortal, +} from '@angular/cdk/portal'; +import {DOCUMENT} from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -19,23 +30,10 @@ import { ViewChild, ViewEncapsulation, } from '@angular/core'; -import {DOCUMENT} from '@angular/common'; -import {matSnackBarAnimations} from './snack-bar-animations'; -import { - BasePortalOutlet, - CdkPortalOutlet, - ComponentPortal, - DomPortal, - TemplatePortal, -} from '@angular/cdk/portal'; import {Observable, Subject} from 'rxjs'; -import {AriaLivePoliteness} from '@angular/cdk/a11y'; -import {Platform} from '@angular/cdk/platform'; -import {AnimationEvent} from '@angular/animations'; +import {matSnackBarAnimations} from './snack-bar-animations'; import {MatSnackBarConfig} from './snack-bar-config'; -let uniqueId = 0; - /** * Internal component that wraps user-provided snack bar content. * @docs-private @@ -60,6 +58,9 @@ let uniqueId = 0; }, }) export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private _document = inject(DOCUMENT); private _trackedModals = new Set(); @@ -104,7 +105,7 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy _role?: 'status' | 'alert'; /** Unique ID of the aria-live element. */ - readonly _liveElementId = `mat-snack-bar-container-live-${uniqueId++}`; + readonly _liveElementId = this._idGenerator.getId('mat-snack-bar-container-live-'); constructor( private _ngZone: NgZone, diff --git a/src/material/tabs/tab-group.ts b/src/material/tabs/tab-group.ts index d4c0049566ea..69446329ed43 100644 --- a/src/material/tabs/tab-group.ts +++ b/src/material/tabs/tab-group.ts @@ -6,9 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ +import {CdkMonitorFocus, FocusOrigin} from '@angular/cdk/a11y'; +import {Platform} from '@angular/cdk/platform'; +import {CdkPortalOutlet} from '@angular/cdk/portal'; import { AfterContentChecked, AfterContentInit, + ANIMATION_MODULE_TYPE, + APP_ID, + booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -16,29 +22,24 @@ import { ElementRef, EventEmitter, Inject, + inject, Input, + numberAttribute, OnDestroy, Optional, Output, QueryList, ViewChild, ViewEncapsulation, - booleanAttribute, - inject, - numberAttribute, - ANIMATION_MODULE_TYPE, } from '@angular/core'; -import {MAT_TAB_GROUP, MatTab} from './tab'; -import {MatTabHeader} from './tab-header'; -import {ThemePalette, MatRipple} from '@angular/material/core'; +import {MatRipple, ThemePalette} from '@angular/material/core'; import {merge, Subscription} from 'rxjs'; -import {MAT_TABS_CONFIG, MatTabsConfig} from './tab-config'; import {startWith} from 'rxjs/operators'; -import {CdkMonitorFocus, FocusOrigin} from '@angular/cdk/a11y'; +import {MAT_TAB_GROUP, MatTab} from './tab'; import {MatTabBody} from './tab-body'; -import {CdkPortalOutlet} from '@angular/cdk/portal'; +import {MAT_TABS_CONFIG, MatTabsConfig} from './tab-config'; +import {MatTabHeader} from './tab-header'; import {MatTabLabelWrapper} from './tab-label-wrapper'; -import {Platform} from '@angular/cdk/platform'; /** Used to generate unique ID's for each tab component */ let nextId = 0; @@ -94,6 +95,9 @@ const ENABLE_BACKGROUND_INPUT = true; ], }) export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDestroy { + /** Generator for assigning unique IDs to DOM elements. */ + private _appId = inject(APP_ID); + /** * All tabs inside the tab group. This includes tabs that belong to groups that are nested * inside the current one. We filter out only the tabs that belong to this group in `_tabs`. @@ -479,7 +483,7 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes /** Returns a unique id for each tab label element */ _getTabLabelId(i: number): string { - return `mat-tab-label-${this._groupId}-${i}`; + return `mat-tab-label-${this._appId}${this._groupId}-${i}`; } /** Returns a unique id for each tab content element */ diff --git a/src/material/tabs/tab-nav-bar/tab-nav-bar.ts b/src/material/tabs/tab-nav-bar/tab-nav-bar.ts index fe2d2c0b8025..3e8b9ee7328b 100644 --- a/src/material/tabs/tab-nav-bar/tab-nav-bar.ts +++ b/src/material/tabs/tab-nav-bar/tab-nav-bar.ts @@ -5,10 +5,17 @@ * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ +import {FocusableOption, FocusMonitor, IdGenerator} from '@angular/cdk/a11y'; +import {Directionality} from '@angular/cdk/bidi'; +import {ENTER, SPACE} from '@angular/cdk/keycodes'; +import {CdkObserveContent} from '@angular/cdk/observers'; +import {Platform} from '@angular/cdk/platform'; +import {ViewportRuler} from '@angular/cdk/scrolling'; import { AfterContentChecked, AfterContentInit, AfterViewInit, + ANIMATION_MODULE_TYPE, Attribute, booleanAttribute, ChangeDetectionStrategy, @@ -18,6 +25,7 @@ import { ElementRef, forwardRef, Inject, + inject, Input, NgZone, numberAttribute, @@ -26,7 +34,6 @@ import { QueryList, ViewChild, ViewEncapsulation, - ANIMATION_MODULE_TYPE, } from '@angular/core'; import { MAT_RIPPLE_GLOBAL_OPTIONS, @@ -36,20 +43,11 @@ import { RippleTarget, ThemePalette, } from '@angular/material/core'; -import {FocusableOption, FocusMonitor} from '@angular/cdk/a11y'; -import {Directionality} from '@angular/cdk/bidi'; -import {ViewportRuler} from '@angular/cdk/scrolling'; -import {Platform} from '@angular/cdk/platform'; -import {MatInkBar, InkBarItem} from '../ink-bar'; import {BehaviorSubject, Subject} from 'rxjs'; import {startWith, takeUntil} from 'rxjs/operators'; -import {ENTER, SPACE} from '@angular/cdk/keycodes'; -import {MAT_TABS_CONFIG, MatTabsConfig} from '../tab-config'; +import {InkBarItem, MatInkBar} from '../ink-bar'; import {MatPaginatedTabHeader} from '../paginated-tab-header'; -import {CdkObserveContent} from '@angular/cdk/observers'; - -// Increasing integer for generating unique ids for tab nav components. -let nextUniqueId = 0; +import {MAT_TABS_CONFIG, MatTabsConfig} from '../tab-config'; /** * Navigation component matching the styles of the tab group header. @@ -271,6 +269,9 @@ export class MatTabLink extends InkBarItem implements AfterViewInit, OnDestroy, RippleTarget, FocusableOption { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + private readonly _destroyed = new Subject(); /** Whether the tab link is active or not. */ @@ -324,7 +325,7 @@ export class MatTabLink } /** Unique id for the tab. */ - @Input() id = `mat-tab-link-${nextUniqueId++}`; + @Input() id = this._idGenerator.getId('mat-tab-link-'); constructor( private _tabNavBar: MatTabNav, @@ -437,8 +438,11 @@ export class MatTabLink standalone: true, }) export class MatTabNavPanel { + /** Generator for assigning unique IDs to DOM elements. */ + private _idGenerator = inject(IdGenerator); + /** Unique id for the tab panel. */ - @Input() id = `mat-tab-nav-panel-${nextUniqueId++}`; + @Input() id = this._idGenerator.getId('mat-tab-nav-panel-'); /** Id of the active tab in the nav bar. */ _activeTabId?: string; diff --git a/tools/public_api_guard/cdk/a11y.md b/tools/public_api_guard/cdk/a11y.md index e07a7d6a6f3b..717d48c70ed3 100644 --- a/tools/public_api_guard/cdk/a11y.md +++ b/tools/public_api_guard/cdk/a11y.md @@ -296,6 +296,15 @@ export interface Highlightable extends ListKeyManagerOption { setInactiveStyles(): void; } +// @public +export class IdGenerator { + getId(prefix: string): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration; +} + // @public export const INPUT_MODALITY_DETECTOR_DEFAULT_OPTIONS: InputModalityDetectorOptions; diff --git a/tools/public_api_guard/cdk/accordion.md b/tools/public_api_guard/cdk/accordion.md index 716cac8f1b5a..54f3362d9ed5 100644 --- a/tools/public_api_guard/cdk/accordion.md +++ b/tools/public_api_guard/cdk/accordion.md @@ -7,6 +7,7 @@ import { ChangeDetectorRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; import * as i0 from '@angular/core'; +import { IdGenerator } from '@angular/cdk/a11y'; import { InjectionToken } from '@angular/core'; import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; @@ -53,6 +54,8 @@ export class CdkAccordionItem implements OnDestroy { protected _expansionDispatcher: UniqueSelectionDispatcher; readonly id: string; // (undocumented) + protected _idGenerator: IdGenerator; + // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) static ngAcceptInputType_expanded: unknown;