ng-hub-ui-utils 22.15.3 → 22.18.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
@@ -62,6 +62,7 @@ This library is part of the **ng-hub-ui** ecosystem:
62
62
  - [**ng-hub-ui-portal**](https://www.npmjs.com/package/ng-hub-ui-portal)
63
63
  - [**ng-hub-ui-skeleton**](https://www.npmjs.com/package/ng-hub-ui-skeleton)
64
64
  - [**ng-hub-ui-sortable**](https://www.npmjs.com/package/ng-hub-ui-sortable)
65
+ - [**ng-hub-ui-spreadsheet**](https://www.npmjs.com/package/ng-hub-ui-spreadsheet)
65
66
  - [**ng-hub-ui-stepper**](https://www.npmjs.com/package/ng-hub-ui-stepper)
66
67
  - [**ng-hub-ui-utils**](https://www.npmjs.com/package/ng-hub-ui-utils) ← You are here
67
68
 
@@ -93,31 +94,34 @@ Advanced system for creating overlays and floating components with flexible posi
93
94
  import { OverlayService, OverlayConfig } from 'ng-hub-ui-utils';
94
95
 
95
96
  @Component({
96
- selector: 'app-example'
97
+ selector: 'app-example'
97
98
  })
98
99
  export class ExampleComponent {
99
- constructor(private overlayService: OverlayService) {}
100
-
101
- openOverlay(elementRef: ElementRef) {
102
- // Create overlay with configuration
103
- const overlayRef = this.overlayService.create({
104
- hasBackdrop: true,
105
- backdropClass: 'custom-backdrop'
106
- });
107
-
108
- // Configure position strategy
109
- const positionStrategy = this.overlayService.position()
110
- .flexibleConnectedTo(elementRef)
111
- .withPositions([{
112
- originX: 'start',
113
- originY: 'bottom',
114
- overlayX: 'start',
115
- overlayY: 'top'
116
- }]);
117
-
118
- // Render a component (or a TemplateRef) into the overlay; you get the host element back
119
- const overlayElement = overlayRef.attach(MyComponent);
120
- }
100
+ constructor(private overlayService: OverlayService) {}
101
+
102
+ openOverlay(elementRef: ElementRef) {
103
+ // Create overlay with configuration
104
+ const overlayRef = this.overlayService.create({
105
+ hasBackdrop: true,
106
+ backdropClass: 'custom-backdrop'
107
+ });
108
+
109
+ // Configure position strategy
110
+ const positionStrategy = this.overlayService
111
+ .position()
112
+ .flexibleConnectedTo(elementRef)
113
+ .withPositions([
114
+ {
115
+ originX: 'start',
116
+ originY: 'bottom',
117
+ overlayX: 'start',
118
+ overlayY: 'top'
119
+ }
120
+ ]);
121
+
122
+ // Render a component (or a TemplateRef) into the overlay; you get the host element back
123
+ const overlayElement = overlayRef.attach(MyComponent);
124
+ }
121
125
  }
122
126
  ```
123
127
 
@@ -138,14 +142,14 @@ import { PopupService } from 'ng-hub-ui-utils';
138
142
 
139
143
  @Injectable()
140
144
  export class MyPopupService extends PopupService<MyPopupComponent> {
141
- constructor() {
142
- super(MyPopupComponent);
143
- }
144
-
145
- openPopup(content?: string | TemplateRef<any>) {
146
- const { windowRef, transition$ } = super.open(content, {}, true);
147
- return { windowRef, transition$ };
148
- }
145
+ constructor() {
146
+ super(MyPopupComponent);
147
+ }
148
+
149
+ openPopup(content?: string | TemplateRef<any>) {
150
+ const { windowRef, transition$ } = super.open(content, {}, true);
151
+ return { windowRef, transition$ };
152
+ }
149
153
  }
150
154
  ```
151
155
 
@@ -248,14 +252,14 @@ import { GetPipe, IsStringPipe, IsObjectPipe, IsObservablePipe, UcfirstPipe, Unw
248
252
  imports: [GetPipe, IsStringPipe, UcfirstPipe, UnwrapAsyncPipe],
249
253
  template: `
250
254
  <!-- Safe nested property access -->
251
- <p>{{ user | get : 'address.city' : 'Unknown' }}</p>
255
+ <p>{{ user | get: 'address.city' : 'Unknown' }}</p>
252
256
 
253
257
  <!-- Capitalize first letter -->
254
258
  <h1>{{ title | ucfirst }}</h1>
255
259
 
256
260
  <!-- Type checking in templates -->
257
261
  @if (value | isString) {
258
- <span>It's a string: {{ value }}</span>
262
+ <span>It's a string: {{ value }}</span>
259
263
  }
260
264
 
261
265
  <!-- Unwrap Observable or direct value -->
@@ -272,12 +276,12 @@ export class ExampleComponent {
272
276
 
273
277
  **Available Pipes:**
274
278
 
275
- - **GetPipe** (`get`): Safe nested property access with default values
276
- - **IsStringPipe** (`isString`): Check if value is a string
277
- - **IsObjectPipe** (`isObject`): Check if value is an object
278
- - **IsObservablePipe** (`isObservable`): Check if value is an Observable
279
- - **UcfirstPipe** (`ucfirst`): Capitalize first letter of a string
280
- - **UnwrapAsyncPipe** (`unwrapAsync`): Unwrap Observable or return direct value
279
+ - **GetPipe** (`get`): Safe nested property access with default values
280
+ - **IsStringPipe** (`isString`): Check if value is a string
281
+ - **IsObjectPipe** (`isObject`): Check if value is an object
282
+ - **IsObservablePipe** (`isObservable`): Check if value is an Observable
283
+ - **UcfirstPipe** (`ucfirst`): Capitalize first letter of a string
284
+ - **UnwrapAsyncPipe** (`unwrapAsync`): Unwrap Observable or return direct value
281
285
 
282
286
  ### 🛠️ General Utility Functions
283
287
 
@@ -383,6 +387,19 @@ export class ExampleComponent {}
383
387
  Inputs: `hubTooltip` (text), `hubTooltipPlacement` (`top` | `bottom` | `left` | `right`,
384
388
  default `top`), `hubTooltipDelay` (fade ms, default `150`), `hubTooltipOffset` (px, default `8`).
385
389
 
390
+ **The placement is a preference, not an instruction (since 22.16.0).** A tooltip that would open
391
+ off the edge of the window opens on the opposite side instead, at all four edges, and one centred
392
+ on a host near the inline edge is slid back inside rather than flipped — flipping does nothing for
393
+ an overflow on the cross axis. It gives way only when it genuinely has no room, so a tooltip that
394
+ did not need to move does not, and the fit is re-run while the label is open, on window resize and
395
+ on a scroll anywhere above it. Under RTL the inline edge is the other one and the flip follows it,
396
+ while `hubTooltipPlacement="left"` still means the host's left edge.
397
+
398
+ If you have been writing `hubTooltipPlacement="bottom"` by hand on every hint in a header, you can
399
+ stop: the attribute is still honoured wherever it fits, but it is no longer what keeps the label
400
+ on screen. The class on the bubble (`hub-tooltip--top`, `--bottom`, `--left`, `--right`) names the
401
+ side it ended up on, so an arrow styled off it follows the flip.
402
+
386
403
  > **`TooltipDirective` (`[tooltip]`) was removed in 22.14.0**, having been deprecated since
387
404
  > 22.9.0. Its bare input names (`tooltip`, `placement`, `delay`, `offset`) belonged to every
388
405
  > directive on the element that declared them, which is how it collided with `[hubDropdown]`'s
@@ -449,10 +466,7 @@ import { hubTooltipAdapter } from 'ng-hub-ui-utils';
449
466
  import { provideHubBadgeTooltip } from 'ng-hub-ui-badges';
450
467
  import { provideHubBreadcrumbTooltip } from 'ng-hub-ui-breadcrumbs';
451
468
 
452
- providers: [
453
- provideHubBadgeTooltip(hubTooltipAdapter),
454
- provideHubBreadcrumbTooltip(hubTooltipAdapter)
455
- ];
469
+ providers: [provideHubBadgeTooltip(hubTooltipAdapter), provideHubBreadcrumbTooltip(hubTooltipAdapter)];
456
470
  ```
457
471
 
458
472
  Inside this package the same token works the other way round: `[hubOverflowTooltip]`
@@ -476,6 +490,55 @@ Also available: the imperative `HubTooltipController` (engine) and the
476
490
  types. See the ecosystem-wide
477
491
  [Synergies & agnosticism](../../README.md#synergies--agnosticism) section.
478
492
 
493
+ ### 🔢 Grid Primitives
494
+
495
+ The arithmetic a sheet of cells is made of, with no DOM and no framework in it. A grid has a
496
+ handful of problems every implementation solves again and gets wrong in the same places: where the
497
+ cursor goes at an edge, what rectangle two corners describe, which cell a merged block swallowed,
498
+ and which tracks are worth drawing at all. `ng-hub-ui-spreadsheet` is built on these, which is how
499
+ it installs without `@angular/cdk`.
500
+
501
+ ```ts
502
+ import {
503
+ resolveGridIntent,
504
+ moveGridFocus,
505
+ gridRangeBetween,
506
+ buildSpanMap,
507
+ anchorOf,
508
+ gridWindow,
509
+ growWindowToSpans
510
+ } from 'ng-hub-ui-utils';
511
+
512
+ // A key read as an intent, so the grid does not grow a switch statement per browser. The
513
+ // roving tab stop is `gridCellTabIndex(cell, active, 'roving')`.
514
+ const intent = resolveGridIntent(event, { pageSize: 20 });
515
+
516
+ // The cursor, with the three different answers at the end of a row told apart.
517
+ const next = moveGridFocus(cursor, { row: 0, col: 1 }, bounds, { horizontal: 'continuous' });
518
+
519
+ // A selection kept as two corners, so ten thousand cells cost nothing.
520
+ const range = gridRangeBetween(anchor, cursor);
521
+
522
+ // Merged blocks: the cursor lands on the block and never inside it.
523
+ const spans = buildSpanMap([{ row: 4, col: 0, rowSpan: 3, colSpan: 2 }]);
524
+ const landing = anchorOf(spans, cursor);
525
+
526
+ // Only what the viewport covers, with the pixels the rest would have taken, so the scrollbar
527
+ // still tells the truth. Then widened until every block it touches is drawn whole.
528
+ const window = growWindowToSpans(
529
+ gridWindow({ offset: scrollTop, viewport: height, sizes: 32, count: rows.length, pinned: 1 }),
530
+ blocks,
531
+ 'row',
532
+ 32,
533
+ { count: rows.length, pinned: 1 }
534
+ );
535
+ ```
536
+
537
+ Also exported: `resolveGridEdge`, `isWithinGridRange`, `gridRangeCells`, `gridRangeSize`,
538
+ `clampGridRange`, `spanAt`, `isCovered`, `coversRange`, `gridCellTabIndex`, and the types
539
+ `HubGridCoords`, `HubGridBounds`, `HubGridRange`, `HubGridSpan`, `HubGridSpanMap`, `HubGridWrap`,
540
+ `HubGridEdge`, `HubGridIntent`, `HubGridWindow` and `HubGridTrackSizes`.
541
+
479
542
  ## 🚀 Installation
480
543
 
481
544
  ```bash
@@ -497,7 +560,7 @@ import { toInteger, isString, ScrollBar, getFocusableBoundaryElements, GetPipe,
497
560
  template: `
498
561
  <div #container>
499
562
  <h1>{{ title | ucfirst }}</h1>
500
- <p>{{ user | get : 'name' : 'Anonymous' }}</p>
563
+ <p>{{ user | get: 'name' : 'Anonymous' }}</p>
501
564
  </div>
502
565
  `
503
566
  })
@@ -598,7 +661,7 @@ class HubTranslationService {
598
661
  ```typescript
599
662
  import { HubTranslationService } from 'ng-hub-ui-utils';
600
663
 
601
- @Component({ /* ... */ })
664
+ @Component({/* ... */})
602
665
  export class LanguageSwitcherComponent {
603
666
  private translationSvc = inject(HubTranslationService);
604
667
 
@@ -638,81 +701,81 @@ These functions back the i18n system and are exported for direct use:
638
701
 
639
702
  ### Conversion Functions
640
703
 
641
- - `toInteger(value: any): number` - Safely converts to integer
642
- - `toString(value: any): string` - Converts to string handling null/undefined
643
- - `getValueInRange(value: number, max: number, min?: number): number` - Limits value to range
644
- - `padNumber(value: number): string` - Adds leading zero to numbers
704
+ - `toInteger(value: any): number` - Safely converts to integer
705
+ - `toString(value: any): string` - Converts to string handling null/undefined
706
+ - `getValueInRange(value: number, max: number, min?: number): number` - Limits value to range
707
+ - `padNumber(value: number): string` - Adds leading zero to numbers
645
708
 
646
709
  ### Validation Functions
647
710
 
648
- - `isString(value: any): value is string` - Checks if value is a string
649
- - `isNumber(value: any): value is number` - Checks if value is a valid number
650
- - `isInteger(value: any): value is number` - Checks if value is an integer
651
- - `isDefined(value: any): boolean` - Checks if not null/undefined
652
- - `isPromise<T>(v: any): v is Promise<T>` - Checks if value is a Promise
711
+ - `isString(value: any): value is string` - Checks if value is a string
712
+ - `isNumber(value: any): value is number` - Checks if value is a valid number
713
+ - `isInteger(value: any): value is number` - Checks if value is an integer
714
+ - `isDefined(value: any): boolean` - Checks if not null/undefined
715
+ - `isPromise<T>(v: any): v is Promise<T>` - Checks if value is a Promise
653
716
 
654
717
  ### String Functions
655
718
 
656
- - `regExpEscape(text: string): string` - Escapes special characters for RegExp
657
- - `removeAccents(str: string): string` - Removes accents from text
658
- - `interpolateString(expr?: string, params?: any, templateMatcher?: RegExp): string` - Replaces `{{ token }}` placeholders
659
- - `generateUniqueId(length: number): string` - Random alphanumeric id, for a DOM node that needs one
719
+ - `regExpEscape(text: string): string` - Escapes special characters for RegExp
720
+ - `removeAccents(str: string): string` - Removes accents from text
721
+ - `interpolateString(expr?: string, params?: any, templateMatcher?: RegExp): string` - Replaces `{{ token }}` placeholders
722
+ - `generateUniqueId(length: number): string` - Random alphanumeric id, for a DOM node that needs one
660
723
 
661
724
  ### Object Functions
662
725
 
663
- - `equals(o1: any, o2: any): boolean` - Deep equality
664
- - `getValue(target: any, key: string): any` - Reads a nested value by dot-notation key
665
- - `isObject(item: any): boolean` - Whether the value is a non-array object
666
- - `mergeDeep(target: any, source: any): any` - Recursive merge; the only deep object helper in the package
726
+ - `equals(o1: any, o2: any): boolean` - Deep equality
727
+ - `getValue(target: any, key: string): any` - Reads a nested value by dot-notation key
728
+ - `isObject(item: any): boolean` - Whether the value is a non-array object
729
+ - `mergeDeep(target: any, source: any): any` - Recursive merge; the only deep object helper in the package
667
730
 
668
731
  ### Signal Utilities
669
732
 
670
- - `debouncedSignal<T>(source: Signal<T>, delay?: number | Signal<number>): Signal<T>` - Mirrors a signal, delaying each change; the delay can itself be a signal
733
+ - `debouncedSignal<T>(source: Signal<T>, delay?: number | Signal<number>): Signal<T>` - Mirrors a signal, delaying each change; the delay can itself be a signal
671
734
 
672
735
  ### DOM Functions
673
736
 
674
- - `closest(element: HTMLElement, selector?: string): HTMLElement | null` - Finds parent element by selector
675
- - `reflow(element: HTMLElement): DOMRect` - Forces browser reflow
676
- - `getActiveElement(root?: Document | ShadowRoot): Element | null` - Gets active element including Shadow DOM
737
+ - `closest(element: HTMLElement, selector?: string): HTMLElement | null` - Finds parent element by selector
738
+ - `reflow(element: HTMLElement): DOMRect` - Forces browser reflow
739
+ - `getActiveElement(root?: Document | ShadowRoot): Element | null` - Gets active element including Shadow DOM
677
740
 
678
741
  ### Accent Resolution
679
742
 
680
- - `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`
743
+ - `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`
681
744
 
682
745
  ### Colour Functions
683
746
 
684
- - `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
685
- - `toRgb(color): HubRgb | null` - Normalises a string or parsed colour to channels
686
- - `toHex(color): string | null` - Renders as `#rrggbb`, or `#rrggbbaa` when translucent
687
- - `isValidColor(value): boolean` - Whether the parser can resolve the string
688
- - `HUB_NAMED_COLORS: Readonly<Record<string, string>>` - The 148 CSS named colours
747
+ - `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
748
+ - `toRgb(color): HubRgb | null` - Normalises a string or parsed colour to channels
749
+ - `toHex(color): string | null` - Renders as `#rrggbb`, or `#rrggbbaa` when translucent
750
+ - `isValidColor(value): boolean` - Whether the parser can resolve the string
751
+ - `HUB_NAMED_COLORS: Readonly<Record<string, string>>` - The 148 CSS named colours
689
752
 
690
753
  ### Contrast Functions
691
754
 
692
- - `relativeLuminance(color): number | null` - WCAG 2 relative luminance, 0 to 1
693
- - `contrastRatio(a, b): number | null` - WCAG 2 contrast ratio, 1 to 21
694
- - `contrastAPCA(text, background): number | null` - APCA lightness contrast, polarity-aware
695
- - `compositeOver(foreground, background): HubColor` - Blends translucent over opaque
696
- - `readableOn(background, metric?): string` - Black or white, whichever reads better. Defaults to `'lightness'`, the same decision `--hub-sys-color-*-on` makes in CSS; `'apca'` and `'wcag'` are also available
697
- - `HUB_INK_LIGHTNESS_THRESHOLD: number` - The OKLCh lightness above which a surface takes dark ink
755
+ - `relativeLuminance(color): number | null` - WCAG 2 relative luminance, 0 to 1
756
+ - `contrastRatio(a, b): number | null` - WCAG 2 contrast ratio, 1 to 21
757
+ - `contrastAPCA(text, background): number | null` - APCA lightness contrast, polarity-aware
758
+ - `compositeOver(foreground, background): HubColor` - Blends translucent over opaque
759
+ - `readableOn(background, metric?): string` - Black or white, whichever reads better. Defaults to `'lightness'`, the same decision `--hub-sys-color-*-on` makes in CSS; `'apca'` and `'wcag'` are also available
760
+ - `HUB_INK_LIGHTNESS_THRESHOLD: number` - The OKLCh lightness above which a surface takes dark ink
698
761
 
699
762
  ### OKLCh Functions
700
763
 
701
- - `rgbToOklch(color): HubOklch` / `oklchToRgb(color): HubRgb` - Conversions in the space the design system mixes in
702
- - `maxSrgbChroma(l, h): number` - Highest in-gamut chroma for a hue at a lightness. The sRGB gamut is not a cylinder — at L 0.578 blue reaches 0.232 and amber only 0.119 — so a palette cannot give every hue the same absolute chroma
703
- - `isInSrgbGamut(color): boolean` - Whether the colour survives the trip to sRGB
704
- - `clampToSrgbGamut(color): HubOklch` - Reduces chroma until it fits, preserving lightness and hue
764
+ - `rgbToOklch(color): HubOklch` / `oklchToRgb(color): HubRgb` - Conversions in the space the design system mixes in
765
+ - `maxSrgbChroma(l, h): number` - Highest in-gamut chroma for a hue at a lightness. The sRGB gamut is not a cylinder — at L 0.578 blue reaches 0.232 and amber only 0.119 — so a palette cannot give every hue the same absolute chroma
766
+ - `isInSrgbGamut(color): boolean` - Whether the colour survives the trip to sRGB
767
+ - `clampToSrgbGamut(color): HubOklch` - Reduces chroma until it fits, preserving lightness and hue
705
768
 
706
769
  ### Palette Derivation
707
770
 
708
771
  One brand colour, the whole palette — and the two numbers that keep it honest.
709
772
 
710
- - `harmoniseSemantics(primary, options?): HubSemanticPalette | null` - Rotates `success`, `warning`, `danger` and `info` towards the brand's hue and returns them as hex. Lightness is left exactly where the anchor had it, because it is what carries the contrast each role was chosen for; chroma is reduced only when the new hue cannot hold it inside sRGB
711
- - `tintNeutrals(primary, options?): HubNeutralRamp | null` - Leans the grey ramp (`100` … `900`) the same way, keeping each step's lightness
712
- - `HUB_MAX_HUE_SHIFT: number` (15) - How far a role may rotate, in degrees. Not taste: success and danger sit 135.8° apart in OKLCh, and a viewer with deuteranopia separates them by hue alone. A brand hue between the two pulls both inwards, so the gap closes by up to twice the cap; at 22.9° it would reach the 90° floor. 15° leaves the worst case at 105.8°
713
- - `HUB_MAX_NEUTRAL_CHROMA: number` (0.015) - The most chroma a tinted neutral may carry. Anchored on the ramp the design system already ships — `gray-600` measures 0.0165 and `gray-500` 0.0145 — so a tinted ramp is never more colourful than the grey people already accept as grey
714
- - `HUB_SEMANTIC_ANCHORS` / `HUB_NEUTRAL_ANCHORS` - The untinted starting points, so a product that harmonises nothing still gets the palette the stylesheet ships
715
- - Types: `HubSemanticRole`, `HubSemanticPalette`, `HubNeutralStep`, `HubNeutralRamp`, `HubHarmoniseOptions`, `HubTintNeutralsOptions`
773
+ - `harmoniseSemantics(primary, options?): HubSemanticPalette | null` - Rotates `success`, `warning`, `danger` and `info` towards the brand's hue and returns them as hex. Lightness is left exactly where the anchor had it, because it is what carries the contrast each role was chosen for; chroma is reduced only when the new hue cannot hold it inside sRGB
774
+ - `tintNeutrals(primary, options?): HubNeutralRamp | null` - Leans the grey ramp (`100` … `900`) the same way, keeping each step's lightness
775
+ - `HUB_MAX_HUE_SHIFT: number` (15) - How far a role may rotate, in degrees. Not taste: success and danger sit 135.8° apart in OKLCh, and a viewer with deuteranopia separates them by hue alone. A brand hue between the two pulls both inwards, so the gap closes by up to twice the cap; at 22.9° it would reach the 90° floor. 15° leaves the worst case at 105.8°
776
+ - `HUB_MAX_NEUTRAL_CHROMA: number` (0.015) - The most chroma a tinted neutral may carry. Anchored on the ramp the design system already ships — `gray-600` measures 0.0165 and `gray-500` 0.0145 — so a tinted ramp is never more colourful than the grey people already accept as grey
777
+ - `HUB_SEMANTIC_ANCHORS` / `HUB_NEUTRAL_ANCHORS` - The untinted starting points, so a product that harmonises nothing still gets the palette the stylesheet ships
778
+ - Types: `HubSemanticRole`, `HubSemanticPalette`, `HubNeutralStep`, `HubNeutralRamp`, `HubHarmoniseOptions`, `HubTintNeutralsOptions`
716
779
 
717
780
  ```typescript
718
781
  import { harmoniseSemantics, tintNeutrals } from 'ng-hub-ui-utils';
@@ -728,9 +791,9 @@ is rounding noise, and harmonising towards it would rotate every role in a direc
728
791
 
729
792
  ### Focus Functions
730
793
 
731
- - `getFocusableBoundaryElements(element: HTMLElement): HTMLElement[]` - Gets first and last focusable elements
732
- - `hubFocusTrap(zone, element, stopFocusTrap$, refocusOnClick?)` - Creates focus trap for modals/overlays
733
- - `FOCUSABLE_ELEMENTS_SELECTOR: string` - CSS selector for focusable elements
794
+ - `getFocusableBoundaryElements(element: HTMLElement): HTMLElement[]` - Gets first and last focusable elements
795
+ - `hubFocusTrap(zone, element, stopFocusTrap$, refocusOnClick?)` - Creates focus trap for modals/overlays
796
+ - `FOCUSABLE_ELEMENTS_SELECTOR: string` - CSS selector for focusable elements
734
797
 
735
798
  ### Drag and Drop
736
799
 
@@ -738,19 +801,19 @@ The engine-agnostic half of native HTML5 drag and drop, shared by the libraries
738
801
  implement it. The UI primitives — handle, placeholder and preview directives — stay in each
739
802
  library, because their selectors and data models differ.
740
803
 
741
- - `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
742
- - `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
743
- - `clamp(value, max)`, `computeTargetIndex(...)`, `toAbsoluteIndex(...)`, `containsNode(...)` - Index arithmetic for sliced and nested lists
744
- - `resolveDropPosition(...)` with `DropRect` and `DragAxis` - Where a pointer sits relative to an item: `'before'` or `'after'`, on a vertical, horizontal or grid axis
745
- - `createNativeDragImage(...)` returning `DragImageResult` - Renders the drag preview the browser shows
746
- - `createPointerDragSession(config: PointerDragSessionConfig): PointerDragSession` - Pointer Events fallback for touch, where native drag events are not delivered
747
- - Types: `DropPosition`, `DragPointerMode`, `DragContainerRef<T>`, `ActiveDrag<T>`, `DragTarget<T>`, `DragRegistration`
804
+ - `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
805
+ - `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
806
+ - `clamp(value, max)`, `computeTargetIndex(...)`, `toAbsoluteIndex(...)`, `containsNode(...)` - Index arithmetic for sliced and nested lists
807
+ - `resolveDropPosition(...)` with `DropRect` and `DragAxis` - Where a pointer sits relative to an item: `'before'` or `'after'`, on a vertical, horizontal or grid axis
808
+ - `createNativeDragImage(...)` returning `DragImageResult` - Renders the drag preview the browser shows
809
+ - `createPointerDragSession(config: PointerDragSessionConfig): PointerDragSession` - Pointer Events fallback for touch, where native drag events are not delivered
810
+ - Types: `DropPosition`, `DragPointerMode`, `DragContainerRef<T>`, `ActiveDrag<T>`, `DragTarget<T>`, `DragRegistration`
748
811
 
749
812
  ### Directives
750
813
 
751
- - `HubTooltipDirective` (`[hubTooltip]`) - Tooltip on hover/focus. Inputs: `hubTooltip`, `hubTooltipPlacement`, `hubTooltipDelay`, `hubTooltipOffset`
752
- - `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)
753
- - `provideHubTooltip(adapter: HubTooltipAdapter)` and `HUB_TOOLTIP_ADAPTER` - Swap the implementation behind `[hubOverflowTooltip]`, app-wide or per subtree; defaults to `hubTooltipAdapter`
814
+ - `HubTooltipDirective` (`[hubTooltip]`) - Tooltip on hover/focus, flipped away from the window edges. Inputs: `hubTooltip`, `hubTooltipPlacement`, `hubTooltipDelay`, `hubTooltipOffset`
815
+ - `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)
816
+ - `provideHubTooltip(adapter: HubTooltipAdapter)` and `HUB_TOOLTIP_ADAPTER` - Swap the implementation behind `[hubOverflowTooltip]`, app-wide or per subtree; defaults to `hubTooltipAdapter`
754
817
 
755
818
  ### Pipes
756
819
 
@@ -807,31 +870,31 @@ library, because their selectors and data models differ.
807
870
  ```typescript
808
871
  @Injectable({ providedIn: 'root' })
809
872
  class OverlayService {
810
- create(config?: OverlayConfig): OverlayRef;
811
- position(): OverlayPosition;
873
+ create(config?: OverlayConfig): OverlayRef;
874
+ position(): OverlayPosition;
812
875
  }
813
876
 
814
877
  class OverlayRef {
815
- // Renders a template or a component into the overlay and returns the host element,
816
- // not a ComponentRef: the overlay owns the view it created and tears it down itself.
817
- attach(content: TemplateRef<unknown> | Type<unknown>, viewContainerRef?: ViewContainerRef): HTMLElement;
818
- detach(): void;
819
- dispose(): void;
820
- hasAttached(): boolean;
821
- updatePosition(): void;
822
- onBackdropClick(callback: () => void): void;
823
- // Only the topmost open overlay is told, so a dropdown inside a dialog takes Escape
824
- // for itself and leaves the dialog open.
825
- onKeydown(callback: (event: KeyboardEvent) => void): void;
878
+ // Renders a template or a component into the overlay and returns the host element,
879
+ // not a ComponentRef: the overlay owns the view it created and tears it down itself.
880
+ attach(content: TemplateRef<unknown> | Type<unknown>, viewContainerRef?: ViewContainerRef): HTMLElement;
881
+ detach(): void;
882
+ dispose(): void;
883
+ hasAttached(): boolean;
884
+ updatePosition(): void;
885
+ onBackdropClick(callback: () => void): void;
886
+ // Only the topmost open overlay is told, so a dropdown inside a dialog takes Escape
887
+ // for itself and leaves the dialog open.
888
+ onKeydown(callback: (event: KeyboardEvent) => void): void;
826
889
  }
827
890
 
828
891
  class OverlayPosition {
829
- // The element the panel is anchored to. The overlay watches it and follows it when it moves.
830
- readonly origin: HTMLElement | null;
831
- flexibleConnectedTo(origin: ElementRef | HTMLElement): this;
832
- withPositions(positions: ConnectionPosition[]): this;
833
- // `start` / `end` are logical and read from the origin element; this overrides that.
834
- withDirection(direction: 'ltr' | 'rtl' | null): this;
892
+ // The element the panel is anchored to. The overlay watches it and follows it when it moves.
893
+ readonly origin: HTMLElement | null;
894
+ flexibleConnectedTo(origin: ElementRef | HTMLElement): this;
895
+ withPositions(positions: ConnectionPosition[]): this;
896
+ // `start` / `end` are logical and read from the origin element; this overrides that.
897
+ withDirection(direction: 'ltr' | 'rtl' | null): this;
835
898
  }
836
899
  ```
837
900
 
@@ -842,15 +905,55 @@ both text directions:
842
905
  ```typescript
843
906
  import { HUB_DROPDOWN_POSITIONS } from 'ng-hub-ui-utils';
844
907
 
845
- overlayService.position().flexibleConnectedTo(origin).withPositions([...HUB_DROPDOWN_POSITIONS]);
908
+ overlayService
909
+ .position()
910
+ .flexibleConnectedTo(origin)
911
+ .withPositions([...HUB_DROPDOWN_POSITIONS]);
912
+ ```
913
+
914
+ #### Viewport fitting (`viewport-fit`)
915
+
916
+ The arithmetic behind both the overlay strategy and the tooltip, exported on its own so a floating
917
+ element that is neither does not have to write a third copy. It takes plain numbers, touches no DOM
918
+ and runs unchanged on a server render.
919
+
920
+ ```typescript
921
+ import { hubAnchorToViewport, hubToAnchorSide, hubToPhysicalSide, hubViewportOf } from 'ng-hub-ui-utils';
922
+
923
+ const rtl = getComputedStyle(host).direction === 'rtl';
924
+
925
+ const placed = hubAnchorToViewport({
926
+ anchor: host.getBoundingClientRect(),
927
+ box: { width: panel.offsetWidth, height: panel.offsetHeight },
928
+ viewport: hubViewportOf(panel), // null on a server render: nothing flips
929
+ side: hubToAnchorSide('bottom', rtl), // 'block-start' | 'block-end' | 'inline-start' | 'inline-end'
930
+ align: 'start', // logical: mirrors under RTL
931
+ offset: 8,
932
+ margin: 0,
933
+ rtl
934
+ });
935
+
936
+ // placed.x / placed.y are viewport coordinates — add scrollX / scrollY for an absolutely
937
+ // positioned element. placed.side is where it ended up, placed.flipped whether that was a flip.
938
+ panel.dataset['side'] = hubToPhysicalSide(placed.side, rtl);
846
939
  ```
847
940
 
941
+ The requested side is kept unless it genuinely cannot hold the box **and** the opposite side has
942
+ more room, so nothing flips for free; the cross axis is clamped rather than flipped, since a box
943
+ centred on an anchor near the inline edge overflows there whichever side it opens on. Sides are
944
+ logical so a single fallback rule serves both text directions — `hubToAnchorSide` and
945
+ `hubToPhysicalSide` round-trip a physical edge through that axis without changing what the caller
946
+ asked for.
947
+
948
+ Also exported: `hubFitsInViewport(point, size, viewport, margin?)`,
949
+ `hubClampToViewport(point, size, viewport, margin?)` and `hubOppositeSide(side)`.
950
+
848
951
  #### ScrollBar Service
849
952
 
850
953
  ```typescript
851
954
  @Injectable({ providedIn: 'root' })
852
955
  class ScrollBar {
853
- hide(): ScrollbarReverter; // Hides scrollbar with compensation
956
+ hide(): ScrollbarReverter; // Hides scrollbar with compensation
854
957
  }
855
958
  ```
856
959
 
@@ -862,48 +965,48 @@ an injection context — as an `@Injectable()` subclass, or from a factory provi
862
965
 
863
966
  ```typescript
864
967
  class PopupService<T> {
865
- constructor(componentType: Type<T>);
866
- open(
867
- content?: string | TemplateRef<any>,
868
- templateContext?: any,
869
- animation?: boolean
870
- ): { windowRef: ComponentRef<T>; transition$: Observable<void> };
871
- close(animation?: boolean): Observable<void>;
968
+ constructor(componentType: Type<T>);
969
+ open(
970
+ content?: string | TemplateRef<any>,
971
+ templateContext?: any,
972
+ animation?: boolean
973
+ ): { windowRef: ComponentRef<T>; transition$: Observable<void> };
974
+ close(animation?: boolean): Observable<void>;
872
975
  }
873
976
 
874
977
  // The nodes and view a popup projects, returned internally by the content resolver.
875
978
  class ContentRef {
876
- constructor(nodes: Node[][], viewRef?: ViewRef, componentRef?: ComponentRef<any>);
979
+ constructor(nodes: Node[][], viewRef?: ViewRef, componentRef?: ComponentRef<any>);
877
980
  }
878
981
  ```
879
982
 
880
983
  ### Transition Utilities
881
984
 
882
- - `hubRunTransition<T>(zone, element, startFn, options)` - Advanced transition system with Observable
883
- - `hubCompleteTransition(element)` - Completes a running transition on an element
884
- - `getTransitionDurationMs(element)` - Gets CSS transition duration in milliseconds
885
- - `runInZone<T>(zone)` - RxJS operator to execute observables inside NgZone
985
+ - `hubRunTransition<T>(zone, element, startFn, options)` - Advanced transition system with Observable
986
+ - `hubCompleteTransition(element)` - Completes a running transition on an element
987
+ - `getTransitionDurationMs(element)` - Gets CSS transition duration in milliseconds
988
+ - `runInZone<T>(zone)` - RxJS operator to execute observables inside NgZone
886
989
 
887
990
  ## 🎨 Support Components
888
991
 
889
992
  This library doesn't include visual components, but support utilities used by other components in the Hub UI ecosystem:
890
993
 
891
- | Utility | Description | Used by |
892
- | --------------- | ----------------------------------- | -------------------------------------- |
893
- | Overlay Service | Flexible overlay positioning system | ng-hub-ui-modal, ng-hub-ui-portal |
894
- | Focus Trap | Focus management in modals/overlays | ng-hub-ui-modal, ng-hub-ui-portal |
895
- | Scrollbar | Scrollbar compensation | ng-hub-ui-modal, ng-hub-ui-portal |
896
- | Popup Service | Host for dynamically created popups | ng-hub-ui-modal, ng-hub-ui-portal |
897
- | Transitions | Smooth animations | ng-hub-ui-accordion, ng-hub-ui-modal |
898
- | Type Guards | Type validation functions | ng-hub-ui-stepper |
899
- | Pipes | Template utilities | All Hub UI components |
994
+ | Utility | Description | Used by |
995
+ | --------------- | ----------------------------------- | ------------------------------------ |
996
+ | Overlay Service | Flexible overlay positioning system | ng-hub-ui-modal, ng-hub-ui-portal |
997
+ | Focus Trap | Focus management in modals/overlays | ng-hub-ui-modal, ng-hub-ui-portal |
998
+ | Scrollbar | Scrollbar compensation | ng-hub-ui-modal, ng-hub-ui-portal |
999
+ | Popup Service | Host for dynamically created popups | ng-hub-ui-modal, ng-hub-ui-portal |
1000
+ | Transitions | Smooth animations | ng-hub-ui-accordion, ng-hub-ui-modal |
1001
+ | Type Guards | Type validation functions | ng-hub-ui-stepper |
1002
+ | Pipes | Template utilities | All Hub UI components |
900
1003
 
901
1004
  ## 🤝 Compatibility
902
1005
 
903
- - Angular 16+
904
- - TypeScript 4.8+
905
- - Node.js 16+
906
- - Browsers: Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
1006
+ - Angular 16+
1007
+ - TypeScript 4.8+
1008
+ - Node.js 16+
1009
+ - Browsers: Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
907
1010
 
908
1011
  ## 🛠️ Development
909
1012
 
@@ -977,10 +1080,10 @@ Recent highlights:
977
1080
 
978
1081
  ## 🐛 Issues and Support
979
1082
 
980
- - [Report a bug](https://github.com/hub-env/hub-ui/issues)
981
- - [Request a feature](https://github.com/hub-env/hub-ui/issues/new?template=feature_request.yml)
982
- - [Repository](https://github.com/hub-env/ng-hub-ui-utils)
983
- - **Author**: [Carlos Morcillo](https://www.carlosmorcillo.com)
1083
+ - [Report a bug](https://github.com/hub-env/hub-ui/issues)
1084
+ - [Request a feature](https://github.com/hub-env/hub-ui/issues/new?template=feature_request.yml)
1085
+ - [Repository](https://github.com/hub-env/ng-hub-ui-utils)
1086
+ - **Author**: [Carlos Morcillo](https://www.carlosmorcillo.com)
984
1087
 
985
1088
  ## ☕ Support the Project
986
1089
 
@@ -991,10 +1094,10 @@ If Hub UI has been useful to you, consider supporting its development:
991
1094
 
992
1095
  Your support helps to:
993
1096
 
994
- - 🚀 Keep the project active
995
- - 🐛 Fix bugs faster
996
- - ✨ Develop new features
997
- - 📚 Improve documentation
1097
+ - 🚀 Keep the project active
1098
+ - 🐛 Fix bugs faster
1099
+ - ✨ Develop new features
1100
+ - 📚 Improve documentation
998
1101
 
999
1102
  ## 🤝 Contributions
1000
1103