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
+ }
+
+ // Con foto: se intenta cargar la imagen; si falla, cae en iniciales
+ if (picture) {
+ return (
+
+ {/* La imagen se carga en background; si no es válida, mostramos iniciales */}
+ {!imageValid && (
+
+ {getInitials(fullName)}
+
+ )}
+ setImageValid(true)}
+ onError={() => setImageValid(false)}
+ />
+
+ )
+ }
+
+ // Sin foto: sólo iniciales
+ return (
+
+ {bgColor ? (
+
+ {getInitials(fullName)}
+
+ ) : null}
+
+ )
+}
+
+Avatar.displayName = 'Avatar'
diff --git a/src/molecules/Avatar/Avatar.types.ts b/src/molecules/Avatar/Avatar.types.ts
new file mode 100644
index 000000000..f884f284a
--- /dev/null
+++ b/src/molecules/Avatar/Avatar.types.ts
@@ -0,0 +1,18 @@
+export interface AvatarProps {
+ /** Nombre completo del usuario (se usan las primeras 2 iniciales como fallback) */
+ fullName: string
+ /** ID del usuario (determina el color de fondo de forma determinista) */
+ userId: number
+ /** URL de la foto de perfil (opcional) */
+ picture?: string
+ /** Diámetro en píxeles */
+ size?: number
+ /** Tamaño de fuente en píxeles para las iniciales */
+ fontSize?: number
+ /** Descripción accesible de la imagen */
+ alt?: string
+ /** ID del elemento raíz */
+ id?: string
+ /** Borde decorativo usando el color primario del tema */
+ borderDecoration?: boolean
+}
diff --git a/src/molecules/Avatar/index.ts b/src/molecules/Avatar/index.ts
new file mode 100644
index 000000000..53b285acd
--- /dev/null
+++ b/src/molecules/Avatar/index.ts
@@ -0,0 +1,2 @@
+export { Avatar } from './Avatar'
+export type { AvatarProps } from './Avatar.types'
diff --git a/src/molecules/index.ts b/src/molecules/index.ts
index 3d36d9a8a..3ff5c001a 100644
--- a/src/molecules/index.ts
+++ b/src/molecules/index.ts
@@ -7,3 +7,4 @@ export { NavBarButton } from './NavBarButtons/NavBarButton'
export { NewTooltip } from './Tooltip/NewTooltip'
export { UserWay } from './UserWay/UserWay'
export { UserWayCookie } from './UserWay/UserWayCookie'
+export { Avatar } from './Avatar'
diff --git a/src/organisms/Alerts/FlashNotification.tsx b/src/organisms/Alerts/FlashNotification.tsx
index 139a56558..26b7d1c91 100644
--- a/src/organisms/Alerts/FlashNotification.tsx
+++ b/src/organisms/Alerts/FlashNotification.tsx
@@ -74,7 +74,7 @@ export function FlashNotification({
id: alertStates[state].id,
}
)
- }, [message, state, m])
+ }, [message, state, m, isMobile])
useEffect(() => {
if (show) {
diff --git a/src/organisms/User/Dropdown/Icons/AcademicHistorialIcon.tsx b/src/organisms/User/Dropdown/Icons/AcademicHistorialIcon.tsx
new file mode 100644
index 000000000..93f867d25
--- /dev/null
+++ b/src/organisms/User/Dropdown/Icons/AcademicHistorialIcon.tsx
@@ -0,0 +1,33 @@
+export const AcademicHistorialIcon = (): JSX.Element => (
+
+)
diff --git a/src/organisms/User/Dropdown/Icons/CalendarIcon.tsx b/src/organisms/User/Dropdown/Icons/CalendarIcon.tsx
new file mode 100644
index 000000000..9d91f08c4
--- /dev/null
+++ b/src/organisms/User/Dropdown/Icons/CalendarIcon.tsx
@@ -0,0 +1,29 @@
+export const CalendarIcon = (): JSX.Element => {
+ return (
+
+ )
+}
diff --git a/src/organisms/User/Dropdown/Icons/CoursesIcon.tsx b/src/organisms/User/Dropdown/Icons/CoursesIcon.tsx
new file mode 100644
index 000000000..f5c430a57
--- /dev/null
+++ b/src/organisms/User/Dropdown/Icons/CoursesIcon.tsx
@@ -0,0 +1,51 @@
+export const CoursesIcon = (): JSX.Element => (
+
+)
diff --git a/src/organisms/User/Dropdown/Icons/ProfileIcon.tsx b/src/organisms/User/Dropdown/Icons/ProfileIcon.tsx
new file mode 100644
index 000000000..1bd0ccb42
--- /dev/null
+++ b/src/organisms/User/Dropdown/Icons/ProfileIcon.tsx
@@ -0,0 +1,43 @@
+export const ProfileIcon = (): JSX.Element => (
+
+)
diff --git a/src/organisms/User/Dropdown/hooks/useHideBackgroundOnMobile.ts b/src/organisms/User/Dropdown/hooks/useHideBackgroundOnMobile.ts
new file mode 100644
index 000000000..a7dc375be
--- /dev/null
+++ b/src/organisms/User/Dropdown/hooks/useHideBackgroundOnMobile.ts
@@ -0,0 +1,64 @@
+import { useEffect } from 'react'
+import { useMediaQuery } from '@chakra-ui/react'
+
+/**
+ * Hook para ocultar el contenido de fondo cuando un menú está abierto en mobile.
+ * Implementa la lógica específica de la v8 (ocultar #ViewContainer o .main)
+ * con un breakpoint de 480px.
+ *
+ * @param isOpen - Estado de apertura del menú
+ * @returns boolean - Indica si el dispositivo actual es considerado mobile (<= 480px)
+ */
+export const useHideBackgroundOnMobile = (isOpen: boolean): boolean => {
+ const [isMobile] = useMediaQuery('(max-width: 480px)')
+
+ useEffect(() => {
+ if (!isMobile) return
+
+ const viewContainer = document.getElementById('ViewContainer')
+
+ if (viewContainer) {
+ // Caso v8: se usa el ID del contenedor principal
+ viewContainer.style.display = isOpen ? 'none' : ''
+ } else {
+ // Caso CV: lógica de respaldo buscando elementos en .main
+ const mainElement = document.querySelector('.main')
+ if (!mainElement) return
+
+ const firstChild = mainElement.firstElementChild
+ if (!firstChild || !(firstChild instanceof HTMLElement)) return
+
+ const headerElement = firstChild.querySelector('header.header')
+ if (!headerElement) return
+
+ const targetElement = headerElement.nextElementSibling
+ if (!targetElement || !(targetElement instanceof HTMLElement)) return
+
+ targetElement.style.display = isOpen ? 'none' : ''
+ }
+
+ // Cleanup: restaurar display cuando se cierra el menú o cambia el breakpoint
+ return () => {
+ const viewContainer = document.getElementById('ViewContainer')
+ if (viewContainer) {
+ viewContainer.style.display = ''
+ } else {
+ const mainElement = document.querySelector('.main')
+ if (mainElement) {
+ const firstChild = mainElement.firstElementChild
+ if (firstChild instanceof HTMLElement) {
+ const headerElement = firstChild.querySelector('header.header')
+ if (headerElement) {
+ const targetElement = headerElement.nextElementSibling
+ if (targetElement instanceof HTMLElement) {
+ targetElement.style.display = ''
+ }
+ }
+ }
+ }
+ }
+ }
+ }, [isMobile, isOpen])
+
+ return isMobile
+}
diff --git a/src/organisms/User/Dropdown/hooks/useTooltipToggleDelay.ts b/src/organisms/User/Dropdown/hooks/useTooltipToggleDelay.ts
new file mode 100644
index 000000000..16cc6241a
--- /dev/null
+++ b/src/organisms/User/Dropdown/hooks/useTooltipToggleDelay.ts
@@ -0,0 +1,16 @@
+import { useEffect } from 'react'
+
+export function useTooltipToggleDelay(
+ isMenuOpen: boolean,
+ setTooltipDisabled: (disabled: boolean) => void,
+ delay = 300
+): void {
+ useEffect(() => {
+ if (isMenuOpen) {
+ setTooltipDisabled(true)
+ } else {
+ const timer = setTimeout(() => setTooltipDisabled(false), delay)
+ return () => clearTimeout(timer)
+ }
+ }, [isMenuOpen, setTooltipDisabled, delay])
+}
diff --git a/src/organisms/User/Dropdown/utils/focusMenuWithKeys.ts b/src/organisms/User/Dropdown/utils/focusMenuWithKeys.ts
new file mode 100644
index 000000000..cd16abb73
--- /dev/null
+++ b/src/organisms/User/Dropdown/utils/focusMenuWithKeys.ts
@@ -0,0 +1,49 @@
+export function focusMenuWithKeys(
+ event: React.KeyboardEvent,
+ menuListRef: React.RefObject
+): void {
+ if (!menuListRef.current) return
+
+ const focusableItems = Array.from(
+ menuListRef.current.querySelectorAll('[role="menuitem"]:not([disabled])')
+ )
+
+ if (!focusableItems.length) return
+
+ const currentIndex = focusableItems.findIndex((item) => item === document.activeElement)
+ const firstIndex = 0
+ const lastIndex = focusableItems.length - 1
+
+ let nextIndex = -1
+
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ nextIndex = currentIndex === -1 ? firstIndex : (currentIndex + 1) % focusableItems.length
+ }
+
+ if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ nextIndex =
+ currentIndex === -1
+ ? lastIndex
+ : (currentIndex - 1 + focusableItems.length) % focusableItems.length
+ }
+
+ if (event.key === 'Tab') {
+ if (event.shiftKey) {
+ if (currentIndex > firstIndex) {
+ event.preventDefault()
+ nextIndex = currentIndex - 1
+ }
+ } else {
+ if (currentIndex < lastIndex) {
+ event.preventDefault()
+ nextIndex = currentIndex + 1
+ }
+ }
+ }
+
+ if (nextIndex >= 0) {
+ focusableItems[nextIndex].focus()
+ }
+}
diff --git a/src/organisms/User/Dropdown/utils/getIconToRender.tsx b/src/organisms/User/Dropdown/utils/getIconToRender.tsx
new file mode 100644
index 000000000..f9beefa7d
--- /dev/null
+++ b/src/organisms/User/Dropdown/utils/getIconToRender.tsx
@@ -0,0 +1,29 @@
+import * as React from 'react'
+import { CoursesIcon } from '../Icons/CoursesIcon'
+import { AcademicHistorialIcon } from '../Icons/AcademicHistorialIcon'
+import { ProfileIcon } from '../Icons/ProfileIcon'
+import { CalendarIcon } from '../Icons/CalendarIcon'
+
+export type ProfileMenuItemType = 'courses' | 'calendar' | 'academic-history' | 'profile' | 'logout'
+
+export const getMenuItemIcon = (
+ type?: ProfileMenuItemType,
+ customIcon?: React.ReactNode
+): React.ReactNode => {
+ if (customIcon) return customIcon
+
+ switch (type) {
+ case 'courses':
+ return
+ case 'calendar':
+ return
+ case 'academic-history':
+ return
+ case 'profile':
+ return
+ case 'logout':
+ return null
+ default:
+ return null
+ }
+}
diff --git a/src/organisms/User/UserDropdownMenu.tsx b/src/organisms/User/UserDropdownMenu.tsx
new file mode 100644
index 000000000..04c5bece3
--- /dev/null
+++ b/src/organisms/User/UserDropdownMenu.tsx
@@ -0,0 +1,198 @@
+import * as React from 'react'
+import { useRef, useState } from 'react'
+import {
+ Box,
+ Heading,
+ Link,
+ Menu,
+ MenuButton,
+ MenuItem,
+ MenuList,
+ MenuListProps,
+ MenuItemProps,
+ HeadingProps,
+ MenuButtonProps,
+ LinkProps,
+} from '@chakra-ui/react'
+
+import { Avatar, NewTooltip } from '@/molecules'
+import { vars } from '@/theme'
+import { profileStyle } from './menuProfileStyle'
+import { focusMenuWithKeys } from './Dropdown/utils/focusMenuWithKeys'
+import { useTooltipToggleDelay } from './Dropdown/hooks/useTooltipToggleDelay'
+import { useHideBackgroundOnMobile } from './Dropdown/hooks/useHideBackgroundOnMobile'
+import { getMenuItemIcon, ProfileMenuItemType } from './Dropdown/utils/getIconToRender'
+
+export type { ProfileMenuItemType }
+
+export interface ProfileMenuItem {
+ label: string
+ icon?: React.ReactNode
+ type?: ProfileMenuItemType
+ href?: string
+ onClick?: () => void
+}
+
+export interface AccesibleProfileMenuProps {
+ /** Nombre del usuario a mostrar en la cabecera del menú */
+ userInfo: {
+ name: string
+ id: number
+ picture: string
+ }
+ /** Items del menú de navegación */
+ menuItems: ProfileMenuItem[]
+ /** Control externo del menú (opcional, para coordinar con otros menús) */
+ isOpen?: boolean
+ onOpen?: () => void
+ onClose?: () => void
+}
+
+const StyledMenuButton = MenuButton as React.FC
+const StyledMenuList = MenuList as React.ForwardRefExoticComponent<
+ MenuListProps & React.RefAttributes
+>
+const StyledHeading = Heading as React.FC
+const StyledMenuItem = MenuItem as React.FC
+
+export const UserDropdownMenu = ({
+ userInfo,
+ menuItems,
+ isOpen: controlledIsOpen,
+ onOpen: controlledOnOpen,
+ onClose: controlledOnClose,
+}: AccesibleProfileMenuProps): React.ReactElement => {
+ const [internalIsOpen, setInternalIsOpen] = useState(false)
+ const isOpen = controlledIsOpen ?? internalIsOpen
+ const onOpen = controlledOnOpen ?? (() => setInternalIsOpen(true))
+ const onClose = controlledOnClose ?? (() => setInternalIsOpen(false))
+
+ const isMobile = useHideBackgroundOnMobile(isOpen)
+ const [isTooltipDisabled, setTooltipDisabled] = useState(false)
+
+ useTooltipToggleDelay(isOpen, setTooltipDisabled)
+ const menuListRef = useRef(null)
+
+ const handleArrowKeyFocus = (event: React.KeyboardEvent): void => {
+ focusMenuWithKeys(event, menuListRef)
+ }
+
+ return (
+ button': {
+ borderBottom: isMobile ? `solid 1px ${vars('colors-neutral-platinum')}` : 'none',
+ },
+ },
+ '.css-r6z5ec': {
+ zIndex: '4',
+ },
+ }}
+ >
+
+
+ )
+}
diff --git a/src/organisms/User/index.ts b/src/organisms/User/index.ts
new file mode 100644
index 000000000..b40e81cb3
--- /dev/null
+++ b/src/organisms/User/index.ts
@@ -0,0 +1 @@
+export * from './UserDropdownMenu'
diff --git a/src/organisms/User/menuProfileStyle.ts b/src/organisms/User/menuProfileStyle.ts
new file mode 100644
index 000000000..9a96e5626
--- /dev/null
+++ b/src/organisms/User/menuProfileStyle.ts
@@ -0,0 +1,50 @@
+import { vars } from '@/theme'
+
+export const profileStyle = {
+ header: {
+ background: vars('colors-neutral-davysGrey'),
+ fontFamily: 'Roboto',
+ fontSize: '15px',
+ fontWeight: '500',
+ margin: '0',
+ padding: '0 20px 15px',
+ color: vars('colors-neutral-white'),
+ },
+ logout: {
+ background: vars('colors-neutral-white'),
+ border: 'none',
+ color: vars('colors-alert-deepSkyBlue'),
+ fontFamily: 'Roboto',
+ fontSize: '14px',
+ fontWeight: '500',
+ justifyContent: 'center',
+ padding: '14px 20px',
+ width: '-webkit-fill-available',
+ _hover: {
+ background: vars('colors-neutral-cultured'),
+ MenuItemShadow: 'none',
+ boxShadow: 'none',
+ },
+ _focus: {
+ boxShadow: `0 0 0 3px ${vars('colors-icon-deepSkyBlue')} inset`,
+ },
+ },
+
+ items: {
+ background: vars('colors-neutral-white'),
+ borderBottom: `solid 1px ${vars('colors-neutral-platinum')}`,
+ color: vars('colors-neutral-spanishGrey'),
+ fontWeight: '500',
+ fontSize: '12px',
+ textTransform: 'uppercase',
+ _focus: {
+ boxShadow: `0 0 0 3px ${vars('colors-icon-deepSkyBlue')} inset`,
+ },
+ _hover: {
+ background: vars('colors-neutral-cultured'),
+ boxShadow: 'none',
+ color: vars('colors-neutral-spanishGrey'),
+ textDecoration: 'none',
+ },
+ },
+}
diff --git a/src/organisms/index.ts b/src/organisms/index.ts
index 65b8db796..04530798e 100644
--- a/src/organisms/index.ts
+++ b/src/organisms/index.ts
@@ -5,3 +5,4 @@ export * from './Events'
export * from './Resources'
export * from './Calendar'
export * from './Modals'
+export * from './User'