diff --git a/src/cdk/overlay/overlay.md b/src/cdk/overlay/overlay.md index 8030aaa6b856..6b5908bcddfc 100644 --- a/src/cdk/overlay/overlay.md +++ b/src/cdk/overlay/overlay.md @@ -1,4 +1,4 @@ -The `overlay` package provides a way to open floating panels on the screen. +The `overlay` package provides a way to open floating panels on the screen. ### Initial setup The CDK overlays depend on a small set of structural styles to work correctly. If you're using @@ -87,6 +87,46 @@ strategy will typically inject `ScrollDispatcher` (from `@angular/cdk/scrolling` of when scrolling takes place. See the documentation for `ScrollDispatcher` for more information on how scroll events are detected and dispatched. +#### Using the native Popover API +As of Angular v21, the CDK overlay supports rendering overlays as native popover elements instead +of using the traditional overlay container. This uses the browser's native [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API), +which provides improved accessibility, automatic focus management, and better handling of scrolling +and positioning. + +To enable the popover behavior, set the `usePopover` option to `true` when creating an overlay: + +```ts +const overlayRef = overlay.create({ + positionStrategy: this.overlay.position() + .flexibleConnectedTo(trigger) + .withPositions(positions), + usePopover: true, +}); +``` + +When using `FlexibleConnectedPositionStrategy` with popovers, you can also specify the popover +insertion point: + +```ts +const overlayRef = overlay.create({ + positionStrategy: this.overlay.position() + .flexibleConnectedTo(trigger) + .withPositions(positions) + .withPopoverInsertionPoint(triggerElement), + usePopover: 'auto', // Can also use 'auto', 'manual' +}); +``` + +**Benefits of using the native Popover API:** +- Better accessibility with automatic focus management +- Automatic dismissal on outside click (for auto popovers) +- Improved performance with less CSS in the critical path +- Native browser support for popover stacking +- Better integration with screen readers + +**Note:** The popover API is not supported on older browsers. Always provide a fallback or test +browser compatibility before using this feature in production. + ### The overlay container The `OverlayContainer` provides a handle to the container element in which all individual overlay elements are rendered. By default, the overlay container is appended directly to the document body diff --git a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.html b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.html index bce80529017e..4e66c0cecb30 100644 --- a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.html +++ b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.html @@ -2,6 +2,7 @@
search
-
- -
- @for (option of options(); track option) { -
+
+ @for (option of options(); track option) { +
+ {{option}} + - {{option}} - -
- } -
- -
+
+ } +
+ diff --git a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts index 5393e0af1fd3..fa69cb9cc9d4 100644 --- a/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts +++ b/src/components-examples/aria/combobox/combobox-auto-select/combobox-auto-select-example.ts @@ -22,20 +22,30 @@ import { signal, viewChild, } from '@angular/core'; +import {CdkConnectedOverlay} from '@angular/cdk/overlay'; /** @title Combobox with auto-select filtering. */ @Component({ selector: 'combobox-auto-select-example', templateUrl: 'combobox-auto-select-example.html', styleUrl: '../combobox-examples.css', - imports: [Combobox, ComboboxInput, ComboboxPopup, ComboboxPopupContainer, Listbox, Option], + imports: [ + Combobox, + ComboboxInput, + ComboboxPopup, + ComboboxPopupContainer, + Listbox, + Option, + CdkConnectedOverlay, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxAutoSelectExample { - popover = viewChild('popover'); listbox = viewChild>(Listbox); combobox = viewChild>(Combobox); + panelWidth = signal(undefined); + searchString = signal(''); options = computed(() => @@ -44,28 +54,17 @@ export class ComboboxAutoSelectExample { constructor() { afterRenderEffect(() => { - const popover = this.popover()!; const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + if (combobox.expanded()) { + const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); + this.panelWidth(comboboxRect?.width); + } else { + this.panelWidth(undefined); + } + this.listbox()?.scrollActiveItemIntoView(); }); } - - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; - - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; - } - - popover.nativeElement.showPopover(); - } } const states = [ diff --git a/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html index a791c1d1f5b4..3eb1e3a72111 100644 --- a/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html +++ b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.html @@ -2,6 +2,7 @@
search
-
- -
- @for (option of options(); track option) { -
+
+ @for (option of options(); track option) { +
+ {{option}} + - {{option}} - -
- } -
- -
+
+ } +
+ diff --git a/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts index bf8e2808fd40..df0483c024f3 100644 --- a/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts +++ b/src/components-examples/aria/combobox/combobox-disabled/combobox-disabled-example.ts @@ -23,6 +23,7 @@ import { viewChild, } from '@angular/core'; import {FormsModule} from '@angular/forms'; +import {CdkConnectedOverlay} from '@angular/cdk/overlay'; /** @title Disabled combobox example. */ @Component({ @@ -37,14 +38,16 @@ import {FormsModule} from '@angular/forms'; Listbox, Option, FormsModule, + CdkConnectedOverlay, ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxDisabledExample { - popover = viewChild('popover'); listbox = viewChild>(Listbox); combobox = viewChild>(Combobox); + panelWidth = signal(undefined); + searchString = signal(''); options = computed(() => @@ -53,29 +56,17 @@ export class ComboboxDisabledExample { constructor() { afterRenderEffect(() => { - const popover = this.popover()!; const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + if (combobox.expanded()) { + const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); + this.panelWidth(comboboxRect?.width); + } else { + this.panelWidth(undefined); + } this.listbox()?.scrollActiveItemIntoView(); }); } - - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; - - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; - } - - popover.nativeElement.showPopover(); - } } const states = [ diff --git a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.html b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.html index 1d64419534d7..238e6a7a8363 100644 --- a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.html +++ b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.html @@ -2,6 +2,7 @@
search
-
- + +
@for (option of options(); track option) {
}
- -
+
+
diff --git a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts index 86f8ef5e07cf..2c70198d9b41 100644 --- a/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts +++ b/src/components-examples/aria/combobox/combobox-highlight/combobox-highlight-example.ts @@ -23,6 +23,7 @@ import { viewChild, } from '@angular/core'; import {FormsModule} from '@angular/forms'; +import {CdkConnectedOverlay} from '@angular/cdk/overlay'; /** @title Combobox with highlight filtering. */ @Component({ @@ -37,14 +38,16 @@ import {FormsModule} from '@angular/forms'; Listbox, Option, FormsModule, + CdkConnectedOverlay, ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxHighlightExample { - popover = viewChild('popover'); listbox = viewChild>(Listbox); combobox = viewChild>(Combobox); + panelWidth = signal(undefined); + searchString = signal(''); options = computed(() => @@ -53,29 +56,17 @@ export class ComboboxHighlightExample { constructor() { afterRenderEffect(() => { - const popover = this.popover()!; const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + if (combobox.expanded()) { + const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); + this.panelWidth(comboboxRect?.width); + } else { + this.panelWidth(undefined); + } this.listbox()?.scrollActiveItemIntoView(); }); } - - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; - - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; - } - - popover.nativeElement.showPopover(); - } } const states = [ diff --git a/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.html b/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.html index e80a360531a5..da9a4219421b 100644 --- a/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.html +++ b/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.html @@ -2,6 +2,7 @@
search
-
- -
- @for (option of options(); track option) { -
+
+ @for (option of options(); track option) { +
+ {{option}} + - {{option}} - -
- } -
- -
+
+ } +
+ diff --git a/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.ts b/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.ts index 4075b3cdae62..430f90f1972e 100644 --- a/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.ts +++ b/src/components-examples/aria/combobox/combobox-manual/combobox-manual-example.ts @@ -13,6 +13,7 @@ import { ComboboxPopupContainer, } from '@angular/aria/combobox'; import {Listbox, Option} from '@angular/aria/listbox'; +import {CdkConnectedOverlay} from '@angular/cdk/overlay'; import { afterRenderEffect, ChangeDetectionStrategy, @@ -37,14 +38,16 @@ import {FormsModule} from '@angular/forms'; Listbox, Option, FormsModule, + CdkConnectedOverlay, ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxManualExample { - popover = viewChild('popover'); listbox = viewChild>(Listbox); combobox = viewChild>(Combobox); + panelWidth = signal(undefined); + searchString = signal(''); options = computed(() => @@ -53,29 +56,17 @@ export class ComboboxManualExample { constructor() { afterRenderEffect(() => { - const popover = this.popover()!; const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + if (combobox.expanded()) { + const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); + this.panelWidth(comboboxRect?.width); + } else { + this.panelWidth(undefined); + } this.listbox()?.scrollActiveItemIntoView(); }); } - - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; - - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; - } - - popover.nativeElement.showPopover(); - } } const states = [ diff --git a/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html b/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html index fe7a5fa8af62..3bdc219e3b00 100644 --- a/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html +++ b/src/components-examples/aria/combobox/combobox-tree-auto-select/combobox-tree-auto-select-example.html @@ -8,6 +8,7 @@
search search
-
- + +
-
-
+ + diff --git a/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.html b/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.html index 41c67e436ab0..cc32fcd2d724 100644 --- a/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.html +++ b/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.html @@ -8,6 +8,7 @@
search
-
- -
    - -
-
-
+ + + +
+ +
+
+
+
diff --git a/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.ts b/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.ts index 5a7967f17d9f..336a9fdcdae8 100644 --- a/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.ts +++ b/src/components-examples/aria/combobox/combobox-tree-manual/combobox-tree-manual-example.ts @@ -22,6 +22,7 @@ import { signal, viewChild, } from '@angular/core'; +import {CdkConnectedOverlay} from '@angular/cdk/overlay'; import {TREE_NODES, TreeNode} from '../data'; import {NgTemplateOutlet} from '@angular/common'; @@ -39,14 +40,16 @@ import {NgTemplateOutlet} from '@angular/common'; TreeItem, TreeItemGroup, NgTemplateOutlet, + CdkConnectedOverlay, ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComboboxTreeManualExample { - popover = viewChild('popover'); tree = viewChild>(Tree); combobox = viewChild>(Combobox); + panelWidth = signal(undefined); + searchString = signal(''); nodes = computed(() => this.filterTreeNodes(TREE_NODES)); @@ -79,26 +82,14 @@ export class ComboboxTreeManualExample { constructor() { afterRenderEffect(() => { - const popover = this.popover()!; const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + if (combobox.expanded()) { + const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); + this.panelWidth(comboboxRect?.width); + } else { + this.panelWidth(undefined); + } this.tree()?.scrollActiveItemIntoView(); }); } - - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; - - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; - } - - popover.nativeElement.showPopover(); - } } diff --git a/src/components-examples/aria/toolbar/simple-toolbar.ts b/src/components-examples/aria/toolbar/simple-toolbar.ts index 56ed29dbd183..fe423d0cc0f0 100644 --- a/src/components-examples/aria/toolbar/simple-toolbar.ts +++ b/src/components-examples/aria/toolbar/simple-toolbar.ts @@ -16,6 +16,7 @@ import { signal, viewChild, } from '@angular/core'; +import {CdkConnectedOverlay} from '@angular/cdk/overlay'; @Directive({ selector: 'button[toolbar-button]', @@ -70,6 +71,7 @@ export class SimpleToolbarRadioButton { Listbox, Option, ToolbarWidget, + CdkConnectedOverlay, ], styleUrl: 'toolbar-common.css', host: {class: 'example-combobox-container'}, @@ -88,8 +90,15 @@ export class SimpleToolbarRadioButton { > -
- + +
@for (option of options; track option) {
@@ -100,43 +109,32 @@ export class SimpleToolbarRadioButton {
}
-
-
+ +
`, }) export class SimpleCombobox { dir = inject(Directionality).valueSignal; - popover = viewChild('popover'); listbox = viewChild>(Listbox); combobox = viewChild>(Combobox); + panelWidth = signal(undefined); + value = signal('Normal text'); options = ['Normal text', 'Title', 'Subtitle', 'Heading 1', 'Heading 2', 'Heading 3']; constructor() { afterRenderEffect(() => { - const popover = this.popover()!; const combobox = this.combobox()!; - combobox.expanded() ? this.showPopover() : popover.nativeElement.hidePopover(); + if (combobox.expanded()) { + const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); + this.panelWidth(comboboxRect?.width); + } else { + this.panelWidth(undefined); + } this.listbox()?.scrollActiveItemIntoView(); }); } - - showPopover() { - const popover = this.popover()!; - const combobox = this.combobox()!; - - const comboboxRect = combobox.inputElement()?.getBoundingClientRect(); - const popoverEl = popover.nativeElement; - - if (comboboxRect) { - popoverEl.style.width = `${comboboxRect.width}px`; - popoverEl.style.top = `${comboboxRect.bottom + 4}px`; - popoverEl.style.left = `${comboboxRect.left - 1}px`; - } - - popover.nativeElement.showPopover(); - } } diff --git a/src/material/autocomplete/autocomplete.md b/src/material/autocomplete/autocomplete.md index 12306f3f9b7d..361268b09104 100644 --- a/src/material/autocomplete/autocomplete.md +++ b/src/material/autocomplete/autocomplete.md @@ -148,6 +148,55 @@ attribute, or the `aria-labelledby` attribute. `MatAutocomplete` preserves focus on the text trigger, using `aria-activedescendant` to support navigation though the autocomplete options. +### Making combobox/autocomplete overlays consistent with menus + +Some implementations render the combobox panel into the DOM always and hide it with CSS (for +example by setting `visibility: hidden` or `opacity: 0`). This can cause differences in behavior +compared to components (like menus) that attach the overlay only when the panel is opened. The +differences can affect keyboard event targeting, focus handling, and performance when many +comboboxes are present. + +Recommended approach: +- Render the overlay only when the panel is open (attach/detach) or use the CDK "popover" + insertion strategy which places the panel inline with the origin when appropriate. This keeps + the menu and combobox behavior consistent and avoids relying on CSS to toggle visibility. + +How to opt-in +- For template-driven overlays (via `cdkConnectedOverlay`) set the `cdkConnectedOverlayUsePopover` + input to `'inline'` or a `FlexibleOverlayPopoverLocation` value so that the overlay will be + inserted as a popover when supported: + +```html + + + +``` + +- For components that create an `OverlayRef` programmatically (like `MatAutocompleteTrigger`), + ensure the position strategy uses `withPopoverLocation('inline')` when appropriate. The + autocomplete implementation in this repo already uses `withPopoverLocation('inline')` in + `_getOverlayPosition()`; if you have custom combobox code, apply the same strategy: + +```ts +const strategy = createFlexibleConnectedPositionStrategy(injector, origin) + .withFlexibleDimensions(false) + .withPush(false) + .withPopoverLocation('inline'); + +const overlayRef = createOverlayRef(injector, {positionStrategy: strategy, width}); +``` + +Notes +- After switching to inline/popover insertion, verify keyboard navigation and focus behavior in + complex interactions (e.g., nested modals or form fields). If you rely on `visibility: hidden` + to keep the panel in the DOM for styling reasons, consider moving that styling into the + panel's classes and controlling rendering via the overlay open state instead. + + By default, `MatAutocomplete` displays a checkmark to identify the selected item. While you can hide the checkmark indicator via `hideSingleSelectionIndicator`, this makes the component less accessible by making it harder or impossible for users to visually identify selected items.