@gsa-tts/graymatter-ui 0.3.17 → 0.3.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsa-tts/graymatter-ui",
3
- "version": "0.3.17",
3
+ "version": "0.3.19",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -148,9 +148,9 @@
148
148
  parent: SubNavItem | null
149
149
  ): SubNavItem | null {
150
150
  for (const it of items) {
151
- if (it.id === item.id) return parent ?? it;
152
- if (it.children) {
153
- const found = search(it.children, parent ?? it);
151
+ if (it.id === item.id) return parent || it;
152
+ if (it.children && it.children.length > 0) {
153
+ const found = search(it.children, it);
154
154
  if (found) return found;
155
155
  }
156
156
  }
@@ -179,6 +179,7 @@
179
179
 
180
180
  let currentSelectedId = $state<string | null>(null);
181
181
  let hydrated = $state(false);
182
+ let pageLoadComplete = $state(false);
182
183
 
183
184
  const effectiveSelectedId = $derived(() => {
184
185
  if (!hydrated || !data) return null;
@@ -195,28 +196,28 @@
195
196
  }
196
197
  }
197
198
 
198
- // Second priority: sessionStorage
199
+ // Second priority: Check if we have a persisted selectedId from sessionStorage
199
200
  if (typeof window !== 'undefined') {
200
201
  const storedId = sessionStorage.getItem('subNav-selectedId');
201
202
  if (storedId) {
202
203
  try {
203
204
  const parsedId = JSON.parse(storedId);
204
- if (parsedId && parsedId !== 'null' && parsedId !== '') {
205
- const targetItem = findItemById(data, parsedId);
206
- if (targetItem) {
207
- return targetItem.id;
208
- }
205
+ const targetItem = findItemById(data, parsedId);
206
+ if (targetItem) {
207
+ return parsedId;
209
208
  }
210
- } catch (e) {
211
- console.log('[SubNavMenu] Error parsing stored ID:', e);
209
+ } catch {
210
+ // Invalid JSON, ignore
212
211
  }
213
212
  }
214
213
  }
215
214
 
216
- // Third priority: first item
217
- const firstItem = findFirstItem(data);
218
- if (firstItem && firstItem.title) {
219
- return firstItem.id;
215
+ // Fallback: use first item when no other selection method works
216
+ if (pageLoadComplete) {
217
+ const firstItem = findFirstItem(data);
218
+ if (firstItem && firstItem.title) {
219
+ return firstItem.id;
220
+ }
220
221
  }
221
222
 
222
223
  return null;
@@ -226,6 +227,27 @@
226
227
  $effect(() => {
227
228
  if (effectiveSelectedId() && effectiveSelectedId() !== currentSelectedId) {
228
229
  currentSelectedId = effectiveSelectedId();
230
+
231
+ // When restoring from sessionStorage, also restore the correct header
232
+ // Only do this if we're on the API documentation page (where this component exists)
233
+ if (data && effectiveSelectedId() && typeof window !== 'undefined') {
234
+ const currentPath = window.location.pathname;
235
+ const isApiDocumentationPage =
236
+ currentPath.includes('/api/documentation');
237
+
238
+ if (isApiDocumentationPage) {
239
+ const restoredItem = findItemById(data, effectiveSelectedId()!);
240
+ if (restoredItem) {
241
+ const topLevel =
242
+ findTopLevelParent(restoredItem, data) || restoredItem;
243
+ import('@gsa-tts/graymatter-ui/stores/navigationStore')
244
+ .then(({ selectedSubNavItemTitle }) => {
245
+ selectedSubNavItemTitle.set(topLevel.title || '');
246
+ })
247
+ .catch();
248
+ }
249
+ }
250
+ }
229
251
  }
230
252
  });
231
253
 
@@ -234,53 +256,46 @@
234
256
 
235
257
  if (!data) return;
236
258
 
237
- // Handle header updates and scrolling
238
- const targetItem = currentSelectedId
239
- ? findItemById(data, currentSelectedId)
240
- : null;
241
- if (targetItem) {
242
- // Always resolve top-level parent from data
243
- const topLevel = findTopLevelParent(targetItem, data) || targetItem;
244
- import('@gsa-tts/graymatter-ui/stores/navigationStore')
245
- .then(({ selectedSubNavItemTitle }) => {
246
- selectedSubNavItemTitle.set(topLevel.title || '');
247
- if (typeof window !== 'undefined') {
248
- sessionStorage.setItem(
249
- 'subNav-selectedId',
250
- JSON.stringify(targetItem.id)
251
- );
252
- sessionStorage.setItem(
253
- 'subNav-topLevelId',
254
- JSON.stringify(topLevel.id)
255
- );
256
- sessionStorage.setItem(
257
- 'subNav-topLevelTitle',
258
- JSON.stringify(topLevel.title || '')
259
- );
260
- }
261
- })
262
- .catch();
263
-
264
- // Handle scrolling if we have a URL hash
265
- if (targetItem.href && targetItem.href.includes('#')) {
266
- const hash = targetItem.href.split('#')[1];
267
- if (hash) {
268
- setTimeout(() => scrollToAnchorWithOffset(hash), 100);
259
+ // Check if we're navigating TO the documentation page (vs refreshing while on it)
260
+ if (typeof window !== 'undefined') {
261
+ const currentPath = window.location.pathname;
262
+ const isDocumentationPage = currentPath.includes('/api/documentation');
263
+
264
+ // If we're on the documentation page, check if we came from a different page
265
+ if (isDocumentationPage) {
266
+ const referrer = document.referrer;
267
+ const isFromDifferentPage =
268
+ referrer && !referrer.includes('/api/documentation');
269
+
270
+ // If we came from a different page, clear sessionStorage to reset to initial values
271
+ if (isFromDifferentPage) {
272
+ sessionStorage.removeItem('subNav-selectedId');
273
+ sessionStorage.removeItem('subNav-selectedTitle');
274
+ sessionStorage.removeItem('subNav-topLevelId');
275
+ sessionStorage.removeItem('subNav-topLevelTitle');
269
276
  }
270
277
  }
271
278
 
272
- // Always dispatch sectionVisible with top-level parent
273
- if (topLevel.title && typeof window !== 'undefined') {
274
- window.dispatchEvent(
275
- new CustomEvent('sectionVisible', {
276
- detail: {
277
- title: topLevel.title,
278
- id: topLevel.id,
279
- },
280
- })
281
- );
279
+ // Clean up any leftover sessionStorage items
280
+ sessionStorage.removeItem('__subNavPageLoaded');
281
+ }
282
+
283
+ // Reset currentSelectedId to ensure clean state
284
+ currentSelectedId = null;
285
+
286
+ // Handle scrolling if we have a URL hash
287
+ const targetItem = currentSelectedId
288
+ ? findItemById(data, currentSelectedId)
289
+ : null;
290
+ if (targetItem && targetItem.href && targetItem.href.includes('#')) {
291
+ const hash = targetItem.href.split('#')[1];
292
+ if (hash) {
293
+ setTimeout(() => scrollToAnchorWithOffset(hash), 100);
282
294
  }
283
295
  }
296
+
297
+ // Mark page load as complete
298
+ pageLoadComplete = true;
284
299
  subNavReady.set(true);
285
300
  });
286
301
 
@@ -293,22 +308,17 @@
293
308
  }
294
309
  currentSelectedId = item.id;
295
310
  const topLevel = findTopLevelParent(item, data) || item;
311
+
296
312
  import('@gsa-tts/graymatter-ui/stores/navigationStore')
297
313
  .then(({ selectedSubNavItemTitle }) => {
298
314
  selectedSubNavItemTitle.set(topLevel.title || '');
299
- if (typeof window !== 'undefined') {
300
- sessionStorage.setItem('subNav-selectedId', JSON.stringify(item.id));
301
- sessionStorage.setItem(
302
- 'subNav-topLevelId',
303
- JSON.stringify(topLevel.id)
304
- );
305
- sessionStorage.setItem(
306
- 'subNav-topLevelTitle',
307
- JSON.stringify(topLevel.title || '')
308
- );
309
- }
310
315
  })
311
316
  .catch();
317
+
318
+ // Save selectedId to sessionStorage for persistence
319
+ if (typeof window !== 'undefined') {
320
+ sessionStorage.setItem('subNav-selectedId', JSON.stringify(item.id));
321
+ }
312
322
  if (typeof window !== 'undefined') {
313
323
  if (item.href && item.href.includes('#')) {
314
324
  const hash = item.href.split('#')[1];
@@ -19,7 +19,6 @@
19
19
  import ApiIcon from './icons/ApiIcon.svelte';
20
20
  import MenuIcon from './icons/MenuIcon.svelte';
21
21
  import ProfileMenu from './ProfileMenu.svelte';
22
- import { getAppUrls } from '../utils/getAppUrls';
23
22
  // import HelpIcon from './icons/HelpIcon.svelte';
24
23
 
25
24
  const {
@@ -35,12 +34,6 @@
35
34
  discoverUrl,
36
35
  } = $props();
37
36
 
38
- // Only call getAppUrls if not all URLs are provided via props
39
- const urls =
40
- apiUrl && chatUrl && consoleUrl && discoverUrl
41
- ? { api: '#', chat: '#', console: '#', discover: '#' }
42
- : getAppUrls();
43
-
44
37
  let hydrated = false;
45
38
  let navContainer: HTMLDivElement;
46
39
  let hoveredItem = $state<string | null>(null);
@@ -332,7 +325,7 @@
332
325
  <div class="nav-items" part="nav-items">
333
326
  {#if !hideDiscover}
334
327
  <a
335
- href={discoverUrl || urls.discover}
328
+ href={discoverUrl || '#'}
336
329
  class="nav-item discover-item"
337
330
  class:selected={isNavSelected('discover')}
338
331
  class:hovered={isNavHovered('discover')}
@@ -356,7 +349,7 @@
356
349
  </a>
357
350
  {/if}
358
351
  <a
359
- href={chatUrl || urls.chat}
352
+ href={chatUrl || '#'}
360
353
  class="nav-item chat-item"
361
354
  class:selected={isNavSelected('chat')}
362
355
  class:hovered={isNavHovered('chat')}
@@ -380,7 +373,7 @@
380
373
  </a>
381
374
 
382
375
  <a
383
- href={consoleUrl || urls.console}
376
+ href={consoleUrl || '#'}
384
377
  class="nav-item console-item"
385
378
  class:selected={isNavSelected('console')}
386
379
  class:hovered={isNavHovered('console')}
@@ -404,7 +397,7 @@
404
397
  </a>
405
398
 
406
399
  <a
407
- href={apiUrl || urls.api}
400
+ href={apiUrl || '#'}
408
401
  class="nav-item api-item"
409
402
  class:selected={isNavSelected('api')}
410
403
  class:hovered={isNavHovered('api')}
@@ -5,7 +5,6 @@
5
5
  import ChatIcon from './icons/ChatIcon.svelte';
6
6
  import ConsoleIcon from './icons/ConsoleIcon.svelte';
7
7
  import ApiIcon from './icons/ApiIcon.svelte';
8
- import { getAppUrls } from '../utils/getAppUrls';
9
8
  import slugify from 'slugify';
10
9
 
11
10
  let {
@@ -29,27 +28,21 @@
29
28
  discoverUrl?: string;
30
29
  } = $props();
31
30
 
32
- // Only call getAppUrls if not all URLs are provided via props
33
- const urls =
34
- apiUrl && chatUrl && consoleUrl && discoverUrl
35
- ? { api: '#', chat: '#', console: '#', discover: '#' }
36
- : getAppUrls();
37
-
38
31
  const allNavItems = [
39
32
  {
40
33
  id: 'discover',
41
34
  label: 'Discover',
42
35
  icon: DiscoverIcon,
43
- href: discoverUrl || urls.discover,
36
+ href: discoverUrl || '#',
44
37
  },
45
- { id: 'chat', label: 'Chat', icon: ChatIcon, href: chatUrl || urls.chat },
38
+ { id: 'chat', label: 'Chat', icon: ChatIcon, href: chatUrl || '#' },
46
39
  {
47
40
  id: 'console',
48
41
  label: 'Console',
49
42
  icon: ConsoleIcon,
50
- href: consoleUrl || urls.console,
43
+ href: consoleUrl || '#',
51
44
  },
52
- { id: 'api', label: 'API', icon: ApiIcon, href: apiUrl || urls.api },
45
+ { id: 'api', label: 'API', icon: ApiIcon, href: apiUrl || '#' },
53
46
  ] as const;
54
47
 
55
48
  // Filter out discover item if hideDiscover is true
@@ -8,6 +8,7 @@ import MobileBottomNav from '../components/MobileBottomNav.svelte';
8
8
  import MobileSideMenu from '../components/MobileSideMenu.svelte';
9
9
  import Logo from '../components/Logo.svelte';
10
10
  import { siteName } from '../constants';
11
+ import { getAppUrls } from '../utils/getAppUrls';
11
12
 
12
13
  const {
13
14
  componentOptions = {},
@@ -21,6 +22,8 @@ const {
21
22
  description,
22
23
  openGraphImage,
23
24
  } = Astro.props;
25
+
26
+ const { api, chat, console, discover } = getAppUrls();
24
27
  ---
25
28
 
26
29
  <BaseLayout title={pageTitle} {gtmID} {description} {openGraphImage}>
@@ -33,6 +36,10 @@ const {
33
36
  ssrSelectedItem={ssrSelectedItem}
34
37
  {showAppIcons}
35
38
  {profileMenuData}
39
+ apiUrl={api}
40
+ chatUrl={chat}
41
+ consoleUrl={console}
42
+ discoverUrl={discover}
36
43
  >
37
44
  <slot name="sub-nav" />
38
45
  </DesktopSideNav>
@@ -54,11 +61,22 @@ const {
54
61
  {profileMenuData}
55
62
  {logoLinkUrl}
56
63
  {logoLinkLabel}
64
+ apiUrl={api}
65
+ chatUrl={chat}
66
+ consoleUrl={console}
67
+ discoverUrl={discover}
57
68
  />
58
69
  <MobileSideMenu client:load {logoLinkUrl} {logoLinkLabel}>
59
70
  <slot name="sub-nav" />
60
71
  </MobileSideMenu>
61
- <MobileBottomNav client:load {showAppIcons} />
72
+ <MobileBottomNav
73
+ client:load
74
+ {showAppIcons}
75
+ apiUrl={api}
76
+ chatUrl={chat}
77
+ consoleUrl={console}
78
+ discoverUrl={discover}
79
+ />
62
80
  </nav>
63
81
 
64
82
  <!-- Fixed Logo (Desktop only) -->
@@ -1,11 +1,23 @@
1
1
  import { getUrlFromBase } from '@gsa-tts/graymatter-ui/helpers/url.js';
2
2
 
3
+ /**
4
+ * Logs a warning message when an environment variable is missing.
5
+ *
6
+ * @param name - The name of the missing environment variable
7
+ * @param value - The value of the environment variable (should be undefined)
8
+ */
3
9
  function warnIfMissing(name: string, value: string | undefined) {
4
10
  if (!value) {
5
11
  console.warn(`[getAppUrls] Environment variable for ${name} is missing!`);
6
12
  }
7
13
  }
8
14
 
15
+ /**
16
+ * Checks if a URL is absolute (starts with protocol like http:// or https://).
17
+ *
18
+ * @param url - The URL string to check
19
+ * @returns True if the URL is absolute, false otherwise
20
+ */
9
21
  function isAbsoluteUrl(url: string): boolean {
10
22
  try {
11
23
  new URL(url);
@@ -15,6 +27,24 @@ function isAbsoluteUrl(url: string): boolean {
15
27
  }
16
28
  }
17
29
 
30
+ /**
31
+ * Processes a URL string, handling both absolute and relative URLs.
32
+ *
33
+ * If the URL is undefined, returns the fallback. If it's absolute (starts with protocol),
34
+ * returns it as-is. If it's relative, processes it through `getUrlFromBase()` to create
35
+ * a full URL.
36
+ *
37
+ * @param url - The URL string to process (can be undefined)
38
+ * @param fallback - The fallback URL to use if the input URL is undefined
39
+ * @returns The processed URL string (can be absolute URL or relative path)
40
+ *
41
+ * @example
42
+ * ```typescript
43
+ * processUrl('https://example.com', '/fallback'); // "https://example.com"
44
+ * processUrl('/api/docs', '/fallback'); // "/api/docs" (processed by getUrlFromBase)
45
+ * processUrl(undefined, '/fallback'); // "/fallback"
46
+ * ```
47
+ */
18
48
  function processUrl(url: string | undefined, fallback: string): string {
19
49
  if (!url) {
20
50
  return fallback;
@@ -27,6 +57,60 @@ function processUrl(url: string | undefined, fallback: string): string {
27
57
  return getUrlFromBase(url);
28
58
  }
29
59
 
60
+ /**
61
+ * Retrieves application URLs/paths from environment variables with fallback support.
62
+ *
63
+ * This utility function processes environment variables to provide URLs or paths for different
64
+ * application sections (chat, console, API, discover). It supports both absolute URLs
65
+ * and relative paths, with intelligent fallback handling.
66
+ *
67
+ * @param envArg - Optional environment object to use instead of `import.meta.env`.
68
+ * Useful for testing or when you want to override environment variables.
69
+ * If not provided, uses Vite's `import.meta.env`.
70
+ *
71
+ * @returns An object containing processed URLs/paths for each application section:
72
+ * - `chat`: Chat application URL or path
73
+ * - `console`: Console application URL or path
74
+ * - `api`: API documentation URL or path
75
+ * - `discover`: Discover page URL or path
76
+ *
77
+ * @example
78
+ * ```typescript
79
+ * // Using default environment variables
80
+ * const urls = getAppUrls();
81
+ * console.log(urls.chat); // "#" (fallback) or "https://chat.example.com" (URL)
82
+ *
83
+ * // Using custom environment object (useful for testing)
84
+ * const urls = getAppUrls({
85
+ * PUBLIC_CHAT_URL: 'https://chat.dev/',
86
+ * PUBLIC_CONSOLE_URL: 'https://console.dev/',
87
+ * PUBLIC_API_URL: 'https://api.dev/docs',
88
+ * PUBLIC_DISCOVER_URL: 'https://discover.dev/'
89
+ * });
90
+ * ```
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * // Environment variables (.env file)
95
+ * PUBLIC_CHAT_URL=https://chat.example.com
96
+ * PUBLIC_CONSOLE_URL=https://console.example.com
97
+ * PUBLIC_API_URL=/api/documentation
98
+ * PUBLIC_DISCOVER_URL=https://discover.example.com
99
+ *
100
+ * // Usage in code
101
+ * const { chat, console, api, discover } = getAppUrls();
102
+ * ```
103
+ *
104
+ * @remarks
105
+ * - URLs are processed through `processUrl()` which handles both absolute and relative URLs
106
+ * - Relative URLs are processed through `getUrlFromBase()` to create full URLs
107
+ * - Missing environment variables result in fallback values (`#` for most, `/api/documentation` for API)
108
+ * - Warnings are logged in development when environment variables are missing
109
+ * - The function is designed to work with Vite's `import.meta.env` system
110
+ *
111
+ * @see {@link processUrl} - URL processing logic
112
+ * @see {@link getUrlFromBase} - Base URL resolution
113
+ */
30
114
  export function getAppUrls(envArg?: Record<string, string | undefined>) {
31
115
  const env = envArg || import.meta.env;
32
116
  const fallback = '#';