- Visit the help center
+ Visit the help center
`,
}),
diff --git a/libs/components/src/card/base-card/base-card.stories.ts b/libs/components/src/card/base-card/base-card.stories.ts
index bae07dd1468..98814c1f9f4 100644
--- a/libs/components/src/card/base-card/base-card.stories.ts
+++ b/libs/components/src/card/base-card/base-card.stories.ts
@@ -1,6 +1,6 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
-import { AnchorLinkDirective } from "../../link";
+import { LinkComponent } from "../../link";
import { TypographyModule } from "../../typography";
import { BaseCardComponent } from "./base-card.component";
@@ -10,7 +10,7 @@ export default {
component: BaseCardComponent,
decorators: [
moduleMetadata({
- imports: [AnchorLinkDirective, TypographyModule],
+ imports: [LinkComponent, TypographyModule],
}),
],
parameters: {
diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts
index 2ce19a9f9e0..63fbb69399d 100644
--- a/libs/components/src/dialog/dialog/dialog.component.ts
+++ b/libs/components/src/dialog/dialog/dialog.component.ts
@@ -12,9 +12,10 @@ import {
computed,
signal,
AfterViewInit,
+ NgZone,
} from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
-import { combineLatest, switchMap } from "rxjs";
+import { combineLatest, firstValueFrom, switchMap } from "rxjs";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -65,6 +66,9 @@ const drawerSizeToWidth = {
})
export class DialogComponent implements AfterViewInit {
private readonly destroyRef = inject(DestroyRef);
+ private readonly ngZone = inject(NgZone);
+ private readonly el = inject(ElementRef);
+
private readonly dialogHeader =
viewChild.required>("dialogHeader");
private readonly scrollableBody = viewChild.required(CdkScrollable);
@@ -144,10 +148,6 @@ export class DialogComponent implements AfterViewInit {
return [...baseClasses, this.width(), ...sizeClasses, ...animationClasses];
});
- ngAfterViewInit() {
- this.focusOnHeader();
- }
-
handleEsc(event: Event) {
if (!this.dialogRef?.disableClose) {
this.dialogRef?.close();
@@ -159,24 +159,54 @@ export class DialogComponent implements AfterViewInit {
this.animationCompleted.set(true);
}
- /**
- * Moves focus to the dialog header element.
- * This is done automatically when the dialog is opened but can be called manually
- * when the contents of the dialog change and focus should be reset.
- */
- focusOnHeader(): void {
+ async ngAfterViewInit() {
/**
- * Wait a tick for any focus management to occur on the trigger element before moving focus to
- * the dialog header. We choose the dialog header because it is always present, unlike possible
- * interactive elements.
- *
- * We are doing this manually instead of using `cdkTrapFocusAutoCapture` and `cdkFocusInitial`
- * because we need this delay behavior.
+ * Wait for the zone to stabilize before performing any focus behaviors. This ensures that all
+ * child elements are rendered and stable.
*/
- const headerFocusTimeout = setTimeout(() => {
- this.dialogHeader().nativeElement.focus();
- }, 0);
+ if (this.ngZone.isStable) {
+ this.handleAutofocus();
+ } else {
+ await firstValueFrom(this.ngZone.onStable);
+ this.handleAutofocus();
+ }
+ }
- this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
+ /**
+ * Ensure that the user's focus is in the dialog by autofocusing the appropriate element.
+ *
+ * If there is a descendant of the dialog with the AutofocusDirective applied, we defer to that.
+ * If not, we want to fallback to a default behavior of focusing the dialog's header element. We
+ * choose the dialog header as the default fallback for dialog focus because it is always present,
+ * unlike possible interactive elements.
+ */
+ handleAutofocus() {
+ /**
+ * Angular's contentChildren query cannot see into the internal templates of child components.
+ * We need to use a regular DOM query instead to see if there are descendants using the
+ * AutofocusDirective.
+ */
+ const dialogRef = this.el.nativeElement;
+ // Must match selectors of AutofocusDirective
+ const autofocusDescendants = dialogRef.querySelectorAll("[appAutofocus], [bitAutofocus]");
+ const hasAutofocusDescendants = autofocusDescendants.length > 0;
+
+ if (!hasAutofocusDescendants) {
+ /**
+ * Wait a tick for any focus management to occur on the trigger element before moving focus
+ * to the dialog header.
+ *
+ * We are doing this manually instead of using Angular's built-in focus management
+ * directives (`cdkTrapFocusAutoCapture` and `cdkFocusInitial`) because we need this delay
+ * behavior.
+ *
+ * And yes, we need the timeout even though we are already waiting for ngZone to stabilize.
+ */
+ const headerFocusTimeout = setTimeout(() => {
+ this.dialogHeader().nativeElement.focus();
+ }, 0);
+
+ this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
+ }
}
}
diff --git a/libs/components/src/input/autofocus.directive.ts b/libs/components/src/input/autofocus.directive.ts
index a4791a51f01..bffac8eb757 100644
--- a/libs/components/src/input/autofocus.directive.ts
+++ b/libs/components/src/input/autofocus.directive.ts
@@ -22,6 +22,8 @@ import { FocusableElement } from "../shared/focusable-element";
*
* If the component provides the `FocusableElement` interface, the `focus`
* method will be called. Otherwise, the native element will be focused.
+ *
+ * If selector changes, `dialog.component.ts` must also be updated
*/
@Directive({
selector: "[appAutofocus], [bitAutofocus]",
diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts
index da30b76a9f0..c71c4e73c6e 100644
--- a/libs/components/src/layout/layout.component.ts
+++ b/libs/components/src/layout/layout.component.ts
@@ -5,7 +5,7 @@ import { booleanAttribute, Component, ElementRef, inject, input, viewChild } fro
import { RouterModule } from "@angular/router";
import { DrawerService } from "../dialog/drawer.service";
-import { LinkModule } from "../link";
+import { LinkComponent, LinkModule } from "../link";
import { SideNavService } from "../navigation/side-nav.service";
import { SharedModule } from "../shared";
@@ -52,11 +52,11 @@ export class LayoutComponent {
*
* @see https://github.com/angular/components/issues/10247#issuecomment-384060265
**/
- private readonly skipLink = viewChild.required>("skipLink");
+ private readonly skipLink = viewChild.required("skipLink");
handleKeydown(ev: KeyboardEvent) {
if (isNothingFocused()) {
ev.preventDefault();
- this.skipLink().nativeElement.focus();
+ this.skipLink().el.nativeElement.focus();
}
}
}
diff --git a/libs/components/src/link/index.ts b/libs/components/src/link/index.ts
index 480f5396de7..08617e813f5 100644
--- a/libs/components/src/link/index.ts
+++ b/libs/components/src/link/index.ts
@@ -1,2 +1,2 @@
-export * from "./link.directive";
+export * from "./link.component";
export * from "./link.module";
diff --git a/libs/components/src/link/link.component.html b/libs/components/src/link/link.component.html
new file mode 100644
index 00000000000..810b65db519
--- /dev/null
+++ b/libs/components/src/link/link.component.html
@@ -0,0 +1,11 @@
+
@@ -247,20 +243,57 @@ export const Inline: Story = {
props: args,
template: /*html*/ `
- On the internet paragraphs often contain inline links, but few know that buttons can be used for similar purposes.
+ On the internet paragraphs often contain inline links with very long text that might break, but few know that buttons can be used for similar purposes.
`,
}),
};
-export const Inactive: Story = {
+export const WithIcons: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
- Primary
- Secondary
-