diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000000000000000000000000000000000000..e5e03e17fc999bdaff2686533e17c1621eb20153 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,20 @@ +/* eslint-env node */ +require("@rushstack/eslint-patch/modern-module-resolution"); + +module.exports = { + root: true, + extends: [ + "plugin:vue/essential", + "plugin:vue/strongly-recommended", + "eslint:recommended", + "prettier", + "@vue/eslint-config-prettier", + ], + env: { + es2021: true, + }, + parserOptions: { + ecmaVersion: "latest", + }, + ignorePatterns: ["dist/*"], +}; diff --git a/.gitignore b/.gitignore index f57d9ce541d878d1ea0315d16112bb22c35005ad..99667679e37f65f3da02bc80026252adf89d39c8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ node_modules dist package-lock.json docs/.vuepress/dist -.idea \ No newline at end of file +.idea diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..47bb04395082982e0b8481a1c827a8b176733686 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,10 @@ +image: node:latest + +stages: + - test + +lint-test-job: + stage: test + script: + - npm install + - npm run lint diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000000000000000000000000000000000..a5b57bb2b9cf325f07294a33ac1139abc88d4eb1 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +dist/ +docs/.vuepress/dist/ +example/src/assets/base.css +.idea/ +node_modules/ +package-lock.json diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index ba76c729433da429b63d52c8dc6ec69e7c841c24..b644b9726bae3993d8d02160a5c863cdefc69e9b 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -1,15 +1,31 @@ module.exports = { - title: 'vue-draggable-grid', - description: 'vue-draggable-grid component library for a draggable grid', - themeConfig: { - sidebar: [ - { - title: 'Components', - collapsable: false, - children: [ - '/components/component.md', - ] - } - ] - } -} \ No newline at end of file + title: "vue-draggable-grid", + description: "vue-draggable-grid component library for a draggable grid", + themeConfig: { + sidebar: [ + { + title: "Examples", + collapsable: false, + children: [ + "/examples/Generic.md", + "/examples/TicTacToe.md", + "/examples/Counters.md", + "/examples/Lessons.md", + "/examples/Colors.md", + "/examples/Disabled.md", + "/examples/DisabledItems.md", + "/examples/Responsive.md", + ], + }, + ], + nav: [ + { text: "Home", link: "/" }, + { text: "Guide", link: "/guide/" }, + { text: "Examples", link: "/examples/Generic.md" }, + { + text: "Repository", + link: "https://edugit.org/AlekSIS/libs/vue-draggable-grid/", + }, + ], + }, +}; diff --git a/docs/.vuepress/enhanceApp.js b/docs/.vuepress/enhanceApp.js new file mode 100644 index 0000000000000000000000000000000000000000..5eee540438597b3104f8ac95f4ab979353f384a5 --- /dev/null +++ b/docs/.vuepress/enhanceApp.js @@ -0,0 +1,11 @@ +import draggableGrid from "../../src/index.js"; + +export default ({ + Vue, // the version of Vue being used in the VuePress app + options, // the options for the root Vue instance + router, // the router instance for the app + siteData, // site metadata + isServer, // is this enhancement applied in server-rendering or client +}) => { + Vue.use(draggableGrid); +}; diff --git a/docs/README.md b/docs/README.md index b4164f3a75046993b8c04626793c42397504d378..17458b43f5b80502feb99782578b646019d54a3c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,8 @@ -# vue-draggable-grid - -Document the library here. - -### Installation - -$ npm install vue-draggable-grid \ No newline at end of file +--- +home: true +heroText: vue-draggable-grid +tagline: A vueJS library to create grids with draggable data blocks. +actionText: Get Started → +actionLink: /guide/ +footer: Copyright © 2023 Julian Leucker +--- diff --git a/docs/components/component.md b/docs/components/component.md deleted file mode 100644 index bbf8e0e4ca52bab4f9e66b35057319fc8ec717a4..0000000000000000000000000000000000000000 --- a/docs/components/component.md +++ /dev/null @@ -1,7 +0,0 @@ -# component - -`Component` is a cool component. Here's how to use it... - -<template> - <component /> -</template> \ No newline at end of file diff --git a/docs/examples/Colors.md b/docs/examples/Colors.md new file mode 100644 index 0000000000000000000000000000000000000000..fa6ae24c8e5d4dd9cb1998247791958764fbc889 --- /dev/null +++ b/docs/examples/Colors.md @@ -0,0 +1,18 @@ +# Example 5: Dynamic colors + +This example showcases the `rawItem` with the custom method +`getColor`. Both grids on the outside call the method with +`"red"` while the one in the middle uses `"green"`. +We use arrays called `placedA`, `placedB` and +`placedC` to save which item is contained in which grid. + +Notice: The third grid doesn't save the item positions, they are always +positioned automatically. + +<ClientOnly> +<script setup> +import Example5Colors from "../../example/src/Example5Colors.vue"; +</script> + +<Example5Colors /> +</ClientOnly> diff --git a/docs/examples/Counters.md b/docs/examples/Counters.md new file mode 100644 index 0000000000000000000000000000000000000000..778baeb2c2163d898f17a74b9bb4a7bfbac83ff0 --- /dev/null +++ b/docs/examples/Counters.md @@ -0,0 +1,15 @@ +# Example 3: Counters + +Showcasing local and global state: The local state of the counter +components doesn't change when moved inside one grid, but is cleared when +moved to another. The global state is in the `data` attribute +of each item and gets transferred as well. Both grids also have custom +highlights. + +<ClientOnly> +<script setup> +import Example3Counters from "../../example/src/Example3Counters.vue"; +</script> + +<Example3Counters /> +</ClientOnly> diff --git a/docs/examples/Disabled.md b/docs/examples/Disabled.md new file mode 100644 index 0000000000000000000000000000000000000000..8f8c86c0c99d3c0f0746cf466dc8b1f17914a83b --- /dev/null +++ b/docs/examples/Disabled.md @@ -0,0 +1,17 @@ +# Example 6: Disabled grid + +This uses the same data as the tic-tac-toe but is completely disabled. +Notice how the items still move if the tic-tac-toe data +change. Uncheck the first checkbox to enable the grid. + +The grid can also be in a loading state, in which it is disabled as well, +but it displays loading symbols everywhere. Check the second checkbox to +enable the loading state. + +<ClientOnly> +<script setup> +import Example6Disabled from "../../example/src/Example6Disabled.vue"; +</script> + +<Example6Disabled /> +</ClientOnly> diff --git a/docs/examples/DisabledItems.md b/docs/examples/DisabledItems.md new file mode 100644 index 0000000000000000000000000000000000000000..8c5b4969f740ea391d80c2a19fdc82adb0be82df --- /dev/null +++ b/docs/examples/DisabledItems.md @@ -0,0 +1,13 @@ +# Example 7: Disabled items and fields + +This is a grid with disabled fields and items. Red items are disabled +and cannot be moved. Pink fields are disabled and items cannot be moved +to them. Green items are normal and enabled. + +<ClientOnly> +<script setup> +import Example7DisabledItems from "../../example/src/Example7DisabledItems.vue"; +</script> + +<Example7DisabledItems /> +</ClientOnly> diff --git a/docs/examples/Generic.md b/docs/examples/Generic.md new file mode 100644 index 0000000000000000000000000000000000000000..bc2060c125df19f3e6c9e1b66cc516a659d4a4bd --- /dev/null +++ b/docs/examples/Generic.md @@ -0,0 +1,11 @@ +# Example 1: Generic Example + +Grid with two programmatically blocked cells and one programmatically blocked item + +<ClientOnly> +<script setup> +import Example1Generic from "../../example/src/Example1Generic.vue"; +</script> + +<Example1Generic /> +</ClientOnly> diff --git a/docs/examples/Lessons.md b/docs/examples/Lessons.md new file mode 100644 index 0000000000000000000000000000000000000000..a04c26f0c59f6b533b5c84b7ed0c6b7a052fe227 --- /dev/null +++ b/docs/examples/Lessons.md @@ -0,0 +1,16 @@ +# Example 4: Dynamic lessons + +These lessons are loaded from `computed` to simulate a +non-editable source like an API. they are changed using the method +`handleLessonMoved`. Try changing the sizes of Lesson1, the +grid and the texts of the items! The lesson container on the side doesn't +have a highlight and doesn't keep track of the item position. Try moving +`item3` back and forth, the text will change! + +<ClientOnly> +<script setup> +import Example4Lessons from "../../example/src/Example4Lessons.vue"; +</script> + +<Example4Lessons /> +</ClientOnly> diff --git a/docs/examples/Responsive.md b/docs/examples/Responsive.md new file mode 100644 index 0000000000000000000000000000000000000000..55eb22025f58e60738e91e7cfc244c7f34987574 --- /dev/null +++ b/docs/examples/Responsive.md @@ -0,0 +1,11 @@ +# Example 8: Responsive + +The grid is responsive. Try resizing it below: + +<ClientOnly> +<script setup> +import Example8Responsive from "../../example/src/Example8Responsive.vue"; +</script> + +<Example8Responsive /> +</ClientOnly> diff --git a/docs/examples/TicTacToe.md b/docs/examples/TicTacToe.md new file mode 100644 index 0000000000000000000000000000000000000000..bfb532845945d01a26b0062de8ac2683865db4c2 --- /dev/null +++ b/docs/examples/TicTacToe.md @@ -0,0 +1,11 @@ +# Example 2: Tic-Tac-Toe + +A grid functioning as a playing field and another functioning as a container for playing pieces. You can drag as many pieces as you want from the container to the field. + +<ClientOnly> +<script setup> +import Example2TicTacToe from "../../example/src/Example2TicTacToe.vue"; +</script> + +<Example2TicTacToe /> +</ClientOnly> diff --git a/docs/guide/index.md b/docs/guide/index.md new file mode 100644 index 0000000000000000000000000000000000000000..1932522e53ec42d3df4ba5b8df043c860adc8fc1 --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,246 @@ +--- +sidebar: auto +--- + +# Guide + +## Quickstart + +Install the package `vue-draggable-grid` via your favourite package manager. +Include the library in your project: + +```javascript +import draggableGrid from "vue-draggable-grid"; + +Vue.use(draggableGrid); + +// Now create your app as usual +``` + +An example usage could look like this: + +```vue +<template> + <drag-grid v-model="items" :cols="4" :rows="4"> + <template #item="item"> + {{ item.data.text }} + </template> + </drag-grid> +</template> + +<script> +export default { + name: "YourComponent", + data() { + return { + items: [ + { + x: 1, + y: 3, + w: 2, + h: 2, + key: "item1", + data: { text: "Hello world 1" }, + }, + { + x: 2, + y: 2, + w: 2, + h: 1, + key: "item2", + data: { text: "Hello world 2" }, + }, + { + x: 3, + y: 1, + w: 1, + h: 1, + key: "item3", + data: { text: "Hello world 3" }, + }, + ], + }; + }, +}; +</script> +``` + +## Blocking fields + +A field (or cell) can be blocked via the `disabledFields` prop. The prop receives an array of objects, containing the +coordinates of the blocked fields. + +```javascript +disabledFields: [ + { x: 1, y: 1 }, + { x: 2, y: 3 }, +]; +``` + +## Prevent items from being dragged + +To disable dragging of a specific item, simply set the attribute `disabled` of the item to `true`. + +```javascript{4,5} +someDisabledItems: [ + { key: "key1", x: 1, y: 3, w: 1, h: 1, data: {} }, + { key: "key2", x: 2, y: 2, w: 1, h: 1, data: {} }, + { key: "key3", x: 3, y: 1, w: 1, h: 1, data: {}, disabled: true }, + { key: "key4", x: 1, y: 2, w: 1, h: 1, data: {}, disabled: true }, +] +``` + +The highlighted items are not draggable. + +## Disabling the grid + +If the boolean property `disabled` is set for the whole grid, the grid itself is disabled, +and items can't be moved. + +::: tip NOTICE +A disabled grid only prevents changing the data inside the grid. If the data changes from outside +of the grid, the grid _will_ rerender. +::: + +## Programmatic validation of movements + +It is also possible to supply a function to dynamically or programmatically hinder fields from being moved to, +and items from being moved. This can be done by supplying a function which takes the `x` and `y` coordinates of +the field as well as the key of the item. If `false` is returned, the movement is prohibited. The highlight which +appears when dragging an element is also disabled for this field. + +Examples for such methods are the following: + +```javascript +function blockField(x, y, key) { + // We won't move items with ID 'obj8' and nothing into (3, 3) and (4, 3) + if (x === 3 && y === 3) return false; + if (x === 4 && y === 3) return false; + return key !== "obj8"; +} + +function blockAllMoving() { + return false; +} +``` + +## Changing the highlight + +The highlight is the grey-bordered rectangle which appears when dragging over a field. + +### Custom highlight + +To customize the highlight, use the `highlight` slot inside the grid component. + +```html +<drag-grid v-model="items" :cols="3" :rows="2"> + <template #highlight> + <div ref="highlight" class="custom-highlight"> + This is a custom highlight with a custom style! + </div> + </template> +</drag-grid> +``` + +### Disabled highlight + +To disable the highlight, use the `no-highlight` prop. + +## Displaying the loading of items + +If the grid is supplied with the `loading` prop, it will be in a loading status. In this status it is +disabled, like if `disabled` where true, but the grid is filled with elements inside the `loader` slot. +This provides the ability to do something like more realistic skeleton loaders. + +## Changing items on move + +It is possible to make changes to an item once it moved successfully. One can supply a function in the +`validate-element` prop which gets called on a moved item and can make (in place) changes to it. Such a +function could look like this: + +```javascript +function randomKey(element) { + if (element.key.length !== 1) return; + element.key += Math.random().toString(36).replace("0.", ""); +} +``` + +This method changes the key of a moved item to a random string if the key has a length of 1. +This is used inside example 2 (the tic-tac-toe game). + +## Functional item properties + +Properties of items don't have to be Numbers, Strings and Objects, they can also be functions +returning those types. They will automatically be called with a grid object containing the `gridId` +as well as the `context`. + +A singular item could look like this: + +```javascript +[ + { + x: (grid) => { + return grid.gridId === "lesson-plan" ? this.lesson1X : 0; + }, + y: (grid) => { + return grid.gridId === "lesson-plan" ? this.lesson1Y : 0; + }, + w: 1, + h: this.lesson1Length, + key: "lesson1", + data: { + text: this.lessonData.lesson1, + }, + }, +]; +``` + +Items can also have custom extra properties. They will however be reset after moving. +An example where these are used is Example 5. + +## Listening to grid changes + +There are two ways to process changes made by dragging and dropping items: the `input` event +and the `itemChanged` event. + +The `input` event works together with the value prop so one can use `v-model` to supply the grid +and have changes made automatically. The event returns the grid how it would look if the item +moved to the specific location. + +::: warning +Notice that this event is only possible if your item properties are basic types, functional items are not +supported and the properties will be reset to `undefined`. +::: + +The `itemChanged` event returns the moved item with following attributes: + +```javascript +let eventData = { + context: String, // Context of the origin grid (same as the target's) + data: Object, // Data Object of the item + gridId: String, // ID of the target grid + h: Number, // Height of the item + key: String, // Key of the item + mouseX: Number, // Mouse position on the element relative to + mouseY: Number, // the center of the top left rectangle + originGridId: String, // ID of the origin grid + w: Number, // Width of the item + x: Number, // New x position (col) of the item + y: Number, // New y position (row) of the item +}; +``` + +This event doesn't change the grid, this change has to be made separately. This is +useful if e.g. a direct API request is needed. + +## Multiple grids + +To connect multiple grids they need to have the same context. If you supply the same string to the +`context` prop of two grids, the items can be moved interchangeably. + +::: warning +Items are not deleted from the source grid if moved to a different one. You have to build a mechanism for this yourself. +Examples for such a mechanism can be found in Examples 4 and 5. +::: + +To handle movements from one grid to another, the attributes `gridId` and `originGridId` of the event will help. diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..133da84efcfe2ef4d2580e0d6a8f91979f30b2be --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,27 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +*.local + +/cypress/videos/ +/cypress/screenshots/ + +# Editor directories and files +.vscode +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000000000000000000000000000000000000..049aebe05d829113e7de6e200e93c62954d817f4 --- /dev/null +++ b/example/index.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" href="/favicon.ico" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Vue-Draggable-Grid Example App</title> + </head> + <body> + <div id="app"></div> + <script type="module" src="/src/main.js"></script> + </body> +</html> diff --git a/example/public/favicon.ico b/example/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..df36fcfb72584e00488330b560ebcf34a41c64c2 Binary files /dev/null and b/example/public/favicon.ico differ diff --git a/example/src/App.vue b/example/src/App.vue new file mode 100644 index 0000000000000000000000000000000000000000..ed093f14b5ee732a8b1829600355e0b01ba25bbf --- /dev/null +++ b/example/src/App.vue @@ -0,0 +1,119 @@ +<script setup> +import Example1Generic from "./Example1Generic.vue"; +import Example2TicTacToe from "./Example2TicTacToe.vue"; +import Example3Counters from "./Example3Counters.vue"; +import Example4Lessons from "./Example4Lessons.vue"; +import Example5Colors from "./Example5Colors.vue"; +import Example6Disabled from "./Example6Disabled.vue"; +import Example7DisabledItems from "./Example7DisabledItems.vue"; +import Example8Responsive from "./Example8Responsive.vue"; +</script> + +<template> + <div id="app"> + <h1><code>DragGrid</code> Examples</h1> + <h2>Example 1</h2> + <p> + Grid with two programmatically blocked cells and one programmatically + blocked item + </p> + <example1-generic></example1-generic> + + <h2>Example 2: "Tic-Tac-Toe"</h2> + <p> + A grid functioning as a playing field and another functioning as a + container for playing pieces. You can drag as many pieces as you want from + the container to the field. + </p> + + <example2-tic-tac-toe></example2-tic-tac-toe> + + <h2>Example 3: Counters</h2> + <p> + Showcasing local and global state: The local state of the counter + components doesn't change when moved inside one grid, but is cleared when + moved to the other. The global state is in the <code>data</code> attribute + of each item and gets transferred as well. Both grids have also custom + highlights. + </p> + + <example3-counters></example3-counters> + + <h2>Example 4: Dynamic lessons</h2> + <p> + These lessons are loaded from <code>computed</code> to simulate a + non-editable source like an API. they are changed using the method + <code>handleLessonMoved</code>. Try changing the sizes of Lesson1, the + grid and the texts of the items! The lesson container on the side doesn't + have a highlight and doesn't keep track of the item position. Try moving + <code>item3</code> back and forth! + </p> + + <example4-lessons></example4-lessons> + + <h2>Example 5: Dynamic colors</h2> + <p> + This example showcases the <code>rawItem</code> with the custom method + <code>getColor</code>. Both grids on the outside call the method with + <code>"red"</code> while the one in the middle uses <code>"green"</code>. + We use arrays called <code>placedA</code>, <code>placedB</code> and + <code>placedC</code> to save, which item is contained in which grid. + Notice: the third grid doesn't save the item position, they are always + positioned automatically. + </p> + <example5-colors></example5-colors> + + <div class="row"> + <div> + <h2>Example 6: Disabled grid</h2> + <p> + This uses the same data as the tic tac toe but is completely disabled. + Notice how the items still move if the data were changed. The grid can + also be loading. Uncheck the checkbox to enable: + </p> + + <example6-disabled></example6-disabled> + </div> + <div style="height: 100%"> + <h2>Example 7: Disabled fields and items with props</h2> + <p> + This is a grid with disabled fields and items. Red items are disabled + and cannot be moved. + </p> + + <example7-disabled-items></example7-disabled-items> + </div> + </div> + + <h2>Example 8:</h2> + <p>The grid is responsive. Try resizing it below:</p> + <example8-responsive></example8-responsive> + </div> +</template> + +<script> +export default { + name: "App", +}; +</script> + +<style scoped> +.row { + display: flex; + justify-content: space-between; +} + +h2 { + padding-top: 1em; +} + +h2 + p { + padding-bottom: 0.5em; +} + +code { + background: lightgray; + padding: 0.2em; + border-radius: 3px; +} +</style> diff --git a/example/src/Example1Generic.vue b/example/src/Example1Generic.vue new file mode 100644 index 0000000000000000000000000000000000000000..23092900f28d42d676a1bef3e84c0dc66a1b7724 --- /dev/null +++ b/example/src/Example1Generic.vue @@ -0,0 +1,100 @@ +<script setup> +import DragGrid from "../../src/DragGrid.vue"; +</script> + +<template> + <DragGrid + :rows="8" + :cols="5" + :pos-validation="blockField" + v-model="items" + class="bordered" + > + <div id="blocker"> + This field and the next one are blocked. + <div>→</div> + </div> + <template #item="item"> + <div class="container">{{ item }}</div> + </template> + </DragGrid> +</template> + +<script> +export default { + name: "Example1Generic", + methods: { + blockField(x, y, key) { + // We won't move fields with ID 'obj8' and nothing into (3, 3) and (4, 3) + if (x === 3 && y === 3) return false; + if (x === 4 && y === 3) return false; + return key !== "obj8"; + }, + }, + data() { + return { + items: [ + { x: 1, y: 3, w: 1, h: 1, key: "obj1", data: {} }, + { x: 2, y: 1, w: 1, h: 1, key: "obj2", data: {} }, + { x: 3, y: 1, w: 2, h: 2, key: "obj3", data: {} }, + { x: 5, y: 2, w: 1, h: 1, key: "obj4", data: {} }, + { x: 1, y: 1, w: 1, h: 2, key: "obj5", data: {} }, + { x: 5, y: 1, w: 1, h: 1, key: "obj6", data: {} }, + { x: 2, y: 2, w: 1, h: 3, key: "obj7", data: {} }, + { + x: 1, + y: 4, + w: 1, + h: 1, + key: "obj8", + data: { title: "I'm blocked from moving!" }, + }, + { x: 5, y: 3, w: 1, h: 1, key: "obj9", data: {} }, + ], + }; + }, +}; +</script> + +<style scoped> +#blocker { + grid-row: 3 / span 1; + grid-column: 3 / span 1; + background-image: linear-gradient( + 45deg, + #edd85f 25%, + #0f2b3d 25%, + #0f2b3d 50%, + #edd85f 50%, + #edd85f 75%, + #0f2b3d 75%, + #0f2b3d 100% + ); + background-size: 56.57px 56.57px; + color: white; + font-size: large; + font-weight: bold; + text-shadow: 4px 4px 4px #2c3e50; + position: relative; + padding: 1em; +} + +#blocker > div { + position: absolute; + right: 0.5rem; + color: red; + font-size: 5em; + top: 50%; + transform: translate(0, -50%); +} + +.container { + background: lightcoral; + width: 100%; + height: 100%; +} + +.bordered { + border: 2px solid grey; +} +</style> diff --git a/example/src/Example2TicTacToe.vue b/example/src/Example2TicTacToe.vue new file mode 100644 index 0000000000000000000000000000000000000000..be5bf394dc6b7a3d7d34bd55bebdec94b05e6bbe --- /dev/null +++ b/example/src/Example2TicTacToe.vue @@ -0,0 +1,81 @@ +<script setup> +import CircularCard from "./components/CircularCard.vue"; +import DragGrid from "../../src/DragGrid.vue"; +</script> + +<template> + <div class="ttt-container"> + <DragGrid + :rows="3" + :cols="3" + v-model="ticTacToe1" + class="tic-tac-toe" + context="ticTacToe" + :validate-element="randomKey" + > + <template #item="item"> + <CircularCard> + {{ item.key.startsWith("a") ? "X" : "O" }} + </CircularCard> + </template> + </DragGrid> + <div> + <p> + These two are two different grids but we can drag from right to left! + </p> + <p>Drag items from the container on the right to play on the left.</p> + </div> + <DragGrid + :rows="1" + :cols="2" + v-model="ticTacToe2" + class="tic-tac-toe" + context="ticTacToe" + :pos-validation="blockAllMoving" + > + <template #item="item"> + <CircularCard> + {{ item.data.text }} + </CircularCard> + </template> + </DragGrid> + </div> +</template> + +<script> +export default { + name: "Example2TicTacToe", + methods: { + blockAllMoving() { + return false; + }, + randomKey(element) { + if (element.key.length !== 1) return; + element.key += Math.random().toString(36).replace("0.", ""); + }, + }, + data() { + return { + ticTacToe1: [ + { x: 1, y: 1, w: 1, h: 1, key: "a1", data: { text: "X" } }, + { x: 3, y: 3, w: 1, h: 1, key: "b1", data: { text: "O" } }, + ], + ticTacToe2: [ + { x: 1, y: 1, w: 1, h: 1, key: "a", data: { text: "X" } }, + { x: 2, y: 1, w: 1, h: 1, key: "b", data: { text: "O" } }, + ], + }; + }, +}; +</script> + +<style scoped> +.tic-tac-toe { + max-width: 400px; +} + +.ttt-container { + display: flex; + justify-content: space-between; +} +</style> diff --git a/example/src/Example3Counters.vue b/example/src/Example3Counters.vue new file mode 100644 index 0000000000000000000000000000000000000000..9f885516294398eecebd26775a4fd3c490f3dd75 --- /dev/null +++ b/example/src/Example3Counters.vue @@ -0,0 +1,77 @@ +<script setup> +import NumberCounter from "./components/NumberCounter.vue"; +import DragGrid from "../../src/DragGrid.vue"; +</script> + +<template> + <div class="row"> + <drag-grid + v-model="counters" + :cols="3" + :rows="2" + id="c-grid" + @input="logInput" + context="counter" + > + <template #item="item"> + <number-counter v-model="item.data.num"></number-counter> + </template> + <template #highlight> + <div ref="highlight" class="custom-highlight"> + This is a custom highlight! + </div> + </template> + </drag-grid> + <span>↠Drag here please →</span> + <drag-grid v-model="counters2" :cols="3" :rows="2" context="counter"> + <template #item="item"> + <number-counter v-model="item.data.num"></number-counter> + </template> + <template #highlight> + <div ref="highlight" class="custom-highlight"> + This is a custom highlight! + </div> + </template> + </drag-grid> + </div> +</template> + +<script> +export default { + name: "Example3Counters", + methods: { + logInput(input) { + console.log("New movement detected:", input); + }, + }, + data() { + return { + counters: [ + { x: 1, y: 1, w: 1, h: 1, key: "a", data: { num: 1 } }, + { x: 2, y: 1, w: 1, h: 1, key: "b", data: { num: 4 } }, + { x: 3, y: 2, w: 1, h: 1, key: "c", data: { num: -1 } }, + ], + counters2: [ + { x: 1, y: 2, w: 1, h: 1, key: "d", data: { num: 30 } }, + { x: 3, y: 1, w: 1, h: 1, key: "e", data: { num: 3 } }, + ], + }; + }, +}; +</script> + +<style scoped> +.row { + display: flex; + justify-content: space-between; +} +.custom-highlight { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + background: aquamarine; + width: 100%; + height: 100%; +} +</style> diff --git a/example/src/Example4Lessons.vue b/example/src/Example4Lessons.vue new file mode 100644 index 0000000000000000000000000000000000000000..f321a7ed0f0d46ac4bde3608df20679db06c84e0 --- /dev/null +++ b/example/src/Example4Lessons.vue @@ -0,0 +1,210 @@ +<script setup> +import DragGrid from "../../src/DragGrid.vue"; +</script> + +<template> + <div> + <div class="row"> + <div> + <label> + Lesson 1 has length: {{ lesson1Length }} + <input + type="range" + v-model="lesson1Length" + min="1" + max="5" + step="1" + /> + </label> + <label for="lesson1">Text of Lesson 1:</label> + <input id="lesson1" type="text" v-model="lessonData.lesson1" /> + </div> + <div> + <label> + Lesson grid has width: {{ lesson1Cols }} + <input type="range" v-model="lesson1Cols" min="2" max="5" step="1" /> + </label> + <label for="lesson2">Text of Lesson 2:</label> + <input id="lesson2" type="text" v-model="lessonData.lesson2" /> + </div> + <div> + <label> + Lesson grid has height: {{ lesson1Rows }} + <input type="range" v-model="lesson1Rows" min="6" max="10" step="1" /> + </label> + + <label for="lesson3">Texts of Lesson 3:</label> + <input id="lesson3" type="text" v-model="lessonData.lesson3.inside" /> + <input type="text" v-model="lessonData.lesson3.outside" /> + </div> + </div> + + <div class="row"> + <drag-grid + :value="lessons1" + :cols="parseInt(lesson1Cols)" + :rows="parseInt(lesson1Rows)" + class="bordered" + context="lesson" + grid-id="lesson-plan" + @itemChanged="handleLessonMoved" + > + <template #item="item"> + <div class="container">{{ item }}</div> + </template> + </drag-grid> + <drag-grid + :value="lessons1" + :cols="1" + :rows="10" + context="lesson" + grid-id="lesson-storage" + @itemChanged="handleLessonDropInContainer" + no-highlight + class="bordered" + > + <template #item="item"> + <div class="container">{{ item }}</div> + </template> + </drag-grid> + </div> + </div> +</template> + +<script> +export default { + name: "Example4Lessons", + data() { + return { + lesson1Length: 1, + lesson1Cols: 4, + lesson1Rows: 6, + lesson1X: 1, + lesson1Y: 1, + lesson2X: 2, + lesson2Y: 1, + lesson3X: -1, + lesson3Y: -1, + lesson3inPlan: false, + lessonData: { + lesson1: "Nothing", + lesson2: "Hallo", + lesson3: { + inside: "This is lesson 3 inside the plan", + outside: "This is lesson3 outside of the plan", + }, + }, + }; + }, + methods: { + logInput(input) { + console.log("New movement detected:", input); + }, + handleLessonMoved(lesson) { + this.logInput(lesson); + switch (lesson.key) { + case "lesson1": + this.lesson1X = lesson.x; + this.lesson1Y = lesson.y; + break; + case "lesson2": + this.lesson2X = lesson.x; + this.lesson2Y = lesson.y; + break; + case "lesson3": + this.lesson3X = lesson.x; + this.lesson3Y = lesson.y; + this.lesson3inPlan = true; + break; + default: + console.error("Something is wrong here!"); + } + }, + handleLessonDropInContainer(lesson) { + if (lesson.key === "lesson3") this.lesson3inPlan = false; + }, + }, + computed: { + lessons1() { + return [ + { + x: (grid) => { + return grid.gridId === "lesson-plan" ? this.lesson1X : 0; + }, + y: (grid) => { + return grid.gridId === "lesson-plan" ? this.lesson1Y : 0; + }, + w: 1, + h: this.lesson1Length, + key: "lesson1", + data: { + text: this.lessonData.lesson1, + }, + }, + { + x: (grid) => { + return grid.gridId === "lesson-plan" ? this.lesson2X : 0; + }, + y: (grid) => { + return grid.gridId === "lesson-plan" ? this.lesson2Y : 0; + }, + w: 1, + h: parseInt(this.lesson1Length) + 1, + key: "lesson2", + data: { + text: this.lessonData.lesson2, + }, + }, + { + x: (grid) => { + if (this.lesson3inPlan) { + return grid.gridId === "lesson-plan" ? this.lesson3X : -1; + } + return grid.gridId === "lesson-plan" ? -1 : 0; + }, + y: (grid) => { + if (this.lesson3inPlan) { + return grid.gridId === "lesson-plan" ? this.lesson3Y : -1; + } + return grid.gridId === "lesson-plan" ? -1 : 0; + }, + w: 1, + h: parseInt(this.lesson1Length) + 1, + key: "lesson3", + data: (grid) => { + return { + text: + grid.gridId === "lesson-storage" + ? this.lessonData.lesson3.outside + : this.lessonData.lesson3.inside, + }; + }, + }, + ]; + }, + }, +}; +</script> + +<style scoped> +.row { + display: flex; + justify-content: space-between; +} + +.row > div:not(.bordered) { + display: flex; + flex-direction: column; + gap: 3px; +} + +.bordered { + border: 2px solid grey; +} + +.container { + background: lightcoral; + width: 100%; + height: 100%; +} +</style> diff --git a/example/src/Example5Colors.vue b/example/src/Example5Colors.vue new file mode 100644 index 0000000000000000000000000000000000000000..47c2d4ec9345724a0c0a4e2778cf4dbd66a6d551 --- /dev/null +++ b/example/src/Example5Colors.vue @@ -0,0 +1,229 @@ +<script setup> +import DragGrid from "../../src/DragGrid.vue"; +</script> + +<template> + <div class="row"> + <drag-grid + :value="colorGrid" + :cols="4" + :rows="4" + grid-id="gridA" + context="colors" + class="bordered color-grid" + @itemChanged="handleColorItemChange" + > + <template #item="item"> + <div + :style="{ + background: item.rawItem.getColor('red'), + color: 'white', + }" + > + {{ item.data.name }} + </div> + </template> + </drag-grid> + <drag-grid + :value="colorGrid" + :cols="4" + :rows="4" + grid-id="gridB" + context="colors" + class="bordered color-grid" + @itemChanged="handleColorItemChange" + > + <template #item="item"> + <div + :style="{ + background: item.rawItem.getColor('green'), + color: 'black', + }" + > + {{ item.data.name }} + </div> + </template> + </drag-grid> + <drag-grid + :value="colorGrid" + :cols="4" + :rows="4" + grid-id="gridC" + context="colors" + class="bordered color-grid" + @itemChanged="handleColorItemChange" + > + <template #item="item"> + <div + :style="{ + background: item.rawItem.getColor('red'), + color: 'white', + }" + > + {{ item.data.name }} + </div> + </template> + </drag-grid> + </div> +</template> + +<script> +export default { + name: "Example5Colors", + methods: { + contains(array, search) { + return array.filter((obj) => obj === search).length > 0; + }, + findAndRemove(array, element) { + let index = array.findIndex((i) => { + return i === element; + }); + if (index >= 0) array.splice(index, 1); + }, + handleColorItemChange(item) { + this[item.key].x = item.x; + this[item.key].y = item.y; + + if (item.gridId === item.originGridId) return; + + switch (item.gridId) { + case "gridA": + // Moved to gridA + this.findAndRemove( + item.originGridId === "gridB" ? this.placedB : this.placedC, + item.key + ); + this.placedA.push(item.key); + break; + case "gridB": + // Moved to gridB + this.findAndRemove( + item.originGridId === "gridA" ? this.placedA : this.placedC, + item.key + ); + this.placedB.push(item.key); + break; + case "gridC": + // Moved to gridC + this.findAndRemove( + item.originGridId === "gridB" ? this.placedB : this.placedA, + item.key + ); + this.placedC.push(item.key); + break; + } + }, + }, + data() { + return { + placedA: ["item1", "item2"], + placedB: ["item3", "item4"], + placedC: ["item5", "item6"], + item1: { + x: 1, + y: 1, + }, + item2: { + x: 2, + y: 2, + }, + item3: { + x: 1, + y: 1, + }, + item4: { + x: 3, + y: 3, + }, + item5: { + x: 2, + y: 4, + }, + item6: { + x: 4, + y: 2, + }, + }; + }, + computed: { + colorGrid() { + let items = ["item1", "item2", "item3", "item4", "item5", "item6"]; + let greens = [ + "green", + "seagreen", + "greenyellow", + "darkolivegreen", + "limegreen", + "palegreen", + ]; + let reds = [ + "darkred", + "indianred", + "orangered", + "red", + "maroon", + "firebrick", + ]; + return items.map((itemName) => { + return { + key: itemName, + x: (grid) => { + if (grid.gridId === "gridA") { + return this.contains(this.placedA, itemName) + ? this[itemName].x + : -1; + } else if (grid.gridId === "gridB") { + return this.contains(this.placedB, itemName) + ? this[itemName].x + : -1; + } else { + // gridC + return this.contains(this.placedC, itemName) ? 0 : -1; + } + }, + y: (grid) => { + if (grid.gridId === "gridA") { + return this.contains(this.placedA, itemName) + ? this[itemName].y + : -1; + } else if (grid.gridId === "gridB") { + return this.contains(this.placedB, itemName) + ? this[itemName].y + : -1; + } else { + // gridC + return this.contains(this.placedC, itemName) ? 0 : -1; + } + }, + w: 1, + h: 1, + data: { + name: itemName, + }, + getColor: (a) => + a === "red" + ? reds[parseInt(itemName[4]) - 1] + : greens[parseInt(itemName[4]) - 1], + }; + }); + }, + }, +}; +</script> + +<style scoped> +.row { + display: flex; + justify-content: space-between; +} + +.bordered { + border: 2px solid grey; +} + +.color-grid { + min-height: 11em; + line-height: 2em; + box-sizing: content-box; +} +</style> diff --git a/example/src/Example6Disabled.vue b/example/src/Example6Disabled.vue new file mode 100644 index 0000000000000000000000000000000000000000..38bd6cd7e33036ff294dec72465e44b04b8249ce --- /dev/null +++ b/example/src/Example6Disabled.vue @@ -0,0 +1,61 @@ +<script setup> +import CircularCard from "./components/CircularCard.vue"; +import DragGrid from "../../src/DragGrid.vue"; +import SpinningLoader from "./components/SpinningLoader.vue"; +</script> + +<template> + <div> + <label> + <input type="checkbox" v-model="gridDisabled" /> + Grid disabled? + </label> + <label> + <input type="checkbox" v-model="gridLoading" /> + Grid is loading? + </label> + + <drag-grid + v-model="ticTacToe" + :cols="3" + :rows="3" + :disabled="gridDisabled" + :loading="gridLoading" + class="tic-tac-toe" + context="ticTacToe" + > + <template #item="item"> + <CircularCard> + {{ item.key.startsWith("a") ? "X" : "O" }} + </CircularCard> + </template> + <template #loader> + <CircularCard> + <SpinningLoader></SpinningLoader> + </CircularCard> + </template> + </drag-grid> + </div> +</template> + +<script> +export default { + name: "Example6Disabled", + data() { + return { + ticTacToe: [ + { x: 1, y: 1, w: 1, h: 1, key: "a1", data: { text: "X" } }, + { x: 3, y: 3, w: 1, h: 1, key: "b1", data: { text: "O" } }, + ], + gridDisabled: true, + gridLoading: false, + }; + }, +}; +</script> + +<style scoped> +.tic-tac-toe { + max-width: 400px; +} +</style> diff --git a/example/src/Example7DisabledItems.vue b/example/src/Example7DisabledItems.vue new file mode 100644 index 0000000000000000000000000000000000000000..dff2a1d248466e8e5bc1f1628b483abd0dd58c8d --- /dev/null +++ b/example/src/Example7DisabledItems.vue @@ -0,0 +1,59 @@ +<script setup> +import DragGrid from "../../src/DragGrid.vue"; +</script> + +<template> + <drag-grid + v-model="someDisabledItems" + :cols="4" + :rows="4" + class="size" + :disabled-fields="disabledFields" + > + <template #item="{ rawItem }"> + <div + class="container" + :style="{ background: rawItem.disabled ? 'red' : 'green' }" + ></div> + </template> + <template #disabledField + ><div class="container">This field is disabled!</div></template + > + </drag-grid> +</template> + +<script> +export default { + name: "Example7DisabledItems", + data() { + return { + someDisabledItems: [ + { key: "key1", x: 1, y: 3, w: 1, h: 1, data: {} }, + { key: "key2", x: 2, y: 2, w: 1, h: 1, data: {} }, + { key: "key3", x: 3, y: 4, w: 1, h: 1, data: {} }, + { key: "key4", x: 3, y: 1, w: 1, h: 1, data: {}, disabled: true }, + { key: "key5", x: 1, y: 2, w: 1, h: 1, data: {}, disabled: true }, + { key: "key6", x: 4, y: 3, w: 1, h: 1, data: {}, disabled: true }, + ], + disabledFields: [ + { x: 1, y: 1 }, + { x: 2, y: 3 }, + { x: 4, y: 2 }, + ], + }; + }, +}; +</script> + +<style scoped> +.container { + background: lightcoral; + width: 100%; + height: 100%; + user-select: none; + text-align: center; +} +.size { + aspect-ratio: 1; +} +</style> diff --git a/example/src/Example8Responsive.vue b/example/src/Example8Responsive.vue new file mode 100644 index 0000000000000000000000000000000000000000..ebf541902d2c839fcf5433428fbee596d618f6ca --- /dev/null +++ b/example/src/Example8Responsive.vue @@ -0,0 +1,46 @@ +<template> + <div class="parent"> + <drag-grid v-model="items" :cols="4" :rows="4" class="grid"> + <template #item="{ key }"> + <div class="banana"> + {{ key }} + </div> + </template> + </drag-grid> + </div> +</template> + +<script> +export default { + name: "Example8Responsive", + data() { + return { + items: [ + { x: 1, y: 1, w: 1, h: 3, key: "item 1" }, + { x: 4, y: 3, w: 1, h: 1, key: "item 2" }, + { x: 2, y: 4, w: 2, h: 1, key: "item 3" }, + ], + }; + }, +}; +</script> + +<style scoped> +.parent { + min-height: 600px; +} + +.grid { + resize: both; + overflow: auto; + aspect-ratio: 1; + border: #edd85f 2px solid; + width: 400px; +} + +.banana { + background: #edd85f; + width: 100%; + height: 100%; +} +</style> diff --git a/example/src/assets/base.css b/example/src/assets/base.css new file mode 100644 index 0000000000000000000000000000000000000000..71dc55a3cb5a72589496743a327c738ead3e1c83 --- /dev/null +++ b/example/src/assets/base.css @@ -0,0 +1,74 @@ +/* color palette from <https://github.com/vuejs/theme> */ +:root { + --vt-c-white: #ffffff; + --vt-c-white-soft: #f8f8f8; + --vt-c-white-mute: #f2f2f2; + + --vt-c-black: #181818; + --vt-c-black-soft: #222222; + --vt-c-black-mute: #282828; + + --vt-c-indigo: #2c3e50; + + --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); + --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); + --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); + --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); + + --vt-c-text-light-1: var(--vt-c-indigo); + --vt-c-text-light-2: rgba(60, 60, 60, 0.66); + --vt-c-text-dark-1: var(--vt-c-white); + --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); +} + +/* semantic color variables for this project */ +:root { + --color-background: var(--vt-c-white); + --color-background-soft: var(--vt-c-white-soft); + --color-background-mute: var(--vt-c-white-mute); + + --color-border: var(--vt-c-divider-light-2); + --color-border-hover: var(--vt-c-divider-light-1); + + --color-heading: var(--vt-c-text-light-1); + --color-text: var(--vt-c-text-light-1); + + --section-gap: 160px; +} + +@media (prefers-color-scheme: dark) { + :root { + --color-background: var(--vt-c-black); + --color-background-soft: var(--vt-c-black-soft); + --color-background-mute: var(--vt-c-black-mute); + + --color-border: var(--vt-c-divider-dark-2); + --color-border-hover: var(--vt-c-divider-dark-1); + + --color-heading: var(--vt-c-text-dark-1); + --color-text: var(--vt-c-text-dark-2); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + position: relative; + font-weight: normal; +} + +body { + min-height: 100vh; + color: var(--color-text); + background: var(--color-background); + transition: color 0.5s, background-color 0.5s; + line-height: 1.6; + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, + Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + font-size: 15px; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/example/src/assets/logo.svg b/example/src/assets/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..bc826fed80ad0c846e5ca25978776f555f4a2370 --- /dev/null +++ b/example/src/assets/logo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69" xmlns:v="https://vecta.io/nano"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg> \ No newline at end of file diff --git a/example/src/assets/main.css b/example/src/assets/main.css new file mode 100644 index 0000000000000000000000000000000000000000..91f9b48b30f85bbcfb0245d743d44f8c730ea501 --- /dev/null +++ b/example/src/assets/main.css @@ -0,0 +1,31 @@ +@import "./base.css"; + +#app { + max-width: 1280px; + min-width: 80vw; + margin: 0 auto; + padding: 2rem; + + font-weight: normal; +} + +a, +.green { + text-decoration: none; + color: hsla(160, 100%, 37%, 1); + transition: 0.4s; +} + +@media (hover: hover) { + a:hover { + background-color: hsla(160, 100%, 37%, 0.2); + } +} + +@media (min-width: 1024px) { + body { + display: flex; + flex-direction: column; + place-items: center; + } +} diff --git a/example/src/components/CircularCard.vue b/example/src/components/CircularCard.vue new file mode 100644 index 0000000000000000000000000000000000000000..bb2440a38d32873e9166efb44906780c15f46001 --- /dev/null +++ b/example/src/components/CircularCard.vue @@ -0,0 +1,26 @@ +<template> + <div> + <span><slot></slot></span> + </div> +</template> + +<script> +export default { + name: "CircularCard", +}; +</script> + +<style scoped> +div { + aspect-ratio: 1/1; + border-radius: 100%; + min-height: 2em; + box-shadow: 2px 2px 1px 0 rgba(0, 0, 0, 0.75); + -webkit-box-shadow: 2px 2px 1px 0 rgba(0, 0, 0, 0.75); + -moz-box-shadow: 2px 2px 1px 0 rgba(0, 0, 0, 0.75); + display: flex; + justify-content: center; + align-items: center; + font-size: large; +} +</style> diff --git a/example/src/components/NumberCounter.vue b/example/src/components/NumberCounter.vue new file mode 100644 index 0000000000000000000000000000000000000000..62cc6c1a0d92ab1de9943ffd6b4204b70afdacac --- /dev/null +++ b/example/src/components/NumberCounter.vue @@ -0,0 +1,52 @@ +<template> + <div> + <p>External counter:</p> + <div class="row"> + <button @click="$emit('input', value - 1)">-</button> + {{ value }} + <button @click="$emit('input', value + 1)">+</button> + </div> + + <p>Internal counter:</p> + <div class="row"> + <button @click="count--">-</button> + {{ count }} + <button @click="count++">+</button> + </div> + </div> +</template> + +<script> +export default { + name: "NumberCounter", + props: { + value: { + type: Number, + required: true, + }, + }, + emits: ["input"], + data() { + return { + count: 0, + }; + }, +}; +</script> + +<style scoped> +div { + background: #2c3e50; + color: white; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 0.2em; + gap: 3px; +} +div.row { + flex-direction: row; + gap: 1em; +} +</style> diff --git a/example/src/components/SpinningLoader.vue b/example/src/components/SpinningLoader.vue new file mode 100644 index 0000000000000000000000000000000000000000..b22bedcf568a70155d9c541d775ecabc3e99bc20 --- /dev/null +++ b/example/src/components/SpinningLoader.vue @@ -0,0 +1,107 @@ +<!-- +This Loader is based on the loader "spinner" from https://loading.io/css/ +--> + +<template> + <div class="lds-spinner"> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + <div></div> + </div> +</template> + +<script> +export default { + name: "SpinningLoader", +}; +</script> + +<style scoped> +.lds-spinner { + color: inherit; + display: inline-block; + position: relative; + width: 80px; + height: 80px; +} +.lds-spinner div { + transform-origin: 40px 40px; + animation: lds-spinner 1.2s linear infinite; +} +.lds-spinner div:after { + content: " "; + display: block; + position: absolute; + top: 3px; + left: 37px; + width: 6px; + height: 18px; + border-radius: 20%; + background: #fff; +} +.lds-spinner div:nth-child(1) { + transform: rotate(0deg); + animation-delay: -1.1s; +} +.lds-spinner div:nth-child(2) { + transform: rotate(30deg); + animation-delay: -1s; +} +.lds-spinner div:nth-child(3) { + transform: rotate(60deg); + animation-delay: -0.9s; +} +.lds-spinner div:nth-child(4) { + transform: rotate(90deg); + animation-delay: -0.8s; +} +.lds-spinner div:nth-child(5) { + transform: rotate(120deg); + animation-delay: -0.7s; +} +.lds-spinner div:nth-child(6) { + transform: rotate(150deg); + animation-delay: -0.6s; +} +.lds-spinner div:nth-child(7) { + transform: rotate(180deg); + animation-delay: -0.5s; +} +.lds-spinner div:nth-child(8) { + transform: rotate(210deg); + animation-delay: -0.4s; +} +.lds-spinner div:nth-child(9) { + transform: rotate(240deg); + animation-delay: -0.3s; +} +.lds-spinner div:nth-child(10) { + transform: rotate(270deg); + animation-delay: -0.2s; +} +.lds-spinner div:nth-child(11) { + transform: rotate(300deg); + animation-delay: -0.1s; +} +.lds-spinner div:nth-child(12) { + transform: rotate(330deg); + animation-delay: 0s; +} +@keyframes lds-spinner { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} +</style> diff --git a/example/src/main.js b/example/src/main.js new file mode 100644 index 0000000000000000000000000000000000000000..2131d413d709e613fddf8d70525aac0cd82d7ed3 --- /dev/null +++ b/example/src/main.js @@ -0,0 +1,12 @@ +import Vue from "vue"; +import App from "./App.vue"; + +import draggableGrid from "../../src/index.js"; + +import "./assets/main.css"; + +Vue.use(draggableGrid); + +new Vue({ + render: (h) => h(App), +}).$mount("#app"); diff --git a/example/vite.config.js b/example/vite.config.js new file mode 100644 index 0000000000000000000000000000000000000000..0994acce87b7a26d69a4778c00e670eda39d9a93 --- /dev/null +++ b/example/vite.config.js @@ -0,0 +1,21 @@ +import { fileURLToPath, URL } from "node:url"; + +import { defineConfig } from "vite"; +import legacy from "@vitejs/plugin-legacy"; +import vue2 from "@vitejs/plugin-vue2"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + vue2(), + legacy({ + targets: ["ie >= 11"], + additionalLegacyPolyfills: ["regenerator-runtime/runtime"], + }), + ], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, +}); diff --git a/package.json b/package.json index 72adff604b42527230310b622cd4ff585ef8311e..143a458c32f3c8fde825400ef169b9c25e2e3b78 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,30 @@ { "name": "vue-draggable-grid", - "version": "1.0.0", + "version": "0.1.0", "scripts": { - "build": "rollup -c", + "build": "vite build", + "example:build": "vite build example", + "example:dev": "vite example", + "example:preview": "vite preview example --port 4173", "docs:dev": "vuepress dev docs", - "docs:build": "vuepress build docs" + "docs:build": "vuepress build docs", + "lint": "prettier --check . && eslint . --ext .vue,.js,.jsx,.cjs,.mjs --ignore-path .gitignore", + "reformat": "prettier --write . && eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore" }, - "keywords": [], - "author": "", - "license": "ISC", - "description": "", - "main": "dist/vue-draggable-grid.js", - "module": "dist/vue-draggable-grid.mjs", + "keywords": [ + "vue", + "vuejs", + "vue2", + "drag", + "drag", + "drop", + "draggable", + "dragndrop" + ], + "author": "Julian Leucker <leuckeju@commu.teckids.org>", + "license": "Apache-2.0", + "description": "VueJS Components to move data inside a grid via drag&drop.", + "main": "src/index.js", "files": [ "dist/*" ], @@ -19,9 +32,22 @@ "vue": "^2.7.14" }, "devDependencies": { + "@rushstack/eslint-patch": "^1.1.0", + "@vitejs/plugin-legacy": "^2.0.0", + "@vitejs/plugin-vue2": "^1.1.2", + "@vue/eslint-config-prettier": "^7.0.0", + "eslint": "^8.5.0", + "eslint-plugin-vue": "^9.0.0", + "prettier": "^2.8.4", "rollup": "^3.12.1", "rollup-plugin-peer-deps-external": "^2.2.4", "rollup-plugin-vue": "^6.0.0", + "terser": "^5.14.2", + "vite": "^3.0.2", "vuepress": "^1.9.8" + }, + "dependencies": { + "uuid": "^9.0.0", + "vue-interactjs": "^0.1.10" } } diff --git a/rollup.config.mjs b/rollup.config.mjs deleted file mode 100644 index c27dc06471784469d8645ba3c69bf2c1ba99a781..0000000000000000000000000000000000000000 --- a/rollup.config.mjs +++ /dev/null @@ -1,21 +0,0 @@ -import vue from 'rollup-plugin-vue' -import peerDepsExternal from 'rollup-plugin-peer-deps-external' - -export default [ - { - input: 'src/index.js', - output: [ - { - format: 'esm', - file: 'dist/vue-draggable-grid.mjs' - }, - { - format: 'cjs', - file: 'dist/vue-draggable-grid.js' - } - ], - plugins: [ - vue(), peerDepsExternal() - ] - } -] \ No newline at end of file diff --git a/src/Component.vue b/src/Component.vue deleted file mode 100644 index 214828e8498041a85a3050cf8c1f634c407c6a3e..0000000000000000000000000000000000000000 --- a/src/Component.vue +++ /dev/null @@ -1,8 +0,0 @@ -<template> - <div/> -</template> -<script> -export default { - name: 'Component' -} -</script> \ No newline at end of file diff --git a/src/DragContainer.vue b/src/DragContainer.vue new file mode 100644 index 0000000000000000000000000000000000000000..6c2b6441dc6bd9fbdf268e9a8e0cd19aafadce7f --- /dev/null +++ b/src/DragContainer.vue @@ -0,0 +1,127 @@ +<template> + <interact + @dragstart="handleDragStart" + @dragmove="handleDragMove" + :draggable="!isDisabled" + id="wrapper" + ref="wrapper" + @dragend="handleDragEnd" + :data-transfer="dataTransferString" + > + <slot></slot> + </interact> +</template> + +<script> +export default { + name: "DragContainer", + methods: { + handleDragStart(event) { + if (this.isDisabled) return; + let rect = event.target.getBoundingClientRect(); + this.dataTransfer = { + key: this.dragID, + x: this.x, + y: this.y, + w: this.w, + h: this.h, + data: this.data, + context: this.context, + originGridId: this.gridId, + mouseX: event.clientX - rect.x - rect.width / (2 * this.w), // relative to center of the top left square + mouseY: event.clientY - rect.y - rect.height / (2 * this.h), + }; + }, + handleDragEnd() { + this.offsetX = 0; + this.offsetY = 0; + }, + handleDragMove(event) { + this.offsetX += event.dx; + this.offsetY += event.dy; + }, + }, + props: { + dragID: { + type: String, + required: true, + }, + x: { + type: Number, + required: true, + }, + y: { + type: Number, + required: true, + }, + w: { + type: Number, + required: true, + }, + h: { + type: Number, + required: true, + }, + data: { + type: Object, + required: false, + default: () => ({}), + }, + context: { + type: String, + required: true, + }, + gridId: { + type: String, + required: true, + }, + disabled: Boolean, + }, + computed: { + isInGrid() { + return this.x >= 0 && this.y >= 0; + }, + isNotInGrid() { + return this.x === -1 || this.y === -1; + }, + getX() { + return this.x === 0 ? "auto" : this.x; + }, + getY() { + return this.y === 0 ? "auto" : this.y; + }, + getDisplay() { + return this.isInGrid ? "block" : "none"; + }, + isDisabled() { + return this.disabled || this.isNotInGrid; + }, + cursor() { + return this.disabled ? "auto" : "grab"; + }, + dataTransferString() { + return JSON.stringify(this.dataTransfer); + }, + }, + data() { + return { + dataTransfer: {}, + offsetX: 0, + offsetY: 0, + }; + }, +}; +</script> + +<style scoped> +#wrapper { + grid-column: v-bind(getX) / span v-bind(w); + grid-row: v-bind(getY) / span v-bind(h); + display: v-bind(getDisplay); + cursor: v-bind(cursor); + transform: translate( + calc(1px * v-bind(offsetX)), + calc(1px * v-bind(offsetY)) + ); +} +</style> diff --git a/src/DragGrid.vue b/src/DragGrid.vue new file mode 100644 index 0000000000000000000000000000000000000000..025bdfe0a1e092b9edb1015accf1975f50157c62 --- /dev/null +++ b/src/DragGrid.vue @@ -0,0 +1,335 @@ +<template> + <interact + droppable + @dropmove="disabled || loading ? undefined : handleDragOver($event)" + @drop.prevent="disabled || loading ? undefined : handleDrop($event)" + @dragleave="$refs.highlightContainer.style.display = 'none'" + class="grid" + > + <template v-if="loading"> + <template v-for="loader in cols * rows"> + <div :key="loader"> + <slot name="loader"></slot> + </div> + </template> + </template> + + <template v-else> + <div class="highlight-container" ref="highlightContainer"> + <slot name="highlight"> + <div class="highlight"></div> + </slot> + </div> + + <DragContainer + v-for="item in value" + :key="item.key" + :drag-i-d="item.key" + :x="getInt('x', item)" + :y="getInt('y', item)" + :w="getInt('w', item)" + :h="getInt('h', item)" + :data="getObject('data', item)" + :context="context" + :grid-id="gridId" + :disabled="disabled || loading || item.disabled" + > + <slot v-bind="transformItem(item)" :raw-item="item" name="item"> + <dl> + <dt>Key</dt> + <dd>{{ item.key }}</dd> + <dt>Position</dt> + <dd>{{ item.x }}, {{ item.y }}</dd> + <dt>Size</dt> + <dd>{{ item.w }} × {{ item.h }}</dd> + <dt>Data</dt> + <dd>{{ item.data }}</dd> + </dl> + </slot> + </DragContainer> + <template v-for="disabledField in disabledFields"> + <GridItem + class="disabledField" + :x="disabledField.x" + :y="disabledField.y" + :key="disabledField.x + '|' + disabledField.y" + > + <slot name="disabledField"></slot> + </GridItem> + </template> + <slot></slot> + </template> + </interact> +</template> + +<script> +import DragContainer from "./DragContainer.vue"; +import { v4 as uuidv4 } from "uuid"; +import GridItem from "./GridItem.vue"; + +export default { + name: "DragGrid", + components: { + GridItem, + DragContainer, + }, + emits: ["input", "itemChanged"], + props: { + rows: { + type: Number, + required: true, + }, + cols: { + type: Number, + required: true, + }, + posValidation: { + type: Function, + required: false, + default: undefined, + }, + validateElement: { + type: Function, + required: false, + default: undefined, + }, + value: { + type: Array, + required: true, + }, + context: { + type: String, + required: false, + default: uuidv4, + }, + gridId: { + type: String, + required: false, + default: uuidv4, + }, + noHighlight: { + type: Boolean, + required: false, + default: false, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + disabledFields: { + type: Array, + required: false, + default: () => [], + }, + loading: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + positionAllowed(x, y, key) { + if (x < 0 || y < 0) return false; + if (x > this.cols) return false; + if (y > this.rows) return false; + + if ( + this.disabledFields.filter((field) => field.x === x && field.y === y) + .length > 0 + ) { + // Field is disabled + return false; + } + + for (let item of this.value) { + if (key === item.key) continue; + if ( + x >= this.getInt("x", item) && + x < this.getInt("x", item) + this.getInt("w", item) + ) { + if ( + y >= this.getInt("y", item) && + y < this.getInt("y", item) + this.getInt("h", item) + ) { + return false; + } + } + } + if (this.posValidation) return this.posValidation(x, y, key); + return true; + }, + handleDragOver(event) { + let data = event.relatedTarget.dataset.transfer; + if (!data) return; + let element = JSON.parse(data); + let coords = this.getCoords( + event.dragEvent.client.x - element.mouseX, + event.dragEvent.client.y - element.mouseY + ); + + if (element.context !== this.context || this.noHighlight) { + this.$refs.highlightContainer.style.display = "none"; + return; + } + + let newPositionValid = true; + + for (let x = coords.x; x < coords.x + element.w; x++) { + for (let y = coords.y; y < coords.y + element.h; y++) { + newPositionValid = this.positionAllowed(x, y, element.key); + if (!newPositionValid) break; + } + if (!newPositionValid) break; + } + + if (!newPositionValid) { + this.$refs.highlightContainer.style.display = "none"; + return; + } + + this.$refs.highlightContainer.style.display = "block"; + this.$refs.highlightContainer.style.gridColumnStart = coords.x + ""; + this.$refs.highlightContainer.style.gridRowStart = coords.y + ""; + this.$refs.highlightContainer.style.gridColumnEnd = "span " + element.w; + this.$refs.highlightContainer.style.gridRowEnd = "span " + element.h; + }, + handleDrop(event) { + this.$refs.highlightContainer.style.display = "none"; + let data = event.relatedTarget.dataset.transfer; + if (!data) return; + let element = JSON.parse(data); + + if (this.validateElement) this.validateElement(element); + + if (element.context !== this.context) { + return; + } + + let coords = this.getCoords( + event.dragEvent.client.x - element.mouseX, + event.dragEvent.client.y - element.mouseY + ); + + let newPositionValid = true; + + for (let x = coords.x; x < coords.x + element.w; x++) { + for (let y = coords.y; y < coords.y + element.h; y++) { + newPositionValid = this.positionAllowed(x, y, element.key); + if (!newPositionValid) break; + } + if (!newPositionValid) break; + } + + if (!newPositionValid) return; + + element.x = coords.x; + element.y = coords.y; + + try { + let valueCopy = structuredClone(this.value); + + let index = valueCopy.findIndex((i) => { + return i.key === element.key; + }); + if (index >= 0) valueCopy.splice(index, 1); + + let elementCopy = structuredClone(element); + + elementCopy.context = undefined; + elementCopy.originGridId = undefined; + elementCopy.mouseX = undefined; + elementCopy.mouseY = undefined; + + valueCopy.push(elementCopy); + this.$emit("input", valueCopy); + } catch (e) { + if (e.code === DOMException.DATA_CLONE_ERR) { + // We use functions for properties → we can't clone; only emit `item-changed` event + console.debug( + "Grid couldn't be cloned, please listen to the `item-changed` event and handle changes yourself." + ); + } else { + throw e; + } + } + + element.gridId = this.gridId; + + this.$emit("itemChanged", element); + }, + clamp: (min, num, max) => Math.min(Math.max(num, min), max), + getCoords(x, y) { + let rect = this.$el.getBoundingClientRect(); + return { + x: this.clamp( + 1, + Math.ceil((x - rect.x) / (rect.width / this.cols)), + this.cols + ), + y: this.clamp( + 1, + Math.ceil((y - rect.y) / (rect.height / this.rows)), + this.rows + ), + }; + }, + getInt(property, item) { + let val = item[property] || 1; + return val instanceof Function ? val(this.gridData) : parseInt(val); + }, + getObject(property, item) { + let val = item[property] || {}; + return val instanceof Function ? val(this.gridData) : val; + }, + transformItem(item) { + let newItem = { key: item.key }; + newItem.x = this.getInt("x", item); + newItem.y = this.getInt("y", item); + newItem.w = this.getInt("w", item); + newItem.h = this.getInt("h", item); + newItem.data = this.getObject("data", item); + return newItem; + }, + }, + computed: { + gridData() { + return { + gridId: this.gridId, + context: this.context, + }; + }, + }, +}; +</script> + +<style scoped> +.highlight { + background: darkgrey; + border: grey dashed 2px; + width: 100%; + height: 100%; +} + +.highlight-container { + display: none; + transition: all 2s ease-in-out; + pointer-events: none; + user-select: none; + width: 100%; + height: 100%; +} + +.grid { + display: grid; + grid-template-columns: repeat(v-bind(cols), 1fr); + grid-template-rows: repeat(v-bind(rows), 1fr); + width: 100%; + height: 100%; + min-width: 100px; + min-height: 100px; + gap: 1em; + touch-action: none; + isolation: isolate; +} +</style> diff --git a/src/GridItem.vue b/src/GridItem.vue new file mode 100644 index 0000000000000000000000000000000000000000..45972047ca560b857de354c2b66ab2313f7d2c94 --- /dev/null +++ b/src/GridItem.vue @@ -0,0 +1,28 @@ +<template> + <div> + <slot></slot> + </div> +</template> + +<script> +export default { + name: "GridItem", + props: { + x: { + type: Number, + required: true, + }, + y: { + type: Number, + required: true, + }, + }, +}; +</script> + +<style scoped> +div { + grid-column: v-bind(x) / span 1; + grid-row: v-bind(y) / span 1; +} +</style> diff --git a/src/components.js b/src/components.js index ab5df4046db649aa1a963642d0a4142ecf5d7bec..b8ed057bb44428cd0f0bf6b5e6f533b61ee89c03 100644 --- a/src/components.js +++ b/src/components.js @@ -1,3 +1,4 @@ -import Component from "./Component.vue"; +import DragContainer from "./DragContainer.vue"; +import DragGrid from "./DragGrid.vue"; -export default {Component}; +export default { DragContainer, DragGrid }; diff --git a/src/index.js b/src/index.js index 4db1c91ed3adc02e0fb1a19039eb03d969d4f4ec..1d2588860cfc3239e748903d51d10108877fe1f5 100644 --- a/src/index.js +++ b/src/index.js @@ -1,14 +1,16 @@ import components from "./components"; +import Vue from "vue"; +import VueInteractJs from "vue-interactjs"; + +Vue.use(VueInteractJs); const plugin = { - install(Vue) { - for (const prop in components) { - if (components.hasOwnProperty(prop)) { - const component = components[prop]; - Vue.component(component.name, component); - } - } + install(Vue) { + for (const prop in components) { + const component = components[prop]; + Vue.component(component.name, component); } -} + }, +}; -export default plugin; \ No newline at end of file +export default plugin; diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000000000000000000000000000000000000..827d48cffec8cc6c015bcd3764aac738bbf23d91 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,33 @@ +// vite.config.js +import vue from "@vitejs/plugin-vue2"; +import { defineConfig } from "vite"; +import { resolve } from "path"; +import peerDepsExternal from "rollup-plugin-peer-deps-external"; + +export default defineConfig({ + plugins: [vue(), peerDepsExternal()], + build: { + lib: { + /* eslint-env node */ + entry: resolve(__dirname, "src/index.js"), + name: "vue-draggable-grid", + }, + output: [ + { + format: "esm", + file: "dist/vue-draggable-grid.mjs", + }, + { + format: "cjs", + file: "dist/vue-draggable-grid.js", + }, + ], + rollupOptions: { + output: { + globals: { + vue: "Vue", + }, + }, + }, + }, +});