Move updated components to app (#15374)

* Move updated components to app

* Make sure storybook is alive
This commit is contained in:
Rijk van Zanten
2022-09-02 14:42:00 -04:00
committed by GitHub
parent 0de05b40a7
commit 4eae2de686
252 changed files with 2060 additions and 2475 deletions

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<div class=\\"v-avatar\\" data-v-83da42c0=\\"\\">Slot Content</div>"`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<div class=\\"v-badge\\" data-v-42b4e3ac=\\"\\"><span class=\\"badge\\" data-v-42b4e3ac=\\"\\"><span data-v-42b4e3ac=\\"\\"></span></span>Slot Content</div>"`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<span class=\\"v-breadcrumb\\" data-v-61eab8ca=\\"\\"><span class=\\"section\\" data-v-61eab8ca=\\"\\"><!--v-if--><router-link-stub to=\\"hi\\" class=\\"section-link\\" data-v-61eab8ca=\\"\\"></router-link-stub></span><span class=\\"section\\" data-v-61eab8ca=\\"\\"><v-icon-stub name=\\"chevron_right\\" small=\\"\\" data-v-61eab8ca=\\"\\"></v-icon-stub><router-link-stub to=\\"wow\\" class=\\"section-link\\" data-v-61eab8ca=\\"\\"></router-link-stub></span><span class=\\"section disabled\\" data-v-61eab8ca=\\"\\"><v-icon-stub name=\\"chevron_right\\" small=\\"\\" data-v-61eab8ca=\\"\\"></v-icon-stub><span class=\\"section-link\\" data-v-61eab8ca=\\"\\"><!--v-if--> Disabled</span></span></span>"`;

View File

@@ -0,0 +1,9 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-button\\" data-v-290ca00e=\\"\\"><button class=\\"button align-center normal\\" type=\\"button\\" disabled=\\"false\\" data-v-290ca00e=\\"\\"><span class=\\"content\\" data-v-290ca00e=\\"\\"></span>
<div class=\\"spinner\\" data-v-290ca00e=\\"\\">
<!--v-if-->
</div>
</button></div>"
`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<div class=\\"v-card-actions\\" data-v-f103ec72=\\"\\">Slot Content</div>"`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<div class=\\"v-card-subtitle\\" data-v-61aef43e=\\"\\">Slot Content</div>"`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<div class=\\"v-card-text\\" data-v-028451d3=\\"\\">Slot Content</div>"`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<div class=\\"v-card-title type-label\\" data-v-0f314222=\\"\\">Slot Content</div>"`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<div class=\\"v-card\\" data-v-1e90e497=\\"\\">Slot Content</div>"`;

View File

@@ -0,0 +1,9 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<button class=\\"v-checkbox\\" type=\\"button\\" role=\\"checkbox\\" aria-pressed=\\"false\\" disabled=\\"false\\" data-v-d0be376c=\\"\\">
<!--v-if-->
<v-icon-stub class=\\"checkbox\\" name=\\"indeterminate_check_box\\" disabled=\\"false\\" data-v-d0be376c=\\"\\"></v-icon-stub><span class=\\"label type-text\\" data-v-d0be376c=\\"\\"><div>Hi</div></span>
<!--v-if-->
</button>"
`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<span class=\\"v-chip label\\" data-v-fbac005a=\\"\\"><span class=\\"chip-content\\" data-v-fbac005a=\\"\\"><!--v-if--></span></span>"`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-divider inlineTitle\\" data-v-cb64903c=\\"\\"><span class=\\"wrapper\\" data-v-cb64903c=\\"\\"><span class=\\"type-text\\" data-v-cb64903c=\\"\\">Default slot</span></span>
<hr role=\\"separator\\" aria-orientation=\\"horizontal\\" data-v-cb64903c=\\"\\">
</div>"
`;

View File

@@ -0,0 +1,39 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-fancy-select\\" data-v-6904cf07=\\"\\">
<transition-group-stub data-v-6904cf07=\\"\\">
<div class=\\"v-fancy-select-option\\" data-v-6904cf07=\\"\\">
<div class=\\"icon\\" data-v-6904cf07=\\"\\">
<v-icon-stub name=\\"person\\" data-v-6904cf07=\\"\\"></v-icon-stub>
</div>
<div class=\\"content\\" data-v-6904cf07=\\"\\">
<div class=\\"text\\" data-v-6904cf07=\\"\\">Person</div>
<div class=\\"description\\" data-v-6904cf07=\\"\\"></div>
</div>
<!--v-if-->
</div>
<div class=\\"v-fancy-select-option\\" style=\\"--index: 1;\\" data-v-6904cf07=\\"\\">
<div class=\\"icon\\" data-v-6904cf07=\\"\\">
<v-icon-stub name=\\"directions_car\\" data-v-6904cf07=\\"\\"></v-icon-stub>
</div>
<div class=\\"content\\" data-v-6904cf07=\\"\\">
<div class=\\"text\\" data-v-6904cf07=\\"\\">Car</div>
<div class=\\"description\\" data-v-6904cf07=\\"\\"></div>
</div>
<!--v-if-->
</div>
<v-divider-stub data-v-6904cf07=\\"\\"></v-divider-stub>
<div class=\\"v-fancy-select-option\\" style=\\"--index: 3;\\" data-v-6904cf07=\\"\\">
<div class=\\"icon\\" data-v-6904cf07=\\"\\">
<v-icon-stub name=\\"home\\" data-v-6904cf07=\\"\\"></v-icon-stub>
</div>
<div class=\\"content\\" data-v-6904cf07=\\"\\">
<div class=\\"text\\" data-v-6904cf07=\\"\\">Home</div>
<div class=\\"description\\" data-v-6904cf07=\\"\\">A home is a nice place</div>
</div>
<!--v-if-->
</div>
</transition-group-stub>
</div>"
`;

View File

@@ -0,0 +1,10 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"Th
<mark class=\\"highlight\\" data-v-aec7f886=\\"\\">is</mark>
<mark class=\\"highlight\\" data-v-aec7f886=\\"\\">is</mark>
a nice
<mark class=\\"highlight\\" data-v-aec7f886=\\"\\">text</mark>"
`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<div>hidden</div>"`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"icon\\" data-v-f5b5f14e=\\"\\">
<v-icon-stub name=\\"insert_drive_file\\" data-v-f5b5f14e=\\"\\"></v-icon-stub><span class=\\"label\\" data-v-f5b5f14e=\\"\\">png</span>
</div>"
`;

View File

@@ -0,0 +1,11 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-info info\\" data-v-29a9cf35=\\"\\">
<div class=\\"icon\\" data-v-29a9cf35=\\"\\">
<v-icon-stub large=\\"\\" name=\\"box\\" outline=\\"\\" data-v-29a9cf35=\\"\\"></v-icon-stub>
</div>
<h2 class=\\"title type-title\\" data-v-29a9cf35=\\"\\">This is an info</h2>
<p class=\\"content\\" data-v-29a9cf35=\\"\\">content</p>
</div>"
`;

View File

@@ -0,0 +1,15 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-input full-width\\" data-v-2a853453=\\"\\">
<!--v-if-->
<div class=\\"input\\" data-v-2a853453=\\"\\">
<!--v-if-->
<!--v-if--><input autocomplete=\\"off\\" type=\\"text\\" step=\\"1\\" data-v-2a853453=\\"\\">
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
<!--v-if-->
</div>"
`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-notice normal\\" data-v-eb1a46c2=\\"\\">
<v-icon-stub name=\\"info\\" left=\\"\\" data-v-eb1a46c2=\\"\\"></v-icon-stub>
</div>"
`;

View File

@@ -0,0 +1,8 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-overlay active has-click\\" data-v-520714ce=\\"\\">
<div class=\\"overlay\\" data-v-520714ce=\\"\\"></div>
<div class=\\"content\\" data-v-520714ce=\\"\\">Slot Content</div>
</div>"
`;

View File

@@ -0,0 +1,18 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-pagination\\" data-v-57411ce1=\\"\\">
<v-button-stub class=\\"previous\\" disabled=\\"false\\" secondary=\\"\\" icon=\\"\\" small=\\"\\" data-v-57411ce1=\\"\\"></v-button-stub>
<!--v-if-->
<!--v-if-->
<v-button-stub class=\\"page\\" secondary=\\"\\" small=\\"\\" disabled=\\"false\\" data-v-57411ce1=\\"\\"></v-button-stub>
<v-button-stub class=\\"page\\" secondary=\\"\\" small=\\"\\" disabled=\\"false\\" data-v-57411ce1=\\"\\"></v-button-stub>
<v-button-stub class=\\"page\\" secondary=\\"\\" small=\\"\\" disabled=\\"false\\" data-v-57411ce1=\\"\\"></v-button-stub>
<v-button-stub class=\\"active page\\" secondary=\\"\\" small=\\"\\" disabled=\\"false\\" data-v-57411ce1=\\"\\"></v-button-stub>
<v-button-stub class=\\"page\\" secondary=\\"\\" small=\\"\\" disabled=\\"false\\" data-v-57411ce1=\\"\\"></v-button-stub>
<v-button-stub class=\\"page\\" secondary=\\"\\" small=\\"\\" disabled=\\"false\\" data-v-57411ce1=\\"\\"></v-button-stub>
<!--v-if-->
<!--v-if-->
<v-button-stub class=\\"next\\" disabled=\\"false\\" secondary=\\"\\" icon=\\"\\" small=\\"\\" data-v-57411ce1=\\"\\"></v-button-stub>
</div>"
`;

View File

@@ -0,0 +1,8 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-progress-circular\\" data-v-5e88a2c8=\\"\\"><svg class=\\"circle\\" viewBox=\\"0 0 30 30\\" data-v-5e88a2c8=\\"\\">
<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)\\" data-v-5e88a2c8=\\"\\"></path>
<path class=\\"circle-path\\" style=\\"stroke-dasharray: 0, 78.5;\\" 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)\\" data-v-5e88a2c8=\\"\\"></path>
</svg>Slot Content</div>"
`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-progress-linear danger\\" data-v-7eeceee4=\\"\\">
<div class=\\"inner\\" style=\\"width: 0%;\\" data-v-7eeceee4=\\"\\"></div>Slot Content
</div>"
`;

View File

@@ -0,0 +1,7 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<button class=\\"v-radio\\" type=\\"button\\" aria-pressed=\\"false\\" disabled=\\"false\\" data-v-ea1b1ff8=\\"\\">
<v-icon-stub name=\\"radio_button_unchecked\\" data-v-ea1b1ff8=\\"\\"></v-icon-stub><span class=\\"label type-text\\" data-v-ea1b1ff8=\\"\\">My Label</span>
</button>"
`;

View File

@@ -0,0 +1,8 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"list-item-icon v-skeleton-loader\\" data-v-499e8f48=\\"\\">
<div class=\\"icon\\" data-v-499e8f48=\\"\\"></div>
<div class=\\"text\\" data-v-499e8f48=\\"\\"></div>
</div>"
`;

View File

@@ -0,0 +1,13 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-slider\\" data-v-18099228=\\"\\">
<!--v-if-->
<div class=\\"slider\\" data-v-18099228=\\"\\"><input type=\\"range\\" max=\\"100\\" min=\\"0\\" step=\\"1\\" data-v-18099228=\\"\\">
<div class=\\"fill\\" data-v-18099228=\\"\\"></div>
<!--v-if-->
<!--v-if-->
</div>
<!--v-if-->
</div>"
`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<div class=\\"v-tabs horizontal\\" data-v-2bea93f6=\\"\\">Some value</div>"`;

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<div class=\\"v-text-overflow\\" data-v-6410b7aa=\\"\\">My text</div>"`;

View File

@@ -0,0 +1,8 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-textarea full-width has-content\\" data-v-f9fe277e=\\"\\">
<!--v-if--><textarea data-v-f9fe277e=\\"\\"></textarea>
<!--v-if-->
</div>"
`;

View File

@@ -0,0 +1,18 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-workspace-tile draggable br-tl br-tr br-br br-bl\\" style=\\"--pos-x: 1; --pos-y: 1; --width: 10; --height: 10;\\" data-move=\\"\\" data-v-5fcd0cad=\\"\\">
<div class=\\"header\\" data-v-5fcd0cad=\\"\\">
<v-icon-stub class=\\"icon\\" style=\\"--v-icon-color: var(--primary);\\" name=\\"space_dashboard\\" small=\\"\\" data-v-5fcd0cad=\\"\\"></v-icon-stub>
<v-text-overflow-stub class=\\"name\\" text=\\"\\" data-v-5fcd0cad=\\"\\"></v-text-overflow-stub>
<div class=\\"spacer\\" data-v-5fcd0cad=\\"\\"></div>
<!--v-if-->
</div>
<!--v-if-->
<div class=\\"resize-details\\" data-v-5fcd0cad=\\"\\"> (0:0) 10×10</div>
<!--v-if-->
<div class=\\"tile-content has-header\\" data-v-5fcd0cad=\\"\\">
<!--v-if-->
</div>
</div>"
`;

View File

@@ -0,0 +1,10 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `
"<div class=\\"v-workspace\\" style=\\"width: 480px; height: 280px;\\" data-v-9d2ce9c4=\\"\\">
<div class=\\"workspace\\" style=\\"transform: scale(1); width: 480px; height: 280px;\\" data-v-9d2ce9c4=\\"\\">
<v-workspace-tile-stub id=\\"1\\" x=\\"1\\" y=\\"1\\" width=\\"10\\" height=\\"10\\" name=\\"My Tile 1\\" edit-mode=\\"false\\" resizable=\\"true\\" data-v-9d2ce9c4=\\"\\"></v-workspace-tile-stub>
<v-workspace-tile-stub id=\\"2\\" x=\\"15\\" y=\\"5\\" width=\\"10\\" height=\\"10\\" name=\\"My Tile 2\\" edit-mode=\\"false\\" resizable=\\"true\\" data-v-9d2ce9c4=\\"\\"></v-workspace-tile-stub>
</div>
</div>"
`;

View File

@@ -8,69 +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 '@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 TransitionBounce from './transition/bounce.vue';
import TransitionDialog from './transition/dialog.vue';
import TransitionExpand from './transition/expand.vue';
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 VDetail from './v-detail.vue';
import VDialog from './v-dialog.vue';
import VDivider from '@directus/components/v-divider.vue';
import VDivider from './v-divider.vue';
import VDrawer from './v-drawer.vue';
import VError from './v-error.vue';
import VFancySelect from '@directus/components/v-fancy-select.vue';
import VFancySelect from './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 '@directus/components/v-hover.vue';
import VHighlight from '@directus/components/v-highlight.vue';
import VIcon from '@directus/components/v-icon/v-icon.vue';
import VHover from './v-hover.vue';
import VHighlight from './v-highlight.vue';
import VIcon from './v-icon/v-icon.vue';
import VImage from './v-image.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 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 VTable from './v-table/v-table.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 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 VUpload from './v-upload.vue';
import VDatePicker from './v-date-picker.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';
import VEmojiPicker from './v-emoji-picker.vue';
import VWorkspace from './v-workspace.vue';
import VWorkspaceTile from './v-workspace-tile.vue';
export function registerComponents(app: App): void {
app.component('VAvatar', VAvatar);

View File

@@ -0,0 +1,71 @@
<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

@@ -0,0 +1,39 @@
<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

@@ -0,0 +1,119 @@
import { capitalize } from 'lodash';
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${capitalize(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

@@ -0,0 +1,33 @@
<template>
<transition name="expand-transition" mode="in-out" v-on="methods">
<slot />
</transition>
</template>
<script setup lang="ts">
import ExpandMethods from './expand-methods';
interface Props {
/** Expand on the horizontal instead vertical axis */
xAxis?: boolean;
/** Add a custom class to the element that is expanded */
expandedParentClass?: string;
}
const props = withDefaults(defineProps<Props>(), {
xAxis: false,
expandedParentClass: '',
});
const emit = defineEmits([
'beforeEnter',
'enter',
'afterEnter',
'enterCancelled',
'leave',
'afterLeave',
'leaveCancelled',
]);
const methods = ExpandMethods(props.expandedParentClass, props.xAxis, emit);
</script>

View File

@@ -0,0 +1,19 @@
import TransitionBounce from './bounce.vue';
document.body.classList.add('light');
export default {
title: 'Components/Transition/TransitionBounce',
component: TransitionBounce,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template:
'<v-hover v-slot="{ hover }">Hover me!<transition-bounce v-bind="args" v-on="args"><div v-if="hover" style="background-color: var(--background-normal); height: 200px; width: 400px; display: flex; justify-content: center; align-items: center">This is only shown on hover.</div></transition-bounce></v-hover>',
});
export const Primary = Template.bind({});
Primary.args = {};

View File

@@ -0,0 +1,19 @@
import TransitionDialog from './dialog.vue';
document.body.classList.add('light');
export default {
title: 'Components/Transition/TransitionDialog',
component: TransitionDialog,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template:
'<v-hover v-slot="{ hover }">Hover me!<transition-dialog v-bind="args" v-on="args"><div v-if="hover" style="background-color: var(--background-normal); height: 200px; width: 400px; display: flex; justify-content: center; align-items: center">This is only shown on hover.</div></transition-dialog></v-hover>',
});
export const Primary = Template.bind({});
Primary.args = {};

View File

@@ -0,0 +1,19 @@
import TransitionExpand from './expand.vue';
document.body.classList.add('light');
export default {
title: 'Components/Transition/TransitionExpand',
component: TransitionExpand,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template:
'<v-hover v-slot="{ hover }">Hover me!<transition-expand v-bind="args" v-on="args"><div v-if="hover" style="background-color: var(--background-normal); height: 200px; width: 400px; display: flex; justify-content: center; align-items: center">This is only shown on hover.</div></transition-expand></v-hover>',
});
export const Primary = Template.bind({});
Primary.args = {};

View File

@@ -0,0 +1,18 @@
import VAvatar from './v-avatar.vue';
document.body.classList.add('light');
export default {
title: 'Components/VAvatar',
component: VAvatar,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-avatar v-bind="args" v-on="args" ><v-icon name="person" /></v-avatar>',
});
export const Primary = Template.bind({});
Primary.args = {};

View File

@@ -0,0 +1,36 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VAvatar from './v-avatar.vue';
test('Mount component', () => {
expect(VAvatar).toBeTruthy();
const wrapper = mount(VAvatar, {
slots: {
default: 'Slot Content',
},
});
expect(wrapper.html()).toMatchSnapshot();
});
test('tile prop', () => {
const wrapper = mount(VAvatar, {
props: {
tile: true,
},
});
expect(wrapper.classes()).toContain('tile');
});
test('small prop', () => {
const wrapper = mount(VAvatar, {
props: {
small: true,
},
});
expect(wrapper.classes()).toContain('small');
});

View File

@@ -0,0 +1,80 @@
<template>
<div class="v-avatar" :class="[{ tile }, sizeClass]">
<slot />
</div>
</template>
<script setup lang="ts">
import { useSizeClass } from '@directus/shared/composables';
interface Props {
/** Render as a tile (square) */
tile?: boolean;
/** Renders a smaller avatar */
xSmall?: boolean;
/** Renders a small avatar */
small?: boolean;
/** Renders a large avatar */
large?: boolean;
/** Renders a larger avatar */
xLarge?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
tile: false,
});
const sizeClass = useSizeClass(props);
</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

@@ -0,0 +1,20 @@
import VBadge from './v-badge.vue';
document.body.classList.add('light');
export default {
title: 'Components/VBadge',
component: VBadge,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-badge v-bind="args" v-on="args" ><v-icon name="notifications_active" /></v-badge>',
});
export const Primary = Template.bind({});
Primary.args = {
value: '+9',
};

View File

@@ -0,0 +1,60 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VBadge from './v-badge.vue';
import { GlobalMountOptions } from '@vue/test-utils/dist/types';
const global: GlobalMountOptions = {
stubs: ['v-icon'],
};
test('Mount component', () => {
expect(VBadge).toBeTruthy();
const wrapper = mount(VBadge, {
slots: {
default: 'Slot Content',
},
global,
});
expect(wrapper.html()).toMatchSnapshot();
});
test('dot and bordered prop', () => {
const wrapper = mount(VBadge, {
props: {
dot: true,
bordered: true,
},
global,
});
expect(wrapper.classes()).toContain('dot');
expect(wrapper.classes()).toContain('bordered');
expect(wrapper.get('span').classes()).toContain('bordered');
expect(wrapper.get('span').classes()).toContain('dot');
});
test('icon prop', () => {
const wrapper = mount(VBadge, {
props: {
icon: 'close',
},
global,
});
expect(wrapper.get('v-icon-stub').attributes().name).toBe('close');
});
test('value prop', () => {
const wrapper = mount(VBadge, {
props: {
value: 'My Badge',
},
global,
});
expect(wrapper.find('span.badge span').exists()).toBeTruthy();
expect(wrapper.find('span.badge span').text()).toBe('My Badge');
});

View File

@@ -0,0 +1,109 @@
<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" x-small />
<span v-else>{{ value }}</span>
</span>
<slot />
</div>
</template>
<script setup lang="ts">
interface Props {
/** The value that will be displayed inside the badge Only 2 characters allowed) */
value?: string | number | boolean | null;
/** Only will show a small dot without any content */
dot?: boolean;
/** Aligns the badge on the left side */
left?: boolean;
/** Aligns the badge on the bottom side */
bottom?: boolean;
/** Shows an icon instead of text */
icon?: string | null;
/** Shows a border around the badge */
bordered?: boolean;
/** Hide the badge */
disabled?: boolean;
}
withDefaults(defineProps<Props>(), {
value: null,
dot: false,
left: false,
bottom: false,
icon: null,
bordered: false,
disabled: 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

@@ -0,0 +1,36 @@
import VBreadcrumb from './v-breadcrumb.vue';
document.body.classList.add('light');
export default {
title: 'Components/VBreadcrumb',
component: VBreadcrumb,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-breadcrumb v-bind="args" v-on="args" />',
});
export const Primary = Template.bind({});
Primary.args = {
items: [
{
to: '/',
name: 'Home',
},
{
to: '/settings',
name: 'settings',
icon: 'settings',
},
{
to: '/settings/profile',
name: 'Profile',
icon: 'person',
disabled: true,
},
],
};

View File

@@ -0,0 +1,37 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VBreadcrumb from './v-breadcrumb.vue';
import { GlobalMountOptions } from '@vue/test-utils/dist/types';
const global: GlobalMountOptions = {
stubs: ['v-icon', 'router-link'],
};
test('Mount component', () => {
expect(VBreadcrumb).toBeTruthy();
const wrapper = mount(VBreadcrumb, {
props: {
items: [
{
to: 'hi',
name: 'Hi',
},
{
to: 'wow',
name: 'Wow',
icon: 'close',
},
{
to: 'disabled',
name: 'Disabled',
disabled: true,
},
],
},
global,
});
expect(wrapper.html()).toMatchSnapshot();
});

View File

@@ -0,0 +1,89 @@
<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 setup lang="ts">
interface Breadcrumb {
to: string;
name: string;
disabled?: boolean;
icon?: string;
}
interface Props {
/** An array of objects which information about each section */
items?: Breadcrumb[];
}
withDefaults(defineProps<Props>(), {
items: () => [],
});
</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

@@ -0,0 +1,22 @@
import VButton from './v-button.vue';
document.body.classList.add('light');
export default {
title: 'Components/VButton',
component: VButton,
argTypes: {
click: { action: 'clicked' },
},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-button v-bind="args" v-on="args">My Button{{args.onClick}}</v-button>',
});
export const Primary = Template.bind({});
Primary.args = {};

View File

@@ -0,0 +1,60 @@
import { test, expect, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import VButton from './v-button.vue';
import { h } from 'vue';
import { generateRouter } from '@/__utils__/router';
import { Router } from 'vue-router';
import { GlobalMountOptions } from '@vue/test-utils/dist/types';
import { Focus } from '@/__utils__/focus';
let router: Router;
let global: GlobalMountOptions;
beforeEach(async () => {
router = generateRouter([
{
path: '/',
component: h('div', VButton),
},
{
path: '/test',
component: h('div', 'empty'),
},
]);
router.push('/');
await router.isReady();
global = {
stubs: ['v-progress-circular'],
directives: {
focus: Focus,
},
plugins: [router],
};
});
test('Mount component', () => {
expect(VButton).toBeTruthy();
const wrapper = mount(VButton, {
global,
});
expect(wrapper.html()).toMatchSnapshot();
});
// test('Click on link', async () => {
// const wrapper = mount(VButton, {
// props: {
// to: '/test'
// },
// global
// });
// await wrapper.get('a').trigger('click')
// expect(router.currentRoute.value.path).toBe('/test')
// });

View File

@@ -0,0 +1,443 @@
<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 setup lang="ts">
import { computed } from 'vue';
import { RouteLocation, useRoute, useLink } from 'vue-router';
import { useSizeClass, useGroupable } from '@directus/shared/composables';
import { isEqual, isNil } from 'lodash';
interface Props {
/** Automatically focuses on the button */
autofocus?: boolean;
/** Styling of the button */
kind?: 'normal' | 'info' | 'success' | 'warning' | 'danger';
/** Stretches the button to it's maximal width */
fullWidth?: boolean;
/** Enable rounded corners */
rounded?: boolean;
/** No background */
outlined?: boolean;
/** Remove padding / min-width. Meant to be used with just an icon as content */
icon?: boolean;
/** Element type to be used */
type?: string;
/** Disables the button */
disabled?: boolean;
/** Show a circular progress bar */
loading?: boolean;
/** To what internal link the button should direct */
to?: string | RouteLocation;
/** To what external link the button should direct */
href?: string;
/** Renders the button highlighted */
active?: boolean;
/** If the button should be highlighted if it matches the current internal link */
exact?: boolean;
/** Renders the button highlighted when it matches the given query */
query?: boolean;
/** Renders the button in a less important styling */
secondary?: boolean;
/** @deprecated The `kind` prop should be used instead */
warning?: boolean;
/** @deprecated The `kind` prop should be used instead */
danger?: boolean;
/** What value to use for the button when rendered inside a group of buttons */
value?: number | string;
/** Renders the button with a dashed border */
dashed?: boolean;
/** Renders the button as a square */
tile?: boolean;
/** Align the button to a given side */
align?: 'left' | 'center' | 'right';
/** Add the download attribute (used in combo with `href`) */
download?: string;
/** Renders a smaller button */
xSmall?: boolean;
/** Renders a small button */
small?: boolean;
/** Renders a large button */
large?: boolean;
/** Renders a larger button */
xLarge?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
autofocus: false,
kind: 'normal',
fullWidth: false,
rounded: false,
outlined: false,
icon: false,
type: 'button',
disabled: false,
loading: false,
to: '',
href: undefined,
active: undefined,
exact: false,
query: false,
secondary: false,
warning: false,
danger: false,
value: undefined,
dashed: false,
tile: false,
align: 'center',
download: undefined,
});
const emit = defineEmits(['click']);
const route = useRoute();
const { route: linkRoute, isActive, isExactActive } = useLink(props);
const sizeClass = useSizeClass(props);
const component = computed(() => {
if (props.disabled) return 'button';
if (!isNil(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;
});
async 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

@@ -0,0 +1,16 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VCardActions from './v-card-actions.vue';
test('Mount component', () => {
expect(VCardActions).toBeTruthy();
const wrapper = mount(VCardActions, {
slots: {
default: 'Slot Content',
},
});
expect(wrapper.html()).toMatchSnapshot();
});

View File

@@ -0,0 +1,15 @@
<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

@@ -0,0 +1,16 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VCardSubtitle from './v-card-subtitle.vue';
test('Mount component', () => {
expect(VCardSubtitle).toBeTruthy();
const wrapper = mount(VCardSubtitle, {
slots: {
default: 'Slot Content',
},
});
expect(wrapper.html()).toMatchSnapshot();
});

View File

@@ -0,0 +1,11 @@
<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

@@ -0,0 +1,16 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VCardText from './v-card-text.vue';
test('Mount component', () => {
expect(VCardText).toBeTruthy();
const wrapper = mount(VCardText, {
slots: {
default: 'Slot Content',
},
});
expect(wrapper.html()).toMatchSnapshot();
});

View File

@@ -0,0 +1,11 @@
<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

@@ -0,0 +1,16 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VCardTitle from './v-card-title.vue';
test('Mount component', () => {
expect(VCardTitle).toBeTruthy();
const wrapper = mount(VCardTitle, {
slots: {
default: 'Slot Content',
},
});
expect(wrapper.html()).toMatchSnapshot();
});

View File

@@ -0,0 +1,15 @@
<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

@@ -0,0 +1,28 @@
import VCard from './v-card.vue';
document.body.classList.add('light');
export default {
title: 'Components/VCard',
component: VCard,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template: `<v-card v-bind="args" v-on="args" >
<v-card-title>Want a cake?</v-card-title>
<v-card-text>
If you want a cake, you have to click on accept.
And the cake is definitely not a lie.
</v-card-text>
<v-card-actions>
<v-button secondary>Decline</v-button>
<v-button>Accept</v-button>
</v-card-actions>
</v-card>`,
});
export const Primary = Template.bind({});
Primary.args = {};

View File

@@ -0,0 +1,30 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VCard from './v-card.vue';
test('Mount component', () => {
expect(VCard).toBeTruthy();
const wrapper = mount(VCard, {
slots: {
default: 'Slot Content',
},
});
expect(wrapper.html()).toMatchSnapshot();
});
test('style props', () => {
const props = ['disabled', 'tile'];
for (const prop of props) {
const wrapper = mount(VCard, {
props: {
[prop]: true,
},
});
expect(wrapper.classes()).toContain(prop);
}
});

View File

@@ -0,0 +1,70 @@
<template>
<div class="v-card" :class="{ disabled, tile }">
<slot />
</div>
</template>
<script setup lang="ts">
interface Props {
/** Disables any interactions with the card */
disabled?: boolean;
/** Render without rounded corners */
tile?: boolean;
}
withDefaults(defineProps<Props>(), {
disabled: false,
tile: false,
});
</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

@@ -0,0 +1,44 @@
import VCheckboxTree from './v-checkbox-tree/v-checkbox-tree.vue';
document.body.classList.add('light');
export default {
title: 'Components/VCheckboxTree',
component: VCheckboxTree,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-checkbox-tree v-bind="args" v-on="args" />',
});
export const Primary = Template.bind({});
Primary.args = {
choices: [
{
text: 'Choice 1',
value: 'choice-1',
children: [
{
text: 'Choice 1.1',
value: 'choice-1.1',
},
{
text: 'Choice 1.2',
value: 'choice-1.2',
},
],
},
{
text: 'Choice 2',
value: 'choice-2',
},
{
text: 'Choice 3',
value: 'choice-3',
},
],
modelValue: ['choice-1.1', 'choice-3'],
};

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<ul class=\\"v-list\\" data-v-6f43a325=\\"\\"></ul>"`;

View File

@@ -0,0 +1,57 @@
import { computed, Ref } from 'vue';
export function useVisibleChildren(
search: Ref<string | null>,
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 | null>,
value: Ref<string | number>
) {
const visibleChildrenValues = computed(() => {
let options = children.value || [];
const _search = search.value;
if (_search) {
options = options.filter(
(child) =>
child[itemText.value].toLowerCase().includes(_search.toLowerCase()) ||
childrenHaveSearchMatch(child[itemChildren.value])
);
}
if (showSelectionOnly.value) {
options = options.filter(
(child) =>
modelValue.value.includes(child[itemValue.value]) ||
childrenHaveValueMatch(child[itemChildren.value]) ||
(parentValue.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

@@ -0,0 +1,431 @@
<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">
export default {
name: 'VCheckboxTreeCheckbox',
};
</script>
<script setup lang="ts">
import { computed, toRefs } from 'vue';
import { difference } from 'lodash';
import { useVisibleChildren } from './use-visible-children';
type Delta = {
added?: (number | string)[];
removed?: (number | string)[];
};
interface Props {
text: string;
value: string | number;
valueCombining: 'all' | 'branch' | 'leaf' | 'indeterminate' | 'exclusive';
children?: Record<string, any>[];
modelValue?: (string | number)[];
checked?: boolean | null;
search?: string | null;
hidden?: boolean;
itemText?: string;
itemValue?: string;
itemChildren?: string;
disabled?: boolean;
showSelectionOnly?: boolean;
parentValue?: string | number | null;
}
const props = withDefaults(defineProps<Props>(), {
children: () => [],
modelValue: () => [],
checked: null,
search: null,
hidden: false,
itemText: 'text',
itemValue: 'value',
itemChildren: 'children',
disabled: false,
showSelectionOnly: false,
parentValue: null,
});
const emit = defineEmits(['update:modelValue']);
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;
});
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

@@ -0,0 +1,91 @@
import { test, expect, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import VCheckboxTree from './v-checkbox-tree.vue';
import VCheckboxTreeCheckbox from './v-checkbox-tree-checkbox.vue';
import VListItem from '../v-list-item.vue';
import VListItemIcon from '../v-list-item-icon.vue';
import VList from '../v-list.vue';
import VListGroup from '../v-list-group.vue';
import VCheckbox from '../v-checkbox.vue';
import { h } from 'vue';
import { GlobalMountOptions } from '@vue/test-utils/dist/types';
import { Router } from 'vue-router';
import { generateRouter } from '@/__utils__/router';
let router: Router;
let global: GlobalMountOptions;
beforeEach(async () => {
router = generateRouter([
{
path: '/',
component: h('div', 'test'),
},
{
path: '/test',
component: h('div', 'empty'),
},
]);
router.push('/');
await router.isReady();
global = {
components: {
VCheckboxTreeCheckbox,
VListItem,
VListItemIcon,
VListGroup,
VCheckbox,
VList,
},
stubs: ['v-highlight', 'v-icon'],
plugins: [router],
};
});
test('Mount component', () => {
expect(VCheckboxTree).toBeTruthy();
const wrapper = mount(VCheckboxTree, {
global,
});
expect(wrapper.html()).toMatchSnapshot();
});
// test('Choices prop', async () => {
// const wrapper = mount(VCheckboxTree, {
// props: {
// modelValue: ['p1', 'c1'],
// showSelectionOnly: true,
// choices: [
// {
// text: 'Parent 1',
// value: 'p1',
// },
// {
// text: 'Parent 2',
// value: 'p2',
// children: [
// {
// text: 'Child 1',
// value: 'c1',
// },
// ],
// },
// ],
// },
// global,
// });
// expect(wrapper.getComponent('.v-checkbox:nth-child(1)').classes()).toContain('checked');
// expect(wrapper.getComponent('.v-checkbox:nth-child(1)').props().label).toBe('Parent 1');
// expect(wrapper.getComponent('.v-checkbox:nth-child(2)').classes()).not.toContain('checked');
// await wrapper.get('.v-checkbox:nth-child(2)').trigger('click');
// const child1 = wrapper.get('.v-checkbox:nth-child(2)').find('.v-checkbox');
// expect(child1.exists()).toBeTruthy();
// expect(child1.props().label).toBe('Child 1');
// });

View File

@@ -0,0 +1,157 @@
<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">
export default {
name: 'VCheckboxTree',
};
</script>
<script setup lang="ts">
import { computed, ref, watch, toRefs } from 'vue';
import { useVisibleChildren } from './use-visible-children';
import VCheckboxTreeCheckbox from './v-checkbox-tree-checkbox.vue';
interface Props {
/** The choices that will be rendered as checkboxes */
choices?: Record<string, any>[];
/** Which choices should be shown as selected, depending on their value */
modelValue?: (string | number)[];
valueCombining?: 'all' | 'branch' | 'leaf' | 'indeterminate' | 'exclusive';
/** Will highlight every text that matches the given search */
search?: string | null;
/** Which key in choices is used to display the text */
itemText?: string;
/** Which key in choices is used to model the active state */
itemValue?: string;
/** Which key in choices is used to render children */
itemChildren?: string;
/** Disables any interaction */
disabled?: boolean;
/** Show only the selected choices */
showSelectionOnly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
choices: () => [],
modelValue: () => [],
valueCombining: 'all',
search: null,
itemText: 'text',
itemValue: 'value',
itemChildren: 'children',
disabled: false,
showSelectionOnly: false,
});
const emit = defineEmits(['update:modelValue', 'group-toggle']);
const value = computed({
get() {
return props.modelValue || [];
},
set(newValue: (string | number)[]) {
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: (string | number)[] = [];
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));
}
</script>

View File

@@ -0,0 +1,22 @@
import VCheckbox from './v-checkbox.vue';
document.body.classList.add('light');
export default {
title: 'Components/VCheckbox',
component: VCheckbox,
argTypes: {
modelValue: { action: 'updateModelValue' },
},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-checkbox v-bind="args" v-on="args">My Checkbox</v-checkbox>',
});
export const Primary = Template.bind({});
Primary.args = {
modelValue: true,
};

View File

@@ -0,0 +1,92 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VCheckbox from './v-checkbox.vue';
import { h } from 'vue';
import { GlobalMountOptions } from '@vue/test-utils/dist/types';
const global: GlobalMountOptions = {
stubs: ['v-icon'],
};
test('Mount component', () => {
expect(VCheckbox).toBeTruthy();
const wrapper = mount(VCheckbox, {
slots: {
default: h('div', 'Hi'),
},
global,
});
expect(wrapper.html()).toMatchSnapshot();
});
test('modelValue prop', async () => {
const wrapper = mount(VCheckbox, {
props: {
modelValue: true,
},
global,
});
expect(wrapper.attributes()['aria-pressed']).toBe('true');
await wrapper.get('.checkbox').trigger('click');
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([false]);
});
test('value prop', async () => {
const wrapper = mount(VCheckbox, {
props: {
value: 'test',
modelValue: ['test', 'test2'],
},
global,
});
expect(wrapper.attributes()['aria-pressed']).toBe('true');
await wrapper.get('.checkbox').trigger('click');
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([['test2']]);
});
test('label prop', async () => {
const wrapper = mount(VCheckbox, {
props: {
label: 'my label',
},
global,
});
expect(wrapper.html()).toContain('my label');
});
test('customValue prop', async () => {
const wrapper = mount(VCheckbox, {
props: {
customValue: true,
},
global,
});
wrapper.find('input').setValue('my custom value');
expect(wrapper.emitted()['update:value'][0]).toEqual(['my custom value']);
});
test('disabled prop', async () => {
const wrapper = mount(VCheckbox, {
props: {
disabled: true,
modelValue: true,
},
global,
});
await wrapper.get('.checkbox').trigger('click');
expect(wrapper.emitted()['update:modelValue']).not.toBeDefined();
});

View File

@@ -0,0 +1,240 @@
<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 setup lang="ts">
import { computed } from 'vue';
import { useSync } from '@directus/shared/composables';
interface Props {
/** If the `modelValue` is an array of strings, activates the checkbox if the value is inside it */
value?: string | null;
/** Used to model the active state */
modelValue?: boolean | string[] | null;
/** Label for the checkbox */
label?: string | null;
/** Disable the checkbox */
disabled?: boolean;
/** Renders the checkbox neither selected nor unselected */
indeterminate?: boolean;
/** What icon to use for the on state */
iconOn?: string;
/** What icon to use for the off state */
iconOff?: string;
/** What icon to use for the indeterminate state */
iconIndeterminate?: string;
/** Show as styled block. Matches input size */
block?: boolean;
/** If a custom value can be entered next to it */
customValue?: boolean;
/** TODO: What the? */
checked?: boolean | null;
}
const props = withDefaults(defineProps<Props>(), {
value: null,
modelValue: null,
label: null,
disabled: false,
indeterminate: false,
iconOn: 'check_box',
iconOff: 'check_box_outline_blank',
iconIndeterminate: 'indeterminate_check_box',
block: false,
customValue: false,
checked: null,
});
const emit = defineEmits(['update:indeterminate', 'update:modelValue', 'update:value']);
const internalValue = useSync(props, 'value', emit);
const isChecked = computed<boolean>(() => {
if (props.checked !== null) return props.checked;
if (props.modelValue instanceof Array) {
if (!props.value) return false;
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;
});
function toggleInput(): void {
if (props.disabled) return;
if (props.indeterminate === true) {
emit('update:indeterminate', false);
}
if (props.modelValue instanceof Array && props.value) {
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

@@ -0,0 +1,20 @@
import VChip from './v-chip.vue';
document.body.classList.add('light');
export default {
title: 'Components/VChip',
component: VChip,
argTypes: {
close: { control: 'boolean' },
},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-chip v-bind="args" v-on="args" >Cake</v-chip>',
});
export const Primary = Template.bind({});
Primary.args = {};

View File

@@ -0,0 +1,65 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VChip from './v-chip.vue';
import { GlobalMountOptions } from '@vue/test-utils/dist/types';
const global: GlobalMountOptions = {
stubs: ['v-icon'],
};
test('Mount component', () => {
expect(VChip).toBeTruthy();
const wrapper = mount(VChip, {
global,
});
expect(wrapper.html()).toMatchSnapshot();
});
test('active prop', async () => {
const wrapper = mount(VChip, {
props: {
active: true,
close: true,
},
global,
});
expect(wrapper.find('.v-chip').exists()).toBeTruthy();
await wrapper.get('.close-outline').trigger('click');
expect(wrapper.emitted()['update:active'][0]).toEqual([false]);
});
test('close prop', async () => {
const wrapper = mount(VChip, {
props: {
close: true,
},
global,
});
expect(wrapper.find('.v-chip').exists()).toBeTruthy();
await wrapper.get('.close-outline').trigger('click');
expect(wrapper.find('.v-chip').exists()).toBeFalsy();
});
test('style props', async () => {
const props = ['outlined', 'label', 'disabled', 'close', 'x-small', 'small', 'large', 'x-large'];
for (const prop of props) {
const wrapper = mount(VChip, {
props: {
[prop]: true,
},
global,
});
expect(wrapper.classes()).toContain(prop);
}
});

View File

@@ -0,0 +1,197 @@
<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 setup lang="ts">
import { ref, computed } from 'vue';
import { useSizeClass } from '@directus/shared/composables';
interface Props {
/** Model the active state */
active?: boolean;
/** Displays a close icon which triggers the close event */
close?: boolean;
/** Which icon should be displayed to close it */
closeIcon?: string;
/** No background */
outlined?: boolean;
/** Adds a border radius */
label?: boolean;
/** Disables the chip */
disabled?: boolean;
/** Renders a smaller chip */
xSmall?: boolean;
/** Renders a small chip */
small?: boolean;
/** Renders a large chip */
large?: boolean;
/** Renders a larger chip */
xLarge?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
active: undefined,
close: false,
closeIcon: 'close',
outlined: false,
label: true,
disabled: false,
});
const emit = defineEmits(['update:active', 'click', 'close']);
const internalLocalActive = ref(true);
const internalActive = computed<boolean>({
get: () => {
if (props.active !== undefined) return props.active;
return internalLocalActive.value;
},
set: (active: boolean) => {
emit('update:active', active);
internalLocalActive.value = active;
},
});
const sizeClass = useSizeClass(props);
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

@@ -0,0 +1,18 @@
import VDivider from './v-divider.vue';
document.body.classList.add('light');
export default {
title: 'Components/VDivider',
component: VDivider,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-divider v-bind="args" v-on="args" />',
});
export const Primary = Template.bind({});
Primary.args = {};

View File

@@ -0,0 +1,30 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VDivider from './v-divider.vue';
test('Mount component', () => {
expect(VDivider).toBeTruthy();
const wrapper = mount(VDivider, {
slots: {
default: 'Default slot',
},
});
expect(wrapper.html()).toMatchSnapshot();
});
test('style props', async () => {
const props = ['vertical', 'inlineTitle', 'large'];
for (const prop of props) {
const wrapper = mount(VDivider, {
props: {
[prop]: true,
},
});
expect(wrapper.classes()).toContain(prop);
}
});

View File

@@ -0,0 +1,109 @@
<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 setup lang="ts">
interface Props {
/** Render the divider vertically */
vertical?: boolean;
/** Render the title inline with the divider, or under it */
inlineTitle?: boolean;
/** Renders a larger divider text */
large?: boolean;
}
withDefaults(defineProps<Props>(), {
vertical: false,
inlineTitle: true,
large: 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

@@ -0,0 +1,18 @@
import VEmojiPicker from './v-emoji-picker.vue';
document.body.classList.add('light');
export default {
title: 'Components/VEmojiPicker',
component: VEmojiPicker,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-emoji-picker v-bind="args" v-on="args" >My Button</v-emoji-picker>',
});
export const Primary = Template.bind({});
Primary.args = {};

View File

@@ -0,0 +1,26 @@
<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

@@ -0,0 +1,39 @@
import VFancySelect from './v-fancy-select.vue';
document.body.classList.add('light');
export default {
title: 'Components/VFancySelect',
component: VFancySelect,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-fancy-select v-bind="args" v-on="args" >My Button</v-fancy-select>',
});
export const Primary = Template.bind({});
Primary.args = {
items: [
{
icon: 'person',
value: 'person',
text: 'Person',
},
{
icon: 'directions_car',
value: 'car',
text: 'Car',
},
{
divider: true,
},
{
icon: 'home',
value: 'home',
text: 'Home',
},
],
};

View File

@@ -0,0 +1,59 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VFancySelect from './v-fancy-select.vue';
import { GlobalMountOptions } from '@vue/test-utils/dist/types';
const global: GlobalMountOptions = {
stubs: ['v-icon', 'v-divider'],
};
const items = [
{
icon: 'person',
value: 'person',
text: 'Person',
},
{
icon: 'directions_car',
value: 'car',
text: 'Car',
},
{
divider: true,
},
{
icon: 'home',
value: 'home',
text: 'Home',
description: 'A home is a nice place',
},
];
test('Mount component', () => {
expect(VFancySelect).toBeTruthy();
const wrapper = mount(VFancySelect, {
props: {
items,
},
global,
});
expect(wrapper.html()).toMatchSnapshot();
});
test('modelValue prop', async () => {
const wrapper = mount(VFancySelect, {
props: {
items,
},
global,
});
expect(wrapper.element.children[0].children.length).toBe(4);
await wrapper.setProps({ modelValue: 'car' });
expect(wrapper.element.children[0].children.length).toBe(1);
});

View File

@@ -0,0 +1,178 @@
<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;
} & Record<string, any>;
interface Props {
/** The list of possible items to display */
items: FancySelectItem[];
/** Used to model the current selected item */
modelValue?: string | number | null;
/** Disable selecting / deselecting a value */
disabled?: boolean;
/** What key in items to use to display text */
itemText?: string;
/** What key in items to use to model the selected item */
itemValue?: string;
/** What key in items to use to display a description */
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

@@ -0,0 +1,21 @@
import VHighlight from './v-highlight.vue';
document.body.classList.add('light');
export default {
title: 'Components/VHighlight',
component: VHighlight,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-highlight v-bind="args" v-on="args" />',
});
export const Primary = Template.bind({});
Primary.args = {
text: 'The cake is a lie.',
query: ['cake', 'lie'],
};

View File

@@ -0,0 +1,29 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VHighlight from './v-highlight.vue';
test('Mount component', () => {
expect(VHighlight).toBeTruthy();
const wrapper = mount(VHighlight, {
props: {
text: 'This is a nice text',
query: ['is', 'text'],
},
});
expect(wrapper.html()).toMatchSnapshot();
});
test('query prop', async () => {
const wrapper = mount(VHighlight, {
props: {
text: 'This is a nice text',
query: 'nice',
},
});
expect(wrapper.find('.highlight').exists()).toBeTruthy();
expect(wrapper.find('.highlight').text()).toBe('nice');
});

View File

@@ -0,0 +1,139 @@
<template>
<template v-for="(part, index) in parts">
<mark v-if="part.highlighted" :key="index" class="highlight">{{ part.text }}</mark>
<template v-else>{{ part.text }}</template>
</template>
</template>
<script setup lang="ts">
import { 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;
};
interface Props {
/** What parts of the `text` should be highlighted */
query?: string | string[] | null;
/** The text to display */
text?: string;
}
const props = withDefaults(defineProps<Props>(), {
query: null,
text: '',
});
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.reduce<number[][][]>((acc, query) => {
if (query === null) return acc;
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);
}
acc.push(indices);
return acc;
}, [])
);
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;
});
</script>
<style scoped>
mark {
margin: -1px -2px;
padding: 1px 2px;
background-color: var(--primary-25);
border-radius: var(--border-radius);
}
</style>

View File

@@ -0,0 +1,19 @@
import VHover from './v-hover.vue';
document.body.classList.add('light');
export default {
title: 'Components/VHover',
component: VHover,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template:
'<v-hover v-bind="args" v-on="args" v-slot="{ hover }">Hover me!<div v-if="hover">This is only shown on hover.</div></v-hover>',
});
export const Primary = Template.bind({});
Primary.args = {};

View File

@@ -0,0 +1,90 @@
import { test, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import VHover from './v-hover.vue';
test('Mount component', () => {
expect(VHover).toBeTruthy();
const wrapper = mount(VHover, {
slots: {
default: ({ hover }) => (hover ? 'shown' : 'hidden'),
},
});
expect(wrapper.html()).toMatchSnapshot();
});
test('hover interaction', async () => {
const wrapper = mount(VHover, {
slots: {
default: ({ hover }) => (hover ? 'shown' : 'hidden'),
},
});
vi.useFakeTimers();
expect(wrapper.text()).toBe('hidden');
await wrapper.trigger('mouseenter');
await vi.advanceTimersByTime(1);
expect(wrapper.text()).toBe('shown');
await wrapper.trigger('mouseleave');
await vi.advanceTimersByTime(1);
expect(wrapper.text()).toBe('hidden');
});
test('openDelay prop', async () => {
const wrapper = mount(VHover, {
props: {
openDelay: 20,
},
slots: {
default: ({ hover }) => (hover ? 'shown' : 'hidden'),
},
});
vi.useFakeTimers();
expect(wrapper.text()).toBe('hidden');
await wrapper.trigger('mouseenter');
await vi.advanceTimersByTime(10);
expect(wrapper.text()).toBe('hidden');
await vi.advanceTimersByTime(10);
expect(wrapper.text()).toBe('shown');
});
test('closeDelay prop', async () => {
const wrapper = mount(VHover, {
props: {
closeDelay: 20,
},
slots: {
default: ({ hover }) => (hover ? 'shown' : 'hidden'),
},
});
vi.useFakeTimers();
expect(wrapper.text()).toBe('hidden');
await wrapper.trigger('mouseenter');
await vi.advanceTimersByTime(1);
expect(wrapper.text()).toBe('shown');
await wrapper.trigger('mouseleave');
await vi.advanceTimersByTime(10);
expect(wrapper.text()).toBe('shown');
await vi.advanceTimersByTime(10);
expect(wrapper.text()).toBe('hidden');
});
test('tag prop', async () => {
const wrapper = mount(VHover, {
props: {
tag: 'span',
},
slots: {
default: ({ hover }) => (hover ? 'shown' : 'hidden'),
},
});
expect(wrapper.element.tagName).toBe('SPAN');
});

View File

@@ -0,0 +1,45 @@
<template>
<component :is="tag" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
<slot v-bind="{ hover }" />
</component>
</template>
<script setup lang="ts">
import { ref } from 'vue';
interface Props {
/** Time in ms until closing */
closeDelay?: number;
/** Time in ms until opening */
openDelay?: number;
/** Disables any intractability */
disabled?: boolean;
/** The type of element to wrap around the slot */
tag?: string;
}
const props = withDefaults(defineProps<Props>(), {
closeDelay: 0,
openDelay: 0,
disabled: false,
tag: 'div',
});
const hover = ref<boolean>(false);
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

@@ -0,0 +1,20 @@
import VIconFile from './v-icon-file.vue';
document.body.classList.add('light');
export default {
title: 'Components/VIconFile',
component: VIconFile,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-icon-file v-bind="args" v-on="args" >My Button</v-icon-file>',
});
export const Primary = Template.bind({});
Primary.args = {
ext: 'png',
};

View File

@@ -0,0 +1,19 @@
import { test, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import VIconFile from './v-icon-file.vue';
test('Mount component', () => {
expect(VIconFile).toBeTruthy();
const wrapper = mount(VIconFile, {
props: {
ext: 'png',
},
global: {
stubs: ['v-icon'],
},
});
expect(wrapper.html()).toMatchSnapshot();
});

View File

@@ -0,0 +1,52 @@
<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 {
/** The extension type of the file */
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

@@ -0,0 +1,20 @@
import VIcon from './v-icon/v-icon.vue';
document.body.classList.add('light');
export default {
title: 'Components/VIcon',
component: VIcon,
argTypes: {},
};
const Template = (args) => ({
setup() {
return { args };
},
template: '<v-icon v-bind="args" v-on="args" />',
});
export const Primary = Template.bind({});
Primary.args = {
name: 'delete',
};

View File

@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`Mount component 1`] = `"<span class=\\"v-icon\\" data-v-4b41578f=\\"\\"><i class=\\"\\" data-icon=\\"close\\" data-v-4b41578f=\\"\\"></i></span>"`;

View File

@@ -0,0 +1,21 @@
<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

@@ -0,0 +1,18 @@
<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

@@ -0,0 +1,19 @@
<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

@@ -0,0 +1,20 @@
<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

@@ -0,0 +1,18 @@
<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

@@ -0,0 +1,19 @@
<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

@@ -0,0 +1,27 @@
<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>

Some files were not shown because too many files have changed in this diff Show More