795 lines
19 KiB
Vue
795 lines
19 KiB
Vue
<template>
|
|
<div class="ui-pick" :class="{'is-disabled': disabled, 'is-combined': configuration.preview.combined }">
|
|
|
|
<!-- previews -->
|
|
<div class="ui-pick-previews" v-if="configuration.preview.enabled && previews.length > 0 && !configuration.preview.combined" v-sortable="{ onUpdate: onSortingUpdated, enabled: configuration.sortable }">
|
|
<div v-for="preview in previews" :key="preview[configuration.keys.id]" class="ui-pick-preview">
|
|
<ui-select-button :icon="getPreviewIcon(preview)" :icon-as-image="configuration.preview.iconAsImage" :label="preview[configuration.keys.name]" :description="getPreviewDescription(preview)" :disabled="disabled" @click="pick(preview[configuration.keys.id])" :tokens="preview" />
|
|
<ui-icon-button v-if="!disabled && configuration.preview.delete" @click="remove(preview[configuration.keys.id])" icon="fth-x" title="@ui.close" :size="14" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- combined preview -->
|
|
<div class="ui-pick-previews" v-if="configuration.preview.enabled && configuration.preview.combined">
|
|
<div class="ui-pick-preview">
|
|
<ui-select-button icon="fth-check-circle" :label="combinedTitle" :disabled="disabled" @click="pick()" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- add button -->
|
|
<div class="ui-pick-add" v-if="canAdd && configuration.addButton.enabled && !configuration.preview.combined">
|
|
<slot name="add">
|
|
<ui-select-button icon="fth-plus" :label="configuration.addButton.label" @click="pick()" :disabled="disabled" />
|
|
</slot>
|
|
</div>
|
|
|
|
<!-- overlay -->
|
|
<ui-dropdown ref="overlay" class="ui-pick-overlay" @opened="overlayOpened" :theme="configuration.theme">
|
|
|
|
<!-- headline -->
|
|
<div class="ui-pick-overlay-head">
|
|
<div class="ui-pick-overlay-head-title">
|
|
<span class="-name" v-localize="title"></span>
|
|
<span class="-max" v-if="configuration.limit > 1" v-localize="{ key: '@ui.pick.max', tokens: { count: configuration.limit }}"></span>
|
|
</div>
|
|
<ui-icon-button @click="hide" icon="fth-x" title="@ui.close" />
|
|
</div>
|
|
|
|
<!-- search -->
|
|
<ui-search v-if="configuration.search.enabled" ref="search" class="ui-pick-overlay-search" :value="searchValue" @input="onSearch" @submit="onSearchSubmit">
|
|
<template v-slot:button="search" v-if="configuration.autocomplete">
|
|
<button type="button" class="ui-searchinput-button" v-localize:title="'@ui.search.button'" @click="search.onSubmit"><ui-icon symbol="fth-check"></ui-icon></button>
|
|
</template>
|
|
</ui-search>
|
|
|
|
<!-- items -->
|
|
<div class="ui-pick-overlay-items">
|
|
<button v-for="item in items" :key="item[configuration.keys.id]" type="button" class="ui-pick-overlay-item" @click="select(item)" :class="{'is-selected': isSelected(item) }">
|
|
<ui-icon v-if="item[configuration.keys.icon] && !configuration.list.iconAsImage" class="-icon" :symbol="item[configuration.keys.icon]" />
|
|
<span v-else="item[configuration.keys.icon]" class="-image">
|
|
<ui-thumbnail :media="item[configuration.keys.icon]" :alt="item[configuration.keys.name]" />
|
|
</span>
|
|
<div class="ui-pick-overlay-item-title">
|
|
<span class="-name" v-localize="item[configuration.keys.name]"></span>
|
|
<span v-if="item[configuration.keys.description] && configuration.list.description" class="-text" v-localize="item[configuration.keys.description]"></span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- loading + empty states -->
|
|
<div class="ui-pick-overlay-center" v-if="isLoading || (!configuration.autocomplete && !items.length)">
|
|
<div v-if="!isLoading && !configuration.autocomplete && !items.length" class="ui-pick-overlay-message">
|
|
<ui-icon class="ui-pick-overlay-message-icon" symbol="fth-list" :size="18" />
|
|
No items found
|
|
</div>
|
|
<ui-loading v-if="isLoading" />
|
|
</div>
|
|
</ui-dropdown>
|
|
</div>
|
|
</template>
|
|
|
|
|
|
<script lang="ts">
|
|
import { arrayMove, debounce, extendObject } from '../utils';
|
|
|
|
const defaultConfig = {
|
|
// picker items, can either be a static list or a promise
|
|
items: [],
|
|
// preview items, can either be a static list or a promise. If null, take the items list
|
|
previews: [],
|
|
// exclude Ids from the picker selection items
|
|
excludedIds: [],
|
|
// autocomplete allows entering custom texts
|
|
autocomplete: false,
|
|
// maximum selection count
|
|
limit: 10,
|
|
// multiple selection
|
|
multiple: true,
|
|
// whether the previews/results are sortable or not
|
|
sortable: true,
|
|
// close picker when an item is clicked
|
|
closeOnClick: true,
|
|
// title in dropdown
|
|
title: null,
|
|
// automatically open picker
|
|
autoOpen: false,
|
|
// picker has pagination
|
|
paging: false,
|
|
// theme for the dropdown
|
|
theme: 'dark',
|
|
|
|
keys: {
|
|
// id key
|
|
id: 'id',
|
|
// name key
|
|
name: 'name',
|
|
// description key
|
|
description: 'text',
|
|
// icon key
|
|
icon: 'icon'
|
|
},
|
|
|
|
addButton: {
|
|
// hide the add button
|
|
enabled: true,
|
|
// text key for the add button
|
|
label: '@ui.select'
|
|
},
|
|
|
|
list: {
|
|
// output description text if available (second line)
|
|
description: false,
|
|
// displays an image instead of an icon in the list
|
|
iconAsImage: false
|
|
},
|
|
|
|
preview: {
|
|
// output previews
|
|
enabled: true,
|
|
// output all selected items in one line (comma-separated)
|
|
combined: false,
|
|
// prefixed title when combine=true
|
|
combinedTitle: null,
|
|
// default icon used for previews
|
|
defaultIcon: 'fth-square',
|
|
// hides the icon in the preview
|
|
icon: true,
|
|
// hides the delete icon in preview
|
|
delete: true,
|
|
// displays an image instead of an icon in the preview
|
|
iconAsImage: false,
|
|
// output description text if available (second line)
|
|
description: true
|
|
},
|
|
|
|
search: {
|
|
// hides the search input
|
|
enabled: true,
|
|
// can force a local search for remote items (via promise)
|
|
local: true,
|
|
// sets the current model as the search input when opened
|
|
setDefaultValue: false,
|
|
// focus search input on open
|
|
focus: false,
|
|
// placeholder key in search input
|
|
placeholder: null //vm.options.autocomplete ? 'ui_search_autocomplete' : 'ui_search',
|
|
}
|
|
};
|
|
|
|
export default {
|
|
name: 'uiPick',
|
|
|
|
props: {
|
|
value: {
|
|
type: [String, Array],
|
|
default: null
|
|
},
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false
|
|
},
|
|
config: {
|
|
type: Object,
|
|
default: () =>
|
|
{
|
|
return defaultConfig;
|
|
}
|
|
}
|
|
},
|
|
|
|
|
|
watch: {
|
|
config: {
|
|
deep: true,
|
|
handler(newval, oldval)
|
|
{
|
|
if (JSON.stringify(newval) !== JSON.stringify(oldval))
|
|
{
|
|
this.buildConfig();
|
|
this.loaded = false;
|
|
}
|
|
}
|
|
},
|
|
value(val)
|
|
{
|
|
this.onValueChanged(val);
|
|
}
|
|
},
|
|
|
|
|
|
computed: {
|
|
multiple()
|
|
{
|
|
return this.configuration.multiple;
|
|
},
|
|
canAdd()
|
|
{
|
|
return (!this.value && !this.multiple) || (!this.value || this.value.length < this.configuration.limit);
|
|
},
|
|
isRemote()
|
|
{
|
|
return typeof this.configuration.items === 'function';
|
|
},
|
|
title()
|
|
{
|
|
if (!!this.configuration.title) return this.configuration.title;
|
|
if (this.configuration.autocomplete) return '@ui.pick.title_autocomplete';
|
|
if (this.multiple) return '@ui.pick.title_multiple';
|
|
return '@ui.pick.title';
|
|
},
|
|
combinedTitle()
|
|
{
|
|
let html = this.configuration.preview.combinedTitle ? this.configuration.preview.combinedTitle : '';
|
|
|
|
if (this.previews.length > 0)
|
|
{
|
|
html += ': <b>' + this.previews.map(p => p[this.configuration.keys.name]).join(', ') + '</b>';
|
|
}
|
|
|
|
return html;
|
|
}
|
|
},
|
|
|
|
|
|
data: () => ({
|
|
configuration: {},
|
|
previews: [],
|
|
allItems: [],
|
|
items: [],
|
|
selected: [],
|
|
loaded: false,
|
|
isLoading: false,
|
|
debouncedUpdate: null,
|
|
searchValue: ''
|
|
}),
|
|
|
|
|
|
created()
|
|
{
|
|
this.buildConfig();
|
|
this.debouncedUpdate = debounce(this.loadSuggestions, 300);
|
|
this.onValueChanged(this.value);
|
|
},
|
|
|
|
|
|
mounted()
|
|
{
|
|
if (this.configuration.autoOpen)
|
|
{
|
|
this.pick();
|
|
}
|
|
},
|
|
|
|
|
|
methods: {
|
|
|
|
refresh()
|
|
{
|
|
this.buildConfig();
|
|
this.loaded = false;
|
|
},
|
|
|
|
onValueChanged(val)
|
|
{
|
|
if (this.multiple)
|
|
{
|
|
this.selected = Array.isArray(val) && val.length ? JSON.parse(JSON.stringify(val)) : [];
|
|
}
|
|
else
|
|
{
|
|
this.selected = val ? [val] : [];
|
|
}
|
|
|
|
this.loadPreviews();
|
|
},
|
|
|
|
|
|
buildConfig()
|
|
{
|
|
var config = JSON.parse(JSON.stringify(defaultConfig));
|
|
this.configuration = extendObject(JSON.parse(JSON.stringify(config)), this.config);
|
|
this.configuration.search = extendObject(config.search, this.config.search || {});
|
|
this.configuration.addButton = extendObject(config.addButton, this.config.addButton || {});
|
|
this.configuration.preview = extendObject(config.preview, this.config.preview || {});
|
|
this.configuration.list = extendObject(config.list, this.config.list || {});
|
|
this.configuration.keys = extendObject(config.keys, this.config.keys || {});
|
|
},
|
|
|
|
|
|
overlayOpened()
|
|
{
|
|
if (!this.loaded)
|
|
{
|
|
this.load();
|
|
}
|
|
if (this.configuration.search.enabled && this.configuration.search.focus)
|
|
{
|
|
this.$nextTick(() => this.$refs.search.focus());
|
|
}
|
|
},
|
|
|
|
|
|
pick()
|
|
{
|
|
this.$refs.overlay.toggle();
|
|
|
|
if (!this.loaded)
|
|
{
|
|
this.load();
|
|
}
|
|
},
|
|
|
|
|
|
hide()
|
|
{
|
|
this.$refs.overlay.hide();
|
|
},
|
|
|
|
|
|
remove(id)
|
|
{
|
|
let index = this.selected.indexOf(id);
|
|
this.selected.splice(index, 1);
|
|
this.onChange(this.multiple ? this.selected : null);
|
|
},
|
|
|
|
|
|
clear()
|
|
{
|
|
this.selected = [];
|
|
this.onChange(this.multiple ? this.selected : null);
|
|
},
|
|
|
|
|
|
// loads remote items or items from the given configuration
|
|
// this will store both all items and the reduced search results
|
|
load()
|
|
{
|
|
if (this.isLoading)
|
|
{
|
|
return;
|
|
}
|
|
|
|
this.isLoading = true;
|
|
this.allItems = [];
|
|
|
|
let onLoaded = (items) =>
|
|
{
|
|
if (this.configuration.paging && typeof items === 'object' && !Array.isArray(items))
|
|
{
|
|
// TODO we need to store metadata to allow pagination
|
|
items = items.items;
|
|
}
|
|
this.allItems = items;
|
|
this.loadSuggestions();
|
|
this.loaded = true;
|
|
this.isLoading = false;
|
|
};
|
|
|
|
if (this.isRemote)
|
|
{
|
|
this.configuration.items().then(onLoaded);
|
|
}
|
|
else
|
|
{
|
|
onLoaded(this.configuration.items);
|
|
}
|
|
},
|
|
|
|
|
|
loadSuggestions()
|
|
{
|
|
let items = [];
|
|
let search = this.searchValue;
|
|
|
|
let handleResult = (res) =>
|
|
{
|
|
if (this.configuration.paging && typeof res === 'object' && !Array.isArray(res))
|
|
{
|
|
// TODO we need to store metadata to allow pagination
|
|
res = res.items;
|
|
}
|
|
|
|
if (this.configuration.excludedIds && this.configuration.excludedIds.length)
|
|
{
|
|
res = res.filter((item) => this.configuration.excludedIds.indexOf(item[this.configuration.keys.id]) < 0);
|
|
}
|
|
|
|
this.items = res;
|
|
};
|
|
|
|
if (!search)
|
|
{
|
|
items = this.allItems;
|
|
}
|
|
else
|
|
{
|
|
if (!this.isRemote || this.configuration.search.local)
|
|
{
|
|
items = this.allItems.filter((item) => item.name.toLowerCase().indexOf(search) > -1);
|
|
}
|
|
else if (this.isRemote)
|
|
{
|
|
this.configuration.items(search).then(res => handleResult(res));
|
|
return;
|
|
}
|
|
}
|
|
|
|
handleResult(items);
|
|
},
|
|
|
|
|
|
loadPreviews()
|
|
{
|
|
let onLoaded = (items, needsFilter) =>
|
|
{
|
|
if (this.configuration.paging && typeof items === 'object' && !Array.isArray(items))
|
|
{
|
|
// TODO we need to store metadata to allow pagination
|
|
items = items.data;
|
|
}
|
|
|
|
if (needsFilter)
|
|
{
|
|
this.previews = [];
|
|
this.selected.forEach(id =>
|
|
{
|
|
let res = items.find(item => item[this.configuration.keys.id] === id);
|
|
|
|
if (!res)
|
|
{
|
|
// TODO push error output
|
|
}
|
|
else
|
|
{
|
|
this.previews.push(JSON.parse(JSON.stringify(res)));
|
|
}
|
|
});
|
|
}
|
|
else
|
|
{
|
|
this.previews = items;
|
|
}
|
|
|
|
//console.info(JSON.parse(JSON.stringify(this.previews)));
|
|
};
|
|
|
|
if (this.configuration.autocomplete)
|
|
{
|
|
onLoaded(this.selected.map(s =>
|
|
{
|
|
let item = { };
|
|
item[this.configuration.keys.id] = s;
|
|
item[this.configuration.keys.name] = s;
|
|
return item;
|
|
}), false);
|
|
}
|
|
else
|
|
{
|
|
let promise = (Array.isArray(this.configuration.previews) && this.configuration.previews.length) || typeof this.configuration.previews === 'function' ? this.configuration.previews : this.configuration.items;
|
|
let isFunc = typeof promise === 'function';
|
|
|
|
if (isFunc && this.selected.length > 0)
|
|
{
|
|
promise(this.selected).then(onLoaded);
|
|
}
|
|
else if (isFunc)
|
|
{
|
|
onLoaded([]);
|
|
}
|
|
else
|
|
{
|
|
onLoaded(promise, true);
|
|
}
|
|
}
|
|
},
|
|
|
|
|
|
onSearch(value)
|
|
{
|
|
this.searchValue = value;
|
|
if (!this.isRemote || this.configuration.search.local)
|
|
{
|
|
this.loadSuggestions();
|
|
}
|
|
else
|
|
{
|
|
this.debouncedUpdate();
|
|
}
|
|
},
|
|
|
|
|
|
onSearchSubmit()
|
|
{
|
|
let value = this.searchValue.trim();
|
|
let item = {};
|
|
item[this.configuration.keys.id] = value;
|
|
item[this.configuration.keys.name] = value;
|
|
this.select(item);
|
|
},
|
|
|
|
|
|
select(item)
|
|
{
|
|
let value = this.configuration.autocomplete ? item[this.configuration.keys.name] : item[this.configuration.keys.id];
|
|
|
|
if (this.multiple)
|
|
{
|
|
if (!this.canAdd)
|
|
{
|
|
if (this.limit > 1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
this.selected = [];
|
|
}
|
|
|
|
var index = this.selected.indexOf(value);
|
|
|
|
if (index > -1)
|
|
{
|
|
this.selected.splice(index, 1);
|
|
this.onDeselected(value, index);
|
|
}
|
|
else
|
|
{
|
|
this.selected.push(value);
|
|
this.onSelected(value, item);
|
|
}
|
|
|
|
this.onChange(this.selected);
|
|
}
|
|
else
|
|
{
|
|
this.selected = [value];
|
|
this.onChange(value);
|
|
this.onSelected(value, item);
|
|
}
|
|
},
|
|
|
|
|
|
onSelected(value, item)
|
|
{
|
|
this.$emit('select', value, item);
|
|
},
|
|
|
|
|
|
onDeselected(value, index)
|
|
{
|
|
this.$emit('deselect', value, index);
|
|
},
|
|
|
|
|
|
onChange(value)
|
|
{
|
|
this.$emit('input', value);
|
|
this.$emit('change', value);
|
|
|
|
if (this.configuration.closeOnClick)
|
|
{
|
|
this.$refs.overlay.hide();
|
|
}
|
|
},
|
|
|
|
|
|
isSelected(item)
|
|
{
|
|
let value = this.configuration.autocomplete ? item[this.configuration.keys.name] : item[this.configuration.keys.id];
|
|
return this.selected.indexOf(value) > -1;
|
|
},
|
|
|
|
|
|
getPreviewIcon(preview)
|
|
{
|
|
return this.configuration.preview.icon ? (preview[this.configuration.keys.icon] || this.configuration.preview.defaultIcon) : null;
|
|
},
|
|
|
|
|
|
getPreviewDescription(preview)
|
|
{
|
|
return this.configuration.preview.description ? preview[this.configuration.keys.description] : null;
|
|
},
|
|
|
|
|
|
onSortingUpdated(ev)
|
|
{
|
|
this.selected = arrayMove(this.selected, ev.oldIndex, ev.newIndex);
|
|
this.onChange(this.multiple ? this.selected : this.selected[0]);
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.ui-pick-overlay-search
|
|
{
|
|
margin: 15px 15px 5px;
|
|
|
|
.ui-input
|
|
{
|
|
min-width: 0;
|
|
}
|
|
}
|
|
|
|
.theme-dark .ui-pick-overlay-search .ui-input
|
|
{
|
|
background: var(--color-bg-shade-1);
|
|
box-shadow: none !important;
|
|
}
|
|
|
|
.ui-pick-overlay
|
|
{
|
|
.ui-dropdown
|
|
{
|
|
min-width: 0;
|
|
width: 100%;
|
|
max-width: 380px;
|
|
}
|
|
}
|
|
|
|
.ui-pick-overlay-center
|
|
{
|
|
display: flex;
|
|
justify-content: center;
|
|
margin: 30px 0;
|
|
}
|
|
|
|
.ui-pick-overlay-head
|
|
{
|
|
padding: 15px 15px 5px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
|
|
.ui-icon-button
|
|
{
|
|
background: none;
|
|
}
|
|
}
|
|
|
|
.ui-pick-overlay-head-title
|
|
{
|
|
font-size: var(--font-size-l);
|
|
font-weight: 600;
|
|
|
|
.-max
|
|
{
|
|
font-size: var(--font-size-s);
|
|
font-weight: 400;
|
|
color: var(--color-text-dim);
|
|
margin-left: .6em;
|
|
}
|
|
}
|
|
|
|
.ui-pick-overlay-message
|
|
{
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
text-align: center;
|
|
padding: 0 15px;
|
|
}
|
|
|
|
.ui-pick-overlay-message-icon
|
|
{
|
|
font-size: 18px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.ui-pick-overlay-items
|
|
{
|
|
margin: 15px 0;
|
|
max-height: 290px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.ui-pick-overlay-item
|
|
{
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
padding: 10px 18px;
|
|
min-height: 48px;
|
|
width: 100%;
|
|
border-radius: var(--radius);
|
|
align-items: center;
|
|
transition: background .2s, transform .2s, opacity .2s;
|
|
position: relative;
|
|
|
|
&:hover
|
|
{
|
|
background: var(--color-dropdown-selected);
|
|
}
|
|
|
|
&.is-selected
|
|
{
|
|
padding-right: 40px;
|
|
|
|
.-name
|
|
{
|
|
font-weight: 600;
|
|
}
|
|
}
|
|
|
|
&.is-selected:after
|
|
{
|
|
font-family: "Feather";
|
|
content: "\e83e";
|
|
font-size: 16px;
|
|
color: var(--color-primary);
|
|
position: absolute;
|
|
right: 20px;
|
|
}
|
|
|
|
.-icon
|
|
{
|
|
margin-right: var(--padding-s);
|
|
}
|
|
|
|
.-image
|
|
{
|
|
display: inline-flex;
|
|
justify-content: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
margin-right: var(--padding-xs);
|
|
|
|
img
|
|
{
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
}
|
|
}
|
|
}
|
|
|
|
.ui-pick-overlay-item-title
|
|
{
|
|
.-name
|
|
{
|
|
display: block;
|
|
}
|
|
|
|
.-text
|
|
{
|
|
display: block;
|
|
color: var(--color-text-dim);
|
|
font-size: var(--font-size-s);
|
|
margin-top: 3px;
|
|
}
|
|
}
|
|
|
|
.ui-pick-preview
|
|
{
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.ui-pick-preview + .ui-pick-preview,
|
|
.ui-pick-previews + .ui-select-button,
|
|
.ui-pick-previews + .ui-pick-add
|
|
{
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.ui-pick.is-combined .ui-select-button-label
|
|
{
|
|
font-weight: 400;
|
|
}
|
|
|
|
.ui-pick-previews
|
|
{
|
|
.ui-icon-button
|
|
{
|
|
height: 24px;
|
|
width: 24px;
|
|
flex-shrink: 0;
|
|
|
|
.ui-button-icon
|
|
{
|
|
font-size: 13px;
|
|
}
|
|
}
|
|
}
|
|
</style> |