This repository has been archived by the owner on Feb 2, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement anchor action to open a browser link
- Loading branch information
1 parent
873f848
commit fc3ae86
Showing
15 changed files
with
404 additions
and
53 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
24 changes: 24 additions & 0 deletions
24
projects/core/src/lib/action-anchor/action-anchor.model.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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')); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
12
projects/material/src/lib/action-mat-button.component.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.