5.0.0 • Published 9 months ago

@lcsga/ng-operators v5.0.0

Weekly downloads
-
License
MIT
Repository
github
Last release
9 months ago

@lcsga/ng-operators

Breaking changes:

v5.0.0

  • The fromChildEvent, fromChildrenEvent, fromChildOuptut and fromChildrenOutput buildNotifier options are now replaced with injector
  • The fromHostEvent host option is now replaced with injector

v4.0.0

  • fromChildrenEvent now returns an Observable<readonly [event: Event, index: number]>

v3.0.0

  • update angular to v16 (will throw a nice error message if it's called without access to inject)

v2.0.0

  • fromChildEvent and fromChildrenEvent must now be used in an injection context (or you can now provide a new buildNotifier option if needed).

This package provides a set of custom RxJS operators, used to make declarative pattern easier to set up within an angular app.

Getting started

Installation

@lcsga/ng-operators is available for download at npm.

npm install @lcsga/ng-operators

Configuration

To work as expected, this library needs the NgZone configuration to enable the event coalescing:

export const appConfig: ApplicationConfig = {
  providers: [
    // ...
    provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }),
  ],
};
// You can also activate the runCoalescing option alongside with the OnPush detection strategy to provide better performances of your apps

Documentation

Table des matières

fromChildEvent

This operator is usefull whenever you want to listen to some @ViewChild or @ContentChild events.

fromChildEvent<T extends Event>(
  childSelector: () => ElementRef | undefined,
  type: keyof HTMLElementEventMap,
  options?: NgEventListenerOptions
): Observable<T>
argumenttypedescription
childSelector() => ElementRef \| undefinedA callback function used to get the child element to listen an event to.
typekeyof HTMLElementEventMapThe type of event to listen.
optionsNgEventListenerOptionsOptional. Default is {}.Options to pass through to the underlying addEventListener function or if you need to manualy provide the injector

Currently, when you want to avoid using the Angular's @HostListner or the (click)="doSomething()", you can create a Subject and use it like this:

@Component({
    selector: "app",
    template: "<button (click)="buttonClick$$.next()">Click me!</button>"
})
export class AppComponent {
    protected readonly buttonClick$$ = new Subject<void>();

    private readonly onButtonClick$ = this.buttonClick$$.pipe(
        tap(() => console.log("hello world!"))
    );

    constructor() {
        this.onButtonClick$.subscribe();
    }
}

It actually works pretty well but since we need to specifically call the next() method of the buttonClick$$ subject, it's not fully declarative.

To make it declarative, we would instead need to use the fromEvent operator from RxJS but we can't do that nicely because it takes an element from the dom.

Indeed to get such an element in Angular, depending on what you've built, you can either use @ViewChild or @ContentChild

@Component({
  selector: 'app',
  template: '<button #button>Click me!</button>',
})
export class AppComponent {
  @ViewChild('button')
  private readonly button?: ElementRef<HTMLButtonElement>;

  private readonly onButtonClick$ = fromEvent(this.button?.nativeElement, 'click').pipe(
    // throws an error!
    tap(() => console.log('hello world!'))
  );

  constructor() {
    this.onButtonClick$.subscribe();
  }
}

The issue with the code above is that the button element is undefined until the dom is rendered. Thus the Cannot read properties of undefined (reading 'addEventListener') error is thrown.

A solution to make it work would be to assign the stream of onButtonClick$ within the afterViewInit() method but the best part of declarative is to write the assigning right at the declaration, so it wouldn't be prefect.

Here comes the fromChildEvent custom operator, to the rescue! It works by listening the event of your choice directly on the document and check if the event's target is the same as a viewChild or a contentChild you'd pass to it

Example:
@Component({
  selector: 'app',
  template: '<button #button>Click me!</button>',
})
export class AppComponent {
  @ViewChild('button')
  private readonly button?: ElementRef<HTMLButtonElement>;

  private readonly onButtonClick$ = fromChildEvent(() => this.button, 'click').pipe(tap(() => console.log('hello world!')));

  constructor() {
    this.onButtonClick$.subscribe();
  }
}

As you can see, fromChildEvent takes a selector callback to get the viewChild or contentChild target.

Since the document's event can only be fired after the dom is rendered, we know that the element passed within the selector callback is always available.

fromChildrenEvent

It works exactly like fromChildEvent but with @ViewChildren or @ContentChildren instead!

  fromChildrenEvent<T extends Event>(
    childrenSelector: () => ElementRef[] | undefined,
    type: keyof HTMLElementEventMap,
    options?: NgEventListenerOptions
  ): Observable<readonly [event: T, index: number]>
argumenttypedescription
childrenSelector() => ElementRef[] \| undefinedA callback function used to get the children elements to listen an event to.
typekeyof HTMLElementEventMapThe type of event to listen.
optionsNgEventListenerOptionsOptional. Default is {}.Options to pass through to the underlying addEventListener function or if you need to manualy provide the injector
<br/>
Example:
@Component({
  selector: 'app',
  template: `
    <button #button>Click me!</button>

    <button #button>Click me!</button>

    <button #button>Click me!</button>
  `,
})
export class AppComponent {
  @ViewChildren('button')
  private readonly buttons?: QueryList<ElementRef<HTMLButtonElement>>;

  private readonly onButtonsClick$ = fromChildrenEvent(() => this.buttons?.toArray(), 'click').pipe(tap(() => console.log('hello world!')));

  constructor() {
    this.onButtonsClick$.subscribe();
  }
}

fromHostEvent

This operator is usefull as an Rx replacement for @HostListner.

  fromHostEvent<T extends Event>(type: keyof HTMLElementEventMap, options?: NgEventListenerOptions): Observable<T>
argumenttypedescription
typekeyof HTMLElementEventMapThe type of event to listen.
optionsNgEventListenerOptionsOptional. Default is {}.Options to pass through to the underlying addEventListener function or if you need to manualy provide the injector
Example:
@Component({
  selector: 'app-root',
  template: '',
})
export class AppComponent {
  constructor() {
    fromHostEvent('click').subscribe(() => console.log('hello world!'));
    // on app-root click => Output: hello world!
  }
}

fromChildOutput

This operator is usefull whenever you want to listen to some @ViewChild or @ContentChild outputs or observables for a Component or a Directive instead of an ElementRef.

  fromChildOutput<TChild extends object, TOutput extends PickOutput<TChild>, TOutputName extends keyof TOutput>(
    childSelector: () => TChild | undefined,
    outputName: TOutputName,
    options?: InjectorOption
  ): Observable<TOutput[TOutputName]>

See PickOutput utility type

argumenttypedescription
childSelector() => TChild \| undefinedA callback function used to get the child element to listen an event to.
outputNameTOutputNameThe name of the public observable to listen.
optionsInjectorOptionOptional. Default is {}.Options to pass through if you need to manualy provide the injector
<br/>
Example:
@Component({
  selector: 'app-child',
  template: `<button (click)="sayHello.emit('hello!')">Say hello</button>`,
})
class AppChildComponent {
  // @Output is not necessary here, if you don't use the angular (eventName)="doSomething()" syntax
  // 'sayHello' must be public to be accessed
  sayHello = new EventEmitter<string>();
}

@Component({
  selector: 'app-root',
  imports: [AppChildComponent],
  template: '<app-child #child />',
})
export class AppComponent {
  @ViewChild('child') child!: AppChildComponent;

  constructor() {
    // the second argument is infered from the child as the childSelector
    fromChildOutput(() => this.child, 'sayHello').subscribe(console.log);
    // on child button click => Output: hello!
  }
}

fromChildrenOutput

It works exactly like fromChildOutput but for an array of components instead!

  fromChildrenOutput<
    TChildren extends object[],
    TUnionOutput extends PickUnionOutput<TChildren>,
    TUnionOutputName extends keyof TUnionOutput
  >(
    childrenSelector: () => TChildren | undefined,
    outputName: TUnionOutputName,
    options?: InjectorOption
  ): Observable<readonly [output: TUnionOutput[TUnionOutputName], index: number]>

See PickOutput utility type

argumenttypedescription
childrenSelector() => TChildren \| undefinedA callback function used to get the child element to listen an event to.
outputNameTOutputNameThe name of the public observable to listen.
optionsInjectorOptionOptional. Default is {}.Options to pass through if you need to manualy provide the injector
<br/>
Example:
@Component({
  selector: 'app-child',
  template: `<button (click)="say.emit('hello!')">Say hello</button>`,
})
class AppChildComponent {
  // @Output is not necessary here, if you don't use the angular (eventName)="doSomething()" syntax
  // 'sayHello' must be public to be accessed
  say = new EventEmitter<string>();
}

@Component({
  selector: 'app-child2',
  template: `<button (click)="say.emit('goodbye!')">Say goodbye</button>`,
})
class AppChild2Component {
  say = new EventEmitter<string>();

  say$ = this.say.asObservablee(); // This won't be available as the second arguement `outputName` of `fromChildrenOutput` since it does not exist on `AppChildComponent`
}

@Component({
  selector: 'app-root',
  imports: [AppChildComponent, AppChild2Component],
  template: `
    <app-child #child />

    <app-child2 #child2 />
  `,
})
export class AppComponent {
  @ViewChild('child') child!: AppChildComponent;
  // Since it takes an array of components, you can rebuild it in your own way
  @ViewChild('child2') child2!: AppChild2Component;

  constructor() {
    // The second argument is infered from the child as the childSelector
    fromChildrenOutput(() => [this.child, this.child2], 'say').subscribe(console.log);
    // On child button click => Output: ["hello!", 0]
  }
}

rxAfterNextRender

It uses the new afterNextRender to send an RxJS notification that the callback function has been called once.

  rxAfterNextRender(injector?: Injector): Observable<void>
argumenttypedescription
injectorInjectorOptional. Default is undefined.
Example
@Component({
  selector: 'app',
  template: '<button #button>Click me!</button>',
})
export class AppComponent {
  @ViewChild('button')
  private readonly button?: ElementRef<HTMLButtonElement>;

  constructor() {
    rxAfterNextRender().subscribe(() => console.log(this.button?.clientHeight)); // the button won't be undefined here
  }
}

rxAfterRender

It uses the new afterRender to send RxJS notifications each time the callback function is called.

  rxAfterRender(injector?: Injector): Observable<void>
argumenttypedescription
injectorInjectorOptional. Default is undefined.
Example
@Component({
  selector: 'app',
  template: '<button #button>Click me!</button>',
})
export class AppComponent {
  @ViewChild('button')
  private readonly button?: ElementRef<HTMLButtonElement>;

  constructor() {
    rxAfterRender().subscribe(() => console.log(this.button?.clientHeight)); // Will log the button's clientHeight each time the view is checked by angular
  }
}

What to expect in the future?

With the upcomming Signal-based Components, we shouldn't need to first declare @ViewChild, @ViewChildren, etc. anymore.-ml-1

This would greatly improve the DX of these operators and it could lead to the following improvements:

@Component({
  selector: 'app',
  template: '<button #button>Click me!</button>',
})
export class AppComponent {
  readonly onButtonClick$ = fromViewChildEvent('button', 'click').pipe(...)
}
@Component({
  selector: 'app',
  template: '<button mat-button>Click me!</button>',
})
export class AppComponent {
  readonly onButtonClick$ = fromViewChildEvent(MatButton, 'click').pipe(...)
}
@Component({
  selector: 'app',
  template: `
    <button #button1>Click me!</button>

    <button #button2>Click me too!</button>
  `,
})
export class AppComponent {
  readonly onButtonsClick$ = fromViewChildrenEvent(['button1', 'button2'], 'click').pipe(...)
  // of course, the 2 buttons could simply be named #button but it is for the example only
}
@Component({
  selector: 'app',
  template: `
    <button mat-button>Click me!</button>

    <button mat-button>Click me too!</button>
  `,
})
export class AppComponent {
  readonly onButtonClick$ = fromViewChildrenEvent(MatButton, 'click').pipe(...)
  // or could be fromViewChildren([MatButton, SomeOtherComponentOrDirective]).pipe(...);
}

| With SBCs, I could either decouple the viewChild<ren> and contentChild<ren> or try to merge them together.

=> Another thing I might improve is merging fromChild<ren>Event and fromChild<ren>Output into one operator.

5.0.0

9 months ago

5.0.0-rc.0

9 months ago

4.0.1

12 months ago

4.0.0

12 months ago

3.0.0

12 months ago

2.2.0

12 months ago

2.1.2

12 months ago

2.1.1

12 months ago

2.1.0

12 months ago

2.0.3

12 months ago

2.0.2

12 months ago

2.0.1

12 months ago

2.0.0

12 months ago

1.2.1

1 year ago

1.2.0

1 year ago

1.1.0

1 year ago

1.0.0

1 year ago

0.2.1

1 year ago

0.2.0

1 year ago

0.1.0

1 year ago