@wyxos/vibe 1.3.1 → 1.4.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/package.json CHANGED
@@ -1,20 +1,36 @@
1
1
  {
2
2
  "name": "@wyxos/vibe",
3
- "version": "1.3.1",
4
- "main": "index.js",
5
- "module": "index.js",
3
+ "version": "1.4.1",
4
+ "main": "lib/index.js",
5
+ "module": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./lib/index.js",
10
+ "types": "./lib/index.d.ts"
11
+ },
12
+ "./vibe.css": "./lib/vibe.css",
13
+ "./package.json": "./package.json"
14
+ },
6
15
  "type": "module",
7
16
  "files": [
8
- "src",
9
- "index.js"
17
+ "lib",
18
+ "src"
19
+ ],
20
+ "sideEffects": [
21
+ "./lib/vibe.css"
10
22
  ],
11
23
  "scripts": {
12
24
  "dev": "vite",
13
- "build": "vite build && node write-cname.js",
25
+ "build": "vue-tsc --noEmit && vite build && node write-cname.js",
26
+ "build:demo": "vue-tsc --noEmit && vite build && node write-cname.js",
27
+ "build:lib": "vite build --config vite.lib.config.ts && vue-tsc --declaration --emitDeclarationOnly --outDir lib",
14
28
  "preview": "vite preview",
15
29
  "watch": "vite build --watch",
16
30
  "release": "node release.mjs",
17
- "test": "vitest"
31
+ "test": "vitest",
32
+ "type-check": "vue-tsc --noEmit",
33
+ "prepublishOnly": "npm run build:lib"
18
34
  },
19
35
  "dependencies": {
20
36
  "lodash": "^4.17.21",
@@ -28,13 +44,17 @@
28
44
  "@tailwindcss/vite": "^4.0.15",
29
45
  "@vitejs/plugin-vue": "^5.2.1",
30
46
  "@vue/test-utils": "^2.4.6",
47
+ "@types/node": "^24.5.2",
31
48
  "chalk": "^5.3.0",
32
49
  "inquirer": "^10.1.8",
33
50
  "jsdom": "^26.0.0",
34
51
  "simple-git": "^3.27.0",
35
52
  "tailwindcss": "^4.0.15",
53
+ "typescript": "^5.9.2",
36
54
  "uuid": "^11.1.0",
37
55
  "vite": "^6.2.0",
38
- "vitest": "^3.0.9"
56
+ "vite-plugin-vue-mcp": "^0.3.2",
57
+ "vitest": "^3.0.9",
58
+ "vue-tsc": "^3.1.0"
39
59
  }
40
60
  }
package/src/App.vue CHANGED
@@ -1,17 +1,18 @@
1
- <script setup>
1
+ <script setup lang="ts">
2
2
  import Masonry from "./Masonry.vue";
3
- import {ref} from "vue";
3
+ import { ref } from "vue";
4
4
  import fixture from "./pages.json";
5
+ import type { MasonryItem, GetPageResult } from "./types";
5
6
 
6
- const items = ref([])
7
+ const items = ref<MasonryItem[]>([]);
7
8
 
8
- const masonry = ref(null)
9
+ const masonry = ref<InstanceType<typeof Masonry> | null>(null);
9
10
 
10
- const getPage = async (page) => {
11
+ const getPage = async (page: number): Promise<GetPageResult> => {
11
12
  return new Promise((resolve) => {
12
13
  setTimeout(() => {
13
14
  // Check if the page exists in the fixture
14
- const pageData = fixture[page - 1];
15
+ const pageData = (fixture as any[])[page - 1] as { items: MasonryItem[] } | undefined;
15
16
 
16
17
  if (!pageData) {
17
18
  // Return empty items if page doesn't exist
@@ -22,15 +23,15 @@ const getPage = async (page) => {
22
23
  return;
23
24
  }
24
25
 
25
- let output = {
26
+ const output: GetPageResult = {
26
27
  items: pageData.items,
27
- nextPage: page < fixture.length ? page + 1 : null
28
+ nextPage: page < (fixture as any[]).length ? page + 1 : null
28
29
  };
29
30
 
30
- resolve(output)
31
- }, 1000)
32
- })
33
- }
31
+ resolve(output);
32
+ }, 1000);
33
+ });
34
+ };
34
35
  </script>
35
36
  <template>
36
37
  <main class="flex flex-col items-center p-4 bg-slate-100 h-screen overflow-hidden">
@@ -49,16 +50,12 @@ const getPage = async (page) => {
49
50
  </div>
50
51
  </header>
51
52
  <masonry class="bg-blue-500 " v-model:items="items" :get-next-page="getPage" :load-at-page="1" ref="masonry">
52
- <template #item="{item, onRemove}">
53
+ <template #item="{item, remove}">
53
54
  <img :src="item.src" class="w-full"/>
54
- <button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer" @click="onRemove(item)">
55
+ <button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer" @click="remove(item)">
55
56
  <i class="fas fa-trash"></i>
56
57
  </button>
57
58
  </template>
58
59
  </masonry>
59
60
  </main>
60
61
  </template>
61
-
62
-
63
-
64
-
package/src/Masonry.vue CHANGED
@@ -1,21 +1,20 @@
1
- <script setup>
2
- import {computed, nextTick, onMounted, onUnmounted, ref} from "vue";
3
- import calculateLayout from "./calculateLayout.js";
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
3
+ import calculateLayout from "./calculateLayout";
4
4
  import { debounce } from 'lodash-es'
5
5
  import {
6
6
  getColumnCount,
7
7
  calculateContainerHeight,
8
8
  getItemAttributes,
9
9
  calculateColumnHeights
10
- } from './masonryUtils.js'
11
- import { useMasonryTransitions } from './useMasonryTransitions.js'
12
- import { useMasonryScroll } from './useMasonryScroll.js'
10
+ } from './masonryUtils'
11
+ import { useMasonryTransitions } from './useMasonryTransitions'
12
+ import { useMasonryScroll } from './useMasonryScroll'
13
13
 
14
14
  const props = defineProps({
15
15
  getNextPage: {
16
16
  type: Function,
17
- default: () => {
18
- }
17
+ default: () => {}
19
18
  },
20
19
  loadAtPage: {
21
20
  type: [Number, String],
@@ -31,7 +30,7 @@ const props = defineProps({
31
30
  paginationType: {
32
31
  type: String,
33
32
  default: 'page', // or 'cursor'
34
- validator: v => ['page', 'cursor'].includes(v)
33
+ validator: (v: string) => ['page', 'cursor'].includes(v)
35
34
  },
36
35
  skipInitialLoad: {
37
36
  type: Boolean,
@@ -45,24 +44,61 @@ const props = defineProps({
45
44
  type: Number,
46
45
  default: 40
47
46
  },
47
+ // Backfill configuration
48
+ backfillEnabled: {
49
+ type: Boolean,
50
+ default: true
51
+ },
52
+ backfillDelayMs: {
53
+ type: Number,
54
+ default: 2000
55
+ },
56
+ backfillMaxCalls: {
57
+ type: Number,
58
+ default: 10
59
+ },
60
+ // Retry configuration
61
+ retryMaxAttempts: {
62
+ type: Number,
63
+ default: 3
64
+ },
65
+ retryInitialDelayMs: {
66
+ type: Number,
67
+ default: 2000
68
+ },
69
+ retryBackoffStepMs: {
70
+ type: Number,
71
+ default: 2000
72
+ },
48
73
  transitionDurationMs: {
49
74
  type: Number,
50
75
  default: 450
51
76
  },
77
+ // Shorter, snappier duration specifically for item removal (leave)
78
+ leaveDurationMs: {
79
+ type: Number,
80
+ default: 160
81
+ },
52
82
  transitionEasing: {
53
83
  type: String,
54
84
  default: 'cubic-bezier(.22,.61,.36,1)'
85
+ },
86
+ // Force motion even when user has reduced-motion enabled
87
+ forceMotion: {
88
+ type: Boolean,
89
+ default: false
55
90
  }
56
91
  })
57
92
 
58
93
  const defaultLayout = {
59
- sizes: { base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6 },
94
+ sizes: {base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6},
60
95
  gutterX: 10,
61
96
  gutterY: 10,
62
97
  header: 0,
63
98
  footer: 0,
64
99
  paddingLeft: 0,
65
- paddingRight: 0
100
+ paddingRight: 0,
101
+ placement: 'masonry'
66
102
  }
67
103
 
68
104
  const layout = computed(() => ({
@@ -74,27 +110,30 @@ const layout = computed(() => ({
74
110
  }
75
111
  }))
76
112
 
77
- const emits = defineEmits(['update:items'])
78
-
79
- const masonry = computed({
113
+ const emits = defineEmits([
114
+ 'update:items',
115
+ 'backfill:start',
116
+ 'backfill:tick',
117
+ 'backfill:stop',
118
+ 'retry:start',
119
+ 'retry:tick',
120
+ 'retry:stop'
121
+ ])
122
+
123
+ const masonry = computed<any>({
80
124
  get: () => props.items,
81
125
  set: (val) => emits('update:items', val)
82
126
  })
83
127
 
84
- const columns = ref(7)
85
-
86
- const container = ref(null)
87
-
88
- const paginationHistory = ref([])
89
-
90
- const nextPage = ref(null)
91
-
92
- const isLoading = ref(false)
93
-
94
- const containerHeight = ref(0)
128
+ const columns = ref<number>(7)
129
+ const container = ref<HTMLElement | null>(null)
130
+ const paginationHistory = ref<any[]>([])
131
+ const nextPage = ref<number | null>(null)
132
+ const isLoading = ref<boolean>(false)
133
+ const containerHeight = ref<number>(0)
95
134
 
96
135
  // Scroll progress tracking
97
- const scrollProgress = ref({
136
+ const scrollProgress = ref<{ distanceToTrigger: number; isNearTrigger: boolean }>({
98
137
  distanceToTrigger: 0,
99
138
  isNearTrigger: false
100
139
  })
@@ -102,13 +141,12 @@ const scrollProgress = ref({
102
141
  const updateScrollProgress = () => {
103
142
  if (!container.value) return
104
143
 
105
- const { scrollTop, clientHeight } = container.value
144
+ const {scrollTop, clientHeight} = container.value
106
145
  const visibleBottom = scrollTop + clientHeight
107
146
 
108
- const columnHeights = calculateColumnHeights(masonry.value, columns.value)
109
- // Use longest column to match the trigger logic in useMasonryScroll.js
147
+ const columnHeights = calculateColumnHeights(masonry.value as any, columns.value)
110
148
  const longestColumn = Math.max(...columnHeights)
111
- const triggerPoint = longestColumn + 300 // Match: longestColumn + 300 < visibleBottom
149
+ const triggerPoint = longestColumn + 300
112
150
 
113
151
  const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
114
152
  const isNearTrigger = distanceToTrigger <= 100
@@ -120,57 +158,75 @@ const updateScrollProgress = () => {
120
158
  }
121
159
 
122
160
  // Setup composables
123
- const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(masonry)
161
+ const {onEnter, onBeforeEnter, onBeforeLeave, onLeave} = useMasonryTransitions(masonry)
124
162
 
125
- const { handleScroll } = useMasonryScroll({
163
+ const {handleScroll} = useMasonryScroll({
126
164
  container,
127
- masonry,
165
+ masonry: masonry as any,
128
166
  columns,
129
167
  containerHeight,
130
168
  isLoading,
131
169
  maxItems: props.maxItems,
132
170
  pageSize: props.pageSize,
133
171
  refreshLayout,
134
- // Allow scroll composable to set items without recalculating layout (phase-1 cleanup)
135
- setItemsRaw: (items) => { masonry.value = items },
136
- loadNext
172
+ setItemsRaw: (items: any[]) => {
173
+ masonry.value = items
174
+ },
175
+ loadNext,
176
+ leaveEstimateMs: props.leaveDurationMs
137
177
  })
138
178
 
139
179
  defineExpose({
140
180
  isLoading,
141
181
  refreshLayout,
142
182
  containerHeight,
143
- onRemove,
183
+ remove,
144
184
  removeMany,
145
185
  loadNext,
146
186
  loadPage,
147
187
  reset,
188
+ init,
148
189
  paginationHistory
149
190
  })
150
191
 
151
- function calculateHeight(content) {
152
- const newHeight = calculateContainerHeight(content)
192
+ function calculateHeight(content: any[]) {
193
+ const newHeight = calculateContainerHeight(content as any)
153
194
  let floor = 0
154
195
  if (container.value) {
155
- const { scrollTop, clientHeight } = container.value
156
- // Ensure the container never shrinks below the visible viewport bottom + small buffer
196
+ const {scrollTop, clientHeight} = container.value
157
197
  floor = scrollTop + clientHeight + 100
158
198
  }
159
199
  containerHeight.value = Math.max(newHeight, floor)
160
200
  }
161
201
 
162
- function refreshLayout(items) {
163
- const content = calculateLayout(items, container.value, columns.value, layout.value);
164
-
165
- calculateHeight(content)
166
-
202
+ function refreshLayout(items: any[]) {
203
+ if (!container.value) return
204
+ const content = calculateLayout(items as any, container.value as HTMLElement, columns.value, layout.value as any)
205
+ calculateHeight(content as any)
167
206
  masonry.value = content
168
207
  }
169
208
 
170
- async function getContent(page) {
209
+ function waitWithProgress(totalMs: number, onTick: (remaining: number, total: number) => void) {
210
+ return new Promise<void>((resolve) => {
211
+ const total = Math.max(0, totalMs | 0)
212
+ const start = Date.now()
213
+ onTick(total, total)
214
+ const id = setInterval(() => {
215
+ const elapsed = Date.now() - start
216
+ const remaining = Math.max(0, total - elapsed)
217
+ onTick(remaining, total)
218
+ if (remaining <= 0) {
219
+ clearInterval(id)
220
+ resolve()
221
+ }
222
+ }, 100)
223
+ })
224
+ }
225
+
226
+ async function getContent(page: number) {
171
227
  try {
172
- const response = await props.getNextPage(page)
173
- refreshLayout([...masonry.value, ...response.items])
228
+ const response = await fetchWithRetry(() => props.getNextPage(page))
229
+ refreshLayout([...(masonry.value as any[]), ...response.items])
174
230
  return response
175
231
  } catch (error) {
176
232
  console.error('Error in getContent:', error)
@@ -178,14 +234,41 @@ async function getContent(page) {
178
234
  }
179
235
  }
180
236
 
181
- async function loadPage(page) {
182
- if (isLoading.value) return // Prevent concurrent loading
237
+ async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
238
+ let attempt = 0
239
+ const max = props.retryMaxAttempts
240
+ let delay = props.retryInitialDelayMs
241
+ // eslint-disable-next-line no-constant-condition
242
+ while (true) {
243
+ try {
244
+ const res = await fn()
245
+ if (attempt > 0) {
246
+ emits('retry:stop', {attempt, success: true})
247
+ }
248
+ return res
249
+ } catch (err) {
250
+ attempt++
251
+ if (attempt > max) {
252
+ emits('retry:stop', {attempt: attempt - 1, success: false})
253
+ throw err
254
+ }
255
+ emits('retry:start', {attempt, max, totalMs: delay})
256
+ await waitWithProgress(delay, (remaining, total) => {
257
+ emits('retry:tick', {attempt, remainingMs: remaining, totalMs: total})
258
+ })
259
+ delay += props.retryBackoffStepMs
260
+ }
261
+ }
262
+ }
183
263
 
264
+ async function loadPage(page: number) {
265
+ if (isLoading.value) return
184
266
  isLoading.value = true
185
-
186
267
  try {
268
+ const baseline = (masonry.value as any[]).length
187
269
  const response = await getContent(page)
188
270
  paginationHistory.value.push(response.nextPage)
271
+ await maybeBackfillToTarget(baseline)
189
272
  return response
190
273
  } catch (error) {
191
274
  console.error('Error loading page:', error)
@@ -196,14 +279,14 @@ async function loadPage(page) {
196
279
  }
197
280
 
198
281
  async function loadNext() {
199
- if (isLoading.value) return // Prevent concurrent loading
200
-
282
+ if (isLoading.value) return
201
283
  isLoading.value = true
202
-
203
284
  try {
285
+ const baseline = (masonry.value as any[]).length
204
286
  const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
205
287
  const response = await getContent(currentPage)
206
288
  paginationHistory.value.push(response.nextPage)
289
+ await maybeBackfillToTarget(baseline)
207
290
  return response
208
291
  } catch (error) {
209
292
  console.error('Error loading next page:', error)
@@ -213,24 +296,90 @@ async function loadNext() {
213
296
  }
214
297
  }
215
298
 
216
- function onRemove(item) {
217
- refreshLayout(masonry.value.filter(i => i.id !== item.id))
299
+ async function remove(item: any) {
300
+ const next = (masonry.value as any[]).filter(i => i.id !== item.id)
301
+ masonry.value = next
302
+ await nextTick()
303
+ // Force a reflow so current transforms are committed
304
+ void container.value?.offsetHeight
305
+ // Start FLIP on next frame (single RAF)
306
+ requestAnimationFrame(() => {
307
+ refreshLayout(next)
308
+ })
218
309
  }
219
310
 
220
- function removeMany(items) {
311
+ async function removeMany(items: any[]) {
221
312
  if (!items || items.length === 0) return
222
313
  const ids = new Set(items.map(i => i.id))
223
- const next = masonry.value.filter(i => !ids.has(i.id))
224
- refreshLayout(next)
314
+ const next = (masonry.value as any[]).filter(i => !ids.has(i.id))
315
+ masonry.value = next
316
+ await nextTick()
317
+ // Force a reflow so survivors' current transforms are committed
318
+ void container.value?.offsetHeight
319
+ // Start FLIP on next frame (single RAF)
320
+ requestAnimationFrame(() => {
321
+ refreshLayout(next)
322
+ })
225
323
  }
226
324
 
227
325
  function onResize() {
228
- columns.value = getColumnCount(layout.value)
229
- refreshLayout(masonry.value)
326
+ columns.value = getColumnCount(layout.value as any)
327
+ refreshLayout(masonry.value as any)
328
+ }
329
+
330
+ let backfillActive = false
331
+
332
+ async function maybeBackfillToTarget(baselineCount: number) {
333
+ if (!props.backfillEnabled) return
334
+ if (backfillActive) return
335
+
336
+ const targetCount = (baselineCount || 0) + (props.pageSize || 0)
337
+ if (!props.pageSize || props.pageSize <= 0) return
338
+
339
+ const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
340
+ if (lastNext == null) return
341
+
342
+ if ((masonry.value as any[]).length >= targetCount) return
343
+
344
+ backfillActive = true
345
+ try {
346
+ let calls = 0
347
+ emits('backfill:start', {target: targetCount, fetched: (masonry.value as any[]).length, calls})
348
+
349
+ while (
350
+ (masonry.value as any[]).length < targetCount &&
351
+ calls < props.backfillMaxCalls &&
352
+ paginationHistory.value[paginationHistory.value.length - 1] != null
353
+ ) {
354
+ await waitWithProgress(props.backfillDelayMs, (remaining, total) => {
355
+ emits('backfill:tick', {
356
+ fetched: (masonry.value as any[]).length,
357
+ target: targetCount,
358
+ calls,
359
+ remainingMs: remaining,
360
+ totalMs: total
361
+ })
362
+ })
363
+
364
+ const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
365
+ try {
366
+ isLoading.value = true
367
+ const response = await getContent(currentPage)
368
+ paginationHistory.value.push(response.nextPage)
369
+ } finally {
370
+ isLoading.value = false
371
+ }
372
+
373
+ calls++
374
+ }
375
+
376
+ emits('backfill:stop', {fetched: (masonry.value as any[]).length, calls})
377
+ } finally {
378
+ backfillActive = false
379
+ }
230
380
  }
231
381
 
232
382
  function reset() {
233
- // Scroll back to top first (while items still exist to scroll through)
234
383
  if (container.value) {
235
384
  container.value.scrollTo({
236
385
  top: 0,
@@ -238,23 +387,16 @@ function reset() {
238
387
  })
239
388
  }
240
389
 
241
- // Clear all items
242
390
  masonry.value = []
243
-
244
- // Reset container height
245
391
  containerHeight.value = 0
246
-
247
- // Reset pagination history to initial state
248
392
  paginationHistory.value = [props.loadAtPage]
249
393
 
250
- // Reset scroll progress
251
394
  scrollProgress.value = {
252
395
  distanceToTrigger: 0,
253
396
  isNearTrigger: false
254
397
  }
255
398
  }
256
399
 
257
- // Create debounced functions with stable references
258
400
  const debouncedScrollHandler = debounce(() => {
259
401
  handleScroll()
260
402
  updateScrollProgress()
@@ -262,28 +404,28 @@ const debouncedScrollHandler = debounce(() => {
262
404
 
263
405
  const debouncedResizeHandler = debounce(onResize, 200)
264
406
 
407
+ function init(items: any[], page: any, next: any) {
408
+ paginationHistory.value = [page]
409
+ paginationHistory.value.push(next)
410
+ refreshLayout([...(masonry.value as any[]), ...items])
411
+ updateScrollProgress()
412
+ }
413
+
265
414
  onMounted(async () => {
266
415
  try {
267
- columns.value = getColumnCount(layout.value)
416
+ columns.value = getColumnCount(layout.value as any)
268
417
 
269
- // For cursor-based pagination, loadAtPage can be null for the first request
270
- const initialPage = props.loadAtPage
418
+ const initialPage = props.loadAtPage as any
271
419
  paginationHistory.value = [initialPage]
272
420
 
273
- // Skip initial load if skipInitialLoad prop is true
274
421
  if (!props.skipInitialLoad) {
275
- await loadPage(paginationHistory.value[0]) // loadPage manages its own loading state
276
- } else {
277
- await nextTick()
278
- // Just refresh the layout with any existing items
279
- refreshLayout(masonry.value)
422
+ await loadPage(paginationHistory.value[0] as any)
280
423
  }
281
424
 
282
425
  updateScrollProgress()
283
426
 
284
427
  } catch (error) {
285
428
  console.error('Error during component initialization:', error)
286
- // Ensure loading state is reset if error occurs during initialization
287
429
  isLoading.value = false
288
430
  }
289
431
 
@@ -298,18 +440,19 @@ onUnmounted(() => {
298
440
  </script>
299
441
 
300
442
  <template>
301
- <div class="overflow-auto w-full flex-1 masonry-container" ref="container"
302
- > <div class="relative" :style="{height: `${containerHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-ease': transitionEasing}">
443
+ <div class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }" ref="container">
444
+ <div class="relative"
445
+ :style="{height: `${containerHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing}">
303
446
  <transition-group name="masonry" :css="false" @enter="onEnter" @before-enter="onBeforeEnter"
304
447
  @leave="onLeave"
305
448
  @before-leave="onBeforeLeave">
306
449
  <div v-for="(item, i) in masonry" :key="`${item.page}-${item.id}`"
307
450
  class="absolute masonry-item"
308
451
  v-bind="getItemAttributes(item, i)">
309
- <slot name="item" v-bind="{item, onRemove}">
452
+ <slot name="item" v-bind="{item, remove}">
310
453
  <img :src="item.src" class="w-full"/>
311
454
  <button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer"
312
- @click="onRemove(item)">
455
+ @click="remove(item)">
313
456
  <i class="fas fa-trash"></i>
314
457
  </button>
315
458
  </slot>
@@ -329,21 +472,26 @@ onUnmounted(() => {
329
472
  </template>
330
473
 
331
474
  <style scoped>
332
- /* Prevent browser scroll anchoring from adjusting scroll on content changes */
333
475
  .masonry-container {
334
476
  overflow-anchor: none;
335
477
  }
336
478
 
337
- /* Items animate transform only for smooth, compositor-driven motion */
338
479
  .masonry-item {
339
480
  will-change: transform, opacity;
340
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22,.61,.36,1)),
341
- opacity 200ms linear;
481
+ contain: layout paint;
482
+ transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
483
+ opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
342
484
  backface-visibility: hidden;
343
485
  }
344
486
 
345
- /* TransitionGroup move-class for FLIP reordering */
346
487
  .masonry-move {
347
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22,.61,.36,1));
488
+ transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1));
489
+ }
490
+
491
+ @media (prefers-reduced-motion: reduce) {
492
+ .masonry-container:not(.force-motion) .masonry-item,
493
+ .masonry-container:not(.force-motion) .masonry-move {
494
+ transition-duration: 1ms !important;
495
+ }
348
496
  }
349
497
  </style>
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, it, expect } from 'vitest'
5
+
6
+ describe('Example Test', () => {
7
+ it('should pass a basic truthiness check', () => {
8
+ expect(true).toBe(true)
9
+ })
10
+ })