ng-hub-ui-utils 22.12.1 → 22.13.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 CHANGED
@@ -400,6 +400,22 @@ Show the label **only while the host is truncated** with `HubOverflowTooltipDire
400
400
  <span class="label" [hubOverflowTooltip]="item.label">{{ item.label }}</span>
401
401
  ```
402
402
 
403
+ The element that is **hovered** and the element that is **measured** need not be the same.
404
+ By default they are, but a control whose text is clipped by a box inside it wants them apart:
405
+ the hover area is the whole control, while the only box that can report truncation is the
406
+ inner one — a child that clips its own text never lets the overflow reach its parent, so
407
+ measuring the parent reports none and the tooltip goes quiet. Point
408
+ `hubOverflowTooltipMeasure` at the inner box with a CSS selector, resolved inside the host:
409
+
410
+ ```html
411
+ <div class="chip" [hubOverflowTooltip]="item.label" hubOverflowTooltipMeasure=".chip__title">
412
+ <span class="chip__icon"></span>
413
+ <span class="chip__title">{{ item.label }}</span>
414
+ </div>
415
+ ```
416
+
417
+ Unset — or pointing at nothing — the host measures itself, exactly as before.
418
+
403
419
  Theme it from any scope with `--hub-tooltip-*` variables:
404
420
 
405
421
  ```css
@@ -710,7 +726,7 @@ library, because their selectors and data models differ.
710
726
  ### Directives
711
727
 
712
728
  - `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`
729
+ - `HubOverflowTooltipDirective` (`[hubOverflowTooltip]`) - Tooltip shown only while the label is truncated. Inputs: `hubOverflowTooltip`, `placement`, `hubOverflowTooltipMeasure` (CSS selector, resolved inside the host, naming the box whose truncation decides it; defaults to the host)
714
730
  - `TooltipDirective` (`[tooltip]`) - **Deprecated since 22.9.0**, kept working. Inputs: `tooltip`, `placement`, `delay`, `offset`
715
731
  - `provideHubTooltip(adapter: HubTooltipAdapter)` and `HUB_TOOLTIP_ADAPTER` - Swap the implementation behind `[hubOverflowTooltip]`, app-wide or per subtree; defaults to `hubTooltipAdapter`
716
732
 
@@ -788,6 +804,8 @@ class OverlayRef {
788
804
  }
789
805
 
790
806
  class OverlayPosition {
807
+ // The element the panel is anchored to. The overlay watches it and follows it when it moves.
808
+ readonly origin: HTMLElement | null;
791
809
  flexibleConnectedTo(origin: ElementRef | HTMLElement): this;
792
810
  withPositions(positions: ConnectionPosition[]): this;
793
811
  // `start` / `end` are logical and read from the origin element; this overrides that.
@@ -1,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, computed, Injectable, effect, InjectionToken, inject, makeEnvironmentProviders, provideAppInitializer, DestroyRef, ElementRef, TemplateRef, createComponent, ApplicationRef, Pipe, ChangeDetectorRef, Injector, ViewContainerRef, NgZone, input, Directive, afterNextRender } from '@angular/core';
2
+ import { signal, computed, Injectable, effect, InjectionToken, inject, makeEnvironmentProviders, provideAppInitializer, DestroyRef, ElementRef, TemplateRef, createComponent, NgZone, ApplicationRef, Pipe, ChangeDetectorRef, Injector, ViewContainerRef, input, Directive, afterNextRender } from '@angular/core';
3
3
  import { fromEvent, Observable, Subject, isObservable, EMPTY, of, timer, race } from 'rxjs';
4
4
  import { takeUntil, map, filter, withLatestFrom, endWith, take, mergeMap, tap } from 'rxjs/operators';
5
5
  import { DOCUMENT } from '@angular/common';
@@ -1729,6 +1729,20 @@ class OverlayPosition {
1729
1729
  this._origin = origin;
1730
1730
  return this;
1731
1731
  }
1732
+ /**
1733
+ * The element this overlay is anchored to, resolved to a plain node.
1734
+ *
1735
+ * Exposed because the strategy is the only place that knows what the panel is connected to,
1736
+ * and the overlay has to watch that element to keep up with it when it moves.
1737
+ *
1738
+ * @returns The origin element, or `null` while none has been set.
1739
+ */
1740
+ get origin() {
1741
+ if (!this._origin) {
1742
+ return null;
1743
+ }
1744
+ return this._origin instanceof ElementRef ? this._origin.nativeElement : this._origin;
1745
+ }
1732
1746
  /**
1733
1747
  * Sets the preferred positions for the overlay.
1734
1748
  * The order of the array determines the fallback priority.
@@ -1914,6 +1928,8 @@ class OverlayRef {
1914
1928
  _isAttached = false;
1915
1929
  _repositionHandler;
1916
1930
  _repositionFrame = 0;
1931
+ _originFrame = 0;
1932
+ _originBox = null;
1917
1933
  _keydownCallback;
1918
1934
  _unregisterKeydown;
1919
1935
  _backdropClickCallback;
@@ -2042,8 +2058,79 @@ class OverlayRef {
2042
2058
  };
2043
2059
  window.addEventListener('scroll', this._repositionHandler, { capture: true, passive: true });
2044
2060
  window.addEventListener('resize', this._repositionHandler, { passive: true });
2061
+ this._followOrigin();
2062
+ }
2063
+ /**
2064
+ * Follows the origin while it moves for a reason no event announces.
2065
+ *
2066
+ * Scroll and resize both describe the page moving under an origin that stays put. Neither says
2067
+ * anything about the origin itself moving inside a page nobody scrolled — a sibling collapsing
2068
+ * above it, an image landing, an accordion animating shut — and the panel is then left hanging
2069
+ * where its trigger used to be. Since that collapse is animated, one re-measure when it starts
2070
+ * would only move the panel to a place the trigger is still on its way out of: the trigger has
2071
+ * to be followed for as long as it slides.
2072
+ *
2073
+ * The origin's box is read every frame, but the position is re-applied only when it actually
2074
+ * changed, so an overlay whose trigger sits still never touches the DOM. Scheduled outside
2075
+ * Angular's zone, or a zone-based application would run change detection on every frame an
2076
+ * overlay is open.
2077
+ */
2078
+ _followOrigin() {
2079
+ if (!this._config.positionStrategy || typeof requestAnimationFrame !== 'function') {
2080
+ return;
2081
+ }
2082
+ this._originBox = this._measureOrigin();
2083
+ const track = () => {
2084
+ if (!this._isAttached) {
2085
+ this._originFrame = 0;
2086
+ return;
2087
+ }
2088
+ this._originFrame = requestAnimationFrame(track);
2089
+ // Read again rather than captured once: a consumer is free to re-anchor a live overlay,
2090
+ // and the panel has to end up following whatever it is connected to now.
2091
+ const box = this._measureOrigin();
2092
+ const previous = this._originBox;
2093
+ if (!box) {
2094
+ return;
2095
+ }
2096
+ if (previous &&
2097
+ previous.top === box.top &&
2098
+ previous.left === box.left &&
2099
+ previous.width === box.width &&
2100
+ previous.height === box.height) {
2101
+ return;
2102
+ }
2103
+ this._originBox = box;
2104
+ this.updatePosition();
2105
+ };
2106
+ const start = () => {
2107
+ this._originFrame = requestAnimationFrame(track);
2108
+ };
2109
+ // Resolved through the injector rather than taken in the constructor, so the shape of a
2110
+ // class consumers can instantiate does not change for a private scheduling detail.
2111
+ const zone = this._appRef.injector.get(NgZone, null);
2112
+ if (zone) {
2113
+ zone.runOutsideAngular(start);
2114
+ return;
2115
+ }
2116
+ start();
2045
2117
  }
2046
- /** Removes the listeners registered by {@link _listenForReposition}. */
2118
+ /**
2119
+ * Reads the origin's box, reduced to the four numbers a move can change.
2120
+ *
2121
+ * @returns The box, or `null` when there is no origin or it has left the document — an element
2122
+ * out of the document reports zeros for everything, and following that would fling the panel
2123
+ * into the corner rather than leave it where it was.
2124
+ */
2125
+ _measureOrigin() {
2126
+ const origin = this._config.positionStrategy?.origin;
2127
+ if (!origin || !origin.isConnected) {
2128
+ return null;
2129
+ }
2130
+ const rect = origin.getBoundingClientRect();
2131
+ return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
2132
+ }
2133
+ /** Releases everything {@link _listenForReposition} started, the origin follow included. */
2047
2134
  _stopListeningForReposition() {
2048
2135
  if (!this._repositionHandler || typeof window === 'undefined') {
2049
2136
  return;
@@ -2055,6 +2142,11 @@ class OverlayRef {
2055
2142
  cancelAnimationFrame(this._repositionFrame);
2056
2143
  this._repositionFrame = 0;
2057
2144
  }
2145
+ if (this._originFrame) {
2146
+ cancelAnimationFrame(this._originFrame);
2147
+ this._originFrame = 0;
2148
+ }
2149
+ this._originBox = null;
2058
2150
  }
2059
2151
  /**
2060
2152
  * Disposes the overlay and cleans up all allocated resources.
@@ -3175,13 +3267,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImpor
3175
3267
  * {@link HUB_TOOLTIP_ADAPTER} token, which defaults to the built-in hub-ui
3176
3268
  * tooltip. Swap it app-wide (or per subtree) with `provideHubTooltip(...)`.
3177
3269
  *
3270
+ * The element that is MEASURED and the element that is HOVERED need not be the same. By
3271
+ * default they are — the host does both — but a control whose text is clipped by a box
3272
+ * inside it wants them apart: the hover area is the whole control, while the only box that
3273
+ * can report truncation is the inner one. Point `hubOverflowTooltipMeasure` at that box and
3274
+ * the tooltip covers the control while answering to the text.
3275
+ *
3178
3276
  * Requires the tooltip styles once in your app:
3179
3277
  * `@use 'ng-hub-ui-utils/styles/tooltip';`.
3180
3278
  *
3181
- * @example
3279
+ * @example Host measures itself
3182
3280
  * ```html
3183
3281
  * <span class="label" [hubOverflowTooltip]="item.label">{{ item.label }}</span>
3184
3282
  * ```
3283
+ *
3284
+ * @example Hover the whole chip, measure the title inside it
3285
+ * ```html
3286
+ * <div class="chip" [hubOverflowTooltip]="item.label" hubOverflowTooltipMeasure=".chip__title">
3287
+ * <span class="chip__icon"></span>
3288
+ * <span class="chip__title">{{ item.label }}</span>
3289
+ * </div>
3290
+ * ```
3185
3291
  */
3186
3292
  class HubOverflowTooltipDirective {
3187
3293
  /** Tooltip text; shown only while the host overflows. */
@@ -3189,6 +3295,15 @@ class HubOverflowTooltipDirective {
3189
3295
  /** Placement of the tooltip relative to the host. */
3190
3296
  placement = input('top', /* @ts-ignore */
3191
3297
  ...(ngDevMode ? [{ debugName: "placement" }] : /* istanbul ignore next */ []));
3298
+ /**
3299
+ * CSS selector, resolved inside the host, naming the element whose truncation decides
3300
+ * whether the tooltip speaks. The tooltip still belongs to the host, so the hover area is
3301
+ * unchanged; only the measurement moves.
3302
+ *
3303
+ * Left unset — or pointing at nothing — the host measures itself, which is what this
3304
+ * directive has always done.
3305
+ */
3306
+ measureTarget = input(undefined, { ...(ngDevMode ? { debugName: "measureTarget" } : /* istanbul ignore next */ {}), alias: 'hubOverflowTooltipMeasure' });
3192
3307
  host = inject(ElementRef);
3193
3308
  adapter = inject(HUB_TOOLTIP_ADAPTER);
3194
3309
  handle = null;
@@ -3200,6 +3315,14 @@ class HubOverflowTooltipDirective {
3200
3315
  ...(ngDevMode ? [{ debugName: "ready" }] : /* istanbul ignore next */ []));
3201
3316
  constructor() {
3202
3317
  afterNextRender(() => this.init());
3318
+ // A selector changed after the fact points at a different box, so the observers and
3319
+ // the truncation state both have to be taken again.
3320
+ effect(() => {
3321
+ this.measureTarget();
3322
+ if (this.ready()) {
3323
+ this.observe();
3324
+ }
3325
+ });
3203
3326
  effect(() => {
3204
3327
  if (!this.ready() || !this.handle) {
3205
3328
  return;
@@ -3216,32 +3339,63 @@ class HubOverflowTooltipDirective {
3216
3339
  /** Wires the tooltip handle and the browser-only truncation observers. */
3217
3340
  init() {
3218
3341
  const el = this.host.nativeElement;
3219
- // Attach with no text; the effect feeds the label only while truncated.
3342
+ // The tooltip belongs to the HOST whatever is measured: it is the control the pointer
3343
+ // is over, and the box that reports truncation may be a fraction of it.
3220
3344
  this.handle = this.adapter.attach(el, '', { placement: this.placement() });
3221
- this.measure(el);
3222
3345
  if (typeof ResizeObserver !== 'undefined') {
3223
- this.resizeObserver = new ResizeObserver(() => this.measure(el));
3224
- this.resizeObserver.observe(el);
3346
+ // Re-resolving on every callback rather than closing over the element: content
3347
+ // rendered after this point can bring the measured box with it.
3348
+ this.resizeObserver = new ResizeObserver(() => this.measure());
3225
3349
  }
3226
3350
  if (typeof MutationObserver !== 'undefined') {
3227
- this.mutationObserver = new MutationObserver(() => this.measure(el));
3351
+ // A mutation can replace the measured box, so the observers are re-pointed rather
3352
+ // than merely re-read.
3353
+ this.mutationObserver = new MutationObserver(() => this.observe());
3228
3354
  this.mutationObserver.observe(el, { childList: true, characterData: true, subtree: true });
3229
3355
  }
3356
+ this.observe();
3230
3357
  this.ready.set(true);
3231
3358
  }
3232
- /** Updates the truncation state from the host's layout. */
3233
- measure(el) {
3359
+ /**
3360
+ * Points the resize observer at the boxes that can change the answer — the host, whose
3361
+ * width bounds everything, and the measured box when it is a different element — and
3362
+ * takes the measurement again.
3363
+ */
3364
+ observe() {
3365
+ const host = this.host.nativeElement;
3366
+ const target = this.resolveTarget();
3367
+ if (this.resizeObserver) {
3368
+ this.resizeObserver.disconnect();
3369
+ this.resizeObserver.observe(host);
3370
+ if (target !== host) {
3371
+ this.resizeObserver.observe(target);
3372
+ }
3373
+ }
3374
+ this.measure();
3375
+ }
3376
+ /**
3377
+ * The element whose overflow is the question. Falls back to the host, so a selector that
3378
+ * matches nothing behaves exactly as no selector at all rather than silently going quiet.
3379
+ */
3380
+ resolveTarget() {
3381
+ const host = this.host.nativeElement;
3382
+ const selector = this.measureTarget();
3383
+ return (selector ? host.querySelector(selector) : null) ?? host;
3384
+ }
3385
+ /** Updates the truncation state from the measured box's layout. */
3386
+ measure() {
3387
+ const el = this.resolveTarget();
3234
3388
  this.overflowing.set(el.scrollWidth > el.clientWidth + 1);
3235
3389
  }
3236
3390
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HubOverflowTooltipDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
3237
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.8", type: HubOverflowTooltipDirective, isStandalone: true, selector: "[hubOverflowTooltip]", inputs: { text: { classPropertyName: "text", publicName: "hubOverflowTooltip", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
3391
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.8", type: HubOverflowTooltipDirective, isStandalone: true, selector: "[hubOverflowTooltip]", inputs: { text: { classPropertyName: "text", publicName: "hubOverflowTooltip", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, measureTarget: { classPropertyName: "measureTarget", publicName: "hubOverflowTooltipMeasure", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
3238
3392
  }
3239
3393
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.8", ngImport: i0, type: HubOverflowTooltipDirective, decorators: [{
3240
3394
  type: Directive,
3241
3395
  args: [{
3242
3396
  selector: '[hubOverflowTooltip]'
3243
3397
  }]
3244
- }], ctorParameters: () => [], propDecorators: { text: [{ type: i0.Input, args: [{ isSignal: true, alias: "hubOverflowTooltip", required: false }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }] } });
3398
+ }], ctorParameters: () => [], propDecorators: { text: [{ type: i0.Input, args: [{ isSignal: true, alias: "hubOverflowTooltip", required: false }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], measureTarget: [{ type: i0.Input, args: [{ isSignal: true, alias: "hubOverflowTooltipMeasure", required: false }] }] } });
3245
3399
 
3246
3400
  /*
3247
3401
  * Public API Surface of utils