@wyxos/vibe 1.6.11 → 1.6.12

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/lib/vibe.css CHANGED
@@ -1 +1 @@
1
- .masonry-container[data-v-110c3294]{overflow-anchor:none}.masonry-item[data-v-110c3294]{will-change:transform,opacity;contain:layout paint;transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),opacity var(--masonry-leave-duration, .16s) ease-out var(--masonry-opacity-delay, 0ms);backface-visibility:hidden}.masonry-move[data-v-110c3294]{transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1))}@media (prefers-reduced-motion: reduce){.masonry-container:not(.force-motion) .masonry-item[data-v-110c3294],.masonry-container:not(.force-motion) .masonry-move[data-v-110c3294]{transition-duration:1ms!important}}
1
+ .masonry-container[data-v-919154a5]{overflow-anchor:none}.masonry-item[data-v-919154a5]{will-change:transform,opacity;contain:layout paint;transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),opacity var(--masonry-leave-duration, .16s) ease-out var(--masonry-opacity-delay, 0ms);backface-visibility:hidden}.masonry-move[data-v-919154a5]{transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1))}@media (prefers-reduced-motion: reduce){.masonry-container:not(.force-motion) .masonry-item[data-v-919154a5],.masonry-container:not(.force-motion) .masonry-move[data-v-919154a5]{transition-duration:1ms!important}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/vibe",
3
- "version": "1.6.11",
3
+ "version": "1.6.12",
4
4
  "main": "lib/index.js",
5
5
  "module": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
package/src/Masonry.vue CHANGED
@@ -97,6 +97,17 @@ const props = defineProps({
97
97
  type: Boolean,
98
98
  default: false
99
99
  },
100
+ // Layout mode: 'auto' (detect from screen size), 'masonry', or 'swipe'
101
+ layoutMode: {
102
+ type: String,
103
+ default: 'auto',
104
+ validator: (v: string) => ['auto', 'masonry', 'swipe'].includes(v)
105
+ },
106
+ // Breakpoint for switching to swipe mode (in pixels or Tailwind breakpoint name)
107
+ mobileBreakpoint: {
108
+ type: [Number, String],
109
+ default: 768 // 'md' breakpoint
110
+ },
100
111
  })
101
112
 
102
113
  const defaultLayout = {
@@ -119,6 +130,57 @@ const layout = computed(() => ({
119
130
  }
120
131
  }))
121
132
 
133
+ const wrapper = ref<HTMLElement | null>(null)
134
+ const containerWidth = ref<number>(typeof window !== 'undefined' ? window.innerWidth : 1024)
135
+ let resizeObserver: ResizeObserver | null = null
136
+
137
+ // Get breakpoint value from Tailwind breakpoint name
138
+ function getBreakpointValue(breakpoint: string): number {
139
+ const breakpoints: Record<string, number> = {
140
+ 'sm': 640,
141
+ 'md': 768,
142
+ 'lg': 1024,
143
+ 'xl': 1280,
144
+ '2xl': 1536
145
+ }
146
+ return breakpoints[breakpoint] || 768
147
+ }
148
+
149
+ // Determine if we should use swipe mode
150
+ const useSwipeMode = computed(() => {
151
+ if (props.layoutMode === 'masonry') return false
152
+ if (props.layoutMode === 'swipe') return true
153
+
154
+ // Auto mode: check container width
155
+ const breakpoint = typeof props.mobileBreakpoint === 'string'
156
+ ? getBreakpointValue(props.mobileBreakpoint)
157
+ : props.mobileBreakpoint
158
+
159
+ return containerWidth.value < breakpoint
160
+ })
161
+
162
+ // Get current item index for swipe mode
163
+ const currentItem = computed(() => {
164
+ if (!useSwipeMode.value || masonry.value.length === 0) return null
165
+ const index = Math.max(0, Math.min(currentSwipeIndex.value, masonry.value.length - 1))
166
+ return (masonry.value as any[])[index] || null
167
+ })
168
+
169
+ // Get next/previous items for preloading in swipe mode
170
+ const nextItem = computed(() => {
171
+ if (!useSwipeMode.value || !currentItem.value) return null
172
+ const nextIndex = currentSwipeIndex.value + 1
173
+ if (nextIndex >= masonry.value.length) return null
174
+ return (masonry.value as any[])[nextIndex] || null
175
+ })
176
+
177
+ const previousItem = computed(() => {
178
+ if (!useSwipeMode.value || !currentItem.value) return null
179
+ const prevIndex = currentSwipeIndex.value - 1
180
+ if (prevIndex < 0) return null
181
+ return (masonry.value as any[])[prevIndex] || null
182
+ })
183
+
122
184
  const emits = defineEmits([
123
185
  'update:items',
124
186
  'backfill:start',
@@ -142,6 +204,14 @@ const currentPage = ref<any>(null) // Track the actual current page being displ
142
204
  const isLoading = ref<boolean>(false)
143
205
  const containerHeight = ref<number>(0)
144
206
 
207
+ // Swipe mode state
208
+ const currentSwipeIndex = ref<number>(0)
209
+ const swipeOffset = ref<number>(0)
210
+ const isDragging = ref<boolean>(false)
211
+ const dragStartY = ref<number>(0)
212
+ const dragStartOffset = ref<number>(0)
213
+ const swipeContainer = ref<HTMLElement | null>(null)
214
+
145
215
  // Diagnostics: track items missing width/height to help developers
146
216
  const invalidDimensionIds = ref<Set<number | string>>(new Set())
147
217
  function isPositiveNumber(value: unknown): boolean {
@@ -320,6 +390,12 @@ function calculateHeight(content: any[]) {
320
390
  }
321
391
 
322
392
  function refreshLayout(items: any[]) {
393
+ if (useSwipeMode.value) {
394
+ // In swipe mode, no layout calculation needed - items are stacked vertically
395
+ masonry.value = items as any
396
+ return
397
+ }
398
+
323
399
  if (!container.value) return
324
400
  // Developer diagnostics: warn when dimensions are invalid
325
401
  checkItemDimensions(items as any[], 'refreshLayout')
@@ -671,6 +747,8 @@ function reset() {
671
747
  }
672
748
 
673
749
  const debouncedScrollHandler = debounce(async () => {
750
+ if (useSwipeMode.value) return // Skip scroll handling in swipe mode
751
+
674
752
  if (container.value) {
675
753
  viewportTop.value = container.value.scrollTop
676
754
  viewportHeight.value = container.value.clientHeight
@@ -688,34 +766,295 @@ const debouncedScrollHandler = debounce(async () => {
688
766
 
689
767
  const debouncedResizeHandler = debounce(onResize, 200)
690
768
 
769
+ // Swipe gesture handlers
770
+ function handleTouchStart(e: TouchEvent) {
771
+ if (!useSwipeMode.value) return
772
+ isDragging.value = true
773
+ dragStartY.value = e.touches[0].clientY
774
+ dragStartOffset.value = swipeOffset.value
775
+ e.preventDefault()
776
+ }
777
+
778
+ function handleTouchMove(e: TouchEvent) {
779
+ if (!useSwipeMode.value || !isDragging.value) return
780
+ const deltaY = e.touches[0].clientY - dragStartY.value
781
+ swipeOffset.value = dragStartOffset.value + deltaY
782
+ e.preventDefault()
783
+ }
784
+
785
+ function handleTouchEnd(e: TouchEvent) {
786
+ if (!useSwipeMode.value || !isDragging.value) return
787
+ isDragging.value = false
788
+
789
+ const deltaY = swipeOffset.value - dragStartOffset.value
790
+ const threshold = 100 // Minimum swipe distance to trigger navigation
791
+
792
+ if (Math.abs(deltaY) > threshold) {
793
+ if (deltaY > 0 && previousItem.value) {
794
+ // Swipe down - go to previous
795
+ goToPreviousItem()
796
+ } else if (deltaY < 0 && nextItem.value) {
797
+ // Swipe up - go to next
798
+ goToNextItem()
799
+ } else {
800
+ // Snap back
801
+ snapToCurrentItem()
802
+ }
803
+ } else {
804
+ // Snap back if swipe wasn't far enough
805
+ snapToCurrentItem()
806
+ }
807
+
808
+ e.preventDefault()
809
+ }
810
+
811
+ // Mouse drag handlers for desktop testing
812
+ function handleMouseDown(e: MouseEvent) {
813
+ if (!useSwipeMode.value) return
814
+ isDragging.value = true
815
+ dragStartY.value = e.clientY
816
+ dragStartOffset.value = swipeOffset.value
817
+ e.preventDefault()
818
+ }
819
+
820
+ function handleMouseMove(e: MouseEvent) {
821
+ if (!useSwipeMode.value || !isDragging.value) return
822
+ const deltaY = e.clientY - dragStartY.value
823
+ swipeOffset.value = dragStartOffset.value + deltaY
824
+ e.preventDefault()
825
+ }
826
+
827
+ function handleMouseUp(e: MouseEvent) {
828
+ if (!useSwipeMode.value || !isDragging.value) return
829
+ isDragging.value = false
830
+
831
+ const deltaY = swipeOffset.value - dragStartOffset.value
832
+ const threshold = 100
833
+
834
+ if (Math.abs(deltaY) > threshold) {
835
+ if (deltaY > 0 && previousItem.value) {
836
+ goToPreviousItem()
837
+ } else if (deltaY < 0 && nextItem.value) {
838
+ goToNextItem()
839
+ } else {
840
+ snapToCurrentItem()
841
+ }
842
+ } else {
843
+ snapToCurrentItem()
844
+ }
845
+
846
+ e.preventDefault()
847
+ }
848
+
849
+ function goToNextItem() {
850
+ if (!nextItem.value) {
851
+ // Try to load next page
852
+ loadNext()
853
+ return
854
+ }
855
+
856
+ currentSwipeIndex.value++
857
+ snapToCurrentItem()
858
+
859
+ // Preload next item if we're near the end
860
+ if (currentSwipeIndex.value >= masonry.value.length - 5) {
861
+ loadNext()
862
+ }
863
+ }
864
+
865
+ function goToPreviousItem() {
866
+ if (!previousItem.value) return
867
+
868
+ currentSwipeIndex.value--
869
+ snapToCurrentItem()
870
+ }
871
+
872
+ function snapToCurrentItem() {
873
+ if (!swipeContainer.value) return
874
+
875
+ // Use container height for swipe mode instead of window height
876
+ const viewportHeight = swipeContainer.value.clientHeight
877
+ swipeOffset.value = -currentSwipeIndex.value * viewportHeight
878
+ }
879
+
880
+ // Watch for container/window resize to update swipe mode
881
+ function handleWindowResize() {
882
+ // Update container width if wrapper is available
883
+ if (wrapper.value) {
884
+ containerWidth.value = wrapper.value.clientWidth
885
+ } else if (typeof window !== 'undefined') {
886
+ containerWidth.value = window.innerWidth
887
+ }
888
+
889
+ // If switching from swipe to masonry, reset swipe state
890
+ if (!useSwipeMode.value && currentSwipeIndex.value > 0) {
891
+ currentSwipeIndex.value = 0
892
+ swipeOffset.value = 0
893
+ }
894
+
895
+ // If switching to swipe mode, ensure we have items loaded
896
+ if (useSwipeMode.value && masonry.value.length === 0 && !isLoading.value) {
897
+ loadPage(paginationHistory.value[0] as any)
898
+ }
899
+
900
+ // Re-snap to current item on resize to adjust offset
901
+ if (useSwipeMode.value) {
902
+ snapToCurrentItem()
903
+ }
904
+ }
905
+
691
906
  function init(items: any[], page: any, next: any) {
692
907
  currentPage.value = page // Track the initial current page
693
908
  paginationHistory.value = [page]
694
909
  paginationHistory.value.push(next)
695
910
  // Diagnostics: check incoming initial items
696
911
  checkItemDimensions(items as any[], 'init')
697
- refreshLayout([...(masonry.value as any[]), ...items])
698
- updateScrollProgress()
912
+
913
+ if (useSwipeMode.value) {
914
+ // In swipe mode, just add items without layout calculation
915
+ masonry.value = [...(masonry.value as any[]), ...items]
916
+ // Reset swipe index if we're at the start
917
+ if (currentSwipeIndex.value === 0 && masonry.value.length > 0) {
918
+ swipeOffset.value = 0
919
+ }
920
+ } else {
921
+ refreshLayout([...(masonry.value as any[]), ...items])
922
+ updateScrollProgress()
923
+ }
699
924
  }
700
925
 
701
926
  // Watch for layout changes and update columns + refresh layout dynamically
702
927
  watch(
703
928
  layout,
704
929
  () => {
930
+ if (useSwipeMode.value) {
931
+ // In swipe mode, no layout recalculation needed
932
+ return
933
+ }
705
934
  if (container.value) {
706
- columns.value = getColumnCount(layout.value as any)
935
+ columns.value = getColumnCount(layout.value as any, containerWidth.value)
707
936
  refreshLayout(masonry.value as any)
708
937
  }
709
938
  },
710
939
  { deep: true }
711
940
  )
712
941
 
942
+ // Watch for swipe mode changes to refresh layout and setup/teardown handlers
943
+ watch(useSwipeMode, (newValue) => {
944
+ nextTick(() => {
945
+ if (newValue) {
946
+ // Switching to Swipe Mode
947
+ document.addEventListener('mousemove', handleMouseMove)
948
+ document.addEventListener('mouseup', handleMouseUp)
949
+
950
+ // Reset index if needed
951
+ currentSwipeIndex.value = 0
952
+ swipeOffset.value = 0
953
+ if (masonry.value.length > 0) {
954
+ snapToCurrentItem()
955
+ }
956
+ } else {
957
+ // Switching to Masonry Mode
958
+ document.removeEventListener('mousemove', handleMouseMove)
959
+ document.removeEventListener('mouseup', handleMouseUp)
960
+
961
+ if (container.value && wrapper.value) {
962
+ // Ensure containerWidth is up to date
963
+ containerWidth.value = wrapper.value.clientWidth
964
+
965
+ // Re-attach scroll listener since container was re-created
966
+ container.value.removeEventListener('scroll', debouncedScrollHandler) // Just in case
967
+ container.value.addEventListener('scroll', debouncedScrollHandler, { passive: true })
968
+
969
+ // Refresh layout with updated width
970
+ if (masonry.value.length > 0) {
971
+ columns.value = getColumnCount(layout.value as any, containerWidth.value)
972
+ refreshLayout(masonry.value as any)
973
+
974
+ // Update viewport state
975
+ viewportTop.value = container.value.scrollTop
976
+ viewportHeight.value = container.value.clientHeight
977
+ updateScrollProgress()
978
+ }
979
+ }
980
+ }
981
+ })
982
+ }, { immediate: true })
983
+
984
+ // Watch for swipe container element to attach touch listeners
985
+ watch(swipeContainer, (el) => {
986
+ if (el) {
987
+ el.addEventListener('touchstart', handleTouchStart, { passive: false })
988
+ el.addEventListener('touchmove', handleTouchMove, { passive: false })
989
+ el.addEventListener('touchend', handleTouchEnd)
990
+ el.addEventListener('mousedown', handleMouseDown)
991
+ }
992
+ })
993
+
994
+ // Watch for items changes in swipe mode to reset index if needed
995
+ watch(() => masonry.value.length, (newLength, oldLength) => {
996
+ if (useSwipeMode.value && newLength > 0 && oldLength === 0) {
997
+ // First items loaded, ensure we're at index 0
998
+ currentSwipeIndex.value = 0
999
+ nextTick(() => snapToCurrentItem())
1000
+ }
1001
+ })
1002
+
1003
+ // Watch wrapper element to setup ResizeObserver for container width
1004
+ watch(wrapper, (el) => {
1005
+ if (resizeObserver) {
1006
+ resizeObserver.disconnect()
1007
+ resizeObserver = null
1008
+ }
1009
+
1010
+ if (el && typeof ResizeObserver !== 'undefined') {
1011
+ resizeObserver = new ResizeObserver((entries) => {
1012
+ for (const entry of entries) {
1013
+ const newWidth = entry.contentRect.width
1014
+ if (containerWidth.value !== newWidth) {
1015
+ containerWidth.value = newWidth
1016
+ }
1017
+ }
1018
+ })
1019
+ resizeObserver.observe(el)
1020
+ // Initial width
1021
+ containerWidth.value = el.clientWidth
1022
+ } else if (el) {
1023
+ // Fallback if ResizeObserver not available
1024
+ containerWidth.value = el.clientWidth
1025
+ }
1026
+ }, { immediate: true })
1027
+
1028
+ // Watch containerWidth changes to refresh layout in masonry mode
1029
+ watch(containerWidth, (newWidth, oldWidth) => {
1030
+ if (newWidth !== oldWidth && newWidth > 0 && !useSwipeMode.value && container.value && masonry.value.length > 0) {
1031
+ // Use nextTick to ensure DOM has updated
1032
+ nextTick(() => {
1033
+ columns.value = getColumnCount(layout.value as any, newWidth)
1034
+ refreshLayout(masonry.value as any)
1035
+ updateScrollProgress()
1036
+ })
1037
+ }
1038
+ })
1039
+
713
1040
  onMounted(async () => {
714
1041
  try {
715
- columns.value = getColumnCount(layout.value as any)
716
- if (container.value) {
717
- viewportTop.value = container.value.scrollTop
718
- viewportHeight.value = container.value.clientHeight
1042
+ // Wait for next tick to ensure wrapper is mounted
1043
+ await nextTick()
1044
+
1045
+ // Initialize container width
1046
+ if (wrapper.value) {
1047
+ containerWidth.value = wrapper.value.clientWidth
1048
+ } else if (typeof window !== 'undefined') {
1049
+ containerWidth.value = window.innerWidth
1050
+ }
1051
+
1052
+ if (!useSwipeMode.value) {
1053
+ columns.value = getColumnCount(layout.value as any, containerWidth.value)
1054
+ if (container.value) {
1055
+ viewportTop.value = container.value.scrollTop
1056
+ viewportHeight.value = container.value.clientHeight
1057
+ }
719
1058
  }
720
1059
 
721
1060
  const initialPage = props.loadAtPage as any
@@ -725,48 +1064,124 @@ onMounted(async () => {
725
1064
  await loadPage(paginationHistory.value[0] as any)
726
1065
  }
727
1066
 
728
- updateScrollProgress()
1067
+ if (!useSwipeMode.value) {
1068
+ updateScrollProgress()
1069
+ } else {
1070
+ // In swipe mode, snap to first item
1071
+ nextTick(() => snapToCurrentItem())
1072
+ }
729
1073
 
730
1074
  } catch (error) {
731
1075
  console.error('Error during component initialization:', error)
732
1076
  isLoading.value = false
733
1077
  }
734
1078
 
735
- container.value?.addEventListener('scroll', debouncedScrollHandler, { passive: true })
1079
+ // Scroll listener is handled by watcher now for consistency
736
1080
  window.addEventListener('resize', debouncedResizeHandler)
1081
+ window.addEventListener('resize', handleWindowResize)
737
1082
  })
738
1083
 
739
1084
  onUnmounted(() => {
1085
+ if (resizeObserver) {
1086
+ resizeObserver.disconnect()
1087
+ resizeObserver = null
1088
+ }
1089
+
740
1090
  container.value?.removeEventListener('scroll', debouncedScrollHandler)
741
1091
  window.removeEventListener('resize', debouncedResizeHandler)
1092
+ window.removeEventListener('resize', handleWindowResize)
1093
+
1094
+ if (swipeContainer.value) {
1095
+ swipeContainer.value.removeEventListener('touchstart', handleTouchStart)
1096
+ swipeContainer.value.removeEventListener('touchmove', handleTouchMove)
1097
+ swipeContainer.value.removeEventListener('touchend', handleTouchEnd)
1098
+ swipeContainer.value.removeEventListener('mousedown', handleMouseDown)
1099
+ }
1100
+
1101
+ // Clean up mouse handlers
1102
+ document.removeEventListener('mousemove', handleMouseMove)
1103
+ document.removeEventListener('mouseup', handleMouseUp)
742
1104
  })
743
1105
  </script>
744
1106
 
745
1107
  <template>
746
- <div class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }" ref="container">
747
- <div class="relative"
748
- :style="{height: `${containerHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing}">
749
- <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter"
750
- @leave="leave"
751
- @before-leave="beforeLeave">
752
- <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`"
753
- class="absolute masonry-item"
754
- v-bind="getItemAttributes(item, i)"
755
- :style="{ paddingTop: `${layout.header}px`, paddingBottom: `${layout.footer}px` }">
756
- <!-- Use default slot if provided, otherwise use MasonryItem -->
757
- <slot :item="item" :remove="remove">
758
- <MasonryItem :item="item" :remove="remove" />
759
- </slot>
1108
+ <div ref="wrapper" class="w-full h-full flex flex-col relative">
1109
+ <!-- Swipe Feed Mode (Mobile/Tablet) -->
1110
+ <div v-if="useSwipeMode"
1111
+ class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
1112
+ :class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
1113
+ ref="swipeContainer"
1114
+ style="height: 100%; max-height: 100%; position: relative;">
1115
+ <div
1116
+ class="relative w-full"
1117
+ :style="{
1118
+ transform: `translateY(${swipeOffset}px)`,
1119
+ transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
1120
+ height: `${masonry.length * 100}%`
1121
+ }">
1122
+ <div
1123
+ v-for="(item, index) in masonry"
1124
+ :key="`${item.page}-${item.id}`"
1125
+ class="absolute top-0 left-0 w-full"
1126
+ :style="{
1127
+ top: `${index * (100 / masonry.length)}%`,
1128
+ height: `${100 / masonry.length}%`
1129
+ }">
1130
+ <div class="w-full h-full flex items-center justify-center p-4">
1131
+ <div class="w-full h-full max-w-full max-h-full">
1132
+ <slot :item="item" :remove="remove">
1133
+ <MasonryItem :item="item" :remove="remove" />
1134
+ </slot>
1135
+ </div>
1136
+ </div>
1137
+ </div>
1138
+ </div>
1139
+
1140
+ <!-- Swipe indicator dots -->
1141
+ <div v-if="masonry.length > 1" class="fixed bottom-4 left-1/2 transform -translate-x-1/2 flex gap-2 z-10 pointer-events-none">
1142
+ <div
1143
+ v-for="(item, index) in masonry.slice(0, Math.min(10, masonry.length))"
1144
+ :key="`dot-${item.id}`"
1145
+ class="w-1.5 h-1.5 rounded-full transition-all duration-300"
1146
+ :class="index === currentSwipeIndex ? 'bg-white w-4' : 'bg-white/40'"
1147
+ />
1148
+ </div>
1149
+
1150
+ <!-- Item Counter -->
1151
+ <div class="fixed top-20 right-4 bg-black/50 backdrop-blur-sm text-white text-xs font-medium px-3 py-1.5 rounded-full z-20 pointer-events-none">
1152
+ {{ currentSwipeIndex + 1 }} / {{ masonry.length }}
1153
+ </div>
1154
+ </div>
1155
+
1156
+ <!-- Masonry Grid Mode (Desktop) -->
1157
+ <div v-else
1158
+ class="overflow-auto w-full flex-1 masonry-container"
1159
+ :class="{ 'force-motion': props.forceMotion }"
1160
+ ref="container">
1161
+ <div class="relative"
1162
+ :style="{height: `${containerHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing}">
1163
+ <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter"
1164
+ @leave="leave"
1165
+ @before-leave="beforeLeave">
1166
+ <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`"
1167
+ class="absolute masonry-item"
1168
+ v-bind="getItemAttributes(item, i)"
1169
+ :style="{ paddingTop: `${layout.header}px`, paddingBottom: `${layout.footer}px` }">
1170
+ <!-- Use default slot if provided, otherwise use MasonryItem -->
1171
+ <slot :item="item" :remove="remove">
1172
+ <MasonryItem :item="item" :remove="remove" />
1173
+ </slot>
1174
+ </div>
1175
+ </transition-group>
1176
+
1177
+ <!-- Scroll Progress Badge -->
1178
+ <div v-if="containerHeight > 0"
1179
+ class="fixed bottom-4 right-4 bg-gray-800 text-white text-xs rounded-full px-3 py-1.5 shadow-lg z-10 transition-opacity duration-300"
1180
+ :class="{'opacity-50 hover:opacity-100': !scrollProgress.isNearTrigger, 'opacity-100': scrollProgress.isNearTrigger}">
1181
+ <span>{{ masonry.length }} items</span>
1182
+ <span class="mx-2">|</span>
1183
+ <span>{{ scrollProgress.distanceToTrigger }}px to load</span>
760
1184
  </div>
761
- </transition-group>
762
-
763
- <!-- Scroll Progress Badge -->
764
- <div v-if="containerHeight > 0"
765
- class="fixed bottom-4 right-4 bg-gray-800 text-white text-xs rounded-full px-3 py-1.5 shadow-lg z-10 transition-opacity duration-300"
766
- :class="{'opacity-50 hover:opacity-100': !scrollProgress.isNearTrigger, 'opacity-100': scrollProgress.isNearTrigger}">
767
- <span>{{ masonry.length }} items</span>
768
- <span class="mx-2">|</span>
769
- <span>{{ scrollProgress.distanceToTrigger }}px to load</span>
770
1185
  </div>
771
1186
  </div>
772
1187
  </div>
@@ -1,10 +1,10 @@
1
1
  import type { LayoutOptions, ProcessedMasonryItem } from './types'
2
2
 
3
3
  /**
4
- * Get responsive column count based on window width and layout sizes
4
+ * Get responsive column count based on container width and layout sizes
5
5
  */
6
- export function getColumnCount(layout: Pick<LayoutOptions, 'sizes'> & { sizes: Required<NonNullable<LayoutOptions['sizes']>> }): number {
7
- const width = window.innerWidth
6
+ export function getColumnCount(layout: Pick<LayoutOptions, 'sizes'> & { sizes: Required<NonNullable<LayoutOptions['sizes']>> }, containerWidth?: number): number {
7
+ const width = containerWidth ?? (typeof window !== 'undefined' ? window.innerWidth : 1024)
8
8
  const sizes = layout.sizes
9
9
 
10
10
  if (width >= 1536 && sizes['2xl']) return sizes['2xl']
@@ -97,16 +97,39 @@
97
97
  </div>
98
98
  </div>
99
99
  </div>
100
+
101
+ <!-- Device Simulation -->
102
+ <div class="md:col-span-2 border-t border-slate-100 pt-6 mt-2">
103
+ <h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Device Simulation</h3>
104
+ <div class="flex flex-wrap gap-2">
105
+ <button
106
+ v-for="mode in ['auto', 'phone', 'tablet', 'desktop']"
107
+ :key="mode"
108
+ @click="deviceMode = mode as any"
109
+ class="px-4 py-2 rounded-lg text-sm font-medium transition-all capitalize"
110
+ :class="deviceMode === mode ? 'bg-blue-600 text-white shadow-md shadow-blue-200' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"
111
+ >
112
+ <i class="fas mr-2" :class="{
113
+ 'fa-desktop': mode === 'desktop' || mode === 'auto',
114
+ 'fa-mobile-alt': mode === 'phone',
115
+ 'fa-tablet-alt': mode === 'tablet'
116
+ }"></i>
117
+ {{ mode }}
118
+ </button>
119
+ </div>
120
+ </div>
100
121
  </div>
101
122
  </div>
102
123
  </transition>
103
124
  </header>
104
125
 
105
126
  <!-- Main Content -->
106
- <div class="flex flex-1 overflow-hidden relative p-5">
107
- <masonry v-model:items="items" :get-next-page="getPage" :load-at-page="1" :layout="layout" ref="masonry">
108
- <!-- MasonryItem is used automatically, but you can customize it -->
109
- </masonry>
127
+ <div class="flex flex-1 overflow-hidden relative p-5 transition-all duration-300 ease-in-out" :class="{'bg-slate-200/50': deviceMode !== 'auto'}">
128
+ <div :style="containerStyle" class="transition-all duration-500 ease-in-out bg-slate-50 shadow-sm relative">
129
+ <masonry v-model:items="items" :get-next-page="getPage" :load-at-page="1" :layout="layout" ref="masonry">
130
+ <!-- MasonryItem is used automatically, but you can customize it -->
131
+ </masonry>
132
+ </div>
110
133
  </div>
111
134
  </main>
112
135
  </template>
@@ -166,4 +189,20 @@ const getPage = async (page: number): Promise<GetPageResult> => {
166
189
  }, 1000);
167
190
  });
168
191
  };
192
+
193
+ // Device Simulation
194
+ const deviceMode = ref<'auto' | 'phone' | 'tablet' | 'desktop'>('auto');
195
+
196
+ const containerStyle = computed(() => {
197
+ switch (deviceMode.value) {
198
+ case 'phone':
199
+ return { width: '375px', maxWidth: '100%', margin: '0 auto', border: '1px solid #e2e8f0', borderRadius: '20px', overflow: 'hidden', height: '100%' };
200
+ case 'tablet':
201
+ return { width: '768px', maxWidth: '100%', margin: '0 auto', border: '1px solid #e2e8f0', borderRadius: '12px', overflow: 'hidden', height: '100%' };
202
+ case 'desktop':
203
+ return { width: '1280px', maxWidth: '100%', margin: '0 auto', border: '1px solid #e2e8f0', borderRadius: '8px', overflow: 'hidden', height: '100%' };
204
+ default:
205
+ return { width: '100%', height: '100%' };
206
+ }
207
+ });
169
208
  </script>