@pure-ds/core 0.7.53 → 0.7.55

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.
@@ -1,3 +1,5 @@
1
+ import { PDS } from "#pds";
2
+
1
3
  /**
2
4
  * Floating Action Button (FAB) with expandable satellite actions
3
5
  *
@@ -15,6 +17,10 @@
15
17
  * @cssprop --sat-fg - Foreground color of satellites (default: var(--color-text-primary))
16
18
  * @cssprop --radius - Distance of satellites from main FAB (default: 100px)
17
19
  * @cssprop --transition-duration - Animation duration (default: 420ms)
20
+ * @cssprop --z-fab-in-drawer - FAB z-index when used inside pds-drawer (default: var(--z-popover))
21
+ *
22
+ * @attr {"click"|"hover"} behavior - Interaction mode for opening satellites (default: click)
23
+ * @attr {"fixed"|"inline"} mode - Layout mode for FAB positioning (default: fixed)
18
24
  *
19
25
  * @csspart fab - The main FAB button
20
26
  * @csspart satellite - Individual satellite buttons
@@ -31,6 +37,11 @@ export class PdsFab extends HTMLElement {
31
37
  #hasCustomSpread = false;
32
38
  #hasCustomStartAngle = false;
33
39
  #iconRetryPending = false;
40
+ #behavior = 'click';
41
+ #mode = 'fixed';
42
+ #fabClass = '';
43
+ #hoverMoveTracker = null;
44
+ #componentStyles = null;
34
45
 
35
46
  constructor() {
36
47
  super();
@@ -39,7 +50,7 @@ export class PdsFab extends HTMLElement {
39
50
  }
40
51
 
41
52
  static get observedAttributes() {
42
- return ['open', 'radius', 'spread', 'start-angle'];
53
+ return ['open', 'radius', 'spread', 'start-angle', 'behavior', 'mode', 'fab-class'];
43
54
  }
44
55
 
45
56
  /**
@@ -109,10 +120,10 @@ export class PdsFab extends HTMLElement {
109
120
  /**
110
121
  * Starting angle in degrees (0=right, 90=down, 180=left, 270=up)
111
122
  * If not specified, the angle is auto-detected based on the FAB's corner position:
112
- * - Bottom-right: 180° (fly left/up)
113
- * - Bottom-left: 315° (fly right/up)
114
- * - Top-right: 225° (fly left/down)
115
- * - Top-left: 45° (fly right/down)
123
+ * - Bottom-right: 180-� (fly left/up)
124
+ * - Bottom-left: 315-� (fly right/up)
125
+ * - Top-right: 225-� (fly left/down)
126
+ * - Top-left: 45-� (fly right/down)
116
127
  * @type {number}
117
128
  * @attr start-angle
118
129
  * @default 180 (or auto-detected)
@@ -130,9 +141,78 @@ export class PdsFab extends HTMLElement {
130
141
  this.#render();
131
142
  }
132
143
 
144
+ /**
145
+ * Interaction mode for satellite menu.
146
+ * - click: toggles satellites when main FAB is clicked
147
+ * - hover: opens on hover/focus and closes when pointer leaves safe area
148
+ * @type {'click'|'hover'}
149
+ * @attr behavior
150
+ * @default click
151
+ */
152
+ get behavior() {
153
+ return this.#behavior;
154
+ }
155
+ set behavior(val) {
156
+ const next = val === 'hover' ? 'hover' : 'click';
157
+ if (this.#behavior === next) return;
158
+ this.#behavior = next;
159
+ if (this.getAttribute('behavior') !== next) {
160
+ this.setAttribute('behavior', next);
161
+ return;
162
+ }
163
+ this.#render();
164
+ }
165
+
166
+ /**
167
+ * Layout mode for host positioning.
168
+ * - fixed: FAB is anchored to viewport bottom-right
169
+ * - inline: FAB participates in normal document flow
170
+ * @type {'fixed'|'inline'}
171
+ * @attr mode
172
+ * @default fixed
173
+ */
174
+ get mode() {
175
+ return this.#mode;
176
+ }
177
+ set mode(val) {
178
+ const next = val === 'inline' ? 'inline' : 'fixed';
179
+ if (this.#mode === next) return;
180
+ this.#mode = next;
181
+ if (this.getAttribute('mode') !== next) {
182
+ this.setAttribute('mode', next);
183
+ return;
184
+ }
185
+ this.#render();
186
+ }
187
+
188
+ /**
189
+ * Space-separated class list forwarded to the internal button.
190
+ * Use this instead of host classes for btn-* and icon-only.
191
+ * @type {string}
192
+ * @attr fab-class
193
+ * @example "btn-primary icon-only"
194
+ */
195
+ get fabClass() {
196
+ return this.#fabClass;
197
+ }
198
+ set fabClass(val) {
199
+ const next = String(val || '').trim();
200
+ if (this.#fabClass === next) return;
201
+ this.#fabClass = next;
202
+ if (this.getAttribute('fab-class') !== next) {
203
+ if (next) {
204
+ this.setAttribute('fab-class', next);
205
+ } else {
206
+ this.removeAttribute('fab-class');
207
+ }
208
+ return;
209
+ }
210
+ this.#render();
211
+ }
212
+
133
213
  /**
134
214
  * Array of satellite button configurations
135
- * @type {Array<{key: string, icon?: string, label?: string, action?: string}>}
215
+ * @type {Array<{key: string, icon?: string, iconColor?: string, bgColor?: string, iconSize?: string, label?: string, action?: string}>}
136
216
  */
137
217
  get satellites() {
138
218
  return this.#satellites;
@@ -184,18 +264,257 @@ export class PdsFab extends HTMLElement {
184
264
  this.#hasCustomStartAngle = true;
185
265
  this.startAngle = Number(newVal) || 180;
186
266
  break;
267
+ case 'behavior':
268
+ this.behavior = newVal === 'hover' ? 'hover' : 'click';
269
+ break;
270
+ case 'mode':
271
+ this.mode = newVal === 'inline' ? 'inline' : 'fixed';
272
+ break;
273
+ case 'fab-class':
274
+ this.fabClass = newVal || '';
275
+ break;
187
276
  }
188
277
  }
189
278
 
190
- connectedCallback() {
279
+ async connectedCallback() {
191
280
  this.#hasCustomRadius = this.hasAttribute('radius');
192
281
  this.#hasCustomSpread = this.hasAttribute('spread');
193
282
  this.#hasCustomStartAngle = this.hasAttribute('start-angle');
283
+ this.#behavior = this.getAttribute('behavior') === 'hover' ? 'hover' : 'click';
284
+ this.#mode = this.getAttribute('mode') === 'inline' ? 'inline' : 'fixed';
285
+ this.#fabClass = (this.getAttribute('fab-class') || '').trim();
286
+ if (!this.#componentStyles) {
287
+ this.#componentStyles = PDS.createStylesheet(/*css*/`
288
+ @layer pds-fab {
289
+ :host {
290
+ --fab-hit-area: var(--control-min-height, 2.5rem);
291
+ --sat-size: 48px;
292
+ --fab-size: 64px;
293
+ --fab-bg: var(--color-primary-600, #0078d4);
294
+ --fab-fg: white;
295
+ --sat-bg: var(--color-surface-elevated, #2a2a2a);
296
+ --sat-fg: var(--color-text-primary, #fff);
297
+ --radius: 100px;
298
+ --transition-duration: 420ms;
299
+
300
+ position: fixed;
301
+ inset: auto 20px 20px auto;
302
+ z-index: var(--fab-host-z-index, var(--z-notification, 1000));
303
+ display: inline-block;
304
+ vertical-align: middle;
305
+ }
306
+
307
+ :host([mode="inline"]) {
308
+ position: relative;
309
+ inset: auto;
310
+ --fab-size: auto;
311
+ --sat-size: 40px;
312
+ }
313
+
314
+ .wrap {
315
+ position: relative;
316
+ width: var(--fab-size);
317
+ height: var(--fab-size);
318
+ }
319
+
320
+ :host([mode="inline"]) .wrap {
321
+ width: auto;
322
+ height: auto;
323
+ display: inline-flex;
324
+ align-items: center;
325
+ justify-content: center;
326
+ }
327
+
328
+ .fab {
329
+ position: relative;
330
+ z-index: 2;
331
+ }
332
+
333
+ .fab ::slotted(pds-icon) {
334
+ display: inline-block;
335
+ transition: transform 0.2s ease;
336
+ }
337
+
338
+ /* When FAB is icon-only, the slot must fill and center for proper button geometry */
339
+ .fab.icon-only ::slotted(pds-icon) {
340
+ display: grid;
341
+ place-items: center;
342
+ inline-size: 100%;
343
+ block-size: 100%;
344
+ }
345
+
346
+ :host([mode="inline"]) .fab.icon-only {
347
+ /* Match compact icon-only geometry used by normal PDS buttons */
348
+ inline-size: max(36px, calc(var(--font-size-base) + (max(calc(var(--spacing-1) * 1), var(--spacing-2)) * 2) + (var(--border-width-medium) * 2)));
349
+ block-size: max(36px, calc(var(--font-size-base) + (max(calc(var(--spacing-1) * 1), var(--spacing-2)) * 2) + (var(--border-width-medium) * 2)));
350
+ min-inline-size: 0;
351
+ min-block-size: 0;
352
+ padding: 0;
353
+ }
354
+
355
+ :host([open]) .fab.has-satellites.has-plus ::slotted(pds-icon) {
356
+ transform: rotate(45deg);
357
+ }
358
+
359
+ .sat-text {
360
+ font-size: 1.25rem;
361
+ font-weight: 600;
362
+ user-select: none;
363
+ }
364
+ }
365
+
366
+ /* Host may carry forwarded button classes, but must never render as a button */
367
+ :host([class*="btn-"]),
368
+ :host(.icon-only) {
369
+ background: transparent;
370
+ border: 0;
371
+ border-radius: 0;
372
+ box-shadow: none;
373
+ color: inherit;
374
+ font: inherit;
375
+ inline-size: auto;
376
+ block-size: auto;
377
+ min-inline-size: 0;
378
+ min-block-size: 0;
379
+ padding: 0;
380
+ text-decoration: none;
381
+ transform: none;
382
+ }
383
+
384
+ /* Unlayered to prevent generic button primitives from overriding satellites */
385
+ .sat {
386
+ all: unset;
387
+ position: absolute;
388
+ top: calc((var(--fab-size) - var(--sat-size)) / 2);
389
+ left: calc((var(--fab-size) - var(--sat-size)) / 2);
390
+ inline-size: var(--sat-size);
391
+ block-size: var(--sat-size);
392
+ border-radius: 50%;
393
+ overflow: hidden;
394
+ background: var(--sat-bg);
395
+ color: var(--sat-fg);
396
+ box-shadow:
397
+ 0 2px 6px rgba(0, 0, 0, 0.15),
398
+ 0 6px 18px rgba(0, 0, 0, 0.2);
399
+ display: grid;
400
+ place-items: center;
401
+ cursor: pointer;
402
+ transform: translate(0, 0) scale(0.2);
403
+ opacity: 0;
404
+ pointer-events: none;
405
+ transition:
406
+ transform 420ms cubic-bezier(0.2, 0.8, 0.2, 1.4),
407
+ opacity 420ms ease;
408
+ }
409
+
410
+ :host([mode="inline"]) .sat {
411
+ inline-size: 40px;
412
+ block-size: 40px;
413
+ border-radius: 50%;
414
+ --sat-icon-offset-x: 0px;
415
+ --sat-icon-offset-y: 3px;
416
+ background: var(--sat-bg-color, var(--sat-bg, var(--color-primary-600, #0078d4)));
417
+ color: var(--sat-icon-color, #fff);
418
+ box-shadow:
419
+ 0 2px 6px rgba(0, 0, 0, 0.18),
420
+ 0 8px 16px rgba(0, 0, 0, 0.16);
421
+ }
422
+
423
+ :host([mode="inline"]) .sat pds-icon {
424
+ color: var(--sat-icon-color, #fff);
425
+ filter: none;
426
+ }
427
+
428
+ :host([open]) .sat {
429
+ transform: translate(var(--tx, 0), var(--ty, 0)) scale(1);
430
+ opacity: 1;
431
+ pointer-events: auto;
432
+ }
433
+
434
+ .sat:hover,
435
+ .sat:focus-visible {
436
+ transform: translate(var(--tx, 0), var(--ty, 0)) scale(1.1);
437
+ outline: 2px solid var(--color-accent-400, #0078d4);
438
+ outline-offset: 2px;
439
+ }
440
+
441
+ .sat pds-icon,
442
+ .sat ::slotted(pds-icon) {
443
+ display: block;
444
+ margin: 0;
445
+ transform: translate(var(--sat-icon-offset-x, 0px), var(--sat-icon-offset-y, 0px));
446
+ pointer-events: none;
447
+ }
448
+
449
+ .sat > slot {
450
+ position: absolute;
451
+ inset: 0;
452
+ display: grid;
453
+ place-items: center;
454
+ inline-size: 100%;
455
+ block-size: 100%;
456
+ }
457
+
458
+ :host([mode="inline"]) .sat[data-key="like"] {
459
+ background: var(--sat-bg-color, var(--color-primary-600, #0078d4));
460
+ }
461
+
462
+ :host([mode="inline"]) .sat[data-key="love"] {
463
+ background: var(--sat-bg-color, var(--color-danger-600, #b42318));
464
+ }
465
+
466
+ :host([mode="inline"]) .sat[data-key="interesting"] {
467
+ background: var(--sat-bg-color, var(--color-warning-600, #b54708));
468
+ }
469
+
470
+ :host(:not([mode="inline"])) .fab {
471
+ appearance: none;
472
+ border: none;
473
+ border-radius: 50%;
474
+ inline-size: var(--fab-size);
475
+ block-size: var(--fab-size);
476
+ background: var(--fab-bg);
477
+ color: var(--fab-fg);
478
+ box-shadow:
479
+ 0 2px 8px rgba(0, 0, 0, 0.15),
480
+ 0 8px 24px rgba(0, 0, 0, 0.25);
481
+ display: grid;
482
+ place-items: center;
483
+ cursor: pointer;
484
+ position: relative;
485
+ z-index: 2;
486
+ transition: transform 0.2s ease, box-shadow 0.3s ease;
487
+ }
488
+
489
+ :host(:not([mode="inline"])) .fab:hover {
490
+ transform: scale(1.05);
491
+ box-shadow:
492
+ 0 4px 12px rgba(0, 0, 0, 0.2),
493
+ 0 12px 32px rgba(0, 0, 0, 0.3);
494
+ }
495
+
496
+ :host(:not([mode="inline"])) .fab:active {
497
+ transform: scale(0.98);
498
+ }
499
+
500
+ :host(:not([mode="inline"])[open]) .fab {
501
+ box-shadow:
502
+ 0 0 0 2px var(--color-accent-400, #0078d4),
503
+ 0 4px 16px rgba(0, 120, 212, 0.4);
504
+ }
505
+
506
+ `);
507
+ }
508
+ await PDS.adoptLayers(this.shadowRoot, ['primitives', 'components'], [this.#componentStyles]);
194
509
  this.#render();
195
510
  }
196
511
 
197
512
  disconnectedCallback() {
198
513
  document.removeEventListener('click', this.#outsideClickBound, true);
514
+ if (this.#hoverMoveTracker) {
515
+ document.removeEventListener('pointermove', this.#hoverMoveTracker);
516
+ this.#hoverMoveTracker = null;
517
+ }
199
518
  }
200
519
 
201
520
  /**
@@ -253,24 +572,44 @@ export class PdsFab extends HTMLElement {
253
572
  const isBottom = rect.bottom > viewportHeight / 2;
254
573
 
255
574
  // Calculate optimal angle based on corner:
256
- // - Bottom-right (default): 180° (fly left/up)
257
- // - Bottom-left: 315° or -45° (fly right/up)
258
- // - Top-right: 225° (fly left/down)
259
- // - Top-left: 45° (fly right/down)
575
+ // - Bottom-right (default): 180-� (fly left/up)
576
+ // - Bottom-left: 315-� or -45-� (fly right/up)
577
+ // - Top-right: 225-� (fly left/down)
578
+ // - Top-left: 45-� (fly right/down)
260
579
 
261
580
  if (isBottom && isRight) {
262
- return 180; // Bottom-right fly left
581
+ return 180; // Bottom-right ��� fly left
263
582
  } else if (isBottom && !isRight) {
264
- return 315; // Bottom-left fly up-right
583
+ return 315; // Bottom-left ��� fly up-right
265
584
  } else if (!isBottom && isRight) {
266
- return 225; // Top-right fly left-down
585
+ return 225; // Top-right ��� fly left-down
267
586
  } else {
268
- return 45; // Top-left fly right-down
587
+ return 45; // Top-left ��� fly right-down
269
588
  }
270
589
  }
271
590
 
272
591
  #render() {
592
+ if (this.#hoverMoveTracker) {
593
+ document.removeEventListener('pointermove', this.#hoverMoveTracker);
594
+ this.#hoverMoveTracker = null;
595
+ }
596
+
273
597
  const count = this.satellites.length;
598
+ const isInDrawer = Boolean(this.closest('pds-drawer'));
599
+ const isInlineMode = this.#mode === 'inline';
600
+ const isIconOnlyFab = this.#fabClass.split(/\s+/).includes('icon-only');
601
+ const hostPosition = isInlineMode ? 'relative' : 'fixed';
602
+ const hostInset = isInlineMode ? 'auto' : 'auto 20px 20px auto';
603
+ const hostZIndex = isInDrawer
604
+ ? 'var(--z-fab-in-drawer, var(--z-popover, 1060))'
605
+ : 'var(--z-notification, 1000)';
606
+
607
+ this.style.setProperty('--fab-host-z-index', hostZIndex);
608
+
609
+ const forwardedClasses = this.#fabClass
610
+ .split(/\s+/)
611
+ .filter(Boolean)
612
+ .join(' ');
274
613
 
275
614
  // Auto-adjust spread and radius if not explicitly set by user
276
615
  const hasCustomRadius = this.#hasCustomRadius;
@@ -291,6 +630,11 @@ export class PdsFab extends HTMLElement {
291
630
  if (!hasCustomSpread) activeSpread = optimal.spread;
292
631
  }
293
632
 
633
+ // Keep inline icon-only FAB satellites tighter to the trigger button.
634
+ if (isInlineMode && isIconOnlyFab && !hasCustomRadius) {
635
+ activeRadius = Math.min(activeRadius, 56);
636
+ }
637
+
294
638
  // Calculate positions along an arc
295
639
  const step = count > 1 ? activeSpread / (count - 1) : 0;
296
640
  const baseAngle = hasCustomStartAngle || typeof activeStartAngle === 'number'
@@ -300,131 +644,9 @@ export class PdsFab extends HTMLElement {
300
644
  ? baseAngle
301
645
  : baseAngle - (activeSpread / 2);
302
646
 
303
- this.shadowRoot.innerHTML = `
304
- <style>
305
- :host {
306
- --sat-size: 48px;
307
- --fab-size: 64px;
308
- --fab-bg: var(--color-primary-600, #0078d4);
309
- --fab-fg: white;
310
- --sat-bg: var(--color-surface-elevated, #2a2a2a);
311
- --sat-fg: var(--color-text-primary, #fff);
312
- --radius: ${activeRadius}px;
313
- --transition-duration: 420ms;
314
-
315
- position: fixed;
316
- inset: auto 20px 20px auto;
317
- z-index: var(--z-notification, 1000);
318
- display: inline-block;
319
- }
320
-
321
- .wrap {
322
- position: relative;
323
- width: var(--fab-size);
324
- height: var(--fab-size);
325
- }
647
+ this.style.setProperty('--radius', `${activeRadius}px`);
326
648
 
327
- /* MAIN FAB */
328
- .fab {
329
- appearance: none;
330
- border: none;
331
- border-radius: 50%;
332
- inline-size: var(--fab-size);
333
- block-size: var(--fab-size);
334
- background: var(--fab-bg);
335
- color: var(--fab-fg);
336
- box-shadow:
337
- 0 2px 8px rgba(0, 0, 0, 0.15),
338
- 0 8px 24px rgba(0, 0, 0, 0.25);
339
- display: grid;
340
- place-items: center;
341
- cursor: pointer;
342
- transition: transform 0.2s ease, box-shadow 0.3s ease;
343
- position: relative;
344
- z-index: 2;
345
- }
346
-
347
- .fab:hover {
348
- transform: scale(1.05);
349
- box-shadow:
350
- 0 4px 12px rgba(0, 0, 0, 0.2),
351
- 0 12px 32px rgba(0, 0, 0, 0.3);
352
- }
353
-
354
- .fab:active {
355
- transform: scale(0.98);
356
- }
357
-
358
- :host([open]) .fab {
359
- box-shadow:
360
- 0 0 0 2px var(--color-accent-400, #0078d4),
361
- 0 4px 16px rgba(0, 120, 212, 0.4);
362
- }
363
-
364
- /* Rotate plus icon 45deg when open to look like X */
365
- .fab ::slotted(pds-icon) {
366
- display: inline-block;
367
- transition: transform 0.2s ease;
368
- }
369
-
370
- :host([open]) .fab.has-satellites.has-plus ::slotted(pds-icon) {
371
- transform: rotate(45deg);
372
- }
373
-
374
- /* SATELLITES */
375
- .sat {
376
- position: absolute;
377
- top: calc((var(--fab-size) - var(--sat-size)) / 2);
378
- left: calc((var(--fab-size) - var(--sat-size)) / 2);
379
- inline-size: var(--sat-size);
380
- block-size: var(--sat-size);
381
- border-radius: 50%;
382
- background: var(--sat-bg);
383
- color: var(--sat-fg);
384
- box-shadow:
385
- 0 2px 6px rgba(0, 0, 0, 0.15),
386
- 0 6px 18px rgba(0, 0, 0, 0.2);
387
- display: grid;
388
- place-items: center;
389
- cursor: pointer;
390
- border: none;
391
- appearance: none;
392
-
393
- transform: translate(0, 0) scale(0.2);
394
- opacity: 0;
395
- pointer-events: none;
396
-
397
- transition:
398
- transform 420ms cubic-bezier(0.2, 0.8, 0.2, 1.4),
399
- opacity 420ms ease;
400
- }
401
-
402
- :host([open]) .sat {
403
- transform: translate(var(--tx, 0), var(--ty, 0)) scale(1);
404
- opacity: 1;
405
- pointer-events: auto;
406
- }
407
-
408
-
409
- .sat:hover,
410
- .sat:focus-visible {
411
- transform: translate(var(--tx, 0), var(--ty, 0)) scale(1.1);
412
- outline: 2px solid var(--color-accent-400, #0078d4);
413
- outline-offset: 2px;
414
- }
415
-
416
- .sat pds-icon,
417
- .sat ::slotted(pds-icon) {
418
- pointer-events: none;
419
- }
420
-
421
- /* Fallback text for satellites without icons */
422
- .sat-text {
423
- font-size: 1.25rem;
424
- font-weight: 600;
425
- user-select: none;
426
- }
427
- </style>
649
+ this.shadowRoot.innerHTML = `
428
650
 
429
651
  <div class="wrap" role="group" aria-label="Floating actions">
430
652
  ${this.satellites.map((sat, i) => {
@@ -438,14 +660,14 @@ export class PdsFab extends HTMLElement {
438
660
  <button
439
661
  class="sat"
440
662
  part="satellite"
441
- style="--tx: ${tx}px; --ty: ${ty}px; transition-delay: ${delay}ms;"
663
+ style="--tx: ${tx}px; --ty: ${ty}px; transition-delay: ${delay}ms;${sat.bgColor ? ` --sat-bg-color: ${sat.bgColor};` : ''}${sat.iconColor ? ` --sat-icon-color: ${sat.iconColor};` : ''}"
442
664
  data-key="${sat.key}"
443
665
  aria-label="${sat.label || sat.key}"
444
666
  title="${sat.label || sat.key}"
445
667
  >
446
668
  <slot name="satellite-${sat.key}">
447
669
  ${sat.icon
448
- ? `<pds-icon icon="${sat.icon}"></pds-icon>`
670
+ ? `<pds-icon icon="${sat.icon}" size="${sat.iconSize || (isInlineMode ? 'md' : 'md')}"${sat.iconColor && !isInlineMode ? ` color="${sat.iconColor}"` : ''}></pds-icon>`
449
671
  : `<span class="sat-text">${(sat.label || sat.key).charAt(0).toUpperCase()}</span>`
450
672
  }
451
673
  </slot>
@@ -454,7 +676,7 @@ export class PdsFab extends HTMLElement {
454
676
  }).join('')}
455
677
 
456
678
  <button
457
- class="fab${count > 0 ? ' has-satellites' : ''}"
679
+ class="fab${count > 0 ? ' has-satellites' : ''}${forwardedClasses ? ' ' + forwardedClasses : ''}"
458
680
  part="fab"
459
681
  aria-expanded="${this.#open}"
460
682
  aria-haspopup="menu"
@@ -476,6 +698,61 @@ export class PdsFab extends HTMLElement {
476
698
 
477
699
  const wrap = this.shadowRoot.querySelector('.wrap');
478
700
  wrap?.addEventListener('keydown', (e) => this.#onKey(e));
701
+
702
+ this.#syncFabClasses();
703
+
704
+ if (this.#behavior === 'hover') {
705
+ const stopTracking = () => {
706
+ if (this.#hoverMoveTracker) {
707
+ document.removeEventListener('pointermove', this.#hoverMoveTracker);
708
+ this.#hoverMoveTracker = null;
709
+ }
710
+ };
711
+
712
+ const isPointerSafe = (x, y) => {
713
+ const fabEl = this.shadowRoot?.querySelector('.fab');
714
+ const satEls = Array.from(this.shadowRoot?.querySelectorAll('.sat') ?? []);
715
+ const padding = 40;
716
+ for (const el of [fabEl, ...satEls].filter(Boolean)) {
717
+ const r = el.getBoundingClientRect();
718
+ if (x >= r.left - padding && x <= r.right + padding &&
719
+ y >= r.top - padding && y <= r.bottom + padding) {
720
+ return true;
721
+ }
722
+ }
723
+ return false;
724
+ };
725
+
726
+ const startTracking = () => {
727
+ stopTracking();
728
+ this.#hoverMoveTracker = (e) => {
729
+ if (!isPointerSafe(e.clientX, e.clientY)) {
730
+ stopTracking();
731
+ this.open = false;
732
+ }
733
+ };
734
+ document.addEventListener('pointermove', this.#hoverMoveTracker, { passive: true });
735
+ };
736
+
737
+ wrap?.addEventListener('mouseenter', () => {
738
+ stopTracking();
739
+ if (count > 0) this.open = true;
740
+ });
741
+
742
+ wrap?.addEventListener('mouseleave', startTracking);
743
+
744
+ wrap?.addEventListener('focusin', () => {
745
+ stopTracking();
746
+ if (count > 0) this.open = true;
747
+ });
748
+
749
+ wrap?.addEventListener('focusout', (e) => {
750
+ if (!wrap.contains(e.relatedTarget)) {
751
+ stopTracking();
752
+ this.open = false;
753
+ }
754
+ });
755
+ }
479
756
 
480
757
  // Handle icon swapping when slot content changes (main FAB slot only)
481
758
  const fabSlot = this.shadowRoot.querySelector('.fab slot');
@@ -484,6 +761,14 @@ export class PdsFab extends HTMLElement {
484
761
  requestAnimationFrame(() => this.#updateIconState());
485
762
  }
486
763
 
764
+ #syncFabClasses() {
765
+ const fab = this.shadowRoot?.querySelector('.fab');
766
+ if (!fab) return;
767
+ const baseClasses = Array.from(fab.classList).filter((c) => c === 'fab' || c === 'has-satellites' || c === 'has-plus');
768
+ const forwarded = this.#fabClass.split(/\s+/).filter(Boolean);
769
+ fab.className = [...baseClasses, ...forwarded].join(' ');
770
+ }
771
+
487
772
  #updateSatelliteTransitionDelays(isOpen) {
488
773
  const sats = this.shadowRoot?.querySelectorAll('.sat');
489
774
  if (!sats?.length) return;