diff --git a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html index f1b165679c1..03c061b6801 100644 --- a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html +++ b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.html @@ -30,15 +30,24 @@ {{ "nestCollectionUnder" | i18n }} - + + + + + + + diff --git a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts index 387cf78555e..236a3fd8ca9 100644 --- a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts +++ b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.component.ts @@ -62,7 +62,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { protected formGroup = this.formBuilder.group({ name: ["", [Validators.required, BitValidators.forbiddenCharacters(["/"])]], externalId: "", - parent: null as string | null, + parent: undefined as string | undefined, access: [[] as AccessItemValue[]], }); protected PermissionMode = PermissionMode; @@ -121,7 +121,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { } const { name, parent } = parseName(this.collection); - if (parent !== null && !this.nestOptions.find((c) => c.name === parent)) { + if (parent !== undefined && !this.nestOptions.find((c) => c.name === parent)) { this.deletedParentName = parent; } @@ -135,7 +135,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { } else { this.nestOptions = collections; const parent = collections.find((c) => c.id === this.params.parentCollectionId); - this.formGroup.patchValue({ parent: parent?.name ?? null }); + this.formGroup.patchValue({ parent: parent?.name ?? undefined }); } this.loading = false; @@ -237,7 +237,7 @@ export class CollectionDialogComponent implements OnInit, OnDestroy { function parseName(collection: CollectionView) { const nameParts = collection.name?.split("/"); const name = nameParts[nameParts.length - 1]; - const parent = nameParts.length > 1 ? nameParts.slice(0, -1).join("/") : null; + const parent = nameParts.length > 1 ? nameParts.slice(0, -1).join("/") : undefined; return { name, parent }; } diff --git a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.module.ts b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.module.ts index 523eeb9a722..c5817eee63a 100644 --- a/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.module.ts +++ b/apps/web/src/app/organizations/shared/components/collection-dialog/collection-dialog.module.ts @@ -1,12 +1,14 @@ import { NgModule } from "@angular/core"; +import { SelectModule } from "@bitwarden/components"; + import { SharedModule } from "../../../../shared/shared.module"; import { AccessSelectorModule } from "../access-selector"; import { CollectionDialogComponent } from "./collection-dialog.component"; @NgModule({ - imports: [SharedModule, AccessSelectorModule], + imports: [SharedModule, AccessSelectorModule, SelectModule], declarations: [CollectionDialogComponent], exports: [CollectionDialogComponent], }) diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3c046cb1c5f..ac97b66acd3 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5670,6 +5670,9 @@ }, "customColor": { "message": "Custom Color" + }, + "selectPlaceholder": { + "message": "-- Select --" }, "multiSelectPlaceholder": { "message": "-- Type to filter --" @@ -6000,6 +6003,9 @@ "collection": { "message": "Collection" }, + "noCollection": { + "message": "No collection" + }, "canView": { "message": "Can view" }, diff --git a/libs/components/src/form-field/form-field.stories.ts b/libs/components/src/form-field/form-field.stories.ts index c376d36a340..64080acc666 100644 --- a/libs/components/src/form-field/form-field.stories.ts +++ b/libs/components/src/form-field/form-field.stories.ts @@ -17,6 +17,7 @@ import { CheckboxModule } from "../checkbox"; import { IconButtonModule } from "../icon-button"; import { InputModule } from "../input/input.module"; import { RadioButtonModule } from "../radio-button"; +import { SelectModule } from "../select"; import { I18nMockService } from "../utils/i18n-mock.service"; import { BitFormFieldComponent } from "./form-field.component"; @@ -37,12 +38,14 @@ export default { AsyncActionsModule, CheckboxModule, RadioButtonModule, + SelectModule, ], providers: [ { provide: I18nService, useFactory: () => { return new I18nMockService({ + selectPlaceholder: "-- Select --", required: "required", inputRequired: "Input is required.", inputEmail: "Input is not an email-address.", @@ -232,6 +235,22 @@ const SelectTemplate: Story = (args: BitFormFieldComponen export const Select = SelectTemplate.bind({}); Select.args = {}; +const AdvancedSelectTemplate: Story = (args: BitFormFieldComponent) => ({ + props: args, + template: ` + + Label + + + + + + `, +}); + +export const AdvancedSelect = AdvancedSelectTemplate.bind({}); +AdvancedSelectTemplate.args = {}; + const TextareaTemplate: Story = (args: BitFormFieldComponent) => ({ props: args, template: ` diff --git a/libs/components/src/form/countries.ts b/libs/components/src/form/countries.ts new file mode 100644 index 00000000000..a35d79dd119 --- /dev/null +++ b/libs/components/src/form/countries.ts @@ -0,0 +1,253 @@ +// DISCLAIMER: This is not an official list and should only used +// to provide realistic example data in stories. +export const countries = [ + { value: "US", name: "United States" }, + { value: "CN", name: "China" }, + { value: "FR", name: "France" }, + { value: "DE", name: "Germany" }, + { value: "CA", name: "Canada" }, + { value: "GB", name: "United Kingdom" }, + { value: "AU", name: "Australia" }, + { value: "IN", name: "India" }, + { value: "AF", name: "Afghanistan" }, + { value: "AX", name: "Åland Islands" }, + { value: "AL", name: "Albania" }, + { value: "DZ", name: "Algeria" }, + { value: "AS", name: "American Samoa" }, + { value: "AD", name: "Andorra" }, + { value: "AO", name: "Angola" }, + { value: "AI", name: "Anguilla" }, + { value: "AQ", name: "Antarctica" }, + { value: "AG", name: "Antigua and Barbuda" }, + { value: "AR", name: "Argentina" }, + { value: "AM", name: "Armenia" }, + { value: "AW", name: "Aruba" }, + { value: "AT", name: "Austria" }, + { value: "AZ", name: "Azerbaijan" }, + { value: "BS", name: "Bahamas" }, + { value: "BH", name: "Bahrain" }, + { value: "BD", name: "Bangladesh" }, + { value: "BB", name: "Barbados" }, + { value: "BY", name: "Belarus" }, + { value: "BE", name: "Belgium" }, + { value: "BZ", name: "Belize" }, + { value: "BJ", name: "Benin" }, + { value: "BM", name: "Bermuda" }, + { value: "BT", name: "Bhutan" }, + { value: "BO", name: "Bolivia, Plurinational State of" }, + { value: "BQ", name: "Bonaire, Sint Eustatius and Saba" }, + { value: "BA", name: "Bosnia and Herzegovina" }, + { value: "BW", name: "Botswana" }, + { value: "BV", name: "Bouvet Island" }, + { value: "BR", name: "Brazil" }, + { value: "IO", name: "British Indian Ocean Territory" }, + { value: "BN", name: "Brunei Darussalam" }, + { value: "BG", name: "Bulgaria" }, + { value: "BF", name: "Burkina Faso" }, + { value: "BI", name: "Burundi" }, + { value: "KH", name: "Cambodia" }, + { value: "CM", name: "Cameroon" }, + { value: "CV", name: "Cape Verde" }, + { value: "KY", name: "Cayman Islands" }, + { value: "CF", name: "Central African Republic" }, + { value: "TD", name: "Chad" }, + { value: "CL", name: "Chile" }, + { value: "CX", name: "Christmas Island" }, + { value: "CC", name: "Cocos (Keeling) Islands" }, + { value: "CO", name: "Colombia" }, + { value: "KM", name: "Comoros" }, + { value: "CG", name: "Congo" }, + { value: "CD", name: "Congo, the Democratic Republic of the" }, + { value: "CK", name: "Cook Islands" }, + { value: "CR", name: "Costa Rica" }, + { value: "CI", name: "Côte d'Ivoire" }, + { value: "HR", name: "Croatia" }, + { value: "CU", name: "Cuba" }, + { value: "CW", name: "Curaçao" }, + { value: "CY", name: "Cyprus" }, + { value: "CZ", name: "Czech Republic" }, + { value: "DK", name: "Denmark" }, + { value: "DJ", name: "Djibouti" }, + { value: "DM", name: "Dominica" }, + { value: "DO", name: "Dominican Republic" }, + { value: "EC", name: "Ecuador" }, + { value: "EG", name: "Egypt" }, + { value: "SV", name: "El Salvador" }, + { value: "GQ", name: "Equatorial Guinea" }, + { value: "ER", name: "Eritrea" }, + { value: "EE", name: "Estonia" }, + { value: "ET", name: "Ethiopia" }, + { value: "FK", name: "Falkland Islands (Malvinas)" }, + { value: "FO", name: "Faroe Islands" }, + { value: "FJ", name: "Fiji" }, + { value: "FI", name: "Finland" }, + { value: "GF", name: "French Guiana" }, + { value: "PF", name: "French Polynesia" }, + { value: "TF", name: "French Southern Territories" }, + { value: "GA", name: "Gabon" }, + { value: "GM", name: "Gambia" }, + { value: "GE", name: "Georgia" }, + { value: "GH", name: "Ghana" }, + { value: "GI", name: "Gibraltar" }, + { value: "GR", name: "Greece" }, + { value: "GL", name: "Greenland" }, + { value: "GD", name: "Grenada" }, + { value: "GP", name: "Guadeloupe" }, + { value: "GU", name: "Guam" }, + { value: "GT", name: "Guatemala" }, + { value: "GG", name: "Guernsey" }, + { value: "GN", name: "Guinea" }, + { value: "GW", name: "Guinea-Bissau" }, + { value: "GY", name: "Guyana" }, + { value: "HT", name: "Haiti" }, + { value: "HM", name: "Heard Island and McDonald Islands" }, + { value: "VA", name: "Holy See (Vatican City State)" }, + { value: "HN", name: "Honduras" }, + { value: "HK", name: "Hong Kong" }, + { value: "HU", name: "Hungary" }, + { value: "IS", name: "Iceland" }, + { value: "ID", name: "Indonesia" }, + { value: "IR", name: "Iran, Islamic Republic of" }, + { value: "IQ", name: "Iraq" }, + { value: "IE", name: "Ireland" }, + { value: "IM", name: "Isle of Man" }, + { value: "IL", name: "Israel" }, + { value: "IT", name: "Italy" }, + { value: "JM", name: "Jamaica" }, + { value: "JP", name: "Japan" }, + { value: "JE", name: "Jersey" }, + { value: "JO", name: "Jordan" }, + { value: "KZ", name: "Kazakhstan" }, + { value: "KE", name: "Kenya" }, + { value: "KI", name: "Kiribati" }, + { value: "KP", name: "Korea, Democratic People's Republic of" }, + { value: "KR", name: "Korea, Republic of" }, + { value: "KW", name: "Kuwait" }, + { value: "KG", name: "Kyrgyzstan" }, + { value: "LA", name: "Lao People's Democratic Republic" }, + { value: "LV", name: "Latvia" }, + { value: "LB", name: "Lebanon" }, + { value: "LS", name: "Lesotho" }, + { value: "LR", name: "Liberia" }, + { value: "LY", name: "Libya" }, + { value: "LI", name: "Liechtenstein" }, + { value: "LT", name: "Lithuania" }, + { value: "LU", name: "Luxembourg" }, + { value: "MO", name: "Macao" }, + { value: "MK", name: "Macedonia, the former Yugoslav Republic of" }, + { value: "MG", name: "Madagascar" }, + { value: "MW", name: "Malawi" }, + { value: "MY", name: "Malaysia" }, + { value: "MV", name: "Maldives" }, + { value: "ML", name: "Mali" }, + { value: "MT", name: "Malta" }, + { value: "MH", name: "Marshall Islands" }, + { value: "MQ", name: "Martinique" }, + { value: "MR", name: "Mauritania" }, + { value: "MU", name: "Mauritius" }, + { value: "YT", name: "Mayotte" }, + { value: "MX", name: "Mexico" }, + { value: "FM", name: "Micronesia, Federated States of" }, + { value: "MD", name: "Moldova, Republic of" }, + { value: "MC", name: "Monaco" }, + { value: "MN", name: "Mongolia" }, + { value: "ME", name: "Montenegro" }, + { value: "MS", name: "Montserrat" }, + { value: "MA", name: "Morocco" }, + { value: "MZ", name: "Mozambique" }, + { value: "MM", name: "Myanmar" }, + { value: "NA", name: "Namibia" }, + { value: "NR", name: "Nauru" }, + { value: "NP", name: "Nepal" }, + { value: "NL", name: "Netherlands" }, + { value: "NC", name: "New Caledonia" }, + { value: "NZ", name: "New Zealand" }, + { value: "NI", name: "Nicaragua" }, + { value: "NE", name: "Niger" }, + { value: "NG", name: "Nigeria" }, + { value: "NU", name: "Niue" }, + { value: "NF", name: "Norfolk Island" }, + { value: "MP", name: "Northern Mariana Islands" }, + { value: "NO", name: "Norway" }, + { value: "OM", name: "Oman" }, + { value: "PK", name: "Pakistan" }, + { value: "PW", name: "Palau" }, + { value: "PS", name: "Palestinian Territory, Occupied" }, + { value: "PA", name: "Panama" }, + { value: "PG", name: "Papua New Guinea" }, + { value: "PY", name: "Paraguay" }, + { value: "PE", name: "Peru" }, + { value: "PH", name: "Philippines" }, + { value: "PN", name: "Pitcairn" }, + { value: "PL", name: "Poland" }, + { value: "PT", name: "Portugal" }, + { value: "PR", name: "Puerto Rico" }, + { value: "QA", name: "Qatar" }, + { value: "RE", name: "Réunion" }, + { value: "RO", name: "Romania" }, + { value: "RU", name: "Russian Federation" }, + { value: "RW", name: "Rwanda" }, + { value: "BL", name: "Saint Barthélemy" }, + { value: "SH", name: "Saint Helena, Ascension and Tristan da Cunha" }, + { value: "KN", name: "Saint Kitts and Nevis" }, + { value: "LC", name: "Saint Lucia" }, + { value: "MF", name: "Saint Martin (French part)" }, + { value: "PM", name: "Saint Pierre and Miquelon" }, + { value: "VC", name: "Saint Vincent and the Grenadines" }, + { value: "WS", name: "Samoa" }, + { value: "SM", name: "San Marino" }, + { value: "ST", name: "Sao Tome and Principe" }, + { value: "SA", name: "Saudi Arabia" }, + { value: "SN", name: "Senegal" }, + { value: "RS", name: "Serbia" }, + { value: "SC", name: "Seychelles" }, + { value: "SL", name: "Sierra Leone" }, + { value: "SG", name: "Singapore" }, + { value: "SX", name: "Sint Maarten (Dutch part)" }, + { value: "SK", name: "Slovakia" }, + { value: "SI", name: "Slovenia" }, + { value: "SB", name: "Solomon Islands" }, + { value: "SO", name: "Somalia" }, + { value: "ZA", name: "South Africa" }, + { value: "GS", name: "South Georgia and the South Sandwich Islands" }, + { value: "SS", name: "South Sudan" }, + { value: "ES", name: "Spain" }, + { value: "LK", name: "Sri Lanka" }, + { value: "SD", name: "Sudan" }, + { value: "SR", name: "Suriname" }, + { value: "SJ", name: "Svalbard and Jan Mayen" }, + { value: "SZ", name: "Swaziland" }, + { value: "SE", name: "Sweden" }, + { value: "CH", name: "Switzerland" }, + { value: "SY", name: "Syrian Arab Republic" }, + { value: "TW", name: "Taiwan" }, + { value: "TJ", name: "Tajikistan" }, + { value: "TZ", name: "Tanzania, United Republic of" }, + { value: "TH", name: "Thailand" }, + { value: "TL", name: "Timor-Leste" }, + { value: "TG", name: "Togo" }, + { value: "TK", name: "Tokelau" }, + { value: "TO", name: "Tonga" }, + { value: "TT", name: "Trinidad and Tobago" }, + { value: "TN", name: "Tunisia" }, + { value: "TR", name: "Turkey" }, + { value: "TM", name: "Turkmenistan" }, + { value: "TC", name: "Turks and Caicos Islands" }, + { value: "TV", name: "Tuvalu" }, + { value: "UG", name: "Uganda" }, + { value: "UA", name: "Ukraine" }, + { value: "AE", name: "United Arab Emirates" }, + { value: "UM", name: "United States Minor Outlying Islands" }, + { value: "UY", name: "Uruguay" }, + { value: "UZ", name: "Uzbekistan" }, + { value: "VU", name: "Vanuatu" }, + { value: "VE", name: "Venezuela, Bolivarian Republic of" }, + { value: "VN", name: "Viet Nam" }, + { value: "VG", name: "Virgin Islands, British" }, + { value: "VI", name: "Virgin Islands, U.S." }, + { value: "WF", name: "Wallis and Futuna" }, + { value: "EH", name: "Western Sahara" }, + { value: "YE", name: "Yemen" }, + { value: "ZM", name: "Zambia" }, + { value: "ZW", name: "Zimbabwe" }, +]; diff --git a/libs/components/src/form/form.stories.ts b/libs/components/src/form/form.stories.ts index 741aac859a2..9b2e8b2c8cc 100644 --- a/libs/components/src/form/form.stories.ts +++ b/libs/components/src/form/form.stories.ts @@ -17,8 +17,11 @@ import { FormControlModule } from "../form-control"; import { FormFieldModule } from "../form-field"; import { InputModule } from "../input/input.module"; import { RadioButtonModule } from "../radio-button"; +import { SelectModule } from "../select"; import { I18nMockService } from "../utils/i18n-mock.service"; +import { countries } from "./countries"; + export default { title: "Component Library/Form", decorators: [ @@ -32,12 +35,14 @@ export default { FormControlModule, CheckboxModule, RadioButtonModule, + SelectModule, ], providers: [ { provide: I18nService, useFactory: () => { return new I18nMockService({ + selectPlaceholder: "-- Select --", required: "required", checkboxRequired: "Option is required", inputRequired: "Input is required.", @@ -60,6 +65,7 @@ const fb = new FormBuilder(); const exampleFormObj = fb.group({ name: ["", [Validators.required]], email: ["", [Validators.required, Validators.email, forbiddenNameValidator(/bit/i)]], + country: [undefined as string | undefined, [Validators.required]], terms: [false, [Validators.requiredTrue]], updates: ["yes"], }); @@ -90,6 +96,13 @@ const FullExampleTemplate: Story = (args) => ({ + + Country + + + + + Agree to terms @@ -115,3 +128,6 @@ const FullExampleTemplate: Story = (args) => ({ }); export const FullExample = FullExampleTemplate.bind({}); +FullExample.args = { + countries, +}; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 5a45cd769be..f6989232683 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -20,5 +20,6 @@ export * from "./radio-button"; export * from "./table"; export * from "./tabs"; export * from "./toggle-group"; +export * from "./select"; export * from "./typography"; export * from "./utils/i18n-mock.service"; diff --git a/libs/components/src/multi-select/scss/bw.theme.scss b/libs/components/src/multi-select/scss/bw.theme.scss index e54f38fa350..8ced6afdc3a 100644 --- a/libs/components/src/multi-select/scss/bw.theme.scss +++ b/libs/components/src/multi-select/scss/bw.theme.scss @@ -115,6 +115,12 @@ $ng-dropdown-shadow: rgb(var(--color-secondary-100)) !default; .ng-placeholder { color: $ng-select-placeholder; } + + .ng-input { + > input { + color: $ng-select-input-text; + } + } } } &.ng-select-single { @@ -204,9 +210,6 @@ $ng-dropdown-shadow: rgb(var(--color-secondary-100)) !default; @include rtl { padding: 0 3px 3px 0; } - > input { - color: $ng-select-input-text; - } } .ng-placeholder { top: 5px; diff --git a/libs/components/src/select/index.ts b/libs/components/src/select/index.ts new file mode 100644 index 00000000000..faebfee9bb8 --- /dev/null +++ b/libs/components/src/select/index.ts @@ -0,0 +1,3 @@ +export * from "./select.module"; +export * from "./select.component"; +export * from "./option.component"; diff --git a/libs/components/src/select/option.component.ts b/libs/components/src/select/option.component.ts new file mode 100644 index 00000000000..c0f8c0a593e --- /dev/null +++ b/libs/components/src/select/option.component.ts @@ -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: ``, +}) +export class OptionComponent implements Option { + @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); + } +} diff --git a/libs/components/src/select/option.ts b/libs/components/src/select/option.ts new file mode 100644 index 00000000000..bb2b4e996af --- /dev/null +++ b/libs/components/src/select/option.ts @@ -0,0 +1,8 @@ +import { TemplateRef } from "@angular/core"; + +export interface Option { + icon?: string; + value?: T; + label?: string; + content?: TemplateRef; +} diff --git a/libs/components/src/select/select.component.html b/libs/components/src/select/select.component.html new file mode 100644 index 00000000000..2609496e10a --- /dev/null +++ b/libs/components/src/select/select.component.html @@ -0,0 +1,22 @@ + + +
+
+ +
+
+ {{ item.label }} +
+
+
+
diff --git a/libs/components/src/select/select.component.ts b/libs/components/src/select/select.component.ts new file mode 100644 index 00000000000..5e59ea37646 --- /dev/null +++ b/libs/components/src/select/select.component.ts @@ -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 implements BitFormFieldControl, ControlValueAccessor { + @ViewChild(NgSelectComponent) select: NgSelectComponent; + + /** Optional: Options can be provided using an array input or using `bit-option` */ + @Input() items: Option[] = []; + @Input() placeholder = this.i18nService.t("selectPlaceholder"); + + protected selectedValue: T; + protected selectedOption: Option; + 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>) { + 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 | 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[], value: T): Option | undefined { + return items.find((item) => item.value === value); + } +} diff --git a/libs/components/src/select/select.module.ts b/libs/components/src/select/select.module.ts new file mode 100644 index 00000000000..4391a518174 --- /dev/null +++ b/libs/components/src/select/select.module.ts @@ -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 {} diff --git a/libs/components/src/select/select.stories.ts b/libs/components/src/select/select.stories.ts new file mode 100644 index 00000000000..9b38b05d6c3 --- /dev/null +++ b/libs/components/src/select/select.stories.ts @@ -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 = (args: MultiSelectComponent) => ({ + props: { + ...args, + }, + template: ` + + + + + `, +}); + +export const Default = DefaultTemplate.bind({}); +Default.args = {}; + +export const Disabled = DefaultTemplate.bind({}); +Disabled.args = { + disabled: true, +};