From dfbe5af6146ed54a2fe7dfdefb57a702fa327768 Mon Sep 17 00:00:00 2001
From: Justin Baur <19896123+justindbaur@users.noreply.github.com>
Date: Mon, 17 Mar 2025 16:16:56 -0400
Subject: [PATCH] ChipMultiSelectComponent
---
.../chip-multi-select.component.html | 110 ++++++
.../chip-multi-select.component.ts | 328 ++++++++++++++++++
.../chip-multi-select.stories.ts | 250 +++++++++++++
.../components/src/chip-multi-select/index.ts | 1 +
4 files changed, 689 insertions(+)
create mode 100644 libs/components/src/chip-multi-select/chip-multi-select.component.html
create mode 100644 libs/components/src/chip-multi-select/chip-multi-select.component.ts
create mode 100644 libs/components/src/chip-multi-select/chip-multi-select.stories.ts
create mode 100644 libs/components/src/chip-multi-select/index.ts
diff --git a/libs/components/src/chip-multi-select/chip-multi-select.component.html b/libs/components/src/chip-multi-select/chip-multi-select.component.html
new file mode 100644
index 00000000000..c0aea857951
--- /dev/null
+++ b/libs/components/src/chip-multi-select/chip-multi-select.component.html
@@ -0,0 +1,110 @@
+
+
+
+
+
+ @if (anySelected) {
+
+ }
+
+
+
+ @if (renderedOptions) {
+
+ @if (getParent(renderedOptions); as parent) {
+
+
+ }
+ @for (option of renderedOptions.children; track option) {
+
+ }
+
+ }
+
diff --git a/libs/components/src/chip-multi-select/chip-multi-select.component.ts b/libs/components/src/chip-multi-select/chip-multi-select.component.ts
new file mode 100644
index 00000000000..427bad000c3
--- /dev/null
+++ b/libs/components/src/chip-multi-select/chip-multi-select.component.ts
@@ -0,0 +1,328 @@
+import {
+ AfterViewInit,
+ booleanAttribute,
+ Component,
+ DestroyRef,
+ ElementRef,
+ HostBinding,
+ HostListener,
+ inject,
+ Input,
+ QueryList,
+ signal,
+ ViewChild,
+ ViewChildren,
+} from "@angular/core";
+import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
+
+import { compareValues } from "@bitwarden/common/platform/misc/compare-values";
+
+import { ButtonModule } from "../button";
+import { ChipSelectOption } from "../chip-select";
+import { IconButtonModule } from "../icon-button";
+import { MenuComponent, MenuItemDirective, MenuModule } from "../menu";
+import { Option } from "../select/option";
+import { SharedModule } from "../shared";
+import { TypographyModule } from "../typography";
+
+@Component({
+ selector: "bit-chip-multi-select",
+ templateUrl: "chip-multi-select.component.html",
+ standalone: true,
+ imports: [SharedModule, ButtonModule, IconButtonModule, MenuModule, TypographyModule],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: ChipMultiSelectComponent,
+ multi: true,
+ },
+ ],
+})
+export class ChipMultiSelectComponent implements ControlValueAccessor, AfterViewInit {
+ @ViewChild(MenuComponent) menu: MenuComponent;
+ @ViewChildren(MenuItemDirective) menuItems: QueryList;
+ @ViewChild("chipSelectButton") chipSelectButton: ElementRef;
+
+ /** 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;
+
+ /** Chip will stretch to full width of its container */
+ @Input({ transform: booleanAttribute }) fullWidth?: boolean;
+
+ /**
+ * 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);
+ }
+
+ @HostBinding("class")
+ get classList() {
+ return ["tw-inline-block", this.fullWidth ? "tw-w-full" : "tw-max-w-52"];
+ }
+
+ private destroyRef = inject(DestroyRef);
+
+ /** 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 selectedOptions: ChipSelectOption[];
+
+ protected get anySelected(): boolean {
+ return this.selectedOptions != null && this.selectedOptions.length !== 0;
+ }
+
+ /**
+ * The initial calculated width of the menu when it opens, which is used to
+ * keep the width consistent as the user navigates through submenus
+ */
+ protected menuWidth: number | null = null;
+
+ /** The label to show in the chip button */
+ protected get label(): string {
+ if (this.selectedOptions == null || this.selectedOptions.length === 0) {
+ return this.placeholderText;
+ }
+
+ return this.selectedOptions[0]?.label || this.placeholderText;
+ }
+
+ /** The icon to show in the chip button */
+ protected get icon(): string {
+ if (this.selectedOptions == null || this.selectedOptions.length === 0) {
+ return this.placeholderIcon;
+ }
+
+ if (this.selectedOptions.length === 1) {
+ return this.selectedOptions[0].icon ?? this.placeholderIcon;
+ }
+
+ const amount = Math.min(this.selectedOptions.length, 10);
+
+ if (amount === 10) {
+ // TODO: Should we have a 9+ icon?
+ return "bwi-icon-9";
+ }
+
+ // The value should be between 2 and 9, which we have icons for.
+ return `bwi-icon-${amount}`;
+ }
+
+ protected getOptionIcon(option: ChipSelectOption) {
+ if (this.isSelected(option)) {
+ return "bwi-check";
+ }
+
+ return option.icon;
+ }
+
+ private isSelected(option: ChipSelectOption) {
+ if (this.selectedOptions == null) {
+ return false;
+ }
+
+ return this.selectedOptions.some((o) => compareValues(o.value, option.value));
+ }
+
+ /**
+ * Set the rendered options based on whether or not an option is already selected, so that the correct
+ * submenu displays.
+ */
+ protected setOrResetRenderedOptions(): void {
+ // TODO: Huh?
+ this.renderedOptions = this.rootTree;
+ // this.renderedOptions = this.selectedOption
+ // ? this.selectedOption.children?.length > 0
+ // ? this.selectedOption
+ // : this.getParent(this.selectedOption)
+ // : this.rootTree;
+ }
+
+ protected handleMenuClosed(): void {
+ this.setOrResetRenderedOptions();
+ // reset menu width so that it can be recalculated upon open
+ this.menuWidth = null;
+ }
+
+ protected selectOption(option: ChipSelectOption, _event: MouseEvent) {
+ this.selectedOptions ??= [];
+ // Check that it isn't already selected?
+ const existingIndex = this.selectedOptions.findIndex((o) =>
+ compareValues(o.value, option.value),
+ );
+
+ if (existingIndex === -1) {
+ // Select it
+ this.selectedOptions.push(option);
+ } else {
+ // De-select it
+ this.selectedOptions.splice(existingIndex, 1);
+ }
+
+ this.onChange(this.selectedOptions);
+ }
+
+ 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.selectedOptions = 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 findOptions(tree: ChipSelectOption, values: T[] | null): ChipSelectOption[] | null {
+ if (values == null) {
+ return [];
+ }
+
+ const results: ChipSelectOption[] = [];
+ for (const value of values) {
+ if (tree.value !== null && compareValues(tree.value, value)) {
+ results.push(tree);
+ break;
+ }
+
+ if (Array.isArray(tree.children) && tree.children.length > 0) {
+ for (const child of tree.children) {
+ results.push(...this.findOptions(child, [value]));
+ }
+ }
+ }
+
+ return results;
+ }
+
+ /** 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;
+ }
+
+ ngAfterViewInit() {
+ /**
+ * menuItems will change when the user navigates into or out of a submenu. when that happens, we want to
+ * direct their focus to the first item in the new menu
+ */
+ this.menuItems.changes.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
+ this.menu.keyManager.setFirstItemActive();
+ });
+ }
+
+ /**
+ * Calculate the width of the menu based on whichever is larger, the chip select width or the width of
+ * the initially rendered options
+ */
+ protected setMenuWidth() {
+ const chipWidth = this.chipSelectButton.nativeElement.getBoundingClientRect().width;
+
+ const firstMenuItemWidth =
+ this.menu.menuItems.first.elementRef.nativeElement.getBoundingClientRect().width;
+
+ this.menuWidth = Math.max(chipWidth, firstMenuItemWidth);
+ }
+
+ /** Control Value Accessor */
+
+ private notifyOnChange?: (value: T[]) => void;
+ private notifyOnTouched?: () => void;
+
+ /** Implemented as part of NG_VALUE_ACCESSOR */
+ writeValue(obj: T[]): void {
+ this.selectedOptions = this.findOptions(this.rootTree, obj);
+ this.setOrResetRenderedOptions();
+ }
+
+ /** 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?.map((o) => o.value) ?? null);
+ }
+
+ /** Implemented as part of NG_VALUE_ACCESSOR */
+ protected onBlur() {
+ if (!this.notifyOnTouched) {
+ return;
+ }
+
+ this.notifyOnTouched();
+ }
+}
diff --git a/libs/components/src/chip-multi-select/chip-multi-select.stories.ts b/libs/components/src/chip-multi-select/chip-multi-select.stories.ts
new file mode 100644
index 00000000000..f4e0c523804
--- /dev/null
+++ b/libs/components/src/chip-multi-select/chip-multi-select.stories.ts
@@ -0,0 +1,250 @@
+import { FormsModule } from "@angular/forms";
+import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
+import { getAllByRole, userEvent } from "@storybook/test";
+
+import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
+
+import { MenuModule } from "../menu";
+import { I18nMockService } from "../utils/i18n-mock.service";
+
+import { ChipMultiSelectComponent } from "./chip-multi-select.component";
+
+export default {
+ title: "Component Library/Chip Multi Select",
+ component: ChipMultiSelectComponent,
+ 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}`,
+ });
+ },
+ },
+ ],
+ }),
+ ],
+ parameters: {
+ design: {
+ type: "figma",
+ url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=16329-29548&t=b5tDKylm5sWm2yKo-4",
+ },
+ },
+} 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,
+ },
+ ],
+ value: ["foo"],
+ },
+};
+
+export const MenuOpen: 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 FullWidth: 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,
+ },
+ ],
+ },
+};
+
+export const NestedOptions: Story = {
+ ...Default,
+ args: {
+ options: [
+ {
+ label: "Foo",
+ value: "foo",
+ icon: "bwi-folder",
+ children: [
+ {
+ label: "Foo1 very long name of folder but even longer than you thought",
+ 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 = {
+ render: (args) => ({
+ props: {
+ ...args,
+ },
+ template: /* html */ `
+
+
+ `,
+ }),
+ args: {
+ options: [
+ {
+ label: "Foo",
+ value: "foo",
+ icon: "bwi-folder",
+ },
+ ],
+ value: ["foo"],
+ },
+};
diff --git a/libs/components/src/chip-multi-select/index.ts b/libs/components/src/chip-multi-select/index.ts
new file mode 100644
index 00000000000..d67116544e5
--- /dev/null
+++ b/libs/components/src/chip-multi-select/index.ts
@@ -0,0 +1 @@
+export * from "./chip-multi-select.component";