Migrate Angular UI from portmaster-ui to desktop/angular. Update Earthfile to build libs, UI and tauri-builtin
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<ng-content></ng-content>
|
||||
@@ -0,0 +1,116 @@
|
||||
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
import { ChangeDetectionStrategy, Component, Input, OnDestroy, TemplateRef } from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { SfngAccordionComponent } from './accordion';
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-accordion-group',
|
||||
templateUrl: './accordion-group.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SfngAccordionGroupComponent implements OnDestroy {
|
||||
/** @private Currently registered accordion components */
|
||||
accordions: SfngAccordionComponent[] = [];
|
||||
|
||||
/**
|
||||
* A template-ref to render as the header for each accordion-component.
|
||||
* Receives the accordion data as an $implicit context.
|
||||
*/
|
||||
@Input()
|
||||
set headerTemplate(v: TemplateRef<any> | null) {
|
||||
this._headerTemplate = v;
|
||||
|
||||
if (!!this.accordions.length) {
|
||||
this.accordions.forEach(a => {
|
||||
a.headerTemplate = v;
|
||||
a.cdr.markForCheck();
|
||||
})
|
||||
}
|
||||
}
|
||||
get headerTemplate() { return this._headerTemplate }
|
||||
private _headerTemplate: TemplateRef<any> | null = null;
|
||||
|
||||
/** Whether or not one or more components can be expanded. */
|
||||
@Input()
|
||||
set singleMode(v: any) {
|
||||
this._singleMode = coerceBooleanProperty(v);
|
||||
}
|
||||
get singleMode() { return this._singleMode }
|
||||
private _singleMode = false;
|
||||
|
||||
/** Whether or not the accordion is disabled and does not allow expanding */
|
||||
@Input()
|
||||
set disabled(v: any) {
|
||||
this._disabled = coerceBooleanProperty(v);
|
||||
if (this._disabled) {
|
||||
this.accordions.forEach(a => a.active = false);
|
||||
}
|
||||
}
|
||||
get disabled(): boolean { return this._disabled; }
|
||||
private _disabled = false;
|
||||
|
||||
/** A list of subscriptions to the activeChange output of the registered accordion-components */
|
||||
private subscriptions: Subscription[] = [];
|
||||
|
||||
/**
|
||||
* Registeres an accordion component to be handled together with this
|
||||
* accordion group.
|
||||
*
|
||||
* @param a The accordion component to register
|
||||
*/
|
||||
register(a: SfngAccordionComponent) {
|
||||
this.accordions.push(a);
|
||||
|
||||
// Tell the accordion-component about the default header-template.
|
||||
if (!a.headerTemplate) {
|
||||
a.headerTemplate = this.headerTemplate;
|
||||
}
|
||||
|
||||
// Subscribe to the activeChange output of the registered
|
||||
// accordion and call toggle() for each event emitted.
|
||||
this.subscriptions.push(a.activeChange.subscribe(() => {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toggle(a);
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters a accordion component
|
||||
*
|
||||
* @param a The accordion component to unregister
|
||||
*/
|
||||
unregister(a: SfngAccordionComponent) {
|
||||
const index = this.accordions.indexOf(a);
|
||||
if (index === -1) return;
|
||||
|
||||
const subscription = this.subscriptions[index];
|
||||
|
||||
subscription.unsubscribe();
|
||||
this.accordions = this.accordions.splice(index, 1);
|
||||
this.subscriptions = this.subscriptions.splice(index, 1);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscriptions.forEach(s => s.unsubscribe());
|
||||
this.subscriptions = [];
|
||||
this.accordions = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand an accordion component and collaps all others if
|
||||
* single-mode is selected.
|
||||
*
|
||||
* @param a The accordion component to toggle.
|
||||
*/
|
||||
private toggle(a: SfngAccordionComponent) {
|
||||
if (!a.active && this._singleMode) {
|
||||
this.accordions?.forEach(a => a.active = false);
|
||||
}
|
||||
|
||||
a.active = !a.active;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<div [class.active]="active" [class.cursor-pointer]="!group || !group.disabled" (click)="toggle($event)">
|
||||
<ng-container *ngTemplateOutlet="headerTemplate; context: {$implicit: data, active: active, accordion: component}">
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="h-auto overflow-visible opacity-100" *ngIf="active" [@fadeIn] [@fadeOut]>
|
||||
<ng-container>
|
||||
<ng-content></ng-content>
|
||||
</ng-container>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { SfngAccordionComponent } from "./accordion";
|
||||
import { SfngAccordionGroupComponent } from "./accordion-group";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
],
|
||||
declarations: [
|
||||
SfngAccordionGroupComponent,
|
||||
SfngAccordionComponent,
|
||||
],
|
||||
exports: [
|
||||
SfngAccordionGroupComponent,
|
||||
SfngAccordionComponent,
|
||||
]
|
||||
})
|
||||
export class SfngAccordionModule { }
|
||||
@@ -0,0 +1,88 @@
|
||||
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostBinding, Input, OnDestroy, OnInit, Optional, Output, TemplateRef, TrackByFunction } from '@angular/core';
|
||||
import { fadeInAnimation, fadeOutAnimation } from '../animations';
|
||||
import { SfngAccordionGroupComponent } from './accordion-group';
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-accordion',
|
||||
templateUrl: './accordion.html',
|
||||
exportAs: 'sfngAccordion',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [
|
||||
fadeInAnimation,
|
||||
fadeOutAnimation
|
||||
]
|
||||
})
|
||||
export class SfngAccordionComponent<T = any> implements OnInit, OnDestroy {
|
||||
/** @deprecated in favor of [data] */
|
||||
@Input()
|
||||
title: string = '';
|
||||
|
||||
/** A reference to the component provided via the template context */
|
||||
component = this;
|
||||
|
||||
/**
|
||||
* The data the accordion component is used for. This is passed as an $implicit context
|
||||
* to the header template.
|
||||
*/
|
||||
@Input()
|
||||
data: T | undefined = undefined;
|
||||
|
||||
@Input()
|
||||
trackBy: TrackByFunction<T | null> = (_, c) => c
|
||||
|
||||
/** Whether or not the accordion component starts active. */
|
||||
@Input()
|
||||
set active(v: any) {
|
||||
this._active = coerceBooleanProperty(v);
|
||||
}
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
private _active: boolean = false;
|
||||
|
||||
/** Emits whenever the active value changes. Supports two-way bindings. */
|
||||
@Output()
|
||||
activeChange = new EventEmitter<boolean>();
|
||||
|
||||
/**
|
||||
* The header-template to render for this component. If null, the default template from
|
||||
* the parent accordion-group will be used.
|
||||
*/
|
||||
@Input()
|
||||
headerTemplate: TemplateRef<any> | null = null;
|
||||
|
||||
@HostBinding('class.active')
|
||||
/** @private Whether or not the accordion should have the 'active' class */
|
||||
get activeClass(): string {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// register at our parent group-component (if any).
|
||||
this.group?.register(this);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.group?.unregister(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the active-state of the accordion-component.
|
||||
*
|
||||
* @param event The mouse event.
|
||||
*/
|
||||
toggle(event?: Event) {
|
||||
if (!!this.group && this.group.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
event?.preventDefault();
|
||||
this.activeChange.emit(!this.active);
|
||||
}
|
||||
|
||||
constructor(
|
||||
public cdr: ChangeDetectorRef,
|
||||
@Optional() public group: SfngAccordionGroupComponent,
|
||||
) { }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { SfngAccordionComponent } from './accordion';
|
||||
export { SfngAccordionGroupComponent } from './accordion-group';
|
||||
export { SfngAccordionModule } from './accordion.module';
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { animate, query, stagger, style, transition, trigger } from '@angular/animations';
|
||||
|
||||
export const fadeInAnimation = trigger(
|
||||
'fadeIn',
|
||||
[
|
||||
transition(
|
||||
':enter',
|
||||
[
|
||||
style({ opacity: 0, transform: 'translateY(-5px)' }),
|
||||
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
|
||||
style({ opacity: 1, transform: 'translateY(0px)' }))
|
||||
]
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export const fadeOutAnimation = trigger(
|
||||
'fadeOut',
|
||||
[
|
||||
transition(
|
||||
':leave',
|
||||
[
|
||||
style({ opacity: 1, transform: 'translateY(0px)' }),
|
||||
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
|
||||
style({ opacity: 0, transform: 'translateY(-5px)' }))
|
||||
]
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
export const fadeInListAnimation = trigger(
|
||||
'fadeInList',
|
||||
[
|
||||
transition(':enter, * => 0, * => -1', []),
|
||||
transition(':increment', [
|
||||
query(':enter', [
|
||||
style({ opacity: 0 }),
|
||||
stagger(5, [
|
||||
animate('300ms ease-out', style({ opacity: 1 })),
|
||||
]),
|
||||
], { optional: true })
|
||||
]),
|
||||
]
|
||||
)
|
||||
|
||||
export const moveInOutAnimation = trigger(
|
||||
'moveInOut',
|
||||
[
|
||||
transition(
|
||||
':enter',
|
||||
[
|
||||
style({ opacity: 0, transform: 'translateX(100%)' }),
|
||||
animate('.2s ease-in',
|
||||
style({ opacity: 1, transform: 'translateX(0%)' }))
|
||||
]
|
||||
),
|
||||
transition(
|
||||
':leave',
|
||||
[
|
||||
style({ opacity: 1 }),
|
||||
animate('.2s ease-out',
|
||||
style({ opacity: 0, transform: 'translateX(100%)' }))
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
export const moveInOutListAnimation = trigger(
|
||||
'moveInOutList',
|
||||
[
|
||||
transition(':enter, * => 0, * => -1', []),
|
||||
transition(':increment', [
|
||||
query(':enter', [
|
||||
style({ opacity: 0, transform: 'translateX(100%)' }),
|
||||
stagger(50, [
|
||||
animate('200ms ease-out', style({ opacity: 1, transform: 'translateX(0%)' })),
|
||||
]),
|
||||
], { optional: true })
|
||||
]),
|
||||
transition(':decrement', [
|
||||
query(':leave', [
|
||||
stagger(-50, [
|
||||
animate('200ms ease-out', style({ opacity: 0, transform: 'translateX(100%)' })),
|
||||
]),
|
||||
], { optional: true })
|
||||
]),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
.sfng-confirm-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
caption {
|
||||
@apply text-sm;
|
||||
opacity: .6;
|
||||
font-size: .6rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message,
|
||||
h1 {
|
||||
flex-shrink: 0;
|
||||
text-overflow: ellipsis;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 0.75rem;
|
||||
flex-grow: 1;
|
||||
opacity: .6;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.message~input {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 95%;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
opacity: .7;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
@apply text-primary;
|
||||
@apply bg-gray-500 border-gray-400 bg-opacity-75 border-opacity-75;
|
||||
|
||||
&::placeholder {
|
||||
@apply text-tertiary;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
|
||||
button.action-button {
|
||||
&:not(:last-child) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
&:not(.outline) {
|
||||
@apply bg-blue;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
@apply bg-red-300;
|
||||
}
|
||||
|
||||
&.outline {
|
||||
@apply outline-none;
|
||||
@apply border;
|
||||
@apply border-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
&>span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
label {
|
||||
margin-left: .5rem;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
sfng-dialog-container {
|
||||
.container {
|
||||
display: block;
|
||||
box-shadow: 0px 0px 5px 2px rgba(0, 0, 0, 0.75);
|
||||
@apply p-6;
|
||||
@apply bg-gray-300;
|
||||
@apply rounded;
|
||||
min-width: 20rem;
|
||||
width: fit-content;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#drag-handle {
|
||||
display: block;
|
||||
height: 6px;
|
||||
background-color: white;
|
||||
opacity: .4;
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
bottom: calc(0.5rem - 2px);
|
||||
width: 30%;
|
||||
left: calc(50% - 15%);
|
||||
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<div class="sfng-confirm-dialog">
|
||||
<caption *ngIf="config.caption">{{config.caption}}</caption>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" *ngIf="config.canCancel" class="w-5 h-5 close-icon" viewBox="0 0 20 20"
|
||||
fill="currentColor" (click)="select()">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
|
||||
<h1 *ngIf="config.header">{{config.header}}</h1>
|
||||
|
||||
<span class="message" *ngIf="config.message">{{ config.message }}</span>
|
||||
|
||||
<input *ngIf="!!config.inputType" [attr.type]="config.inputType" [(ngModel)]="config.inputModel"
|
||||
[attr.placeholder]="config.inputPlaceholder || null">
|
||||
|
||||
<div class="actions" *ngIf="!!config.buttons">
|
||||
<button *ngFor="let button of config.buttons" (click)="select(button.id)" type="button"
|
||||
class="action-button {{button.class}}">{{button.text}}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject, InjectionToken } from '@angular/core';
|
||||
import { SfngDialogRef, SFNG_DIALOG_REF } from './dialog.ref';
|
||||
|
||||
export interface ConfirmDialogButton {
|
||||
text: string;
|
||||
id: string;
|
||||
class?: 'danger' | 'outline';
|
||||
}
|
||||
|
||||
export interface ConfirmDialogConfig {
|
||||
buttons?: ConfirmDialogButton[];
|
||||
canCancel?: boolean;
|
||||
header?: string;
|
||||
message?: string;
|
||||
caption?: string;
|
||||
inputType?: 'text' | 'password';
|
||||
inputModel?: string;
|
||||
inputPlaceholder?: string;
|
||||
}
|
||||
|
||||
export const CONFIRM_DIALOG_CONFIG = new InjectionToken<ConfirmDialogConfig>('ConfirmDialogConfig');
|
||||
|
||||
@Component({
|
||||
templateUrl: './confirm.dialog.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SfngConfirmDialogComponent {
|
||||
constructor(
|
||||
@Inject(SFNG_DIALOG_REF) private dialogRef: SfngDialogRef<any>,
|
||||
@Inject(CONFIRM_DIALOG_CONFIG) public config: ConfirmDialogConfig,
|
||||
) {
|
||||
if (config.inputType !== undefined && config.inputModel === undefined) {
|
||||
config.inputModel = '';
|
||||
}
|
||||
}
|
||||
|
||||
select(action?: string) {
|
||||
this.dialogRef.close(action || null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { animate, state, style, transition, trigger } from "@angular/animations";
|
||||
|
||||
export const dialogAnimation = trigger(
|
||||
'dialogContainer',
|
||||
[
|
||||
state('void, exit', style({ opacity: 0, transform: 'scale(0.7)' })),
|
||||
state('enter', style({ transform: 'none', opacity: 1 })),
|
||||
transition(
|
||||
'* => enter',
|
||||
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
|
||||
style({ opacity: 1, transform: 'translateY(0px)' }))
|
||||
),
|
||||
transition(
|
||||
'* => void, * => exit',
|
||||
animate('120ms cubic-bezier(0, 0, 0.2, 1)',
|
||||
style({ opacity: 0, transform: 'scale(0.7)' }))
|
||||
),
|
||||
]
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { AnimationEvent } from '@angular/animations';
|
||||
import { CdkDrag } from '@angular/cdk/drag-drop';
|
||||
import { CdkPortalOutlet, ComponentPortal, Portal, TemplatePortal } from '@angular/cdk/portal';
|
||||
import { ChangeDetectorRef, Component, ComponentRef, EmbeddedViewRef, HostBinding, HostListener, InjectionToken, Input, ViewChild } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { dialogAnimation } from './dialog.animations';
|
||||
|
||||
export const SFNG_DIALOG_PORTAL = new InjectionToken<Portal<any>>('SfngDialogPortal');
|
||||
|
||||
export type SfngDialogState = 'opening' | 'open' | 'closing' | 'closed';
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-dialog-container',
|
||||
template: `
|
||||
<div class="container" cdkDrag cdkDragRootElement=".cdk-overlay-pane" [cdkDragDisabled]="!dragable">
|
||||
<div *ngIf="dragable" cdkDragHandle id="drag-handle"></div>
|
||||
<ng-container cdkPortalOutlet></ng-container>
|
||||
</div>
|
||||
`,
|
||||
animations: [dialogAnimation]
|
||||
})
|
||||
export class SfngDialogContainerComponent<T> {
|
||||
onStateChange = new Subject<SfngDialogState>();
|
||||
|
||||
ref: ComponentRef<T> | EmbeddedViewRef<T> | null = null;
|
||||
|
||||
constructor(
|
||||
private cdr: ChangeDetectorRef,
|
||||
) { }
|
||||
|
||||
@HostBinding('@dialogContainer')
|
||||
state = 'enter';
|
||||
|
||||
@ViewChild(CdkPortalOutlet, { static: true })
|
||||
_portalOutlet: CdkPortalOutlet | null = null;
|
||||
|
||||
@ViewChild(CdkDrag, { static: true })
|
||||
drag!: CdkDrag;
|
||||
|
||||
attachComponentPortal(portal: ComponentPortal<T>): ComponentRef<T> {
|
||||
this.ref = this._portalOutlet!.attachComponentPortal(portal)
|
||||
return this.ref;
|
||||
}
|
||||
|
||||
attachTemplatePortal(portal: TemplatePortal<T>): EmbeddedViewRef<T> {
|
||||
this.ref = this._portalOutlet!.attachTemplatePortal(portal);
|
||||
return this.ref;
|
||||
}
|
||||
|
||||
@Input()
|
||||
dragable: boolean = false;
|
||||
|
||||
@HostListener('@dialogContainer.start', ['$event'])
|
||||
onAnimationStart({ toState }: AnimationEvent) {
|
||||
if (toState === 'enter') {
|
||||
this.onStateChange.next('opening');
|
||||
} else if (toState === 'exit') {
|
||||
this.onStateChange.next('closing');
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('@dialogContainer.done', ['$event'])
|
||||
onAnimationEnd({ toState }: AnimationEvent) {
|
||||
if (toState === 'enter') {
|
||||
this.onStateChange.next('open');
|
||||
} else if (toState === 'exit') {
|
||||
this.onStateChange.next('closed');
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the exit animation */
|
||||
_startExit() {
|
||||
this.state = 'exit';
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { DragDropModule } from "@angular/cdk/drag-drop";
|
||||
import { OverlayModule } from "@angular/cdk/overlay";
|
||||
import { PortalModule } from "@angular/cdk/portal";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { SfngConfirmDialogComponent } from "./confirm.dialog";
|
||||
import { SfngDialogContainerComponent } from "./dialog.container";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
OverlayModule,
|
||||
PortalModule,
|
||||
DragDropModule,
|
||||
FormsModule,
|
||||
],
|
||||
declarations: [
|
||||
SfngDialogContainerComponent,
|
||||
SfngConfirmDialogComponent,
|
||||
]
|
||||
})
|
||||
export class SfngDialogModule { }
|
||||
@@ -0,0 +1,62 @@
|
||||
import { OverlayRef } from "@angular/cdk/overlay";
|
||||
import { InjectionToken } from "@angular/core";
|
||||
import { Observable, PartialObserver, Subject } from "rxjs";
|
||||
import { filter, take } from "rxjs/operators";
|
||||
import { SfngDialogContainerComponent, SfngDialogState } from "./dialog.container";
|
||||
|
||||
export const SFNG_DIALOG_REF = new InjectionToken<SfngDialogRef<any>>('SfngDialogRef');
|
||||
|
||||
export class SfngDialogRef<T, R = any, D = any> {
|
||||
constructor(
|
||||
private _overlayRef: OverlayRef,
|
||||
private container: SfngDialogContainerComponent<T>,
|
||||
public readonly data: D,
|
||||
) {
|
||||
this.container.onStateChange
|
||||
.pipe(
|
||||
filter(state => state === 'closed'),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this._overlayRef.detach();
|
||||
this._overlayRef.dispose();
|
||||
this.onClose.next(this.value);
|
||||
this.onClose.complete();
|
||||
});
|
||||
}
|
||||
|
||||
get onStateChange(): Observable<SfngDialogState> {
|
||||
return this.container.onStateChange;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @returns The overlayref that holds the dialog container.
|
||||
*/
|
||||
overlay() { return this._overlayRef }
|
||||
|
||||
/**
|
||||
* @returns the instance attached to the dialog container
|
||||
*/
|
||||
contentRef() { return this.container.ref! }
|
||||
|
||||
/** Value holds the value passed on close() */
|
||||
private value: R | null = null;
|
||||
|
||||
/**
|
||||
* Emits the result of the dialog and closes the overlay.
|
||||
*/
|
||||
onClose = new Subject<R | null>()
|
||||
|
||||
/** onAction only emits if close() is called with action. */
|
||||
onAction<T extends R>(action: T, observer: PartialObserver<T> | ((value: T) => void)): this {
|
||||
(this.onClose.pipe(filter(val => val === action)) as Observable<T>)
|
||||
.subscribe(observer as any); // typescript does not select the correct type overload here.
|
||||
return this;
|
||||
}
|
||||
|
||||
close(result?: R) {
|
||||
this.value = result || null;
|
||||
this.container._startExit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Overlay, OverlayConfig, OverlayPositionBuilder, PositionStrategy } from '@angular/cdk/overlay';
|
||||
import { ComponentPortal, ComponentType, TemplatePortal } from '@angular/cdk/portal';
|
||||
import { EmbeddedViewRef, Injectable, Injector } from '@angular/core';
|
||||
import { filter, take, takeUntil } from 'rxjs/operators';
|
||||
import { ConfirmDialogConfig, CONFIRM_DIALOG_CONFIG, SfngConfirmDialogComponent } from './confirm.dialog';
|
||||
import { SfngDialogContainerComponent } from './dialog.container';
|
||||
import { SfngDialogModule } from './dialog.module';
|
||||
import { SfngDialogRef, SFNG_DIALOG_REF } from './dialog.ref';
|
||||
|
||||
export interface BaseDialogConfig {
|
||||
/** whether or not the dialog should close on outside-clicks and ESC */
|
||||
autoclose?: boolean;
|
||||
|
||||
/** whether or not a backdrop should be visible */
|
||||
backdrop?: boolean | 'light';
|
||||
|
||||
/** whether or not the dialog should be dragable */
|
||||
dragable?: boolean;
|
||||
|
||||
/**
|
||||
* optional position strategy for the overlay. if omitted, the
|
||||
* overlay will be centered on the screen
|
||||
*/
|
||||
positionStrategy?: PositionStrategy;
|
||||
|
||||
/**
|
||||
* Optional data for the dialog that is available either via the
|
||||
* SfngDialogRef for ComponentPortals as an $implicit context value
|
||||
* for TemplatePortals.
|
||||
*
|
||||
* Note, for template portals, data is only set as an $implicit context
|
||||
* value if it is not yet set in the portal!
|
||||
*/
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface ComponentPortalConfig {
|
||||
injector?: Injector;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: SfngDialogModule })
|
||||
export class SfngDialogService {
|
||||
|
||||
constructor(
|
||||
private injector: Injector,
|
||||
private overlay: Overlay,
|
||||
) { }
|
||||
|
||||
position(): OverlayPositionBuilder {
|
||||
return this.overlay.position();
|
||||
}
|
||||
|
||||
create<T>(template: TemplatePortal<T>, opts?: BaseDialogConfig): SfngDialogRef<EmbeddedViewRef<T>>;
|
||||
create<T>(target: ComponentType<T>, opts?: BaseDialogConfig & ComponentPortalConfig): SfngDialogRef<T>;
|
||||
create<T>(target: ComponentType<T> | TemplatePortal<T>, opts: BaseDialogConfig & ComponentPortalConfig = {}): SfngDialogRef<any> {
|
||||
let position: PositionStrategy = opts?.positionStrategy || this.overlay
|
||||
.position()
|
||||
.global()
|
||||
.centerVertically()
|
||||
.centerHorizontally();
|
||||
|
||||
let hasBackdrop = true;
|
||||
let backdropClass = 'dialog-screen-backdrop';
|
||||
if (opts.backdrop !== undefined) {
|
||||
if (opts.backdrop === false) {
|
||||
hasBackdrop = false;
|
||||
} else if (opts.backdrop === 'light') {
|
||||
backdropClass = 'dialog-screen-backdrop-light';
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = new OverlayConfig({
|
||||
scrollStrategy: this.overlay.scrollStrategies.noop(),
|
||||
positionStrategy: position,
|
||||
hasBackdrop: hasBackdrop,
|
||||
backdropClass: backdropClass,
|
||||
});
|
||||
const overlayref = this.overlay.create(cfg);
|
||||
|
||||
// create our dialog container and attach it to the
|
||||
// overlay.
|
||||
const containerPortal = new ComponentPortal<SfngDialogContainerComponent<T>>(
|
||||
SfngDialogContainerComponent,
|
||||
undefined,
|
||||
this.injector,
|
||||
)
|
||||
const containerRef = containerPortal.attach(overlayref);
|
||||
|
||||
if (!!opts.dragable) {
|
||||
containerRef.instance.dragable = true;
|
||||
}
|
||||
|
||||
// create the dialog ref
|
||||
const dialogRef = new SfngDialogRef<T>(overlayref, containerRef.instance, opts.data);
|
||||
|
||||
// prepare the content portal and attach it to the container
|
||||
let result: any;
|
||||
if (target instanceof TemplatePortal) {
|
||||
let r = containerRef.instance.attachTemplatePortal(target)
|
||||
|
||||
if (!!r.context && typeof r.context === 'object' && !('$implicit' in r.context)) {
|
||||
r.context = {
|
||||
$implicit: opts.data,
|
||||
...r.context,
|
||||
}
|
||||
}
|
||||
|
||||
result = r
|
||||
} else {
|
||||
const contentPortal = new ComponentPortal(target, null, Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: SFNG_DIALOG_REF,
|
||||
useValue: dialogRef,
|
||||
}
|
||||
],
|
||||
parent: opts?.injector || this.injector,
|
||||
}));
|
||||
result = containerRef.instance.attachComponentPortal(contentPortal);
|
||||
}
|
||||
// update the container position now that we have some content.
|
||||
overlayref.updatePosition();
|
||||
|
||||
if (!!opts?.autoclose) {
|
||||
overlayref.outsidePointerEvents()
|
||||
.pipe(take(1))
|
||||
.subscribe(() => dialogRef.close());
|
||||
overlayref.keydownEvents()
|
||||
.pipe(
|
||||
takeUntil(overlayref.detachments()),
|
||||
filter(event => event.key === 'Escape')
|
||||
)
|
||||
.subscribe(() => {
|
||||
dialogRef.close();
|
||||
})
|
||||
}
|
||||
return dialogRef;
|
||||
}
|
||||
|
||||
confirm(opts: ConfirmDialogConfig): SfngDialogRef<SfngConfirmDialogComponent, string> {
|
||||
return this.create(SfngConfirmDialogComponent, {
|
||||
autoclose: opts.canCancel,
|
||||
injector: Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: CONFIRM_DIALOG_CONFIG,
|
||||
useValue: opts,
|
||||
},
|
||||
],
|
||||
parent: this.injector,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ConfirmDialogConfig } from './confirm.dialog';
|
||||
export * from './dialog.module';
|
||||
export * from './dialog.ref';
|
||||
export * from './dialog.service';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<div *ngIf="!externalTrigger" class="w-full" cdkOverlayOrigin #trigger="cdkOverlayOrigin" (click)="toggle(trigger)">
|
||||
<ng-template [ngTemplateOutlet]="triggerTemplate || defaultTriggerTemplate"></ng-template>
|
||||
</div>
|
||||
|
||||
<ng-template #defaultTriggerTemplate>
|
||||
<!-- TODO(ppacher): use a button rather than a div but first fix the button styling -->
|
||||
<div [class.rounded-b]="!isOpen"
|
||||
class="flex flex-row items-center justify-between w-full px-4 py-2 mt-6 bg-gray-100 rounded-t cursor-pointer hover:bg-gray-100 hover:bg-opacity-75 text-secondary">
|
||||
{{ label }}
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOffsetY]="offsetY" [cdkConnectedOverlayOffsetX]="offsetX"
|
||||
[cdkConnectedOverlayMinWidth]="minWidth" [cdkConnectedOverlayMinHeight]="minHeight"
|
||||
[cdkConnectedOverlayOrigin]="trigger!" [cdkConnectedOverlayOpen]="isOpen" (detach)="onOverlayClosed()"
|
||||
[cdkConnectedOverlayScrollStrategy]="scrollStrategy" (overlayOutsideClick)="onOutsideClick($event)"
|
||||
[cdkConnectedOverlayPositions]="positions">
|
||||
<div class="w-full overflow-hidden bg-gray-200 rounded-b shadow {{ overlayClass }}" [style.maxHeight]="maxHeight"
|
||||
[style.maxWidth]="maxWidth" [@fadeIn] [@fadeOut]>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { OverlayModule } from "@angular/cdk/overlay";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { SfngDropdownComponent } from "./dropdown";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
OverlayModule,
|
||||
],
|
||||
declarations: [
|
||||
SfngDropdownComponent,
|
||||
],
|
||||
exports: [
|
||||
SfngDropdownComponent,
|
||||
]
|
||||
})
|
||||
export class SfngDropDownModule { }
|
||||
216
desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.ts
Normal file
216
desktop/angular/projects/safing/ui/src/lib/dropdown/dropdown.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { coerceBooleanProperty, coerceCssPixelValue, coerceNumberProperty } from "@angular/cdk/coercion";
|
||||
import { CdkOverlayOrigin, ConnectedPosition, ScrollStrategy, ScrollStrategyOptions } from "@angular/cdk/overlay";
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnInit, Output, Renderer2, TemplateRef, ViewChild } from "@angular/core";
|
||||
import { fadeInAnimation, fadeOutAnimation } from '../animations';
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-dropdown',
|
||||
exportAs: 'sfngDropdown',
|
||||
templateUrl: './dropdown.html',
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [fadeInAnimation, fadeOutAnimation],
|
||||
})
|
||||
export class SfngDropdownComponent implements OnInit {
|
||||
/** The trigger origin used to open the drop-down */
|
||||
@ViewChild('trigger', { read: CdkOverlayOrigin })
|
||||
trigger: CdkOverlayOrigin | null = null;
|
||||
|
||||
/**
|
||||
* The button/drop-down label. Only when not using
|
||||
* {@Link SfngDropdown.externalTrigger}
|
||||
*/
|
||||
@Input()
|
||||
label: string = '';
|
||||
|
||||
/** The trigger template to use when {@Link SfngDropdown.externalTrigger} */
|
||||
@Input()
|
||||
triggerTemplate: TemplateRef<any> | null = null;
|
||||
|
||||
/** Set to true to provide an external dropdown trigger template using {@Link SfngDropdown.triggerTemplate} */
|
||||
@Input()
|
||||
set externalTrigger(v: any) {
|
||||
this._externalTrigger = coerceBooleanProperty(v)
|
||||
}
|
||||
get externalTrigger() {
|
||||
return this._externalTrigger;
|
||||
}
|
||||
private _externalTrigger = false;
|
||||
|
||||
/** A list of classes to apply to the overlay element */
|
||||
@Input()
|
||||
overlayClass: string = '';
|
||||
|
||||
/** Whether or not the drop-down is disabled. */
|
||||
@Input()
|
||||
set disabled(v: any) {
|
||||
this._disabled = coerceBooleanProperty(v)
|
||||
}
|
||||
get disabled() {
|
||||
return this._disabled;
|
||||
}
|
||||
private _disabled = false;
|
||||
|
||||
/** The Y-offset of the drop-down overlay */
|
||||
@Input()
|
||||
set offsetY(v: any) {
|
||||
this._offsetY = coerceNumberProperty(v);
|
||||
}
|
||||
get offsetY() { return this._offsetY }
|
||||
private _offsetY = 4;
|
||||
|
||||
/** The X-offset of the drop-down overlay */
|
||||
@Input()
|
||||
set offsetX(v: any) {
|
||||
this._offsetX = coerceNumberProperty(v);
|
||||
}
|
||||
get offsetX() { return this._offsetX }
|
||||
private _offsetX = 0;
|
||||
|
||||
/** The scrollStrategy of the drop-down */
|
||||
@Input()
|
||||
scrollStrategy!: ScrollStrategy;
|
||||
|
||||
/** Whether or not the pop-over is currently shown. Do not modify this directly */
|
||||
isOpen = false;
|
||||
|
||||
/** The minimum width of the drop-down */
|
||||
@Input()
|
||||
set minWidth(val: any) {
|
||||
this._minWidth = coerceCssPixelValue(val)
|
||||
}
|
||||
get minWidth() { return this._minWidth }
|
||||
private _minWidth: string | number = 0;
|
||||
|
||||
/** The maximum width of the drop-down */
|
||||
@Input()
|
||||
set maxWidth(val: any) {
|
||||
this._maxWidth = coerceCssPixelValue(val)
|
||||
}
|
||||
get maxWidth() { return this._maxWidth }
|
||||
private _maxWidth: string | number | null = null;
|
||||
|
||||
/** The minimum height of the drop-down */
|
||||
@Input()
|
||||
set minHeight(val: any) {
|
||||
this._minHeight = coerceCssPixelValue(val)
|
||||
}
|
||||
get minHeight() { return this._minHeight }
|
||||
private _minHeight: string | number | null = null;
|
||||
|
||||
/** The maximum width of the drop-down */
|
||||
@Input()
|
||||
set maxHeight(val: any) {
|
||||
this._maxHeight = coerceCssPixelValue(val)
|
||||
}
|
||||
get maxHeight() { return this._maxHeight }
|
||||
private _maxHeight: string | number | null = null;
|
||||
|
||||
/** Emits whenever the drop-down is opened */
|
||||
@Output()
|
||||
opened = new EventEmitter<void>();
|
||||
|
||||
/** Emits whenever the drop-down is closed. */
|
||||
@Output()
|
||||
closed = new EventEmitter<void>();
|
||||
|
||||
@Input()
|
||||
positions: ConnectedPosition[] = [
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'end',
|
||||
overlayY: 'top',
|
||||
},
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'top',
|
||||
overlayX: 'end',
|
||||
overlayY: 'bottom',
|
||||
},
|
||||
{
|
||||
originX: 'end',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
},
|
||||
]
|
||||
|
||||
constructor(
|
||||
public readonly elementRef: ElementRef,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private renderer: Renderer2,
|
||||
private scrollOptions: ScrollStrategyOptions,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.scrollStrategy = this.scrollStrategy || this.scrollOptions.close();
|
||||
}
|
||||
|
||||
onOutsideClick(event: MouseEvent) {
|
||||
if (!!this.trigger) {
|
||||
const triggerEl = this.trigger.elementRef.nativeElement;
|
||||
|
||||
let node = event.target;
|
||||
while (!!node) {
|
||||
if (node === triggerEl) {
|
||||
return;
|
||||
}
|
||||
node = this.renderer.parentNode(node);
|
||||
}
|
||||
}
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
onOverlayClosed() {
|
||||
this.closed.next();
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isOpen = false;
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
toggle(t: CdkOverlayOrigin | null = this.trigger) {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.show(t);
|
||||
}
|
||||
|
||||
show(t: CdkOverlayOrigin | null = this.trigger) {
|
||||
if (t === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isOpen || this._disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!!t) {
|
||||
this.trigger = t;
|
||||
const rect = (this.trigger.elementRef.nativeElement as HTMLElement).getBoundingClientRect()
|
||||
|
||||
this.minWidth = rect ? rect.width : this.trigger.elementRef.nativeElement.offsetWidth;
|
||||
|
||||
}
|
||||
this.isOpen = true;
|
||||
this.opened.next();
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './dropdown';
|
||||
export * from './dropdown.module';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './overlay-stepper';
|
||||
export * from './overlay-stepper.module';
|
||||
export * from './refs';
|
||||
export * from './step';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" *ngIf="canAbort" (click)="close()"
|
||||
class="absolute top-0 right-0 w-5 h-5 -mt-2 -mr-2 opacity-75 cursor-pointer hover:opacity-100" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
|
||||
<div class="flex-grow py-4 mb-4" [@moveInOut]="portal.hasAttached()">
|
||||
<ng-container cdkPortalOutlet #portal="cdkPortalOutlet"></ng-container>
|
||||
</div>
|
||||
|
||||
<ng-template [ngIf]="!!currentStep">
|
||||
<ng-container *ngTemplateOutlet="currentStep?.buttonTemplate || defaultButtonTemplate"></ng-container>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #defaultButtonTemplate>
|
||||
<div class="flex flex-row justify-between">
|
||||
<button class="w-32 py-2" (click)="goBack()">Go Back</button>
|
||||
<button class="w-32 py-2 custom bg-blue hover:bg-blue hover:bg-opacity-75 active:shadow-inner"
|
||||
[disabled]="(currentStep?.validChange | async) === false" (click)="next()">
|
||||
{{ currentStep?.nextButtonLabel || (!isLast ? 'Next' : 'Finish') }}</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,261 @@
|
||||
import { animate, style, transition, trigger } from "@angular/animations";
|
||||
import { CdkPortalOutlet, ComponentPortal, ComponentType } from "@angular/cdk/portal";
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, Inject, InjectionToken, Injector, isDevMode, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { Subject } from "rxjs";
|
||||
import { SfngDialogRef, SFNG_DIALOG_REF } from "../dialog";
|
||||
import { StepperControl, StepRef, STEP_REF } from "./refs";
|
||||
import { Step, StepperConfig } from "./step";
|
||||
import { StepOutletComponent, STEP_ANIMATION_DIRECTION, STEP_PORTAL } from "./step-outlet";
|
||||
|
||||
/**
|
||||
* STEP_CONFIG is used to inject the StepperConfig into the OverlayStepperContainer.
|
||||
*/
|
||||
export const STEP_CONFIG = new InjectionToken<StepperConfig>('StepperConfig');
|
||||
|
||||
@Component({
|
||||
templateUrl: './overlay-stepper-container.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 600px;
|
||||
}
|
||||
`
|
||||
],
|
||||
animations: [
|
||||
trigger(
|
||||
'moveInOut',
|
||||
[
|
||||
transition(
|
||||
':enter',
|
||||
[
|
||||
style({ opacity: 0, transform: 'translateX({{ in }})' }),
|
||||
animate('.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
style({ opacity: 1, transform: 'translateX(0%)' }))
|
||||
],
|
||||
{ params: { in: '100%' } } // default parameters
|
||||
),
|
||||
transition(
|
||||
':leave',
|
||||
[
|
||||
style({ opacity: 1 }),
|
||||
animate('.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
style({ opacity: 0, transform: 'translateX({{ out }})' }))
|
||||
],
|
||||
{ params: { out: '-100%' } } // default parameters
|
||||
)
|
||||
]
|
||||
)]
|
||||
})
|
||||
export class OverlayStepperContainerComponent implements OnInit, OnDestroy, StepperControl {
|
||||
/** Used to keep cache the stepRef instances. See documentation for {@class StepRef} */
|
||||
private stepRefCache = new Map<number, StepRef>();
|
||||
|
||||
/** Used to emit when the stepper finished. This is always folled by emitting on onClose$ */
|
||||
private onFinish$ = new Subject<void>();
|
||||
|
||||
/** Emits when the stepper finished - also see {@link OverlayStepperContainerComponent.onClose}*/
|
||||
get onFinish() {
|
||||
return this.onFinish$.asObservable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits when the stepper is closed.
|
||||
* If the stepper if finished then onFinish will emit first
|
||||
*/
|
||||
get onClose() {
|
||||
return this.dialogRef.onClose;
|
||||
}
|
||||
|
||||
/** The index of the currently displayed step */
|
||||
currentStepIndex = -1;
|
||||
|
||||
/** The component instance of the current step */
|
||||
currentStep: Step | null = null;
|
||||
|
||||
/** A reference to the portalOutlet used to render our steps */
|
||||
@ViewChild(CdkPortalOutlet, { static: true })
|
||||
portalOutlet!: CdkPortalOutlet;
|
||||
|
||||
/** Whether or not the user can go back */
|
||||
canGoBack = false;
|
||||
|
||||
/** Whether or not the user can abort and close the stepper */
|
||||
canAbort = false;
|
||||
|
||||
/** Whether the current step is the last step */
|
||||
get isLast() {
|
||||
return this.currentStepIndex + 1 >= this.config.steps.length;
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(STEP_CONFIG) public readonly config: StepperConfig,
|
||||
@Inject(SFNG_DIALOG_REF) public readonly dialogRef: SfngDialogRef<void>,
|
||||
private injector: Injector,
|
||||
private cdr: ChangeDetectorRef
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Moves forward to the next step or closes the stepper
|
||||
* when moving beyond the last one.
|
||||
*/
|
||||
next(): Promise<void> {
|
||||
if (this.isLast) {
|
||||
this.onFinish$.next();
|
||||
this.close();
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return this.attachStep(this.currentStepIndex + 1, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves back to the previous step. This does not take canGoBack
|
||||
* into account.
|
||||
*/
|
||||
goBack(): Promise<void> {
|
||||
return this.attachStep(this.currentStepIndex - 1, false)
|
||||
}
|
||||
|
||||
|
||||
/** Closes the stepper - this does not run the onFinish hooks of the steps */
|
||||
async close(): Promise<void> {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.next();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.onFinish$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a new step component in the current outlet. It detaches any previous
|
||||
* step and calls onBeforeBack and onBeforeNext respectively.
|
||||
*
|
||||
* @param index The index of the new step to attach.
|
||||
* @param forward Whether or not the new step is attached by going "forward" or "backward"
|
||||
* @returns
|
||||
*/
|
||||
private async attachStep(index: number, forward = true) {
|
||||
if (index >= this.config.steps.length) {
|
||||
if (isDevMode()) {
|
||||
throw new Error(`Cannot attach step at ${index}: index out of range`)
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// call onBeforeNext or onBeforeBack of the current step
|
||||
if (this.currentStep) {
|
||||
if (forward) {
|
||||
if (!!this.currentStep.onBeforeNext) {
|
||||
try {
|
||||
await this.currentStep.onBeforeNext();
|
||||
} catch (err) {
|
||||
console.error(`Failed to move to next step`, err)
|
||||
// TODO(ppacher): display error
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!!this.currentStep.onBeforeBack) {
|
||||
try {
|
||||
await this.currentStep.onBeforeBack()
|
||||
} catch (err) {
|
||||
console.error(`Step onBeforeBack callback failed`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// detach the current step component.
|
||||
this.portalOutlet.detach();
|
||||
}
|
||||
|
||||
const stepType = this.config.steps[index];
|
||||
const contentPortal = this.createStepContentPortal(stepType, index)
|
||||
const outletPortal = this.createStepOutletPortal(contentPortal, forward ? 'right' : 'left')
|
||||
|
||||
// attach the new step (which is wrapped in a StepOutletComponent).
|
||||
const ref = this.portalOutlet.attachComponentPortal(outletPortal);
|
||||
|
||||
// We need to wait for the step to be actually attached in the outlet
|
||||
// to get access to the actual step component instance.
|
||||
ref.instance.portalOutlet!.attached
|
||||
.subscribe((stepRef: ComponentRef<Step>) => {
|
||||
this.currentStep = stepRef.instance;
|
||||
this.currentStepIndex = index;
|
||||
|
||||
if (typeof this.config.canAbort === 'function') {
|
||||
this.canAbort = this.config.canAbort(this.currentStepIndex, this.currentStep);
|
||||
}
|
||||
|
||||
// make sure we trigger a change-detection cycle now
|
||||
// markForCheck() is not enough here as we need a CD to run
|
||||
// immediately for the Step.buttonTemplate to be accounted for correctly.
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new component portal for a step and provides access to the {@class StepRef}
|
||||
* using dependency injection.
|
||||
*
|
||||
* @param stepType The component type of the step for which a new portal should be created.
|
||||
* @param index The index of the current step. Used to create/cache the {@class StepRef}
|
||||
*/
|
||||
private createStepContentPortal(stepType: ComponentType<Step>, index: number): ComponentPortal<Step> {
|
||||
let stepRef = this.stepRefCache.get(index);
|
||||
if (stepRef === undefined) {
|
||||
stepRef = new StepRef(index, this)
|
||||
this.stepRefCache.set(index, stepRef);
|
||||
}
|
||||
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: STEP_REF,
|
||||
useValue: stepRef,
|
||||
}
|
||||
],
|
||||
parent: this.config.injector || this.injector,
|
||||
})
|
||||
|
||||
return new ComponentPortal(stepType, undefined, injector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new component portal for a step outlet component that will attach another content
|
||||
* portal and wrap the attachment in a "move in" animation for a given direction.
|
||||
*
|
||||
* @param contentPortal The portal of the actual content that should be attached in the outlet
|
||||
* @param dir The direction for the animation of the step outlet.
|
||||
*/
|
||||
private createStepOutletPortal(contentPortal: ComponentPortal<Step>, dir: 'left' | 'right'): ComponentPortal<StepOutletComponent> {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: STEP_PORTAL,
|
||||
useValue: contentPortal,
|
||||
},
|
||||
{
|
||||
provide: STEP_ANIMATION_DIRECTION,
|
||||
useValue: dir,
|
||||
},
|
||||
],
|
||||
parent: this.injector,
|
||||
})
|
||||
|
||||
return new ComponentPortal(
|
||||
StepOutletComponent,
|
||||
undefined,
|
||||
injector,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { OverlayModule } from "@angular/cdk/overlay";
|
||||
import { PortalModule } from "@angular/cdk/portal";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { SfngDialogModule } from "../dialog";
|
||||
import { OverlayStepperContainerComponent } from "./overlay-stepper-container";
|
||||
import { StepOutletComponent } from "./step-outlet";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
PortalModule,
|
||||
OverlayModule,
|
||||
SfngDialogModule,
|
||||
],
|
||||
declarations: [
|
||||
OverlayStepperContainerComponent,
|
||||
StepOutletComponent,
|
||||
]
|
||||
})
|
||||
export class OverlayStepperModule { }
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ComponentRef, Injectable, Injector } from "@angular/core";
|
||||
import { SfngDialogService } from "../dialog";
|
||||
import { OverlayStepperContainerComponent, STEP_CONFIG } from "./overlay-stepper-container";
|
||||
import { OverlayStepperModule } from "./overlay-stepper.module";
|
||||
import { StepperRef } from "./refs";
|
||||
import { StepperConfig } from "./step";
|
||||
|
||||
@Injectable({ providedIn: OverlayStepperModule })
|
||||
export class OverlayStepper {
|
||||
constructor(
|
||||
private injector: Injector,
|
||||
private dialog: SfngDialogService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Creates a new overlay stepper given it's configuration and returns
|
||||
* a reference to the stepper that can be used to wait for or control
|
||||
* the stepper from outside.
|
||||
*
|
||||
* @param config The configuration for the overlay stepper.
|
||||
*/
|
||||
create(config: StepperConfig): StepperRef {
|
||||
// create a new injector for our OverlayStepperContainer
|
||||
// that holds a reference to the StepperConfig.
|
||||
const injector = this.createInjector(config);
|
||||
|
||||
const dialogRef = this.dialog.create(OverlayStepperContainerComponent, {
|
||||
injector: injector,
|
||||
autoclose: false,
|
||||
backdrop: 'light',
|
||||
dragable: false,
|
||||
})
|
||||
|
||||
const containerComponentRef = dialogRef.contentRef() as ComponentRef<OverlayStepperContainerComponent>;
|
||||
|
||||
return new StepperRef(containerComponentRef.instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new dependency injector that provides access to the
|
||||
* stepper configuration using the STEP_CONFIG injection token.
|
||||
*
|
||||
* @param config The stepper configuration to provide using DI
|
||||
* @returns
|
||||
*/
|
||||
private createInjector(config: StepperConfig): Injector {
|
||||
return Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: STEP_CONFIG,
|
||||
useValue: config,
|
||||
},
|
||||
],
|
||||
parent: this.injector,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { InjectionToken } from "@angular/core";
|
||||
import { Observable } from "rxjs";
|
||||
import { take } from "rxjs/operators";
|
||||
import { OverlayStepperContainerComponent } from "./overlay-stepper-container";
|
||||
|
||||
/**
|
||||
* STEP_REF is the injection token that is used to provide a reference to the
|
||||
* Stepper to each step.
|
||||
*/
|
||||
export const STEP_REF = new InjectionToken<StepRef<any>>('StepRef')
|
||||
|
||||
export interface StepperControl {
|
||||
/**
|
||||
* Next should move the stepper forward to the next
|
||||
* step or close the stepper if no more steps are
|
||||
* available.
|
||||
* If the stepper is closed this way all onFinish hooks
|
||||
* registered at {@link StepRef} are executed.
|
||||
*/
|
||||
next(): Promise<void>;
|
||||
|
||||
/**
|
||||
* goBack should move the stepper back to the previous
|
||||
* step. This is a no-op if there's no previous step to
|
||||
* display.
|
||||
*/
|
||||
goBack(): Promise<void>;
|
||||
|
||||
/**
|
||||
* close closes the stepper but does not run any onFinish hooks
|
||||
* of {@link StepRef}.
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* StepRef is a reference to the overlay stepper and can be used to control, abort
|
||||
* or otherwise interact with the stepper.
|
||||
*
|
||||
* It is made available to individual steps using the STEP_REF injection token.
|
||||
* Each step in the OverlayStepper receives it's own StepRef instance and will receive
|
||||
* a reference to the same instance in case the user goes back and re-opens a step
|
||||
* again.
|
||||
*
|
||||
* Steps should therefore store any configuration data that is needed to restore
|
||||
* the previous view in the StepRef using it's save() and load() methods.
|
||||
*/
|
||||
export class StepRef<T = any> implements StepperControl {
|
||||
private onFinishHooks: (() => PromiseLike<void> | void)[] = [];
|
||||
private data: T | null = null;
|
||||
|
||||
constructor(
|
||||
private currentStepIndex: number,
|
||||
private stepContainerRef: OverlayStepperContainerComponent,
|
||||
) {
|
||||
this.stepContainerRef.onFinish
|
||||
.pipe(take(1))
|
||||
.subscribe(() => this.runOnFinishHooks)
|
||||
}
|
||||
|
||||
next(): Promise<void> {
|
||||
return this.stepContainerRef.next();
|
||||
}
|
||||
|
||||
goBack(): Promise<void> {
|
||||
return this.stepContainerRef.goBack();
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.stepContainerRef.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save saves data of the current step in the stepper session.
|
||||
* This data is saved in case the user decides to "go back" to
|
||||
* to a previous step so the old view can be restored.
|
||||
*
|
||||
* @param data The data to save in the stepper session.
|
||||
*/
|
||||
save(data: T): void {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load returns the data previously stored using save(). The
|
||||
* StepperRef automatically makes sure the correct data is returned
|
||||
* for the current step.
|
||||
*/
|
||||
load(): T | null {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* registerOnFinish registers fn to be called when the last step
|
||||
* completes and the stepper is going to finish.
|
||||
*/
|
||||
registerOnFinish(fn: () => PromiseLike<void> | void) {
|
||||
this.onFinishHooks.push(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes all onFinishHooks in the order they have been defined
|
||||
* and waits for each hook to complete.
|
||||
*/
|
||||
private async runOnFinishHooks() {
|
||||
for (let i = 0; i < this.onFinishHooks.length; i++) {
|
||||
let res = this.onFinishHooks[i]();
|
||||
if (typeof res === 'object' && 'then' in res) {
|
||||
// res is a PromiseLike so wait for it
|
||||
try {
|
||||
await res;
|
||||
} catch (err) {
|
||||
console.error(`Failed to execute on-finish hook of step ${this.currentStepIndex}: `, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class StepperRef implements StepperControl {
|
||||
constructor(private stepContainerRef: OverlayStepperContainerComponent) { }
|
||||
|
||||
next(): Promise<void> {
|
||||
return this.stepContainerRef.next();
|
||||
}
|
||||
|
||||
goBack(): Promise<void> {
|
||||
return this.stepContainerRef.goBack();
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.stepContainerRef.close();
|
||||
}
|
||||
|
||||
get onFinish(): Observable<void> {
|
||||
return this.stepContainerRef.onFinish;
|
||||
}
|
||||
|
||||
get onClose(): Observable<void> {
|
||||
return this.stepContainerRef.onClose;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { animate, style, transition, trigger } from "@angular/animations";
|
||||
import { CdkPortalOutlet, ComponentPortal } from "@angular/cdk/portal";
|
||||
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, Inject, InjectionToken, ViewChild } from "@angular/core";
|
||||
import { Step } from "./step";
|
||||
|
||||
export const STEP_PORTAL = new InjectionToken<ComponentPortal<Step>>('STEP_PORTAL')
|
||||
export const STEP_ANIMATION_DIRECTION = new InjectionToken<'left' | 'right'>('STEP_ANIMATION_DIRECTION');
|
||||
|
||||
/**
|
||||
* A simple wrapper component around CdkPortalOutlet to add nice
|
||||
* move animations.
|
||||
*/
|
||||
@Component({
|
||||
template: `
|
||||
<div [@moveInOut]="{value: _appAnimate, params: {in: in, out: out}}" class="flex flex-col overflow-auto">
|
||||
<ng-template [cdkPortalOutlet]="portal"></ng-template>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
`
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [
|
||||
trigger(
|
||||
'moveInOut',
|
||||
[
|
||||
transition(
|
||||
':enter',
|
||||
[
|
||||
style({ opacity: 0, transform: 'translateX({{ in }})' }),
|
||||
animate('.2s ease-in',
|
||||
style({ opacity: 1, transform: 'translateX(0%)' }))
|
||||
],
|
||||
{ params: { in: '100%' } } // default parameters
|
||||
),
|
||||
transition(
|
||||
':leave',
|
||||
[
|
||||
style({ opacity: 1 }),
|
||||
animate('.2s ease-out',
|
||||
style({ opacity: 0, transform: 'translateX({{ out }})' }))
|
||||
],
|
||||
{ params: { out: '-100%' } } // default parameters
|
||||
)
|
||||
]
|
||||
)]
|
||||
})
|
||||
export class StepOutletComponent implements AfterViewInit {
|
||||
/** @private - Whether or not the animation should run. */
|
||||
_appAnimate = false;
|
||||
|
||||
/** The actual step instance that has been attached. */
|
||||
stepInstance: ComponentRef<Step> | null = null;
|
||||
|
||||
/** @private - used in animation interpolation for translateX */
|
||||
get in() {
|
||||
return this._animateDirection == 'left' ? '-100%' : '100%'
|
||||
}
|
||||
|
||||
/** @private - used in animation interpolation for traslateX */
|
||||
get out() {
|
||||
return this._animateDirection == 'left' ? '100%' : '-100%'
|
||||
}
|
||||
|
||||
/** The portal outlet in our view used to attach the step */
|
||||
@ViewChild(CdkPortalOutlet, { static: true })
|
||||
portalOutlet!: CdkPortalOutlet;
|
||||
|
||||
constructor(
|
||||
@Inject(STEP_PORTAL) public portal: ComponentPortal<Step>,
|
||||
@Inject(STEP_ANIMATION_DIRECTION) public _animateDirection: 'left' | 'right',
|
||||
private cdr: ChangeDetectorRef
|
||||
) { }
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.portalOutlet?.attached
|
||||
.subscribe(ref => {
|
||||
this.stepInstance = ref as ComponentRef<Step>;
|
||||
|
||||
this._appAnimate = true;
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Injector, TemplateRef, Type } from "@angular/core";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export interface Step {
|
||||
/**
|
||||
* validChange should emit true or false when the current step
|
||||
* is valid and the "next" button should be visible.
|
||||
*/
|
||||
validChange: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* onBeforeBack, if it exists, is called when the user
|
||||
* clicks the "Go Back" button but before the current step
|
||||
* is unloaded.
|
||||
*
|
||||
* The OverlayStepper will wait for the callback to resolve or
|
||||
* reject but will not abort going back!
|
||||
*/
|
||||
onBeforeBack?: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* onBeforeNext, if it exists, is called when the user
|
||||
* clicks the "Next" button but before the current step
|
||||
* is unloaded.
|
||||
*
|
||||
* The OverlayStepper willw ait for the callback to resolve
|
||||
* or reject. If it rejects the current step will not be unloaded
|
||||
* and the rejected error will be displayed to the user.
|
||||
*/
|
||||
onBeforeNext?: () => Promise<void>;
|
||||
|
||||
/**
|
||||
* nextButtonLabel can overwrite the label for the "Next" button.
|
||||
*/
|
||||
nextButtonLabel?: string;
|
||||
|
||||
/**
|
||||
* buttonTemplate may hold a tempalte ref that is rendered instead
|
||||
* of the default button row with a "Go Back" and a "Next" button.
|
||||
* Note that if set, the step component must make sure to handle
|
||||
* navigation itself. See {@class StepRef} for more information on how
|
||||
* to control the stepper.
|
||||
*/
|
||||
buttonTemplate?: TemplateRef<any>;
|
||||
}
|
||||
|
||||
export interface StepperConfig {
|
||||
/**
|
||||
* canAbort can be set to a function that is called
|
||||
* for each step to determine if the stepper is abortable.
|
||||
*/
|
||||
canAbort?: (idx: number, step: Step) => boolean;
|
||||
|
||||
/** steps holds the list of steps to execute */
|
||||
steps: Array<Type<Step>>
|
||||
|
||||
/**
|
||||
* injector, if set, defines the parent injector used to
|
||||
* create dedicated instances of the step types.
|
||||
*/
|
||||
injector?: Injector;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
sfng-pagination {
|
||||
.pagination {
|
||||
@apply my-2 w-full flex justify-between;
|
||||
|
||||
button {
|
||||
@apply text-xxs px-2 flex items-center justify-start;
|
||||
|
||||
&.page {
|
||||
@apply bg-cards-secondary;
|
||||
@apply opacity-50;
|
||||
|
||||
&:hover {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
&.active-page {
|
||||
@apply text-blue font-medium opacity-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
import { BehaviorSubject, Observable, Subscription } from "rxjs";
|
||||
import { Pagination, clipPage } from "./pagination";
|
||||
|
||||
export interface Datasource<T> {
|
||||
// view should emit all items in the given page using the specified page number.
|
||||
view(page: number, pageSize: number): Observable<T[]>;
|
||||
}
|
||||
|
||||
export class DynamicItemsPaginator<T> implements Pagination<T> {
|
||||
private _total = 0;
|
||||
private _pageNumber$ = new BehaviorSubject<number>(1);
|
||||
private _pageItems$ = new BehaviorSubject<T[]>([]);
|
||||
private _pageLoading$ = new BehaviorSubject<boolean>(false);
|
||||
private _pageSubscription = Subscription.EMPTY;
|
||||
|
||||
/** Returns the number of total pages. */
|
||||
get total() { return this._total; }
|
||||
|
||||
/** Emits the current page number */
|
||||
get pageNumber$() { return this._pageNumber$.asObservable() }
|
||||
|
||||
/** Emits all items of the current page */
|
||||
get pageItems$() { return this._pageItems$.asObservable() }
|
||||
|
||||
/** Emits whether or not we're loading the next page */
|
||||
get pageLoading$() { return this._pageLoading$.asObservable() }
|
||||
|
||||
constructor(
|
||||
private source: Datasource<T>,
|
||||
public readonly pageSize = 25,
|
||||
) { }
|
||||
|
||||
reset(newTotal: number) {
|
||||
this._total = Math.ceil(newTotal / this.pageSize);
|
||||
this.openPage(1);
|
||||
}
|
||||
|
||||
/** Clear resets the current total and emits an empty item set. */
|
||||
clear() {
|
||||
this._total = 0;
|
||||
this._pageItems$.next([]);
|
||||
this._pageNumber$.next(1);
|
||||
this._pageSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
openPage(pageNumber: number): void {
|
||||
pageNumber = clipPage(pageNumber, this.total);
|
||||
this._pageLoading$.next(true);
|
||||
|
||||
this._pageSubscription.unsubscribe()
|
||||
this._pageSubscription = this.source.view(pageNumber, this.pageSize)
|
||||
.subscribe({
|
||||
next: results => {
|
||||
this._pageLoading$.next(false);
|
||||
this._pageItems$.next(results);
|
||||
this._pageNumber$.next(pageNumber);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
nextPage(): void { this.openPage(this._pageNumber$.getValue() + 1) }
|
||||
prevPage(): void { this.openPage(this._pageNumber$.getValue() - 1) }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './dynamic-items-paginator';
|
||||
export * from './pagination';
|
||||
export * from './pagination.module';
|
||||
export * from './snapshot-paginator';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<!-- Pagination template -->
|
||||
<ng-template #paginationTpl>
|
||||
<div class="pagination" *ngIf="source!.total > 1">
|
||||
<button class="btn-outline" [disabled]="currentPageIdx === 1" (click)="source!.prevPage()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="inline-block w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
Previous
|
||||
</button>
|
||||
<span class="flex flex-row items-center gap-1">
|
||||
<button class="page" *ngFor="let page of pageNumbers" [class.active-page]="page === currentPageIdx"
|
||||
(click)="source!.openPage(page)">{{ page }}</button>
|
||||
</span>
|
||||
<button class="btn-outline" [disabled]="currentPageIdx+1 > source!.total" (click)="source!.nextPage()">
|
||||
Next
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
<!-- End Pagination Template -->
|
||||
|
||||
<ng-container *ngIf="!!content && !!source">
|
||||
<ng-container *ngTemplateOutlet="paginationTpl"></ng-container>
|
||||
<ng-container *ngTemplateOutlet="content!.templateRef">
|
||||
</ng-container>
|
||||
<ng-container *ngTemplateOutlet="paginationTpl"></ng-container>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { SfngPaginationContentDirective } from ".";
|
||||
import { SfngPaginationWrapperComponent } from "./pagination";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
],
|
||||
declarations: [
|
||||
SfngPaginationContentDirective,
|
||||
SfngPaginationWrapperComponent,
|
||||
],
|
||||
exports: [
|
||||
SfngPaginationContentDirective,
|
||||
SfngPaginationWrapperComponent,
|
||||
],
|
||||
})
|
||||
export class SfngPaginationModule { }
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, Directive, EventEmitter, Input, OnChanges, OnDestroy, Output, SimpleChanges, TemplateRef } from "@angular/core";
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
|
||||
export interface Pagination<T> {
|
||||
/**
|
||||
* Total should return the total number of pages
|
||||
*/
|
||||
total: number;
|
||||
|
||||
/**
|
||||
* pageNumber$ should emit the currently displayed page
|
||||
*/
|
||||
pageNumber$: Observable<number>;
|
||||
|
||||
/**
|
||||
* pageItems$ should emit all items of the current page
|
||||
*/
|
||||
pageItems$: Observable<T[]>;
|
||||
|
||||
/**
|
||||
* nextPage should progress to the next page. If there are no more
|
||||
* pages than nextPage() should be a no-op.
|
||||
*/
|
||||
nextPage(): void;
|
||||
|
||||
/**
|
||||
* prevPage should move back the the previous page. If there is no
|
||||
* previous page, prevPage should be a no-op.
|
||||
*/
|
||||
prevPage(): void;
|
||||
|
||||
/**
|
||||
* openPage opens the page @pageNumber. If pageNumber is greater than
|
||||
* the total amount of pages it is clipped to the lastPage. If it is
|
||||
* less than 1, it is clipped to 1.
|
||||
*/
|
||||
openPage(pageNumber: number): void
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Directive({
|
||||
selector: '[sfngPageContent]'
|
||||
})
|
||||
export class SfngPaginationContentDirective<T = any> {
|
||||
constructor(public readonly templateRef: TemplateRef<T>) { }
|
||||
}
|
||||
|
||||
export interface PageChangeEvent {
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-pagination',
|
||||
templateUrl: './pagination.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SfngPaginationWrapperComponent<T = any> implements OnChanges, OnDestroy {
|
||||
private _sub: Subscription = Subscription.EMPTY;
|
||||
|
||||
@Input()
|
||||
source: Pagination<T> | null = null;
|
||||
|
||||
@Output()
|
||||
pageChange = new EventEmitter<PageChangeEvent>();
|
||||
|
||||
@ContentChild(SfngPaginationContentDirective)
|
||||
content: SfngPaginationContentDirective | null = null;
|
||||
|
||||
currentPageIdx: number = 0;
|
||||
pageNumbers: number[] = [];
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if ('source' in changes) {
|
||||
this.subscribeToSource(changes.source.currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this._sub.unsubscribe();
|
||||
}
|
||||
|
||||
private subscribeToSource(source: Pagination<T>) {
|
||||
// Unsubscribe from the previous pagination, if any
|
||||
this._sub.unsubscribe();
|
||||
|
||||
this._sub = new Subscription();
|
||||
|
||||
this._sub.add(
|
||||
source.pageNumber$
|
||||
.subscribe(current => {
|
||||
this.currentPageIdx = current;
|
||||
this.pageNumbers = generatePageNumbers(current - 1, source.total);
|
||||
this.cdr.markForCheck();
|
||||
|
||||
this.pageChange.next({
|
||||
totalPages: source.total,
|
||||
currentPage: current,
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
constructor(private cdr: ChangeDetectorRef) { }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an array of page numbers that should be displayed in paginations.
|
||||
*
|
||||
* @param current The current page number
|
||||
* @param countPages The total number of pages
|
||||
* @returns An array of page numbers to display
|
||||
*/
|
||||
export function generatePageNumbers(current: number, countPages: number): number[] {
|
||||
let delta = 2;
|
||||
let leftRange = current - delta;
|
||||
let rightRange = current + delta + 1;
|
||||
|
||||
return Array.from({ length: countPages }, (v, k) => k + 1)
|
||||
.filter(i => i === 1 || i === countPages || (i >= leftRange && i < rightRange));
|
||||
}
|
||||
|
||||
export function clipPage(pageNumber: number, total: number): number {
|
||||
if (pageNumber < 1) {
|
||||
return 1;
|
||||
}
|
||||
if (pageNumber > total) {
|
||||
return total;
|
||||
}
|
||||
return pageNumber;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
import { debounceTime, map } from "rxjs/operators";
|
||||
import { clipPage, Pagination } from "./pagination";
|
||||
|
||||
export class SnapshotPaginator<T> implements Pagination<T> {
|
||||
private _itemSnapshot: T[] = [];
|
||||
private _activePageItems = new BehaviorSubject<T[]>([]);
|
||||
private _totalPages = 1;
|
||||
private _updatePending = false;
|
||||
|
||||
constructor(
|
||||
public items$: Observable<T[]>,
|
||||
public readonly pageSize: number,
|
||||
) {
|
||||
items$
|
||||
.pipe(debounceTime(100))
|
||||
.subscribe(data => {
|
||||
this._itemSnapshot = data;
|
||||
this.openPage(this._currentPage.getValue());
|
||||
});
|
||||
|
||||
this._currentPage
|
||||
.subscribe(page => {
|
||||
this._updatePending = false;
|
||||
const start = this.pageSize * (page - 1);
|
||||
const end = this.pageSize * page;
|
||||
this._totalPages = Math.ceil(this._itemSnapshot.length / this.pageSize) || 1;
|
||||
this._activePageItems.next(this._itemSnapshot.slice(start, end));
|
||||
})
|
||||
}
|
||||
|
||||
private _currentPage = new BehaviorSubject<number>(0);
|
||||
|
||||
get updatePending() {
|
||||
return this._updatePending;
|
||||
}
|
||||
get pageNumber$(): Observable<number> {
|
||||
return this._activePageItems.pipe(map(() => this._currentPage.getValue()));
|
||||
}
|
||||
get pageNumber(): number {
|
||||
return this._currentPage.getValue();
|
||||
}
|
||||
get total(): number {
|
||||
return this._totalPages
|
||||
}
|
||||
get pageItems$(): Observable<T[]> {
|
||||
return this._activePageItems.asObservable();
|
||||
}
|
||||
get pageItems(): T[] {
|
||||
return this._activePageItems.getValue();
|
||||
}
|
||||
get snapshot(): T[] { return this._itemSnapshot };
|
||||
|
||||
reload(): void { this.openPage(this._currentPage.getValue()) }
|
||||
|
||||
nextPage(): void { this.openPage(this._currentPage.getValue() + 1) }
|
||||
|
||||
prevPage(): void { this.openPage(this._currentPage.getValue() - 1) }
|
||||
|
||||
openPage(pageNumber: number): void {
|
||||
pageNumber = clipPage(pageNumber, this.total);
|
||||
this._currentPage.next(pageNumber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
.sfng-select {
|
||||
@apply cursor-pointer relative p-0 flex whitespace-nowrap w-full items-center outline-none self-center overflow-hidden;
|
||||
@apply hover:bg-gray-400;
|
||||
@apply bg-gray-300 border border-gray-300 transition ease-in-out duration-200;
|
||||
|
||||
&.disabled {
|
||||
@apply cursor-not-allowed opacity-75 hover:bg-gray-400;
|
||||
}
|
||||
|
||||
min-width: 6rem;
|
||||
max-width: 12rem;
|
||||
|
||||
&.active {
|
||||
@apply bg-gray-400;
|
||||
|
||||
div.arrow svg {
|
||||
@apply transform -rotate-90;
|
||||
}
|
||||
}
|
||||
|
||||
& > span {
|
||||
@apply flex-grow text-ellipsis inline-block overflow-hidden;
|
||||
@apply px-2;
|
||||
}
|
||||
|
||||
div.arrow {
|
||||
@apply flex flex-row items-center justify-center bg-gray-200 rounded-r-sm;
|
||||
@apply w-5 h-7;
|
||||
|
||||
svg {
|
||||
@apply w-4 m-0 p-0 rotate-90 transform transition ease-in-out duration-100;
|
||||
|
||||
g {
|
||||
@apply text-white;
|
||||
stroke: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sfng-select-dropdown {
|
||||
ul {
|
||||
max-height: 12rem;
|
||||
@apply relative py-1 overflow-auto;
|
||||
|
||||
li {
|
||||
@apply py-2;
|
||||
@apply flex flex-row items-center justify-start gap-1 transition duration-200 ease-in-out cursor-pointer hover:bg-gray-300;
|
||||
}
|
||||
|
||||
li:not(.disabled) {
|
||||
@apply hover:bg-gray-300;
|
||||
}
|
||||
|
||||
li.disabled {
|
||||
@apply cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sfng-select-dropdown.sfng-select-inline {
|
||||
ul {
|
||||
max-height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
sfng-select-item {
|
||||
@apply text-xxs w-full font-medium gap-3 text-primary flex flex-row items-center justify-start;
|
||||
|
||||
&.disabled {
|
||||
@apply opacity-75 cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './item';
|
||||
export * from './select';
|
||||
export * from './select.module';
|
||||
|
||||
64
desktop/angular/projects/safing/ui/src/lib/select/item.ts
Normal file
64
desktop/angular/projects/safing/ui/src/lib/select/item.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ListKeyManagerOption } from '@angular/cdk/a11y';
|
||||
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
import { Component, Directive, HostBinding, Input, Optional, TemplateRef } from '@angular/core';
|
||||
|
||||
export interface SelectOption<T = any> extends ListKeyManagerOption {
|
||||
value: any;
|
||||
selected: boolean;
|
||||
|
||||
data?: T;
|
||||
label?: string;
|
||||
description?: string;
|
||||
templateRef?: TemplateRef<any>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-select-item',
|
||||
template: `<ng-content></ng-content>`,
|
||||
})
|
||||
export class SfngSelectItemComponent implements ListKeyManagerOption {
|
||||
@HostBinding('class.disabled')
|
||||
get disabled() {
|
||||
return this.sfngSelectValue?.disabled || false;
|
||||
}
|
||||
|
||||
getLabel() {
|
||||
return this.sfngSelectValue?.label || '';
|
||||
}
|
||||
|
||||
constructor(@Optional() private sfngSelectValue: SfngSelectValueDirective) { }
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: '[sfngSelectValue]',
|
||||
})
|
||||
export class SfngSelectValueDirective<T = any> implements SelectOption<T> {
|
||||
@Input('sfngSelectValue')
|
||||
value: any;
|
||||
|
||||
@Input('sfngSelectValueLabel')
|
||||
label?: string;
|
||||
|
||||
@Input('sfngSelectValueData')
|
||||
data?: T;
|
||||
|
||||
@Input('sfngSelectValueDescription')
|
||||
description = '';
|
||||
|
||||
@Input('sfngSelectValueDisabled')
|
||||
set disabled(v: any) {
|
||||
this._disabled = coerceBooleanProperty(v)
|
||||
}
|
||||
get disabled() { return this._disabled }
|
||||
private _disabled = false;
|
||||
|
||||
getLabel() {
|
||||
return this.label || ('' + this.value);
|
||||
}
|
||||
|
||||
/** Whether or not the item is currently selected */
|
||||
selected = false;
|
||||
|
||||
constructor(public templateRef: TemplateRef<any>) { }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<ng-template #customTriggerTemplate>
|
||||
<button [class.active]="dropdown?.isOpen" type="button" class="sfng-select" [class.disabled]="disabled">
|
||||
|
||||
<ng-template [ngIf]="mode !== 'multi' || (currentItems.length || 0) <= 1" [ngIfElse]="multiTemplate">
|
||||
<span *ngIf="!currentItems.length; else: itemTemplate">
|
||||
{{ placeholder }}
|
||||
</span>
|
||||
<ng-template #itemTemplate>
|
||||
<span class="flex flex-row items-center justify-start">
|
||||
<ng-template [ngIf]="!!currentItems[0].label" [ngIfElse]="renderTemplate">
|
||||
{{ currentItems[0].label }}
|
||||
</ng-template>
|
||||
<ng-template #renderTemplate>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="currentItems[0].templateRef || dynamicValueTemplate || defaultDynamicValueTemplate; context: {$implicit: currentItems[0]}">
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</span>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #multiTemplate>
|
||||
<span>
|
||||
{{ itemName ? itemName + ': ' : '' }}{{currentItems.length}} selected
|
||||
</span>
|
||||
</ng-template>
|
||||
|
||||
<div class="arrow">
|
||||
<svg viewBox="0 0 24 24" class="arrow-icon">
|
||||
<g fill="none" class="inner">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.2" d="M10 16l4-4-4-4" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #content>
|
||||
<input *ngIf="allowSearch && (userProvidedItems?.length || 0) >= searchItemThreshold" type="text"
|
||||
class="w-full mb-2 rounded-t" [placeholder]="searchPlaceholder" [ngModel]="searchText"
|
||||
(ngModelChange)="onSearch($event)" (keydown)="onKeyDown($event)" (keydown.enter)="onEnter($event)">
|
||||
|
||||
<ul #scrollable>
|
||||
<li *ngFor="let item of items" (click)="selectItem(item)" [sfng-tooltip]="item.description || null"
|
||||
snfgTooltipPosition="left" [class.disabled]="item.disabled" #renderedItem [sfngSelectRenderedListItem]="item"
|
||||
class="pl-1 pr-5">
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="flex-grow-0 flex-shrink-0 w-4 h-4 transition-all duration-200"
|
||||
viewBox="0 0 20 20" fill="currentColor" [class.opacity-0]="!item.selected">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<ng-container
|
||||
*ngTemplateOutlet="item.templateRef || dynamicValueTemplate || defaultDynamicValueTemplate; context: {$implicit: item}">
|
||||
</ng-container>
|
||||
</li>
|
||||
|
||||
<!-- fake item for "dynamic" values -->
|
||||
<li *ngIf="!!searchText && items.length === 0 && dynamicValues"
|
||||
(click)="selectItem({selected: false, value: searchText})" class="pl-1 pr-5">
|
||||
<sfng-select-item>
|
||||
<span>
|
||||
<span class="mx-2 text-tertiary">Add </span> {{ searchText }}
|
||||
</span>
|
||||
</sfng-select-item>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-template>
|
||||
|
||||
<!-- This template displays the overlay content and is connected to the button -->
|
||||
<ng-container [ngSwitch]="displayMode">
|
||||
<sfng-dropdown *ngSwitchCase="'dropdown'" #dropdown="sfngDropdown" [triggerTemplate]="customTriggerTemplate"
|
||||
overlayClass="sfng-select-dropdown" [disabled]="allItems.length === 0 && searchText === '' && disableWhenEmpty"
|
||||
[minWidth]="minWidth" [minHeight]="minHeight" (opened)="onDropdownOpen()" (closed)="onDropdownClose()">
|
||||
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||
</sfng-dropdown>
|
||||
|
||||
<div *ngSwitchCase="'inline'" class="sfng-select-dropdown sfng-select-inline">
|
||||
<ng-container *ngTemplateOutlet="content"></ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
||||
<ng-template #defaultDynamicValueTemplate let-data>
|
||||
<sfng-select-item>{{ data.label || data.value }}</sfng-select-item>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { CdkScrollableModule } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||
import { SfngDropDownModule } from "../dropdown";
|
||||
import { SfngTooltipModule } from "../tooltip";
|
||||
import { SfngSelectItemComponent, SfngSelectValueDirective } from "./item";
|
||||
import { SfngSelectComponent, SfngSelectRenderedItemDirective } from "./select";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
SfngDropDownModule,
|
||||
SfngTooltipModule,
|
||||
CdkScrollableModule
|
||||
],
|
||||
declarations: [
|
||||
SfngSelectComponent,
|
||||
SfngSelectValueDirective,
|
||||
SfngSelectItemComponent,
|
||||
SfngSelectRenderedItemDirective
|
||||
],
|
||||
exports: [
|
||||
SfngSelectComponent,
|
||||
SfngSelectValueDirective,
|
||||
SfngSelectItemComponent,
|
||||
]
|
||||
})
|
||||
export class SfngSelectModule { }
|
||||
495
desktop/angular/projects/safing/ui/src/lib/select/select.ts
Normal file
495
desktop/angular/projects/safing/ui/src/lib/select/select.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import { ListKeyManager, ListKeyManagerOption } from '@angular/cdk/a11y';
|
||||
import { coerceBooleanProperty, coerceNumberProperty } from '@angular/cdk/coercion';
|
||||
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, DestroyRef, Directive, ElementRef, EventEmitter, HostBinding, HostListener, Input, OnDestroy, Output, QueryList, TemplateRef, ViewChild, ViewChildren, forwardRef, inject } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
import { BehaviorSubject, combineLatest } from 'rxjs';
|
||||
import { startWith } from 'rxjs/operators';
|
||||
import { SfngDropdownComponent } from '../dropdown';
|
||||
import { SelectOption, SfngSelectValueDirective } from './item';
|
||||
|
||||
|
||||
export type SelectModes = 'single' | 'multi';
|
||||
|
||||
type ModeInput = {
|
||||
mode: SelectModes;
|
||||
}
|
||||
|
||||
type SelectValue<T, S extends ModeInput> = S['mode'] extends 'single' ? T : T[];
|
||||
|
||||
export type SortByFunc = (a: SelectOption, b: SelectOption) => number;
|
||||
|
||||
export type SelectDisplayMode = 'dropdown' | 'inline';
|
||||
|
||||
@Directive({
|
||||
selector: '[sfngSelectRenderedListItem]'
|
||||
})
|
||||
export class SfngSelectRenderedItemDirective implements ListKeyManagerOption {
|
||||
@Input('sfngSelectRenderedListItem')
|
||||
option: SelectOption | null = null;
|
||||
|
||||
getLabel() {
|
||||
return this.option?.label || '';
|
||||
}
|
||||
|
||||
get disabled() {
|
||||
return this.option?.disabled || false;
|
||||
}
|
||||
|
||||
@HostBinding('class.bg-gray-300')
|
||||
set focused(v: boolean) {
|
||||
this._focused = v;
|
||||
}
|
||||
get focused() { return this._focused }
|
||||
private _focused = false;
|
||||
|
||||
constructor(public readonly elementRef: ElementRef) { }
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-select',
|
||||
templateUrl: './select.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => SfngSelectComponent),
|
||||
multi: true,
|
||||
},
|
||||
]
|
||||
})
|
||||
export class SfngSelectComponent<T> implements AfterViewInit, ControlValueAccessor, OnDestroy {
|
||||
/** emits the search text entered by the user */
|
||||
private search$ = new BehaviorSubject('');
|
||||
|
||||
/** emits and completes when the component is destroyed. */
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
/** the key manager used for keyboard support */
|
||||
private keyManager!: ListKeyManager<SfngSelectRenderedItemDirective>;
|
||||
|
||||
@ViewChild(SfngDropdownComponent, { static: false })
|
||||
dropdown: SfngDropdownComponent | null = null;
|
||||
|
||||
/** A reference to the cdk-scrollable directive that's placed on the item list */
|
||||
@ViewChild('scrollable', { read: ElementRef })
|
||||
scrollableList?: ElementRef;
|
||||
|
||||
@ContentChildren(SfngSelectValueDirective)
|
||||
userProvidedItems!: QueryList<SfngSelectValueDirective>;
|
||||
|
||||
@ViewChildren('renderedItem', { read: SfngSelectRenderedItemDirective })
|
||||
renderedItems!: QueryList<SfngSelectRenderedItemDirective>;
|
||||
|
||||
/** A list of all items available in the select box including dynamic ones. */
|
||||
allItems: SelectOption[] = []
|
||||
|
||||
/** The acutally rendered list of items after applying search and item threshold */
|
||||
items: SelectOption[] = [];
|
||||
|
||||
@Input()
|
||||
@HostBinding('attr.tabindex')
|
||||
readonly tabindex = 0;
|
||||
|
||||
@HostBinding('attr.role')
|
||||
readonly role = 'listbox';
|
||||
|
||||
value?: SelectValue<T, this>;
|
||||
|
||||
/** A list of currently selected items */
|
||||
currentItems: SelectOption[] = [];
|
||||
|
||||
/** The current search text. Used by ngModel */
|
||||
searchText = '';
|
||||
|
||||
/** Whether or not the select operates in "single" or "multi" mode */
|
||||
@Input()
|
||||
mode: SelectModes = 'single';
|
||||
|
||||
@Input()
|
||||
displayMode: SelectDisplayMode = 'dropdown';
|
||||
|
||||
/** The placehodler to show when nothing is selected */
|
||||
@Input()
|
||||
placeholder = 'Select'
|
||||
|
||||
/** The type of item to show in multi mode when more than one value is selected */
|
||||
@Input()
|
||||
itemName = '';
|
||||
|
||||
/** The maximum number of items to render. */
|
||||
@Input()
|
||||
set itemLimit(v: any) {
|
||||
this._maxItemLimit = coerceNumberProperty(v)
|
||||
}
|
||||
get itemLimit(): number { return this._maxItemLimit }
|
||||
private _maxItemLimit = Infinity;
|
||||
|
||||
/** The placeholder text for the search bar */
|
||||
@Input()
|
||||
searchPlaceholder = '';
|
||||
|
||||
/** Whether or not the search bar is visible */
|
||||
@Input()
|
||||
set allowSearch(v: any) {
|
||||
this._allowSearch = coerceBooleanProperty(v);
|
||||
}
|
||||
get allowSearch(): boolean {
|
||||
return this._allowSearch;
|
||||
}
|
||||
private _allowSearch = false;
|
||||
|
||||
/** The minimum number of items required for the search bar to be visible */
|
||||
@Input()
|
||||
set searchItemThreshold(v: any) {
|
||||
this._searchItemThreshold = coerceNumberProperty(v);
|
||||
}
|
||||
get searchItemThreshold(): number {
|
||||
return this._searchItemThreshold;
|
||||
}
|
||||
private _searchItemThreshold = 0;
|
||||
|
||||
/**
|
||||
* Whether or not the select should be disabled when not options
|
||||
* are available.
|
||||
*/
|
||||
@Input()
|
||||
set disableWhenEmpty(v: any) {
|
||||
this._disableWhenEmpty = coerceBooleanProperty(v);
|
||||
}
|
||||
get disableWhenEmpty() {
|
||||
return this._disableWhenEmpty;
|
||||
}
|
||||
private _disableWhenEmpty = false;
|
||||
|
||||
/** Whether or not the select component will add options for dynamic values as well. */
|
||||
@Input()
|
||||
set dynamicValues(v: any) {
|
||||
this._dynamicValues = coerceBooleanProperty(v);
|
||||
}
|
||||
get dynamicValues() {
|
||||
return this._dynamicValues
|
||||
}
|
||||
private _dynamicValues = false;
|
||||
|
||||
/** An optional template to use for dynamic values. */
|
||||
@Input()
|
||||
dynamicValueTemplate?: TemplateRef<any>;
|
||||
|
||||
/** The minimum-width of the drop-down. See {@link SfngDropdownComponent.minWidth} */
|
||||
@Input()
|
||||
minWidth: any;
|
||||
|
||||
/** The minimum-width of the drop-down. See {@link SfngDropdownComponent.minHeight} */
|
||||
@Input()
|
||||
minHeight: any;
|
||||
|
||||
/** Whether or not selected items should be sorted to the top */
|
||||
@Input()
|
||||
set sortValues(v: any) {
|
||||
this._sortValues = coerceBooleanProperty(v);
|
||||
}
|
||||
get sortValues() {
|
||||
if (this._sortValues === null) {
|
||||
return this.mode === 'multi';
|
||||
}
|
||||
return this._sortValues;
|
||||
}
|
||||
private _sortValues: boolean | null = null;
|
||||
|
||||
/** The sort function to use. Defaults to sort by label/value */
|
||||
@Input()
|
||||
sortBy: SortByFunc = (a: SelectOption, b: SelectOption) => {
|
||||
if ((a.label || a.value) < (b.label || b.value)) {
|
||||
return 1;
|
||||
}
|
||||
if ((a.label || a.value) > (b.label || b.value)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Input()
|
||||
set disabled(v: any) {
|
||||
const disabled = coerceBooleanProperty(v);
|
||||
this.setDisabledState(disabled);
|
||||
}
|
||||
get disabled() {
|
||||
return this._disabled;
|
||||
}
|
||||
private _disabled: boolean = false;
|
||||
|
||||
@HostListener('keydown.enter', ['$event'])
|
||||
@HostListener('keydown.space', ['$event'])
|
||||
onEnter(event: Event) {
|
||||
if (!this.dropdown?.isOpen) {
|
||||
this.dropdown?.toggle()
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.keyManager.activeItem !== null && !!this.keyManager.activeItem?.option) {
|
||||
this.selectItem(this.keyManager.activeItem.option)
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('keydown', ['$event'])
|
||||
onKeyDown(event: KeyboardEvent) {
|
||||
this.keyManager.onKeydown(event);
|
||||
}
|
||||
|
||||
@Output()
|
||||
closed = new EventEmitter<void>();
|
||||
|
||||
@Output()
|
||||
opened = new EventEmitter<void>();
|
||||
|
||||
trackItem(_: number, item: SelectOption) {
|
||||
return item.value;
|
||||
}
|
||||
|
||||
setDisabledState(disabled: boolean) {
|
||||
this._disabled = disabled;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
constructor(private cdr: ChangeDetectorRef) { }
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.keyManager = new ListKeyManager(this.renderedItems)
|
||||
.withVerticalOrientation()
|
||||
.withHomeAndEnd()
|
||||
.withWrap()
|
||||
.withTypeAhead();
|
||||
|
||||
this.keyManager.change
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(itemIdx => {
|
||||
this.renderedItems.forEach(item => {
|
||||
item.focused = false;
|
||||
})
|
||||
|
||||
this.keyManager.activeItem!.focused = true;
|
||||
|
||||
// the item might be out-of-view so make sure
|
||||
// we scroll enough to have it inside the view
|
||||
const scrollable = this.scrollableList?.nativeElement;
|
||||
if (!!scrollable) {
|
||||
const active = this.keyManager.activeItem!.elementRef.nativeElement;
|
||||
const activeHeight = active.getBoundingClientRect().height;
|
||||
const bottom = scrollable.scrollTop + scrollable.getBoundingClientRect().height;
|
||||
const top = scrollable.scrollTop;
|
||||
|
||||
let scrollTo = -1;
|
||||
if (active.offsetTop >= bottom) {
|
||||
scrollTo = top + active.offsetTop - bottom + activeHeight;
|
||||
} else if (active.offsetTop < top) {
|
||||
scrollTo = active.offsetTop;
|
||||
}
|
||||
|
||||
if (scrollTo > -1) {
|
||||
scrollable.scrollTo({
|
||||
behavior: 'smooth',
|
||||
top: scrollTo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.cdr.markForCheck();
|
||||
})
|
||||
|
||||
|
||||
combineLatest([
|
||||
this.userProvidedItems!.changes
|
||||
.pipe(startWith(undefined)),
|
||||
this.search$
|
||||
])
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(
|
||||
([_, search]) => {
|
||||
this.updateItems();
|
||||
|
||||
search = (search || '').toLocaleLowerCase()
|
||||
let items: SelectOption[] = [];
|
||||
if (search === '') {
|
||||
items = this.allItems!;
|
||||
} else {
|
||||
items = this.allItems!.filter(item => {
|
||||
// we always count selected items as a "match" in search mode.
|
||||
// this is to ensure the user always see all selected items.
|
||||
if (item.selected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!!item.value && typeof item.value === 'string') {
|
||||
if (item.value.toLocaleLowerCase().includes(search)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!!item.label) {
|
||||
if (item.label.toLocaleLowerCase().includes(search)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
}
|
||||
|
||||
this.items = items.slice(0, this._maxItemLimit);
|
||||
this.keyManager.setActiveItem(0);
|
||||
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.search$.complete();
|
||||
}
|
||||
|
||||
@HostListener('blur')
|
||||
onBlur(): void {
|
||||
this.onTouch();
|
||||
}
|
||||
|
||||
/** @private - called when the internal dropdown opens */
|
||||
onDropdownOpen() {
|
||||
// emit the open event on this component as well
|
||||
this.opened.next();
|
||||
|
||||
// reset the search. We do that when opened instead of closed
|
||||
// to avoid flickering when the component height increases
|
||||
// during the "close" animation
|
||||
this.onSearch('');
|
||||
}
|
||||
|
||||
/** @private - called when the internal dropdown closes */
|
||||
onDropdownClose() {
|
||||
this.closed.next();
|
||||
}
|
||||
|
||||
onSearch(text: string) {
|
||||
this.searchText = text;
|
||||
this.search$.next(text);
|
||||
}
|
||||
|
||||
selectItem(item: SelectOption) {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSelected = this.currentItems.findIndex(selected => item.value === selected.value);
|
||||
if (isSelected === -1) {
|
||||
item.selected = true;
|
||||
|
||||
if (this.mode === 'single') {
|
||||
this.currentItems.forEach(i => i.selected = false);
|
||||
this.currentItems = [item];
|
||||
this.value = item.value;
|
||||
} else {
|
||||
this.currentItems.push(item);
|
||||
// TODO(ppacher): somehow typescript does not correctly pick up
|
||||
// the type of this.value here although it can be infered from the
|
||||
// mode === 'single' check above.
|
||||
this.value = [
|
||||
...(this.value || []) as any,
|
||||
item.value,
|
||||
] as any
|
||||
}
|
||||
} else if (this.mode !== 'single') { // "unselecting" a value is not allowed in single mode
|
||||
this.currentItems.splice(isSelected, 1)
|
||||
item.selected = false;
|
||||
// same note about typescript as above.
|
||||
this.value = (this.value as T[]).filter(val => val !== item.value) as any;
|
||||
}
|
||||
|
||||
// only close the drop down in single mode. In multi-mode
|
||||
// we keep it open as the user might want to select an additional
|
||||
// item as well.
|
||||
if (this.mode === 'single') {
|
||||
this.dropdown?.close();
|
||||
}
|
||||
this.onChange(this.value!);
|
||||
}
|
||||
|
||||
private updateItems() {
|
||||
let values: T[] = [];
|
||||
if (this.mode === 'single') {
|
||||
values = [this.value as T];
|
||||
} else {
|
||||
values = (this.value as T[]) || [];
|
||||
}
|
||||
|
||||
this.currentItems = [];
|
||||
this.allItems = [];
|
||||
|
||||
// mark all user-selected items as "deselected" first
|
||||
this.userProvidedItems?.forEach(item => {
|
||||
item.selected = false;
|
||||
this.allItems.push(item);
|
||||
});
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const val = values[i];
|
||||
let option: SelectOption | undefined = this.userProvidedItems?.find(item => item.value === val);
|
||||
if (!option) {
|
||||
if (!this._dynamicValues) {
|
||||
continue
|
||||
}
|
||||
|
||||
option = {
|
||||
selected: true,
|
||||
value: val,
|
||||
label: `${val}`,
|
||||
}
|
||||
this.allItems.push(option);
|
||||
} else {
|
||||
option.selected = true
|
||||
}
|
||||
|
||||
this.currentItems.push(option);
|
||||
}
|
||||
|
||||
if (this.sortValues) {
|
||||
this.allItems.sort((a, b) => {
|
||||
if (b.selected && !a.selected) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (a.selected && !b.selected) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return this.sortBy(a, b)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeValue(value: SelectValue<T, this>): void {
|
||||
this.value = value;
|
||||
|
||||
this.updateItems();
|
||||
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
onChange = (value: SelectValue<T, this>): void => { }
|
||||
registerOnChange(fn: (value: SelectValue<T, this>) => void): void {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
onTouch = (): void => { }
|
||||
registerOnTouched(fn: () => void): void {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
sfng-tab-group {
|
||||
@apply flex flex-col overflow-hidden;
|
||||
}
|
||||
4
desktop/angular/projects/safing/ui/src/lib/tabs/index.ts
Normal file
4
desktop/angular/projects/safing/ui/src/lib/tabs/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { SfngTabComponent, SfngTabContentDirective } from './tab';
|
||||
export { SfngTabContentScrollEvent, SfngTabGroupComponent } from './tab-group';
|
||||
export { SfngTabModule as TabModule } from './tabs.module';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<div *ngIf="!customHeader" class="relative flex flex-row mb-2 border-b outline-none border-secondary" tabindex="0"
|
||||
(keydown)="onKeydown($event)">
|
||||
|
||||
<!-- Tab Group Header -->
|
||||
<div *ngFor="let tab of (tabs$ | async); let index=index"
|
||||
class="flex flex-row items-center justify-center px-4 py-2 space-x-1 cursor-pointer hover:text-primary" #tabHeader
|
||||
[ngClass]="{'text-primary': index === activeTabIndex, 'text-secondary': index !== activeTabIndex}"
|
||||
(click)="activateTab(index)">
|
||||
|
||||
<span>{{ tab.title }}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-yellow-300" fill="none" viewBox="0 0 24 24"
|
||||
*ngIf="tab.warning" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<sfng-tipup [key]="tab.tipUpKey" *ngIf="tab.tipUpKey"></sfng-tipup>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- There are no "transition" classes yet because we add it AFTER the first animation -->
|
||||
<div class="absolute top-0 left-0 border-t border-white opacity-0" #activeTabBar></div>
|
||||
</div>
|
||||
|
||||
<ng-container cdkPortalOutlet></ng-container>
|
||||
352
desktop/angular/projects/safing/ui/src/lib/tabs/tab-group.ts
Normal file
352
desktop/angular/projects/safing/ui/src/lib/tabs/tab-group.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { ListKeyManager } from "@angular/cdk/a11y";
|
||||
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
import { CdkPortalOutlet, ComponentPortal } from "@angular/cdk/portal";
|
||||
import { AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, ContentChildren, DestroyRef, ElementRef, EventEmitter, Injector, Input, OnInit, Output, QueryList, ViewChild, ViewChildren, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { Observable, Subject } from "rxjs";
|
||||
import { distinctUntilChanged, map, startWith } from "rxjs/operators";
|
||||
import { SfngTabComponent, TAB_ANIMATION_DIRECTION, TAB_PORTAL, TAB_SCROLL_HANDLER, TabOutletComponent } from "./tab";
|
||||
|
||||
export interface SfngTabContentScrollEvent {
|
||||
event?: Event;
|
||||
scrollTop: number;
|
||||
previousScrollTop: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab group component for rendering a tab-style navigation with support for
|
||||
* keyboard navigation and type-ahead. Tab content are lazy loaded using a
|
||||
* structural directive.
|
||||
* The tab group component also supports adding the current active tab index
|
||||
* to the active route so it is possible to navigate through tabs using back/forward
|
||||
* keys (browser history) as well.
|
||||
*
|
||||
* Example:
|
||||
* <sfng-tab-group>
|
||||
*
|
||||
* <sfng-tab id="tab1" title="Overview">
|
||||
* <div *sfngTabContent>
|
||||
* Some content
|
||||
* </div>
|
||||
* </sfng-tab>
|
||||
*
|
||||
* <sfng-tab id="tab2" title="Settings">
|
||||
* <div *sfngTabContent>
|
||||
* Some different content
|
||||
* </div>
|
||||
* </sfng-tab>
|
||||
*
|
||||
* </sfng-tab-group>
|
||||
*/
|
||||
@Component({
|
||||
selector: 'sfng-tab-group',
|
||||
templateUrl: './tab-group.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SfngTabGroupComponent implements AfterContentInit, AfterViewInit, OnInit {
|
||||
@ContentChildren(SfngTabComponent)
|
||||
tabs: QueryList<SfngTabComponent> | null = null;
|
||||
|
||||
/** References to all tab header elements */
|
||||
@ViewChildren('tabHeader', { read: ElementRef })
|
||||
tabHeaders: QueryList<ElementRef<HTMLDivElement>> | null = null;
|
||||
|
||||
/** Reference to the active tab bar element */
|
||||
@ViewChild('activeTabBar', { read: ElementRef, static: false })
|
||||
activeTabBar: ElementRef<HTMLDivElement> | null = null;
|
||||
|
||||
/** Reference to the portal outlet that we will use to render a TabOutletComponent. */
|
||||
@ViewChild(CdkPortalOutlet, { static: true })
|
||||
portalOutlet: CdkPortalOutlet | null = null;
|
||||
|
||||
@Output()
|
||||
tabContentScroll = new EventEmitter<SfngTabContentScrollEvent>();
|
||||
|
||||
/** The name of the tab group. Used to update the currently active tab in the route */
|
||||
@Input()
|
||||
name = 'tab'
|
||||
|
||||
@Input()
|
||||
outletClass = '';
|
||||
|
||||
private scrollTop: number = 0;
|
||||
|
||||
/** Whether or not the current tab should be syncronized with the angular router using a query parameter */
|
||||
@Input()
|
||||
set linkRouter(v: any) {
|
||||
this._linkRouter = coerceBooleanProperty(v)
|
||||
}
|
||||
get linkRouter() { return this._linkRouter }
|
||||
private _linkRouter = true;
|
||||
|
||||
/** Whether or not the default tab header should be rendered */
|
||||
@Input()
|
||||
set customHeader(v: any) {
|
||||
this._customHeader = coerceBooleanProperty(v)
|
||||
}
|
||||
get customHeader() { return this._customHeader }
|
||||
private _customHeader = false;
|
||||
|
||||
private tabActivate$ = new Subject<string>();
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
/** Emits the tab QueryList every time there are changes to the content-children */
|
||||
get tabs$() {
|
||||
return this.tabs?.changes
|
||||
.pipe(
|
||||
map(() => this.tabs),
|
||||
startWith(this.tabs)
|
||||
)
|
||||
}
|
||||
|
||||
/** onActivate fires when a tab has been activated. */
|
||||
get onActivate(): Observable<string> { return this.tabActivate$.asObservable() }
|
||||
|
||||
/** the index of the currently active tab. */
|
||||
activeTabIndex = -1;
|
||||
|
||||
/** The key manager used to support keyboard navigation and type-ahead in the tab group */
|
||||
private keymanager: ListKeyManager<SfngTabComponent> | null = null;
|
||||
|
||||
/** Used to force the animation direction when calling activateTab. */
|
||||
private forceAnimationDirection: 'left' | 'right' | null = null;
|
||||
|
||||
/**
|
||||
* pendingTabIdx holds the id or the index of a tab that should be activated after the component
|
||||
* has been bootstrapped. We need to cache this value here because the ActivatedRoute might emit
|
||||
* before we are AfterViewInit.
|
||||
*/
|
||||
private pendingTabIdx: string | null = null;
|
||||
|
||||
constructor(
|
||||
private injector: Injector,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private cdr: ChangeDetectorRef
|
||||
) { }
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Used to forward keyboard events to the keymanager.
|
||||
*/
|
||||
onKeydown(v: KeyboardEvent) {
|
||||
this.keymanager?.onKeydown(v);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.queryParamMap
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(params => params.get(this.name)),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
.subscribe(newIdx => {
|
||||
if (!this._linkRouter) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!!this.keymanager && !!this.tabs) {
|
||||
const actualIndex = this.getIndex(newIdx);
|
||||
if (actualIndex !== null) {
|
||||
this.keymanager.setActiveItem(actualIndex);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
} else {
|
||||
this.pendingTabIdx = newIdx;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ngAfterContentInit(): void {
|
||||
this.keymanager = new ListKeyManager(this.tabs!)
|
||||
.withHomeAndEnd()
|
||||
.withHorizontalOrientation("ltr")
|
||||
.withTypeAhead()
|
||||
.withWrap()
|
||||
|
||||
this.tabs!.changes
|
||||
.subscribe(() => {
|
||||
if (this.portalOutlet?.hasAttached()) {
|
||||
if (this.tabs!.length === 0) {
|
||||
this.portalOutlet.detach();
|
||||
}
|
||||
} else {
|
||||
if (this.tabs!.length > 0) {
|
||||
this.activateTab(0)
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
this.keymanager.change
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(change => {
|
||||
const activeTab = this.tabs!.get(change);
|
||||
if (!!activeTab && !!activeTab.tabContent) {
|
||||
const prevIdx = this.activeTabIndex;
|
||||
|
||||
let animationDirection: 'left' | 'right' = prevIdx < change ? 'left' : 'right';
|
||||
if (this.forceAnimationDirection !== null) {
|
||||
animationDirection = this.forceAnimationDirection;
|
||||
this.forceAnimationDirection = null;
|
||||
}
|
||||
|
||||
if (this.portalOutlet?.attachedRef) {
|
||||
// we know for sure that attachedRef is a ComponentRef of TabOutletComponent
|
||||
const ref = (this.portalOutlet.attachedRef as ComponentRef<TabOutletComponent>)
|
||||
ref.instance._animateDirection = animationDirection;
|
||||
ref.instance.outletClass = this.outletClass;
|
||||
ref.changeDetectorRef.detectChanges();
|
||||
}
|
||||
|
||||
this.portalOutlet?.detach();
|
||||
|
||||
const newOutletPortal = this.createTabOutlet(activeTab, animationDirection);
|
||||
this.activeTabIndex = change;
|
||||
this.tabContentScroll.next({
|
||||
scrollTop: 0,
|
||||
previousScrollTop: this.scrollTop,
|
||||
})
|
||||
|
||||
this.scrollTop = 0;
|
||||
|
||||
this.tabActivate$.next(activeTab.id);
|
||||
this.portalOutlet?.attach(newOutletPortal);
|
||||
|
||||
this.repositionTabBar();
|
||||
|
||||
if (this._linkRouter) {
|
||||
this.router.navigate([], {
|
||||
queryParams: {
|
||||
...this.route.snapshot.queryParams,
|
||||
[this.name]: this.activeTabIndex,
|
||||
}
|
||||
})
|
||||
}
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.pendingTabIdx === null) {
|
||||
// active the first tab that is NOT disabled
|
||||
const firstActivatable = this.tabs?.toArray().findIndex(tap => !tap.disabled);
|
||||
if (firstActivatable !== undefined) {
|
||||
this.keymanager.setActiveItem(firstActivatable);
|
||||
}
|
||||
} else {
|
||||
const idx = this.getIndex(this.pendingTabIdx);
|
||||
if (idx !== null) {
|
||||
this.keymanager.setActiveItem(idx);
|
||||
this.pendingTabIdx = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.repositionTabBar();
|
||||
this.tabHeaders?.changes.subscribe(() => this.repositionTabBar())
|
||||
setTimeout(() => this.repositionTabBar(), 250)
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Activates a new tab
|
||||
*
|
||||
* @param idx The index of the new tab.
|
||||
*/
|
||||
activateTab(idx: number, forceDirection?: 'left' | 'right') {
|
||||
if (forceDirection !== undefined) {
|
||||
this.forceAnimationDirection = forceDirection;
|
||||
}
|
||||
|
||||
this.keymanager?.setActiveItem(idx);
|
||||
}
|
||||
|
||||
private getIndex(newIdx: string | null): number | null {
|
||||
let actualIndex: number = -1;
|
||||
if (!this.tabs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (newIdx === undefined || newIdx === null) { // not present in the URL
|
||||
return null;
|
||||
}
|
||||
if (isNaN(+newIdx)) { // likley the ID of a tab
|
||||
actualIndex = this.tabs?.toArray().findIndex(tab => tab.id === newIdx) || -1;
|
||||
} else { // it's a number as a string
|
||||
actualIndex = +newIdx;
|
||||
}
|
||||
|
||||
if (actualIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
return actualIndex;
|
||||
}
|
||||
|
||||
private repositionTabBar() {
|
||||
if (!this.tabHeaders) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const tabHeader = this.tabHeaders!.get(this.activeTabIndex);
|
||||
if (!tabHeader || !this.activeTabBar) {
|
||||
return;
|
||||
}
|
||||
const rect = tabHeader.nativeElement.getBoundingClientRect();
|
||||
const transform = `translate(${tabHeader.nativeElement.offsetLeft}px, ${tabHeader.nativeElement.offsetTop + rect.height}px)`
|
||||
this.activeTabBar.nativeElement.style.width = `${rect.width}px`
|
||||
this.activeTabBar.nativeElement.style.transform = transform;
|
||||
this.activeTabBar.nativeElement.style.opacity = '1';
|
||||
|
||||
// initialize animations on the active-tab-bar required
|
||||
if (!this.activeTabBar.nativeElement.classList.contains("transition-all")) {
|
||||
// only initialize the transitions if this is the very first "reposition"
|
||||
// this is to prevent the bar from animating to the "bottom" line of the tab
|
||||
// header the first time.
|
||||
requestAnimationFrame(() => {
|
||||
this.activeTabBar?.nativeElement.classList.add("transition-all", "duration-200");
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private createTabOutlet(tab: SfngTabComponent, animationDir: 'left' | 'right'): ComponentPortal<TabOutletComponent> {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
provide: TAB_PORTAL,
|
||||
useValue: tab.tabContent!.portal,
|
||||
},
|
||||
{
|
||||
provide: TAB_ANIMATION_DIRECTION,
|
||||
useValue: animationDir,
|
||||
},
|
||||
{
|
||||
provide: TAB_SCROLL_HANDLER,
|
||||
useValue: (e: Event) => {
|
||||
const newScrollTop = (e.target as HTMLElement).scrollTop;
|
||||
|
||||
tab.tabContentScroll.next(e);
|
||||
this.tabContentScroll.next({
|
||||
event: e,
|
||||
scrollTop: newScrollTop,
|
||||
previousScrollTop: this.scrollTop,
|
||||
});
|
||||
|
||||
this.scrollTop = newScrollTop;
|
||||
}
|
||||
},
|
||||
],
|
||||
parent: this.injector,
|
||||
name: 'TabOutletInjectot',
|
||||
})
|
||||
|
||||
return new ComponentPortal(
|
||||
TabOutletComponent,
|
||||
undefined,
|
||||
injector
|
||||
)
|
||||
}
|
||||
}
|
||||
167
desktop/angular/projects/safing/ui/src/lib/tabs/tab.ts
Normal file
167
desktop/angular/projects/safing/ui/src/lib/tabs/tab.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { animate, style, transition, trigger } from "@angular/animations";
|
||||
import { ListKeyManagerOption } from "@angular/cdk/a11y";
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { CdkPortalOutlet, TemplatePortal } from "@angular/cdk/portal";
|
||||
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, Directive, EventEmitter, Inject, InjectionToken, Input, Output, TemplateRef, ViewChild, ViewContainerRef } from "@angular/core";
|
||||
|
||||
/** TAB_PORTAL is the injection token used to inject the TabContentDirective portal into TabOutletComponent */
|
||||
export const TAB_PORTAL = new InjectionToken<TemplatePortal>('TAB_PORTAL');
|
||||
|
||||
/** TAB_ANIMATION_DIRECTION is the injection token used to control the :enter animation origin of TabOutletComponent */
|
||||
export const TAB_ANIMATION_DIRECTION = new InjectionToken<'left' | 'right'>('TAB_ANIMATION_DIRECTION');
|
||||
|
||||
/** TAB_SCROLL_HANDLER is called by the SfngTabOutletComponent when a scroll event occurs. */
|
||||
export const TAB_SCROLL_HANDLER = new InjectionToken<(_: Event) => void>('TAB_SCROLL_HANDLER')
|
||||
|
||||
/**
|
||||
* Structural directive (*sfngTabContent) to defined lazy-loaded tab content.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[sfngTabContent]',
|
||||
})
|
||||
export class SfngTabContentDirective<T> {
|
||||
portal: TemplatePortal;
|
||||
|
||||
constructor(
|
||||
public readonly templateRef: TemplateRef<T>,
|
||||
public readonly viewRef: ViewContainerRef,
|
||||
) {
|
||||
this.portal = new TemplatePortal(this.templateRef, this.viewRef);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The tab component that is used to define a new tab as a part of a tab group.
|
||||
* The content of the tab is lazy-loaded by using the TabContentDirective.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'sfng-tab',
|
||||
template: '<ng-content></ng-content>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SfngTabComponent implements ListKeyManagerOption {
|
||||
@ContentChild(SfngTabContentDirective, { static: false })
|
||||
tabContent: SfngTabContentDirective<any> | null = null;
|
||||
|
||||
/** The ID of the tab used to programatically activate the tab. */
|
||||
@Input()
|
||||
id = '';
|
||||
|
||||
/** The title for the tab as displayed in the tab group header. */
|
||||
@Input()
|
||||
title = '';
|
||||
|
||||
/** The key for the tip up in the tab group header. */
|
||||
@Input()
|
||||
tipUpKey = '';
|
||||
|
||||
@Input()
|
||||
set warning(v) {
|
||||
this._warning = coerceBooleanProperty(v)
|
||||
}
|
||||
get warning() { return this._warning }
|
||||
private _warning = false;
|
||||
|
||||
/** Emits when the tab content is scrolled */
|
||||
@Output()
|
||||
tabContentScroll = new EventEmitter<Event>();
|
||||
|
||||
/** Whether or not the tab is currently disabled. */
|
||||
@Input()
|
||||
set disabled(v: any) {
|
||||
this._disabled = coerceBooleanProperty(v);
|
||||
}
|
||||
get disabled() {
|
||||
return this._disabled;
|
||||
}
|
||||
private _disabled: boolean = false;
|
||||
|
||||
/** getLabel is used by the list key manager to support type-ahead */
|
||||
getLabel() { return this.title }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A simple wrapper component around CdkPortalOutlet to add nice
|
||||
* move animations.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'sfng-tab-outlet',
|
||||
template: `
|
||||
<div [@moveInOut]="{value: _appAnimate, params: {in: in, out: out}}" class="flex flex-col overflow-auto {{ outletClass }}" (scroll)="onTabContentScroll($event)">
|
||||
<ng-template [cdkPortalOutlet]="portal"></ng-template>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
`
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [
|
||||
trigger(
|
||||
'moveInOut',
|
||||
[
|
||||
transition(
|
||||
':enter',
|
||||
[
|
||||
style({ opacity: 0, transform: 'translateX({{ in }})' }),
|
||||
animate('.2s ease-in',
|
||||
style({ opacity: 1, transform: 'translateX(0%)' }))
|
||||
],
|
||||
{ params: { in: '100%' } } // default parameters
|
||||
),
|
||||
transition(
|
||||
':leave',
|
||||
[
|
||||
style({ opacity: 1 }),
|
||||
animate('.2s ease-out',
|
||||
style({ opacity: 0, transform: 'translateX({{ out }})' }))
|
||||
],
|
||||
{ params: { out: '-100%' } } // default parameters
|
||||
)
|
||||
]
|
||||
)]
|
||||
})
|
||||
export class TabOutletComponent implements AfterViewInit {
|
||||
_appAnimate = false;
|
||||
|
||||
@Input()
|
||||
outletClass = ''
|
||||
|
||||
get in() {
|
||||
return this._animateDirection == 'left' ? '100%' : '-100%'
|
||||
}
|
||||
get out() {
|
||||
return this._animateDirection == 'left' ? '-100%' : '100%'
|
||||
}
|
||||
|
||||
onTabContentScroll(event: Event) {
|
||||
if (!!this.scrollHandler) {
|
||||
this.scrollHandler(event)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewChild(CdkPortalOutlet, { static: true })
|
||||
portalOutlet!: CdkPortalOutlet;
|
||||
|
||||
constructor(
|
||||
@Inject(TAB_PORTAL) public portal: TemplatePortal<any>,
|
||||
@Inject(TAB_ANIMATION_DIRECTION) public _animateDirection: 'left' | 'right',
|
||||
@Inject(TAB_SCROLL_HANDLER) public scrollHandler: (_: Event) => void,
|
||||
private cdr: ChangeDetectorRef
|
||||
) { }
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.portalOutlet?.attached
|
||||
.subscribe(() => {
|
||||
this._appAnimate = true;
|
||||
this.cdr.detectChanges();
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PortalModule } from "@angular/cdk/portal";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { SfngTipUpModule } from "../tipup";
|
||||
import { SfngTabComponent, SfngTabContentDirective, TabOutletComponent } from "./tab";
|
||||
import { SfngTabGroupComponent } from "./tab-group";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
PortalModule,
|
||||
SfngTipUpModule,
|
||||
BrowserAnimationsModule
|
||||
],
|
||||
declarations: [
|
||||
SfngTabContentDirective,
|
||||
SfngTabComponent,
|
||||
SfngTabGroupComponent,
|
||||
TabOutletComponent,
|
||||
],
|
||||
exports: [
|
||||
SfngTabContentDirective,
|
||||
SfngTabComponent,
|
||||
SfngTabGroupComponent
|
||||
]
|
||||
})
|
||||
export class SfngTabModule { }
|
||||
52
desktop/angular/projects/safing/ui/src/lib/tipup/_tipup.scss
Normal file
52
desktop/angular/projects/safing/ui/src/lib/tipup/_tipup.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
sfng-tipup-container {
|
||||
display: block;
|
||||
|
||||
caption {
|
||||
@apply text-sm;
|
||||
opacity: .6;
|
||||
font-size: .6rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message,
|
||||
h1 {
|
||||
flex-shrink: 0;
|
||||
text-overflow: ellipsis;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 0.75rem;
|
||||
flex-grow: 1;
|
||||
opacity: .8;
|
||||
max-width: 300px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
opacity: .7;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
43
desktop/angular/projects/safing/ui/src/lib/tipup/anchor.ts
Normal file
43
desktop/angular/projects/safing/ui/src/lib/tipup/anchor.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Directive, ElementRef, HostBinding, Input, isDevMode } from "@angular/core";
|
||||
import { SfngTipUpPlacement } from "./utils";
|
||||
|
||||
@Directive({
|
||||
selector: '[sfngTipUpAnchor]',
|
||||
})
|
||||
export class SfngTipUpAnchorDirective implements SfngTipUpPlacement {
|
||||
constructor(
|
||||
public readonly elementRef: ElementRef,
|
||||
) { }
|
||||
|
||||
origin: 'left' | 'right' = 'right';
|
||||
offset: number = 10;
|
||||
|
||||
@HostBinding('class.active-tipup-anchor')
|
||||
isActiveAnchor = false;
|
||||
|
||||
@Input()
|
||||
set sfngTipUpAnchor(posSpec: string | undefined) {
|
||||
const parts = (posSpec || '').split(';')
|
||||
if (parts.length > 2) {
|
||||
if (isDevMode()) {
|
||||
throw new Error(`Invalid value "${posSpec}" for [sfngTipUpAnchor]`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts[0] === 'left') {
|
||||
this.origin = 'left';
|
||||
} else {
|
||||
this.origin = 'right';
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
this.offset = +parts[1];
|
||||
if (isNaN(this.offset)) {
|
||||
this.offset = 10;
|
||||
}
|
||||
} else {
|
||||
this.offset = 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
128
desktop/angular/projects/safing/ui/src/lib/tipup/clone-node.ts
Normal file
128
desktop/angular/projects/safing/ui/src/lib/tipup/clone-node.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google LLC All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
/** Creates a deep clone of an element. */
|
||||
export function deepCloneNode(node: HTMLElement): HTMLElement {
|
||||
const clone = node.cloneNode(true) as HTMLElement;
|
||||
const descendantsWithId = clone.querySelectorAll('[id]');
|
||||
const nodeName = node.nodeName.toLowerCase();
|
||||
|
||||
// Remove the `id` to avoid having multiple elements with the same id on the page.
|
||||
clone.removeAttribute('id');
|
||||
|
||||
for (let i = 0; i < descendantsWithId.length; i++) {
|
||||
descendantsWithId[i].removeAttribute('id');
|
||||
}
|
||||
|
||||
if (nodeName === 'canvas') {
|
||||
transferCanvasData(node as HTMLCanvasElement, clone as HTMLCanvasElement);
|
||||
} else if (nodeName === 'input' || nodeName === 'select' || nodeName === 'textarea') {
|
||||
transferInputData(node as HTMLInputElement, clone as HTMLInputElement);
|
||||
}
|
||||
|
||||
transferData('canvas', node, clone, transferCanvasData);
|
||||
transferData('input, textarea, select', node, clone, transferInputData);
|
||||
return clone;
|
||||
}
|
||||
|
||||
/** Matches elements between an element and its clone and allows for their data to be cloned. */
|
||||
function transferData<T extends Element>(selector: string, node: HTMLElement, clone: HTMLElement,
|
||||
callback: (source: T, clone: T) => void) {
|
||||
const descendantElements = node.querySelectorAll<T>(selector);
|
||||
|
||||
if (descendantElements.length) {
|
||||
const cloneElements = clone.querySelectorAll<T>(selector);
|
||||
|
||||
for (let i = 0; i < descendantElements.length; i++) {
|
||||
callback(descendantElements[i], cloneElements[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Counter for unique cloned radio button names.
|
||||
let cloneUniqueId = 0;
|
||||
|
||||
/** Transfers the data of one input element to another. */
|
||||
function transferInputData(source: Element & { value: string },
|
||||
clone: Element & { value: string; name: string; type: string }) {
|
||||
// Browsers throw an error when assigning the value of a file input programmatically.
|
||||
if (clone.type !== 'file') {
|
||||
clone.value = source.value;
|
||||
}
|
||||
|
||||
// Radio button `name` attributes must be unique for radio button groups
|
||||
// otherwise original radio buttons can lose their checked state
|
||||
// once the clone is inserted in the DOM.
|
||||
if (clone.type === 'radio' && clone.name) {
|
||||
clone.name = `sfng-clone-${clone.name}-${cloneUniqueId++}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Transfers the data of one canvas element to another. */
|
||||
function transferCanvasData(source: HTMLCanvasElement, clone: HTMLCanvasElement) {
|
||||
const context = clone.getContext('2d');
|
||||
|
||||
if (context) {
|
||||
// In some cases `drawImage` can throw (e.g. if the canvas size is 0x0).
|
||||
// We can't do much about it so just ignore the error.
|
||||
try {
|
||||
context.drawImage(source, 0, 0);
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a 3d `transform` that can be applied to an element.
|
||||
* @param x Desired position of the element along the X axis.
|
||||
* @param y Desired position of the element along the Y axis.
|
||||
*/
|
||||
export function getTransform(x: number, y: number): string {
|
||||
// Round the transforms since some browsers will
|
||||
// blur the elements for sub-pixel transforms.
|
||||
return `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches the target element's size to the source's size.
|
||||
* @param target Element that needs to be resized.
|
||||
* @param sourceRect Dimensions of the source element.
|
||||
*/
|
||||
export function matchElementSize(target: HTMLElement, sourceRect: ClientRect): void {
|
||||
target.style.width = `${sourceRect.width}px`;
|
||||
target.style.height = `${sourceRect.height}px`;
|
||||
target.style.transform = getTransform(sourceRect.left, sourceRect.top);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow-extends a stylesheet object with another stylesheet-like object.
|
||||
* Note that the keys in `source` have to be dash-cased.
|
||||
*/
|
||||
export function extendStyles(dest: CSSStyleDeclaration,
|
||||
source: Record<string, string>,
|
||||
importantProperties?: Set<string>) {
|
||||
for (let key in source) {
|
||||
if (source.hasOwnProperty(key)) {
|
||||
const value = source[key];
|
||||
|
||||
if (value) {
|
||||
dest.setProperty(key, value, importantProperties?.has(key) ? 'important' : '');
|
||||
} else {
|
||||
dest.removeProperty(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dest;
|
||||
}
|
||||
|
||||
export function removeNode(node: Node | null) {
|
||||
if (node && node.parentNode) {
|
||||
node.parentNode.removeChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
|
||||
export function synchronizeCssStyles(src: HTMLElement, destination: HTMLElement, skipStyles: Set<string>) {
|
||||
// Get a list of all the source and destination elements
|
||||
const srcElements = <HTMLCollectionOf<HTMLElement>>src.getElementsByTagName('*');
|
||||
const dstElements = <HTMLCollectionOf<HTMLElement>>destination.getElementsByTagName('*');
|
||||
|
||||
cloneStyle(src, destination, skipStyles);
|
||||
|
||||
// For each element
|
||||
for (let i = srcElements.length; i--;) {
|
||||
const srcElement = srcElements[i];
|
||||
const dstElement = dstElements[i];
|
||||
cloneStyle(srcElement, dstElement, skipStyles);
|
||||
}
|
||||
}
|
||||
|
||||
function cloneStyle(srcElement: HTMLElement, dstElement: HTMLElement, skipStyles: Set<string>) {
|
||||
const sourceElementStyles = document.defaultView!.getComputedStyle(srcElement, '');
|
||||
const styleAttributeKeyNumbers = Object.keys(sourceElementStyles);
|
||||
|
||||
// Copy the attribute
|
||||
for (let j = 0; j < styleAttributeKeyNumbers.length; j++) {
|
||||
const attributeKeyNumber = styleAttributeKeyNumbers[j];
|
||||
const attributeKey: string = sourceElementStyles[attributeKeyNumber as any];
|
||||
if (!isNaN(+attributeKey)) {
|
||||
continue
|
||||
}
|
||||
if (attributeKey === 'cssText') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (skipStyles.has(attributeKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
dstElement.style[attributeKey as any] = sourceElementStyles[attributeKey as any];
|
||||
} catch (e) {
|
||||
console.error(attributeKey, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a CSS selector for el from rootNode.
|
||||
*
|
||||
* @param el The source element to get the CSS path to
|
||||
* @param rootNode The root node at which the CSS path should be applyable
|
||||
* @returns A CSS selector to access el from rootNode.
|
||||
*/
|
||||
export function getCssSelector(el: HTMLElement, rootNode: HTMLElement | null): string {
|
||||
if (!el) {
|
||||
return '';
|
||||
}
|
||||
let stack = [];
|
||||
let isShadow = false;
|
||||
while (el !== rootNode && el.parentNode !== null) {
|
||||
// console.log(el.nodeName);
|
||||
let sibCount = 0;
|
||||
let sibIndex = 0;
|
||||
// get sibling indexes
|
||||
for (let i = 0; i < (el.parentNode as HTMLElement).childNodes.length; i++) {
|
||||
let sib = (el.parentNode as HTMLElement).childNodes[i];
|
||||
if (sib.nodeName == el.nodeName) {
|
||||
if (sib === el) {
|
||||
sibIndex = sibCount;
|
||||
}
|
||||
sibCount++;
|
||||
}
|
||||
}
|
||||
let nodeName = el.nodeName.toLowerCase();
|
||||
if (isShadow) {
|
||||
throw new Error(`cannot traverse into shadow dom.`)
|
||||
}
|
||||
if (sibCount > 1) {
|
||||
stack.unshift(nodeName + ':nth-of-type(' + (sibIndex + 1) + ')');
|
||||
} else {
|
||||
stack.unshift(nodeName);
|
||||
}
|
||||
el = el.parentNode as HTMLElement;
|
||||
if (el.nodeType === 11) { // for shadow dom, we
|
||||
isShadow = true;
|
||||
el = (el as any).host;
|
||||
}
|
||||
}
|
||||
return stack.join(' > ');
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './anchor';
|
||||
export * from './tipup';
|
||||
export * from './tipup-component';
|
||||
export * from './tipup.module';
|
||||
export * from './translations';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import { DomSanitizer, SafeHtml, SafeStyle, SafeScript, SafeUrl, SafeResourceUrl } from '@angular/platform-browser';
|
||||
|
||||
@Pipe({
|
||||
name: 'safe'
|
||||
})
|
||||
export class SafePipe implements PipeTransform {
|
||||
|
||||
constructor(protected sanitizer: DomSanitizer) { }
|
||||
|
||||
public transform(value: any, type: string): SafeHtml | SafeStyle | SafeScript | SafeUrl | SafeResourceUrl {
|
||||
switch (type) {
|
||||
case 'html': return this.sanitizer.bypassSecurityTrustHtml(value);
|
||||
case 'style': return this.sanitizer.bypassSecurityTrustStyle(value);
|
||||
case 'script': return this.sanitizer.bypassSecurityTrustScript(value);
|
||||
case 'url': return this.sanitizer.bypassSecurityTrustUrl(value);
|
||||
case 'resourceUrl': return this.sanitizer.bypassSecurityTrustResourceUrl(value);
|
||||
default: throw new Error(`Invalid safe type specified: ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject, OnInit } from "@angular/core";
|
||||
import { SfngDialogRef, SFNG_DIALOG_REF } from "../dialog";
|
||||
import { SfngTipUpService } from "./tipup";
|
||||
import { ActionRunner, Button, SFNG_TIP_UP_ACTION_RUNNER, TipUp } from './translations';
|
||||
import { TIPUP_TOKEN } from "./utils";
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-tipup-container',
|
||||
templateUrl: './tipup.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SfngTipUpComponent implements OnInit, TipUp<any> {
|
||||
title: string = 'N/A';
|
||||
content: string = 'N/A';
|
||||
nextKey?: string;
|
||||
buttons?: Button<any>[];
|
||||
url?: string;
|
||||
urlText: string = 'Read More';
|
||||
|
||||
constructor(
|
||||
@Inject(TIPUP_TOKEN) public readonly token: string,
|
||||
@Inject(SFNG_DIALOG_REF) private readonly dialogRef: SfngDialogRef<SfngTipUpComponent>,
|
||||
@Inject(SFNG_TIP_UP_ACTION_RUNNER) private runner: ActionRunner<any>,
|
||||
private tipupService: SfngTipUpService,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
const doc = this.tipupService.getTipUp(this.token);
|
||||
if (!!doc) {
|
||||
Object.assign(this, doc);
|
||||
this.urlText = doc.urlText || 'Read More';
|
||||
}
|
||||
}
|
||||
|
||||
async next() {
|
||||
if (!this.nextKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tipupService.open(this.nextKey);
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
async runAction(btn: Button<any>) {
|
||||
await this.runner.performAction(btn.action);
|
||||
|
||||
// if we have a nextKey for the button but do not do in-app
|
||||
// routing we should be able to open the next tipup as soon
|
||||
// as the action finished
|
||||
if (!!btn.nextKey) {
|
||||
this.tipupService.waitFor(btn.nextKey!)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.dialogRef.close();
|
||||
this.tipupService.open(btn.nextKey!);
|
||||
},
|
||||
error: console.error
|
||||
})
|
||||
} else {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
22
desktop/angular/projects/safing/ui/src/lib/tipup/tipup.html
Normal file
22
desktop/angular/projects/safing/ui/src/lib/tipup/tipup.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<div class="flex flex-col items-start">
|
||||
<caption>Tip</caption>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 close-icon" viewBox="0 0 20 20" fill="currentColor"
|
||||
(click)="close()">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
||||
<h1 [innerHTML]="title | safe: 'html'"></h1>
|
||||
|
||||
<markdown emoji [data]="content" class="message"></markdown>
|
||||
|
||||
<a *ngIf="!!url" [href]="url" target="_blank">{{ urlText }}</a>
|
||||
|
||||
<div class="buttons">
|
||||
<div class="actions">
|
||||
<button *ngFor="let btn of buttons" (click)="runAction(btn)">{{ btn.name }}</button>
|
||||
</div>
|
||||
<button *ngIf="!!nextKey" class="btn" (click)="next()">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ModuleWithProviders, NgModule, Type } from "@angular/core";
|
||||
import { MarkdownModule } from "ngx-markdown";
|
||||
import { SfngDialogModule } from "../dialog";
|
||||
import { SfngTipUpAnchorDirective } from './anchor';
|
||||
import { SfngsfngTipUpTriggerDirective, SfngTipUpIconComponent } from './tipup';
|
||||
import { SfngTipUpComponent } from './tipup-component';
|
||||
import { ActionRunner, HelpTexts, SFNG_TIP_UP_ACTION_RUNNER, SFNG_TIP_UP_CONTENTS } from "./translations";
|
||||
import { SafePipe } from "./safe.pipe";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
MarkdownModule.forChild(),
|
||||
SfngDialogModule,
|
||||
],
|
||||
declarations: [
|
||||
SfngTipUpIconComponent,
|
||||
SfngsfngTipUpTriggerDirective,
|
||||
SfngTipUpComponent,
|
||||
SfngTipUpAnchorDirective,
|
||||
SafePipe
|
||||
],
|
||||
exports: [
|
||||
SfngTipUpIconComponent,
|
||||
SfngsfngTipUpTriggerDirective,
|
||||
SfngTipUpComponent,
|
||||
SfngTipUpAnchorDirective
|
||||
],
|
||||
})
|
||||
export class SfngTipUpModule {
|
||||
static forRoot(text: HelpTexts<any>, runner: Type<ActionRunner<any>>): ModuleWithProviders<SfngTipUpModule> {
|
||||
return {
|
||||
ngModule: SfngTipUpModule,
|
||||
providers: [
|
||||
{
|
||||
provide: SFNG_TIP_UP_CONTENTS,
|
||||
useValue: text,
|
||||
},
|
||||
{
|
||||
provide: SFNG_TIP_UP_ACTION_RUNNER,
|
||||
useExisting: runner,
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
526
desktop/angular/projects/safing/ui/src/lib/tipup/tipup.ts
Normal file
526
desktop/angular/projects/safing/ui/src/lib/tipup/tipup.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
/* eslint-disable @angular-eslint/no-input-rename */
|
||||
import { coerceBooleanProperty, coerceNumberProperty } from '@angular/cdk/coercion';
|
||||
import { ConnectedPosition } from '@angular/cdk/overlay';
|
||||
import { _getShadowRoot } from '@angular/cdk/platform';
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, ElementRef, HostBinding, HostListener, Inject, Injectable, Injector, Input, NgZone, OnDestroy, Optional, Renderer2, RendererFactory2 } from '@angular/core';
|
||||
import { Observable, of, Subject } from 'rxjs';
|
||||
import { debounce, debounceTime, filter, map, skip, take, timeout } from 'rxjs/operators';
|
||||
import { SfngDialogRef, SfngDialogService } from '../dialog';
|
||||
import { SfngTipUpAnchorDirective } from './anchor';
|
||||
import { deepCloneNode, extendStyles, matchElementSize, removeNode } from './clone-node';
|
||||
import { getCssSelector, synchronizeCssStyles } from './css-utils';
|
||||
import { SfngTipUpComponent } from './tipup-component';
|
||||
import { Button, HelpTexts, SFNG_TIP_UP_CONTENTS, TipUp } from './translations';
|
||||
import { SfngTipUpPlacement, TIPUP_TOKEN } from './utils';
|
||||
|
||||
@Directive({
|
||||
selector: '[sfngTipUpTrigger]',
|
||||
})
|
||||
export class SfngsfngTipUpTriggerDirective implements OnDestroy {
|
||||
constructor(
|
||||
public readonly elementRef: ElementRef,
|
||||
public dialog: SfngDialogService,
|
||||
@Optional() @Inject(SfngTipUpAnchorDirective) public anchor: SfngTipUpAnchorDirective | ElementRef<any> | HTMLElement,
|
||||
@Inject(SFNG_TIP_UP_CONTENTS) private tipUpContents: HelpTexts<any>,
|
||||
private tipupService: SfngTipUpService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
) { }
|
||||
|
||||
private dialogRef: SfngDialogRef<SfngTipUpComponent> | null = null;
|
||||
|
||||
/**
|
||||
* The helptext token used to search for the tip up defintion.
|
||||
*/
|
||||
@Input('sfngTipUpTrigger')
|
||||
set textKey(s: string) {
|
||||
if (!!this._textKey) {
|
||||
this.tipupService.deregister(this._textKey, this);
|
||||
}
|
||||
this._textKey = s;
|
||||
this.tipupService.register(this._textKey, this);
|
||||
}
|
||||
get textKey() { return this._textKey; }
|
||||
private _textKey: string = '';
|
||||
|
||||
/**
|
||||
* The text to display inside the tip up. If unset, the tipup definition
|
||||
* will be loaded form helptexts.yaml.
|
||||
* This input property is mainly designed for programatic/dynamic tip-up generation
|
||||
*/
|
||||
@Input('sfngTipUpText')
|
||||
text: string | undefined;
|
||||
|
||||
@Input('sfngTipUpTitle')
|
||||
title: string | undefined;
|
||||
|
||||
@Input('sfngTipUpButtons')
|
||||
buttons: Button<any>[] | undefined;
|
||||
|
||||
/**
|
||||
* asTipUp returns a tip-up definition built from the input
|
||||
* properties sfngTipUpText and sfngTipUpTitle. If none are set
|
||||
* then null is returned.
|
||||
*/
|
||||
asTipUp(): TipUp<any> | null {
|
||||
// TODO(ppacher): we could also merge the defintions from MyYamlFile
|
||||
// and the properties set on this directive....
|
||||
if (!this.text) {
|
||||
return this.tipUpContents[this.textKey];
|
||||
}
|
||||
return {
|
||||
title: this.title || '',
|
||||
content: this.text,
|
||||
buttons: this.buttons,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The default anchor for the tipup if non is provided via Dependency-Injection
|
||||
* or using sfngTipUpAnchorRef
|
||||
*/
|
||||
@Input('sfngTipUpDefaultAnchor')
|
||||
defaultAnchor: ElementRef<any> | HTMLElement | null = null;
|
||||
|
||||
/** Optionally overwrite the anchor element received via Dependency Injection */
|
||||
@Input('sfngTipUpAnchorRef')
|
||||
set anchorRef(ref: ElementRef<any> | HTMLElement | null) {
|
||||
this.anchor = ref ?? this.anchor;
|
||||
}
|
||||
|
||||
/** Used to ensure all tip-up triggers have a pointer cursor */
|
||||
@HostBinding('style.cursor')
|
||||
cursor = 'pointer';
|
||||
|
||||
/** De-register ourself upon destroy */
|
||||
ngOnDestroy() {
|
||||
this.tipupService.deregister(this.textKey, this);
|
||||
}
|
||||
|
||||
/** Whether or not we're passive-only and thus do not handle click-events form the user */
|
||||
@Input('sfngTipUpPassive')
|
||||
set passive(v: any) {
|
||||
this._passive = coerceBooleanProperty(v ?? true);
|
||||
}
|
||||
get passive() { return this._passive; }
|
||||
private _passive = false;
|
||||
|
||||
@Input('sfngTipUpOffset')
|
||||
set offset(v: any) {
|
||||
this._defaultOffset = coerceNumberProperty(v)
|
||||
}
|
||||
get offset() { return this._defaultOffset }
|
||||
private _defaultOffset = 20;
|
||||
|
||||
@Input('sfngTipUpPlacement')
|
||||
placement: SfngTipUpPlacement | null = null;
|
||||
|
||||
@HostListener('click', ['$event'])
|
||||
onClick(event?: MouseEvent): Promise<any> {
|
||||
if (!!event) {
|
||||
// if there's a click event the user actually clicked the element.
|
||||
// we only handle this if we're not marked as passive.
|
||||
if (this._passive) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (!!this.dialogRef) {
|
||||
this.dialogRef.close();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
let anchorElement: ElementRef<any> | HTMLElement | null = this.defaultAnchor || this.elementRef;
|
||||
let placement: SfngTipUpPlacement | null = this.placement;
|
||||
|
||||
if (!!this.anchor) {
|
||||
if (this.anchor instanceof SfngTipUpAnchorDirective) {
|
||||
anchorElement = this.anchor.elementRef;
|
||||
placement = this.anchor;
|
||||
} else {
|
||||
anchorElement = this.anchor;
|
||||
}
|
||||
}
|
||||
|
||||
this.dialogRef = this.tipupService.createTipup(
|
||||
anchorElement,
|
||||
this.textKey,
|
||||
this,
|
||||
placement,
|
||||
)
|
||||
|
||||
this.dialogRef.onClose
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
this.dialogRef = null;
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
|
||||
this.cdr.detectChanges();
|
||||
|
||||
return this.dialogRef.onStateChange
|
||||
.pipe(
|
||||
filter(state => state === 'opening'),
|
||||
take(1),
|
||||
)
|
||||
.toPromise()
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-tipup',
|
||||
template:
|
||||
`<svg viewBox="0 0 24 24"
|
||||
class="tipup"
|
||||
[sfngTipUpTrigger]="key"
|
||||
[sfngTipUpDefaultAnchor]="parent"
|
||||
[sfngTipUpPlacement]="placement"
|
||||
[sfngTipUpText]="text"
|
||||
[sfngTipUpTitle]="title"
|
||||
[sfngTipUpButtons]="buttons"
|
||||
[sfngTipUpAnchorRef]="anchor">
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" >
|
||||
<path stroke="#ffff" shape-rendering="geometricPrecision" d="M12 21v0c-4.971 0-9-4.029-9-9v0c0-4.971 4.029-9 9-9v0c4.971 0 9 4.029 9 9v0c0 4.971-4.029 9-9 9z"/>
|
||||
<path stroke="#ffff" shape-rendering="geometricPrecision" d="M12 17v-5h-1M11.749 8c-.138 0-.25.112-.249.25 0 .138.112.25.25.25s.25-.112.25-.25-.112-.25-.251-.25"/>
|
||||
</g>
|
||||
</svg>`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: inline-block;
|
||||
width : 1rem;
|
||||
position: relative;
|
||||
opacity: 0.55;
|
||||
cursor : pointer;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
:host:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SfngTipUpIconComponent implements SfngTipUpPlacement {
|
||||
@Input()
|
||||
key: string = '';
|
||||
|
||||
// see sfngTipUpTrigger sfngTipUpText and sfngTipUpTitle
|
||||
@Input() text: string | undefined = undefined;
|
||||
@Input() title: string | undefined = undefined;
|
||||
@Input() buttons: Button<any>[] | undefined = undefined;
|
||||
|
||||
@Input()
|
||||
anchor: ElementRef<any> | HTMLElement | null = null;
|
||||
|
||||
@Input('placement')
|
||||
origin: 'left' | 'right' = 'right';
|
||||
|
||||
@Input()
|
||||
set offset(v: any) {
|
||||
this._offset = coerceNumberProperty(v);
|
||||
}
|
||||
get offset() { return this._offset; }
|
||||
private _offset: number = 10;
|
||||
|
||||
constructor(private elementRef: ElementRef<any>) { }
|
||||
|
||||
get placement(): SfngTipUpPlacement {
|
||||
return this
|
||||
}
|
||||
|
||||
get parent(): HTMLElement | null {
|
||||
return (this.elementRef?.nativeElement as HTMLElement)?.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SfngTipUpService {
|
||||
tipups = new Map<string, SfngsfngTipUpTriggerDirective>();
|
||||
|
||||
private _onRegister = new Subject<string>();
|
||||
private _onUnregister = new Subject<string>();
|
||||
|
||||
get onRegister(): Observable<string> {
|
||||
return this._onRegister.asObservable();
|
||||
}
|
||||
|
||||
get onUnregister(): Observable<string> {
|
||||
return this._onUnregister.asObservable();
|
||||
}
|
||||
|
||||
waitFor(key: string): Observable<void> {
|
||||
if (this.tipups.has(key)) {
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
return this.onRegister
|
||||
.pipe(
|
||||
filter(val => val === key),
|
||||
debounce(() => this.ngZone.onStable.pipe(skip(2))),
|
||||
debounceTime(1000),
|
||||
take(1),
|
||||
map(() => { }),
|
||||
timeout(5000),
|
||||
);
|
||||
}
|
||||
|
||||
private renderer: Renderer2;
|
||||
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private _document: Document,
|
||||
private dialog: SfngDialogService,
|
||||
private ngZone: NgZone,
|
||||
private injector: Injector,
|
||||
rendererFactory: RendererFactory2
|
||||
) {
|
||||
this.renderer = rendererFactory.createRenderer(null, null)
|
||||
}
|
||||
|
||||
register(key: string, trigger: SfngsfngTipUpTriggerDirective) {
|
||||
if (this.tipups.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tipups.set(key, trigger);
|
||||
this._onRegister.next(key);
|
||||
}
|
||||
|
||||
deregister(key: string, trigger: SfngsfngTipUpTriggerDirective) {
|
||||
if (this.tipups.get(key) === trigger) {
|
||||
this.tipups.delete(key);
|
||||
this._onUnregister.next(key);
|
||||
}
|
||||
}
|
||||
|
||||
getTipUp(key: string): TipUp<any> | null {
|
||||
return this.tipups.get(key)?.asTipUp() || null;
|
||||
}
|
||||
|
||||
private _latestTipUp: SfngDialogRef<SfngTipUpComponent> | null = null;
|
||||
|
||||
createTipup(
|
||||
anchor: HTMLElement | ElementRef<any>,
|
||||
key: string,
|
||||
origin?: SfngsfngTipUpTriggerDirective,
|
||||
opts: SfngTipUpPlacement | null = {},
|
||||
injector?: Injector): SfngDialogRef<SfngTipUpComponent> {
|
||||
|
||||
const lastTipUp = this._latestTipUp
|
||||
let closePrevious = () => {
|
||||
if (!!lastTipUp) {
|
||||
lastTipUp.close();
|
||||
}
|
||||
}
|
||||
|
||||
// make sure we have an ElementRef to work with
|
||||
if (!(anchor instanceof ElementRef)) {
|
||||
anchor = new ElementRef(anchor)
|
||||
}
|
||||
|
||||
// the the origin placement of the tipup
|
||||
const positions: ConnectedPosition[] = [];
|
||||
if (opts?.origin === 'left') {
|
||||
positions.push({
|
||||
originX: 'start',
|
||||
originY: 'center',
|
||||
overlayX: 'end',
|
||||
overlayY: 'center',
|
||||
})
|
||||
} else {
|
||||
positions.push({
|
||||
originX: 'end',
|
||||
originY: 'center',
|
||||
overlayX: 'start',
|
||||
overlayY: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
// determine the offset to the tipup origin
|
||||
let offset = opts?.offset ?? 10;
|
||||
if (opts?.origin === 'left') {
|
||||
offset *= -1;
|
||||
}
|
||||
|
||||
let postitionStrategy = this.dialog.position()
|
||||
.flexibleConnectedTo(anchor)
|
||||
.withPositions(positions)
|
||||
.withDefaultOffsetX(offset);
|
||||
|
||||
const inj = Injector.create({
|
||||
providers: [
|
||||
{
|
||||
useValue: key,
|
||||
provide: TIPUP_TOKEN,
|
||||
}
|
||||
],
|
||||
parent: injector || this.injector,
|
||||
});
|
||||
|
||||
|
||||
const newTipUp = this.dialog.create(SfngTipUpComponent, {
|
||||
dragable: false,
|
||||
autoclose: true,
|
||||
backdrop: 'light',
|
||||
injector: inj,
|
||||
positionStrategy: postitionStrategy
|
||||
});
|
||||
this._latestTipUp = newTipUp;
|
||||
|
||||
const _preview = this._createPreview(anchor.nativeElement, _getShadowRoot(anchor.nativeElement));
|
||||
|
||||
// construct a CSS selector that targets the clicked origin (sfngTipUpTriggerDirective) from within
|
||||
// the anchor. We use that path to highlight the copy of the trigger-directive in the preview.
|
||||
if (!!origin) {
|
||||
const originSelector = getCssSelector(origin.elementRef.nativeElement, anchor.nativeElement);
|
||||
let target: HTMLElement | null = null;
|
||||
if (!!originSelector) {
|
||||
target = _preview.querySelector(originSelector);
|
||||
} else {
|
||||
target = _preview;
|
||||
}
|
||||
|
||||
this.renderer.addClass(target, 'active-tipup-trigger')
|
||||
}
|
||||
|
||||
newTipUp.onStateChange
|
||||
.pipe(
|
||||
filter(state => state === 'open'),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(() => {
|
||||
closePrevious();
|
||||
_preview.attach()
|
||||
})
|
||||
|
||||
newTipUp.onStateChange
|
||||
.pipe(
|
||||
filter(state => state === 'closing'),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(() => {
|
||||
if (this._latestTipUp === newTipUp) {
|
||||
this._latestTipUp = null;
|
||||
}
|
||||
_preview.classList.remove('visible');
|
||||
setTimeout(() => {
|
||||
removeNode(_preview);
|
||||
}, 300)
|
||||
});
|
||||
|
||||
return newTipUp;
|
||||
}
|
||||
|
||||
private _createPreview(element: HTMLElement, shadowRoot: ShadowRoot | null): HTMLElement & { attach: () => void } {
|
||||
const preview = deepCloneNode(element);
|
||||
// clone all CSS styles by applying them directly to the copied
|
||||
// nodes. Though, we skip the opacity property because we use that
|
||||
// a lot and it makes the preview strange ....
|
||||
synchronizeCssStyles(element, preview, new Set([
|
||||
'opacity'
|
||||
]));
|
||||
|
||||
// make sure the preview element is at the exact same position
|
||||
// as the original one.
|
||||
matchElementSize(preview, element.getBoundingClientRect());
|
||||
|
||||
extendStyles(preview.style, {
|
||||
// We have to reset the margin, because it can throw off positioning relative to the viewport.
|
||||
'margin': '0',
|
||||
'position': 'fixed',
|
||||
'top': '0',
|
||||
'left': '0',
|
||||
'z-index': '1000',
|
||||
'opacity': 'unset',
|
||||
}, new Set(['position']));
|
||||
|
||||
// We add a dedicated class to the preview element so
|
||||
// it can handle special higlighting itself.
|
||||
preview.classList.add('tipup-preview')
|
||||
|
||||
// since the user might want to click on the preview element we must
|
||||
// intercept the click-event, determine the path to the target element inside
|
||||
// the preview and eventually dispatch a click-event on the actual
|
||||
// - real - target inside the cloned element.
|
||||
preview.onclick = function (event: MouseEvent) {
|
||||
let path = getCssSelector(event.target as HTMLElement, preview);
|
||||
if (!!path) {
|
||||
// find the target by it's CSS path
|
||||
let actualTarget: HTMLElement | null = element.querySelector<HTMLElement>(path);
|
||||
|
||||
// some (SVG) elements don't have a direct click() listener so we need to search
|
||||
// the parents upwards to find one that implements click().
|
||||
// we're basically searching up until we reach the <html> tag.
|
||||
//
|
||||
// TODO(ppacher): stop searching at the respective root node.
|
||||
if (!!actualTarget) {
|
||||
let iter: HTMLElement = actualTarget;
|
||||
while (iter != null) {
|
||||
if ('click' in iter && typeof iter['click'] === 'function') {
|
||||
iter.click();
|
||||
break;
|
||||
}
|
||||
iter = iter.parentNode as HTMLElement;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// the user clicked the preview element directly
|
||||
try {
|
||||
element.click()
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let attach = () => {
|
||||
const parent = this._getPreviewInserationPoint(shadowRoot)
|
||||
const cdkOverlayContainer = parent.getElementsByClassName('cdk-overlay-container')[0]
|
||||
// if we find a cdkOverlayContainer in our inseration point (which we expect to be there)
|
||||
// we insert the preview element right after the overlay-backdrop. This way the tip-up
|
||||
// dialog will still be on top of the preview.
|
||||
if (!!cdkOverlayContainer) {
|
||||
const reference = cdkOverlayContainer.getElementsByClassName("cdk-overlay-backdrop")[0].nextSibling;
|
||||
cdkOverlayContainer.insertBefore(preview, reference)
|
||||
} else {
|
||||
parent.appendChild(preview);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
preview.classList.add('visible');
|
||||
})
|
||||
}
|
||||
|
||||
Object.defineProperty(preview, 'attach', {
|
||||
value: attach,
|
||||
})
|
||||
|
||||
return preview as any;
|
||||
}
|
||||
|
||||
private _getPreviewInserationPoint(shadowRoot: ShadowRoot | null): HTMLElement {
|
||||
const documentRef = this._document;
|
||||
return shadowRoot ||
|
||||
documentRef.fullscreenElement ||
|
||||
(documentRef as any).webkitFullscreenElement ||
|
||||
(documentRef as any).mozFullScreenElement ||
|
||||
(documentRef as any).msFullscreenElement ||
|
||||
documentRef.body;
|
||||
}
|
||||
|
||||
async open(key: string) {
|
||||
const comp = this.tipups.get(key);
|
||||
if (!comp) {
|
||||
console.error('Tried to open unknown tip-up with key ' + key);
|
||||
return;
|
||||
}
|
||||
comp.onClick()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export const SFNG_TIP_UP_CONTENTS = new InjectionToken<HelpTexts<any>>('SfngTipUpContents');
|
||||
export const SFNG_TIP_UP_ACTION_RUNNER = new InjectionToken<ActionRunner<any>>('SfngTipUpActionRunner')
|
||||
|
||||
export interface Button<T> {
|
||||
name: string;
|
||||
action: T;
|
||||
nextKey?: string;
|
||||
}
|
||||
|
||||
export interface TipUp<T> {
|
||||
title: string;
|
||||
content: string;
|
||||
url?: string;
|
||||
urlText?: string;
|
||||
buttons?: Button<T>[];
|
||||
nextKey?: string;
|
||||
}
|
||||
|
||||
export interface HelpTexts<T> {
|
||||
[key: string]: TipUp<T>;
|
||||
}
|
||||
|
||||
export interface ActionRunner<T> {
|
||||
performAction(action: T): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { InjectionToken } from "@angular/core";
|
||||
|
||||
export const TIPUP_TOKEN = new InjectionToken<string>('TipUPJSONToken');
|
||||
|
||||
export interface SfngTipUpPlacement {
|
||||
origin?: 'left' | 'right';
|
||||
offset?: number;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
sfng-toggle {
|
||||
@apply flex items-center;
|
||||
|
||||
label {
|
||||
@apply inline-block w-10 h-5 relative bg-gray-500 rounded-full;
|
||||
}
|
||||
|
||||
.slider {
|
||||
@apply absolute cursor-pointer top-0 left-0 right-0 bottom-0 bg-gray-600 transition-all duration-100 rounded-full shadow-inner-xs;
|
||||
}
|
||||
|
||||
.dot {
|
||||
@apply absolute transition-all duration-200 rounded-full bg-white;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
bottom: 1px;
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
input:checked:not(:disabled)+.slider {
|
||||
@apply bg-green-300 bg-opacity-50 text-green;
|
||||
}
|
||||
|
||||
input:disabled+.slider {
|
||||
@apply opacity-75 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.dot.checked {
|
||||
transform: translateX(calc(2.5rem - 18px - 2px));
|
||||
}
|
||||
|
||||
.dot.disabled {
|
||||
transform: translateX(calc((2.5rem - 18px - 2px)/2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './toggle-switch';
|
||||
export * from './toggle.module';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<label>
|
||||
<input type="checkbox" class="block w-0 h-0 opacity-0" [ngModel]="value" (ngModelChange)="onValueChange($event)" [disabled]="disabled">
|
||||
<span class="slider">
|
||||
<span class="flex items-center justify-center dot" [class.checked]="value && !disabled" [class.disabled]="disabled">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" *ngIf="value && !disabled">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" *ngIf="!value && !disabled">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" *ngIf="disabled">
|
||||
<path fill-rule="evenodd" d="M5 10a1 1 0 011-1h8a1 1 0 110 2H6a1 1 0 01-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, HostListener } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-toggle',
|
||||
templateUrl: './toggle-switch.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => SfngToggleSwitchComponent),
|
||||
multi: true,
|
||||
}
|
||||
]
|
||||
})
|
||||
export class SfngToggleSwitchComponent implements ControlValueAccessor {
|
||||
@HostListener('blur')
|
||||
onBlur() {
|
||||
this.onTouch();
|
||||
}
|
||||
|
||||
set disabled(v: any) {
|
||||
this.setDisabledState(coerceBooleanProperty(v))
|
||||
}
|
||||
get disabled() {
|
||||
return this._disabled;
|
||||
}
|
||||
private _disabled = false;
|
||||
|
||||
value: boolean = false;
|
||||
|
||||
constructor(private _changeDetector: ChangeDetectorRef) { }
|
||||
|
||||
setDisabledState(isDisabled: boolean) {
|
||||
this._disabled = isDisabled;
|
||||
this._changeDetector.markForCheck();
|
||||
}
|
||||
|
||||
onValueChange(value: boolean) {
|
||||
this.value = value;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
|
||||
writeValue(value: boolean) {
|
||||
this.value = value;
|
||||
this._changeDetector.markForCheck();
|
||||
}
|
||||
|
||||
onChange = (_: any): void => { };
|
||||
registerOnChange(fn: (value: any) => void) {
|
||||
this.onChange = fn;
|
||||
}
|
||||
|
||||
onTouch = (): void => { };
|
||||
registerOnTouched(fn: () => void) {
|
||||
this.onTouch = fn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { SfngToggleSwitchComponent } from "./toggle-switch";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
],
|
||||
declarations: [
|
||||
SfngToggleSwitchComponent,
|
||||
],
|
||||
exports: [
|
||||
SfngToggleSwitchComponent,
|
||||
]
|
||||
})
|
||||
export class SfngToggleSwitchModule { }
|
||||
@@ -0,0 +1,5 @@
|
||||
sfng-tooltip-container {
|
||||
@apply relative block;
|
||||
|
||||
max-width: 16rem;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './tooltip';
|
||||
export * from './tooltip.module';
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<div [@moveInOut]="{value: _appAnimate, params: {value: value, what: what}}" *ngIf="_appAnimate"
|
||||
[style.transformOrigin]="transformOrigin" (@moveInOut.done)="animationDone($event)"
|
||||
class="relative px-2 py-0.5 text-white bg-gray-100 text-xxs border rounded shadow-lg w-fit">
|
||||
{{ message }}
|
||||
<ng-container [cdkPortalOutlet]="portal"></ng-container>
|
||||
</div>
|
||||
@@ -0,0 +1,139 @@
|
||||
import { animate, AnimationEvent, style, transition, trigger } from "@angular/animations";
|
||||
import { OverlayRef } from "@angular/cdk/overlay";
|
||||
import { TemplatePortal } from "@angular/cdk/portal";
|
||||
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, HostListener, Inject, InjectionToken, OnDestroy, TemplateRef, ViewContainerRef } from "@angular/core";
|
||||
import { SfngTooltipDirective } from "./tooltip";
|
||||
|
||||
export const SFNG_TOOLTIP_CONTENT = new InjectionToken<string | TemplateRef<any>>('SFNG_TOOLTIP_CONTENT');
|
||||
export const SFNG_TOOLTIP_OVERLAY = new InjectionToken<OverlayRef>('SFNG_TOOLTIP_OVERLAY');
|
||||
|
||||
@Component({
|
||||
selector: 'sfng-tooltip-container',
|
||||
templateUrl: './tooltip-component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [
|
||||
trigger(
|
||||
'moveInOut',
|
||||
[
|
||||
transition(
|
||||
':enter',
|
||||
[
|
||||
style({ opacity: 0, transform: 'translate{{ what }}({{ value }}) scale(0.75)' }),
|
||||
animate('.1s ease-in',
|
||||
style({ opacity: 1, transform: 'translate{{ what }}(0%) scale(1)' }))
|
||||
],
|
||||
{ params: { what: 'Y', value: '-8px' } } // default parameters
|
||||
),
|
||||
transition(
|
||||
':leave',
|
||||
[
|
||||
style({ opacity: 1 }),
|
||||
animate('.1s ease-out',
|
||||
style({ opacity: 0, transform: 'translate{{ what }}({{ value }}) scale(0.75)' }))
|
||||
],
|
||||
{ params: { what: 'Y', value: '8px' } } // default parameters
|
||||
)
|
||||
]
|
||||
)]
|
||||
|
||||
})
|
||||
export class SfngTooltipComponent implements AfterViewInit, OnDestroy {
|
||||
/**
|
||||
* Adds snfg-tooltip-instance class to the host element.
|
||||
* This is used as a selector in the FlexibleConnectedPosition stragegy
|
||||
* to set a transform-origin. That origin is then used for the "arrow" anchor
|
||||
* placement.
|
||||
*/
|
||||
@HostBinding('class.sfng-tooltip-instance')
|
||||
_hostClass = true;
|
||||
|
||||
/**
|
||||
* Used to clear the "hide" timeout when the cursor moves from the the origin
|
||||
* into the tooltip content.
|
||||
* This is required if the tooltip contains rich and likely clickable content.
|
||||
*/
|
||||
@HostListener('mouseenter')
|
||||
onMouseEnter() { this.directive.show() }
|
||||
|
||||
/**
|
||||
* If the tooltip is visible because the user moved inside the tooltip-component
|
||||
* (see comment above) then we need to handle a mouse-leave event as well.
|
||||
*/
|
||||
@HostListener('mouseleave')
|
||||
onMouseLeave() { this.directive.hide() }
|
||||
|
||||
what = 'Y';
|
||||
value = '8px'
|
||||
transformOrigin = '';
|
||||
|
||||
_appAnimate = false;
|
||||
|
||||
private observer: MutationObserver | null = null;
|
||||
|
||||
/** Message is the tooltip message to display in case tooltipContent is a string */
|
||||
message = '';
|
||||
|
||||
/** Portal is the tooltip content to display in case tooltipContent is a template reference */
|
||||
portal: TemplatePortal<any> | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(SFNG_TOOLTIP_CONTENT) tooltipContent: string | TemplateRef<any>,
|
||||
@Inject(SFNG_TOOLTIP_OVERLAY) public overlayRef: OverlayRef,
|
||||
private directive: SfngTooltipDirective,
|
||||
private elementRef: ElementRef<HTMLElement>,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private viewContainer: ViewContainerRef
|
||||
) {
|
||||
if (tooltipContent instanceof TemplateRef) {
|
||||
this.portal = new TemplatePortal(tooltipContent, this.viewContainer)
|
||||
} else {
|
||||
this.message = tooltipContent;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._appAnimate = false;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
animationDone(event: AnimationEvent) {
|
||||
if (event.toState === 'void') {
|
||||
this.overlayRef.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.observer?.disconnect();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.observer = new MutationObserver(mutations => {
|
||||
this.transformOrigin = this.elementRef.nativeElement.style.transformOrigin;
|
||||
if (!this.transformOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [x, y] = this.transformOrigin.split(" ");
|
||||
if (x === 'center') {
|
||||
this.what = 'Y'
|
||||
if (y === 'top') {
|
||||
this.value = '-8px'
|
||||
} else {
|
||||
this.value = '8px'
|
||||
}
|
||||
} else {
|
||||
this.what = 'X'
|
||||
if (x === 'left') {
|
||||
this.value = '-8px'
|
||||
} else {
|
||||
this.value = '8px'
|
||||
}
|
||||
}
|
||||
|
||||
this._appAnimate = true;
|
||||
this.cdr.detectChanges();
|
||||
});
|
||||
this.observer.observe(this.elementRef.nativeElement, { attributes: true, attributeFilter: ['style'] })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { OverlayModule } from "@angular/cdk/overlay";
|
||||
import { PortalModule } from "@angular/cdk/portal";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { SfngTooltipDirective } from "./tooltip";
|
||||
import { SfngTooltipComponent } from "./tooltip-component";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
PortalModule,
|
||||
OverlayModule,
|
||||
CommonModule,
|
||||
],
|
||||
declarations: [
|
||||
SfngTooltipDirective,
|
||||
SfngTooltipComponent
|
||||
],
|
||||
exports: [
|
||||
SfngTooltipDirective
|
||||
]
|
||||
})
|
||||
export class SfngTooltipModule { }
|
||||
|
||||
244
desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip.ts
Normal file
244
desktop/angular/projects/safing/ui/src/lib/tooltip/tooltip.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/* eslint-disable @angular-eslint/no-input-rename */
|
||||
import { coerceNumberProperty } from "@angular/cdk/coercion";
|
||||
import { ConnectedPosition, Overlay, OverlayRef, PositionStrategy } from "@angular/cdk/overlay";
|
||||
import { ComponentPortal } from "@angular/cdk/portal";
|
||||
import { ComponentRef, Directive, ElementRef, HostListener, Injector, Input, isDevMode, OnChanges, OnDestroy, OnInit, TemplateRef } from "@angular/core";
|
||||
import { Subject } from "rxjs";
|
||||
import { SfngTooltipComponent, SFNG_TOOLTIP_CONTENT, SFNG_TOOLTIP_OVERLAY } from "./tooltip-component";
|
||||
|
||||
/** The allowed tooltip positions. */
|
||||
export type SfngTooltipPosition = 'left' | 'right' | 'bottom' | 'top';
|
||||
|
||||
@Directive({
|
||||
selector: '[sfng-tooltip],[snfgTooltip]',
|
||||
})
|
||||
export class SfngTooltipDirective implements OnInit, OnDestroy, OnChanges {
|
||||
/** Used to control the visibility of the tooltip */
|
||||
private attach$ = new Subject<boolean>();
|
||||
|
||||
/** Holds a reference to the tooltip overlay */
|
||||
private tooltipRef: ComponentRef<SfngTooltipComponent> | null = null;
|
||||
|
||||
/**
|
||||
* A reference to a timeout created by setTimeout used to debounce
|
||||
* displaying the tooltip
|
||||
*/
|
||||
private debouncer: any | null = null;
|
||||
|
||||
constructor(
|
||||
private overlay: Overlay,
|
||||
private injector: Injector,
|
||||
private originRef: ElementRef<any>,
|
||||
) { }
|
||||
|
||||
@HostListener('mouseenter')
|
||||
show(delay = this.delay) {
|
||||
if (this.debouncer !== null) {
|
||||
clearTimeout(this.debouncer);
|
||||
}
|
||||
|
||||
this.debouncer = setTimeout(() => {
|
||||
this.debouncer = null;
|
||||
this.attach$.next(true);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
@HostListener('mouseleave')
|
||||
hide(delay = this.delay / 2) {
|
||||
// if we're currently debouncing a "show" than
|
||||
// we should clear that out to avoid re-attaching
|
||||
// the tooltip right after we disposed it.
|
||||
if (this.debouncer !== null) {
|
||||
clearTimeout(this.debouncer);
|
||||
this.debouncer = null;
|
||||
}
|
||||
|
||||
this.debouncer = setTimeout(() => {
|
||||
this.attach$.next(false);
|
||||
this.debouncer = null;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Debounce delay before showing the tooltip */
|
||||
@Input('sfngTooltipDelay')
|
||||
set delay(v: any) {
|
||||
this._delay = coerceNumberProperty(v);
|
||||
}
|
||||
get delay() { return this._delay }
|
||||
private _delay = 500;
|
||||
|
||||
/** An additional offset between the tooltip overlay and the origin centers */
|
||||
@Input('sfngTooltipOffset')
|
||||
set offset(v: any) {
|
||||
this._offset = coerceNumberProperty(v);
|
||||
}
|
||||
private _offset: number | null = 8;
|
||||
|
||||
/** The actual content that should be displayed in the tooltip overlay. */
|
||||
@Input('sfngTooltip')
|
||||
@Input('sfng-tooltip')
|
||||
tooltipContent: string | TemplateRef<any> | null = null;
|
||||
|
||||
@Input('snfgTooltipPosition')
|
||||
position: ConnectedPosition | SfngTooltipPosition | (SfngTooltipPosition | ConnectedPosition)[] | 'any' = 'any';
|
||||
|
||||
ngOnInit() {
|
||||
this.attach$
|
||||
.subscribe(attach => {
|
||||
if (attach) {
|
||||
this.createTooltip();
|
||||
return;
|
||||
}
|
||||
if (!!this.tooltipRef) {
|
||||
this.tooltipRef.instance.dispose();
|
||||
this.tooltipRef = null;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.attach$.next(false);
|
||||
this.attach$.complete();
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
// if the tooltip content has be set to null and we're still
|
||||
// showing the tooltip we treat that as an attempt to hide.
|
||||
if (this.tooltipContent === null && !!this.tooltipRef) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates the actual tooltip overlay */
|
||||
private createTooltip() {
|
||||
// there's nothing to do if the tooltip is still active.
|
||||
if (!!this.tooltipRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
// support disabling the tooltip by passing "null" for
|
||||
// the content.
|
||||
if (this.tooltipContent === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = this.buildPositionStrategy();
|
||||
|
||||
const overlayRef = this.overlay.create({
|
||||
positionStrategy: position,
|
||||
scrollStrategy: this.overlay.scrollStrategies.close(),
|
||||
disposeOnNavigation: true,
|
||||
});
|
||||
|
||||
// make sure we close the tooltip if the user clicks on our
|
||||
// originRef.
|
||||
overlayRef.outsidePointerEvents()
|
||||
.subscribe(() => this.hide());
|
||||
|
||||
overlayRef.attachments()
|
||||
.subscribe(() => {
|
||||
if (!overlayRef) {
|
||||
return
|
||||
}
|
||||
overlayRef.updateSize({});
|
||||
overlayRef.updatePosition();
|
||||
})
|
||||
|
||||
// create a component portal for the tooltip component
|
||||
// and attach it to our newly created overlay.
|
||||
const portal = this.getOverlayPortal(overlayRef);
|
||||
this.tooltipRef = overlayRef.attach(portal);
|
||||
}
|
||||
|
||||
private getOverlayPortal(ref: OverlayRef): ComponentPortal<SfngTooltipComponent> {
|
||||
const inj = Injector.create({
|
||||
providers: [
|
||||
{ provide: SFNG_TOOLTIP_CONTENT, useValue: this.tooltipContent },
|
||||
{ provide: SFNG_TOOLTIP_OVERLAY, useValue: ref },
|
||||
],
|
||||
parent: this.injector,
|
||||
name: 'SfngTooltipDirective'
|
||||
})
|
||||
|
||||
const portal = new ComponentPortal(
|
||||
SfngTooltipComponent,
|
||||
undefined,
|
||||
inj
|
||||
)
|
||||
|
||||
return portal;
|
||||
}
|
||||
|
||||
/** Builds a FlexibleConnectedPositionStrategy for the tooltip overlay */
|
||||
private buildPositionStrategy(): PositionStrategy {
|
||||
let pos = this.position;
|
||||
if (pos === 'any') {
|
||||
pos = ['top', 'bottom', 'right', 'left']
|
||||
} else if (!Array.isArray(pos)) {
|
||||
pos = [pos];
|
||||
}
|
||||
|
||||
let allowedPositions: ConnectedPosition[] =
|
||||
pos.map(p => {
|
||||
if (typeof p === 'string') {
|
||||
return this.getAllowedConnectedPosition(p);
|
||||
}
|
||||
// this is already a ConnectedPosition
|
||||
return p
|
||||
});
|
||||
|
||||
let position = this.overlay.position()
|
||||
.flexibleConnectedTo(this.originRef)
|
||||
.withFlexibleDimensions(true)
|
||||
.withPush(true)
|
||||
.withPositions(allowedPositions)
|
||||
.withGrowAfterOpen(true)
|
||||
.withTransformOriginOn('.sfng-tooltip-instance')
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
private getAllowedConnectedPosition(type: SfngTooltipPosition): ConnectedPosition {
|
||||
switch (type) {
|
||||
case 'left':
|
||||
return {
|
||||
originX: 'start',
|
||||
originY: 'center',
|
||||
overlayX: 'end',
|
||||
overlayY: 'center',
|
||||
offsetX: - (this._offset || 0),
|
||||
}
|
||||
case 'right':
|
||||
return {
|
||||
originX: 'end',
|
||||
originY: 'center',
|
||||
overlayX: 'start',
|
||||
overlayY: 'center',
|
||||
offsetX: (this._offset || 0),
|
||||
}
|
||||
case 'top':
|
||||
return {
|
||||
originX: 'center',
|
||||
originY: 'top',
|
||||
overlayX: 'center',
|
||||
overlayY: 'bottom',
|
||||
offsetY: - (this._offset || 0),
|
||||
}
|
||||
case 'bottom':
|
||||
return {
|
||||
originX: 'center',
|
||||
originY: 'bottom',
|
||||
overlayX: 'center',
|
||||
overlayY: 'top',
|
||||
offsetY: (this._offset || 0),
|
||||
}
|
||||
default:
|
||||
if (isDevMode()) {
|
||||
throw new Error(`invalid value for SfngTooltipPosition: ${type}`)
|
||||
}
|
||||
// fallback to "right"
|
||||
return this.getAllowedConnectedPosition('right')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
desktop/angular/projects/safing/ui/src/lib/ui.module.ts
Normal file
10
desktop/angular/projects/safing/ui/src/lib/ui.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { SfngAccordionModule } from './accordion';
|
||||
|
||||
|
||||
@NgModule({
|
||||
exports: [
|
||||
SfngAccordionModule
|
||||
]
|
||||
})
|
||||
export class UiModule { }
|
||||
16
desktop/angular/projects/safing/ui/src/public-api.ts
Normal file
16
desktop/angular/projects/safing/ui/src/public-api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Public API Surface of ui
|
||||
*/
|
||||
|
||||
export * from './lib/accordion';
|
||||
export * from './lib/dialog';
|
||||
export * from './lib/dropdown';
|
||||
export * from './lib/overlay-stepper';
|
||||
export * from './lib/pagination';
|
||||
export * from './lib/select';
|
||||
export * from './lib/tabs';
|
||||
export * from './lib/tipup';
|
||||
export * from './lib/toggle-switch';
|
||||
export * from './lib/tooltip';
|
||||
export * from './lib/ui.module';
|
||||
|
||||
16
desktop/angular/projects/safing/ui/src/test.ts
Normal file
16
desktop/angular/projects/safing/ui/src/test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
|
||||
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
import {
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting
|
||||
} from '@angular/platform-browser-dynamic/testing';
|
||||
import 'zone.js';
|
||||
import 'zone.js/testing';
|
||||
|
||||
// First, initialize the Angular testing environment.
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserDynamicTestingModule,
|
||||
platformBrowserDynamicTesting(),
|
||||
{ teardown: { destroyAfterEach: true } },
|
||||
);
|
||||
Reference in New Issue
Block a user