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:
Jacob Rienstra
2020-05-13 19:31:30 -04:00
committed by GitHub
parent 536967553c
commit c222e2123d
8 changed files with 423 additions and 3 deletions

View File

@@ -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);
}
}
}

View File

@@ -170,6 +170,7 @@ export default defineComponent({
event.preventDefault();
}
}
emit('keydown', event);
}
function emitValue(event: InputEvent) {

View File

@@ -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,
];

View 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',
},
],
}));

View 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 | `[]` |

View 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>
`,
});

View 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>

View File

@@ -411,6 +411,7 @@
"always": "Always",
"create": "Create",
"add_tags": "Add tags...",
"full": "All",
"mine": "Mine Only",
"on_create": "On Creation",