@gsa-tts/graymatter-ui 0.2.1 → 0.2.3

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,241 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ /**
4
+ * Array of menu items to render in the profile menu.
5
+ * Each item: { label: string, icon: string, onClick: () => void }
6
+ */
7
+ export let menuItems: Array<{ label: string; icon: string; onClick: () => void }> = [];
8
+ export let userMeta: { initials: string};
9
+ export let ariaLabel: string = 'Open profile menu';
10
+ export let menuAlign: 'desktop' | 'mobile' = 'desktop';
11
+
12
+ let menuId = `profile-menu-${Math.random().toString(36).slice(2, 10)}`;
13
+ let menuOpen = false;
14
+ function toggleMenu() { menuOpen = !menuOpen; }
15
+ function closeMenu() { menuOpen = false; }
16
+
17
+ // Optional: close menu on outside click
18
+ function handleBlur(event: FocusEvent) {
19
+ const related = event.relatedTarget as HTMLElement | null;
20
+ if (!related || !related.closest('.profile-context-menu')) {
21
+ closeMenu();
22
+ }
23
+ }
24
+
25
+ let profileButton: HTMLButtonElement;
26
+
27
+ // For keyboard navigation, keep refs to all menu item buttons
28
+ let menuItemButtons: HTMLButtonElement[] = [];
29
+ let btns: (HTMLButtonElement | null)[] = [];
30
+ $: menuItemButtons = btns.filter(Boolean) as HTMLButtonElement[];
31
+
32
+ function handleMenuKeydown(e: KeyboardEvent, idx: number) {
33
+ if (e.key === 'ArrowDown') {
34
+ e.preventDefault();
35
+ const next = (idx + 1) % menuItems.length;
36
+ menuItemButtons[next]?.focus();
37
+ } else if (e.key === 'ArrowUp') {
38
+ e.preventDefault();
39
+ const prev = (idx - 1 + menuItems.length) % menuItems.length;
40
+ menuItemButtons[prev]?.focus();
41
+ } else if (e.key === 'Escape') {
42
+ closeMenu();
43
+ profileButton?.focus();
44
+ e.stopPropagation();
45
+ }
46
+ }
47
+
48
+ function handleGlobalEscape(e: KeyboardEvent) {
49
+ if (menuOpen && e.key === 'Escape') {
50
+ closeMenu();
51
+ profileButton?.focus();
52
+ e.stopPropagation();
53
+ }
54
+ }
55
+
56
+ onMount(() => {
57
+ window.addEventListener('keydown', handleGlobalEscape, true);
58
+ return () => {
59
+ window.removeEventListener('keydown', handleGlobalEscape, true);
60
+ };
61
+ });
62
+ </script>
63
+
64
+ <div style="position: relative; display: inline-block;">
65
+ <button
66
+ class="user-profile-button"
67
+ tabindex="0"
68
+ aria-label={ariaLabel}
69
+ aria-haspopup="menu"
70
+ aria-expanded={menuOpen}
71
+ aria-controls={menuId}
72
+ on:click={toggleMenu}
73
+ on:blur={handleBlur}
74
+ bind:this={profileButton}
75
+ >
76
+ <div class="user-profile-container">
77
+ <div class="user-profile-icon" part="user-profile-icon">
78
+ <span class="user-initials">{userMeta?.initials}</span>
79
+ </div>
80
+ </div>
81
+ </button>
82
+
83
+ {#if menuOpen}
84
+ <div
85
+ class="profile-context-menu {menuAlign === 'desktop' ? 'desktop-align' : 'mobile-align'}"
86
+ tabindex="-1"
87
+ id={menuId}
88
+ role="menu"
89
+ >
90
+ {#each menuItems as item, idx (item.label)}
91
+ <button
92
+ type="button"
93
+ class="menu-item"
94
+ on:click={() => { item.onClick(); closeMenu(); }}
95
+ bind:this={btns[idx]}
96
+ on:keydown={(e) => handleMenuKeydown(e, idx)}
97
+ role="menuitem"
98
+ >
99
+ <span class="icon">
100
+ <img src={item.icon} alt="" aria-hidden="true" width="20" height="20" />
101
+ </span>
102
+ <div class="menu-text">{item.label}</div>
103
+ </button>
104
+ {/each}
105
+ </div>
106
+ {/if}
107
+ </div>
108
+
109
+ <style>
110
+ .user-profile-container,
111
+ .user-profile-icon {
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ }
116
+
117
+ .user-profile-container,
118
+ .user-profile-icon {
119
+ display: flex;
120
+ align-items: center;
121
+ justify-content: center;
122
+ }
123
+
124
+ .user-profile-icon {
125
+ border-radius: var(--ai-size-4);
126
+ background: var(--ai-color-blue-800);
127
+ }
128
+
129
+ .user-initials {
130
+ color: var(--ai-color-white);
131
+ text-align: center;
132
+ font-weight: 400;
133
+ line-height: 1.3;
134
+ }
135
+
136
+ .user-profile-button {
137
+ background: none;
138
+ border: none;
139
+ padding: 0;
140
+ cursor: pointer;
141
+ display: flex;
142
+ justify-content: center;
143
+ align-items: center;
144
+ }
145
+
146
+ .user-profile-button:focus {
147
+ outline: var(--ai-size-2) solid var(--ai-color-blue-600);
148
+ outline-offset: var(--ai-size-2);
149
+ border-radius: var(--ai-size-4);
150
+ }
151
+
152
+ .user-profile-button:focus:not(:focus-visible) {
153
+ outline: none;
154
+ }
155
+
156
+ .user-profile-container {
157
+ width: var(--ai-size-40);
158
+ height: var(--ai-size-40);
159
+ aspect-ratio: 1/1;
160
+ }
161
+
162
+ .user-profile-icon {
163
+ padding: 0.5556rem 0.4931rem 0.5694rem 0.3819rem;
164
+ }
165
+
166
+ .user-initials {
167
+ font-size: var(--ai-size-16);
168
+ font-style: normal;
169
+ }
170
+ .profile-context-menu {
171
+ display: flex;
172
+ width: 13.75rem;
173
+ padding: var(--ai-size-12);
174
+ flex-direction: column;
175
+ align-items: flex-start;
176
+ gap: var(--ai-size-2);
177
+ border-radius: var(--ai-size-8);
178
+ border: 0.0625rem solid var(--ai-color-steel-200);
179
+ background: var(--ai-color-white);
180
+ box-shadow: 0 0.1875rem var(--ai-size-6) 0 rgba(50, 54, 61, 0.20);
181
+ position: absolute;
182
+ z-index: var(--ai-layer-50);
183
+ }
184
+ .profile-context-menu.desktop-align {
185
+ left: 100%;
186
+ top: auto;
187
+ bottom: 0;
188
+ margin-left: var(--ai-size-8);
189
+ /* aligns bottom of menu with bottom of button */
190
+ transform: translateY(0);
191
+ }
192
+ .profile-context-menu.mobile-align {
193
+ top: 110%;
194
+ right: 0;
195
+ left: auto;
196
+ bottom: auto;
197
+ margin-left: 0;
198
+ transform: none;
199
+ }
200
+ .menu-item {
201
+ display: flex;
202
+ align-items: center;
203
+ gap: var(--ai-size-10);
204
+ flex: 1 0 0;
205
+ width: 100%;
206
+ cursor: pointer;
207
+ padding: var(--ai-size-8);
208
+ background: none;
209
+ border: none;
210
+ box-shadow: none;
211
+ outline: none;
212
+ }
213
+ .menu-item:focus {
214
+ background: var(--ai-color-steel-200);
215
+ border-radius: var(--ai-size-4);
216
+ outline: var(--ai-size-2) solid var(--ai-color-blue-600);
217
+ outline-offset: var(--ai-size-2);
218
+ }
219
+
220
+ .menu-item:focus:not(:focus-visible) {
221
+ outline: none;
222
+ }
223
+ .menu-item:hover {
224
+ background: var(--ai-color-steel-200);
225
+ border-radius: var(--ai-size-4);
226
+ }
227
+ .icon {
228
+ width: var(--ai-size-20);
229
+ height: var(--ai-size-20);
230
+ display: flex;
231
+ align-items: center;
232
+ justify-content: center;
233
+ }
234
+ .menu-text {
235
+ color: var(--ai-color-black);
236
+ font-size: var(--ai-size-14);
237
+ font-style: normal;
238
+ font-weight: 400;
239
+ line-height: 130%;
240
+ }
241
+ </style>
@@ -18,7 +18,10 @@ const mappedSize = sizeMap[maxWidth] || sizeMap.none;
18
18
  aria-label="Official website of the United States government"
19
19
  >
20
20
  <div class="usa-accordion">
21
- <header class="usa-banner__header ai-banner__header">
21
+ <header
22
+ class="usa-banner__header ai-banner__header"
23
+ aria-label="USA banner"
24
+ >
22
25
  <div class=`usa-banner__inner ${mappedSize}`>
23
26
  <div class="grid-col-auto">
24
27
  <img
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ export let isHovered: boolean = false;
3
+ </script>
4
+ <svg
5
+ xmlns="http://www.w3.org/2000/svg"
6
+ width="24"
7
+ height="24"
8
+ viewBox="0 0 24 24"
9
+ fill="none"
10
+ >
11
+ <path
12
+ d="M9.87891 7.51884C11.0505 6.49372 12.95 6.49372 14.1215 7.51884C15.2931 8.54397 15.2931 10.206 14.1215 11.2312C13.9176 11.4096 13.6917 11.5569 13.4513 11.6733C12.7056 12.0341 12.0002 12.6716 12.0002 13.5V14.25M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12ZM12 17.25H12.0075V17.2575H12V17.25Z"
13
+ stroke={isHovered ? '#000' : '#585C63'}
14
+ stroke-width="1.5"
15
+ stroke-linecap="round"
16
+ stroke-linejoin="round"
17
+ />
18
+ </svg>
@@ -1 +1,2 @@
1
- export const siteName = 'GSAI.GOV';
1
+ export const siteName = 'USAI.GOV';
2
+ export const salesEmail = 'partnerships@usai.gov';
@@ -8,8 +8,17 @@ import MobileTopNav from '../components/MobileTopNav.svelte';
8
8
  import MobileBottomNav from '../components/MobileBottomNav.svelte';
9
9
  import MobileSideMenu from '../components/MobileSideMenu.svelte';
10
10
  import Logo from '../components/Logo.svelte';
11
-
12
- const { componentOptions = {}, ssrSelectedItem, gtmID } = Astro.props;
11
+ import { siteName } from '../constants';
12
+
13
+ const {
14
+ componentOptions = {},
15
+ ssrSelectedItem,
16
+ gtmID,
17
+ showAppIcons = true,
18
+ profileMenuData = undefined,
19
+ logoLinkUrl,
20
+ logoLinkLabel,
21
+ } = Astro.props;
13
22
 
14
23
  const title = getPageTitle(Astro);
15
24
  ---
@@ -19,15 +28,20 @@ const title = getPageTitle(Astro);
19
28
  <!-- Desktop Navigation -->
20
29
  <div class="layout-container">
21
30
  <nav aria-label="Main navigation">
22
- <DesktopSideNav client:load ssrSelectedItem={ssrSelectedItem}>
31
+ <DesktopSideNav
32
+ client:load
33
+ ssrSelectedItem={ssrSelectedItem}
34
+ {showAppIcons}
35
+ {profileMenuData}
36
+ >
23
37
  <slot name="sub-nav" />
24
38
  </DesktopSideNav>
25
39
  </nav>
26
40
  <div class="main-container">
27
- <header>
41
+ <header aria-label="Main site header">
28
42
  <DesktopHeader client:load />
29
43
  </header>
30
- <main id="main-content">
44
+ <main id="main-content" tabindex="0">
31
45
  <slot />
32
46
  </main>
33
47
  </div>
@@ -35,16 +49,28 @@ const title = getPageTitle(Astro);
35
49
 
36
50
  <!-- Mobile Navigation -->
37
51
  <nav aria-label="Mobile navigation">
38
- <MobileTopNav client:load />
39
- <MobileSideMenu client:load>
52
+ <MobileTopNav client:load {profileMenuData} />
53
+ <MobileSideMenu client:load {logoLinkUrl} {logoLinkLabel}>
40
54
  <slot name="sub-nav" />
41
55
  </MobileSideMenu>
42
- <MobileBottomNav client:load />
56
+ <MobileBottomNav client:load {showAppIcons} />
43
57
  </nav>
44
58
 
45
59
  <!-- Fixed Logo (Desktop only) -->
46
- <div class="logo-fixed-container display-none tablet:display-flex">
47
- <Logo width={75} height={22} />
60
+ <div
61
+ class="logo-fixed-container display-none tablet:display-flex"
62
+ aria-label={siteName}
63
+ role="region"
64
+ >
65
+ {
66
+ logoLinkUrl ? (
67
+ <a class="display-flex" href={logoLinkUrl} aria-label={logoLinkLabel}>
68
+ <Logo width={75} height={22} />
69
+ </a>
70
+ ) : (
71
+ <Logo width={75} height={22} />
72
+ )
73
+ }
48
74
  </div>
49
75
 
50
76
  <!-- Mobile Main Content -->
@@ -0,0 +1,44 @@
1
+ import { vi } from 'vitest';
2
+ import { writable } from 'svelte/store';
3
+
4
+ export function setupCommonMocks() {
5
+ // navigationStore.js mock
6
+ vi.mock('../stores/navigationStore.js', () => {
7
+ const isExpanded = writable(true);
8
+ const selectedItem = writable('console');
9
+ const isMenuButtonDisabled = writable(false);
10
+ const selectedSubNavItemTitle = writable('');
11
+ return {
12
+ isExpanded,
13
+ selectedItem,
14
+ isMenuButtonDisabled,
15
+ selectedSubNavItemTitle,
16
+ navigationStore: {
17
+ setSelectedItem: vi.fn(),
18
+ setExpanded: (val: boolean) => isExpanded.set(val),
19
+ },
20
+ };
21
+ });
22
+
23
+ vi.mock('../utils/getAppUrls', () => ({
24
+ getAppUrls: () => ({
25
+ discover: '/fake-discover',
26
+ chat: '/fake-chat',
27
+ console: '/fake-console',
28
+ api: '/fake-api',
29
+ }),
30
+ }));
31
+
32
+ vi.mock('slugify', () => ({
33
+ default: (x: string) => x.toLowerCase().replace(/ /g, '-'),
34
+ }));
35
+
36
+ vi.stubGlobal(
37
+ 'window',
38
+ Object.create(window, {
39
+ sessionStorage: {
40
+ value: { setItem: vi.fn(), removeItem: vi.fn(), getItem: vi.fn() },
41
+ },
42
+ })
43
+ );
44
+ }