@ceedcv-maya/shared-layout-react 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maya-AQSS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @ceedcv-maya/shared-layout-react
2
+
3
+ App layout primitives for React: AppShell, useDarkMode, Sidebar slot, header/topbar widgets — wired to shared-auth/UI by props.
4
+
5
+ Part of the [ceedcv-maya/maya_platform](https://github.com/Maya-AQSS/maya_platform) mono-repo. Distributed independently for reuse outside the Maya ecosystem.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @ceedcv-maya/shared-layout-react @ceedcv-maya/shared-auth-react @ceedcv-maya/shared-ui-react
11
+ ```
12
+
13
+ ```tsx
14
+ import { AppShell, useDarkMode } from '@ceedcv-maya/shared-layout-react'
15
+
16
+ export function Layout({ children }) {
17
+ return <AppShell sidebar={<MySidebar />} topbar={<MyTopbar />}>{children}</AppShell>
18
+ }
19
+ ```
20
+
21
+
22
+ ## Peer dependencies
23
+
24
+ This package expects the following sibling packages to be installed by the consumer:
25
+
26
+ - `@ceedcv-maya/shared-auth-react`
27
+ - `@ceedcv-maya/shared-ui-react`
28
+
29
+ ## TypeScript / build notes
30
+ This package ships TypeScript source (`src/index.ts` as entry). Consumers using Vite or Webpack with `ts-loader` work out of the box. Next.js consumers must add this package to `transpilePackages` in `next.config.js`.
31
+
32
+ ## License
33
+
34
+ MIT — see [LICENSE](LICENSE).
35
+
36
+ ## Reporting issues
37
+
38
+ The canonical source lives in [Maya-AQSS/maya_platform](https://github.com/Maya-AQSS/maya_platform). File issues there; this read-only split repo is only the published artifact.
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@ceedcv-maya/shared-layout-react",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "peerDependencies": {
8
+ "react": "^18.0.0 || ^19.0.0",
9
+ "react-dom": "^18.0.0 || ^19.0.0",
10
+ "react-router-dom": "^6.0.0 || ^7.0.0",
11
+ "react-i18next": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0",
12
+ "@ceedcv-maya/shared-auth-react": "^0.2.0",
13
+ "@ceedcv-maya/shared-ui-react": "^0.2.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/react": "^19.0.0",
17
+ "@types/react-dom": "^19.0.0",
18
+ "react": "^19.0.0",
19
+ "react-dom": "^19.0.0",
20
+ "react-router-dom": "^7.0.0",
21
+ "react-i18next": "^17.0.0",
22
+ "i18next": "^26.0.0",
23
+ "@types/react-router-dom": "^5.3.3"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc --noEmit",
27
+ "test": "echo \"no tests yet\" && exit 0",
28
+ "typecheck": "tsc --noEmit",
29
+ "lint": "echo \"no linter configured\""
30
+ },
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/Maya-AQSS/maya_platform.git",
35
+ "directory": "packages/js/shared-layout-react"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "description": "App layout primitives for React: AppShell, useDarkMode, Sidebar slot, header/topbar widgets \u2014 wired to shared-auth/UI by props.",
41
+ "keywords": [
42
+ "react",
43
+ "layout",
44
+ "appshell",
45
+ "sidebar",
46
+ "darkmode",
47
+ "ceedcv",
48
+ "maya"
49
+ ],
50
+ "author": {
51
+ "name": "CEEDCV",
52
+ "email": "info@ceedcv.es",
53
+ "homepage": "https://ceedcv.es"
54
+ },
55
+ "homepage": "https://github.com/Maya-AQSS/shared-layout-react#readme",
56
+ "bugs": {
57
+ "url": "https://github.com/Maya-AQSS/maya_platform/issues"
58
+ },
59
+ "sideEffects": false,
60
+ "files": [
61
+ "src",
62
+ "LICENSE",
63
+ "README.md",
64
+ "tsconfig.json"
65
+ ]
66
+ }
@@ -0,0 +1,135 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useState } from 'react'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { Outlet } from 'react-router-dom'
5
+ import { Sidebar } from './Sidebar'
6
+ import { useDarkMode } from './useDarkMode'
7
+ import { HamburgerIcon } from './navIcons'
8
+ import type { NavItem } from './types'
9
+
10
+ type AppLayoutProps = {
11
+ navItems: NavItem[]
12
+ brandName: string
13
+ brandVersion?: string
14
+ /** Imagen del logo de la app (opcional). Si no se aporta, usa logo Maya. */
15
+ brandLogoUrl?: string
16
+
17
+ // Datos de usuario (van al fondo del sidebar)
18
+ userName: string
19
+ userEmail?: string
20
+ userInitials: string
21
+ userAvatarUrl?: string | null
22
+ onLogout: () => void
23
+ onProfile?: () => void
24
+
25
+ // Slots opcionales en el footer del sidebar
26
+ /** Bloque de favoritos (típicamente <SidebarFavorites />). */
27
+ favoritesSlot?: ReactNode
28
+ /** Notificaciones (típicamente <NotificationsBell />). */
29
+ notificationsSlot?: ReactNode
30
+
31
+ /** Si se aporta, renderiza children en lugar de <Outlet />. */
32
+ children?: ReactNode
33
+ }
34
+
35
+ /**
36
+ * Layout principal Maya — versión 2026 sin Topbar.
37
+ * - Sidebar con brand, nav, y footer apilado (favoritos / notificaciones / usuario).
38
+ * - El contenido principal renderiza directamente las rutas; cada página gestiona
39
+ * su propio `<PageTitle>` (sin header global).
40
+ * - Toggle de tema, perfil y logout viven en el SidebarUserBlock al fondo del sidebar.
41
+ *
42
+ * Skip-link `#main-content` para WCAG 2.4.1 (Bypass Blocks).
43
+ */
44
+ export function AppLayout({
45
+ navItems,
46
+ brandName,
47
+ brandVersion,
48
+ brandLogoUrl,
49
+ userName,
50
+ userEmail,
51
+ userInitials,
52
+ userAvatarUrl,
53
+ onLogout,
54
+ onProfile,
55
+ favoritesSlot,
56
+ notificationsSlot,
57
+ children,
58
+ }: AppLayoutProps) {
59
+ const { t } = useTranslation('common')
60
+ const { isDark, toggle: handleToggleDark } = useDarkMode()
61
+ const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
62
+ const [mobileOpen, setMobileOpen] = useState(false)
63
+
64
+ // Exponemos el ancho efectivo del sidebar como CSS variable para que
65
+ // overlays/modales descendientes puedan posicionarse al lado del aside
66
+ // sin cubrirlo. Se actualiza con la transición y respeta el modo móvil.
67
+ const sidebarWidthDesktop = sidebarCollapsed ? '4.25rem' : '17rem'
68
+
69
+ return (
70
+ <div
71
+ className="min-h-screen bg-app-gradient font-sans"
72
+ style={{ ['--sidebar-w' as string]: sidebarWidthDesktop }}
73
+ >
74
+ {/* Skip-link: visible solo al recibir foco con Tab. WCAG 2.4.1. */}
75
+ <a
76
+ href="#main-content"
77
+ className="
78
+ sr-only focus:not-sr-only
79
+ focus:fixed focus:top-2 focus:left-2 focus:z-[var(--z-index-toast,500)]
80
+ focus:px-3 focus:py-2 focus:rounded-md
81
+ focus:bg-gradient-primary focus:text-text-inverse
82
+ focus:shadow-card-md focus:outline-none focus:ring-2 focus:ring-odoo-purple/35
83
+ text-sm font-semibold
84
+ "
85
+ >
86
+ {t('layout.skipToContent', { defaultValue: 'Skip to main content' })}
87
+ </a>
88
+
89
+ <Sidebar
90
+ navItems={navItems}
91
+ brandName={brandName}
92
+ brandVersion={brandVersion}
93
+ brandLogoUrl={brandLogoUrl}
94
+ collapsed={sidebarCollapsed}
95
+ onToggle={() => setSidebarCollapsed((prev) => !prev)}
96
+ mobileOpen={mobileOpen}
97
+ onMobileClose={() => setMobileOpen(false)}
98
+ favoritesSlot={favoritesSlot}
99
+ notificationsSlot={notificationsSlot}
100
+ userName={userName}
101
+ userEmail={userEmail}
102
+ userInitials={userInitials}
103
+ userAvatarUrl={userAvatarUrl}
104
+ onLogout={onLogout}
105
+ onProfile={onProfile}
106
+ isDark={isDark}
107
+ onToggleDark={handleToggleDark}
108
+ />
109
+
110
+ {/* Botón hamburguesa flotante en móvil (sin topbar). */}
111
+ <button
112
+ type="button"
113
+ onClick={() => setMobileOpen(true)}
114
+ aria-label={t('layout.openSidebarMenu', { defaultValue: 'Open side menu' })}
115
+ className="md:hidden fixed top-4 left-4 z-[90] inline-flex items-center justify-center w-10 h-10 rounded-full bg-gradient-primary text-text-inverse shadow-[0_4px_14px_-4px_rgba(113,75,103,0.55)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-odoo-purple/35"
116
+ >
117
+ <HamburgerIcon />
118
+ </button>
119
+
120
+ <div
121
+ className={`flex flex-col min-h-screen transition-[margin] duration-200 ${
122
+ sidebarCollapsed ? 'md:ml-[4.25rem]' : 'md:ml-[17rem]'
123
+ }`}
124
+ >
125
+ <main
126
+ id="main-content"
127
+ tabIndex={-1}
128
+ className="flex-1 p-4 sm:p-6 md:p-8 overflow-x-clip focus-visible:outline-none"
129
+ >
130
+ {children ?? <Outlet />}
131
+ </main>
132
+ </div>
133
+ </div>
134
+ )
135
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Logo Maya por defecto — círculo con gradiente y "M" en blanco.
3
+ * Sirve como fallback cuando el consumidor no aporta `brandLogoUrl`.
4
+ */
5
+ export function MayaLogoIcon({ size = 32 }: { size?: number }) {
6
+ return (
7
+ <svg
8
+ width={size}
9
+ height={size}
10
+ viewBox="0 0 32 32"
11
+ role="img"
12
+ aria-label="Maya"
13
+ xmlns="http://www.w3.org/2000/svg"
14
+ >
15
+ <defs>
16
+ <linearGradient id="maya-logo-grad" x1="0%" y1="0%" x2="100%" y2="100%">
17
+ <stop offset="0%" stopColor="#714B67" />
18
+ <stop offset="55%" stopColor="#5A3A52" />
19
+ <stop offset="100%" stopColor="#017E84" />
20
+ </linearGradient>
21
+ </defs>
22
+ <rect x="0" y="0" width="32" height="32" rx="9" fill="url(#maya-logo-grad)" />
23
+ <path
24
+ d="M8.5 22.5V10.5h2.6l4.4 7.6 4.4-7.6h2.6v12h-2.4v-7.5l-3.6 6.1h-2l-3.6-6.1v7.5z"
25
+ fill="#FFFFFF"
26
+ fillOpacity="0.96"
27
+ />
28
+ </svg>
29
+ )
30
+ }
@@ -0,0 +1,226 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import { NavLink } from 'react-router-dom'
4
+ import { SidebarUserBlock } from './SidebarUserBlock'
5
+ import { MayaLogoIcon } from './MayaLogoIcon'
6
+ import { SidebarCollapsedProvider } from './SidebarCollapsedContext'
7
+ import type { NavItem } from './types'
8
+
9
+ interface SidebarProps {
10
+ navItems: NavItem[]
11
+ brandName: string
12
+ brandVersion?: string
13
+ /** URL de imagen del logo. Si no se aporta, se usa `<MayaLogoIcon />`. */
14
+ brandLogoUrl?: string
15
+ collapsed: boolean
16
+ onToggle: () => void
17
+ mobileOpen: boolean
18
+ onMobileClose: () => void
19
+
20
+ // Footer en orden vertical: favoritos → user block
21
+ favoritesSlot?: ReactNode
22
+ /** Notificaciones — se ubican en el header (top-right). */
23
+ notificationsSlot?: ReactNode
24
+
25
+ // Datos para el bloque de usuario
26
+ userName: string
27
+ userEmail?: string
28
+ userInitials: string
29
+ userAvatarUrl?: string | null
30
+ onLogout: () => void
31
+ onProfile?: () => void
32
+
33
+ // Toggle dark mode (movido del Topbar al UserBlock)
34
+ isDark: boolean
35
+ onToggleDark: () => void
36
+ }
37
+
38
+ function ChevronIcon({ collapsed }: { collapsed: boolean }) {
39
+ return (
40
+ <svg
41
+ className={`w-3.5 h-3.5 transition-transform duration-200 ${collapsed ? 'rotate-180' : ''}`}
42
+ fill="none"
43
+ stroke="currentColor"
44
+ viewBox="0 0 24 24"
45
+ aria-hidden="true"
46
+ >
47
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M15 19l-7-7 7-7" />
48
+ </svg>
49
+ )
50
+ }
51
+
52
+ export function Sidebar({
53
+ navItems,
54
+ brandName,
55
+ brandVersion,
56
+ brandLogoUrl,
57
+ collapsed,
58
+ onToggle,
59
+ mobileOpen,
60
+ onMobileClose,
61
+ favoritesSlot,
62
+ notificationsSlot,
63
+ userName,
64
+ userEmail,
65
+ userInitials,
66
+ userAvatarUrl,
67
+ onLogout,
68
+ onProfile,
69
+ isDark,
70
+ onToggleDark,
71
+ }: SidebarProps) {
72
+ const { t } = useTranslation('common')
73
+ const effectiveCollapsed = collapsed && !mobileOpen
74
+
75
+ return (
76
+ <>
77
+ {mobileOpen && (
78
+ <div
79
+ className="fixed inset-0 bg-black/50 z-[99] md:hidden"
80
+ onClick={onMobileClose}
81
+ aria-hidden="true"
82
+ />
83
+ )}
84
+
85
+ <aside
86
+ className={[
87
+ 'fixed inset-y-0 left-0 bg-sidebar-gradient text-text-inverse',
88
+ 'flex flex-col z-[100] border-r border-text-inverse/8',
89
+ mobileOpen
90
+ ? 'translate-x-0 pointer-events-auto'
91
+ : '-translate-x-full md:translate-x-0 pointer-events-none md:pointer-events-auto',
92
+ ].join(' ')}
93
+ style={{
94
+ width: effectiveCollapsed ? '4.25rem' : '17rem',
95
+ transition: 'width 200ms ease, transform 200ms ease',
96
+ }}
97
+ >
98
+ {/* ── Header: logo + brand + (notificaciones top-right) ── */}
99
+ <div
100
+ className={[
101
+ 'h-16 flex items-center border-b border-text-inverse/8 shrink-0',
102
+ effectiveCollapsed ? 'justify-center px-2' : 'gap-2.5 px-4',
103
+ ].join(' ')}
104
+ >
105
+ <span className="shrink-0 flex items-center justify-center">
106
+ {brandLogoUrl ? (
107
+ <img
108
+ src={brandLogoUrl}
109
+ alt={brandName}
110
+ className="w-9 h-9 object-contain"
111
+ />
112
+ ) : (
113
+ <MayaLogoIcon size={36} />
114
+ )}
115
+ </span>
116
+
117
+ {!effectiveCollapsed && (
118
+ <span className="flex-1 min-w-0 text-base font-display font-bold text-text-inverse tracking-wide truncate">
119
+ {brandName}
120
+ </span>
121
+ )}
122
+
123
+ {!effectiveCollapsed && notificationsSlot ? (
124
+ <div className="shrink-0 ml-auto flex items-center">
125
+ {notificationsSlot}
126
+ </div>
127
+ ) : null}
128
+ </div>
129
+
130
+ {/* Notificaciones flotantes (solo cuando el sidebar está colapsado en
131
+ desktop). Se ubican a la derecha del sidebar para no taparlo.
132
+ Estilo oscuro tipo sidebar para diferenciarse del fondo claro. */}
133
+ {notificationsSlot && effectiveCollapsed ? (
134
+ <div
135
+ className="hidden md:flex fixed top-3 z-[101] items-center justify-center w-11 h-11 rounded-full bg-sidebar-gradient text-text-inverse border border-text-inverse/10 shadow-[0_8px_22px_-8px_rgba(0,0,0,0.45)]"
136
+ style={{ left: 'calc(4.25rem + 0.75rem)' }}
137
+ >
138
+ {notificationsSlot}
139
+ </div>
140
+ ) : null}
141
+
142
+ {/* ── Nav: items + favoritos en línea ── */}
143
+ <nav className="flex-1 py-4 px-2.5 space-y-1 overflow-y-auto overflow-x-hidden">
144
+ {navItems.map((item) => (
145
+ <NavLink
146
+ key={item.id}
147
+ to={item.path}
148
+ title={effectiveCollapsed ? item.label : undefined}
149
+ onClick={(event) => {
150
+ item.onClick?.(event)
151
+ if (mobileOpen && !event.defaultPrevented) onMobileClose()
152
+ }}
153
+ className={({ isActive }: { isActive: boolean }) =>
154
+ [
155
+ 'relative w-full flex items-center rounded-xl text-left text-sm font-medium',
156
+ 'motion-safe:transition-all motion-safe:duration-150',
157
+ 'overflow-hidden whitespace-nowrap py-2.5',
158
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-inverse/30',
159
+ effectiveCollapsed ? 'justify-center px-0' : 'gap-3 px-3.5',
160
+ isActive
161
+ ? 'bg-gradient-primary text-text-inverse shadow-[0_4px_14px_-4px_rgba(113,75,103,0.55)]'
162
+ : 'text-text-inverse/70 hover:bg-text-inverse/8 hover:text-text-inverse',
163
+ ].join(' ')
164
+ }
165
+ >
166
+ <span className="w-6 h-6 flex items-center justify-center shrink-0">
167
+ <item.icon />
168
+ </span>
169
+ {!effectiveCollapsed && <span className="truncate">{item.label}</span>}
170
+ </NavLink>
171
+ ))}
172
+
173
+ {/* Favoritos justo debajo de los enlaces (no pinned al fondo). El
174
+ propio componente devuelve null si no hay favoritos, por lo que
175
+ no se reserva espacio cuando está vacío. En modo colapsado los
176
+ widgets internos (SidebarFavorites, SidebarProcesos) reciben el
177
+ estado vía `useSidebarCollapsed()` para renderizar solo iconos. */}
178
+ <SidebarCollapsedProvider value={effectiveCollapsed}>
179
+ {favoritesSlot}
180
+ </SidebarCollapsedProvider>
181
+ </nav>
182
+
183
+ {/* ── Botón flotante de colapso (estilo Beluga) ── */}
184
+ <button
185
+ type="button"
186
+ onClick={onToggle}
187
+ className={[
188
+ 'hidden md:flex absolute top-20 -right-3 z-[101]',
189
+ 'w-6 h-6 items-center justify-center rounded-full',
190
+ 'bg-ui-card dark:bg-ui-dark-card text-text-secondary dark:text-text-dark-secondary',
191
+ 'border border-ui-border-l dark:border-ui-dark-border shadow-card-md',
192
+ 'hover:text-odoo-purple dark:hover:text-odoo-dark-purple',
193
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-odoo-purple/35',
194
+ 'transition-colors',
195
+ ].join(' ')}
196
+ aria-label={collapsed
197
+ ? t('layout.expandSidebarMenu', { defaultValue: 'Expand side menu' })
198
+ : t('layout.collapseSidebarMenu', { defaultValue: 'Collapse side menu' })}
199
+ >
200
+ <ChevronIcon collapsed={collapsed} />
201
+ </button>
202
+
203
+ {/* ── Footer: solo bloque de usuario ── */}
204
+ <div className="shrink-0">
205
+ <SidebarUserBlock
206
+ userName={userName}
207
+ userEmail={userEmail}
208
+ userInitials={userInitials}
209
+ userAvatarUrl={userAvatarUrl}
210
+ onLogout={onLogout}
211
+ onProfile={onProfile}
212
+ isDark={isDark}
213
+ onToggleDark={onToggleDark}
214
+ collapsed={effectiveCollapsed}
215
+ />
216
+
217
+ {brandVersion && !effectiveCollapsed ? (
218
+ <p className="px-4 pb-3 pt-0.5 text-xs text-text-inverse/35 whitespace-nowrap">
219
+ {brandVersion}
220
+ </p>
221
+ ) : null}
222
+ </div>
223
+ </aside>
224
+ </>
225
+ )
226
+ }
@@ -0,0 +1,18 @@
1
+ import { createContext, useContext } from 'react'
2
+
3
+ /**
4
+ * Estado del sidebar (colapsado vs expandido). Lo provee el `AppLayout`
5
+ * y lo consumen widgets que viven dentro del `favoritesSlot`, como
6
+ * `SidebarFavorites` o el `SidebarProcesos` de DMS, para renderizar
7
+ * en modo icono-only cuando el aside está colapsado.
8
+ *
9
+ * Default `false` (expandido) — si un consumidor olvida envolver con el
10
+ * provider, los componentes seguirán funcionando como antes.
11
+ */
12
+ const SidebarCollapsedContext = createContext<boolean>(false)
13
+
14
+ export const SidebarCollapsedProvider = SidebarCollapsedContext.Provider
15
+
16
+ export function useSidebarCollapsed(): boolean {
17
+ return useContext(SidebarCollapsedContext)
18
+ }
@@ -0,0 +1,276 @@
1
+ import {
2
+ type KeyboardEvent as ReactKeyboardEvent,
3
+ type ReactElement,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ } from 'react'
8
+ import { useTranslation } from 'react-i18next'
9
+ import { Avatar } from '@ceedcv-maya/shared-ui-react'
10
+
11
+ type Props = {
12
+ userName: string
13
+ userEmail?: string
14
+ userInitials: string
15
+ userAvatarUrl?: string | null
16
+ onLogout: () => void
17
+ onProfile?: () => void
18
+ isDark: boolean
19
+ onToggleDark: () => void
20
+ /** Cuando el sidebar está colapsado, muestra solo el avatar. */
21
+ collapsed?: boolean
22
+ }
23
+
24
+ /**
25
+ * Bloque de usuario al fondo del sidebar.
26
+ * - Expandido: avatar + nombre + email; al hacer click abre un menú con
27
+ * tema, perfil y cerrar sesión.
28
+ * - Colapsado: solo avatar; mismo menú al click (se abre a la derecha).
29
+ *
30
+ * Patrón ARIA `menubutton` con flechas, Home/End, Esc y restauración de foco.
31
+ */
32
+ export function SidebarUserBlock({
33
+ userName,
34
+ userEmail,
35
+ userInitials,
36
+ userAvatarUrl,
37
+ onLogout,
38
+ onProfile,
39
+ isDark,
40
+ onToggleDark,
41
+ collapsed = false,
42
+ }: Props) {
43
+ const { t } = useTranslation('common')
44
+ const [open, setOpen] = useState(false)
45
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
46
+ const rootRef = useRef<HTMLDivElement | null>(null)
47
+ const itemsRef = useRef<HTMLButtonElement[]>([])
48
+
49
+ useEffect(() => {
50
+ if (!open) return
51
+ function onClickOutside(e: MouseEvent) {
52
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
53
+ setOpen(false)
54
+ }
55
+ }
56
+ function onKey(e: KeyboardEvent) {
57
+ if (e.key === 'Escape') {
58
+ setOpen(false)
59
+ triggerRef.current?.focus()
60
+ }
61
+ }
62
+ document.addEventListener('mousedown', onClickOutside)
63
+ document.addEventListener('keydown', onKey)
64
+ return () => {
65
+ document.removeEventListener('mousedown', onClickOutside)
66
+ document.removeEventListener('keydown', onKey)
67
+ }
68
+ }, [open])
69
+
70
+ useEffect(() => {
71
+ if (!open) return
72
+ const t = window.setTimeout(() => itemsRef.current[0]?.focus(), 0)
73
+ return () => window.clearTimeout(t)
74
+ }, [open])
75
+
76
+ function onItemKey(e: ReactKeyboardEvent<HTMLButtonElement>, idx: number) {
77
+ const items = itemsRef.current
78
+ if (items.length === 0) return
79
+ if (e.key === 'ArrowDown') {
80
+ e.preventDefault()
81
+ items[(idx + 1) % items.length]?.focus()
82
+ } else if (e.key === 'ArrowUp') {
83
+ e.preventDefault()
84
+ items[(idx - 1 + items.length) % items.length]?.focus()
85
+ } else if (e.key === 'Home') {
86
+ e.preventDefault()
87
+ items[0]?.focus()
88
+ } else if (e.key === 'End') {
89
+ e.preventDefault()
90
+ items[items.length - 1]?.focus()
91
+ }
92
+ }
93
+
94
+ function registerItem(idx: number) {
95
+ return (el: HTMLButtonElement | null) => {
96
+ if (el) itemsRef.current[idx] = el
97
+ else itemsRef.current.splice(idx, 1)
98
+ }
99
+ }
100
+
101
+ // Construye dinámicamente la lista de items para indexar correctamente refs y teclas.
102
+ const menuItems: Array<{
103
+ label: string
104
+ onClick: () => void
105
+ danger?: boolean
106
+ icon: () => ReactElement
107
+ iconClass: string
108
+ }> = []
109
+
110
+ menuItems.push({
111
+ label: isDark ? t('userMenu.lightMode') : t('userMenu.darkMode'),
112
+ onClick: onToggleDark,
113
+ icon: () => (isDark ? <SunIcon /> : <MoonIcon />),
114
+ // Sol amber, luna púrpura — refleja el modo destino al pulsar.
115
+ iconClass: isDark ? 'text-warning' : 'text-odoo-purple dark:text-odoo-dark-purple',
116
+ })
117
+
118
+ if (onProfile) {
119
+ menuItems.push({
120
+ label: t('userMenu.profile'),
121
+ onClick: onProfile,
122
+ icon: () => <ProfileIcon />,
123
+ iconClass: 'text-odoo-teal dark:text-odoo-teal-d',
124
+ })
125
+ }
126
+
127
+ menuItems.push({
128
+ label: t('userMenu.logout'),
129
+ onClick: onLogout,
130
+ danger: true,
131
+ icon: () => <LogoutIcon />,
132
+ iconClass: 'text-danger',
133
+ })
134
+
135
+ // Posicionamiento del menú:
136
+ // - Expandido: pop-up por encima del bloque, alineado a la izquierda del trigger.
137
+ // - Colapsado: pop-out a la derecha del avatar.
138
+ const menuPositionClass = collapsed
139
+ ? 'left-full bottom-0 ml-2'
140
+ : 'bottom-full left-3 right-3 mb-2'
141
+
142
+ return (
143
+ <div ref={rootRef} className="relative border-t border-text-inverse/8 px-3 py-3">
144
+ <button
145
+ ref={triggerRef}
146
+ type="button"
147
+ onClick={() => setOpen((v) => !v)}
148
+ aria-haspopup="menu"
149
+ aria-expanded={open}
150
+ title={userEmail ? `${userName} · ${userEmail}` : userName}
151
+ className={[
152
+ 'w-full rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-inverse/30',
153
+ collapsed
154
+ ? 'flex items-center justify-center'
155
+ : 'flex items-center gap-3 px-2 py-2 text-left hover:bg-text-inverse/5 transition-colors',
156
+ ].join(' ')}
157
+ >
158
+ <Avatar
159
+ src={userAvatarUrl ?? undefined}
160
+ initials={userInitials}
161
+ name={userName}
162
+ size="md"
163
+ interactive={collapsed}
164
+ className="ring-2 ring-text-inverse/10 shrink-0"
165
+ />
166
+ {!collapsed && (
167
+ <>
168
+ <span className="flex-1 min-w-0 text-base font-semibold text-text-inverse truncate">
169
+ {userName}
170
+ </span>
171
+ <ChevronUpIcon
172
+ className={`w-3 h-3 text-text-inverse/50 shrink-0 transition-transform duration-150 ${
173
+ open ? '' : 'rotate-180'
174
+ }`}
175
+ />
176
+ </>
177
+ )}
178
+ </button>
179
+
180
+ {open ? (
181
+ <div
182
+ role="menu"
183
+ aria-label={t('layout.userMenuOf', {
184
+ name: userName,
185
+ defaultValue: `Menu of ${userName}`,
186
+ })}
187
+ className={[
188
+ 'absolute z-[210] min-w-[200px] py-1',
189
+ 'bg-ui-card dark:bg-ui-dark-card border border-ui-border dark:border-ui-dark-border rounded-lg shadow-dropdown',
190
+ menuPositionClass,
191
+ ].join(' ')}
192
+ >
193
+ {/* En modo colapsado el trigger no muestra texto, así que el menú lo
194
+ añade en su cabecera. En expandido el trigger ya muestra el
195
+ nombre y omitimos esta sección para no duplicar. */}
196
+ {collapsed ? (
197
+ <div className="px-3 py-2 border-b border-ui-border-l dark:border-ui-dark-border-l">
198
+ <p className="text-sm font-semibold text-text-primary dark:text-text-dark-primary truncate">
199
+ {userName}
200
+ </p>
201
+ </div>
202
+ ) : null}
203
+ {menuItems.map((item, idx) => (
204
+ <button
205
+ key={item.label}
206
+ ref={registerItem(idx)}
207
+ type="button"
208
+ role="menuitem"
209
+ onClick={() => {
210
+ setOpen(false)
211
+ item.onClick()
212
+ }}
213
+ onKeyDown={(e) => onItemKey(e, idx)}
214
+ className={[
215
+ 'w-full text-left px-3 py-2 text-sm flex items-center gap-2.5',
216
+ 'focus-visible:outline-none transition-colors',
217
+ item.danger
218
+ ? 'text-danger dark:text-danger hover:bg-danger/10 focus-visible:bg-danger/10'
219
+ : 'text-text-primary dark:text-text-dark-primary hover:bg-ui-body dark:hover:bg-ui-dark-bg focus-visible:bg-ui-body dark:focus-visible:bg-ui-dark-bg',
220
+ ].join(' ')}
221
+ >
222
+ <span className={`w-4 h-4 flex items-center justify-center shrink-0 ${item.iconClass}`}>
223
+ {item.icon()}
224
+ </span>
225
+ <span>{item.label}</span>
226
+ </button>
227
+ ))}
228
+ </div>
229
+ ) : null}
230
+ </div>
231
+ )
232
+ }
233
+
234
+ function MoonIcon() {
235
+ return (
236
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
237
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
238
+ </svg>
239
+ )
240
+ }
241
+
242
+ function SunIcon() {
243
+ return (
244
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
245
+ <circle cx="12" cy="12" r="4" />
246
+ <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
247
+ </svg>
248
+ )
249
+ }
250
+
251
+ function ProfileIcon() {
252
+ return (
253
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
254
+ <circle cx="12" cy="8" r="4" />
255
+ <path d="M4 21a8 8 0 0 1 16 0" />
256
+ </svg>
257
+ )
258
+ }
259
+
260
+ function LogoutIcon() {
261
+ return (
262
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
263
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
264
+ <polyline points="16 17 21 12 16 7" />
265
+ <line x1="21" y1="12" x2="9" y2="12" />
266
+ </svg>
267
+ )
268
+ }
269
+
270
+ function ChevronUpIcon({ className }: { className?: string }) {
271
+ return (
272
+ <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
273
+ <polyline points="18 15 12 9 6 15" />
274
+ </svg>
275
+ )
276
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ export { AppLayout } from './AppLayout';
2
+ export { Sidebar } from './Sidebar';
3
+ export { SidebarUserBlock } from './SidebarUserBlock';
4
+ export { MayaLogoIcon } from './MayaLogoIcon';
5
+ export { useDarkMode } from './useDarkMode';
6
+ export {
7
+ SidebarCollapsedProvider,
8
+ useSidebarCollapsed,
9
+ } from './SidebarCollapsedContext';
10
+ export type { NavItem } from './types';
11
+ export {
12
+ FolderIcon,
13
+ HomeIcon,
14
+ UploadIcon,
15
+ SearchIcon,
16
+ UsersIcon,
17
+ TemplateIcon,
18
+ HamburgerIcon,
19
+ MoonIcon,
20
+ SunIcon,
21
+ GridIcon,
22
+ UserIcon,
23
+ ShieldIcon,
24
+ KeyIcon,
25
+ AppsIcon,
26
+ } from './navIcons';
@@ -0,0 +1,106 @@
1
+ /** Iconos SVG compartidos para menú lateral y topbar. */
2
+
3
+ export const FolderIcon = () => (
4
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
5
+ <path d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
6
+ </svg>
7
+ );
8
+
9
+ export const HomeIcon = () => (
10
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
11
+ <path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
12
+ </svg>
13
+ );
14
+
15
+ export const UploadIcon = () => (
16
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
17
+ <path
18
+ fillRule="evenodd"
19
+ d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z"
20
+ clipRule="evenodd"
21
+ />
22
+ </svg>
23
+ );
24
+
25
+ export const SearchIcon = () => (
26
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
27
+ <path
28
+ fillRule="evenodd"
29
+ d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
30
+ clipRule="evenodd"
31
+ />
32
+ </svg>
33
+ );
34
+
35
+ export const UsersIcon = () => (
36
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
37
+ <path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
38
+ </svg>
39
+ );
40
+
41
+ export const TemplateIcon = () => (
42
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
43
+ <path d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm0 2h16v10H4V5zm2 2h8v2H6V7zm0 4h12v2H6v-2zm0 4h8v2H6v-2z" />
44
+ </svg>
45
+ );
46
+
47
+ export const HamburgerIcon = () => (
48
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
49
+ <path
50
+ fillRule="evenodd"
51
+ d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
52
+ clipRule="evenodd"
53
+ />
54
+ </svg>
55
+ );
56
+
57
+ export const MoonIcon = () => (
58
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
59
+ <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
60
+ </svg>
61
+ );
62
+
63
+ export const SunIcon = () => (
64
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
65
+ <path
66
+ fillRule="evenodd"
67
+ d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
68
+ clipRule="evenodd"
69
+ />
70
+ </svg>
71
+ );
72
+
73
+ /** Icono de grid (apps/herramientas) — usado por maya_dashboard */
74
+ export const GridIcon = () => (
75
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
76
+ <path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
77
+ </svg>
78
+ );
79
+
80
+ /** Icono de perfil de usuario — usado por maya_dashboard */
81
+ export const UserIcon = () => (
82
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
83
+ <path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
84
+ </svg>
85
+ );
86
+
87
+ /** Icono de escudo — usado por maya_authorization (roles) */
88
+ export const ShieldIcon = () => (
89
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
90
+ <path fillRule="evenodd" d="M10 1.944A11.954 11.954 0 012.166 5C2.056 5.649 2 6.319 2 7c0 5.225 3.34 9.67 8 11.317C14.66 16.67 18 12.225 18 7c0-.682-.057-1.35-.166-2.001A11.954 11.954 0 0110 1.944zM11 14a1 1 0 11-2 0 1 1 0 012 0zm0-7a1 1 0 10-2 0v3a1 1 0 102 0V7z" clipRule="evenodd" />
91
+ </svg>
92
+ );
93
+
94
+ /** Icono de llave — usado por maya_authorization (permisos) */
95
+ export const KeyIcon = () => (
96
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
97
+ <path fillRule="evenodd" d="M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z" clipRule="evenodd" />
98
+ </svg>
99
+ );
100
+
101
+ /** Icono de cuadrados apilados — usado por maya_authorization (aplicaciones) */
102
+ export const AppsIcon = () => (
103
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-[22px] h-[22px]">
104
+ <path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z" />
105
+ </svg>
106
+ );
package/src/types.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { FC, MouseEvent } from 'react';
2
+
3
+ /**
4
+ * Item del menú lateral. `path` es el destino estándar (NavLink to=...).
5
+ * Si se define `onClick`, se invoca antes de navegar — el handler puede
6
+ * llamar a `event.preventDefault()` para suprimir la navegación
7
+ * (caso: abrir un drawer secundario en desktop sin cambiar la ruta).
8
+ */
9
+ export type NavItem = {
10
+ id: string;
11
+ label: string;
12
+ icon: FC;
13
+ path: string;
14
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
15
+ };
@@ -0,0 +1,128 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { readOverrides, writeOverrides } from '@ceedcv-maya/shared-auth-react'
3
+
4
+ /**
5
+ * Hook compartido de tema claro/oscuro con sincronización cross-subdomain.
6
+ *
7
+ * Cadena de precedencia al inicializar:
8
+ * 1. Cookie `maya_session_overrides.theme` (única fuente cross-app si
9
+ * otra app del ecosistema cambió el tema desde su sidebar).
10
+ * 2. `localStorage.maya-theme` (cache local same-origin).
11
+ * 3. `prefers-color-scheme` del sistema operativo.
12
+ *
13
+ * Al cambiar el tema desde ESTA app (`toggle` / `setIsDark`):
14
+ * - Se aplica `<html class="dark">`.
15
+ * - Se persiste en `localStorage.maya-theme`.
16
+ * - Se escribe la cookie compartida `maya_session_overrides.theme`,
17
+ * que las demás apps leen al recuperar foco.
18
+ *
19
+ * Cuando el cambio llega DESDE FUERA (otra app, otra pestaña):
20
+ * - `reconcile` recoge el theme de la cookie y actualiza el state local
21
+ * sin reescribir la cookie (evita bucles).
22
+ */
23
+
24
+ const STORAGE_KEY = 'maya-theme'
25
+ const OVERRIDES_EVENT = 'maya:profile-overrides'
26
+
27
+ type Theme = 'dark' | 'light'
28
+
29
+ function readThemeFromOverrides(): Theme | null {
30
+ const overrides = readOverrides()
31
+ if (!overrides) return null
32
+ const value = overrides.theme
33
+ return value === 'dark' || value === 'light' ? value : null
34
+ }
35
+
36
+ function readInitialIsDark(): boolean {
37
+ if (typeof window === 'undefined') return false
38
+
39
+ const fromCookie = readThemeFromOverrides()
40
+ if (fromCookie) return fromCookie === 'dark'
41
+
42
+ const stored = localStorage.getItem(STORAGE_KEY)
43
+ if (stored === 'dark' || stored === 'light') return stored === 'dark'
44
+
45
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
46
+ }
47
+
48
+ export function useDarkMode() {
49
+ const [isDark, setIsDark] = useState<boolean>(readInitialIsDark)
50
+
51
+ // `true` cuando el cambio se inició localmente (toggle/setIsDark) y aún
52
+ // hay que propagarlo a la cookie. Se baja a `false` en cuanto el effect
53
+ // de propagación lo persiste, o cuando un listener externo entra primero.
54
+ const pendingPropagationRef = useRef<boolean>(false)
55
+
56
+ // Efecto único: aplica el tema al DOM, al localStorage y, si el cambio
57
+ // se originó aquí, a la cookie compartida.
58
+ useEffect(() => {
59
+ if (typeof document === 'undefined') return
60
+ const theme: Theme = isDark ? 'dark' : 'light'
61
+
62
+ document.documentElement.classList.toggle('dark', isDark)
63
+ try {
64
+ localStorage.setItem(STORAGE_KEY, theme)
65
+ } catch {
66
+ /* localStorage no disponible — ignorable */
67
+ }
68
+
69
+ if (pendingPropagationRef.current) {
70
+ pendingPropagationRef.current = false
71
+ const fromCookie = readThemeFromOverrides()
72
+ if (fromCookie !== theme) {
73
+ writeOverrides({ theme })
74
+ }
75
+ }
76
+ }, [isDark])
77
+
78
+ // Listeners cross-tab / cross-subdomain: NO escriben cookie, solo
79
+ // actualizan el state local con lo que la cookie ya trae.
80
+ useEffect(() => {
81
+ if (typeof window === 'undefined') return undefined
82
+
83
+ const reconcile = (): void => {
84
+ const fromCookie = readThemeFromOverrides()
85
+ if (!fromCookie) return
86
+ setIsDark((current) => {
87
+ const desired = fromCookie === 'dark'
88
+ return current === desired ? current : desired
89
+ })
90
+ }
91
+
92
+ const onStorage = (e: StorageEvent): void => {
93
+ if (e.key !== STORAGE_KEY) return
94
+ if (e.newValue !== 'dark' && e.newValue !== 'light') return
95
+ const desired = e.newValue === 'dark'
96
+ setIsDark((current) => (current === desired ? current : desired))
97
+ }
98
+
99
+ window.addEventListener(OVERRIDES_EVENT, reconcile)
100
+ document.addEventListener('visibilitychange', reconcile)
101
+ // `focus` y `pageshow` cubren navegadores/contextos donde
102
+ // `visibilitychange` no se dispara al alternar entre ventanas (multi-monitor,
103
+ // popups) o tras navegación back/forward con bfcache.
104
+ window.addEventListener('focus', reconcile)
105
+ window.addEventListener('pageshow', reconcile)
106
+ window.addEventListener('storage', onStorage)
107
+
108
+ return () => {
109
+ window.removeEventListener(OVERRIDES_EVENT, reconcile)
110
+ document.removeEventListener('visibilitychange', reconcile)
111
+ window.removeEventListener('focus', reconcile)
112
+ window.removeEventListener('pageshow', reconcile)
113
+ window.removeEventListener('storage', onStorage)
114
+ }
115
+ }, [])
116
+
117
+ const toggle = (): void => {
118
+ pendingPropagationRef.current = true
119
+ setIsDark((d) => !d)
120
+ }
121
+
122
+ const setDark = (next: boolean): void => {
123
+ pendingPropagationRef.current = true
124
+ setIsDark(next)
125
+ }
126
+
127
+ return { isDark, setIsDark: setDark, toggle } as const
128
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
6
+ "allowJs": false,
7
+ "skipLibCheck": true,
8
+ "esModuleInterop": false,
9
+ "allowSyntheticDefaultImports": true,
10
+ "strict": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "module": "ESNext",
13
+ "moduleResolution": "bundler",
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true,
16
+ "noEmit": true,
17
+ "jsx": "react-jsx"
18
+ },
19
+ "include": ["src"]
20
+ }