diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json
index 4a89889810..dd9b7b320d 100644
--- a/apps/browser/src/_locales/en/messages.json
+++ b/apps/browser/src/_locales/en/messages.json
@@ -3229,7 +3229,37 @@
"errorAssigningTargetFolder": {
"message": "Error assigning target folder."
},
+ "viewItemsIn": {
+ "message": "View items in $NAME$",
+ "description": "Button to view the contents of a folder or collection",
+ "placeholders": {
+ "name": {
+ "content": "$1",
+ "example": "Work"
+ }
+ }
+ },
+ "backTo": {
+ "message": "Back to $NAME$",
+ "description": "Navigate back to a previous folder or collection",
+ "placeholders": {
+ "name": {
+ "content": "$1",
+ "example": "Work"
+ }
+ }
+ },
"new": {
"message": "New"
+ },
+ "removeItem": {
+ "message": "Remove $NAME$",
+ "description": "Remove a selected option, such as a folder or collection",
+ "placeholders": {
+ "name": {
+ "content": "$1",
+ "example": "Work"
+ }
+ }
}
}
diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json
index 9348b8dc0f..017b9d10c4 100644
--- a/apps/desktop/src/locales/en/messages.json
+++ b/apps/desktop/src/locales/en/messages.json
@@ -2822,5 +2822,39 @@
},
"errorAssigningTargetFolder": {
"message": "Error assigning target folder."
+ },
+ "viewItemsIn": {
+ "message": "View items in $NAME$",
+ "description": "Button to view the contents of a folder or collection",
+ "placeholders": {
+ "name": {
+ "content": "$1",
+ "example": "Work"
+ }
+ }
+ },
+ "backTo": {
+ "message": "Back to $NAME$",
+ "description": "Navigate back to a previous folder or collection",
+ "placeholders": {
+ "name": {
+ "content": "$1",
+ "example": "Work"
+ }
+ }
+ },
+ "back": {
+ "message": "Back",
+ "description": "Button text to navigate back"
+ },
+ "removeItem": {
+ "message": "Remove $NAME$",
+ "description": "Remove a selected option, such as a folder or collection",
+ "placeholders": {
+ "name": {
+ "content": "$1",
+ "example": "Work"
+ }
+ }
}
}
diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json
index f2546e38ec..124081b5f4 100644
--- a/apps/web/src/locales/en/messages.json
+++ b/apps/web/src/locales/en/messages.json
@@ -8199,6 +8199,40 @@
"manageBillingFromProviderPortalMessage": {
"message": "Manage billing from the Provider Portal"
},
+ "viewItemsIn": {
+ "message": "View items in $NAME$",
+ "description": "Button to view the contents of a folder or collection",
+ "placeholders": {
+ "name": {
+ "content": "$1",
+ "example": "Work"
+ }
+ }
+ },
+ "backTo": {
+ "message": "Back to $NAME$",
+ "description": "Navigate back to a previous folder or collection",
+ "placeholders": {
+ "name": {
+ "content": "$1",
+ "example": "Work"
+ }
+ }
+ },
+ "back": {
+ "message": "Back",
+ "description": "Button text to navigate back"
+ },
+ "removeItem": {
+ "message": "Remove $NAME$",
+ "description": "Remove a selected option, such as a folder or collection",
+ "placeholders": {
+ "name": {
+ "content": "$1",
+ "example": "Work"
+ }
+ }
+ },
"viewInfo": {
"message": "View info"
},
diff --git a/libs/components/src/chip-select/chip-select.component.html b/libs/components/src/chip-select/chip-select.component.html
new file mode 100644
index 0000000000..9ee12983f8
--- /dev/null
+++ b/libs/components/src/chip-select/chip-select.component.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libs/components/src/chip-select/chip-select.component.ts b/libs/components/src/chip-select/chip-select.component.ts
new file mode 100644
index 0000000000..e91ee16f97
--- /dev/null
+++ b/libs/components/src/chip-select/chip-select.component.ts
@@ -0,0 +1,197 @@
+import { Component, HostListener, Input, booleanAttribute, signal } from "@angular/core";
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
+
+import { ButtonModule } from "../button";
+import { IconButtonModule } from "../icon-button";
+import { MenuModule } from "../menu";
+import { Option } from "../select/option";
+import { SharedModule } from "../shared";
+import { TypographyModule } from "../typography";
+
+/** An option that will be showed in the overlay menu of `ChipSelectComponent` */
+export type ChipSelectOption = Option & {
+ /** The options that will be nested under this option */
+ children?: ChipSelectOption[];
+};
+
+@Component({
+ selector: "bit-chip-select",
+ templateUrl: "chip-select.component.html",
+ standalone: true,
+ imports: [SharedModule, ButtonModule, IconButtonModule, MenuModule, TypographyModule],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: ChipSelectComponent,
+ multi: true,
+ },
+ ],
+})
+export class ChipSelectComponent implements ControlValueAccessor {
+ /** Text to show when there is no selected option */
+ @Input({ required: true }) placeholderText: string;
+
+ /** Icon to show when there is no selected option or the selected option does not have an icon */
+ @Input() placeholderIcon: string;
+
+ private _options: ChipSelectOption[];
+ /** The select options to render */
+ @Input({ required: true })
+ get options(): ChipSelectOption[] {
+ return this._options;
+ }
+ set options(value: ChipSelectOption[]) {
+ this._options = value;
+ this.initializeRootTree(value);
+ }
+
+ /** Disables the entire chip */
+ @Input({ transform: booleanAttribute }) disabled = false;
+
+ /**
+ * We have `:focus-within` and `:focus-visible` but no `:focus-visible-within`
+ */
+ protected focusVisibleWithin = signal(false);
+ @HostListener("focusin", ["$event.target"])
+ onFocusIn(target: HTMLElement) {
+ this.focusVisibleWithin.set(target.matches(".fvw-target:focus-visible"));
+ }
+ @HostListener("focusout")
+ onFocusOut() {
+ this.focusVisibleWithin.set(false);
+ }
+
+ /** Tree constructed from `this.options` */
+ private rootTree: ChipSelectOption;
+
+ /** Options that are currently displayed in the menu */
+ protected renderedOptions: ChipSelectOption;
+
+ /** The option that is currently selected by the user */
+ protected selectedOption: ChipSelectOption;
+
+ /** The label to show in the chip button */
+ protected get label(): string {
+ return this.selectedOption?.label || this.placeholderText;
+ }
+
+ /** The icon to show in the chip button */
+ protected get icon(): string {
+ return this.selectedOption?.icon || this.placeholderIcon;
+ }
+
+ protected selectOption(option: ChipSelectOption, _event: MouseEvent) {
+ this.selectedOption = option;
+ this.onChange(option);
+ }
+
+ protected viewOption(option: ChipSelectOption, event: MouseEvent) {
+ this.renderedOptions = option;
+
+ /** We don't want the menu to close */
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }
+
+ /** Click handler for the X button */
+ protected clear() {
+ this.renderedOptions = this.rootTree;
+ this.selectedOption = null;
+ this.onChange(null);
+ }
+
+ /**
+ * Find a `ChipSelectOption` by its value
+ * @param tree the root tree to search
+ * @param value the option value to look for
+ * @returns the `ChipSelectOption` associated with the provided value, or null if not found
+ */
+ private findOption(tree: ChipSelectOption, value: T): ChipSelectOption | null {
+ let result = null;
+ if (tree.value === value) {
+ return tree;
+ }
+
+ if (Array.isArray(tree.children) && tree.children.length > 0) {
+ tree.children.some((node) => {
+ result = this.findOption(node, value);
+ return result;
+ });
+ }
+ return result;
+ }
+
+ /** Maps child options to their parent, to enable navigating up the tree */
+ private childParentMap = new Map, ChipSelectOption>();
+
+ /** For each descendant in the provided `tree`, update `_parent` to be a refrence to the parent node. This allows us to navigate back in the menu. */
+ private markParents(tree: ChipSelectOption) {
+ tree.children?.forEach((child) => {
+ this.childParentMap.set(child, tree);
+ this.markParents(child);
+ });
+ }
+
+ protected getParent(option: ChipSelectOption): ChipSelectOption | null {
+ return this.childParentMap.get(option);
+ }
+
+ private initializeRootTree(options: ChipSelectOption[]) {
+ /** Since the component is just initialized with an array of options, we need to construct the root tree. */
+ const root: ChipSelectOption = {
+ children: options,
+ value: null,
+ };
+ this.markParents(root);
+ this.rootTree = root;
+ this.renderedOptions = this.rootTree;
+ }
+
+ /** Control Value Accessor */
+
+ private notifyOnChange?: (value: T) => void;
+ private notifyOnTouched?: () => void;
+
+ /** Implemented as part of NG_VALUE_ACCESSOR */
+ writeValue(obj: T): void {
+ this.selectedOption = this.findOption(this.rootTree, obj);
+
+ /** Update the rendered options for next time the menu is opened */
+ this.renderedOptions = this.selectedOption
+ ? this.getParent(this.selectedOption)
+ : this.rootTree;
+ }
+
+ /** Implemented as part of NG_VALUE_ACCESSOR */
+ registerOnChange(fn: (value: T) => void): void {
+ this.notifyOnChange = fn;
+ }
+
+ /** Implemented as part of NG_VALUE_ACCESSOR */
+ registerOnTouched(fn: any): void {
+ this.notifyOnTouched = fn;
+ }
+
+ /** Implemented as part of NG_VALUE_ACCESSOR */
+ setDisabledState(isDisabled: boolean): void {
+ this.disabled = isDisabled;
+ }
+
+ /** Implemented as part of NG_VALUE_ACCESSOR */
+ protected onChange(option: Option | null) {
+ if (!this.notifyOnChange) {
+ return;
+ }
+
+ this.notifyOnChange(option?.value);
+ }
+
+ /** Implemented as part of NG_VALUE_ACCESSOR */
+ protected onBlur() {
+ if (!this.notifyOnTouched) {
+ return;
+ }
+
+ this.notifyOnTouched();
+ }
+}
diff --git a/libs/components/src/chip-select/chip-select.mdx b/libs/components/src/chip-select/chip-select.mdx
new file mode 100644
index 0000000000..90bc755620
--- /dev/null
+++ b/libs/components/src/chip-select/chip-select.mdx
@@ -0,0 +1,113 @@
+import { Meta, Story, Primary, Controls, Canvas } from "@storybook/addon-docs";
+
+import * as stories from "./chip-select.stories";
+
+
+
+```ts
+import { ChipSelectComponent } from "@bitwarden/components";
+```
+
+# Chip Select
+
+`` is a select element that is commonly used to filter items in lists or tables.
+
+
+
+## Options
+
+Options are passed to the select via an `options` input.
+
+```ts
+@Component({
+ selector: `
+
+ `,
+})
+class MyComponent {
+ protected options = [
+ {
+ label: "Foo",
+ value: "foo",
+ icon: "bwi-folder",
+ },
+ {
+ label: "Bar",
+ value: "bar",
+ icon: "bwi-exclamation-triangle tw-text-danger",
+ },
+ {
+ label: "Baz",
+ value: "baz",
+ disabled: true,
+ },
+ ];
+}
+```
+
+### Option Trees
+
+Nested trees of options are also supported by passing an array of options to `children`.
+
+```ts
+const options = [
+ {
+ label: "Foo0",
+ value: "foo0",
+ icon: "bwi-folder",
+ children: [
+ {
+ label: "Foo1",
+ value: "foo1",
+ icon: "bwi-folder",
+ children: [
+ {
+ label: "Foo2",
+ value: "foo2",
+ icon: "bwi-folder",
+ children: [
+ {
+ label: "Foo3",
+ value: "foo3",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ label: "Bar",
+ value: "bar",
+ icon: "bwi-folder",
+ },
+ {
+ label: "Baz",
+ value: "baz",
+ icon: "bwi-folder",
+ },
+];
+```
+
+
+
+## Placeholder Content
+
+Placeholder content is shown when no item is selected.
+
+```html
+
+```
+
+## Reading the current value
+
+The component implements `ControlValueAccessor`, so the current selected value can be read via
+`ngModel` or `[formControlName]`.
+
+```html
+
+```
diff --git a/libs/components/src/chip-select/chip-select.stories.ts b/libs/components/src/chip-select/chip-select.stories.ts
new file mode 100644
index 0000000000..a54cdf23b3
--- /dev/null
+++ b/libs/components/src/chip-select/chip-select.stories.ts
@@ -0,0 +1,160 @@
+import { FormsModule } from "@angular/forms";
+import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
+import { getAllByRole, userEvent } from "@storybook/testing-library";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+
+import { MenuModule } from "../menu";
+import { I18nMockService } from "../utils/i18n-mock.service";
+
+import { ChipSelectComponent } from "./chip-select.component";
+
+export default {
+ title: "Component Library/Chip Select",
+ component: ChipSelectComponent,
+ decorators: [
+ moduleMetadata({
+ imports: [MenuModule, FormsModule],
+ providers: [
+ {
+ provide: I18nService,
+ useFactory: () => {
+ return new I18nMockService({
+ viewItemsIn: (name) => `View items in ${name}`,
+ back: "Back",
+ backTo: (name) => `Back to ${name}`,
+ removeItem: (name) => `Remove ${name}`,
+ });
+ },
+ },
+ ],
+ }),
+ ],
+} as Meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => ({
+ props: {
+ ...args,
+ },
+ template: /* html */ `
+
+ `,
+ }),
+ args: {
+ options: [
+ {
+ label: "Foo",
+ value: "foo",
+ icon: "bwi-folder",
+ },
+ {
+ label: "Bar",
+ value: "bar",
+ icon: "bwi-exclamation-triangle tw-text-danger",
+ },
+ {
+ label: "Baz",
+ value: "baz",
+ disabled: true,
+ },
+ ],
+ },
+ play: async (context) => {
+ const canvas = context.canvasElement;
+ const buttons = getAllByRole(canvas, "button");
+ await userEvent.click(buttons[0]);
+ },
+};
+
+export const NestedOptions: Story = {
+ ...Default,
+ args: {
+ options: [
+ {
+ label: "Foo",
+ value: "foo",
+ icon: "bwi-folder",
+ children: [
+ {
+ label: "Foo1",
+ value: "foo1",
+ icon: "bwi-folder",
+ children: [
+ {
+ label: "Foo2",
+ value: "foo2",
+ icon: "bwi-folder",
+ children: [
+ {
+ label: "Foo3",
+ value: "foo3",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ label: "Bar",
+ value: "bar",
+ icon: "bwi-folder",
+ },
+ {
+ label: "Baz",
+ value: "baz",
+ icon: "bwi-folder",
+ },
+ ],
+ value: "foo1",
+ },
+};
+
+export const TextOverflow: Story = {
+ ...Default,
+ args: {
+ options: [
+ {
+ label: "Fooooooooooooooooooooooooooooooooooooooooooooo",
+ value: "foo",
+ },
+ ],
+ value: "foo",
+ },
+};
+
+export const Disabled: Story = {
+ ...Default,
+ render: (args) => ({
+ props: {
+ ...args,
+ },
+ template: /* html */ `
+
+ `,
+ }),
+ args: {
+ options: [
+ {
+ label: "Foo",
+ value: "foo",
+ icon: "bwi-folder",
+ },
+ ],
+ value: "foo",
+ },
+};
diff --git a/libs/components/src/chip-select/index.ts b/libs/components/src/chip-select/index.ts
new file mode 100644
index 0000000000..f90e040f14
--- /dev/null
+++ b/libs/components/src/chip-select/index.ts
@@ -0,0 +1 @@
+export * from "./chip-select.component";
diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts
index 1e4a3a86ff..9e9329b215 100644
--- a/libs/components/src/index.ts
+++ b/libs/components/src/index.ts
@@ -9,6 +9,7 @@ export { ButtonType } from "./shared/button-like.abstraction";
export * from "./callout";
export * from "./card";
export * from "./checkbox";
+export * from "./chip-select";
export * from "./color-password";
export * from "./container";
export * from "./dialog";
diff --git a/libs/components/src/menu/menu-item.component.html b/libs/components/src/menu/menu-item.component.html
new file mode 100644
index 0000000000..f6a05c3cc9
--- /dev/null
+++ b/libs/components/src/menu/menu-item.component.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libs/components/src/menu/menu-item.directive.ts b/libs/components/src/menu/menu-item.directive.ts
index 77246bbcdf..37289c9364 100644
--- a/libs/components/src/menu/menu-item.directive.ts
+++ b/libs/components/src/menu/menu-item.directive.ts
@@ -1,12 +1,14 @@
import { FocusableOption } from "@angular/cdk/a11y";
-import { Directive, ElementRef, HostBinding } from "@angular/core";
+import { Component, ElementRef, HostBinding } from "@angular/core";
-@Directive({
+@Component({
selector: "[bitMenuItem]",
+ templateUrl: "menu-item.component.html",
})
export class MenuItemDirective implements FocusableOption {
@HostBinding("class") classList = [
"tw-block",
+ "tw-w-full",
"tw-py-1",
"tw-px-4",
"!tw-text-main",
@@ -24,6 +26,9 @@ export class MenuItemDirective implements FocusableOption {
"focus-visible:tw-ring-primary-700",
"active:!tw-ring-0",
"active:!tw-ring-offset-0",
+ "disabled:!tw-text-muted",
+ "disabled:hover:tw-bg-background",
+ "disabled:tw-cursor-not-allowed",
];
@HostBinding("attr.role") role = "menuitem";
@HostBinding("tabIndex") tabIndex = "-1";
diff --git a/libs/components/src/menu/menu-trigger-for.directive.ts b/libs/components/src/menu/menu-trigger-for.directive.ts
index 05f2e7a8ef..e49bb040d2 100644
--- a/libs/components/src/menu/menu-trigger-for.directive.ts
+++ b/libs/components/src/menu/menu-trigger-for.directive.ts
@@ -16,13 +16,16 @@ import { MenuComponent } from "./menu.component";
@Directive({
selector: "[bitMenuTriggerFor]",
+ exportAs: "menuTrigger",
})
export class MenuTriggerForDirective implements OnDestroy {
@HostBinding("attr.aria-expanded") isOpen = false;
@HostBinding("attr.aria-haspopup") get hasPopup(): "menu" | "dialog" {
return this.menu?.ariaRole || "menu";
}
- @HostBinding("attr.role") role = "button";
+ @HostBinding("attr.role")
+ @Input()
+ role = "button";
@Input("bitMenuTriggerFor") menu: MenuComponent;
diff --git a/libs/components/src/menu/menu.mdx b/libs/components/src/menu/menu.mdx
index 14ac280410..67c276cf37 100644
--- a/libs/components/src/menu/menu.mdx
+++ b/libs/components/src/menu/menu.mdx
@@ -10,7 +10,26 @@ Menus are used to help organize related options. Menus are most often used for i
tables.
-
+
+
+
+## Slots
+
+`bitMenuItem` supports the following slots:
+
+| Slot | Description |
+| -------------- | --------------------------------------------------- |
+| default | primary text or arbitrary content |
+| `slot="start"` | commonly an icon or avatar; before the default slot |
+| `slot="end"` | commonly an icon; after the default slot |
+
+```html
+
+```
## Accessibility
diff --git a/libs/components/src/menu/menu.stories.ts b/libs/components/src/menu/menu.stories.ts
index d0f78ed66d..c5d232b205 100644
--- a/libs/components/src/menu/menu.stories.ts
+++ b/libs/components/src/menu/menu.stories.ts
@@ -35,13 +35,21 @@ type Story = StoryObj;
export const OpenMenu: Story = {
render: (args) => ({
props: args,
- template: `
+ template: /*html*/ `
Anchor link
Another link
-
+
+
@@ -55,7 +63,7 @@ export const OpenMenu: Story = {
export const ClosedMenu: Story = {
render: (args) => ({
props: args,
- template: `
+ template: /*html*/ `
@@ -65,7 +73,15 @@ export const ClosedMenu: Story = {
Another link
-
+
+
`,
}),
};
diff --git a/libs/components/src/select/option.component.ts b/libs/components/src/select/option.component.ts
index c0f8c0a593..d513feb744 100644
--- a/libs/components/src/select/option.component.ts
+++ b/libs/components/src/select/option.component.ts
@@ -1,5 +1,4 @@
-import { coerceBooleanProperty } from "@angular/cdk/coercion";
-import { Component, Input } from "@angular/core";
+import { Component, Input, booleanAttribute } from "@angular/core";
import { Option } from "./option";
@@ -11,18 +10,12 @@ export class OptionComponent
implements Option {
@Input()
icon?: string;
- @Input()
- value?: T = undefined;
+ @Input({ required: true })
+ value: T;
- @Input()
- label?: string;
+ @Input({ required: true })
+ label: string;
- private _disabled = false;
- @Input()
- get disabled() {
- return this._disabled;
- }
- set disabled(value: boolean | "") {
- this._disabled = coerceBooleanProperty(value);
- }
+ @Input({ transform: booleanAttribute })
+ disabled: boolean;
}
diff --git a/libs/components/src/select/option.ts b/libs/components/src/select/option.ts
index bb2b4e996a..4d7cc70bda 100644
--- a/libs/components/src/select/option.ts
+++ b/libs/components/src/select/option.ts
@@ -1,8 +1,6 @@
-import { TemplateRef } from "@angular/core";
-
export interface Option {
icon?: string;
- value?: T;
+ value: T | null;
label?: string;
- content?: TemplateRef;
+ disabled?: boolean;
}