ng-hub-ui-utils
v22.19.0
Published
Shared utilities for the ng-hub-ui family: standalone pipes, an overlay and popup system, tooltip controller, focus trap, scrollbar compensation, transitions, drag-and-drop helpers, colour utilities and a lightweight i18n layer. Ships no visual components
Downloads
2,725
Maintainers
Readme
ng-hub-ui-utils
Common utilities library for Angular, fundamental support for the Hub UI ecosystem.
Español | English
📝 Description
ng-hub-ui-utils is the foundational utilities library for the entire Hub UI ecosystem. It provides a curated set of framework-agnostic helper functions, type guards, standalone Angular pipes, a flexible overlay/popup system, focus-trap and accessibility helpers, scrollbar compensation, a smooth transition engine, and a lightweight internationalization (i18n) system. It ships no visual components — instead it powers the shared low-level behavior used across the rest of the ng-hub-ui libraries, while remaining tree-shakable so you only bundle what you import.
📑 Table of Contents
- Description
- Documentation and Live Examples
- Library Family
ng-hub-ui - Inspiration
- Features
- Installation
- Quick Start
- Internationalization (i18n)
- Utilities API
- Support Components
- Compatibility
- Development
- Testing
- Changelog
- Issues and Support
- Support the Project
- Contributions
- License
📚 Documentation and Live Examples
This package is part of Hub UI, a collection of Angular component libraries for standalone apps.
- Docs: https://hubui.dev/en/utils/overview/
- Live examples: https://hubui.dev/en/utils/examples/
- Hub UI: https://hubui.dev/en/
🧩 Library Family ng-hub-ui
This library is part of the ng-hub-ui ecosystem:
- ng-hub-ui-accordion (deprecated — use ng-hub-ui-panels)
- ng-hub-ui-action-sheet
- ng-hub-ui-avatar
- ng-hub-ui-board
- ng-hub-ui-breadcrumbs
- ng-hub-ui-calendar
- ng-hub-ui-dropdown
- ng-hub-ui-ds
- ng-hub-ui-forms
- ng-hub-ui-history
- ng-hub-ui-milestones
- ng-hub-ui-modal
- ng-hub-ui-nav
- ng-hub-ui-paginable
- ng-hub-ui-panels
- ng-hub-ui-portal
- ng-hub-ui-skeleton
- ng-hub-ui-sortable
- ng-hub-ui-spreadsheet
- ng-hub-ui-stepper
- ng-hub-ui-utils ← You are here
💡 Inspiration
This utilities library emerged from the need to provide common, reusable, and optimized support functions for the entire Hub UI ecosystem. Inspired by best practices in Angular development and internal utilities from libraries like Angular Bootstrap and Material Design, it provides essential tools for developing modern UI components.
✨ Features
🔧 Focus Management and Accessibility
Advanced utilities for focus handling, focus trapping, and keyboard navigation.
import { getFocusableBoundaryElements, FOCUSABLE_ELEMENTS_SELECTOR } from 'ng-hub-ui-utils';
// Get focusable elements in a container
const [firstElement, lastElement] = getFocusableBoundaryElements(containerElement);
// Create a focus trap in a modal
const focusTrap = hubFocusTrap(ngZone, modalElement, stopFocusTrap$);🪟 Overlay Service
Advanced system for creating overlays and floating components with flexible positioning.
import { OverlayService, OverlayConfig } from 'ng-hub-ui-utils';
@Component({
selector: 'app-example'
})
export class ExampleComponent {
constructor(private overlayService: OverlayService) {}
openOverlay(elementRef: ElementRef) {
// Create overlay with configuration
const overlayRef = this.overlayService.create({
hasBackdrop: true,
backdropClass: 'custom-backdrop'
});
// Configure position strategy
const positionStrategy = this.overlayService
.position()
.flexibleConnectedTo(elementRef)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top'
}
]);
// Render a component (or a TemplateRef) into the overlay; you get the host element back
const overlayElement = overlayRef.attach(MyComponent);
}
}Stacking (z-index). OverlayRef resolves its inline z-index through the design-system tokens — var(--hub-overlay-zindex, 1000) for the container and var(--hub-overlay-backdrop-zindex, 999) for the backdrop — so re-stacking an overlay (e.g. a dropdown above a modal) is a plain CSS override, no !important needed. For a single instance, pass an explicit layer instead:
this.overlayService.create({ zIndex: 1100 }); // OverlayConfig.zIndex: number | string — takes precedence over the token🎯 Popup Service
PopupService<T> hosts a dynamically created popup component and runs its show/hide
transition. It is a concrete class — subclass it when the popup needs its own API, as
below, or provide it through a factory.
import { PopupService } from 'ng-hub-ui-utils';
@Injectable()
export class MyPopupService extends PopupService<MyPopupComponent> {
constructor() {
super(MyPopupComponent);
}
openPopup(content?: string | TemplateRef<any>) {
const { windowRef, transition$ } = super.open(content, {}, true);
return { windowRef, transition$ };
}
}📜 Scrollbar Management
Intelligent scrollbar control with layout compensation.
import { ScrollBar } from 'ng-hub-ui-utils';
constructor(private scrollBar: ScrollBar) {}
openModal() {
// Hide scrollbar and compensate for space
const reverter = this.scrollBar.hide();
// On modal close, restore scrollbar
modalClose.subscribe(() => reverter());
}⚡ Transition System
Utilities for smooth animations and transitions with automatic detection.
import { hubRunTransition } from 'ng-hub-ui-utils';
// Execute transition with callback
hubRunTransition(
this.ngZone,
element,
(element, animation, context) => {
// Transition start logic
element.classList.add('transitioning');
return () => {
// Cleanup at transition end
element.classList.remove('transitioning');
};
},
{
animation: true,
runningTransition: 'continue',
context: { customData: 'value' }
}
).subscribe(() => {
console.log('Transition completed');
});🌐 Internationalization (i18n)
Lightweight, dependency-injection based translation system with a reactive pipe. Register translation dictionaries at bootstrap with provideHubTranslation, then translate keys in templates with the translate pipe.
import { provideHubTranslation, HubTranslationService, TranslatePipe } from 'ng-hub-ui-utils';
// In your application config / bootstrap providers
bootstrapApplication(AppComponent, {
providers: [
provideHubTranslation({
language: 'en',
fallbackLanguage: 'en',
dictionaries: {
en: { greeting: 'Hello {name}!' },
es: { greeting: '¡Hola {name}!' }
}
})
]
});@Component({
standalone: true,
imports: [TranslatePipe],
template: `
<!-- Simple key -->
<p>{{ 'greeting' | translate }}</p>
<!-- With interpolation params -->
<p>{{ 'greeting' | translate: { name: 'Carlos' } }}</p>
`
})
export class ExampleComponent {}See Internationalization (i18n) for the full API.
🧰 Standalone Angular Pipes
Complete set of utility pipes for validation, transformation, and data manipulation.
import { GetPipe, IsStringPipe, IsObjectPipe, IsObservablePipe, UcfirstPipe, UnwrapAsyncPipe } from 'ng-hub-ui-utils';
@Component({
standalone: true,
imports: [GetPipe, IsStringPipe, UcfirstPipe, UnwrapAsyncPipe],
template: `
<!-- Safe nested property access -->
<p>{{ user | get: 'address.city' : 'Unknown' }}</p>
<!-- Capitalize first letter -->
<h1>{{ title | ucfirst }}</h1>
<!-- Type checking in templates -->
@if (value | isString) {
<span>It's a string: {{ value }}</span>
}
<!-- Unwrap Observable or direct value -->
<div>{{ observableOrValue | unwrapAsync }}</div>
`
})
export class ExampleComponent {
user = { address: { city: 'New York' } };
title = 'hello world';
value: any = 'test';
observableOrValue = of('Observable value');
}Available Pipes:
- GetPipe (
get): Safe nested property access with default values - IsStringPipe (
isString): Check if value is a string - IsObjectPipe (
isObject): Check if value is an object - IsObservablePipe (
isObservable): Check if value is an Observable - UcfirstPipe (
ucfirst): Capitalize first letter of a string - UnwrapAsyncPipe (
unwrapAsync): Unwrap Observable or return direct value
🛠️ General Utility Functions
Complete set of helpers for validation, transformation, and data manipulation.
import {
toInteger,
toString,
getValueInRange,
isString,
isNumber,
isInteger,
isDefined,
isPromise,
padNumber,
regExpEscape,
closest,
reflow,
removeAccents,
getActiveElement
} from 'ng-hub-ui-utils';
// Safe conversions
const numValue = toInteger('42'); // 42
const strValue = toString(null); // ''
// Type validations
if (isString(value)) {
/* ... */
}
if (isPromise(result)) {
/* ... */
}
// DOM manipulation
const parent = closest(element, '.container');
reflow(element); // Force browser reflow
// String utilities
const clean = removeAccents('niño'); // "nino"
const escaped = regExpEscape('hello?'); // "hello\\?"
// Focus management
const activeEl = getActiveElement(); // Includes shadow DOM🎯 Full TypeScript Support
Strict typing throughout the library with well-defined interfaces and types.
// Transition types
type TransitionStartFn<T> = (element: HTMLElement, animation: boolean, context: T) => TransitionEndFn | void;
interface TransitionOptions<T> {
animation: boolean;
runningTransition: 'continue' | 'stop';
context?: T;
}
// Scrollbar reverter type
type ScrollbarReverter = () => void;⚡ Optimized Tree-shaking
Import only the utilities you need to optimize your bundle.
// Specific imports
import { toInteger, isString } from 'ng-hub-ui-utils';
import { ScrollBar } from 'ng-hub-ui-utils';
import { hubRunTransition } from 'ng-hub-ui-utils';
import { GetPipe, UcfirstPipe } from 'ng-hub-ui-utils';🏷️ Tooltip Directive
Add a lightweight, themeable tooltip to any element with the HubTooltipDirective
([hubTooltip]). The tooltip is appended to <body> (never clipped) and shows on
hover/focus.
import { HubTooltipDirective } from 'ng-hub-ui-utils';
@Component({
standalone: true,
imports: [HubTooltipDirective],
template: `<button hubTooltip="Save changes" hubTooltipPlacement="top">Save</button>`
})
export class ExampleComponent {}Styles (required since 22.4.0). The tooltip no longer injects its CSS at runtime — import its stylesheet once in your app (e.g.
styles.scss), the same way asoverlay:@use 'ng-hub-ui-utils/styles/tooltip';
Inputs: hubTooltip (text), hubTooltipPlacement (top | bottom | left | right,
default top), hubTooltipDelay (fade ms, default 150), hubTooltipOffset (px, default 8).
The placement is a preference, not an instruction (since 22.16.0). A tooltip that would open
off the edge of the window opens on the opposite side instead, at all four edges, and one centred
on a host near the inline edge is slid back inside rather than flipped — flipping does nothing for
an overflow on the cross axis. It gives way only when it genuinely has no room, so a tooltip that
did not need to move does not, and the fit is re-run while the label is open, on window resize and
on a scroll anywhere above it. Under RTL the inline edge is the other one and the flip follows it,
while hubTooltipPlacement="left" still means the host's left edge.
If you have been writing hubTooltipPlacement="bottom" by hand on every hint in a header, you can
stop: the attribute is still honoured wherever it fits, but it is no longer what keeps the label
on screen. The class on the bubble (hub-tooltip--top, --bottom, --left, --right) names the
side it ended up on, so an arrow styled off it follows the flip.
TooltipDirective([tooltip]) was removed in 22.14.0, having been deprecated since 22.9.0. Its bare input names (tooltip,placement,delay,offset) belonged to every directive on the element that declared them, which is how it collided with[hubDropdown]'s ownplacementand with thetooltipinput of<hub-badge>. Migration is attribute for attribute:tooltip→hubTooltip,placement→hubTooltipPlacement,delay→hubTooltipDelay,offset→hubTooltipOffset. A template left writingtooltip="…"still compiles and shows nothing at all, so check the markup as well as the imports.
Show the label only while the host is truncated with HubOverflowTooltipDirective
([hubOverflowTooltip]), which tracks truncation live with a ResizeObserver and a
MutationObserver and resolves its tooltip through HUB_TOOLTIP_ADAPTER:
<span class="label" [hubOverflowTooltip]="item.label">{{ item.label }}</span>The element that is hovered and the element that is measured need not be the same.
By default they are, but a control whose text is clipped by a box inside it wants them apart:
the hover area is the whole control, while the only box that can report truncation is the
inner one — a child that clips its own text never lets the overflow reach its parent, so
measuring the parent reports none and the tooltip goes quiet. Point
hubOverflowTooltipMeasure at the inner box with a CSS selector, resolved inside the host:
<div class="chip" [hubOverflowTooltip]="item.label" hubOverflowTooltipMeasure=".chip__title">
<span class="chip__icon"></span>
<span class="chip__title">{{ item.label }}</span>
</div>Unset — or pointing at nothing — the host measures itself, exactly as before.
Theme it from any scope with --hub-tooltip-* variables:
.my-scope {
--hub-tooltip-bg: var(--hub-sys-color-primary);
--hub-tooltip-color: #fff;
--hub-tooltip-border-radius: 999px;
--hub-tooltip-opacity: 1;
}Available tokens: --hub-tooltip-bg, --hub-tooltip-color, --hub-tooltip-opacity,
--hub-tooltip-padding-x, --hub-tooltip-padding-y, --hub-tooltip-border-radius,
--hub-tooltip-font-size, --hub-tooltip-font-weight, --hub-tooltip-line-height,
--hub-tooltip-max-width, --hub-tooltip-zindex, --hub-tooltip-transition-duration,
--hub-tooltip-shadow, --hub-tooltip-font-family, --hub-tooltip-white-space,
--hub-tooltip-text-align.
The last two arrived in 22.10.0, for the tooltip that carries a sentence rather than a
name: they are set on the host, which is the only element you can reach, because the
bubble itself lives on <body>, outside every component's styles.
Tooltip adapter for other libraries (hubTooltipAdapter)
The same tooltip engine is exposed as a framework-agnostic adapter so sibling libraries can offer the hub-ui tooltip without hard-depending on this package. Wire it into their optional tooltip token — e.g. for badges or breadcrumbs:
import { hubTooltipAdapter } from 'ng-hub-ui-utils';
import { provideHubBadgeTooltip } from 'ng-hub-ui-badges';
import { provideHubBreadcrumbTooltip } from 'ng-hub-ui-breadcrumbs';
providers: [provideHubBadgeTooltip(hubTooltipAdapter), provideHubBreadcrumbTooltip(hubTooltipAdapter)];Inside this package the same token works the other way round: [hubOverflowTooltip]
resolves its tooltip through HUB_TOOLTIP_ADAPTER, which defaults to hubTooltipAdapter.
Swap it app-wide, or for one subtree, with provideHubTooltip():
import { provideHubTooltip, HubTooltipAdapter } from 'ng-hub-ui-utils';
const myTooltip: HubTooltipAdapter = {
attach(host, text, options) {
/* … returns a HubTooltipHandle with update(text) and destroy() */
}
};
providers: [provideHubTooltip(myTooltip)];Also available: the imperative HubTooltipController (engine) and the
HubTooltipAdapter / HubTooltipHandle / HubTooltipOptions / HubTooltipPlacement
types. See the ecosystem-wide
Synergies & agnosticism section.
🔢 Grid Primitives
The arithmetic a sheet of cells is made of, with no DOM and no framework in it. A grid has a
handful of problems every implementation solves again and gets wrong in the same places: where the
cursor goes at an edge, what rectangle two corners describe, which cell a merged block swallowed,
and which tracks are worth drawing at all. ng-hub-ui-spreadsheet is built on these, which is how
it installs without @angular/cdk.
import {
resolveGridIntent,
moveGridFocus,
gridRangeBetween,
buildSpanMap,
anchorOf,
gridWindow,
growWindowToSpans
} from 'ng-hub-ui-utils';
// A key read as an intent, so the grid does not grow a switch statement per browser. The
// roving tab stop is `gridCellTabIndex(cell, active, 'roving')`.
const intent = resolveGridIntent(event, { pageSize: 20 });
// The cursor, with the three different answers at the end of a row told apart.
const next = moveGridFocus(cursor, { row: 0, col: 1 }, bounds, { horizontal: 'continuous' });
// A selection kept as two corners, so ten thousand cells cost nothing.
const range = gridRangeBetween(anchor, cursor);
// Merged blocks: the cursor lands on the block and never inside it.
const spans = buildSpanMap([{ row: 4, col: 0, rowSpan: 3, colSpan: 2 }]);
const landing = anchorOf(spans, cursor);
// Only what the viewport covers, with the pixels the rest would have taken, so the scrollbar
// still tells the truth. Then widened until every block it touches is drawn whole.
const window = growWindowToSpans(
gridWindow({ offset: scrollTop, viewport: height, sizes: 32, count: rows.length, pinned: 1 }),
blocks,
'row',
32,
{ count: rows.length, pinned: 1 }
);Also exported: resolveGridEdge, isWithinGridRange, gridRangeCells, gridRangeSize,
clampGridRange, spanAt, isCovered, coversRange, gridCellTabIndex, and the types
HubGridCoords, HubGridBounds, HubGridRange, HubGridSpan, HubGridSpanMap, HubGridWrap,
HubGridEdge, HubGridIntent, HubGridWindow and HubGridTrackSizes.
Also exported for a selection of several rectangles: isWithinGridSelection, addGridRange,
gridSelectionCells, gridSelectionSize, gridSelectionBounds, clampGridSelection and
gridSelectionTable — the last of which decides whether a disjoint selection can be copied at all
(it can when the rectangles line up, and returns null when there is no honest table to write).
🚀 Installation
npm install ng-hub-ui-utils
# or
yarn add ng-hub-ui-utils📖 Quick Start
// Import specific utilities
import { toInteger, isString, ScrollBar, getFocusableBoundaryElements, GetPipe, UcfirstPipe } from 'ng-hub-ui-utils';
@Component({
selector: 'app-example',
standalone: true,
imports: [GetPipe, UcfirstPipe],
template: `
<div #container>
<h1>{{ title | ucfirst }}</h1>
<p>{{ user | get: 'name' : 'Anonymous' }}</p>
</div>
`
})
export class ExampleComponent {
constructor(private scrollBar: ScrollBar) {}
@ViewChild('container') containerElement!: ElementRef<HTMLElement>;
title = 'welcome';
user = { name: 'John Doe' };
ngAfterViewInit() {
// Get focusable elements
const [first, last] = getFocusableBoundaryElements(this.containerElement.nativeElement);
// Safe conversion
const value = toInteger('42');
if (isString(this.title)) {
console.log("It's a string");
}
}
openOverlay() {
// Hide scrollbar during overlay
const reverter = this.scrollBar.hide();
// Restore on close
this.overlayRef.onClose(() => reverter());
}
}🌐 Internationalization (i18n)
The i18n system (available since v1.2.0) lets you register translation dictionaries via dependency injection and resolve keys reactively in templates. Updating the active translations at runtime automatically refreshes any translate pipe in the view.
provideHubTranslation(config?)
Environment provider helper that registers HubTranslationService and its configuration. Call it once in your application bootstrap providers.
provideHubTranslationAdapter(factory)
Use this provider when the application owns translations through Transloco, ngx-translate or another reactive service. The factory runs once in Angular's injection context and returns an observable-like source of complete dictionaries. Every Hub UI library using HubTranslationService receives language changes without component-level subscriptions.
function provideHubTranslation(config?: HubTranslationConfig): EnvironmentProviders;HubTranslationConfig
interface HubTranslationConfig {
/** Map of language code → translation dictionary. */
dictionaries?: Record<string, Record<string, any>>;
/** Active language code (defaults to fallbackLanguage, then 'en'). */
language?: string;
/** Fallback language merged under the active language (defaults to 'en'). */
fallbackLanguage?: string;
}The configuration is also exposed through the HUB_TRANSLATION_CONFIG injection token for advanced scenarios.
HUB_TRANSLATION_PREFIX
Injection token that scopes a library's lookups to a collision-safe HUBUI.<LIBRARY>.*
namespace. TranslatePipe resolves the prefixed key first and falls back to the bare key,
so a flat dictionary that predates the token keeps working untouched.
providers: [{ provide: HUB_TRANSLATION_PREFIX, useValue: 'HUBUI.TABLE' }];The adapter types are exported alongside it: HubTranslationSource,
HubTranslationOverrides, HubTranslationAdapterConfig, HubTranslationAdapterFactory
and the HUB_TRANSLATION_SOURCE token provideHubTranslationAdapter() registers.
HubTranslationService
Injectable service that holds the active translations and notifies subscribers when they change.
@Injectable()
class HubTranslationService {
/** Currently active flat translations map. */
translations: Record<string, string>;
/** Emits whenever the active translations are updated. */
translationObserver: Observable<Record<string, string>>;
/** Resolves a key (supports dot notation) against the active translations. */
getTranslation(key: string): any;
/** Replaces the active translations, merging them over the fallback dictionary. */
setTranslations(translations?: Record<string, string>): void;
}import { HubTranslationService } from 'ng-hub-ui-utils';
@Component({/* ... */})
export class LanguageSwitcherComponent {
private translationSvc = inject(HubTranslationService);
switchToSpanish() {
// Swap the active dictionary at runtime; the `translate` pipe updates automatically.
this.translationSvc.setTranslations({ greeting: '¡Hola {name}!' });
}
}TranslatePipe (translate)
Impure standalone pipe that resolves a translation key with optional interpolation params. It subscribes to the service so the view stays in sync when translations change.
// Simple key
{{ 'greeting' | translate }}
// With an object of interpolation params
{{ 'greeting' | translate: { name: 'Carlos' } }}
// Params can also be written inline as a pseudo-object string
{{ 'greeting' | translate: "{name: 'Carlos'}" }}If a key has no matching translation, the key itself is returned. Interpolation tokens use the {paramName} syntax (powered by the interpolateString utility).
Supporting utilities
These functions back the i18n system and are exported for direct use:
getValue(target: any, key: string): any- Reads a nested value by dot-notation key.interpolateString(text: string, params?: object): string- Replaces{token}placeholders in a string.equals(o1: any, o2: any): boolean- Deep equality check used to memoize the pipe value.
📊 Utilities API
Conversion Functions
toInteger(value: any): number- Safely converts to integertoString(value: any): string- Converts to string handling null/undefinedgetValueInRange(value: number, max: number, min?: number): number- Limits value to rangepadNumber(value: number): string- Adds leading zero to numbers
Validation Functions
isString(value: any): value is string- Checks if value is a stringisNumber(value: any): value is number- Checks if value is a valid numberisInteger(value: any): value is number- Checks if value is an integerisDefined(value: any): boolean- Checks if not null/undefinedisPromise<T>(v: any): v is Promise<T>- Checks if value is a Promise
String Functions
regExpEscape(text: string): string- Escapes special characters for RegExpremoveAccents(str: string): string- Removes accents from textinterpolateString(expr?: string, params?: any, templateMatcher?: RegExp): string- Replaces{{ token }}placeholdersgenerateUniqueId(length: number): string- Random alphanumeric id, for a DOM node that needs one
Object Functions
equals(o1: any, o2: any): boolean- Deep equalitygetValue(target: any, key: string): any- Reads a nested value by dot-notation keyisObject(item: any): boolean- Whether the value is a non-array objectmergeDeep(target: any, source: any): any- Recursive merge; the only deep object helper in the package
Signal Utilities
debouncedSignal<T>(source: Signal<T>, delay?: number | Signal<number>): Signal<T>- Mirrors a signal, delaying each change; the delay can itself be a signal
DOM Functions
closest(element: HTMLElement, selector?: string): HTMLElement | null- Finds parent element by selectorreflow(element: HTMLElement): DOMRect- Forces browser reflowgetActiveElement(root?: Document | ShadowRoot): Element | null- Gets active element including Shadow DOM
Accent Resolution
resolveHubAccent(value: string | null | undefined): string | null- The "any colour" accent resolver shared across the family: a bareword becomesvar(--hub-sys-color-<name>, <name>), a literal#hex/rgb()/oklch()/var()passes through unchanged, and an empty value yieldsnull
Colour Functions
parseColor(value): HubRgb | null- Parses hex (3/4/6/8),rgb(),hsl(),oklch(),oklab(), the 148 CSS named colours andtransparent, in modern and legacy syntax. No DOM, so it runs under SSR. Returnsnull— never throws — for anything it cannot resolve,var()andcurrentColorincludedtoRgb(color): HubRgb | null- Normalises a string or parsed colour to channelstoHex(color): string | null- Renders as#rrggbb, or#rrggbbaawhen translucentisValidColor(value): boolean- Whether the parser can resolve the stringHUB_NAMED_COLORS: Readonly<Record<string, string>>- The 148 CSS named colours
Contrast Functions
relativeLuminance(color): number | null- WCAG 2 relative luminance, 0 to 1contrastRatio(a, b): number | null- WCAG 2 contrast ratio, 1 to 21contrastAPCA(text, background): number | null- APCA lightness contrast, polarity-awarecompositeOver(foreground, background): HubColor- Blends translucent over opaquereadableOn(background, metric?): string- Black or white, whichever reads better. Defaults to'lightness', the same decision--hub-sys-color-*-onmakes in CSS;'apca'and'wcag'are also availableHUB_INK_LIGHTNESS_THRESHOLD: number- The OKLCh lightness above which a surface takes dark ink
OKLCh Functions
rgbToOklch(color): HubOklch/oklchToRgb(color): HubRgb- Conversions in the space the design system mixes inmaxSrgbChroma(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 chromaisInSrgbGamut(color): boolean- Whether the colour survives the trip to sRGBclampToSrgbGamut(color): HubOklch- Reduces chroma until it fits, preserving lightness and hue
Palette Derivation
One brand colour, the whole palette — and the two numbers that keep it honest.
harmoniseSemantics(primary, options?): HubSemanticPalette | null- Rotatessuccess,warning,dangerandinfotowards 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 sRGBtintNeutrals(primary, options?): HubNeutralRamp | null- Leans the grey ramp (100…900) the same way, keeping each step's lightnessHUB_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°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-600measures 0.0165 andgray-5000.0145 — so a tinted ramp is never more colourful than the grey people already accept as greyHUB_SEMANTIC_ANCHORS/HUB_NEUTRAL_ANCHORS- The untinted starting points, so a product that harmonises nothing still gets the palette the stylesheet ships- Types:
HubSemanticRole,HubSemanticPalette,HubNeutralStep,HubNeutralRamp,HubHarmoniseOptions,HubTintNeutralsOptions
import { harmoniseSemantics, tintNeutrals } from 'ng-hub-ui-utils';
harmoniseSemantics('#6f42c1');
// { success: '#00866b', warning: '#ffbd6e', danger: '#d8336b', info: '#44c4ff' }
tintNeutrals('#6f42c1'); // greys leaning violet, chroma never above 0.015A brand with no hue of its own — a pure grey — leaves both sets untouched: OKLCh's hue on a grey is rounding noise, and harmonising towards it would rotate every role in a direction nobody chose.
Focus Functions
getFocusableBoundaryElements(element: HTMLElement): HTMLElement[]- Gets first and last focusable elementshubFocusTrap(zone, element, stopFocusTrap$, refocusOnClick?)- Creates focus trap for modals/overlaysFOCUSABLE_ELEMENTS_SELECTOR: string- CSS selector for focusable elements
Drag and Drop
The engine-agnostic half of native HTML5 drag and drop, shared by the libraries that implement it. The UI primitives — handle, placeholder and preview directives — stay in each library, because their selectors and data models differ.
HubDragDropService- Root-provided coordinator. A drag spans two component instances and the nativedataTransferpayload is unreadable duringdragover, so a shared service is the only reliable channel for what is being dragged and from where. Ownersregister()/unregister();begin(),setTarget()and the readonlyactive/target/isDraggingsignals report the drag in progress. It coordinates state only — it never mutates your collectionsmoveItemInArray<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/cdkhelpers of the same namesclamp(value, max),computeTargetIndex(...),toAbsoluteIndex(...),containsNode(...)- Index arithmetic for sliced and nested listsresolveDropPosition(...)withDropRectandDragAxis- Where a pointer sits relative to an item:'before'or'after', on a vertical, horizontal or grid axiscreateNativeDragImage(...)returningDragImageResult- Renders the drag preview the browser showscreatePointerDragSession(config: PointerDragSessionConfig): PointerDragSession- Pointer Events fallback for touch, where native drag events are not delivered- Types:
DropPosition,DragPointerMode,DragContainerRef<T>,ActiveDrag<T>,DragTarget<T>,DragRegistration
Directives
HubTooltipDirective([hubTooltip]) - Tooltip on hover/focus, flipped away from the window edges. Inputs:hubTooltip,hubTooltipPlacement,hubTooltipDelay,hubTooltipOffsetHubOverflowTooltipDirective([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)provideHubTooltip(adapter: HubTooltipAdapter)andHUB_TOOLTIP_ADAPTER- Swap the implementation behind[hubOverflowTooltip], app-wide or per subtree; defaults tohubTooltipAdapter
Pipes
GetPipe
// Safe nested property access
{{ object | get:'path.to.property':'defaultValue' }}IsStringPipe
// Type checking
@if (value | isString) { <span>String value</span> }IsObjectPipe
// Object checking
@if (value | isObject) { <span>Object value</span> }IsObservablePipe
// Observable checking
@if (stream | isObservable) { <span>Observable stream</span> }UcfirstPipe
// Capitalize first letter
{{ 'hello world' | ucfirst }} <!-- Hello world -->UnwrapAsyncPipe
// Unwrap Observable or return direct value
{
{
observableOrValue | unwrapAsync;
}
}Services
OverlayService
@Injectable({ providedIn: 'root' })
class OverlayService {
create(config?: OverlayConfig): OverlayRef;
position(): OverlayPosition;
}
class OverlayRef {
// Renders a template or a component into the overlay and returns the host element,
// not a ComponentRef: the overlay owns the view it created and tears it down itself.
attach(content: TemplateRef<unknown> | Type<unknown>, viewContainerRef?: ViewContainerRef): HTMLElement;
detach(): void;
dispose(): void;
hasAttached(): boolean;
updatePosition(): void;
onBackdropClick(callback: () => void): void;
// Only the topmost open overlay is told, so a dropdown inside a dialog takes Escape
// for itself and leaves the dialog open.
onKeydown(callback: (event: KeyboardEvent) => void): void;
}
class OverlayPosition {
// The element the panel is anchored to. The overlay watches it and follows it when it moves.
readonly origin: HTMLElement | null;
flexibleConnectedTo(origin: ElementRef | HTMLElement): this;
withPositions(positions: ConnectionPosition[]): this;
// `start` / `end` are logical and read from the origin element; this overrides that.
withDirection(direction: 'ltr' | 'rtl' | null): this;
}HUB_DROPDOWN_POSITIONS is the ready-made fallback chain for a dropdown — below the
origin, flipping above when there is no room — expressed logically so one list serves
both text directions:
import { HUB_DROPDOWN_POSITIONS } from 'ng-hub-ui-utils';
overlayService
.position()
.flexibleConnectedTo(origin)
.withPositions([...HUB_DROPDOWN_POSITIONS]);Viewport fitting (viewport-fit)
The arithmetic behind both the overlay strategy and the tooltip, exported on its own so a floating element that is neither does not have to write a third copy. It takes plain numbers, touches no DOM and runs unchanged on a server render.
import { hubAnchorToViewport, hubToAnchorSide, hubToPhysicalSide, hubViewportOf } from 'ng-hub-ui-utils';
const rtl = getComputedStyle(host).direction === 'rtl';
const placed = hubAnchorToViewport({
anchor: host.getBoundingClientRect(),
box: { width: panel.offsetWidth, height: panel.offsetHeight },
viewport: hubViewportOf(panel), // null on a server render: nothing flips
side: hubToAnchorSide('bottom', rtl), // 'block-start' | 'block-end' | 'inline-start' | 'inline-end'
align: 'start', // logical: mirrors under RTL
offset: 8,
margin: 0,
rtl
});
// placed.x / placed.y are viewport coordinates — add scrollX / scrollY for an absolutely
// positioned element. placed.side is where it ended up, placed.flipped whether that was a flip.
panel.dataset['side'] = hubToPhysicalSide(placed.side, rtl);The requested side is kept unless it genuinely cannot hold the box and the opposite side has
more room, so nothing flips for free; the cross axis is clamped rather than flipped, since a box
centred on an anchor near the inline edge overflows there whichever side it opens on. Sides are
logical so a single fallback rule serves both text directions — hubToAnchorSide and
hubToPhysicalSide round-trip a physical edge through that axis without changing what the caller
asked for.
Also exported: hubFitsInViewport(point, size, viewport, margin?),
hubClampToViewport(point, size, viewport, margin?) and hubOppositeSide(side).
ScrollBar Service
@Injectable({ providedIn: 'root' })
class ScrollBar {
hide(): ScrollbarReverter; // Hides scrollbar with compensation
}PopupService<T>
A concrete generic class, not an abstract one: it takes the popup component type in its
constructor, and it reads its collaborators with inject(), so it has to be created inside
an injection context — as an @Injectable() subclass, or from a factory provider.
class PopupService<T> {
constructor(componentType: Type<T>);
open(
content?: string | TemplateRef<any>,
templateContext?: any,
animation?: boolean
): { windowRef: ComponentRef<T>; transition$: Observable<void> };
close(animation?: boolean): Observable<void>;
}
// The nodes and view a popup projects, returned internally by the content resolver.
class ContentRef {
constructor(nodes: Node[][], viewRef?: ViewRef, componentRef?: ComponentRef<any>);
}Transition Utilities
hubRunTransition<T>(zone, element, startFn, options)- Advanced transition system with ObservablehubCompleteTransition(element)- Completes a running transition on an elementgetTransitionDurationMs(element)- Gets CSS transition duration in millisecondsrunInZone<T>(zone)- RxJS operator to execute observables inside NgZone
🎨 Support Components
This library doesn't include visual components, but support utilities used by other components in the Hub UI ecosystem:
| Utility | Description | Used by | | --------------- | ----------------------------------- | ------------------------------------ | | Overlay Service | Flexible overlay positioning system | ng-hub-ui-modal, ng-hub-ui-portal | | Focus Trap | Focus management in modals/overlays | ng-hub-ui-modal, ng-hub-ui-portal | | Scrollbar | Scrollbar compensation | ng-hub-ui-modal, ng-hub-ui-portal | | Popup Service | Host for dynamically created popups | ng-hub-ui-modal, ng-hub-ui-portal | | Transitions | Smooth animations | ng-hub-ui-accordion, ng-hub-ui-modal | | Type Guards | Type validation functions | ng-hub-ui-stepper | | Pipes | Template utilities | All Hub UI components |
🤝 Compatibility
- Angular 16+
- TypeScript 4.8+
- Node.js 16+
- Browsers: Chrome 90+, Firefox 88+, Safari 14+, Edge 90+
🛠️ Development
git clone https://github.com/hub-env/ng-hub-ui-utils
cd ng-hub-ui-utils
npm install
npm run build
npm run testAvailable Scripts
npm run build:lib # Build library
npm run test:unit # Unit tests
npm run test:e2e # End-to-end tests
npm run lint # Linting
npm run format # Format code🧪 Testing
import { TestBed } from '@angular/core/testing';
import { ScrollBar, toInteger, isString, GetPipe } from 'ng-hub-ui-utils';
describe('ng-hub-ui-utils', () => {
it('should convert values safely', () => {
expect(toInteger('42')).toBe(42);
expect(toInteger('invalid')).toBe(NaN);
expect(isString('hello')).toBe(true);
expect(isString(42)).toBe(false);
});
it('should manage scrollbar', () => {
const scrollBar = TestBed.inject(ScrollBar);
const reverter = scrollBar.hide();
expect(typeof reverter).toBe('function');
reverter(); // Cleanup
});
it('should get nested properties safely', () => {
const pipe = new GetPipe();
const obj = { user: { name: 'John' } };
expect(pipe.transform(obj, 'user.name')).toBe('John');
expect(pipe.transform(obj, 'user.age', 0)).toBe(0);
});
});📋 Changelog
All notable changes are documented in the CHANGELOG.md, following Keep a Changelog and Semantic Versioning.
Recent highlights:
- 1.2.1 — Renamed internal i18n files and refreshed
TranslatePipe; added a test suite forHubTranslationService. - 1.2.0 — Added the i18n system (
HubTranslationService,provideHubTranslation,TranslatePipe, translation tokens) plus theequals,interpolateStringandgetValueutilities.
External translation services
HubTranslationService is intentionally framework-agnostic. Applications using Transloco or ngx-translate can extract a library namespace when the language changes and pass it to setTranslations(). Libraries such as Calendar, Stepper and Paginable consume that bridge; their README files document the namespace each expects.
// Transloco: hubTranslation.setTranslations(transloco.translateObject('STEPPER'))
// ngx-translate: hubTranslation.setTranslations(translate.instant('STEPPER'))🐛 Issues and Support
☕ Support the Project
If Hub UI has been useful to you, consider supporting its development:
Your support helps to:
- 🚀 Keep the project active
- 🐛 Fix bugs faster
- ✨ Develop new features
- 📚 Improve documentation
🤝 Contributions
Contributions are welcome! Please:
- 🍴 Fork the repository
- 🌿 Create a branch for your feature (
git checkout -b feature/new-utility) - ✍️ Commit your changes (
git commit -am 'feat: add new utility') - 📤 Push to the branch (
git push origin feature/new-utility) - 🔄 Open a Pull Request
Check our contribution guidelines for more details.
💼 Commercial support
These libraries are maintained by Carlos Morcillo Fernández, a freelance frontend architect working with teams that build and maintain Angular applications.
If your team depends on Hub-UI and needs more than an issue thread can solve, that is my day job: architecture audits, design systems, Angular migrations and team mentoring. For projects that also need design and a full team, I run them through Frog Hub, my development studio.
Have a look at the services or tell me about your project.
📄 License
MIT © Hub UI contributors
MIT License
Copyright (c) 2025 Hub UI Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.⭐ If you like this project, don't forget to give it a star on GitHub!
