feat(utilities): add multiple injectors and documentation for enhance… - #680
feat(utilities): add multiple injectors and documentation for enhance…#680eneajaho wants to merge 1 commit into
Conversation
…d reactive features Added new utilities including `inject-bluetooth`, `inject-element-bounding`, `inject-element-visibility` and others to the `tsconfig.base.json`. Comprehensive documentation was also added to provide guidance on their usage.
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx affected --target=lint --parallel=3 |
❌ Failed | 1m 12s | View ↗ |
nx-cloud record -- nx format:check |
✅ Succeeded | 4s | View ↗ |
☁️ Nx Cloud last updated this comment at 2026-03-29 20:48:08 UTC
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive suite of reactive injectors ported from VueUse, including utilities for Bluetooth, Clipboard, Color Mode, Element Bounding, Media Controls, and more. Each new utility is accompanied by documentation and unit tests. The review feedback identifies critical errors in documentation examples regarding the timing of viewChild access, suggests optimizing internal implementations by replacing polling with reactive effects, and recommends API enhancements for better consistency and support for dynamic content.
| export class ScrollTrackerComponent { | ||
| targetElement = viewChild.required<ElementRef>('target'); | ||
| isVisible = injectElementVisibility({ | ||
| element: this.targetElement().nativeElement, | ||
| }); | ||
| } |
There was a problem hiding this comment.
This example is incorrect. viewChild.required results are not available during property initialization, so this.targetElement() will throw a runtime error. This incorrect pattern is used in several other examples in this file (ScrollContainerComponent, InfiniteScrollComponent, StickyNavComponent).
All these examples need to be corrected to initialize injectElementVisibility only after the view children are available (e.g., in ngAfterViewInit or by making the element option reactive).
| export class DynamicElementComponent { | ||
| showElement = signal(false); | ||
| elementRef = signal<ElementRef | undefined>(undefined); | ||
|
|
||
| size = injectElementSize(this.elementRef, { | ||
| initialSize: { width: 0, height: 0 }, | ||
| }); | ||
|
|
||
| toggleElement() { | ||
| this.showElement.update((v) => !v); | ||
| } | ||
| } |
There was a problem hiding this comment.
This example for dynamic elements is non-functional because the elementRef signal is declared but never updated with the element reference. To fix this, you can use viewChild to get the element and a computed signal to pass it to injectElementSize.
Also, signal, viewChild, and computed should be imported from @angular/core.
| export class DynamicElementComponent { | |
| showElement = signal(false); | |
| elementRef = signal<ElementRef | undefined>(undefined); | |
| size = injectElementSize(this.elementRef, { | |
| initialSize: { width: 0, height: 0 }, | |
| }); | |
| toggleElement() { | |
| this.showElement.update((v) => !v); | |
| } | |
| } | |
| export class DynamicElementComponent { | |
| showElement = signal(false); | |
| dynamicElement = viewChild<ElementRef>('dynamicElement'); | |
| elementRef = computed(() => this.dynamicElement()); | |
| size = injectElementSize(this.elementRef, { | |
| initialSize: { width: 0, height: 0 }, | |
| }); | |
| toggleElement() { | |
| this.showElement.update((v) => !v); | |
| } | |
| } |
| <div #target>Content</div> | ||
| `, | ||
| }) | ||
| export class ExampleComponent { |
There was a problem hiding this comment.
This example uses signal and the ngAfterViewInit lifecycle hook, but the code snippet doesn't show the import for signal or the class implementing AfterViewInit. For clarity and correctness, please update the example to include these.
import { Component, ElementRef, signal, viewChild, AfterViewInit } from '@angular/core';
// ...
export class ExampleComponent implements AfterViewInit {
// ...
}| @Component({ | ||
| selector: 'app-element-size', | ||
| template: ` | ||
| <div #myElement>Content</div> | ||
| <p>Width: {{ size.width() }}px</p> | ||
| <p>Height: {{ size.height() }}px</p> | ||
| `, | ||
| }) |
There was a problem hiding this comment.
The @Component decorator is missing standalone: true. All other component examples in this document are standalone, so this seems to be an omission that would prevent the code from working as-is.
| @Component({ | |
| selector: 'app-element-size', | |
| template: ` | |
| <div #myElement>Content</div> | |
| <p>Width: {{ size.width() }}px</p> | |
| <p>Height: {{ size.height() }}px</p> | |
| `, | |
| }) | |
| @Component({ | |
| selector: 'app-element-size', | |
| standalone: true, | |
| template: ` | |
| <div #myElement>Content</div> | |
| <p>Width: {{ size.width() }}px</p> | |
| <p>Height: {{ size.height() }}px</p> | |
| `, | |
| }) |
|
|
||
| `injectElementVisibility` tracks whether an element is visible in the viewport and returns a signal that updates whenever the visibility state changes. | ||
|
|
||
| ### Basic usage with ElementRef |
|
|
||
| // Check if Clipboard API with ClipboardItem is supported | ||
| const isSupported = computed( | ||
| () => navigator != null && 'clipboard' in navigator, |
There was a problem hiding this comment.
The isSupported check only verifies navigator.clipboard. Since this utility is specifically for ClipboardItem, it would be more accurate to also check for the existence of ClipboardItem in the global scope to ensure full functionality is available.
| () => navigator != null && 'clipboard' in navigator, | |
| () => navigator != null && 'clipboard' in navigator && typeof ClipboardItem !== 'undefined', |
| if (typeof target === 'function') { | ||
| // Watch for element changes | ||
| new Observable<HTMLElement | null>((subscriber) => { | ||
| const checkElement = () => { | ||
| const element = getElement(); | ||
| subscriber.next(element); | ||
| }; | ||
|
|
||
| // Initial check | ||
| checkElement(); | ||
|
|
||
| // Use effect to watch signal changes - but we need to do this outside Observable | ||
| // So we'll use a different approach with an interval or manual tracking | ||
| const interval = setInterval(checkElement, 100); | ||
|
|
||
| return () => { | ||
| clearInterval(interval); | ||
| }; | ||
| }) | ||
| .pipe(takeUntilDestroyed()) | ||
| .subscribe((element) => { | ||
| // Disconnect previous observations | ||
| resizeObserver.disconnect(); | ||
|
|
||
| if (element) { | ||
| // Update to current size immediately | ||
| if ('offsetWidth' in element) { | ||
| width.set((element as HTMLElement).offsetWidth); | ||
| } | ||
| if ('offsetHeight' in element) { | ||
| height.set((element as HTMLElement).offsetHeight); | ||
| } | ||
| resizeObserver.observe(element, { box }); | ||
| } else { | ||
| width.set(initialSize.width); | ||
| height.set(initialSize.height); | ||
| } | ||
| }); |
There was a problem hiding this comment.
When the target is a signal, the current implementation uses setInterval for polling, which is inefficient. Since injectElementSize runs within an injection context, you can use an effect to react to changes in the target signal. This is more efficient and idiomatic for Angular Signals.
if (typeof target === 'function') {
// Watch for element changes with an effect
effect((onCleanup) => {
const element = getElement();
if (element) {
resizeObserver.observe(element, { box });
} else {
width.set(initialSize.width);
height.set(initialSize.height);
}
onCleanup(() => resizeObserver.disconnect());
});
}| export interface InjectElementVisibilityOptions { | ||
| /** | ||
| * Custom injector instance for dependency injection. | ||
| */ | ||
| injector?: Injector; | ||
| /** | ||
| * The element to track visibility for. If not provided, will attempt to inject ElementRef. | ||
| */ | ||
| element?: Element | ElementRef<Element>; | ||
| /** | ||
| * Custom window instance. Useful for testing or iframe scenarios. | ||
| */ | ||
| window?: Window; | ||
| /** | ||
| * Initial value for visibility. | ||
| * | ||
| * @default false | ||
| */ | ||
| initialValue?: boolean; | ||
| /** | ||
| * The element that is used as the viewport for checking visibility of the target. | ||
| */ | ||
| scrollTarget?: Element | null; | ||
| /** | ||
| * Margin around the root. Can have values similar to the CSS margin property. | ||
| */ | ||
| rootMargin?: string; | ||
| /** | ||
| * Either a single number or an array of numbers which indicate at what percentage | ||
| * of the target's visibility the observer's callback should be executed. | ||
| * | ||
| * @default 0 | ||
| */ | ||
| threshold?: number | number[]; | ||
| /** | ||
| * Stop tracking when element visibility changes for the first time | ||
| * | ||
| * @default false | ||
| */ | ||
| once?: boolean; | ||
| } |
There was a problem hiding this comment.
The element option in InjectElementVisibilityOptions only accepts Element | ElementRef<Element>, but not a Signal. This makes it difficult to use with elements that are not immediately available (e.g., inside an @if block), and is inconsistent with other injectors in ngxtension like injectElementBounding.
Consider updating this injector to also accept a Signal<Element | ElementRef<Element> | undefined> for the element option to improve its usability with dynamic content.

…d reactive features
Added new utilities including
inject-bluetooth,inject-element-bounding,inject-element-visibilityand others to thetsconfig.base.json. Comprehensive documentation was also added to provide guidance on their usage.TODO
[ ] - Mention vueuse on all the utilties that were ported
[ ] - cleanup some utils and dx usage
[ ] - add more tests