mirror of
https://github.com/directus/directus.git
synced 2026-04-03 03:00:39 -04:00
Add v-select component (#262)
This commit is contained in:
38
.storybook/raw-value.vue
Normal file
38
.storybook/raw-value.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template functional>
|
||||
<div class="raw-value">
|
||||
<div class="label">{{ props.label }}</div>
|
||||
<pre><slot /></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Value:',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.raw-value {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
pre {
|
||||
max-width: max-content;
|
||||
padding: 0.5rem;
|
||||
font-family: monospace;
|
||||
background-color: #eee;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -56,7 +56,7 @@ export function usePopper(
|
||||
{
|
||||
...offset,
|
||||
options: {
|
||||
offset: options.value.attached ? [0, 0] : [0, 8],
|
||||
offset: options.value.attached ? [0, -2] : [0, 8],
|
||||
},
|
||||
},
|
||||
computeStyles,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<div
|
||||
ref="popper"
|
||||
class="v-menu-popper"
|
||||
:class="{ active: isActive }"
|
||||
:class="{ active: isActive, attached }"
|
||||
:data-placement="popperPlacement"
|
||||
:style="styles"
|
||||
>
|
||||
@@ -276,5 +276,12 @@ export default defineComponent({
|
||||
transition-timing-function: cubic-bezier(0, 0, 0.2, 1.5);
|
||||
transition-duration: var(--medium);
|
||||
}
|
||||
|
||||
&.attached {
|
||||
.v-menu-content {
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
4
src/components/v-select/index.ts
Normal file
4
src/components/v-select/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VSelect from './v-select.vue';
|
||||
|
||||
export { VSelect };
|
||||
export default VSelect;
|
||||
44
src/components/v-select/readme.md
Normal file
44
src/components/v-select/readme.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Select
|
||||
|
||||
Renders a dropdown input.
|
||||
|
||||
## Usage
|
||||
|
||||
```html
|
||||
<v-select
|
||||
v-model="value"
|
||||
:items="[
|
||||
{
|
||||
text: 'Item 1',
|
||||
value: 'item-1',
|
||||
},
|
||||
{
|
||||
text: 'Item 2',
|
||||
value: 'item-2',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Description | Default |
|
||||
|---------------|-----------------------------------------------------|---------|
|
||||
| `items`* | Items to render in the select | |
|
||||
| `itemText` | What item value to use for the display text | `text` |
|
||||
| `itemValue` | What item value to use for the item value | `value` |
|
||||
| `value` | Currently selected item(s) | |
|
||||
| `multiple` | Allow multiple items to be selected | `false` |
|
||||
| `placeholder` | What placeholder to show when no items are selected | |
|
||||
|
||||
|
||||
## Events
|
||||
| Event | Description | Value |
|
||||
|---------|--------------------------|-----------------------------------------|
|
||||
| `input` | New value for the select | `(string | number)[] | string | number` |
|
||||
|
||||
## Slots
|
||||
n/a
|
||||
|
||||
## CSS Variables
|
||||
n/a
|
||||
61
src/components/v-select/v-select.story.ts
Normal file
61
src/components/v-select/v-select.story.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import readme from './readme.md';
|
||||
import { defineComponent, ref } from '@vue/composition-api';
|
||||
import withPadding from '../../../.storybook/decorators/with-padding';
|
||||
import { withKnobs, array, text } from '@storybook/addon-knobs';
|
||||
import RawValue from '../../../.storybook/raw-value.vue';
|
||||
|
||||
import VSelect from './v-select.vue';
|
||||
|
||||
export default {
|
||||
title: 'Components / Select',
|
||||
parameters: {
|
||||
notes: readme,
|
||||
},
|
||||
decorators: [withKnobs, withPadding],
|
||||
};
|
||||
|
||||
export const basic = () =>
|
||||
defineComponent({
|
||||
components: { VSelect, RawValue },
|
||||
props: {
|
||||
items: {
|
||||
default: array('Items', ['Item 1', 'Item 2', 'Item 3']),
|
||||
},
|
||||
placeholder: {
|
||||
default: text('Placeholder', 'Enter value...'),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const value = ref(null);
|
||||
return { value };
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<v-select :placeholder="placeholder" v-model="value" :items="items" />
|
||||
<raw-value>{{ value }}</raw-value>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
export const multiple = () =>
|
||||
defineComponent({
|
||||
components: { VSelect, RawValue },
|
||||
props: {
|
||||
items: {
|
||||
default: array('Items', ['Item 1', 'Item 2', 'Item 3']),
|
||||
},
|
||||
placeholder: {
|
||||
default: text('Placeholder', 'Enter value...'),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const value = ref(null);
|
||||
return { value };
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<v-select :placeholder="placeholder" v-model="value" :items="items" multiple />
|
||||
<raw-value>{{ value }}</raw-value>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
134
src/components/v-select/v-select.test.ts
Normal file
134
src/components/v-select/v-select.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import VSelect from './v-select.vue';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
|
||||
import VMenu from '@/components/v-menu/';
|
||||
import VList, { VListItem, VListItemContent, VListItemTitle } from '@/components/v-list/';
|
||||
import VCheckbox from '@/components/v-checkbox/';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('v-menu', VMenu);
|
||||
localVue.component('v-list', VList);
|
||||
localVue.component('v-list-item', VListItem);
|
||||
localVue.component('v-list-item-content', VListItemContent);
|
||||
localVue.component('v-list-item-title', VListItemTitle);
|
||||
localVue.component('v-checkbox', VCheckbox);
|
||||
|
||||
describe('Components / Select', () => {
|
||||
it('Renders', () => {
|
||||
const component = shallowMount(VSelect, {
|
||||
localVue,
|
||||
propsData: {
|
||||
items: [
|
||||
{ text: 'test', value: 1 },
|
||||
{ text: 'test', value: 2 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(component.isVueInstance()).toBe(true);
|
||||
});
|
||||
|
||||
it('Converts the items to a standardized array', async () => {
|
||||
const component = shallowMount(VSelect, {
|
||||
localVue,
|
||||
propsData: {
|
||||
items: ['Item 1', 'Item 2'],
|
||||
},
|
||||
});
|
||||
|
||||
expect((component.vm as any)._items).toEqual([
|
||||
{
|
||||
text: 'Item 1',
|
||||
value: 'Item 1',
|
||||
},
|
||||
{
|
||||
text: 'Item 2',
|
||||
value: 'Item 2',
|
||||
},
|
||||
]);
|
||||
|
||||
component.setProps({
|
||||
items: [
|
||||
{
|
||||
test: 'item 1',
|
||||
another: 'value',
|
||||
},
|
||||
],
|
||||
itemText: 'test',
|
||||
itemValue: 'another',
|
||||
});
|
||||
|
||||
await component.vm.$nextTick();
|
||||
|
||||
expect((component.vm as any)._items).toEqual([
|
||||
{
|
||||
text: 'item 1',
|
||||
value: 'value',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('Calculates the displayValue based on the given value', async () => {
|
||||
const component = shallowMount(VSelect, {
|
||||
localVue,
|
||||
propsData: {
|
||||
items: ['Item 1', 'Item 2'],
|
||||
value: 'Item 1',
|
||||
},
|
||||
});
|
||||
|
||||
expect((component.vm as any).displayValue).toBe('Item 1');
|
||||
|
||||
component.setProps({
|
||||
items: [
|
||||
{
|
||||
text: 'Item 1',
|
||||
value: 'item-1',
|
||||
},
|
||||
],
|
||||
value: 'item-1',
|
||||
});
|
||||
|
||||
await component.vm.$nextTick();
|
||||
|
||||
expect((component.vm as any).displayValue).toBe('Item 1');
|
||||
|
||||
component.setProps({
|
||||
itemText: 'test',
|
||||
itemValue: 'test2',
|
||||
items: [
|
||||
{
|
||||
test: 'Item 1',
|
||||
test2: 'item-1',
|
||||
},
|
||||
],
|
||||
value: 'item-1',
|
||||
});
|
||||
|
||||
await component.vm.$nextTick();
|
||||
|
||||
expect((component.vm as any).displayValue).toBe('Item 1');
|
||||
|
||||
component.setProps({
|
||||
itemText: 'text',
|
||||
itemValue: 'value',
|
||||
items: [
|
||||
{
|
||||
text: 'Item 1',
|
||||
value: 'item-1',
|
||||
},
|
||||
{
|
||||
text: 'Item 2',
|
||||
value: 'item-2',
|
||||
},
|
||||
],
|
||||
multiple: true,
|
||||
value: ['item-1', 'item-2'],
|
||||
});
|
||||
|
||||
await component.vm.$nextTick();
|
||||
|
||||
expect((component.vm as any).displayValue).toBe('Item 1, Item 2');
|
||||
});
|
||||
});
|
||||
108
src/components/v-select/v-select.vue
Normal file
108
src/components/v-select/v-select.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<v-menu class="v-select" attached :close-on-content-click="multiple === false">
|
||||
<template #activator="{ toggle }">
|
||||
<v-input readonly :value="displayValue" @click="toggle" :placeholder="placeholder">
|
||||
<template #append><v-icon name="expand_more" /></template>
|
||||
</v-input>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="item in _items"
|
||||
:key="item.value"
|
||||
:class="{
|
||||
active: multiple ? (value || []).includes(item.value) : value === item.value,
|
||||
}"
|
||||
@click="multiple ? null : $emit('input', item.value)"
|
||||
>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title v-if="multiple === false">{{ item.text }}</v-list-item-title>
|
||||
<v-checkbox
|
||||
v-else
|
||||
:inputValue="value || []"
|
||||
:label="item.text"
|
||||
:value="item.value"
|
||||
@change="$emit('input', $event.length > 0 ? $event : null)"
|
||||
/>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
|
||||
type Item = {
|
||||
text: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type ItemsRaw = (string | any)[];
|
||||
type InputValue = (string | number)[] | string | number;
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<ItemsRaw>,
|
||||
required: true,
|
||||
},
|
||||
itemText: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
itemValue: {
|
||||
type: String,
|
||||
default: 'value',
|
||||
},
|
||||
value: {
|
||||
type: [Array, String, Number] as PropType<InputValue>,
|
||||
default: null,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const _items = computed(() =>
|
||||
props.items.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
text: item,
|
||||
value: item,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: item[props.itemText],
|
||||
value: item[props.itemValue],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const displayValue = computed(() => {
|
||||
if (Array.isArray(props.value)) {
|
||||
return props.value
|
||||
.map((value) => {
|
||||
return getTextForValue(value);
|
||||
})
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
return getTextForValue(props.value);
|
||||
});
|
||||
|
||||
return { _items, displayValue };
|
||||
|
||||
function getTextForValue(value: string | number) {
|
||||
return _items.value.find((item) => item.value === value)?.['text'];
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user