@@ -19,24 +19,60 @@ function normalizePath(p: string): string {
1919 return trimmed === ' ' ? ' /' : trimmed ;
2020}
2121
22- function isItemActive(item : SecondaryTabNavItem , pathname : string ): boolean {
22+ function parseTabHref(href : string ): { path: string ; hash: string } {
23+ const hashIndex = href .indexOf (' #' );
24+ if (hashIndex === - 1 ) {
25+ return { path: normalizePath (href ), hash: ' ' };
26+ }
27+ return {
28+ path: normalizePath (href .slice (0 , hashIndex )),
29+ hash: href .slice (hashIndex + 1 ),
30+ };
31+ }
32+
33+ function getTabHashesOnPath(navItems : SecondaryTabNavItem [], itemPath : string ): string [] {
34+ return navItems
35+ .map ((item ) => parseTabHref (item .href ))
36+ .filter (({ path , hash }) => path === itemPath && hash !== ' ' )
37+ .map (({ hash }) => hash );
38+ }
39+
40+ function isItemActive(
41+ item : SecondaryTabNavItem ,
42+ pathname : string ,
43+ currentHash : string ,
44+ navItems : SecondaryTabNavItem []
45+ ): boolean {
46+ const { path : itemPath, hash : itemHash } = parseTabHref (item .href );
2347 const path = normalizePath (pathname );
24- const base = normalizePath ( item . href );
48+
2549 if (item .exact ) {
26- return path === base ;
50+ if (path !== itemPath ) return false ;
51+ } else if (path !== itemPath && ! path .startsWith (` ${itemPath }/ ` )) {
52+ return false ;
53+ }
54+
55+ if (itemHash ) {
56+ return currentHash === itemHash ;
57+ }
58+
59+ const tabHashes = getTabHashesOnPath (navItems , itemPath );
60+ if (tabHashes .length === 0 ) {
61+ return true ;
2762 }
28- return path === base || path . startsWith ( ` ${ base }/ ` );
63+ return ! tabHashes . includes ( currentHash );
2964}
3065
3166const currentPath = Astro .url .pathname ;
32- const activeItem = items .find ((item ) => isItemActive (item , currentPath )) ?? items [0 ];
67+ const activeItem = items .find ((item ) => isItemActive (item , currentPath , ' ' , items )) ?? items [0 ];
3368const activeLabel = activeItem .label ;
3469
3570const mobileMenuId = ` ${idPrefix }-mobile-menu ` ;
3671const mobileTriggerId = ` ${idPrefix }-mobile-trigger ` ;
72+ const hasHashTabs = items .some ((item ) => parseTabHref (item .href ).hash !== ' ' );
3773---
3874
39- <nav class =" secondary-tab-nav" aria-label ={ ariaLabel } >
75+ <nav class =" secondary-tab-nav" aria-label ={ ariaLabel } data-hash-tabs = { hasHashTabs || undefined } >
4076 <div class =" secondary-tab-nav-mobile md:hidden" x-data =" { open: false }" >
4177 <button
4278 type =" button"
@@ -48,7 +84,9 @@ const mobileTriggerId = `${idPrefix}-mobile-trigger`;
4884 aria-controls ={ mobileMenuId }
4985 >
5086 <span class =" secondary-tab-nav-mobile-label" >{ mobileLabel } </span >
51- <span class =" secondary-tab-nav-mobile-selected" >{ activeLabel } </span >
87+ <span class =" secondary-tab-nav-mobile-selected" data-secondary-tab-mobile-selected
88+ >{ activeLabel } </span
89+ >
5290 <span
5391 class =" secondary-tab-nav-mobile-chevron"
5492 :class =" { 'secondary-tab-nav-mobile-chevron--open': open }"
@@ -69,46 +107,122 @@ const mobileTriggerId = `${idPrefix}-mobile-trigger`;
69107 >
70108 <ul class =" secondary-tab-nav-mobile-list" role =" presentation" >
71109 {
72- items .map ((item ) => (
73- <li role = " presentation" >
74- <a
75- href = { item .href }
76- class :list = { [
77- ' secondary-tab-nav-mobile-link' ,
78- { ' secondary-tab-nav-mobile-link--active' : isItemActive (item , currentPath ) },
79- ]}
80- role = " option"
81- aria-selected = { isItemActive (item , currentPath ) ? ' true' : ' false' }
82- @click="open = false"
83- data-astro-prefetch = " hover"
84- >
85- <span >{ item .label } </span >
86- </a >
87- </li >
88- ))
110+ items .map ((item ) => {
111+ const { path : tabPath, hash : tabHash } = parseTabHref (item .href );
112+ const active = isItemActive (item , currentPath , ' ' , items );
113+ return (
114+ <li role = " presentation" >
115+ <a
116+ href = { item .href }
117+ class :list = { [
118+ ' secondary-tab-nav-mobile-link' ,
119+ { ' secondary-tab-nav-mobile-link--active' : active },
120+ ]}
121+ role = " option"
122+ aria-selected = { active ? ' true' : ' false' }
123+ data-secondary-tab-path = { tabPath }
124+ data-secondary-tab-hash = { tabHash }
125+ data-secondary-tab-label = { item .label }
126+ @click="open = false"
127+ data-astro-prefetch = " hover"
128+ >
129+ <span >{ item .label } </span >
130+ </a >
131+ </li >
132+ );
133+ })
89134 }
90135 </ul >
91136 </div >
92137 </div >
93138
94139 <ul class =" secondary-tab-nav__list" >
95140 {
96- items .map ((item ) => (
97- <li >
98- <a
99- href = { item .href }
100- class :list = { [
101- ' secondary-tab-nav__tab' ,
102- {
103- ' secondary-tab-nav__tab--active' : isItemActive (item , currentPath ),
104- },
105- ]}
106- aria-current = { isItemActive (item , currentPath ) ? ' page' : undefined }
107- >
108- { item .label }
109- </a >
110- </li >
111- ))
141+ items .map ((item ) => {
142+ const { path : tabPath, hash : tabHash } = parseTabHref (item .href );
143+ const active = isItemActive (item , currentPath , ' ' , items );
144+ return (
145+ <li >
146+ <a
147+ href = { item .href }
148+ class :list = { [
149+ ' secondary-tab-nav__tab' ,
150+ {
151+ ' secondary-tab-nav__tab--active' : active ,
152+ },
153+ ]}
154+ aria-current = { active ? ' page' : undefined }
155+ data-secondary-tab-path = { tabPath }
156+ data-secondary-tab-hash = { tabHash }
157+ data-secondary-tab-label = { item .label }
158+ >
159+ { item .label }
160+ </a >
161+ </li >
162+ );
163+ })
112164 }
113165 </ul >
114166</nav >
167+
168+ <script >
169+ function normalizeTabPath(path: string): string {
170+ const trimmed = path.replace(/\/+$/, '');
171+ return trimmed === '' ? '/' : trimmed;
172+ }
173+
174+ function syncSecondaryTabNavHashState() {
175+ const currentPath = normalizeTabPath(window.location.pathname);
176+ const currentHash = window.location.hash.slice(1);
177+
178+ document.querySelectorAll('[data-hash-tabs]').forEach((nav) => {
179+ const links = nav.querySelectorAll<HTMLAnchorElement>('[data-secondary-tab-path]');
180+ const tabHashes = Array.from(links)
181+ .map((link) => link.dataset.secondaryTabHash ?? '')
182+ .filter((hash) => hash !== '');
183+
184+ let activeLabel = '';
185+
186+ links.forEach((link) => {
187+ const tabPath = link.dataset.secondaryTabPath ?? '';
188+ const tabHash = link.dataset.secondaryTabHash ?? '';
189+ if (tabPath !== currentPath) return;
190+
191+ const isActive = tabHash
192+ ? currentHash === tabHash
193+ : tabHashes.length === 0 || !tabHashes.includes(currentHash);
194+
195+ link.classList.toggle('secondary-tab-nav__tab--active', isActive);
196+ link.classList.toggle('secondary-tab-nav-mobile-link--active', isActive);
197+
198+ if (link.classList.contains('secondary-tab-nav__tab')) {
199+ if (isActive) {
200+ link.setAttribute('aria-current', 'page');
201+ } else {
202+ link.removeAttribute('aria-current');
203+ }
204+ } else {
205+ link.setAttribute('aria-selected', isActive ? 'true' : 'false');
206+ }
207+ if (isActive) {
208+ activeLabel = link.dataset.secondaryTabLabel ?? link.textContent?.trim() ?? '';
209+ }
210+ });
211+
212+ const selected = nav.querySelector('[data-secondary-tab-mobile-selected]');
213+ if (selected && activeLabel) {
214+ selected.textContent = activeLabel;
215+ }
216+ });
217+ }
218+
219+ const globalHook = globalThis as typeof globalThis & {
220+ __secondaryTabNavHashHook?: boolean;
221+ };
222+
223+ if (!globalHook.__secondaryTabNavHashHook) {
224+ globalHook.__secondaryTabNavHashHook = true;
225+ syncSecondaryTabNavHashState();
226+ window.addEventListener('hashchange', syncSecondaryTabNavHashState);
227+ }
228+ </script >
0 commit comments