mirror of
https://github.com/directus/directus.git
synced 2026-04-03 03:00:39 -04:00
Move updated components to app (#15374)
* Move updated components to app * Make sure storybook is alive
This commit is contained in:
3
app/src/components/__snapshots__/v-avatar.test.ts.snap
Normal file
3
app/src/components/__snapshots__/v-avatar.test.ts.snap
Normal file
@@ -0,0 +1,3 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`Mount component 1`] = `"<div class=\\"v-avatar\\" data-v-83da42c0=\\"\\">Slot Content</div>"`;
|
||||
3
app/src/components/__snapshots__/v-badge.test.ts.snap
Normal file
3
app/src/components/__snapshots__/v-badge.test.ts.snap
Normal 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>"`;
|
||||
@@ -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>"`;
|
||||
9
app/src/components/__snapshots__/v-button.test.ts.snap
Normal file
9
app/src/components/__snapshots__/v-button.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`Mount component 1`] = `"<div class=\\"v-card-actions\\" data-v-f103ec72=\\"\\">Slot Content</div>"`;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`Mount component 1`] = `"<div class=\\"v-card-subtitle\\" data-v-61aef43e=\\"\\">Slot Content</div>"`;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`Mount component 1`] = `"<div class=\\"v-card-text\\" data-v-028451d3=\\"\\">Slot Content</div>"`;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`Mount component 1`] = `"<div class=\\"v-card-title type-label\\" data-v-0f314222=\\"\\">Slot Content</div>"`;
|
||||
3
app/src/components/__snapshots__/v-card.test.ts.snap
Normal file
3
app/src/components/__snapshots__/v-card.test.ts.snap
Normal file
@@ -0,0 +1,3 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`Mount component 1`] = `"<div class=\\"v-card\\" data-v-1e90e497=\\"\\">Slot Content</div>"`;
|
||||
9
app/src/components/__snapshots__/v-checkbox.test.ts.snap
Normal file
9
app/src/components/__snapshots__/v-checkbox.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
3
app/src/components/__snapshots__/v-chip.test.ts.snap
Normal file
3
app/src/components/__snapshots__/v-chip.test.ts.snap
Normal 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>"`;
|
||||
7
app/src/components/__snapshots__/v-divider.test.ts.snap
Normal file
7
app/src/components/__snapshots__/v-divider.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
39
app/src/components/__snapshots__/v-fancy-select.test.ts.snap
Normal file
39
app/src/components/__snapshots__/v-fancy-select.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
10
app/src/components/__snapshots__/v-highlight.test.ts.snap
Normal file
10
app/src/components/__snapshots__/v-highlight.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
3
app/src/components/__snapshots__/v-hover.test.ts.snap
Normal file
3
app/src/components/__snapshots__/v-hover.test.ts.snap
Normal file
@@ -0,0 +1,3 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`Mount component 1`] = `"<div>hidden</div>"`;
|
||||
@@ -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>"
|
||||
`;
|
||||
11
app/src/components/__snapshots__/v-info.test.ts.snap
Normal file
11
app/src/components/__snapshots__/v-info.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
15
app/src/components/__snapshots__/v-input.test.ts.snap
Normal file
15
app/src/components/__snapshots__/v-input.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
7
app/src/components/__snapshots__/v-notice.test.ts.snap
Normal file
7
app/src/components/__snapshots__/v-notice.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
8
app/src/components/__snapshots__/v-overlay.test.ts.snap
Normal file
8
app/src/components/__snapshots__/v-overlay.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
18
app/src/components/__snapshots__/v-pagination.test.ts.snap
Normal file
18
app/src/components/__snapshots__/v-pagination.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
@@ -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>"
|
||||
`;
|
||||
@@ -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>"
|
||||
`;
|
||||
7
app/src/components/__snapshots__/v-radio.test.ts.snap
Normal file
7
app/src/components/__snapshots__/v-radio.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
@@ -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>"
|
||||
`;
|
||||
13
app/src/components/__snapshots__/v-slider.test.ts.snap
Normal file
13
app/src/components/__snapshots__/v-slider.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
3
app/src/components/__snapshots__/v-tabs.test.ts.snap
Normal file
3
app/src/components/__snapshots__/v-tabs.test.ts.snap
Normal file
@@ -0,0 +1,3 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`Mount component 1`] = `"<div class=\\"v-tabs horizontal\\" data-v-2bea93f6=\\"\\">Some value</div>"`;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`Mount component 1`] = `"<div class=\\"v-text-overflow\\" data-v-6410b7aa=\\"\\">My text</div>"`;
|
||||
8
app/src/components/__snapshots__/v-textarea.test.ts.snap
Normal file
8
app/src/components/__snapshots__/v-textarea.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
@@ -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>"
|
||||
`;
|
||||
10
app/src/components/__snapshots__/v-workspace.test.ts.snap
Normal file
10
app/src/components/__snapshots__/v-workspace.test.ts.snap
Normal 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>"
|
||||
`;
|
||||
@@ -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);
|
||||
|
||||
71
app/src/components/transition/bounce.vue
Normal file
71
app/src/components/transition/bounce.vue
Normal 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>
|
||||
39
app/src/components/transition/dialog.vue
Normal file
39
app/src/components/transition/dialog.vue
Normal 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>
|
||||
119
app/src/components/transition/expand-methods.ts
Normal file
119
app/src/components/transition/expand-methods.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
33
app/src/components/transition/expand.vue
Normal file
33
app/src/components/transition/expand.vue
Normal 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>
|
||||
19
app/src/components/transition/transition-bounce.stories.ts
Normal file
19
app/src/components/transition/transition-bounce.stories.ts
Normal 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 = {};
|
||||
19
app/src/components/transition/transition-dialog.stories.ts
Normal file
19
app/src/components/transition/transition-dialog.stories.ts
Normal 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 = {};
|
||||
19
app/src/components/transition/transition-expand.stories.ts
Normal file
19
app/src/components/transition/transition-expand.stories.ts
Normal 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 = {};
|
||||
18
app/src/components/v-avatar.stories.ts
Normal file
18
app/src/components/v-avatar.stories.ts
Normal 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 = {};
|
||||
36
app/src/components/v-avatar.test.ts
Normal file
36
app/src/components/v-avatar.test.ts
Normal 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');
|
||||
});
|
||||
80
app/src/components/v-avatar.vue
Normal file
80
app/src/components/v-avatar.vue
Normal 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>
|
||||
20
app/src/components/v-badge.stories.ts
Normal file
20
app/src/components/v-badge.stories.ts
Normal 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',
|
||||
};
|
||||
60
app/src/components/v-badge.test.ts
Normal file
60
app/src/components/v-badge.test.ts
Normal 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');
|
||||
});
|
||||
109
app/src/components/v-badge.vue
Normal file
109
app/src/components/v-badge.vue
Normal 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>
|
||||
36
app/src/components/v-breadcrumb.stories.ts
Normal file
36
app/src/components/v-breadcrumb.stories.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
37
app/src/components/v-breadcrumb.test.ts
Normal file
37
app/src/components/v-breadcrumb.test.ts
Normal 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();
|
||||
});
|
||||
89
app/src/components/v-breadcrumb.vue
Normal file
89
app/src/components/v-breadcrumb.vue
Normal 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>
|
||||
22
app/src/components/v-button.stories.ts
Normal file
22
app/src/components/v-button.stories.ts
Normal 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 = {};
|
||||
60
app/src/components/v-button.test.ts
Normal file
60
app/src/components/v-button.test.ts
Normal 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')
|
||||
|
||||
// });
|
||||
443
app/src/components/v-button.vue
Normal file
443
app/src/components/v-button.vue
Normal 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>
|
||||
16
app/src/components/v-card-actions.test.ts
Normal file
16
app/src/components/v-card-actions.test.ts
Normal 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();
|
||||
});
|
||||
15
app/src/components/v-card-actions.vue
Normal file
15
app/src/components/v-card-actions.vue
Normal 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>
|
||||
16
app/src/components/v-card-subtitle.test.ts
Normal file
16
app/src/components/v-card-subtitle.test.ts
Normal 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();
|
||||
});
|
||||
11
app/src/components/v-card-subtitle.vue
Normal file
11
app/src/components/v-card-subtitle.vue
Normal 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>
|
||||
16
app/src/components/v-card-text.test.ts
Normal file
16
app/src/components/v-card-text.test.ts
Normal 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();
|
||||
});
|
||||
11
app/src/components/v-card-text.vue
Normal file
11
app/src/components/v-card-text.vue
Normal 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>
|
||||
16
app/src/components/v-card-title.test.ts
Normal file
16
app/src/components/v-card-title.test.ts
Normal 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();
|
||||
});
|
||||
15
app/src/components/v-card-title.vue
Normal file
15
app/src/components/v-card-title.vue
Normal 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>
|
||||
28
app/src/components/v-card.stories.ts
Normal file
28
app/src/components/v-card.stories.ts
Normal 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 = {};
|
||||
30
app/src/components/v-card.test.ts
Normal file
30
app/src/components/v-card.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
70
app/src/components/v-card.vue
Normal file
70
app/src/components/v-card.vue
Normal 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>
|
||||
44
app/src/components/v-checkbox-tree.stories.ts
Normal file
44
app/src/components/v-checkbox-tree.stories.ts
Normal 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'],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`Mount component 1`] = `"<ul class=\\"v-list\\" data-v-6f43a325=\\"\\"></ul>"`;
|
||||
57
app/src/components/v-checkbox-tree/use-visible-children.ts
Normal file
57
app/src/components/v-checkbox-tree/use-visible-children.ts
Normal 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 };
|
||||
}
|
||||
431
app/src/components/v-checkbox-tree/v-checkbox-tree-checkbox.vue
Normal file
431
app/src/components/v-checkbox-tree/v-checkbox-tree-checkbox.vue
Normal 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>
|
||||
91
app/src/components/v-checkbox-tree/v-checkbox-tree.test.ts
Normal file
91
app/src/components/v-checkbox-tree/v-checkbox-tree.test.ts
Normal 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');
|
||||
// });
|
||||
157
app/src/components/v-checkbox-tree/v-checkbox-tree.vue
Normal file
157
app/src/components/v-checkbox-tree/v-checkbox-tree.vue
Normal 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>
|
||||
22
app/src/components/v-checkbox.stories.ts
Normal file
22
app/src/components/v-checkbox.stories.ts
Normal 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,
|
||||
};
|
||||
92
app/src/components/v-checkbox.test.ts
Normal file
92
app/src/components/v-checkbox.test.ts
Normal 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();
|
||||
});
|
||||
240
app/src/components/v-checkbox.vue
Normal file
240
app/src/components/v-checkbox.vue
Normal 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>
|
||||
20
app/src/components/v-chip.stories.ts
Normal file
20
app/src/components/v-chip.stories.ts
Normal 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 = {};
|
||||
65
app/src/components/v-chip.test.ts
Normal file
65
app/src/components/v-chip.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
197
app/src/components/v-chip.vue
Normal file
197
app/src/components/v-chip.vue
Normal 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>
|
||||
18
app/src/components/v-divider.stories.ts
Normal file
18
app/src/components/v-divider.stories.ts
Normal 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 = {};
|
||||
30
app/src/components/v-divider.test.ts
Normal file
30
app/src/components/v-divider.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
109
app/src/components/v-divider.vue
Normal file
109
app/src/components/v-divider.vue
Normal 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>
|
||||
18
app/src/components/v-emoji-picker.stories.ts
Normal file
18
app/src/components/v-emoji-picker.stories.ts
Normal 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 = {};
|
||||
26
app/src/components/v-emoji-picker.vue
Normal file
26
app/src/components/v-emoji-picker.vue
Normal 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>
|
||||
39
app/src/components/v-fancy-select.stories.ts
Normal file
39
app/src/components/v-fancy-select.stories.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
59
app/src/components/v-fancy-select.test.ts
Normal file
59
app/src/components/v-fancy-select.test.ts
Normal 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);
|
||||
});
|
||||
178
app/src/components/v-fancy-select.vue
Normal file
178
app/src/components/v-fancy-select.vue
Normal 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>
|
||||
21
app/src/components/v-highlight.stories.ts
Normal file
21
app/src/components/v-highlight.stories.ts
Normal 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'],
|
||||
};
|
||||
29
app/src/components/v-highlight.test.ts
Normal file
29
app/src/components/v-highlight.test.ts
Normal 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');
|
||||
});
|
||||
139
app/src/components/v-highlight.vue
Normal file
139
app/src/components/v-highlight.vue
Normal 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>
|
||||
19
app/src/components/v-hover.stories.ts
Normal file
19
app/src/components/v-hover.stories.ts
Normal 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 = {};
|
||||
90
app/src/components/v-hover.test.ts
Normal file
90
app/src/components/v-hover.test.ts
Normal 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');
|
||||
});
|
||||
45
app/src/components/v-hover.vue
Normal file
45
app/src/components/v-hover.vue
Normal 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>
|
||||
20
app/src/components/v-icon-file.stories.ts
Normal file
20
app/src/components/v-icon-file.stories.ts
Normal 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',
|
||||
};
|
||||
19
app/src/components/v-icon-file.test.ts
Normal file
19
app/src/components/v-icon-file.test.ts
Normal 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();
|
||||
});
|
||||
52
app/src/components/v-icon-file.vue
Normal file
52
app/src/components/v-icon-file.vue
Normal 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>
|
||||
20
app/src/components/v-icon.stories.ts
Normal file
20
app/src/components/v-icon.stories.ts
Normal 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',
|
||||
};
|
||||
@@ -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>"`;
|
||||
21
app/src/components/v-icon/custom-icons/bookmark_save.vue
Normal file
21
app/src/components/v-icon/custom-icons/bookmark_save.vue
Normal 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>
|
||||
18
app/src/components/v-icon/custom-icons/box.vue
Normal file
18
app/src/components/v-icon/custom-icons/box.vue
Normal 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>
|
||||
19
app/src/components/v-icon/custom-icons/commit_node.vue
Normal file
19
app/src/components/v-icon/custom-icons/commit_node.vue
Normal 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>
|
||||
20
app/src/components/v-icon/custom-icons/directus.vue
Normal file
20
app/src/components/v-icon/custom-icons/directus.vue
Normal 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>
|
||||
18
app/src/components/v-icon/custom-icons/flip_horizontal.vue
Normal file
18
app/src/components/v-icon/custom-icons/flip_horizontal.vue
Normal 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>
|
||||
19
app/src/components/v-icon/custom-icons/flip_vertical.vue
Normal file
19
app/src/components/v-icon/custom-icons/flip_vertical.vue
Normal 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>
|
||||
27
app/src/components/v-icon/custom-icons/folder_lock.vue
Normal file
27
app/src/components/v-icon/custom-icons/folder_lock.vue
Normal 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
Reference in New Issue
Block a user