@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 +21 -0
- package/README.md +38 -0
- package/package.json +66 -0
- package/src/AppLayout.tsx +135 -0
- package/src/MayaLogoIcon.tsx +30 -0
- package/src/Sidebar.tsx +226 -0
- package/src/SidebarCollapsedContext.tsx +18 -0
- package/src/SidebarUserBlock.tsx +276 -0
- package/src/index.ts +26 -0
- package/src/navIcons.tsx +106 -0
- package/src/types.ts +15 -0
- package/src/useDarkMode.ts +128 -0
- package/tsconfig.json +20 -0
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
|
+
}
|
package/src/Sidebar.tsx
ADDED
|
@@ -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';
|
package/src/navIcons.tsx
ADDED
|
@@ -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
|
+
}
|