@zauru-sdk/components 6.15.1 → 6.16.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.
@@ -1,6 +1,14 @@
1
- import React, { useState, useEffect, useMemo } from "react";
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from "react";
2
9
  import {
3
10
  DropDownArrowSvgIcon,
11
+ ExitSvg,
4
12
  LogoutDropDownSvgIcon,
5
13
  MenuAlt4Svg,
6
14
  OpcionButtonSvgIcon,
@@ -8,73 +16,69 @@ import {
8
16
  import { COLORS } from "./NavBar.utils.js";
9
17
  import type {
10
18
  ColorInterface,
11
- EntityProps,
12
19
  NavBarItem,
13
20
  NavBarProps,
14
21
  } from "./NavBar.types.js";
15
- import { Link, useNavigate, useLocation } from "@remix-run/react";
22
+ import { Link, useLocation, useNavigate } from "@remix-run/react";
16
23
  import { useAppSelector } from "@zauru-sdk/redux";
17
24
 
18
- const OptionsDropDownButton = ({
19
- color,
20
- options,
21
- name,
22
- ariaLabel,
23
- }: EntityProps & { ariaLabel?: string }) => {
24
- const [showOptionsMenu, setShowOptionsMenu] = useState(true);
25
+ type NavLayout = "desktop" | "mobile";
25
26
 
26
- return (
27
- <div className="nav-item ml-auto">
28
- <div className="flex justify-center">
29
- <div className="relative inline-block">
30
- <button
31
- type="button"
32
- aria-label={
33
- ariaLabel ?? (typeof name === "string" ? name : "Menú")
34
- }
35
- aria-haspopup="menu"
36
- aria-expanded={!showOptionsMenu}
37
- onClick={() => setShowOptionsMenu(!showOptionsMenu)}
38
- className={`relative flex cursor-pointer items-center p-2 text-xs text-white ${color.bg700} active:${color.bg900} border border-transparent rounded-full uppercase focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2`}
39
- >
40
- {name ?? <OpcionButtonSvgIcon />}
41
- <DropDownArrowSvgIcon />
42
- </button>
43
- <div
44
- className="absolute right-0 z-20 w-56 py-2 mt-2 overflow-hidden bg-white rounded-md shadow-xl dark:bg-gray-800"
45
- hidden={showOptionsMenu}
46
- onMouseLeave={() => setShowOptionsMenu(true)}
47
- >
48
- {options.map((option, index) => (
49
- <React.Fragment key={index}>{option}</React.Fragment>
50
- ))}
51
- </div>
52
- </div>
53
- </div>
54
- </div>
55
- );
27
+ type UserMenuEntry = {
28
+ key: string;
29
+ label: string;
30
+ to?: string;
31
+ onClick?: () => void;
32
+ icon?: ReactNode;
33
+ danger?: boolean;
56
34
  };
57
35
 
58
- const NavItem = ({
59
- name,
60
- link,
61
- icon,
62
- selectedColor,
63
- childrens = [],
64
- reduxNotificationBadge,
65
- }: NavBarItem) => {
66
- const location = useLocation();
67
- const isActive = location.pathname === link;
36
+ const itemColor = (
37
+ item: Pick<NavBarItem, "selectedColor">,
38
+ fallback: ColorInterface
39
+ ): ColorInterface =>
40
+ item.selectedColor ? COLORS[item.selectedColor] : fallback;
41
+
42
+ const visibleChildrenOf = (item: NavBarItem): NavBarItem[] =>
43
+ (item.childrens ?? []).filter((child) => child.hide !== true);
44
+
45
+ const pathMatches = (pathname: string, link?: string) => {
46
+ if (!link) return false;
47
+ return pathname === link || pathname.startsWith(`${link}/`);
48
+ };
49
+
50
+ const focusRing =
51
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-transparent";
68
52
 
69
- const specialColor: ColorInterface = selectedColor
70
- ? COLORS[selectedColor]
71
- : COLORS["slate"];
53
+ const desktopTriggerClass = (color: ColorInterface, isActive: boolean) =>
54
+ `inline-flex min-h-11 cursor-pointer items-center gap-1 rounded-full px-4 text-xs font-medium uppercase leading-none text-white transition-opacity duration-150 motion-reduce:transition-none ${
55
+ isActive ? color.bg900 : color.bg700
56
+ } hover:opacity-90 ${focusRing}`;
72
57
 
73
- // Selecciona solo la parte del estado relevante
58
+ const mobileRowClass = (isActive: boolean) =>
59
+ `flex min-h-11 w-full cursor-pointer items-center gap-2 rounded-lg px-3 text-left text-sm font-medium text-white transition-colors duration-150 motion-reduce:transition-none hover:bg-white/10 ${focusRing} ${
60
+ isActive ? "bg-white/15" : ""
61
+ }`;
62
+
63
+ const dropdownItemClass = (danger?: boolean) =>
64
+ `flex min-h-11 w-full cursor-pointer items-center gap-2 px-4 text-sm transition-colors duration-150 motion-reduce:transition-none ${
65
+ danger
66
+ ? "text-red-700 hover:bg-red-50"
67
+ : "text-gray-700 hover:bg-gray-100"
68
+ }`;
69
+
70
+ const NotificationBadge = ({ value }: { value: string | number }) => (
71
+ <span className="absolute -top-1.5 -right-1.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white">
72
+ {value}
73
+ </span>
74
+ );
75
+
76
+ const useNotificationBadge = (item: NavBarItem) => {
74
77
  const relevantState = useAppSelector((state) =>
75
- reduxNotificationBadge ? reduxNotificationBadge(state) : undefined
78
+ item.reduxNotificationBadge
79
+ ? item.reduxNotificationBadge(state)
80
+ : undefined
76
81
  );
77
-
78
82
  const [notificationBadge, setNotificationBadge] = useState<
79
83
  undefined | string | number
80
84
  >();
@@ -83,55 +87,343 @@ const NavItem = ({
83
87
  setNotificationBadge(relevantState);
84
88
  }, [relevantState]);
85
89
 
86
- // Si este NavItem tiene elementos hijos, renderiza el botón desplegable:
87
- if (childrens.length > 0) {
90
+ return notificationBadge;
91
+ };
92
+
93
+ const Chevron = ({ open }: { open: boolean }) => (
94
+ <span
95
+ className={`inline-flex shrink-0 transition-transform duration-150 motion-reduce:transition-none ${
96
+ open ? "rotate-180" : ""
97
+ }`}
98
+ aria-hidden="true"
99
+ >
100
+ <DropDownArrowSvgIcon />
101
+ </span>
102
+ );
103
+
104
+ const NavLinkItem = ({
105
+ item,
106
+ color,
107
+ layout,
108
+ onNavigate,
109
+ }: {
110
+ item: NavBarItem;
111
+ color: ColorInterface;
112
+ layout: NavLayout;
113
+ onNavigate?: () => void;
114
+ }) => {
115
+ const location = useLocation();
116
+ const isActive = pathMatches(location.pathname, item.link);
117
+ const notificationBadge = useNotificationBadge(item);
118
+ const palette = itemColor(item, color);
119
+
120
+ if (layout === "mobile") {
88
121
  return (
89
- <OptionsDropDownButton
90
- name={name}
91
- color={specialColor}
92
- options={childrens.map((x, index) => (
122
+ <li>
123
+ <Link
124
+ to={item.link}
125
+ aria-current={isActive ? "page" : undefined}
126
+ onClick={onNavigate}
127
+ className={`${mobileRowClass(isActive)} relative`}
128
+ >
129
+ {item.icon}
130
+ <span className="flex-1">{item.name}</span>
131
+ {notificationBadge !== undefined && (
132
+ <NotificationBadge value={notificationBadge} />
133
+ )}
134
+ </Link>
135
+ </li>
136
+ );
137
+ }
138
+
139
+ return (
140
+ <li className="relative">
141
+ <Link
142
+ to={item.link}
143
+ aria-current={isActive ? "page" : undefined}
144
+ className={`${desktopTriggerClass(palette, isActive)} relative`}
145
+ >
146
+ {item.icon}
147
+ <span>{item.name}</span>
148
+ {notificationBadge !== undefined && (
149
+ <NotificationBadge value={notificationBadge} />
150
+ )}
151
+ </Link>
152
+ </li>
153
+ );
154
+ };
155
+
156
+ const NavItemGroup = ({
157
+ item,
158
+ color,
159
+ layout,
160
+ open,
161
+ onToggle,
162
+ onNavigate,
163
+ }: {
164
+ item: NavBarItem;
165
+ color: ColorInterface;
166
+ layout: NavLayout;
167
+ open: boolean;
168
+ onToggle: () => void;
169
+ onNavigate?: () => void;
170
+ }) => {
171
+ const location = useLocation();
172
+ const children = visibleChildrenOf(item);
173
+ const childActive = children.some((child) =>
174
+ pathMatches(location.pathname, child.link)
175
+ );
176
+ const palette = itemColor(item, color);
177
+ const menuId = useId();
178
+
179
+ if (children.length === 0) {
180
+ return null;
181
+ }
182
+
183
+ if (layout === "mobile") {
184
+ return (
185
+ <li>
186
+ <button
187
+ type="button"
188
+ aria-expanded={open}
189
+ aria-controls={menuId}
190
+ onClick={onToggle}
191
+ className={mobileRowClass(childActive)}
192
+ >
193
+ {item.icon}
194
+ <span className="flex-1">{item.name}</span>
195
+ <Chevron open={open} />
196
+ </button>
197
+ <ul
198
+ id={menuId}
199
+ hidden={!open}
200
+ className="mt-1 ml-3 space-y-1 border-l border-white/20 pl-2"
201
+ >
202
+ {children.map((child) => {
203
+ const isActive = pathMatches(location.pathname, child.link);
204
+ return (
205
+ <li key={child.link}>
206
+ <Link
207
+ to={child.link}
208
+ aria-current={isActive ? "page" : undefined}
209
+ onClick={onNavigate}
210
+ className={mobileRowClass(isActive)}
211
+ >
212
+ {child.icon}
213
+ <span>{child.name}</span>
214
+ </Link>
215
+ </li>
216
+ );
217
+ })}
218
+ </ul>
219
+ </li>
220
+ );
221
+ }
222
+
223
+ return (
224
+ <li className="relative">
225
+ <button
226
+ type="button"
227
+ aria-haspopup="menu"
228
+ aria-expanded={open}
229
+ aria-controls={menuId}
230
+ onClick={onToggle}
231
+ className={desktopTriggerClass(palette, childActive)}
232
+ >
233
+ {item.icon}
234
+ <span>{item.name}</span>
235
+ <Chevron open={open} />
236
+ </button>
237
+ <div
238
+ id={menuId}
239
+ role="menu"
240
+ hidden={!open}
241
+ className="absolute left-0 z-30 mt-2 min-w-[14rem] overflow-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5"
242
+ >
243
+ {children.map((child) => (
93
244
  <Link
94
- key={index}
95
- to={x.link}
96
- className={`block px-4 py-3 text-sm text-gray-600 capitalize transition-colors duration-200 transform dark:text-gray-300 hover:bg-red-100 dark:hover:bg-gray-700 dark:hover:text-white`}
245
+ key={child.link}
246
+ role="menuitem"
247
+ to={child.link}
248
+ onClick={onNavigate}
249
+ className={dropdownItemClass()}
97
250
  >
98
- {x.name}
251
+ {child.icon}
252
+ <span>{child.name}</span>
99
253
  </Link>
100
254
  ))}
101
- />
255
+ </div>
256
+ </li>
257
+ );
258
+ };
259
+
260
+ const UserMenu = ({
261
+ color,
262
+ layout,
263
+ open,
264
+ onToggle,
265
+ onNavigate,
266
+ entries,
267
+ }: {
268
+ color: ColorInterface;
269
+ layout: NavLayout;
270
+ open: boolean;
271
+ onToggle: () => void;
272
+ onNavigate?: () => void;
273
+ entries: UserMenuEntry[];
274
+ }) => {
275
+ const menuId = useId();
276
+
277
+ if (entries.length === 0) {
278
+ return null;
279
+ }
280
+
281
+ if (layout === "mobile") {
282
+ return (
283
+ <div className="mt-4 border-t border-white/20 pt-4">
284
+ <p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-white/70">
285
+ Cuenta
286
+ </p>
287
+ <ul className="space-y-1">
288
+ {entries.map((entry) => (
289
+ <li key={entry.key}>
290
+ {entry.to ? (
291
+ <Link
292
+ to={entry.to}
293
+ onClick={onNavigate}
294
+ className={`${mobileRowClass(false)} [&_svg]:!text-current ${
295
+ entry.danger ? "text-red-100 hover:bg-red-500/20" : ""
296
+ }`}
297
+ >
298
+ {entry.icon}
299
+ <span>{entry.label}</span>
300
+ </Link>
301
+ ) : (
302
+ <button
303
+ type="button"
304
+ onClick={() => {
305
+ entry.onClick?.();
306
+ onNavigate?.();
307
+ }}
308
+ className={`${mobileRowClass(false)} [&_svg]:!text-current`}
309
+ >
310
+ {entry.icon}
311
+ <span>{entry.label}</span>
312
+ </button>
313
+ )}
314
+ </li>
315
+ ))}
316
+ </ul>
317
+ </div>
102
318
  );
103
319
  }
104
320
 
105
- // Si NO tiene elementos hijos, se renderiza como un ítem simple:
106
321
  return (
107
- <li className="nav-item relative">
322
+ <div className="relative ml-auto">
323
+ <button
324
+ type="button"
325
+ aria-label="Menú de usuario"
326
+ aria-haspopup="menu"
327
+ aria-expanded={open}
328
+ aria-controls={menuId}
329
+ onClick={onToggle}
330
+ className={`${desktopTriggerClass(color, open)} p-0 px-3`}
331
+ >
332
+ <OpcionButtonSvgIcon />
333
+ <Chevron open={open} />
334
+ </button>
108
335
  <div
109
- // Si está activo, usamos color de fondo más oscuro (bg900)
110
- // De lo contrario, usamos el color normal (bg700)
111
- className={`${
112
- isActive ? specialColor.bg900 : specialColor.bg700
113
- } container text-white w-full sm:w-auto h-10 text-sm py-1 uppercase shadow hover:shadow-lg outline-none rounded-full focus:outline-none my-auto sm:my-0 sm:mr-1 mb-1 ease-linear transition-all duration-150`}
336
+ id={menuId}
337
+ role="menu"
338
+ hidden={!open}
339
+ className="absolute right-0 z-30 mt-2 w-56 overflow-hidden rounded-md bg-white py-1 shadow-lg ring-1 ring-black/5"
114
340
  >
115
- <Link
116
- className="px-3 flex items-center text-xs leading-snug text-white uppercase hover:opacity-75 relative"
117
- to={link}
118
- >
119
- <div className="mx-auto pt-2">
120
- {icon}
121
- <span>{name}</span>
122
- </div>
123
- {/* Badge de notificaciones */}
124
- {notificationBadge !== undefined && (
125
- <span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center w-5 h-5">
126
- {notificationBadge}
127
- </span>
128
- )}
129
- </Link>
341
+ {entries.map((entry) =>
342
+ entry.to ? (
343
+ <Link
344
+ key={entry.key}
345
+ role="menuitem"
346
+ to={entry.to}
347
+ onClick={onNavigate}
348
+ className={dropdownItemClass(entry.danger)}
349
+ >
350
+ {entry.icon}
351
+ <span>{entry.label}</span>
352
+ </Link>
353
+ ) : (
354
+ <button
355
+ key={entry.key}
356
+ type="button"
357
+ role="menuitem"
358
+ onClick={() => {
359
+ entry.onClick?.();
360
+ onNavigate?.();
361
+ }}
362
+ className={dropdownItemClass(entry.danger)}
363
+ >
364
+ {entry.icon}
365
+ <span>{entry.label}</span>
366
+ </button>
367
+ )
368
+ )}
130
369
  </div>
131
- </li>
370
+ </div>
132
371
  );
133
372
  };
134
373
 
374
+ const NavItems = ({
375
+ items,
376
+ color,
377
+ layout,
378
+ openMenuId,
379
+ setOpenMenuId,
380
+ onNavigate,
381
+ }: {
382
+ items: NavBarItem[];
383
+ color: ColorInterface;
384
+ layout: NavLayout;
385
+ openMenuId: string | null;
386
+ setOpenMenuId: (id: string | null) => void;
387
+ onNavigate?: () => void;
388
+ }) => (
389
+ <ul
390
+ className={
391
+ layout === "mobile"
392
+ ? "flex flex-col gap-1"
393
+ : "flex flex-wrap items-center gap-1"
394
+ }
395
+ >
396
+ {items.map((item, index) => {
397
+ const children = visibleChildrenOf(item);
398
+ const menuKey = `${item.name}-${index}`;
399
+ if (children.length > 0) {
400
+ return (
401
+ <NavItemGroup
402
+ key={menuKey}
403
+ item={item}
404
+ color={color}
405
+ layout={layout}
406
+ open={openMenuId === menuKey}
407
+ onToggle={() =>
408
+ setOpenMenuId(openMenuId === menuKey ? null : menuKey)
409
+ }
410
+ onNavigate={onNavigate}
411
+ />
412
+ );
413
+ }
414
+ return (
415
+ <NavLinkItem
416
+ key={menuKey}
417
+ item={item}
418
+ color={color}
419
+ layout={layout}
420
+ onNavigate={onNavigate}
421
+ />
422
+ );
423
+ })}
424
+ </ul>
425
+ );
426
+
135
427
  export const NavBar = ({
136
428
  title,
137
429
  loggedIn,
@@ -143,10 +435,22 @@ export const NavBar = ({
143
435
  showDarkModeToggle = false,
144
436
  }: NavBarProps) => {
145
437
  const color: ColorInterface = COLORS[selectedColor];
146
- const [NavBarOpen, setNavBarOpen] = useState(false);
438
+ const [navOpen, setNavOpen] = useState(false);
439
+ const [openMenuId, setOpenMenuId] = useState<string | null>(null);
147
440
  const [currentVersion, setCurrentVersion] = useState(version);
148
441
  const [isDarkMode, setIsDarkMode] = useState(false);
149
442
  const navigate = useNavigate();
443
+ const location = useLocation();
444
+ const desktopMenuRef = useRef<HTMLDivElement>(null);
445
+ const mobileDrawerRef = useRef<HTMLDivElement>(null);
446
+ const wasNavOpen = useRef(false);
447
+ const mobileNavId = useId();
448
+
449
+ const visibleItems = useMemo(
450
+ () =>
451
+ items.filter((item) => item.loggedIn === loggedIn && item.hide !== true),
452
+ [items, loggedIn]
453
+ );
150
454
 
151
455
  useEffect(() => {
152
456
  document.documentElement.classList.toggle("dark", isDarkMode);
@@ -158,180 +462,248 @@ export const NavBar = ({
158
462
  }
159
463
  }, [version, currentVersion]);
160
464
 
161
- const refreshPage = () => {
162
- navigate(0);
163
- };
465
+ useEffect(() => {
466
+ setNavOpen(false);
467
+ setOpenMenuId(null);
468
+ }, [location.pathname]);
164
469
 
165
- const renderNavItems = (items: NavBarItem[]) => (
166
- <div className="flex flex-col lg:flex-row w-full">
167
- {items.map((item, index) => {
168
- return (
169
- <NavItem
170
- key={index}
171
- name={item.name}
172
- link={item.link}
173
- icon={item.icon}
174
- selectedColor={item.selectedColor}
175
- color={color}
176
- loggedIn={item.loggedIn}
177
- reduxNotificationBadge={item.reduxNotificationBadge}
178
- childrens={item.childrens?.map((x) => {
179
- return { ...x } as any;
180
- })}
181
- />
182
- );
183
- })}
184
- </div>
185
- );
470
+ useEffect(() => {
471
+ if (!navOpen) return;
472
+ const previousOverflow = document.body.style.overflow;
473
+ document.body.style.overflow = "hidden";
474
+ return () => {
475
+ document.body.style.overflow = previousOverflow;
476
+ };
477
+ }, [navOpen]);
186
478
 
187
- const hiddenItemsChange = useMemo(
188
- () => items.filter((item) => item.hide === true),
189
- [items]
190
- );
479
+ useEffect(() => {
480
+ const drawer = mobileDrawerRef.current;
481
+ if (!drawer) return;
482
+ if (navOpen) {
483
+ drawer.removeAttribute("inert");
484
+ } else {
485
+ drawer.setAttribute("inert", "");
486
+ }
487
+ }, [navOpen]);
191
488
 
192
- const options = useMemo(() => {
193
- return (
194
- <>
195
- <ul className="w-full lg:flex lg:items-center">
196
- {renderNavItems(
197
- items
198
- .filter((item) => item.loggedIn === loggedIn)
199
- .filter((item) => item.hide !== true)
200
- )}
201
- </ul>
202
- <ul className="sm:flex sm:flex-col lg:flex-row ml-auto">
203
- {loggedIn && (
204
- <OptionsDropDownButton
205
- color={color}
206
- ariaLabel="Menú de usuario"
207
- options={[
208
- settingsOption && (
209
- <Link
210
- key="configuracion"
211
- className="block cursor-pointer px-4 py-3 text-sm text-gray-600 capitalize transition-colors duration-200 transform dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 dark:hover:text-white"
212
- to="/configuracion"
213
- >
214
- <div className="mx-auto pt-2">
215
- <svg
216
- className="h-5 w-5 mr-1"
217
- width="24"
218
- height="24"
219
- fill="none"
220
- stroke="currentColor"
221
- strokeWidth="1.5"
222
- viewBox="0 0 24 24"
223
- strokeLinecap="round"
224
- strokeLinejoin="round"
225
- xmlns="http://www.w3.org/2000/svg"
226
- aria-hidden="true"
227
- >
228
- <path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" />
229
- <path d="M19.4 13a7.7 7.7 0 0 0 .06-2l2.1-1.64-2-3.46-2.55 1a7.7 7.7 0 0 0-1.73-1L14.7 2H9.3l-.58 2.9a7.7 7.7 0 0 0-1.73 1l-2.55-1-2 3.46L4.54 11a7.7 7.7 0 0 0 0 2l-2.1 1.64 2 3.46 2.55-1a7.7 7.7 0 0 0 1.73 1L9.3 22h5.4l.58-2.9a7.7 7.7 0 0 0 1.73-1l2.55 1 2-3.46Z" />
230
- </svg>
231
- <span>Configuración</span>
232
- </div>
233
- </Link>
234
- ),
235
- reloadCatalogsOption && (
236
- <Link
237
- key="recargar-catalogos"
238
- className={`block px-4 py-3 text-sm text-gray-600 capitalize transition-colors duration-200 transform dark:text-gray-300 hover:bg-red-100 dark:hover:bg-gray-700 dark:hover:text-white`}
239
- to="/reload-catalogs"
240
- >
241
- <div className="mx-auto pt-2">
242
- <svg
243
- className={`h-5 w-5 mr-1`}
244
- width="24"
245
- height="24"
246
- fill="none"
247
- stroke="currentColor"
248
- strokeWidth="1.5"
249
- viewBox="0 0 24 24"
250
- strokeLinecap="round"
251
- strokeLinejoin="round"
252
- xmlns="http://www.w3.org/2000/svg"
253
- >
254
- <path d="M20.5 8c-1.392-3.179-4.823-5-8.522-5C7.299 3 3.453 6.552 3 11.1" />
255
- <path d="M16.489 8.4h3.97A.54.54 0 0 0 21 7.86V3.9M3.5 16c1.392 3.179 4.823 5 8.522 5 4.679 0 8.525-3.552 8.978-8.1" />
256
- <path d="M7.511 15.6h-3.97a.54.54 0 0 0-.541.54v3.96" />
257
- </svg>
258
- <span>Recargar catálogos</span>
259
- </div>
260
- </Link>
261
- ),
262
- showDarkModeToggle && (
263
- <button
264
- key="dark-mode-toggle"
265
- type="button"
266
- className="block w-full cursor-pointer text-left px-4 py-3 text-sm text-gray-600 capitalize transition-colors duration-200 transform dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 dark:hover:text-white"
267
- onClick={() => setIsDarkMode((value) => !value)}
268
- >
269
- <div className="mx-auto pt-2">
270
- <span>{isDarkMode ? "Modo claro" : "Modo oscuro"}</span>
271
- </div>
272
- </button>
273
- ),
274
- <Link
275
- key="cerrar-sesion"
276
- className={`block px-4 py-3 text-sm text-gray-600 capitalize transition-colors duration-200 transform dark:text-gray-300 hover:bg-red-100 dark:hover:bg-gray-700 dark:hover:text-white`}
277
- to="/logout"
278
- >
279
- <div className="mx-auto pt-2">
280
- {<LogoutDropDownSvgIcon />}
281
- <span>Cerrar sesión</span>
282
- </div>
283
- </Link>,
284
- ]}
285
- />
286
- )}
287
- </ul>
288
- </>
489
+ useEffect(() => {
490
+ const onKeyDown = (event: KeyboardEvent) => {
491
+ if (event.key !== "Escape") return;
492
+ setNavOpen(false);
493
+ setOpenMenuId(null);
494
+ };
495
+ document.addEventListener("keydown", onKeyDown);
496
+ return () => document.removeEventListener("keydown", onKeyDown);
497
+ }, []);
498
+
499
+ useEffect(() => {
500
+ const onPointerDown = (event: MouseEvent) => {
501
+ if (
502
+ desktopMenuRef.current &&
503
+ !desktopMenuRef.current.contains(event.target as Node)
504
+ ) {
505
+ setOpenMenuId(null);
506
+ }
507
+ };
508
+ document.addEventListener("mousedown", onPointerDown);
509
+ return () => document.removeEventListener("mousedown", onPointerDown);
510
+ }, []);
511
+
512
+ const closeMenus = () => {
513
+ setNavOpen(false);
514
+ setOpenMenuId(null);
515
+ };
516
+
517
+ const userEntries = useMemo<UserMenuEntry[]>(() => {
518
+ if (!loggedIn) return [];
519
+ const entries: UserMenuEntry[] = [];
520
+ if (settingsOption) {
521
+ entries.push({
522
+ key: "configuracion",
523
+ label: "Configuración",
524
+ to: "/configuracion",
525
+ icon: (
526
+ <svg
527
+ className="h-5 w-5 shrink-0"
528
+ width="24"
529
+ height="24"
530
+ fill="none"
531
+ stroke="currentColor"
532
+ strokeWidth="1.5"
533
+ viewBox="0 0 24 24"
534
+ strokeLinecap="round"
535
+ strokeLinejoin="round"
536
+ xmlns="http://www.w3.org/2000/svg"
537
+ aria-hidden="true"
538
+ >
539
+ <path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" />
540
+ <path d="M19.4 13a7.7 7.7 0 0 0 .06-2l2.1-1.64-2-3.46-2.55 1a7.7 7.7 0 0 0-1.73-1L14.7 2H9.3l-.58 2.9a7.7 7.7 0 0 0-1.73 1l-2.55-1-2 3.46L4.54 11a7.7 7.7 0 0 0 0 2l-2.1 1.64 2 3.46 2.55-1a7.7 7.7 0 0 0 1.73 1L9.3 22h5.4l.58-2.9a7.7 7.7 0 0 0 1.73-1l2.55 1 2-3.46Z" />
541
+ </svg>
542
+ ),
543
+ });
544
+ }
545
+ if (reloadCatalogsOption) {
546
+ entries.push({
547
+ key: "recargar-catalogos",
548
+ label: "Recargar catálogos",
549
+ to: "/reload-catalogs",
550
+ icon: (
551
+ <svg
552
+ className="h-5 w-5 shrink-0"
553
+ width="24"
554
+ height="24"
555
+ fill="none"
556
+ stroke="currentColor"
557
+ strokeWidth="1.5"
558
+ viewBox="0 0 24 24"
559
+ strokeLinecap="round"
560
+ strokeLinejoin="round"
561
+ xmlns="http://www.w3.org/2000/svg"
562
+ aria-hidden="true"
563
+ >
564
+ <path d="M20.5 8c-1.392-3.179-4.823-5-8.522-5C7.299 3 3.453 6.552 3 11.1" />
565
+ <path d="M16.489 8.4h3.97A.54.54 0 0 0 21 7.86V3.9M3.5 16c1.392 3.179 4.823 5 8.522 5 4.679 0 8.525-3.552 8.978-8.1" />
566
+ <path d="M7.511 15.6h-3.97a.54.54 0 0 0-.541.54v3.96" />
567
+ </svg>
568
+ ),
569
+ });
570
+ }
571
+ if (showDarkModeToggle) {
572
+ entries.push({
573
+ key: "dark-mode-toggle",
574
+ label: isDarkMode ? "Modo claro" : "Modo oscuro",
575
+ onClick: () => setIsDarkMode((value) => !value),
576
+ });
577
+ }
578
+ entries.push({
579
+ key: "cerrar-sesion",
580
+ label: "Cerrar sesión",
581
+ to: "/logout",
582
+ icon: <LogoutDropDownSvgIcon />,
583
+ danger: true,
584
+ });
585
+ return entries;
586
+ }, [
587
+ loggedIn,
588
+ settingsOption,
589
+ reloadCatalogsOption,
590
+ showDarkModeToggle,
591
+ isDarkMode,
592
+ ]);
593
+
594
+ useEffect(() => {
595
+ const justOpened = navOpen && !wasNavOpen.current;
596
+ wasNavOpen.current = navOpen;
597
+ if (!justOpened) return;
598
+ const activeGroup = visibleItems.find((item) =>
599
+ visibleChildrenOf(item).some((child) =>
600
+ pathMatches(location.pathname, child.link)
601
+ )
289
602
  );
290
- }, [items, loggedIn, hiddenItemsChange, reloadCatalogsOption, settingsOption, showDarkModeToggle, isDarkMode]);
603
+ if (!activeGroup) return;
604
+ const index = visibleItems.indexOf(activeGroup);
605
+ setOpenMenuId(`${activeGroup.name}-${index}`);
606
+ }, [navOpen, visibleItems, location.pathname]);
607
+
608
+ const renderCluster = (layout: NavLayout) => (
609
+ <>
610
+ <NavItems
611
+ items={visibleItems}
612
+ color={color}
613
+ layout={layout}
614
+ openMenuId={openMenuId}
615
+ setOpenMenuId={setOpenMenuId}
616
+ onNavigate={closeMenus}
617
+ />
618
+ <UserMenu
619
+ color={color}
620
+ layout={layout}
621
+ open={openMenuId === "user"}
622
+ onToggle={() => setOpenMenuId(openMenuId === "user" ? null : "user")}
623
+ onNavigate={closeMenus}
624
+ entries={userEntries}
625
+ />
626
+ </>
627
+ );
291
628
 
292
629
  return (
293
- <nav className={`py-3 ${color.bg600}`}>
294
- <div className="flex items-center justify-between ml-5 mr-5">
295
- <div className="flex justify-between items-center w-full lg:w-auto">
630
+ <nav className={`py-2 ${color.bg600}`} aria-label="Principal">
631
+ <div className="flex items-center justify-between gap-3 px-4 sm:px-5">
632
+ <div className="flex min-w-0 flex-1 items-center justify-between gap-2 lg:w-auto lg:flex-none">
296
633
  <Link
297
- className="text-sm font-bold leading-relaxed inline-block mr-4 py-2 whitespace-nowrap uppercase text-white"
298
- to={"/home"}
634
+ className={`inline-flex min-h-11 min-w-0 cursor-pointer items-center gap-2 truncate text-sm font-bold uppercase text-white ${focusRing}`}
635
+ to="/home"
299
636
  >
300
- <div className="inline-block mr-2 mb-2 align-middle">
301
- <img className="w-auto h-7" src="/logo.png" alt="logo-zauru" />
302
- </div>
303
- {title}
637
+ <img className="h-7 w-auto shrink-0" src="/logo.png" alt="" />
638
+ <span className="truncate">{title}</span>
304
639
  </Link>
305
640
  {version !== currentVersion && (
306
641
  <button
307
- className={`ml-2 px-2 py-1 text-xs text-white ${color.bg700} rounded-full hover:${color.bg900} transition-colors duration-200`}
308
- onClick={refreshPage}
642
+ type="button"
643
+ className={`shrink-0 cursor-pointer rounded-full px-3 py-2 text-xs text-white ${color.bg700} hover:opacity-90 ${focusRing}`}
644
+ onClick={() => navigate(0)}
309
645
  >
310
- 🔄 Actualizar versión
646
+ Actualizar versión
311
647
  </button>
312
648
  )}
313
649
  <button
314
- className={`rounded lg:hidden focus:outline-none focus:ring focus:${color.ring600} focus:ring-opacity-50`}
315
- aria-label="Toggle mobile menu"
316
650
  type="button"
317
- onClick={() => setNavBarOpen(!NavBarOpen)}
651
+ className={`inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-md text-white lg:hidden ${focusRing} ${
652
+ navOpen ? "pointer-events-none invisible" : ""
653
+ }`}
654
+ aria-label="Abrir menú"
655
+ aria-expanded={navOpen}
656
+ aria-controls={mobileNavId}
657
+ aria-hidden={navOpen}
658
+ tabIndex={navOpen ? -1 : undefined}
659
+ onClick={() => setNavOpen(true)}
318
660
  >
319
- <MenuAlt4Svg open={NavBarOpen} />
661
+ <MenuAlt4Svg open={false} />
320
662
  </button>
321
663
  </div>
322
- {/* Menú para dispositivos móviles */}
664
+
665
+ {navOpen && (
666
+ <button
667
+ type="button"
668
+ aria-label="Cerrar menú"
669
+ className="fixed inset-0 z-40 cursor-pointer bg-black/40 lg:hidden"
670
+ onClick={closeMenus}
671
+ />
672
+ )}
673
+
323
674
  <div
324
- className={`lg:hidden fixed top-0 left-0 z-50 w-64 h-full ${
675
+ ref={mobileDrawerRef}
676
+ id={mobileNavId}
677
+ aria-hidden={!navOpen}
678
+ className={`fixed inset-y-0 left-0 z-50 flex w-[min(20rem,86vw)] flex-col ${
325
679
  color.bg700
326
- } dark:bg-gray-900 shadow-lg transform ${
327
- NavBarOpen ? "translate-x-0" : "-translate-x-full"
328
- } transition-transform duration-300 ease-in-out overflow-y-auto`}
680
+ } shadow-lg transition-transform duration-200 ease-out motion-reduce:transition-none lg:hidden ${
681
+ navOpen ? "translate-x-0" : "pointer-events-none -translate-x-full"
682
+ }`}
329
683
  >
330
- <div className="p-4">{options}</div>
684
+ <div className="flex items-center justify-between gap-2 border-b border-white/15 px-4 py-3">
685
+ <p className="truncate text-sm font-bold uppercase text-white">
686
+ {title}
687
+ </p>
688
+ <button
689
+ type="button"
690
+ className={`inline-flex min-h-11 min-w-11 cursor-pointer items-center justify-center rounded-md text-white ${focusRing}`}
691
+ aria-label="Cerrar menú"
692
+ onClick={closeMenus}
693
+ >
694
+ <ExitSvg />
695
+ </button>
696
+ </div>
697
+ <div className="flex-1 overflow-y-auto overscroll-contain p-3">
698
+ {renderCluster("mobile")}
699
+ </div>
331
700
  </div>
332
- {/* Menú para escritorio */}
333
- <div className="hidden lg:flex lg:items-center w-full lg:w-auto">
334
- {options}
701
+
702
+ <div
703
+ ref={desktopMenuRef}
704
+ className="hidden min-w-0 flex-1 items-center gap-2 lg:flex"
705
+ >
706
+ {renderCluster("desktop")}
335
707
  </div>
336
708
  </div>
337
709
  </nav>