Skip to content
This repository has been archived by the owner on Feb 2, 2023. It is now read-only.

Commit

Permalink
feat: implement anchor action to open a browser link
Browse files Browse the repository at this point in the history
  • Loading branch information
klemenoslaj committed Jan 22, 2021
1 parent 873f848 commit fc3ae86
Show file tree
Hide file tree
Showing 15 changed files with 404 additions and 53 deletions.
2 changes: 1 addition & 1 deletion projects/core/src/lib/action-abstract/action-abstract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export class ActionCustom extends ActionAbstract<ActionCustomOptions, ActionCust
}
```
*/
export abstract class ActionAbstract<Options extends ActionAbstractOptions, FireEvent extends ActionAbstractEvent> {
export abstract class ActionAbstract<Options extends ActionAbstractOptions, FireEvent extends ActionAbstractEvent | null> {
/**
* @internal
*
Expand Down
24 changes: 24 additions & 0 deletions projects/core/src/lib/action-anchor/action-anchor.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { UrlTree } from '@angular/router';
import { ActionAbstractComponentImpl, ActionAbstractOptions } from '../action-abstract/action-abstract.model';
import { ActionAnchor } from './action-anchor';

/**
* Type that components used by `ActionAnchor` should implement
*/
export type ActionAnchorComponentImpl = ActionAbstractComponentImpl<ActionAnchor>;

/**
* _self: the current browsing context. (Default)
* _blank: usually a new tab, but users can configure browsers to open a new window instead.
* _parent: the parent browsing context of the current one. If no parent, behaves as _self.
* _top: the topmost browsing context (the "highest" context that’s an ancestor of the current one). If no ancestors, behaves as _self.
*/
export type AnchorTarget = '_self' | '_blank' | '_parent' | '_top';

/**
* `ActionAnchor` specific options, extending abstract options with it's specific properties
*/
export interface ActionAnchorOptions extends ActionAbstractOptions {
readonly href?: UrlTree | string | string[];
readonly target?: AnchorTarget;
}
75 changes: 75 additions & 0 deletions projects/core/src/lib/action-anchor/action-anchor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { UrlTree } from '@angular/router';
import { TestScheduler } from 'rxjs/testing';

import { ActionAnchor } from './action-anchor';

interface TestContext {
action: ActionAnchor;
testScheduler: TestScheduler;
}

describe('Class: ActionAnchor', function (): void {
beforeEach(function (this: TestContext): void {
this.action = new ActionAnchor().activate();
this.testScheduler = new TestScheduler(
(actual, e) => <any>expect(actual).toEqual(e)
);
});

it('should have public methods', function (this: TestContext): void {
expect(this.action.trigger).toEqual(jasmine.any(Function));
expect(this.action.setHref).toEqual(jasmine.any(Function));
expect(this.action.setTarget).toEqual(jasmine.any(Function));
expect(this.action.isExternalLink).toEqual(jasmine.any(Function));
});

it('should use provided href and target', function (this: TestContext): void {
this.action = new ActionAnchor({ href: '/home', target: '_self' });
this.action.href$.subscribe(href => expect(href).toBe('/home'));
this.action.target$.subscribe(target => expect(target).toBe('_self'));
});

it('should properly differentiate external url and router links', function (this: TestContext): void {
this.action.setHref('/home');
expect(this.action.isExternalLink()).toBeFalse();

this.action.setHref('home');
expect(this.action.isExternalLink()).toBeFalse();

this.action.setHref(['user', '11111', 'overview']);
expect(this.action.isExternalLink()).toBeFalse();

this.action.setHref(new UrlTree());
expect(this.action.isExternalLink()).toBeFalse();

this.action.setHref('www.external.com');
expect(this.action.isExternalLink()).toBeTrue();

this.action.setHref('http://www.external.com');
expect(this.action.isExternalLink()).toBeTrue();
});

it('should notify every change with changes$', function (this: TestContext): void {
const marble = '(tivdlg)';
const values = {
t: { title: 'Test Title' },
i: { icon: 'test-icon' },
v: { visible: true },
d: { disabled: false },
l: { link: '/home' },
g: { target: '_blank' },
};

this.action.setTitle('Test Title');
this.action.setIcon('test-icon');
this.action.show();
this.action.trigger();
this.action.enable();
this.action.disable(); // Won't disable link
this.action.setHref('/home');
this.action.setTarget('_blank');

this.testScheduler.expectObservable(this.action.changes$).toBe(marble, values);
this.testScheduler.flush();
});
});
109 changes: 109 additions & 0 deletions projects/core/src/lib/action-anchor/action-anchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Type } from '@angular/core';
import { UrlTree } from '@angular/router';
import { Observable, merge, EMPTY, BehaviorSubject } from 'rxjs';
import { map } from 'rxjs/operators';

import { ActionAbstract } from '../action-abstract/action-abstract';
import { ActionAnchorComponentImpl, ActionAnchorOptions, AnchorTarget } from './action-anchor.model';

/**
* Default options for `ActionAnchor`
* Extended by provided options in action `constructor`
*/
const defaultButtonOptions: ActionAnchorOptions = { };

/**
* `ActionAnchor` used to create basic link
*
* ## Example
*
*
*
```typescript
const button = new ActionAnchor({ title: 'Test', link: 'https://...' });
```
*
* **Or**
*
*
```typescript
const button = actionFactory.createButton({ title: 'Test', link: 'https://...' });
```
*
* **Or**
*
```typescript
const button = actionFactory.createButton().setTitle('Test');
```
*/
export class ActionAnchor extends ActionAbstract<ActionAnchorOptions, null> {
/**
* `EMPTY Observable` as link is handled by the browser
*/
readonly fire$ = EMPTY;
/**
* `Observable` notifies subscriptions on following changes:
* *title, icon, visibility, disabled*
*/
readonly changes$: Observable<ActionAnchorOptions>;

readonly href$: Observable<UrlTree | string | string[] | null>;
readonly target$: Observable<AnchorTarget | null>;

protected href: BehaviorSubject<UrlTree | string | string[] | null>;
protected target: BehaviorSubject<AnchorTarget | null>;

/**
* Public `constructor` used to instantiate `ActionAnchor`
*
* @param options Options for `ActionAnchor`
* @param component Optional custom `Component`
*/
constructor(options: ActionAnchorOptions = defaultButtonOptions,
component?: Type<ActionAnchorComponentImpl>) {
super({ ...defaultButtonOptions, ...options }, component);

this.href = new BehaviorSubject(options.href ?? null);
this.target = new BehaviorSubject(options.target ?? null);

this.href$ = this.handleLivecycleDistinct(this.href.asObservable(), false);
this.target$ = this.handleLivecycleDistinct(this.target.asObservable(), false);
this.changes$ = this.handleLivecycle(merge(
this.title$.pipe(map(title => (<ActionAnchorOptions>{ title }))),
this.icon$.pipe(map(icon => (<ActionAnchorOptions>{ icon }))),
this.visible$.pipe(map(visible => (<ActionAnchorOptions>{ visible }))),
this.disabled$.pipe(map(disabled => (<ActionAnchorOptions>{ disabled }))),
this.href$.pipe(map(href => (<ActionAnchorOptions>{ link: href }))),
this.target$.pipe(map(target => (<ActionAnchorOptions>{ target }))),
));
}

trigger(): this {
return this;
}

setHref(link: UrlTree | string | string[] | null) {
this.href.next(link);
return this;
}

setTarget(target: AnchorTarget) {
this.target.next(target);
return this;
}

disable() {
// HTML Anchors cannot be disabled
return this;
}

enable() {
// HTML Anchors cannot be disabled
return this;
}

isExternalLink() {
const link = this.href.getValue();
return typeof link === 'string' && (link.startsWith('http') || link.startsWith('www'));
}
}
3 changes: 3 additions & 0 deletions projects/core/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ export * from './lib/action-group/action-group.model';

export * from './lib/action-toggle/action-toggle';
export * from './lib/action-toggle/action-toggle.model';

export * from './lib/action-anchor/action-anchor';
export * from './lib/action-anchor/action-anchor.model';
74 changes: 74 additions & 0 deletions projects/material/src/lib/action-mat-anchor.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, ElementRef, HostBinding, Inject } from '@angular/core';
import { ActionAnchor, ActionAnchorComponentImpl } from '@ng-action-outlet/core';
import { FocusableOption, FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';

import { isMenuItem } from './common';
import { actionMatButtonTemplate } from './action-mat-button.template';
import { ICON_TYPE, ACTION_ICON_TYPE_TOKEN } from './action-icon-type-token';

export const AnchorType = <const>{
Link: 0,
MenuLink: 1
};

@Component({
selector: 'action-mat-anchor',
template: `
<ng-container *ngIf="(_action?.visible$ | async) && _action; let action" [ngSwitch]="_getAnchorType(action!)">
<ng-container *ngIf="(action.href$ | async); let href" [ngTemplateOutlet]="action.isExternalLink() ? external : internal" [ngTemplateOutletContext]="{ $implicit: href }"></ng-container>
<ng-template #external let-href>
<a *ngSwitchCase="AnchorType.Link" mat-button [actionMatButton]="action" [href]="href" [attr.target]="action.target$ | async">
<ng-container *ngTemplateOutlet="content; context: { $implicit: action }"></ng-container>
</a>
<a *ngSwitchCase="AnchorType.MenuLink" mat-menu-item [actionMatButton]="action" [href]="href" [attr.target]="action.target$ | async">
<ng-container *ngTemplateOutlet="content; context: { $implicit: action }"></ng-container>
</a>
</ng-template>
<ng-template #internal let-href>
<a *ngSwitchCase="AnchorType.Link" mat-button [actionMatButton]="action" [routerLink]="href" [target]="action.target$ | async">
<ng-container *ngTemplateOutlet="content; context: { $implicit: action }"></ng-container>
</a>
<a *ngSwitchCase="AnchorType.MenuLink" mat-menu-item [actionMatButton]="action" [routerLink]="href" [target]="action.target$ | async">
<ng-container *ngTemplateOutlet="content; context: { $implicit: action }"></ng-container>
</a>
</ng-template>
</ng-container>
${actionMatButtonTemplate}
`,
styleUrls: ['./action-mat-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
export class ActionMatAnchorComponent implements ActionAnchorComponentImpl, FocusableOption {
@Input('action')
readonly _action?: ActionAnchor | null;

@HostBinding('class')
readonly _classname = 'action-mat-button';

AnchorType = AnchorType;

constructor(
@Inject(ACTION_ICON_TYPE_TOKEN)
readonly _iconType: ICON_TYPE,
private readonly _elementRef: ElementRef,
private readonly _focusMonitor?: FocusMonitor,
) {}

focus(origin: FocusOrigin = 'program', options?: FocusOptions): void {
if (this._focusMonitor) {
this._focusMonitor.focusVia(this._elementRef.nativeElement, origin, options);
} else {
this._elementRef.nativeElement.focus(options);
}
}

_getAnchorType(action: ActionAnchor) {
return isMenuItem(action.getParent()) ? AnchorType.MenuLink : AnchorType.Link;
}
}
12 changes: 12 additions & 0 deletions projects/material/src/lib/action-mat-button.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.action-mat-button {
display: contents;
}

.action-mat-button .mat-button,
.action-mat-button .mat-icon-button {
vertical-align: top;
}

.action-mat-button a:hover {
text-decoration: underline;
}
32 changes: 2 additions & 30 deletions projects/material/src/lib/action-mat-button.component.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation, ElementRef, HostBinding, Inject } from '@angular/core';
import { ActionButton, ActionButtonComponentImpl } from '@ng-action-outlet/core';
import { FocusableOption, FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';
import { MatMenu } from '@angular/material/menu';

import { isMenuItem } from './common';
import { actionMatButtonTemplate } from './action-mat-button.template';
import { ICON_TYPE, ACTION_ICON_TYPE_TOKEN } from './action-icon-type-token';

export const ButtonType = <const>{
Button: 0,
ButtonMenu: 1,
MenuItem: 2,
MenuItemMenu: 3,
MenuItem: 1
};

@Component({
Expand All @@ -22,41 +19,21 @@ export const ButtonType = <const>{
<ng-container *ngTemplateOutlet="content; context: { $implicit: _action }"></ng-container>
</button>
<button *ngSwitchCase="ButtonType.ButtonMenu" mat-button [actionMatButton]="_action" [matMenuTriggerFor]="_forMatMenu">
<ng-container *ngTemplateOutlet="content; context: { $implicit: _action }"></ng-container>
</button>
<button *ngSwitchCase="ButtonType.MenuItem" mat-menu-item [actionMatButton]="_action">
<ng-container *ngTemplateOutlet="content; context: { $implicit: _action }"></ng-container>
</button>
<button *ngSwitchCase="ButtonType.MenuItemMenu" mat-menu-item [actionMatButton]="_action" [matMenuTriggerFor]="_forMatMenu">
<ng-container *ngTemplateOutlet="content; context: { $implicit: _action }"></ng-container>
</button>
</ng-container>
${actionMatButtonTemplate}
`,
styles: [`
.action-mat-button {
display: contents;
}
.action-mat-button .mat-button,
.action-mat-button .mat-icon-button {
vertical-align: top;
}
`],
styleUrls: ['./action-mat-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
export class ActionMatButtonComponent implements ActionButtonComponentImpl, FocusableOption {
@Input('action')
readonly _action?: ActionButton | null;

@Input('forMatMenu')
readonly _forMatMenu?: MatMenu;

@HostBinding('class')
readonly _classname = 'action-mat-button';

Expand All @@ -79,11 +56,6 @@ export class ActionMatButtonComponent implements ActionButtonComponentImpl, Focu

_getButtonType(action: ActionButton) {
const isButtonMenuItem = isMenuItem(action.getParent());

if (this._forMatMenu) {
return isButtonMenuItem ? ButtonType.MenuItemMenu : ButtonType.ButtonMenu;
}

return isButtonMenuItem ? ButtonType.MenuItem : ButtonType.Button;
}
}

0 comments on commit fc3ae86

Please sign in to comment.