diff --git a/src/atoms/Icons/index.tsx b/src/atoms/Icons/index.tsx index c5725b34d..77b57493c 100644 --- a/src/atoms/Icons/index.tsx +++ b/src/atoms/Icons/index.tsx @@ -1,6 +1,7 @@ export * from './AcademicRecord' export * from './AlertInfo' export * from './ArrowRight' +export * from './Calendar' export * from './Certificate' export * from './CircularCheck' export * from './CircularInformation' diff --git a/src/documentation/pages/Molecules/Avatar.tsx b/src/documentation/pages/Molecules/Avatar.tsx new file mode 100644 index 000000000..c972838c9 --- /dev/null +++ b/src/documentation/pages/Molecules/Avatar.tsx @@ -0,0 +1,161 @@ +import { Box } from '@chakra-ui/react' +import { Avatar } from '@molecules' +import { ListComponent, MyHeading, MyText, MyTitle, Code } from '@/documentation/components' + +export const ViewAvatar = (): JSX.Element => { + return ( + <> + Avatar + + Muestra la foto de perfil de un usuario dentro de un círculo. Si la imagen no está + disponible o no se proporciona, muestra las primeras dos iniciales del nombre completo sobre + un fondo de color determinista según el userId. + + + + {/* ── Uso básico ─────────────────────────────────────────────── */} + Uso básico + + Proporciona fullName y userId. Cuando no hay imagen, se renderizan + las iniciales con un color de fondo único para ese usuario. + + + + + + + + +`} + /> + + {/* ── Con imagen ─────────────────────────────────────────────── */} + Con foto de perfil + + Cuando se proporciona picture, el componente intenta cargar la imagen. Si la + URL es inválida, cae automáticamente en las iniciales. + + + + + + +{/* Si la URL falla, muestra las iniciales */} +`} + /> + + {/* ── Tamaños ────────────────────────────────────────────────── */} + Tamaños + + El prop size controla el diámetro en píxeles. El valor por defecto es{' '} + 50. fontSize ajusta el tamaño de las iniciales (por defecto{' '} + 14). + + + + + 24px + + + + 32px + + + + 40px + + + + 50px (default) + + + + 64px + + + + + + {/* default */} +`} + /> + + {/* ── Color determinista ─────────────────────────────────────── */} + Color por userId + + El color de fondo se calcula a partir del último dígito del userId, por lo que + siempre es consistente para el mismo usuario en todas las vistas. + + + {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((digit) => ( + + + id: {digit} + + ))} + + + +{/* ... hasta userId terminado en 9 */}`} + /> + + {/* ── Borde decorativo ───────────────────────────────────────── */} + Borde decorativo + + El prop borderDecoration añade un borde en el color primario del sistema de + diseño (#0189FF). Útil para destacar al usuario activo en un listado. + + + + + + +`} + /> + + {/* ── Props ──────────────────────────────────────────────────── */} + Props + + + ) +} + +export default ViewAvatar diff --git a/src/documentation/pages/Organisms/UserDropdown.tsx b/src/documentation/pages/Organisms/UserDropdown.tsx new file mode 100644 index 000000000..8c768f265 --- /dev/null +++ b/src/documentation/pages/Organisms/UserDropdown.tsx @@ -0,0 +1,142 @@ +import { Code, MyHeading, MyText, MyTitle } from '@/documentation/components' +import { UserDropdownMenu, ProfileMenuItem } from '@/organisms' +import { Box } from '@chakra-ui/react' +import { vars } from '@theme' + +const user = { + name: 'Josefin ferrada', + id: 564654, + picture: 'https://i.pravatar.cc/150?img=12', +} + +const onLogout = (): void => { + console.log('cerrar sesión') +} + +const profileMenuItems: ProfileMenuItem[] = [ + { label: 'Mis Cursos activos', type: 'courses', href: '/courses' }, + { label: 'Mi Calendario', type: 'calendar', href: '/calendar' }, + { label: 'Mi Historial académico', type: 'academic-history', href: '/courses-past' }, + { label: 'Mi perfil', type: 'profile', href: '/profile' }, + { label: 'Cerrar sesión', type: 'logout', onClick: onLogout }, +] + +export const UserDropdownPage = (): JSX.Element => { + return ( + <> + UserDropdownMenu + + + El componente UserDropdownMenu es un menú desplegable de perfil de usuario + accesible. Muestra un avatar como botón disparador que, al hacer clic, despliega un dropdown + con: +
    +
  • El nombre del usuario en el encabezado del menú
  • +
  • Una lista de links de navegación
  • +
  • Un botón de cerrar sesión (opcional)
  • +
+
+ El componente es responsivo: en pantallas móviles ({'<'}480px) el menú ocupa toda la + pantalla con un overlay de fondo. Soporta navegación por teclado con las teclas de flecha y + es compatible con lectores de pantalla. +
+
+ El estado del menú puede manejarse de forma interna (sin pasar props) o de + forma controlada externamente (pasando isOpen,{' '} + onOpen y onClose), lo que permite coordinarlo con otros menús. +
+ + Demo + + + + + + + Uso + Importa el componente desde el paquete de ui-kit: + + + Uso básico + + Ejemplo mínimo con estado interno (el componente maneja su propio open/close): + + console.log('logout') }, +] + +`} + /> + + Uso controlado + + Pasa isOpen, onOpen y onClose para controlar el + estado externamente, útil cuando hay otros menús que deben cerrarse al abrir éste: + + setIsOpen(true)} + onClose={() => setIsOpen(false)} +/>`} + /> + + Props + El componente acepta los siguientes props: + void // Función a ejecutar (si es un botón) +} + +interface AccesibleProfileMenuProps { + userInfo: { + name: string // Nombre del usuario (se muestra en el header del menú) + id: number // ID del usuario + picture: string // URL del avatar del usuario + } + menuItems: ProfileMenuItem[] // Lista de items del menú (links o acciones) + + // Control externo del estado (opcional) + isOpen?: boolean + onOpen?: () => void + onClose?: () => void +}`} + /> + + + ) +} + +export default UserDropdownPage diff --git a/src/documentation/utils/routes.tsx b/src/documentation/utils/routes.tsx index c227301c8..41f1ce038 100644 --- a/src/documentation/utils/routes.tsx +++ b/src/documentation/utils/routes.tsx @@ -18,6 +18,7 @@ const Ripples = React.lazy(async () => await import('../pages/Atoms/Ripples')) const TinyAlert = React.lazy(async () => await import('../pages/Atoms/TinyAlert')) /** MOLECULES */ +const Avatar = React.lazy(async () => await import('../pages/Molecules/Avatar')) const Buttons = React.lazy(async () => await import('../pages/Molecules/Buttons')) const Tooltip = React.lazy(async () => await import('../pages/Molecules/Tooltip')) const UserWay = React.lazy(async () => await import('../pages/Molecules/UserWay')) @@ -34,6 +35,7 @@ const Modals = React.lazy(async () => await import('../pages/Organisms/Modals')) const Events = React.lazy(async () => await import('../pages/Organisms/Events')) const EventsList = React.lazy(async () => await import('../pages/Organisms/EventsList')) const Resources = React.lazy(async () => await import('../pages/Organisms/Resources')) +const UserDropdown = React.lazy(async () => await import('../pages/Organisms/UserDropdown')) /** * Rutas que tiene el proyecto con el respectivo link en la navegación. @@ -125,6 +127,11 @@ export const routes: IRoute[] = [ label: 'Molecules', }, /** ****************************** */ + { + path: '/molecules/avatar', + label: 'Avatar', + component: , + }, { path: '/molecules/buttons', label: 'Buttons', @@ -155,6 +162,11 @@ export const routes: IRoute[] = [ label: 'Calendar Dropdown', component: , }, + { + path: '/organisms/userdropdown', + label: 'User Dropdown', + component: , + }, { path: '/organisms/courselist', label: 'Course List', diff --git a/src/index.ts b/src/index.ts index c8ebdb760..07226268d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,8 @@ export { ModalAlert } from './organisms/ModalAlert' export { Eventos } from './organisms/Events' export { Resources } from './organisms/Resources' export { CalendarDropdown, EventsList } from './organisms/Calendar' +export { UserDropdownMenu } from './organisms/User' +export type { AccesibleProfileMenuProps, ProfileMenuItem } from './organisms/User' // Tema export { theme, vars } from './theme' diff --git a/src/molecules/Avatar/Avatar.tsx b/src/molecules/Avatar/Avatar.tsx new file mode 100644 index 000000000..b8d0acc1a --- /dev/null +++ b/src/molecules/Avatar/Avatar.tsx @@ -0,0 +1,125 @@ +import * as React from 'react' +import { useState, useEffect } from 'react' +import { AvatarProps } from './Avatar.types' + +/** Los mismos 10 colores que usaba la v8, determinados por el último dígito del userId */ +const AVATAR_COLORS = [ + '#0076ba', + '#229f9c', + '#2cb26b', + '#30baed', + '#95569e', + '#9bc03a', + '#d84315', + '#f5a623', + '#f6712f', + '#ff6363', +] + +const getColorByUserId = (userId: number): string => { + if (userId >= 0) { + const digits = userId.toString().split('') + const position = parseInt(digits[digits.length - 1], 10) + return AVATAR_COLORS[position] + } + return AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)] +} + +const getInitials = (fullName: string): string => + fullName + .split(' ') + .map((word) => word[0]) + .slice(0, 2) + .join('') + .toUpperCase() + +/** + * Muestra la foto de perfil del usuario o un placeholder circular con sus iniciales. + * El color de fondo es determinista según el `userId`. + */ +export const Avatar = ({ + fullName, + userId, + picture, + size = 50, + fontSize = 14, + alt = 'Avatar', + id, + borderDecoration = false, +}: AvatarProps): React.ReactElement => { + const [bgColor, setBgColor] = useState('') + const [imageValid, setImageValid] = useState(false) + + useEffect(() => { + setBgColor(getColorByUserId(userId)) + }, [userId]) + + const wrapperStyle: React.CSSProperties = { + alignItems: 'center', + backgroundColor: fullName?.length === 0 ? '#fff' : bgColor, + border: borderDecoration ? `3px solid #0189FF` : 'none', + borderRadius: '50%', + display: 'flex', + flexShrink: 0, + height: `${size}px`, + justifyContent: 'center', + margin: 0, + overflow: 'hidden', + width: `${size}px`, + } + + const initialsStyle: React.CSSProperties = { + color: '#fff', + fontFamily: 'Roboto, sans-serif', + fontSize: `${fontSize}px`, + fontWeight: 'bold', + lineHeight: '30px', + textTransform: 'uppercase', + } + + const imgStyle: React.CSSProperties = { + height: 'auto', + maxWidth: '100%', + } + + // Sin nombre: no hay nada que mostrar + if (!fullName || fullName.length === 0) { + return