@eintrek/erp-shell 0.1.47 → 0.1.48

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/dist/index.esm.js CHANGED
@@ -419,44 +419,49 @@ function useSidebar() {
419
419
  }
420
420
  return context;
421
421
  }
422
+ /**
423
+ * Below this width the sidebar is a drawer over the page rather than a docked
424
+ * column (AppLayoutShell uses the same query).
425
+ */
426
+ const DRAWER_QUERY = "(max-width: 1024px)";
427
+ const isDrawerLayout = () => typeof window !== "undefined" && window.matchMedia(DRAWER_QUERY).matches;
428
+ /** Whether the docked sidebar was left open, as remembered in this browser. */
429
+ function rememberedDockedOpen(fallback) {
430
+ if (typeof window === "undefined")
431
+ return fallback;
432
+ const saved = localStorage.getItem("sidebar-open");
433
+ return saved !== null ? saved === "true" : fallback;
434
+ }
422
435
  /**
423
436
  * Sidebar Provider Component
424
- * Provides sidebar state management through React Context
425
- * Persists state to localStorage
437
+ * Provides sidebar state management through React Context.
438
+ *
439
+ * What is remembered (localStorage) is whether the DOCKED sidebar was open.
440
+ * A drawer always starts closed — a remembered desktop preference, or a
441
+ * drawer someone left open, is not a request to cover the page on load.
426
442
  */
427
443
  function SidebarProvider({ children, defaultOpen = true, }) {
428
- // Initialize from localStorage or default
429
- const [open, setOpenState] = useState(() => {
430
- if (typeof window !== "undefined") {
431
- const saved = localStorage.getItem("sidebar-open");
432
- return saved !== null ? saved === "true" : defaultOpen;
433
- }
434
- return defaultOpen;
435
- });
444
+ const [open, setOpenState] = useState(() => isDrawerLayout() ? false : rememberedDockedOpen(defaultOpen));
436
445
  const [isMobile, setIsMobile] = useState(false);
437
- // Persist to localStorage
446
+ // Remember the docked sidebar only; opening the drawer changes nothing.
438
447
  useEffect(() => {
439
- if (typeof window !== "undefined") {
448
+ if (typeof window !== "undefined" && !isDrawerLayout()) {
440
449
  localStorage.setItem("sidebar-open", String(open));
441
450
  }
442
451
  }, [open]);
443
- // Handle mobile detection.
444
- //
445
- // Crossing into a narrow viewport closes the sidebar. One `open` state
446
- // serves both layouts, and on desktop it is remembered as true — so
447
- // narrowing the window turned that into a drawer covering the whole page,
448
- // which had to be dismissed before anything underneath could be reached.
449
- // A remembered desktop preference is not a request to open a drawer.
452
+ // Crossing into the drawer layout closes it; crossing back restores the
453
+ // docked sidebar as it was left.
450
454
  useEffect(() => {
451
- let wasMobile = null;
452
- const checkMobile = () => {
453
- const nowMobile = window.innerWidth < 768;
454
- setIsMobile(nowMobile);
455
- if (nowMobile && wasMobile === false) {
456
- setOpenState(false);
457
- }
458
- wasMobile = nowMobile;
455
+ const drawer = window.matchMedia(DRAWER_QUERY);
456
+ const onChange = () => {
457
+ setOpenState(drawer.matches ? false : rememberedDockedOpen(defaultOpen));
459
458
  };
459
+ drawer.addEventListener("change", onChange);
460
+ return () => drawer.removeEventListener("change", onChange);
461
+ }, [defaultOpen]);
462
+ // Phone width — the organisation switcher opens its menu from the bottom.
463
+ useEffect(() => {
464
+ const checkMobile = () => setIsMobile(window.innerWidth < 768);
460
465
  checkMobile();
461
466
  window.addEventListener("resize", checkMobile);
462
467
  return () => window.removeEventListener("resize", checkMobile);
@@ -1076,52 +1081,88 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1076
1081
  setOpen(false);
1077
1082
  };
1078
1083
  const code = orgCode ?? params?.code;
1084
+ // Which module (and sub-item) the current page belongs to.
1085
+ const states = items.map(item => {
1086
+ const itemUrl = buildUrlWithCode(code, item.url);
1087
+ // Most specific (longest) matching sub-item URL so only one link is active.
1088
+ const activeSubItemUrl = item.subModules?.reduce((best, subItem) => {
1089
+ const subItemUrl = buildUrlWithCode(code, subItem.url);
1090
+ const matches = isPathActive(pathname, subItemUrl) ||
1091
+ isPathActive(pathname, subItem.url);
1092
+ const subPageMatches = subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1093
+ isPathActive(pathname, subPage.url));
1094
+ if (!matches && !subPageMatches)
1095
+ return best;
1096
+ const candidateUrl = subItemUrl;
1097
+ if (!best)
1098
+ return candidateUrl;
1099
+ return candidateUrl.length > best.length ? candidateUrl : best;
1100
+ }, null) ?? null;
1101
+ const hasActiveSubItem = !!activeSubItemUrl ||
1102
+ !!item.subModules?.some(subItem => subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1103
+ isPathActive(pathname, subPage.url)));
1104
+ // Parent only active when path matches its URL exactly AND no child
1105
+ // is active (so parent+child don't both light up).
1106
+ const isItemActive = (workspace ? samePage(pathname, itemUrl) : pathname === itemUrl) &&
1107
+ !hasActiveSubItem;
1108
+ return { itemUrl, activeSubItemUrl, hasActiveSubItem, isItemActive };
1109
+ });
1110
+ // A group opens when its page becomes current and otherwise stays as the
1111
+ // user left it. defaultOpen alone decided once, on the first render — and
1112
+ // on a direct load the sub-items arrive after permissions do, so the group
1113
+ // holding the current page stayed shut.
1114
+ const activeKey = items
1115
+ .filter((_, i) => states[i].isItemActive || states[i].hasActiveSubItem)
1116
+ .map(item => item.id)
1117
+ .join(",");
1118
+ const [openIds, setOpenIds] = React.useState(() => new Set(activeKey ? activeKey.split(",") : []));
1119
+ React.useEffect(() => {
1120
+ if (!activeKey)
1121
+ return;
1122
+ setOpenIds(prev => {
1123
+ const ids = activeKey.split(",");
1124
+ if (ids.every(id => prev.has(id)))
1125
+ return prev;
1126
+ const next = new Set(prev);
1127
+ ids.forEach(id => next.add(id));
1128
+ return next;
1129
+ });
1130
+ }, [activeKey]);
1131
+ const setItemOpen = (id, open) => setOpenIds(prev => {
1132
+ if (prev.has(id) === open)
1133
+ return prev;
1134
+ const next = new Set(prev);
1135
+ if (open)
1136
+ next.add(id);
1137
+ else
1138
+ next.delete(id);
1139
+ return next;
1140
+ });
1079
1141
  return (jsx(SidebarGroup, { "data-sidebar": true, className: workspace ? "p-0" : undefined, children: jsx(SidebarMenu, { className: workspace ? "gap-[3px]" : undefined, children: items.map((item, index) => {
1080
1142
  const groupHeading = workspace &&
1081
1143
  item.group &&
1082
- item.group !== items[index - 1]?.group ? (jsx("li", { className: "list-none px-[15px] pb-2.5 text-xs text-muted-foreground" +
1083
- (index > 0 ? " pt-8" : ""), children: item.group }, `group-${item.id}`)) : null;
1084
- const itemUrl = buildUrlWithCode(code, item.url);
1085
- // Most specific (longest) matching sub-item URL so only one link is active.
1086
- const activeSubItemUrl = item.subModules?.reduce((best, subItem) => {
1087
- const subItemUrl = buildUrlWithCode(code, subItem.url);
1088
- const matches = isPathActive(pathname, subItemUrl) ||
1089
- isPathActive(pathname, subItem.url);
1090
- const subPageMatches = subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1091
- isPathActive(pathname, subPage.url));
1092
- if (!matches && !subPageMatches)
1093
- return best;
1094
- const candidateUrl = subItemUrl;
1095
- if (!best)
1096
- return candidateUrl;
1097
- return candidateUrl.length > best.length ? candidateUrl : best;
1098
- }, null) ?? null;
1099
- const hasActiveSubItem = !!activeSubItemUrl ||
1100
- !!item.subModules?.some(subItem => subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1101
- isPathActive(pathname, subPage.url)));
1102
- // Parent only active when path matches its URL exactly AND no child
1103
- // is active (so parent+child don't both light up).
1104
- const isItemActive = (workspace ? samePage(pathname, itemUrl) : pathname === itemUrl) &&
1105
- !hasActiveSubItem;
1144
+ item.group !== items[index - 1]?.group ? (jsx("li", { className: "list-none px-3 pb-2.5 text-xs text-muted-foreground" +
1145
+ (index > 0 ? " pt-5" : ""), children: item.group }, `group-${item.id}`)) : null;
1146
+ const { itemUrl, activeSubItemUrl, isItemActive } = states[index];
1106
1147
  const IconComp = item.icon;
1107
1148
  const workspaceButton = workspace
1108
- ? "h-auto gap-3 rounded-[10px] px-3.5 py-[11px] text-sm " +
1149
+ ? "h-auto gap-3 rounded-[10px] px-3 py-2.5 text-[13px] " +
1109
1150
  (isItemActive
1110
1151
  ? "bg-sidebar-accent font-bold text-sidebar-accent-foreground"
1111
1152
  : "font-medium text-sidebar-foreground/70")
1112
1153
  : "";
1113
1154
  const icon = workspace && IconComp ? (jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
1114
1155
  const badge = workspace && item.badge !== undefined && item.badge !== "" ? (jsx("span", { className: "ml-auto min-w-5 rounded-[5px] bg-primary/15 px-1 text-center text-[10px] leading-5 font-bold tabular-nums text-primary", children: item.badge })) : null;
1115
- return (jsxs(React.Fragment, { children: [groupHeading, jsx(Collapsible, { asChild: true, defaultOpen: isItemActive || hasActiveSubItem, className: "group/collapsible", children: jsxs(SidebarMenuItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
1156
+ return (jsxs(React.Fragment, { children: [groupHeading, jsx(Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: open => setItemOpen(item.id, open), className: "group/collapsible", children: jsxs(SidebarMenuItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
1116
1157
  workspaceButton, children: item.subModules && item.subModules.length > 0 ? (jsxs(Fragment$1, { children: [icon, jsx("span", { id: item.id, children: item.titleTh }), jsx(ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] })) : (jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
1117
1158
  (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsx("span", { id: item.id, className: "flex-1 truncate", title: item.titleTh, children: item.titleTh }), badge] })) }) }), item.subModules && item.subModules.length > 0 && (jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: workspace
1118
- ? "mx-0 ml-[23px] mt-1 border-l border-sidebar-border pl-2.5 [&_a]:text-[13px] [&_svg]:!size-4"
1159
+ ? "mx-0 mt-1 mb-3 ml-[21px] gap-0.5 border-l border-sidebar-border pl-2.5 [&_a]:h-auto [&_a]:gap-[11px] [&_a]:px-3 [&_a]:py-2 [&_a]:text-[13px] [&_svg]:!size-[17px]"
1119
1160
  : undefined, children: item.subModules.map(subItem => {
1120
1161
  const subItemUrl = buildUrlWithCode(code, subItem.url);
1121
1162
  const isSubItemActive = subItemUrl === activeSubItemUrl;
1122
1163
  const SubIcon = subItem.icon;
1123
1164
  const approvalCount = approvalCounts && subItem.approvalCountKey
1124
- ? approvalCounts[subItem.approvalCountKey] ?? 0
1165
+ ? (approvalCounts[subItem.approvalCountKey] ?? 0)
1125
1166
  : 0;
1126
1167
  if (subItem.isCollapsible &&
1127
1168
  subItem.subPages &&
@@ -1136,7 +1177,12 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
1136
1177
  : "text-sidebar-foreground"}`, isActive: false, children: jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubPageIcon && (jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subPage.titleTh, children: subPage.titleTh })] }) }) }, subPage.id));
1137
1178
  }) }) })] }) }, subItem.id));
1138
1179
  }
1139
- return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: "w-full", isActive: isSubItemActive, children: jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), approvalCount > 0 && (jsx(Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99 ? "99+" : approvalCount }))] }) }) }, subItem.id));
1180
+ return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
1181
+ (workspace && isSubItemActive
1182
+ ? " font-semibold"
1183
+ : ""), isActive: isSubItemActive, children: jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), approvalCount > 0 && (jsx(Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
1184
+ ? "99+"
1185
+ : approvalCount }))] }) }) }, subItem.id));
1140
1186
  }) }) }))] }) })] }, item.id));
1141
1187
  }) }) }));
1142
1188
  }