@wyxos/vibe 1.6.16 → 1.6.18

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/src/Masonry.vue CHANGED
@@ -16,7 +16,7 @@ import MasonryItem from './components/MasonryItem.vue'
16
16
  const props = defineProps({
17
17
  getNextPage: {
18
18
  type: Function,
19
- default: () => {}
19
+ default: () => { }
20
20
  },
21
21
  loadAtPage: {
22
22
  type: [Number, String],
@@ -112,7 +112,7 @@ const props = defineProps({
112
112
  })
113
113
 
114
114
  const defaultLayout = {
115
- sizes: {base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6},
115
+ sizes: { base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6 },
116
116
  gutterX: 10,
117
117
  gutterY: 10,
118
118
  header: 0,
@@ -153,12 +153,12 @@ function getBreakpointValue(breakpoint: string): number {
153
153
  const useSwipeMode = computed(() => {
154
154
  if (props.layoutMode === 'masonry') return false
155
155
  if (props.layoutMode === 'swipe') return true
156
-
156
+
157
157
  // Auto mode: check container width
158
- const breakpoint = typeof props.mobileBreakpoint === 'string'
158
+ const breakpoint = typeof props.mobileBreakpoint === 'string'
159
159
  ? getBreakpointValue(props.mobileBreakpoint)
160
160
  : props.mobileBreakpoint
161
-
161
+
162
162
  return containerWidth.value < breakpoint
163
163
  })
164
164
 
@@ -212,6 +212,8 @@ const paginationHistory = ref<any[]>([])
212
212
  const currentPage = ref<any>(null) // Track the actual current page being displayed
213
213
  const isLoading = ref<boolean>(false)
214
214
  const masonryContentHeight = ref<number>(0)
215
+ const hasReachedEnd = ref<boolean>(false) // Track when we've reached the last page
216
+ const loadError = ref<Error | null>(null) // Track load errors
215
217
 
216
218
  // Current breakpoint
217
219
  const currentBreakpoint = computed(() => getBreakpointName(containerWidth.value))
@@ -278,7 +280,7 @@ const scrollProgress = ref<{ distanceToTrigger: number; isNearTrigger: boolean }
278
280
  const updateScrollProgress = (precomputedHeights?: number[]) => {
279
281
  if (!container.value) return
280
282
 
281
- const {scrollTop, clientHeight} = container.value
283
+ const { scrollTop, clientHeight } = container.value
282
284
  const visibleBottom = scrollTop + clientHeight
283
285
 
284
286
  const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value as any, columns.value)
@@ -298,7 +300,7 @@ const updateScrollProgress = (precomputedHeights?: number[]) => {
298
300
  }
299
301
 
300
302
  // Setup composables
301
- const {onEnter, onBeforeEnter, onBeforeLeave, onLeave} = useMasonryTransitions(masonry, { leaveDurationMs: props.leaveDurationMs })
303
+ const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(masonry, { leaveDurationMs: props.leaveDurationMs })
302
304
 
303
305
  // Transition wrappers that skip animation during virtualization
304
306
  function enter(el: HTMLElement, done: () => void) {
@@ -358,7 +360,7 @@ const visibleMasonry = computed(() => {
358
360
  })
359
361
  })
360
362
 
361
- const {handleScroll} = useMasonryScroll({
363
+ const { handleScroll } = useMasonryScroll({
362
364
  container,
363
365
  masonry: masonry as any,
364
366
  columns,
@@ -405,6 +407,10 @@ defineExpose({
405
407
  contentHeight: masonryContentHeight,
406
408
  // Current page
407
409
  currentPage,
410
+ // End of list tracking
411
+ hasReachedEnd,
412
+ // Load error tracking
413
+ loadError,
408
414
  // Set fixed dimensions (overrides ResizeObserver)
409
415
  setFixedDimensions,
410
416
  remove,
@@ -414,6 +420,7 @@ defineExpose({
414
420
  loadPage,
415
421
  refreshCurrentPage,
416
422
  reset,
423
+ destroy,
417
424
  init,
418
425
  paginationHistory,
419
426
  cancelLoad,
@@ -426,7 +433,7 @@ function calculateHeight(content: any[]) {
426
433
  const newHeight = calculateContainerHeight(content as any)
427
434
  let floor = 0
428
435
  if (container.value) {
429
- const {scrollTop, clientHeight} = container.value
436
+ const { scrollTop, clientHeight } = container.value
430
437
  floor = scrollTop + clientHeight + 100
431
438
  }
432
439
  masonryContentHeight.value = Math.max(newHeight, floor)
@@ -438,7 +445,7 @@ function refreshLayout(items: any[]) {
438
445
  masonry.value = items as any
439
446
  return
440
447
  }
441
-
448
+
442
449
  if (!container.value) return
443
450
  // Developer diagnostics: warn when dimensions are invalid
444
451
  checkItemDimensions(items as any[], 'refreshLayout')
@@ -447,7 +454,7 @@ function refreshLayout(items: any[]) {
447
454
  ...item,
448
455
  originalIndex: item.originalIndex ?? index
449
456
  }))
450
-
457
+
451
458
  // When fixed dimensions are set, ensure container uses the fixed width for layout
452
459
  // This prevents gaps when the container's actual width differs from the fixed width
453
460
  const containerEl = container.value as HTMLElement
@@ -459,13 +466,13 @@ function refreshLayout(items: any[]) {
459
466
  containerEl.style.width = `${fixedDimensions.value.width}px`
460
467
  // Force reflow
461
468
  containerEl.offsetWidth
462
-
469
+
463
470
  const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
464
-
471
+
465
472
  // Restore original width
466
473
  containerEl.style.width = originalWidth
467
474
  containerEl.style.boxSizing = originalBoxSizing
468
-
475
+
469
476
  calculateHeight(content as any)
470
477
  masonry.value = content
471
478
  } else {
@@ -518,18 +525,18 @@ async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
518
525
  try {
519
526
  const res = await fn()
520
527
  if (attempt > 0) {
521
- emits('retry:stop', {attempt, success: true})
528
+ emits('retry:stop', { attempt, success: true })
522
529
  }
523
530
  return res
524
531
  } catch (err) {
525
532
  attempt++
526
533
  if (attempt > max) {
527
- emits('retry:stop', {attempt: attempt - 1, success: false})
534
+ emits('retry:stop', { attempt: attempt - 1, success: false })
528
535
  throw err
529
536
  }
530
- emits('retry:start', {attempt, max, totalMs: delay})
537
+ emits('retry:start', { attempt, max, totalMs: delay })
531
538
  await waitWithProgress(delay, (remaining, total) => {
532
- emits('retry:tick', {attempt, remainingMs: remaining, totalMs: total})
539
+ emits('retry:tick', { attempt, remainingMs: remaining, totalMs: total })
533
540
  })
534
541
  delay += props.retryBackoffStepMs
535
542
  }
@@ -541,17 +548,28 @@ async function loadPage(page: number) {
541
548
  // Starting a new load should clear any previous cancel request
542
549
  cancelRequested.value = false
543
550
  isLoading.value = true
551
+ // Reset hasReachedEnd and loadError when loading a new page
552
+ hasReachedEnd.value = false
553
+ loadError.value = null
544
554
  try {
545
555
  const baseline = (masonry.value as any[]).length
546
556
  if (cancelRequested.value) return
547
557
  const response = await getContent(page)
548
558
  if (cancelRequested.value) return
559
+ // Clear error on successful load
560
+ loadError.value = null
549
561
  currentPage.value = page // Track the current page
550
562
  paginationHistory.value.push(response.nextPage)
563
+ // Update hasReachedEnd if nextPage is null
564
+ if (response.nextPage == null) {
565
+ hasReachedEnd.value = true
566
+ }
551
567
  await maybeBackfillToTarget(baseline)
552
568
  return response
553
569
  } catch (error) {
554
570
  console.error('Error loading page:', error)
571
+ // Set load error
572
+ loadError.value = error instanceof Error ? error : new Error(String(error))
555
573
  throw error
556
574
  } finally {
557
575
  isLoading.value = false
@@ -560,21 +578,39 @@ async function loadPage(page: number) {
560
578
 
561
579
  async function loadNext() {
562
580
  if (isLoading.value) return
581
+ // Don't load if we've already reached the end
582
+ if (hasReachedEnd.value) return
563
583
  // Starting a new load should clear any previous cancel request
564
584
  cancelRequested.value = false
565
585
  isLoading.value = true
586
+ // Clear error when attempting to load
587
+ loadError.value = null
566
588
  try {
567
589
  const baseline = (masonry.value as any[]).length
568
590
  if (cancelRequested.value) return
569
591
  const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
592
+ // Don't load if nextPageToLoad is null
593
+ if (nextPageToLoad == null) {
594
+ hasReachedEnd.value = true
595
+ isLoading.value = false
596
+ return
597
+ }
570
598
  const response = await getContent(nextPageToLoad)
571
599
  if (cancelRequested.value) return
600
+ // Clear error on successful load
601
+ loadError.value = null
572
602
  currentPage.value = nextPageToLoad // Track the current page
573
603
  paginationHistory.value.push(response.nextPage)
604
+ // Update hasReachedEnd if nextPage is null
605
+ if (response.nextPage == null) {
606
+ hasReachedEnd.value = true
607
+ }
574
608
  await maybeBackfillToTarget(baseline)
575
609
  return response
576
610
  } catch (error) {
577
611
  console.error('Error loading next page:', error)
612
+ // Set load error
613
+ loadError.value = error instanceof Error ? error : new Error(String(error))
578
614
  throw error
579
615
  } finally {
580
616
  isLoading.value = false
@@ -589,40 +625,50 @@ async function refreshCurrentPage() {
589
625
  if (isLoading.value) return
590
626
  cancelRequested.value = false
591
627
  isLoading.value = true
592
-
628
+
593
629
  try {
594
630
  // Use the tracked current page
595
631
  const pageToRefresh = currentPage.value
596
-
632
+
597
633
  if (pageToRefresh == null) {
598
634
  console.warn('[Masonry] No current page to refresh - currentPage:', currentPage.value, 'paginationHistory:', paginationHistory.value)
599
635
  return
600
636
  }
601
-
637
+
602
638
  // Clear existing items
603
639
  masonry.value = []
604
640
  masonryContentHeight.value = 0
605
-
641
+ hasReachedEnd.value = false // Reset end flag when refreshing
642
+ loadError.value = null // Reset error flag when refreshing
643
+
606
644
  // Reset pagination history to just the current page
607
645
  paginationHistory.value = [pageToRefresh]
608
-
646
+
609
647
  await nextTick()
610
-
648
+
611
649
  // Reload the current page
612
650
  const response = await getContent(pageToRefresh)
613
651
  if (cancelRequested.value) return
614
-
652
+
653
+ // Clear error on successful load
654
+ loadError.value = null
615
655
  // Update pagination state
616
656
  currentPage.value = pageToRefresh
617
657
  paginationHistory.value.push(response.nextPage)
618
-
658
+ // Update hasReachedEnd if nextPage is null
659
+ if (response.nextPage == null) {
660
+ hasReachedEnd.value = true
661
+ }
662
+
619
663
  // Optionally backfill if needed
620
664
  const baseline = (masonry.value as any[]).length
621
665
  await maybeBackfillToTarget(baseline)
622
-
666
+
623
667
  return response
624
668
  } catch (error) {
625
669
  console.error('[Masonry] Error refreshing current page:', error)
670
+ // Set load error
671
+ loadError.value = error instanceof Error ? error : new Error(String(error))
626
672
  throw error
627
673
  } finally {
628
674
  isLoading.value = false
@@ -633,7 +679,7 @@ async function remove(item: any) {
633
679
  const next = (masonry.value as any[]).filter(i => i.id !== item.id)
634
680
  masonry.value = next
635
681
  await nextTick()
636
-
682
+
637
683
  // If all items were removed, either refresh current page or load next based on prop
638
684
  if (next.length === 0 && paginationHistory.value.length > 0) {
639
685
  if (props.autoRefreshOnEmpty) {
@@ -644,11 +690,11 @@ async function remove(item: any) {
644
690
  // Force backfill from 0 to ensure viewport is filled
645
691
  // Pass baseline=0 and force=true to trigger backfill even if backfillEnabled was temporarily disabled
646
692
  await maybeBackfillToTarget(0, true)
647
- } catch {}
693
+ } catch { }
648
694
  }
649
695
  return
650
696
  }
651
-
697
+
652
698
  // Commit DOM updates without forcing sync reflow
653
699
  await new Promise<void>(r => requestAnimationFrame(() => r()))
654
700
  // Start FLIP on next frame
@@ -663,7 +709,7 @@ async function removeMany(items: any[]) {
663
709
  const next = (masonry.value as any[]).filter(i => !ids.has(i.id))
664
710
  masonry.value = next
665
711
  await nextTick()
666
-
712
+
667
713
  // If all items were removed, either refresh current page or load next based on prop
668
714
  if (next.length === 0 && paginationHistory.value.length > 0) {
669
715
  if (props.autoRefreshOnEmpty) {
@@ -673,11 +719,11 @@ async function removeMany(items: any[]) {
673
719
  await loadNext()
674
720
  // Force backfill from 0 to ensure viewport is filled
675
721
  await maybeBackfillToTarget(0, true)
676
- } catch {}
722
+ } catch { }
677
723
  }
678
724
  return
679
725
  }
680
-
726
+
681
727
  // Commit DOM updates without forcing sync reflow
682
728
  await new Promise<void>(r => requestAnimationFrame(() => r()))
683
729
  // Start FLIP on next frame
@@ -699,15 +745,15 @@ function scrollToTop(options?: ScrollToOptions) {
699
745
  async function removeAll() {
700
746
  // Scroll to top first for better UX
701
747
  scrollToTop({ behavior: 'smooth' })
702
-
748
+
703
749
  // Clear all items
704
750
  masonry.value = []
705
-
751
+
706
752
  // Recalculate height to 0
707
753
  containerHeight.value = 0
708
-
754
+
709
755
  await nextTick()
710
-
756
+
711
757
  // Emit completion event
712
758
  emits('remove-all:complete')
713
759
  }
@@ -728,25 +774,31 @@ async function maybeBackfillToTarget(baselineCount: number, force = false) {
728
774
  if (!force && !props.backfillEnabled) return
729
775
  if (backfillActive) return
730
776
  if (cancelRequested.value) return
777
+ // Don't backfill if we've reached the end
778
+ if (hasReachedEnd.value) return
731
779
 
732
780
  const targetCount = (baselineCount || 0) + (props.pageSize || 0)
733
781
  if (!props.pageSize || props.pageSize <= 0) return
734
782
 
735
783
  const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
736
- if (lastNext == null) return
784
+ if (lastNext == null) {
785
+ hasReachedEnd.value = true
786
+ return
787
+ }
737
788
 
738
789
  if ((masonry.value as any[]).length >= targetCount) return
739
790
 
740
791
  backfillActive = true
741
792
  try {
742
793
  let calls = 0
743
- emits('backfill:start', {target: targetCount, fetched: (masonry.value as any[]).length, calls})
794
+ emits('backfill:start', { target: targetCount, fetched: (masonry.value as any[]).length, calls })
744
795
 
745
796
  while (
746
797
  (masonry.value as any[]).length < targetCount &&
747
798
  calls < props.backfillMaxCalls &&
748
799
  paginationHistory.value[paginationHistory.value.length - 1] != null &&
749
- !cancelRequested.value
800
+ !cancelRequested.value &&
801
+ !hasReachedEnd.value
750
802
  ) {
751
803
  await waitWithProgress(props.backfillDelayMs, (remaining, total) => {
752
804
  emits('backfill:tick', {
@@ -761,11 +813,24 @@ async function maybeBackfillToTarget(baselineCount: number, force = false) {
761
813
  if (cancelRequested.value) break
762
814
 
763
815
  const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
816
+ if (currentPage == null) {
817
+ hasReachedEnd.value = true
818
+ break
819
+ }
764
820
  try {
765
821
  isLoading.value = true
766
822
  const response = await getContent(currentPage)
767
823
  if (cancelRequested.value) break
824
+ // Clear error on successful load
825
+ loadError.value = null
768
826
  paginationHistory.value.push(response.nextPage)
827
+ // Update hasReachedEnd if nextPage is null
828
+ if (response.nextPage == null) {
829
+ hasReachedEnd.value = true
830
+ }
831
+ } catch (error) {
832
+ // Set load error but don't break the backfill loop
833
+ loadError.value = error instanceof Error ? error : new Error(String(error))
769
834
  } finally {
770
835
  isLoading.value = false
771
836
  }
@@ -773,7 +838,7 @@ async function maybeBackfillToTarget(baselineCount: number, force = false) {
773
838
  calls++
774
839
  }
775
840
 
776
- emits('backfill:stop', {fetched: (masonry.value as any[]).length, calls})
841
+ emits('backfill:stop', { fetched: (masonry.value as any[]).length, calls })
777
842
  } finally {
778
843
  backfillActive = false
779
844
  }
@@ -800,6 +865,8 @@ function reset() {
800
865
  containerHeight.value = 0
801
866
  currentPage.value = props.loadAtPage // Reset current page tracking
802
867
  paginationHistory.value = [props.loadAtPage]
868
+ hasReachedEnd.value = false // Reset end flag
869
+ loadError.value = null // Reset error flag
803
870
 
804
871
  scrollProgress.value = {
805
872
  distanceToTrigger: 0,
@@ -807,9 +874,52 @@ function reset() {
807
874
  }
808
875
  }
809
876
 
877
+ function destroy() {
878
+ // Cancel any ongoing loads
879
+ cancelLoad()
880
+
881
+ // Reset all state
882
+ masonry.value = []
883
+ masonryContentHeight.value = 0
884
+ currentPage.value = null
885
+ paginationHistory.value = []
886
+ hasReachedEnd.value = false
887
+ loadError.value = null
888
+ isLoading.value = false
889
+ backfillActive = false
890
+ cancelRequested.value = false
891
+
892
+ // Reset swipe mode state
893
+ currentSwipeIndex.value = 0
894
+ swipeOffset.value = 0
895
+ isDragging.value = false
896
+
897
+ // Reset viewport state
898
+ viewportTop.value = 0
899
+ viewportHeight.value = 0
900
+ virtualizing.value = false
901
+
902
+ // Reset scroll progress
903
+ scrollProgress.value = {
904
+ distanceToTrigger: 0,
905
+ isNearTrigger: false
906
+ }
907
+
908
+ // Reset invalid dimension tracking
909
+ invalidDimensionIds.value.clear()
910
+
911
+ // Scroll to top if container exists
912
+ if (container.value) {
913
+ container.value.scrollTo({
914
+ top: 0,
915
+ behavior: 'auto' // Instant scroll for destroy
916
+ })
917
+ }
918
+ }
919
+
810
920
  const debouncedScrollHandler = debounce(async () => {
811
921
  if (useSwipeMode.value) return // Skip scroll handling in swipe mode
812
-
922
+
813
923
  if (container.value) {
814
924
  viewportTop.value = container.value.scrollTop
815
925
  viewportHeight.value = container.value.clientHeight
@@ -846,10 +956,10 @@ function handleTouchMove(e: TouchEvent) {
846
956
  function handleTouchEnd(e: TouchEvent) {
847
957
  if (!useSwipeMode.value || !isDragging.value) return
848
958
  isDragging.value = false
849
-
959
+
850
960
  const deltaY = swipeOffset.value - dragStartOffset.value
851
961
  const threshold = 100 // Minimum swipe distance to trigger navigation
852
-
962
+
853
963
  if (Math.abs(deltaY) > threshold) {
854
964
  if (deltaY > 0 && previousItem.value) {
855
965
  // Swipe down - go to previous
@@ -865,7 +975,7 @@ function handleTouchEnd(e: TouchEvent) {
865
975
  // Snap back if swipe wasn't far enough
866
976
  snapToCurrentItem()
867
977
  }
868
-
978
+
869
979
  e.preventDefault()
870
980
  }
871
981
 
@@ -888,10 +998,10 @@ function handleMouseMove(e: MouseEvent) {
888
998
  function handleMouseUp(e: MouseEvent) {
889
999
  if (!useSwipeMode.value || !isDragging.value) return
890
1000
  isDragging.value = false
891
-
1001
+
892
1002
  const deltaY = swipeOffset.value - dragStartOffset.value
893
1003
  const threshold = 100
894
-
1004
+
895
1005
  if (Math.abs(deltaY) > threshold) {
896
1006
  if (deltaY > 0 && previousItem.value) {
897
1007
  goToPreviousItem()
@@ -903,7 +1013,7 @@ function handleMouseUp(e: MouseEvent) {
903
1013
  } else {
904
1014
  snapToCurrentItem()
905
1015
  }
906
-
1016
+
907
1017
  e.preventDefault()
908
1018
  }
909
1019
 
@@ -913,10 +1023,10 @@ function goToNextItem() {
913
1023
  loadNext()
914
1024
  return
915
1025
  }
916
-
1026
+
917
1027
  currentSwipeIndex.value++
918
1028
  snapToCurrentItem()
919
-
1029
+
920
1030
  // Preload next item if we're near the end
921
1031
  if (currentSwipeIndex.value >= masonry.value.length - 5) {
922
1032
  loadNext()
@@ -925,14 +1035,14 @@ function goToNextItem() {
925
1035
 
926
1036
  function goToPreviousItem() {
927
1037
  if (!previousItem.value) return
928
-
1038
+
929
1039
  currentSwipeIndex.value--
930
1040
  snapToCurrentItem()
931
1041
  }
932
1042
 
933
1043
  function snapToCurrentItem() {
934
1044
  if (!swipeContainer.value) return
935
-
1045
+
936
1046
  // Use container height for swipe mode instead of window height
937
1047
  const viewportHeight = swipeContainer.value.clientHeight
938
1048
  swipeOffset.value = -currentSwipeIndex.value * viewportHeight
@@ -946,12 +1056,12 @@ function handleWindowResize() {
946
1056
  currentSwipeIndex.value = 0
947
1057
  swipeOffset.value = 0
948
1058
  }
949
-
1059
+
950
1060
  // If switching to swipe mode, ensure we have items loaded
951
1061
  if (useSwipeMode.value && masonry.value.length === 0 && !isLoading.value) {
952
1062
  loadPage(paginationHistory.value[0] as any)
953
1063
  }
954
-
1064
+
955
1065
  // Re-snap to current item on resize to adjust offset
956
1066
  if (useSwipeMode.value) {
957
1067
  snapToCurrentItem()
@@ -962,9 +1072,11 @@ function init(items: any[], page: any, next: any) {
962
1072
  currentPage.value = page // Track the initial current page
963
1073
  paginationHistory.value = [page]
964
1074
  paginationHistory.value.push(next)
1075
+ // Update hasReachedEnd if next is null
1076
+ hasReachedEnd.value = next == null
965
1077
  // Diagnostics: check incoming initial items
966
1078
  checkItemDimensions(items as any[], 'init')
967
-
1079
+
968
1080
  if (useSwipeMode.value) {
969
1081
  // In swipe mode, just add items without layout calculation
970
1082
  masonry.value = [...(masonry.value as any[]), ...items]
@@ -1004,17 +1116,34 @@ watch(() => props.layoutMode, () => {
1004
1116
  }
1005
1117
  })
1006
1118
 
1119
+ // Watch container element to attach scroll listener when available
1120
+ watch(container, (el) => {
1121
+ if (el && !useSwipeMode.value) {
1122
+ // Attach scroll listener for masonry mode
1123
+ el.removeEventListener('scroll', debouncedScrollHandler) // Just in case
1124
+ el.addEventListener('scroll', debouncedScrollHandler, { passive: true })
1125
+ } else if (el) {
1126
+ // Remove scroll listener if switching to swipe mode
1127
+ el.removeEventListener('scroll', debouncedScrollHandler)
1128
+ }
1129
+ }, { immediate: true })
1130
+
1007
1131
  // Watch for swipe mode changes to refresh layout and setup/teardown handlers
1008
1132
  watch(useSwipeMode, (newValue, oldValue) => {
1009
1133
  // Skip if this is the initial watch call and values are the same
1010
1134
  if (oldValue === undefined && newValue === false) return
1011
-
1135
+
1012
1136
  nextTick(() => {
1013
1137
  if (newValue) {
1014
1138
  // Switching to Swipe Mode
1015
1139
  document.addEventListener('mousemove', handleMouseMove)
1016
1140
  document.addEventListener('mouseup', handleMouseUp)
1017
-
1141
+
1142
+ // Remove scroll listener
1143
+ if (container.value) {
1144
+ container.value.removeEventListener('scroll', debouncedScrollHandler)
1145
+ }
1146
+
1018
1147
  // Reset index if needed
1019
1148
  currentSwipeIndex.value = 0
1020
1149
  swipeOffset.value = 0
@@ -1025,7 +1154,7 @@ watch(useSwipeMode, (newValue, oldValue) => {
1025
1154
  // Switching to Masonry Mode
1026
1155
  document.removeEventListener('mousemove', handleMouseMove)
1027
1156
  document.removeEventListener('mouseup', handleMouseUp)
1028
-
1157
+
1029
1158
  if (container.value && wrapper.value) {
1030
1159
  // Ensure containerWidth is up to date - use fixed dimensions if set
1031
1160
  if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
@@ -1033,16 +1162,16 @@ watch(useSwipeMode, (newValue, oldValue) => {
1033
1162
  } else {
1034
1163
  containerWidth.value = wrapper.value.clientWidth
1035
1164
  }
1036
-
1037
- // Re-attach scroll listener since container was re-created
1165
+
1166
+ // Attach scroll listener (container watcher will handle this, but ensure it's attached)
1038
1167
  container.value.removeEventListener('scroll', debouncedScrollHandler) // Just in case
1039
1168
  container.value.addEventListener('scroll', debouncedScrollHandler, { passive: true })
1040
-
1169
+
1041
1170
  // Refresh layout with updated width
1042
1171
  if (masonry.value.length > 0) {
1043
1172
  columns.value = getColumnCount(layout.value as any, containerWidth.value)
1044
1173
  refreshLayout(masonry.value as any)
1045
-
1174
+
1046
1175
  // Update viewport state
1047
1176
  viewportTop.value = container.value.scrollTop
1048
1177
  viewportHeight.value = container.value.clientHeight
@@ -1078,12 +1207,12 @@ watch(wrapper, (el) => {
1078
1207
  resizeObserver.disconnect()
1079
1208
  resizeObserver = null
1080
1209
  }
1081
-
1210
+
1082
1211
  if (el && typeof ResizeObserver !== 'undefined') {
1083
1212
  resizeObserver = new ResizeObserver((entries) => {
1084
1213
  // Skip updates if fixed dimensions are set
1085
1214
  if (fixedDimensions.value) return
1086
-
1215
+
1087
1216
  for (const entry of entries) {
1088
1217
  const newWidth = entry.contentRect.width
1089
1218
  const newHeight = entry.contentRect.height
@@ -1126,14 +1255,14 @@ onMounted(async () => {
1126
1255
  try {
1127
1256
  // Wait for next tick to ensure wrapper is mounted
1128
1257
  await nextTick()
1129
-
1258
+
1130
1259
  // Container dimensions are managed by ResizeObserver
1131
1260
  // Only set initial values if ResizeObserver isn't available
1132
1261
  if (wrapper.value && !resizeObserver) {
1133
1262
  containerWidth.value = wrapper.value.clientWidth
1134
1263
  containerHeight.value = wrapper.value.clientHeight
1135
1264
  }
1136
-
1265
+
1137
1266
  if (!useSwipeMode.value) {
1138
1267
  columns.value = getColumnCount(layout.value as any, containerWidth.value)
1139
1268
  if (container.value) {
@@ -1171,18 +1300,18 @@ onUnmounted(() => {
1171
1300
  resizeObserver.disconnect()
1172
1301
  resizeObserver = null
1173
1302
  }
1174
-
1303
+
1175
1304
  container.value?.removeEventListener('scroll', debouncedScrollHandler)
1176
1305
  window.removeEventListener('resize', debouncedResizeHandler)
1177
1306
  window.removeEventListener('resize', handleWindowResize)
1178
-
1307
+
1179
1308
  if (swipeContainer.value) {
1180
1309
  swipeContainer.value.removeEventListener('touchstart', handleTouchStart)
1181
1310
  swipeContainer.value.removeEventListener('touchmove', handleTouchMove)
1182
1311
  swipeContainer.value.removeEventListener('touchend', handleTouchEnd)
1183
1312
  swipeContainer.value.removeEventListener('mousedown', handleMouseDown)
1184
1313
  }
1185
-
1314
+
1186
1315
  // Clean up mouse handlers
1187
1316
  document.removeEventListener('mousemove', handleMouseMove)
1188
1317
  document.removeEventListener('mouseup', handleMouseUp)
@@ -1192,41 +1321,27 @@ onUnmounted(() => {
1192
1321
  <template>
1193
1322
  <div ref="wrapper" class="w-full h-full flex flex-col relative">
1194
1323
  <!-- Swipe Feed Mode (Mobile/Tablet) -->
1195
- <div v-if="useSwipeMode"
1196
- class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
1197
- :class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
1198
- ref="swipeContainer"
1199
- style="height: 100%; max-height: 100%; position: relative;">
1200
- <div
1201
- class="relative w-full"
1202
- :style="{
1203
- transform: `translateY(${swipeOffset}px)`,
1204
- transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
1205
- height: `${masonry.length * 100}%`
1206
- }">
1207
- <div
1208
- v-for="(item, index) in masonry"
1209
- :key="`${item.page}-${item.id}`"
1210
- class="absolute top-0 left-0 w-full"
1211
- :style="{
1324
+ <div v-if="useSwipeMode" class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
1325
+ :class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
1326
+ ref="swipeContainer" style="height: 100%; max-height: 100%; position: relative;">
1327
+ <div class="relative w-full" :style="{
1328
+ transform: `translateY(${swipeOffset}px)`,
1329
+ transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
1330
+ height: `${masonry.length * 100}%`
1331
+ }">
1332
+ <div v-for="(item, index) in masonry" :key="`${item.page}-${item.id}`" class="absolute top-0 left-0 w-full"
1333
+ :style="{
1212
1334
  top: `${index * (100 / masonry.length)}%`,
1213
1335
  height: `${100 / masonry.length}%`
1214
1336
  }">
1215
1337
  <div class="w-full h-full flex items-center justify-center p-4">
1216
1338
  <div class="w-full h-full max-w-full max-h-full relative">
1217
1339
  <slot :item="item" :remove="remove">
1218
- <MasonryItem
1219
- :item="item"
1220
- :remove="remove"
1221
- :header-height="layout.header"
1222
- :footer-height="layout.footer"
1223
- :in-swipe-mode="true"
1224
- :is-active="index === currentSwipeIndex"
1340
+ <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
1341
+ :in-swipe-mode="true" :is-active="index === currentSwipeIndex"
1225
1342
  @preload:success="(p) => emits('item:preload:success', p)"
1226
1343
  @preload:error="(p) => emits('item:preload:error', p)"
1227
- @mouse-enter="(p) => emits('item:mouse-enter', p)"
1228
- @mouse-leave="(p) => emits('item:mouse-leave', p)"
1229
- >
1344
+ @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
1230
1345
  <!-- Pass through header and footer slots to MasonryItem -->
1231
1346
  <template #header="slotProps">
1232
1347
  <slot name="item-header" v-bind="slotProps" />
@@ -1240,39 +1355,35 @@ onUnmounted(() => {
1240
1355
  </div>
1241
1356
  </div>
1242
1357
  </div>
1243
-
1244
-
1245
-
1246
-
1358
+ <!-- End of list message for swipe mode -->
1359
+ <div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
1360
+ <slot name="end-message">
1361
+ <p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
1362
+ </slot>
1363
+ </div>
1364
+ <!-- Error message for swipe mode -->
1365
+ <div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
1366
+ <slot name="error-message" :error="loadError">
1367
+ <p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
1368
+ </slot>
1369
+ </div>
1247
1370
  </div>
1248
1371
 
1249
1372
  <!-- Masonry Grid Mode (Desktop) -->
1250
- <div v-else
1251
- class="overflow-auto w-full flex-1 masonry-container"
1252
- :class="{ 'force-motion': props.forceMotion }"
1253
- ref="container">
1373
+ <div v-else class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }"
1374
+ ref="container">
1254
1375
  <div class="relative"
1255
- :style="{height: `${masonryContentHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing}">
1256
- <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter"
1257
- @leave="leave"
1258
- @before-leave="beforeLeave">
1259
- <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`"
1260
- class="absolute masonry-item"
1261
- v-bind="getItemAttributes(item, i)">
1376
+ :style="{ height: `${masonryContentHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing }">
1377
+ <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter" @leave="leave"
1378
+ @before-leave="beforeLeave">
1379
+ <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`" class="absolute masonry-item"
1380
+ v-bind="getItemAttributes(item, i)">
1262
1381
  <!-- Use default slot if provided, otherwise use MasonryItem -->
1263
1382
  <slot :item="item" :remove="remove">
1264
- <MasonryItem
1265
- :item="item"
1266
- :remove="remove"
1267
- :header-height="layout.header"
1268
- :footer-height="layout.footer"
1269
- :in-swipe-mode="false"
1270
- :is-active="false"
1271
- @preload:success="(p) => emits('item:preload:success', p)"
1383
+ <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
1384
+ :in-swipe-mode="false" :is-active="false" @preload:success="(p) => emits('item:preload:success', p)"
1272
1385
  @preload:error="(p) => emits('item:preload:error', p)"
1273
- @mouse-enter="(p) => emits('item:mouse-enter', p)"
1274
- @mouse-leave="(p) => emits('item:mouse-leave', p)"
1275
- >
1386
+ @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
1276
1387
  <!-- Pass through header and footer slots to MasonryItem -->
1277
1388
  <template #header="slotProps">
1278
1389
  <slot name="item-header" v-bind="slotProps" />
@@ -1284,8 +1395,18 @@ onUnmounted(() => {
1284
1395
  </slot>
1285
1396
  </div>
1286
1397
  </transition-group>
1287
-
1288
-
1398
+ </div>
1399
+ <!-- End of list message -->
1400
+ <div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
1401
+ <slot name="end-message">
1402
+ <p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
1403
+ </slot>
1404
+ </div>
1405
+ <!-- Error message -->
1406
+ <div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
1407
+ <slot name="error-message" :error="loadError">
1408
+ <p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
1409
+ </slot>
1289
1410
  </div>
1290
1411
  </div>
1291
1412
  </div>
@@ -1300,7 +1421,7 @@ onUnmounted(() => {
1300
1421
  will-change: transform, opacity;
1301
1422
  contain: layout paint;
1302
1423
  transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
1303
- opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
1424
+ opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
1304
1425
  backface-visibility: hidden;
1305
1426
  }
1306
1427
 
@@ -1309,6 +1430,7 @@ onUnmounted(() => {
1309
1430
  }
1310
1431
 
1311
1432
  @media (prefers-reduced-motion: reduce) {
1433
+
1312
1434
  .masonry-container:not(.force-motion) .masonry-item,
1313
1435
  .masonry-container:not(.force-motion) .masonry-move {
1314
1436
  transition-duration: 1ms !important;