@zauru-sdk/components 6.15.1 → 6.16.1

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