diff --git a/components/src/index.js b/components/src/index.js
index 89534fc6..08d3550e 100644
--- a/components/src/index.js
+++ b/components/src/index.js
@@ -9,6 +9,7 @@ export { default as Icon } from '~widgets/icon/widget.vue';
export { default as View } from '~widgets/view/widget.vue';
export { default as Navigation } from '~widgets/navigation/widget.vue';
export { default as Status } from '~widgets/status/widget.vue';
+export { default as Select } from '~widgets/select/widget.vue';
export { default as Textfield } from '~widgets/textfield/widget.vue';
export { default as Table } from '~widgets/table/widget.vue';
export { default as ComplexTable } from './widgets/complexTable/widget.vue';
diff --git a/components/src/stories/Select.stories.js b/components/src/stories/Select.stories.js
new file mode 100644
index 00000000..077940b1
--- /dev/null
+++ b/components/src/stories/Select.stories.js
@@ -0,0 +1,80 @@
+import { ref } from 'vue';
+
+import Select from '~widgets/select/widget.vue';
+import registerWidget from '~core/registerWidget';
+
+registerWidget('ui-select', Select);
+
+export const Basic = {
+ name: 'Basic options',
+ render: (args) => ({
+ setup() {
+ return { args };
+ },
+ template: '',
+ }),
+
+ args: {
+ label: 'Label text',
+ modelValue: '',
+ hint: 'Some hint text',
+ options: ['foo', 'bar', 'baz'],
+ },
+};
+
+export const Object = {
+ name: 'Array of objects in options',
+ render: Basic.render,
+ args: {
+ ...Basic.args,
+ propValue: 'id',
+ propText: 'name',
+ options: [
+ { id: 'OBJ-123', name: 'The first object' },
+ { id: 'OBJ-456', name: 'The second object' },
+ { id: 'OBJ-789', name: 'The third object' },
+ ],
+ },
+};
+
+export const Events = {
+ name: 'Using v-model',
+ render: (args) => ({
+ setup() {
+ const selectedItem = ref('');
+ const setSelectedItem = (event) => {
+ selectedItem.value = event.detail[0];
+ };
+
+ return { args, selectedItem, setSelectedItem };
+ },
+ template: `
+
+
The current selected value is: {{ selectedItem }}
+
+
+ `,
+ }),
+ args: Basic.args,
+};
+
+export default {
+ title: 'Components/Select',
+ component: Select,
+ parameters: {
+ layout: 'centered',
+ },
+ argTypes: {
+ label: 'text',
+ modelValue: 'text',
+ hint: 'text',
+ propValue: 'text',
+ propText: 'text',
+ options: { control: 'array' },
+ },
+};
diff --git a/components/src/widgets/select/widget.spec.js b/components/src/widgets/select/widget.spec.js
new file mode 100644
index 00000000..652e08b4
--- /dev/null
+++ b/components/src/widgets/select/widget.spec.js
@@ -0,0 +1,74 @@
+import { mount } from '@vue/test-utils';
+import Select from './widget.vue';
+
+describe('Select', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = mount(Select, {
+ props: {
+ modelValue: '',
+ options: ['foo', 'bar', 'baz'],
+ label: 'My select',
+ hint: 'Some random hint',
+ },
+ });
+ });
+
+ describe('render', () => {
+ it('renders the base component', () => {
+ expect(wrapper.get('.select-input__label').text()).toEqual('My select');
+ expect(wrapper.get('.select-input__hint').text()).toEqual('Some random hint');
+ expect(wrapper.get('.select-input__no-selection').text()).toEqual('—');
+ });
+
+ it('renders a simple array of text elements', () => {
+ const menuOptions = wrapper.findAll('.select-input__option');
+
+ expect(menuOptions.length).toEqual(3);
+ expect(menuOptions[0].text()).toEqual('foo');
+ expect(menuOptions[1].text()).toEqual('bar');
+ expect(menuOptions[2].text()).toEqual('baz');
+ });
+
+ it('renders a complex array of objects', async () => {
+ await wrapper.setProps({
+ options: [
+ { id: '123', external_id: 'ext-123', name: 'Foo' },
+ { id: '456', external_id: 'ext-456', name: 'Bar' },
+ { id: '789', external_id: 'ext-789', name: 'Baz' },
+ ],
+ propValue: 'external_id',
+ propText: 'name',
+ });
+
+ const menuOptions = wrapper.findAll('.select-input__option');
+
+ expect(menuOptions.length).toEqual(3);
+ expect(menuOptions[0].text()).toEqual('Foo');
+ expect(menuOptions[1].text()).toEqual('Bar');
+ expect(menuOptions[2].text()).toEqual('Baz');
+ });
+ });
+
+ describe('events', () => {
+ describe('when an item is selected', () => {
+ beforeEach(async () => {
+ await wrapper.findAll('.select-input__option')[1].trigger('click');
+ });
+
+ it('renders the selected item', () => {
+ expect(wrapper.get('.select-input__option_selected').text()).toEqual('bar');
+ expect(wrapper.get('.select-input__selected').text()).toEqual('bar');
+ });
+
+ it('emits the update:modelValue event with the selected value', () => {
+ expect(wrapper.emitted('update:modelValue')[0]).toEqual(['bar']);
+ });
+
+ it('emits the valueChange event with the selected value', () => {
+ expect(wrapper.emitted('valueChange')[0]).toEqual(['bar']);
+ });
+ });
+ });
+});
diff --git a/components/src/widgets/select/widget.vue b/components/src/widgets/select/widget.vue
new file mode 100644
index 00000000..b6ef65bd
--- /dev/null
+++ b/components/src/widgets/select/widget.vue
@@ -0,0 +1,175 @@
+
+
+
+
+
+
+