@gsa-tts/graymatter-ui 1.2.0 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsa-tts/graymatter-ui",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -67,8 +67,8 @@
67
67
  "typescript": "5.7.3",
68
68
  "vite": "^7.3.2",
69
69
  "vitest": "^3.2.4",
70
- "@gsa-tts/graymatter-eslint": "0.0.2",
71
70
  "@gsa-tts/graymatter-typescript-config": "0.0.3",
71
+ "@gsa-tts/graymatter-eslint": "0.0.2",
72
72
  "@gsa-tts/graymatter-vitest-config": "0.0.2"
73
73
  },
74
74
  "dependencies": {
@@ -45,8 +45,8 @@ export const componentOptions: ComponentOptions = {
45
45
  header: { theme: 'inverse' },
46
46
  },
47
47
  partnershipsSubpage: {
48
- usaBanner: { theme: 'default' },
49
- header: { theme: 'default' },
48
+ usaBanner: { theme: 'inverse' },
49
+ header: { theme: 'inverse' },
50
50
  },
51
51
  website: {
52
52
  usaBanner: { theme: 'inverse' },
@@ -25,6 +25,7 @@
25
25
  import MenuIcon from './icons/MenuIcon.svelte';
26
26
  import ProfileMenu from './ProfileMenu.svelte';
27
27
  import HelpIcon from './icons/HelpIcon.svelte';
28
+ import Tooltip from './Tooltip.svelte';
28
29
 
29
30
  let {
30
31
  ssrSelectedItem = undefined,
@@ -530,22 +531,23 @@
530
531
  class="nav-footer-icon-container"
531
532
  part="nav-footer-icon-container"
532
533
  >
533
- <a
534
- class="help-link"
535
- class:selected={isHelpSelected()}
536
- aria-current={isHelpSelected() ? 'page' : undefined}
537
- href={helpUrl()}
538
- part="help-link"
539
- title="Help"
540
- aria-label="Help"
541
- onclick={handleHelpClick}
542
- >
543
- <div class="icon-container" part="help-icon-container">
544
- <div class="icon-wrapper">
545
- <HelpIcon isSelected={isHelpSelected()} />
534
+ <Tooltip text="User Guide" position="right">
535
+ <a
536
+ class="help-link"
537
+ class:selected={isHelpSelected()}
538
+ aria-current={isHelpSelected() ? 'page' : undefined}
539
+ href={helpUrl()}
540
+ part="help-link"
541
+ aria-label="User Guide"
542
+ onclick={handleHelpClick}
543
+ >
544
+ <div class="icon-container" part="help-icon-container">
545
+ <div class="icon-wrapper">
546
+ <HelpIcon isSelected={isHelpSelected()} />
547
+ </div>
546
548
  </div>
547
- </div>
548
- </a>
549
+ </a>
550
+ </Tooltip>
549
551
  </div>
550
552
  {/if}
551
553
  <div class="nav-footer-profile-container">
@@ -7,6 +7,7 @@
7
7
  import Logo from './Logo.svelte';
8
8
  import MenuIcon from './icons/MenuIcon.svelte';
9
9
  import HelpIcon from './icons/HelpIcon.svelte';
10
+ import Tooltip from './Tooltip.svelte';
10
11
 
11
12
  let { profileMenuData, logoLinkUrl, logoLinkLabel, supplementalMenuData } =
12
13
  $props();
@@ -141,19 +142,20 @@
141
142
  </div>
142
143
 
143
144
  {#if helpUrl()}
144
- <a
145
- class="help-button"
146
- class:selected={isHelpSelected()}
147
- aria-current={isHelpSelected() ? 'page' : undefined}
148
- href={helpUrl()}
149
- title="Help"
150
- aria-label="Help"
151
- onclick={handleHelpClick}
152
- >
153
- <span class="icon-container">
154
- <HelpIcon isSelected={isHelpSelected()} />
155
- </span>
156
- </a>
145
+ <Tooltip text="User Guide" position="bottom">
146
+ <a
147
+ class="help-button"
148
+ class:selected={isHelpSelected()}
149
+ aria-current={isHelpSelected() ? 'page' : undefined}
150
+ href={helpUrl()}
151
+ aria-label="User Guide"
152
+ onclick={handleHelpClick}
153
+ >
154
+ <span class="icon-container">
155
+ <HelpIcon isSelected={isHelpSelected()} />
156
+ </span>
157
+ </a>
158
+ </Tooltip>
157
159
  {/if}
158
160
 
159
161
  {#if profileMenuData}
@@ -65,6 +65,8 @@
65
65
  * - composed: true (allows event to cross Shadow DOM boundaries)
66
66
  */
67
67
 
68
+ import Tooltip from './Tooltip.svelte';
69
+
68
70
  /**
69
71
  * Array of menu items to render in the profile menu.
70
72
  * Each item: { label: string, icon: string, action: string }
@@ -167,23 +169,28 @@
167
169
  </script>
168
170
 
169
171
  <div style="position: relative; display: inline-block;" data-profile-menu>
170
- <button
171
- class="user-profile-button"
172
- tabindex="0"
173
- aria-label={ariaLabel}
174
- aria-haspopup="menu"
175
- aria-expanded={menuOpen}
176
- aria-controls={menuId}
177
- onclick={toggleMenu}
178
- onblur={handleBlur}
179
- bind:this={profileButton}
172
+ <Tooltip
173
+ text="Account"
174
+ position={menuAlign === 'desktop' ? 'right' : 'bottom'}
180
175
  >
181
- <div class="user-profile-container">
182
- <div class="user-profile-icon" part="user-profile-icon">
183
- <span class="user-initials">{userMeta?.initials}</span>
176
+ <button
177
+ class="user-profile-button"
178
+ tabindex="0"
179
+ aria-label={ariaLabel}
180
+ aria-haspopup="menu"
181
+ aria-expanded={menuOpen}
182
+ aria-controls={menuId}
183
+ onclick={toggleMenu}
184
+ onblur={handleBlur}
185
+ bind:this={profileButton}
186
+ >
187
+ <div class="user-profile-container">
188
+ <div class="user-profile-icon" part="user-profile-icon">
189
+ <span class="user-initials">{userMeta?.initials}</span>
190
+ </div>
184
191
  </div>
185
- </div>
186
- </button>
192
+ </button>
193
+ </Tooltip>
187
194
 
188
195
  {#if menuOpen}
189
196
  <div
@@ -0,0 +1,288 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Tooltip Component
4
+ *
5
+ * Ported from usai-frontend's Tooltip:
6
+ * https://github.com/GSA-TTS/usai-frontend/blob/4050b68aeccf21572c3897a0b2e01c4719b9456a/src/lib/components/shared/Tooltip.svelte
7
+ * (kept behaviorally identical; renamed `.tooltip*` classes to `.ai-tooltip*`
8
+ * and `--tooltip-*` vars to `--ai-tooltip-*` to match this package's naming).
9
+ *
10
+ * The bubble is portaled to <body> and positioned with `position: fixed` +
11
+ * JS coordinates so it never gets clipped by an `overflow: hidden` ancestor.
12
+ *
13
+ * Appearance is driven entirely by `--ai-tooltip-*` CSS custom properties. To
14
+ * restyle per app, override those variables at `:root` (or any scope that
15
+ * reaches <body>) — no component changes needed.
16
+ *
17
+ * `children` is a snippet that receives accessibility props. Callers that
18
+ * don't need them can pass plain content; the extra args are simply ignored.
19
+ *
20
+ * Props:
21
+ * - text: string - Text shown inside the tooltip bubble
22
+ * - position?: 'top' | 'bottom' | 'left' | 'right' (default: 'top')
23
+ * - align?: 'center' | 'left' | 'right' (default: 'center')
24
+ * - showOn?: 'hover' | 'disabled' (default: 'hover')
25
+ * - disabled?: boolean (default: false)
26
+ * - showArrow?: boolean (default: false)
27
+ * - minWidth?: string
28
+ * - maxWidth?: string
29
+ * - tooltipId?: string - Auto-generated if omitted
30
+ * - wrapperClass?: string
31
+ * - children?: Snippet<[TooltipChildrenProps]>
32
+ *
33
+ * Example:
34
+ * ```svelte
35
+ * <Tooltip text="Help" position="right">
36
+ * <a href="/help" aria-label="Help"><HelpIcon /></a>
37
+ * </Tooltip>
38
+ * ```
39
+ */
40
+ import { onMount, onDestroy } from 'svelte';
41
+
42
+ interface TooltipChildrenProps {
43
+ disabled: boolean;
44
+ tooltipId: string;
45
+ ariaDisabled: 'true' | 'false';
46
+ ariaDescribedby: string;
47
+ }
48
+
49
+ interface Props {
50
+ text: string;
51
+ position?: 'top' | 'bottom' | 'left' | 'right';
52
+ align?: 'center' | 'left' | 'right';
53
+ showOn?: 'hover' | 'disabled';
54
+ disabled?: boolean;
55
+ showArrow?: boolean;
56
+ minWidth?: string;
57
+ maxWidth?: string;
58
+ tooltipId?: string;
59
+ wrapperClass?: string;
60
+ children?: import('svelte').Snippet<[TooltipChildrenProps]>;
61
+ }
62
+
63
+ let {
64
+ text,
65
+ position = 'top',
66
+ align = 'center',
67
+ showOn = 'hover',
68
+ disabled = false,
69
+ showArrow = false,
70
+ minWidth,
71
+ maxWidth,
72
+ tooltipId = `tooltip-${Math.random().toString(36).substring(2, 9)}`,
73
+ wrapperClass = '',
74
+ children,
75
+ }: Props = $props();
76
+
77
+ let isVisible = $state(false);
78
+ let tooltipElement: HTMLDivElement;
79
+ let wrapperElement: HTMLDivElement;
80
+
81
+ // Gap in px between the trigger and the bubble.
82
+ const GAP = 6;
83
+
84
+ // 'hover' → always available; 'disabled' → only when the trigger is disabled.
85
+ let shouldShow = $derived(
86
+ showOn === 'hover' || (showOn === 'disabled' && disabled)
87
+ );
88
+
89
+ let customStyle = $derived(
90
+ [minWidth && `min-width: ${minWidth}`, maxWidth && `max-width: ${maxWidth}`]
91
+ .filter(Boolean)
92
+ .join('; ') || undefined
93
+ );
94
+
95
+ function updatePosition() {
96
+ if (!tooltipElement || !wrapperElement || typeof window === 'undefined') {
97
+ return;
98
+ }
99
+
100
+ const wrap = wrapperElement.getBoundingClientRect();
101
+ const tip = tooltipElement.getBoundingClientRect();
102
+ const centerX = wrap.left + wrap.width / 2;
103
+ const centerY = wrap.top + wrap.height / 2;
104
+
105
+ // Clear any previously-set coordinates so only the ones we set below apply.
106
+ tooltipElement.style.top = '';
107
+ tooltipElement.style.left = '';
108
+ tooltipElement.style.transform = 'none';
109
+
110
+ let top = 0;
111
+ let left = 0;
112
+
113
+ if (position === 'top' || position === 'bottom') {
114
+ top =
115
+ position === 'top' ? wrap.top - tip.height - GAP : wrap.bottom + GAP;
116
+ if (align === 'left') left = wrap.left;
117
+ else if (align === 'right') left = wrap.right - tip.width;
118
+ else left = centerX - tip.width / 2;
119
+ } else {
120
+ left =
121
+ position === 'left' ? wrap.left - tip.width - GAP : wrap.right + GAP;
122
+ top = centerY - tip.height / 2;
123
+ }
124
+
125
+ tooltipElement.style.top = `${top}px`;
126
+ tooltipElement.style.left = `${left}px`;
127
+
128
+ // Point the arrow at the trigger's center regardless of alignment.
129
+ if (showArrow && arrowElement) {
130
+ if (position === 'top' || position === 'bottom') {
131
+ arrowElement.style.left = `${centerX - left}px`;
132
+ arrowElement.style.top = '';
133
+ } else {
134
+ arrowElement.style.top = `${centerY - top}px`;
135
+ arrowElement.style.left = '';
136
+ }
137
+ }
138
+ }
139
+
140
+ let arrowElement = $state<HTMLDivElement>();
141
+
142
+ $effect(() => {
143
+ if (isVisible && tooltipElement) {
144
+ requestAnimationFrame(updatePosition);
145
+ }
146
+ });
147
+
148
+ onMount(() => {
149
+ if (typeof window !== 'undefined' && tooltipElement) {
150
+ document.body.appendChild(tooltipElement);
151
+ }
152
+ });
153
+
154
+ onDestroy(() => {
155
+ if (typeof window !== 'undefined' && tooltipElement?.parentNode) {
156
+ tooltipElement.parentNode.removeChild(tooltipElement);
157
+ }
158
+ });
159
+
160
+ function open() {
161
+ if (shouldShow) isVisible = true;
162
+ }
163
+ function close() {
164
+ isVisible = false;
165
+ }
166
+ </script>
167
+
168
+ <div
169
+ bind:this={wrapperElement}
170
+ class={`ai-tooltip-trigger ${wrapperClass}`}
171
+ role="none"
172
+ onmouseenter={open}
173
+ onmouseleave={close}
174
+ onfocusin={open}
175
+ onfocusout={close}
176
+ >
177
+ {@render children?.({
178
+ disabled,
179
+ tooltipId,
180
+ ariaDisabled: disabled ? 'true' : 'false',
181
+ ariaDescribedby: shouldShow ? tooltipId : '',
182
+ })}
183
+ </div>
184
+
185
+ <div
186
+ bind:this={tooltipElement}
187
+ id={tooltipId}
188
+ class={`ai-tooltip pos-${position}`}
189
+ class:visible={isVisible && shouldShow}
190
+ style={customStyle}
191
+ role="tooltip"
192
+ aria-live="polite"
193
+ >
194
+ {text}{#if showArrow}<div
195
+ bind:this={arrowElement}
196
+ class="ai-tooltip-arrow"
197
+ aria-hidden="true"
198
+ ></div>{/if}
199
+ </div>
200
+
201
+ <style>
202
+ /*! purgecss start ignore */
203
+ :root,
204
+ :host {
205
+ --ai-tooltip-bg: var(--ai-color-black);
206
+ --ai-tooltip-fg: var(--ai-color-white);
207
+ --ai-tooltip-radius: var(--ai-size-8, 0.625rem);
208
+ --ai-tooltip-padding: var(--ai-size-8, 0.5rem);
209
+ --ai-tooltip-font-size: 0.8125rem;
210
+ --ai-tooltip-font-weight: 500;
211
+ --ai-tooltip-line-height: 1.4;
212
+ --ai-tooltip-shadow: 0 0.25rem 0.375rem rgba(0, 0, 0, 0.3);
213
+ --ai-tooltip-max-width: 21.875rem;
214
+ --ai-tooltip-max-width-mobile: 18rem;
215
+ --ai-tooltip-arrow-size: 0.3125rem;
216
+ }
217
+
218
+ .ai-tooltip-trigger {
219
+ position: relative;
220
+ display: inline-block;
221
+ }
222
+
223
+ .ai-tooltip {
224
+ position: fixed;
225
+ top: 0;
226
+ left: 0;
227
+ z-index: var(--ai-layer-100);
228
+ display: none;
229
+ max-width: var(--ai-tooltip-max-width);
230
+ padding: var(--ai-tooltip-padding);
231
+ border-radius: var(--ai-tooltip-radius);
232
+ background-color: var(--ai-tooltip-bg);
233
+ box-shadow: var(--ai-tooltip-shadow);
234
+ color: var(--ai-tooltip-fg);
235
+ font-size: var(--ai-tooltip-font-size);
236
+ font-weight: var(--ai-tooltip-font-weight);
237
+ line-height: var(--ai-tooltip-line-height);
238
+ pointer-events: none;
239
+ white-space: normal;
240
+ word-wrap: break-word;
241
+ overflow-wrap: break-word;
242
+ }
243
+
244
+ .ai-tooltip.visible {
245
+ display: block;
246
+ }
247
+
248
+ /* Arrow — JS positions it along the trigger-facing edge; here we only place
249
+ * it on the correct side and point it in the right direction. */
250
+ .ai-tooltip-arrow {
251
+ position: absolute;
252
+ width: 0;
253
+ height: 0;
254
+ border: var(--ai-tooltip-arrow-size) solid transparent;
255
+ }
256
+
257
+ .ai-tooltip.pos-top .ai-tooltip-arrow {
258
+ top: 100%;
259
+ transform: translateX(-50%);
260
+ border-top-color: var(--ai-tooltip-bg);
261
+ }
262
+
263
+ .ai-tooltip.pos-bottom .ai-tooltip-arrow {
264
+ bottom: 100%;
265
+ transform: translateX(-50%);
266
+ border-bottom-color: var(--ai-tooltip-bg);
267
+ }
268
+
269
+ .ai-tooltip.pos-left .ai-tooltip-arrow {
270
+ left: 100%;
271
+ transform: translateY(-50%);
272
+ border-left-color: var(--ai-tooltip-bg);
273
+ }
274
+
275
+ .ai-tooltip.pos-right .ai-tooltip-arrow {
276
+ right: 100%;
277
+ transform: translateY(-50%);
278
+ border-right-color: var(--ai-tooltip-bg);
279
+ }
280
+
281
+ /* Mobile: cap width so long messages stay readable. */
282
+ @media (max-width: 48rem) {
283
+ .ai-tooltip {
284
+ max-width: var(--ai-tooltip-max-width-mobile);
285
+ }
286
+ }
287
+ /*! purgecss end ignore */
288
+ </style>
@@ -15,6 +15,7 @@ export { default as ApiDocSubNavMenuItem } from './ApiDocSubNavMenuItem.svelte';
15
15
  export { default as PlatformFooter } from './PlatformFooter.svelte';
16
16
  export { default as PlatformFooterMenu } from './PlatformFooterMenu.svelte';
17
17
  export { default as Modal } from './Modal.svelte';
18
+ export { default as Tooltip } from './Tooltip.svelte';
18
19
 
19
20
  // Icon Components
20
21
  export * from './icons/index.js';