Skip to content
Commits on Source (5)
# [42.6.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v42.5.0...v42.6.0) (2022-06-22)
### Features
* **GlFormCombobox:** Support Object values ([8632aae](https://gitlab.com/gitlab-org/gitlab-ui/commit/8632aae819692db6ef0e952715147f1c5a71126a))
# [42.5.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v42.4.1...v42.5.0) (2022-06-20)
......
{
"name": "@gitlab/ui",
"version": "42.5.0",
"version": "42.6.0",
"description": "GitLab UI Components",
"license": "MIT",
"main": "dist/index.js",
......
export const tokenList = [
export const stringTokenList = [
'giraffe',
'dog',
'dodo',
......@@ -14,3 +14,18 @@ export const tokenList = [
];
export const labelText = 'Animals We Tolerate';
export const objectTokenList = [
{ id: '1', title: 'giraffe' },
{ id: '2', title: 'dog' },
{ id: '3', title: 'dodo' },
{ id: '4', title: 'komodo dragon' },
{ id: '5', title: 'hippo' },
{ id: '6', title: 'platypus' },
{ id: '7', title: 'jackalope' },
{ id: '8', title: 'quetzal' },
{ id: '9', title: 'badger' },
{ id: '10', title: 'vicuña' },
{ id: '11', title: 'whale' },
{ id: '12', title: 'xenarthra' },
];
......@@ -5,6 +5,7 @@
.show-dropdown {
@include gl-display-block;
max-height: $gl-max-dropdown-max-height;
}
.highlight-dropdown {
......
import { mount } from '@vue/test-utils';
import GlDropdownItem from '../../dropdown/dropdown_item.vue';
import GlFormInput from '../form_input/form_input.vue';
import { tokenList, labelText } from './constants';
import { stringTokenList, labelText, objectTokenList } from './constants';
import GlFormCombobox from './form_combobox.vue';
const partialToken = 'do';
const partialTokenMatch = ['dog', 'dodo', 'komodo dragon'];
const partialStringTokenMatch = ['dog', 'dodo', 'komodo dragon'];
const partialObjectTokenMatch = [
{ id: '2', title: 'dog' },
{ id: '3', title: 'dodo' },
{ id: '4', title: 'komodo dragon' },
];
const unlistedToken = 'elephant';
const doTimes = (num, fn) => {
......@@ -17,13 +22,14 @@ const doTimes = (num, fn) => {
describe('GlFormCombobox', () => {
let wrapper;
const createComponent = () => {
const createComponent = ({ tokens = stringTokenList, matchValueToAttr = undefined } = {}) => {
wrapper = mount({
data() {
return {
inputVal: '',
tokens: tokenList,
tokens,
labelText,
matchValueToAttr,
};
},
components: { GlFormCombobox },
......@@ -32,7 +38,8 @@ describe('GlFormCombobox', () => {
<gl-form-combobox
v-model="inputVal"
:token-list="tokens"
:labelText="labelText"
:label-text="labelText"
:match-value-to-attr="matchValueToAttr"
/>
</div>
`,
......@@ -48,123 +55,163 @@ describe('GlFormCombobox', () => {
const setInput = (val) => findInput().setValue(val);
const arrowDown = () => findInput().trigger('keydown.down');
describe('match and filter functionality', () => {
beforeEach(() => {
createComponent();
});
it('is closed when the input is empty', () => {
expect(findInput().isVisible()).toBe(true);
expect(findInputValue()).toBe('');
expect(findDropdown().isVisible()).toBe(false);
});
it('is open when the input text matches a token', async () => {
await setInput(partialToken);
expect(findDropdown().isVisible()).toBe(true);
});
it('shows partial matches at string start and mid-string', async () => {
await setInput(partialToken);
expect(findDropdown().isVisible()).toBe(true);
expect(findDropdownOptions()).toEqual(partialTokenMatch);
});
it('is closed when the text does not match', async () => {
await setInput(unlistedToken);
expect(findDropdown().isVisible()).toBe(false);
});
});
describe.each`
valueType | tokens | matchValueToAttr | partialTokenMatch
${'string'} | ${stringTokenList} | ${undefined} | ${partialStringTokenMatch}
${'object'} | ${objectTokenList} | ${'title'} | ${partialObjectTokenMatch}
`('with value as $valueType', ({ valueType, tokens, matchValueToAttr, partialTokenMatch }) => {
describe('match and filter functionality', () => {
beforeEach(() => {
createComponent({ tokens, matchValueToAttr });
});
describe('keyboard navigation in dropdown', () => {
beforeEach(() => {
createComponent();
});
it('is closed when the input is empty', () => {
expect(findInput().isVisible()).toBe(true);
expect(findInputValue()).toBe('');
expect(findDropdown().isVisible()).toBe(false);
});
describe('on down arrow + enter', () => {
it('selects the next item in the list and closes the dropdown', async () => {
it('is open when the input text matches a token', async () => {
await setInput(partialToken);
findInput().trigger('keydown.down');
await findInput().trigger('keydown.enter');
expect(findInputValue()).toBe(partialTokenMatch[0]);
expect(findDropdown().isVisible()).toBe(true);
});
it('loops to the top when it reaches the bottom', async () => {
it('shows partial matches at string start and mid-string', async () => {
await setInput(partialToken);
doTimes(findDropdownOptions().length + 1, arrowDown);
await findInput().trigger('keydown.enter');
expect(findInputValue()).toBe(partialTokenMatch[0]);
expect(findDropdown().isVisible()).toBe(true);
if (valueType === 'string') {
expect(findDropdownOptions()).toEqual(partialTokenMatch);
} else {
findDropdownOptions().forEach((option, index) => {
expect(option).toContain(partialTokenMatch[index][matchValueToAttr]);
});
}
});
it('is closed when the text does not match', async () => {
await setInput(unlistedToken);
expect(findDropdown().isVisible()).toBe(false);
});
});
describe('on up arrow + enter', () => {
it('selects the previous item in the list and closes the dropdown', async () => {
setInput(partialToken);
describe('keyboard navigation in dropdown', () => {
beforeEach(() => {
createComponent({ tokens, matchValueToAttr });
});
await wrapper.vm.$nextTick();
doTimes(3, arrowDown);
findInput().trigger('keydown.up');
findInput().trigger('keydown.enter');
describe('on down arrow + enter', () => {
it('selects the next item in the list and closes the dropdown', async () => {
await setInput(partialToken);
findInput().trigger('keydown.down');
await findInput().trigger('keydown.enter');
if (valueType === 'string') {
expect(findInputValue()).toBe(partialTokenMatch[0]);
} else {
expect(findInputValue()).toBe(partialTokenMatch[0][matchValueToAttr]);
}
});
await wrapper.vm.$nextTick();
expect(findInputValue()).toBe(partialTokenMatch[1]);
expect(findDropdown().isVisible()).toBe(false);
it('loops to the top when it reaches the bottom', async () => {
await setInput(partialToken);
doTimes(findDropdownOptions().length + 1, arrowDown);
await findInput().trigger('keydown.enter');
if (valueType === 'string') {
expect(findInputValue()).toBe(partialTokenMatch[0]);
} else {
expect(findInputValue()).toBe(partialTokenMatch[0][matchValueToAttr]);
}
});
});
it('loops to the bottom when it reaches the top', async () => {
await setInput(partialToken);
findInput().trigger('keydown.down');
findInput().trigger('keydown.up');
await findInput().trigger('keydown.enter');
expect(findInputValue()).toBe(partialTokenMatch[partialTokenMatch.length - 1]);
});
});
describe('on up arrow + enter', () => {
it('selects the previous item in the list and closes the dropdown', async () => {
setInput(partialToken);
describe('on enter with no item highlighted', () => {
it('does not select any item and closes the dropdown', async () => {
await setInput(partialToken);
await findInput().trigger('keydown.enter');
expect(findInputValue()).toBe(partialToken);
expect(findDropdown().isVisible()).toBe(false);
await wrapper.vm.$nextTick();
doTimes(3, arrowDown);
findInput().trigger('keydown.up');
findInput().trigger('keydown.enter');
await wrapper.vm.$nextTick();
if (valueType === 'string') {
expect(findInputValue()).toBe(partialTokenMatch[1]);
} else {
expect(findInputValue()).toBe(partialTokenMatch[1][matchValueToAttr]);
}
expect(findDropdown().isVisible()).toBe(false);
});
it('loops to the bottom when it reaches the top', async () => {
await setInput(partialToken);
findInput().trigger('keydown.down');
findInput().trigger('keydown.up');
await findInput().trigger('keydown.enter');
if (valueType === 'string') {
expect(findInputValue()).toBe(partialTokenMatch[partialTokenMatch.length - 1]);
} else {
expect(findInputValue()).toBe(
partialTokenMatch[partialTokenMatch.length - 1][matchValueToAttr]
);
}
});
});
});
describe('on click', () => {
it('selects the clicked item regardless of arrow highlight', async () => {
await setInput(partialToken);
await wrapper.find('[data-testid="combobox-dropdown"] button').trigger('click');
expect(findInputValue()).toBe(partialTokenMatch[0]);
describe('on enter with no item highlighted', () => {
it('does not select any item and closes the dropdown', async () => {
await setInput(partialToken);
await findInput().trigger('keydown.enter');
expect(findInputValue()).toBe(partialToken);
expect(findDropdown().isVisible()).toBe(false);
});
});
});
describe('on tab', () => {
it('selects entered text, closes dropdown', async () => {
await setInput(partialToken);
findInput().trigger('keydown.tab');
doTimes(2, arrowDown);
describe('on click', () => {
it('selects the clicked item regardless of arrow highlight', async () => {
await setInput(partialToken);
await wrapper.find('[data-testid="combobox-dropdown"] button').trigger('click');
await wrapper.vm.$nextTick();
expect(findInputValue()).toBe(partialToken);
expect(findDropdown().isVisible()).toBe(false);
if (valueType === 'string') {
expect(findInputValue()).toBe(partialTokenMatch[0]);
} else {
expect(findInputValue()).toBe(partialTokenMatch[0][matchValueToAttr]);
}
});
});
});
describe('on esc', () => {
describe('when dropdown is open', () => {
it('closes dropdown and does not select anything', async () => {
describe('on tab', () => {
it('selects entered text, closes dropdown', async () => {
await setInput(partialToken);
await findInput().trigger('keydown.esc');
findInput().trigger('keydown.tab');
doTimes(2, arrowDown);
await wrapper.vm.$nextTick();
expect(findInputValue()).toBe(partialToken);
expect(findDropdown().isVisible()).toBe(false);
});
});
describe('when dropdown is closed', () => {
it('clears the input field', async () => {
await setInput(unlistedToken);
expect(findDropdown().isVisible()).toBe(false);
await findInput().trigger('keydown.esc');
expect(findInputValue()).toBe('');
describe('on esc', () => {
describe('when dropdown is open', () => {
it('closes dropdown and does not select anything', async () => {
await setInput(partialToken);
await findInput().trigger('keydown.esc');
expect(findInputValue()).toBe(partialToken);
expect(findDropdown().isVisible()).toBe(false);
});
});
describe('when dropdown is closed', () => {
it('clears the input field', async () => {
await setInput(unlistedToken);
expect(findDropdown().isVisible()).toBe(false);
await findInput().trigger('keydown.esc');
expect(findInputValue()).toBe('');
});
});
});
});
......
import { tokenList, labelText } from './constants';
import { stringTokenList, labelText, objectTokenList } from './constants';
import readme from './form_combobox.md';
import GlFormCombobox from './form_combobox.vue';
const getProps = () => ({
const template = `
<gl-form-combobox
v-model="value"
:token-list="tokenList"
:label-text="labelText"
:match-value-to-attr="matchValueToAttr"
/>`;
const generateProps = ({ tokenList = stringTokenList, matchValueToAttr = undefined } = {}) => ({
tokenList,
labelText,
matchValueToAttr,
});
const Template = (args) => ({
......@@ -15,17 +24,40 @@ const Template = (args) => ({
};
},
props: Object.keys(args),
template: `
<gl-form-combobox
v-model="value"
:token-list="tokenList"
:labelText="labelText"
/>
`,
template,
});
export const Default = Template.bind({});
Default.args = getProps();
Default.args = generateProps();
export const WithObjectValue = (args, { argTypes }) => ({
components: { GlFormCombobox },
props: Object.keys(argTypes),
mounted() {
document.querySelector('.gl-form-input').focus();
},
data: () => {
return {
value: '',
};
},
template: `
<gl-form-combobox
v-model="value"
:token-list="tokenList"
:label-text="labelText"
:match-value-to-attr="matchValueToAttr"
>
<template #result="{ item }">
<div class="gl-display-flex">
<div class="gl-text-gray-400 gl-mr-4">{{ item.id }}</div>
<div>{{ item.title }}</div>
</div>
</template>
</gl-form-combobox>
`,
});
WithObjectValue.args = generateProps({ tokenList: objectTokenList, matchValueToAttr: 'title' });
export default {
title: 'base/form/form-combobox',
......
......@@ -31,9 +31,19 @@ export default {
required: true,
},
value: {
type: String,
type: [String, Object],
required: true,
},
matchValueToAttr: {
type: String,
required: false,
default: undefined,
},
autofocus: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
......@@ -54,6 +64,11 @@ export default {
showSuggestions() {
return this.results.length > 0;
},
displayedValue() {
return this.matchValueToAttr && this.value[this.matchValueToAttr]
? this.value[this.matchValueToAttr]
: this.value;
},
},
mounted() {
document.addEventListener('click', this.handleClickOutside);
......@@ -112,9 +127,12 @@ export default {
return;
}
const filteredTokens = this.tokenList.filter((token) =>
token.toLowerCase().includes(value.toLowerCase())
);
const filteredTokens = this.tokenList.filter((token) => {
if (this.matchValueToAttr) {
return token[this.matchValueToAttr].toLowerCase().includes(value.toLowerCase());
}
return token.toLowerCase().includes(value.toLowerCase());
});
if (filteredTokens.length) {
this.openSuggestions(filteredTokens);
......@@ -147,13 +165,14 @@ export default {
<gl-form-group :label="labelText" :label-for="inputId" :label-sr-only="labelSrOnly">
<gl-form-input
:id="inputId"
:value="value"
:value="displayedValue"
type="text"
role="searchbox"
:autocomplete="showAutocomplete"
aria-autocomplete="list"
:aria-controls="suggestionsId"
aria-haspopup="listbox"
:autofocus="autofocus"
@input="onEntry"
@keydown.down="onArrowDown"
@keydown.up="onArrowUp"
......@@ -163,11 +182,11 @@ export default {
/>
</gl-form-group>
<div
<ul
v-show="showSuggestions && !userDismissedResults"
:id="suggestionsId"
data-testid="combobox-dropdown"
class="dropdown-menu dropdown-full-width"
class="dropdown-menu dropdown-full-width gl-list-style-none gl-pl-0 gl-mb-0 gl-overflow-y-auto"
:class="{ 'show-dropdown': showSuggestions }"
>
<gl-dropdown-item
......@@ -179,8 +198,8 @@ export default {
tabindex="-1"
@click="selectToken(result)"
>
{{ result }}
<slot name="result" :item="result">{{ result }}</slot>
</gl-dropdown-item>
</div>
</ul>
</div>
</template>