Directives
The upp-wdgt library provides three utility directives that address cross-cutting concerns: test automation identifiers, performant touch/click handling, and viewport visibility detection. Unlike components, these directives attach behaviour to existing host elements without introducing new DOM nodes.
These directives exist because their concerns recur across the entire widget library and application code:
- Every interactive element needs a deterministic test ID for end-to-end automation.
- Touch and click events must be handled outside Angular's zone to avoid unnecessary change detection on high-frequency events.
- Many components benefit from knowing whether they are visible in the viewport so they can defer expensive work until the user can actually see them.
When to Use
| Directive | Use when you need to... |
|---|---|
uppDataid | Assign a normalised, deterministic data-id attribute to an element for end-to-end testing or automation. |
uppTouch | Listen to touch and click events with better performance than standard Angular event bindings. |
uppVisible | Detect whether an element is visible in the viewport, for lazy loading, analytics, or performance optimisation. |
DataIdDirective (uppDataid)
Purpose
Assigns a normalised string to the host element's data-id attribute. This provides a stable, predictable identifier that automated tests (e.g. Cypress, Playwright) can use to locate elements regardless of styling or structural changes.
The normalisation is performed by GenericUtils.Normalize() from @unpispas/upp-defs, which typically lowercases the string, replaces spaces with hyphens, and strips special characters.
Selector
[uppDataid]
API
| Property | Direction | Type | Default | Description |
|---|---|---|---|---|
uppDataid | @Input | string | null | null | The human-readable text to normalise. The normalised result is bound to data-id. Passing null removes the attribute. |
How It Works
- The directive implements
OnChanges. - When the
uppDataidinput changes, it runs the value throughGenericUtils.Normalize(). - The normalised result is bound to
attr.data-idvia@HostBinding.
Usage Examples
Static label
<ion-button uppDataid="Save Product">Save</ion-button>
<!-- Renders: <ion-button data-id="save-product">Save</ion-button> -->
Dynamic label from data
<ion-item *ngFor="let cat of categories" [uppDataid]="'category-' + cat.name">
{{ cat.name }}
</ion-item>
<!-- Renders: <ion-item data-id="category-beverages">Beverages</ion-item> -->
In automated tests (Cypress example)
cy.get('[data-id="save-product"]').click();
cy.get('[data-id="category-beverages"]').should('be.visible');
Why Not Just Use a Plain data-id Attribute?
The directive adds normalisation. Without it, each developer would need to manually ensure consistent casing and formatting. By passing the human-readable label, the directive guarantees that "Save Product", "save product", and "SAVE PRODUCT" all produce the same data-id="save-product", making test selectors reliable even if the display text changes slightly.
TouchDirective (uppTouch)
Purpose
Handles touch and click events outside Angular's change detection zone for better performance. On a POS application running on tablets, touch events fire frequently (touchstart, touchmove, touchend on every finger movement). If these were bound through Angular's template syntax, each event would trigger a full change detection cycle. uppTouch avoids this by registering native event listeners outside NgZone and only re-entering the zone when actually emitting to a subscriber.
Selector
[uppTouch]
API -- Outputs
| Property | Type | Fires On |
|---|---|---|
TouchClick | EventEmitter<MouseEvent> | click event on the host element. |
TouchStart | EventEmitter<TouchEvent> | touchstart event. |
TouchLeave | EventEmitter<TouchEvent> | touchend, touchcancel, touchleave, or touchmove events. All four are consolidated into a single output because they all represent "the touch gesture has ended or left the element". |
How It Works
- Implements
AfterViewInit. - In
ngAfterViewInit, callsNgZone.runOutsideAngular()to register native event listeners with{ passive: true }on the host element. - When an event fires, the handler calls
NgZone.run()to re-enter Angular's zone before emitting the event through the appropriateEventEmitter. - If the consumer does not bind to an output (e.g. no
(TouchStart)in the template), the event still fires but Angular's zone is not entered unnecessarily because theEventEmitterhas no subscribers.
Event Mapping
| Native DOM Event(s) | Directive Output |
|---|---|
click | TouchClick |
touchstart | TouchStart |
touchend, touchcancel, touchleave, touchmove | TouchLeave |
Usage Examples
Basic tap handling
<div uppTouch (TouchClick)="onTap($event)">
Tap me
</div>
Equivalent to (click)="onTap($event)" but with the performance benefit of zone-less event registration.
Long-press detection pattern
<div uppTouch
(TouchStart)="onPressStart()"
(TouchLeave)="onPressEnd()">
Press and hold
</div>
private pressTimer: any;
onPressStart() {
this.pressTimer = setTimeout(() => {
this.handleLongPress();
}, 800);
}
onPressEnd() {
clearTimeout(this.pressTimer);
}
This is the same pattern that UppThumbComponent uses internally for its long-press feature. If you need long-press on a custom element, use uppTouch directly.
Combined with uppDataid for testability
<div uppTouch uppDataid="product-card"
(TouchClick)="openProduct()"
(TouchStart)="startHighlight()"
(TouchLeave)="endHighlight()">
Product Card
</div>
When to Use Standard (click) Instead
For simple buttons that fire infrequently and where performance is not a concern, standard Angular (click) binding is simpler and sufficient. Use uppTouch on elements that appear in large lists (grid items, table rows) or on surfaces that receive continuous touch interaction (drag handles, swipe areas).
VisibleDirective (uppVisible)
Purpose
Detects whether the host element is visible in the viewport using the IntersectionObserver API. Emits a boolean event when the visibility state changes. This is the foundation for lazy-loading and performance optimisation patterns throughout the application.
Selector
[uppVisible]
API -- Inputs
| Property | Type | Default | Description |
|---|---|---|---|
threshold | number | 0.1 | Intersection ratio (0.0 to 1.0) required to consider the element "visible". The default of 0.1 means 10% of the element must be in the viewport. Use 0.5 for "half visible" or 1.0 for "fully visible". |
API -- Outputs
| Property | Type | Description |
|---|---|---|
visibilityChange | EventEmitter<boolean> | Emits true when the element enters the viewport (above the threshold), and false when it leaves. Only emits when the state actually changes, not on every intersection callback. |
Computed Properties
| Property | Type | Description |
|---|---|---|
isVisible | boolean | The current visibility state. Can be read imperatively if you have a reference to the directive. |
How It Works
- Implements
AfterViewInitandOnDestroy. - After the view initialises, creates an
IntersectionObserverwith the configuredthresholdand starts observing the host element. - Performs an immediate manual check using
getBoundingClientRect()in the next microtask (setTimeout(0)) to handle elements that are already visible on first render. - On each intersection callback, compares
entry.isIntersectingto the stored state. If it changed, updates_isvisibleand emitsvisibilityChange. - On destroy, disconnects the observer to prevent memory leaks.
Usage Examples
Lazy-load an image when visible
<div uppVisible (visibilityChange)="imageVisible = $event">
<img *ngIf="imageVisible" [src]="heavyImageUrl" />
</div>
imageVisible = false;
The <img> tag is not rendered until the container scrolls into view, saving bandwidth and rendering time.
Track element visibility for analytics
<div uppVisible [threshold]="0.5" (visibilityChange)="onBannerVisible($event)">
<div class="promotional-banner">Special Offer</div>
</div>
onBannerVisible(visible: boolean) {
if (visible) {
this.analytics.trackImpression('special-offer');
}
}
The threshold of 0.5 ensures the impression is only tracked when at least half the banner is visible.
Custom threshold for full visibility
<div uppVisible [threshold]="1.0" (visibilityChange)="fullyVisible = $event">
Content that must be fully in view
</div>
Relationship with UppVisibleControlComponent
The VisibleDirective is used internally by UppVisibleControlComponent. The component wraps the directive and adds automatic change-detection detach/reattach behaviour: when the element leaves the viewport, ChangeDetectorRef.detach() is called to stop checking the component tree, and when it re-enters, reattach() resumes normal detection. This is a significant performance optimisation for screens with many off-screen components, such as long scrollable lists.
Common Patterns
Combining directives
All three directives can be applied to the same element:
<div
uppDataid="product-tile"
uppTouch
uppVisible
(TouchClick)="selectProduct()"
(visibilityChange)="onProductVisible($event)">
{{ product.name }}
</div>
This gives you a test ID, performant touch handling, and visibility tracking on a single element.
Directive usage inside custom components
If you are building a custom widget that needs visibility detection internally, inject VisibleDirective or apply uppVisible in the component template. For touch handling, apply uppTouch to the clickable surface within your component template rather than on the host element, because the directive needs access to the native element at AfterViewInit time.
Test automation strategy
Apply uppDataid to every interactive element (buttons, links, form fields, list items) and use the normalised data-id as the primary selector in end-to-end tests. This decouples tests from CSS classes and component structure, making them resilient to UI refactors.
Related Components
UppVisibleControlComponentwrapsVisibleDirectivewith automatic change-detection detach/reattach.UppThumbComponentusesuppTouchinternally for its tap and long-press handling.UppImageComponentusesuppDataidfor the file upload button.- Overview -- For the full list of directives, components, and services exported by
upp-wdgt.