Migrate Angular UI from portmaster-ui to desktop/angular. Update Earthfile to build libs, UI and tauri-builtin

This commit is contained in:
Patrick Pacher
2024-03-20 10:43:29 +01:00
parent 66381baa1a
commit 4b77945517
922 changed files with 84071 additions and 26 deletions

View File

@@ -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;
}
}

View File

@@ -0,0 +1,4 @@
export * from './item';
export * from './select';
export * from './select.module';

View 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>) { }
}

View File

@@ -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>

View File

@@ -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 { }

View 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;
}
}