From 1d61d26dcce488e3f7e4a45360ab3ce5d407877b Mon Sep 17 00:00:00 2001 From: Vicki League Date: Tue, 5 Nov 2024 09:43:41 -0500 Subject: [PATCH 1/4] [CL-500] Add toggle functionality for icon button --- .../src/icon-button/icon-button.component.ts | 30 ++++++++++++- .../src/icon-button/icon-button.mdx | 43 ++++++++++++++----- .../src/icon-button/icon-button.stories.ts | 28 +++++++++--- .../src/icon-button/toggled-by.directive.ts | 43 +++++++++++++++++++ 4 files changed, 127 insertions(+), 17 deletions(-) create mode 100644 libs/components/src/icon-button/toggled-by.directive.ts diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 54f6dfda963..b53672e6f4e 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, HostBinding, Input } from "@angular/core"; +import { Component, ElementRef, HostBinding, HostListener, Input } from "@angular/core"; import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction"; import { FocusableElement } from "../shared/focusable-element"; @@ -52,10 +52,14 @@ const styles: Record = { "tw-bg-transparent", "!tw-text-muted", "tw-border-transparent", + "aria-expanded:tw-bg-text-muted", + "aria-expanded:!tw-text-contrast", "hover:tw-bg-transparent-hover", "hover:tw-border-primary-700", "focus-visible:before:tw-ring-primary-700", "disabled:tw-opacity-60", + "aria-expanded:hover:tw-bg-secondary-700", + "aria-expanded:hover:tw-border-secondary-700", "disabled:hover:tw-border-transparent", "disabled:hover:tw-bg-transparent", ...focusRing, @@ -167,6 +171,30 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE this.buttonType = value; } + /** + * when using the `bitToggledBy` directive, pass `expanded` to indicate whether the + * icon button inits as expanded or not + */ + @Input() expanded: boolean | null = null; + @HostBinding("attr.aria-expanded") + get expandedAttr() { + return this.expanded !== null ? this.expanded : null; + } + + /** + * used by the `bitToggledBy` directive to populate `aria-controls` + */ + @Input() controls: string | null = null; + @HostBinding("attr.aria-controls") + get controlsAttr() { + return this.controls !== null ? this.controls : null; + } + + /** + * used by the `bitToggledBy` directive + */ + @HostListener("click") click: () => void; + getFocusTarget() { return this.elementRef.nativeElement; } diff --git a/libs/components/src/icon-button/icon-button.mdx b/libs/components/src/icon-button/icon-button.mdx index 8361d4c3997..b82311e5d57 100644 --- a/libs/components/src/icon-button/icon-button.mdx +++ b/libs/components/src/icon-button/icon-button.mdx @@ -29,8 +29,6 @@ Icon buttons can be found in other components such as: the [dialog](?path=/docs/component-library-dialogs--docs), and [table](?path=/docs/component-library-table--docs). - - ## Styles There are 4 common styles for button main, muted, contrast, and danger. The other styles follow the @@ -40,48 +38,48 @@ button component styles. Used for general icon buttons appearing on the theme’s main `background` - + ### Muted Used for low emphasis icon buttons appearing on the theme’s main `background` - + ### Contrast Used on a theme’s colored or contrasting backgrounds such as in the navigation or on toasts and banners. - + ### Danger Danger is used for “trash” actions throughout the experience, most commonly in the bottom right of the dialog component. - + ### Primary Used in place of the main button component if no text is used. This allows the button to display square. - + ### Secondary Used in place of the main button component if no text is used. This allows the button to display square. - + ### Light Used on a background that is dark in both light theme and dark theme. Example: end user navigation styles. - + **Note:** Main and contrast styles appear on backgrounds where using `primary-700` as a focus indicator does not meet WCAG graphic contrast guidelines. @@ -95,11 +93,34 @@ with less padding around the icon, such as in the navigation component. ### Small - + ### Default - + + +## Icon Button as a Toggle + +The icon button can be used to toggle the visibility of another content area. To do so: + +1. Create a `buttonType="muted"` icon button. (This is currently the only supported button type for + use as a toggle button) +2. Set a template reference on the icon button +3. Use the `bitToggledBy` directive on the content area, and pass it the icon button template + reference +4. Set the `expanded` property on the icon button to init the toggle as either currently expanded or + currently collapsed + +The toggle functionality follows the Disclosure (Show/Hide) pattern for accessibility. + +``` + +
click button to hide this content
+``` + + ## Accessibility diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts index 0f25d2de583..65119b33a4c 100644 --- a/libs/components/src/icon-button/icon-button.stories.ts +++ b/libs/components/src/icon-button/icon-button.stories.ts @@ -1,10 +1,16 @@ -import { Meta, StoryObj } from "@storybook/angular"; +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; import { BitIconButtonComponent } from "./icon-button.component"; +import { IconButtonToggledByDirective } from "./toggled-by.directive"; export default { title: "Component Library/Icon Button", component: BitIconButtonComponent, + decorators: [ + moduleMetadata({ + imports: [IconButtonToggledByDirective], + }), + ], args: { bitIconButton: "bwi-plus", size: "default", @@ -23,7 +29,7 @@ type Story = StoryObj; export const Default: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -56,7 +62,7 @@ export const Small: Story = { export const Primary: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ ` `, }), @@ -96,7 +102,7 @@ export const Muted: Story = { export const Light: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -110,7 +116,7 @@ export const Light: Story = { export const Contrast: Story = { render: (args) => ({ props: args, - template: ` + template: /*html*/ `
@@ -136,3 +142,15 @@ export const Disabled: Story = { loading: true, }, }; + +export const ToggleIconButton: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + +
click button to hide this content
+ `, + }), +}; diff --git a/libs/components/src/icon-button/toggled-by.directive.ts b/libs/components/src/icon-button/toggled-by.directive.ts new file mode 100644 index 00000000000..ab350e573c6 --- /dev/null +++ b/libs/components/src/icon-button/toggled-by.directive.ts @@ -0,0 +1,43 @@ +import { Directive, HostBinding, Input, OnInit } from "@angular/core"; + +import { BitIconButtonComponent } from "./icon-button.component"; + +let nextId = 0; + +@Directive({ + selector: "[bitToggledBy]", + exportAs: "iconButtonToggledBy", + standalone: true, +}) +export class IconButtonToggledByDirective implements OnInit { + @Input("bitToggledBy") iconButton: BitIconButtonComponent; + + @HostBinding("id") id = `bit-toggled-by-${nextId++}`; + + @HostBinding("class") classList = ""; + + ngOnInit() { + this.iconButton.controls = this.id; + this.iconButton.click = this.iconButtonClick.bind(this); + + this.setElementVisibility(); + } + + iconButtonClick() { + if (this.iconButton.expanded !== null) { + this.iconButton.expanded = !this.iconButton.expanded; + } + + this.setElementVisibility(); + } + + setElementVisibility() { + if (this.iconButton.expanded === false) { + this.classList = "tw-hidden"; + } + + if (this.iconButton.expanded) { + this.classList = ""; + } + } +} From a145ac8af4813ce3f20af8dc033a6ff920e2a471 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Wed, 6 Nov 2024 10:34:44 -0500 Subject: [PATCH 2/4] structural changes --- .../disclosure-trigger-for.directive.ts | 31 +++++++++++++ .../src/disclosure/disclosure.component.ts | 21 +++++++++ libs/components/src/disclosure/disclosure.mdx | 43 +++++++++++++++++++ .../src/disclosure/disclosure.stories.ts | 30 +++++++++++++ libs/components/src/disclosure/index.ts | 2 + .../src/icon-button/icon-button.component.ts | 26 +---------- .../src/icon-button/icon-button.mdx | 23 ---------- .../src/icon-button/icon-button.stories.ts | 20 +-------- .../src/icon-button/toggled-by.directive.ts | 43 ------------------- libs/components/src/index.ts | 1 + 10 files changed, 130 insertions(+), 110 deletions(-) create mode 100644 libs/components/src/disclosure/disclosure-trigger-for.directive.ts create mode 100644 libs/components/src/disclosure/disclosure.component.ts create mode 100644 libs/components/src/disclosure/disclosure.mdx create mode 100644 libs/components/src/disclosure/disclosure.stories.ts create mode 100644 libs/components/src/disclosure/index.ts delete mode 100644 libs/components/src/icon-button/toggled-by.directive.ts diff --git a/libs/components/src/disclosure/disclosure-trigger-for.directive.ts b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts new file mode 100644 index 00000000000..48a6e9623f8 --- /dev/null +++ b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts @@ -0,0 +1,31 @@ +import { Directive, HostBinding, HostListener, Input } from "@angular/core"; + +import { DisclosureComponent } from "./disclosure.component"; + +let nextId = 0; + +@Directive({ + selector: "[bitDisclosureTriggerFor]", + exportAs: "disclosureTriggerFor", + standalone: true, +}) +export class DisclosureTriggerForDirective { + /** + * Accepts template reference for a bit-disclosure component instance + */ + @Input("bitDisclosureTriggerFor") disclosure: DisclosureComponent; + + @HostBinding("id") id = `bit-trigger-for-${nextId++}`; + + @HostBinding("attr.aria-expanded") get ariaExpanded() { + return this.disclosure.open; + } + + @HostBinding("attr.aria-controls") get ariaControls() { + return this.id; + } + + @HostListener("click") click() { + this.disclosure.open = !this.disclosure.open; + } +} diff --git a/libs/components/src/disclosure/disclosure.component.ts b/libs/components/src/disclosure/disclosure.component.ts new file mode 100644 index 00000000000..58c67ad0f0e --- /dev/null +++ b/libs/components/src/disclosure/disclosure.component.ts @@ -0,0 +1,21 @@ +import { Component, HostBinding, Input, booleanAttribute } from "@angular/core"; + +let nextId = 0; + +@Component({ + selector: "bit-disclosure", + standalone: true, + template: ``, +}) +export class DisclosureComponent { + /** + * Optionally init the disclosure in its opened state + */ + @Input({ transform: booleanAttribute }) open?: boolean = false; + + @HostBinding("class") get classList() { + return this.open ? "" : "tw-hidden"; + } + + @HostBinding("id") id = `bit-disclosure-${nextId++}`; +} diff --git a/libs/components/src/disclosure/disclosure.mdx b/libs/components/src/disclosure/disclosure.mdx new file mode 100644 index 00000000000..e746f69db4c --- /dev/null +++ b/libs/components/src/disclosure/disclosure.mdx @@ -0,0 +1,43 @@ +import { Meta, Story, Primary, Controls } from "@storybook/addon-docs"; + +import * as stories from "./disclosure.stories"; + + + +```ts +import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/components"; +``` + +# Disclosure + +The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to +create a content area whose visibility is controlled by a trigger button. + +## Using Icon Button as the Disclosure Trigger + +The icon button can be used as the trigger for displaying the disclosure area. To do so: + +1. Create a `buttonType="muted"` icon button. (This is currently the only supported icon button type + for use as a disclosure trigger button) +2. Create a `bit-disclosure` +3. Set a template reference on the `bit-disclosure` +4. Use the `bitDisclosureTriggerFor` directive on the icon button, 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. + +``` + +click button to hide this content +``` + + + +## Accessibility + +The disclosure and trigger directive functionality follow the Disclosure (Show/Hide) pattern for +accessibility, automatically handling the `aria-controls` and `aria-expanded` properties. A `button` +element must be used as the trigger for the disclosure. diff --git a/libs/components/src/disclosure/disclosure.stories.ts b/libs/components/src/disclosure/disclosure.stories.ts new file mode 100644 index 00000000000..1c6e6625636 --- /dev/null +++ b/libs/components/src/disclosure/disclosure.stories.ts @@ -0,0 +1,30 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { IconButtonModule } from "../icon-button"; + +import { DisclosureTriggerForDirective } from "./disclosure-trigger-for.directive"; +import { DisclosureComponent } from "./disclosure.component"; + +export default { + title: "Component Library/Disclosure", + component: DisclosureComponent, + decorators: [ + moduleMetadata({ + imports: [DisclosureTriggerForDirective, DisclosureComponent, IconButtonModule], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const DisclosureWithIconButton: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + click button to hide this content + `, + }), +}; diff --git a/libs/components/src/disclosure/index.ts b/libs/components/src/disclosure/index.ts new file mode 100644 index 00000000000..b5bdf68725f --- /dev/null +++ b/libs/components/src/disclosure/index.ts @@ -0,0 +1,2 @@ +export * from "./disclosure-trigger-for.directive"; +export * from "./disclosure.component"; diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index b53672e6f4e..d036e1c77ca 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, HostBinding, HostListener, Input } from "@angular/core"; +import { Component, ElementRef, HostBinding, Input } from "@angular/core"; import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction"; import { FocusableElement } from "../shared/focusable-element"; @@ -171,30 +171,6 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE this.buttonType = value; } - /** - * when using the `bitToggledBy` directive, pass `expanded` to indicate whether the - * icon button inits as expanded or not - */ - @Input() expanded: boolean | null = null; - @HostBinding("attr.aria-expanded") - get expandedAttr() { - return this.expanded !== null ? this.expanded : null; - } - - /** - * used by the `bitToggledBy` directive to populate `aria-controls` - */ - @Input() controls: string | null = null; - @HostBinding("attr.aria-controls") - get controlsAttr() { - return this.controls !== null ? this.controls : null; - } - - /** - * used by the `bitToggledBy` directive - */ - @HostListener("click") click: () => void; - getFocusTarget() { return this.elementRef.nativeElement; } diff --git a/libs/components/src/icon-button/icon-button.mdx b/libs/components/src/icon-button/icon-button.mdx index b82311e5d57..a45160d7884 100644 --- a/libs/components/src/icon-button/icon-button.mdx +++ b/libs/components/src/icon-button/icon-button.mdx @@ -99,29 +99,6 @@ with less padding around the icon, such as in the navigation component. -## Icon Button as a Toggle - -The icon button can be used to toggle the visibility of another content area. To do so: - -1. Create a `buttonType="muted"` icon button. (This is currently the only supported button type for - use as a toggle button) -2. Set a template reference on the icon button -3. Use the `bitToggledBy` directive on the content area, and pass it the icon button template - reference -4. Set the `expanded` property on the icon button to init the toggle as either currently expanded or - currently collapsed - -The toggle functionality follows the Disclosure (Show/Hide) pattern for accessibility. - -``` - -
click button to hide this content
-``` - - - ## Accessibility Follow guidelines outlined in the [Button docs](?path=/docs/component-library-button--doc) diff --git a/libs/components/src/icon-button/icon-button.stories.ts b/libs/components/src/icon-button/icon-button.stories.ts index 65119b33a4c..b5542f78600 100644 --- a/libs/components/src/icon-button/icon-button.stories.ts +++ b/libs/components/src/icon-button/icon-button.stories.ts @@ -1,16 +1,10 @@ -import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { Meta, StoryObj } from "@storybook/angular"; import { BitIconButtonComponent } from "./icon-button.component"; -import { IconButtonToggledByDirective } from "./toggled-by.directive"; export default { title: "Component Library/Icon Button", component: BitIconButtonComponent, - decorators: [ - moduleMetadata({ - imports: [IconButtonToggledByDirective], - }), - ], args: { bitIconButton: "bwi-plus", size: "default", @@ -142,15 +136,3 @@ export const Disabled: Story = { loading: true, }, }; - -export const ToggleIconButton: Story = { - render: (args) => ({ - props: args, - template: /*html*/ ` - -
click button to hide this content
- `, - }), -}; diff --git a/libs/components/src/icon-button/toggled-by.directive.ts b/libs/components/src/icon-button/toggled-by.directive.ts deleted file mode 100644 index ab350e573c6..00000000000 --- a/libs/components/src/icon-button/toggled-by.directive.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Directive, HostBinding, Input, OnInit } from "@angular/core"; - -import { BitIconButtonComponent } from "./icon-button.component"; - -let nextId = 0; - -@Directive({ - selector: "[bitToggledBy]", - exportAs: "iconButtonToggledBy", - standalone: true, -}) -export class IconButtonToggledByDirective implements OnInit { - @Input("bitToggledBy") iconButton: BitIconButtonComponent; - - @HostBinding("id") id = `bit-toggled-by-${nextId++}`; - - @HostBinding("class") classList = ""; - - ngOnInit() { - this.iconButton.controls = this.id; - this.iconButton.click = this.iconButtonClick.bind(this); - - this.setElementVisibility(); - } - - iconButtonClick() { - if (this.iconButton.expanded !== null) { - this.iconButton.expanded = !this.iconButton.expanded; - } - - this.setElementVisibility(); - } - - setElementVisibility() { - if (this.iconButton.expanded === false) { - this.classList = "tw-hidden"; - } - - if (this.iconButton.expanded) { - this.classList = ""; - } - } -} diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 6881d801e0f..810f32bdd3c 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -13,6 +13,7 @@ export * from "./chip-select"; export * from "./color-password"; export * from "./container"; export * from "./dialog"; +export * from "./disclosure"; export * from "./form-field"; export * from "./icon-button"; export * from "./icon"; From 95d97bbda64502f7e87ca2510aa1435b4f44ea5f Mon Sep 17 00:00:00 2001 From: Vicki League Date: Wed, 6 Nov 2024 11:45:34 -0500 Subject: [PATCH 3/4] add a11y note --- libs/components/src/disclosure/disclosure.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/components/src/disclosure/disclosure.mdx b/libs/components/src/disclosure/disclosure.mdx index e746f69db4c..1bee14f648f 100644 --- a/libs/components/src/disclosure/disclosure.mdx +++ b/libs/components/src/disclosure/disclosure.mdx @@ -40,4 +40,6 @@ The icon button can be used as the trigger for displaying the disclosure area. T The disclosure and trigger directive functionality follow the Disclosure (Show/Hide) pattern for accessibility, automatically handling the `aria-controls` and `aria-expanded` properties. A `button` -element must be used as the trigger for the disclosure. +element must be used as the trigger for the disclosure. The `button` element must also have an +accessible label/title -- please follow the accessibility guidelines for whatever trigger component +you choose. From 42f9779f9c2d4aec81493778b4a7c88347965221 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Wed, 6 Nov 2024 15:23:57 -0500 Subject: [PATCH 4/4] cr fixes --- .../disclosure-trigger-for.directive.ts | 6 +--- libs/components/src/disclosure/disclosure.mdx | 34 ++++++++++++------- .../src/disclosure/disclosure.stories.ts | 1 - 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/libs/components/src/disclosure/disclosure-trigger-for.directive.ts b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts index 48a6e9623f8..05470281729 100644 --- a/libs/components/src/disclosure/disclosure-trigger-for.directive.ts +++ b/libs/components/src/disclosure/disclosure-trigger-for.directive.ts @@ -2,8 +2,6 @@ import { Directive, HostBinding, HostListener, Input } from "@angular/core"; import { DisclosureComponent } from "./disclosure.component"; -let nextId = 0; - @Directive({ selector: "[bitDisclosureTriggerFor]", exportAs: "disclosureTriggerFor", @@ -15,14 +13,12 @@ export class DisclosureTriggerForDirective { */ @Input("bitDisclosureTriggerFor") disclosure: DisclosureComponent; - @HostBinding("id") id = `bit-trigger-for-${nextId++}`; - @HostBinding("attr.aria-expanded") get ariaExpanded() { return this.disclosure.open; } @HostBinding("attr.aria-controls") get ariaControls() { - return this.id; + return this.disclosure.id; } @HostListener("click") click() { diff --git a/libs/components/src/disclosure/disclosure.mdx b/libs/components/src/disclosure/disclosure.mdx index 1bee14f648f..8df8e7025b8 100644 --- a/libs/components/src/disclosure/disclosure.mdx +++ b/libs/components/src/disclosure/disclosure.mdx @@ -11,34 +11,44 @@ import { DisclosureComponent, DisclosureTriggerForDirective } from "@bitwarden/c # Disclosure The `bit-disclosure` component is used in tandem with the `bitDisclosureTriggerFor` directive to -create a content area whose visibility is controlled by a trigger button. +create an accessible content area whose visibility is controlled by a trigger button. -## Using Icon Button as the Disclosure Trigger +To compose a disclosure and trigger: -The icon button can be used as the trigger for displaying the disclosure area. To do so: - -1. Create a `buttonType="muted"` icon button. (This is currently the only supported icon button type - for use as a disclosure trigger button) +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 icon button, and pass it the `bit-disclosure` - template reference +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. ``` - + click button to hide this content ``` +
+
+ +## Supported Trigger Components + +This is the list of currently supported trigger components: + +- Icon button `muted` variant + ## Accessibility -The disclosure and trigger directive functionality follow the Disclosure (Show/Hide) pattern for +The disclosure and trigger directive functionality follow the +[Disclosure (Show/Hide)](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) pattern for accessibility, automatically handling the `aria-controls` and `aria-expanded` properties. A `button` element must be used as the trigger for the disclosure. The `button` element must also have an accessible label/title -- please follow the accessibility guidelines for whatever trigger component diff --git a/libs/components/src/disclosure/disclosure.stories.ts b/libs/components/src/disclosure/disclosure.stories.ts index 1c6e6625636..974589a667c 100644 --- a/libs/components/src/disclosure/disclosure.stories.ts +++ b/libs/components/src/disclosure/disclosure.stories.ts @@ -22,7 +22,6 @@ export const DisclosureWithIconButton: Story = { props: args, template: /*html*/ ` click button to hide this content `,