mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Tags interface (#556)
* afirst draft * mtag adding works, chekcing for bugs * fixed it * readme * fixes * Finish tags Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
@@ -90,6 +90,8 @@ body {
|
||||
--v-chip-color-hover: var(--white);
|
||||
--v-chip-background-color-hover: var(--primary-125);
|
||||
--v-chip-close-color: var(--danger);
|
||||
--v-chip-close-color-disabled: var(--primary);
|
||||
--v-chip-close-color-hover: var(--primary-125);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -179,15 +181,15 @@ body {
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background-color: var(--v-chip-primary-close-color-disabled);
|
||||
background-color: var(--v-chip-close-color-disabled);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--v-chip-primary-close-color-disabled);
|
||||
background-color: var(--v-chip-close-color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--v-chip-primary-close-color-hover);
|
||||
background-color: var(--v-chip-close-color-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ export default defineComponent({
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
emit('keydown', event);
|
||||
}
|
||||
|
||||
function emitValue(event: InputEvent) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import InterfaceColor from './color';
|
||||
import InterfaceHash from './hash';
|
||||
import InterfaceSlug from './slug';
|
||||
import InterfaceUser from './user';
|
||||
import InterfaceTags from './tags';
|
||||
import InterfaceRepeater from './repeater';
|
||||
|
||||
export const interfaces = [
|
||||
@@ -43,6 +44,7 @@ export const interfaces = [
|
||||
InterfaceHash,
|
||||
InterfaceSlug,
|
||||
InterfaceUser,
|
||||
InterfaceTags,
|
||||
InterfaceRepeater,
|
||||
];
|
||||
|
||||
|
||||
53
src/interfaces/tags/index.ts
Normal file
53
src/interfaces/tags/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import InterfaceTags from './tags.vue';
|
||||
import { defineInterface } from '@/interfaces/define';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'tags',
|
||||
name: i18n.t('interfaces.tags.tags'),
|
||||
icon: 'local_offer',
|
||||
component: InterfaceTags,
|
||||
options: [
|
||||
{
|
||||
field: 'placeholder',
|
||||
name: i18n.t('placeholder'),
|
||||
width: 'half',
|
||||
interface: 'text-input',
|
||||
},
|
||||
{
|
||||
field: 'lowercase',
|
||||
name: i18n.t('lowercase'),
|
||||
width: 'half',
|
||||
interface: 'toggle',
|
||||
},
|
||||
{
|
||||
field: 'alphabetize',
|
||||
name: i18n.t('alphabetize'),
|
||||
width: 'half',
|
||||
interface: 'toggle',
|
||||
},
|
||||
{
|
||||
field: 'iconLeft',
|
||||
name: i18n.t('icon_left'),
|
||||
width: 'half',
|
||||
interface: 'icon',
|
||||
},
|
||||
{
|
||||
field: 'iconRight',
|
||||
name: i18n.t('icon_right'),
|
||||
width: 'half',
|
||||
interface: 'icon',
|
||||
},
|
||||
{
|
||||
field: 'presets',
|
||||
name: i18n.t('presets'),
|
||||
width: 'full',
|
||||
interface: 'text-input',
|
||||
},
|
||||
{
|
||||
field: 'allowCustom',
|
||||
name: i18n.t('allow_custom'),
|
||||
width: 'half',
|
||||
interface: 'toggle',
|
||||
},
|
||||
],
|
||||
}));
|
||||
14
src/interfaces/tags/readme.md
Normal file
14
src/interfaces/tags/readme.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Tags
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| ------------- | --------------------------------------------------- | ------------- |
|
||||
| `disabled` | Disabled | `false` |
|
||||
| `placeholder` | Text to show when no input is entered | `null` |
|
||||
| `lowercase` | Converts all tags to lowercase | `false` |
|
||||
| `alphabetize` | Alphabetizes tags, both in display and output | `false` |
|
||||
| `iconLeft` | Icon to appear on the inside left of the input box | `null` |
|
||||
| `iconRight` | Icon to appear on the inside right of the input box | `local_offer` |
|
||||
| `allowCustom` | Allows the user to input their own tags | `true` |
|
||||
| `presets` | List of preset values users can choose from | `[]` |
|
||||
135
src/interfaces/tags/tags.story.ts
Normal file
135
src/interfaces/tags/tags.story.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { withKnobs, boolean, text, array } from '@storybook/addon-knobs';
|
||||
import markdown from './readme.md';
|
||||
import withPadding from '../../../.storybook/decorators/with-padding';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { defineComponent, ref } from '@vue/composition-api';
|
||||
import RawValue from '../../../.storybook/raw-value.vue';
|
||||
import i18n from '@/lang';
|
||||
|
||||
export default {
|
||||
title: 'Interfaces / Tags',
|
||||
decorators: [withKnobs, withPadding],
|
||||
parameters: {
|
||||
notes: markdown,
|
||||
},
|
||||
};
|
||||
|
||||
export const basic = () =>
|
||||
defineComponent({
|
||||
i18n,
|
||||
components: { RawValue },
|
||||
props: {
|
||||
disabled: {
|
||||
default: boolean('Disabled', false, 'Options'),
|
||||
},
|
||||
presets: {
|
||||
default: array('Preset Values', [], ',', 'Options'),
|
||||
},
|
||||
value: {
|
||||
default: array('Value', [], ',', 'Options'),
|
||||
},
|
||||
allowCustom: {
|
||||
default: boolean('Allow Custom Values', true, 'Options'),
|
||||
},
|
||||
placeholder: {
|
||||
default: text(
|
||||
'Placeholder',
|
||||
'Click tags below, or add a new one here...',
|
||||
'Options'
|
||||
),
|
||||
},
|
||||
lowercase: {
|
||||
default: boolean('Lowercase', false, 'Options'),
|
||||
},
|
||||
alphabetize: {
|
||||
default: boolean('Alphabetize', false, 'Options'),
|
||||
},
|
||||
iconLeft: {
|
||||
default: text('Icon Left', '', 'Options'),
|
||||
},
|
||||
iconRight: {
|
||||
default: text('Icon Right', 'local_offer', 'Options'),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const onInput = action('input');
|
||||
return { onInput };
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<interface-tags
|
||||
v-model="value"
|
||||
v-bind="{ disabled, presets, allowCustom, placeholder, lowercase, alphabetize, iconLeft, iconRight }"
|
||||
@input="onInput"
|
||||
/>
|
||||
<raw-value>{{ value }}</raw-value>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
|
||||
export const withPresets = () =>
|
||||
defineComponent({
|
||||
i18n,
|
||||
components: { RawValue },
|
||||
props: {
|
||||
disabled: {
|
||||
default: boolean('Disabled', false, 'Options'),
|
||||
},
|
||||
presets: {
|
||||
default: array(
|
||||
'Preset Values',
|
||||
[
|
||||
'Healthy',
|
||||
'Mexican',
|
||||
'Barbeque',
|
||||
'Chinese',
|
||||
'International',
|
||||
'Sushi',
|
||||
'Pizza',
|
||||
'Burger',
|
||||
'Asian',
|
||||
'Fast Food',
|
||||
],
|
||||
',',
|
||||
'Options'
|
||||
),
|
||||
},
|
||||
allowCustom: {
|
||||
default: boolean('Allow Custom Values', true, 'Options'),
|
||||
},
|
||||
placeholder: {
|
||||
default: text(
|
||||
'Placeholder',
|
||||
'Click tags below, or add a new one here...',
|
||||
'Options'
|
||||
),
|
||||
},
|
||||
lowercase: {
|
||||
default: boolean('Lowercase', false, 'Options'),
|
||||
},
|
||||
alphabetize: {
|
||||
default: boolean('Alphabetize', false, 'Options'),
|
||||
},
|
||||
iconLeft: {
|
||||
default: text('Icon Left', '', 'Options'),
|
||||
},
|
||||
iconRight: {
|
||||
default: text('Icon Right', 'local_offer', 'Options'),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const onInput = action('input');
|
||||
const value = ref<string[]>(null);
|
||||
return { onInput, value };
|
||||
},
|
||||
template: `
|
||||
<div>
|
||||
<interface-tags
|
||||
v-model="value"
|
||||
v-bind="{ disabled, presets, allowCustom, placeholder, lowercase, alphabetize, iconLeft, iconRight }"
|
||||
@input="onInput"
|
||||
/>
|
||||
<raw-value>{{ value }}</raw-value>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
212
src/interfaces/tags/tags.vue
Normal file
212
src/interfaces/tags/tags.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="interface-tags">
|
||||
<v-input
|
||||
:placeholder="placeholder || $t('add_tags')"
|
||||
@keydown="onInput"
|
||||
:disabled="disabled"
|
||||
v-if="allowCustom"
|
||||
>
|
||||
<template #prepend><v-icon v-if="iconLeft" :name="iconLeft" /></template>
|
||||
<template #append><v-icon :name="iconRight" /></template>
|
||||
</v-input>
|
||||
<div class="tags">
|
||||
<span v-if="presetVals.length > 0" class="presets tag-container">
|
||||
<v-chip
|
||||
v-for="preset in presetVals"
|
||||
:class="['tag', { inactive: !selectedVals.includes(preset) }]"
|
||||
:key="preset"
|
||||
:disabled="disabled"
|
||||
small
|
||||
label
|
||||
@click="toggleTag(preset)"
|
||||
>
|
||||
{{ preset }}
|
||||
</v-chip>
|
||||
</span>
|
||||
<span v-if="customVals.length > 0 && allowCustom" class="custom tag-container">
|
||||
<v-icon name="chevron_right" />
|
||||
<v-chip
|
||||
v-for="val in customVals"
|
||||
:key="val"
|
||||
:disabled="disabled"
|
||||
class="tag"
|
||||
small
|
||||
label
|
||||
close
|
||||
@click="removeTag(val)"
|
||||
>
|
||||
{{ val }}
|
||||
</v-chip>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, ref, computed, watch } from '@vue/composition-api';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: null,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
lowercase: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
alphabetize: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
iconLeft: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
iconRight: {
|
||||
type: String,
|
||||
default: 'local_offer',
|
||||
},
|
||||
presets: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: [],
|
||||
},
|
||||
allowCustom: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const presetVals = computed<string[]>(() => {
|
||||
return processArray(props.presets ?? []);
|
||||
});
|
||||
|
||||
const selectedValsLocal = ref<string[]>(processArray(props.value ?? []));
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(newVal) => {
|
||||
if (Array.isArray(newVal)) {
|
||||
selectedValsLocal.value = processArray(newVal);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const selectedVals = computed<string[]>(() => {
|
||||
let vals = processArray(selectedValsLocal.value);
|
||||
if (!props.allowCustom) {
|
||||
vals = vals.filter((val) => presetVals.value.includes(val));
|
||||
}
|
||||
return vals;
|
||||
});
|
||||
|
||||
const customVals = computed<string[]>(() => {
|
||||
return selectedVals.value.filter((val) => !presetVals.value.includes(val));
|
||||
});
|
||||
|
||||
function processArray(array: string[]): string[] {
|
||||
array = array.map((val) => (props.lowercase ? val.toLowerCase().trim() : val.trim()));
|
||||
|
||||
if (props.alphabetize) {
|
||||
array = array.concat().sort();
|
||||
}
|
||||
array = [...new Set(array)];
|
||||
return array;
|
||||
}
|
||||
|
||||
return {
|
||||
onInput,
|
||||
addTag,
|
||||
removeTag,
|
||||
toggleTag,
|
||||
presetVals,
|
||||
customVals,
|
||||
selectedVals,
|
||||
};
|
||||
|
||||
function onInput(event: KeyboardEvent) {
|
||||
if (event.target && (event.key === 'Enter' || event.key === ',')) {
|
||||
event.preventDefault();
|
||||
addTag((event.target as HTMLInputElement).value);
|
||||
(event.target as HTMLInputElement).value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
selectedVals.value.includes(tag) ? removeTag(tag) : addTag(tag);
|
||||
}
|
||||
|
||||
function addTag(tag: string) {
|
||||
if (!tag || tag === '') return;
|
||||
// Remove any leading / trailing whitespace from the value
|
||||
tag = tag.trim();
|
||||
// Convert the tag to lowercase
|
||||
selectedValsLocal.value.push(tag);
|
||||
emitValue();
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
selectedValsLocal.value = selectedValsLocal.value.filter(
|
||||
(savedTag) => (props.lowercase ? savedTag.toLowerCase() : savedTag) !== tag
|
||||
);
|
||||
emitValue();
|
||||
}
|
||||
|
||||
function emitValue() {
|
||||
emit('input', selectedVals.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 14px 0px;
|
||||
|
||||
span.tag-container {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.tag + .tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.presets {
|
||||
.v-chip {
|
||||
&.inactive {
|
||||
--v-chip-background-color: var(--background-subdued);
|
||||
--v-chip-color: var(--foreground-subdued);
|
||||
--v-chip-background-color-hover: var(--background-normal);
|
||||
--v-chip-color-hover: var(--foreground-subdued);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom {
|
||||
.v-chip {
|
||||
--v-chip-background-color-hover: var(--danger);
|
||||
--v-chip-close-color: var(--v-chip-background-color);
|
||||
--v-chip-close-color-hover: var(--white);
|
||||
|
||||
&:hover {
|
||||
--v-chip-close-color: var(--white);
|
||||
::v-deep .chip-content .close-outline .close:hover {
|
||||
--v-icon-color: var(--danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -411,6 +411,7 @@
|
||||
|
||||
"always": "Always",
|
||||
"create": "Create",
|
||||
"add_tags": "Add tags...",
|
||||
"full": "All",
|
||||
"mine": "Mine Only",
|
||||
"on_create": "On Creation",
|
||||
|
||||
Reference in New Issue
Block a user