@wyxos/vibe 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Wyxos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # 🔷 VIBE — Vue Infinite Block Engine
2
+
3
+ [![npm](https://img.shields.io/npm/v/@wyxos/vibe?color=%2300c58e&label=npm)](https://www.npmjs.com/package/@wyxos/vibe)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Demo](https://img.shields.io/badge/Demo-Live%20Preview-blue?logo=githubpages)](https://wyxos.github.io/vibe/)
6
+
7
+ A responsive, dynamic, infinite-scroll masonry layout engine for Vue 3.
8
+ Built for performance, flexibility, and pixel-perfect layout control.
9
+
10
+ ---
11
+
12
+ ## ✅ Features
13
+
14
+ - Responsive masonry layout that adapts to screen size
15
+ - Automatically loads more items as you scroll
16
+ - Supports removing and reflowing items with animation
17
+ - Keeps scroll position stable after layout updates
18
+ - Fully customizable item rendering
19
+ - Optimized for large datasets
20
+
21
+ ---
22
+
23
+ ## 📦 Installation
24
+
25
+ ```bash
26
+ npm install @wyxos/vibe
27
+ ```
28
+
29
+ ---
30
+
31
+ ## 🚀 Usage
32
+
33
+ ```vue
34
+ <script setup>
35
+ import Vibe from '@wyxos/vibe'
36
+ import { ref } from 'vue'
37
+ import fixture from './pages.json'
38
+
39
+ const items = ref([])
40
+
41
+ const getNextPage = async (page) => {
42
+ return new Promise((resolve) => {
43
+ setTimeout(() => {
44
+ resolve({
45
+ items: fixture[page - 1].items,
46
+ nextPage: page + 1
47
+ })
48
+ }, 1000)
49
+ })
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <Vibe v-model:items="items" :get-next-page="getNextPage">
55
+ <template #item="{ item, onRemove }">
56
+ <img :src="item.src" class="w-full" />
57
+ <button
58
+ class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer"
59
+ @click="onRemove(item)"
60
+ >
61
+ <i class="fas fa-trash"></i>
62
+ </button>
63
+ </template>
64
+ </Vibe>
65
+ </template>
66
+ ```
67
+
68
+ ---
69
+
70
+ ## ⚙️ Props
71
+
72
+ | Prop | Type | Required | Description |
73
+ |--------------|----------|----------|-----------------------------------------------------------------------------|
74
+ | `items` | `Array` | ✅ | Two-way bound item array (each item must include `width`, `height`, `id`) |
75
+ | `getNextPage`| `Function(page: Number)` | ✅ | Async function to load the next page — returns `{ items, nextPage }` |
76
+ | `loadAtPage` | `Number` | ❌ | Starting page number (default: `1`) |
77
+ | `sizes` | `Object` | ❌ | Mobile-first column config (default: Tailwind-style breakpoints) |
78
+ | `gutterX` | `Number` | ❌ | Horizontal gutter between items (default: `10`) |
79
+ | `gutterY` | `Number` | ❌ | Vertical gutter between items (default: `10`) |
80
+
81
+ ### `sizes` example:
82
+ ```js
83
+ {
84
+ base: 1,
85
+ sm: 2,
86
+ md: 3,
87
+ lg: 4,
88
+ xl: 5,
89
+ '2xl': 6
90
+ }
91
+ ```
92
+
93
+ ---
94
+
95
+ ## 💡 Slots
96
+
97
+ | Slot Name | Props | Description |
98
+ |-----------|--------------------------------|-----------------------------------|
99
+ | `item` | `{ item, onRemove }` | Custom rendering for each block |
100
+
101
+ ---
102
+
103
+ ## 🧪 Run Locally
104
+
105
+ ```bash
106
+ git clone https://github.com/wyxos/vibe
107
+ cd vibe
108
+ npm install
109
+ npm run dev
110
+ ```
111
+
112
+ Visit [`http://localhost:5173`](http://localhost:5173)
113
+
114
+ ---
115
+
116
+ ## 🌐 Live Demo
117
+
118
+ 👉 [View Demo on GitHub Pages](https://wyxos.github.io/vibe/)
119
+
120
+ ---
121
+
122
+ ## 📄 License
123
+
124
+ MIT © [@wyxos](https://github.com/wyxos)
package/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import InfiniteMasonry from './src/archive/InfiniteMasonry.vue';
2
+
3
+ export default {
4
+ install(app) {
5
+ app.component('InfiniteMasonry', InfiniteMasonry);
6
+ }
7
+ };
8
+
9
+ export { InfiniteMasonry };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@wyxos/vibe",
3
+ "version": "1.2.1",
4
+ "main": "index.js",
5
+ "module": "index.js",
6
+ "type": "module",
7
+ "files": [
8
+ "src",
9
+ "index.js"
10
+ ],
11
+ "scripts": {
12
+ "dev": "vite",
13
+ "build": "vite build && node write-cname.js",
14
+ "preview": "vite preview",
15
+ "watch": "vite build --watch",
16
+ "release": "node release.mjs",
17
+ "test": "vitest"
18
+ },
19
+ "dependencies": {
20
+ "lodash": "^4.17.21",
21
+ "vue": "^3.0.0"
22
+ },
23
+ "peerDependencies": {
24
+ "vue": "^3.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@tailwindcss/vite": "^4.0.15",
28
+ "@vitejs/plugin-vue": "^5.2.1",
29
+ "@vue/test-utils": "^2.4.6",
30
+ "chalk": "^5.3.0",
31
+ "inquirer": "^10.1.8",
32
+ "jsdom": "^26.0.0",
33
+ "simple-git": "^3.27.0",
34
+ "tailwindcss": "^4.0.15",
35
+ "uuid": "^11.1.0",
36
+ "vite": "^6.2.0",
37
+ "vitest": "^3.0.9"
38
+ }
39
+ }
package/src/App.vue ADDED
@@ -0,0 +1,47 @@
1
+ <script setup>
2
+ import Masonry from "./Masonry.vue";
3
+ import {ref} from "vue";
4
+ import fixture from "./pages.json";
5
+
6
+ const items = ref([])
7
+
8
+ const getPage = async (index) => {
9
+ console.log('index', index)
10
+ return new Promise((resolve) => {
11
+ setTimeout(() => {
12
+ let output = {
13
+ items: fixture[index - 1].items,
14
+ nextPage: index + 1
15
+ };
16
+
17
+ console.log('output', output)
18
+ resolve(output)
19
+ }, 1000)
20
+ })
21
+ }
22
+ </script>
23
+ <template>
24
+ <main class="flex flex-col items-center p-4 bg-slate-100 h-screen overflow-hidden">
25
+ <header class="sticky top-0 z-10 bg-slate-100 w-full p-4 flex flex-col items-center gap-4">
26
+ <h1 class="text-2xl font-semibold mb-4">VIBE</h1>
27
+ <p>Vue Infinite Block Engine</p>
28
+
29
+ <p class="text-sm text-gray-500 text-center mb-4">
30
+ 🚀 Built by <a href="https://wyxos.com" target="_blank" class="underline hover:text-black">wyxos.com</a> •
31
+ 💾 <a href="https://github.com/wyxos/vibe" target="_blank" class="underline hover:text-black">Source on GitHub</a>
32
+ </p>
33
+ </header>
34
+ <masonry v-model:items="items" :get-next-page="getPage">
35
+ <template #item="{item, onRemove}">
36
+ <img :src="item.src" class="w-full"/>
37
+ <button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer" @click="onRemove(item)">
38
+ <i class="fas fa-trash"></i>
39
+ </button>
40
+ </template>
41
+ </masonry>
42
+ </main>
43
+ </template>
44
+
45
+
46
+
47
+
@@ -0,0 +1,262 @@
1
+ <script setup>
2
+ import {computed, nextTick, onMounted, onUnmounted, ref} from "vue";
3
+ import calculateLayout from "./calculateLayout.js";
4
+ import {debounce} from 'lodash'
5
+
6
+ const props = defineProps({
7
+ getNextPage: {
8
+ type: Function,
9
+ default: () => {
10
+ }
11
+ },
12
+ loadAtPage: {
13
+ type: Number,
14
+ default: 1
15
+ },
16
+ items: {
17
+ type: Array,
18
+ default: () => []
19
+ },
20
+ sizes: {
21
+ type: Object,
22
+ default: () => ({
23
+ base: 1, // mobile-first default
24
+ sm: 2, // ≥ 640px
25
+ md: 3, // ≥ 768px
26
+ lg: 4, // ≥ 1024px
27
+ xl: 5, // ≥ 1280px
28
+ '2xl': 6 // ≥ 1536px
29
+ })
30
+ },
31
+ gutterX: {
32
+ type: Number,
33
+ default: 10
34
+ },
35
+ gutterY: {
36
+ type: Number,
37
+ default: 10
38
+ }
39
+ })
40
+
41
+ const emits = defineEmits(['update:items'])
42
+
43
+ const masonry = computed({
44
+ get: () => props.items,
45
+ set: (val) => emits('update:items', val)
46
+ })
47
+
48
+
49
+ const columns = ref(7)
50
+
51
+ const container = ref(null)
52
+
53
+ const currentPage = ref(null)
54
+
55
+ const nextPage = ref(null)
56
+
57
+ const isLoading = ref(false)
58
+
59
+ const containerHeight = ref(0)
60
+
61
+ const columnHeights = computed(() => {
62
+ const heights = new Array(columns.value).fill(0)
63
+ for (let i = 0; i < masonry.value.length; i++) {
64
+ const item = masonry.value[i]
65
+ const col = i % columns.value
66
+ heights[col] = Math.max(heights[col], item.top + item.columnHeight)
67
+ }
68
+ return heights
69
+ })
70
+
71
+ async function onScroll() {
72
+ const {scrollTop, clientHeight} = container.value
73
+ const visibleBottom = scrollTop + clientHeight
74
+
75
+ const whitespaceVisible = columnHeights.value.some(height => height + 300 < visibleBottom - 1)
76
+
77
+ if (whitespaceVisible && !isLoading.value) {
78
+ isLoading.value = true
79
+
80
+ if (currentPage.value > 3) {
81
+ // get first item
82
+ const firstItem = masonry.value[0]
83
+
84
+ // get page number
85
+ const page = firstItem.page
86
+
87
+ // find all item with this page
88
+ const removedItems = masonry.value.filter(i => i.page !== page)
89
+
90
+ refreshLayout(removedItems)
91
+
92
+ await nextTick()
93
+
94
+ const lowestColumnIndex = columnHeights.value.indexOf(Math.min(...columnHeights.value))
95
+
96
+ // find the last item in that column
97
+ const lastItemInColumn = masonry.value.filter((_, index) => index % columns.value === lowestColumnIndex).pop()
98
+ const lastItemInColumnTop = lastItemInColumn.top
99
+ const lastItemInColumnBottom = lastItemInColumnTop + lastItemInColumn.columnHeight
100
+ const containerTop = container.value.scrollTop
101
+ const containerBottom = containerTop + container.value.clientHeight
102
+ const itemInView = lastItemInColumnTop >= containerTop && lastItemInColumnBottom <= containerBottom
103
+ if (!itemInView) {
104
+ container.value.scrollTo({
105
+ top: lastItemInColumnTop - 10,
106
+ behavior: 'smooth'
107
+ })
108
+ }
109
+ }
110
+
111
+ await loadNext()
112
+
113
+ await nextTick()
114
+
115
+ isLoading.value = false
116
+ }
117
+ }
118
+
119
+ function getColumnCount() {
120
+ const width = window.innerWidth
121
+
122
+ if (width >= 1536 && props.sizes['2xl']) return props.sizes['2xl']
123
+ if (width >= 1280 && props.sizes.xl) return props.sizes.xl
124
+ if (width >= 1024 && props.sizes.lg) return props.sizes.lg
125
+ if (width >= 768 && props.sizes.md) return props.sizes.md
126
+ if (width >= 640 && props.sizes.sm) return props.sizes.sm
127
+ return props.sizes.base
128
+ }
129
+
130
+ function calculateHeight(layout) {
131
+ containerHeight.value = layout.reduce((acc, item) => {
132
+ return Math.max(acc, item.top + item.columnHeight)
133
+ }, 0)
134
+ }
135
+
136
+ function refreshLayout(items){
137
+ const layout = calculateLayout(items, container.value, columns.value, props.gutterX, props.gutterY);
138
+
139
+ calculateHeight(layout)
140
+
141
+ masonry.value = layout
142
+ }
143
+
144
+ async function getContent(page) {
145
+ const response = await props.getNextPage(page)
146
+
147
+ refreshLayout([...masonry.value, ...response.items])
148
+
149
+ return response
150
+ }
151
+
152
+ async function loadNext() {
153
+ const response = await getContent(nextPage.value)
154
+ currentPage.value = nextPage.value
155
+ nextPage.value = response.nextPage
156
+ }
157
+
158
+ const getItemStyle = (item) => {
159
+ return {
160
+ top: `${item.top}px`,
161
+ left: `${item.left}px`,
162
+ width: `${item.columnWidth}px`,
163
+ height: `${item.columnHeight}px`
164
+ }
165
+ }
166
+
167
+ function onRemove(item) {
168
+ refreshLayout(masonry.value.filter(i => i.id !== item.id))
169
+ }
170
+
171
+ function onEnter(el, done) {
172
+ // set top to data-top
173
+ const top = el.dataset.top
174
+ requestAnimationFrame(() => {
175
+ el.style.top = `${top}px`
176
+ done()
177
+ })
178
+ }
179
+
180
+ function onBeforeEnter(el) {
181
+ // set top to last item + 500
182
+ const lastItem = masonry.value[masonry.value.length - 1]
183
+ if (lastItem) {
184
+ const lastTop = lastItem.top + lastItem.columnHeight + 10
185
+ el.style.top = `${lastTop}px`
186
+ } else {
187
+ el.style.top = '0px'
188
+ }
189
+ }
190
+
191
+ function onBeforeLeave(el) {
192
+ // Ensure it's at its current position before animating
193
+ el.style.transition = 'none'
194
+ el.style.top = `${el.offsetTop}px`
195
+ void el.offsetWidth // force reflow to flush style
196
+ el.style.transition = '' // allow transition to apply again
197
+ }
198
+
199
+ function onLeave(el, done) {
200
+ el.style.top = '-600px'
201
+ el.style.opacity = '0'
202
+ el.addEventListener('transitionend', done)
203
+ }
204
+
205
+ function itemAttributes(item) {
206
+ return {
207
+ style: getItemStyle(item),
208
+ 'data-top': item.top,
209
+ 'data-id': `${item.page}-${item.id}`,
210
+ }
211
+ }
212
+
213
+ function onResize() {
214
+ columns.value = getColumnCount()
215
+ refreshLayout(masonry.value)
216
+ }
217
+
218
+ onMounted(async () => {
219
+ isLoading.value = true
220
+
221
+ columns.value = getColumnCount()
222
+
223
+ currentPage.value = props.loadAtPage
224
+
225
+ const response = await getContent(currentPage.value)
226
+
227
+ nextPage.value = response.nextPage
228
+
229
+ isLoading.value = false
230
+
231
+ container.value?.addEventListener('scroll', debounce(onScroll, 200));
232
+
233
+ window.addEventListener('resize', debounce(onResize, 200));
234
+ })
235
+
236
+ onUnmounted(() => {
237
+ container.value?.removeEventListener('scroll', debounce(onScroll, 200));
238
+
239
+ window.removeEventListener('resize', debounce(onResize, 200));
240
+ })
241
+ </script>
242
+
243
+ <template>
244
+ <div class="overflow-auto bg-blue-500 w-full flex-1" ref="container">
245
+ <div class="relative" :style="{height: `${containerHeight}px`}">
246
+ <transition-group :css="false" @enter="onEnter" @before-enter="onBeforeEnter"
247
+ @leave="onLeave"
248
+ @before-leave="onBeforeLeave">
249
+ <div v-for="item in masonry" :key="`${item.page}-${item.id}`"
250
+ class="bg-slate-200 absolute transition-[top,left,opacity] duration-500 ease-in-out"
251
+ v-bind="itemAttributes(item)">
252
+ <slot name="item" v-bind="{item, onRemove}">
253
+ <img :src="item.src" class="w-full"/>
254
+ <button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer" @click="onRemove(item)">
255
+ <i class="fas fa-trash"></i>
256
+ </button>
257
+ </slot>
258
+ </div>
259
+ </transition-group>
260
+ </div>
261
+ </div>
262
+ </template>
@@ -0,0 +1,98 @@
1
+ <!--<script setup>-->
2
+ <!--import InfiniteMasonry from "./components/InfiniteMasonry.vue";-->
3
+ <!--import {nextTick, onMounted, ref} from "vue";-->
4
+ <!--import pages from './pages.json'-->
5
+
6
+ <!--const scrollDetails = ref({-->
7
+ <!-- position: 0,-->
8
+ <!-- direction: 'down',-->
9
+ <!-- isEnd: false,-->
10
+ <!-- isStart: true,-->
11
+ <!-- hasShortColumn: false,-->
12
+ <!--});-->
13
+
14
+ <!--const items = ref([]);-->
15
+
16
+ <!--const count = ref(30);-->
17
+
18
+ <!--const scroller = ref();-->
19
+
20
+ <!--const pageIndex = ref(0);-->
21
+
22
+ <!--const isLoading = ref(false);-->
23
+
24
+ <!--const updateItems = (action) => {-->
25
+ <!-- setTimeout(async () => {-->
26
+ <!-- if (action === 'add') {-->
27
+ <!-- if(items.value.length > 3){-->
28
+ <!-- // remove the first page from items-->
29
+ <!-- items.value.splice(0, 1);-->
30
+ <!-- }-->
31
+
32
+ <!-- items.value.push(pages[pageIndex.value]);-->
33
+
34
+ <!-- pageIndex.value = pageIndex.value + 1;-->
35
+
36
+ <!-- console.log(pageIndex.value)-->
37
+
38
+ <!-- await nextTick()-->
39
+
40
+ <!-- isLoading.value = false;-->
41
+ <!-- } else {-->
42
+ <!-- items.value.splice(-count.value);-->
43
+
44
+ <!-- isLoading.value = false;-->
45
+ <!-- }-->
46
+ <!-- }, 1000)-->
47
+ <!--}-->
48
+
49
+ <!--const onScroll = (attributes) => {-->
50
+ <!-- scrollDetails.value = attributes-->
51
+
52
+ <!-- if (autoLoad.value && attributes.hasShortColumn && !isLoading.value) {-->
53
+ <!-- isLoading.value = true;-->
54
+
55
+ <!-- updateItems('add');-->
56
+ <!-- }-->
57
+ <!--}-->
58
+
59
+ <!--const autoLoad = ref(true);-->
60
+
61
+ <!--onMounted(async () => {-->
62
+ <!-- setTimeout(() => {-->
63
+ <!-- items.value = [pages[pageIndex.value]]-->
64
+
65
+ <!-- pageIndex.value = pageIndex.value + 1;-->
66
+ <!-- }, 2000)-->
67
+ <!--})-->
68
+ <!--</script>-->
69
+
70
+ <!--<template>-->
71
+ <!-- <main class="flex flex-col items-center p-4 bg-slate-100 h-screen overflow-hidden">-->
72
+ <!-- <header class="sticky top-0 z-10 bg-slate-100 w-full p-4 flex flex-col items-center gap-4">-->
73
+ <!-- <h1 class="text-2xl font-semibold mb-4">Vue Infinite Masonry</h1>-->
74
+
75
+ <!-- <p class="text-sm text-gray-500 text-center mb-4">-->
76
+ <!-- 🚀 Built by <a href="https://wyxos.com" target="_blank" class="underline hover:text-black">wyxos.com</a> •-->
77
+ <!-- 💾 <a href="https://github.com/wyxos/vue-infinite-masonry" target="_blank" class="underline hover:text-black">Source on GitHub</a>-->
78
+ <!-- </p>-->
79
+
80
+ <!-- <div class="flex flex-col md:flex-row gap-4 items-center">-->
81
+ <!-- <p>Scroll {{ scrollDetails }}</p>-->
82
+
83
+ <!-- <p>Page: {{ pageIndex }}</p>-->
84
+
85
+ <!-- <p>Pages in array {{ items.length }}</p>-->
86
+
87
+ <!-- </div>-->
88
+ <!-- </header>-->
89
+
90
+
91
+ <!-- <infinite-masonry-->
92
+ <!-- ref="scroller"-->
93
+ <!-- v-model="items"-->
94
+ <!-- @scroll="onScroll"-->
95
+ <!-- :options="{ gutterY: 50 }"-->
96
+ <!-- />-->
97
+ <!-- </main>-->
98
+ <!--</template>-->
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+
7
+ describe('Example Test', () => {
8
+ it('should pass a basic truthiness check', () => {
9
+ expect(true).toBe(true)
10
+ })
11
+ })