Skip to content

Commit

Permalink
Create autocomplete component
Browse files Browse the repository at this point in the history
  • Loading branch information
paulinaczybir committed Aug 13, 2024
1 parent e754480 commit 30dbad1
Show file tree
Hide file tree
Showing 7 changed files with 306 additions and 11 deletions.
1 change: 1 addition & 0 deletions components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export { default as Textarea } from '~widgets/textarea/widget.vue';
export { default as Alert } from '~widgets/alert/widget.vue';
export { default as Radio } from '~widgets/radio/widget.vue';
export { default as Dialog } from '~widgets/dialog/widget.vue';
export { default as Autocomplete } from '~widgets/autocomplete/widget.vue';

export { default as store } from '~core/store';
export { default as bus } from '~core/eventBus';
Expand Down
71 changes: 71 additions & 0 deletions components/src/stories/Autocomplete.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Autocomplete from '~widgets/autocomplete/widget.vue';
import registerWidget from '~core/registerWidget';

registerWidget('ui-autocomplete', Autocomplete);

export const Basic = {
name: 'Basic options',
render: (args) => ({
setup() {
return { args };
},
template: '<ui-autocomplete v-bind="args" style="width:400px;"></ui-autocomplete>',
}),

args: {
label: 'Label text',
options: ['Andorra', 'Peru', 'Poland', 'Spain', 'USA'],
},
};

export const Object = {
name: 'Array of objects in options',
render: Basic.render,
args: {
...Basic.args,
propValue: 'value',
propText: 'label',
options: [
{ value: 'AR', label: 'Argentina' },
{ value: 'AD', label: 'Andorra' },
{ value: 'PL', label: 'Poland' },
],
},
};

export const Validation = {
name: 'Input validation',
render: Basic.render,

args: {
...Basic.args,
label: 'Select input with validation',
hint: 'Select the second option if you want the validation to be successful',
propValue: 'id',
propText: 'name',
required: true,
options: [
{ id: 'OBJ-123', name: 'The first object' },
{ id: 'OBJ-456', name: 'The second object' },
{ id: 'OBJ-789', name: 'The third object' },
],
rules: [(value) => value === 'OBJ-456' || 'You picked the wrong option :( '],
},
};

export default {
title: 'Components/Autocomplete',
component: Autocomplete,
parameters: {
layout: 'centered',
},
argTypes: {
label: 'text',
modelValue: 'text',
hint: 'text',
propValue: 'text',
propText: 'text',
required: 'boolean',
options: { control: 'array' },
},
};
75 changes: 75 additions & 0 deletions components/src/widgets/autocomplete/widget.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { mount } from '@vue/test-utils';
import Autocomplete from './widget.vue';

describe('Autocomplete', () => {
let wrapper;

const options = [
{ id: '1', name: 'Andorra' },
{ id: '2', name: 'Poland' },
{ id: '3', name: 'Spain' },
];

beforeEach(() => {
wrapper = mount(Autocomplete, {
props: {
label: 'Countries',
options,
propText: 'name',
propValue: 'id',
required: true,
hint: 'Select a country',
},
});
});

describe('render', () => {
it('renders the base component', () => {
expect(wrapper.exists()).toBe(true);
expect(wrapper.find('.autocomplete').exists()).toBe(true);
expect(wrapper.find('ui-select').exists()).toBe(true);
expect(wrapper.find('ui-textfield').exists()).toBe(true);
});

it('filters options based on user input', async () => {
const textField = wrapper.find('ui-textfield');

await textField.trigger('input', { detail: ['an'] });

const filteredOptions = wrapper.vm.filteredOptions;

expect(filteredOptions.length).toBe(2);
expect(filteredOptions).toEqual([
{ id: '1', name: 'Andorra' },
{ id: '2', name: 'Poland' },
]);
});

it('updates selected value correctly', async () => {
const select = wrapper.find('ui-select');

await select.trigger('update:modelValue', { detail: ['some option'] });

const selected = wrapper.vm.selected;

expect(selected).toBe('some option');
});

it('clears the user input after selection', async () => {
const select = wrapper.find('ui-select');
const textfield = wrapper.find('ui-textfield');

await textfield.trigger('input', { detail: ['spa'] });

let userInput = wrapper.vm.userInput;

expect(userInput).toBe('spa');

await select.trigger('update:modelValue', { detail: ['Spain'] });

userInput = wrapper.vm.userInput;

expect(userInput).toBe('');
});
});
});
121 changes: 121 additions & 0 deletions components/src/widgets/autocomplete/widget.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<template>
<div class="autocomplete">
<ui-select
:options="filteredOptions"
:label="label"
:modelValue="selected"
:required="required"
:propText="propText"
:propValue="propValue"
:rules="rules"
:hint="hint"
@update:modelValue="updateSelected"

Check warning on line 12 in components/src/widgets/autocomplete/widget.vue

View workflow job for this annotation

GitHub Actions / Node 18

v-on event '@update:modelValue' must be hyphenated

Check warning on line 12 in components/src/widgets/autocomplete/widget.vue

View workflow job for this annotation

GitHub Actions / Node 20

v-on event '@update:modelValue' must be hyphenated

Check warning on line 12 in components/src/widgets/autocomplete/widget.vue

View workflow job for this annotation

GitHub Actions / Node 18

v-on event '@update:modelValue' must be hyphenated

Check warning on line 12 in components/src/widgets/autocomplete/widget.vue

View workflow job for this annotation

GitHub Actions / Node 20

v-on event '@update:modelValue' must be hyphenated
>
<div
slot="search-input"
class="autocomplete__search"
>
<ui-textfield
:value="userInput"
:noBorders="true"
:browserAutocomplete="false"
@input="onUserInput"
></ui-textfield>
</div>
</ui-select>
</div>
</template>

<script setup>
import { ref, computed } from 'vue';
import Select from '~widgets/select/widget.vue';
import TextField from '~widgets/textfield/widget.vue';
import registerWidget from '~core/registerWidget';
registerWidget('ui-select', Select);
registerWidget('ui-textfield', TextField);
const props = defineProps({
label: {
type: String,
default: '',
},
optionTextFn: {
type: Function,
default: null,
},
propText: {
type: String,
default: 'id',
},
propValue: {
type: String,
default: 'id',
},
required: {
type: Boolean,
default: false,
},
options: {
type: Array,
required: true,
},
rules: {
type: Array,
default: () => [],
},
hint: {
type: String,
default: '',
},
});
let userInput = ref('');
let selected = ref('');
const getOptionText = (option) => (typeof option === 'object' ? option[props.propText] : option);
const filteredOptions = computed(() => {
if (userInput.value === '') {
return props.options;
}
return props.options.filter((option) =>
getOptionText(option).toLowerCase().includes(userInput.value.toLowerCase()),
);
});
const onUserInput = (e) => {
userInput.value = e.detail[0];
};
const updateSelected = (e) => {
selected.value = e.detail[0];
userInput.value = '';
};
</script>

<style lang="stylus" scoped>
.autocomplete {
&__search {
color: inherit;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
line-height: 20px;
font-size: 14px;
border: 1px solid transparent;
border-left: none;
outline: none;
margin: 0;
background: none;
box-shadow: none;
width: 0;
max-width: 100%;
flex-grow: 1;
z-index: 1;
}
}
</style>
2 changes: 1 addition & 1 deletion components/src/widgets/select/widget.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('Select', () => {
it('renders the base component', () => {
expect(wrapper.get('.select-input__label').text()).toEqual('My select (Optional)');
expect(wrapper.get('.select-input__hint').text()).toEqual('Some random hint');
expect(wrapper.get('.select-input__no-selection').text()).toEqual('');
expect(wrapper.get('.select-input__no-selection').text()).toEqual('');
});

it('renders a simple array of text elements', () => {
Expand Down
31 changes: 21 additions & 10 deletions components/src/widgets/select/widget.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@
<span
v-else
class="select-input__no-selection"
>
</span>
></span>
</slot>
<slot name="search-input"></slot>
<ui-icon
iconName="googleArrowDropDownBaseline"
color="#666666"
Expand All @@ -50,6 +49,12 @@
>
<span>{{ getDisplayText(option) }}</span>
</div>
<span
v-if="computedOptionsAreEmpty"
class="select-input__option select-input__empty-option"
>
<span>{{ noDataText }}</span>
</span>
</div>
</ui-menu>
<div
Expand Down Expand Up @@ -125,13 +130,18 @@ const props = defineProps({
type: Boolean,
default: false,
},
noDataText: {
type: String,
default: 'Nothing was found',
},
});
const emit = defineEmits(['valueChange']);
const { isValid, errorMessagesString } = useFieldValidation(model, props.rules);
const isFocused = ref(false);
const selectedOption = ref(null);
const computedClasses = computed(() => ({
'select-input_focused': isFocused.value,
Expand All @@ -145,11 +155,10 @@ const computedOptions = computed(() =>
}),
);
const selectedOption = computed(() =>
computedOptions.value.find((option) => option[props.propValue] === model.value),
);
const computedOptionsAreEmpty = computed(() => computedOptions.value.length === 0);
const setSelected = (option) => {
selectedOption.value = option;
const value = option[props.propValue];
model.value = value;
emit('valueChange', value);
Expand All @@ -164,6 +173,7 @@ const getDisplayText = (item) => {
<style lang="stylus" scoped>
.select-input {
color: #212121;
font-size: 14px;
&__selected {
height: 44px;
Expand All @@ -184,9 +194,6 @@ const getDisplayText = (item) => {
.select-input_invalid & {
border-color: #FF6A6A;
}
.select-input_focused.select-input_invalid & {
outline: 1px solid #FF6A6A;
}
}
Expand All @@ -195,7 +202,7 @@ const getDisplayText = (item) => {
position: relative;
overflow: hidden auto;
z-index: 1;
padding: 16px 0;
padding: 8px 0;
border: 1px solid #d8d8d8;
border-radius: 2px;
background-color: #fbfbfb;
Expand All @@ -216,6 +223,10 @@ const getDisplayText = (item) => {
}
}
&__empty-option {
color: #BDBDBD;
}
&__hint {
margin-top: 4px;
Expand Down
Loading

0 comments on commit 30dbad1

Please sign in to comment.