mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 23:33:31 +00:00
[EC-667] Collection modal add search function (#4291)
* [EC-667] feat: scaffold new select component * [EC-667] feat: sort of working implementation * [EC-667] feat: support for using in forms * [EC-667] feat: add bit-select example to full form * [EC-667] fix: broken aria label connetion * [EC-667] fix: web not building * [EC-667] fix: dropdown getting trapped in dialog * [EC-667] fix: select not emitting correct value * [EC-667] feat: add collection icon to options * [EC-667] feat: add default select placeholder translation * [EC-667] fix: undefined handling * [EC-667] fix: value vs options race condition * [EC-667] feat: remove x and add "no collection" option * [EC-667] chore: add country list disclaimer * chore: clean up comments * [EC-667] chore: cleanup commented import * [EC-667] fix: input text color not applying to single-select
This commit is contained in:
3
libs/components/src/select/index.ts
Normal file
3
libs/components/src/select/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./select.module";
|
||||
export * from "./select.component";
|
||||
export * from "./option.component";
|
||||
28
libs/components/src/select/option.component.ts
Normal file
28
libs/components/src/select/option.component.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { Component, Input } from "@angular/core";
|
||||
|
||||
import { Option } from "./option";
|
||||
|
||||
@Component({
|
||||
selector: "bit-option",
|
||||
template: `<ng-template><ng-content></ng-content></ng-template>`,
|
||||
})
|
||||
export class OptionComponent<T = unknown> implements Option<T> {
|
||||
@Input()
|
||||
icon?: string;
|
||||
|
||||
@Input()
|
||||
value?: T = undefined;
|
||||
|
||||
@Input()
|
||||
label?: string;
|
||||
|
||||
private _disabled = false;
|
||||
@Input()
|
||||
get disabled() {
|
||||
return this._disabled;
|
||||
}
|
||||
set disabled(value: boolean | "") {
|
||||
this._disabled = coerceBooleanProperty(value);
|
||||
}
|
||||
}
|
||||
8
libs/components/src/select/option.ts
Normal file
8
libs/components/src/select/option.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { TemplateRef } from "@angular/core";
|
||||
|
||||
export interface Option<T> {
|
||||
icon?: string;
|
||||
value?: T;
|
||||
label?: string;
|
||||
content?: TemplateRef<unknown>;
|
||||
}
|
||||
22
libs/components/src/select/select.component.html
Normal file
22
libs/components/src/select/select.component.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<ng-select
|
||||
[(ngModel)]="selectedOption"
|
||||
(ngModelChange)="onChange($event)"
|
||||
[disabled]="disabled"
|
||||
[placeholder]="placeholder"
|
||||
[items]="items"
|
||||
(blur)="onBlur()"
|
||||
[labelForId]="labelForId"
|
||||
[clearable]="false"
|
||||
appendTo="body"
|
||||
>
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
<div class="tw-flex">
|
||||
<div class="tw-mr-2 tw-flex-initial">
|
||||
<i *ngIf="item.icon != null" class="bwi bwi-fw {{ item.icon }}" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="tw-flex-1">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
149
libs/components/src/select/select.component.ts
Normal file
149
libs/components/src/select/select.component.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
Component,
|
||||
ContentChildren,
|
||||
HostBinding,
|
||||
Input,
|
||||
Optional,
|
||||
QueryList,
|
||||
Self,
|
||||
ViewChild,
|
||||
} from "@angular/core";
|
||||
import { ControlValueAccessor, NgControl, Validators } from "@angular/forms";
|
||||
import { NgSelectComponent } from "@ng-select/ng-select";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { BitFormFieldControl } from "../form-field";
|
||||
|
||||
import { Option } from "./option";
|
||||
import { OptionComponent } from "./option.component";
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
@Component({
|
||||
selector: "bit-select",
|
||||
templateUrl: "select.component.html",
|
||||
providers: [{ provide: BitFormFieldControl, useExisting: SelectComponent }],
|
||||
})
|
||||
export class SelectComponent<T> implements BitFormFieldControl, ControlValueAccessor {
|
||||
@ViewChild(NgSelectComponent) select: NgSelectComponent;
|
||||
|
||||
/** Optional: Options can be provided using an array input or using `bit-option` */
|
||||
@Input() items: Option<T>[] = [];
|
||||
@Input() placeholder = this.i18nService.t("selectPlaceholder");
|
||||
|
||||
protected selectedValue: T;
|
||||
protected selectedOption: Option<T>;
|
||||
protected searchInputId = `bit-select-search-input-${nextId++}`;
|
||||
|
||||
private notifyOnChange?: (value: T) => void;
|
||||
private notifyOnTouched?: () => void;
|
||||
|
||||
constructor(private i18nService: I18nService, @Optional() @Self() private ngControl?: NgControl) {
|
||||
if (ngControl != null) {
|
||||
ngControl.valueAccessor = this;
|
||||
}
|
||||
}
|
||||
|
||||
@ContentChildren(OptionComponent)
|
||||
protected set options(value: QueryList<OptionComponent<T>>) {
|
||||
this.items = value.toArray();
|
||||
this.selectedOption = this.findSelectedOption(this.items, this.selectedValue);
|
||||
}
|
||||
|
||||
@HostBinding("class") protected classes = ["tw-block", "tw-w-full"];
|
||||
|
||||
@HostBinding()
|
||||
@Input()
|
||||
get disabled() {
|
||||
return this._disabled ?? this.ngControl?.disabled ?? false;
|
||||
}
|
||||
set disabled(value: any) {
|
||||
this._disabled = value != null && value !== false;
|
||||
}
|
||||
private _disabled: boolean;
|
||||
|
||||
/**Implemented as part of NG_VALUE_ACCESSOR */
|
||||
writeValue(obj: T): void {
|
||||
this.selectedValue = obj;
|
||||
this.selectedOption = this.findSelectedOption(this.items, this.selectedValue);
|
||||
}
|
||||
|
||||
/**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<T> | null) {
|
||||
if (!this.notifyOnChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifyOnChange(option?.value);
|
||||
}
|
||||
|
||||
/**Implemented as part of NG_VALUE_ACCESSOR */
|
||||
protected onBlur() {
|
||||
if (!this.notifyOnTouched) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifyOnTouched();
|
||||
}
|
||||
|
||||
/**Implemented as part of BitFormFieldControl */
|
||||
@HostBinding("attr.aria-describedby")
|
||||
get ariaDescribedBy() {
|
||||
return this._ariaDescribedBy;
|
||||
}
|
||||
set ariaDescribedBy(value: string) {
|
||||
this._ariaDescribedBy = value;
|
||||
this.select?.searchInput.nativeElement.setAttribute("aria-describedby", value);
|
||||
}
|
||||
private _ariaDescribedBy: string;
|
||||
|
||||
/**Implemented as part of BitFormFieldControl */
|
||||
get labelForId() {
|
||||
return this.searchInputId;
|
||||
}
|
||||
|
||||
/**Implemented as part of BitFormFieldControl */
|
||||
@HostBinding() @Input() id = `bit-multi-select-${nextId++}`;
|
||||
|
||||
/**Implemented as part of BitFormFieldControl */
|
||||
@HostBinding("attr.required")
|
||||
@Input()
|
||||
get required() {
|
||||
return this._required ?? this.ngControl?.control?.hasValidator(Validators.required) ?? false;
|
||||
}
|
||||
set required(value: any) {
|
||||
this._required = value != null && value !== false;
|
||||
}
|
||||
private _required: boolean;
|
||||
|
||||
/**Implemented as part of BitFormFieldControl */
|
||||
get hasError() {
|
||||
return this.ngControl?.status === "INVALID" && this.ngControl?.touched;
|
||||
}
|
||||
|
||||
/**Implemented as part of BitFormFieldControl */
|
||||
get error(): [string, any] {
|
||||
const key = Object.keys(this.ngControl?.errors)[0];
|
||||
return [key, this.ngControl?.errors[key]];
|
||||
}
|
||||
|
||||
private findSelectedOption(items: Option<T>[], value: T): Option<T> | undefined {
|
||||
return items.find((item) => item.value === value);
|
||||
}
|
||||
}
|
||||
14
libs/components/src/select/select.module.ts
Normal file
14
libs/components/src/select/select.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { NgSelectModule } from "@ng-select/ng-select";
|
||||
|
||||
import { OptionComponent } from "./option.component";
|
||||
import { SelectComponent } from "./select.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, NgSelectModule, FormsModule],
|
||||
declarations: [SelectComponent, OptionComponent],
|
||||
exports: [SelectComponent, OptionComponent],
|
||||
})
|
||||
export class SelectModule {}
|
||||
58
libs/components/src/select/select.stories.ts
Normal file
58
libs/components/src/select/select.stories.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Meta, moduleMetadata, Story } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
|
||||
import { MultiSelectComponent } from "../multi-select/multi-select.component";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { SelectComponent } from "./select.component";
|
||||
import { SelectModule } from "./select.module";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Form/Select",
|
||||
component: SelectComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [SelectModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
selectPlaceholder: "-- Select --",
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
disabled: false,
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/file/3tWtMSYoLB0ZLEimLNzYsm/End-user-%26-admin-Vault-Refresh?t=7QEmGA69YTOF8sXU-0",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const DefaultTemplate: Story<MultiSelectComponent> = (args: MultiSelectComponent) => ({
|
||||
props: {
|
||||
...args,
|
||||
},
|
||||
template: `<bit-select [disabled]="disabled">
|
||||
<bit-option value="value1" label="Value 1" icon="bwi-collection"></bit-option>
|
||||
<bit-option value="value2" label="Value 2" icon="bwi-collection"></bit-option>
|
||||
<bit-option value="value3" label="Value 3" icon="bwi-collection"></bit-option>
|
||||
<bit-option value="value4" label="Value 4" icon="bwi-collection" disabled></bit-option>
|
||||
</bit-select>`,
|
||||
});
|
||||
|
||||
export const Default = DefaultTemplate.bind({});
|
||||
Default.args = {};
|
||||
|
||||
export const Disabled = DefaultTemplate.bind({});
|
||||
Disabled.args = {
|
||||
disabled: true,
|
||||
};
|
||||
Reference in New Issue
Block a user