@gsa-tts/graymatter-ui 0.3.14 → 0.3.16

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.14",
3
+ "version": "0.3.16",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -18,6 +18,9 @@
18
18
  "./components": "./src/components/index.ts",
19
19
  "./components/*": "./src/components/*",
20
20
  "./constants": "./src/constants/index.ts",
21
+ "./data": "./src/data/index.ts",
22
+ "./data/*": "./src/data/*",
23
+ "./content/api/*": "./src/content/api/*",
21
24
  "./helpers": "./src/helpers/index.ts",
22
25
  "./helpers/*": "./src/helpers/*",
23
26
  "./utils": "./src/utils/index.ts",
@@ -56,8 +59,8 @@
56
59
  "vite": "^6.3.5",
57
60
  "vitest": "^3.0.7",
58
61
  "@gsa-tts/graymatter-eslint": "0.0.2",
59
- "@gsa-tts/graymatter-vitest-config": "0.0.1",
60
- "@gsa-tts/graymatter-typescript-config": "0.0.2"
62
+ "@gsa-tts/graymatter-typescript-config": "0.0.2",
63
+ "@gsa-tts/graymatter-vitest-config": "0.0.1"
61
64
  },
62
65
  "dependencies": {
63
66
  "@fontsource-variable/archivo": "^5.2.6",
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ import ApiDocSubNavMenuItem from './ApiDocSubNavMenuItem.svelte';
3
+ import Self from './ApiDocSubNavList.svelte';
4
+
5
+ const { items, level = 0, selectedId, onClick, onKeyDown } = $props();
6
+ </script>
7
+
8
+ {#each items as item}
9
+ <ApiDocSubNavMenuItem
10
+ {item}
11
+ {level}
12
+ isSelected={selectedId === item.id}
13
+ {onClick}
14
+ {onKeyDown}
15
+ />
16
+ {#if item.children}
17
+ <Self
18
+ items={item.children}
19
+ level={level + 1}
20
+ {selectedId}
21
+ {onClick}
22
+ {onKeyDown}
23
+ />
24
+ {/if}
25
+ {/each}
@@ -0,0 +1,464 @@
1
+ <script lang="ts">
2
+ import { createEventDispatcher, onMount } from 'svelte';
3
+ import { subNavReady } from '@gsa-tts/graymatter-ui/stores/subNavReadyStore';
4
+ import type { SubNavData, SubNavItem } from '../types/subnav.ts';
5
+ import ApiDocSubNavList from './ApiDocSubNavList.svelte';
6
+
7
+ const { data = null } = $props();
8
+
9
+ const sections = $derived(() =>
10
+ !Array.isArray(data) &&
11
+ data &&
12
+ typeof data === 'object' &&
13
+ 'sections' in data &&
14
+ Array.isArray(
15
+ (data as { sections: { header: string; items: SubNavItem[] }[] }).sections
16
+ )
17
+ ? (data as { sections: { header: string; items: SubNavItem[] }[] })
18
+ .sections
19
+ : null
20
+ );
21
+
22
+ const dispatch = createEventDispatcher<{
23
+ navigate: { item: SubNavItem };
24
+ itemSelect: { item: SubNavItem };
25
+ mobileAnchorNavigate: { hash: string };
26
+ }>();
27
+
28
+ function findFirstItem(data: SubNavData | null): SubNavItem | null {
29
+ if (!data) return null;
30
+
31
+ if (Array.isArray(data)) {
32
+ return data[0] || null;
33
+ } else if (
34
+ data &&
35
+ typeof data === 'object' &&
36
+ 'sections' in data &&
37
+ Array.isArray(
38
+ (data as { sections: { header: string; items: SubNavItem[] }[] })
39
+ .sections
40
+ )
41
+ ) {
42
+ const sections = (
43
+ data as { sections: { header: string; items: SubNavItem[] }[] }
44
+ ).sections;
45
+ if (
46
+ sections.length > 0 &&
47
+ sections[0] &&
48
+ sections[0].items &&
49
+ sections[0].items.length > 0
50
+ ) {
51
+ return sections[0].items[0] || null;
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function findItemByHash(
58
+ data: SubNavData | null,
59
+ hash: string
60
+ ): SubNavItem | null {
61
+ if (!data) return null;
62
+
63
+ function searchInItems(items: SubNavItem[]): SubNavItem | null {
64
+ for (const item of items) {
65
+ if (item.href && item.href.includes('#')) {
66
+ const itemHash = item.href.split('#')[1];
67
+ if (itemHash === hash) {
68
+ return item;
69
+ }
70
+ }
71
+ if (item.children) {
72
+ const found = searchInItems(item.children);
73
+ if (found) return found;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+
79
+ if (Array.isArray(data)) {
80
+ return searchInItems(data);
81
+ } else if (
82
+ data &&
83
+ typeof data === 'object' &&
84
+ 'sections' in data &&
85
+ Array.isArray(
86
+ (data as { sections: { header: string; items: SubNavItem[] }[] })
87
+ .sections
88
+ )
89
+ ) {
90
+ for (const section of (
91
+ data as { sections: { header: string; items: SubNavItem[] }[] }
92
+ ).sections) {
93
+ const found = searchInItems(section.items);
94
+ if (found) return found;
95
+ }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function findItemById(
101
+ data: SubNavData | null,
102
+ id: string
103
+ ): SubNavItem | null {
104
+ if (!data) return null;
105
+
106
+ function searchInItems(items: SubNavItem[]): SubNavItem | null {
107
+ for (const item of items) {
108
+ if (item.id === id) {
109
+ return item;
110
+ }
111
+ if (item.children) {
112
+ const found = searchInItems(item.children);
113
+ if (found) return found;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ if (Array.isArray(data)) {
120
+ return searchInItems(data);
121
+ } else if (
122
+ data &&
123
+ typeof data === 'object' &&
124
+ 'sections' in data &&
125
+ Array.isArray(
126
+ (data as { sections: { header: string; items: SubNavItem[] }[] })
127
+ .sections
128
+ )
129
+ ) {
130
+ for (const section of (
131
+ data as { sections: { header: string; items: SubNavItem[] }[] }
132
+ ).sections) {
133
+ const found = searchInItems(section.items);
134
+ if (found) return found;
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
140
+ // Utility: Find top-level parent of a submenu item
141
+ function findTopLevelParent(
142
+ item: SubNavItem,
143
+ data: SubNavData | null
144
+ ): SubNavItem | null {
145
+ if (!data || !item) return null;
146
+ function search(
147
+ items: SubNavItem[],
148
+ parent: SubNavItem | null
149
+ ): SubNavItem | null {
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);
154
+ if (found) return found;
155
+ }
156
+ }
157
+ return null;
158
+ }
159
+ if (Array.isArray(data)) {
160
+ return search(data, null);
161
+ } else if (
162
+ data &&
163
+ typeof data === 'object' &&
164
+ 'sections' in data &&
165
+ Array.isArray(
166
+ (data as { sections: { header: string; items: SubNavItem[] }[] })
167
+ .sections
168
+ )
169
+ ) {
170
+ for (const section of (
171
+ data as { sections: { header: string; items: SubNavItem[] }[] }
172
+ ).sections) {
173
+ const found = search(section.items, null);
174
+ if (found) return found;
175
+ }
176
+ }
177
+ return null;
178
+ }
179
+
180
+ let currentSelectedId = $state<string | null>(null);
181
+ let hydrated = $state(false);
182
+
183
+ const effectiveSelectedId = $derived(() => {
184
+ if (!hydrated || !data) return null;
185
+
186
+ // If we already have a selectedId, use it
187
+ if (currentSelectedId) return currentSelectedId;
188
+
189
+ // First priority: URL hash
190
+ if (typeof window !== 'undefined' && window.location.hash) {
191
+ const hash = window.location.hash.substring(1);
192
+ const targetItem = findItemByHash(data, hash);
193
+ if (targetItem) {
194
+ return targetItem.id;
195
+ }
196
+ }
197
+
198
+ // Second priority: sessionStorage
199
+ if (typeof window !== 'undefined') {
200
+ const storedId = sessionStorage.getItem('subNav-selectedId');
201
+ if (storedId) {
202
+ try {
203
+ 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
+ }
209
+ }
210
+ } catch (e) {
211
+ console.log('[SubNavMenu] Error parsing stored ID:', e);
212
+ }
213
+ }
214
+ }
215
+
216
+ // Third priority: first item
217
+ const firstItem = findFirstItem(data);
218
+ if (firstItem && firstItem.title) {
219
+ return firstItem.id;
220
+ }
221
+
222
+ return null;
223
+ });
224
+
225
+ // Update currentSelectedId when effectiveSelectedId changes
226
+ $effect(() => {
227
+ if (effectiveSelectedId() && effectiveSelectedId() !== currentSelectedId) {
228
+ currentSelectedId = effectiveSelectedId();
229
+ }
230
+ });
231
+
232
+ onMount(() => {
233
+ hydrated = true;
234
+
235
+ if (!data) return;
236
+
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);
269
+ }
270
+ }
271
+
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
+ );
282
+ }
283
+ }
284
+ subNavReady.set(true);
285
+ });
286
+
287
+ function handleItemClick(event: Event, item: SubNavItem) {
288
+ // Always prevent default for anchor links
289
+ if (item.href && item.href.includes('#')) {
290
+ event.preventDefault();
291
+ } else if (!item.href || item.href === '#') {
292
+ event.preventDefault();
293
+ }
294
+ currentSelectedId = item.id;
295
+ const topLevel = findTopLevelParent(item, data) || item;
296
+ import('@gsa-tts/graymatter-ui/stores/navigationStore')
297
+ .then(({ selectedSubNavItemTitle }) => {
298
+ 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
+ })
311
+ .catch();
312
+ if (typeof window !== 'undefined') {
313
+ if (item.href && item.href.includes('#')) {
314
+ const hash = item.href.split('#')[1];
315
+ if (hash) {
316
+ window.location.hash = hash;
317
+ if (window.innerWidth > 640) {
318
+ // Desktop: use delay for consistent timing
319
+ setTimeout(() => {
320
+ scrollToAnchorWithOffset(hash);
321
+ }, 350);
322
+ } else {
323
+ // Mobile: immediate scroll without delay
324
+ window.dispatchEvent(
325
+ new CustomEvent('mobileAnchorNavigate', { detail: { hash } })
326
+ );
327
+ }
328
+ }
329
+ }
330
+ // Always use top-level parent for header
331
+ if (topLevel.title) {
332
+ window.dispatchEvent(
333
+ new CustomEvent('sectionVisible', {
334
+ detail: {
335
+ title: topLevel.title,
336
+ id: topLevel.id,
337
+ },
338
+ })
339
+ );
340
+ }
341
+ // Mobile menu logic unchanged
342
+ if (window.innerWidth <= 640 && item.href && item.href !== '#') {
343
+ import('@gsa-tts/graymatter-ui/stores/navigationStore').then(
344
+ ({ navigationStore }) => {
345
+ navigationStore.setExpanded(false);
346
+ }
347
+ );
348
+ }
349
+ if (window.innerWidth <= 640) {
350
+ window.dispatchEvent(new CustomEvent('closeMobileMenu'));
351
+ }
352
+ }
353
+ if (item.href && item.href !== '#') {
354
+ dispatch('navigate', { item });
355
+ } else {
356
+ dispatch('itemSelect', { item });
357
+ }
358
+ }
359
+
360
+ function handleKeyDown(event: KeyboardEvent, item: SubNavItem) {
361
+ if (event.key === 'Enter' || event.key === ' ') {
362
+ event.preventDefault();
363
+ handleItemClick(event, item);
364
+ }
365
+ }
366
+
367
+ // Utility: Scroll to anchor with header offset
368
+ function scrollToAnchorWithOffset(anchorId: string) {
369
+ const mainContent = document.getElementById('main-content');
370
+ if (mainContent) {
371
+ const anchor = mainContent.querySelector(
372
+ `[id="${anchorId}"]`
373
+ ) as HTMLElement;
374
+ if (anchor) {
375
+ // Use instant scrolling without animation
376
+ try {
377
+ // Add temporary scroll margin to account for header
378
+ const originalScrollMargin = anchor.style.scrollMarginTop;
379
+ anchor.style.scrollMarginTop = '56px';
380
+
381
+ anchor.scrollIntoView({
382
+ behavior: 'auto',
383
+ block: 'start',
384
+ inline: 'nearest',
385
+ });
386
+
387
+ // Restore original scroll margin after a delay
388
+ setTimeout(() => {
389
+ anchor.style.scrollMarginTop = originalScrollMargin;
390
+ }, 1000);
391
+ } catch {
392
+ // Fallback to manual calculation
393
+ const headerHeight = 56;
394
+ const rect = anchor.getBoundingClientRect();
395
+ const scrollTop = window.scrollY + rect.top - headerHeight;
396
+ const finalScrollTop = Math.max(0, scrollTop);
397
+ window.scrollTo({ top: finalScrollTop, behavior: 'auto' });
398
+ }
399
+ }
400
+ }
401
+ }
402
+ </script>
403
+
404
+ <div class="sub-menu-container">
405
+ <div class="sub-menu-content">
406
+ {#if data}
407
+ {#if Array.isArray(data)}
408
+ <ApiDocSubNavList
409
+ items={data}
410
+ level={0}
411
+ selectedId={effectiveSelectedId()}
412
+ onClick={handleItemClick}
413
+ onKeyDown={handleKeyDown}
414
+ />
415
+ {/if}
416
+ {#each sections() ?? [] as section: { header: string; items: SubNavItem[] }}
417
+ <div class="sub-menu-header">
418
+ {section.header}
419
+ </div>
420
+ <ApiDocSubNavList
421
+ items={section.items}
422
+ level={0}
423
+ selectedId={effectiveSelectedId()}
424
+ onClick={handleItemClick}
425
+ onKeyDown={handleKeyDown}
426
+ />
427
+ {/each}
428
+ {/if}
429
+ </div>
430
+ </div>
431
+
432
+ <style>
433
+ .sub-menu-container {
434
+ display: flex;
435
+ padding: 0 var(--ai-size-8) var(--ai-size-16) var(--ai-size-8);
436
+ flex-direction: column;
437
+ align-items: flex-start;
438
+ gap: var(--ai-size-32);
439
+ align-self: stretch;
440
+ min-height: 6.25rem;
441
+ }
442
+ .sub-menu-content {
443
+ display: flex;
444
+ flex-direction: column;
445
+ align-items: flex-start;
446
+ gap: var(--ai-size-2);
447
+ align-self: stretch;
448
+ }
449
+ .sub-menu-header {
450
+ display: flex;
451
+ padding: var(--ai-size-8);
452
+ justify-content: flex-start;
453
+ align-items: center;
454
+ gap: var(--ai-size-10);
455
+ align-self: stretch;
456
+ color: var(--ai-color-steel-900);
457
+ font-size: var(--ai-size-12);
458
+ font-style: normal;
459
+ font-weight: 600;
460
+ line-height: 1.2;
461
+ letter-spacing: 0.0075rem;
462
+ text-transform: uppercase;
463
+ }
464
+ </style>
@@ -0,0 +1,116 @@
1
+ <script lang="ts">
2
+ const { item, level = 0, isSelected = false, onClick, onKeyDown } = $props();
3
+ const levelClass = $derived(() => `level-${level}`);
4
+ const hasHref = $derived(() => item.href && item.href !== '#');
5
+ </script>
6
+
7
+ {#if hasHref()}
8
+ <a
9
+ href={item.href}
10
+ class={`menu-item ${levelClass()}`}
11
+ class:selected={isSelected}
12
+ onclick={e => onClick(e, item)}
13
+ >
14
+ <div class="menu-item-content">
15
+ <span class="menu-item-text" class:selected={isSelected}>
16
+ {item.title}
17
+ </span>
18
+ </div>
19
+ </a>
20
+ {:else}
21
+ <div
22
+ class={`menu-item ${levelClass()}${!hasHref() ? ' non-clickable' : ''}`}
23
+ class:selected={isSelected}
24
+ tabindex="0"
25
+ role="button"
26
+ aria-label={`Select ${item.title}`}
27
+ onclick={e => onClick(e, item)}
28
+ onkeydown={e => onKeyDown(e, item)}
29
+ >
30
+ <div class="menu-item-content">
31
+ <span class="menu-item-text" class:selected={isSelected}>
32
+ {item.title}
33
+ </span>
34
+ </div>
35
+ </div>
36
+ {/if}
37
+
38
+ <style>
39
+ .menu-item {
40
+ display: flex;
41
+ height: 2.1625rem; /* 34.6px */
42
+ padding: var(--ai-size-8);
43
+ align-items: center;
44
+ gap: var(--ai-size-4);
45
+ align-self: stretch;
46
+ width: 100%;
47
+ background: none;
48
+ border: none;
49
+ cursor: pointer;
50
+ text-decoration: none;
51
+ color: inherit;
52
+ border-radius: var(--ai-size-4);
53
+ transition: background-color var(--ai-transition-ease-fast);
54
+ }
55
+
56
+ .menu-item:hover {
57
+ background-color: var(--ai-color-steel-100);
58
+ }
59
+
60
+ .menu-item:focus {
61
+ outline: var(--ai-size-2) solid var(--ai-color-blue-600);
62
+ outline-offset: var(--ai-size-2);
63
+ }
64
+
65
+ /* Hide focus outline on click */
66
+ .menu-item:focus:not(:focus-visible) {
67
+ outline: none;
68
+ }
69
+
70
+ .menu-item.selected {
71
+ background-color: var(--ai-color-steel-100);
72
+ }
73
+
74
+ .menu-item.non-clickable {
75
+ cursor: default;
76
+ }
77
+
78
+ .menu-item.non-clickable:hover {
79
+ background-color: transparent;
80
+ }
81
+
82
+ /* Nesting levels with indentation */
83
+ .menu-item.level-0 {
84
+ padding-left: var(--ai-size-8);
85
+ }
86
+
87
+ .menu-item.level-1 {
88
+ padding-left: var(--ai-size-20);
89
+ }
90
+
91
+ .menu-item.level-2 {
92
+ padding-left: var(--ai-size-32);
93
+ }
94
+
95
+ .menu-item-content {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: var(--ai-size-10);
99
+ flex: 1 0 0;
100
+ }
101
+
102
+ .menu-item-text {
103
+ color: var(--ai-color-steel-800);
104
+ font-size: var(--ai-size-14);
105
+ font-style: normal;
106
+ font-weight: 400;
107
+ line-height: 1.3;
108
+ transition: all var(--ai-transition-ease-fast);
109
+ }
110
+
111
+ .menu-item-text.selected,
112
+ .menu-item.selected .menu-item-text {
113
+ color: var(--ai-color-black);
114
+ font-weight: 600;
115
+ }
116
+ </style>
@@ -542,7 +542,7 @@
542
542
  opacity: 1;
543
543
  flex: none;
544
544
  height: 100%;
545
- background: var(--ai-color-steel-100);
545
+ background: var(--ai-color-steel-50);
546
546
  border-right: 0.0625rem solid var(--ai-color-steel-100);
547
547
  overflow: hidden;
548
548
  flex-direction: column;
@@ -568,7 +568,7 @@
568
568
  align-items: flex-start;
569
569
  gap: var(--ai-size-4);
570
570
  flex: none;
571
- background: var(--ai-color-steel-200);
571
+ background: var(--ai-color-steel-100);
572
572
  border-right: 0.0625rem solid var(--ai-color-steel-200);
573
573
  }
574
574
 
@@ -12,6 +12,9 @@ export { default as MobileBottomNav } from './MobileBottomNav.svelte';
12
12
  export { default as MobileSideMenu } from './MobileSideMenu.svelte';
13
13
  export { default as NavigationInitializer } from './NavigationInitializer.svelte';
14
14
  export { default as UsaSkipNav } from './UsaSkipNav.svelte';
15
+ export { default as ApiDocSubNavMenu } from './ApiDocSubNavMenu.svelte';
16
+ export { default as ApiDocSubNavList } from './ApiDocSubNavList.svelte';
17
+ export { default as ApiDocSubNavMenuItem } from './ApiDocSubNavMenuItem.svelte';
15
18
 
16
19
  // Icon Components
17
20
  export * from './icons/index.js';