mirror of
https://github.com/directus/directus.git
synced 2026-02-08 20:44:57 -05:00
Add Components Package (#15094)
* move components without dependencies to packages * make every components use vue script setup * move components and utils from shared to @directus/components * fix imports * move over some more components * get rid of unnecessary isEmpty and notEmpty * move pagination * fix missing ! * move groupable components * move text-overflow and useElementSize * fix icons not being shown * add first unit tests * remove capitalizeFirst * simple cleanup * add css-var unit test * move over most other components * make every component use script setup * add some more unit tests * add more tests and burn v-switch to the ground. 🔥 * add checkbox tests * start with next test * add storybook * add more pages to storybook * add final stories * fix stories actions * improve action fix * cleaning props and adding tests * unit tests -.- * add some documentation to components * Add docs to each prop * clean storybook paths * add more unit tests * apply v-select fix * update lock file * small tweaks * move back to shared * fix imports * fix imports * cleaning * stories to typescript * Fix version number Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
This commit is contained in:
@@ -8,70 +8,69 @@ import DocsWrapper from '@/views/private/components/docs-wrapper.vue';
|
||||
import DrawerItem from '@/views/private/components/drawer-item.vue';
|
||||
import DrawerBatch from '@/views/private/components/drawer-batch.vue';
|
||||
import { App } from 'vue';
|
||||
import TransitionBounce from './transition/bounce';
|
||||
import TransitionDialog from './transition/dialog';
|
||||
import TransitionExpand from './transition/expand';
|
||||
import VAvatar from './v-avatar.vue';
|
||||
import VBadge from './v-badge.vue';
|
||||
import VBreadcrumb from './v-breadcrumb.vue';
|
||||
import VButton from './v-button.vue';
|
||||
import VCard from './v-card.vue';
|
||||
import VCardActions from './v-card-actions.vue';
|
||||
import VCardSubtitle from './v-card-subtitle.vue';
|
||||
import VCardText from './v-card-text.vue';
|
||||
import VCardTitle from './v-card-title.vue';
|
||||
import VCheckbox from './v-checkbox.vue';
|
||||
import VCheckboxTree from './v-checkbox-tree/v-checkbox-tree.vue';
|
||||
import VChip from './v-chip.vue';
|
||||
import TransitionBounce from '@directus/components/transition/bounce.vue';
|
||||
import TransitionDialog from '@directus/components/transition/dialog.vue';
|
||||
import TransitionExpand from '@directus/components/transition/expand.vue';
|
||||
import VAvatar from '@directus/components/v-avatar.vue';
|
||||
import VBadge from '@directus/components/v-badge.vue';
|
||||
import VBreadcrumb from '@directus/components/v-breadcrumb.vue';
|
||||
import VButton from '@directus/components/v-button.vue';
|
||||
import VCard from '@directus/components/v-card.vue';
|
||||
import VCardActions from '@directus/components/v-card-actions.vue';
|
||||
import VCardSubtitle from '@directus/components/v-card-subtitle.vue';
|
||||
import VCardText from '@directus/components/v-card-text.vue';
|
||||
import VCardTitle from '@directus/components/v-card-title.vue';
|
||||
import VCheckbox from '@directus/components/v-checkbox.vue';
|
||||
import VCheckboxTree from '@directus/components/v-checkbox-tree/v-checkbox-tree.vue';
|
||||
import VChip from '@directus/components/v-chip.vue';
|
||||
import VDetail from './v-detail.vue';
|
||||
import VDialog from './v-dialog.vue';
|
||||
import VDivider from './v-divider.vue';
|
||||
import VDivider from '@directus/components/v-divider.vue';
|
||||
import VDrawer from './v-drawer.vue';
|
||||
import VError from './v-error.vue';
|
||||
import VFancySelect from './v-fancy-select.vue';
|
||||
import VFancySelect from '@directus/components/v-fancy-select.vue';
|
||||
import VFieldTemplate from './v-field-template/v-field-template.vue';
|
||||
import VFieldList from './v-field-list/v-field-list.vue';
|
||||
import VForm from './v-form/v-form.vue';
|
||||
import VHover from './v-hover.vue';
|
||||
import VHighlight from './v-highlight.vue';
|
||||
import VIcon from './v-icon/v-icon.vue';
|
||||
import VHover from '@directus/components/v-hover.vue';
|
||||
import VHighlight from '@directus/components/v-highlight.vue';
|
||||
import VIcon from '@directus/components/v-icon/v-icon.vue';
|
||||
import VImage from './v-image.vue';
|
||||
import VIconFile from './v-icon-file.vue';
|
||||
import VInfo from './v-info.vue';
|
||||
import VInput from './v-input.vue';
|
||||
import VItemGroup from './v-item-group.vue';
|
||||
import VItem from './v-item.vue';
|
||||
import VList from './v-list.vue';
|
||||
import VListGroup from './v-list-group.vue';
|
||||
import VListItem from './v-list-item.vue';
|
||||
import VListItemContent from './v-list-item-content.vue';
|
||||
import VListItemHint from './v-list-item-hint.vue';
|
||||
import VListItemIcon from './v-list-item-icon.vue';
|
||||
import VMenu from './v-menu.vue';
|
||||
import VNotice from './v-notice.vue';
|
||||
import VOverlay from './v-overlay.vue';
|
||||
import VPagination from './v-pagination.vue';
|
||||
import VProgressCircular from './v-progress-circular.vue';
|
||||
import VProgressLinear from './v-progress-linear.vue';
|
||||
import VRadio from './v-radio.vue';
|
||||
import VSelect from './v-select/v-select.vue';
|
||||
import VSheet from './v-sheet.vue';
|
||||
import VSkeletonLoader from './v-skeleton-loader.vue';
|
||||
import VSlider from './v-slider.vue';
|
||||
import VSwitch from './v-switch.vue';
|
||||
import VIconFile from '@directus/components/v-icon-file.vue';
|
||||
import VInfo from '@directus/components/v-info.vue';
|
||||
import VInput from '@directus/components/v-input.vue';
|
||||
import VItemGroup from '@directus/components/v-item-group.vue';
|
||||
import VItem from '@directus/components/v-item.vue';
|
||||
import VList from '@directus/components/v-list.vue';
|
||||
import VListGroup from '@directus/components/v-list-group.vue';
|
||||
import VListItem from '@directus/components/v-list-item.vue';
|
||||
import VListItemContent from '@directus/components/v-list-item-content.vue';
|
||||
import VListItemHint from '@directus/components/v-list-item-hint.vue';
|
||||
import VListItemIcon from '@directus/components/v-list-item-icon.vue';
|
||||
import VMenu from '@directus/components/v-menu.vue';
|
||||
import VNotice from '@directus/components/v-notice.vue';
|
||||
import VOverlay from '@directus/components/v-overlay.vue';
|
||||
import VPagination from '@directus/components/v-pagination.vue';
|
||||
import VProgressCircular from '@directus/components/v-progress-circular.vue';
|
||||
import VProgressLinear from '@directus/components/v-progress-linear.vue';
|
||||
import VRadio from '@directus/components/v-radio.vue';
|
||||
import VSelect from '@directus/components/v-select/v-select.vue';
|
||||
import VSheet from '@directus/components/v-sheet.vue';
|
||||
import VSkeletonLoader from '@directus/components/v-skeleton-loader.vue';
|
||||
import VSlider from '@directus/components/v-slider.vue';
|
||||
import VTable from './v-table/v-table.vue';
|
||||
import VTabs from './v-tabs.vue';
|
||||
import VTab from './v-tab.vue';
|
||||
import VTabItem from './v-tab-item.vue';
|
||||
import VTabsItems from './v-tabs-items.vue';
|
||||
import VTemplateInput from './v-template-input.vue';
|
||||
import VTextOverflow from './v-text-overflow.vue';
|
||||
import VTextarea from './v-textarea.vue';
|
||||
import VTabs from '@directus/components/v-tabs.vue';
|
||||
import VTab from '@directus/components/v-tab.vue';
|
||||
import VTabItem from '@directus/components/v-tab-item.vue';
|
||||
import VTabsItems from '@directus/components/v-tabs-items.vue';
|
||||
import VTemplateInput from '@directus/components/v-template-input.vue';
|
||||
import VTextOverflow from '@directus/components/v-text-overflow.vue';
|
||||
import VTextarea from '@directus/components/v-textarea.vue';
|
||||
import VUpload from './v-upload.vue';
|
||||
import VDatePicker from './v-date-picker.vue';
|
||||
import VEmojiPicker from './v-emoji-picker.vue';
|
||||
import VWorkspace from './v-workspace.vue';
|
||||
import VWorkspaceTile from './v-workspace-tile.vue';
|
||||
import VEmojiPicker from '@directus/components/v-emoji-picker.vue';
|
||||
import VWorkspace from '@directus/components/v-workspace.vue';
|
||||
import VWorkspaceTile from '@directus/components/v-workspace-tile.vue';
|
||||
|
||||
export function registerComponents(app: App): void {
|
||||
app.component('VAvatar', VAvatar);
|
||||
@@ -121,7 +120,6 @@ export function registerComponents(app: App): void {
|
||||
app.component('VSheet', VSheet);
|
||||
app.component('VSkeletonLoader', VSkeletonLoader);
|
||||
app.component('VSlider', VSlider);
|
||||
app.component('VSwitch', VSwitch);
|
||||
app.component('VTabItem', VTabItem);
|
||||
app.component('VTab', VTab);
|
||||
app.component('VTable', VTable);
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import TransitionBounce from './transition-bounce.vue';
|
||||
|
||||
export { TransitionBounce };
|
||||
export default TransitionBounce;
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<transition-group name="bounce" tag="div" v-bind="$attrs">
|
||||
<slot />
|
||||
</transition-group>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
/** @NOTE this is not scoped on purpose. The children are outside of the tree (teleport) */
|
||||
|
||||
.bounce-enter-active,
|
||||
.bounce-leave-active {
|
||||
transition: opacity var(--fast) var(--transition);
|
||||
|
||||
& > .v-menu-content {
|
||||
transition: transform var(--fast) cubic-bezier(0, 0, 0.2, 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
.bounce-enter-from,
|
||||
.bounce-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
&[data-placement='top'] > .v-menu-content {
|
||||
transform: scaleY(0.8);
|
||||
}
|
||||
|
||||
&[data-placement='top-start'] > .v-menu-content {
|
||||
transform: scaleY(0.8) scaleX(0.8);
|
||||
}
|
||||
|
||||
&[data-placement='top-end'] > .v-menu-content {
|
||||
transform: scaleY(0.8) scaleX(0.8);
|
||||
}
|
||||
|
||||
&[data-placement='right'] > .v-menu-content {
|
||||
transform: scaleX(0.8);
|
||||
}
|
||||
|
||||
&[data-placement='right-start'] > .v-menu-content {
|
||||
transform: scaleY(0.8) scaleX(0.8);
|
||||
}
|
||||
|
||||
&[data-placement='right-end'] > .v-menu-content {
|
||||
transform: scaleY(0.8) scaleX(0.8);
|
||||
}
|
||||
|
||||
&[data-placement='bottom'] > .v-menu-content {
|
||||
transform: scaleY(0.8);
|
||||
}
|
||||
|
||||
&[data-placement='bottom-start'] > .v-menu-content {
|
||||
transform: scaleY(0.8);
|
||||
}
|
||||
|
||||
&[data-placement='bottom-end'] > .v-menu-content {
|
||||
transform: scaleY(0.8);
|
||||
}
|
||||
|
||||
&[data-placement='left'] > .v-menu-content {
|
||||
transform: scaleX(0.8);
|
||||
}
|
||||
|
||||
&[data-placement='left-start'] > .v-menu-content {
|
||||
transform: scaleY(0.8) scaleX(0.8);
|
||||
}
|
||||
|
||||
&[data-placement='left-end'] > .v-menu-content {
|
||||
transform: scaleY(0.8) scaleX(0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +0,0 @@
|
||||
import TransitionDialog from './transition-dialog.vue';
|
||||
|
||||
export { TransitionDialog };
|
||||
export default TransitionDialog;
|
||||
@@ -1,39 +0,0 @@
|
||||
<template>
|
||||
<transition name="dialog">
|
||||
<slot />
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
/** @NOTE this is not scoped on purpose. The children are outside of the tree (teleport) */
|
||||
|
||||
.dialog-enter-active,
|
||||
.dialog-leave-active {
|
||||
transition: opacity var(--slow) var(--transition);
|
||||
|
||||
&.center > *:not(.v-overlay) {
|
||||
transform: translateY(0px);
|
||||
transition: transform var(--slow) var(--transition-in);
|
||||
}
|
||||
|
||||
&.right > *:not(.v-overlay) {
|
||||
transform: translateX(0px);
|
||||
transition: transform var(--slow) var(--transition-in);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-enter-from,
|
||||
.dialog-leave-to {
|
||||
opacity: 0;
|
||||
|
||||
&.center > *:not(.v-overlay) {
|
||||
transform: translateY(50px);
|
||||
transition: transform var(--slow) var(--transition-out);
|
||||
}
|
||||
|
||||
&.right > *:not(.v-overlay) {
|
||||
transform: translateX(50px);
|
||||
transition: transform var(--slow) var(--transition-out);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +0,0 @@
|
||||
import TransitionExpand from './transition-expand.vue';
|
||||
|
||||
export { TransitionExpand };
|
||||
export default TransitionExpand;
|
||||
@@ -1,119 +0,0 @@
|
||||
import { capitalizeFirst } from '@/utils/capitalize-first';
|
||||
|
||||
interface HTMLExpandElement extends HTMLElement {
|
||||
_parent?: (Node & ParentNode & HTMLElement) | null;
|
||||
_initialStyle?: {
|
||||
transition: string;
|
||||
visibility: string;
|
||||
overflow: string;
|
||||
height?: string | null;
|
||||
width?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export default function (
|
||||
expandedParentClass = '',
|
||||
xAxis = false,
|
||||
emit: (
|
||||
event: 'beforeEnter' | 'enter' | 'afterEnter' | 'enterCancelled' | 'leave' | 'afterLeave' | 'leaveCancelled',
|
||||
...args: any[]
|
||||
) => void
|
||||
): Record<string, any> {
|
||||
const sizeProperty = xAxis ? 'width' : ('height' as 'width' | 'height');
|
||||
const offsetProperty = `offset${capitalizeFirst(sizeProperty)}` as 'offsetHeight' | 'offsetWidth';
|
||||
|
||||
return {
|
||||
beforeEnter(el: HTMLExpandElement) {
|
||||
emit('beforeEnter');
|
||||
|
||||
el._parent = el.parentNode as (Node & ParentNode & HTMLElement) | null;
|
||||
el._initialStyle = {
|
||||
transition: el.style.transition,
|
||||
visibility: el.style.visibility,
|
||||
overflow: el.style.overflow,
|
||||
[sizeProperty]: el.style[sizeProperty],
|
||||
};
|
||||
},
|
||||
|
||||
enter(el: HTMLExpandElement) {
|
||||
emit('enter');
|
||||
|
||||
const initialStyle = el._initialStyle;
|
||||
if (!initialStyle) return;
|
||||
const offset = `${el[offsetProperty]}px`;
|
||||
|
||||
el.style.setProperty('transition', 'none', 'important');
|
||||
el.style.visibility = 'hidden';
|
||||
el.style.visibility = initialStyle.visibility;
|
||||
el.style.overflow = 'hidden';
|
||||
el.style[sizeProperty] = '0';
|
||||
|
||||
void el.offsetHeight; // force reflow
|
||||
|
||||
el.style.transition =
|
||||
initialStyle.transition !== '' ? initialStyle.transition : `${sizeProperty} var(--medium) var(--transition)`;
|
||||
|
||||
if (expandedParentClass && el._parent) {
|
||||
el._parent.classList.add(expandedParentClass);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.style[sizeProperty] = offset;
|
||||
});
|
||||
},
|
||||
|
||||
afterEnter(el: HTMLExpandElement) {
|
||||
emit('afterEnter');
|
||||
resetStyles(el);
|
||||
},
|
||||
|
||||
enterCancelled(el: HTMLExpandElement) {
|
||||
emit('enterCancelled');
|
||||
resetStyles(el);
|
||||
},
|
||||
|
||||
leave(el: HTMLExpandElement) {
|
||||
emit('leave');
|
||||
|
||||
el._initialStyle = {
|
||||
transition: '',
|
||||
visibility: '',
|
||||
overflow: el.style.overflow,
|
||||
[sizeProperty]: el.style[sizeProperty],
|
||||
};
|
||||
|
||||
el.style.overflow = 'hidden';
|
||||
el.style[sizeProperty] = `${el[offsetProperty]}px`;
|
||||
void el.offsetHeight; // force reflow
|
||||
|
||||
requestAnimationFrame(() => (el.style[sizeProperty] = '0'));
|
||||
},
|
||||
|
||||
afterLeave(el: HTMLExpandElement) {
|
||||
emit('afterLeave');
|
||||
|
||||
if (expandedParentClass && el._parent) {
|
||||
el._parent.classList.remove(expandedParentClass);
|
||||
}
|
||||
|
||||
resetStyles(el);
|
||||
},
|
||||
leaveCancelled(el: HTMLExpandElement) {
|
||||
emit('leaveCancelled');
|
||||
|
||||
if (expandedParentClass && el._parent) {
|
||||
el._parent.classList.remove(expandedParentClass);
|
||||
}
|
||||
|
||||
resetStyles(el);
|
||||
},
|
||||
};
|
||||
|
||||
function resetStyles(el: HTMLExpandElement) {
|
||||
if (!el._initialStyle) return;
|
||||
const size = el._initialStyle[sizeProperty];
|
||||
el.style.overflow = el._initialStyle.overflow;
|
||||
if (size != null) el.style[sizeProperty] = size;
|
||||
delete el._initialStyle;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<transition name="expand-transition" mode="in-out" v-on="methods">
|
||||
<slot />
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import ExpandMethods from './transition-expand-methods';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
xAxis: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
expandedParentClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['beforeEnter', 'enter', 'afterEnter', 'enterCancelled', 'leave', 'afterLeave', 'leaveCancelled'],
|
||||
setup(props, { emit }) {
|
||||
const methods = ExpandMethods(props.expandedParentClass, props.xAxis, emit);
|
||||
return { methods };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,4 +0,0 @@
|
||||
import TransitionExpand from './expand';
|
||||
|
||||
export { TransitionExpand };
|
||||
export default { TransitionExpand };
|
||||
@@ -1,80 +0,0 @@
|
||||
<template>
|
||||
<div class="v-avatar" :class="[{ tile }, sizeClass]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { useSizeClass, sizeProps } from '@/composables/use-size-class';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
size: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
tile: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
...sizeProps,
|
||||
},
|
||||
setup(props) {
|
||||
const sizeClass = useSizeClass(props);
|
||||
return { sizeClass };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-avatar-color: var(--background-normal);
|
||||
--v-avatar-size: 48px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.v-avatar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--v-avatar-size);
|
||||
height: var(--v-avatar-size);
|
||||
overflow: hidden;
|
||||
color: var(--foreground-subdued);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
background-color: var(--v-avatar-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.tile {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.x-small {
|
||||
--v-avatar-size: 24px;
|
||||
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.small {
|
||||
--v-avatar-size: 36px;
|
||||
}
|
||||
|
||||
.large {
|
||||
--v-avatar-size: 60px;
|
||||
}
|
||||
|
||||
.x-large {
|
||||
--v-avatar-size: 80px;
|
||||
}
|
||||
|
||||
:slotted(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<div class="v-badge" :class="{ dot, bordered }">
|
||||
<span v-if="!disabled" class="badge" :class="{ dot, bordered, left, bottom }">
|
||||
<v-icon v-if="icon" :name="icon" :color="color" x-small />
|
||||
<span v-else>{{ value }}</span>
|
||||
</span>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: [Boolean, String, Number],
|
||||
default: null,
|
||||
},
|
||||
dot: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
left: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
bottom: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
bordered: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:global(body) {
|
||||
--v-badge-color: var(--white);
|
||||
--v-badge-background-color: var(--red);
|
||||
--v-badge-border-color: var(--background-page);
|
||||
--v-badge-offset-x: 0px;
|
||||
--v-badge-offset-y: 0px;
|
||||
--v-badge-size: 16px;
|
||||
}
|
||||
|
||||
.v-badge {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
&.dot {
|
||||
--v-badge-size: 8px;
|
||||
|
||||
&.bordered {
|
||||
--v-badge-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: calc(var(--v-badge-size) / -2 + var(--v-badge-offset-y));
|
||||
right: calc(var(--v-badge-size) / -2 + var(--v-badge-offset-x));
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: max-content;
|
||||
min-width: var(--v-badge-size);
|
||||
height: var(--v-badge-size);
|
||||
padding: 0 5px;
|
||||
color: var(--v-badge-color);
|
||||
font-weight: 800;
|
||||
font-size: 9px;
|
||||
background-color: var(--v-badge-background-color);
|
||||
border-radius: calc(var(--v-badge-size) / 2);
|
||||
|
||||
&.left {
|
||||
right: unset;
|
||||
left: calc(var(--v-badge-size) / -2 + var(--v-badge-offset-x));
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
top: unset;
|
||||
bottom: calc(var(--v-badge-size) / -2 + var(--v-badge-offset-y));
|
||||
}
|
||||
|
||||
&.bordered {
|
||||
filter: drop-shadow(1.5px 1.5px 0 var(--v-badge-border-color))
|
||||
drop-shadow(1.5px -1.5px 0 var(--v-badge-border-color)) drop-shadow(-1.5px 1.5px 0 var(--v-badge-border-color))
|
||||
drop-shadow(-1.5px -1.5px 0 var(--v-badge-border-color));
|
||||
}
|
||||
|
||||
&.dot {
|
||||
width: var(--v-badge-size);
|
||||
min-width: 0;
|
||||
height: var(--v-badge-size);
|
||||
border: 0;
|
||||
|
||||
* {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,93 +0,0 @@
|
||||
<template>
|
||||
<span class="v-breadcrumb">
|
||||
<span v-for="(item, index) in items" :key="item.name" class="section" :class="{ disabled: item.disabled }">
|
||||
<v-icon v-if="index > 0" name="chevron_right" small />
|
||||
<router-link v-if="!item.disabled" :to="item.to" class="section-link">
|
||||
<v-icon v-if="item.icon" :name="item.icon" small />
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
<span v-else class="section-link">
|
||||
<v-icon v-if="item.icon" :name="item.icon" />
|
||||
{{ item.name }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
|
||||
interface Breadcrumb {
|
||||
to: string;
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<Breadcrumb[]>,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-breadcrumb-color: var(--foreground-subdued);
|
||||
--v-breadcrumb-color-hover: var(--foreground-normal);
|
||||
--v-breadcrumb-color-disabled: var(--foreground-subdued);
|
||||
--v-breadcrumb-divider-color: var(--foreground-subdued);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.section {
|
||||
display: contents;
|
||||
|
||||
.v-icon {
|
||||
--v-icon-color: var(--v-breadcrumb-divider-color);
|
||||
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
&-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--v-breadcrumb-color);
|
||||
text-decoration: none;
|
||||
|
||||
.v-icon {
|
||||
--v-icon-color: var(--v-breadcrumb-color);
|
||||
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--v-breadcrumb-color-hover);
|
||||
|
||||
.v-icon {
|
||||
--v-icon-color: var(--v-breadcrumb-color-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
.section-link,
|
||||
.section-link:hover,
|
||||
.section-link .v-icon {
|
||||
color: var(--v-breadcrumb-color-disabled);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,460 +0,0 @@
|
||||
<template>
|
||||
<div class="v-button" :class="{ secondary, warning, danger, 'full-width': fullWidth, rounded }">
|
||||
<slot name="prepend-outer" />
|
||||
<component
|
||||
:is="component"
|
||||
v-focus="autofocus"
|
||||
:download="download"
|
||||
class="button"
|
||||
:class="[
|
||||
sizeClass,
|
||||
`align-${align}`,
|
||||
{
|
||||
active: isActiveRoute,
|
||||
icon,
|
||||
outlined,
|
||||
loading,
|
||||
dashed,
|
||||
tile,
|
||||
'full-width': fullWidth,
|
||||
},
|
||||
kind,
|
||||
]"
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
v-bind="additionalProps"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="content" :class="{ invisible: loading }">
|
||||
<slot v-bind="{ active, toggle }" />
|
||||
</span>
|
||||
<div class="spinner">
|
||||
<slot v-if="loading" name="loading">
|
||||
<v-progress-circular :x-small="xSmall" :small="small" indeterminate />
|
||||
</slot>
|
||||
</div>
|
||||
</component>
|
||||
<slot name="append-outer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, PropType } from 'vue';
|
||||
import { RouteLocation, useRoute, useLink } from 'vue-router';
|
||||
import { useSizeClass, sizeProps } from '@/composables/use-size-class';
|
||||
import { useGroupable } from '@/composables/use-groupable';
|
||||
import { notEmpty } from '@/utils/is-empty';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
kind: {
|
||||
type: String as PropType<'normal' | 'info' | 'success' | 'warning' | 'danger'>,
|
||||
default: 'normal',
|
||||
},
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
outlined: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
to: {
|
||||
type: [String, Object] as PropType<string | RouteLocation>,
|
||||
default: '',
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
exact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
query: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
secondary: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
warning: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
danger: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: [Number, String],
|
||||
default: undefined,
|
||||
},
|
||||
dashed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tile: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
align: {
|
||||
type: String,
|
||||
default: 'center',
|
||||
validator: (val: string) => ['left', 'center', 'right'].includes(val),
|
||||
},
|
||||
download: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
...sizeProps,
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
const route = useRoute();
|
||||
|
||||
const { route: linkRoute, isActive, isExactActive } = useLink(props);
|
||||
const sizeClass = useSizeClass(props);
|
||||
|
||||
const component = computed(() => {
|
||||
if (props.disabled) return 'button';
|
||||
if (notEmpty(props.href)) return 'a';
|
||||
if (props.to) return 'router-link';
|
||||
return 'button';
|
||||
});
|
||||
|
||||
const additionalProps = computed(() => {
|
||||
if (props.to) {
|
||||
return {
|
||||
to: props.to,
|
||||
};
|
||||
}
|
||||
|
||||
if (component.value === 'a') {
|
||||
return {
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
const { active, toggle } = useGroupable({
|
||||
value: props.value,
|
||||
group: 'item-group',
|
||||
});
|
||||
|
||||
const isActiveRoute = computed(() => {
|
||||
if (props.active !== undefined) return props.active;
|
||||
|
||||
if (props.to) {
|
||||
const isQueryActive = !props.query || isEqual(route.query, linkRoute.value.query);
|
||||
|
||||
if (!props.exact) {
|
||||
return (isActive.value && isQueryActive) || active.value;
|
||||
} else {
|
||||
return (isExactActive.value && isQueryActive) || active.value;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
return { sizeClass, onClick, component, additionalProps, isActiveRoute, toggle };
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (props.loading === true) return;
|
||||
// Toggles the active state in the parent groupable element. Allows buttons to work ootb in button-groups
|
||||
toggle();
|
||||
emit('click', event);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:global(body) {
|
||||
--v-button-width: auto;
|
||||
--v-button-height: 44px;
|
||||
--v-button-color: var(--foreground-inverted);
|
||||
--v-button-color-hover: var(--foreground-inverted);
|
||||
--v-button-color-active: var(--foreground-inverted);
|
||||
--v-button-color-disabled: var(--foreground-subdued);
|
||||
--v-button-background-color: var(--primary);
|
||||
--v-button-background-color-hover: var(--primary-125);
|
||||
--v-button-background-color-active: var(--primary);
|
||||
--v-button-background-color-disabled: var(--background-normal);
|
||||
--v-button-font-size: 16px;
|
||||
--v-button-font-weight: 600;
|
||||
--v-button-line-height: 22px;
|
||||
--v-button-min-width: 140px;
|
||||
}
|
||||
|
||||
.info {
|
||||
--v-button-color: var(--white);
|
||||
--v-button-color-hover: var(--white);
|
||||
--v-button-background-color: var(--blue);
|
||||
--v-button-background-color-hover: var(--blue-125);
|
||||
--v-button-background-color-active: var(--blue);
|
||||
}
|
||||
|
||||
.success {
|
||||
--v-button-color: var(--white);
|
||||
--v-button-color-hover: var(--white);
|
||||
--v-button-background-color: var(--success);
|
||||
--v-button-background-color-hover: var(--success-125);
|
||||
--v-button-background-color-active: var(--success);
|
||||
}
|
||||
|
||||
.warning {
|
||||
--v-button-color: var(--white);
|
||||
--v-button-color-hover: var(--white);
|
||||
--v-button-background-color: var(--warning);
|
||||
--v-button-background-color-hover: var(--warning-125);
|
||||
--v-button-background-color-active: var(--warning);
|
||||
}
|
||||
|
||||
.danger {
|
||||
--v-button-color: var(--white);
|
||||
--v-button-color-hover: var(--white);
|
||||
--v-button-background-color: var(--danger);
|
||||
--v-button-background-color-hover: var(--danger-125);
|
||||
--v-button-background-color-active: var(--danger);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
--v-button-color: var(--foreground-normal);
|
||||
--v-button-color-hover: var(--foreground-normal);
|
||||
--v-button-color-active: var(--foreground-normal);
|
||||
--v-button-background-color: var(--border-subdued);
|
||||
--v-button-background-color-hover: var(--background-normal-alt);
|
||||
--v-button-background-color-active: var(--background-normal-alt);
|
||||
}
|
||||
|
||||
.secondary.rounded {
|
||||
--v-button-background-color: var(--background-normal);
|
||||
--v-button-background-color-active: var(--background-normal);
|
||||
--v-button-background-color-hover: var(--background-normal-alt);
|
||||
}
|
||||
|
||||
.warning.rounded {
|
||||
--v-button-background-color: var(--warning-10);
|
||||
--v-button-color: var(--warning);
|
||||
--v-button-background-color-hover: var(--warning-25);
|
||||
--v-button-color-hover: var(--warning);
|
||||
}
|
||||
|
||||
.danger.rounded {
|
||||
--v-button-background-color: var(--danger-10);
|
||||
--v-button-color: var(--danger);
|
||||
--v-button-background-color-hover: var(--danger-25);
|
||||
--v-button-color-hover: var(--danger);
|
||||
}
|
||||
|
||||
.v-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.v-button.full-width {
|
||||
display: flex;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: var(--v-button-width);
|
||||
min-width: var(--v-button-min-width);
|
||||
height: var(--v-button-height);
|
||||
padding: 0 19px;
|
||||
color: var(--v-button-color);
|
||||
font-weight: var(--v-button-font-weight);
|
||||
font-size: var(--v-button-font-size);
|
||||
line-height: var(--v-button-line-height);
|
||||
text-decoration: none;
|
||||
background-color: var(--v-button-background-color);
|
||||
border: var(--border-width) solid var(--v-button-background-color);
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
transition: var(--fast) var(--transition);
|
||||
transition-property: background-color border;
|
||||
}
|
||||
|
||||
.button:focus,
|
||||
.button:hover {
|
||||
color: var(--v-button-color-hover);
|
||||
background-color: var(--v-button-background-color-hover);
|
||||
border-color: var(--v-button-background-color-hover);
|
||||
}
|
||||
|
||||
.align-left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.align-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
color: var(--v-button-color-disabled);
|
||||
background-color: var(--v-button-background-color-disabled);
|
||||
border: var(--border-width) solid var(--v-button-background-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.rounded,
|
||||
.rounded .button {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.outlined {
|
||||
--v-button-color: var(--v-button-background-color);
|
||||
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.outlined:not(.active):not(:disabled):focus,
|
||||
.outlined:not(.active):not(:disabled):hover {
|
||||
color: var(--v-button-background-color-hover);
|
||||
background-color: transparent;
|
||||
border-color: var(--v-button-background-color-hover);
|
||||
}
|
||||
|
||||
.outlined.secondary {
|
||||
--v-button-color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.outlined.active {
|
||||
background-color: var(--v-button-background-color);
|
||||
}
|
||||
|
||||
.dashed {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.x-small {
|
||||
--v-button-height: 28px;
|
||||
--v-button-font-size: 12px;
|
||||
--v-button-font-weight: 600;
|
||||
--v-button-min-width: 60px;
|
||||
--border-radius: 4px;
|
||||
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.small {
|
||||
--v-button-height: 36px;
|
||||
--v-button-font-size: 14px;
|
||||
--v-button-min-width: 120px;
|
||||
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.large {
|
||||
--v-button-height: 52px;
|
||||
--v-button-min-width: 154px;
|
||||
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.x-large {
|
||||
--v-button-height: 60px;
|
||||
--v-button-font-size: 18px;
|
||||
--v-button-min-width: 180px;
|
||||
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: var(--v-button-height);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.button.full-width {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.content,
|
||||
.spinner {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.content.invisible {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.spinner .v-progress-circular {
|
||||
--v-progress-circular-color: var(--v-button-color);
|
||||
--v-progress-circular-background-color: transparent;
|
||||
}
|
||||
|
||||
.active {
|
||||
--v-button-color: var(--v-button-color-active) !important;
|
||||
--v-button-color-hover: var(--v-button-color-active) !important;
|
||||
--v-button-background-color: var(--v-button-background-color-active) !important;
|
||||
--v-button-background-color-hover: var(--v-button-background-color-active) !important;
|
||||
}
|
||||
|
||||
.tile {
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div class="v-card-actions"><slot /></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.v-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--v-card-padding);
|
||||
}
|
||||
|
||||
.v-card-actions > :slotted(.v-button + .v-button) {
|
||||
margin-left: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<div class="v-card-subtitle"><slot /></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-card-subtitle {
|
||||
margin-top: -16px;
|
||||
padding: 16px;
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<div class="v-card-text"><slot /></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-card-text {
|
||||
padding: var(--v-card-padding);
|
||||
padding-top: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div class="v-card-title type-label"><slot /></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-card-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
padding: var(--v-card-padding);
|
||||
font-weight: 600;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<div class="v-card" :class="{ disabled, tile }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tile: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-card-min-width: none;
|
||||
--v-card-max-width: 400px;
|
||||
--v-card-height: auto;
|
||||
--v-card-min-height: none;
|
||||
--v-card-max-height: 90vh;
|
||||
--v-card-padding: 16px;
|
||||
--v-card-background-color: var(--background-subdued);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-card {
|
||||
--border-radius: 6px;
|
||||
--input-height: 60px;
|
||||
--input-padding: 16px; /* (60 - 4 - 24) / 2 */
|
||||
--form-vertical-gap: 52px;
|
||||
|
||||
min-width: var(--v-card-min-width);
|
||||
max-width: var(--v-card-max-width);
|
||||
height: var(--v-card-height);
|
||||
min-height: var(--v-card-min-height);
|
||||
max-height: var(--v-card-max-height);
|
||||
|
||||
/* Page Content Spacing */
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
background-color: var(--v-card-background-color);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
& > :first-child {
|
||||
border-top-left-radius: var(--border-radius);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
|
||||
& > * {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
&.tile {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
import { computed, Ref } from 'vue';
|
||||
|
||||
export function useVisibleChildren(
|
||||
search: Ref<string>,
|
||||
modelValue: Ref<(string | number)[]>,
|
||||
children: Ref<Record<string, any>[]>,
|
||||
showSelectionOnly: Ref<boolean>,
|
||||
itemText: Ref<string>,
|
||||
itemValue: Ref<string>,
|
||||
itemChildren: Ref<string>,
|
||||
parentValue: Ref<string | number>,
|
||||
value: Ref<string | number>
|
||||
) {
|
||||
const visibleChildrenValues = computed(() => {
|
||||
let options = children.value || [];
|
||||
|
||||
if (search.value) {
|
||||
options = options.filter(
|
||||
(child) =>
|
||||
child[itemText.value].toLowerCase().includes(search.value.toLowerCase()) ||
|
||||
childrenHaveSearchMatch(child[itemChildren.value])
|
||||
);
|
||||
}
|
||||
|
||||
if (showSelectionOnly.value) {
|
||||
options = options.filter(
|
||||
(child) =>
|
||||
modelValue.value.includes(child[itemValue.value]) ||
|
||||
childrenHaveValueMatch(child[itemChildren.value]) ||
|
||||
modelValue.value.includes(parentValue.value) ||
|
||||
modelValue.value.includes(value.value)
|
||||
);
|
||||
}
|
||||
|
||||
return options.map((child) => child[itemValue.value]);
|
||||
|
||||
function childrenHaveSearchMatch(children: Record<string, any>[] | undefined): boolean {
|
||||
if (!children) return false;
|
||||
return children.some(
|
||||
(child) =>
|
||||
child[itemText.value].toLowerCase().includes(search.value.toLowerCase()) ||
|
||||
childrenHaveSearchMatch(child[itemChildren.value])
|
||||
);
|
||||
}
|
||||
|
||||
function childrenHaveValueMatch(children: Record<string, any>[] | undefined): boolean {
|
||||
if (!children) return false;
|
||||
return children.some(
|
||||
(child) =>
|
||||
modelValue.value.includes(child[itemValue.value]) || childrenHaveValueMatch(child[itemChildren.value])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return { visibleChildrenValues };
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
<template>
|
||||
<v-list-group v-if="visibleChildrenValues.length > 0" v-show="groupShown" :value="value" arrow-placement="before">
|
||||
<template #activator>
|
||||
<v-checkbox
|
||||
v-model="treeValue"
|
||||
:indeterminate="groupIndeterminateState"
|
||||
:checked="groupCheckedStateOverride"
|
||||
:label="text"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<v-highlight :text="text" :query="search" />
|
||||
</v-checkbox>
|
||||
</template>
|
||||
|
||||
<v-checkbox-tree-checkbox
|
||||
v-for="choice in children"
|
||||
:key="choice[itemValue]"
|
||||
v-model="treeValue"
|
||||
:value-combining="valueCombining"
|
||||
:checked="childrenCheckedStateOverride"
|
||||
:hidden="visibleChildrenValues.includes(choice[itemValue]) === false"
|
||||
:search="search"
|
||||
:item-text="itemText"
|
||||
:item-value="itemValue"
|
||||
:item-children="itemChildren"
|
||||
:text="choice[itemText]"
|
||||
:value="choice[itemValue]"
|
||||
:children="choice[itemChildren]"
|
||||
:disabled="disabled"
|
||||
:show-selection-only="showSelectionOnly"
|
||||
:parent-value="value"
|
||||
/>
|
||||
</v-list-group>
|
||||
|
||||
<v-list-item v-else-if="!hidden" class="item">
|
||||
<v-checkbox v-model="treeValue" :disabled="disabled" :checked="checked" :label="text" :value="value">
|
||||
<v-highlight :text="text" :query="search" />
|
||||
</v-checkbox>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, PropType, toRefs } from 'vue';
|
||||
import { difference } from 'lodash';
|
||||
import { useVisibleChildren } from './use-visible-children';
|
||||
|
||||
type Delta = {
|
||||
added?: (number | string)[];
|
||||
removed?: (number | string)[];
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'VCheckboxTreeCheckbox',
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
children: {
|
||||
type: Array as PropType<Record<string, any>[]>,
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: Array as PropType<(string | number)[]>,
|
||||
default: () => [],
|
||||
},
|
||||
valueCombining: {
|
||||
type: String as PropType<'all' | 'branch' | 'leaf' | 'indeterminate' | 'exclusive'>,
|
||||
required: true,
|
||||
},
|
||||
checked: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
search: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
hidden: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
itemText: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
itemValue: {
|
||||
type: String,
|
||||
default: 'value',
|
||||
},
|
||||
itemChildren: {
|
||||
type: String,
|
||||
default: 'children',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSelectionOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
parentValue: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const { search, modelValue, children, showSelectionOnly, itemText, itemValue, itemChildren, parentValue, value } =
|
||||
toRefs(props);
|
||||
|
||||
const { visibleChildrenValues } = useVisibleChildren(
|
||||
search,
|
||||
modelValue,
|
||||
children,
|
||||
showSelectionOnly,
|
||||
itemText,
|
||||
itemValue,
|
||||
itemChildren,
|
||||
parentValue,
|
||||
value
|
||||
);
|
||||
|
||||
const groupShown = computed(() => {
|
||||
if (props.showSelectionOnly === true && props.modelValue.includes(props.value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !props.hidden;
|
||||
});
|
||||
|
||||
const childrenValues = computed(() => props.children?.map((child) => child[props.itemValue]) || []);
|
||||
|
||||
const treeValue = computed({
|
||||
get() {
|
||||
return props.modelValue || [];
|
||||
},
|
||||
set(newValue: (string | number)[]) {
|
||||
const added = difference(newValue, props.modelValue);
|
||||
const removed = difference(props.modelValue, newValue);
|
||||
|
||||
if (props.children) {
|
||||
switch (props.valueCombining) {
|
||||
case 'all':
|
||||
return emitAll(newValue, { added, removed });
|
||||
case 'branch':
|
||||
return emitBranch(newValue, { added, removed });
|
||||
case 'leaf':
|
||||
return emitLeaf(newValue, { added, removed });
|
||||
case 'indeterminate':
|
||||
return emitIndeterminate(newValue, { added, removed });
|
||||
case 'exclusive':
|
||||
return emitExclusive(newValue, { added, removed });
|
||||
default:
|
||||
return emitValue(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
emitValue(newValue);
|
||||
},
|
||||
});
|
||||
|
||||
const groupCheckedStateOverride = computed(() => {
|
||||
if (props.checked !== null) return props.checked;
|
||||
if (props.valueCombining === 'all') return null;
|
||||
|
||||
if (props.valueCombining === 'leaf') {
|
||||
const leafChildrenRecursive = getRecursiveChildrenValues('leaf');
|
||||
return leafChildrenRecursive.every((childVal) => props.modelValue.includes(childVal));
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const groupIndeterminateState = computed(() => {
|
||||
const allChildrenValues = getRecursiveChildrenValues('all');
|
||||
|
||||
if (props.valueCombining === 'all' || props.valueCombining === 'branch') {
|
||||
return (
|
||||
allChildrenValues.some((childVal) => props.modelValue.includes(childVal)) &&
|
||||
props.modelValue.includes(props.value) === false
|
||||
);
|
||||
}
|
||||
|
||||
if (props.valueCombining === 'indeterminate') {
|
||||
return (
|
||||
allChildrenValues.some((childVal) => props.modelValue.includes(childVal)) &&
|
||||
allChildrenValues.every((childVal) => props.modelValue.includes(childVal)) === false
|
||||
);
|
||||
}
|
||||
|
||||
if (props.valueCombining === 'leaf') {
|
||||
const leafChildrenRecursive = getRecursiveChildrenValues('leaf');
|
||||
return (
|
||||
leafChildrenRecursive.some((childVal) => props.modelValue.includes(childVal)) &&
|
||||
leafChildrenRecursive.every((childVal) => props.modelValue.includes(childVal)) === false
|
||||
);
|
||||
}
|
||||
|
||||
if (props.valueCombining === 'exclusive') {
|
||||
return allChildrenValues.some((childVal) => props.modelValue.includes(childVal));
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const childrenCheckedStateOverride = computed(() => {
|
||||
if (props.checked !== null) return props.checked;
|
||||
if (props.valueCombining === 'all') return null;
|
||||
|
||||
if (props.valueCombining === 'branch') {
|
||||
if (props.modelValue.includes(props.value)) return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
return {
|
||||
groupCheckedStateOverride,
|
||||
childrenCheckedStateOverride,
|
||||
treeValue,
|
||||
groupIndeterminateState,
|
||||
visibleChildrenValues,
|
||||
groupShown,
|
||||
};
|
||||
|
||||
function emitAll(rawValue: (string | number)[], { added, removed }: Delta) {
|
||||
const childrenValuesRecursive = getRecursiveChildrenValues('all');
|
||||
|
||||
// When enabling the group level
|
||||
if (added?.[0] === props.value) {
|
||||
const newValue = [
|
||||
...rawValue.filter((val) => val !== props.value && childrenValues.value.includes(val) === false),
|
||||
...childrenValuesRecursive,
|
||||
props.value,
|
||||
];
|
||||
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
// When disabling the group level
|
||||
if (removed?.[0] === props.value) {
|
||||
const newValue = rawValue.filter(
|
||||
(val) => val !== props.value && childrenValuesRecursive.includes(val) === false
|
||||
);
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
// When all children are clicked
|
||||
if (childrenValues.value.every((childVal) => rawValue.includes(childVal))) {
|
||||
const newValue = [
|
||||
...rawValue.filter((val) => val !== props.value && childrenValuesRecursive.includes(val) === false),
|
||||
...childrenValuesRecursive,
|
||||
props.value,
|
||||
];
|
||||
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
const newValue = rawValue.filter((val) => val !== props.value);
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
function emitBranch(rawValue: (string | number)[], { added, removed }: Delta) {
|
||||
const allChildrenRecursive = getRecursiveChildrenValues('all');
|
||||
|
||||
// Note: Added/removed is a tad confusing here, as an item that gets added to the array of
|
||||
// selected items can immediately be negated by the logic below, as it's potentially
|
||||
// replaced by the parent item's value
|
||||
|
||||
// When clicking on an individual item in the enabled group
|
||||
if (
|
||||
(props.modelValue.includes(props.value) || props.checked === true) &&
|
||||
added &&
|
||||
added.length === 1 &&
|
||||
childrenValues.value.includes(added[0])
|
||||
) {
|
||||
const newValue = [
|
||||
...rawValue.filter((val) => val !== props.value && val !== added[0]),
|
||||
...childrenValues.value.filter((childVal) => childVal !== added[0]),
|
||||
];
|
||||
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
// When a childgroup is modified
|
||||
if (
|
||||
props.modelValue.includes(props.value) &&
|
||||
allChildrenRecursive.some((childVal) => rawValue.includes(childVal))
|
||||
) {
|
||||
const childThatContainsSelection = props.children.find((child) => {
|
||||
const childNestedValues = getRecursiveChildrenValues('all', child[props.itemChildren]);
|
||||
return rawValue.some((rawVal) => childNestedValues.includes(rawVal)) === true;
|
||||
});
|
||||
|
||||
const newValue = [
|
||||
...rawValue.filter((val) => val !== props.value),
|
||||
...(props.children || [])
|
||||
.filter((child) => {
|
||||
if (!child[props.itemChildren]) return true;
|
||||
return child[props.itemValue] !== childThatContainsSelection?.[props.itemValue];
|
||||
})
|
||||
.map((child) => child[props.itemValue]),
|
||||
...(childThatContainsSelection?.[props.itemChildren] ?? [])
|
||||
.filter((grandChild: Record<string, any>) => {
|
||||
const childNestedValues = getRecursiveChildrenValues('all', grandChild[props.itemChildren]);
|
||||
return rawValue.some((rawVal) => childNestedValues.includes(rawVal)) === false;
|
||||
})
|
||||
.map((grandChild: Record<string, any>) => grandChild[props.itemValue]),
|
||||
];
|
||||
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
// When enabling the group level
|
||||
if (added?.includes(props.value)) {
|
||||
const newValue = [
|
||||
...rawValue.filter((val) => val !== props.value && allChildrenRecursive.includes(val) === false),
|
||||
props.value,
|
||||
];
|
||||
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
// When disabling the group level
|
||||
if (removed?.includes(props.value)) {
|
||||
const newValue = rawValue.filter((val) => val !== props.value && allChildrenRecursive.includes(val) === false);
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
// When all children are clicked
|
||||
if (childrenValues.value.every((childVal) => rawValue.includes(childVal))) {
|
||||
const newValue = [
|
||||
...rawValue.filter((val) => val !== props.value && allChildrenRecursive.includes(val) === false),
|
||||
props.value,
|
||||
];
|
||||
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
return emitValue(rawValue);
|
||||
}
|
||||
|
||||
function emitLeaf(rawValue: (string | number)[], { added }: Delta) {
|
||||
const allChildrenRecursive = getRecursiveChildrenValues('all');
|
||||
const leafChildrenRecursive = getRecursiveChildrenValues('leaf');
|
||||
|
||||
// When enabling the group level
|
||||
if (added?.includes(props.value)) {
|
||||
if (leafChildrenRecursive.every((childVal) => rawValue.includes(childVal))) {
|
||||
const newValue = rawValue.filter(
|
||||
(val) => val !== props.value && allChildrenRecursive.includes(val) === false
|
||||
);
|
||||
return emitValue(newValue);
|
||||
} else {
|
||||
const newValue = [
|
||||
...rawValue.filter((val) => val !== props.value && allChildrenRecursive.includes(val) === false),
|
||||
...leafChildrenRecursive,
|
||||
];
|
||||
|
||||
return emitValue(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
return emitValue(rawValue);
|
||||
}
|
||||
|
||||
function emitIndeterminate(rawValue: (string | number)[], { added, removed }: Delta) {
|
||||
const childrenValuesRecursive = getRecursiveChildrenValues('all');
|
||||
|
||||
// When enabling the group level
|
||||
if (added?.[0] === props.value) {
|
||||
const newValue = [
|
||||
...rawValue.filter((val) => val !== props.value && childrenValues.value.includes(val) === false),
|
||||
...childrenValuesRecursive,
|
||||
props.value,
|
||||
];
|
||||
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
// When disabling the group level
|
||||
if (removed?.[0] === props.value) {
|
||||
const newValue = rawValue.filter(
|
||||
(val) => val !== props.value && childrenValuesRecursive.includes(val) === false
|
||||
);
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
// When a child value is clicked
|
||||
if (childrenValues.value.some((childVal) => rawValue.includes(childVal))) {
|
||||
const newValue = [...rawValue.filter((val) => val !== props.value), props.value];
|
||||
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
// When no children are clicked
|
||||
if (childrenValues.value.every((childVal) => rawValue.includes(childVal) === false)) {
|
||||
return emitValue(rawValue.filter((val) => val !== props.value));
|
||||
}
|
||||
|
||||
return emitValue(rawValue);
|
||||
}
|
||||
|
||||
function emitExclusive(rawValue: (string | number)[], { added }: Delta) {
|
||||
const childrenValuesRecursive = getRecursiveChildrenValues('all');
|
||||
|
||||
// When enabling the group level
|
||||
if (added?.[0] === props.value) {
|
||||
const newValue = [
|
||||
...rawValue.filter((val) => val !== props.value && childrenValuesRecursive.includes(val) === false),
|
||||
props.value,
|
||||
];
|
||||
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
// When a child value is clicked
|
||||
if (childrenValuesRecursive.some((childVal) => rawValue.includes(childVal))) {
|
||||
const newValue = [...rawValue.filter((val) => val !== props.value)];
|
||||
return emitValue(newValue);
|
||||
}
|
||||
|
||||
return emitValue(rawValue);
|
||||
}
|
||||
|
||||
function emitValue(newValue: (string | number)[]) {
|
||||
emit('update:modelValue', newValue);
|
||||
}
|
||||
|
||||
function getRecursiveChildrenValues(
|
||||
mode: 'all' | 'branch' | 'leaf',
|
||||
children: Record<string, any>[] = props.children
|
||||
) {
|
||||
const values: (string | number)[] = [];
|
||||
|
||||
getChildrenValuesRecursive(children);
|
||||
|
||||
return values;
|
||||
|
||||
function getChildrenValuesRecursive(children: Record<string, any>[]) {
|
||||
if (!children) return;
|
||||
|
||||
for (const child of children) {
|
||||
if (mode === 'all') {
|
||||
values.push(child[props.itemValue]);
|
||||
}
|
||||
|
||||
if (mode === 'branch' && child[props.itemChildren]) {
|
||||
values.push(child[props.itemValue]);
|
||||
}
|
||||
|
||||
if (mode === 'leaf' && !child[props.itemChildren]) {
|
||||
values.push(child[props.itemValue]);
|
||||
}
|
||||
|
||||
if (child[props.itemChildren]) {
|
||||
getChildrenValuesRecursive(child[props.itemChildren]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
padding-left: 32px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,164 +0,0 @@
|
||||
<template>
|
||||
<v-list v-model="openSelection" :mandatory="false" @toggle="$emit('group-toggle', $event)">
|
||||
<v-checkbox-tree-checkbox
|
||||
v-for="choice in choices"
|
||||
:key="choice[itemValue]"
|
||||
v-model="value"
|
||||
:value-combining="valueCombining"
|
||||
:search="search"
|
||||
:item-text="itemText"
|
||||
:item-value="itemValue"
|
||||
:item-children="itemChildren"
|
||||
:text="choice[itemText]"
|
||||
:hidden="visibleChildrenValues.includes(choice[itemValue]) === false"
|
||||
:value="choice[itemValue]"
|
||||
:children="choice[itemChildren]"
|
||||
:disabled="disabled"
|
||||
:show-selection-only="showSelectionOnly"
|
||||
/>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, ref, defineComponent, PropType, watch, toRefs } from 'vue';
|
||||
import { useVisibleChildren } from './use-visible-children';
|
||||
import VCheckboxTreeCheckbox from './v-checkbox-tree-checkbox.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'VCheckboxTree',
|
||||
components: { VCheckboxTreeCheckbox },
|
||||
props: {
|
||||
choices: {
|
||||
type: Array as PropType<Record<string, any>[]>,
|
||||
default: () => [],
|
||||
},
|
||||
modelValue: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: null,
|
||||
},
|
||||
valueCombining: {
|
||||
type: String as PropType<'all' | 'branch' | 'leaf' | 'indeterminate' | 'exclusive'>,
|
||||
default: 'all',
|
||||
},
|
||||
search: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
itemText: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
itemValue: {
|
||||
type: String,
|
||||
default: 'value',
|
||||
},
|
||||
itemChildren: {
|
||||
type: String,
|
||||
default: 'children',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSelectionOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'group-toggle'],
|
||||
setup(props, { emit }) {
|
||||
const value = computed({
|
||||
get() {
|
||||
return props.modelValue || [];
|
||||
},
|
||||
set(newValue: string[]) {
|
||||
emit('update:modelValue', newValue);
|
||||
},
|
||||
});
|
||||
|
||||
const fakeValue = ref('');
|
||||
const fakeParentValue = ref('');
|
||||
|
||||
const { search, modelValue, showSelectionOnly, itemText, itemValue, itemChildren, choices } = toRefs(props);
|
||||
|
||||
const { visibleChildrenValues } = useVisibleChildren(
|
||||
search,
|
||||
modelValue,
|
||||
choices,
|
||||
showSelectionOnly,
|
||||
itemText,
|
||||
itemValue,
|
||||
itemChildren,
|
||||
fakeParentValue,
|
||||
fakeValue
|
||||
);
|
||||
|
||||
let showAllSelection: (string | number)[] = [];
|
||||
const openSelection = ref<(string | number)[]>([]);
|
||||
|
||||
watch(
|
||||
() => props.search,
|
||||
(newValue) => {
|
||||
if (!newValue) return;
|
||||
|
||||
const selection = new Set([...openSelection.value, ...searchChoices(newValue, props.choices)]);
|
||||
|
||||
openSelection.value = [...selection];
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(showSelectionOnly, (isSelectionOnly) => {
|
||||
if (isSelectionOnly) {
|
||||
const selection = new Set([...openSelection.value, ...findSelectedChoices(props.choices, value.value)]);
|
||||
|
||||
showAllSelection = openSelection.value;
|
||||
openSelection.value = [...selection];
|
||||
} else {
|
||||
openSelection.value = [...showAllSelection];
|
||||
}
|
||||
});
|
||||
|
||||
function searchChoices(text: string, target: Record<string, any>[]) {
|
||||
const selection: string[] = [];
|
||||
|
||||
for (const item of target) {
|
||||
if (item[props.itemText].toLowerCase().includes(text.toLowerCase())) {
|
||||
selection.push(item[props.itemValue]);
|
||||
}
|
||||
|
||||
if (item[props.itemChildren]) {
|
||||
selection.push(...searchChoices(text, item[props.itemChildren]));
|
||||
}
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
function findSelectedChoices(choices: Record<string, any>[], checked: (string | number)[]) {
|
||||
function selectedChoices(item: Record<string, any>): (string | number)[] {
|
||||
if (!item[props.itemValue]) return [];
|
||||
let result = [];
|
||||
|
||||
const itemValue: string | number = item[props.itemValue];
|
||||
if (checked.includes(itemValue)) result.push(itemValue);
|
||||
|
||||
if (item[props.itemChildren]) {
|
||||
const children = item[props.itemChildren];
|
||||
if (Array.isArray(children) && children.length > 0) {
|
||||
const nestedResult = children.flatMap((child) => selectedChoices(child));
|
||||
if (nestedResult.length > 0) {
|
||||
result.push(...nestedResult, itemValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return choices.flatMap((item) => selectedChoices(item));
|
||||
}
|
||||
|
||||
return { value, openSelection, visibleChildrenValues };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,248 +0,0 @@
|
||||
<template>
|
||||
<component
|
||||
:is="customValue ? 'div' : 'button'"
|
||||
class="v-checkbox"
|
||||
type="button"
|
||||
role="checkbox"
|
||||
:aria-pressed="isChecked ? 'true' : 'false'"
|
||||
:disabled="disabled"
|
||||
:class="{ checked: isChecked, indeterminate, block }"
|
||||
@click.stop="toggleInput"
|
||||
>
|
||||
<div v-if="$slots.prepend" class="prepend"><slot name="prepend" /></div>
|
||||
<v-icon class="checkbox" :name="icon" :disabled="disabled" />
|
||||
<span class="label type-text">
|
||||
<slot v-if="customValue === false">{{ label }}</slot>
|
||||
<input v-else v-model="internalValue" class="custom-input" />
|
||||
</span>
|
||||
<div v-if="$slots.append" class="append"><slot name="append" /></div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { useSync } from '@directus/shared/composables';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: [Boolean, Array],
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
indeterminate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
iconOn: {
|
||||
type: String,
|
||||
default: 'check_box',
|
||||
},
|
||||
iconOff: {
|
||||
type: String,
|
||||
default: 'check_box_outline_blank',
|
||||
},
|
||||
iconIndeterminate: {
|
||||
type: String,
|
||||
default: 'indeterminate_check_box',
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
checked: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['update:indeterminate', 'update:modelValue', 'update:value'],
|
||||
setup(props, { emit }) {
|
||||
const internalValue = useSync(props, 'value', emit);
|
||||
|
||||
const isChecked = computed<boolean>(() => {
|
||||
if (props.checked !== null) return props.checked;
|
||||
|
||||
if (props.modelValue instanceof Array) {
|
||||
return props.modelValue.includes(props.value);
|
||||
}
|
||||
|
||||
return props.modelValue === true;
|
||||
});
|
||||
|
||||
const icon = computed<string>(() => {
|
||||
if (props.indeterminate === true) return props.iconIndeterminate;
|
||||
if (props.checked === null && props.modelValue === null) return props.iconIndeterminate;
|
||||
|
||||
return isChecked.value ? props.iconOn : props.iconOff;
|
||||
});
|
||||
|
||||
return { isChecked, toggleInput, icon, internalValue };
|
||||
|
||||
function toggleInput(): void {
|
||||
if (props.indeterminate === true) {
|
||||
emit('update:indeterminate', false);
|
||||
}
|
||||
|
||||
if (props.modelValue instanceof Array) {
|
||||
const newValue = [...props.modelValue];
|
||||
|
||||
if (props.modelValue.includes(props.value) === false) {
|
||||
newValue.push(props.value);
|
||||
} else {
|
||||
newValue.splice(newValue.indexOf(props.value), 1);
|
||||
}
|
||||
|
||||
emit('update:modelValue', newValue);
|
||||
} else {
|
||||
emit('update:modelValue', !props.modelValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-checkbox-color: var(--primary);
|
||||
--v-checkbox-unchecked-color: var(--foreground-subdued);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/no-wrap';
|
||||
|
||||
.v-checkbox {
|
||||
--v-icon-color: var(--v-checkbox-unchecked-color);
|
||||
--v-icon-color-hover: var(--primary);
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0;
|
||||
text-align: left;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
appearance: none;
|
||||
|
||||
.label:not(:empty) {
|
||||
flex-grow: 1;
|
||||
margin-left: 8px;
|
||||
transition: color var(--fast) var(--transition);
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--border-normal);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@include no-wrap;
|
||||
}
|
||||
|
||||
& .checkbox {
|
||||
--v-icon-color: var(--v-checkbox-unchecked-color);
|
||||
|
||||
transition: color var(--fast) var(--transition);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.label {
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
}
|
||||
}
|
||||
|
||||
&.block {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
padding: 10px; // 14 - 4 (border)
|
||||
background-color: var(--background-page);
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius);
|
||||
transition: all var(--fast) var(--transition);
|
||||
|
||||
&:disabled {
|
||||
background-color: var(--background-subdued);
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--border-radius);
|
||||
content: '';
|
||||
}
|
||||
|
||||
> * {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
.checkbox {
|
||||
--v-icon-color: var(--primary);
|
||||
}
|
||||
|
||||
&.block {
|
||||
background-color: var(--background-subdued);
|
||||
border-color: var(--border-normal-alt);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:disabled):not(.indeterminate) {
|
||||
.label {
|
||||
color: var(--foreground-normal);
|
||||
}
|
||||
|
||||
&.block {
|
||||
&::before {
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:disabled):not(.indeterminate).checked {
|
||||
.checkbox {
|
||||
--v-icon-color: var(--v-checkbox-color);
|
||||
}
|
||||
|
||||
&.block {
|
||||
.label {
|
||||
color: var(--v-checkbox-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prepend,
|
||||
.append {
|
||||
display: contents;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,197 +0,0 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="internalActive"
|
||||
class="v-chip"
|
||||
:class="[sizeClass, { outlined, label, disabled, close }]"
|
||||
@click="onClick"
|
||||
>
|
||||
<span class="chip-content">
|
||||
<slot />
|
||||
<span v-if="close" class="close-outline" :class="{ disabled }" @click.stop="onCloseClick">
|
||||
<v-icon class="close" :name="closeIcon" x-small />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed } from 'vue';
|
||||
import { useSizeClass, sizeProps } from '@/composables/use-size-class';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
close: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closeIcon: {
|
||||
type: String,
|
||||
default: 'close',
|
||||
},
|
||||
outlined: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
...sizeProps,
|
||||
},
|
||||
emits: ['update:active', 'click', 'close'],
|
||||
setup(props, { emit }) {
|
||||
const internalLocalActive = ref(true);
|
||||
|
||||
const internalActive = computed<boolean>({
|
||||
get: () => {
|
||||
if (props.active !== null) return props.active;
|
||||
return internalLocalActive.value;
|
||||
},
|
||||
set: (active: boolean) => {
|
||||
emit('update:active', active);
|
||||
internalLocalActive.value = active;
|
||||
},
|
||||
});
|
||||
|
||||
const sizeClass = useSizeClass(props);
|
||||
|
||||
return { sizeClass, internalActive, onClick, onCloseClick };
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (props.disabled) return;
|
||||
emit('click', event);
|
||||
}
|
||||
|
||||
function onCloseClick(event: MouseEvent) {
|
||||
if (props.disabled) return;
|
||||
internalActive.value = !internalActive.value;
|
||||
emit('close', event);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-chip-color: var(--foreground-normal);
|
||||
--v-chip-background-color: var(--background-normal-alt);
|
||||
--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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
color: var(--v-chip-color);
|
||||
font-weight: var(--weight-normal);
|
||||
line-height: 22px;
|
||||
background-color: var(--v-chip-background-color);
|
||||
border: var(--border-width) solid var(--v-chip-background-color);
|
||||
border-radius: 16px;
|
||||
|
||||
&.clickable:hover {
|
||||
color: var(--v-chip-color-hover);
|
||||
background-color: var(--v-chip-background-color-hover);
|
||||
border-color: var(--v-chip-background-color-hover);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.outlined {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
color: var(--v-chip-color);
|
||||
background-color: var(--v-chip-background-color);
|
||||
border-color: var(--v-chip-background-color);
|
||||
|
||||
&.clickable:hover {
|
||||
color: var(--v-chip-color);
|
||||
background-color: var(--v-chip-background-color);
|
||||
border-color: var(--v-chip-background-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.x-small {
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: 24px;
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
&.large {
|
||||
height: 44px;
|
||||
padding: 0 20px;
|
||||
font-size: 16px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
&.x-large {
|
||||
height: 48px;
|
||||
padding: 0 20px;
|
||||
font-size: 18px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
&.label {
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.chip-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
.close-outline {
|
||||
position: relative;
|
||||
right: -4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-left: 4px;
|
||||
background-color: var(--v-chip-close-color);
|
||||
border-radius: 10px;
|
||||
|
||||
.close {
|
||||
--v-icon-color: var(--v-chip-background-color);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background-color: var(--v-chip-close-color-disabled);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--v-chip-close-color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--v-chip-close-color-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,151 +4,138 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, ref, onMounted, onBeforeUnmount, computed, PropType, watch } from 'vue';
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue';
|
||||
import Flatpickr from 'flatpickr';
|
||||
import { format, formatISO } from 'date-fns';
|
||||
import { getFlatpickrLocale } from '@/utils/get-flatpickr-locale';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String as PropType<'timestamp' | 'dateTime' | 'time' | 'date'>,
|
||||
required: true,
|
||||
validator: (val: string) => ['dateTime', 'date', 'time', 'timestamp'].includes(val),
|
||||
},
|
||||
includeSeconds: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
use24: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'close'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
interface Props {
|
||||
type: 'date' | 'time' | 'dateTime' | 'timestamp';
|
||||
modelValue?: string;
|
||||
disabled?: boolean;
|
||||
includeSeconds?: boolean;
|
||||
use24?: boolean;
|
||||
}
|
||||
|
||||
const wrapper = ref<HTMLElement | null>(null);
|
||||
let flatpickr: Flatpickr.Instance | null;
|
||||
|
||||
onMounted(async () => {
|
||||
if (wrapper.value) {
|
||||
const flatpickrLocale = await getFlatpickrLocale();
|
||||
flatpickr = Flatpickr(wrapper.value, { ...flatpickrOptions.value, locale: flatpickrLocale });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (props.modelValue) {
|
||||
flatpickr?.setDate(props.modelValue, false);
|
||||
} else {
|
||||
flatpickr?.clear();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (flatpickr) {
|
||||
flatpickr.close();
|
||||
flatpickr = null;
|
||||
}
|
||||
});
|
||||
|
||||
const defaultOptions = {
|
||||
static: true,
|
||||
inline: true,
|
||||
nextArrow:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z"/></svg>',
|
||||
prevArrow:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12l4.58-4.59z"/></svg>',
|
||||
wrap: true,
|
||||
|
||||
onChange(selectedDates: Date[], _dateStr: string, _instance: Flatpickr.Instance) {
|
||||
const selectedDate = selectedDates.length > 0 ? selectedDates[0] : null;
|
||||
emitValue(selectedDate);
|
||||
},
|
||||
onClose(selectedDates: Date[], _dateStr: string, _instance: Flatpickr.Instance) {
|
||||
const selectedDate = selectedDates.length > 0 ? selectedDates[0] : null;
|
||||
emitValue(selectedDate);
|
||||
},
|
||||
onReady(_selectedDates: Date[], _dateStr: string, instance: Flatpickr.Instance) {
|
||||
const setToNowButton: HTMLElement = document.createElement('button');
|
||||
setToNowButton.innerHTML = t('interfaces.datetime.set_to_now');
|
||||
setToNowButton.classList.add('set-to-now-button');
|
||||
setToNowButton.tabIndex = -1;
|
||||
setToNowButton.addEventListener('click', setToNow);
|
||||
instance.calendarContainer.appendChild(setToNowButton);
|
||||
|
||||
if (!props.use24) {
|
||||
instance.amPM?.addEventListener('keyup', enterToClose);
|
||||
} else if (props.includeSeconds) {
|
||||
instance.secondElement?.addEventListener('keyup', enterToClose);
|
||||
} else {
|
||||
instance.minuteElement?.addEventListener('keyup', enterToClose);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const flatpickrOptions = computed<Record<string, any>>(() => {
|
||||
return Object.assign({}, defaultOptions, {
|
||||
enableSeconds: props.includeSeconds,
|
||||
enableTime: ['dateTime', 'time', 'timestamp'].includes(props.type),
|
||||
noCalendar: props.type === 'time',
|
||||
time_24hr: props.use24,
|
||||
});
|
||||
});
|
||||
|
||||
function emitValue(value: Date | null) {
|
||||
if (!value) return emit('update:modelValue', null);
|
||||
|
||||
switch (props.type) {
|
||||
case 'dateTime':
|
||||
emit('update:modelValue', format(value, "yyyy-MM-dd'T'HH:mm:ss"));
|
||||
break;
|
||||
case 'date':
|
||||
emit('update:modelValue', format(value, 'yyyy-MM-dd'));
|
||||
break;
|
||||
case 'time':
|
||||
emit('update:modelValue', format(value, 'HH:mm:ss'));
|
||||
break;
|
||||
case 'timestamp':
|
||||
emit('update:modelValue', formatISO(value));
|
||||
break;
|
||||
}
|
||||
|
||||
// close the calendar on input change if it's only a date picker without time input
|
||||
if (props.type === 'date') {
|
||||
emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
function setToNow() {
|
||||
flatpickr?.setDate(new Date(), true);
|
||||
}
|
||||
|
||||
function enterToClose(e: any) {
|
||||
if (e.key !== 'Enter') return;
|
||||
flatpickr?.close();
|
||||
emit('close');
|
||||
}
|
||||
|
||||
return { t, wrapper };
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: undefined,
|
||||
disabled: false,
|
||||
includeSeconds: false,
|
||||
use24: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const wrapper = ref<HTMLElement | null>(null);
|
||||
let flatpickr: Flatpickr.Instance | null;
|
||||
|
||||
onMounted(async () => {
|
||||
if (wrapper.value) {
|
||||
const flatpickrLocale = await getFlatpickrLocale();
|
||||
flatpickr = Flatpickr(wrapper.value, { ...flatpickrOptions.value, locale: flatpickrLocale } as any);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (props.modelValue) {
|
||||
flatpickr?.setDate(props.modelValue, false);
|
||||
} else {
|
||||
flatpickr?.clear();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (flatpickr) {
|
||||
flatpickr.close();
|
||||
flatpickr = null;
|
||||
}
|
||||
});
|
||||
|
||||
const defaultOptions = {
|
||||
static: true,
|
||||
inline: true,
|
||||
nextArrow:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z"/></svg>',
|
||||
prevArrow:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12l4.58-4.59z"/></svg>',
|
||||
wrap: true,
|
||||
|
||||
onChange(selectedDates: Date[], _dateStr: string, _instance: Flatpickr.Instance) {
|
||||
const selectedDate = selectedDates.length > 0 ? selectedDates[0] : null;
|
||||
emitValue(selectedDate);
|
||||
},
|
||||
onClose(selectedDates: Date[], _dateStr: string, _instance: Flatpickr.Instance) {
|
||||
const selectedDate = selectedDates.length > 0 ? selectedDates[0] : null;
|
||||
emitValue(selectedDate);
|
||||
},
|
||||
onReady(_selectedDates: Date[], _dateStr: string, instance: Flatpickr.Instance) {
|
||||
const setToNowButton: HTMLElement = document.createElement('button');
|
||||
setToNowButton.innerHTML = t('interfaces.datetime.set_to_now');
|
||||
setToNowButton.classList.add('set-to-now-button');
|
||||
setToNowButton.tabIndex = -1;
|
||||
setToNowButton.addEventListener('click', setToNow);
|
||||
instance.calendarContainer.appendChild(setToNowButton);
|
||||
|
||||
if (!props.use24) {
|
||||
instance.amPM?.addEventListener('keyup', enterToClose);
|
||||
} else if (props.includeSeconds) {
|
||||
instance.secondElement?.addEventListener('keyup', enterToClose);
|
||||
} else {
|
||||
instance.minuteElement?.addEventListener('keyup', enterToClose);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const flatpickrOptions = computed<Record<string, any>>(() => {
|
||||
return Object.assign({}, defaultOptions, {
|
||||
enableSeconds: props.includeSeconds,
|
||||
enableTime: ['dateTime', 'time', 'timestamp'].includes(props.type),
|
||||
noCalendar: props.type === 'time',
|
||||
time_24hr: props.use24,
|
||||
});
|
||||
});
|
||||
|
||||
function emitValue(value: Date | null) {
|
||||
if (!value) return emit('update:modelValue', null);
|
||||
|
||||
switch (props.type) {
|
||||
case 'dateTime':
|
||||
emit('update:modelValue', format(value, "yyyy-MM-dd'T'HH:mm:ss"));
|
||||
break;
|
||||
case 'date':
|
||||
emit('update:modelValue', format(value, 'yyyy-MM-dd'));
|
||||
break;
|
||||
case 'time':
|
||||
emit('update:modelValue', format(value, 'HH:mm:ss'));
|
||||
break;
|
||||
case 'timestamp':
|
||||
emit('update:modelValue', formatISO(value));
|
||||
break;
|
||||
}
|
||||
|
||||
// close the calendar on input change if it's only a date picker without time input
|
||||
if (props.type === 'date') {
|
||||
emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
function setToNow() {
|
||||
flatpickr?.setDate(new Date(), true);
|
||||
}
|
||||
|
||||
function enterToClose(e: any) {
|
||||
if (e.key !== 'Enter') return;
|
||||
flatpickr?.close();
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -14,61 +14,52 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref } from 'vue';
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { i18n } from '@/lang';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: i18n.global.t('toggle'),
|
||||
},
|
||||
startOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
interface Props {
|
||||
modelValue?: boolean;
|
||||
label?: string;
|
||||
startOpen?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: undefined,
|
||||
label: i18n.global.t('toggle'),
|
||||
startOpen: false,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const localActive = ref(props.startOpen);
|
||||
|
||||
const internalActive = computed({
|
||||
get() {
|
||||
if (props.modelValue !== undefined) {
|
||||
return props.modelValue;
|
||||
}
|
||||
return localActive.value;
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const localActive = ref(props.startOpen);
|
||||
|
||||
const internalActive = computed({
|
||||
get() {
|
||||
if (props.modelValue !== undefined) {
|
||||
return props.modelValue;
|
||||
}
|
||||
return localActive.value;
|
||||
},
|
||||
set(newActive: boolean) {
|
||||
localActive.value = newActive;
|
||||
emit('update:modelValue', newActive);
|
||||
},
|
||||
});
|
||||
|
||||
return { internalActive, enable, disable, toggle };
|
||||
|
||||
function enable() {
|
||||
internalActive.value = true;
|
||||
}
|
||||
|
||||
function disable() {
|
||||
internalActive.value = false;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
internalActive.value = !internalActive.value;
|
||||
}
|
||||
set(newActive: boolean) {
|
||||
localActive.value = newActive;
|
||||
emit('update:modelValue', newActive);
|
||||
},
|
||||
});
|
||||
|
||||
function enable() {
|
||||
internalActive.value = true;
|
||||
}
|
||||
|
||||
function disable() {
|
||||
internalActive.value = false;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
internalActive.value = !internalActive.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -18,73 +18,63 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed } from 'vue';
|
||||
import { nanoid } from 'nanoid';
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useShortcut } from '@/composables/use-shortcut';
|
||||
import { useDialogRouteLeave } from '@/composables/use-dialog-route';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
persistent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'center',
|
||||
validator: (val: string) => ['center', 'right'].includes(val),
|
||||
},
|
||||
interface Props {
|
||||
modelValue?: boolean;
|
||||
persistent?: boolean;
|
||||
placement?: 'right' | 'center';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: undefined,
|
||||
persistent: false,
|
||||
placement: 'center',
|
||||
});
|
||||
|
||||
const emit = defineEmits(['esc', 'update:modelValue']);
|
||||
|
||||
useShortcut('escape', (event, cancelNext) => {
|
||||
if (internalActive.value) {
|
||||
emit('esc');
|
||||
cancelNext();
|
||||
}
|
||||
});
|
||||
|
||||
const localActive = ref(false);
|
||||
|
||||
const className = ref<string | null>(null);
|
||||
|
||||
const internalActive = computed({
|
||||
get() {
|
||||
return props.modelValue !== undefined ? props.modelValue : localActive.value;
|
||||
},
|
||||
emits: ['esc', 'update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
useShortcut('escape', (event, cancelNext) => {
|
||||
if (internalActive.value) {
|
||||
emit('esc');
|
||||
cancelNext();
|
||||
}
|
||||
});
|
||||
|
||||
const localActive = ref(false);
|
||||
|
||||
const className = ref<string | null>(null);
|
||||
const id = computed(() => nanoid());
|
||||
|
||||
const internalActive = computed({
|
||||
get() {
|
||||
return props.modelValue !== undefined ? props.modelValue : localActive.value;
|
||||
},
|
||||
set(newActive: boolean) {
|
||||
localActive.value = newActive;
|
||||
emit('update:modelValue', newActive);
|
||||
},
|
||||
});
|
||||
|
||||
const leave = useDialogRouteLeave();
|
||||
|
||||
return { emitToggle, className, nudge, leave, id, internalActive };
|
||||
|
||||
function emitToggle() {
|
||||
if (props.persistent === false) {
|
||||
emit('update:modelValue', !props.modelValue);
|
||||
} else {
|
||||
nudge();
|
||||
}
|
||||
}
|
||||
|
||||
function nudge() {
|
||||
className.value = 'nudge';
|
||||
|
||||
setTimeout(() => {
|
||||
className.value = null;
|
||||
}, 200);
|
||||
}
|
||||
set(newActive: boolean) {
|
||||
localActive.value = newActive;
|
||||
emit('update:modelValue', newActive);
|
||||
},
|
||||
});
|
||||
|
||||
const leave = useDialogRouteLeave();
|
||||
|
||||
function emitToggle() {
|
||||
if (props.persistent === false) {
|
||||
emit('update:modelValue', !props.modelValue);
|
||||
} else {
|
||||
nudge();
|
||||
}
|
||||
}
|
||||
|
||||
function nudge() {
|
||||
className.value = 'nudge';
|
||||
|
||||
setTimeout(() => {
|
||||
className.value = null;
|
||||
}, 200);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<template>
|
||||
<div class="v-divider" :class="{ vertical, inlineTitle, large }">
|
||||
<span v-if="$slots.icon || $slots.default" class="wrapper">
|
||||
<slot name="icon" class="icon" />
|
||||
<span v-if="!vertical && $slots.default" class="type-text"><slot /></span>
|
||||
</span>
|
||||
<hr role="separator" :aria-orientation="vertical ? 'vertical' : 'horizontal'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
vertical: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inlineTitle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-divider-color: var(--border-normal);
|
||||
--v-divider-label-color: var(--foreground-normal-alt);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-divider {
|
||||
flex-basis: 0px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
|
||||
hr {
|
||||
flex-grow: 1;
|
||||
order: 1;
|
||||
max-width: 100%;
|
||||
margin-top: 8px;
|
||||
border: solid;
|
||||
border-color: var(--v-divider-color);
|
||||
border-width: var(--border-width) 0 0 0;
|
||||
}
|
||||
|
||||
span.wrapper {
|
||||
display: flex;
|
||||
color: var(--v-divider-label-color);
|
||||
|
||||
:slotted(.v-icon) {
|
||||
margin-right: 4px;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.type-text {
|
||||
width: 100%;
|
||||
color: var(--v-divider-label-color);
|
||||
font-weight: 600;
|
||||
transition: color var(--fast) var(--transition);
|
||||
}
|
||||
|
||||
&.large .type-text {
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&.inlineTitle {
|
||||
display: flex;
|
||||
|
||||
span.wrapper {
|
||||
order: 0;
|
||||
margin-right: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
height: 100%;
|
||||
|
||||
hr {
|
||||
width: 0px;
|
||||
max-width: 0px;
|
||||
border-width: 0 var(--border-width) 0 0;
|
||||
}
|
||||
|
||||
span.wrapper {
|
||||
order: 0;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@
|
||||
</v-button>
|
||||
|
||||
<div class="content">
|
||||
<v-overlay v-if="$slots.sidebar" absolute @click="sidebarActive = false" />
|
||||
<v-overlay v-if="$slots.sidebar" absolute />
|
||||
|
||||
<nav v-if="$slots.sidebar" class="sidebar">
|
||||
<slot name="sidebar" />
|
||||
@@ -60,67 +60,48 @@
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, ref, computed, provide } from 'vue';
|
||||
import { ref, computed, provide } from 'vue';
|
||||
import HeaderBar from '@/views/private/components/header-bar.vue';
|
||||
import { i18n } from '@/lang';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
HeaderBar,
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
modelValue?: boolean;
|
||||
persistent?: boolean;
|
||||
icon?: string;
|
||||
sidebarLabel?: string;
|
||||
cancelable?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
subtitle: null,
|
||||
modelValue: undefined,
|
||||
persistent: false,
|
||||
icon: 'box',
|
||||
sidebarLabel: i18n.global.t('sidebar'),
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['cancel', 'update:modelValue']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const localActive = ref(false);
|
||||
|
||||
const mainEl = ref<Element>();
|
||||
|
||||
provide('main-element', mainEl);
|
||||
|
||||
const internalActive = computed({
|
||||
get() {
|
||||
return props.modelValue === undefined ? localActive.value : props.modelValue;
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
persistent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'box',
|
||||
},
|
||||
sidebarLabel: {
|
||||
type: String,
|
||||
default: i18n.global.t('sidebar'),
|
||||
},
|
||||
cancelable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['cancel', 'update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const localActive = ref(false);
|
||||
|
||||
const mainEl = ref<Element>();
|
||||
|
||||
provide('main-element', mainEl);
|
||||
|
||||
const internalActive = computed({
|
||||
get() {
|
||||
return props.modelValue === undefined ? localActive.value : props.modelValue;
|
||||
},
|
||||
set(newActive: boolean) {
|
||||
localActive.value = newActive;
|
||||
emit('update:modelValue', newActive);
|
||||
},
|
||||
});
|
||||
|
||||
return { t, internalActive, mainEl };
|
||||
set(newActive: boolean) {
|
||||
localActive.value = newActive;
|
||||
emit('update:modelValue', newActive);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<v-button class="emoji-button" x-small secondary icon @click="emojiPicker.togglePicker($event.target as HTMLElement)">
|
||||
<v-icon name="insert_emoticon" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EmojiButton } from '@joeattardi/emoji-button';
|
||||
import { onUnmounted } from 'vue';
|
||||
|
||||
const emojiPicker = new EmojiButton({
|
||||
theme: 'auto',
|
||||
zIndex: 10000,
|
||||
position: 'bottom',
|
||||
emojisPerRow: 8,
|
||||
});
|
||||
const emit = defineEmits(['emoji-selected']);
|
||||
|
||||
emojiPicker.on('emoji', (event) => {
|
||||
emit('emoji-selected', event.emoji);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
emojiPicker.destroyPicker();
|
||||
});
|
||||
</script>
|
||||
@@ -13,52 +13,46 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, computed, PropType, ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { isPlainObject } from 'lodash';
|
||||
import { useClipboard } from '@/composables/use-clipboard';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
error: {
|
||||
type: [Object, Error] as PropType<Record<string, any>>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
interface Props {
|
||||
error: Record<string, any>;
|
||||
}
|
||||
|
||||
const code = computed(() => {
|
||||
return props.error?.response?.data?.errors?.[0]?.extensions?.code || props.error?.extensions?.code || 'UNKNOWN';
|
||||
});
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const message = computed(() => {
|
||||
let message = props.error?.response?.data?.errors?.[0]?.message || props.error?.message;
|
||||
const { t } = useI18n();
|
||||
|
||||
if (message.length > 200) {
|
||||
message = message.substring(0, 197) + '...';
|
||||
}
|
||||
|
||||
return message;
|
||||
});
|
||||
|
||||
const copied = ref(false);
|
||||
|
||||
const { isCopySupported, copyToClipboard } = useClipboard();
|
||||
|
||||
return { t, code, copyError, isCopySupported, copied, message };
|
||||
|
||||
async function copyError() {
|
||||
const error = props.error?.response?.data || props.error;
|
||||
const isCopied = await copyToClipboard(
|
||||
JSON.stringify(error, isPlainObject(error) ? null : Object.getOwnPropertyNames(error), 2)
|
||||
);
|
||||
if (!isCopied) return;
|
||||
copied.value = true;
|
||||
}
|
||||
},
|
||||
const code = computed(() => {
|
||||
return props.error?.response?.data?.errors?.[0]?.extensions?.code || props.error?.extensions?.code || 'UNKNOWN';
|
||||
});
|
||||
|
||||
const message = computed(() => {
|
||||
let message = props.error?.response?.data?.errors?.[0]?.message || props.error?.message;
|
||||
|
||||
if (message.length > 200) {
|
||||
message = message.substring(0, 197) + '...';
|
||||
}
|
||||
|
||||
return message;
|
||||
});
|
||||
|
||||
const copied = ref(false);
|
||||
|
||||
const { isCopySupported, copyToClipboard } = useClipboard();
|
||||
|
||||
async function copyError() {
|
||||
const error = props.error?.response?.data || props.error;
|
||||
const isCopied = await copyToClipboard(
|
||||
JSON.stringify(error, isPlainObject(error) ? null : Object.getOwnPropertyNames(error), 2)
|
||||
);
|
||||
if (!isCopied) return;
|
||||
copied.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
<template>
|
||||
<div class="v-fancy-select">
|
||||
<transition-group tag="div" name="option">
|
||||
<template v-for="(item, index) in visibleItems" :key="index">
|
||||
<v-divider v-if="item.divider === true" />
|
||||
<div
|
||||
v-else
|
||||
class="v-fancy-select-option"
|
||||
:class="{ active: item[itemValue] === modelValue, disabled }"
|
||||
:style="{
|
||||
'--index': index,
|
||||
}"
|
||||
@click="toggle(item)"
|
||||
>
|
||||
<div class="icon">
|
||||
<v-icon :name="item.icon" />
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="text">{{ item[itemText] }}</div>
|
||||
<div class="description">{{ item[itemDescription] }}</div>
|
||||
</div>
|
||||
|
||||
<v-icon
|
||||
v-if="modelValue === item[itemValue] && disabled === false"
|
||||
name="cancel"
|
||||
@click.stop="toggle(item)"
|
||||
/>
|
||||
<v-icon v-else-if="item.iconRight" class="icon-right" :name="item.iconRight" />
|
||||
</div>
|
||||
</template>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
export type FancySelectItem = {
|
||||
icon: string;
|
||||
value?: string | number;
|
||||
text: string;
|
||||
description?: string;
|
||||
divider?: boolean;
|
||||
iconRight?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
items: FancySelectItem[];
|
||||
modelValue?: string | number | null;
|
||||
disabled?: boolean;
|
||||
itemText?: string;
|
||||
itemValue?: string;
|
||||
itemDescription?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => null,
|
||||
disabled: false,
|
||||
itemText: 'text',
|
||||
itemValue: 'value',
|
||||
itemDescription: 'description',
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
if (props.modelValue === null) return props.items;
|
||||
|
||||
return props.items.filter((item) => {
|
||||
return item[props.itemValue] === props.modelValue;
|
||||
});
|
||||
});
|
||||
|
||||
function toggle(item: Record<string, any>) {
|
||||
if (props.disabled === true) return;
|
||||
if (props.modelValue === item[props.itemValue]) emit('update:modelValue', null);
|
||||
else emit('update:modelValue', item[props.itemValue]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-fancy-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.v-fancy-select-option {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px;
|
||||
background-color: var(--background-normal);
|
||||
border: 2px solid var(--background-normal);
|
||||
border-radius: 6px;
|
||||
backface-visibility: hidden;
|
||||
cursor: pointer;
|
||||
transition-timing-function: var(--transition);
|
||||
transition-duration: var(--fast);
|
||||
transition-property: background-color, border-color;
|
||||
|
||||
&:not(.disabled):hover {
|
||||
border-color: var(--background-normal-alt);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-right: 12px;
|
||||
background-color: var(--background-page);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
|
||||
.description {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
z-index: 2;
|
||||
color: var(--primary);
|
||||
background-color: var(--primary-alt);
|
||||
border-color: var(--primary);
|
||||
|
||||
.v-icon {
|
||||
--v-icon-color: var(--primary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-enter-active,
|
||||
.option-leave-active {
|
||||
transition: opacity var(--slow) var(--transition);
|
||||
}
|
||||
|
||||
.option-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.option-move {
|
||||
transition: all 500ms var(--transition);
|
||||
}
|
||||
|
||||
.option-enter-from,
|
||||
.option-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.icon-right {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.v-divider {
|
||||
margin: 24px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -74,7 +74,10 @@ interface Props {
|
||||
includeFunctions?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { depth: undefined, search: undefined, includeFunctions: false });
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
search: undefined,
|
||||
includeFunctions: false,
|
||||
});
|
||||
|
||||
defineEmits(['add']);
|
||||
|
||||
|
||||
@@ -40,10 +40,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
field: undefined,
|
||||
disabledFields: () => [],
|
||||
includeFunctions: false,
|
||||
includeRelations: true,
|
||||
field: undefined,
|
||||
});
|
||||
|
||||
defineEmits(['select-field']);
|
||||
|
||||
@@ -13,32 +13,24 @@
|
||||
v-for="childField in field.children"
|
||||
:key="childField.key"
|
||||
:field="childField"
|
||||
:depth="depth - 1"
|
||||
:depth="depth ? depth - 1 : undefined"
|
||||
@add="$emit('add', $event)"
|
||||
/>
|
||||
</v-list-group>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
<script setup lang="ts">
|
||||
import { FieldTree } from './types';
|
||||
import formatTitle from '@directus/format-title';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FieldListItem',
|
||||
props: {
|
||||
field: {
|
||||
type: Object as PropType<FieldTree>,
|
||||
required: true,
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['add'],
|
||||
setup() {
|
||||
return { formatTitle };
|
||||
},
|
||||
interface Props {
|
||||
field: FieldTree;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
depth: undefined,
|
||||
});
|
||||
|
||||
defineEmits(['add']);
|
||||
</script>
|
||||
|
||||
@@ -28,276 +28,263 @@
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, ref, watch, onMounted, onUnmounted, PropType, computed } from 'vue';
|
||||
<script setup lang="ts">
|
||||
import { toRefs, ref, watch, onMounted, onUnmounted, computed } from 'vue';
|
||||
import FieldListItem from './field-list-item.vue';
|
||||
import { FieldTree } from './types';
|
||||
import { Field, Relation } from '@directus/shared/types';
|
||||
import { useFieldTree } from '@/composables/use-field-tree';
|
||||
import { flattenFieldGroups } from '@/utils/flatten-field-groups';
|
||||
|
||||
export default defineComponent({
|
||||
components: { FieldListItem },
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
nullable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
inject: {
|
||||
type: Object as PropType<{ fields: Field[]; relations: Relation[] }>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const contentEl = ref<HTMLElement | null>(null);
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
modelValue?: string | null;
|
||||
nullable?: boolean;
|
||||
collection?: string | null;
|
||||
depth?: number;
|
||||
placeholder?: string | null;
|
||||
inject?: {
|
||||
fields: Field[];
|
||||
relations: Relation[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
const menuActive = ref(false);
|
||||
|
||||
const { collection, inject } = toRefs(props);
|
||||
const { treeList, loadFieldRelations } = useFieldTree(collection, inject);
|
||||
|
||||
watch(() => props.modelValue, setContent, { immediate: true });
|
||||
|
||||
const grouplessTree = computed(() => {
|
||||
return flattenFieldGroups(treeList.value);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (contentEl.value) {
|
||||
contentEl.value.addEventListener('selectstart', onSelect);
|
||||
setContent();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (contentEl.value) {
|
||||
contentEl.value.removeEventListener('selectstart', onSelect);
|
||||
}
|
||||
});
|
||||
|
||||
return { menuActive, treeList, addField, onInput, contentEl, onClick, loadFieldRelations, onKeyDown };
|
||||
|
||||
function onInput() {
|
||||
if (!contentEl.value) return;
|
||||
|
||||
const valueString = getInputValue();
|
||||
emit('update:modelValue', valueString);
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (target.tagName.toLowerCase() !== 'button') return;
|
||||
|
||||
const field = target.dataset.field;
|
||||
emit('update:modelValue', props.modelValue.replace(`{{${field}}}`, ''));
|
||||
|
||||
const before = target.previousElementSibling;
|
||||
const after = target.nextElementSibling;
|
||||
|
||||
if (!before || !after || !(before instanceof HTMLElement) || !(after instanceof HTMLElement)) return;
|
||||
|
||||
target.remove();
|
||||
joinElements(before, after);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
onInput();
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === '{' || event.key === '}') {
|
||||
event.preventDefault();
|
||||
menuActive.value = true;
|
||||
}
|
||||
|
||||
if (contentEl.value?.innerHTML === '') {
|
||||
contentEl.value.innerHTML = '<span class="text"></span>';
|
||||
}
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
if (!contentEl.value) return;
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount <= 0) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range) return;
|
||||
const start = range.startContainer;
|
||||
|
||||
if (
|
||||
!(start instanceof HTMLElement && start.classList.contains('text')) &&
|
||||
!start.parentElement?.classList.contains('text')
|
||||
) {
|
||||
selection.removeAllRanges();
|
||||
const range = new Range();
|
||||
let textSpan = null;
|
||||
|
||||
for (let i = 0; i < contentEl.value.childNodes.length || !textSpan; i++) {
|
||||
const child = contentEl.value.children[i];
|
||||
if (child.classList.contains('text')) {
|
||||
textSpan = child;
|
||||
}
|
||||
}
|
||||
|
||||
if (!textSpan) {
|
||||
textSpan = document.createElement('span');
|
||||
textSpan.classList.add('text');
|
||||
contentEl.value.appendChild(textSpan);
|
||||
}
|
||||
|
||||
range.setStart(textSpan, 0);
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
function addField(field: FieldTree) {
|
||||
if (!contentEl.value) return;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.dataset.field = field.key;
|
||||
button.setAttribute('contenteditable', 'false');
|
||||
button.innerText = String(field.name);
|
||||
|
||||
if (window.getSelection()?.rangeCount == 0) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(contentEl.value.children[0]);
|
||||
window.getSelection()?.addRange(range);
|
||||
}
|
||||
|
||||
const range = window.getSelection()?.getRangeAt(0);
|
||||
if (!range) return;
|
||||
range.deleteContents();
|
||||
|
||||
const end = splitElements();
|
||||
|
||||
if (end) {
|
||||
contentEl.value.insertBefore(button, end);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
} else {
|
||||
contentEl.value.appendChild(button);
|
||||
const span = document.createElement('span');
|
||||
span.classList.add('text');
|
||||
contentEl.value.appendChild(span);
|
||||
}
|
||||
|
||||
onInput();
|
||||
}
|
||||
|
||||
function findTree(tree: FieldTree[] | undefined, fieldSections: string[]): FieldTree | undefined {
|
||||
if (tree === undefined) return undefined;
|
||||
|
||||
const fieldObject = tree.find((f) => f.field === fieldSections[0]);
|
||||
|
||||
if (fieldObject === undefined) return undefined;
|
||||
if (fieldSections.length === 1) return fieldObject;
|
||||
return findTree(fieldObject.children, fieldSections.slice(1));
|
||||
}
|
||||
|
||||
function joinElements(first: HTMLElement, second: HTMLElement) {
|
||||
first.innerText += second.innerText;
|
||||
second.remove();
|
||||
}
|
||||
|
||||
function splitElements() {
|
||||
const range = window.getSelection()?.getRangeAt(0);
|
||||
if (!range) return;
|
||||
|
||||
const textNode = range.startContainer;
|
||||
if (textNode.nodeType !== Node.TEXT_NODE) return;
|
||||
const start = textNode.parentElement;
|
||||
if (!start || !(start instanceof HTMLSpanElement) || !start.classList.contains('text')) return;
|
||||
|
||||
const startOffset = range.startOffset;
|
||||
|
||||
const left = start.textContent?.slice(0, startOffset) || '';
|
||||
const right = start.textContent?.slice(startOffset) || '';
|
||||
|
||||
start.innerText = left;
|
||||
|
||||
const nextSpan = document.createElement('span');
|
||||
nextSpan.classList.add('text');
|
||||
nextSpan.innerText = right;
|
||||
contentEl.value?.insertBefore(nextSpan, start.nextSibling);
|
||||
return nextSpan;
|
||||
}
|
||||
|
||||
function getInputValue() {
|
||||
if (!contentEl.value) return null;
|
||||
|
||||
const value = Array.from(contentEl.value.childNodes).reduce((acc, node) => {
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName;
|
||||
|
||||
if (tag && tag.toLowerCase() === 'button') return (acc += `{{${el.dataset.field}}}`);
|
||||
else if ('textContent' in el) return (acc += el.textContent);
|
||||
|
||||
return (acc += '');
|
||||
}, '');
|
||||
|
||||
if (props.nullable === true && value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function setContent() {
|
||||
if (!contentEl.value) return;
|
||||
|
||||
if (props.modelValue === null || props.modelValue === '') {
|
||||
contentEl.value.innerHTML = '<span class="text"></span>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.modelValue !== getInputValue()) {
|
||||
const regex = /({{.*?}})/g;
|
||||
|
||||
const newInnerHTML = props.modelValue
|
||||
.split(regex)
|
||||
.map((part) => {
|
||||
if (part.startsWith('{{') === false) {
|
||||
return `<span class="text">${part}</span>`;
|
||||
}
|
||||
const fieldKey = part.replace(/({|})/g, '').trim();
|
||||
const fieldPath = fieldKey.split('.');
|
||||
|
||||
for (let i = 0; i < fieldPath.length; i++) {
|
||||
loadFieldRelations(fieldPath.slice(0, i).join('.'));
|
||||
}
|
||||
|
||||
const field = findTree(grouplessTree.value, fieldPath);
|
||||
|
||||
if (!field) return '';
|
||||
|
||||
return `<button contenteditable="false" data-field="${fieldKey}" ${props.disabled ? 'disabled' : ''}>${
|
||||
field.name
|
||||
}</button>`;
|
||||
})
|
||||
.join('');
|
||||
contentEl.value.innerHTML = newInnerHTML;
|
||||
}
|
||||
}
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
modelValue: null,
|
||||
nullable: true,
|
||||
collection: null,
|
||||
depth: undefined,
|
||||
placeholder: null,
|
||||
inject: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const contentEl = ref<HTMLElement | null>(null);
|
||||
|
||||
const menuActive = ref(false);
|
||||
|
||||
const { collection, inject } = toRefs(props);
|
||||
const { treeList, loadFieldRelations } = useFieldTree(collection, inject);
|
||||
|
||||
watch(() => props.modelValue, setContent, { immediate: true });
|
||||
|
||||
const grouplessTree = computed(() => {
|
||||
return flattenFieldGroups(treeList.value);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (contentEl.value) {
|
||||
contentEl.value.addEventListener('selectstart', onSelect);
|
||||
setContent();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (contentEl.value) {
|
||||
contentEl.value.removeEventListener('selectstart', onSelect);
|
||||
}
|
||||
});
|
||||
|
||||
function onInput() {
|
||||
if (!contentEl.value) return;
|
||||
|
||||
const valueString = getInputValue();
|
||||
emit('update:modelValue', valueString);
|
||||
}
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if (target.tagName.toLowerCase() !== 'button') return;
|
||||
|
||||
const field = target.dataset.field;
|
||||
emit('update:modelValue', props.modelValue?.replace(`{{${field}}}`, ''));
|
||||
|
||||
const before = target.previousElementSibling;
|
||||
const after = target.nextElementSibling;
|
||||
|
||||
if (!before || !after || !(before instanceof HTMLElement) || !(after instanceof HTMLElement)) return;
|
||||
|
||||
target.remove();
|
||||
joinElements(before, after);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
onInput();
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === '{' || event.key === '}') {
|
||||
event.preventDefault();
|
||||
menuActive.value = true;
|
||||
}
|
||||
|
||||
if (contentEl.value?.innerHTML === '') {
|
||||
contentEl.value.innerHTML = '<span class="text"></span>';
|
||||
}
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
if (!contentEl.value) return;
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount <= 0) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range) return;
|
||||
const start = range.startContainer;
|
||||
|
||||
if (
|
||||
!(start instanceof HTMLElement && start.classList.contains('text')) &&
|
||||
!start.parentElement?.classList.contains('text')
|
||||
) {
|
||||
selection.removeAllRanges();
|
||||
const range = new Range();
|
||||
let textSpan = null;
|
||||
|
||||
for (let i = 0; i < contentEl.value.childNodes.length || !textSpan; i++) {
|
||||
const child = contentEl.value.children[i];
|
||||
if (child.classList.contains('text')) {
|
||||
textSpan = child;
|
||||
}
|
||||
}
|
||||
|
||||
if (!textSpan) {
|
||||
textSpan = document.createElement('span');
|
||||
textSpan.classList.add('text');
|
||||
contentEl.value.appendChild(textSpan);
|
||||
}
|
||||
|
||||
range.setStart(textSpan, 0);
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
function addField(field: FieldTree) {
|
||||
if (!contentEl.value) return;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.dataset.field = field.key;
|
||||
button.setAttribute('contenteditable', 'false');
|
||||
button.innerText = String(field.name);
|
||||
|
||||
if (window.getSelection()?.rangeCount == 0) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(contentEl.value.children[0]);
|
||||
window.getSelection()?.addRange(range);
|
||||
}
|
||||
|
||||
const range = window.getSelection()?.getRangeAt(0);
|
||||
if (!range) return;
|
||||
range.deleteContents();
|
||||
|
||||
const end = splitElements();
|
||||
|
||||
if (end) {
|
||||
contentEl.value.insertBefore(button, end);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
} else {
|
||||
contentEl.value.appendChild(button);
|
||||
const span = document.createElement('span');
|
||||
span.classList.add('text');
|
||||
contentEl.value.appendChild(span);
|
||||
}
|
||||
|
||||
onInput();
|
||||
}
|
||||
|
||||
function findTree(tree: FieldTree[] | undefined, fieldSections: string[]): FieldTree | undefined {
|
||||
if (tree === undefined) return undefined;
|
||||
|
||||
const fieldObject = tree.find((f) => f.field === fieldSections[0]);
|
||||
|
||||
if (fieldObject === undefined) return undefined;
|
||||
if (fieldSections.length === 1) return fieldObject;
|
||||
return findTree(fieldObject.children, fieldSections.slice(1));
|
||||
}
|
||||
|
||||
function joinElements(first: HTMLElement, second: HTMLElement) {
|
||||
first.innerText += second.innerText;
|
||||
second.remove();
|
||||
}
|
||||
|
||||
function splitElements() {
|
||||
const range = window.getSelection()?.getRangeAt(0);
|
||||
if (!range) return;
|
||||
|
||||
const textNode = range.startContainer;
|
||||
if (textNode.nodeType !== Node.TEXT_NODE) return;
|
||||
const start = textNode.parentElement;
|
||||
if (!start || !(start instanceof HTMLSpanElement) || !start.classList.contains('text')) return;
|
||||
|
||||
const startOffset = range.startOffset;
|
||||
|
||||
const left = start.textContent?.slice(0, startOffset) || '';
|
||||
const right = start.textContent?.slice(startOffset) || '';
|
||||
|
||||
start.innerText = left;
|
||||
|
||||
const nextSpan = document.createElement('span');
|
||||
nextSpan.classList.add('text');
|
||||
nextSpan.innerText = right;
|
||||
contentEl.value?.insertBefore(nextSpan, start.nextSibling);
|
||||
return nextSpan;
|
||||
}
|
||||
|
||||
function getInputValue() {
|
||||
if (!contentEl.value) return null;
|
||||
|
||||
const value = Array.from(contentEl.value.childNodes).reduce((acc, node) => {
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName;
|
||||
|
||||
if (tag && tag.toLowerCase() === 'button') return (acc += `{{${el.dataset.field}}}`);
|
||||
else if ('textContent' in el) return (acc += el.textContent);
|
||||
|
||||
return (acc += '');
|
||||
}, '');
|
||||
|
||||
if (props.nullable === true && value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function setContent() {
|
||||
if (!contentEl.value) return;
|
||||
|
||||
if (props.modelValue === null || props.modelValue === '') {
|
||||
contentEl.value.innerHTML = '<span class="text"></span>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.modelValue !== getInputValue()) {
|
||||
const regex = /({{.*?}})/g;
|
||||
|
||||
const newInnerHTML = props.modelValue
|
||||
.split(regex)
|
||||
.map((part) => {
|
||||
if (part.startsWith('{{') === false) {
|
||||
return `<span class="text">${part}</span>`;
|
||||
}
|
||||
const fieldKey = part.replace(/({|})/g, '').trim();
|
||||
const fieldPath = fieldKey.split('.');
|
||||
|
||||
for (let i = 0; i < fieldPath.length; i++) {
|
||||
loadFieldRelations(fieldPath.slice(0, i).join('.'));
|
||||
}
|
||||
|
||||
const field = findTree(grouplessTree.value, fieldPath);
|
||||
|
||||
if (!field) return '';
|
||||
|
||||
return `<button contenteditable="false" data-field="${fieldKey}" ${props.disabled ? 'disabled' : ''}>${
|
||||
field.name
|
||||
}</button>`;
|
||||
})
|
||||
.join('');
|
||||
contentEl.value.innerHTML = newInnerHTML;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -44,69 +44,45 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, PropType, computed } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { Field } from '@directus/shared/types';
|
||||
import { getInterface } from '@/interfaces';
|
||||
import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
field: {
|
||||
type: Object as PropType<Field>,
|
||||
required: true,
|
||||
},
|
||||
batchMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
batchActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
primaryKey: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Object, Array, Boolean],
|
||||
default: undefined,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rawEditorEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rawEditorActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'setFieldValue'],
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
interface Props {
|
||||
field: Field;
|
||||
batchMode?: boolean;
|
||||
batchActive?: boolean;
|
||||
primaryKey?: string | number | null;
|
||||
modelValue?: string | number | boolean | Record<string, any> | Array<any>;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
autofocus?: boolean;
|
||||
rawEditorEnabled?: boolean;
|
||||
rawEditorActive?: boolean;
|
||||
direction?: string;
|
||||
}
|
||||
|
||||
const interfaceExists = computed(() => !!getInterface(props.field?.meta?.interface || 'input'));
|
||||
|
||||
return { t, interfaceExists, getDefaultInterfaceForType };
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
batchMode: false,
|
||||
batchActive: false,
|
||||
primaryKey: null,
|
||||
modelValue: undefined,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
autofocus: false,
|
||||
rawEditorEnabled: false,
|
||||
rawEditorActive: false,
|
||||
direction: undefined,
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue', 'setFieldValue']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const interfaceExists = computed(() => !!getInterface(props.field?.meta?.interface || 'input'));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -26,69 +26,41 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
import { Field } from '@directus/shared/types';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
batchMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
batchActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
field: {
|
||||
type: Object as PropType<Field>,
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
toggle: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
edited: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
badge: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rawEditorEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rawEditorActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['toggle-batch', 'toggle-raw'],
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
interface Props {
|
||||
field: Field;
|
||||
toggle: (event: Event) => any;
|
||||
batchMode?: boolean;
|
||||
batchActive?: boolean;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
edited?: boolean;
|
||||
hasError?: boolean;
|
||||
badge?: string | null;
|
||||
loading?: boolean;
|
||||
rawEditorEnabled?: boolean;
|
||||
rawEditorActive?: boolean;
|
||||
}
|
||||
|
||||
return { t };
|
||||
},
|
||||
withDefaults(defineProps<Props>(), {
|
||||
batchMode: false,
|
||||
batchActive: false,
|
||||
disabled: false,
|
||||
active: false,
|
||||
edited: false,
|
||||
hasError: false,
|
||||
badge: null,
|
||||
loading: false,
|
||||
rawEditorEnabled: false,
|
||||
rawEditorActive: false,
|
||||
});
|
||||
|
||||
defineEmits(['toggle-batch', 'toggle-raw']);
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -47,54 +47,44 @@
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, PropType, computed } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { Field } from '@directus/shared/types';
|
||||
import { useClipboard } from '@/composables/use-clipboard';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
field: {
|
||||
type: Object as PropType<Field>,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Object, Array, Boolean],
|
||||
default: null,
|
||||
},
|
||||
initialValue: {
|
||||
type: [String, Number, Object, Array, Boolean],
|
||||
default: null,
|
||||
},
|
||||
restricted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'unset', 'edit-raw', 'copy-raw', 'paste-raw'],
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
interface Props {
|
||||
field: Field;
|
||||
modelValue?: string | number | boolean | Record<string, any> | Array<any> | null;
|
||||
initialValue?: string | number | boolean | Record<string, any> | Array<any> | null;
|
||||
restricted?: boolean;
|
||||
}
|
||||
|
||||
const { isCopySupported, isPasteSupported } = useClipboard();
|
||||
|
||||
const defaultValue = computed(() => {
|
||||
const savedValue = props.field?.schema?.default_value;
|
||||
return savedValue !== undefined ? savedValue : null;
|
||||
});
|
||||
|
||||
const isRequired = computed(() => {
|
||||
return props.field?.schema?.is_nullable === false;
|
||||
});
|
||||
|
||||
const relational = computed(
|
||||
() =>
|
||||
props.field.meta?.special?.find((type) =>
|
||||
['file', 'files', 'm2o', 'o2m', 'm2m', 'm2a', 'translations'].includes(type)
|
||||
) !== undefined
|
||||
);
|
||||
|
||||
return { t, defaultValue, isRequired, isCopySupported, isPasteSupported, relational };
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: null,
|
||||
initialValue: null,
|
||||
restricted: false,
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue', 'unset', 'edit-raw', 'copy-raw', 'paste-raw']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { isCopySupported, isPasteSupported } = useClipboard();
|
||||
|
||||
const defaultValue = computed(() => {
|
||||
const savedValue = props.field?.schema?.default_value;
|
||||
return savedValue !== undefined ? savedValue : null;
|
||||
});
|
||||
|
||||
const isRequired = computed(() => {
|
||||
return props.field?.schema?.is_nullable === false;
|
||||
});
|
||||
|
||||
const relational = computed(
|
||||
() =>
|
||||
props.field.meta?.special?.find((type) =>
|
||||
['file', 'files', 'm2o', 'o2m', 'm2m', 'm2a', 'translations'].includes(type)
|
||||
) !== undefined
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
import { getJSType } from '@/utils/get-js-type';
|
||||
import { Field, ValidationError } from '@directus/shared/types';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<form-field
|
||||
v-else-if="!fieldsMap[fieldName]?.meta?.hidden"
|
||||
:ref="
|
||||
(el: Element) => {
|
||||
(el) => {
|
||||
formFieldEls[fieldName] = el;
|
||||
}
|
||||
"
|
||||
@@ -77,8 +77,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useElementSize } from '@/composables/use-element-size';
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@directus/shared/composables';
|
||||
import { useFormFields } from '@/composables/use-form-fields';
|
||||
import { useFieldsStore } from '@/stores/fields';
|
||||
import { applyConditions } from '@/utils/apply-conditions';
|
||||
@@ -86,8 +86,7 @@ import { extractFieldFromFunction } from '@/utils/extract-field-from-function';
|
||||
import { getDefaultValuesFromFields } from '@/utils/get-default-values-from-fields';
|
||||
import { Field, ValidationError } from '@directus/shared/types';
|
||||
import { assign, cloneDeep, isEqual, isNil, omit, pick } from 'lodash';
|
||||
import { computed, ComputedRef, defineComponent, onBeforeUpdate, PropType, provide, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { computed, ComputedRef, onBeforeUpdate, provide, ref, watch } from 'vue';
|
||||
import FormField from './form-field.vue';
|
||||
import ValidationErrors from './validation-errors.vue';
|
||||
|
||||
@@ -95,338 +94,279 @@ type FieldValues = {
|
||||
[field: string]: any;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'VForm',
|
||||
components: { FormField, ValidationErrors },
|
||||
props: {
|
||||
collection: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
fields: {
|
||||
type: Array as PropType<Field[]>,
|
||||
default: undefined,
|
||||
},
|
||||
initialValues: {
|
||||
type: Object as PropType<FieldValues>,
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: Object as PropType<FieldValues>,
|
||||
default: null,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
batchMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Note: can be null when the form is used in batch mode
|
||||
primaryKey: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
validationErrors: {
|
||||
type: Array as PropType<ValidationError[]>,
|
||||
default: () => [],
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
group: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
badge: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
nested: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rawEditorEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
interface Props {
|
||||
collection?: string;
|
||||
fields?: Field[];
|
||||
initialValues?: FieldValues | null;
|
||||
modelValue?: FieldValues | null;
|
||||
loading?: boolean;
|
||||
batchMode?: boolean;
|
||||
primaryKey?: string | number;
|
||||
disabled?: boolean;
|
||||
validationErrors?: ValidationError[];
|
||||
autofocus?: boolean;
|
||||
group?: string | null;
|
||||
badge?: string;
|
||||
nested?: boolean;
|
||||
rawEditorEnabled?: boolean;
|
||||
direction?: string;
|
||||
}
|
||||
|
||||
const values = computed(() => {
|
||||
return Object.assign({}, props.initialValues, props.modelValue);
|
||||
});
|
||||
|
||||
const el = ref<Element>();
|
||||
|
||||
const { width } = useElementSize(el);
|
||||
|
||||
const gridClass = computed<string | null>(() => {
|
||||
if (el.value === null) return null;
|
||||
|
||||
if (width.value > 792) {
|
||||
return 'grid with-fill';
|
||||
} else {
|
||||
return 'grid';
|
||||
}
|
||||
});
|
||||
|
||||
const formFieldEls = ref<Record<string, any>>({});
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
formFieldEls.value = {};
|
||||
});
|
||||
|
||||
const { fieldNames, fieldsMap, getFieldsForGroup, fieldsForGroup, isDisabled } = useForm();
|
||||
const { toggleBatchField, batchActiveFields } = useBatch();
|
||||
const { toggleRawField, rawActiveFields } = useRawEditor();
|
||||
|
||||
const firstEditableFieldIndex = computed(() => {
|
||||
for (let i = 0; i < fieldNames.value.length; i++) {
|
||||
const field = fieldsMap.value[fieldNames.value[i]];
|
||||
if (field?.meta && !field.meta?.readonly && !field.meta?.hidden) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const firstVisibleFieldIndex = computed(() => {
|
||||
for (let i = 0; i < fieldNames.value.length; i++) {
|
||||
const field = fieldsMap.value[fieldNames.value[i]];
|
||||
if (field?.meta && !field.meta?.hidden) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.validationErrors,
|
||||
(newVal, oldVal) => {
|
||||
if (props.nested) return;
|
||||
if (isEqual(newVal, oldVal)) return;
|
||||
if (newVal?.length > 0) el?.value?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
);
|
||||
|
||||
provide('values', values);
|
||||
|
||||
return {
|
||||
t,
|
||||
values,
|
||||
setValue,
|
||||
batchActiveFields,
|
||||
toggleBatchField,
|
||||
rawActiveFields,
|
||||
toggleRawField,
|
||||
unsetValue,
|
||||
firstEditableFieldIndex,
|
||||
firstVisibleFieldIndex,
|
||||
isNil,
|
||||
apply,
|
||||
el,
|
||||
gridClass,
|
||||
omit,
|
||||
getFieldsForGroup,
|
||||
fieldsForGroup,
|
||||
isDisabled,
|
||||
scrollToField,
|
||||
formFieldEls,
|
||||
fieldNames,
|
||||
fieldsMap,
|
||||
};
|
||||
|
||||
function useForm() {
|
||||
const fieldsStore = useFieldsStore();
|
||||
const fields = ref<Field[]>(getFields());
|
||||
|
||||
watch(
|
||||
() => props.fields,
|
||||
() => {
|
||||
const newVal = getFields();
|
||||
if (!isEqual(fields.value, newVal)) {
|
||||
fields.value = newVal;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const defaultValues = getDefaultValuesFromFields(fields);
|
||||
|
||||
const { formFields } = useFormFields(fields);
|
||||
|
||||
const fieldsMap: ComputedRef<Record<string, Field | undefined>> = computed(() => {
|
||||
if (props.loading) return {} as Record<string, undefined>;
|
||||
const valuesWithDefaults = Object.assign({}, defaultValues.value, values.value);
|
||||
return formFields.value.reduce((result: Record<string, Field>, field: Field) => {
|
||||
const newField = applyConditions(valuesWithDefaults, setPrimaryKeyReadonly(field));
|
||||
if (newField.field) result[newField.field] = newField;
|
||||
return result;
|
||||
}, {} as Record<string, Field>);
|
||||
});
|
||||
|
||||
const fieldsInGroup = computed(() =>
|
||||
formFields.value.filter(
|
||||
(field: Field) => field.meta?.group === props.group || (props.group === null && isNil(field.meta?.group))
|
||||
)
|
||||
);
|
||||
const fieldNames = computed(() => {
|
||||
return fieldsInGroup.value.map((f) => f.field);
|
||||
});
|
||||
|
||||
const fieldsForGroup = computed(() =>
|
||||
fieldNames.value.map((name: string) => getFieldsForGroup(fieldsMap.value[name]?.meta?.field || null))
|
||||
);
|
||||
|
||||
return { fieldNames, fieldsMap, isDisabled, getFieldsForGroup, fieldsForGroup };
|
||||
|
||||
function isDisabled(field: Field | undefined) {
|
||||
if (!field) return true;
|
||||
const meta = fieldsMap.value?.[field.field]?.meta;
|
||||
return (
|
||||
props.loading ||
|
||||
props.disabled === true ||
|
||||
meta?.readonly === true ||
|
||||
field.schema?.is_generated === true ||
|
||||
(props.batchMode && batchActiveFields.value.includes(field.field) === false)
|
||||
);
|
||||
}
|
||||
|
||||
function getFieldsForGroup(group: null | string, passed: string[] = []): Field[] {
|
||||
const fieldsInGroup: Field[] = fields.value.filter((field) => {
|
||||
const meta = fieldsMap.value?.[field.field]?.meta;
|
||||
return meta?.group === group || (group === null && isNil(meta));
|
||||
});
|
||||
|
||||
for (const field of fieldsInGroup) {
|
||||
const meta = fieldsMap.value?.[field.field]?.meta;
|
||||
if (meta?.special?.includes('group') && !passed.includes(meta!.field)) {
|
||||
passed.push(meta!.field);
|
||||
fieldsInGroup.push(...getFieldsForGroup(meta!.field, passed));
|
||||
}
|
||||
}
|
||||
|
||||
return fieldsInGroup;
|
||||
}
|
||||
|
||||
function getFields(): Field[] {
|
||||
if (props.collection) {
|
||||
return fieldsStore.getFieldsForCollection(props.collection);
|
||||
}
|
||||
if (props.fields) {
|
||||
return props.fields;
|
||||
}
|
||||
|
||||
throw new Error('[v-form]: You need to pass either the collection or fields prop.');
|
||||
}
|
||||
|
||||
function setPrimaryKeyReadonly(field: Field) {
|
||||
if (
|
||||
field.schema?.has_auto_increment === true ||
|
||||
(field.schema?.is_primary_key === true && props.primaryKey !== '+')
|
||||
) {
|
||||
const fieldClone = cloneDeep(field) as any;
|
||||
if (!fieldClone.meta) fieldClone.meta = {};
|
||||
fieldClone.meta.readonly = true;
|
||||
return fieldClone;
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
function setValue(fieldKey: string, value: any, opts?: { force?: boolean }) {
|
||||
const field = fieldsMap.value[fieldKey];
|
||||
|
||||
if (opts?.force !== true && (!field || isDisabled(field))) return;
|
||||
|
||||
const edits = props.modelValue ? cloneDeep(props.modelValue) : {};
|
||||
edits[fieldKey] = value;
|
||||
emit('update:modelValue', edits);
|
||||
}
|
||||
|
||||
function apply(updates: { [field: string]: any }) {
|
||||
const updatableKeys = props.batchMode
|
||||
? Object.keys(updates)
|
||||
: Object.keys(updates).filter((key) => {
|
||||
const field = fieldsMap.value[key];
|
||||
if (!field) return false;
|
||||
return field.schema?.is_primary_key || !isDisabled(field);
|
||||
});
|
||||
|
||||
if (!isNil(props.group)) {
|
||||
const groupFields = getFieldsForGroup(props.group)
|
||||
.filter((field) => !field.schema?.is_primary_key && !isDisabled(field))
|
||||
.map((field) => field.field);
|
||||
emit('update:modelValue', assign({}, omit(props.modelValue, groupFields), pick(updates, updatableKeys)));
|
||||
} else {
|
||||
emit('update:modelValue', pick(assign({}, props.modelValue, updates), updatableKeys));
|
||||
}
|
||||
}
|
||||
|
||||
function unsetValue(field: Field | undefined) {
|
||||
if (!field) return;
|
||||
if (!props.batchMode && isDisabled(field)) return;
|
||||
|
||||
if (field.field in (props.modelValue || {})) {
|
||||
const newEdits = { ...props.modelValue };
|
||||
delete newEdits[field.field];
|
||||
emit('update:modelValue', newEdits);
|
||||
}
|
||||
}
|
||||
|
||||
function useBatch() {
|
||||
const batchActiveFields = ref<string[]>([]);
|
||||
|
||||
return { batchActiveFields, toggleBatchField };
|
||||
|
||||
function toggleBatchField(field: Field | undefined) {
|
||||
if (!field) return;
|
||||
if (batchActiveFields.value.includes(field.field)) {
|
||||
batchActiveFields.value = batchActiveFields.value.filter((fieldKey) => fieldKey !== field.field);
|
||||
|
||||
unsetValue(field);
|
||||
} else {
|
||||
batchActiveFields.value = [...batchActiveFields.value, field.field];
|
||||
setValue(field.field, field.schema?.default_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToField(fieldKey: string) {
|
||||
const { field } = extractFieldFromFunction(fieldKey);
|
||||
if (!formFieldEls.value[field]) return;
|
||||
formFieldEls.value[field].$el.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function useRawEditor() {
|
||||
const rawActiveFields = ref(new Set<string>());
|
||||
|
||||
return { rawActiveFields, toggleRawField };
|
||||
|
||||
function toggleRawField(field: Field | undefined) {
|
||||
if (!field) return;
|
||||
if (rawActiveFields.value.has(field.field)) {
|
||||
rawActiveFields.value.delete(field.field);
|
||||
} else {
|
||||
rawActiveFields.value.add(field.field);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
collection: undefined,
|
||||
fields: undefined,
|
||||
initialValues: null,
|
||||
modelValue: null,
|
||||
loading: false,
|
||||
batchMode: false,
|
||||
primaryKey: undefined,
|
||||
disabled: false,
|
||||
validationErrors: () => [],
|
||||
autofocus: false,
|
||||
group: null,
|
||||
badge: undefined,
|
||||
nested: false,
|
||||
rawEditorEnabled: false,
|
||||
direction: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const values = computed(() => {
|
||||
return Object.assign({}, props.initialValues, props.modelValue);
|
||||
});
|
||||
|
||||
const el = ref<Element>();
|
||||
|
||||
const { width } = useElementSize(el);
|
||||
|
||||
const gridClass = computed<string | null>(() => {
|
||||
if (el.value === null) return null;
|
||||
|
||||
if (width.value > 792) {
|
||||
return 'grid with-fill';
|
||||
} else {
|
||||
return 'grid';
|
||||
}
|
||||
});
|
||||
|
||||
const formFieldEls = ref<Record<string, any>>({});
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
formFieldEls.value = {};
|
||||
});
|
||||
|
||||
const { fieldNames, fieldsMap, getFieldsForGroup, fieldsForGroup, isDisabled } = useForm();
|
||||
const { toggleBatchField, batchActiveFields } = useBatch();
|
||||
const { toggleRawField, rawActiveFields } = useRawEditor();
|
||||
|
||||
const firstEditableFieldIndex = computed(() => {
|
||||
for (let i = 0; i < fieldNames.value.length; i++) {
|
||||
const field = fieldsMap.value[fieldNames.value[i]];
|
||||
if (field?.meta && !field.meta?.readonly && !field.meta?.hidden) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const firstVisibleFieldIndex = computed(() => {
|
||||
for (let i = 0; i < fieldNames.value.length; i++) {
|
||||
const field = fieldsMap.value[fieldNames.value[i]];
|
||||
if (field?.meta && !field.meta?.hidden) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.validationErrors,
|
||||
(newVal, oldVal) => {
|
||||
if (props.nested) return;
|
||||
if (isEqual(newVal, oldVal)) return;
|
||||
if (newVal?.length > 0) el?.value?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
);
|
||||
|
||||
provide('values', values);
|
||||
|
||||
function useForm() {
|
||||
const fieldsStore = useFieldsStore();
|
||||
const fields = ref<Field[]>(getFields());
|
||||
|
||||
watch(
|
||||
() => props.fields,
|
||||
() => {
|
||||
const newVal = getFields();
|
||||
if (!isEqual(fields.value, newVal)) {
|
||||
fields.value = newVal;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const defaultValues = getDefaultValuesFromFields(fields);
|
||||
|
||||
const { formFields } = useFormFields(fields);
|
||||
|
||||
const fieldsMap: ComputedRef<Record<string, Field | undefined>> = computed(() => {
|
||||
if (props.loading) return {} as Record<string, undefined>;
|
||||
const valuesWithDefaults = Object.assign({}, defaultValues.value, values.value);
|
||||
return formFields.value.reduce((result: Record<string, Field>, field: Field) => {
|
||||
const newField = applyConditions(valuesWithDefaults, setPrimaryKeyReadonly(field));
|
||||
if (newField.field) result[newField.field] = newField;
|
||||
return result;
|
||||
}, {} as Record<string, Field>);
|
||||
});
|
||||
|
||||
const fieldsInGroup = computed(() =>
|
||||
formFields.value.filter(
|
||||
(field: Field) => field.meta?.group === props.group || (props.group === null && isNil(field.meta?.group))
|
||||
)
|
||||
);
|
||||
const fieldNames = computed(() => {
|
||||
return fieldsInGroup.value.map((f) => f.field);
|
||||
});
|
||||
|
||||
const fieldsForGroup = computed(() =>
|
||||
fieldNames.value.map((name: string) => getFieldsForGroup(fieldsMap.value[name]?.meta?.field || null))
|
||||
);
|
||||
|
||||
return { fieldNames, fieldsMap, isDisabled, getFieldsForGroup, fieldsForGroup };
|
||||
|
||||
function isDisabled(field: Field | undefined) {
|
||||
if (!field) return true;
|
||||
const meta = fieldsMap.value?.[field.field]?.meta;
|
||||
return (
|
||||
props.loading ||
|
||||
props.disabled === true ||
|
||||
meta?.readonly === true ||
|
||||
field.schema?.is_generated === true ||
|
||||
(props.batchMode && batchActiveFields.value.includes(field.field) === false)
|
||||
);
|
||||
}
|
||||
|
||||
function getFieldsForGroup(group: null | string, passed: string[] = []): Field[] {
|
||||
const fieldsInGroup: Field[] = fields.value.filter((field) => {
|
||||
const meta = fieldsMap.value?.[field.field]?.meta;
|
||||
return meta?.group === group || (group === null && isNil(meta));
|
||||
});
|
||||
|
||||
for (const field of fieldsInGroup) {
|
||||
const meta = fieldsMap.value?.[field.field]?.meta;
|
||||
if (meta?.special?.includes('group') && !passed.includes(meta!.field)) {
|
||||
passed.push(meta!.field);
|
||||
fieldsInGroup.push(...getFieldsForGroup(meta!.field, passed));
|
||||
}
|
||||
}
|
||||
|
||||
return fieldsInGroup;
|
||||
}
|
||||
|
||||
function getFields(): Field[] {
|
||||
if (props.collection) {
|
||||
return fieldsStore.getFieldsForCollection(props.collection);
|
||||
}
|
||||
if (props.fields) {
|
||||
return props.fields;
|
||||
}
|
||||
|
||||
throw new Error('[v-form]: You need to pass either the collection or fields prop.');
|
||||
}
|
||||
|
||||
function setPrimaryKeyReadonly(field: Field) {
|
||||
if (
|
||||
field.schema?.has_auto_increment === true ||
|
||||
(field.schema?.is_primary_key === true && props.primaryKey !== '+')
|
||||
) {
|
||||
const fieldClone = cloneDeep(field) as any;
|
||||
if (!fieldClone.meta) fieldClone.meta = {};
|
||||
fieldClone.meta.readonly = true;
|
||||
return fieldClone;
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
}
|
||||
|
||||
function setValue(fieldKey: string, value: any, opts?: { force?: boolean }) {
|
||||
const field = fieldsMap.value[fieldKey];
|
||||
|
||||
if (opts?.force !== true && (!field || isDisabled(field))) return;
|
||||
|
||||
const edits = props.modelValue ? cloneDeep(props.modelValue) : {};
|
||||
edits[fieldKey] = value;
|
||||
emit('update:modelValue', edits);
|
||||
}
|
||||
|
||||
function apply(updates: { [field: string]: any }) {
|
||||
const updatableKeys = props.batchMode
|
||||
? Object.keys(updates)
|
||||
: Object.keys(updates).filter((key) => {
|
||||
const field = fieldsMap.value[key];
|
||||
if (!field) return false;
|
||||
return field.schema?.is_primary_key || !isDisabled(field);
|
||||
});
|
||||
|
||||
if (!isNil(props.group)) {
|
||||
const groupFields = getFieldsForGroup(props.group)
|
||||
.filter((field) => !field.schema?.is_primary_key && !isDisabled(field))
|
||||
.map((field) => field.field);
|
||||
emit('update:modelValue', assign({}, omit(props.modelValue, groupFields), pick(updates, updatableKeys)));
|
||||
} else {
|
||||
emit('update:modelValue', pick(assign({}, props.modelValue, updates), updatableKeys));
|
||||
}
|
||||
}
|
||||
|
||||
function unsetValue(field: Field | undefined) {
|
||||
if (!field) return;
|
||||
if (!props.batchMode && isDisabled(field)) return;
|
||||
|
||||
if (field.field in (props.modelValue || {})) {
|
||||
const newEdits = { ...props.modelValue };
|
||||
delete newEdits[field.field];
|
||||
emit('update:modelValue', newEdits);
|
||||
}
|
||||
}
|
||||
|
||||
function useBatch() {
|
||||
const batchActiveFields = ref<string[]>([]);
|
||||
|
||||
return { batchActiveFields, toggleBatchField };
|
||||
|
||||
function toggleBatchField(field: Field | undefined) {
|
||||
if (!field) return;
|
||||
if (batchActiveFields.value.includes(field.field)) {
|
||||
batchActiveFields.value = batchActiveFields.value.filter((fieldKey) => fieldKey !== field.field);
|
||||
|
||||
unsetValue(field);
|
||||
} else {
|
||||
batchActiveFields.value = [...batchActiveFields.value, field.field];
|
||||
setValue(field.field, field.schema?.default_value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToField(fieldKey: string) {
|
||||
const { field } = extractFieldFromFunction(fieldKey);
|
||||
if (!formFieldEls.value[field]) return;
|
||||
formFieldEls.value[field].$el.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function useRawEditor() {
|
||||
const rawActiveFields = ref(new Set<string>());
|
||||
|
||||
return { rawActiveFields, toggleRawField };
|
||||
|
||||
function toggleRawField(field: Field | undefined) {
|
||||
if (!field) return;
|
||||
if (rawActiveFields.value.has(field.field)) {
|
||||
rawActiveFields.value.delete(field.field);
|
||||
} else {
|
||||
rawActiveFields.value.add(field.field);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
<template>
|
||||
<template v-for="({ highlighted, text: textContent }, index) in parts">
|
||||
<mark v-if="highlighted" :key="index" class="highlight">{{ textContent }}</mark>
|
||||
<template v-else>{{ textContent }}</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, computed } from 'vue';
|
||||
import { flatten } from 'lodash';
|
||||
import { remove as removeDiacritics } from 'diacritics';
|
||||
import { toArray } from '@directus/shared/utils';
|
||||
|
||||
type HighlightPart = {
|
||||
text: string;
|
||||
highlighted: boolean;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'VHighlight',
|
||||
props: {
|
||||
query: {
|
||||
type: [String, Array] as PropType<string | string[]>,
|
||||
default: null,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const parts = computed<HighlightPart[]>(() => {
|
||||
let searchText = removeDiacritics(props.text.toLowerCase());
|
||||
|
||||
const queries = toArray(props.query);
|
||||
|
||||
if (queries.length === 0) {
|
||||
return [
|
||||
{
|
||||
highlighted: false,
|
||||
text: props.text,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const matches = flatten(
|
||||
queries
|
||||
.filter((query) => query)
|
||||
.map((query) => {
|
||||
query = removeDiacritics(query.toLowerCase());
|
||||
|
||||
const indices = [];
|
||||
|
||||
let startIndex = 0;
|
||||
let index = searchText.indexOf(query, startIndex);
|
||||
|
||||
while (index > -1) {
|
||||
startIndex = index + query.length;
|
||||
indices.push([index, startIndex]);
|
||||
index = searchText.indexOf(query, index + 1);
|
||||
}
|
||||
|
||||
return indices;
|
||||
})
|
||||
);
|
||||
|
||||
matches.sort((a, b) => {
|
||||
if (a[0] !== b[0]) return a[0] - b[0];
|
||||
return a[1] - b[1];
|
||||
});
|
||||
|
||||
if (matches.length === 0) {
|
||||
return [
|
||||
{
|
||||
highlighted: false,
|
||||
text: props.text,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const mergedMatches = [];
|
||||
|
||||
let curStart = matches[0][0];
|
||||
let curEnd = matches[0][1];
|
||||
|
||||
matches.shift();
|
||||
|
||||
for (const [start, end] of matches) {
|
||||
if (start >= curEnd) {
|
||||
mergedMatches.push([curStart, curEnd]);
|
||||
curStart = start;
|
||||
curEnd = end;
|
||||
} else if (end > curEnd) {
|
||||
curEnd = end;
|
||||
}
|
||||
}
|
||||
|
||||
mergedMatches.push([curStart, curEnd]);
|
||||
|
||||
let lastEnd = 0;
|
||||
|
||||
const parts: HighlightPart[] = [];
|
||||
|
||||
for (const [start, end] of mergedMatches) {
|
||||
if (lastEnd !== start) {
|
||||
parts.push({
|
||||
highlighted: false,
|
||||
text: props.text.slice(lastEnd, start),
|
||||
});
|
||||
}
|
||||
|
||||
parts.push({
|
||||
highlighted: true,
|
||||
text: props.text.slice(start, end),
|
||||
});
|
||||
|
||||
lastEnd = end;
|
||||
}
|
||||
|
||||
if (lastEnd !== searchText.length) {
|
||||
parts.push({
|
||||
highlighted: false,
|
||||
text: props.text.slice(lastEnd),
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
});
|
||||
|
||||
return { parts };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
mark {
|
||||
margin: -1px -2px;
|
||||
padding: 1px 2px;
|
||||
background-color: var(--primary-25);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<component :is="tag" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
|
||||
<slot v-bind="{ hover }" />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
closeDelay: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
openDelay: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tag: {
|
||||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const hover = ref<boolean>(false);
|
||||
|
||||
return { hover, onMouseEnter, onMouseLeave };
|
||||
|
||||
function onMouseEnter() {
|
||||
if (props.disabled === true) return;
|
||||
|
||||
setTimeout(() => {
|
||||
hover.value = true;
|
||||
}, props.openDelay);
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
if (props.disabled === true) return;
|
||||
|
||||
setTimeout(() => {
|
||||
hover.value = false;
|
||||
}, props.closeDelay);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<div class="icon" :class="{ right: ext.length >= 4 }">
|
||||
<v-icon name="insert_drive_file" />
|
||||
<span class="label">{{ ext }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
ext: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:global(body) {
|
||||
--v-icon-file-color: var(--primary);
|
||||
--v-icon-file-background-color: var(--background-normal);
|
||||
}
|
||||
|
||||
.icon {
|
||||
--v-icon-size: 64px;
|
||||
--v-icon-color: var(--v-icon-file-color);
|
||||
color: var(--v-icon-file-color);
|
||||
position: relative;
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
text-transform: uppercase;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: 55%;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
padding: 2px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.right {
|
||||
.label {
|
||||
background-color: var(--v-icon-file-background-color);
|
||||
left: calc(100% - 12px - 3ch);
|
||||
text-align: left;
|
||||
transform: none;
|
||||
padding-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.38 3.6c-.38-.4-.83-.6-1.36-.6H6.98c-.53 0-1 .2-1.4.6-.38.42-.56.88-.56 1.42V21L12 18l6.98 3V5.02c0-.54-.2-1-.6-1.41zm-8.05 11.5l6.7-6.7-1.4-1.4-5.3 5.3-1.92-1.93L7 11.79l3.33 3.33z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 22 22"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
d="M2.036 16.961l8.578 3.944a.886.886 0 00.772 0l8.576-3.944c.357-.164.536-.443.536-.836V5.875a.924.924 0 00-.016-.166v-.048a.95.95 0 00-.037-.118l-.014-.039a.91.91 0 00-.073-.138l-.021-.03a1.067 1.067 0 00-.081-.097l-.043-.03a.901.901 0 00-.102-.083l-.025-.018a1.003 1.003 0 00-.124-.069l-8.578-3.944a.886.886 0 00-.772 0L2.036 5.039a.935.935 0 00-.124.069l-.025.018a.901.901 0 00-.102.083l-.03.032a.915.915 0 00-.08.097l-.021.03a.916.916 0 00-.074.138l-.025.037a1.006 1.006 0 00-.037.118v.048a.924.924 0 00-.016.166v10.25c0 .393.178.671.534.836zm1.306-9.646l6.735 3.102v8.223l-6.735-3.104V7.315zm8.578 11.323v-8.221l6.735-3.102v8.223l-6.735 3.1zm-.921-15.7l6.376 2.937-6.376 2.937-6.376-2.937 6.376-2.937z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
d="M10.588 13.412c.408.408.879.611 1.412.611.533 0 1.004-.203 1.412-.611.408-.408.611-.879.611-1.412 0-.533-.203-1.004-.611-1.412-.408-.408-.879-.612-1.412-.612-.533 0-1.004.204-1.412.612-.408.408-.612.879-.612 1.412 0 .533.204 1.004.612 1.412zM9.176 9.176C9.961 8.392 10.902 8 12 8c1.098 0 2.04.392 2.823 1.176C15.608 9.961 16 10.902 16 12c0 1.098-.392 2.04-1.177 2.823C14.04 15.608 13.098 16 12 16c-1.098 0-2.04-.392-2.824-1.177C8.392 14.04 8 13.098 8 12c0-1.098.392-2.04 1.176-2.824z"
|
||||
/>
|
||||
<path d="M13 3v6h-2V3h2zM13 15v6h-2v-6h2z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M19.2 14.15a1.8 1.8 0 01-.35-.12c-.1-.05-.14-.18-.13-.3.02-.41-.01-.78.03-1.2.18-1.85 1.38-1.26 2.46-1.57.58-.16 1.17-.47 1.42-1.06.07-.16.02-.35-.1-.48a13.66 13.66 0 00-2.27-1.98 14.25 14.25 0 00-9.85-2.31.2.2 0 00-.15.32A5.17 5.17 0 0011.93 7c.1.06.06.2-.06.18-.3-.06-.68-.18-1.06-.4a.43.43 0 00-.38-.05l-.44.18a.2.2 0 00-.05.34 5.32 5.32 0 006.14.42c.1-.06.25.07.22.18-.07.21-.15.52-.23.95-.48 2.37-1.87 2.18-3.58 1.59-3.36-1.19-5.3-.22-7-2.1-.19-.21-.5-.29-.7-.09a1.55 1.55 0 00.1 2.29c.15.12.36.07.5-.06.07-.06.13-.1.21-.14.1-.04.16.11.07.18-.4.33-.51.7-.76 1.48-.38 1.16-.22 2.36-1.98 2.67-.93.04-.91.66-1.25 1.58A5.2 5.2 0 01.3 18.27c-.4.41-.44 1.18.14 1.23.18 0 .36-.03.55-.1.99-.4 1.75-1.65 2.47-2.46.8-.9 2.72-.51 4.17-1.4.82-.48 1.3-1.1 1.08-2.07-.02-.12.12-.2.18-.09.1.2.18.41.23.63.05.22.25.39.47.4 1.36.1 3.02 1.3 4.63 1.88.32.11.56-.26.43-.57-.1-.24-.18-.48-.23-.7-.02-.13.16-.16.22-.05a3.5 3.5 0 002.88 1.88c.46.03.97-.02 1.5-.18.63-.18 1.22-.42 1.91-.3.52.1 1 .36 1.3.79.36.5 1.04.7 1.54.38.28-.19.29-.58.13-.87-1.14-2.08-3.61-2.25-4.7-2.52z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
d="M 3 2 L 3 11 L 21 11 L 3 2 z M 5 5.2363281 L 12.527344 9 L 5 9 L 5 5.2363281 z M 3 13 L 3 22 L 21 13 L 3 13 z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
d="M3,2 L3,11 L21,11 L3,2 Z M5,5.2363281 L12.527344,9 L5,9 L5,5.2363281 Z M3,13 L3,22 L21,13 L3,13 Z"
|
||||
transform="translate(12.000000, 12.000000) rotate(-90.000000) translate(-12.000000, -12.000000) "
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,27 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.1875 6L11.1562 8.01562H20.0156V18H3.98438V6H9.1875ZM9.98438 3.98438H3.98438C2.90625 3.98438 2.01562 4.92188 2.01562 6V18C2.01562 19.0781 2.90625 20.0156 3.98438 20.0156H20.0156C21.0938 20.0156 21.9844 19.0781 21.9844 18V8.01562C21.9844 6.89062 21.0938 6 20.0156 6H12L9.98438 3.98438Z"
|
||||
/>
|
||||
<rect x="13" y="12" width="6" height="5" rx="1" />
|
||||
<rect x="17" y="11" width="1" height="1" />
|
||||
<rect x="14" y="11" width="1" height="1" />
|
||||
<path
|
||||
d="M17 11H18C18 9.89543 17.1046 9 16 9C14.8954 9 14 9.89543 14 11H15C15 10.4477 15.4477 10 16 10C16.5523 10 17 10.4477 17 11Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.17 8l-2-2H4v12h16V8h-8.83zM4 4h6l2 2h8a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2l.01-12A2 2 0 014 4zm10.44 5l4 4-4 4v-3H11v-2h3.44V9z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path d="M3 3h18v18H3z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path d="M11 2H2v9h9V2zm0 11H2v9h9v-9zm2-11h9v9h-9V2zm9 11h-9v9h9v-9z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
d="M1 1h6v6H1V1zm0 8h6v6H1V9zm6 8H1v6h6v-6zM9 1h6v6H9V1zm6 8H9v6h6V9zm-6 8h6v6H9v-6zM23 1h-6v6h6V1zm-6 8h6v6h-6V9zm6 8h-6v6h6v-6z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
d="M6 3H3v3h3V3zm0 5H3v3h3V8zm-3 5h3v3H3v-3zm3 5H3v3h3v-3zM8 3h3v3H8V3zm3 5H8v3h3V8zm-3 5h3v3H8v-3zm3 5H8v3h3v-3zm2-15h3v3h-3V3zm3 5h-3v3h3V8zm-3 5h3v3h-3v-3zm3 5h-3v3h3v-3zm2-15h3v3h-3V3zm3 5h-3v3h3V8zm-3 5h3v3h-3v-3zm3 5h-3v3h3v-3z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
d="M3 3h2v2H3V3zm4 0h2v2H7V3zm0 4h2v2H7V7zm2 4H7v2h2v-2zm-2 4h2v2H7v-2zm2 4H7v2h2v-2zM5 7H3v2h2V7zm-2 4h2v2H3v-2zm2 4H3v2h2v-2zm-2 4h2v2H3v-2zM13 3h-2v2h2V3zm-2 4h2v2h-2V7zm2 4h-2v2h2v-2zm-2 4h2v2h-2v-2zm2 4h-2v2h2v-2zm2-16h2v2h-2V3zm2 4h-2v2h2V7zm-2 4h2v2h-2v-2zm2 4h-2v2h2v-2zm-2 4h2v2h-2v-2zm6-16h-2v2h2V3zm-2 4h2v2h-2V7zm2 4h-2v2h2v-2zm-2 4h2v2h-2v-2zm2 4h-2v2h2v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
d="M5 4H4v1h1V4zm0 6H4v1h1v-1zm-1 6h1v1H4v-1zm1-9H4v1h1V7zm-1 6h1v1H4v-1zm1 6H4v1h1v-1zM7 4h1v1H7V4zm1 6H7v1h1v-1zm-1 6h1v1H7v-1zm1-9H7v1h1V7zm-1 6h1v1H7v-1zm1 6H7v1h1v-1zm2-15h1v1h-1V4zm1 6h-1v1h1v-1zm-1 6h1v1h-1v-1zm1-9h-1v1h1V7zm-1 6h1v1h-1v-1zm1 6h-1v1h1v-1zm2-15h1v1h-1V4zm1 6h-1v1h1v-1zm-1 6h1v1h-1v-1zm1-9h-1v1h1V7zm-1 6h1v1h-1v-1zm1 6h-1v1h1v-1zm2-15h1v1h-1V4zm1 6h-1v1h1v-1zm-1 6h1v1h-1v-1zm1-9h-1v1h1V7zm-1 6h1v1h-1v-1zm1 6h-1v1h1v-1zm2-15h1v1h-1V4zm1 6h-1v1h1v-1zm-1 6h1v1h-1v-1zm1-9h-1v1h1V7zm-1 6h1v1h-1v-1zm1 6h-1v1h1v-1z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4a2 2 0 00-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 48 48"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
fill-opacity=".3"
|
||||
d="M24.02 42.98L47.28 14c-.9-.68-9.85-8-23.28-8S1.62 13.32.72 14l23.26 28.98.02.02.02-.02z"
|
||||
/>
|
||||
<path d="M0 0h48v48H0z" fill="none" />
|
||||
<path d="M13.34 29.72l10.65 13.27.01.01.01-.01 10.65-13.27C34.13 29.31 30.06 26 24 26s-10.13 3.31-10.66 3.72z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 48 48"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
fill-opacity=".3"
|
||||
d="M24.02 42.98L47.28 14c-.9-.68-9.85-8-23.28-8S1.62 13.32.72 14l23.26 28.98.02.02.02-.02z"
|
||||
/>
|
||||
<path d="M0 0h48v48H0z" fill="none" />
|
||||
<path
|
||||
d="M7.07 21.91l16.92 21.07.01.02.02-.02 16.92-21.07C40.08 21.25 33.62 16 24 16c-9.63 0-16.08 5.25-16.93 5.91z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 48 48"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
>
|
||||
<path
|
||||
fill-opacity=".3"
|
||||
d="M24.02 42.98L47.28 14c-.9-.68-9.85-8-23.28-8S1.62 13.32.72 14l23.26 28.98.02.02.02-.02z"
|
||||
/>
|
||||
<path d="M0 0h48v48H0z" fill="none" />
|
||||
<path d="M9.58 25.03l14.41 17.95.01.02.01-.02 14.41-17.95C37.7 24.47 32.2 20 24 20s-13.7 4.47-14.42 5.03z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {};
|
||||
</script>
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent, h, PropType } from 'vue';
|
||||
import { icon, findIconDefinition, IconName } from '@fortawesome/fontawesome-svg-core';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
name: {
|
||||
type: String as PropType<IconName>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
render() {
|
||||
const socialIcon = icon(findIconDefinition({ prefix: 'fab', iconName: this.name }));
|
||||
|
||||
if (socialIcon && socialIcon.abstract[0] && socialIcon.abstract[0].children && socialIcon.abstract[0].children[0]) {
|
||||
return h(
|
||||
'svg',
|
||||
{
|
||||
...socialIcon.abstract[0].attributes,
|
||||
},
|
||||
h('path', { ...socialIcon.abstract![0].children![0].attributes })
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,716 +0,0 @@
|
||||
<template>
|
||||
<span
|
||||
class="v-icon"
|
||||
:class="[sizeClass, { 'has-click': !disabled && clickable, left, right }]"
|
||||
:role="clickable ? 'button' : null"
|
||||
:tabindex="clickable ? 0 : null"
|
||||
:style="{ '--v-icon-color': color }"
|
||||
@click="emitClick"
|
||||
>
|
||||
<component :is="customIconName" v-if="customIconName" />
|
||||
<socialIcon v-else-if="socialIconName" :name="socialIconName" />
|
||||
<i v-else :class="{ filled }" :data-icon="name"></i>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { fab } from '@fortawesome/free-brands-svg-icons';
|
||||
import { useSizeClass, sizeProps } from '@/composables/use-size-class';
|
||||
|
||||
import CustomIconDirectus from './custom-icons/directus.vue';
|
||||
import CustomIconBookmarkSave from './custom-icons/bookmark_save.vue';
|
||||
import CustomIconBox from './custom-icons/box.vue';
|
||||
import CustomIconCommitNode from './custom-icons/commit_node.vue';
|
||||
import CustomIconGrid1 from './custom-icons/grid_1.vue';
|
||||
import CustomIconGrid2 from './custom-icons/grid_2.vue';
|
||||
import CustomIconGrid3 from './custom-icons/grid_3.vue';
|
||||
import CustomIconGrid4 from './custom-icons/grid_4.vue';
|
||||
import CustomIconGrid5 from './custom-icons/grid_5.vue';
|
||||
import CustomIconGrid6 from './custom-icons/grid_6.vue';
|
||||
import CustomIconSignalWifi1Bar from './custom-icons/signal_wifi_1_bar.vue';
|
||||
import CustomIconSignalWifi2Bar from './custom-icons/signal_wifi_2_bar.vue';
|
||||
import CustomIconSignalWifi3Bar from './custom-icons/signal_wifi_3_bar.vue';
|
||||
import CustomIconFlipHorizontal from './custom-icons/flip_horizontal.vue';
|
||||
import CustomIconFlipVertical from './custom-icons/flip_vertical.vue';
|
||||
import CustomIconFolderMove from './custom-icons/folder_move.vue';
|
||||
import CustomIconFolderLock from './custom-icons/folder_lock.vue';
|
||||
import CustomIconLogout from './custom-icons/logout.vue';
|
||||
|
||||
import SocialIcon from './social-icon.vue';
|
||||
|
||||
library.add(fab);
|
||||
|
||||
const customIcons: string[] = [
|
||||
'directus',
|
||||
'bookmark_save',
|
||||
'box',
|
||||
'commit_node',
|
||||
'grid_1',
|
||||
'grid_2',
|
||||
'grid_3',
|
||||
'grid_4',
|
||||
'grid_5',
|
||||
'grid_6',
|
||||
'signal_wifi_1_bar',
|
||||
'signal_wifi_2_bar',
|
||||
'signal_wifi_3_bar',
|
||||
'flip_horizontal',
|
||||
'flip_vertical',
|
||||
'folder_move',
|
||||
'folder_lock',
|
||||
'logout',
|
||||
];
|
||||
|
||||
const socialIcons: string[] = [
|
||||
'500px',
|
||||
'accessible_icon',
|
||||
'accusoft',
|
||||
'acquisitions_incorporated',
|
||||
'adn',
|
||||
'adversal',
|
||||
'affiliatetheme',
|
||||
'airbnb',
|
||||
'algolia',
|
||||
'alipay',
|
||||
'amazon',
|
||||
'amazon_pay',
|
||||
'amilia',
|
||||
'android',
|
||||
'angellist',
|
||||
'angrycreative',
|
||||
'angular',
|
||||
'app_store',
|
||||
'app_store_ios',
|
||||
'apper',
|
||||
'apple',
|
||||
'apple_pay',
|
||||
'artstation',
|
||||
'asymmetrik',
|
||||
'atlassian',
|
||||
'audible',
|
||||
'autoprefixer',
|
||||
'avianex',
|
||||
'aviato',
|
||||
'aws',
|
||||
'bandcamp',
|
||||
'battle_net',
|
||||
'behance',
|
||||
'behance_square',
|
||||
'bimobject',
|
||||
'bitbucket',
|
||||
'bitcoin',
|
||||
'bity',
|
||||
'black_tie',
|
||||
'blackberry',
|
||||
'blogger',
|
||||
'blogger_b',
|
||||
'bluetooth',
|
||||
'bluetooth_b',
|
||||
'bootstrap',
|
||||
'btc',
|
||||
'buffer',
|
||||
'buromobelexperte',
|
||||
'buy_n_large',
|
||||
'buysellads',
|
||||
'canadian_maple_leaf',
|
||||
'cc_amazon_pay',
|
||||
'cc_amex',
|
||||
'cc_apple_pay',
|
||||
'cc_diners_club',
|
||||
'cc_discover',
|
||||
'cc_jcb',
|
||||
'cc_mastercard',
|
||||
'cc_paypal',
|
||||
'cc_stripe',
|
||||
'cc_visa',
|
||||
'centercode',
|
||||
'centos',
|
||||
'chrome',
|
||||
'chromecast',
|
||||
'cloudflare',
|
||||
'cloudscale',
|
||||
'cloudsmith',
|
||||
'cloudversify',
|
||||
'codepen',
|
||||
'codiepie',
|
||||
'confluence',
|
||||
'connectdevelop',
|
||||
'contao',
|
||||
'cotton_bureau',
|
||||
'cpanel',
|
||||
'creative_commons',
|
||||
'creative_commons_by',
|
||||
'creative_commons_nc',
|
||||
'creative_commons_nc_eu',
|
||||
'creative_commons_nc_jp',
|
||||
'creative_commons_nd',
|
||||
'creative_commons_pd',
|
||||
'creative_commons_pd_alt',
|
||||
'creative_commons_remix',
|
||||
'creative_commons_sa',
|
||||
'creative_commons_sampling',
|
||||
'creative_commons_sampling_plus',
|
||||
'creative_commons_share',
|
||||
'creative_commons_zero',
|
||||
'critical_role',
|
||||
'css3',
|
||||
'css3_alt',
|
||||
'cuttlefish',
|
||||
'd_and_d',
|
||||
'd_and_d_beyond',
|
||||
'dailymotion',
|
||||
'dashcube',
|
||||
'deezer',
|
||||
'delicious',
|
||||
'deploydog',
|
||||
'deskpro',
|
||||
'dev',
|
||||
'deviantart',
|
||||
'dhl',
|
||||
'diaspora',
|
||||
'digg',
|
||||
'digital_ocean',
|
||||
'discord',
|
||||
'discourse',
|
||||
'dochub',
|
||||
'docker',
|
||||
'draft2digital',
|
||||
'dribbble',
|
||||
'dribbble_square',
|
||||
'dropbox',
|
||||
'drupal',
|
||||
'dyalog',
|
||||
'earlybirds',
|
||||
'ebay',
|
||||
'edge',
|
||||
'edge_legacy',
|
||||
'elementor',
|
||||
'ello',
|
||||
'ember',
|
||||
'empire',
|
||||
'envira',
|
||||
'erlang',
|
||||
'ethereum',
|
||||
'etsy',
|
||||
'evernote',
|
||||
'expeditedssl',
|
||||
'facebook',
|
||||
'facebook_f',
|
||||
'facebook_messenger',
|
||||
'facebook_square',
|
||||
'fantasy_flight_games',
|
||||
'fedex',
|
||||
'fedora',
|
||||
'figma',
|
||||
'firefox',
|
||||
'firefox_browser',
|
||||
'first_order',
|
||||
'first_order_alt',
|
||||
'firstdraft',
|
||||
'flickr',
|
||||
'flipboard',
|
||||
'fly',
|
||||
'font_awesome',
|
||||
'font_awesome_alt',
|
||||
'font_awesome_flag',
|
||||
'fonticons',
|
||||
'fonticons_fi',
|
||||
'fort_awesome',
|
||||
'fort_awesome_alt',
|
||||
'forumbee',
|
||||
'foursquare',
|
||||
'free_code_camp',
|
||||
'freebsd',
|
||||
'fulcrum',
|
||||
'galactic_republic',
|
||||
'galactic_senate',
|
||||
'get_pocket',
|
||||
'gg',
|
||||
'gg_circle',
|
||||
'git',
|
||||
'git_alt',
|
||||
'git_square',
|
||||
'github',
|
||||
'github_alt',
|
||||
'github_square',
|
||||
'gitkraken',
|
||||
'gitlab',
|
||||
'gitter',
|
||||
'glide',
|
||||
'glide_g',
|
||||
'gofore',
|
||||
'goodreads',
|
||||
'goodreads_g',
|
||||
'google',
|
||||
'google_drive',
|
||||
'google_pay',
|
||||
'google_play',
|
||||
'google_plus',
|
||||
'google_plus_g',
|
||||
'google_plus_square',
|
||||
'google_wallet',
|
||||
'gratipay',
|
||||
'grav',
|
||||
'gripfire',
|
||||
'grunt',
|
||||
'guilded',
|
||||
'gulp',
|
||||
'hacker_news',
|
||||
'hacker_news_square',
|
||||
'hackerrank',
|
||||
'hips',
|
||||
'hire_a_helper',
|
||||
'hive',
|
||||
'hooli',
|
||||
'hornbill',
|
||||
'hotjar',
|
||||
'houzz',
|
||||
'html5',
|
||||
'hubspot',
|
||||
'ideal',
|
||||
'imdb',
|
||||
'innosoft',
|
||||
'instagram',
|
||||
'instagram_square',
|
||||
'instalod',
|
||||
'intercom',
|
||||
'internet_explorer',
|
||||
'invision',
|
||||
'ioxhost',
|
||||
'itch_io',
|
||||
'itunes',
|
||||
'itunes_note',
|
||||
'java',
|
||||
'jedi_order',
|
||||
'jenkins',
|
||||
'jira',
|
||||
'joget',
|
||||
'joomla',
|
||||
'js',
|
||||
'js_square',
|
||||
'jsfiddle',
|
||||
'kaggle',
|
||||
'keybase',
|
||||
'keycdn',
|
||||
'kickstarter',
|
||||
'kickstarter_k',
|
||||
'korvue',
|
||||
'laravel',
|
||||
'lastfm',
|
||||
'lastfm_square',
|
||||
'leanpub',
|
||||
'less',
|
||||
'line',
|
||||
'linkedin',
|
||||
'linkedin_in',
|
||||
'linode',
|
||||
'linux',
|
||||
'lyft',
|
||||
'magento',
|
||||
'mailchimp',
|
||||
'mandalorian',
|
||||
'markdown',
|
||||
'mastodon',
|
||||
'maxcdn',
|
||||
'mdb',
|
||||
'medapps',
|
||||
'medium',
|
||||
'medium_m',
|
||||
'medrt',
|
||||
'meetup',
|
||||
'megaport',
|
||||
'mendeley',
|
||||
'microblog',
|
||||
'microsoft',
|
||||
'mix',
|
||||
'mixcloud',
|
||||
'mixer',
|
||||
'mizuni',
|
||||
'modx',
|
||||
'monero',
|
||||
'napster',
|
||||
'neos',
|
||||
'nimblr',
|
||||
'node',
|
||||
'node_js',
|
||||
'npm',
|
||||
'ns8',
|
||||
'nutritionix',
|
||||
'octopus_deploy',
|
||||
'odnoklassniki',
|
||||
'odnoklassniki_square',
|
||||
'old_republic',
|
||||
'opencart',
|
||||
'openid',
|
||||
'opera',
|
||||
'optin_monster',
|
||||
'orcid',
|
||||
'osi',
|
||||
'page4',
|
||||
'pagelines',
|
||||
'palfed',
|
||||
'patreon',
|
||||
'paypal',
|
||||
'penny_arcade',
|
||||
'perbyte',
|
||||
'periscope',
|
||||
'phabricator',
|
||||
'phoenix_framework',
|
||||
'phoenix_squadron',
|
||||
'php',
|
||||
'pied_piper',
|
||||
'pied_piper_alt',
|
||||
'pied_piper_hat',
|
||||
'pied_piper_pp',
|
||||
'pied_piper_square',
|
||||
'pinterest',
|
||||
'pinterest_p',
|
||||
'pinterest_square',
|
||||
'playstation',
|
||||
'product_hunt',
|
||||
'pushed',
|
||||
'python',
|
||||
'qq',
|
||||
'quinscape',
|
||||
'quora',
|
||||
'r_project',
|
||||
'raspberry_pi',
|
||||
'ravelry',
|
||||
'react',
|
||||
'reacteurope',
|
||||
'readme',
|
||||
'rebel',
|
||||
'red_river',
|
||||
'reddit',
|
||||
'reddit_alien',
|
||||
'reddit_square',
|
||||
'redhat',
|
||||
'renren',
|
||||
'replyd',
|
||||
'researchgate',
|
||||
'resolving',
|
||||
'rev',
|
||||
'rocketchat',
|
||||
'rockrms',
|
||||
'rust',
|
||||
'safari',
|
||||
'salesforce',
|
||||
'sass',
|
||||
'schlix',
|
||||
'scribd',
|
||||
'searchengin',
|
||||
'sellcast',
|
||||
'sellsy',
|
||||
'servicestack',
|
||||
'shirtsinbulk',
|
||||
'shopify',
|
||||
'shopware',
|
||||
'simplybuilt',
|
||||
'sistrix',
|
||||
'sith',
|
||||
'sketch',
|
||||
'skyatlas',
|
||||
'skype',
|
||||
'slack',
|
||||
'slack_hash',
|
||||
'slideshare',
|
||||
'snapchat',
|
||||
'snapchat_ghost',
|
||||
'snapchat_square',
|
||||
'soundcloud',
|
||||
'sourcetree',
|
||||
'speakap',
|
||||
'speaker_deck',
|
||||
'spotify',
|
||||
'squarespace',
|
||||
'stack_exchange',
|
||||
'stack_overflow',
|
||||
'stackpath',
|
||||
'staylinked',
|
||||
'steam',
|
||||
'steam_square',
|
||||
'steam_symbol',
|
||||
'sticker_mule',
|
||||
'strava',
|
||||
'stripe',
|
||||
'stripe_s',
|
||||
'studiovinari',
|
||||
'stumbleupon',
|
||||
'stumbleupon_circle',
|
||||
'superpowers',
|
||||
'supple',
|
||||
'suse',
|
||||
'swift',
|
||||
'symfony',
|
||||
'teamspeak',
|
||||
'telegram',
|
||||
'telegram_plane',
|
||||
'tencent_weibo',
|
||||
'the_red_yeti',
|
||||
'themeco',
|
||||
'themeisle',
|
||||
'think_peaks',
|
||||
'tiktok',
|
||||
'trade_federation',
|
||||
'trello',
|
||||
'tumblr',
|
||||
'tumblr_square',
|
||||
'twitch',
|
||||
'twitter',
|
||||
'twitter_square',
|
||||
'typo3',
|
||||
'uber',
|
||||
'ubuntu',
|
||||
'uikit',
|
||||
'umbraco',
|
||||
'uncharted',
|
||||
'uniregistry',
|
||||
'unity',
|
||||
'unsplash',
|
||||
'untappd',
|
||||
'ups',
|
||||
'usb',
|
||||
'usps',
|
||||
'ussunnah',
|
||||
'vaadin',
|
||||
'viacoin',
|
||||
'viadeo',
|
||||
'viadeo_square',
|
||||
'viber',
|
||||
'vimeo',
|
||||
'vimeo_square',
|
||||
'vimeo_v',
|
||||
'vine',
|
||||
'vk',
|
||||
'vnv',
|
||||
'vuejs',
|
||||
'watchman_monitoring',
|
||||
'waze',
|
||||
'weebly',
|
||||
'weibo',
|
||||
'weixin',
|
||||
'whatsapp',
|
||||
'whatsapp_square',
|
||||
'whmcs',
|
||||
'wikipedia_w',
|
||||
'windows',
|
||||
'wix',
|
||||
'wizards_of_the_coast',
|
||||
'wodu',
|
||||
'wolf_pack_battalion',
|
||||
'wordpress',
|
||||
'wordpress_simple',
|
||||
'wpbeginner',
|
||||
'wpexplorer',
|
||||
'wpforms',
|
||||
'wpressr',
|
||||
'xbox',
|
||||
'xing',
|
||||
'xing_square',
|
||||
'y_combinator',
|
||||
'yahoo',
|
||||
'yammer',
|
||||
'yandex',
|
||||
'yandex_international',
|
||||
'yarn',
|
||||
'yelp',
|
||||
'yoast',
|
||||
'youtube',
|
||||
'youtube_square',
|
||||
'zhihu',
|
||||
];
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
CustomIconDirectus,
|
||||
CustomIconBookmarkSave,
|
||||
CustomIconBox,
|
||||
CustomIconCommitNode,
|
||||
CustomIconGrid1,
|
||||
CustomIconGrid2,
|
||||
CustomIconGrid3,
|
||||
CustomIconGrid4,
|
||||
CustomIconGrid5,
|
||||
CustomIconGrid6,
|
||||
CustomIconSignalWifi1Bar,
|
||||
CustomIconSignalWifi2Bar,
|
||||
CustomIconSignalWifi3Bar,
|
||||
CustomIconFlipHorizontal,
|
||||
CustomIconFlipVertical,
|
||||
CustomIconFolderMove,
|
||||
CustomIconFolderLock,
|
||||
CustomIconLogout,
|
||||
SocialIcon,
|
||||
},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
filled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sup: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
left: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
right: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
...sizeProps,
|
||||
},
|
||||
emits: ['click'],
|
||||
|
||||
setup(props, { emit }) {
|
||||
const sizeClass = computed<string | null>(() => {
|
||||
if (props.sup) return 'sup';
|
||||
return useSizeClass(props).value;
|
||||
});
|
||||
|
||||
const customIconName = computed<string | null>(() => {
|
||||
if (customIcons.includes(props.name)) return `custom-icon-${props.name}`.replace(/_/g, '-');
|
||||
return null;
|
||||
});
|
||||
|
||||
const socialIconName = computed<string | null>(() => {
|
||||
if (socialIcons.includes(props.name)) return props.name.replace(/_/g, '-');
|
||||
return null;
|
||||
});
|
||||
|
||||
return {
|
||||
sizeClass,
|
||||
customIconName,
|
||||
socialIconName,
|
||||
emitClick,
|
||||
};
|
||||
|
||||
function emitClick(event: MouseEvent) {
|
||||
if (props.disabled) return;
|
||||
emit('click', event);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-icon-color: currentColor;
|
||||
--v-icon-color-hover: currentColor;
|
||||
--v-icon-size: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-icon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: var(--v-icon-size);
|
||||
min-width: var(--v-icon-size);
|
||||
height: var(--v-icon-size);
|
||||
color: var(--v-icon-color);
|
||||
font-size: 0;
|
||||
vertical-align: middle;
|
||||
|
||||
i {
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
font-size: var(--v-icon-size);
|
||||
font-family: 'Material Icons Outline';
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
white-space: nowrap;
|
||||
text-transform: none;
|
||||
word-wrap: normal;
|
||||
font-feature-settings: 'liga';
|
||||
|
||||
&::after {
|
||||
content: attr(data-icon);
|
||||
}
|
||||
|
||||
&.filled {
|
||||
font-family: 'Material Icons';
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
display: inline-block;
|
||||
color: inherit;
|
||||
fill: currentColor;
|
||||
|
||||
&.svg-inline--fa {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-click {
|
||||
cursor: pointer;
|
||||
transition: color var(--fast) var(--transition);
|
||||
|
||||
&:hover {
|
||||
color: var(--v-icon-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.sup {
|
||||
--v-icon-size: 8px;
|
||||
|
||||
vertical-align: 5px;
|
||||
}
|
||||
|
||||
&.x-small {
|
||||
--v-icon-size: 12px;
|
||||
}
|
||||
|
||||
&.small {
|
||||
--v-icon-size: 18px;
|
||||
}
|
||||
|
||||
&.large {
|
||||
--v-icon-size: 36px;
|
||||
}
|
||||
|
||||
&.x-large {
|
||||
--v-icon-size: 48px;
|
||||
}
|
||||
|
||||
&.left {
|
||||
margin-right: 8px;
|
||||
|
||||
&.small {
|
||||
margin-right: 4px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.right {
|
||||
margin-left: 6px;
|
||||
|
||||
&.small {
|
||||
margin-right: 4px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<div class="v-info" :class="[type, { center }]">
|
||||
<div class="icon">
|
||||
<v-icon large :name="icon" outline />
|
||||
</div>
|
||||
<h2 class="title type-title">{{ title }}</h2>
|
||||
<p class="content"><slot /></p>
|
||||
<slot name="append" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'box',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String as PropType<'info' | 'success' | 'warning' | 'danger'>,
|
||||
default: 'info',
|
||||
},
|
||||
center: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.info .icon {
|
||||
color: var(--foreground-subdued);
|
||||
background-color: var(--background-normal);
|
||||
}
|
||||
|
||||
.success .icon {
|
||||
color: var(--success);
|
||||
background-color: var(--success-alt);
|
||||
}
|
||||
|
||||
.warning .icon {
|
||||
color: var(--warning);
|
||||
background-color: var(--warning-alt);
|
||||
}
|
||||
|
||||
.danger .icon {
|
||||
color: var(--danger);
|
||||
background-color: var(--danger-alt);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 300px;
|
||||
color: var(--foreground-subdued);
|
||||
line-height: 22px;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,434 +0,0 @@
|
||||
<template>
|
||||
<div class="v-input" :class="classes" @click="$emit('click', $event)">
|
||||
<div v-if="$slots['prepend-outer']" class="prepend-outer">
|
||||
<slot name="prepend-outer" :value="modelValue" :disabled="disabled" />
|
||||
</div>
|
||||
<div class="input" :class="{ disabled, active }">
|
||||
<div v-if="$slots.prepend" class="prepend">
|
||||
<slot name="prepend" :value="modelValue" :disabled="disabled" />
|
||||
</div>
|
||||
<span v-if="prefix" class="prefix">{{ prefix }}</span>
|
||||
<slot name="input">
|
||||
<input
|
||||
ref="input"
|
||||
v-focus="autofocus"
|
||||
v-bind="attributes"
|
||||
:placeholder="placeholder ? String(placeholder) : undefined"
|
||||
:autocomplete="autocomplete"
|
||||
:type="type"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:disabled="disabled"
|
||||
:value="modelValue === undefined || modelValue === null ? '' : String(modelValue)"
|
||||
v-on="listeners"
|
||||
/>
|
||||
</slot>
|
||||
<span v-if="suffix" class="suffix">{{ suffix }}</span>
|
||||
<span v-if="type === 'number' && !hideArrows">
|
||||
<v-icon
|
||||
:class="{ disabled: !isStepUpAllowed }"
|
||||
name="keyboard_arrow_up"
|
||||
class="step-up"
|
||||
tabindex="-1"
|
||||
clickable
|
||||
:disabled="!isStepUpAllowed"
|
||||
@click="stepUp"
|
||||
/>
|
||||
<v-icon
|
||||
:class="{ disabled: !isStepDownAllowed }"
|
||||
name="keyboard_arrow_down"
|
||||
class="step-down"
|
||||
tabindex="-1"
|
||||
clickable
|
||||
:disabled="!isStepDownAllowed"
|
||||
@click="stepDown"
|
||||
/>
|
||||
</span>
|
||||
<div v-if="$slots.append" class="append">
|
||||
<slot name="append" :value="modelValue" :disabled="disabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots['append-outer']" class="append-outer">
|
||||
<slot name="append-outer" :value="modelValue" :disabled="disabled" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, useAttrs } from 'vue';
|
||||
import { omit } from 'lodash';
|
||||
import slugify from '@sindresorhus/slugify';
|
||||
|
||||
interface Props {
|
||||
autofocus?: boolean;
|
||||
disabled?: boolean;
|
||||
clickable?: boolean;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
fullWidth?: boolean;
|
||||
placeholder?: string | number;
|
||||
modelValue?: string | number;
|
||||
nullable?: boolean;
|
||||
slug?: boolean;
|
||||
slugSeparator?: string;
|
||||
type?: string;
|
||||
hideArrows?: boolean;
|
||||
max?: number;
|
||||
min?: number;
|
||||
step?: number;
|
||||
active?: boolean;
|
||||
dbSafe?: boolean;
|
||||
trim?: boolean;
|
||||
autocomplete?: string;
|
||||
small?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
autofocus: false,
|
||||
disabled: false,
|
||||
clickable: false,
|
||||
prefix: undefined,
|
||||
suffix: undefined,
|
||||
fullWidth: true,
|
||||
placeholder: undefined,
|
||||
modelValue: undefined,
|
||||
nullable: true,
|
||||
slug: false,
|
||||
slugSeparator: '-',
|
||||
type: 'text',
|
||||
hideArrows: false,
|
||||
max: undefined,
|
||||
min: undefined,
|
||||
step: 1,
|
||||
active: false,
|
||||
dbSafe: false,
|
||||
trim: false,
|
||||
autocomplete: 'off',
|
||||
small: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'keydown', 'update:modelValue', 'focus']);
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const input = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const listeners = computed(() => ({
|
||||
input: emitValue,
|
||||
keydown: processValue,
|
||||
blur: (e: Event) => {
|
||||
trimIfEnabled();
|
||||
if (typeof attrs.onBlur === 'function') attrs.onBlur(e);
|
||||
},
|
||||
focus: (e: PointerEvent) => emit('focus', e),
|
||||
}));
|
||||
const attributes = computed(() => omit(attrs, ['class']));
|
||||
|
||||
const classes = computed(() => [
|
||||
{
|
||||
'full-width': props.fullWidth,
|
||||
'has-click': props.clickable,
|
||||
disabled: props.disabled,
|
||||
small: props.small,
|
||||
},
|
||||
...((attrs.class || '') as string).split(' '),
|
||||
]);
|
||||
|
||||
const isStepUpAllowed = computed(() => {
|
||||
return props.disabled === false && (props.max === undefined || parseInt(String(props.modelValue), 10) < props.max);
|
||||
});
|
||||
|
||||
const isStepDownAllowed = computed(() => {
|
||||
return props.disabled === false && (props.min === undefined || parseInt(String(props.modelValue), 10) > props.min);
|
||||
});
|
||||
|
||||
function processValue(event: KeyboardEvent) {
|
||||
if (!event.key) return;
|
||||
const key = event.key.toLowerCase();
|
||||
const systemKeys = ['meta', 'shift', 'alt', 'backspace', 'delete', 'tab'];
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
|
||||
if (props.slug === true) {
|
||||
const slugSafeCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789-_~ '.split('');
|
||||
|
||||
const isAllowed = slugSafeCharacters.includes(key) || systemKeys.includes(key) || key.startsWith('arrow');
|
||||
|
||||
if (isAllowed === false) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (key === ' ' && value.endsWith(props.slugSeparator)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
if (props.dbSafe === true) {
|
||||
const dbSafeCharacters = 'abcdefghijklmnopqrstuvwxyz0123456789_ '.split('');
|
||||
|
||||
const isAllowed = dbSafeCharacters.includes(key) || systemKeys.includes(key) || key.startsWith('arrow');
|
||||
|
||||
if (isAllowed === false) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Prevent leading number
|
||||
if (value.length === 0 && '0123456789'.split('').includes(key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
emit('keydown', event);
|
||||
}
|
||||
|
||||
function trimIfEnabled() {
|
||||
if (props.modelValue && props.trim && ['string', 'text'].includes(props.type)) {
|
||||
emit('update:modelValue', String(props.modelValue).trim());
|
||||
}
|
||||
}
|
||||
|
||||
function emitValue(event: InputEvent) {
|
||||
let value = (event.target as HTMLInputElement).value;
|
||||
|
||||
if (props.nullable === true && value === '') {
|
||||
emit('update:modelValue', null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.type === 'number') {
|
||||
const parsedNumber = Number(value);
|
||||
|
||||
// Ignore if numeric value remains unchanged
|
||||
if (props.modelValue !== parsedNumber) {
|
||||
emit('update:modelValue', parsedNumber);
|
||||
}
|
||||
} else {
|
||||
if (props.slug === true) {
|
||||
const endsWithSpace = value.endsWith(' ');
|
||||
value = slugify(value, { separator: props.slugSeparator, preserveTrailingDash: true });
|
||||
if (endsWithSpace) value += props.slugSeparator;
|
||||
}
|
||||
|
||||
if (props.dbSafe === true) {
|
||||
value = value.replace(/\s/g, '_');
|
||||
// prevent pasting of non dbSafeCharacters from bypassing the keydown checks
|
||||
value = value.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
// Replace é -> e etc
|
||||
value = value.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
}
|
||||
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
}
|
||||
|
||||
function stepUp() {
|
||||
if (!input.value) return;
|
||||
if (isStepUpAllowed.value === false) return;
|
||||
|
||||
input.value.stepUp();
|
||||
|
||||
if (input.value.value != null) {
|
||||
return emit('update:modelValue', Number(input.value.value));
|
||||
}
|
||||
}
|
||||
|
||||
function stepDown() {
|
||||
if (!input.value) return;
|
||||
if (isStepDownAllowed.value === false) return;
|
||||
|
||||
input.value.stepDown();
|
||||
|
||||
if (input.value.value) {
|
||||
return emit('update:modelValue', Number(input.value.value));
|
||||
} else {
|
||||
return emit('update:modelValue', props.min || 0);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:global(body) {
|
||||
--v-input-font-family: var(--family-sans-serif);
|
||||
--v-input-placeholder-color: var(--foreground-subdued);
|
||||
--v-input-box-shadow-color-focus: var(--primary);
|
||||
--v-input-color: var(--foreground-normal);
|
||||
--v-input-background-color: var(--background-input);
|
||||
--v-input-border-color-focus: var(--primary);
|
||||
}
|
||||
|
||||
.v-input {
|
||||
--arrow-color: var(--border-normal);
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
height: var(--input-height);
|
||||
|
||||
.prepend-outer {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: var(--input-padding);
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
color: var(--v-input-color);
|
||||
font-family: var(--v-input-font-family);
|
||||
background-color: var(--v-input-background-color);
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius);
|
||||
transition: border-color var(--fast) var(--transition);
|
||||
|
||||
.prepend {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.step-up {
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
|
||||
.step-down {
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.step-up,
|
||||
.step-down {
|
||||
--v-icon-color: var(--arrow-color);
|
||||
|
||||
display: block;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
--arrow-color: var(--primary);
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
--arrow-color: var(--border-normal);
|
||||
|
||||
cursor: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
--arrow-color: var(--border-normal-alt);
|
||||
|
||||
color: var(--v-input-color);
|
||||
background-color: var(--background-input);
|
||||
border-color: var(--border-normal-alt);
|
||||
}
|
||||
|
||||
&:focus-within,
|
||||
&.active {
|
||||
--arrow-color: var(--border-normal-alt);
|
||||
|
||||
color: var(--v-input-color);
|
||||
background-color: var(--background-input);
|
||||
border-color: var(--v-input-border-color-focus);
|
||||
box-shadow: 0 0 16px -8px var(--v-input-box-shadow-color-focus);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
--arrow-color: var(--border-normal);
|
||||
|
||||
color: var(--foreground-subdued);
|
||||
background-color: var(--background-subdued);
|
||||
border-color: var(--border-normal);
|
||||
}
|
||||
|
||||
.prefix,
|
||||
.suffix {
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.append {
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
flex-grow: 1;
|
||||
width: 20px; /* allows flex to grow/shrink to allow for slots */
|
||||
height: 100%;
|
||||
padding: var(--input-padding);
|
||||
padding-right: 0px;
|
||||
padding-left: 0px;
|
||||
font-family: var(--v-input-font-family);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
appearance: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--v-input-placeholder-color);
|
||||
}
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
margin: 0;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--v-input-border-color-focus);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
|
||||
&[type='number'] {
|
||||
appearance: textfield;
|
||||
}
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: 38px;
|
||||
|
||||
.input {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&.full-width {
|
||||
width: 100%;
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-click {
|
||||
cursor: pointer;
|
||||
|
||||
&.disabled {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
input {
|
||||
pointer-events: none;
|
||||
|
||||
.prefix,
|
||||
.suffix {
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
}
|
||||
|
||||
.append-outer {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<div class="v-item-group">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, toRefs } from 'vue';
|
||||
import { useGroupableParent } from '@/composables/use-groupable';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
mandatory: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Array as PropType<(string | number)[]>,
|
||||
default: undefined,
|
||||
},
|
||||
scope: {
|
||||
type: String,
|
||||
default: 'item-group',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const { modelValue: selection, multiple, max, mandatory } = toRefs(props);
|
||||
useGroupableParent(
|
||||
{
|
||||
selection: selection,
|
||||
onSelectionChange: (newSelectionValues) => emit('update:modelValue', newSelectionValues),
|
||||
},
|
||||
{
|
||||
multiple: multiple,
|
||||
max: max,
|
||||
mandatory: mandatory,
|
||||
},
|
||||
props.scope
|
||||
);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<div class="v-item">
|
||||
<slot v-bind="{ active: isActive, toggle }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs } from 'vue';
|
||||
import { useGroupable } from '@/composables/use-groupable';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
scope: {
|
||||
type: String,
|
||||
default: 'item-group',
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
watch: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { active } = toRefs(props);
|
||||
const { active: isActive, toggle } = useGroupable({
|
||||
value: props.value,
|
||||
group: props.scope,
|
||||
watch: props.watch,
|
||||
active,
|
||||
});
|
||||
|
||||
return { isActive, toggle };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,145 +0,0 @@
|
||||
<template>
|
||||
<li class="v-list-group">
|
||||
<v-list-item
|
||||
class="activator"
|
||||
:active="active"
|
||||
:to="to"
|
||||
:exact="exact"
|
||||
:query="query"
|
||||
:disabled="disabled"
|
||||
:dense="dense"
|
||||
:clickable="Boolean(clickable || to || !open)"
|
||||
@click="onClick"
|
||||
>
|
||||
<v-list-item-icon
|
||||
v-if="$slots.default && arrowPlacement && arrowPlacement === 'before'"
|
||||
class="activator-icon"
|
||||
:class="{ active: groupActive }"
|
||||
>
|
||||
<v-icon name="chevron_right" :disabled="disabled" @click.stop.prevent="toggle" />
|
||||
</v-list-item-icon>
|
||||
|
||||
<slot name="activator" :active="groupActive" />
|
||||
|
||||
<v-list-item-icon
|
||||
v-if="$slots.default && arrowPlacement && arrowPlacement === 'after'"
|
||||
class="activator-icon"
|
||||
:class="{ active: groupActive }"
|
||||
>
|
||||
<v-icon name="chevron_right" :disabled="disabled" @click.stop.prevent="toggle" />
|
||||
</v-list-item-icon>
|
||||
</v-list-item>
|
||||
|
||||
<ul v-if="groupActive" class="items">
|
||||
<slot />
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { useGroupable } from '@/composables/use-groupable';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
exact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
query: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scope: {
|
||||
type: String,
|
||||
default: 'v-list',
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: undefined,
|
||||
},
|
||||
dense: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
arrowPlacement: {
|
||||
type: [String, Boolean],
|
||||
default: 'after',
|
||||
validator: (val: string | boolean) => ['before', 'after', false].includes(val),
|
||||
},
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
const { active, toggle } = useGroupable({
|
||||
group: props.scope,
|
||||
value: props.value,
|
||||
});
|
||||
|
||||
const groupActive = computed(() => active.value || props.open);
|
||||
|
||||
return { groupActive, toggle, onClick };
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
if (props.to) return null;
|
||||
if (props.clickable) return emit('click', event);
|
||||
|
||||
event.stopPropagation();
|
||||
toggle();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-list-group {
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.activator-icon {
|
||||
margin-right: 0 !important;
|
||||
color: var(--foreground-subdued);
|
||||
transform: rotate(0deg);
|
||||
transition: transform var(--medium) var(--transition);
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground-normal);
|
||||
}
|
||||
|
||||
&.active {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.items {
|
||||
padding-left: 18px;
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,48 +0,0 @@
|
||||
<template>
|
||||
<div class="v-list-item-content">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-list-item-content-padding: 9px 0;
|
||||
--v-list-item-content-font-family: var(--family-sans-serif);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.v-list-item-content {
|
||||
display: flex;
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
padding: var(--v-list-item-content-padding);
|
||||
overflow: hidden;
|
||||
font-family: var(--v-list-item-content-font-family);
|
||||
}
|
||||
|
||||
.v-list.three-line .v-list-item-content,
|
||||
.v-list-item.three-line .v-list-item-content {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.v-list-item-content > :deep(*) {
|
||||
flex-basis: 100%;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.v-list-item-content > :slotted(*:not(:last-child)) {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.v-list:not(.nav) .v-list-item-content,
|
||||
.v-list-item:not(.nav) .v-list-item-content {
|
||||
--v-list-item-content-padding: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,72 +0,0 @@
|
||||
<template>
|
||||
<div class="v-list-item-hint" :class="{ center }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
center: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-list-item-hint {
|
||||
$this: &;
|
||||
|
||||
display: inline-flex;
|
||||
align-self: center;
|
||||
margin: 8px 0;
|
||||
color: var(--foreground-subdued);
|
||||
|
||||
&:not(:only-child) {
|
||||
&:first-child {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root {
|
||||
.v-list,
|
||||
.v-list-item {
|
||||
#{$this} {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:not(:only-child) {
|
||||
&:first-child {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.nav {
|
||||
&.three-line,
|
||||
&.two-line {
|
||||
#{$this} {
|
||||
align-self: flex-start;
|
||||
|
||||
&.center {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<div class="v-list-item-icon" :class="{ center }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
center: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-list-item-icon-color: var(--foreground-subdued);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-list-item-icon {
|
||||
$this: &;
|
||||
|
||||
display: inline-flex;
|
||||
align-self: center;
|
||||
margin: 8px 0;
|
||||
|
||||
&:not(:only-child) {
|
||||
&:first-child {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@at-root {
|
||||
.v-list,
|
||||
.v-list-item {
|
||||
#{$this} {
|
||||
--v-icon-color: var(--v-list-item-icon-color);
|
||||
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:not(:only-child) {
|
||||
&:first-child {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.nav #{$this} :slotted(.v-icon) {
|
||||
&.dense {
|
||||
--v-icon-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled #{$this} :slotted(.v-icon) {
|
||||
--v-icon-color: var(--foreground-subdued) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,339 +0,0 @@
|
||||
<template>
|
||||
<component
|
||||
:is="component"
|
||||
class="v-list-item"
|
||||
:class="{
|
||||
active: isActiveRoute,
|
||||
dense,
|
||||
link: isLink,
|
||||
disabled,
|
||||
dashed,
|
||||
block,
|
||||
nav,
|
||||
clickable,
|
||||
}"
|
||||
:download="download"
|
||||
v-bind="additionalProps"
|
||||
@click="onClick"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { RouteLocation, useLink, useRoute } from 'vue-router';
|
||||
import { defineComponent, PropType, computed } from 'vue';
|
||||
import { useGroupable } from '@/composables/use-groupable';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
block: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dense: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
to: {
|
||||
type: [String, Object] as PropType<string | RouteLocation>,
|
||||
default: '',
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
dashed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
exact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
query: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
download: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: undefined,
|
||||
},
|
||||
nav: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scope: {
|
||||
type: String,
|
||||
default: 'v-list',
|
||||
},
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
const route = useRoute();
|
||||
|
||||
const { route: linkRoute, isActive, isExactActive } = useLink(props);
|
||||
|
||||
const component = computed(() => {
|
||||
if (props.to) return 'router-link';
|
||||
if (props.href) return 'a';
|
||||
return 'li';
|
||||
});
|
||||
|
||||
const additionalProps = computed(() => {
|
||||
if (props.to) {
|
||||
return {
|
||||
to: props.to,
|
||||
};
|
||||
}
|
||||
|
||||
if (component.value === 'a') {
|
||||
return {
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
useGroupable({
|
||||
value: props.value,
|
||||
group: props.scope,
|
||||
});
|
||||
|
||||
const isLink = computed(() => Boolean(props.to || props.href || props.clickable));
|
||||
|
||||
const isActiveRoute = computed(() => {
|
||||
if (props.active !== undefined) return props.active;
|
||||
|
||||
if (props.to) {
|
||||
const isQueryActive = !props.query || isEqual(route.query, linkRoute.value.query);
|
||||
|
||||
if (!props.exact) {
|
||||
return isActive.value && isQueryActive;
|
||||
} else {
|
||||
return isExactActive.value && isQueryActive;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return { component, additionalProps, isLink, isActiveRoute, onClick };
|
||||
|
||||
function onClick(event: PointerEvent) {
|
||||
if (props.disabled === true) return;
|
||||
emit('click', event);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-list-item-padding-nav: 0 var(--input-padding);
|
||||
--v-list-item-padding: 0 var(--input-padding) 0 calc(var(--input-padding) + var(--v-list-item-indent, 0px));
|
||||
--v-list-item-margin-nav: 2px 0;
|
||||
--v-list-item-margin: 2px 0;
|
||||
--v-list-item-min-width: none;
|
||||
--v-list-item-max-width: none;
|
||||
--v-list-item-min-height-nav: 36px;
|
||||
--v-list-item-min-height: 32px;
|
||||
--v-list-item-max-height: auto;
|
||||
--v-list-item-border-radius: var(--border-radius);
|
||||
--v-list-item-border-color: var(--border-subdued);
|
||||
--v-list-item-border-color-hover: var(--border-normal-alt);
|
||||
--v-list-item-color: var(--v-list-color, var(--foreground-normal));
|
||||
--v-list-item-color-hover: var(--v-list-color-hover, var(--foreground-normal));
|
||||
--v-list-item-color-active: var(--v-list-color-active, var(--foreground-normal));
|
||||
--v-list-item-background-color-hover: var(--v-list-background-color-hover, var(--background-normal));
|
||||
--v-list-item-background-color-active: var(--v-list-background-color-active, var(--background-normal));
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-list-item {
|
||||
$this: &;
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-basis: 100%;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
align-items: center;
|
||||
min-width: var(--v-list-item-min-width);
|
||||
max-width: var(--v-list-item-max-width);
|
||||
min-height: var(--v-list-item-min-height);
|
||||
max-height: var(--v-list-item-max-height);
|
||||
margin: var(--v-list-item-margin);
|
||||
padding: var(--v-list-item-padding);
|
||||
overflow: hidden;
|
||||
color: var(--v-list-item-color);
|
||||
text-decoration: none;
|
||||
border-radius: var(--v-list-item-border-radius);
|
||||
|
||||
&.dashed {
|
||||
&::after {
|
||||
/* Borders normally render outside the element, this is a way of showing it as inner */
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
border: 2px dashed var(--border-normal);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.link {
|
||||
cursor: pointer;
|
||||
transition: var(--fast) var(--transition);
|
||||
transition-property: background-color, color;
|
||||
user-select: none;
|
||||
|
||||
&:not(.disabled):not(.dense):not(.block):hover {
|
||||
color: var(--v-list-item-color-hover);
|
||||
background-color: var(--v-list-item-background-color-hover);
|
||||
}
|
||||
|
||||
&:not(.disabled):not(.dense):not(.block):active {
|
||||
color: var(--v-list-item-color-active);
|
||||
background-color: var(--v-list-item-background-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.dense).active {
|
||||
color: var(--v-list-item-color-active);
|
||||
background-color: var(--v-list-item-background-color-active);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
--v-list-item-color: var(--foreground-subdued) !important;
|
||||
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.dense {
|
||||
:deep(.v-text-overflow) {
|
||||
color: var(--foreground-normal);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
:deep(.v-text-overflow) {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.block {
|
||||
--v-list-item-border-color: var(--border-subdued);
|
||||
--v-list-item-background-color: var(--background-page);
|
||||
--v-list-item-background-color-hover: var(--card-face-color);
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: var(--input-height);
|
||||
margin: 0;
|
||||
padding: 8px var(--input-padding);
|
||||
background-color: var(--v-list-item-background-color);
|
||||
border: var(--border-width) solid var(--v-list-item-border-color);
|
||||
border-radius: var(--border-radius);
|
||||
transition: border-color var(--fast) var(--transition);
|
||||
|
||||
:slotted(.drag-handle) {
|
||||
cursor: grab;
|
||||
|
||||
&:hover {
|
||||
color: var(--foreground-color);
|
||||
}
|
||||
}
|
||||
|
||||
:slotted(.drag-handle:active) {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
:slotted(.spacer) {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&.clickable:hover {
|
||||
background-color: var(--v-list-item-background-color-hover);
|
||||
border: var(--border-width) solid var(--v-list-item-border-color-hover);
|
||||
}
|
||||
|
||||
&.sortable-chosen {
|
||||
border: var(--border-width) solid var(--primary) !important;
|
||||
}
|
||||
|
||||
&.sortable-ghost {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
& + & {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&.dense {
|
||||
height: 44px;
|
||||
padding: 4px 8px;
|
||||
|
||||
& + & {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@at-root {
|
||||
.v-list.nav {
|
||||
#{$this}:not(.dense) {
|
||||
--v-list-item-min-height: var(--v-list-item-min-height-nav);
|
||||
--v-list-item-border-radius: 4px;
|
||||
|
||||
margin: var(--v-list-item-margin-nav);
|
||||
padding: var(--v-list-item-padding-nav);
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:only-child {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.v-list.nav.dense {
|
||||
#{$this}:not(.dense) {
|
||||
--v-list-item-min-height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,103 +0,0 @@
|
||||
<template>
|
||||
<ul class="v-list" :class="{ nav, dense }">
|
||||
<slot />
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, toRefs } from 'vue';
|
||||
import { useGroupableParent } from '@/composables/use-groupable';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array as PropType<(number | string)[]>,
|
||||
default: null,
|
||||
},
|
||||
nav: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dense: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
mandatory: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
scope: {
|
||||
type: String,
|
||||
default: 'v-list',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'toggle'],
|
||||
setup(props, { emit }) {
|
||||
const { modelValue, multiple, mandatory } = toRefs(props);
|
||||
|
||||
useGroupableParent(
|
||||
{
|
||||
selection: modelValue,
|
||||
onSelectionChange: (newSelection) => {
|
||||
emit('update:modelValue', newSelection);
|
||||
},
|
||||
onToggle: (item) => {
|
||||
emit('toggle', item);
|
||||
},
|
||||
},
|
||||
{
|
||||
mandatory,
|
||||
multiple,
|
||||
},
|
||||
props.scope
|
||||
);
|
||||
|
||||
return {};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:global(body) {
|
||||
--v-list-padding: 4px 0;
|
||||
--v-list-border-radius: var(--border-radius);
|
||||
--v-list-max-height: none;
|
||||
--v-list-max-width: none;
|
||||
--v-list-min-width: 220px;
|
||||
--v-list-min-height: none;
|
||||
--v-list-color: var(--foreground-normal-alt);
|
||||
--v-list-color-hover: var(--foreground-normal-alt);
|
||||
--v-list-color-active: var(--foreground-normal-alt);
|
||||
--v-list-background-color-hover: var(--background-normal);
|
||||
--v-list-background-color-active: var(--background-normal);
|
||||
}
|
||||
|
||||
.v-list {
|
||||
position: static;
|
||||
display: block;
|
||||
min-width: var(--v-list-min-width);
|
||||
max-width: var(--v-list-max-width);
|
||||
min-height: var(--v-list-min-height);
|
||||
max-height: var(--v-list-max-height);
|
||||
padding: var(--v-list-padding);
|
||||
overflow: auto;
|
||||
color: var(--v-list-color);
|
||||
line-height: 22px;
|
||||
list-style: none;
|
||||
border-radius: var(--v-list-border-radius);
|
||||
}
|
||||
|
||||
.nav {
|
||||
--v-list-padding: 12px;
|
||||
--v-list-item-icon-color: var(--primary);
|
||||
}
|
||||
|
||||
:slotted(.v-divider) {
|
||||
max-width: calc(100% - 16px);
|
||||
margin: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,609 +0,0 @@
|
||||
<template>
|
||||
<div class="v-menu" @click="onClick">
|
||||
<div
|
||||
ref="activator"
|
||||
class="v-menu-activator"
|
||||
:class="{ attached }"
|
||||
@pointerenter.stop="onPointerEnter"
|
||||
@pointerleave.stop="onPointerLeave"
|
||||
>
|
||||
<slot
|
||||
name="activator"
|
||||
v-bind="{
|
||||
toggle: toggle,
|
||||
active: isActive,
|
||||
activate: activate,
|
||||
deactivate: deactivate,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<teleport to="#menu-outlet">
|
||||
<transition-bounce>
|
||||
<div
|
||||
v-if="isActive"
|
||||
:id="id"
|
||||
:key="id"
|
||||
v-click-outside="{
|
||||
handler: deactivate,
|
||||
middleware: onClickOutsideMiddleware,
|
||||
disabled: isActive === false || closeOnClick === false,
|
||||
events: ['click'],
|
||||
}"
|
||||
class="v-menu-popper"
|
||||
:class="{ active: isActive, attached }"
|
||||
:data-placement="popperPlacement"
|
||||
:style="styles"
|
||||
>
|
||||
<div class="arrow" :class="{ active: showArrow && isActive }" :style="arrowStyles" data-popper-arrow />
|
||||
<div
|
||||
class="v-menu-content"
|
||||
:class="{ 'full-height': fullHeight, seamless }"
|
||||
@click.stop="onContentClick"
|
||||
@pointerenter.stop="onPointerEnter"
|
||||
@pointerleave.stop="onPointerLeave"
|
||||
>
|
||||
<slot
|
||||
v-bind="{
|
||||
toggle: toggle,
|
||||
active: isActive,
|
||||
activate: activate,
|
||||
deactivate: deactivate,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition-bounce>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Instance, Modifier, Placement } from '@popperjs/core';
|
||||
import arrow from '@popperjs/core/lib/modifiers/arrow';
|
||||
import computeStyles from '@popperjs/core/lib/modifiers/computeStyles';
|
||||
import eventListeners from '@popperjs/core/lib/modifiers/eventListeners';
|
||||
import flip from '@popperjs/core/lib/modifiers/flip';
|
||||
import offset from '@popperjs/core/lib/modifiers/offset';
|
||||
import popperOffsets from '@popperjs/core/lib/modifiers/popperOffsets';
|
||||
import preventOverflow from '@popperjs/core/lib/modifiers/preventOverflow';
|
||||
import { createPopper } from '@popperjs/core/lib/popper-lite';
|
||||
import { debounce } from 'lodash';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { computed, defineComponent, nextTick, onUnmounted, PropType, ref, Ref, watch } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
placement: {
|
||||
type: String as PropType<Placement>,
|
||||
default: 'bottom',
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
closeOnClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
closeOnContentClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
attached: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showArrow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
trigger: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: (val: string) => ['hover', 'click', 'keyDown'].includes(val),
|
||||
},
|
||||
delay: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
offsetY: {
|
||||
type: Number,
|
||||
default: 8,
|
||||
},
|
||||
offsetX: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
fullHeight: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
seamless: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const activator = ref<HTMLElement | null>(null);
|
||||
const reference = ref<HTMLElement | null>(null);
|
||||
|
||||
const virtualReference = ref({
|
||||
getBoundingClientRect() {
|
||||
return {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const id = computed(() => nanoid());
|
||||
const popper = ref<HTMLElement | null>(null);
|
||||
|
||||
const {
|
||||
start,
|
||||
stop,
|
||||
styles,
|
||||
arrowStyles,
|
||||
placement: popperPlacement,
|
||||
} = usePopper(
|
||||
reference,
|
||||
popper,
|
||||
computed(() => ({
|
||||
placement: props.placement,
|
||||
attached: props.attached,
|
||||
arrow: props.showArrow,
|
||||
offsetY: props.offsetY,
|
||||
offsetX: props.offsetX,
|
||||
}))
|
||||
);
|
||||
|
||||
const { isActive, activate, deactivate, toggle } = useActiveState();
|
||||
|
||||
watch(isActive, (newActive) => {
|
||||
if (newActive === true) {
|
||||
reference.value = (activator.value?.children[0] as HTMLElement) || virtualReference.value;
|
||||
|
||||
nextTick(() => {
|
||||
popper.value = document.getElementById(id.value);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const { onClick, onPointerEnter, onPointerLeave } = useEvents();
|
||||
|
||||
const hoveringOnPopperContent = ref(false);
|
||||
|
||||
return {
|
||||
id,
|
||||
activator,
|
||||
popper,
|
||||
reference,
|
||||
isActive,
|
||||
toggle,
|
||||
deactivate,
|
||||
onContentClick,
|
||||
onClickOutsideMiddleware,
|
||||
styles,
|
||||
arrowStyles,
|
||||
popperPlacement,
|
||||
activate,
|
||||
onClick,
|
||||
onPointerLeave,
|
||||
onPointerEnter,
|
||||
hoveringOnPopperContent,
|
||||
};
|
||||
|
||||
function useActiveState() {
|
||||
const localIsActive = ref(false);
|
||||
|
||||
const isActive = computed<boolean>({
|
||||
get() {
|
||||
if (props.modelValue !== undefined) {
|
||||
return props.modelValue;
|
||||
}
|
||||
|
||||
return localIsActive.value;
|
||||
},
|
||||
async set(newActive) {
|
||||
localIsActive.value = newActive;
|
||||
emit('update:modelValue', newActive);
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
popper,
|
||||
async () => {
|
||||
if (popper.value !== null) {
|
||||
await start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return { isActive, activate, deactivate, toggle };
|
||||
|
||||
function activate(event?: MouseEvent) {
|
||||
if (event) {
|
||||
virtualReference.value = {
|
||||
getBoundingClientRect() {
|
||||
return {
|
||||
top: event.clientY,
|
||||
left: event.clientX,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
isActive.value = true;
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
isActive.value = false;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled === true) return;
|
||||
isActive.value = !isActive.value;
|
||||
}
|
||||
}
|
||||
|
||||
function onClickOutsideMiddleware(e: Event) {
|
||||
return !activator.value?.contains(e.target as Node);
|
||||
}
|
||||
|
||||
function onContentClick(e: Event) {
|
||||
if (props.closeOnContentClick === true && e.target !== e.currentTarget) {
|
||||
deactivate();
|
||||
}
|
||||
}
|
||||
|
||||
function useEvents() {
|
||||
const isHovered = ref(false);
|
||||
|
||||
watch(
|
||||
isHovered,
|
||||
debounce((newHoveredState) => {
|
||||
if (newHoveredState) {
|
||||
if (!isActive.value) activate();
|
||||
} else {
|
||||
deactivate();
|
||||
}
|
||||
}, props.delay)
|
||||
);
|
||||
|
||||
return { onClick, onPointerLeave, onPointerEnter };
|
||||
|
||||
function onClick() {
|
||||
if (props.trigger !== 'click') return;
|
||||
|
||||
toggle();
|
||||
}
|
||||
|
||||
function onPointerEnter() {
|
||||
if (props.trigger !== 'hover') return;
|
||||
isHovered.value = true;
|
||||
}
|
||||
|
||||
function onPointerLeave() {
|
||||
if (props.trigger !== 'hover') return;
|
||||
isHovered.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function usePopper(
|
||||
reference: Ref<HTMLElement | null>,
|
||||
popper: Ref<HTMLElement | null>,
|
||||
options: Readonly<
|
||||
Ref<Readonly<{ placement: Placement; attached: boolean; arrow: boolean; offsetY: number; offsetX: number }>>
|
||||
>
|
||||
): Record<string, any> {
|
||||
const popperInstance = ref<Instance | null>(null);
|
||||
const styles = ref({});
|
||||
const arrowStyles = ref({});
|
||||
|
||||
// The internal placement can change based on the flip / overflow modifiers
|
||||
const placement = ref(options.value.placement);
|
||||
|
||||
onUnmounted(() => {
|
||||
stop();
|
||||
});
|
||||
|
||||
watch(
|
||||
options,
|
||||
() => {
|
||||
popperInstance.value?.setOptions({
|
||||
placement: options.value.attached ? 'bottom-start' : options.value.placement,
|
||||
modifiers: getModifiers(),
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
popperInstance.value?.forceUpdate();
|
||||
});
|
||||
|
||||
return { popperInstance, placement, start, stop, styles, arrowStyles };
|
||||
|
||||
function start() {
|
||||
return new Promise((resolve) => {
|
||||
popperInstance.value = createPopper(reference.value!, popper.value!, {
|
||||
placement: options.value.attached ? 'bottom-start' : options.value.placement,
|
||||
modifiers: getModifiers(resolve),
|
||||
strategy: 'fixed',
|
||||
});
|
||||
popperInstance.value.forceUpdate();
|
||||
observer.observe(popper.value!, {
|
||||
attributes: false,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stop() {
|
||||
popperInstance.value?.destroy();
|
||||
observer.disconnect();
|
||||
}
|
||||
|
||||
function getModifiers(callback: (value?: unknown) => void = () => undefined) {
|
||||
const modifiers: Modifier<string, any>[] = [
|
||||
popperOffsets,
|
||||
{
|
||||
...offset,
|
||||
options: {
|
||||
offset: options.value.attached ? [0, 0] : [options.value.offsetX ?? 0, options.value.offsetY ?? 8],
|
||||
},
|
||||
},
|
||||
{
|
||||
...preventOverflow,
|
||||
options: {
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
computeStyles,
|
||||
flip,
|
||||
eventListeners,
|
||||
{
|
||||
name: 'placementUpdater',
|
||||
enabled: true,
|
||||
phase: 'afterWrite',
|
||||
fn({ state }) {
|
||||
placement.value = state.placement;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'applyStyles',
|
||||
enabled: true,
|
||||
phase: 'write',
|
||||
fn({ state }) {
|
||||
styles.value = state.styles.popper;
|
||||
arrowStyles.value = state.styles.arrow;
|
||||
callback();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (options.value.arrow === true) {
|
||||
modifiers.push({
|
||||
...arrow,
|
||||
options: {
|
||||
padding: 6,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (options.value.attached === true) {
|
||||
modifiers.push({
|
||||
name: 'sameWidth',
|
||||
enabled: true,
|
||||
fn: ({ state }) => {
|
||||
state.styles.popper.width = `${state.rects.reference.width}px`;
|
||||
},
|
||||
phase: 'beforeWrite',
|
||||
requires: ['computeStyles'],
|
||||
});
|
||||
}
|
||||
|
||||
return modifiers;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-menu-min-width: 100px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-menu {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.v-menu-activator {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.v-menu-popper {
|
||||
position: fixed;
|
||||
left: -999px;
|
||||
z-index: 500;
|
||||
min-width: var(--v-menu-min-width);
|
||||
transform: translateY(2px);
|
||||
pointer-events: none;
|
||||
|
||||
&.active {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow,
|
||||
.arrow::after {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
&::after {
|
||||
background: var(--card-face-color);
|
||||
transform: rotate(45deg) scale(0);
|
||||
transition: transform var(--fast) var(--transition-out);
|
||||
transition-delay: 0;
|
||||
content: '';
|
||||
}
|
||||
|
||||
&.active::after {
|
||||
transform: rotate(45deg) scale(1);
|
||||
transition: transform var(--medium) var(--transition-in);
|
||||
}
|
||||
}
|
||||
|
||||
[data-placement^='top'] .arrow {
|
||||
bottom: -6px;
|
||||
|
||||
&::after {
|
||||
bottom: 3px;
|
||||
box-shadow: 2px 2px 4px -2px rgba(var(--card-shadow-color), 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
[data-placement^='bottom'] .arrow {
|
||||
top: -6px;
|
||||
|
||||
&::after {
|
||||
top: 3px;
|
||||
box-shadow: -2px -2px 4px -2px rgba(var(--card-shadow-color), 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
[data-placement^='right'] .arrow {
|
||||
left: -6px;
|
||||
|
||||
&::after {
|
||||
left: 2px;
|
||||
box-shadow: -2px 2px 4px -2px rgba(var(--card-shadow-color), 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
[data-placement^='left'] .arrow {
|
||||
right: -6px;
|
||||
|
||||
&::after {
|
||||
right: 2px;
|
||||
box-shadow: 2px -2px 4px -2px rgba(var(--card-shadow-color), 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.v-menu-content {
|
||||
max-height: 30vh;
|
||||
padding: 0 4px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background-color: var(--card-face-color);
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0px 0px 6px 0px rgb(var(--card-shadow-color), 0.2), 0px 0px 12px 2px rgb(var(--card-shadow-color), 0.05);
|
||||
transition-timing-function: var(--transition-out);
|
||||
transition-duration: var(--fast);
|
||||
transition-property: opacity, transform;
|
||||
contain: content;
|
||||
|
||||
.v-list {
|
||||
--v-list-background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.v-menu-content.full-height {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.v-menu-content.seamless {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[data-placement='top'] > .v-menu-content {
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
|
||||
[data-placement='top-start'] > .v-menu-content {
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
[data-placement='top-end'] > .v-menu-content {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
[data-placement='right'] > .v-menu-content {
|
||||
transform-origin: center left;
|
||||
}
|
||||
|
||||
[data-placement='right-start'] > .v-menu-content {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
[data-placement='right-end'] > .v-menu-content {
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
[data-placement='bottom'] > .v-menu-content {
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
[data-placement='bottom-start'] > .v-menu-content {
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
[data-placement='bottom-end'] > .v-menu-content {
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
[data-placement='left'] > .v-menu-content {
|
||||
transform-origin: center right;
|
||||
}
|
||||
|
||||
[data-placement='left-start'] > .v-menu-content {
|
||||
transform-origin: top right;
|
||||
}
|
||||
|
||||
[data-placement='left-end'] > .v-menu-content {
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.attached {
|
||||
&[data-placement^='top'] {
|
||||
> .v-menu-content {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-placement^='bottom'] {
|
||||
> .v-menu-content {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,129 +0,0 @@
|
||||
<template>
|
||||
<div class="v-notice" :class="[type, { center }]">
|
||||
<v-icon v-if="icon !== false" :name="iconName" left />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, PropType } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
type: {
|
||||
type: String as PropType<'normal' | 'info' | 'success' | 'warning' | 'danger'>,
|
||||
default: 'normal',
|
||||
},
|
||||
icon: {
|
||||
type: [String, Boolean],
|
||||
default: null,
|
||||
},
|
||||
center: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const iconName = computed(() => {
|
||||
if (props.icon !== false && typeof props.icon === 'string') {
|
||||
return props.icon;
|
||||
}
|
||||
|
||||
if (props.type == 'info') {
|
||||
return 'info';
|
||||
} else if (props.type == 'success') {
|
||||
return 'check_circle';
|
||||
} else if (props.type == 'warning') {
|
||||
return 'warning';
|
||||
} else if (props.type == 'danger') {
|
||||
return 'error';
|
||||
} else {
|
||||
return 'info';
|
||||
}
|
||||
});
|
||||
|
||||
return { iconName };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:global(body) {
|
||||
--v-notice-color: var(--foreground-subdued);
|
||||
--v-notice-background-color: var(--background-subdued);
|
||||
--v-notice-border-color: var(--background-subdued);
|
||||
--v-notice-icon-color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.v-notice {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: auto;
|
||||
min-height: var(--input-height);
|
||||
padding: 12px 16px;
|
||||
color: var(--v-notice-color);
|
||||
line-height: 22px;
|
||||
background-color: var(--v-notice-background-color);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.v-notice::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background-color: var(--v-notice-border-color);
|
||||
}
|
||||
|
||||
.v-icon {
|
||||
--v-icon-color: var(--v-notice-icon-color);
|
||||
}
|
||||
|
||||
.v-icon.left {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.info {
|
||||
--v-notice-icon-color: var(--primary);
|
||||
--v-notice-border-color: var(--primary);
|
||||
--v-notice-color: var(--foreground-normal);
|
||||
--v-notice-background-color: var(--background-normal);
|
||||
}
|
||||
|
||||
.success {
|
||||
--v-notice-icon-color: var(--success);
|
||||
--v-notice-border-color: var(--success);
|
||||
--v-notice-color: var(--success);
|
||||
--v-notice-background-color: var(--background-normal);
|
||||
}
|
||||
|
||||
.warning {
|
||||
--v-notice-icon-color: var(--warning);
|
||||
--v-notice-border-color: var(--warning);
|
||||
--v-notice-color: var(--foreground-normal);
|
||||
--v-notice-background-color: var(--background-normal);
|
||||
}
|
||||
|
||||
.danger {
|
||||
--v-notice-icon-color: var(--danger);
|
||||
--v-notice-border-color: var(--danger);
|
||||
--v-notice-color: var(--danger);
|
||||
--v-notice-background-color: var(--background-normal);
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
:slotted(a) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -1,88 +0,0 @@
|
||||
<template>
|
||||
<div class="v-overlay" :class="{ active, absolute, 'has-click': clickable }" @click="onClick">
|
||||
<div class="overlay" />
|
||||
<div v-if="active" class="content"><slot /></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
absolute: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { emit }) {
|
||||
return { onClick };
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
emit('click', event);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-overlay-color: var(--overlay-color);
|
||||
--v-overlay-z-index: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: var(--v-overlay-z-index);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
|
||||
&.has-click {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--v-overlay-color);
|
||||
opacity: 0;
|
||||
transition: opacity var(--slow) var(--transition);
|
||||
}
|
||||
|
||||
&.active {
|
||||
pointer-events: auto;
|
||||
|
||||
.overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,196 +0,0 @@
|
||||
<template>
|
||||
<div class="v-pagination">
|
||||
<v-button class="previous" :disabled="disabled || modelValue === 1" secondary icon small @click="toPrev">
|
||||
<v-icon name="chevron_left" />
|
||||
</v-button>
|
||||
|
||||
<v-button
|
||||
v-if="showFirstLast && modelValue > Math.ceil(totalVisible / 2) + 1 && length > totalVisible"
|
||||
class="page"
|
||||
secondary
|
||||
small
|
||||
:disabled="disabled"
|
||||
@click="toPage(1)"
|
||||
>
|
||||
1
|
||||
</v-button>
|
||||
|
||||
<span v-if="showFirstLast && modelValue > Math.ceil(totalVisible / 2) + 1 && length > totalVisible + 1" class="gap">
|
||||
...
|
||||
</span>
|
||||
|
||||
<v-button
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
:class="{ active: modelValue === page }"
|
||||
class="page"
|
||||
secondary
|
||||
small
|
||||
:disabled="disabled"
|
||||
@click="toPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</v-button>
|
||||
|
||||
<span
|
||||
v-if="showFirstLast && modelValue < length - Math.ceil(totalVisible / 2) && length > totalVisible + 1"
|
||||
class="gap"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
|
||||
<v-button
|
||||
v-if="showFirstLast && modelValue <= length - Math.ceil(totalVisible / 2) && length > totalVisible"
|
||||
:class="{ active: modelValue === length }"
|
||||
class="page"
|
||||
secondary
|
||||
small
|
||||
:disabled="disabled"
|
||||
@click="toPage(length)"
|
||||
>
|
||||
{{ length }}
|
||||
</v-button>
|
||||
|
||||
<v-button class="next" :disabled="disabled || modelValue === length" secondary icon small @click="toNext">
|
||||
<v-icon name="chevron_right" />
|
||||
</v-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { isEmpty } from '@/utils/is-empty';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
length: {
|
||||
type: Number,
|
||||
default: null,
|
||||
required: true,
|
||||
validator: (val: number) => val % 1 === 0,
|
||||
},
|
||||
totalVisible: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
validator: (val: number) => val >= 0,
|
||||
},
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
showFirstLast: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const visiblePages = computed<number[]>(() => {
|
||||
if (props.totalVisible === undefined) return [];
|
||||
|
||||
let startPage: number;
|
||||
let endPage: number;
|
||||
|
||||
if (isEmpty(props.totalVisible) || props.length <= props.totalVisible) {
|
||||
startPage = 1;
|
||||
endPage = props.length;
|
||||
} else {
|
||||
const pagesBeforeCurrentPage = Math.floor(props.totalVisible / 2);
|
||||
const pagesAfterCurrentPage = Math.ceil(props.totalVisible / 2) - 1;
|
||||
|
||||
if (props.modelValue <= pagesBeforeCurrentPage) {
|
||||
startPage = 1;
|
||||
endPage = props.totalVisible;
|
||||
} else if (props.modelValue + pagesAfterCurrentPage >= props.length) {
|
||||
startPage = props.length - props.totalVisible + 1;
|
||||
endPage = props.length;
|
||||
} else {
|
||||
startPage = props.modelValue - pagesBeforeCurrentPage;
|
||||
endPage = props.modelValue + pagesAfterCurrentPage;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(Array(endPage + 1 - startPage).keys()).map((i) => startPage + i);
|
||||
});
|
||||
|
||||
return { toPage, toPrev, toNext, visiblePages };
|
||||
|
||||
function toPrev() {
|
||||
toPage(props.modelValue - 1);
|
||||
}
|
||||
|
||||
function toNext() {
|
||||
toPage(props.modelValue + 1);
|
||||
}
|
||||
|
||||
function toPage(page: number) {
|
||||
emit('update:modelValue', page);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:global(body) {
|
||||
--v-pagination-active-color: var(--primary);
|
||||
}
|
||||
|
||||
.v-pagination {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.gap {
|
||||
display: none;
|
||||
margin: 0 4px;
|
||||
color: var(--foreground-subdued);
|
||||
line-height: 2em;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.gap {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.v-button {
|
||||
--v-button-background-color-hover: var(--background-normal);
|
||||
--v-button-background-color: var(--background-subdued);
|
||||
--v-button-color: var(--foreground-normal);
|
||||
|
||||
margin: 0 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.v-button.page:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.v-button.page:not(.active) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.v-button :deep(.small) {
|
||||
--v-button-min-width: 32px;
|
||||
}
|
||||
|
||||
.v-button:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.v-button:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.v-button.active {
|
||||
--v-button-background-color-hover: var(--primary);
|
||||
--v-button-color-hover: var(--foreground-inverted);
|
||||
--v-button-background-color: var(--primary);
|
||||
--v-button-color: var(--foreground-inverted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,153 +0,0 @@
|
||||
<template>
|
||||
<div class="v-progress-circular" :class="sizeClass">
|
||||
<svg
|
||||
class="circle"
|
||||
viewBox="0 0 30 30"
|
||||
:class="{ indeterminate }"
|
||||
@animationiteration="$emit('animationiteration')"
|
||||
>
|
||||
<path
|
||||
class="circle-background"
|
||||
d="M12.5,0A12.5,12.5,0,1,1,0,12.5,12.5,12.5,0,0,1,12.5,0Z"
|
||||
transform="translate(2.5 2.5)"
|
||||
/>
|
||||
<path
|
||||
class="circle-path"
|
||||
:style="circleStyle"
|
||||
d="M12.5,0A12.5,12.5,0,1,1,0,12.5,12.5,12.5,0,0,1,12.5,0Z"
|
||||
transform="translate(2.5 2.5)"
|
||||
/>
|
||||
</svg>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
import { useSizeClass, sizeProps } from '@/composables/use-size-class';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
indeterminate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
...sizeProps,
|
||||
},
|
||||
emits: ['animationiteration'],
|
||||
setup(props) {
|
||||
const sizeClass = useSizeClass(props);
|
||||
|
||||
const circleStyle = computed(() => ({
|
||||
'stroke-dasharray': (props.value / 100) * 78.5 + ', 78.5',
|
||||
}));
|
||||
|
||||
return { sizeClass, circleStyle };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-progress-circular-color: var(--foreground-normal);
|
||||
--v-progress-circular-background-color: var(--border-normal);
|
||||
--v-progress-circular-transition: 400ms;
|
||||
--v-progress-circular-speed: 2s;
|
||||
--v-progress-circular-size: 28px;
|
||||
--v-progress-circular-line-size: 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-progress-circular {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--v-progress-circular-size);
|
||||
height: var(--v-progress-circular-size);
|
||||
|
||||
&.x-small {
|
||||
--v-progress-circular-size: 12px;
|
||||
--v-progress-circular-line-size: 4px;
|
||||
}
|
||||
|
||||
&.small {
|
||||
--v-progress-circular-size: 20px;
|
||||
--v-progress-circular-line-size: 3px;
|
||||
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
&.large {
|
||||
--v-progress-circular-size: 48px;
|
||||
--v-progress-circular-line-size: 2.5px;
|
||||
}
|
||||
|
||||
&.x-large {
|
||||
--v-progress-circular-size: 64px;
|
||||
--v-progress-circular-line-size: 2px;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--v-progress-circular-size);
|
||||
height: var(--v-progress-circular-size);
|
||||
|
||||
&-path {
|
||||
transition: stroke-dasharray var(--v-progress-circular-transition) ease-in-out;
|
||||
fill: transparent;
|
||||
stroke: var(--v-progress-circular-color);
|
||||
stroke-width: var(--v-progress-circular-line-size);
|
||||
}
|
||||
|
||||
&.indeterminate {
|
||||
animation: rotate var(--v-progress-circular-speed) infinite linear;
|
||||
|
||||
.circle-path {
|
||||
animation: stroke var(--v-progress-circular-speed) infinite linear;
|
||||
}
|
||||
}
|
||||
|
||||
&-background {
|
||||
fill: transparent;
|
||||
stroke: var(--v-progress-circular-background-color);
|
||||
stroke-width: var(--v-progress-circular-line-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(1080deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes stroke {
|
||||
0% {
|
||||
stroke-dasharray: 0, 78.5px;
|
||||
}
|
||||
|
||||
50% {
|
||||
stroke-dasharray: 78.5px, 78.5px;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dasharray: 0, 78.5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,172 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="v-progress-linear"
|
||||
:class="[
|
||||
{
|
||||
absolute,
|
||||
bottom,
|
||||
fixed,
|
||||
indeterminate,
|
||||
rounded,
|
||||
top,
|
||||
colorful,
|
||||
},
|
||||
color,
|
||||
]"
|
||||
@animationiteration="$emit('animationiteration')"
|
||||
>
|
||||
<div
|
||||
class="inner"
|
||||
:style="{
|
||||
width: value + '%',
|
||||
}"
|
||||
/>
|
||||
<slot :value="value" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
absolute: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
bottom: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fixed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
indeterminate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
top: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
colorful: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['animationiteration'],
|
||||
setup(props) {
|
||||
const color = computed(() => {
|
||||
if (props.value <= 33) return 'danger';
|
||||
if (props.value <= 66) return 'warning';
|
||||
return 'success';
|
||||
});
|
||||
|
||||
return { color };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-progress-linear-height: 4px;
|
||||
--v-progress-linear-color: var(--foreground-normal);
|
||||
--v-progress-linear-background-color: var(--border-normal);
|
||||
--v-progress-linear-transition: 400ms;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-progress-linear {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: var(--v-progress-linear-height);
|
||||
overflow: hidden;
|
||||
background-color: var(--v-progress-linear-background-color);
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background-color: var(--v-progress-linear-color);
|
||||
transition: width var(--v-progress-linear-transition) ease-in-out;
|
||||
}
|
||||
|
||||
&.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
&.indeterminate .inner {
|
||||
position: relative;
|
||||
width: 100% !important;
|
||||
transform-origin: left;
|
||||
animation: indeterminate 2s infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
&.rounded,
|
||||
&.rounded .inner {
|
||||
border-radius: calc(var(--v-progress-linear-height) / 2);
|
||||
}
|
||||
|
||||
&.top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&.colorful {
|
||||
&.danger .inner {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
|
||||
&.warning .inner {
|
||||
background-color: var(--warning);
|
||||
}
|
||||
|
||||
&.success .inner {
|
||||
background-color: var(--success);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
transform: scaleX(0) translateX(-30%);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: scaleX(0) translateX(-30%);
|
||||
animation-timing-function: cubic-bezier(0.1, 0.6, 0.9, 0.5);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: scaleX(1) translateX(25%);
|
||||
animation-timing-function: cubic-bezier(0.4, 0.1, 0.2, 0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scaleX(1) translateX(100%);
|
||||
animation-timing-function: cubic-bezier(0.1, 0.6, 0.9, 0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,160 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
class="v-radio"
|
||||
type="button"
|
||||
:aria-pressed="isChecked ? 'true' : 'false'"
|
||||
:disabled="disabled"
|
||||
:class="{ checked: isChecked, block }"
|
||||
@click="emitValue"
|
||||
>
|
||||
<v-icon :name="icon" />
|
||||
<span class="label type-text">
|
||||
<slot name="label">{{ label }}</slot>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
iconOn: {
|
||||
type: String,
|
||||
default: 'radio_button_checked',
|
||||
},
|
||||
iconOff: {
|
||||
type: String,
|
||||
default: 'radio_button_unchecked',
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const isChecked = computed<boolean>(() => {
|
||||
return props.modelValue === props.value;
|
||||
});
|
||||
|
||||
const icon = computed<string>(() => {
|
||||
return isChecked.value ? props.iconOn : props.iconOff;
|
||||
});
|
||||
|
||||
return { isChecked, emitValue, icon };
|
||||
|
||||
function emitValue(): void {
|
||||
emit('update:modelValue', props.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-radio-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/no-wrap';
|
||||
|
||||
.v-radio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0;
|
||||
text-align: left;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
appearance: none;
|
||||
|
||||
.label:not(:empty) {
|
||||
margin-left: 8px;
|
||||
|
||||
@include no-wrap;
|
||||
}
|
||||
|
||||
& .v-icon {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.label {
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.v-icon {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
}
|
||||
}
|
||||
|
||||
&.block {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
padding: 10px; // 14 - 4 (border)
|
||||
border: 2px solid var(--background-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--background-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.label {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
.v-icon {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:disabled).checked {
|
||||
.v-icon {
|
||||
--v-icon-color: var(--v-radio-color);
|
||||
}
|
||||
|
||||
&.block {
|
||||
border-color: var(--v-radio-color);
|
||||
|
||||
.label {
|
||||
color: var(--v-radio-color);
|
||||
}
|
||||
|
||||
&::before {
|
||||
background-color: var(--v-radio-color);
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,99 +0,0 @@
|
||||
<template>
|
||||
<v-list-group
|
||||
:active="isActive"
|
||||
:clickable="groupSelectable || item.selectable"
|
||||
:value="item.value"
|
||||
@click="onGroupClick(item)"
|
||||
>
|
||||
<template #activator>
|
||||
<v-list-item-icon v-if="multiple === false && allowOther === false && item.icon">
|
||||
<v-icon :name="item.icon" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<span v-if="multiple === false || item.selectable === false" class="item-text">{{ item.text }}</span>
|
||||
<v-checkbox
|
||||
v-else
|
||||
:model-value="modelValue || []"
|
||||
:label="item.text"
|
||||
:value="item.value"
|
||||
:disabled="item.disabled"
|
||||
@update:model-value="$emit('update:modelValue', $event.length > 0 ? $event : null)"
|
||||
/>
|
||||
</v-list-item-content>
|
||||
</template>
|
||||
|
||||
<template v-for="(childItem, index) in item.children" :key="index">
|
||||
<select-list-item-group
|
||||
v-if="childItem.children"
|
||||
:item="childItem"
|
||||
:model-value="modelValue"
|
||||
:multiple="multiple"
|
||||
:allow-other="allowOther"
|
||||
:group-selectable="groupSelectable"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<select-list-item
|
||||
v-else
|
||||
:model-value="modelValue"
|
||||
:item="childItem"
|
||||
:multiple="multiple"
|
||||
:allow-other="allowOther"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-group>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType } from 'vue';
|
||||
import { Option } from './types';
|
||||
import SelectListItem from './select-list-item.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SelectListItemGroup',
|
||||
components: { SelectListItem },
|
||||
props: {
|
||||
item: {
|
||||
type: Object as PropType<Option>,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Array] as PropType<string | number | (string | number)[]>,
|
||||
default: null,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
allowOther: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
groupSelectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const isActive = computed(() => {
|
||||
if (props.multiple) {
|
||||
if (!Array.isArray(props.modelValue) || !props.item.value) {
|
||||
return false;
|
||||
}
|
||||
return props.modelValue.includes(props.item.value);
|
||||
} else {
|
||||
return props.modelValue === props.item.value;
|
||||
}
|
||||
});
|
||||
|
||||
return { isActive, onGroupClick };
|
||||
|
||||
function onGroupClick(item: Option) {
|
||||
if (!props.groupSelectable) return;
|
||||
|
||||
emit('update:modelValue', item.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<v-divider v-if="item.divider === true" />
|
||||
|
||||
<v-list-item
|
||||
v-else
|
||||
:active="isActive"
|
||||
:disabled="item.disabled"
|
||||
clickable
|
||||
:value="item.value"
|
||||
@click="multiple ? null : $emit('update:modelValue', item.value)"
|
||||
>
|
||||
<v-list-item-icon v-if="multiple === false && allowOther === false && item.icon">
|
||||
<v-icon :name="item.icon" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<span v-if="multiple === false || item.selectable === false" class="item-text">{{ item.text }}</span>
|
||||
<v-checkbox
|
||||
v-else
|
||||
:model-value="modelValue || []"
|
||||
:label="item.text"
|
||||
:value="item.value"
|
||||
:disabled="item.disabled"
|
||||
@update:model-value="$emit('update:modelValue', $event.length > 0 ? $event : null)"
|
||||
/>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType } from 'vue';
|
||||
import { Option } from './types';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SelectListItem',
|
||||
props: {
|
||||
item: {
|
||||
type: Object as PropType<Option>,
|
||||
required: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Array] as PropType<string | number | (string | number)[]>,
|
||||
default: null,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
allowOther: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props) {
|
||||
const isActive = computed(() => {
|
||||
if (props.multiple) {
|
||||
if (!Array.isArray(props.modelValue) || !props.item.value) {
|
||||
return false;
|
||||
}
|
||||
return props.modelValue.includes(props.item.value);
|
||||
} else {
|
||||
return props.modelValue === props.item.value;
|
||||
}
|
||||
});
|
||||
return {
|
||||
isActive,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,9 +0,0 @@
|
||||
export type Option = {
|
||||
value: string | number | null;
|
||||
icon?: string;
|
||||
text?: string;
|
||||
disabled?: boolean;
|
||||
children?: Option[];
|
||||
divider?: boolean;
|
||||
selectable?: boolean;
|
||||
};
|
||||
@@ -1,454 +0,0 @@
|
||||
<template>
|
||||
<v-menu
|
||||
class="v-select"
|
||||
:disabled="disabled"
|
||||
:attached="inline === false"
|
||||
:show-arrow="inline === true"
|
||||
:close-on-content-click="closeOnContentClick"
|
||||
:placement="placement"
|
||||
>
|
||||
<template #activator="{ toggle, active }">
|
||||
<div v-if="inline" class="inline-display" :class="{ placeholder: !displayValue, label, active }" @click="toggle">
|
||||
<slot name="preview">{{ displayValue || placeholder }}</slot>
|
||||
<v-icon name="expand_more" :class="{ active }" />
|
||||
</div>
|
||||
<slot v-else name="preview">
|
||||
<v-input
|
||||
:full-width="fullWidth"
|
||||
readonly
|
||||
:model-value="displayValue"
|
||||
clickable
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:active="active"
|
||||
@click="toggle"
|
||||
>
|
||||
<template v-if="$slots.prepend" #prepend><slot name="prepend" /></template>
|
||||
<template #append>
|
||||
<v-icon name="expand_more" :class="{ active }" />
|
||||
<slot name="append" />
|
||||
</template>
|
||||
</v-input>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<v-list class="list" :mandatory="mandatory" @toggle="$emit('group-toggle', $event)">
|
||||
<template v-if="showDeselect">
|
||||
<v-list-item clickable :disabled="modelValue === null" @click="$emit('update:modelValue', null)">
|
||||
<v-list-item-icon v-if="multiple === true">
|
||||
<v-icon name="close" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ multiple ? t('deselect_all') : t('deselect') }}
|
||||
</v-list-item-content>
|
||||
<v-list-item-icon v-if="multiple === false">
|
||||
<v-icon name="close" />
|
||||
</v-list-item-icon>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
</template>
|
||||
|
||||
<v-list-item v-if="internalItemsCount > 20 || search">
|
||||
<v-list-item-content>
|
||||
<v-input v-model="search" autofocus small :placeholder="t('search')" @click.stop.prevent>
|
||||
<template #append>
|
||||
<v-icon small name="search" />
|
||||
</template>
|
||||
</v-input>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<template v-for="(item, index) in internalItems" :key="index">
|
||||
<select-list-item-group
|
||||
v-if="item.children"
|
||||
:item="item"
|
||||
:model-value="modelValue"
|
||||
:multiple="multiple"
|
||||
:allow-other="allowOther"
|
||||
:group-selectable="groupSelectable"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<select-list-item
|
||||
v-else
|
||||
:model-value="modelValue"
|
||||
:item="item"
|
||||
:multiple="multiple"
|
||||
:allow-other="allowOther"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-list-item v-if="allowOther && multiple === false" :active="usesOtherValue" @click.stop>
|
||||
<v-list-item-content>
|
||||
<input
|
||||
v-model="otherValue"
|
||||
class="other-input"
|
||||
:placeholder="t('other')"
|
||||
@focus="otherValue ? $emit('update:modelValue', otherValue) : null"
|
||||
/>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<template v-if="allowOther && multiple === true">
|
||||
<v-list-item
|
||||
v-for="otherVal in otherValues"
|
||||
:key="otherVal.key"
|
||||
:active="(modelValue || []).includes(otherVal.value)"
|
||||
@click.stop
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-checkbox
|
||||
:model-value="modelValue || []"
|
||||
:value="otherVal.value"
|
||||
@update:model-value="$emit('update:modelValue', $event.length > 0 ? $event : null)"
|
||||
/>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<input
|
||||
v-focus
|
||||
class="other-input"
|
||||
:value="otherVal.value"
|
||||
:placeholder="t('other')"
|
||||
@input="setOtherValue(otherVal.key, $event.target.value)"
|
||||
@blur="otherVal.value.length === 0 && setOtherValue(otherVal.key, null)"
|
||||
/>
|
||||
</v-list-item-content>
|
||||
<v-list-item-icon>
|
||||
<v-icon name="close" clickable @click="setOtherValue(otherVal.key, null)" />
|
||||
</v-list-item-icon>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item @click.stop="addOtherValue()">
|
||||
<v-list-item-icon><v-icon name="add" /></v-list-item-icon>
|
||||
<v-list-item-content>{{ t('other') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useCustomSelection, useCustomSelectionMultiple } from '@/composables/use-custom-selection';
|
||||
import { Placement } from '@popperjs/core';
|
||||
import { debounce, get } from 'lodash';
|
||||
import { computed, defineComponent, PropType, Ref, ref, toRefs, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SelectListItemGroup from './select-list-item-group.vue';
|
||||
import SelectListItem from './select-list-item.vue';
|
||||
import { Option } from './types';
|
||||
|
||||
type ItemsRaw = (string | any)[];
|
||||
type InputValue = string[] | string;
|
||||
|
||||
export default defineComponent({
|
||||
components: { SelectListItemGroup, SelectListItem },
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<ItemsRaw>,
|
||||
required: true,
|
||||
},
|
||||
itemText: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
itemValue: {
|
||||
type: String,
|
||||
default: 'value',
|
||||
},
|
||||
itemIcon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
itemDisabled: {
|
||||
type: String,
|
||||
default: 'disabled',
|
||||
},
|
||||
itemSelectable: {
|
||||
type: String,
|
||||
default: 'selectable',
|
||||
},
|
||||
itemChildren: {
|
||||
type: String,
|
||||
default: 'children',
|
||||
},
|
||||
modelValue: {
|
||||
type: [Array, String, Number, Boolean] as PropType<InputValue>,
|
||||
default: null,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
groupSelectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
mandatory: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showDeselect: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowOther: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closeOnContentClick: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
multiplePreviewThreshold: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
placement: {
|
||||
type: String as PropType<Placement>,
|
||||
default: 'bottom',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'group-toggle'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { internalItems, internalItemsCount, internalSearch } = useItems();
|
||||
const { displayValue } = useDisplayValue();
|
||||
const { modelValue } = toRefs(props);
|
||||
const { otherValue, usesOtherValue } = useCustomSelection(modelValue as Ref<string>, internalItems, (value) =>
|
||||
emit('update:modelValue', value)
|
||||
);
|
||||
const { otherValues, addOtherValue, setOtherValue } = useCustomSelectionMultiple(
|
||||
modelValue as Ref<string[]>,
|
||||
internalItems,
|
||||
(value) => emit('update:modelValue', value)
|
||||
);
|
||||
|
||||
const search = ref<string | null>(null);
|
||||
watch(
|
||||
search,
|
||||
debounce((val: string | null) => {
|
||||
internalSearch.value = val;
|
||||
}, 250)
|
||||
);
|
||||
|
||||
return {
|
||||
t,
|
||||
internalItems,
|
||||
internalItemsCount,
|
||||
displayValue,
|
||||
otherValue,
|
||||
usesOtherValue,
|
||||
otherValues,
|
||||
addOtherValue,
|
||||
setOtherValue,
|
||||
search,
|
||||
};
|
||||
|
||||
function useItems() {
|
||||
const internalSearch = ref<string | null>(null);
|
||||
|
||||
const internalItems = computed(() => {
|
||||
const parseItem = (item: Record<string, any>): Option => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
text: item,
|
||||
value: item,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.divider === true) return { value: null, divider: true };
|
||||
|
||||
const children = get(item, props.itemChildren) ? get(item, props.itemChildren).map(parseItem) : null;
|
||||
|
||||
return {
|
||||
text: get(item, props.itemText),
|
||||
value: get(item, props.itemValue),
|
||||
icon: get(item, props.itemIcon),
|
||||
disabled: get(item, props.itemDisabled),
|
||||
selectable: get(item, props.itemSelectable),
|
||||
children: children ? children.filter(filterItem) : children,
|
||||
};
|
||||
};
|
||||
|
||||
const filterItem = (item: Record<string, any>): boolean => {
|
||||
if (!internalSearch.value) return true;
|
||||
|
||||
const searchValue = internalSearch.value.toLowerCase();
|
||||
|
||||
return item?.children
|
||||
? isMatchingCurrentItem(item, searchValue) ||
|
||||
item.children.some((item: Record<string, any>) => filterItem(item))
|
||||
: isMatchingCurrentItem(item, searchValue);
|
||||
|
||||
function isMatchingCurrentItem(item: Record<string, any>, searchValue: string): boolean {
|
||||
const text = get(item, props.itemText);
|
||||
const value = get(item, props.itemValue);
|
||||
return (
|
||||
(text ? String(text).toLowerCase().includes(searchValue) : false) ||
|
||||
(value ? String(value).toLowerCase().includes(searchValue) : false)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const items = internalSearch.value ? props.items.filter(filterItem).map(parseItem) : props.items.map(parseItem);
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const internalItemsCount = computed<number>(() => {
|
||||
const countItems = (items: Option[]): number => {
|
||||
const count = items.reduce((acc, item): number => {
|
||||
if (item?.children) {
|
||||
acc += countItems(item.children);
|
||||
}
|
||||
return acc + 1;
|
||||
}, 0);
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
return countItems(props.items);
|
||||
});
|
||||
|
||||
return { internalItems, internalItemsCount, internalSearch };
|
||||
}
|
||||
|
||||
function useDisplayValue() {
|
||||
const displayValue = computed(() => {
|
||||
if (Array.isArray(props.modelValue)) {
|
||||
if (props.modelValue.length < props.multiplePreviewThreshold) {
|
||||
return props.modelValue
|
||||
.map((value) => {
|
||||
return getTextForValue(value) || value;
|
||||
})
|
||||
.join(', ');
|
||||
} else {
|
||||
const itemCount = internalItems.value.length + otherValues.value.length;
|
||||
const selectionCount = props.modelValue.length;
|
||||
|
||||
if (itemCount === selectionCount) {
|
||||
return t('all_items');
|
||||
} else {
|
||||
return t('item_count', selectionCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getTextForValue(props.modelValue) || props.modelValue;
|
||||
});
|
||||
|
||||
return { displayValue };
|
||||
|
||||
function getTextForValue(value: string | number) {
|
||||
return findValue(internalItems.value);
|
||||
|
||||
function findValue(choices: Option[]): string | undefined {
|
||||
let textValue: string | undefined = choices.find((item) => item.value === value)?.['text'];
|
||||
|
||||
for (const choice of choices) {
|
||||
if (!textValue) {
|
||||
if (choice.children) {
|
||||
textValue = findValue(choice.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:global(body) {
|
||||
--v-select-font-family: var(--family-sans-serif);
|
||||
--v-select-placeholder-color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.list {
|
||||
--v-list-min-width: 0;
|
||||
}
|
||||
|
||||
.item-text {
|
||||
font-family: var(--v-select-font-family);
|
||||
}
|
||||
|
||||
.v-input {
|
||||
--v-input-font-family: var(--v-select-font-family);
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.v-input .v-icon {
|
||||
transition: transform var(--medium) var(--transition-out);
|
||||
}
|
||||
|
||||
.v-input .v-icon.active {
|
||||
transform: scaleY(-1);
|
||||
transition-timing-function: var(--transition-in);
|
||||
}
|
||||
|
||||
.v-input :deep(input) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.other-input {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.2;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.inline-display {
|
||||
width: max-content;
|
||||
padding-right: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inline-display.label {
|
||||
padding: 4px 8px;
|
||||
padding-right: 26px;
|
||||
color: var(--foreground-subdued);
|
||||
background-color: var(--background-subdued);
|
||||
border-radius: var(--border-radius);
|
||||
transition: color var(--fast) var(--transition);
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.inline-display .v-icon {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.inline-display.placeholder {
|
||||
color: var(--v-select-placeholder-color);
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +0,0 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import VSheet from './v-sheet.vue';
|
||||
|
||||
test('Mount component', () => {
|
||||
expect(VSheet).toBeTruthy();
|
||||
|
||||
const wrapper = mount(VSheet, {
|
||||
slots: {
|
||||
default: 'Slot Content',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
<template>
|
||||
<div class="v-sheet">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:global(body) {
|
||||
--v-sheet-background-color: var(--background-subdued);
|
||||
--v-sheet-height: auto;
|
||||
--v-sheet-min-height: var(--input-height);
|
||||
--v-sheet-max-height: none;
|
||||
--v-sheet-width: auto;
|
||||
--v-sheet-min-width: none;
|
||||
--v-sheet-max-width: none;
|
||||
--v-sheet-padding: 8px;
|
||||
}
|
||||
|
||||
.v-sheet {
|
||||
width: var(--v-sheet-width);
|
||||
min-width: var(--v-sheet-min-width);
|
||||
max-width: var(--v-sheet-max-width);
|
||||
height: var(--v-sheet-height);
|
||||
min-height: var(--v-sheet-min-height);
|
||||
max-height: var(--v-sheet-max-height);
|
||||
padding: var(--v-sheet-padding);
|
||||
overflow: auto;
|
||||
background-color: var(--v-sheet-background-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
</style>
|
||||
@@ -1,144 +0,0 @@
|
||||
<template>
|
||||
<div :class="type" class="v-skeleton-loader">
|
||||
<template v-if="type === 'list-item-icon'">
|
||||
<div class="icon" />
|
||||
<div class="text" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'input',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-skeleton-loader-color: var(--background-page);
|
||||
--v-skeleton-loader-background-color: var(--background-subdued);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-skeleton-loader {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
@mixin loader {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: var(--v-skeleton-loader-background-color);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, var(--v-skeleton-loader-color), transparent);
|
||||
transform: translateX(-100%);
|
||||
opacity: 0.5;
|
||||
animation: loading 1.5s infinite;
|
||||
content: '';
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input,
|
||||
.input-tall {
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
border: var(--border-width) solid var(--v-skeleton-loader-background-color);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
@include loader;
|
||||
}
|
||||
|
||||
.input-tall {
|
||||
height: var(--input-height-tall);
|
||||
}
|
||||
|
||||
.block-list-item {
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
@include loader;
|
||||
|
||||
& + & {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.block-list-item-dense {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
@include loader;
|
||||
|
||||
& + & {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
flex-grow: 1;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
|
||||
@include loader;
|
||||
}
|
||||
|
||||
.list-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 12px;
|
||||
border-radius: 50%;
|
||||
|
||||
@include loader;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex-grow: 1;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
|
||||
@include loader;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity var(--medium) var(--transition);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,292 +0,0 @@
|
||||
<template>
|
||||
<div class="v-slider" :style="styles">
|
||||
<div v-if="$slots.prepend" class="prepend">
|
||||
<slot name="prepend" :value="modelValue" />
|
||||
</div>
|
||||
<div class="slider" :class="{ disabled }">
|
||||
<input
|
||||
:disabled="disabled"
|
||||
type="range"
|
||||
:value="modelValue"
|
||||
:max="max"
|
||||
:min="min"
|
||||
:step="step"
|
||||
@change="onChange"
|
||||
@input="onInput"
|
||||
/>
|
||||
<div class="fill" />
|
||||
<div v-if="showTicks" class="ticks">
|
||||
<span v-for="i in Math.floor((max - min) / step) + 1" :key="i" class="tick" />
|
||||
</div>
|
||||
<div v-if="showThumbLabel" class="thumb-label-wrapper">
|
||||
<div class="thumb-label" :class="{ visible: alwaysShowValue }">
|
||||
<slot name="thumb-label type-text" :value="modelValue">
|
||||
{{ modelValue }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.append" class="append">
|
||||
<slot name="append" :value="modelValue" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showThumbLabel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
step: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
showTicks: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
alwaysShowValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
emits: ['change', 'update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const styles = computed(() => {
|
||||
if (props.modelValue === null) return { '--_v-slider-percentage': 50 };
|
||||
|
||||
let percentage = ((props.modelValue - props.min) / (props.max - props.min)) * 100;
|
||||
if (isNaN(percentage)) percentage = 0;
|
||||
return { '--_v-slider-percentage': percentage };
|
||||
});
|
||||
|
||||
return {
|
||||
styles,
|
||||
onChange,
|
||||
onInput,
|
||||
};
|
||||
|
||||
function onChange(event: InputEvent) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emit('change', Number(target.value));
|
||||
}
|
||||
|
||||
function onInput(event: InputEvent) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emit('update:modelValue', Number(target.value));
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-slider-color: var(--border-normal);
|
||||
--v-slider-thumb-color: var(--primary);
|
||||
--v-slider-fill-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-slider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.prepend {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: relative;
|
||||
top: -3px;
|
||||
flex-grow: 1;
|
||||
|
||||
&.disabled {
|
||||
--v-slider-thumb-color: var(--foreground-subdued);
|
||||
--v-slider-fill-color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
padding: 8px 0;
|
||||
background-color: var(--background-page);
|
||||
background-image: var(--v-slider-track-background-image);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
height: 4px;
|
||||
background: var(--v-slider-color);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&::-moz-range-track {
|
||||
height: 4px;
|
||||
background: var(--v-slider-color);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -2px;
|
||||
background: var(--background-page);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: none;
|
||||
box-shadow: 0 0 0 4px var(--v-slider-thumb-color);
|
||||
transition: all var(--fast) var(--transition);
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -2px;
|
||||
background: var(--v-slider-thumb-color);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
box-shadow: none;
|
||||
box-shadow: 0 0 0 4px var(--v-slider-thumb-color);
|
||||
transition: all var(--fast) var(--transition);
|
||||
appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fill {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: var(--v-slider-fill-color);
|
||||
border-radius: 4px;
|
||||
transform: translateY(-5px) scaleX(calc(var(--_v-slider-percentage) / 100));
|
||||
transform-origin: left;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
padding: 0 7px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--transition);
|
||||
pointer-events: none;
|
||||
|
||||
.tick {
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background-color: var(--v-slider-color);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.thumb-label-wrapper {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 7px;
|
||||
width: calc(100% - 14px);
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.thumb-label {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: calc(var(--_v-slider-percentage) * 1%);
|
||||
width: auto;
|
||||
padding: 2px 6px;
|
||||
color: var(--foreground-inverted);
|
||||
font-weight: 600;
|
||||
background-color: var(--primary);
|
||||
border-radius: var(--border-radius);
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--transition);
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(.disabled),
|
||||
&:focus-within:not(.disabled) {
|
||||
input {
|
||||
height: 4px;
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: -4px;
|
||||
box-shadow: 0 0 0 4px var(--v-slider-thumb-color);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: -4px;
|
||||
box-shadow: 0 0 0 4px var(--v-slider-thumb-color);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
}
|
||||
|
||||
.thumb-label {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:active:not(.disabled) {
|
||||
.thumb-label,
|
||||
.ticks {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.append {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,155 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
class="v-switch"
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-pressed="isChecked ? 'true' : 'false'"
|
||||
:disabled="disabled"
|
||||
@click="toggleInput"
|
||||
>
|
||||
<span class="switch" />
|
||||
<span class="label type-label">
|
||||
<slot name="label">{{ label }}</slot>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
modelValue: {
|
||||
type: [Boolean, Array],
|
||||
default: false,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const isChecked = computed<boolean>(() => {
|
||||
if (props.modelValue instanceof Array) {
|
||||
return props.modelValue.includes(props.value);
|
||||
}
|
||||
|
||||
return props.modelValue === true;
|
||||
});
|
||||
|
||||
return { isChecked, toggleInput };
|
||||
|
||||
function toggleInput(): void {
|
||||
if (props.modelValue instanceof Array) {
|
||||
const newValue = [...props.modelValue];
|
||||
|
||||
if (isChecked.value === false) {
|
||||
newValue.push(props.value);
|
||||
} else {
|
||||
newValue.splice(newValue.indexOf(props.value), 1);
|
||||
}
|
||||
|
||||
emit('update:modelValue', newValue);
|
||||
} else {
|
||||
emit('update:modelValue', !isChecked.value);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-switch-color: var(--foreground-normal);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
appearance: none;
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
vertical-align: middle;
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: 12px;
|
||||
transition: var(--fast) var(--transition);
|
||||
transition-property: background-color border;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--border-normal);
|
||||
border-radius: 8px;
|
||||
transition: transform var(--fast) var(--transition);
|
||||
content: '';
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-normal);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-pressed='true'] .switch {
|
||||
background-color: var(--v-switch-color);
|
||||
border-color: var(--v-switch-color);
|
||||
|
||||
&::after {
|
||||
background-color: var(--background-page);
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.label:not(:empty) {
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
|
||||
.switch {
|
||||
background-color: var(--background-normal-alt);
|
||||
border-color: var(--border-normal);
|
||||
|
||||
&::after {
|
||||
background-color: var(--border-normal);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<div v-if="active" class="v-tab-item">
|
||||
<slot v-bind="{ active, toggle }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { useGroupable } from '@/composables/use-groupable';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { active, toggle } = useGroupable({ value: props.value, group: 'v-tabs-items' });
|
||||
return { active, toggle };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<v-list-item v-if="vertical" class="v-tab vertical" :active="active" :disabled="disabled" clickable @click="onClick">
|
||||
<slot v-bind="{ active, toggle }" />
|
||||
</v-list-item>
|
||||
<div v-else class="v-tab horizontal" :class="{ active, disabled }" @click="onClick">
|
||||
<slot v-bind="{ active, toggle }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, inject, ref } from 'vue';
|
||||
import { useGroupable } from '@/composables/use-groupable';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { active, toggle } = useGroupable({
|
||||
value: props.value,
|
||||
group: 'v-tabs',
|
||||
});
|
||||
|
||||
const vertical = inject('v-tabs-vertical', ref(false));
|
||||
|
||||
return { active, toggle, onClick, vertical };
|
||||
|
||||
function onClick() {
|
||||
if (props.disabled === false) toggle();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-tab-color: var(--foreground-subdued);
|
||||
--v-tab-background-color: var(--background-page);
|
||||
--v-tab-color-active: var(--foreground-normal);
|
||||
--v-tab-background-color-active: var(--background-page);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-tab.horizontal {
|
||||
color: var(--v-tab-color);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
background-color: var(--v-tab-background-color);
|
||||
transition: color var(--fast) var(--transition);
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
color: var(--v-tab-color-active);
|
||||
background-color: var(--v-tab-background-color-active);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -111,11 +111,11 @@ import { useSync } from '@directus/shared/composables';
|
||||
interface Props {
|
||||
headers: Header[];
|
||||
sort: Sort;
|
||||
reordering: boolean;
|
||||
allowHeaderReorder: boolean;
|
||||
showSelect?: ShowSelect;
|
||||
showResize?: boolean;
|
||||
showManualSort?: boolean;
|
||||
allowHeaderReorder: boolean;
|
||||
reordering: boolean;
|
||||
someItemsSelected?: boolean;
|
||||
allItemsSelected?: boolean;
|
||||
fixed?: boolean;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<v-item-group class="v-tabs-items" :model-value="modelValue" scope="v-tabs-items" @update:model-value="update">
|
||||
<slot />
|
||||
</v-item-group>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array as PropType<(string | number)[]>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
function update(newSelection: readonly (string | number)[]) {
|
||||
emit('update:modelValue', newSelection);
|
||||
}
|
||||
|
||||
return { update };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,73 +0,0 @@
|
||||
<template>
|
||||
<v-list v-if="vertical" class="v-tabs vertical alt-colors" nav>
|
||||
<slot />
|
||||
</v-list>
|
||||
<div v-else class="v-tabs horizontal">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, toRefs, provide, ref } from 'vue';
|
||||
import { useGroupableParent } from '@/composables/use-groupable';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
vertical: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Array as PropType<(string | number)[]>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const { modelValue: selection, vertical } = toRefs(props);
|
||||
|
||||
provide('v-tabs-vertical', vertical);
|
||||
|
||||
const { items } = useGroupableParent(
|
||||
{
|
||||
selection: selection,
|
||||
onSelectionChange: update,
|
||||
},
|
||||
{
|
||||
multiple: ref(false),
|
||||
mandatory: ref(true),
|
||||
},
|
||||
'v-tabs'
|
||||
);
|
||||
|
||||
function update(newSelection: readonly (string | number)[]) {
|
||||
emit('update:modelValue', newSelection);
|
||||
}
|
||||
|
||||
return { update, items };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:global(body) {
|
||||
--v-tabs-underline-color: var(--foreground-normal);
|
||||
}
|
||||
|
||||
.v-tabs.horizontal {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.v-tabs.horizontal :slotted(.v-tab) {
|
||||
display: flex;
|
||||
flex-basis: 0px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 38px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,361 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="input"
|
||||
class="v-template-input"
|
||||
:class="{ multiline }"
|
||||
contenteditable="true"
|
||||
tabindex="1"
|
||||
:placeholder="placeholder"
|
||||
@input="processText"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, ref, watch, onMounted, onUnmounted } from 'vue';
|
||||
import { position } from 'caret-pos';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
captureGroup: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
multiline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
triggerCharacter: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Object as PropType<Record<string, string>>,
|
||||
required: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'trigger', 'deactivate', 'up', 'down', 'enter'],
|
||||
setup(props, { emit }) {
|
||||
const input = ref<HTMLDivElement>();
|
||||
|
||||
let hasTriggered = false;
|
||||
let matchedPositions: number[] = [];
|
||||
let previousInnerTextLength = 0;
|
||||
let previousCaretPos = 0;
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newText) => {
|
||||
if (!input.value) return;
|
||||
|
||||
if (newText !== input.value.innerText) {
|
||||
parseHTML(newText, true);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue && props.modelValue !== input.value!.innerText) {
|
||||
parseHTML(props.modelValue);
|
||||
}
|
||||
|
||||
if (input.value) {
|
||||
input.value.addEventListener('click', checkClick);
|
||||
input.value.addEventListener('keydown', checkKeyDown);
|
||||
input.value.addEventListener('keyup', checkKeyUp);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (input.value) {
|
||||
input.value.removeEventListener('click', checkClick);
|
||||
input.value.removeEventListener('keydown', checkKeyDown);
|
||||
input.value.removeEventListener('keyup', checkKeyUp);
|
||||
}
|
||||
});
|
||||
|
||||
return { processText, input };
|
||||
|
||||
function checkKeyDown(event: any) {
|
||||
const caretPos = window.getSelection()?.rangeCount ? position(input.value as Element).pos : 0;
|
||||
|
||||
if (event.code === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
if (hasTriggered) {
|
||||
emit('enter');
|
||||
} else {
|
||||
parseHTML(
|
||||
input.value!.innerText.substring(0, caretPos) +
|
||||
(caretPos === input.value!.innerText.length && input.value!.innerText.charAt(caretPos - 1) !== '\n'
|
||||
? '\n\n'
|
||||
: '\n') +
|
||||
input.value!.innerText.substring(caretPos),
|
||||
true
|
||||
);
|
||||
position(input.value!, caretPos + 1);
|
||||
}
|
||||
} else if (event.code === 'ArrowUp' && !event.shiftKey) {
|
||||
if (hasTriggered) {
|
||||
event.preventDefault();
|
||||
|
||||
emit('up');
|
||||
}
|
||||
} else if (event.code === 'ArrowDown' && !event.shiftKey) {
|
||||
if (hasTriggered) {
|
||||
event.preventDefault();
|
||||
|
||||
emit('down');
|
||||
}
|
||||
} else if (event.code === 'ArrowLeft' && !event.shiftKey) {
|
||||
const checkCaretPos = matchedPositions.indexOf(caretPos - 1);
|
||||
if (checkCaretPos !== -1 && checkCaretPos % 2 === 1) {
|
||||
event.preventDefault();
|
||||
|
||||
position(input.value!, matchedPositions[checkCaretPos - 1] - 1);
|
||||
}
|
||||
} else if (event.code === 'ArrowRight' && !event.shiftKey) {
|
||||
const checkCaretPos = matchedPositions.indexOf(caretPos + 1);
|
||||
if (checkCaretPos !== -1 && checkCaretPos % 2 === 0) {
|
||||
event.preventDefault();
|
||||
|
||||
position(input.value!, matchedPositions[checkCaretPos + 1] + 1);
|
||||
}
|
||||
} else if (event.code === 'Backspace') {
|
||||
const checkCaretPos = matchedPositions.indexOf(caretPos - 1);
|
||||
if (checkCaretPos !== -1 && checkCaretPos % 2 === 1) {
|
||||
event.preventDefault();
|
||||
|
||||
const newCaretPos = matchedPositions[checkCaretPos - 1];
|
||||
parseHTML(
|
||||
(input.value!.innerText.substring(0, newCaretPos) + input.value!.innerText.substring(caretPos)).replaceAll(
|
||||
String.fromCharCode(160),
|
||||
' '
|
||||
),
|
||||
true
|
||||
);
|
||||
position(input.value!, newCaretPos);
|
||||
emit('update:modelValue', input.value!.innerText);
|
||||
}
|
||||
} else if (event.code === 'Delete') {
|
||||
const checkCaretPos = matchedPositions.indexOf(caretPos + 1);
|
||||
if (checkCaretPos !== -1 && checkCaretPos % 2 === 0) {
|
||||
event.preventDefault();
|
||||
|
||||
parseHTML(
|
||||
(
|
||||
input.value!.innerText.substring(0, caretPos) +
|
||||
input.value!.innerText.substring(matchedPositions[checkCaretPos + 1])
|
||||
).replaceAll(String.fromCharCode(160), ' '),
|
||||
true
|
||||
);
|
||||
position(input.value!, caretPos);
|
||||
emit('update:modelValue', input.value!.innerText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkKeyUp(event: any) {
|
||||
const caretPos = window.getSelection()?.rangeCount ? position(input.value as Element).pos : 0;
|
||||
|
||||
if ((event.code === 'ArrowUp' || event.code === 'ArrowDown') && !event.shiftKey) {
|
||||
const checkCaretPos = matchedPositions.indexOf(caretPos);
|
||||
if (checkCaretPos !== -1 && checkCaretPos % 2 === 1) {
|
||||
position(input.value!, matchedPositions[checkCaretPos] + 1);
|
||||
} else if (checkCaretPos !== -1 && checkCaretPos % 2 === 0) {
|
||||
position(input.value!, matchedPositions[checkCaretPos] - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkClick(event: any) {
|
||||
const caretPos = window.getSelection()?.rangeCount ? position(input.value as Element).pos : 0;
|
||||
|
||||
const checkCaretPos = matchedPositions.indexOf(caretPos);
|
||||
if (checkCaretPos !== -1) {
|
||||
if (checkCaretPos % 2 === 0) {
|
||||
position(input.value!, caretPos - 1);
|
||||
} else {
|
||||
position(input.value!, caretPos + 1);
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function processText(event: KeyboardEvent) {
|
||||
const input = event.target as HTMLDivElement;
|
||||
|
||||
const caretPos = window.getSelection()?.rangeCount ? position(input).pos : 0;
|
||||
|
||||
const text = input.innerText ?? '';
|
||||
|
||||
let endPos = text.indexOf(' ', caretPos);
|
||||
if (endPos === -1) endPos = text.indexOf('\n', caretPos);
|
||||
if (endPos === -1) endPos = text.length;
|
||||
const result = /\S+$/.exec(text.slice(0, endPos));
|
||||
let word = result ? result[0] : null;
|
||||
if (word) word = word.replace(/[\s'";:,./?\\-]$/, '');
|
||||
|
||||
if (word?.startsWith(props.triggerCharacter)) {
|
||||
emit('trigger', { searchQuery: word.substring(props.triggerCharacter.length), caretPosition: caretPos });
|
||||
hasTriggered = true;
|
||||
} else {
|
||||
if (hasTriggered) {
|
||||
emit('deactivate');
|
||||
hasTriggered = false;
|
||||
}
|
||||
}
|
||||
|
||||
parseHTML();
|
||||
|
||||
emit('update:modelValue', input.innerText);
|
||||
}
|
||||
|
||||
function parseHTML(innerText?: string, isDirectInput = false) {
|
||||
if (!input.value) return;
|
||||
|
||||
if (input.value.innerText === '\n') {
|
||||
input.value.innerText = '';
|
||||
}
|
||||
|
||||
if (innerText !== undefined) {
|
||||
input.value.innerText = innerText;
|
||||
hasTriggered = false;
|
||||
}
|
||||
|
||||
let newHTML = input.value.innerText;
|
||||
|
||||
const caretPos = isDirectInput
|
||||
? previousCaretPos
|
||||
: window.getSelection()?.rangeCount
|
||||
? position(input.value).pos
|
||||
: 0;
|
||||
|
||||
let lastMatchIndex = 0;
|
||||
const matches = newHTML.match(new RegExp(`${props.captureGroup}(?!</mark>)`, 'gi'));
|
||||
matchedPositions = [];
|
||||
|
||||
if (matches) {
|
||||
for (const match of matches ?? []) {
|
||||
let replaceSpaceBefore = '';
|
||||
let replaceSpaceAfter = '';
|
||||
let addSpaceBefore = '';
|
||||
let addSpaceAfter = '';
|
||||
|
||||
let htmlMatchIndex = newHTML.indexOf(match, lastMatchIndex);
|
||||
const charCodeBefore = newHTML.charCodeAt(htmlMatchIndex - 1);
|
||||
const charCodeAfter = newHTML.charCodeAt(htmlMatchIndex + match.length);
|
||||
|
||||
if (charCodeBefore === 32) {
|
||||
replaceSpaceBefore = ' ';
|
||||
addSpaceBefore = ' ';
|
||||
} else if (charCodeBefore !== 160) {
|
||||
addSpaceBefore = ' ';
|
||||
}
|
||||
|
||||
if (charCodeAfter === 32) {
|
||||
replaceSpaceAfter = ' ';
|
||||
addSpaceAfter = ' ';
|
||||
} else if (charCodeAfter !== 160) {
|
||||
addSpaceAfter = ' ';
|
||||
}
|
||||
|
||||
let searchString = replaceSpaceBefore + match + replaceSpaceAfter;
|
||||
let replacementString = `${addSpaceBefore}<mark class="preview" data-preview="${
|
||||
props.items[match.substring(props.triggerCharacter.length)]
|
||||
}" contenteditable="false">${match}</mark>${addSpaceAfter}`;
|
||||
|
||||
newHTML = newHTML.replace(new RegExp(`(${searchString})(?!</mark>)`), replacementString);
|
||||
lastMatchIndex = htmlMatchIndex + replacementString.length - searchString.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (input.value.innerHTML !== newHTML.replaceAll(String.fromCharCode(160), ' ')) {
|
||||
input.value.innerHTML = newHTML;
|
||||
|
||||
const delta = input.value.innerText.length - previousInnerTextLength;
|
||||
const newPosition = caretPos + delta;
|
||||
|
||||
if (newPosition > input.value.innerText.length || newPosition < 0) {
|
||||
position(input.value, input.value.innerText.length);
|
||||
} else {
|
||||
position(input.value, newPosition);
|
||||
}
|
||||
}
|
||||
|
||||
lastMatchIndex = 0;
|
||||
for (const match of matches ?? []) {
|
||||
let matchIndex = input.value.innerText.indexOf(match, lastMatchIndex);
|
||||
matchedPositions.push(matchIndex, matchIndex + match.length);
|
||||
lastMatchIndex = matchIndex + match.length;
|
||||
}
|
||||
|
||||
previousInnerTextLength = input.value.innerText.length;
|
||||
previousCaretPos = caretPos;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.v-template-input {
|
||||
position: relative;
|
||||
height: var(--input-height);
|
||||
padding: var(--input-padding);
|
||||
padding-bottom: 32px;
|
||||
overflow: hidden;
|
||||
color: var(--foreground-normal);
|
||||
font-family: var(--family-sans-serif);
|
||||
white-space: nowrap;
|
||||
background-color: var(--background-page);
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius);
|
||||
transition: border-color var(--fast) var(--transition);
|
||||
|
||||
&:empty::before {
|
||||
pointer-events: none;
|
||||
content: attr(placeholder);
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
&.multiline {
|
||||
height: var(--input-height-tall);
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-normal-alt);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
:deep(.preview) {
|
||||
display: inline-block;
|
||||
margin: 0px;
|
||||
padding: 2px 4px;
|
||||
color: var(--primary);
|
||||
font-size: 0;
|
||||
line-height: 1;
|
||||
vertical-align: -2px;
|
||||
background: var(--primary-alt);
|
||||
border-radius: var(--border-radius);
|
||||
user-select: text;
|
||||
|
||||
&::before {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
content: attr(data-preview);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div ref="el" v-tooltip:[placement]="hasEllipsis && text" class="v-text-overflow">
|
||||
<v-highlight v-if="highlight" :query="highlight" :text="text" />
|
||||
<template v-else>{{ text }}</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch } from 'vue';
|
||||
import { useElementSize } from '@/composables/use-element-size';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
text: {
|
||||
type: [String, Number, Array, Object, Boolean],
|
||||
required: true,
|
||||
},
|
||||
highlight: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'top',
|
||||
validator: (val: string) => ['top', 'bottom', 'left', 'right', 'start', 'end'].includes(val),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const el = ref<HTMLElement>();
|
||||
const hasEllipsis = ref(false);
|
||||
|
||||
const { width } = useElementSize(el);
|
||||
|
||||
watch(
|
||||
width,
|
||||
() => {
|
||||
if (!el.value) return;
|
||||
hasEllipsis.value = el.value.offsetWidth < el.value.scrollWidth;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return { el, hasEllipsis };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-text-overflow {
|
||||
overflow: hidden;
|
||||
line-height: normal;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -1,181 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="v-textarea"
|
||||
:class="{
|
||||
disabled,
|
||||
'expand-on-focus': expandOnFocus,
|
||||
'full-width': fullWidth,
|
||||
'has-content': hasContent,
|
||||
}"
|
||||
>
|
||||
<div v-if="$slots.prepend" class="prepend"><slot name="prepend" /></div>
|
||||
<textarea
|
||||
v-focus="autofocus"
|
||||
v-bind="$attrs"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="modelValue"
|
||||
v-on="listeners"
|
||||
/>
|
||||
<div v-if="$slots.append" class="append"><slot name="append" /></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
nullable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
expandOnFocus: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
trim: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const listeners = computed(() => ({
|
||||
input: emitValue,
|
||||
blur: trimIfEnabled,
|
||||
}));
|
||||
|
||||
const hasContent = computed(() => props.modelValue && props.modelValue.length > 0);
|
||||
|
||||
return { listeners, hasContent };
|
||||
|
||||
function emitValue(event: InputEvent) {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
|
||||
if (props.nullable === true && value === '') {
|
||||
emit('update:modelValue', null);
|
||||
} else {
|
||||
emit('update:modelValue', value);
|
||||
}
|
||||
}
|
||||
|
||||
function trimIfEnabled() {
|
||||
if (props.modelValue && props.trim) {
|
||||
emit('update:modelValue', props.modelValue.trim());
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-textarea-min-height: none;
|
||||
--v-textarea-max-height: var(--input-height-tall);
|
||||
--v-textarea-height: var(--input-height-tall);
|
||||
--v-textarea-font-family: var(--family-sans-serif);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-textarea {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
height: var(--v-textarea-height);
|
||||
min-height: var(--v-textarea-min-height);
|
||||
max-height: var(--v-textarea-max-height);
|
||||
background-color: var(--background-input);
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius);
|
||||
transition: border-color var(--fast) var(--transition);
|
||||
|
||||
.append,
|
||||
.prepend {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.expand-on-focus {
|
||||
height: var(--input-height);
|
||||
transition: height var(--medium) var(--transition);
|
||||
|
||||
.append,
|
||||
.prepend {
|
||||
opacity: 0;
|
||||
transition: opacity var(--medium) var(--transition);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-within,
|
||||
&.has-content {
|
||||
height: var(--v-textarea-max-height);
|
||||
|
||||
.append,
|
||||
.prepend {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
border-color: var(--border-normal-alt);
|
||||
}
|
||||
|
||||
&:focus:not(.disabled),
|
||||
&:focus-within:not(.disabled) {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 16px -8px var(--primary);
|
||||
}
|
||||
|
||||
textarea {
|
||||
position: relative;
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: var(--input-height);
|
||||
padding: var(--input-padding);
|
||||
color: var(--foreground-normal);
|
||||
font-family: var(--v-textarea-font-family);
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
resize: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled textarea {
|
||||
color: var(--foreground-subdued);
|
||||
background-color: var(--background-subdued);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -84,9 +84,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, ref, computed } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { uploadFiles } from '@/utils/upload-files';
|
||||
import { uploadFile } from '@/utils/upload-file';
|
||||
import DrawerCollection from '@/views/private/components/drawer-collection.vue';
|
||||
@@ -95,237 +95,203 @@ import emitter, { Events } from '@/events';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { Filter } from '@directus/shared/types';
|
||||
|
||||
export default defineComponent({
|
||||
components: { DrawerCollection },
|
||||
props: {
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
preset: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
fileId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
fromUrl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fromLibrary: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
folder: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['input'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
interface Props {
|
||||
multiple?: boolean;
|
||||
preset?: Record<string, any>;
|
||||
fileId?: string;
|
||||
fromUrl?: boolean;
|
||||
fromLibrary?: boolean;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
const { uploading, progress, upload, onBrowseSelect, done, numberOfFiles } = useUpload();
|
||||
const { onDragEnter, onDragLeave, onDrop, dragging } = useDragging();
|
||||
const { url, isValidURL, loading: urlLoading, importFromURL } = useURLImport();
|
||||
const { setSelection } = useSelection();
|
||||
const activeDialog = ref<'choose' | 'url' | null>(null);
|
||||
const input = ref(null);
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
preset: () => ({}),
|
||||
fileId: undefined,
|
||||
fromUrl: false,
|
||||
fromLibrary: false,
|
||||
folder: undefined,
|
||||
});
|
||||
|
||||
const filterByFolder = computed(() => {
|
||||
if (!props.folder) return undefined;
|
||||
return { folder: { id: { _eq: props.folder } } } as Filter;
|
||||
});
|
||||
const emit = defineEmits(['input']);
|
||||
|
||||
return {
|
||||
t,
|
||||
uploading,
|
||||
progress,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
dragging,
|
||||
onBrowseSelect,
|
||||
done,
|
||||
numberOfFiles,
|
||||
activeDialog,
|
||||
filterByFolder,
|
||||
url,
|
||||
isValidURL,
|
||||
urlLoading,
|
||||
importFromURL,
|
||||
setSelection,
|
||||
openFileBrowser,
|
||||
input,
|
||||
};
|
||||
const { t } = useI18n();
|
||||
|
||||
function useUpload() {
|
||||
const uploading = ref(false);
|
||||
const progress = ref(0);
|
||||
const numberOfFiles = ref(0);
|
||||
const done = ref(0);
|
||||
const { uploading, progress, upload, onBrowseSelect, done, numberOfFiles } = useUpload();
|
||||
const { onDragEnter, onDragLeave, onDrop, dragging } = useDragging();
|
||||
const { url, isValidURL, loading: urlLoading, importFromURL } = useURLImport();
|
||||
const { setSelection } = useSelection();
|
||||
const activeDialog = ref<'choose' | 'url' | null>(null);
|
||||
const input = ref<HTMLInputElement>();
|
||||
|
||||
return { uploading, progress, upload, onBrowseSelect, numberOfFiles, done };
|
||||
const filterByFolder = computed(() => {
|
||||
if (!props.folder) return undefined;
|
||||
return { folder: { id: { _eq: props.folder } } } as Filter;
|
||||
});
|
||||
|
||||
async function upload(files: FileList) {
|
||||
uploading.value = true;
|
||||
progress.value = 0;
|
||||
function useUpload() {
|
||||
const uploading = ref(false);
|
||||
const progress = ref(0);
|
||||
const numberOfFiles = ref(0);
|
||||
const done = ref(0);
|
||||
|
||||
const folderPreset: { folder?: string } = {};
|
||||
return { uploading, progress, upload, onBrowseSelect, numberOfFiles, done };
|
||||
|
||||
if (props.folder) {
|
||||
folderPreset.folder = props.folder;
|
||||
}
|
||||
async function upload(files: FileList) {
|
||||
uploading.value = true;
|
||||
progress.value = 0;
|
||||
|
||||
try {
|
||||
numberOfFiles.value = files.length;
|
||||
const folderPreset: { folder?: string } = {};
|
||||
|
||||
if (props.multiple === true) {
|
||||
const uploadedFiles = await uploadFiles(Array.from(files), {
|
||||
onProgressChange: (percentage) => {
|
||||
progress.value = Math.round(percentage.reduce((acc, cur) => (acc += cur)) / files.length);
|
||||
done.value = percentage.filter((p) => p === 100).length;
|
||||
},
|
||||
preset: {
|
||||
...props.preset,
|
||||
...folderPreset,
|
||||
},
|
||||
});
|
||||
|
||||
uploadedFiles && emit('input', uploadedFiles);
|
||||
} else {
|
||||
const uploadedFile = await uploadFile(Array.from(files)[0], {
|
||||
onProgressChange: (percentage) => {
|
||||
progress.value = percentage;
|
||||
done.value = percentage === 100 ? 1 : 0;
|
||||
},
|
||||
fileId: props.fileId,
|
||||
preset: {
|
||||
...props.preset,
|
||||
...folderPreset,
|
||||
},
|
||||
});
|
||||
|
||||
uploadedFile && emit('input', uploadedFile);
|
||||
}
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
done.value = 0;
|
||||
numberOfFiles.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function onBrowseSelect(event: InputEvent) {
|
||||
const files = (event.target as HTMLInputElement)?.files;
|
||||
|
||||
if (files) {
|
||||
upload(files);
|
||||
}
|
||||
}
|
||||
if (props.folder) {
|
||||
folderPreset.folder = props.folder;
|
||||
}
|
||||
|
||||
function useDragging() {
|
||||
const dragging = ref(false);
|
||||
try {
|
||||
numberOfFiles.value = files.length;
|
||||
|
||||
let dragCounter = 0;
|
||||
if (props.multiple === true) {
|
||||
const uploadedFiles = await uploadFiles(Array.from(files), {
|
||||
onProgressChange: (percentage) => {
|
||||
progress.value = Math.round(percentage.reduce((acc, cur) => (acc += cur)) / files.length);
|
||||
done.value = percentage.filter((p) => p === 100).length;
|
||||
},
|
||||
preset: {
|
||||
...props.preset,
|
||||
...folderPreset,
|
||||
},
|
||||
});
|
||||
|
||||
return { onDragEnter, onDragLeave, onDrop, dragging };
|
||||
uploadedFiles && emit('input', uploadedFiles);
|
||||
} else {
|
||||
const uploadedFile = await uploadFile(Array.from(files)[0], {
|
||||
onProgressChange: (percentage) => {
|
||||
progress.value = percentage;
|
||||
done.value = percentage === 100 ? 1 : 0;
|
||||
},
|
||||
fileId: props.fileId,
|
||||
preset: {
|
||||
...props.preset,
|
||||
...folderPreset,
|
||||
},
|
||||
});
|
||||
|
||||
function onDragEnter() {
|
||||
dragCounter++;
|
||||
|
||||
if (dragCounter === 1) {
|
||||
dragging.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragCounter--;
|
||||
|
||||
if (dragCounter === 0) {
|
||||
dragging.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragCounter = 0;
|
||||
dragging.value = false;
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
|
||||
if (files) {
|
||||
upload(files);
|
||||
}
|
||||
uploadedFile && emit('input', uploadedFile);
|
||||
}
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
done.value = 0;
|
||||
numberOfFiles.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function useSelection() {
|
||||
return { setSelection };
|
||||
function onBrowseSelect(event: Event) {
|
||||
const files = (event.target as HTMLInputElement)?.files;
|
||||
|
||||
async function setSelection(selection: string[]) {
|
||||
if (selection[0]) {
|
||||
const id = selection[0];
|
||||
const fileResponse = await api.get(`/files/${id}`);
|
||||
emit('input', fileResponse.data.data);
|
||||
} else {
|
||||
emit('input', null);
|
||||
}
|
||||
}
|
||||
if (files) {
|
||||
upload(files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useURLImport() {
|
||||
const url = ref('');
|
||||
const loading = ref(false);
|
||||
function useDragging() {
|
||||
const dragging = ref(false);
|
||||
|
||||
const isValidURL = computed(() => {
|
||||
try {
|
||||
new URL(url.value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
let dragCounter = 0;
|
||||
|
||||
return { onDragEnter, onDragLeave, onDrop, dragging };
|
||||
|
||||
function onDragEnter() {
|
||||
dragCounter++;
|
||||
|
||||
if (dragCounter === 1) {
|
||||
dragging.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragCounter--;
|
||||
|
||||
if (dragCounter === 0) {
|
||||
dragging.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
dragCounter = 0;
|
||||
dragging.value = false;
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
|
||||
if (files) {
|
||||
upload(files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useSelection() {
|
||||
return { setSelection };
|
||||
|
||||
async function setSelection(selection: string[]) {
|
||||
if (selection[0]) {
|
||||
const id = selection[0];
|
||||
const fileResponse = await api.get(`/files/${id}`);
|
||||
emit('input', fileResponse.data.data);
|
||||
} else {
|
||||
emit('input', null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useURLImport() {
|
||||
const url = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
const isValidURL = computed(() => {
|
||||
try {
|
||||
new URL(url.value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return { url, loading, isValidURL, importFromURL };
|
||||
|
||||
async function importFromURL() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await api.post(`/files/import`, {
|
||||
url: url.value,
|
||||
data: {
|
||||
folder: props.folder,
|
||||
},
|
||||
});
|
||||
|
||||
return { url, loading, isValidURL, importFromURL };
|
||||
emitter.emit(Events.upload);
|
||||
|
||||
async function importFromURL() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await api.post(`/files/import`, {
|
||||
url: url.value,
|
||||
data: {
|
||||
folder: props.folder,
|
||||
},
|
||||
});
|
||||
|
||||
emitter.emit(Events.upload);
|
||||
|
||||
if (props.multiple) {
|
||||
emit('input', [response.data.data]);
|
||||
} else {
|
||||
emit('input', response.data.data);
|
||||
}
|
||||
|
||||
activeDialog.value = null;
|
||||
url.value = '';
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
if (props.multiple) {
|
||||
emit('input', [response.data.data]);
|
||||
} else {
|
||||
emit('input', response.data.data);
|
||||
}
|
||||
}
|
||||
|
||||
function openFileBrowser() {
|
||||
input.value.click();
|
||||
activeDialog.value = null;
|
||||
url.value = '';
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openFileBrowser() {
|
||||
input.value?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,538 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="v-workspace-tile"
|
||||
:style="positionStyling"
|
||||
:class="{
|
||||
editing: editMode,
|
||||
draggable,
|
||||
dragging,
|
||||
'br-tl': dragging || borderRadius[0],
|
||||
'br-tr': dragging || borderRadius[1],
|
||||
'br-br': dragging || borderRadius[2],
|
||||
'br-bl': dragging || borderRadius[3],
|
||||
}"
|
||||
data-move
|
||||
@pointerdown="onPointerDown('move', $event)"
|
||||
>
|
||||
<div v-if="showHeader" class="header">
|
||||
<v-icon class="icon" :style="iconColor" :name="icon" small />
|
||||
<v-text-overflow class="name" :text="name || ''" />
|
||||
<div class="spacer" />
|
||||
<v-icon v-if="note" v-tooltip="note" class="note" name="info" />
|
||||
</div>
|
||||
|
||||
<div v-if="editMode" class="edit-actions" @pointerdown.stop>
|
||||
<v-icon v-tooltip="t('edit')" class="edit-icon" name="edit" clickable @click.stop="$emit('edit')" />
|
||||
|
||||
<v-menu v-if="showOptions" placement="bottom-end" show-arrow>
|
||||
<template #activator="{ toggle }">
|
||||
<v-icon class="more-icon" name="more_vert" clickable @click="toggle" />
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item clickable :disabled="id.startsWith('_')" @click="$emit('move')">
|
||||
<v-list-item-icon>
|
||||
<v-icon class="move-icon" name="input" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
{{ t('copy_to') }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item clickable @click="$emit('duplicate')">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="control_point_duplicate" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>{{ t('duplicate') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item class="delete-action" clickable @click="$emit('delete')">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="delete" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>{{ t('delete') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div class="resize-details">
|
||||
({{ positioning.x - 1 }}:{{ positioning.y - 1 }})
|
||||
<template v-if="resizable">{{ positioning.width }}×{{ positioning.height }}</template>
|
||||
</div>
|
||||
|
||||
<div v-if="editMode && resizable" class="resize-handlers">
|
||||
<div class="top" @pointerdown.stop="onPointerDown('resize-top', $event)" />
|
||||
<div class="right" @pointerdown.stop="onPointerDown('resize-right', $event)" />
|
||||
<div class="bottom" @pointerdown.stop="onPointerDown('resize-bottom', $event)" />
|
||||
<div class="left" @pointerdown.stop="onPointerDown('resize-left', $event)" />
|
||||
<div class="top-left" @pointerdown.stop="onPointerDown('resize-top-left', $event)" />
|
||||
<div class="top-right" @pointerdown.stop="onPointerDown('resize-top-right', $event)" />
|
||||
<div class="bottom-right" @pointerdown.stop="onPointerDown('resize-bottom-right', $event)" />
|
||||
<div class="bottom-left" @pointerdown.stop="onPointerDown('resize-bottom-left', $event)" />
|
||||
</div>
|
||||
|
||||
<div class="tile-content" :class="{ 'has-header': showHeader }">
|
||||
<slot></slot>
|
||||
<div v-if="$slots.footer" class="footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="body"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Panel } from '@directus/shared/types';
|
||||
import { computed, ref, reactive, StyleValue } from 'vue';
|
||||
import { throttle } from 'lodash';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export type AppTile = {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
note?: string;
|
||||
showHeader?: boolean;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
draggable?: boolean;
|
||||
borderRadius?: [boolean, boolean, boolean, boolean];
|
||||
data?: Record<string, any>;
|
||||
};
|
||||
|
||||
// Right now, it is not possible to do type Props = AppTile & {resizable?: boolean; editMode?: boolean}
|
||||
type Props = {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
note?: string;
|
||||
showHeader?: boolean;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
draggable?: boolean;
|
||||
borderRadius?: [boolean, boolean, boolean, boolean];
|
||||
resizable?: boolean;
|
||||
editMode?: boolean;
|
||||
showOptions?: boolean;
|
||||
alwaysUpdatePosition?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
name: undefined,
|
||||
icon: 'space_dashboard',
|
||||
color: 'var(--primary)',
|
||||
note: undefined,
|
||||
showHeader: true,
|
||||
minWidth: 8,
|
||||
minHeight: 6,
|
||||
resizable: true,
|
||||
editMode: false,
|
||||
draggable: true,
|
||||
borderRadius: () => [true, true, true, true],
|
||||
showOptions: true,
|
||||
alwaysUpdatePosition: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'move', 'duplicate', 'delete', 'edit', 'preview']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
/**
|
||||
* When drag-n-dropping for positioning/resizing, we're
|
||||
*/
|
||||
const editedPosition = reactive<Partial<Panel>>({
|
||||
position_x: undefined,
|
||||
position_y: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
});
|
||||
|
||||
const { onPointerDown, dragging } = useDragDrop();
|
||||
|
||||
const positioning = computed(() => {
|
||||
if (dragging.value) {
|
||||
return {
|
||||
x: editedPosition.position_x ?? props.x,
|
||||
y: editedPosition.position_y ?? props.y,
|
||||
width: editedPosition.width ?? props.width,
|
||||
height: editedPosition.height ?? props.height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: props.x,
|
||||
y: props.y,
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
};
|
||||
});
|
||||
|
||||
const positionStyling = computed(() => {
|
||||
if (dragging.value) {
|
||||
return {
|
||||
'--pos-x': editedPosition.position_x ?? props.x,
|
||||
'--pos-y': editedPosition.position_y ?? props.y,
|
||||
'--width': editedPosition.width ?? props.width,
|
||||
'--height': editedPosition.height ?? props.height,
|
||||
} as StyleValue;
|
||||
}
|
||||
|
||||
return {
|
||||
'--pos-x': props.x,
|
||||
'--pos-y': props.y,
|
||||
'--width': props.width,
|
||||
'--height': props.height,
|
||||
} as StyleValue;
|
||||
});
|
||||
|
||||
const iconColor = computed(() => ({
|
||||
'--v-icon-color': props.color,
|
||||
}));
|
||||
|
||||
function useDragDrop() {
|
||||
const dragging = ref(false);
|
||||
|
||||
let pointerStartPosX = 0;
|
||||
let pointerStartPosY = 0;
|
||||
|
||||
let panelStartPosX = 0;
|
||||
let panelStartPosY = 0;
|
||||
let panelStartWidth = 0;
|
||||
let panelStartHeight = 0;
|
||||
|
||||
type Operation =
|
||||
| 'move'
|
||||
| 'resize-top'
|
||||
| 'resize-right'
|
||||
| 'resize-bottom'
|
||||
| 'resize-left'
|
||||
| 'resize-top-left'
|
||||
| 'resize-top-right'
|
||||
| 'resize-bottom-right'
|
||||
| 'resize-bottom-left';
|
||||
|
||||
let operation: Operation = 'move';
|
||||
|
||||
const onPointerMove = throttle((event: PointerEvent) => {
|
||||
if (props.editMode === false || dragging.value === false || props.draggable === false) return;
|
||||
|
||||
const pointerDeltaX = event.pageX - pointerStartPosX;
|
||||
const pointerDeltaY = event.pageY - pointerStartPosY;
|
||||
|
||||
const gridDeltaX = Math.round(pointerDeltaX / 20);
|
||||
const gridDeltaY = Math.round(pointerDeltaY / 20);
|
||||
|
||||
if (operation === 'move') {
|
||||
editedPosition.position_x = panelStartPosX + gridDeltaX;
|
||||
editedPosition.position_y = panelStartPosY + gridDeltaY;
|
||||
|
||||
if (editedPosition.position_x < 1) editedPosition.position_x = 1;
|
||||
if (editedPosition.position_y < 1) editedPosition.position_y = 1;
|
||||
} else {
|
||||
if (operation.includes('top')) {
|
||||
editedPosition.height = panelStartHeight - gridDeltaY;
|
||||
editedPosition.position_y = panelStartPosY + gridDeltaY;
|
||||
}
|
||||
|
||||
if (operation.includes('right')) {
|
||||
editedPosition.width = panelStartWidth + gridDeltaX;
|
||||
}
|
||||
|
||||
if (operation.includes('bottom')) {
|
||||
editedPosition.height = panelStartHeight + gridDeltaY;
|
||||
}
|
||||
|
||||
if (operation.includes('left')) {
|
||||
editedPosition.width = panelStartWidth - gridDeltaX;
|
||||
editedPosition.position_x = panelStartPosX + gridDeltaX;
|
||||
}
|
||||
|
||||
const minWidth = props.minWidth;
|
||||
const minHeight = props.minHeight;
|
||||
|
||||
if (editedPosition.position_x && editedPosition.position_x < 1) editedPosition.position_x = 1;
|
||||
if (editedPosition.position_y && editedPosition.position_y < 1) editedPosition.position_y = 1;
|
||||
if (editedPosition.width && editedPosition.width < minWidth) editedPosition.width = minWidth;
|
||||
if (editedPosition.height && editedPosition.height < minHeight) editedPosition.height = minHeight;
|
||||
}
|
||||
|
||||
if (props.alwaysUpdatePosition) emit('update', editedPosition);
|
||||
}, 20);
|
||||
|
||||
return { dragging, onPointerDown, onPointerUp, onPointerMove };
|
||||
|
||||
function onPointerDown(op: Operation, event: PointerEvent) {
|
||||
if (props.editMode === false || props.draggable === false) return;
|
||||
|
||||
operation = op;
|
||||
|
||||
dragging.value = true;
|
||||
|
||||
pointerStartPosX = event.pageX;
|
||||
pointerStartPosY = event.pageY;
|
||||
|
||||
panelStartPosX = props.x;
|
||||
panelStartPosY = props.y;
|
||||
|
||||
panelStartWidth = props.width;
|
||||
panelStartHeight = props.height;
|
||||
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging.value = false;
|
||||
if (
|
||||
props.editMode === false ||
|
||||
props.draggable === false ||
|
||||
Object.values(editedPosition).every((v) => v === undefined)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('update', editedPosition);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
|
||||
editedPosition.position_x = undefined;
|
||||
editedPosition.position_y = undefined;
|
||||
editedPosition.width = undefined;
|
||||
editedPosition.height = undefined;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.v-workspace-tile {
|
||||
--pos-x: 1;
|
||||
--pos-y: 1;
|
||||
--width: 6;
|
||||
--height: 6;
|
||||
|
||||
position: relative;
|
||||
display: block;
|
||||
grid-row: var(--pos-y) / span var(--height);
|
||||
grid-column: var(--pos-x) / span var(--width);
|
||||
background-color: var(--background-page);
|
||||
border: 1px solid var(--border-subdued);
|
||||
box-shadow: 0 0 0 1px var(--border-subdued);
|
||||
z-index: 1;
|
||||
transition: border var(--fast) var(--transition);
|
||||
|
||||
&:hover {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
&.editing {
|
||||
&.draggable {
|
||||
border-color: var(--border-normal);
|
||||
box-shadow: 0 0 0 1px var(--border-normal);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
&.draggable:hover {
|
||||
border-color: var(--border-normal-alt);
|
||||
box-shadow: 0 0 0 1px var(--border-normal-alt);
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
z-index: 3 !important;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
}
|
||||
|
||||
&.dragging .resize-details {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& .tile-content {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resize-details {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
padding: 17px 14px;
|
||||
color: var(--foreground-subdued);
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
font-family: var(--family-monospace);
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
background-color: var(--background-page);
|
||||
border-top-right-radius: var(--border-radius-outline);
|
||||
opacity: 0;
|
||||
transition: opacity var(--fast) var(--transition), color var(--fast) var(--transition);
|
||||
pointer-events: none;
|
||||
}
|
||||
.tile-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tile-content.has-header {
|
||||
height: calc(100% - 42px);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 42px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0 12px;
|
||||
border-top: 2px solid var(--border-subdued);
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--foreground-normal-alt);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
font-family: var(--family-sans-serif);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.more-icon,
|
||||
.edit-icon,
|
||||
.note {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
--v-icon-color-hover: var(--foreground-normal);
|
||||
}
|
||||
|
||||
.delete-action {
|
||||
--v-list-item-color: var(--danger);
|
||||
--v-list-item-color-hover: var(--danger);
|
||||
--v-list-item-icon-color: var(--danger);
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 12px 12px 8px;
|
||||
background-color: var(--background-page);
|
||||
border-top-right-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.resize-handlers div {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.resize-handlers .top {
|
||||
top: -3px;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .right {
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .bottom {
|
||||
bottom: -3px;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .left {
|
||||
top: 0;
|
||||
left: -3px;
|
||||
width: 10px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .top-left {
|
||||
top: -3px;
|
||||
left: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .top-right {
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .bottom-right {
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handlers .bottom-left {
|
||||
bottom: -3px;
|
||||
left: -3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.br-tl {
|
||||
border-top-left-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.br-tr {
|
||||
border-top-right-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.br-br {
|
||||
border-bottom-right-radius: var(--border-radius-outline);
|
||||
}
|
||||
|
||||
.br-bl {
|
||||
border-bottom-left-radius: var(--border-radius-outline);
|
||||
}
|
||||
</style>
|
||||
@@ -1,169 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="v-workspace"
|
||||
:class="{ editing: editMode }"
|
||||
:style="{ width: workspaceBoxSize.width + 'px', height: workspaceBoxSize.height + 'px' }"
|
||||
>
|
||||
<div
|
||||
class="workspace"
|
||||
:style="{
|
||||
transform: `scale(${zoomScale})`,
|
||||
width: workspaceSize.width + 'px',
|
||||
height: workspaceSize.height + 'px',
|
||||
}"
|
||||
>
|
||||
<template v-if="!$slots.tile">
|
||||
<v-workspace-tile
|
||||
v-for="tile in tiles"
|
||||
:key="tile.id"
|
||||
v-bind="tile"
|
||||
:edit-mode="editMode"
|
||||
:resizable="resizable"
|
||||
@preview="$emit('preview', tile)"
|
||||
@edit="$emit('edit', tile)"
|
||||
@update="$emit('update', { edits: $event, id: tile.id })"
|
||||
@move="$emit('move', tile.id)"
|
||||
@delete="$emit('delete', tile.id)"
|
||||
@duplicate="$emit('duplicate', tile)"
|
||||
>
|
||||
<slot :tile="tile"></slot>
|
||||
</v-workspace-tile>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="tile in tiles" :key="tile.id">
|
||||
<slot name="tile" :tile="tile"></slot>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import { useElementSize } from '@/composables/use-element-size';
|
||||
import { AppTile } from './v-workspace-tile.vue';
|
||||
import { cssVar } from '@directus/shared/utils/browser';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tiles: AppTile[];
|
||||
editMode?: boolean;
|
||||
zoomToFit?: boolean;
|
||||
resizable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
editMode: false,
|
||||
zoomToFit: false,
|
||||
resizable: true,
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits(['update', 'move', 'delete', 'duplicate', 'edit', 'preview']);
|
||||
|
||||
const mainElement = inject('main-element', ref<Element>());
|
||||
const mainElementSize = useElementSize(mainElement);
|
||||
|
||||
const paddingSize = computed(() => Number(cssVar('--content-padding', mainElement.value)?.slice(0, -2) || 0));
|
||||
|
||||
const workspaceSize = computed(() => {
|
||||
const furthestTileX = props.tiles.reduce(
|
||||
(aggr, tile) => {
|
||||
if (tile.x! > aggr.x!) {
|
||||
aggr.x = tile.x!;
|
||||
aggr.width = tile.width!;
|
||||
}
|
||||
|
||||
return aggr;
|
||||
},
|
||||
{ x: 0, width: 0 }
|
||||
);
|
||||
|
||||
const furthestPanelY = props.tiles.reduce(
|
||||
(aggr, tile) => {
|
||||
if (tile.y! > aggr.y!) {
|
||||
aggr.y = tile.y!;
|
||||
aggr.height = tile.height!;
|
||||
}
|
||||
|
||||
return aggr;
|
||||
},
|
||||
{ y: 0, height: 0 }
|
||||
);
|
||||
|
||||
if (props.editMode === true) {
|
||||
return {
|
||||
width: (furthestTileX.x! + furthestTileX.width! + 25) * 20,
|
||||
height: (furthestPanelY.y! + furthestPanelY.height! + 25) * 20,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
width: (furthestTileX.x! + furthestTileX.width! - 1) * 20,
|
||||
height: (furthestPanelY.y! + furthestPanelY.height! - 1) * 20,
|
||||
};
|
||||
});
|
||||
|
||||
const zoomScale = computed(() => {
|
||||
if (props.zoomToFit === false) return 1;
|
||||
|
||||
const { width, height } = mainElementSize;
|
||||
|
||||
const scaleWidth: number = (width.value - paddingSize.value * 2) / workspaceSize.value.width;
|
||||
const scaleHeight: number = (height.value - 114 - paddingSize.value * 2) / workspaceSize.value.height;
|
||||
|
||||
return Math.min(scaleWidth, scaleHeight);
|
||||
});
|
||||
|
||||
const workspaceBoxSize = computed(() => {
|
||||
return {
|
||||
width: workspaceSize.value.width * zoomScale.value + paddingSize.value * 2,
|
||||
height: workspaceSize.value.height * zoomScale.value + paddingSize.value * 2,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-workspace {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
position: absolute;
|
||||
left: var(--content-padding);
|
||||
display: grid;
|
||||
grid-template-rows: repeat(auto-fill, 20px);
|
||||
grid-template-columns: repeat(auto-fill, 20px);
|
||||
min-width: calc(100%);
|
||||
min-height: calc(100% - 120px);
|
||||
transform: scale(1);
|
||||
transform-origin: top left;
|
||||
|
||||
/* This causes the header bar to "unhinge" on the left edge :C */
|
||||
|
||||
/* transition: transform var(--slow) var(--transition); */
|
||||
}
|
||||
|
||||
.workspace > * {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.workspace::before {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
display: block;
|
||||
width: calc(100% + 8px);
|
||||
height: calc(100% + 8px);
|
||||
background-image: radial-gradient(var(--border-normal) 10%, transparent 10%);
|
||||
background-position: -6px -6px;
|
||||
background-size: 20px 20px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--slow) var(--transition);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.v-workspace.editing .workspace::before {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user