Merge remote-tracking branch 'origin/feat/wizard-plan-vista'

This commit is contained in:
2026-01-09 11:19:21 -06:00
35 changed files with 2566 additions and 377 deletions

View File

@@ -1,7 +1,7 @@
import type {
NewSubjectWizardState,
TipoAsignatura,
} from '@/features/asignaturas/new/types'
} from '@/features/asignaturas/nueva/types'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -15,7 +15,7 @@ import {
import {
ESTRUCTURAS_SEP,
TIPOS_MATERIA,
} from '@/features/asignaturas/new/catalogs'
} from '@/features/asignaturas/nueva/catalogs'
export function PasoBasicosForm({
wizard,

View File

@@ -1,6 +1,6 @@
import * as Icons from 'lucide-react'
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import { Button } from '@/components/ui/button'
import {
@@ -25,7 +25,7 @@ import {
FACULTADES,
MATERIAS_MOCK,
PLANES_MOCK,
} from '@/features/asignaturas/new/catalogs'
} from '@/features/asignaturas/nueva/catalogs'
export function PasoConfiguracionPanel({
wizard,

View File

@@ -4,7 +4,7 @@ import type {
ModoCreacion,
NewSubjectWizardState,
SubModoClonado,
} from '@/features/asignaturas/new/types'
} from '@/features/asignaturas/nueva/types'
import {
Card,

View File

@@ -1,6 +1,6 @@
import * as Icons from 'lucide-react'
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import {
Card,
@@ -9,7 +9,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/new/catalogs'
import { ESTRUCTURAS_SEP } from '@/features/asignaturas/nueva/catalogs'
export function PasoResumenCard({ wizard }: { wizard: NewSubjectWizardState }) {
return (

View File

@@ -1,4 +1,4 @@
import type { NewSubjectWizardState } from '@/features/asignaturas/new/types'
import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types'
import { Button } from '@/components/ui/button'

View File

@@ -1,181 +0,0 @@
import type { CARRERAS } from '@/features/planes/new/catalogs'
import type { NewPlanWizardState } from '@/features/planes/new/types'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
FACULTADES,
NIVELES,
TIPOS_CICLO,
} from '@/features/planes/new/catalogs'
export function PasoBasicosForm({
wizard,
onChange,
carrerasFiltradas,
}: {
wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
carrerasFiltradas: typeof CARRERAS
}) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombrePlan">Nombre del plan</Label>
<Input
id="nombrePlan"
placeholder="Ej. Ingeniería en Sistemas 2026"
value={wizard.datosBasicos.nombrePlan}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
}))
}
/>
</div>
<div className="grid gap-1">
<Label htmlFor="facultad">Facultad</Label>
<Select
value={wizard.datosBasicos.facultadId}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
facultadId: value,
carreraId: '',
},
}))
}
>
<SelectTrigger
id="facultad"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona facultad…" />
</SelectTrigger>
<SelectContent>
{FACULTADES.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="carrera">Carrera</Label>
<Select
value={wizard.datosBasicos.carreraId}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, carreraId: value },
}))
}
disabled={!wizard.datosBasicos.facultadId}
>
<SelectTrigger
id="carrera"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona carrera…" />
</SelectTrigger>
<SelectContent>
{carrerasFiltradas.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="nivel">Nivel</Label>
<Select
value={wizard.datosBasicos.nivel}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nivel: value },
}))
}
>
<SelectTrigger
id="nivel"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue placeholder="Selecciona nivel…" />
</SelectTrigger>
<SelectContent>
{NIVELES.map((n) => (
<SelectItem key={n} value={n}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
<Select
value={wizard.datosBasicos.tipoCiclo}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
tipoCiclo: value as any,
},
}))
}
>
<SelectTrigger
id="tipoCiclo"
className="w-full min-w-0 [&>span]:block! [&>span]:truncate!"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TIPOS_CICLO.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="numCiclos">Número de ciclos</Label>
<Input
id="numCiclos"
type="number"
min={1}
value={wizard.datosBasicos.numCiclos}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
numCiclos: Number(e.target.value || 1),
},
}))
}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,253 @@
import { TemplateSelectorCard } from './TemplateSelectorCard'
import type { CARRERAS } from '@/features/planes/nuevo/catalogs'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
FACULTADES,
NIVELES,
TIPOS_CICLO,
PLANTILLAS_ANEXO_1,
PLANTILLAS_ANEXO_2,
} from '@/features/planes/nuevo/catalogs'
import { cn } from '@/lib/utils'
export function PasoBasicosForm({
wizard,
onChange,
carrerasFiltradas,
}: {
wizard: NewPlanWizardState
onChange: React.Dispatch<React.SetStateAction<NewPlanWizardState>>
carrerasFiltradas: typeof CARRERAS
}) {
return (
<div className="flex flex-col gap-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-1 sm:col-span-2">
<Label htmlFor="nombrePlan">
Nombre del plan <span className="text-destructive">*</span>
</Label>
<Input
id="nombrePlan"
placeholder="Ej. Ingeniería en Sistemas 2026"
value={wizard.datosBasicos.nombrePlan}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nombrePlan: e.target.value },
}))
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
/>
</div>
<div className="grid gap-1">
<Label htmlFor="facultad">Facultad</Label>
<Select
value={wizard.datosBasicos.facultadId}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
facultadId: value,
carreraId: '',
},
}))
}
>
<SelectTrigger
id="facultad"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.facultadId
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
>
<SelectValue placeholder="Ej. Facultad de Ingeniería" />
</SelectTrigger>
<SelectContent>
{FACULTADES.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="carrera">Carrera</Label>
<Select
value={wizard.datosBasicos.carreraId}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, carreraId: value },
}))
}
disabled={!wizard.datosBasicos.facultadId}
>
<SelectTrigger
id="carrera"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.carreraId
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
>
<SelectValue placeholder="Ej. Ingeniería en Cibernética y Sistemas Computacionales" />
</SelectTrigger>
<SelectContent>
{carrerasFiltradas.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="nivel">Nivel</Label>
<Select
value={wizard.datosBasicos.nivel}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: { ...w.datosBasicos, nivel: value },
}))
}
>
<SelectTrigger
id="nivel"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.nivel
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
>
<SelectValue placeholder="Ej. Licenciatura" />
</SelectTrigger>
<SelectContent>
{NIVELES.map((n) => (
<SelectItem key={n} value={n}>
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="tipoCiclo">Tipo de ciclo</Label>
<Select
value={wizard.datosBasicos.tipoCiclo}
onValueChange={(value) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
tipoCiclo: value as any,
},
}))
}
>
<SelectTrigger
id="tipoCiclo"
className={cn(
'w-full min-w-0 [&>span]:block! [&>span]:truncate!',
!wizard.datosBasicos.tipoCiclo
? 'text-muted-foreground font-normal italic opacity-70' // Es Placeholder
: 'font-medium not-italic', // Tiene Valor (Medium)
)}
>
<SelectValue placeholder="Ej. Semestre" />
</SelectTrigger>
<SelectContent>
{TIPOS_CICLO.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1">
<Label htmlFor="numCiclos">Número de ciclos</Label>
<Input
id="numCiclos"
type="number"
min={1}
value={wizard.datosBasicos.numCiclos ?? ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
// Keep undefined when the input is empty so the field stays optional
numCiclos:
e.target.value === '' ? undefined : Number(e.target.value),
},
}))
}
className="placeholder:text-muted-foreground/70 font-medium not-italic placeholder:font-normal placeholder:italic"
placeholder="Ej. 8"
/>
</div>
</div>
<Separator className="my-3" />
<div className="grid gap-4 sm:grid-cols-2">
<TemplateSelectorCard
cardTitle="Plantilla de plan de estudios"
cardDescription="Selecciona el Word para tu nuevo plan."
templatesData={PLANTILLAS_ANEXO_1}
selectedTemplateId={wizard.datosBasicos.plantillaPlanId || ''}
selectedVersion={wizard.datosBasicos.plantillaPlanVersion || ''}
onChange={({ templateId, version }) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
plantillaPlanId: templateId,
plantillaPlanVersion: version,
},
}))
}
/>
<TemplateSelectorCard
cardTitle="Plantilla de mapa curricular"
cardDescription="Selecciona el Excel para tu mapa curricular."
templatesData={PLANTILLAS_ANEXO_2}
selectedTemplateId={wizard.datosBasicos.plantillaMapaId || ''}
selectedVersion={wizard.datosBasicos.plantillaMapaVersion || ''}
onChange={({ templateId, version }) =>
onChange((w) => ({
...w,
datosBasicos: {
...w.datosBasicos,
plantillaMapaId: templateId,
plantillaMapaVersion: version,
},
}))
}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,189 @@
import { useMemo, useState } from 'react'
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
type TemplateData = {
id: string
name: string
versions: Array<string>
}
// Default data (kept for backward compatibility if caller doesn't pass templates)
const DEFAULT_TEMPLATES_DATA: Array<TemplateData> = [
{
id: 'sep-2025',
name: 'Licenciatura RVOE SEP',
versions: ['v2025.2 (Vigente)', 'v2025.1', 'v2024.Final'],
},
{
id: 'interno-mix',
name: 'Estándar Institucional Mixto',
versions: ['v2.0', 'v1.5', 'v1.0-beta'],
},
{
id: 'conacyt',
name: 'Formato Posgrado CONAHCYT',
versions: ['v3.0 (2025)', 'v2.8'],
},
]
interface Props {
cardTitle?: string
cardDescription?: string
templatesData?: Array<TemplateData>
// Controlled selection (optional). If not provided, component manages its own state
selectedTemplateId?: string
selectedVersion?: string
onChange?: (sel: { templateId: string; version: string }) => void
}
export function TemplateSelectorCard({
cardTitle = 'Configuración del Documento',
cardDescription = 'Selecciona la base para tu nuevo plan.',
templatesData = DEFAULT_TEMPLATES_DATA,
selectedTemplateId,
selectedVersion,
onChange,
}: Props) {
const [internalTemplate, setInternalTemplate] = useState<string>('')
const [internalVersion, setInternalVersion] = useState<string>('')
const selectedTemplate = selectedTemplateId ?? internalTemplate
const version = selectedVersion ?? internalVersion
// Buscamos las versiones de la plantilla seleccionada
const currentTemplateData = useMemo(
() => templatesData.find((t) => t.id === selectedTemplate),
[templatesData, selectedTemplate],
)
const availableVersions = currentTemplateData?.versions || []
const handleTemplateChange = (value: string) => {
const template = templatesData.find((t) => t.id === value)
const firstVersion = template?.versions?.[0] ?? ''
if (onChange) {
onChange({ templateId: value, version: firstVersion })
} else {
setInternalTemplate(value)
setInternalVersion(firstVersion)
}
}
const handleVersionChange = (value: string) => {
if (onChange) {
onChange({ templateId: selectedTemplate, version: value })
} else {
setInternalVersion(value)
}
}
return (
<Card className="w-full max-w-lg gap-2 overflow-hidden">
<CardHeader className="px-4 pb-2 sm:px-6 sm:pb-4">
<CardTitle className="text-lg">{cardTitle}</CardTitle>
<CardDescription>{cardDescription}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{/* SELECT 1: PRIMARIO (Llamativo) */}
<div className="space-y-2">
<Label
htmlFor="template-select"
className="text-foreground text-base font-semibold"
>
Plantilla
</Label>
<Select value={selectedTemplate} onValueChange={handleTemplateChange}>
<SelectTrigger
id="template-select"
className="bg-background border-primary/40 focus:ring-primary/20 focus:border-primary flex h-11 w-full min-w-0 items-center justify-between gap-2 text-base shadow-sm [&>span]:block! [&>span]:truncate! [&>span]:text-left"
>
<SelectValue placeholder="Selecciona una plantilla..." />
</SelectTrigger>
<SelectContent>
{templatesData.map((t) => (
<SelectItem key={t.id} value={t.id} className="font-medium">
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* SELECT 2: SECUNDARIO (Sutil) */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label
htmlFor="version-select"
className={cn(
'text-xs tracking-wider uppercase transition-colors',
!selectedTemplate
? 'text-muted-foreground/50'
: 'text-muted-foreground',
)}
>
Versión
</Label>
</div>
<Select
value={version}
onValueChange={handleVersionChange}
disabled={!selectedTemplate}
>
<SelectTrigger
id="version-select"
className={cn(
'flex h-9 min-w-0 items-center justify-between gap-2 text-sm transition-all duration-300',
/* AQUÍ ESTÁ EL CAMBIO DE ANCHO: */
'w-full max-w-full sm:w-55',
/* Las correcciones vitales para truncado que ya teníamos: */
'min-w-0 [&>span]:block! [&>span]:truncate! [&>span]:text-left',
'[&>span]:block [&>span]:min-w-0 [&>span]:truncate [&>span]:text-left',
!selectedTemplate
? 'bg-muted/50 cursor-not-allowed border-transparent opacity-50'
: 'bg-muted/20 border-border hover:bg-background hover:border-primary/30',
)}
>
<SelectValue
placeholder={
!selectedTemplate
? '— Esperando plantilla —'
: 'Selecciona versión'
}
/>
</SelectTrigger>
<SelectContent>
{availableVersions.map((v) => (
<SelectItem
key={v}
value={v}
className="text-muted-foreground focus:text-foreground text-sm"
>
{v}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,205 @@
import { Upload, File, X, FileText } from 'lucide-react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface UploadedFile {
id: string
name: string
size: string
type: string
}
interface FileDropzoneProps {
onFilesChange?: (files: Array<UploadedFile>) => void
acceptedTypes?: string
maxFiles?: number
title?: string
description?: string
}
export function FileDropzone({
onFilesChange,
acceptedTypes = '.doc,.docx,.pdf',
maxFiles = 5,
title = 'Arrastra archivos aquí',
description = 'o haz clic para seleccionar',
}: FileDropzoneProps) {
const [isDragging, setIsDragging] = useState(false)
const [files, setFiles] = useState<Array<UploadedFile>>([])
const onFilesChangeRef = useRef<typeof onFilesChange>(onFilesChange)
const addFiles = useCallback(
(newFiles: Array<File>) => {
const toUpload: Array<UploadedFile> = newFiles.map((file) => ({
id:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name,
size: formatFileSize(file.size),
type: file.name.split('.').pop() || 'file',
}))
setFiles((prev) => {
const room = Math.max(0, maxFiles - prev.length)
const next = [...prev, ...toUpload.slice(0, room)].slice(0, maxFiles)
return next
})
},
[maxFiles],
)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files)
addFiles(droppedFiles)
},
[addFiles],
)
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files)
addFiles(selectedFiles)
}
},
[addFiles],
)
const removeFile = useCallback((fileId: string) => {
setFiles((prev) => {
const next = prev.filter((f) => f.id !== fileId)
return next
})
}, [])
// Keep latest callback in a ref to avoid retriggering effect on identity change
useEffect(() => {
onFilesChangeRef.current = onFilesChange
}, [onFilesChange])
// Only emit when files actually change to avoid parent update loops
useEffect(() => {
if (onFilesChangeRef.current) onFilesChangeRef.current(files)
}, [files])
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const getFileIcon = (type: string) => {
switch (type.toLowerCase()) {
case 'pdf':
return <FileText className="text-destructive h-4 w-4" />
case 'doc':
case 'docx':
return <FileText className="text-info h-4 w-4" />
default:
return <File className="text-muted-foreground h-4 w-4" />
}
}
return (
<div className="space-y-3">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'border-border hover:border-primary/50 cursor-pointer rounded-xl border-2 border-dashed p-8 text-center transition-all duration-300',
isDragging && 'active',
)}
>
<input
type="file"
accept={acceptedTypes}
multiple
onChange={handleFileInput}
className="hidden"
id="file-upload"
/>
<label
htmlFor="file-upload"
className="cursor-pointer"
aria-label="Seleccionar archivos"
>
<div className="flex flex-col items-center gap-3">
<div
className={cn(
'flex h-12 w-12 items-center justify-center rounded-xl transition-colors',
isDragging
? 'bg-primary text-primary-foreground'
: 'bg-accent text-accent-foreground',
)}
>
<Upload className="h-6 w-6" />
</div>
<div className="text-center">
<p className="text-foreground text-sm font-medium">{title}</p>
<p className="text-muted-foreground mt-1 text-xs">
{description}
</p>
<p className="text-muted-foreground mt-1 text-xs">
Formatos:{' '}
{acceptedTypes
.replace(/\./g, '')
.toUpperCase()
.replace(/,/g, ', ')}
</p>
</div>
</div>
</label>
</div>
{/* Uploaded files list */}
{files.length > 0 && (
<div className="space-y-2">
{files.map((file) => (
<div
key={file.id}
className="bg-accent/50 border-border fade-in flex items-center gap-3 rounded-lg border p-3"
>
{getFileIcon(file.type)}
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{file.name}
</p>
<p className="text-muted-foreground text-xs">{file.size}</p>
</div>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive h-8 w-8"
onClick={() => removeFile(file.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{files.length >= maxFiles && (
<p className="text-warning text-center text-xs">
Máximo de {maxFiles} archivos alcanzado
</p>
)}
</div>
)
}

View File

@@ -1,4 +1,7 @@
import type { NewPlanWizardState } from '@/features/planes/new/types'
import { FileDropzone } from './FileDropZone'
import ReferenciasParaIA from './ReferenciasParaIA'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import { Button } from '@/components/ui/button'
import {
@@ -14,7 +17,7 @@ import {
CARRERAS,
FACULTADES,
PLANES_EXISTENTES,
} from '@/features/planes/new/catalogs'
} from '@/features/planes/nuevo/catalogs'
export function PasoDetallesPanel({
wizard,
@@ -42,8 +45,8 @@ export function PasoDetallesPanel({
if (wizard.modoCreacion === 'IA') {
return (
<div className="grid gap-4">
<div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="desc">Descripción del enfoque</Label>
<textarea
id="desc"
@@ -61,24 +64,8 @@ export function PasoDetallesPanel({
}
/>
</div>
<div>
<Label htmlFor="poblacion">Población objetivo</Label>
<Input
id="poblacion"
placeholder="Ej. Egresados de bachillerato con perfil STEM"
value={wizard.iaConfig?.poblacionObjetivo || ''}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
poblacionObjetivo: e.target.value,
},
}))
}
/>
</div>
<div>
<div className="flex flex-col gap-1">
<Label htmlFor="notas">Notas adicionales</Label>
<textarea
id="notas"
@@ -96,6 +83,49 @@ export function PasoDetallesPanel({
}
/>
</div>
<ReferenciasParaIA
selectedArchivoIds={wizard.iaConfig?.archivosReferencia || []}
selectedRepositorioIds={wizard.iaConfig?.repositoriosReferencia || []}
onToggleArchivo={(id, checked) =>
onChange((w) => {
const prev = w.iaConfig?.archivosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((x) => x !== id)
return {
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
archivosReferencia: next,
},
}
})
}
onToggleRepositorio={(id, checked) =>
onChange((w) => {
const prev = w.iaConfig?.repositoriosReferencia || []
const next = checked
? [...prev, id]
: prev.filter((x) => x !== id)
return {
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
repositoriosReferencia: next,
},
}
})
}
onFilesChange={(files) =>
onChange((w) => ({
...w,
iaConfig: {
...(w.iaConfig || ({} as any)),
archivosAdjuntos: files,
},
}))
}
/>
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
Opcional: se pueden adjuntar recursos IA más adelante.
@@ -144,6 +174,7 @@ export function PasoDetallesPanel({
<select
id="clonFacultad"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
aria-label="Facultad"
value={wizard.datosBasicos.facultadId}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
onChange((w) => ({
@@ -168,6 +199,7 @@ export function PasoDetallesPanel({
<select
id="clonCarrera"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
aria-label="Carrera"
value={wizard.datosBasicos.carreraId}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
onChange((w) => ({
@@ -242,10 +274,10 @@ export function PasoDetallesPanel({
wizard.subModoClonado === 'TRADICIONAL'
) {
return (
<div className="grid gap-4">
<div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<Label htmlFor="word">Word del plan (obligatorio)</Label>
<input
{/* <input
id="word"
type="file"
accept=".doc,.docx"
@@ -261,6 +293,21 @@ export function PasoDetallesPanel({
},
}))
}
/> */}
<FileDropzone
acceptedTypes=".doc,.docx"
maxFiles={1}
onFilesChange={(files) => {
const f = files[0] || null
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoWordPlanId: f,
},
}))
}}
/>
</div>
<div>
@@ -269,17 +316,32 @@ export function PasoDetallesPanel({
id="mapa"
type="file"
accept=".xls,.xlsx"
title="Subir mapa curricular"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoMapaExcelId: e.target.files?.[0]
? `file_${e.target.files[0].name}`
: null,
},
}))
onChange((w) => {
const file = e.target.files?.[0] || null
const next = file
? {
id:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`,
name: file.name,
size: formatFileSize(file.size),
type: file.name.split('.').pop() || 'file',
}
: null
return {
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoMapaExcelId: next,
},
}
})
}
/>
</div>
@@ -289,17 +351,32 @@ export function PasoDetallesPanel({
id="asignaturas"
type="file"
accept=".xls,.xlsx,.csv"
title="Subir listado de asignaturas"
className="bg-background text-foreground ring-offset-background focus-visible:ring-ring file:bg-secondary block w-full rounded-md border px-3 py-2 text-sm shadow-sm file:mr-4 file:rounded-md file:border-0 file:px-3 file:py-1.5 file:text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onChange((w) => ({
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoAsignaturasExcelId: e.target.files?.[0]
? `file_${e.target.files[0].name}`
: null,
},
}))
onChange((w) => {
const file = e.target.files?.[0] || null
const next = file
? {
id:
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? (crypto as any).randomUUID()
: `file-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`,
name: file.name,
size: formatFileSize(file.size),
type: file.name.split('.').pop() || 'file',
}
: null
return {
...w,
clonTradicional: {
...(w.clonTradicional || ({} as any)),
archivoAsignaturasExcelId: next,
},
}
})
}
/>
</div>
@@ -321,3 +398,9 @@ export function PasoDetallesPanel({
</Card>
)
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}

View File

@@ -0,0 +1,210 @@
import { FileText, FolderOpen, Upload } from 'lucide-react'
import { useMemo, useState } from 'react'
import BarraBusqueda from '../../BarraBusqueda'
import { FileDropzone } from './FileDropZone'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
TabsContents,
} from '@/components/ui/motion-tabs'
import { ARCHIVOS, REPOSITORIOS } from '@/features/planes/nuevo/catalogs'
const ReferenciasParaIA = ({
selectedArchivoIds = [],
selectedRepositorioIds = [],
onToggleArchivo,
onToggleRepositorio,
onFilesChange,
}: {
selectedArchivoIds?: Array<string>
selectedRepositorioIds?: Array<string>
onToggleArchivo?: (id: string, checked: boolean) => void
onToggleRepositorio?: (id: string, checked: boolean) => void
onFilesChange?: (
files: Array<{ id: string; name: string; size: string; type: string }>,
) => void
}) => {
const [busquedaArchivos, setBusquedaArchivos] = useState('')
const [busquedaRepositorios, setBusquedaRepositorios] = useState('')
const cleanText = (text: string) => {
return text
.normalize('NFD') // Descompone "á" en "a" + "´"
.replace(/[\u0300-\u036f]/g, '') // Elimina los símbolos diacríticos
.toLowerCase() // Convierte a minúsculas
}
// Filtrado de archivos y de repositorios
const archivosFiltrados = useMemo(() => {
// Función helper para limpiar texto (quita acentos y hace minúsculas)
const term = cleanText(busquedaArchivos)
return ARCHIVOS.filter((archivo) =>
cleanText(archivo.nombre).includes(term),
)
}, [busquedaArchivos])
const repositoriosFiltrados = useMemo(() => {
const term = cleanText(busquedaRepositorios)
return REPOSITORIOS.filter((repositorio) =>
cleanText(repositorio.nombre).includes(term),
)
}, [busquedaRepositorios])
const tabs = [
{
name: 'Archivos existentes',
value: 'archivos-existentes',
icon: FileText,
content: (
<div className="flex flex-col">
<BarraBusqueda
value={busquedaArchivos}
onChange={setBusquedaArchivos}
placeholder="Buscar archivo existente..."
className="m-1 mb-1.5"
/>
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
{archivosFiltrados.map((archivo) => (
<Label
key={archivo.id}
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
>
<Checkbox
checked={selectedArchivoIds.includes(archivo.id)}
onCheckedChange={(checked) =>
onToggleArchivo?.(archivo.id, !!checked)
}
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<FileText className="text-muted-foreground h-4 w-4" />
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{archivo.nombre}
</p>
<p className="text-muted-foreground text-xs">
{archivo.tamaño}
</p>
</div>
</Label>
))}
</div>
</div>
),
},
{
name: 'Repositorios',
value: 'repositorios',
icon: FolderOpen,
content: (
<div className="flex flex-col">
<BarraBusqueda
value={busquedaRepositorios}
onChange={setBusquedaRepositorios}
placeholder="Buscar repositorio..."
className="m-1 mb-1.5"
/>
<div className="flex h-72 flex-col gap-0.5 overflow-y-auto">
{repositoriosFiltrados.map((repositorio) => (
<Label
key={repositorio.id}
className="border-border hover:border-primary/30 hover:bg-accent/50 m-0.5 flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition-colors has-aria-checked:border-blue-600 has-aria-checked:bg-blue-50 dark:has-aria-checked:border-blue-900 dark:has-aria-checked:bg-blue-950"
>
<Checkbox
checked={selectedRepositorioIds.includes(repositorio.id)}
onCheckedChange={(checked) =>
onToggleRepositorio?.(repositorio.id, !!checked)
}
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<FolderOpen className="text-muted-foreground h-4 w-4" />
<div className="min-w-0 flex-1">
<p className="text-foreground text-sm font-medium">
{repositorio.nombre}
</p>
<p className="text-muted-foreground text-xs">
{repositorio.descripcion} · {repositorio.cantidadArchivos}{' '}
archivos
</p>
</div>
</Label>
))}
</div>
</div>
),
},
{
name: 'Subir archivos',
value: 'subir-archivos',
icon: Upload,
content: (
<div>
<FileDropzone
onFilesChange={onFilesChange}
title="Sube archivos de referencia"
description="Documentos que serán usados como contexto para la generación"
/>
</div>
),
},
]
return (
<div className="flex w-full flex-col gap-1">
<Label>Referencias para la IA</Label>
<Tabs defaultValue="archivos-existentes" className="gap-4">
<TabsList className="w-full">
{tabs.map(({ icon: Icon, name, value }) => (
<TabsTrigger
key={value}
value={value}
className="flex items-center gap-1 px-2.5 sm:px-3"
>
<Icon />
<span className="hidden sm:inline">{name}</span>
</TabsTrigger>
))}
</TabsList>
<TabsContents className="bg-background mx-1 -mt-2 mb-1 h-full rounded-sm">
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="animate-in fade-in duration-300 ease-out"
>
{tab.content}
</TabsContent>
))}
</TabsContents>
</Tabs>
</div>
)
}
export default ReferenciasParaIA

View File

@@ -4,7 +4,7 @@ import type {
NewPlanWizardState,
ModoCreacion,
SubModoClonado,
} from '@/features/planes/new/types'
} from '@/features/planes/nuevo/types'
import {
Card,
@@ -23,6 +23,19 @@ export function PasoModoCardGroup({
}) {
const isSelected = (m: ModoCreacion) => wizard.modoCreacion === m
const isSubSelected = (s: SubModoClonado) => wizard.subModoClonado === s
const handleKeyActivate = (e: React.KeyboardEvent, cb: () => void) => {
const key = e.key
if (
key === 'Enter' ||
key === ' ' ||
key === 'Spacebar' ||
key === 'Space'
) {
e.preventDefault()
e.stopPropagation()
cb()
}
}
return (
<div className="grid gap-4 sm:grid-cols-3">
<Card
@@ -34,6 +47,15 @@ export function PasoModoCardGroup({
subModoClonado: undefined,
}))
}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange((w) => ({
...w,
modoCreacion: 'MANUAL',
subModoClonado: undefined,
})),
)
}
role="button"
tabIndex={0}
>
@@ -54,6 +76,15 @@ export function PasoModoCardGroup({
subModoClonado: undefined,
}))
}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange((w) => ({
...w,
modoCreacion: 'IA',
subModoClonado: undefined,
})),
)
}
role="button"
tabIndex={0}
>
@@ -70,6 +101,11 @@ export function PasoModoCardGroup({
<Card
className={isSelected('CLONADO') ? 'ring-ring ring-2' : ''}
onClick={() => onChange((w) => ({ ...w, modoCreacion: 'CLONADO' }))}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange((w) => ({ ...w, modoCreacion: 'CLONADO' })),
)
}
role="button"
tabIndex={0}
>
@@ -88,6 +124,11 @@ export function PasoModoCardGroup({
e.stopPropagation()
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' }))
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange((w) => ({ ...w, subModoClonado: 'INTERNO' })),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
isSubSelected('INTERNO')
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'
@@ -105,6 +146,11 @@ export function PasoModoCardGroup({
e.stopPropagation()
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' }))
}}
onKeyDown={(e: React.KeyboardEvent) =>
handleKeyActivate(e, () =>
onChange((w) => ({ ...w, subModoClonado: 'TRADICIONAL' })),
)
}
className={`hover:border-primary/50 hover:bg-accent flex cursor-pointer flex-row items-center justify-center gap-2 rounded-lg border p-4 text-center transition-all sm:flex-col ${
isSubSelected('TRADICIONAL')
? 'border-primary bg-primary/5 ring-primary text-primary ring-1'

View File

@@ -1,4 +1,4 @@
import type { NewPlanWizardState } from '@/features/planes/new/types'
import type { NewPlanWizardState } from '@/features/planes/nuevo/types'
import {
Card,
@@ -7,10 +7,15 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
PLANTILLAS_ANEXO_1,
PLANTILLAS_ANEXO_2,
PLANES_EXISTENTES,
ARCHIVOS,
REPOSITORIOS,
} from '@/features/planes/nuevo/catalogs'
export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
const modo = wizard.modoCreacion
const sub = wizard.subModoClonado
return (
<Card>
<CardHeader>
@@ -21,53 +26,210 @@ export function PasoResumenCard({ wizard }: { wizard: NewPlanWizardState }) {
</CardHeader>
<CardContent>
<div className="grid gap-2 text-sm">
<div>
<span className="text-muted-foreground">Nombre: </span>
<span className="font-medium">
{wizard.datosBasicos.nombrePlan || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Facultad/Carrera: </span>
<span className="font-medium">
{wizard.datosBasicos.facultadId || '—'} /{' '}
{wizard.datosBasicos.carreraId || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Nivel: </span>
<span className="font-medium">
{wizard.datosBasicos.nivel || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Ciclos: </span>
<span className="font-medium">
{wizard.datosBasicos.numCiclos} ({wizard.datosBasicos.tipoCiclo})
</span>
</div>
<div className="mt-2">
<span className="text-muted-foreground">Modo: </span>
<span className="font-medium">
{modo === 'MANUAL' && 'Manual'}
{modo === 'IA' && 'Generado con IA'}
{modo === 'CLONADO' &&
sub === 'INTERNO' &&
'Clonado desde plan del sistema'}
{modo === 'CLONADO' &&
sub === 'TRADICIONAL' &&
'Importado desde documentos tradicionales'}
</span>
</div>
{wizard.resumen.previewPlan && (
<div className="bg-muted mt-2 rounded-md p-3">
<div className="font-medium">Preview IA</div>
<div className="text-muted-foreground">
Asignaturas aprox.:{' '}
{wizard.resumen.previewPlan.numAsignaturasAprox}
</div>
</div>
)}
{(() => {
// Precompute common derived values to avoid unnecessary optional chaining warnings
const archivosRef = wizard.iaConfig?.archivosReferencia ?? []
const repositoriosRef =
wizard.iaConfig?.repositoriosReferencia ?? []
const adjuntos = wizard.iaConfig?.archivosAdjuntos ?? []
const plantillaPlan = PLANTILLAS_ANEXO_1.find(
(x) => x.id === wizard.datosBasicos.plantillaPlanId,
)
const plantillaMapa = PLANTILLAS_ANEXO_2.find(
(x) => x.id === wizard.datosBasicos.plantillaMapaId,
)
const contenido = (
<>
<div>
<span className="text-muted-foreground">Nombre: </span>
<span className="font-medium">
{wizard.datosBasicos.nombrePlan || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">
Facultad/Carrera:{' '}
</span>
<span className="font-medium">
{wizard.datosBasicos.facultadId || '—'} /{' '}
{wizard.datosBasicos.carreraId || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Nivel: </span>
<span className="font-medium">
{wizard.datosBasicos.nivel || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Ciclos: </span>
<span className="font-medium">
{wizard.datosBasicos.numCiclos} (
{wizard.datosBasicos.tipoCiclo})
</span>
</div>
<div className="mt-2">
<span className="text-muted-foreground">
Plantilla plan:{' '}
</span>
<span className="font-medium">
{(plantillaPlan?.name ||
wizard.datosBasicos.plantillaPlanId ||
'—') +
' · ' +
(wizard.datosBasicos.plantillaPlanVersion || '—')}
</span>
</div>
<div>
<span className="text-muted-foreground">
Mapa curricular:{' '}
</span>
<span className="font-medium">
{(plantillaMapa?.name ||
wizard.datosBasicos.plantillaMapaId ||
'—') +
' · ' +
(wizard.datosBasicos.plantillaMapaVersion || '—')}
</span>
</div>
<div className="mt-2">
<span className="text-muted-foreground">Modo: </span>
<span className="font-medium">
{wizard.modoCreacion === 'MANUAL' && 'Manual'}
{wizard.modoCreacion === 'IA' && 'Generado con IA'}
{wizard.modoCreacion === 'CLONADO' &&
wizard.subModoClonado === 'INTERNO' &&
'Clonado desde plan del sistema'}
{wizard.modoCreacion === 'CLONADO' &&
wizard.subModoClonado === 'TRADICIONAL' &&
'Importado desde documentos tradicionales'}
</span>
</div>
{wizard.modoCreacion === 'CLONADO' &&
wizard.subModoClonado === 'INTERNO' && (
<div className="mt-2">
<span className="text-muted-foreground">
Plan origen:{' '}
</span>
<span className="font-medium">
{(() => {
const p = PLANES_EXISTENTES.find(
(x) => x.id === wizard.clonInterno?.planOrigenId,
)
return (
p?.nombre || wizard.clonInterno?.planOrigenId || '—'
)
})()}
</span>
</div>
)}
{wizard.modoCreacion === 'CLONADO' &&
wizard.subModoClonado === 'TRADICIONAL' && (
<div className="mt-2">
<div className="font-medium">Documentos adjuntos</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs">
<li>
<span className="text-foreground">
Word del plan:
</span>{' '}
{wizard.clonTradicional?.archivoWordPlanId?.name ||
'—'}
</li>
<li>
<span className="text-foreground">
Mapa curricular:
</span>{' '}
{wizard.clonTradicional?.archivoMapaExcelId?.name ||
'—'}
</li>
<li>
<span className="text-foreground">Asignaturas:</span>{' '}
{wizard.clonTradicional?.archivoAsignaturasExcelId
?.name || '—'}
</li>
</ul>
</div>
)}
{wizard.modoCreacion === 'IA' && (
<div className="bg-muted/50 mt-2 rounded-md p-3">
<div>
<span className="text-muted-foreground">Enfoque: </span>
<span className="font-medium">
{wizard.iaConfig?.descripcionEnfoque || '—'}
</span>
</div>
<div>
<span className="text-muted-foreground">Notas: </span>
<span className="font-medium">
{wizard.iaConfig?.notasAdicionales || '—'}
</span>
</div>
{archivosRef.length > 0 && (
<div className="mt-2">
<div className="font-medium">Archivos existentes</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{archivosRef.map((id) => {
const a = ARCHIVOS.find((x) => x.id === id)
return (
<li key={id}>
<span className="text-foreground">
{a?.nombre || id}
</span>{' '}
{a?.tamaño ? <span>· {a.tamaño}</span> : null}
</li>
)
})}
</ul>
</div>
)}
{repositoriosRef.length > 0 && (
<div className="mt-2">
<div className="font-medium">Repositorios</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{repositoriosRef.map((id) => {
const r = REPOSITORIOS.find((x) => x.id === id)
return (
<li key={id}>
<span className="text-foreground">
{r?.nombre || id}
</span>{' '}
{r?.cantidadArchivos ? (
<span>· {r.cantidadArchivos} archivos</span>
) : null}
</li>
)
})}
</ul>
</div>
)}
{adjuntos.length > 0 && (
<div className="mt-2">
<div className="font-medium">Adjuntos</div>
<ul className="text-muted-foreground list-disc pl-5 text-xs">
{adjuntos.map((f) => (
<li key={f.id}>
<span className="text-foreground">{f.name}</span>{' '}
<span>· {f.size}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
{wizard.resumen.previewPlan && (
<div className="bg-muted mt-2 rounded-md p-3">
<div className="font-medium">Preview IA</div>
<div className="text-muted-foreground">
Asignaturas aprox.:{' '}
{wizard.resumen.previewPlan.numAsignaturasAprox}
</div>
</div>
)}
</>
)
return contenido
})()}
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,32 @@
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
const CheckboxCardDemo = () => {
return (
<div className="space-y-2">
<Label className="border-border hover:border-primary/30 hover:bg-accent/50 flex cursor-pointer items-center items-start gap-2 gap-3 rounded-lg border p-3 transition-colors has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950">
<Checkbox
defaultChecked
className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
<div className="grid gap-1.5 font-normal">
<p className="text-sm leading-none font-medium">Auto Start</p>
<p className="text-muted-foreground text-sm">
Starting with your OS.
</p>
</div>
</Label>
<Label className="hover:bg-accent/50 flex items-start gap-2 rounded-lg border p-3 has-[[aria-checked=true]]:border-blue-600 has-[[aria-checked=true]]:bg-blue-50 dark:has-[[aria-checked=true]]:border-blue-900 dark:has-[[aria-checked=true]]:bg-blue-950">
<Checkbox className="peer border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:ring-ring h-5 w-5 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
<div className="grid gap-1.5 font-normal">
<p className="text-sm leading-none font-medium">Auto update</p>
<p className="text-muted-foreground text-sm">
Download and install new version
</p>
</div>
</Label>
</div>
)
}
export default CheckboxCardDemo

View File

@@ -0,0 +1,76 @@
import { BookIcon, GiftIcon, HeartIcon } from 'lucide-react'
import CheckboxCardDemo from '../checkbox/checkbox-13'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
const tabs = [
{
name: 'Explore',
value: 'explore',
icon: BookIcon,
content: (
<>
<CheckboxCardDemo />
</>
),
},
{
name: 'Favorites',
value: 'favorites',
icon: HeartIcon,
content: (
<>
All your{' '}
<span className="text-foreground font-semibold">favorites</span> are
saved here. Revisit articles, collections, and moments you love, any
time you want a little inspiration.
</>
),
},
{
name: 'Surprise',
value: 'surprise',
icon: GiftIcon,
content: (
<>
<span className="text-foreground font-semibold">Surprise!</span>{' '}
Here&apos;s something unexpecteda fun fact, a quirky tip, or a daily
challenge. Come back for a new surprise every day!
</>
),
},
]
const TabsWithIconDemo = () => {
return (
<div className="w-full">
<Tabs defaultValue="explore" className="gap-4">
<TabsList className="w-full">
{tabs.map(({ icon: Icon, name, value }) => (
<TabsTrigger
key={value}
value={value}
className="flex items-center gap-1 px-2.5 sm:px-3"
>
<Icon />
{name}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="animate-in fade-in duration-300 ease-out"
>
<p className="text-muted-foreground text-sm">{tab.content}</p>
</TabsContent>
))}
</Tabs>
</div>
)
}
export default TabsWithIconDemo

View File

@@ -0,0 +1,72 @@
import { Tabs, TabsContent, TabsContents, TabsList, TabsTrigger } from '@/components/ui/motion-tabs'
const tabs = [
{
name: 'Explore',
value: 'explore',
content: (
<>
Discover <span className='text-foreground font-semibold'>fresh ideas</span>, trending topics, and hidden gems
curated just for you. Start exploring and let your curiosity lead the way!
</>
)
},
{
name: 'Favorites',
value: 'favorites',
content: (
<>
All your <span className='text-foreground font-semibold'>favorites</span> are saved here. Revisit articles,
collections, and moments you love, any time you want a little inspiration.
</>
)
},
{
name: 'Surprise Me',
value: 'surprise',
content: (
<>
<span className='text-foreground font-semibold'>Surprise!</span> Here&apos;s something unexpecteda fun fact, a
quirky tip, or a daily challenge. Come back for a new surprise every day!
</>
)
}
]
const AnimatedTabsDemo = () => {
return (
<div className='w-full max-w-md'>
<Tabs defaultValue='explore' className='gap-4'>
<TabsList>
{tabs.map(tab => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.name}
</TabsTrigger>
))}
</TabsList>
<TabsContents className='bg-background mx-1 -mt-2 mb-1 h-full rounded-sm'>
{tabs.map(tab => (
<TabsContent key={tab.value} value={tab.value}>
<p className='text-muted-foreground text-sm'>{tab.content}</p>
</TabsContent>
))}
</TabsContents>
</Tabs>
<p className='text-muted-foreground mt-4 text-center text-xs'>
Inspired by{' '}
<a
className='hover:text-foreground underline'
href='https://animate-ui.com/docs/components/tabs'
target='_blank'
rel='noopener noreferrer'
>
Animate UI
</a>
</p>
</div>
)
}
export default AnimatedTabsDemo

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,549 @@
import * as React from 'react'
import type { Transition } from 'motion/react'
import { AnimatePresence, motion } from 'motion/react'
import { cn } from '@/lib/utils'
type MotionHighlightMode = 'children' | 'parent'
type Bounds = {
top: number
left: number
width: number
height: number
}
type MotionHighlightContextType<T extends string> = {
mode: MotionHighlightMode
activeValue: T | null
setActiveValue: (value: T | null) => void
setBounds: (bounds: DOMRect) => void
clearBounds: () => void
id: string
hover: boolean
className?: string
activeClassName?: string
setActiveClassName: (className: string) => void
transition?: Transition
disabled?: boolean
enabled?: boolean
exitDelay?: number
forceUpdateBounds?: boolean
}
const MotionHighlightContext = React.createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MotionHighlightContextType<any> | undefined
>(undefined)
function useMotionHighlight<T extends string>(): MotionHighlightContextType<T> {
const context = React.useContext(MotionHighlightContext)
if (!context) {
throw new Error('useMotionHighlight must be used within a MotionHighlightProvider')
}
return context as unknown as MotionHighlightContextType<T>
}
type BaseMotionHighlightProps<T extends string> = {
mode?: MotionHighlightMode
value?: T | null
defaultValue?: T | null
onValueChange?: (value: T | null) => void
className?: string
transition?: Transition
hover?: boolean
disabled?: boolean
enabled?: boolean
exitDelay?: number
}
type ParentModeMotionHighlightProps = {
boundsOffset?: Partial<Bounds>
containerClassName?: string
forceUpdateBounds?: boolean
}
type ControlledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> &
ParentModeMotionHighlightProps & {
mode: 'parent'
controlledItems: true
children: React.ReactNode
}
type ControlledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & {
mode?: 'children' | undefined
controlledItems: true
children: React.ReactNode
}
type UncontrolledParentModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> &
ParentModeMotionHighlightProps & {
mode: 'parent'
controlledItems?: false
itemsClassName?: string
children: React.ReactElement | React.ReactElement[]
}
type UncontrolledChildrenModeMotionHighlightProps<T extends string> = BaseMotionHighlightProps<T> & {
mode?: 'children'
controlledItems?: false
itemsClassName?: string
children: React.ReactElement | React.ReactElement[]
}
type MotionHighlightProps<T extends string> = React.ComponentProps<'div'> &
(
| ControlledParentModeMotionHighlightProps<T>
| ControlledChildrenModeMotionHighlightProps<T>
| UncontrolledParentModeMotionHighlightProps<T>
| UncontrolledChildrenModeMotionHighlightProps<T>
)
function MotionHighlight<T extends string>({ ref, ...props }: MotionHighlightProps<T>) {
const {
children,
value,
defaultValue,
onValueChange,
className,
transition = { type: 'spring', stiffness: 350, damping: 35 },
hover = false,
enabled = true,
controlledItems,
disabled = false,
exitDelay = 0.2,
mode = 'children'
} = props
const localRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement)
const [activeValue, setActiveValue] = React.useState<T | null>(value ?? defaultValue ?? null)
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null)
const [activeClassNameState, setActiveClassNameState] = React.useState<string>('')
const safeSetActiveValue = React.useCallback(
(id: T | null) => {
setActiveValue(prev => (prev === id ? prev : id))
if (id !== activeValue) onValueChange?.(id as T)
},
[activeValue, onValueChange]
)
const safeSetBounds = React.useCallback(
(bounds: DOMRect) => {
if (!localRef.current) return
const boundsOffset = (props as ParentModeMotionHighlightProps)?.boundsOffset ?? {
top: 0,
left: 0,
width: 0,
height: 0
}
const containerRect = localRef.current.getBoundingClientRect()
const newBounds: Bounds = {
top: bounds.top - containerRect.top + (boundsOffset.top ?? 0),
left: bounds.left - containerRect.left + (boundsOffset.left ?? 0),
width: bounds.width + (boundsOffset.width ?? 0),
height: bounds.height + (boundsOffset.height ?? 0)
}
setBoundsState(prev => {
if (
prev &&
prev.top === newBounds.top &&
prev.left === newBounds.left &&
prev.width === newBounds.width &&
prev.height === newBounds.height
) {
return prev
}
return newBounds
})
},
[props]
)
const clearBounds = React.useCallback(() => {
setBoundsState(prev => (prev === null ? prev : null))
}, [])
React.useEffect(() => {
if (value !== undefined) setActiveValue(value)
else if (defaultValue !== undefined) setActiveValue(defaultValue)
}, [value, defaultValue])
const id = React.useId()
React.useEffect(() => {
if (mode !== 'parent') return
const container = localRef.current
if (!container) return
const onScroll = () => {
if (!activeValue) return
const activeEl = container.querySelector<HTMLElement>(`[data-value="${activeValue}"][data-highlight="true"]`)
if (activeEl) safeSetBounds(activeEl.getBoundingClientRect())
}
container.addEventListener('scroll', onScroll, { passive: true })
return () => container.removeEventListener('scroll', onScroll)
}, [mode, activeValue, safeSetBounds])
const render = React.useCallback(
(children: React.ReactNode) => {
if (mode === 'parent') {
return (
<div
ref={localRef}
data-slot='motion-highlight-container'
className={cn('relative', (props as ParentModeMotionHighlightProps)?.containerClassName)}
>
<AnimatePresence initial={false}>
{boundsState && (
<motion.div
data-slot='motion-highlight'
animate={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 1
}}
initial={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 0
}}
exit={{
opacity: 0,
transition: {
...transition,
delay: (transition?.delay ?? 0) + (exitDelay ?? 0)
}
}}
transition={transition}
className={cn('bg-muted absolute z-0', className, activeClassNameState)}
/>
)}
</AnimatePresence>
{children}
</div>
)
}
return children
},
[mode, props, boundsState, transition, exitDelay, className, activeClassNameState]
)
return (
<MotionHighlightContext.Provider
value={{
mode,
activeValue,
setActiveValue: safeSetActiveValue,
id,
hover,
className,
transition,
disabled,
enabled,
exitDelay,
setBounds: safeSetBounds,
clearBounds,
activeClassName: activeClassNameState,
setActiveClassName: setActiveClassNameState,
forceUpdateBounds: (props as ParentModeMotionHighlightProps)?.forceUpdateBounds
}}
>
{enabled
? controlledItems
? render(children)
: render(
React.Children.map(children, (child, index) => (
<MotionHighlightItem key={index} className={props?.itemsClassName}>
{child}
</MotionHighlightItem>
))
)
: children}
</MotionHighlightContext.Provider>
)
}
function getNonOverridingDataAttributes(
element: React.ReactElement,
dataAttributes: Record<string, unknown>
): Record<string, unknown> {
return Object.keys(dataAttributes).reduce<Record<string, unknown>>((acc, key) => {
if ((element.props as Record<string, unknown>)[key] === undefined) {
acc[key] = dataAttributes[key]
}
return acc
}, {})
}
type ExtendedChildProps = React.ComponentProps<'div'> & {
id?: string
ref?: React.Ref<HTMLElement>
'data-active'?: string
'data-value'?: string
'data-disabled'?: boolean
'data-highlight'?: boolean
'data-slot'?: string
}
type MotionHighlightItemProps = React.ComponentProps<'div'> & {
children: React.ReactElement
id?: string
value?: string
className?: string
transition?: Transition
activeClassName?: string
disabled?: boolean
exitDelay?: number
asChild?: boolean
forceUpdateBounds?: boolean
}
function MotionHighlightItem({
ref,
children,
id,
value,
className,
transition,
disabled = false,
activeClassName,
exitDelay,
asChild = false,
forceUpdateBounds,
...props
}: MotionHighlightItemProps) {
const itemId = React.useId()
const {
activeValue,
setActiveValue,
mode,
setBounds,
clearBounds,
hover,
enabled,
className: contextClassName,
transition: contextTransition,
id: contextId,
disabled: contextDisabled,
exitDelay: contextExitDelay,
forceUpdateBounds: contextForceUpdateBounds,
setActiveClassName
} = useMotionHighlight()
const element = children as React.ReactElement<ExtendedChildProps>
const childValue = id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId
const isActive = activeValue === childValue
const isDisabled = disabled === undefined ? contextDisabled : disabled
const itemTransition = transition ?? contextTransition
const localRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement)
React.useEffect(() => {
if (mode !== 'parent') return
let rafId: number
let previousBounds: Bounds | null = null
const shouldUpdateBounds = forceUpdateBounds === true || (contextForceUpdateBounds && forceUpdateBounds !== false)
const updateBounds = () => {
if (!localRef.current) return
const bounds = localRef.current.getBoundingClientRect()
if (shouldUpdateBounds) {
if (
previousBounds &&
previousBounds.top === bounds.top &&
previousBounds.left === bounds.left &&
previousBounds.width === bounds.width &&
previousBounds.height === bounds.height
) {
rafId = requestAnimationFrame(updateBounds)
return
}
previousBounds = bounds
rafId = requestAnimationFrame(updateBounds)
}
setBounds(bounds)
}
if (isActive) {
updateBounds()
setActiveClassName(activeClassName ?? '')
} else if (!activeValue) clearBounds()
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId)
}, [
mode,
isActive,
activeValue,
setBounds,
clearBounds,
activeClassName,
setActiveClassName,
forceUpdateBounds,
contextForceUpdateBounds
])
if (!React.isValidElement(children)) return children
const dataAttributes = {
'data-active': isActive ? 'true' : 'false',
'aria-selected': isActive,
'data-disabled': isDisabled,
'data-value': childValue,
'data-highlight': true
}
const commonHandlers = hover
? {
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue)
element.props.onMouseEnter?.(e)
},
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(null)
element.props.onMouseLeave?.(e)
}
}
: {
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue)
element.props.onClick?.(e)
}
}
if (asChild) {
if (mode === 'children') {
return React.cloneElement(
element,
{
key: childValue,
ref: localRef,
className: cn('relative', element.props.className),
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item-container'
}),
...commonHandlers,
...props
},
<>
<AnimatePresence initial={false}>
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot='motion-highlight'
className={cn('bg-muted absolute inset-0 z-0', contextClassName, activeClassName)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0)
}
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
<div data-slot='motion-highlight-item' className={cn('relative z-[1]', className)} {...dataAttributes}>
{children}
</div>
</>
)
}
return React.cloneElement(element, {
ref: localRef,
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item'
}),
...commonHandlers
})
}
return enabled ? (
<div
key={childValue}
ref={localRef}
data-slot='motion-highlight-item-container'
className={cn(mode === 'children' && 'relative', className)}
{...dataAttributes}
{...props}
{...commonHandlers}
>
{mode === 'children' && (
<AnimatePresence initial={false}>
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot='motion-highlight'
className={cn('bg-muted absolute inset-0 z-0', contextClassName, activeClassName)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay: (itemTransition?.delay ?? 0) + (exitDelay ?? contextExitDelay ?? 0)
}
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
)}
{React.cloneElement(element, {
className: cn('relative z-[1]', element.props.className),
...getNonOverridingDataAttributes(element, {
...dataAttributes,
'data-slot': 'motion-highlight-item'
})
})}
</div>
) : (
children
)
}
export {
MotionHighlight,
MotionHighlightItem,
useMotionHighlight,
type MotionHighlightProps,
type MotionHighlightItemProps
}

View File

@@ -0,0 +1,261 @@
'use client'
import * as React from 'react'
import { motion, type Transition, type HTMLMotionProps } from 'motion/react'
import { cn } from '@/lib/utils'
import { MotionHighlight, MotionHighlightItem } from '@/components/ui/motion-highlight'
type TabsContextType<T extends string> = {
activeValue: T
handleValueChange: (value: T) => void
registerTrigger: (value: T, node: HTMLElement | null) => void
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TabsContext = React.createContext<TabsContextType<any> | undefined>(undefined)
function useTabs<T extends string = string>(): TabsContextType<T> {
const context = React.useContext(TabsContext)
if (!context) {
throw new Error('useTabs must be used within a TabsProvider')
}
return context
}
type BaseTabsProps = React.ComponentProps<'div'> & {
children: React.ReactNode
}
type UnControlledTabsProps<T extends string = string> = BaseTabsProps & {
defaultValue?: T
value?: never
onValueChange?: never
}
type ControlledTabsProps<T extends string = string> = BaseTabsProps & {
value: T
onValueChange?: (value: T) => void
defaultValue?: never
}
type TabsProps<T extends string = string> = UnControlledTabsProps<T> | ControlledTabsProps<T>
function Tabs<T extends string = string>({
defaultValue,
value,
onValueChange,
children,
className,
...props
}: TabsProps<T>) {
const [activeValue, setActiveValue] = React.useState<T | undefined>(defaultValue ?? undefined)
const triggersRef = React.useRef(new Map<string, HTMLElement>())
const initialSet = React.useRef(false)
const isControlled = value !== undefined
React.useEffect(() => {
if (!isControlled && activeValue === undefined && triggersRef.current.size > 0 && !initialSet.current) {
const firstTab = Array.from(triggersRef.current.keys())[0]
setActiveValue(firstTab as T)
initialSet.current = true
}
}, [activeValue, isControlled])
const registerTrigger = (value: string, node: HTMLElement | null) => {
if (node) {
triggersRef.current.set(value, node)
if (!isControlled && activeValue === undefined && !initialSet.current) {
setActiveValue(value as T)
initialSet.current = true
}
} else {
triggersRef.current.delete(value)
}
}
const handleValueChange = (val: T) => {
if (!isControlled) setActiveValue(val)
else onValueChange?.(val)
}
return (
<TabsContext.Provider
value={{
activeValue: (value ?? activeValue)!,
handleValueChange,
registerTrigger
}}
>
<div data-slot='tabs' className={cn('flex flex-col gap-2', className)} {...props}>
{children}
</div>
</TabsContext.Provider>
)
}
type TabsListProps = React.ComponentProps<'div'> & {
children: React.ReactNode
activeClassName?: string
transition?: Transition
}
function TabsList({
children,
className,
activeClassName,
transition = {
type: 'spring',
stiffness: 200,
damping: 25
},
...props
}: TabsListProps) {
const { activeValue } = useTabs()
return (
<MotionHighlight
controlledItems
className={cn('bg-background rounded-sm shadow-sm', activeClassName)}
value={activeValue}
transition={transition}
>
<div
role='tablist'
data-slot='tabs-list'
className={cn(
'bg-muted text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg p-[4px]',
className
)}
{...props}
>
{children}
</div>
</MotionHighlight>
)
}
type TabsTriggerProps = HTMLMotionProps<'button'> & {
value: string
children: React.ReactNode
}
function TabsTrigger({ ref, value, children, className, ...props }: TabsTriggerProps) {
const { activeValue, handleValueChange, registerTrigger } = useTabs()
const localRef = React.useRef<HTMLButtonElement | null>(null)
React.useImperativeHandle(ref, () => localRef.current as HTMLButtonElement)
React.useEffect(() => {
registerTrigger(value, localRef.current)
return () => registerTrigger(value, null)
}, [value, registerTrigger])
return (
<MotionHighlightItem value={value} className='size-full'>
<motion.button
ref={localRef}
data-slot='tabs-trigger'
role='tab'
onClick={() => handleValueChange(value)}
data-state={activeValue === value ? 'active' : 'inactive'}
className={cn(
'ring-offset-background focus-visible:ring-ring data-[state=active]:text-foreground z-[1] inline-flex size-full cursor-pointer items-center justify-center rounded-sm px-2 py-1 text-sm font-medium whitespace-nowrap transition-transform focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
className
)}
{...props}
>
{children}
</motion.button>
</MotionHighlightItem>
)
}
type TabsContentsProps = React.ComponentProps<'div'> & {
children: React.ReactNode
transition?: Transition
}
function TabsContents({
children,
className,
transition = {
type: 'spring',
stiffness: 300,
damping: 30,
bounce: 0,
restDelta: 0.01
},
...props
}: TabsContentsProps) {
const { activeValue } = useTabs()
const childrenArray = React.Children.toArray(children)
const activeIndex = childrenArray.findIndex(
(child): child is React.ReactElement<{ value: string }> =>
React.isValidElement(child) &&
typeof child.props === 'object' &&
child.props !== null &&
'value' in child.props &&
child.props.value === activeValue
)
return (
<div data-slot='tabs-contents' className={cn('overflow-hidden', className)} {...props}>
<motion.div className='-mx-2 flex' animate={{ x: activeIndex * -100 + '%' }} transition={transition}>
{childrenArray.map((child, index) => (
<div key={index} className='w-full shrink-0 px-2'>
{child}
</div>
))}
</motion.div>
</div>
)
}
type TabsContentProps = HTMLMotionProps<'div'> & {
value: string
children: React.ReactNode
}
function TabsContent({ children, value, className, ...props }: TabsContentProps) {
const { activeValue } = useTabs()
const isActive = activeValue === value
return (
<motion.div
role='tabpanel'
data-slot='tabs-content'
className={cn('overflow-hidden', className)}
initial={{ filter: 'blur(0px)' }}
animate={{ filter: isActive ? 'blur(0px)' : 'blur(2px)' }}
exit={{ filter: 'blur(0px)' }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
{...props}
>
{children}
</motion.div>
)
}
export {
Tabs,
TabsList,
TabsTrigger,
TabsContents,
TabsContent,
useTabs,
type TabsContextType,
type TabsProps,
type TabsListProps,
type TabsTriggerProps,
type TabsContentsProps,
type TabsContentProps
}