mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
Add AnimatedList component
This commit is contained in:
@@ -55,6 +55,7 @@ export const CATEGORIES = [
|
|||||||
{
|
{
|
||||||
name: 'Components',
|
name: 'Components',
|
||||||
subcategories: [
|
subcategories: [
|
||||||
|
'Animated List',
|
||||||
'Masonry',
|
'Masonry',
|
||||||
'Profile Card',
|
'Profile Card',
|
||||||
'Dock',
|
'Dock',
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const textAnimations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const components = {
|
const components = {
|
||||||
|
'animated-list': () => import('../demo/Components/AnimatedListDemo.vue'),
|
||||||
'masonry': () => import('../demo/Components/MasonryDemo.vue'),
|
'masonry': () => import('../demo/Components/MasonryDemo.vue'),
|
||||||
'profile-card': () => import('../demo/Components/ProfileCardDemo.vue'),
|
'profile-card': () => import('../demo/Components/ProfileCardDemo.vue'),
|
||||||
'dock': () => import('../demo/Components/DockDemo.vue'),
|
'dock': () => import('../demo/Components/DockDemo.vue'),
|
||||||
|
|||||||
27
src/constants/code/Components/animatedListCode.ts
Normal file
27
src/constants/code/Components/animatedListCode.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import code from '@content/Components/AnimatedList/AnimatedList.vue?raw'
|
||||||
|
import type { CodeObject } from '../../../types/code'
|
||||||
|
|
||||||
|
export const animatedList: CodeObject = {
|
||||||
|
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/AnimatedList`,
|
||||||
|
installation: `npm install motion-v`,
|
||||||
|
usage: `<template>
|
||||||
|
<AnimatedList
|
||||||
|
:items="items"
|
||||||
|
:showGradients="true"
|
||||||
|
:enableArrowNavigation="true"
|
||||||
|
:displayScrollbar="true"
|
||||||
|
@itemSelected="(item, index) => console.log(item, index)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AnimatedList from './AnimatedList.vue'
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
'Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5',
|
||||||
|
'Item 6', 'Item 7', 'Item 8', 'Item 9', 'Item 10',
|
||||||
|
'Item 11', 'Item 12', 'Item 13', 'Item 14', 'Item 15'
|
||||||
|
]
|
||||||
|
</script>`,
|
||||||
|
code
|
||||||
|
}
|
||||||
185
src/content/Components/AnimatedList/AnimatedList.vue
Normal file
185
src/content/Components/AnimatedList/AnimatedList.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="containerRef" :class="`relative w-[500px] ${className}`.trim()">
|
||||||
|
<div
|
||||||
|
ref="listRef"
|
||||||
|
:class="`max-h-[400px] overflow-y-auto p-4 ${
|
||||||
|
displayScrollbar
|
||||||
|
? '[&::-webkit-scrollbar]:w-[8px] [&::-webkit-scrollbar-track]:bg-[#0b0b0b] [&::-webkit-scrollbar-thumb]:bg-[#222] [&::-webkit-scrollbar-thumb]:rounded-[4px]'
|
||||||
|
: 'scrollbar-hide'
|
||||||
|
}`"
|
||||||
|
:style="{
|
||||||
|
scrollbarWidth: displayScrollbar ? 'thin' : 'none',
|
||||||
|
scrollbarColor: '#222 #0b0b0b'
|
||||||
|
}"
|
||||||
|
@scroll="handleScroll"
|
||||||
|
>
|
||||||
|
<Motion
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="index"
|
||||||
|
tag="div"
|
||||||
|
:data-index="index"
|
||||||
|
class="mb-4 cursor-pointer"
|
||||||
|
:initial="{ scale: 0.7, opacity: 0 }"
|
||||||
|
:animate="getItemInView(index) ? { scale: 1, opacity: 1 } : { scale: 0.7, opacity: 0 }"
|
||||||
|
:transition="{ duration: 0.2, delay: 0.1 }"
|
||||||
|
@mouseenter="() => setSelectedIndex(index)"
|
||||||
|
@click="() => {
|
||||||
|
setSelectedIndex(index)
|
||||||
|
emit('itemSelected', item, index)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div :class="`p-4 bg-[#111] rounded-lg ${selectedIndex === index ? 'bg-[#222]' : ''} ${itemClassName}`">
|
||||||
|
<p class="text-white m-0">{{ item }}</p>
|
||||||
|
</div>
|
||||||
|
</Motion>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="showGradients"
|
||||||
|
class="absolute top-0 left-0 right-0 h-[50px] bg-gradient-to-b from-[#0b0b0b] to-transparent pointer-events-none transition-opacity duration-300 ease"
|
||||||
|
:style="{ opacity: topGradientOpacity }"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="showGradients"
|
||||||
|
class="absolute bottom-0 left-0 right-0 h-[100px] bg-gradient-to-t from-[#0b0b0b] to-transparent pointer-events-none transition-opacity duration-300 ease"
|
||||||
|
:style="{ opacity: bottomGradientOpacity }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, watch, useTemplateRef } from 'vue'
|
||||||
|
import { Motion } from 'motion-v'
|
||||||
|
|
||||||
|
interface AnimatedListProps {
|
||||||
|
items?: string[]
|
||||||
|
showGradients?: boolean
|
||||||
|
enableArrowNavigation?: boolean
|
||||||
|
className?: string
|
||||||
|
itemClassName?: string
|
||||||
|
displayScrollbar?: boolean
|
||||||
|
initialSelectedIndex?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<AnimatedListProps>(), {
|
||||||
|
items: () => [
|
||||||
|
'Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5',
|
||||||
|
'Item 6', 'Item 7', 'Item 8', 'Item 9', 'Item 10',
|
||||||
|
'Item 11', 'Item 12', 'Item 13', 'Item 14', 'Item 15'
|
||||||
|
],
|
||||||
|
showGradients: true,
|
||||||
|
enableArrowNavigation: true,
|
||||||
|
className: '',
|
||||||
|
itemClassName: '',
|
||||||
|
displayScrollbar: true,
|
||||||
|
initialSelectedIndex: -1
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
itemSelected: [item: string, index: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||||
|
const listRef = useTemplateRef<HTMLDivElement>('listRef')
|
||||||
|
const selectedIndex = ref(props.initialSelectedIndex)
|
||||||
|
const keyboardNav = ref(false)
|
||||||
|
const topGradientOpacity = ref(0)
|
||||||
|
const bottomGradientOpacity = ref(1)
|
||||||
|
const itemsInView = ref<boolean[]>([])
|
||||||
|
|
||||||
|
const setSelectedIndex = (index: number) => {
|
||||||
|
selectedIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
const getItemInView = (index: number) => {
|
||||||
|
return itemsInView.value[index] ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (e: Event) => {
|
||||||
|
const target = e.target as HTMLDivElement
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = target
|
||||||
|
topGradientOpacity.value = Math.min(scrollTop / 50, 1)
|
||||||
|
const bottomDistance = scrollHeight - (scrollTop + clientHeight)
|
||||||
|
bottomGradientOpacity.value =
|
||||||
|
scrollHeight <= clientHeight ? 0 : Math.min(bottomDistance / 50, 1)
|
||||||
|
|
||||||
|
updateItemsInView()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateItemsInView = () => {
|
||||||
|
if (!listRef.value) return
|
||||||
|
|
||||||
|
const container = listRef.value
|
||||||
|
const containerRect = container.getBoundingClientRect()
|
||||||
|
|
||||||
|
itemsInView.value = props.items.map((_, index) => {
|
||||||
|
const item = container.querySelector(`[data-index="${index}"]`) as HTMLElement
|
||||||
|
if (!item) return false
|
||||||
|
|
||||||
|
const itemRect = item.getBoundingClientRect()
|
||||||
|
const viewHeight = containerRect.height
|
||||||
|
const itemTop = itemRect.top - containerRect.top
|
||||||
|
const itemBottom = itemTop + itemRect.height
|
||||||
|
|
||||||
|
return itemTop < viewHeight && itemBottom > 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'ArrowDown' || (e.key === 'Tab' && !e.shiftKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
keyboardNav.value = true
|
||||||
|
setSelectedIndex(Math.min(selectedIndex.value + 1, props.items.length - 1))
|
||||||
|
} else if (e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
keyboardNav.value = true
|
||||||
|
setSelectedIndex(Math.max(selectedIndex.value - 1, 0))
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
if (selectedIndex.value >= 0 && selectedIndex.value < props.items.length) {
|
||||||
|
e.preventDefault()
|
||||||
|
emit('itemSelected', props.items[selectedIndex.value], selectedIndex.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([selectedIndex, keyboardNav], () => {
|
||||||
|
if (!keyboardNav.value || selectedIndex.value < 0 || !listRef.value) return
|
||||||
|
const container = listRef.value
|
||||||
|
const selectedItem = container.querySelector(
|
||||||
|
`[data-index="${selectedIndex.value}"]`
|
||||||
|
) as HTMLElement | null
|
||||||
|
if (selectedItem) {
|
||||||
|
const extraMargin = 50
|
||||||
|
const containerScrollTop = container.scrollTop
|
||||||
|
const containerHeight = container.clientHeight
|
||||||
|
const itemTop = selectedItem.offsetTop
|
||||||
|
const itemBottom = itemTop + selectedItem.offsetHeight
|
||||||
|
if (itemTop < containerScrollTop + extraMargin) {
|
||||||
|
container.scrollTo({ top: itemTop - extraMargin, behavior: 'smooth' })
|
||||||
|
} else if (
|
||||||
|
itemBottom >
|
||||||
|
containerScrollTop + containerHeight - extraMargin
|
||||||
|
) {
|
||||||
|
container.scrollTo({
|
||||||
|
top: itemBottom - containerHeight + extraMargin,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyboardNav.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.enableArrowNavigation) {
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsInView.value = new Array(props.items.length).fill(true)
|
||||||
|
setTimeout(updateItemsInView, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (props.enableArrowNavigation) {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
100
src/demo/Components/AnimatedListDemo.vue
Normal file
100
src/demo/Components/AnimatedListDemo.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<TabbedLayout>
|
||||||
|
<template #preview>
|
||||||
|
<div class="demo-container relative min-h-[500px] max-h-[500px] overflow-hidden flex items-center justify-center">
|
||||||
|
<AnimatedList
|
||||||
|
:key="rerenderKey"
|
||||||
|
:show-gradients="showGradients"
|
||||||
|
:enable-arrow-navigation="enableArrowNavigation"
|
||||||
|
:display-scrollbar="displayScrollbar"
|
||||||
|
@itemSelected="(item, index) => console.log('Selected:', item, 'at index:', index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Customize>
|
||||||
|
<PreviewSwitch title="Fade Items" v-model="showGradients" />
|
||||||
|
<PreviewSwitch title="Keyboard Navigation" v-model="enableArrowNavigation" />
|
||||||
|
<PreviewSwitch title="Show Scrollbar" v-model="displayScrollbar" />
|
||||||
|
</Customize>
|
||||||
|
|
||||||
|
<PropTable :data="propData" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #code>
|
||||||
|
<CodeExample :code-object="animatedList" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #cli>
|
||||||
|
<CliInstallation :command="animatedList.cli" />
|
||||||
|
</template>
|
||||||
|
</TabbedLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import TabbedLayout from '../../components/common/TabbedLayout.vue'
|
||||||
|
import PropTable from '../../components/common/PropTable.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 AnimatedList from '../../content/Components/AnimatedList/AnimatedList.vue'
|
||||||
|
import { animatedList } from '../../constants/code/Components/animatedListCode'
|
||||||
|
import { useForceRerender } from '@/composables/useForceRerender'
|
||||||
|
|
||||||
|
const { rerenderKey } = useForceRerender()
|
||||||
|
const showGradients = ref(true)
|
||||||
|
const enableArrowNavigation = ref(true)
|
||||||
|
const displayScrollbar = ref(true)
|
||||||
|
|
||||||
|
const propData = [
|
||||||
|
{
|
||||||
|
name: 'items',
|
||||||
|
type: 'string[]',
|
||||||
|
default: '["Item 1", "Item 2", ...]',
|
||||||
|
description: 'Array of items to display in the list'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'showGradients',
|
||||||
|
type: 'boolean',
|
||||||
|
default: 'true',
|
||||||
|
description: 'Whether to show fade gradients at top and bottom'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'enableArrowNavigation',
|
||||||
|
type: 'boolean',
|
||||||
|
default: 'true',
|
||||||
|
description: 'Enable keyboard navigation with arrow keys'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'className',
|
||||||
|
type: 'string',
|
||||||
|
default: '""',
|
||||||
|
description: 'Additional CSS classes for the container'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'itemClassName',
|
||||||
|
type: 'string',
|
||||||
|
default: '""',
|
||||||
|
description: 'Additional CSS classes for each item'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'displayScrollbar',
|
||||||
|
type: 'boolean',
|
||||||
|
default: 'true',
|
||||||
|
description: 'Whether to display the scrollbar'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'initialSelectedIndex',
|
||||||
|
type: 'number',
|
||||||
|
default: '-1',
|
||||||
|
description: 'Initial selected item index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '@itemSelected',
|
||||||
|
type: 'event',
|
||||||
|
default: '-',
|
||||||
|
description: 'Emitted when an item is selected. Receives (item: string, index: number)'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user