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