ng-hub-ui-utils 22.12.0 → 22.12.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/README.md CHANGED
@@ -116,8 +116,8 @@ export class ExampleComponent {
116
116
  overlayY: 'top'
117
117
  }]);
118
118
 
119
- // Attach component to overlay
120
- const componentRef = overlayRef.attach(MyComponent);
119
+ // Render a component (or a TemplateRef) into the overlay; you get the host element back
120
+ const overlayElement = overlayRef.attach(MyComponent);
121
121
  }
122
122
  }
123
123
  ```
@@ -128,9 +128,11 @@ export class ExampleComponent {
128
128
  this.overlayService.create({ zIndex: 1100 }); // OverlayConfig.zIndex: number | string — takes precedence over the token
129
129
  ```
130
130
 
131
- ### 🎯 Popup Service (Base Class)
131
+ ### 🎯 Popup Service
132
132
 
133
- Base service for creating custom popup implementations.
133
+ `PopupService<T>` hosts a dynamically created popup component and runs its show/hide
134
+ transition. It is a concrete class — subclass it when the popup needs its own API, as
135
+ below, or provide it through a factory.
134
136
 
135
137
  ```typescript
136
138
  import { PopupService } from 'ng-hub-ui-utils';
@@ -356,16 +358,17 @@ import { GetPipe, UcfirstPipe } from 'ng-hub-ui-utils';
356
358
 
357
359
  ### 🏷️ Tooltip Directive
358
360
 
359
- Add a lightweight, themeable tooltip to any element with the `[tooltip]` directive.
360
- The tooltip is appended to `<body>` (never clipped) and shows on hover/focus.
361
+ Add a lightweight, themeable tooltip to any element with the `HubTooltipDirective`
362
+ (`[hubTooltip]`). The tooltip is appended to `<body>` (never clipped) and shows on
363
+ hover/focus.
361
364
 
362
365
  ```typescript
363
- import { TooltipDirective } from 'ng-hub-ui-utils';
366
+ import { HubTooltipDirective } from 'ng-hub-ui-utils';
364
367
 
365
368
  @Component({
366
369
  standalone: true,
367
- imports: [TooltipDirective],
368
- template: `<button tooltip="Save changes" placement="top">Save</button>`
370
+ imports: [HubTooltipDirective],
371
+ template: `<button hubTooltip="Save changes" hubTooltipPlacement="top">Save</button>`
369
372
  })
370
373
  export class ExampleComponent {}
371
374
  ```
@@ -378,8 +381,24 @@ export class ExampleComponent {}
378
381
  > @use 'ng-hub-ui-utils/styles/tooltip';
379
382
  > ```
380
383
 
381
- Inputs: `tooltip` (text), `placement` (`top` | `bottom` | `left` | `right`, default `top`),
382
- `delay` (fade ms, default `150`), `offset` (px, default `8`).
384
+ Inputs: `hubTooltip` (text), `hubTooltipPlacement` (`top` | `bottom` | `left` | `right`,
385
+ default `top`), `hubTooltipDelay` (fade ms, default `150`), `hubTooltipOffset` (px, default `8`).
386
+
387
+ > **`TooltipDirective` (`[tooltip]`) is deprecated since 22.9.0.** It still works,
388
+ > unchanged — both directives are thin shells over the same `HubTooltipController` — but its
389
+ > bare input names (`tooltip`, `placement`, `delay`, `offset`) belong to every directive on
390
+ > the element that declares them, which is how it collided with `[hubDropdown]`'s own
391
+ > `placement` and with the `tooltip` input of `<hub-badge>`. Migration is attribute for
392
+ > attribute: `tooltip` → `hubTooltip`, `placement` → `hubTooltipPlacement`,
393
+ > `delay` → `hubTooltipDelay`, `offset` → `hubTooltipOffset`.
394
+
395
+ Show the label **only while the host is truncated** with `HubOverflowTooltipDirective`
396
+ (`[hubOverflowTooltip]`), which tracks truncation live with a `ResizeObserver` and a
397
+ `MutationObserver` and resolves its tooltip through `HUB_TOOLTIP_ADAPTER`:
398
+
399
+ ```html
400
+ <span class="label" [hubOverflowTooltip]="item.label">{{ item.label }}</span>
401
+ ```
383
402
 
384
403
  Theme it from any scope with `--hub-tooltip-*` variables:
385
404
 
@@ -394,8 +413,14 @@ Theme it from any scope with `--hub-tooltip-*` variables:
394
413
 
395
414
  Available tokens: `--hub-tooltip-bg`, `--hub-tooltip-color`, `--hub-tooltip-opacity`,
396
415
  `--hub-tooltip-padding-x`, `--hub-tooltip-padding-y`, `--hub-tooltip-border-radius`,
397
- `--hub-tooltip-font-size`, `--hub-tooltip-max-width`, `--hub-tooltip-zindex`,
398
- `--hub-tooltip-transition-duration`, `--hub-tooltip-shadow`, `--hub-tooltip-font-family`.
416
+ `--hub-tooltip-font-size`, `--hub-tooltip-font-weight`, `--hub-tooltip-line-height`,
417
+ `--hub-tooltip-max-width`, `--hub-tooltip-zindex`, `--hub-tooltip-transition-duration`,
418
+ `--hub-tooltip-shadow`, `--hub-tooltip-font-family`, `--hub-tooltip-white-space`,
419
+ `--hub-tooltip-text-align`.
420
+
421
+ The last two arrived in 22.10.0, for the tooltip that carries a sentence rather than a
422
+ name: they are set on the **host**, which is the only element you can reach, because the
423
+ bubble itself lives on `<body>`, outside every component's styles.
399
424
 
400
425
  #### Tooltip adapter for other libraries (`hubTooltipAdapter`)
401
426
 
@@ -414,10 +439,26 @@ providers: [
414
439
  ];
415
440
  ```
416
441
 
442
+ Inside this package the same token works the other way round: `[hubOverflowTooltip]`
443
+ resolves its tooltip through `HUB_TOOLTIP_ADAPTER`, which defaults to `hubTooltipAdapter`.
444
+ Swap it app-wide, or for one subtree, with `provideHubTooltip()`:
445
+
446
+ ```ts
447
+ import { provideHubTooltip, HubTooltipAdapter } from 'ng-hub-ui-utils';
448
+
449
+ const myTooltip: HubTooltipAdapter = {
450
+ attach(host, text, options) {
451
+ /* … returns a HubTooltipHandle with update(text) and destroy() */
452
+ }
453
+ };
454
+
455
+ providers: [provideHubTooltip(myTooltip)];
456
+ ```
457
+
417
458
  Also available: the imperative `HubTooltipController` (engine) and the
418
- `HubTooltipAdapter` / `HubTooltipHandle` / `HubTooltipOptions` types. See the
419
- ecosystem-wide [Synergies & agnosticism](../../README.md#synergies--agnosticism)
420
- section.
459
+ `HubTooltipAdapter` / `HubTooltipHandle` / `HubTooltipOptions` / `HubTooltipPlacement`
460
+ types. See the ecosystem-wide
461
+ [Synergies & agnosticism](../../README.md#synergies--agnosticism) section.
421
462
 
422
463
  ## 🚀 Installation
423
464
 
@@ -505,6 +546,20 @@ interface HubTranslationConfig {
505
546
 
506
547
  The configuration is also exposed through the `HUB_TRANSLATION_CONFIG` injection token for advanced scenarios.
507
548
 
549
+ ### `HUB_TRANSLATION_PREFIX`
550
+
551
+ Injection token that scopes a library's lookups to a collision-safe `HUBUI.<LIBRARY>.*`
552
+ namespace. `TranslatePipe` resolves the prefixed key first and falls back to the bare key,
553
+ so a flat dictionary that predates the token keeps working untouched.
554
+
555
+ ```typescript
556
+ providers: [{ provide: HUB_TRANSLATION_PREFIX, useValue: 'HUBUI.TABLE' }];
557
+ ```
558
+
559
+ The adapter types are exported alongside it: `HubTranslationSource`,
560
+ `HubTranslationOverrides`, `HubTranslationAdapterConfig`, `HubTranslationAdapterFactory`
561
+ and the `HUB_TRANSLATION_SOURCE` token `provideHubTranslationAdapter()` registers.
562
+
508
563
  ### `HubTranslationService`
509
564
 
510
565
  Injectable service that holds the active translations and notifies subscribers when they change.
@@ -584,6 +639,19 @@ These functions back the i18n system and are exported for direct use:
584
639
 
585
640
  - `regExpEscape(text: string): string` - Escapes special characters for RegExp
586
641
  - `removeAccents(str: string): string` - Removes accents from text
642
+ - `interpolateString(expr?: string, params?: any, templateMatcher?: RegExp): string` - Replaces `{{ token }}` placeholders
643
+ - `generateUniqueId(length: number): string` - Random alphanumeric id, for a DOM node that needs one
644
+
645
+ ### Object Functions
646
+
647
+ - `equals(o1: any, o2: any): boolean` - Deep equality
648
+ - `getValue(target: any, key: string): any` - Reads a nested value by dot-notation key
649
+ - `isObject(item: any): boolean` - Whether the value is a non-array object
650
+ - `mergeDeep(target: any, source: any): any` - Recursive merge; the only deep object helper in the package
651
+
652
+ ### Signal Utilities
653
+
654
+ - `debouncedSignal<T>(source: Signal<T>, delay?: number | Signal<number>): Signal<T>` - Mirrors a signal, delaying each change; the delay can itself be a signal
587
655
 
588
656
  ### DOM Functions
589
657
 
@@ -591,6 +659,10 @@ These functions back the i18n system and are exported for direct use:
591
659
  - `reflow(element: HTMLElement): DOMRect` - Forces browser reflow
592
660
  - `getActiveElement(root?: Document | ShadowRoot): Element | null` - Gets active element including Shadow DOM
593
661
 
662
+ ### Accent Resolution
663
+
664
+ - `resolveHubAccent(value: string | null | undefined): string | null` - The "any colour" accent resolver shared across the family: a bareword becomes `var(--hub-sys-color-<name>, <name>)`, a literal `#hex` / `rgb()` / `oklch()` / `var()` passes through unchanged, and an empty value yields `null`
665
+
594
666
  ### Colour Functions
595
667
 
596
668
  - `parseColor(value): HubRgb | null` - Parses hex (3/4/6/8), `rgb()`, `hsl()`, `oklch()`, `oklab()`, the 148 CSS named colours and `transparent`, in modern and legacy syntax. No DOM, so it runs under SSR. Returns `null` — never throws — for anything it cannot resolve, `var()` and `currentColor` included
@@ -621,6 +693,27 @@ These functions back the i18n system and are exported for direct use:
621
693
  - `hubFocusTrap(zone, element, stopFocusTrap$, refocusOnClick?)` - Creates focus trap for modals/overlays
622
694
  - `FOCUSABLE_ELEMENTS_SELECTOR: string` - CSS selector for focusable elements
623
695
 
696
+ ### Drag and Drop
697
+
698
+ The engine-agnostic half of native HTML5 drag and drop, shared by the libraries that
699
+ implement it. The UI primitives — handle, placeholder and preview directives — stay in each
700
+ library, because their selectors and data models differ.
701
+
702
+ - `HubDragDropService` - Root-provided coordinator. A drag spans two component instances and the native `dataTransfer` payload is unreadable during `dragover`, so a shared service is the only reliable channel for what is being dragged and from where. Owners `register()` / `unregister()`; `begin()`, `setTarget()` and the readonly `active` / `target` / `isDragging` signals report the drag in progress. It coordinates state only — it never mutates your collections
703
+ - `moveItemInArray<T>(array, fromIndex, toIndex): void` / `transferArrayItem<T>(source, target, fromIndex, toIndex): void` / `copyArrayItem<T>(source, target, fromIndex, toIndex): void` - In-place array moves, mirroring the `@angular/cdk` helpers of the same names
704
+ - `clamp(value, max)`, `computeTargetIndex(...)`, `toAbsoluteIndex(...)`, `containsNode(...)` - Index arithmetic for sliced and nested lists
705
+ - `resolveDropPosition(...)` with `DropRect` and `DragAxis` - Where a pointer sits relative to an item: `'before'` or `'after'`, on a vertical, horizontal or grid axis
706
+ - `createNativeDragImage(...)` returning `DragImageResult` - Renders the drag preview the browser shows
707
+ - `createPointerDragSession(config: PointerDragSessionConfig): PointerDragSession` - Pointer Events fallback for touch, where native drag events are not delivered
708
+ - Types: `DropPosition`, `DragPointerMode`, `DragContainerRef<T>`, `ActiveDrag<T>`, `DragTarget<T>`, `DragRegistration`
709
+
710
+ ### Directives
711
+
712
+ - `HubTooltipDirective` (`[hubTooltip]`) - Tooltip on hover/focus. Inputs: `hubTooltip`, `hubTooltipPlacement`, `hubTooltipDelay`, `hubTooltipOffset`
713
+ - `HubOverflowTooltipDirective` (`[hubOverflowTooltip]`) - Tooltip shown only while the host label is truncated. Inputs: `hubOverflowTooltip`, `placement`
714
+ - `TooltipDirective` (`[tooltip]`) - **Deprecated since 22.9.0**, kept working. Inputs: `tooltip`, `placement`, `delay`, `offset`
715
+ - `provideHubTooltip(adapter: HubTooltipAdapter)` and `HUB_TOOLTIP_ADAPTER` - Swap the implementation behind `[hubOverflowTooltip]`, app-wide or per subtree; defaults to `hubTooltipAdapter`
716
+
624
717
  ### Pipes
625
718
 
626
719
  #### GetPipe
@@ -681,18 +774,37 @@ class OverlayService {
681
774
  }
682
775
 
683
776
  class OverlayRef {
684
- attach<T>(component: ComponentType<T>): ComponentRef<T>;
777
+ // Renders a template or a component into the overlay and returns the host element,
778
+ // not a ComponentRef: the overlay owns the view it created and tears it down itself.
779
+ attach(content: TemplateRef<unknown> | Type<unknown>, viewContainerRef?: ViewContainerRef): HTMLElement;
685
780
  detach(): void;
686
781
  dispose(): void;
782
+ hasAttached(): boolean;
687
783
  updatePosition(): void;
784
+ onBackdropClick(callback: () => void): void;
785
+ // Only the topmost open overlay is told, so a dropdown inside a dialog takes Escape
786
+ // for itself and leaves the dialog open.
787
+ onKeydown(callback: (event: KeyboardEvent) => void): void;
688
788
  }
689
789
 
690
790
  class OverlayPosition {
691
- flexibleConnectedTo(element: ElementRef | HTMLElement): this;
791
+ flexibleConnectedTo(origin: ElementRef | HTMLElement): this;
692
792
  withPositions(positions: ConnectionPosition[]): this;
793
+ // `start` / `end` are logical and read from the origin element; this overrides that.
794
+ withDirection(direction: 'ltr' | 'rtl' | null): this;
693
795
  }
694
796
  ```
695
797
 
798
+ `HUB_DROPDOWN_POSITIONS` is the ready-made fallback chain for a dropdown — below the
799
+ origin, flipping above when there is no room — expressed logically so one list serves
800
+ both text directions:
801
+
802
+ ```typescript
803
+ import { HUB_DROPDOWN_POSITIONS } from 'ng-hub-ui-utils';
804
+
805
+ overlayService.position().flexibleConnectedTo(origin).withPositions([...HUB_DROPDOWN_POSITIONS]);
806
+ ```
807
+
696
808
  #### ScrollBar Service
697
809
 
698
810
  ```typescript
@@ -702,14 +814,26 @@ class ScrollBar {
702
814
  }
703
815
  ```
704
816
 
705
- #### PopupService<T> (Base Class)
817
+ #### PopupService&lt;T&gt;
818
+
819
+ A concrete generic class, not an abstract one: it takes the popup component type in its
820
+ constructor, and it reads its collaborators with `inject()`, so it has to be created inside
821
+ an injection context — as an `@Injectable()` subclass, or from a factory provider.
706
822
 
707
823
  ```typescript
708
- abstract class PopupService<T> {
709
- // Base system for creating dynamic popups
710
- // Extend this class to create specific popup services
711
- open(content?, templateContext?, animation?): { windowRef: ComponentRef<T>; transition$: Observable<void> };
712
- close(animation?): Observable<void>;
824
+ class PopupService<T> {
825
+ constructor(componentType: Type<T>);
826
+ open(
827
+ content?: string | TemplateRef<any>,
828
+ templateContext?: any,
829
+ animation?: boolean
830
+ ): { windowRef: ComponentRef<T>; transition$: Observable<void> };
831
+ close(animation?: boolean): Observable<void>;
832
+ }
833
+
834
+ // The nodes and view a popup projects, returned internally by the content resolver.
835
+ class ContentRef {
836
+ constructor(nodes: Node[][], viewRef?: ViewRef, componentRef?: ComponentRef<any>);
713
837
  }
714
838
  ```
715
839
 
@@ -729,7 +853,7 @@ This library doesn't include visual components, but support utilities used by ot
729
853
  | Overlay Service | Flexible overlay positioning system | ng-hub-ui-modal, ng-hub-ui-portal |
730
854
  | Focus Trap | Focus management in modals/overlays | ng-hub-ui-modal, ng-hub-ui-portal |
731
855
  | Scrollbar | Scrollbar compensation | ng-hub-ui-modal, ng-hub-ui-portal |
732
- | Popup Service | Base class for popup components | ng-hub-ui-modal, ng-hub-ui-portal |
856
+ | Popup Service | Host for dynamically created popups | ng-hub-ui-modal, ng-hub-ui-portal |
733
857
  | Transitions | Smooth animations | ng-hub-ui-accordion, ng-hub-ui-modal |
734
858
  | Type Guards | Type validation functions | ng-hub-ui-stepper |
735
859
  | Pipes | Template utilities | All Hub UI components |
@@ -2683,6 +2683,18 @@ const TOOLTIP_THEME_VARS = [
2683
2683
  '--hub-tooltip-white-space',
2684
2684
  '--hub-tooltip-text-align'
2685
2685
  ];
2686
+ /**
2687
+ * Grace period, in milliseconds, between the pointer leaving the host and the label
2688
+ * starting to fade.
2689
+ *
2690
+ * WCAG 1.4.13 asks hover-revealed content to stay put while the pointer travels onto it,
2691
+ * and `offset` opens exactly the gap that trip has to cross. Without the grace period the
2692
+ * bubble is already fading before the pointer can reach it, so a label longer than its box
2693
+ * could be read only for as long as it takes to cross 8px.
2694
+ */
2695
+ const HOVER_GRACE_MS = 100;
2696
+ /** Feeds the unique `id` each bubble needs so `aria-describedby` can point at it. */
2697
+ let nextTooltipId = 0;
2686
2698
  /**
2687
2699
  * Framework-agnostic tooltip engine.
2688
2700
  *
@@ -2692,6 +2704,10 @@ const TOOLTIP_THEME_VARS = [
2692
2704
  * (e.g. a badge overflow tooltip) that want the exact same visual contract
2693
2705
  * without re-implementing the DOM logic.
2694
2706
  *
2707
+ * The bubble is a `role="tooltip"` element the host is `aria-describedby` while it is on
2708
+ * screen, so the label exists for a screen reader and not only for a pointer, and it obeys
2709
+ * WCAG 1.4.13: Escape dismisses it, and it survives the trip of the pointer onto it.
2710
+ *
2695
2711
  * Styles ship in `styles/tooltip.scss`. Import once in your app:
2696
2712
  * `@use 'ng-hub-ui-utils/styles/tooltip';`.
2697
2713
  */
@@ -2699,14 +2715,30 @@ class HubTooltipController {
2699
2715
  host;
2700
2716
  tooltipEl = null;
2701
2717
  hideTimeout = null;
2718
+ leaveTimeout = null;
2702
2719
  text = '';
2703
2720
  placement = 'top';
2704
2721
  delay = 150;
2705
2722
  offset = 8;
2706
2723
  doc;
2707
2724
  view;
2725
+ /** Id of this controller's bubble, minted once so the host can be described by it. */
2726
+ tooltipId = `hub-tooltip-${++nextTooltipId}`;
2708
2727
  onShow = () => this.show();
2709
2728
  onHide = () => this.hide();
2729
+ onPointerLeave = () => this.scheduleHide();
2730
+ onTooltipEnter = () => this.retain();
2731
+ /**
2732
+ * Escape dismisses the label without moving the pointer or the focus, which is the
2733
+ * half of WCAG 1.4.13 a hover-only bubble cannot satisfy on its own. Listened for on
2734
+ * the document, and in the capture phase, so it still reaches us on a page whose own
2735
+ * handlers stop the event before it bubbles.
2736
+ */
2737
+ onKeydown = (event) => {
2738
+ if (event.key === 'Escape') {
2739
+ this.hide();
2740
+ }
2741
+ };
2710
2742
  /**
2711
2743
  * @param host Element the tooltip is anchored to and whose pointer/focus
2712
2744
  * events trigger the tooltip.
@@ -2719,7 +2751,7 @@ class HubTooltipController {
2719
2751
  this.setOptions(options);
2720
2752
  this.host.addEventListener('mouseenter', this.onShow);
2721
2753
  this.host.addEventListener('focus', this.onShow);
2722
- this.host.addEventListener('mouseleave', this.onHide);
2754
+ this.host.addEventListener('mouseleave', this.onPointerLeave);
2723
2755
  this.host.addEventListener('blur', this.onHide);
2724
2756
  this.host.addEventListener('click', this.onHide);
2725
2757
  }
@@ -2761,20 +2793,29 @@ class HubTooltipController {
2761
2793
  destroy() {
2762
2794
  this.host.removeEventListener('mouseenter', this.onShow);
2763
2795
  this.host.removeEventListener('focus', this.onShow);
2764
- this.host.removeEventListener('mouseleave', this.onHide);
2796
+ this.host.removeEventListener('mouseleave', this.onPointerLeave);
2765
2797
  this.host.removeEventListener('blur', this.onHide);
2766
2798
  this.host.removeEventListener('click', this.onHide);
2767
2799
  this.removeElement();
2768
2800
  }
2769
2801
  /** Creates, positions and reveals the tooltip element. */
2770
2802
  show() {
2771
- if (this.tooltipEl || !this.text) {
2803
+ if (!this.text) {
2804
+ return;
2805
+ }
2806
+ // A bubble that is still fading out is brought back rather than left to expire:
2807
+ // the pointer returning to the host is the user asking for the label again.
2808
+ if (this.tooltipEl) {
2809
+ this.retain();
2772
2810
  return;
2773
2811
  }
2774
2812
  this.clearHideTimeout();
2775
2813
  const el = this.doc.createElement('span');
2776
2814
  el.textContent = this.text;
2777
2815
  el.classList.add('hub-tooltip', `hub-tooltip--${this.placement}`);
2816
+ // The bubble is the host's description, and it has to be findable by id to say so.
2817
+ el.id = this.tooltipId;
2818
+ el.setAttribute('role', 'tooltip');
2778
2819
  // Taken out of flow here rather than left to the stylesheet alone.
2779
2820
  //
2780
2821
  // The sheet ships `position: absolute` and this is the same value, so nothing changes
@@ -2791,28 +2832,87 @@ class HubTooltipController {
2791
2832
  el.style.position = 'absolute';
2792
2833
  el.style.transitionDuration = `${this.delay}ms`;
2793
2834
  this.forwardThemeVars(el);
2835
+ el.addEventListener('mouseenter', this.onTooltipEnter);
2836
+ el.addEventListener('mouseleave', this.onHide);
2794
2837
  this.doc.body.appendChild(el);
2795
2838
  this.tooltipEl = el;
2839
+ this.describeHost();
2840
+ this.doc.addEventListener('keydown', this.onKeydown, true);
2796
2841
  this.position();
2797
2842
  el.classList.add('hub-tooltip--show');
2798
2843
  }
2844
+ /**
2845
+ * Fades the tooltip out after the grace period, so the pointer can cross the gap the
2846
+ * offset opens between host and bubble without the label vanishing on the way.
2847
+ */
2848
+ scheduleHide() {
2849
+ if (!this.tooltipEl) {
2850
+ return;
2851
+ }
2852
+ this.clearLeaveTimeout();
2853
+ this.leaveTimeout = setTimeout(() => this.hide(), HOVER_GRACE_MS);
2854
+ }
2855
+ /** Cancels a pending hide and brings a fading bubble back to full opacity. */
2856
+ retain() {
2857
+ this.clearLeaveTimeout();
2858
+ this.clearHideTimeout();
2859
+ if (this.tooltipEl) {
2860
+ this.tooltipEl.style.pointerEvents = '';
2861
+ this.tooltipEl.classList.add('hub-tooltip--show');
2862
+ }
2863
+ }
2799
2864
  /** Fades the tooltip out and removes it after the fade completes. */
2800
2865
  hide() {
2866
+ this.clearLeaveTimeout();
2801
2867
  if (!this.tooltipEl) {
2802
2868
  return;
2803
2869
  }
2804
2870
  this.tooltipEl.classList.remove('hub-tooltip--show');
2871
+ // Fading, so the pointer is not coming: stop the bubble from catching clicks meant
2872
+ // for whatever it floats over while it is invisible but still in the document.
2873
+ this.tooltipEl.style.pointerEvents = 'none';
2805
2874
  this.clearHideTimeout();
2806
2875
  this.hideTimeout = setTimeout(() => this.removeElement(), this.delay);
2807
2876
  }
2808
2877
  /** Removes the tooltip element immediately. */
2809
2878
  removeElement() {
2810
2879
  this.clearHideTimeout();
2880
+ this.clearLeaveTimeout();
2811
2881
  if (this.tooltipEl) {
2882
+ this.doc.removeEventListener('keydown', this.onKeydown, true);
2883
+ this.tooltipEl.removeEventListener('mouseenter', this.onTooltipEnter);
2884
+ this.tooltipEl.removeEventListener('mouseleave', this.onHide);
2885
+ this.undescribeHost();
2812
2886
  this.tooltipEl.remove();
2813
2887
  this.tooltipEl = null;
2814
2888
  }
2815
2889
  }
2890
+ /**
2891
+ * Points the host at the live bubble so assistive technology reads the label as the
2892
+ * host's description. Any `aria-describedby` the consumer already wrote is kept: the
2893
+ * tooltip joins that list instead of replacing it, and leaves it as it found it.
2894
+ */
2895
+ describeHost() {
2896
+ const tokens = this.describedBy();
2897
+ if (!tokens.includes(this.tooltipId)) {
2898
+ tokens.push(this.tooltipId);
2899
+ }
2900
+ this.host.setAttribute('aria-describedby', tokens.join(' '));
2901
+ }
2902
+ /** Removes this tooltip from the host's description, dropping an emptied attribute. */
2903
+ undescribeHost() {
2904
+ const tokens = this.describedBy().filter((id) => id !== this.tooltipId);
2905
+ if (tokens.length) {
2906
+ this.host.setAttribute('aria-describedby', tokens.join(' '));
2907
+ }
2908
+ else {
2909
+ this.host.removeAttribute('aria-describedby');
2910
+ }
2911
+ }
2912
+ /** Current `aria-describedby` of the host, as a token list. */
2913
+ describedBy() {
2914
+ return (this.host.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
2915
+ }
2816
2916
  /**
2817
2917
  * Copies any `--hub-tooltip-*` value defined on the host (or its scope) onto
2818
2918
  * the body-portaled tooltip, so scoped theming applies despite the portal.
@@ -2835,6 +2935,12 @@ class HubTooltipController {
2835
2935
  this.hideTimeout = null;
2836
2936
  }
2837
2937
  }
2938
+ clearLeaveTimeout() {
2939
+ if (this.leaveTimeout !== null) {
2940
+ clearTimeout(this.leaveTimeout);
2941
+ this.leaveTimeout = null;
2942
+ }
2943
+ }
2838
2944
  /** Positions the tooltip around the host according to the current placement. */
2839
2945
  position() {
2840
2946
  if (!this.tooltipEl) {