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 +19 -1
- package/fesm2022/ng-hub-ui-utils.mjs +166 -12
- package/fesm2022/ng-hub-ui-utils.mjs.map +1 -1
- package/package.json +1 -1
- package/types/ng-hub-ui-utils.d.ts +73 -4
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
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
-
|
|
3224
|
-
this.
|
|
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
|
-
|
|
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
|
-
/**
|
|
3233
|
-
|
|
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
|