@helloimjolopez-pathway/pathway-components 0.0.0-stage → 0.1.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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * NavShell, Pathway Design System
3
+ *
4
+ * Composes TopNav + SideNav + Shell.Main into the application frame that wraps
5
+ * every Amplify page. Not a Figma component set (the shell has no Figma node of
6
+ * its own); it is a layout composition of TopNav, SideNav, and PageTemplate.
7
+ *
8
+ * Spec: components/nav-shell/nav-shell-spec.md
9
+ * Figma: TopNav 40007067:6508, SideNav.Container 40004059:1375,
10
+ * ScreenTemplate (set) 40009709:26016
11
+ */
12
+
13
+ import React, { useState, useEffect, useCallback } from "react";
14
+ import TopNav, { DEFAULT_MODULES } from "../top-nav/top-nav.jsx";
15
+ import { SideNav } from "../sidenav/sidenav.jsx";
16
+
17
+ // ─── CONSTANTS ────────────────────────────────────────────────────────────────
18
+ const TOPNAV_H = 60; // px — spec §9.3 rule 1
19
+
20
+ // SideNav widths via contextual tokens (same source as sidenav.jsx L.navW/L.navWcol)
21
+ const CL = (p) => `var(--contextual-layout-units-${p})`;
22
+ const SIDENAV_W_EXP = CL("sidenav-width-expanded");
23
+ const SIDENAV_W_COL = CL("sidenav-width-collapsed");
24
+
25
+ // ─── HOOK: responsive breakpoint ─────────────────────────────────────────────
26
+ function useBreakpoint() {
27
+ const get = () => {
28
+ if (typeof window === "undefined") return "desktop";
29
+ if (window.innerWidth < 768) return "mobile";
30
+ if (window.innerWidth < 1024) return "tablet";
31
+ return "desktop";
32
+ };
33
+ const [bp, setBp] = useState(get);
34
+ useEffect(() => {
35
+ const h = () => setBp(get());
36
+ window.addEventListener("resize", h, { passive: true });
37
+ return () => window.removeEventListener("resize", h);
38
+ }, []);
39
+ return bp;
40
+ }
41
+
42
+ // ─── COMPONENT ────────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * NavShell — the fixed application frame.
46
+ *
47
+ * Layout zones (always present):
48
+ * TopNav fixed, full width, 60px tall, z-index 100
49
+ * SideNav fixed, left edge, top 60px → bottom, push layout
50
+ * Shell.Main scrollable content area, margin-left = SideNav width
51
+ *
52
+ * Desktop: SideNav 240px expanded | 84px collapsed, user toggles
53
+ * Tablet: SideNav always 84px rail, toggle locked
54
+ * Mobile: SideNav hidden, slides in as 240px overlay on hamburger tap
55
+ *
56
+ * Props (TopNav pass-through):
57
+ * modules Array<{id, label, icon}> — all modules (default: DEFAULT_MODULES)
58
+ * activeModuleId string — which module is active
59
+ * org {id, name, campus, initials, logoUrl?}
60
+ * orgs Array<org> — OrgSwitcher dropdown list
61
+ * user {name, initials, email}
62
+ * onModuleSelect (id) => void
63
+ * onOrgSelect (id) => void
64
+ *
65
+ * Props (SideNav pass-through):
66
+ * navItems Array<SideNavItem> — flat nav items (use this OR navSections)
67
+ * navSections Array<{label, items}> — sectioned nav items
68
+ * activeNavId string — current page id
69
+ * onNavigate (id) => void
70
+ * navHeaderEnd ReactNode — slot after collapse toggle in nav header
71
+ * navChildren ReactNode — slot inside scroll area, after items
72
+ * navFooter ReactNode — slot pinned below scroll area
73
+ *
74
+ * Props (shell):
75
+ * defaultCollapsed boolean — initial desktop collapse state (default false)
76
+ * children ReactNode — page content (renders inside Shell.Main)
77
+ */
78
+ export function NavShell({
79
+ // TopNav
80
+ modules = DEFAULT_MODULES,
81
+ activeModuleId = "home",
82
+ org,
83
+ orgs,
84
+ user,
85
+ onModuleSelect,
86
+ onOrgSelect,
87
+
88
+ // SideNav
89
+ navItems,
90
+ navSections,
91
+ activeNavId,
92
+ onNavigate,
93
+ navHeaderEnd = null,
94
+ navChildren = null,
95
+ navFooter = null,
96
+
97
+ // Shell
98
+ defaultCollapsed = false,
99
+ children,
100
+ }) {
101
+ const breakpoint = useBreakpoint();
102
+ const isMobile = breakpoint === "mobile";
103
+ const isTablet = breakpoint === "tablet";
104
+
105
+ // Desktop collapse state (controlled within NavShell)
106
+ const [collapsed, setCollapsed] = useState(defaultCollapsed);
107
+ // Mobile overlay open state
108
+ const [mobileOpen, setMobileOpen] = useState(false);
109
+
110
+ // Tablet: rail (84px) is fixed, no user toggle
111
+ // Mobile: always "collapsed" visually (but managed separately via mobileOpen)
112
+ // Desktop: user-controlled
113
+ const effectiveCollapsed = isTablet || isMobile ? true : collapsed;
114
+
115
+ // Shell.Main margin-left: matches current SideNav width for push layout.
116
+ // Mobile: no margin (SideNav is an overlay).
117
+ const mainMarginLeft = isMobile
118
+ ? 0
119
+ : effectiveCollapsed ? SIDENAV_W_COL : SIDENAV_W_EXP;
120
+
121
+ const handleCollapseChange = useCallback((next) => {
122
+ if (!isTablet && !isMobile) setCollapsed(next);
123
+ }, [isTablet, isMobile]);
124
+
125
+ // TopNav calls this when hamburger (mobile) or collapse button (desktop) is tapped
126
+ const handleSideNavToggle = useCallback(() => {
127
+ if (isMobile) setMobileOpen(o => !o);
128
+ else if (!isTablet) setCollapsed(c => !c);
129
+ }, [isMobile, isTablet]);
130
+
131
+ // Close mobile overlay on Escape — spec §8 mobile hamburger rules
132
+ useEffect(() => {
133
+ if (!mobileOpen) return;
134
+ const h = (e) => { if (e.key === "Escape") setMobileOpen(false); };
135
+ document.addEventListener("keydown", h);
136
+ return () => document.removeEventListener("keydown", h);
137
+ }, [mobileOpen]);
138
+
139
+ // Also close mobile overlay when breakpoint changes away from mobile
140
+ useEffect(() => {
141
+ if (!isMobile) setMobileOpen(false);
142
+ }, [isMobile]);
143
+
144
+ // ── Render ─────────────────────────────────────────────────────────────────
145
+
146
+ return (
147
+ <>
148
+ {/* Skip navigation — first focusable element, WCAG 2.4.1 */}
149
+ <a
150
+ href="#main-content"
151
+ style={{
152
+ position: "absolute", top: -48, left: 8, zIndex: 300,
153
+ background: "var(--semantic-color-fill-action-primary-strong-rest)",
154
+ color: "var(--semantic-color-foreground-action-ghost-rest)",
155
+ padding: "8px 12px", borderRadius: 6,
156
+ fontSize: 14, fontWeight: 500, textDecoration: "none",
157
+ transition: "top var(--motion-duration-2) var(--motion-easing-standard)",
158
+ }}
159
+ onFocus={(e) => { e.currentTarget.style.top = "8px"; }}
160
+ onBlur={(e) => { e.currentTarget.style.top = "-48px"; }}
161
+ >
162
+ Skip to content
163
+ </a>
164
+
165
+ {/* ── TopNav: position fixed, full width, 60px ─────────────────────── */}
166
+ <TopNav
167
+ modules={modules}
168
+ activeModuleId={activeModuleId}
169
+ org={org}
170
+ orgs={orgs}
171
+ user={user}
172
+ breakpoint={breakpoint}
173
+ onModuleSelect={onModuleSelect}
174
+ onOrgSelect={onOrgSelect}
175
+ onSideNavToggle={handleSideNavToggle}
176
+ />
177
+
178
+ {/* ── Mobile scrim — darkens content while SideNav overlay is open ─── */}
179
+ {isMobile && mobileOpen && (
180
+ <div
181
+ aria-hidden="true"
182
+ onClick={() => setMobileOpen(false)}
183
+ style={{
184
+ position: "fixed", inset: 0, top: TOPNAV_H,
185
+ background: "rgba(0,0,0,0.32)",
186
+ zIndex: 49,
187
+ }}
188
+ />
189
+ )}
190
+
191
+ {/* ── SideNav container ────────────────────────────────────────────── */}
192
+ {/* Position: fixed so it stays below TopNav while the content scrolls.
193
+ On mobile: width controls the overlay reveal (0 → 240px); the SideNav
194
+ itself renders inside a clipping container and slides in from the left.
195
+ On desktop/tablet: overflow hidden so the SideNav's own width transition
196
+ stays within the container; no width needed here. */}
197
+ <div
198
+ aria-hidden={isMobile && !mobileOpen}
199
+ style={{
200
+ position: "fixed",
201
+ top: TOPNAV_H,
202
+ left: 0,
203
+ bottom: 0,
204
+ zIndex: isMobile ? 51 : 10,
205
+ overflow: "hidden",
206
+ // Mobile: clipping container, SideNav slides in via its own width
207
+ ...(isMobile && {
208
+ width: mobileOpen
209
+ ? SIDENAV_W_EXP
210
+ : 0,
211
+ transition: `width var(--motion-duration-6) var(--motion-easing-emphasized)`,
212
+ }),
213
+ }}
214
+ >
215
+ <SideNav
216
+ items={navItems}
217
+ sections={navSections}
218
+ activeId={activeNavId}
219
+ onNavigate={onNavigate}
220
+ collapsed={effectiveCollapsed}
221
+ onCollapseChange={handleCollapseChange}
222
+ hideCollapseButton={isTablet}
223
+ headerEnd={navHeaderEnd}
224
+ footer={navFooter}
225
+ >
226
+ {navChildren}
227
+ </SideNav>
228
+ </div>
229
+
230
+ {/* ── Shell.Main: scrollable content area ─────────────────────────── */}
231
+ {/* margin-left matches the SideNav width and transitions simultaneously.
232
+ spec §9.3 rule 3: never overflow hidden here. */}
233
+ <main
234
+ id="main-content"
235
+ role="main"
236
+ style={{
237
+ marginTop: TOPNAV_H,
238
+ marginLeft: mainMarginLeft,
239
+ minHeight: `calc(100vh - ${TOPNAV_H}px)`,
240
+ overflowY: "auto",
241
+ overflowX: "hidden",
242
+ background: "var(--semantic-color-fill-surface-canvas)",
243
+ transition: `margin-left var(--motion-duration-6) var(--motion-easing-emphasized)`,
244
+ }}
245
+ >
246
+ {children}
247
+ </main>
248
+ </>
249
+ );
250
+ }
251
+
252
+ export default NavShell;