Skip to main content

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

DirectiveUse when you need to...
uppDataidAssign a normalised, deterministic data-id attribute to an element for end-to-end testing or automation.
uppTouchListen to touch and click events with better performance than standard Angular event bindings.
uppVisibleDetect 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

PropertyDirectionTypeDefaultDescription
uppDataid@Inputstring | nullnullThe human-readable text to normalise. The normalised result is bound to data-id. Passing null removes the attribute.

How It Works

  1. The directive implements OnChanges.
  2. When the uppDataid input changes, it runs the value through GenericUtils.Normalize().
  3. The normalised result is bound to attr.data-id via @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

PropertyTypeFires On
TouchClickEventEmitter<MouseEvent>click event on the host element.
TouchStartEventEmitter<TouchEvent>touchstart event.
TouchLeaveEventEmitter<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

  1. Implements AfterViewInit.
  2. In ngAfterViewInit, calls NgZone.runOutsideAngular() to register native event listeners with { passive: true } on the host element.
  3. When an event fires, the handler calls NgZone.run() to re-enter Angular's zone before emitting the event through the appropriate EventEmitter.
  4. 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 the EventEmitter has no subscribers.

Event Mapping

Native DOM Event(s)Directive Output
clickTouchClick
touchstartTouchStart
touchend, touchcancel, touchleave, touchmoveTouchLeave

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

PropertyTypeDefaultDescription
thresholdnumber0.1Intersection 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

PropertyTypeDescription
visibilityChangeEventEmitter<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

PropertyTypeDescription
isVisiblebooleanThe current visibility state. Can be read imperatively if you have a reference to the directive.

How It Works

  1. Implements AfterViewInit and OnDestroy.
  2. After the view initialises, creates an IntersectionObserver with the configured threshold and starts observing the host element.
  3. Performs an immediate manual check using getBoundingClientRect() in the next microtask (setTimeout(0)) to handle elements that are already visible on first render.
  4. On each intersection callback, compares entry.isIntersecting to the stored state. If it changed, updates _isvisible and emits visibilityChange.
  5. 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.


  • UppVisibleControlComponent wraps VisibleDirective with automatic change-detection detach/reattach.
  • UppThumbComponent uses uppTouch internally for its tap and long-press handling. UppImageComponent uses uppDataid for the file upload button.
  • Overview -- For the full list of directives, components, and services exported by upp-wdgt.