Demo Improvements and Masonry

This commit is contained in:
David Haz
2025-07-09 09:24:46 +03:00
parent 636822252d
commit 83607dc6c5
9 changed files with 541 additions and 4 deletions

View File

@@ -0,0 +1,126 @@
<template>
<div class="preview-select">
<span class="select-label">{{ title }}</span>
<Select
:model-value="modelValue"
@update:model-value="handleSelectChange"
:options="options"
v-bind="selectAttributes"
:placeholder="placeholder"
:disabled="disabled"
class="custom-select"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Select from 'primevue/select'
interface Option {
label: string
value: string | number
}
const props = defineProps<{
title: string
modelValue: string | number
options: Option[] | string[] | number[]
optionLabel?: string
optionValue?: string
placeholder?: string
disabled?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | number]
}>()
const handleSelectChange = (value: string | number) => {
emit('update:modelValue', value)
}
const isObjectArray = computed(() => {
return props.options.length > 0 && typeof props.options[0] === 'object'
})
const selectAttributes = computed(() => {
if (isObjectArray.value) {
return {
optionLabel: props.optionLabel || 'label',
optionValue: props.optionValue || 'value'
}
}
return {}
})
</script>
<style scoped>
.preview-select {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.5rem 0;
}
.select-label {
font-size: 14px;
color: #fff;
white-space: nowrap;
min-width: 120px;
}
.custom-select {
width: 150px;
}
:deep(.p-select) {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
font-size: 0.875rem;
min-height: 2.5rem;
padding: 0;
}
:deep(.p-select:hover) {
border-color: #555;
}
:deep(.p-select:focus) {
outline: none;
border-color: #555;
box-shadow: 0 0 0 2px rgba(82, 39, 255, 0.2);
}
:deep(.p-select-label) {
color: #fff;
padding: 0.5rem;
}
:deep(.p-select-dropdown) {
background: #1a1a1a;
border: none;
color: #fff;
width: 2.5rem;
border-radius: 0 6px 6px 0;
}
:deep(.p-select-dropdown:hover) {
background: #333;
}
:deep(.p-select-overlay) {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
:deep(.p-select-option) {
color: #fff;
padding: 0.5rem;
transition: background-color 0.2s;
}
</style>

View File

@@ -19,7 +19,7 @@ export const CATEGORIES = [
{
name: 'Components',
subcategories: [
'Masonry',
],
},
{

View File

@@ -7,7 +7,7 @@ const textAnimations = {
};
const components = {
'masonry': () => import("../demo/Components/MasonryDemo.vue"),
};
const backgrounds = {

View File

@@ -0,0 +1,32 @@
import code from '@content/Components/Masonry/Masonry.vue?raw'
import type { CodeObject } from '../../../types/code'
export const masonry: CodeObject = {
cli: `npx jsrepo add https://vuebits.dev/Components/Masonry`,
installation: `npm install gsap`,
usage: `<template>
<Masonry
:items="items"
:duration="0.6"
:stagger="0.05"
animate-from="bottom"
:scale-on-hover="true"
:hover-scale="0.95"
:blur-to-focus="true"
:color-shift-on-hover="false"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Masonry from "./Masonry.vue"
const items = ref([
{ id: '1', img: 'https://picsum.photos/300/400?random=1', url: 'https://picsum.photos', height: 400 },
{ id: '2', img: 'https://picsum.photos/300/600?random=2', url: 'https://picsum.photos', height: 600 },
{ id: '3', img: 'https://picsum.photos/300/350?random=3', url: 'https://picsum.photos', height: 350 },
// ... more items
])
</script>`,
code
}

View File

@@ -0,0 +1,261 @@
<template>
<div ref="containerRef" class="relative w-full h-full">
<div v-for="item in grid" :key="item.id" :data-key="item.id" class="absolute box-content"
:style="{ willChange: 'transform, width, height, opacity' }" @click="openUrl(item.url)"
@mouseenter="(e) => handleMouseEnter(item.id, e.currentTarget as HTMLElement)"
@mouseleave="(e) => handleMouseLeave(item.id, e.currentTarget as HTMLElement)">
<div
class="relative w-full h-full bg-cover bg-center rounded-[10px] shadow-[0px_10px_50px_-10px_rgba(0,0,0,0.2)] uppercase text-[10px] leading-[10px]"
:style="{ backgroundImage: `url(${item.img})` }">
<div v-if="colorShiftOnHover"
class="color-overlay absolute inset-0 rounded-[10px] bg-gradient-to-tr from-pink-500/50 to-sky-500/50 opacity-0 pointer-events-none" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watchEffect, nextTick } from 'vue'
import { gsap } from 'gsap'
interface Item {
id: string
img: string
url: string
height: number
}
interface MasonryProps {
items: Item[]
ease?: string
duration?: number
stagger?: number
animateFrom?: 'bottom' | 'top' | 'left' | 'right' | 'center' | 'random'
scaleOnHover?: boolean
hoverScale?: number
blurToFocus?: boolean
colorShiftOnHover?: boolean
}
const props = withDefaults(defineProps<MasonryProps>(), {
ease: 'power3.out',
duration: 0.6,
stagger: 0.05,
animateFrom: 'bottom',
scaleOnHover: true,
hoverScale: 0.95,
blurToFocus: true,
colorShiftOnHover: false
})
const useMedia = (queries: string[], values: number[], defaultValue: number) => {
const get = () => values[queries.findIndex((q) => matchMedia(q).matches)] ?? defaultValue
const value = ref<number>(get())
onMounted(() => {
const handler = () => value.value = get()
queries.forEach((q) => matchMedia(q).addEventListener('change', handler))
onUnmounted(() => {
queries.forEach((q) => matchMedia(q).removeEventListener('change', handler))
})
})
return value
}
const useMeasure = () => {
const containerRef = ref<HTMLDivElement | null>(null)
const size = ref({ width: 0, height: 0 })
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
if (!containerRef.value) return
resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect
size.value = { width, height }
})
resizeObserver.observe(containerRef.value)
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
})
return [containerRef, size] as const
}
const preloadImages = async (urls: string[]): Promise<void> => {
await Promise.all(
urls.map(
(src) =>
new Promise<void>((resolve) => {
const img = new Image()
img.src = src
img.onload = img.onerror = () => resolve()
})
)
)
}
const columns = useMedia(
[
'(min-width:1500px)',
'(min-width:1000px)',
'(min-width:600px)',
'(min-width:400px)'
],
[5, 4, 3, 2],
1
)
const [containerRef, size] = useMeasure()
const imagesReady = ref(false)
const hasMounted = ref(false)
const grid = computed(() => {
if (!size.value.width) return []
const colHeights = new Array(columns.value).fill(0)
const gap = 16
const totalGaps = (columns.value - 1) * gap
const columnWidth = (size.value.width - totalGaps) / columns.value
return props.items.map((child) => {
const col = colHeights.indexOf(Math.min(...colHeights))
const x = col * (columnWidth + gap)
const height = child.height / 2
const y = colHeights[col]
colHeights[col] += height + gap
return { ...child, x, y, w: columnWidth, h: height }
})
})
const openUrl = (url: string) => {
window.open(url, '_blank', 'noopener')
}
interface GridItem extends Item {
x: number
y: number
w: number
h: number
}
const getInitialPosition = (item: GridItem) => {
const containerRect = containerRef.value?.getBoundingClientRect()
if (!containerRect) return { x: item.x, y: item.y }
let direction = props.animateFrom
if (props.animateFrom === 'random') {
const dirs = ['top', 'bottom', 'left', 'right']
direction = dirs[Math.floor(Math.random() * dirs.length)] as typeof props.animateFrom
}
switch (direction) {
case 'top':
return { x: item.x, y: -200 }
case 'bottom':
return { x: item.x, y: window.innerHeight + 200 }
case 'left':
return { x: -200, y: item.y }
case 'right':
return { x: window.innerWidth + 200, y: item.y }
case 'center':
return {
x: containerRect.width / 2 - item.w / 2,
y: containerRect.height / 2 - item.h / 2
}
default:
return { x: item.x, y: item.y + 100 }
}
}
const handleMouseEnter = (id: string, element: HTMLElement) => {
if (props.scaleOnHover) {
gsap.to(`[data-key="${id}"]`, {
scale: props.hoverScale,
duration: 0.3,
ease: 'power2.out'
})
}
if (props.colorShiftOnHover) {
const overlay = element.querySelector('.color-overlay') as HTMLElement
if (overlay) gsap.to(overlay, { opacity: 0.3, duration: 0.3 })
}
}
const handleMouseLeave = (id: string, element: HTMLElement) => {
if (props.scaleOnHover) {
gsap.to(`[data-key="${id}"]`, {
scale: 1,
duration: 0.3,
ease: 'power2.out'
})
}
if (props.colorShiftOnHover) {
const overlay = element.querySelector('.color-overlay') as HTMLElement
if (overlay) gsap.to(overlay, { opacity: 0, duration: 0.3 })
}
}
watchEffect(() => {
preloadImages(props.items.map((i) => i.img)).then(() => {
imagesReady.value = true
})
})
watchEffect(() => {
if (!imagesReady.value) return
const currentGrid = grid.value
void props.items.length
void columns.value
void size.value.width
if (!currentGrid.length) return
nextTick(() => {
currentGrid.forEach((item, index) => {
const selector = `[data-key="${item.id}"]`
const animProps = { x: item.x, y: item.y, width: item.w, height: item.h }
if (!hasMounted.value) {
const start = getInitialPosition(item)
gsap.fromTo(
selector,
{
opacity: 0,
x: start.x,
y: start.y,
width: item.w,
height: item.h,
...(props.blurToFocus && { filter: 'blur(10px)' })
},
{
opacity: 1,
...animProps,
...(props.blurToFocus && { filter: 'blur(0px)' }),
duration: 0.8,
ease: 'power3.out',
delay: index * props.stagger
}
)
} else {
gsap.to(selector, {
...animProps,
duration: props.duration,
ease: props.ease,
overwrite: 'auto'
})
}
})
hasMounted.value = true
})
})
</script>

View File

@@ -16,8 +16,8 @@
class="fade-content-demo-content"
>
<div class="demo-content">
<h4>Fade Content Example</h4>
<p>This content will fade in when it enters the viewport.</p>
<h4>Fade Content</h4>
<p>It will fade in when it enters the viewport.</p>
</div>
</FadeContent>
</div>

View File

@@ -0,0 +1,116 @@
<template>
<div class="masonry-demo">
<TabbedLayout>
<template #preview>
<div class="demo-container" style="height: 900px; overflow: hidden;">
<RefreshButton @refresh="forceRerender" />
<Masonry :key="rerenderKey" :items="sampleItems" :ease="ease" :duration="duration" :stagger="stagger"
:animate-from="animateFrom" :scale-on-hover="scaleOnHover" :hover-scale="hoverScale"
:blur-to-focus="blurToFocus" :color-shift-on-hover="colorShiftOnHover" class="masonry-demo-container" />
</div>
<Customize>
<PreviewSwitch title="Scale on Hover" v-model="scaleOnHover" @update:model-value="forceRerender" />
<PreviewSwitch title="Blur to Focus" v-model="blurToFocus" @update:model-value="forceRerender" />
<PreviewSwitch title="Color Shift on Hover" v-model="colorShiftOnHover" @update:model-value="forceRerender" />
<PreviewSelect title="Animation Direction" v-model="animateFrom" :options="[
{ label: 'Bottom', value: 'bottom' },
{ label: 'Top', value: 'top' },
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
{ label: 'Center', value: 'center' },
{ label: 'Random', value: 'random' }
]" @update:model-value="forceRerender" />
<PreviewSlider title="Duration (s)" v-model="duration" :min="0.1" :max="2" :step="0.1"
@update:model-value="forceRerender" />
<PreviewSlider title="Stagger Delay (s)" v-model="stagger" :min="0.01" :max="0.2" :step="0.01"
@update:model-value="forceRerender" />
<PreviewSlider title="Hover Scale" v-model="hoverScale" :min="0.8" :max="1.2" :step="0.05"
@update:model-value="forceRerender" />
</Customize>
<PropTable :data="propData" />
<Dependencies :dependency-list="['gsap']" />
</template>
<template #code>
<CodeExample :code-object="masonry" />
</template>
<template #cli>
<CliInstallation :command="masonry.cli" />
</template>
</TabbedLayout>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import TabbedLayout from '../../components/common/TabbedLayout.vue'
import RefreshButton from '../../components/common/RefreshButton.vue'
import PropTable from '../../components/common/PropTable.vue'
import Dependencies from '../../components/code/Dependencies.vue'
import CliInstallation from '../../components/code/CliInstallation.vue'
import CodeExample from '../../components/code/CodeExample.vue'
import Customize from '../../components/common/Customize.vue'
import PreviewSwitch from '../../components/common/PreviewSwitch.vue'
import PreviewSlider from '../../components/common/PreviewSlider.vue'
import PreviewSelect from '../../components/common/PreviewSelect.vue'
import Masonry from '../../content/Components/Masonry/Masonry.vue'
import { masonry } from '@/constants/code/Components/masonryCode'
import { useForceRerender } from '@/composables/useForceRerender'
const ease = ref('power3.out')
const duration = ref(0.6)
const stagger = ref(0.05)
const animateFrom = ref<'bottom' | 'top' | 'left' | 'right' | 'center' | 'random'>('bottom')
const scaleOnHover = ref(true)
const hoverScale = ref(0.95)
const blurToFocus = ref(true)
const colorShiftOnHover = ref(false)
const { rerenderKey, forceRerender } = useForceRerender()
const sampleItems = ref([
{ id: '1', img: 'https://picsum.photos/300/400?random=1&grayscale', url: 'https://picsum.photos', height: 400 },
{ id: '2', img: 'https://picsum.photos/300/600?random=2&grayscale', url: 'https://picsum.photos', height: 600 },
{ id: '3', img: 'https://picsum.photos/300/350?random=3&grayscale', url: 'https://picsum.photos', height: 350 },
{ id: '4', img: 'https://picsum.photos/300/500?random=4&grayscale', url: 'https://picsum.photos', height: 500 },
{ id: '5', img: 'https://picsum.photos/300/450?random=5&grayscale', url: 'https://picsum.photos', height: 450 },
{ id: '6', img: 'https://picsum.photos/300/380?random=6&grayscale', url: 'https://picsum.photos', height: 380 },
{ id: '7', img: 'https://picsum.photos/300/520?random=7&grayscale', url: 'https://picsum.photos', height: 520 },
{ id: '8', img: 'https://picsum.photos/300/420?random=8&grayscale', url: 'https://picsum.photos', height: 420 },
{ id: '9', img: 'https://picsum.photos/300/480?random=9&grayscale', url: 'https://picsum.photos', height: 480 },
{ id: '10', img: 'https://picsum.photos/300/360?random=10&grayscale', url: 'https://picsum.photos', height: 360 },
{ id: '11', img: 'https://picsum.photos/300/550?random=11&grayscale', url: 'https://picsum.photos', height: 550 },
{ id: '12', img: 'https://picsum.photos/300/400?random=12&grayscale', url: 'https://picsum.photos', height: 400 },
{ id: '13', img: 'https://picsum.photos/300/470?random=13&grayscale', url: 'https://picsum.photos', height: 470 },
{ id: '14', img: 'https://picsum.photos/300/390?random=14&grayscale', url: 'https://picsum.photos', height: 390 },
{ id: '15', img: 'https://picsum.photos/300/510?random=15&grayscale', url: 'https://picsum.photos', height: 510 },
])
const propData = [
{ name: 'items', type: 'Item[]', default: '[]', description: 'Array of items to display in the masonry grid. Each item must have id, img, url, and height properties.' },
{ name: 'ease', type: 'string', default: '"power3.out"', description: 'GSAP easing function for animations.' },
{ name: 'duration', type: 'number', default: '0.6', description: 'Duration of the animation in seconds.' },
{ name: 'stagger', type: 'number', default: '0.05', description: 'Stagger delay between item animations in seconds.' },
{ name: 'animateFrom', type: 'string', default: '"bottom"', description: 'Direction items animate from: "bottom", "top", "left", "right", "center", or "random".' },
{ name: 'scaleOnHover', type: 'boolean', default: 'true', description: 'Whether items scale on hover.' },
{ name: 'hoverScale', type: 'number', default: '0.95', description: 'Scale factor when hovering over items.' },
{ name: 'blurToFocus', type: 'boolean', default: 'true', description: 'Whether items start blurred and focus on entrance.' },
{ name: 'colorShiftOnHover', type: 'boolean', default: 'false', description: 'Whether to show color overlay on hover.' },
]
</script>
<style scoped>
.masonry-demo-container {
width: 100%;
height: 100%;
}
</style>

View File

@@ -13,6 +13,7 @@ import PrimeVue from 'primevue/config'
import Aura from '@primeuix/themes/aura'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import Select from 'primevue/select'
import ToastService from 'primevue/toastservice'
const app = createApp(App)
@@ -28,5 +29,6 @@ app.use(ToastService)
// Global components
app.component('Button', Button)
app.component('Toast', Toast)
app.component('Select', Select)
app.mount('#app')