feat: Add folder component

This commit is contained in:
jhanma17
2025-07-16 22:40:22 -05:00
parent 337c40bf7f
commit 35cfd2ae4e
5 changed files with 362 additions and 0 deletions

View File

@@ -61,6 +61,7 @@ export const CATEGORIES = [
'Spotlight Card', 'Spotlight Card',
'Circular Gallery', 'Circular Gallery',
'Flying Posters', 'Flying Posters',
'Folder',
'Card Swap', 'Card Swap',
'Infinite Scroll', 'Infinite Scroll',
'Tilted Card', 'Tilted Card',

View File

@@ -49,6 +49,7 @@ const components = {
'spotlight-card': () => import('../demo/Components/SpotlightCardDemo.vue'), 'spotlight-card': () => import('../demo/Components/SpotlightCardDemo.vue'),
'circular-gallery': () => import('../demo/Components/CircularGalleryDemo.vue'), 'circular-gallery': () => import('../demo/Components/CircularGalleryDemo.vue'),
'flying-posters': () => import('../demo/Components/FlyingPostersDemo.vue'), 'flying-posters': () => import('../demo/Components/FlyingPostersDemo.vue'),
'folder': () => import('../demo/Components/FolderDemo.vue'),
'card-swap': () => import('../demo/Components/CardSwapDemo.vue'), 'card-swap': () => import('../demo/Components/CardSwapDemo.vue'),
'infinite-scroll': () => import('../demo/Components/InfiniteScrollDemo.vue'), 'infinite-scroll': () => import('../demo/Components/InfiniteScrollDemo.vue'),
'glass-icons': () => import('../demo/Components/GlassIconsDemo.vue'), 'glass-icons': () => import('../demo/Components/GlassIconsDemo.vue'),

View File

@@ -0,0 +1,16 @@
import code from '@content/Components/Folder/Folder.vue?raw';
import type { CodeObject } from '../../../types/code';
export const folder: CodeObject = {
cli: `npx jsrepo add https://vue-bits.dev/ui/Components/Folder`,
usage: `<template>
<Folder :items="items" :size="2" color="#5227FF" class="my-folder-class" />
</template>
<script setup lang="ts">
import Folder from "./Folder.vue";
const items = ['Doc 1', 'Doc 2', 'Doc 3'];
</script>`,
code
};

View File

@@ -0,0 +1,274 @@
<template>
<div :style="{transform: `scale(${props.size})`}" :class="class">
<div
:class="folderClass"
:style="folderStyle"
@click="handleClick"
>
<div class="folder_back">
<div
v-for="(item, i) in papers"
:key="i"
:class="`paper paper-${i + 1}`"
@mousemove="(e) => handlePaperMouseMove(e, i)"
@mouseleave="(e) => handlePaperMouseLeave(e, i)"
:style="open ? {
'--magnet-x': `${paperOffsets[i]?.x || 0}px`,
'--magnet-y': `${paperOffsets[i]?.y || 0}px`,
} : {}"
>
<slot
:name="`item-${i + 1}`"
:item="item"
:index="i"
:isOpen="open"
>
{{ item }}
</slot>
</div>
<div class="folder_front"></div>
<div class="folder_front right"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
// Definir tipos
interface PaperOffset {
x: number
y: number
}
interface Props {
color?: string
size?: number
items?: (string | null)[]
class?: string
}
// Props con valores por defecto
const props = withDefaults(defineProps<Props>(), {
color: '#5227FF',
size: 1,
items: () => [],
class: ''
})
// Función para oscurecer colores
const darkenColor = (hex: string, percent: number): string => {
let color = hex.startsWith('#') ? hex.slice(1) : hex
if (color.length === 3) {
color = color
.split('')
.map((c) => c + c)
.join('')
}
const num = parseInt(color, 16)
let r = (num >> 16) & 0xff
let g = (num >> 8) & 0xff
let b = num & 0xff
r = Math.max(0, Math.min(255, Math.floor(r * (1 - percent))))
g = Math.max(0, Math.min(255, Math.floor(g * (1 - percent))))
b = Math.max(0, Math.min(255, Math.floor(b * (1 - percent))))
return (
'#' +
((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()
)
}
// Estado reactivo
const open = ref(false)
const maxItems = 3
const paperOffsets = ref<PaperOffset[]>(
Array.from({ length: maxItems }, () => ({ x: 0, y: 0 }))
)
// Computed properties
const papers = computed(() => {
const result = props.items.slice(0, maxItems)
while (result.length < maxItems) {
result.push(null)
}
return result
})
const folderBackColor = computed(() => darkenColor(props.color, 0.08))
const paper1 = computed(() => darkenColor('#ffffff', 0.1))
const paper2 = computed(() => darkenColor('#ffffff', 0.05))
const paper3 = computed(() => '#ffffff')
const folderStyle = computed(() => ({
'--folder-color': props.color,
'--folder-back-color': folderBackColor.value,
'--paper-1': paper1.value,
'--paper-2': paper2.value,
'--paper-3': paper3.value,
}))
const folderClass = computed(() => `folder ${open.value ? 'open' : ''}`.trim())
const scaleStyle = computed(() => ({
transform: `scale(${props.size})`
}))
// Métodos
const handleClick = () => {
open.value = !open.value
if (!open.value) {
paperOffsets.value = Array.from({ length: maxItems }, () => ({ x: 0, y: 0 }))
}
}
const handlePaperMouseMove = (e: MouseEvent, index: number) => {
if (!open.value) return
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const offsetX = (e.clientX - centerX) * 0.15
const offsetY = (e.clientY - centerY) * 0.15
paperOffsets.value = paperOffsets.value.map((offset, i) =>
i === index ? { x: offsetX, y: offsetY } : offset
)
}
const handlePaperMouseLeave = (e: MouseEvent, index: number) => {
paperOffsets.value = paperOffsets.value.map((offset, i) =>
i === index ? { x: 0, y: 0 } : offset
)
}
</script>
<style scoped>
:root {
--folder-color: #70a1ff;
--folder-back-color: #4785ff;
--paper-1: #e6e6e6;
--paper-2: #f2f2f2;
--paper-3: #ffffff;
}
.folder {
transition: all 0.2s ease-in;
cursor: pointer;
}
.folder:not(.folder--click):hover {
transform: translateY(-8px);
}
.folder:not(.folder--click):hover .paper {
transform: translate(-50%, 0%);
}
.folder:not(.folder--click):hover .folder_front {
transform: skew(15deg) scaleY(0.6);
}
.folder:not(.folder--click):hover .right {
transform: skew(-15deg) scaleY(0.6);
}
.folder.open {
transform: translateY(-8px);
}
.folder.open .paper:nth-child(1) {
transform: translate(-120%, -70%) rotateZ(-15deg);
}
.folder.open .paper:nth-child(1):hover {
transform: translate(-120%, -70%) rotateZ(-15deg) scale(1.1);
z-index: 3;
}
.folder.open .paper:nth-child(2) {
transform: translate(10%, -70%) rotateZ(15deg);
height: 80%;
}
.folder.open .paper:nth-child(2):hover {
transform: translate(10%, -70%) rotateZ(15deg) scale(1.1);
z-index: 3;
}
.folder.open .paper:nth-child(3) {
transform: translate(-50%, -100%) rotateZ(5deg);
height: 80%;
}
.folder.open .paper:nth-child(3):hover {
transform: translate(-50%, -100%) rotateZ(5deg) scale(1.1);
z-index: 3;
}
.folder.open .folder_front {
transform: skew(15deg) scaleY(0.6);
}
.folder.open .right {
transform: skew(-15deg) scaleY(0.6);
}
.folder_back {
position: relative;
width: 100px;
height: 80px;
background: var(--folder-back-color);
border-radius: 0px 10px 10px 10px;
}
.folder_back::after {
position: absolute;
z-index: 0;
bottom: 98%;
left: 0;
content: "";
width: 30px;
height: 10px;
background: var(--folder-back-color);
border-radius: 5px 5px 0 0;
}
.paper {
position: absolute;
z-index: 2;
bottom: 10%;
left: 50%;
transform: translate(-50%, 10%);
width: 70%;
height: 80%;
background: var(--paper-1);
border-radius: 10px;
transition: all 0.3s ease-in-out;
overflow: hidden;
}
.paper:nth-child(2) {
background: var(--paper-2);
width: 80%;
height: 70%;
}
.paper:nth-child(3) {
background: var(--paper-3);
width: 90%;
height: 60%;
}
.folder_front {
position: absolute;
z-index: 3;
width: 100%;
height: 100%;
background: var(--folder-color);
border-radius: 5px 10px 10px 10px;
transform-origin: bottom;
transition: all 0.3s ease-in-out;
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<TabbedLayout>
<template #preview>
<div class="demo-container">
<Folder :items="items" :size="size" :color="color">
</Folder>
</div>
<Customize>
<PreviewColor title="Folder Color" v-model="color" />
<PreviewSlider title="Folder Size" v-model="size" :min="1" :max="3" :step="0.1" />
</Customize>
<PropTable :data="propData" />
</template>
<template #code>
<CodeExample :code-object="folder" />
</template>
<template #cli>
<CliInstallation :command="folder.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { folder } from '@/constants/code/Components/folderCode';
import Folder from '@/content/Components/Folder/Folder.vue';
import CliInstallation from '@/components/code/CliInstallation.vue';
import CodeExample from '@/components/code/CodeExample.vue';
import Customize from '@/components/common/Customize.vue';
import PreviewColor from '@/components/common/PreviewColor.vue';
import PreviewSlider from '@/components/common/PreviewSlider.vue';
import PropTable from '@/components/common/PropTable.vue';
import TabbedLayout from '@/components/common/TabbedLayout.vue';
const items = ['Doc 1', 'Doc 2', 'Doc 3'];
const color = ref('#5227FF');
const size = ref(2);
const propData = [
{
name: 'color',
type: 'string',
default: '#5227FF',
description: 'The color of the folder.'
},
{
name: 'class',
type: 'string',
default: '',
description: 'Additional CSS classes for the folder container.'
},
{
name: 'items',
type: '(string | null)[]',
default: '[]',
description: 'An array of up to 3 items to display in the folder.'
},
{
name: 'size',
type: 'number',
default: '1',
description: 'Size multiplier for the folder.'
},
];
</script>