Skip to content

Commit

Permalink
feat(activeElement): introduce injectActiveElement (#110)
Browse files Browse the repository at this point in the history
* feat(activeElement): add injectActiveElement

* docs: active-element

* feat(activeElement): add activeElement example to the test app

* feat(activeElement): add activeElement e2e tests

* chore: rollback unnecessary changes

* chore: use local-plugin entry point generator

* feat(activeElement): use assertInjector

* Update docs/src/content/docs/utilities/Injectors/active-element.md

Co-authored-by: Enea Jahollari <jahollarienea14@gmail.com>

* Update libs/ngxtension/active-element/src/active-element.ts

---------

Co-authored-by: Chau Tran <nartc7789@gmail.com>
Co-authored-by: Enea Jahollari <jahollarienea14@gmail.com>
  • Loading branch information
3 people committed Oct 14, 2023
1 parent 1755b74 commit 48fdf25
Show file tree
Hide file tree
Showing 12 changed files with 267 additions and 0 deletions.
11 changes: 11 additions & 0 deletions apps/test-app-e2e/src/e2e/active-element.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
describe('activeElement', () => {
beforeEach(() => cy.visit('/active-element'));

it('should emit the focussed button', () => {
cy.get('button').eq(1).as('buttonToFocus');
cy.get('span').as('focussedElementHTML');

cy.get('@buttonToFocus').focus();
cy.get('@focussedElementHTML').should('contain.text', 'btn2');
});
});
22 changes: 22 additions & 0 deletions apps/test-app/src/app/active-element/active-element.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { AsyncPipe } from '@angular/common';
import { Component } from '@angular/core';
import { injectActiveElement } from 'ngxtension/active-element';

@Component({
standalone: true,
host: {
style: 'display: block; margin: 12px',
},
imports: [AsyncPipe],
template: `
<button>btn1</button>
<button>btn2</button>
<button>btn3</button>
<span>
{{ (activeElement$ | async)?.innerHTML }}
</span>
`,
})
export default class ActiveElement {
readonly activeElement$ = injectActiveElement();
}
3 changes: 3 additions & 0 deletions apps/test-app/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ import { RouterLink, RouterOutlet } from '@angular/router';
<li>
<a routerLink="/drag">Drag Gesture</a>
</li>
<li>
<a routerLink="/active-element">Active Element</a>
</li>
</ul>
<hr />
Expand Down
5 changes: 5 additions & 0 deletions apps/test-app/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export const appConfig: ApplicationConfig = {
path: 'drag',
loadComponent: () => import('./drag/drag.component'),
},
{
path: 'active-element',
loadComponent: () =>
import('./active-element/active-element.component'),
},
]),
],
};
60 changes: 60 additions & 0 deletions docs/src/content/docs/utilities/Injectors/active-element.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
title: injectActiveElement
description: An Angular utility to create an Observable that emits active element from the document.
---

## Import

```ts
import { injectActiveElement } from 'ngxtension/active-element';
```

## Usage

### Basic

Create an Observable that emits when the active -focussed- element changes.

```ts
import { Component } from '@angular/core';
import { injectActiveElement } from 'ngxtension/active-element';

@Component({
standalone: true,
selector: 'app-example',
template: `
<button>btn1</button>
<button>btn2</button>
<button>btn3</button>
<span>{{ (activeElement$ | async)?.innerHTML }}</span>
`,
})
export class ExampleComponent {
activeElement$ = injectActiveElement();
}
```

## Use Outside of an Injection Context

The `injectActiveElement` function accepts an optional `Injector` parameter, enabling usage outside of an injection context.

```ts
@Component()
export class ExampleComponent implements OnInit {
private readonly injector = inject(Injector);

ngOnInit() {
const activeElement$ = injectActiveElement(this.injector);
}
}
```

## API

### Inputs

- `injector?: Injector` - Optional. Allows using the function outside of an Angular injection context.

### Outputs

- Emits an Observable of active HTMLElement from the document.
3 changes: 3 additions & 0 deletions libs/ngxtension/active-element/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ngxtension/active-element

Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/active-element`.
5 changes: 5 additions & 0 deletions libs/ngxtension/active-element/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
33 changes: 33 additions & 0 deletions libs/ngxtension/active-element/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "ngxtension/active-element",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"sourceRoot": "libs/ngxtension/active-element/src",
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/ngxtension/jest.config.ts",
"testPathPattern": ["active-element"],
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"libs/ngxtension/active-element/**/*.ts",
"libs/ngxtension/active-element/**/*.html"
]
}
}
}
}
100 changes: 100 additions & 0 deletions libs/ngxtension/active-element/src/active-element.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { AsyncPipe } from '@angular/common';
import {
Component,
DebugElement,
INJECTOR,
OnInit,
inject,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Observable } from 'rxjs';
import { injectActiveElement } from './active-element';

describe('injectActiveElement', () => {
@Component({
standalone: true,
imports: [AsyncPipe],
template: `
<button>btn1</button>
<button>btn2</button>
<button>btn3</button>
<span>{{ (activeElement | async)?.innerHTML }}</span>
`,
})
class TestComponent {
activeElement = injectActiveElement();
}

it('update focussed element when it changes', () => {
const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
const span: DebugElement = fixture.debugElement.query(By.css('span'));

const buttonToFocus = buttons.at(1);

buttonToFocus?.nativeElement.focus();
fixture.detectChanges();

const actual = span.nativeElement.innerHTML;
const expected = buttonToFocus?.nativeElement.innerHTML;

expect(actual).toBe(expected);
});

it('be null when no active element', () => {
const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
const span: DebugElement = fixture.debugElement.query(By.css('span'));

const buttonToFocus = buttons.at(1);

buttonToFocus?.nativeElement.focus();
fixture.detectChanges();
buttonToFocus?.nativeElement.blur();
fixture.detectChanges();

const actual = span.nativeElement.innerHTML;
const expected = '';

expect(actual).toBe(expected);
});

it('work with given injector', () => {
@Component({
standalone: true,
imports: [AsyncPipe],
template: `
<button>btn1</button>
<button>btn2</button>
<button>btn3</button>
<span>{{ (activeElement | async)?.innerHTML }}</span>
`,
})
class DummyComponent implements OnInit {
readonly injector = inject(INJECTOR);
activeElement?: Observable<Element | null>;

ngOnInit(): void {
this.activeElement = injectActiveElement(this.injector);
}
}

const fixture = TestBed.createComponent(DummyComponent);
fixture.detectChanges();
const buttons = fixture.debugElement.queryAll(By.css('button'));
const span: DebugElement = fixture.debugElement.query(By.css('span'));

const buttonToFocus = buttons.at(2);

buttonToFocus?.nativeElement.focus();
fixture.detectChanges();

const actual = span.nativeElement.innerHTML;
const expected = buttonToFocus?.nativeElement.innerHTML;

expect(actual).toBe(expected);
});
});
21 changes: 21 additions & 0 deletions libs/ngxtension/active-element/src/active-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { DOCUMENT } from '@angular/common';
import { inject, Injector } from '@angular/core';
import { assertInjector } from 'ngxtension/assert-injector';
import { fromEvent, map, merge, shareReplay } from 'rxjs';

export function injectActiveElement(injector?: Injector) {
return assertInjector(injectActiveElement, injector, () => {
const doc = inject(DOCUMENT);
return merge(
fromEvent(doc, 'focus', { capture: true, passive: true }).pipe(
map(() => true)
),
fromEvent(doc, 'blur', { capture: true, passive: true }).pipe(
map(() => false)
)
).pipe(
map((hasFocus) => (hasFocus ? doc.activeElement : null)),
shareReplay({ refCount: true, bufferSize: 1 })
);
});
}
1 change: 1 addition & 0 deletions libs/ngxtension/active-element/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './active-element';
3 changes: 3 additions & 0 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
"paths": {
"local-plugin": ["libs/local-plugin/src/index.ts"],
"ngxtension": ["libs/ngxtension/src/index.ts"],
"ngxtension/active-element": [
"libs/ngxtension/active-element/src/index.ts"
],
"ngxtension/assert-injector": [
"libs/ngxtension/assert-injector/src/index.ts"
],
Expand Down

0 comments on commit 48fdf25

Please sign in to comment.