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; }