@gsa-tts/graymatter-ui 0.2.7 → 0.3.0
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/README.md +81 -0
- package/assets/images/favicon-dark.ico +0 -0
- package/assets/images/favicon-dark.svg +7 -0
- package/assets/images/favicon-light.svg +7 -0
- package/assets/images/favicon.ico +0 -0
- package/assets/images/favicon.svg +5 -6
- package/dist/graymatter-ui.js +1663 -1628
- package/dist/graymatter-ui.umd.cjs +80 -33
- package/package.json +4 -4
- package/src/components/Button.svelte +27 -31
- package/src/components/Button.webcomponent.svelte +13 -11
- package/src/components/DesktopHeader.svelte +70 -53
- package/src/components/DesktopHeader.webcomponent.svelte +13 -9
- package/src/components/DesktopSideNav.svelte +209 -166
- package/src/components/DesktopSideNav.webcomponent.svelte +7 -5
- package/src/components/Head.astro +24 -2
- package/src/components/Logo.svelte +48 -41
- package/src/components/Logo.webcomponent.svelte +16 -16
- package/src/components/MobileBottomNav.svelte +102 -69
- package/src/components/MobileBottomNav.webcomponent.svelte +13 -4
- package/src/components/MobileSideMenu.svelte +70 -42
- package/src/components/MobileSideMenu.webcomponent.svelte +6 -4
- package/src/components/MobileTopNav.svelte +24 -19
- package/src/components/MobileTopNav.webcomponent.svelte +6 -6
- package/src/components/NavigationInitializer.svelte +10 -2
- package/src/components/ProfileMenu.svelte +116 -27
- package/src/components/UsaSkipNav.svelte +23 -0
- package/src/components/icons/ApiIcon.svelte +182 -33
- package/src/components/icons/ChatIcon.svelte +10 -10
- package/src/components/icons/CloseIcon.svelte +10 -10
- package/src/components/icons/ConsoleIcon.svelte +68 -13
- package/src/components/icons/DiscoverIcon.svelte +14 -14
- package/src/components/icons/HelpIcon.svelte +2 -1
- package/src/components/icons/MenuIcon.svelte +47 -7
- package/src/components/index.ts +1 -1
- package/src/layouts/BaseLayout.astro +1 -1
- package/src/styles/base/global.css +7 -1
- package/src/components/UsaSkipNav.astro +0 -5
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
selectedItem,
|
|
5
|
+
isExpanded,
|
|
6
|
+
navigationStore,
|
|
7
|
+
} from '../stores/navigationStore.js';
|
|
4
8
|
import Logo from './Logo.svelte';
|
|
5
9
|
import CloseIcon from './icons/CloseIcon.svelte';
|
|
6
10
|
|
|
7
11
|
let sideMenuElement: HTMLDivElement;
|
|
8
12
|
let pendingMobileHash: string | null = null;
|
|
9
|
-
|
|
13
|
+
|
|
10
14
|
let { logoLinkUrl, logoLinkLabel } = $props();
|
|
11
15
|
|
|
12
16
|
function handleMobileAnchorNavigate(event: Event) {
|
|
@@ -19,23 +23,27 @@
|
|
|
19
23
|
if (el) {
|
|
20
24
|
// Check if the anchor exists in mobile content
|
|
21
25
|
const mobileContent = document.getElementById('main-content-mobile');
|
|
22
|
-
const mobileAnchor = mobileContent
|
|
23
|
-
|
|
26
|
+
const mobileAnchor = mobileContent
|
|
27
|
+
? mobileContent.querySelector(`[id="${pendingMobileHash}"]`)
|
|
28
|
+
: null;
|
|
29
|
+
|
|
24
30
|
if (mobileAnchor && mobileContent) {
|
|
25
31
|
// For mobile: show section title in both header and content
|
|
26
32
|
const rect = mobileAnchor.getBoundingClientRect();
|
|
27
33
|
const mobileRect = mobileContent.getBoundingClientRect();
|
|
28
34
|
const scrollTop = mobileContent.scrollTop + rect.top - mobileRect.top;
|
|
29
35
|
mobileContent.scrollTo({ top: scrollTop, behavior: 'auto' });
|
|
30
|
-
|
|
36
|
+
|
|
31
37
|
// Update header after scroll completes
|
|
32
38
|
setTimeout(() => {
|
|
33
|
-
window.dispatchEvent(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
window.dispatchEvent(
|
|
40
|
+
new CustomEvent('sectionVisible', {
|
|
41
|
+
detail: {
|
|
42
|
+
title: mobileAnchor.textContent?.trim(),
|
|
43
|
+
id: mobileAnchor.id,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
);
|
|
39
47
|
}, 100);
|
|
40
48
|
} else {
|
|
41
49
|
// Fallback to desktop content
|
|
@@ -47,18 +55,20 @@
|
|
|
47
55
|
}
|
|
48
56
|
scrollableParent = scrollableParent.parentElement;
|
|
49
57
|
}
|
|
50
|
-
|
|
58
|
+
|
|
51
59
|
if (scrollableParent && scrollableParent !== document.body) {
|
|
52
60
|
const desktopMain = document.getElementById('main-content');
|
|
53
61
|
if (desktopMain) {
|
|
54
62
|
el.scrollIntoView({ behavior: 'auto', block: 'start' });
|
|
55
63
|
setTimeout(() => {
|
|
56
|
-
window.dispatchEvent(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
window.dispatchEvent(
|
|
65
|
+
new CustomEvent('sectionVisible', {
|
|
66
|
+
detail: {
|
|
67
|
+
title: el.textContent?.trim(),
|
|
68
|
+
id: el.id,
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
);
|
|
62
72
|
}, 100);
|
|
63
73
|
} else {
|
|
64
74
|
el.scrollIntoView({ behavior: 'auto', block: 'start' });
|
|
@@ -75,16 +85,16 @@
|
|
|
75
85
|
onMount(() => {
|
|
76
86
|
// Listen for the custom event globally
|
|
77
87
|
window.addEventListener('mobileAnchorNavigate', handleMobileAnchorNavigate);
|
|
78
|
-
|
|
88
|
+
|
|
79
89
|
const handleCloseMobileMenu = () => {
|
|
80
90
|
closeMobileMenu();
|
|
81
91
|
};
|
|
82
92
|
window.addEventListener('closeMobileMenu', handleCloseMobileMenu);
|
|
83
|
-
|
|
93
|
+
|
|
84
94
|
const handleLayoutTransition = (event: Event) => {
|
|
85
95
|
const customEvent = event as CustomEvent;
|
|
86
96
|
const { isMobile } = customEvent.detail;
|
|
87
|
-
|
|
97
|
+
|
|
88
98
|
if (isMobile) {
|
|
89
99
|
navigationStore.setExpanded(false);
|
|
90
100
|
}
|
|
@@ -102,9 +112,12 @@
|
|
|
102
112
|
}
|
|
103
113
|
};
|
|
104
114
|
window.addEventListener('keydown', handleEscape);
|
|
105
|
-
|
|
115
|
+
|
|
106
116
|
return () => {
|
|
107
|
-
window.removeEventListener(
|
|
117
|
+
window.removeEventListener(
|
|
118
|
+
'mobileAnchorNavigate',
|
|
119
|
+
handleMobileAnchorNavigate
|
|
120
|
+
);
|
|
108
121
|
window.removeEventListener('closeMobileMenu', handleCloseMobileMenu);
|
|
109
122
|
window.removeEventListener('layoutTransition', handleLayoutTransition);
|
|
110
123
|
window.removeEventListener('keydown', handleEscape);
|
|
@@ -118,36 +131,51 @@
|
|
|
118
131
|
|
|
119
132
|
<!-- Mobile overlay -->
|
|
120
133
|
{#if $isExpanded}
|
|
121
|
-
<div
|
|
122
|
-
class="mobile-overlay"
|
|
134
|
+
<div
|
|
135
|
+
class="mobile-overlay"
|
|
123
136
|
role="button"
|
|
124
137
|
tabindex="0"
|
|
125
138
|
aria-label="Close navigation menu (Escape key supported)"
|
|
126
139
|
part="mobile-overlay"
|
|
127
140
|
onclick={closeMobileMenu}
|
|
128
|
-
onkeydown={
|
|
141
|
+
onkeydown={e => {
|
|
129
142
|
if (e.key === 'Enter' || e.key === ' ') closeMobileMenu();
|
|
130
143
|
}}
|
|
131
144
|
></div>
|
|
132
145
|
{/if}
|
|
133
146
|
|
|
134
147
|
<!-- Mobile side menu -->
|
|
135
|
-
<div
|
|
148
|
+
<div
|
|
149
|
+
class="mobile-side-menu"
|
|
150
|
+
class:open={$isExpanded}
|
|
151
|
+
bind:this={sideMenuElement}
|
|
152
|
+
part="mobile-side-menu"
|
|
153
|
+
>
|
|
136
154
|
<div class="menu-header" part="menu-header">
|
|
137
155
|
<div class="logo-container" part="logo-container">
|
|
138
156
|
{#if logoLinkUrl}
|
|
139
|
-
<a
|
|
140
|
-
|
|
157
|
+
<a
|
|
158
|
+
href={logoLinkUrl}
|
|
159
|
+
part="logo-link"
|
|
160
|
+
class="logo-link"
|
|
161
|
+
aria-label={logoLinkLabel}
|
|
162
|
+
>
|
|
163
|
+
<Logo width={75} height={22} />
|
|
141
164
|
</a>
|
|
142
165
|
{:else}
|
|
143
|
-
<Logo width={75} height={22}/>
|
|
166
|
+
<Logo width={75} height={22} />
|
|
144
167
|
{/if}
|
|
145
168
|
</div>
|
|
146
|
-
<button
|
|
169
|
+
<button
|
|
170
|
+
class="close-button"
|
|
171
|
+
onclick={closeMobileMenu}
|
|
172
|
+
aria-label="Close navigation menu"
|
|
173
|
+
part="close-button"
|
|
174
|
+
>
|
|
147
175
|
<CloseIcon />
|
|
148
176
|
</button>
|
|
149
177
|
</div>
|
|
150
|
-
|
|
178
|
+
|
|
151
179
|
<div class="menu-content" part="menu-content">
|
|
152
180
|
<!-- Show submenu items if available and not on discover -->
|
|
153
181
|
{#if $selectedItem && $selectedItem !== 'discover'}
|
|
@@ -170,11 +198,11 @@
|
|
|
170
198
|
color: var(--ai-color-steel-600);
|
|
171
199
|
font-size: var(--ai-size-14);
|
|
172
200
|
}
|
|
173
|
-
|
|
201
|
+
|
|
174
202
|
.no-subnav-message p {
|
|
175
203
|
margin: 0;
|
|
176
204
|
}
|
|
177
|
-
|
|
205
|
+
|
|
178
206
|
.mobile-side-menu {
|
|
179
207
|
position: fixed;
|
|
180
208
|
top: 0;
|
|
@@ -184,16 +212,16 @@
|
|
|
184
212
|
max-width: 85vw;
|
|
185
213
|
background: var(--ai-color-white);
|
|
186
214
|
transform: translateX(-100%);
|
|
187
|
-
transition: transform
|
|
215
|
+
transition: transform var(--ai-transition-ease-medium);
|
|
188
216
|
z-index: var(--ai-layer-50);
|
|
189
217
|
flex-direction: column;
|
|
190
218
|
box-shadow: var(--ai-size-2) 0 var(--ai-size-10) #0000001a;
|
|
191
219
|
}
|
|
192
|
-
|
|
220
|
+
|
|
193
221
|
.mobile-side-menu.open {
|
|
194
222
|
transform: translateX(0);
|
|
195
223
|
}
|
|
196
|
-
|
|
224
|
+
|
|
197
225
|
.menu-header {
|
|
198
226
|
display: flex;
|
|
199
227
|
align-items: center;
|
|
@@ -201,11 +229,11 @@
|
|
|
201
229
|
padding: var(--ai-size-16) var(--ai-size-20);
|
|
202
230
|
border-bottom: 0.0625rem solid var(--ai-color-neutral-200);
|
|
203
231
|
}
|
|
204
|
-
|
|
232
|
+
|
|
205
233
|
.mobile-side-menu .logo-container {
|
|
206
234
|
display: flex;
|
|
207
235
|
}
|
|
208
|
-
|
|
236
|
+
|
|
209
237
|
.logo-link {
|
|
210
238
|
display: flex;
|
|
211
239
|
}
|
|
@@ -221,19 +249,19 @@
|
|
|
221
249
|
color: var(--ai-color-neutral-700);
|
|
222
250
|
cursor: pointer;
|
|
223
251
|
border-radius: var(--ai-size-6);
|
|
224
|
-
transition: background-color
|
|
252
|
+
transition: background-color var(--ai-transition-ease-fast);
|
|
225
253
|
}
|
|
226
254
|
|
|
227
255
|
.close-button:hover {
|
|
228
256
|
background-color: var(--ai-color-neutral-100);
|
|
229
257
|
}
|
|
230
|
-
|
|
258
|
+
|
|
231
259
|
.menu-content {
|
|
232
260
|
flex: 1;
|
|
233
261
|
overflow-y: auto;
|
|
234
262
|
padding: 0;
|
|
235
263
|
}
|
|
236
|
-
|
|
264
|
+
|
|
237
265
|
.subnav-section {
|
|
238
266
|
padding: var(--ai-size-16) 0;
|
|
239
267
|
overflow-y: auto;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
<svelte:options
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<svelte:options
|
|
2
|
+
customElement={{
|
|
3
|
+
tag: 'graymatter-mobile-side-menu',
|
|
4
|
+
}}
|
|
5
|
+
/>
|
|
4
6
|
|
|
5
7
|
<script lang="ts">
|
|
6
8
|
import MobileSideMenu from './MobileSideMenu.svelte';
|
|
7
|
-
|
|
9
|
+
|
|
8
10
|
let { logoLinkUrl, logoLinkLabel } = $props();
|
|
9
11
|
</script>
|
|
10
12
|
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
if (customEvent.detail && customEvent.detail.title) {
|
|
23
23
|
selectedSubNavItemTitle.set(customEvent.detail.title);
|
|
24
24
|
if (typeof window !== 'undefined') {
|
|
25
|
-
sessionStorage.setItem(
|
|
25
|
+
sessionStorage.setItem(
|
|
26
|
+
'subNav-selectedTitle',
|
|
27
|
+
JSON.stringify(customEvent.detail.title)
|
|
28
|
+
);
|
|
26
29
|
}
|
|
27
30
|
}
|
|
28
31
|
};
|
|
@@ -30,27 +33,32 @@
|
|
|
30
33
|
// Set default header based on first submenu item
|
|
31
34
|
const setDefaultHeaderFromFirstSubmenu = () => {
|
|
32
35
|
if (typeof window === 'undefined') return;
|
|
33
|
-
|
|
36
|
+
|
|
34
37
|
const mainContent = document.getElementById('main-content');
|
|
35
38
|
if (!mainContent) return;
|
|
36
|
-
|
|
39
|
+
|
|
37
40
|
// Find the first heading with an ID (first submenu item)
|
|
38
|
-
const firstSection = mainContent.querySelector(
|
|
41
|
+
const firstSection = mainContent.querySelector(
|
|
42
|
+
'h2[id], h3[id], h4[id], h5[id], h6[id]'
|
|
43
|
+
);
|
|
39
44
|
if (firstSection && firstSection.textContent) {
|
|
40
45
|
const firstSectionTitle = firstSection.textContent.trim();
|
|
41
|
-
|
|
46
|
+
|
|
42
47
|
// Only set if no header is currently set
|
|
43
48
|
if (!$selectedSubNavItemTitle) {
|
|
44
49
|
selectedSubNavItemTitle.set(firstSectionTitle);
|
|
45
50
|
if (typeof window !== 'undefined') {
|
|
46
|
-
sessionStorage.setItem(
|
|
51
|
+
sessionStorage.setItem(
|
|
52
|
+
'subNav-selectedTitle',
|
|
53
|
+
JSON.stringify(firstSectionTitle)
|
|
54
|
+
);
|
|
47
55
|
}
|
|
48
56
|
}
|
|
49
57
|
}
|
|
50
58
|
};
|
|
51
59
|
|
|
52
60
|
window.addEventListener('sectionVisible', handleSectionVisible);
|
|
53
|
-
|
|
61
|
+
|
|
54
62
|
// Set default header on mount only if no stored title exists
|
|
55
63
|
const storedTitle = sessionStorage.getItem('subNav-selectedTitle');
|
|
56
64
|
if (!storedTitle || storedTitle === '""' || storedTitle === 'null') {
|
|
@@ -58,7 +66,7 @@
|
|
|
58
66
|
setDefaultHeaderFromFirstSubmenu();
|
|
59
67
|
}, 100);
|
|
60
68
|
}
|
|
61
|
-
|
|
69
|
+
|
|
62
70
|
return () => {
|
|
63
71
|
window.removeEventListener('sectionVisible', handleSectionVisible);
|
|
64
72
|
};
|
|
@@ -67,7 +75,6 @@
|
|
|
67
75
|
function toggleMobileNav() {
|
|
68
76
|
navigationStore.toggleExpanded();
|
|
69
77
|
}
|
|
70
|
-
|
|
71
78
|
</script>
|
|
72
79
|
|
|
73
80
|
<nav class="mobile-top-nav" part="mobile-top-nav">
|
|
@@ -88,17 +95,16 @@
|
|
|
88
95
|
</div>
|
|
89
96
|
|
|
90
97
|
{#if profileMenuData}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
98
|
+
<ProfileMenu
|
|
99
|
+
userMeta={profileMenuData?.userMeta}
|
|
100
|
+
menuItems={profileMenuData?.menuItems}
|
|
101
|
+
ariaLabel="Open profile menu"
|
|
102
|
+
menuAlign="mobile"
|
|
103
|
+
/>
|
|
97
104
|
{/if}
|
|
98
105
|
</nav>
|
|
99
106
|
|
|
100
107
|
<style>
|
|
101
|
-
|
|
102
108
|
.menu-button {
|
|
103
109
|
display: flex;
|
|
104
110
|
align-items: center;
|
|
@@ -107,7 +113,7 @@
|
|
|
107
113
|
background: none;
|
|
108
114
|
cursor: pointer;
|
|
109
115
|
border-radius: var(--ai-size-8);
|
|
110
|
-
transition: background-color
|
|
116
|
+
transition: background-color var(--ai-transition-ease-fast);
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
.menu-button:disabled,
|
|
@@ -158,7 +164,7 @@
|
|
|
158
164
|
border: none;
|
|
159
165
|
cursor: pointer;
|
|
160
166
|
opacity: 1;
|
|
161
|
-
transition: opacity
|
|
167
|
+
transition: opacity var(--ai-transition-ease-fast);
|
|
162
168
|
will-change: opacity;
|
|
163
169
|
}
|
|
164
170
|
|
|
@@ -171,5 +177,4 @@
|
|
|
171
177
|
.menu-button:focus {
|
|
172
178
|
outline: none;
|
|
173
179
|
}
|
|
174
|
-
|
|
175
180
|
</style>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
<svelte:options
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<svelte:options
|
|
2
|
+
customElement={{
|
|
3
|
+
tag: 'graymatter-mobile-top-nav',
|
|
4
|
+
}}
|
|
5
|
+
/>
|
|
4
6
|
|
|
5
7
|
<script lang="ts">
|
|
6
8
|
import MobileTopNav from './MobileTopNav.svelte';
|
|
@@ -9,6 +11,4 @@
|
|
|
9
11
|
let { profileMenuData } = $props();
|
|
10
12
|
</script>
|
|
11
13
|
|
|
12
|
-
<MobileTopNav
|
|
13
|
-
{profileMenuData}
|
|
14
|
-
/>
|
|
14
|
+
<MobileTopNav {profileMenuData} />
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
navigationStore,
|
|
5
|
+
type NavigationItem,
|
|
6
|
+
} from '../stores/navigationStore.js';
|
|
4
7
|
|
|
5
8
|
export let item: NavigationItem;
|
|
6
9
|
export let autoExpand: boolean = false;
|
|
@@ -9,7 +12,12 @@
|
|
|
9
12
|
if (item) {
|
|
10
13
|
navigationStore.setSelectedItem(item);
|
|
11
14
|
// Auto-expand for non-discover pages on desktop
|
|
12
|
-
if (
|
|
15
|
+
if (
|
|
16
|
+
autoExpand &&
|
|
17
|
+
item !== 'discover' &&
|
|
18
|
+
typeof window !== 'undefined' &&
|
|
19
|
+
window.innerWidth > 640
|
|
20
|
+
) {
|
|
13
21
|
navigationStore.setExpanded(true);
|
|
14
22
|
}
|
|
15
23
|
}
|
|
@@ -1,34 +1,112 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ProfileMenu Component - Event-Driven Architecture with Shadow DOM Support
|
|
6
|
+
*
|
|
7
|
+
* This component uses a custom event system to handle menu item actions.
|
|
8
|
+
* It dispatches 'profileMenuAction' events that consumers can listen to,
|
|
9
|
+
* providing a clean separation between the UI component and business logic.
|
|
10
|
+
*
|
|
11
|
+
* SHADOW DOM COMPATIBILITY:
|
|
12
|
+
* - Events are configured with bubbles: true and composed: true
|
|
13
|
+
* - Works in both regular DOM and Shadow DOM contexts
|
|
14
|
+
* - Compatible with web components and Astro apps
|
|
15
|
+
*
|
|
16
|
+
* USAGE:
|
|
17
|
+
* 1. Pass menu items with 'action' strings instead of functions
|
|
18
|
+
* 2. Listen for 'profileMenuAction' events in your app
|
|
19
|
+
* 3. Handle actions based on the 'action' property in event.detail
|
|
20
|
+
*
|
|
21
|
+
* Examples:
|
|
22
|
+
*
|
|
23
|
+
* // Astro App (Regular DOM)
|
|
24
|
+
* // In your Astro layout or component
|
|
25
|
+
* window.addEventListener('profileMenuAction', (event) => {
|
|
26
|
+
* const { action, userMeta, timestamp } = event.detail;
|
|
27
|
+
* switch (action) {
|
|
28
|
+
* case 'settings':
|
|
29
|
+
* // Handle settings action
|
|
30
|
+
* console.log('Settings clicked for user:', userMeta.initials);
|
|
31
|
+
* break;
|
|
32
|
+
* case 'logout':
|
|
33
|
+
* // Handle logout action
|
|
34
|
+
* console.log('Logout clicked for user:', userMeta.initials);
|
|
35
|
+
* break;
|
|
36
|
+
* }
|
|
37
|
+
* });
|
|
38
|
+
*
|
|
39
|
+
* // Web Component App (Shadow DOM)
|
|
40
|
+
* // In your Svelte app using web components
|
|
41
|
+
* document.addEventListener('profileMenuAction', (event) => {
|
|
42
|
+
* const { action, userMeta, timestamp } = event.detail;
|
|
43
|
+
* // Same handling as above - events bubble through Shadow DOM
|
|
44
|
+
* });
|
|
45
|
+
*
|
|
46
|
+
* MENU ITEMS FORMAT:
|
|
47
|
+
* ```
|
|
48
|
+
* menuItems: [
|
|
49
|
+
* {
|
|
50
|
+
* label: 'Settings',
|
|
51
|
+
* icon: '/path/to/icon.svg',
|
|
52
|
+
* action: 'settings' // String identifier for the action
|
|
53
|
+
* },
|
|
54
|
+
* {
|
|
55
|
+
* label: 'Log out',
|
|
56
|
+
* icon: '/path/to/logout.svg',
|
|
57
|
+
* action: 'logout' // String identifier for the action
|
|
58
|
+
* }
|
|
59
|
+
* ]
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* EVENT DETAILS:
|
|
63
|
+
* - action: String identifier for the menu item action
|
|
64
|
+
* - userMeta: User metadata object
|
|
65
|
+
* - timestamp: When the action was triggered
|
|
66
|
+
* - bubbles: true (allows event to bubble through DOM)
|
|
67
|
+
* - composed: true (allows event to cross Shadow DOM boundaries)
|
|
68
|
+
*/
|
|
69
|
+
|
|
3
70
|
/**
|
|
4
71
|
* Array of menu items to render in the profile menu.
|
|
5
|
-
* Each item: { label: string, icon: string,
|
|
72
|
+
* Each item: { label: string, icon: string, action: string }
|
|
6
73
|
*/
|
|
7
|
-
export let menuItems: Array<{ label: string; icon: string;
|
|
8
|
-
|
|
74
|
+
export let menuItems: Array<{ label: string; icon: string; action: string }> =
|
|
75
|
+
[];
|
|
76
|
+
export let userMeta: { initials: string };
|
|
9
77
|
export let ariaLabel: string = 'Open profile menu';
|
|
10
78
|
export let menuAlign: 'desktop' | 'mobile' = 'desktop';
|
|
11
79
|
|
|
12
80
|
let menuId = `profile-menu-${Math.random().toString(36).slice(2, 10)}`;
|
|
13
81
|
let menuOpen = false;
|
|
14
|
-
function toggleMenu() {
|
|
15
|
-
|
|
82
|
+
function toggleMenu() {
|
|
83
|
+
menuOpen = !menuOpen;
|
|
84
|
+
}
|
|
85
|
+
function closeMenu() {
|
|
86
|
+
menuOpen = false;
|
|
87
|
+
}
|
|
16
88
|
|
|
17
|
-
|
|
18
|
-
function executeOnClick(onClick: string | (() => void)) {
|
|
89
|
+
function executeMenuAction(action: string) {
|
|
19
90
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
91
|
+
const event = new CustomEvent('profileMenuAction', {
|
|
92
|
+
detail: {
|
|
93
|
+
action,
|
|
94
|
+
timestamp: Date.now(),
|
|
95
|
+
userMeta,
|
|
96
|
+
},
|
|
97
|
+
bubbles: true, // Allow event to bubble up through DOM
|
|
98
|
+
composed: true, // Allow event to cross Shadow DOM boundaries
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Try to dispatch from the component element first (for web components)
|
|
102
|
+
// Fall back to window if component element is not available
|
|
103
|
+
const target =
|
|
104
|
+
document
|
|
105
|
+
.querySelector('.profile-context-menu')
|
|
106
|
+
?.closest('[data-profile-menu]') || window;
|
|
107
|
+
target.dispatchEvent(event);
|
|
30
108
|
} catch (error) {
|
|
31
|
-
console.error('Error
|
|
109
|
+
console.error('Error dispatching profile menu action:', error);
|
|
32
110
|
}
|
|
33
111
|
}
|
|
34
112
|
|
|
@@ -79,7 +157,7 @@
|
|
|
79
157
|
});
|
|
80
158
|
</script>
|
|
81
159
|
|
|
82
|
-
<div style="position: relative; display: inline-block;">
|
|
160
|
+
<div style="position: relative; display: inline-block;" data-profile-menu>
|
|
83
161
|
<button
|
|
84
162
|
class="user-profile-button"
|
|
85
163
|
tabindex="0"
|
|
@@ -100,7 +178,9 @@
|
|
|
100
178
|
|
|
101
179
|
{#if menuOpen}
|
|
102
180
|
<div
|
|
103
|
-
class="profile-context-menu {menuAlign === 'desktop'
|
|
181
|
+
class="profile-context-menu {menuAlign === 'desktop'
|
|
182
|
+
? 'desktop-align'
|
|
183
|
+
: 'mobile-align'}"
|
|
104
184
|
tabindex="-1"
|
|
105
185
|
id={menuId}
|
|
106
186
|
role="menu"
|
|
@@ -109,15 +189,24 @@
|
|
|
109
189
|
<button
|
|
110
190
|
type="button"
|
|
111
191
|
class="menu-item"
|
|
112
|
-
on:click={() => {
|
|
192
|
+
on:click={() => {
|
|
193
|
+
executeMenuAction(item.action);
|
|
194
|
+
closeMenu();
|
|
195
|
+
}}
|
|
113
196
|
bind:this={btns[idx]}
|
|
114
|
-
on:keydown={
|
|
197
|
+
on:keydown={e => handleMenuKeydown(e, idx)}
|
|
115
198
|
role="menuitem"
|
|
116
199
|
>
|
|
117
200
|
{#if item.icon}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
201
|
+
<span class="icon">
|
|
202
|
+
<img
|
|
203
|
+
src={item.icon}
|
|
204
|
+
alt=""
|
|
205
|
+
aria-hidden="true"
|
|
206
|
+
width="20"
|
|
207
|
+
height="20"
|
|
208
|
+
/>
|
|
209
|
+
</span>
|
|
121
210
|
{/if}
|
|
122
211
|
<div class="menu-text">{item.label}</div>
|
|
123
212
|
</button>
|
|
@@ -198,7 +287,7 @@
|
|
|
198
287
|
border-radius: var(--ai-size-8);
|
|
199
288
|
border: 0.0625rem solid var(--ai-color-steel-200);
|
|
200
289
|
background: var(--ai-color-white);
|
|
201
|
-
box-shadow: 0 0.1875rem var(--ai-size-6) 0 rgba(50, 54, 61, 0.
|
|
290
|
+
box-shadow: 0 0.1875rem var(--ai-size-6) 0 rgba(50, 54, 61, 0.2);
|
|
202
291
|
position: absolute;
|
|
203
292
|
z-index: var(--ai-layer-50);
|
|
204
293
|
}
|
|
@@ -259,4 +348,4 @@
|
|
|
259
348
|
font-weight: 400;
|
|
260
349
|
line-height: 130%;
|
|
261
350
|
}
|
|
262
|
-
</style>
|
|
351
|
+
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import Button from '@gsa-tts/graymatter-ui/components/Button.svelte';
|
|
3
|
+
|
|
4
|
+
let { mainContentId = '#main-content', skipLinkTheme = 'inverted' } =
|
|
5
|
+
$props();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<Button
|
|
9
|
+
href={mainContentId}
|
|
10
|
+
theme={skipLinkTheme}
|
|
11
|
+
size="small"
|
|
12
|
+
class="usa-skipnav ai-skipnav"
|
|
13
|
+
>
|
|
14
|
+
Skip to main content
|
|
15
|
+
</Button>
|
|
16
|
+
|
|
17
|
+
<style>
|
|
18
|
+
:global(.ai-skipnav:focus) {
|
|
19
|
+
margin-block-start: var(--ai-size-12);
|
|
20
|
+
margin-inline-start: var(--ai-size-12);
|
|
21
|
+
text-decoration: none;
|
|
22
|
+
}
|
|
23
|
+
</style>
|