diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts index 6850a474af5..469247f9692 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-list-items-container/vault-list-items-container.component.ts @@ -428,7 +428,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit { await this.vaultPopupSectionService.updateSectionOpenStoredState( this.collapsibleKey()!, - this.disclosure.open, + this.disclosure.open(), ); } diff --git a/libs/components/src/disclosure/disclosure-trigger-for.directive.ts b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts index 78bc9f2b038..f5ee4ad27c1 100644 --- a/libs/components/src/disclosure/disclosure-trigger-for.directive.ts +++ b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts @@ -1,10 +1,20 @@ -import { Directive, HostBinding, HostListener, input } from "@angular/core"; +import { Directive, computed, input } from "@angular/core"; import { DisclosureComponent } from "./disclosure.component"; +/** + * Directive that connects a trigger element (like a button) to a disclosure component. + * Automatically handles click events to toggle the disclosure open/closed state and + * manages ARIA attributes for accessibility. + */ @Directive({ selector: "[bitDisclosureTriggerFor]", exportAs: "disclosureTriggerFor", + host: { + "[attr.aria-expanded]": "ariaExpanded()", + "[attr.aria-controls]": "ariaControls()", + "(click)": "toggle()", + }, }) export class DisclosureTriggerForDirective { /** @@ -12,15 +22,11 @@ export class DisclosureTriggerForDirective { */ readonly disclosure = input.required({ alias: "bitDisclosureTriggerFor" }); - @HostBinding("attr.aria-expanded") get ariaExpanded() { - return this.disclosure().open; - } + protected readonly ariaExpanded = computed(() => this.disclosure().open()); - @HostBinding("attr.aria-controls") get ariaControls() { - return this.disclosure().id; - } + protected readonly ariaControls = computed(() => this.disclosure().id); - @HostListener("click") click() { - this.disclosure().open = !this.disclosure().open; + protected toggle() { + this.disclosure().open.update((open) => !open); } } diff --git a/libs/components/src/disclosure/disclosure.component.ts b/libs/components/src/disclosure/disclosure.component.ts index 60e886f1dcc..4a9c48b2716 100644 --- a/libs/components/src/disclosure/disclosure.component.ts +++ b/libs/components/src/disclosure/disclosure.component.ts @@ -1,70 +1,50 @@ -import { - Component, - EventEmitter, - HostBinding, - Input, - Output, - booleanAttribute, -} from "@angular/core"; +import { ChangeDetectionStrategy, Component, computed, model } from "@angular/core"; let nextId = 0; /** - * The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to create an accessible content area whose visibility is controlled by a trigger button. - - * To compose a disclosure and trigger: - - * 1. Create a trigger component (see "Supported Trigger Components" section below) - * 2. Create a `bit-disclosure` - * 3. Set a template reference on the `bit-disclosure` - * 4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the `bit-disclosure` template reference - * 5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to being hidden. - * - * @example - * - * ```html - * - * click button to hide this content - * ``` - * + * The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to create an accessible content area whose visibility is controlled by a trigger button. + * + * To compose a disclosure and trigger: + * + * 1. Create a trigger component (see "Supported Trigger Components" section below) + * 2. Create a `bit-disclosure` + * 3. Set a template reference on the `bit-disclosure` + * 4. Use the `bitDisclosureTriggerFor` directive on the trigger component, and pass it the `bit-disclosure` template reference + * 5. Set the `open` property on the `bit-disclosure` to init the disclosure as either currently expanded or currently collapsed. The disclosure will default to `false`, meaning it defaults to being hidden. + * + * @example + * + * ```html + * + * click button to hide this content + * ``` */ -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-disclosure", template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + "[class]": "classList()", + "[id]": "id", + }, }) export class DisclosureComponent { - /** Emits the visibility of the disclosure content */ - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref - @Output() openChange = new EventEmitter(); - - private _open?: boolean; /** - * Optionally init the disclosure in its opened state + * Controls the visibility of the disclosure content. */ - // TODO: Skipped for signal migration because: - // Accessor inputs cannot be migrated as they are too complex. - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ transform: booleanAttribute }) set open(isOpen: boolean) { - this._open = isOpen; - this.openChange.emit(isOpen); - } - get open(): boolean { - return !!this._open; - } + readonly open = model(false); - @HostBinding("class") get classList() { - return this.open ? "" : "tw-hidden"; - } + /** + * Autogenerated id. + */ + readonly id = `bit-disclosure-${nextId++}`; - @HostBinding("id") id = `bit-disclosure-${nextId++}`; + protected readonly classList = computed(() => (this.open() ? "" : "tw-hidden")); } diff --git a/libs/components/src/disclosure/disclosure.mdx b/libs/components/src/disclosure/disclosure.mdx index 50ccf936acc..9f01df8d3d9 100644 --- a/libs/components/src/disclosure/disclosure.mdx +++ b/libs/components/src/disclosure/disclosure.mdx @@ -11,7 +11,7 @@ import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/c <Description /> -<Canvas of={stories.DisclosureWithIconButton} /> +<Canvas of={stories.DisclosureOpen} /> ## Supported Trigger Components diff --git a/libs/components/src/disclosure/disclosure.stories.ts b/libs/components/src/disclosure/disclosure.stories.ts index 3ed6903060c..cd9d9e02360 100644 --- a/libs/components/src/disclosure/disclosure.stories.ts +++ b/libs/components/src/disclosure/disclosure.stories.ts @@ -36,13 +36,30 @@ export default { type Story = StoryObj<DisclosureComponent>; -export const DisclosureWithIconButton: Story = { +export const DisclosureOpen: Story = { + args: { + open: true, + }, render: (args) => ({ props: args, template: /*html*/ ` - <button type="button" label="Settings" bitIconButton="bwi-sliders" [buttonType]="'muted'" [bitDisclosureTriggerFor]="disclosureRef"> + <button type="button" label="Settings" bitIconButton="bwi-sliders" buttonType="muted" [bitDisclosureTriggerFor]="disclosureRef"> </button> - <bit-disclosure #disclosureRef class="tw-text-main tw-block" open>click button to hide this content</bit-disclosure> + <bit-disclosure #disclosureRef class="tw-text-main tw-block" [(open)]="open">click button to hide this content</bit-disclosure> + `, + }), +}; + +export const DisclosureClosed: Story = { + args: { + open: false, + }, + render: (args) => ({ + props: args, + template: /*html*/ ` + <button type="button" label="Settings" bitIconButton="bwi-sliders" buttonType="muted" [bitDisclosureTriggerFor]="disclosureRef"> + </button> + <bit-disclosure #disclosureRef class="tw-text-main tw-block" [(open)]="open">click button to hide this content</bit-disclosure> `, }), };