@gsa-tts/graymatter-ui 0.4.19 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsa-tts/graymatter-ui",
3
- "version": "0.4.19",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -53,13 +53,13 @@
53
53
  "@types/unist": "^3.0.3",
54
54
  "@uswds/compile": "1.2.2",
55
55
  "@vitest/coverage-istanbul": "^3.2.4",
56
- "astro": "^5.13.7",
56
+ "astro": "^5.15.9",
57
57
  "astro-purgecss": "^5.2.2",
58
58
  "eslint": "^9.21.0",
59
59
  "gulp": "^5.0.0",
60
- "svelte": "^5.35.4",
60
+ "svelte": "^5.53.5",
61
61
  "typescript": "5.7.3",
62
- "vite": "^6.3.5",
62
+ "vite": "^6.4.1",
63
63
  "vitest": "^3.0.7",
64
64
  "@gsa-tts/graymatter-eslint": "0.0.2",
65
65
  "@gsa-tts/graymatter-typescript-config": "0.0.2",
@@ -1,10 +1,10 @@
1
1
  <script lang="ts">
2
- import { createEventDispatcher, onMount } from 'svelte';
2
+ import { createEventDispatcher } from 'svelte';
3
3
  import { subNavReady } from '../stores/subNavReadyStore';
4
4
  import type { SubNavData, SubNavItem } from '../types/subnav.ts';
5
5
  import ApiDocSubNavList from './ApiDocSubNavList.svelte';
6
6
 
7
- const { data = null } = $props();
7
+ let { data = null } = $props();
8
8
  // TODO: remove legacy /api/documentation path once all consuming apps migrate
9
9
  const API_DOC_PATHS = ['/api-documentation', '/api/documentation'];
10
10
 
@@ -187,6 +187,7 @@
187
187
  let currentSelectedId = $state<string | null>(null);
188
188
  let hydrated = $state(false);
189
189
  let pageLoadComplete = $state(false);
190
+ let hasMounted = $state(false);
190
191
  let scrollTimeoutId: ReturnType<typeof setTimeout> | null = null;
191
192
 
192
193
  function queueScrollToAnchor(hash: string, delay: number) {
@@ -268,7 +269,10 @@
268
269
  }
269
270
  });
270
271
 
271
- onMount(() => {
272
+ $effect(() => {
273
+ if (hasMounted) return;
274
+ hasMounted = true;
275
+
272
276
  hydrated = true;
273
277
 
274
278
  if (!data) return;
@@ -28,6 +28,7 @@
28
28
  type?: 'button' | 'submit' | 'reset';
29
29
  onclick?: (event: MouseEvent) => void;
30
30
  class?: string;
31
+ children?: import('svelte').Snippet;
31
32
  [key: `data-${string}`]: string;
32
33
  [key: `aria-${string}`]: string;
33
34
  }
@@ -42,6 +43,7 @@
42
43
  size = 'medium',
43
44
  type = 'button',
44
45
  class: className = '',
46
+ children,
45
47
  // eslint-disable-next-line svelte/valid-compile
46
48
  ...props
47
49
  }: Props = $props();
@@ -71,11 +73,11 @@
71
73
 
72
74
  {#if href}
73
75
  <a class={mergedClasses()} {href} class:disabled {onclick} {...props}>
74
- <slot />
76
+ {@render children?.()}
75
77
  </a>
76
78
  {:else}
77
79
  <button class={mergedClasses()} {type} {disabled} {onclick} {...props}>
78
- <slot />
80
+ {@render children?.()}
79
81
  </button>
80
82
  {/if}
81
83
 
@@ -16,17 +16,32 @@
16
16
  import Button from './Button.svelte';
17
17
 
18
18
  // Props for the web component
19
+ interface Props {
20
+ variant?: 'primary' | 'secondary' | 'unstyled';
21
+ theme?: 'default' | 'inverted';
22
+ href?: string;
23
+ disabled?: boolean;
24
+ size?: 'small' | 'medium' | 'large';
25
+ type?: 'button' | 'submit' | 'reset';
26
+ onclick?: (event: MouseEvent) => void;
27
+ class?: string;
28
+ children?: import('svelte').Snippet;
29
+ [key: `data-${string}`]: string;
30
+ [key: `aria-${string}`]: string;
31
+ }
32
+
19
33
  let {
20
34
  variant = 'primary',
21
35
  theme = 'default',
22
- href = null,
36
+ href = undefined,
23
37
  disabled = false,
24
38
  size = 'medium',
25
39
  type = 'button',
26
40
  onclick,
27
41
  class: className = '',
42
+ children,
28
43
  ...props
29
- } = $props();
44
+ }: Props = $props();
30
45
  </script>
31
46
 
32
47
  <Button
@@ -37,8 +52,7 @@
37
52
  {size}
38
53
  {type}
39
54
  {onclick}
55
+ {children}
40
56
  class={className}
41
57
  {...props}
42
- >
43
- <slot />
44
- </Button>
58
+ ></Button>
@@ -4,11 +4,10 @@
4
4
  isExpanded,
5
5
  selectedSubNavItemTitle,
6
6
  } from '../stores/navigationStore.js';
7
- import { onMount } from 'svelte';
8
7
 
9
- const { customHeaderText = '' } = $props();
8
+ let { customHeaderText = '' } = $props();
10
9
 
11
- let isLayoutTransitioning = false;
10
+ let isLayoutTransitioning = $state(false);
12
11
 
13
12
  const showHeader = $derived(
14
13
  () =>
@@ -21,20 +20,19 @@
21
20
  () => customHeaderText || $selectedSubNavItemTitle
22
21
  );
23
22
 
24
- onMount(() => {
23
+ $effect(() => {
24
+ if (typeof window === 'undefined') return;
25
25
  const handleSectionVisible = (event: Event) => {
26
- if (typeof window !== 'undefined' && window.innerWidth <= 640) {
26
+ if (window.innerWidth <= 640) {
27
27
  return;
28
28
  }
29
29
  let topLevelTitle = null;
30
- if (typeof window !== 'undefined') {
31
- topLevelTitle = sessionStorage.getItem('subNav-topLevelTitle');
32
- if (topLevelTitle) {
33
- try {
34
- topLevelTitle = JSON.parse(topLevelTitle);
35
- } catch {
36
- /* ignore parse error */
37
- }
30
+ topLevelTitle = sessionStorage.getItem('subNav-topLevelTitle');
31
+ if (topLevelTitle) {
32
+ try {
33
+ topLevelTitle = JSON.parse(topLevelTitle);
34
+ } catch {
35
+ /* ignore parse error */
38
36
  }
39
37
  }
40
38
  if (typeof topLevelTitle === 'string' && topLevelTitle.trim() !== '') {
@@ -26,7 +26,7 @@
26
26
  import ProfileMenu from './ProfileMenu.svelte';
27
27
  import HelpIcon from './icons/HelpIcon.svelte';
28
28
 
29
- const {
29
+ let {
30
30
  ssrSelectedItem = undefined,
31
31
  controlledSelectedItem = undefined,
32
32
  onNavToggle = undefined,
@@ -39,9 +39,10 @@
39
39
  consoleUrl,
40
40
  discoverUrl,
41
41
  supplementalMenuData,
42
+ children,
42
43
  } = $props();
43
44
 
44
- const helpUrl = supplementalMenuData?.help?.url?.trim();
45
+ const helpUrl = $derived(() => supplementalMenuData?.help?.url?.trim());
45
46
 
46
47
  // Check if we're on the help page by comparing URLs (help is not a nav/subnav item)
47
48
  function getPathname(url: string, baseOrigin: string) {
@@ -53,23 +54,24 @@
53
54
  }
54
55
 
55
56
  function checkIsHelpPage(): boolean {
56
- if (!helpUrl || helpUrl === '#' || helpUrl.startsWith('#')) {
57
+ const url = helpUrl();
58
+ if (!url || url === '#' || url.startsWith('#')) {
57
59
  return false;
58
60
  }
59
61
  const location = (
60
62
  globalThis as { location?: { pathname?: string; origin?: string } }
61
63
  ).location;
62
64
  if (location?.origin && location?.pathname) {
63
- const helpPath = getPathname(helpUrl, location.origin);
65
+ const helpPath = getPathname(url, location.origin);
64
66
  return helpPath !== null && location.pathname === helpPath;
65
67
  }
66
68
  return false;
67
69
  }
68
70
 
69
- const isHelpSelected = $derived(checkIsHelpPage());
71
+ const isHelpSelected = $derived(() => checkIsHelpPage());
70
72
 
71
- let hydrated = false;
72
- let navContainer: HTMLDivElement;
73
+ let hydrated = $state(false);
74
+ let navContainer = $state<HTMLDivElement | null>(null);
73
75
  let hoveredItem = $state<string | null>(null);
74
76
  let menuButtonHovered = $state(false);
75
77
  let canHover = $state(false);
@@ -118,9 +120,20 @@
118
120
  );
119
121
 
120
122
  onMount(() => {
123
+ if (typeof window === 'undefined') return;
124
+
121
125
  hydrated = true;
126
+
127
+ if (
128
+ controlledSelectedItem === undefined &&
129
+ ssrSelectedItem &&
130
+ $selectedItem === null
131
+ ) {
132
+ navigationStore.setSelectedItem(ssrSelectedItem);
133
+ }
122
134
  // Remove the CSS class since reactive state takes over
123
135
  document.documentElement.classList.remove('nav-initially-expanded');
136
+
124
137
  // Remove the custom property since reactive CSS takes over
125
138
  document.documentElement.style.removeProperty('--initial-main-top-padding');
126
139
  // Add hydrated class to re-enable transitions
@@ -129,100 +142,114 @@
129
142
  }
130
143
 
131
144
  // Detect if device supports hover (desktop only)
132
- if (typeof window !== 'undefined') {
133
- const mq = window.matchMedia('(hover: hover)');
145
+ const mq =
146
+ typeof window.matchMedia === 'function'
147
+ ? window.matchMedia('(hover: hover)')
148
+ : null;
149
+ const handleHoverChange = (e: MediaQueryListEvent) => {
150
+ canHover = e.matches;
151
+ };
152
+ if (mq) {
134
153
  canHover = mq.matches;
135
- mq.addEventListener('change', e => {
136
- canHover = e.matches;
137
- });
154
+ mq.addEventListener('change', handleHoverChange);
155
+ } else {
156
+ canHover = false;
157
+ }
138
158
 
139
- // Listen for layout transitions
140
- const handleLayoutTransition = (event: Event) => {
141
- const customEvent = event as CustomEvent;
142
- const { isMobile } = customEvent.detail;
159
+ // Listen for layout transitions
160
+ const handleLayoutTransition = (event: Event) => {
161
+ const customEvent = event as CustomEvent;
162
+ const { isMobile } = customEvent.detail;
143
163
 
144
- if (isMobile) {
145
- // Switching to mobile: close navigation
146
- navigationStore.setExpanded(false);
164
+ if (isMobile) {
165
+ // Switching to mobile: close navigation
166
+ navigationStore.setExpanded(false);
167
+ } else {
168
+ // Switching to desktop: open navigation if on non-discover page
169
+ const currentItem = selectedForUi();
170
+
171
+ if (currentItem && currentItem !== 'discover') {
172
+ navigationStore.setExpanded(true);
147
173
  } else {
148
- // Switching to desktop: open navigation if on non-discover page
149
- const currentItem = selectedForUi();
150
-
151
- if (currentItem && currentItem !== 'discover') {
152
- navigationStore.setExpanded(true);
153
- } else {
154
- navigationStore.setExpanded(false);
155
- }
156
- }
157
- };
158
-
159
- window.addEventListener('layoutTransition', handleLayoutTransition);
160
-
161
- // Add Escape key handler for nav collapse, but ignore if focus is in profile context menu
162
- const handleEscape = (e: KeyboardEvent) => {
163
- const active = document.activeElement;
164
- if (
165
- e.key === 'Escape' &&
166
- $isExpanded &&
167
- !(active && active.closest('.profile-context-menu'))
168
- ) {
169
174
  navigationStore.setExpanded(false);
170
175
  }
171
- };
172
- window.addEventListener('keydown', handleEscape);
176
+ }
177
+ };
173
178
 
174
- // Custom event system for external control
175
- const handleDesktopNavControlEvent = (event: Event) => {
176
- const customEvent = event as CustomEvent;
177
- const { action, isOpen: eventIsOpen } = customEvent.detail;
179
+ window.addEventListener('layoutTransition', handleLayoutTransition);
178
180
 
179
- if (action === 'toggle') {
180
- navigationStore.toggleExpanded();
181
- } else if (action === 'open') {
182
- navigationStore.setExpanded(true);
183
- } else if (action === 'close') {
184
- navigationStore.setExpanded(false);
185
- } else if (action === 'set' && typeof eventIsOpen === 'boolean') {
186
- navigationStore.setExpanded(eventIsOpen);
187
- }
188
- };
181
+ // Add Escape key handler for nav collapse, but ignore if focus is in profile context menu
182
+ const handleEscape = (e: KeyboardEvent) => {
183
+ const active = document.activeElement;
184
+ const profileMenuOpen = Boolean(
185
+ document.querySelector('.profile-context-menu')
186
+ );
187
+ if (
188
+ e.key === 'Escape' &&
189
+ $isExpanded &&
190
+ !profileMenuOpen &&
191
+ !(active && active.closest('.profile-context-menu'))
192
+ ) {
193
+ navigationStore.setExpanded(false);
194
+ }
195
+ };
196
+ window.addEventListener('keydown', handleEscape);
197
+
198
+ // Custom event system for external control
199
+ const handleDesktopNavControlEvent = (event: Event) => {
200
+ const customEvent = event as CustomEvent;
201
+ const { action, isOpen: eventIsOpen } = customEvent.detail;
202
+
203
+ if (action === 'toggle') {
204
+ navigationStore.toggleExpanded();
205
+ } else if (action === 'open') {
206
+ navigationStore.setExpanded(true);
207
+ } else if (action === 'close') {
208
+ navigationStore.setExpanded(false);
209
+ } else if (action === 'set' && typeof eventIsOpen === 'boolean') {
210
+ navigationStore.setExpanded(eventIsOpen);
211
+ }
212
+ };
213
+
214
+ // Listen for custom desktop navigation control events
215
+ window.addEventListener(
216
+ 'graymatter-desktop-nav-control',
217
+ handleDesktopNavControlEvent
218
+ );
189
219
 
190
- // Listen for custom desktop navigation control events
191
- window.addEventListener(
220
+ eventListenerCleanup = () => {
221
+ window.removeEventListener(
192
222
  'graymatter-desktop-nav-control',
193
223
  handleDesktopNavControlEvent
194
224
  );
195
-
196
- eventListenerCleanup = () => {
197
- window.removeEventListener(
198
- 'graymatter-desktop-nav-control',
199
- handleDesktopNavControlEvent
200
- );
201
- };
202
-
203
- // Listen for navigation changes to update help button state
204
- const handleNavigationChange = () => {
205
- // Force reactivity update by accessing the derived value
206
- // Accessing isHelpSelected triggers reactivity
207
- void isHelpSelected;
208
- };
209
-
210
- window.addEventListener('popstate', handleNavigationChange);
211
-
212
- return () => {
213
- window.removeEventListener('layoutTransition', handleLayoutTransition);
214
- window.removeEventListener('keydown', handleEscape);
215
- window.removeEventListener('popstate', handleNavigationChange);
216
- if (eventListenerCleanup) {
217
- eventListenerCleanup();
218
- }
219
- };
220
- }
225
+ };
226
+
227
+ // Listen for navigation changes to update help button state
228
+ const handleNavigationChange = () => {
229
+ // Force reactivity update by accessing the derived value
230
+ // Accessing isHelpSelected triggers reactivity
231
+ void isHelpSelected();
232
+ };
233
+
234
+ window.addEventListener('popstate', handleNavigationChange);
235
+
236
+ return () => {
237
+ window.removeEventListener('layoutTransition', handleLayoutTransition);
238
+ window.removeEventListener('keydown', handleEscape);
239
+ window.removeEventListener('popstate', handleNavigationChange);
240
+ if (mq) {
241
+ mq.removeEventListener('change', handleHoverChange);
242
+ }
243
+ if (eventListenerCleanup) {
244
+ eventListenerCleanup();
245
+ }
246
+ };
221
247
  });
222
248
 
223
249
  function handleHelpClick(event: MouseEvent) {
224
250
  // If it's a hash link, prevent default to stay on same page
225
- if (helpUrl === '#' || helpUrl?.startsWith('#')) {
251
+ const url = helpUrl();
252
+ if (url === '#' || url?.startsWith('#')) {
226
253
  event.preventDefault();
227
254
  }
228
255
  }
@@ -480,16 +507,16 @@
480
507
 
481
508
  {#if profileMenuData}
482
509
  <div class="nav-footer" part="nav-footer">
483
- {#if helpUrl}
510
+ {#if helpUrl()}
484
511
  <div
485
512
  class="nav-footer-icon-container"
486
513
  part="nav-footer-icon-container"
487
514
  >
488
515
  <a
489
516
  class="help-link"
490
- class:selected={isHelpSelected}
491
- aria-current={isHelpSelected ? 'page' : undefined}
492
- href={helpUrl}
517
+ class:selected={isHelpSelected()}
518
+ aria-current={isHelpSelected() ? 'page' : undefined}
519
+ href={helpUrl()}
493
520
  part="help-link"
494
521
  title="Help"
495
522
  aria-label="Help"
@@ -497,7 +524,7 @@
497
524
  >
498
525
  <div class="icon-container" part="help-icon-container">
499
526
  <div class="icon-wrapper">
500
- <HelpIcon isSelected={isHelpSelected} />
527
+ <HelpIcon isSelected={isHelpSelected()} />
501
528
  </div>
502
529
  </div>
503
530
  </a>
@@ -523,7 +550,7 @@
523
550
  {#if sideNavExpanded()}
524
551
  <div class="drawer-content" part="drawer-content">
525
552
  <nav aria-label="Sub navigation" part="sub-navigation">
526
- <slot />
553
+ {@render children?.()}
527
554
  </nav>
528
555
  </div>
529
556
  {/if}
@@ -21,6 +21,7 @@
21
21
  consoleUrl,
22
22
  discoverUrl,
23
23
  supplementalMenuData,
24
+ children,
24
25
  } = $props();
25
26
  </script>
26
27
 
@@ -37,6 +38,5 @@
37
38
  {consoleUrl}
38
39
  {discoverUrl}
39
40
  {supplementalMenuData}
40
- >
41
- <slot />
42
- </DesktopSideNav>
41
+ {children}
42
+ />
@@ -8,9 +8,9 @@
8
8
  ...restProps
9
9
  } = $props();
10
10
 
11
- const logoColor = isInverted
12
- ? 'var(--ai-color-white)'
13
- : 'var(--ai-color-black)';
11
+ const logoColor = $derived(() =>
12
+ isInverted ? 'var(--ai-color-white)' : 'var(--ai-color-black)'
13
+ );
14
14
  </script>
15
15
 
16
16
  {#if !showServiceMark}
@@ -20,7 +20,7 @@
20
20
  viewBox="0 0 102 30"
21
21
  fill="none"
22
22
  xmlns="http://www.w3.org/2000/svg"
23
- style="fill: {logoColor};"
23
+ style="fill: {logoColor()};"
24
24
  {...restProps}
25
25
  >
26
26
  <g>
@@ -44,7 +44,7 @@
44
44
  viewBox="0 0 75 20"
45
45
  fill="none"
46
46
  xmlns="http://www.w3.org/2000/svg"
47
- style="fill: {logoColor};"
47
+ style="fill: {logoColor()};"
48
48
  {...restProps}
49
49
  >
50
50
  <path
@@ -31,27 +31,32 @@
31
31
 
32
32
  const selectedForUi = $derived(() => controlledSelectedItem ?? $selectedItem);
33
33
 
34
- const allNavItems = [
35
- {
36
- id: 'discover',
37
- label: 'Discover',
38
- icon: DiscoverIcon,
39
- href: discoverUrl || '#',
40
- },
41
- { id: 'chat', label: 'Chat', icon: ChatIcon, href: chatUrl || '#' },
42
- {
43
- id: 'console',
44
- label: 'Console',
45
- icon: ConsoleIcon,
46
- href: consoleUrl || '#',
47
- },
48
- { id: 'api', label: 'API', icon: ApiIcon, href: apiUrl || '#' },
49
- ] as const;
34
+ const allNavItems = $derived(
35
+ () =>
36
+ [
37
+ {
38
+ id: 'discover',
39
+ label: 'Discover',
40
+ icon: DiscoverIcon,
41
+ href: discoverUrl || '#',
42
+ },
43
+ { id: 'chat', label: 'Chat', icon: ChatIcon, href: chatUrl || '#' },
44
+ {
45
+ id: 'console',
46
+ label: 'Console',
47
+ icon: ConsoleIcon,
48
+ href: consoleUrl || '#',
49
+ },
50
+ { id: 'api', label: 'API', icon: ApiIcon, href: apiUrl || '#' },
51
+ ] as const
52
+ );
50
53
 
51
54
  // Filter out discover item if hideDiscover is true
52
- const navItems = hideDiscover
53
- ? allNavItems.filter(item => item.id !== 'discover')
54
- : allNavItems;
55
+ const navItems = $derived(() =>
56
+ hideDiscover
57
+ ? allNavItems().filter(item => item.id !== 'discover')
58
+ : allNavItems()
59
+ );
55
60
 
56
61
  function handleNavItemClick(item: NavigationItem) {
57
62
  // Set navigation flag immediately to prevent header restoration
@@ -132,7 +137,7 @@
132
137
 
133
138
  {#if showAppIcons}
134
139
  <nav class="mobile-bottom-nav" part="mobile-bottom-nav">
135
- {#each navItems as navItem}
140
+ {#each navItems() as navItem}
136
141
  <a
137
142
  href={navItem.href}
138
143
  class={`nav-item ${navItem.id}-item`}
@@ -1,5 +1,4 @@
1
1
  <script lang="ts">
2
- import { onMount } from 'svelte';
3
2
  import {
4
3
  selectedItem,
5
4
  isExpanded,
@@ -8,10 +7,10 @@
8
7
  import Logo from './Logo.svelte';
9
8
  import MenuIcon from './icons/MenuIcon.svelte';
10
9
 
11
- let sideMenuElement: HTMLDivElement;
10
+ let sideMenuElement = $state<HTMLDivElement | null>(null);
12
11
  let pendingMobileHash: string | null = null;
13
12
 
14
- let { logoLinkUrl, logoLinkLabel } = $props();
13
+ let { logoLinkUrl, logoLinkLabel, children } = $props();
15
14
 
16
15
  function handleMobileAnchorNavigate(event: Event) {
17
16
  const customEvent = event as CustomEvent;
@@ -82,7 +81,8 @@
82
81
  }
83
82
  }
84
83
 
85
- onMount(() => {
84
+ $effect(() => {
85
+ if (typeof window === 'undefined') return;
86
86
  // Listen for the custom event globally
87
87
  window.addEventListener('mobileAnchorNavigate', handleMobileAnchorNavigate);
88
88
 
@@ -180,7 +180,7 @@
180
180
  <!-- Show submenu items if available and not on discover -->
181
181
  {#if $selectedItem && $selectedItem !== 'discover'}
182
182
  <div class="subnav-section" part="subnav-section">
183
- <slot />
183
+ {@render children?.()}
184
184
  </div>
185
185
  {:else}
186
186
  <!-- Show message when no submenu is available -->
@@ -7,9 +7,7 @@
7
7
  <script lang="ts">
8
8
  import MobileSideMenu from './MobileSideMenu.svelte';
9
9
 
10
- let { logoLinkUrl, logoLinkLabel } = $props();
10
+ let { logoLinkUrl, logoLinkLabel, children } = $props();
11
11
  </script>
12
12
 
13
- <MobileSideMenu {logoLinkUrl} {logoLinkLabel}>
14
- <slot />
15
- </MobileSideMenu>
13
+ <MobileSideMenu {logoLinkUrl} {logoLinkLabel} {children} />