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:
Nitwel
2022-09-01 22:07:31 +02:00
committed by GitHub
parent 38fb314950
commit 5fe28db539
282 changed files with 17644 additions and 6081 deletions

View File

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

View File

@@ -1,4 +0,0 @@
import TransitionBounce from './transition-bounce.vue';
export { TransitionBounce };
export default TransitionBounce;

View File

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

View File

@@ -1,4 +0,0 @@
import TransitionDialog from './transition-dialog.vue';
export { TransitionDialog };
export default TransitionDialog;

View File

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

View File

@@ -1,4 +0,0 @@
import TransitionExpand from './transition-expand.vue';
export { TransitionExpand };
export default TransitionExpand;

View File

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

View File

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

View File

@@ -1,4 +0,0 @@
import TransitionExpand from './expand';
export { TransitionExpand };
export default { TransitionExpand };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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']);

View File

@@ -40,10 +40,10 @@ interface Props {
}
const props = withDefaults(defineProps<Props>(), {
field: undefined,
disabledFields: () => [],
includeFunctions: false,
includeRelations: true,
field: undefined,
});
defineEmits(['select-field']);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
export type Option = {
value: string | number | null;
icon?: string;
text?: string;
disabled?: boolean;
children?: Option[];
divider?: boolean;
selectable?: boolean;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = '&nbsp;';
} else if (charCodeBefore !== 160) {
addSpaceBefore = '&nbsp;';
}
if (charCodeAfter === 32) {
replaceSpaceAfter = ' ';
addSpaceAfter = '&nbsp;';
} else if (charCodeAfter !== 160) {
addSpaceAfter = '&nbsp;';
}
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), '&nbsp;')) {
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>

View File

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

View File

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

View File

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

View File

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

View File

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