- {{ driver.name }}
{{
driver.commit | slice: 0 : 9
}}
{{
@@ -132,7 +133,7 @@ import { TranslatePipe } from '../ui/translate.pipe';
}
@@ -142,7 +143,7 @@ import { TranslatePipe } from '../ui/translate.pipe';
>
@@ -161,15 +162,12 @@ import { TranslatePipe } from '../ui/translate.pipe';
`,
],
imports: [
- LoadingLabelDirective,
+ DialogCloseDirective,
+ SpinnerComponent,
+ TooltipDirective,
DialogTitleDirective,
- MatDialogModule,
TranslatePipe,
IconComponent,
- MatRippleModule,
- MatProgressSpinnerModule,
- MatCheckboxModule,
- MatTooltipModule,
FormsModule,
DateFromPipe,
SlicePipe,
@@ -177,7 +175,7 @@ import { TranslatePipe } from '../ui/translate.pipe';
})
export class DriverUpdateListModalComponent {
private _dialog_ref =
- inject>(MatDialogRef);
+ inject>(DialogRef);
public readonly loading = signal(i18n('DRIVERS.LOADING'));
private readonly _change = signal(0);
diff --git a/src/app/drivers/drivers.component.ts b/src/app/drivers/drivers.component.ts
index 211d2f432..2a9aa7662 100644
--- a/src/app/drivers/drivers.component.ts
+++ b/src/app/drivers/drivers.component.ts
@@ -1,8 +1,7 @@
import { Component, computed, inject } from '@angular/core';
-import { MatRippleModule } from '@angular/material/core';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { extensionsForItem } from '../common/api';
import { i18n } from '../common/locale.service';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { IconComponent } from '../ui/icon.component';
import { ItemShellComponent } from '../ui/item-shell.component';
import { ItemTab } from '../ui/item-tablist.component';
@@ -15,10 +14,12 @@ import { DriverStateService } from './driver-state.service';
@if (updates_available()) {
update
@@ -27,10 +28,9 @@ import { DriverStateService } from './driver-state.service';
`,
imports: [
+ TooltipDirective,
ItemShellComponent,
IconComponent,
- MatRippleModule,
- MatTooltipModule,
TranslatePipe,
],
})
diff --git a/src/app/groups/group-about.component.ts b/src/app/groups/group-about.component.ts
index b14288d5a..de518d7f2 100644
--- a/src/app/groups/group-about.component.ts
+++ b/src/app/groups/group-about.component.ts
@@ -1,6 +1,5 @@
import { AsyncPipe } from '@angular/common';
import { Component, computed, effect, inject, signal } from '@angular/core';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import {
PlaceDomain,
@@ -8,6 +7,7 @@ import {
showDomain,
showGroup,
} from '@placeos/ts-client';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { DateFromPipe } from '../ui/pipes/date-from.pipe';
import { MarkdownPipe } from '../ui/pipes/markdown.pipe';
import { TranslatePipe } from '../ui/translate.pipe';
@@ -139,7 +139,7 @@ import { GroupStateService } from './group-state.service';
{{ created_at() * 1000 | dateFrom }}
@@ -150,7 +150,7 @@ import { GroupStateService } from './group-state.service';
{{ updated_at() * 1000 | dateFrom }}
@@ -181,7 +181,7 @@ import { GroupStateService } from './group-state.service';
`,
],
imports: [
- MatTooltipModule,
+ TooltipDirective,
TranslatePipe,
DateFromPipe,
MarkdownPipe,
diff --git a/src/app/groups/group-ad-groups-field.component.ts b/src/app/groups/group-ad-groups-field.component.ts
index bd993378a..86aa56ffe 100644
--- a/src/app/groups/group-ad-groups-field.component.ts
+++ b/src/app/groups/group-ad-groups-field.component.ts
@@ -1,3 +1,4 @@
+import { Dialog } from '@angular/cdk/dialog';
import {
Component,
computed,
@@ -7,18 +8,18 @@ import {
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
-import { MatDialog } from '@angular/material/dialog';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { PlaceGroupAdMappings } from '@placeos/ts-client';
import { waitForEvent } from '../common/signals';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component';
import { IconComponent } from '../ui/icon.component';
import { TranslatePipe } from '../ui/translate.pipe';
import { groupPermissionLabels } from './group-permissions';
-import { GroupPermissionsModalComponent } from './group-permissions-modal.component';
+import {
+ GroupPermissionsModalComponent,
+ GroupPermissionsResult,
+} from './group-permissions-modal.component';
import {
removeAdGroupMapping,
searchStaffGroups,
@@ -47,38 +48,30 @@ import {
/>
} @else if (searchable() === false) {
-
+
-
-
+
+
-
+
add
@@ -112,8 +105,8 @@ import {
edit
@@ -122,8 +115,8 @@ import {
type="button"
icon
error
- matRipple
- [matTooltip]="'GROUPS.AD_GROUP_REMOVE' | translate"
+ ripple
+ [appTooltip]="'GROUPS.AD_GROUP_REMOVE' | translate"
(click)="removeGroup(row.id)"
>
delete
@@ -138,18 +131,16 @@ import {
`,
styles: [``],
imports: [
+ TooltipDirective,
+ FormFieldComponent,
FormsModule,
IconComponent,
ItemSearchFieldComponent,
- MatFormFieldModule,
- MatInputModule,
- MatRippleModule,
- MatTooltipModule,
TranslatePipe,
],
})
export class GroupAdGroupsFieldComponent {
- private _dialog = inject(MatDialog);
+ private _dialog = inject(Dialog);
public readonly mappings = model({});
public readonly default_permissions = input(0);
@@ -200,14 +191,15 @@ export class GroupAdGroupsFieldComponent {
permissions: number;
}) {
const result = await waitForEvent(
- this._dialog
- .open(GroupPermissionsModalComponent, {
+ this._dialog.open(
+ GroupPermissionsModalComponent,
+ {
data: {
title: 'GROUPS.AD_GROUP_PERMISSIONS',
permissions: row.permissions,
},
- })
- .afterClosed(),
+ },
+ ).closed,
);
if (!result) return;
this.mappings.update((mappings) =>
diff --git a/src/app/groups/group-bulk-add-modal.component.ts b/src/app/groups/group-bulk-add-modal.component.ts
index 2648e229e..ffcccbf2e 100644
--- a/src/app/groups/group-bulk-add-modal.component.ts
+++ b/src/app/groups/group-bulk-add-modal.component.ts
@@ -1,18 +1,12 @@
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import { Component, computed, inject, resource, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
-import {
- MAT_DIALOG_DATA,
- MatDialogModule,
- MatDialogRef,
-} from '@angular/material/dialog';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatTabsModule } from '@angular/material/tabs';
+import { DialogCloseDirective } from '../ui/controls/dialog';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
+import { SpinnerComponent } from '../ui/controls/spinner.component';
+import { TabsComponent } from '../ui/controls/tabs.component';
import { DialogTitleDirective } from '../ui/dialog-title.directive';
import { IconComponent } from '../ui/icon.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { SettingsToggleComponent } from '../ui/settings-toggle.component';
import { TranslatePipe } from '../ui/translate.pipe';
import {
@@ -59,51 +53,50 @@ export interface GroupBulkAddResult {
close
@if (show_permissions) {
-
-
-
-
+ [labels]="[
+ 'GROUPS.TAB_USERS' | translate,
+ 'GROUPS.FIELD_PERMISSIONS' | translate,
+ ]"
+ [(selectedIndex)]="tab"
+ />
}
@if (!show_permissions || tab() === 0) {
-
+
-
+
search
@if (loading()) {
-
-
+
}
-
+
@if (list_items().length) {
@for (item of list_items(); track item.id) {
{
>
{{ 'COMMON.CANCEL' | translate }}
{
`,
],
imports: [
- LoadingLabelDirective,
+ DialogCloseDirective,
+ SpinnerComponent,
+ FormFieldComponent,
DialogTitleDirective,
FormsModule,
IconComponent,
- MatDialogModule,
- MatFormFieldModule,
- MatInputModule,
- MatProgressSpinnerModule,
- MatRippleModule,
- MatTabsModule,
+ TabsComponent,
SettingsToggleComponent,
TranslatePipe,
],
})
export class GroupBulkAddModalComponent {
private _dialog_ref =
- inject<
- MatDialogRef, GroupBulkAddResult>
- >(MatDialogRef);
- private _data = inject>(MAT_DIALOG_DATA);
+ inject, GroupBulkAddModalComponent>>(
+ DialogRef,
+ );
+ private _data = inject>(DIALOG_DATA);
public readonly selected = signal([]);
public readonly search = signal('');
diff --git a/src/app/groups/group-form.component.ts b/src/app/groups/group-form.component.ts
index 57a9b0576..eec712c20 100644
--- a/src/app/groups/group-form.component.ts
+++ b/src/app/groups/group-form.component.ts
@@ -1,4 +1,4 @@
-import { COMMA, ENTER } from '@angular/cdk/keycodes';
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import {
Component,
computed,
@@ -11,11 +11,6 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { disabled, form, FormField } from '@angular/forms/signals';
-import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips';
-import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatSelectModule } from '@angular/material/select';
import {
addGroup,
cleanObject,
@@ -26,18 +21,16 @@ import {
showGroup,
updateGroup,
} from '@placeos/ts-client';
-import {
- addSignalChipItem,
- onSaveHotkey,
- removeSignalChipItem,
- submitFormDialog,
-} from '../common/forms';
+import { onSaveHotkey, submitFormDialog } from '../common/forms';
import { subtreeFilter } from '../common/hierarchy';
import { i18n } from '../common/locale.service';
import { DialogEvent } from '../common/types';
+import { ChipListComponent } from '../ui/controls/chip-list.component';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
+import { OptionComponent } from '../ui/controls/option.component';
+import { SelectComponent } from '../ui/controls/select.component';
import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component';
import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component';
-import { IconComponent } from '../ui/icon.component';
import { SettingsToggleComponent } from '../ui/settings-toggle.component';
import { TranslatePipe } from '../ui/translate.pipe';
import { GroupAdGroupsFieldComponent } from './group-ad-groups-field.component';
@@ -70,32 +63,30 @@ import {
>
{{ 'COMMON.FIELD_NAME' | translate }}*
-
+
- {{
- 'GROUPS.NAME_REQUIRED' | translate
- }}
-
+
+ {{ 'GROUPS.NAME_REQUIRED' | translate }}
+
+
{{
'COMMON.FIELD_DESCRIPTION' | translate
}}
-
+
-
+
@@ -120,8 +111,8 @@ import {
id="group-authority-label"
>{{ 'GROUPS.AUTHORITY_ID' | translate }}
-
-
+
+
{{ domain.name }}
-
+
}
-
-
+
+
{{
'GROUPS.SUBSYSTEMS' | translate
}}
-
-
- @for (
- subsystem of subsystem_list();
- track subsystem
- ) {
-
-
- {{ subsystem }}
-
-
- cancel
-
-
- }
-
-
+
-
+
@@ -233,13 +197,12 @@ import {
`,
styles: [``],
imports: [
- MatFormFieldModule,
- MatInputModule,
- MatSelectModule,
- MatChipsModule,
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
+ ChipListComponent,
FormsModule,
ItemSearchFieldComponent,
- IconComponent,
TranslatePipe,
FormField,
FullscreenModalShellComponent,
@@ -249,8 +212,8 @@ import {
})
export class GroupFormComponent implements OnInit {
private _dialog_ref =
- inject>(MatDialogRef);
- private _data = inject<{ item: PlaceGroup }>(MAT_DIALOG_DATA);
+ inject>(DialogRef);
+ private _data = inject<{ item: PlaceGroup }>(DIALOG_DATA);
private readonly _name = 'GROUPS';
@Output() public event = new EventEmitter();
@@ -272,10 +235,6 @@ export class GroupFormComponent implements OnInit {
() => this._domain_list.value() || [],
);
public readonly parent_group = signal(null);
- public readonly subsystem_list = computed(
- () => this.formModel().subsystems || [],
- );
- public readonly separators: number[] = [ENTER, COMMA];
public readonly permission_flags = GROUP_PERMISSION_FLAGS;
public readonly hasPermission = hasGroupPermission;
/** Whether AD groups can be found with the staff API */
@@ -344,18 +303,6 @@ export class GroupFormComponent implements OnInit {
this.formModel.update((value) => ({ ...value, ad_group_mappings }));
}
- public readonly addSubsystem = (event: MatChipInputEvent) =>
- this.formModel.update((value) => ({
- ...value,
- subsystems: addSignalChipItem(value.subsystems, event),
- }));
-
- public readonly removeSubsystem = (subsystem: string) =>
- this.formModel.update((value) => ({
- ...value,
- subsystems: removeSignalChipItem(value.subsystems, subsystem),
- }));
-
public async submit(): Promise {
await submitFormDialog(
this.form,
diff --git a/src/app/groups/group-membership.component.ts b/src/app/groups/group-membership.component.ts
index d0890d5d0..60c743fdf 100644
--- a/src/app/groups/group-membership.component.ts
+++ b/src/app/groups/group-membership.component.ts
@@ -6,11 +6,10 @@ import {
viewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
-import { MatProgressBarModule } from '@angular/material/progress-bar';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { i18n } from '../common/locale.service';
+import { ProgressBarComponent } from '../ui/controls/progress-bar.component';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component';
import { IconComponent } from '../ui/icon.component';
import {
@@ -39,9 +38,9 @@ import { groupPermissionLabels } from './group-permissions';
/>
playlist_add
@@ -51,7 +50,7 @@ import { groupPermissionLabels } from './group-permissions';
-
edit
@@ -130,8 +129,8 @@ import { groupPermissionLabels } from './group-permissions';
icon
default
error
- matRipple
- [matTooltip]="opts.one + '_REMOVE' | translate"
+ ripple
+ [appTooltip]="opts.one + '_REMOVE' | translate"
(click)="members().remove(row)"
>
delete
@@ -149,12 +148,11 @@ import { groupPermissionLabels } from './group-permissions';
`,
],
imports: [
+ ProgressBarComponent,
+ TooltipDirective,
FormsModule,
IconComponent,
ItemSearchFieldComponent,
- MatProgressBarModule,
- MatRippleModule,
- MatTooltipModule,
RouterModule,
SimpleTableComponent,
TranslatePipe,
diff --git a/src/app/groups/group-membership.ts b/src/app/groups/group-membership.ts
index 9d12f0ed5..8c9526b40 100644
--- a/src/app/groups/group-membership.ts
+++ b/src/app/groups/group-membership.ts
@@ -1,5 +1,5 @@
+import { Dialog } from '@angular/cdk/dialog';
import { signal } from '@angular/core';
-import { MatDialog } from '@angular/material/dialog';
import { describeError } from '../common/errors';
import { escapeHtml } from '../common/general';
import { i18n } from '../common/locale.service';
@@ -12,7 +12,10 @@ import {
GroupBulkAddModalData,
GroupBulkAddResult,
} from './group-bulk-add-modal.component';
-import { GroupPermissionsModalComponent } from './group-permissions-modal.component';
+import {
+ GroupPermissionsModalComponent,
+ GroupPermissionsResult,
+} from './group-permissions-modal.component';
/** Settings for one kind of group membership, e.g. the users of a group */
export interface GroupMembershipOptions {
@@ -73,7 +76,7 @@ export type GroupMembership<
export function groupMembership<
Row extends { permissions?: number; deny?: boolean },
Item extends GroupBulkAddItem,
->(dialog: MatDialog, options: GroupMembershipOptions) {
+>(dialog: Dialog, options: GroupMembershipOptions) {
const { one, many } = options;
const saving = signal(false);
@@ -105,29 +108,27 @@ export function groupMembership<
const bulkAdd = async () => {
const show_permissions = !!options.bulk_permissions;
const result = await waitForEvent(
- dialog
- .open<
- GroupBulkAddModalComponent- ,
- GroupBulkAddModalData
- ,
- GroupBulkAddResult
-
- >(GroupBulkAddModalComponent
- , {
- data: {
- title: `${many}_BULK`,
- placeholder: options.search,
- empty_message: `${many}_BULK_EMPTY`,
- show_permissions,
- permissions: options.bulk_permissions?.(),
- query_fn: (q: string) =>
- options.query(q, options.authority_id()),
- exclude,
- },
- panelClass: 'bulk-add-dialog',
- height: 'auto',
- width: 'auto',
- maxHeight: 'calc(100vh - 2em)',
- maxWidth: 'calc(100vw - 2em)',
- })
- .afterClosed(),
+ dialog.open<
+ GroupBulkAddResult
- ,
+ GroupBulkAddModalData
- ,
+ GroupBulkAddModalComponent
-
+ >(GroupBulkAddModalComponent
- , {
+ data: {
+ title: `${many}_BULK`,
+ placeholder: options.search,
+ empty_message: `${many}_BULK_EMPTY`,
+ show_permissions,
+ permissions: options.bulk_permissions?.(),
+ query_fn: (q: string) =>
+ options.query(q, options.authority_id()),
+ exclude,
+ },
+ panelClass: 'bulk-add-dialog',
+ height: 'auto',
+ width: 'auto',
+ maxHeight: 'calc(100vh - 2em)',
+ maxWidth: 'calc(100vw - 2em)',
+ }).closed,
);
const items = result?.items;
if (!items?.length) return;
@@ -178,16 +179,17 @@ export function groupMembership<
const editPermissions = async (row: Row) => {
const result = await waitForEvent(
- dialog
- .open(GroupPermissionsModalComponent, {
+ dialog.open
(
+ GroupPermissionsModalComponent,
+ {
data: {
title: `${one}_PERMISSIONS`,
permissions: row.permissions,
deny: row.deny,
show_deny: !!options.show_deny,
},
- })
- .afterClosed(),
+ },
+ ).closed,
);
if (!result) return;
try {
diff --git a/src/app/groups/group-permissions-modal.component.ts b/src/app/groups/group-permissions-modal.component.ts
index c8c6ffb9d..fc7369e8b 100644
--- a/src/app/groups/group-permissions-modal.component.ts
+++ b/src/app/groups/group-permissions-modal.component.ts
@@ -1,11 +1,7 @@
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
-import {
- MAT_DIALOG_DATA,
- MatDialogModule,
- MatDialogRef,
-} from '@angular/material/dialog';
+import { DialogCloseDirective } from '../ui/controls/dialog';
import { DialogTitleDirective } from '../ui/dialog-title.directive';
import { IconComponent } from '../ui/icon.component';
import { SettingsToggleComponent } from '../ui/settings-toggle.component';
@@ -28,6 +24,12 @@ export interface GroupPermissionsModalResult {
deny?: boolean;
}
+/** Result of the permissions modal when the user saves */
+export interface GroupPermissionsResult {
+ permissions: number;
+ deny: boolean;
+}
+
@Component({
selector: 'group-permissions-modal',
template: `
@@ -65,13 +67,13 @@ export interface GroupPermissionsModalResult {
>
{{ 'COMMON.CANCEL' | translate }}
-
+
save
{{ 'COMMON.SAVE' | translate }}
@@ -79,11 +81,10 @@ export interface GroupPermissionsModalResult {
`,
styles: [``],
imports: [
+ DialogCloseDirective,
DialogTitleDirective,
FormsModule,
IconComponent,
- MatDialogModule,
- MatRippleModule,
SettingsToggleComponent,
TranslatePipe,
],
@@ -91,12 +92,12 @@ export interface GroupPermissionsModalResult {
export class GroupPermissionsModalComponent {
private _dialog_ref =
inject<
- MatDialogRef<
- GroupPermissionsModalComponent,
- GroupPermissionsModalResult
+ DialogRef<
+ GroupPermissionsModalResult,
+ GroupPermissionsModalComponent
>
- >(MatDialogRef);
- private _data = inject(MAT_DIALOG_DATA);
+ >(DialogRef);
+ private _data = inject(DIALOG_DATA);
public readonly title = this._data.title;
public readonly show_deny = this._data.show_deny;
@@ -112,9 +113,10 @@ export class GroupPermissionsModalComponent {
}
public save() {
- this._dialog_ref.close({
+ const result: GroupPermissionsResult = {
permissions: this.permissions(),
deny: this.deny,
- });
+ };
+ this._dialog_ref.close(result);
}
}
diff --git a/src/app/groups/group-state.service.ts b/src/app/groups/group-state.service.ts
index 06b4b1d39..a32660046 100644
--- a/src/app/groups/group-state.service.ts
+++ b/src/app/groups/group-state.service.ts
@@ -1,5 +1,5 @@
+import { Dialog } from '@angular/cdk/dialog';
import { computed, inject, resource, Service, signal } from '@angular/core';
-import { MatDialog } from '@angular/material/dialog';
import {
addGroupUser,
addGroupZone,
@@ -27,7 +27,7 @@ import { groupMembership } from './group-membership';
@Service()
export class GroupStateService {
private _state = inject(ActiveItemService);
- private _dialog = inject(MatDialog);
+ private _dialog = inject(Dialog);
private _changed = signal(0);
public readonly item = computed(
diff --git a/src/app/modules/module-about.component.ts b/src/app/modules/module-about.component.ts
index f8b58533c..f7fb0e8cb 100644
--- a/src/app/modules/module-about.component.ts
+++ b/src/app/modules/module-about.component.ts
@@ -2,16 +2,14 @@ import { Component, inject, signal } from '@angular/core';
import { PlaceModule } from '@placeos/ts-client';
import { hasSupportRole } from '../common/support-access';
-import { MatRippleModule } from '@angular/material/core';
-import { MatDialog } from '@angular/material/dialog';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatTooltipModule } from '@angular/material/tooltip';
+import { Dialog } from '@angular/cdk/dialog';
import { RouterModule } from '@angular/router';
+import { SpinnerComponent } from '../ui/controls/spinner.component';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { CustomTooltipComponent } from '../ui/custom-tooltip.component';
import { SettingsFormComponent } from '../ui/forms/settings-form.component';
import { IconComponent } from '../ui/icon.component';
import { ItemTimestampsComponent } from '../ui/item-timestamps.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { ModuleRuntimeErrorsModalComponent } from '../ui/module-runtime-errors-modal.component';
import { TranslatePipe } from '../ui/translate.pipe';
import { ModuleStateService } from './module-state.service';
@@ -145,7 +143,7 @@ import { ModuleStateService } from './module-state.service';
@if (item.has_runtime_error && hasSupportRole()) {
@@ -161,15 +159,15 @@ import { ModuleStateService } from './module-state.service';
icon
default
start
- matRipple
+ ripple
[disabled]="item.running || stopping()"
(click)="toggleModuleState()"
- [matTooltip]="'SYSTEMS.START' | translate"
+ [appTooltip]="'SYSTEMS.START' | translate"
>
@if (!stopping()) {
play_arrow
} @else {
-
+
}
@if (!stopping()) {
stop
} @else {
-
+
}
@@ -205,7 +203,7 @@ import { ModuleStateService } from './module-state.service';
-
+
{{ 'MODULES.LOADING_SETTINGS' | translate }}
}
@@ -224,13 +222,11 @@ import { ModuleStateService } from './module-state.service';
`,
],
imports: [
+ SpinnerComponent,
+ TooltipDirective,
ItemTimestampsComponent,
- LoadingLabelDirective,
TranslatePipe,
- MatProgressSpinnerModule,
SettingsFormComponent,
- MatRippleModule,
- MatTooltipModule,
CustomTooltipComponent,
IconComponent,
RouterModule,
@@ -239,7 +235,7 @@ import { ModuleStateService } from './module-state.service';
export class ModuleAboutComponent {
public readonly hasSupportRole = hasSupportRole;
private _service = inject(ModuleStateService);
- private _dialog = inject(MatDialog);
+ private _dialog = inject(Dialog);
/** Driver for the active item */
public readonly driver = this._service.driver;
@@ -263,7 +259,7 @@ export class ModuleAboutComponent {
}
public viewErrors() {
- this._dialog.open(
+ this._dialog.open(
ModuleRuntimeErrorsModalComponent,
{ data: this.item.id },
);
diff --git a/src/app/modules/module-form.component.ts b/src/app/modules/module-form.component.ts
index fc2dd7c2d..2156b8397 100644
--- a/src/app/modules/module-form.component.ts
+++ b/src/app/modules/module-form.component.ts
@@ -25,13 +25,13 @@ import {
querySupportSystems as querySystems,
} from '../common/support-access';
-import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatSelectModule } from '@angular/material/select';
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import { mergeFormItem, onSaveHotkey, submitFormDialog } from '../common/forms';
import { i18n } from '../common/locale.service';
import { DialogEvent } from '../common/types';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
+import { OptionComponent } from '../ui/controls/option.component';
+import { SelectComponent } from '../ui/controls/select.component';
import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component';
import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component';
import { SettingsToggleComponent } from '../ui/settings-toggle.component';
@@ -142,19 +142,18 @@ import {
{{ 'MODULES.URI' | translate
}}*
-
+
- {{
- 'MODULES.URI_REQUIRED' | translate
- }}
-
+
+ {{ 'MODULES.URI_REQUIRED' | translate }}
+
+
}
@@ -173,22 +172,21 @@ import {
{{ 'MODULES.FIELD_IP' | translate }}
*
-
+
@if (form.ip().invalid()) {
-
+
{{
'MODULES.IP_REQUIRED'
| translate
}}
-
+
}
-
+
}
@if (
@@ -206,9 +204,8 @@ import {
{{ 'MODULES.PORT_NUMBER' | translate }}
*
-
+
@if (form.port().invalid()) {
-
+
{{
'MODULES.PORT_REQUIRED'
| translate
}}
-
+
}
-
+
}
@@ -272,8 +269,8 @@ import {
{{ 'COMMON.ALERT_LEVEL' | translate }}
-
-
+
+
{{ level.name | translate }}
-
+
}
-
-
+
+
}
@if (form.notes) {
@@ -295,16 +292,15 @@ import {
{{
'COMMON.NOTES' | translate
}}
-
+
-
+
}
@if (form.custom_name) {
@@ -312,16 +308,15 @@ import {
{{ 'MODULES.CUSTOM_NAME' | translate }}
-
+
-
+
}
@if (form.edge && !formModel().id) {
@@ -348,21 +343,21 @@ import {
`,
styles: [``],
imports: [
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
ItemSearchFieldComponent,
TranslatePipe,
FormField,
- MatFormFieldModule,
- MatInputModule,
- MatSelectModule,
SettingsToggleComponent,
FullscreenModalShellComponent,
],
})
export class ModuleFormComponent implements OnInit {
private _dialog_ref =
- inject>(MatDialogRef);
+ inject>(DialogRef);
private _data = inject<{ item: PlaceModule; readonly?: string }>(
- MAT_DIALOG_DATA,
+ DIALOG_DATA,
);
private readonly _name = 'MODULES';
diff --git a/src/app/modules/module-state.service.ts b/src/app/modules/module-state.service.ts
index b7e1af1c9..3305306ef 100644
--- a/src/app/modules/module-state.service.ts
+++ b/src/app/modules/module-state.service.ts
@@ -1,5 +1,5 @@
+import { Dialog } from '@angular/cdk/dialog';
import { computed, inject, resource, Service, signal } from '@angular/core';
-import { MatDialog } from '@angular/material/dialog';
import {
moduleSettings,
PlaceModule,
@@ -19,7 +19,7 @@ import { ViewResponseModalComponent } from '../overlays/view-response-modal.comp
@Service()
export class ModuleStateService {
private _state = inject(ActiveItemService);
- private _dialog = inject(MatDialog);
+ private _dialog = inject(Dialog);
private _loading = signal(false);
@@ -132,7 +132,7 @@ export class ModuleStateService {
/** View Results of the execute */
private viewDetails(content: unknown) {
- this._dialog.open(
+ this._dialog.open(
ViewResponseModalComponent,
{
data: { content },
diff --git a/src/app/modules/module-systems.component.ts b/src/app/modules/module-systems.component.ts
index 4f29e281b..5a6c7a9d2 100644
--- a/src/app/modules/module-systems.component.ts
+++ b/src/app/modules/module-systems.component.ts
@@ -1,12 +1,10 @@
import { Component, computed, inject, model } from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatProgressBarModule } from '@angular/material/progress-bar';
import { RouterModule } from '@angular/router';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
+import { ProgressBarComponent } from '../ui/controls/progress-bar.component';
import { IconComponent } from '../ui/icon.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { DateFromPipe } from '../ui/pipes/date-from.pipe';
import { SimpleTableComponent } from '../ui/simple-table.component';
import { TranslatePipe } from '../ui/translate.pipe';
@@ -17,22 +15,21 @@ import { ModuleStateService } from './module-state.service';
template: `
-
-
}
@if (item()) {
@@ -112,9 +113,10 @@ export type AuthSourceTypes = 'oauth' | 'saml' | 'ldap';
`,
styles: [``],
imports: [
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
FullscreenModalShellComponent,
- MatFormFieldModule,
- MatSelectModule,
TranslatePipe,
FormsModule,
SamlSourceFormComponent,
@@ -124,8 +126,8 @@ export type AuthSourceTypes = 'oauth' | 'saml' | 'ldap';
})
export class AuthSourceModalComponent implements OnInit {
private _dialog =
- inject>(MatDialogRef);
- private _data = inject(MAT_DIALOG_DATA);
+ inject>(DialogRef);
+ private _data = inject(DIALOG_DATA);
/** List of available authentication sources */
public source_types: Identity[] = [
diff --git a/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts b/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts
index e5d489634..d9e2c8bac 100644
--- a/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts
+++ b/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts
@@ -1,11 +1,6 @@
import { Component, Type, computed, inject, signal } from '@angular/core';
-import {
- MAT_DIALOG_DATA,
- MatDialogModule,
- MatDialogRef,
-} from '@angular/material/dialog';
-import { MatRippleModule } from '@angular/material/core';
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import {
PlaceDriver,
PlaceModule,
@@ -17,6 +12,7 @@ import {
import { unique } from '../../common/general';
import { notifyError, notifyWarn } from '../../common/notifications';
import { HashMap, Identity } from '../../common/types';
+import { DialogCloseDirective } from '../../ui/controls/dialog';
import { DialogTitleDirective } from '../../ui/dialog-title.directive';
import { IconComponent } from '../../ui/icon.component';
import { TranslatePipe } from '../../ui/translate.pipe';
@@ -69,8 +65,8 @@ export interface BulkItemModalData> {
@if (!loading) {
close
@@ -156,11 +152,10 @@ export interface BulkItemModalData> {
`,
],
imports: [
+ DialogCloseDirective,
DialogTitleDirective,
- MatDialogModule,
TranslatePipe,
IconComponent,
- MatRippleModule,
StatusListComponent,
ListComponent,
MatchFieldsComponent,
@@ -171,8 +166,8 @@ export class BulkItemModalComponent<
T extends HashMap = HashMap,
> {
private _dialog_ref =
- inject>(MatDialogRef);
- private _data = inject>(MAT_DIALOG_DATA);
+ inject>(DialogRef);
+ private _data = inject>(DIALOG_DATA);
/** Current step in the bulk add flow */
public readonly flow_step = signal('upload');
diff --git a/src/app/overlays/bulk-item-modal/csv-upload.component.ts b/src/app/overlays/bulk-item-modal/csv-upload.component.ts
index 5eccb42b4..46bd39556 100644
--- a/src/app/overlays/bulk-item-modal/csv-upload.component.ts
+++ b/src/app/overlays/bulk-item-modal/csv-upload.component.ts
@@ -1,12 +1,10 @@
import { Component, input, output, signal } from '@angular/core';
-import { MatRippleModule } from '@angular/material/core';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { downloadFile, jsonToCsv, parseCSV } from '../../common/general';
import { notifyError, notifyWarn } from '../../common/notifications';
import { HashMap } from '../../common/types';
+import { SpinnerComponent } from '../../ui/controls/spinner.component';
import { IconComponent } from '../../ui/icon.component';
-import { LoadingLabelDirective } from '../../ui/loading-label.directive';
import { TranslatePipe } from '../../ui/translate.pipe';
@Component({
@@ -15,7 +13,7 @@ import { TranslatePipe } from '../../ui/translate.pipe';
@if (!loading) {
@@ -60,7 +58,7 @@ import { TranslatePipe } from '../../ui/translate.pipe';
-
+
{{ 'COMMON.BULK_DROP_LOADING' | translate }}
@if (file_name()) {
@@ -73,13 +71,7 @@ import { TranslatePipe } from '../../ui/translate.pipe';
}
`,
styles: [``],
- imports: [
- LoadingLabelDirective,
- MatRippleModule,
- TranslatePipe,
- MatProgressSpinnerModule,
- IconComponent,
- ],
+ imports: [SpinnerComponent, TranslatePipe, IconComponent],
})
export class CsvUploadComponent {
/** Data for the template CSV */
diff --git a/src/app/overlays/bulk-item-modal/list.component.ts b/src/app/overlays/bulk-item-modal/list.component.ts
index b0805df84..befdcba41 100644
--- a/src/app/overlays/bulk-item-modal/list.component.ts
+++ b/src/app/overlays/bulk-item-modal/list.component.ts
@@ -9,7 +9,6 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
import { i18n } from '../../common/locale.service';
import { HashMap, Identity } from '../../common/types';
import { IconComponent } from '../../ui/icon.component';
@@ -118,17 +117,12 @@ import { TranslatePipe } from '../../ui/translate.pipe';
-
+
{{ 'COMMON.BACK' | translate }}
Required
}
-
-
+
- Skip field
+ Skip field
@for (type of source_fields(); track type.id) {
-
+
{{ type.name }}
-
+
}
-
-
+
+
}
@@ -83,25 +83,20 @@ import { TranslatePipe } from '../../ui/translate.pipe';
-
+
{{ 'COMMON.BACK' | translate }}
-
+
{{ 'COMMON.CONTINUE' | translate }}
`,
styles: [``],
imports: [
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
TranslatePipe,
- MatRippleModule,
- MatFormFieldModule,
- MatSelectModule,
FormsModule,
],
})
@@ -189,8 +184,7 @@ export class MatchFieldsComponent implements OnChanges, OnInit {
public readonly mapped_field_count = computed(() => {
const mapping = this.field_mapping();
- return this.field_list().filter((field) => !!mapping[field.id])
- .length;
+ return this.field_list().filter((field) => !!mapping[field.id]).length;
});
public isRequired(field: unknown): boolean {
diff --git a/src/app/overlays/bulk-item-modal/status-list.component.ts b/src/app/overlays/bulk-item-modal/status-list.component.ts
index 526f02991..1c21df7f1 100644
--- a/src/app/overlays/bulk-item-modal/status-list.component.ts
+++ b/src/app/overlays/bulk-item-modal/status-list.component.ts
@@ -7,14 +7,12 @@ import {
signal,
SimpleChanges,
} from '@angular/core';
-import { MatRippleModule } from '@angular/material/core';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { PlaceResource } from '@placeos/ts-client';
import { readError } from '../../common/errors';
import { notifyError } from '../../common/notifications';
+import { SpinnerComponent } from '../../ui/controls/spinner.component';
+import { TooltipDirective } from '../../ui/controls/tooltip.directive';
import { IconComponent } from '../../ui/icon.component';
-import { LoadingLabelDirective } from '../../ui/loading-label.directive';
import { TranslatePipe } from '../../ui/translate.pipe';
import { VirtualScrollComponent } from '../../ui/virtual-scroll.component';
@@ -64,7 +62,7 @@ const BATCH_SIZE = 5;
>
@@ -94,7 +92,7 @@ const BATCH_SIZE = 5;
[class.text-error-content]="state !== 'done'"
[class.bg-success]="state === 'done'"
[class.text-success-content]="state === 'done'"
- [matTooltip]="state"
+ [appTooltip]="state"
>
{{ state === 'done' ? 'done' : 'close' }}
@@ -102,7 +100,7 @@ const BATCH_SIZE = 5;
}
@if (state === 'loading') {
-
+
}
@@ -117,11 +115,9 @@ const BATCH_SIZE = 5;
`,
],
imports: [
- LoadingLabelDirective,
- MatRippleModule,
- MatProgressSpinnerModule,
+ SpinnerComponent,
+ TooltipDirective,
IconComponent,
- MatTooltipModule,
TranslatePipe,
VirtualScrollComponent,
],
diff --git a/src/app/overlays/changelog-modal.component.ts b/src/app/overlays/changelog-modal.component.ts
index c26bf5dfe..c49bdaae4 100644
--- a/src/app/overlays/changelog-modal.component.ts
+++ b/src/app/overlays/changelog-modal.component.ts
@@ -1,7 +1,7 @@
import { AsyncPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
-import { MAT_DIALOG_DATA } from '@angular/material/dialog';
+import { DIALOG_DATA } from '@angular/cdk/dialog';
import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component';
import { IconComponent } from '../ui/icon.component';
import { MarkdownPipe } from '../ui/pipes/markdown.pipe';
@@ -45,7 +45,7 @@ export interface ChangelogModalData {
],
})
export class ChangelogModalComponent {
- private _data = inject(MAT_DIALOG_DATA);
+ private _data = inject(DIALOG_DATA);
public readonly changelog = this._data.changelog;
}
diff --git a/src/app/overlays/confirm-modal.component.ts b/src/app/overlays/confirm-modal.component.ts
index 17909b1c1..1b467c486 100644
--- a/src/app/overlays/confirm-modal.component.ts
+++ b/src/app/overlays/confirm-modal.component.ts
@@ -8,17 +8,9 @@ import {
inject,
signal,
} from '@angular/core';
-import {
- MAT_DIALOG_DATA,
- MatDialog,
- MatDialogModule,
- MatDialogRef,
-} from '@angular/material/dialog';
import { Clipboard } from '@angular/cdk/clipboard';
-import { MatCheckboxModule } from '@angular/material/checkbox';
-import { MatRippleModule } from '@angular/material/core';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { DIALOG_DATA, Dialog, DialogRef } from '@angular/cdk/dialog';
import { lastValueFrom } from 'rxjs';
import { describeError, readError } from '../common/errors';
import { i18n } from '../common/locale.service';
@@ -29,9 +21,10 @@ import {
} from '../common/notifications';
import { waitForEvent } from '../common/signals';
import { ApplicationIcon, DialogEvent } from '../common/types';
+import { DialogCloseDirective } from '../ui/controls/dialog';
+import { SpinnerComponent } from '../ui/controls/spinner.component';
import { DialogTitleDirective } from '../ui/dialog-title.directive';
import { IconComponent } from '../ui/icon.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { TranslatePipe } from '../ui/translate.pipe';
/** Breakdown of what enabling a `ConfirmModalOption` would do */
@@ -157,23 +150,25 @@ export interface ConfirmResponse {
*/
export async function openConfirmModal(
data: ConfirmModalData,
- dialog: MatDialog,
+ dialog: Dialog,
): Promise {
- const ref = dialog.open(
+ const ref = dialog.open(
ConfirmModalComponent,
{
...CONFIRM_METADATA,
data,
},
);
+ // Only a 'done' event passes the filter, and a dismissal closes empty
+ const result = (await Promise.race([
+ waitForEvent(
+ ref.componentInstance.event,
+ (_: DialogEvent) => _.reason === 'done',
+ ),
+ lastValueFrom(ref.closed),
+ ])) as Pick | undefined;
return {
- ...(await Promise.race([
- waitForEvent(
- ref.componentInstance.event,
- (_: DialogEvent) => _.reason === 'done',
- ),
- lastValueFrom(ref.afterClosed()),
- ])),
+ ...result,
loading: (s) => ref.componentInstance?.loading.set(s),
close: () => ref.close(),
};
@@ -186,7 +181,7 @@ export async function openConfirmModal(
* @returns Whether the action ran and succeeded
*/
export async function confirmAndRun(
- dialog: MatDialog,
+ dialog: Dialog,
data: ConfirmModalData,
options: {
loading: string;
@@ -290,20 +285,14 @@ export async function confirmAndRun(
@if (receipt.note) {
{{ receipt.note }}
}
-
+
{{ 'CASCADE.RECEIPT_COPY' | translate }}
-
+
{{ 'COMMON.CLOSE' | translate }}
@@ -324,15 +313,20 @@ export async function confirmAndRun(
class="border-base-200 bg-base-200/40 w-full space-y-2 rounded-sm border p-3"
[attr.data-option]="option.id"
>
-
+
+
{{ option.label }}
-
+
@if (option.description) {
-
+
{{ option.description }}
}
@@ -343,7 +337,7 @@ export async function confirmAndRun(
details-loading
class="flex items-center space-x-2 opacity-60"
>
-
+
{{
'CASCADE.RESOLVING' | translate
@@ -403,7 +397,7 @@ export async function confirmAndRun(
@@ -414,15 +408,15 @@ export async function confirmAndRun(
>
{{ cancel_text | translate }}
>(MatDialogRef);
- private _data = inject(MAT_DIALOG_DATA);
+ inject>(DialogRef);
+ private _data = inject(DIALOG_DATA);
private _clipboard = inject(Clipboard);
/** Loading state */
@@ -582,10 +573,10 @@ export class ConfirmModalComponent implements OnInit {
public readonly enableClose = () => (this._dialog_ref.disableClose = false);
constructor() {
- // Block Escape and backdrop dismissal while the action runs. Material
- // nulls `componentInstance` on close, so a dismissal mid-action makes
- // the caller's later `loading.set()` throw. The receipt stays
- // dismissable.
+ // Block Escape and backdrop dismissal while the action runs. The CDK
+ // dialog nulls `componentInstance` on close, so a dismissal
+ // mid-action makes the caller's later `loading.set()` throw. The
+ // receipt stays dismissable.
effect(() => {
this._dialog_ref.disableClose = !!this.loading() && !this.result();
});
diff --git a/src/app/overlays/duplicate-modal.component.ts b/src/app/overlays/duplicate-modal.component.ts
index 83814c312..9f4fb4183 100644
--- a/src/app/overlays/duplicate-modal.component.ts
+++ b/src/app/overlays/duplicate-modal.component.ts
@@ -1,24 +1,21 @@
import {
Component,
+ DestroyRef,
inject,
output,
signal,
WritableSignal,
} from '@angular/core';
-import {
- MAT_DIALOG_DATA,
- MatDialogModule,
- MatDialogRef,
- MatDialogState,
-} from '@angular/material/dialog';
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import { FormsModule } from '@angular/forms';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { describeError } from '../common/errors';
import { notifyError } from '../common/notifications';
import { DialogEvent, HashMap } from '../common/types';
+import { DialogCloseDirective } from '../ui/controls/dialog';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
+import { SpinnerComponent } from '../ui/controls/spinner.component';
+import { DialogTitleDirective } from '../ui/dialog-title.directive';
import { IconComponent } from '../ui/icon.component';
export interface DuplicateModalData {
@@ -30,15 +27,16 @@ export interface DuplicateModalData {
selector: 'app-duplicate-modal',
template: `
- Duplicate Item
+
+ Duplicate Item
+
-
+
@if (!loading()) {
How many times would you like to duplicate this item?
-
+
-
+
} @else {
@@ -65,7 +63,7 @@ export interface DuplicateModalData {
{{ item.name }} ({{ i + 1 }})
@if (status()[i] === 'loading') {
-
+
} @else if (status()[i] === 'done') {
done
} @else {
@@ -75,10 +73,10 @@ export interface DuplicateModalData {
}
}
-
+
@if (!loading()) {
-
-
+
+
Cancel
Duplicate
-
+
}
`,
imports: [
- MatDialogModule,
+ DialogCloseDirective,
+ DialogTitleDirective,
+ SpinnerComponent,
+ FormFieldComponent,
IconComponent,
- MatFormFieldModule,
- MatInputModule,
FormsModule,
- MatProgressSpinnerModule,
],
})
export class DuplicateModalComponent {
- private _data = inject(MAT_DIALOG_DATA);
+ private _data = inject(DIALOG_DATA);
private _dialog_ref =
- inject>(MatDialogRef);
+ inject>(DialogRef);
+ /** Whether the user closed the dialog */
+ private _closed = false;
+
+ constructor() {
+ // Closing the dialog destroys this component before `closed` emits
+ inject(DestroyRef).onDestroy(() => (this._closed = true));
+ }
/** Emitter for user action on the modal */
public readonly event = output();
@@ -151,7 +156,7 @@ export class DuplicateModalComponent {
this.temp.set(new Array(this.times).fill({}));
for (let i = 0; i < this.times; i++) {
// Stop creating duplicates if the user closes the dialog
- if (this._dialog_ref.getState() !== MatDialogState.OPEN) break;
+ if (this._closed) break;
const new_item = new ItemConstructor({
...item,
id: '',
@@ -167,6 +172,8 @@ export class DuplicateModalComponent {
this.setStatus(i, 'done');
}
}
+ // A closed dialog has no one to tell, and closing it again throws
+ if (this._closed) return;
const clean_list = list.filter((item) => !!item);
this.event.emit({
reason: 'done',
diff --git a/src/app/overlays/metadata-details-modal.component.ts b/src/app/overlays/metadata-details-modal.component.ts
index c14e3fb3a..b2a24c29a 100644
--- a/src/app/overlays/metadata-details-modal.component.ts
+++ b/src/app/overlays/metadata-details-modal.component.ts
@@ -1,4 +1,4 @@
-import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import { Component, inject, OnInit, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
@@ -8,16 +8,9 @@ import {
submit,
validate,
} from '@angular/forms/signals';
-import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips';
-import { MatRippleModule } from '@angular/material/core';
-import {
- MAT_DIALOG_DATA,
- MatDialogModule,
- MatDialogRef,
-} from '@angular/material/dialog';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { addSignalChipItem, removeSignalChipItem } from '../common/forms';
+import { ChipListComponent } from '../ui/controls/chip-list.component';
+import { DialogCloseDirective } from '../ui/controls/dialog';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
import { DialogTitleDirective } from '../ui/dialog-title.directive';
import { IconComponent } from '../ui/icon.component';
import type { MetadataFormModel } from '../ui/metadata-display.component';
@@ -42,7 +35,7 @@ export interface MetadataDetailsModalData {
close
@@ -57,61 +50,46 @@ export interface MetadataDetailsModalData {
"
>Name* :
-
+
- {{
+
+ {{
form.name().errors().find((error) => error.kind === 'name')
? 'Property name must be unique'
: 'Property name is required'
- }}
-
+ }}
+
+
Description:
-
+
-
+
Editors:
-
-
- @for (item of editors; track item) {
-
- {{ item }}
-
- cancel
-
-
- }
-
-
+
-
+
Schema:
-
+
-
+
}
>(MatDialogRef);
- private _data = inject(MAT_DIALOG_DATA);
+ inject>(DialogRef);
+ private _data = inject(DIALOG_DATA);
public readonly formModel = signal({
name: '',
@@ -162,24 +137,6 @@ export class MetadataDetailsModalComponent implements OnInit {
: undefined,
);
});
- /** List of separator characters for tags */
- public readonly separators: number[] = [ENTER, COMMA, SPACE];
-
- public readonly addEditor = (e: MatChipInputEvent) =>
- this.formModel.update((model) => ({
- ...model,
- editors: addSignalChipItem(model.editors, e),
- }));
- public readonly removeEditor = (i: string) =>
- this.formModel.update((model) => ({
- ...model,
- editors: removeSignalChipItem(model.editors, i),
- }));
-
- public get editors() {
- return this.formModel().editors;
- }
-
public ngOnInit(): void {
this.formModel.set({
name: this._data.value.name || '',
diff --git a/src/app/overlays/metadata-history-modal.component.ts b/src/app/overlays/metadata-history-modal.component.ts
index 491a83919..1b7f181f0 100644
--- a/src/app/overlays/metadata-history-modal.component.ts
+++ b/src/app/overlays/metadata-history-modal.component.ts
@@ -5,15 +5,17 @@ import {
signal,
WritableSignal,
} from '@angular/core';
-import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { DIALOG_DATA } from '@angular/cdk/dialog';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatSelectModule } from '@angular/material/select';
import { listMetadataHistory, PlaceMetadata } from '@placeos/ts-client';
import { describeError } from '../common/errors';
import { notifyError } from '../common/notifications';
+import { DialogCloseDirective } from '../ui/controls/dialog';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
+import { OptionComponent } from '../ui/controls/option.component';
+import { SelectComponent } from '../ui/controls/select.component';
import { DialogTitleDirective } from '../ui/dialog-title.directive';
import { DiffViewerComponent } from '../ui/diff-viewer.component';
import { IconComponent } from '../ui/icon.component';
@@ -33,8 +35,8 @@ import { TranslatePipe } from '../ui/translate.pipe';
close
@@ -75,8 +77,8 @@ import { TranslatePipe } from '../ui/translate.pipe';
-
-
+
@@ -111,18 +113,18 @@ import { TranslatePipe } from '../ui/translate.pipe';
>
{{ item.description }}
-
+
}
}
-
+
@if (first) {
- {{
- first.description
- }}
+
+ {{ first.description }}
+
}
-
-
-
+
+
@@ -158,16 +160,16 @@ import { TranslatePipe } from '../ui/translate.pipe';
>
{{ item.description }}
-
+
}
}
-
+
@if (second()) {
- {{
- second()?.description
- }}
+
+ {{ second()?.description }}
+
}
-
+
@if (first_details() || second_details()) {
@@ -189,11 +191,12 @@ import { TranslatePipe } from '../ui/translate.pipe';
`,
styles: [``],
imports: [
+ DialogCloseDirective,
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
DialogTitleDirective,
- MatDialogModule,
DiffViewerComponent,
- MatFormFieldModule,
- MatSelectModule,
FormsModule,
TranslatePipe,
IconComponent,
@@ -205,7 +208,7 @@ export class MetadataHistoryModalComponent implements OnInit {
id: string;
parent_name: string;
name: string;
- }>(MAT_DIALOG_DATA);
+ }>(DIALOG_DATA);
public readonly id = this._data.id;
public readonly parent_name = this._data.parent_name;
@@ -216,7 +219,7 @@ export class MetadataHistoryModalComponent implements OnInit {
public first = null;
public readonly second = signal(null);
// The history can hold thousands of versions. A closed select renders
- // only a few options and the selected one; mat-select does not open
+ // only a few options and the selected one; a select does not open
// without options.
public readonly CLOSED_OPTIONS = 20;
public readonly first_open = signal(false);
diff --git a/src/app/overlays/select-item-modal.component.ts b/src/app/overlays/select-item-modal.component.ts
index 66f1979e0..b1df274ce 100644
--- a/src/app/overlays/select-item-modal.component.ts
+++ b/src/app/overlays/select-item-modal.component.ts
@@ -1,13 +1,12 @@
import { Component, EventEmitter, Output, inject } from '@angular/core';
-import { MatRippleModule } from '@angular/material/core';
-import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { DIALOG_DATA } from '@angular/cdk/dialog';
import { FormsModule } from '@angular/forms';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { DialogEvent } from '../common/types';
+import { DialogCloseDirective } from '../ui/controls/dialog';
+import { SpinnerComponent } from '../ui/controls/spinner.component';
import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component';
import { IconComponent } from '../ui/icon.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { TranslatePipe } from '../ui/translate.pipe';
export interface SelectItemModalData
{
@@ -27,8 +26,8 @@ export interface SelectItemModalData {
@if (!loading) {
close
@@ -50,7 +49,7 @@ export interface SelectItemModalData {
class="flex w-full flex-col items-center justify-center p-16 opacity-30"
>
{{ 'COMMON.PROCESSING_REQUEST' | translate }}
@@ -61,7 +60,7 @@ export interface SelectItemModalData {
{
`,
styles: [``],
imports: [
- LoadingLabelDirective,
- MatDialogModule,
- MatRippleModule,
+ DialogCloseDirective,
+ SpinnerComponent,
TranslatePipe,
- MatProgressSpinnerModule,
ItemSearchFieldComponent,
FormsModule,
IconComponent,
],
})
export class SelectItemModalComponent {
- private _data = inject(MAT_DIALOG_DATA);
+ private _data = inject(DIALOG_DATA);
/** Emitter for user action on the modal */
@Output() public event = new EventEmitter();
diff --git a/src/app/overlays/view-module-state.component.ts b/src/app/overlays/view-module-state.component.ts
index ff85eaeaa..7ec60eddb 100644
--- a/src/app/overlays/view-module-state.component.ts
+++ b/src/app/overlays/view-module-state.component.ts
@@ -1,21 +1,20 @@
import { Component, OnInit, inject, signal } from '@angular/core';
-import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import {
PlaceModule,
PlaceSystem,
systemModuleState,
} from '@placeos/ts-client';
+import { DIALOG_DATA } from '@angular/cdk/dialog';
import { FormsModule } from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
import { describeError } from '../common/errors';
import { notifyError } from '../common/notifications';
import { HashMap } from '../common/types';
+import { DialogCloseDirective } from '../ui/controls/dialog';
+import { SpinnerComponent } from '../ui/controls/spinner.component';
import { SettingsFieldComponent } from '../ui/custom-fields/settings-field.component';
import { DialogTitleDirective } from '../ui/dialog-title.directive';
import { IconComponent } from '../ui/icon.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { TranslatePipe } from '../ui/translate.pipe';
export interface ModuleStateModalData {
@@ -38,8 +37,8 @@ export interface ModuleStateModalData {
close
@@ -80,12 +79,7 @@ export interface ModuleStateModalData {
-
+
{{ 'MODULES.STATE_UPDATE' | translate }}
@@ -103,7 +97,7 @@ export interface ModuleStateModalData {
-
+
{{ 'MODULES.STATE_LOADING' | translate }}
@@ -112,19 +106,17 @@ export interface ModuleStateModalData {
`,
styles: [``],
imports: [
- LoadingLabelDirective,
+ DialogCloseDirective,
+ SpinnerComponent,
DialogTitleDirective,
- MatDialogModule,
- MatProgressSpinnerModule,
TranslatePipe,
SettingsFieldComponent,
FormsModule,
- MatRippleModule,
IconComponent,
],
})
export class ViewModuleStateModalComponent implements OnInit {
- private _data = inject(MAT_DIALOG_DATA);
+ private _data = inject(DIALOG_DATA);
/** Current state of the selected module */
public readonly state = signal(null);
diff --git a/src/app/overlays/view-response-modal.component.ts b/src/app/overlays/view-response-modal.component.ts
index 631c5c63d..10516da52 100644
--- a/src/app/overlays/view-response-modal.component.ts
+++ b/src/app/overlays/view-response-modal.component.ts
@@ -1,6 +1,6 @@
+import { DIALOG_DATA } from '@angular/cdk/dialog';
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { SettingsFieldComponent } from '../ui/custom-fields/settings-field.component';
import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component';
import { TranslatePipe } from '../ui/translate.pipe';
@@ -36,7 +36,7 @@ export interface ViewResponseModalData {
],
})
export class ViewResponseModalComponent {
- private _data = inject(MAT_DIALOG_DATA);
+ private _data = inject(DIALOG_DATA);
public readonly title = this._data.title || '';
public readonly content_string = signal(null);
diff --git a/src/app/repositories/repositories-state.service.ts b/src/app/repositories/repositories-state.service.ts
index 331004bf6..5aa7546d3 100644
--- a/src/app/repositories/repositories-state.service.ts
+++ b/src/app/repositories/repositories-state.service.ts
@@ -1,5 +1,4 @@
import { computed, inject, resource, Service, signal } from '@angular/core';
-import { MatDialog } from '@angular/material/dialog';
import {
listRepositoryCommits,
listRepositoryDrivers,
@@ -10,6 +9,7 @@ import {
showRepository,
} from '@placeos/ts-client';
+import { Dialog } from '@angular/cdk/dialog';
import { describeError } from '../common/errors';
import { ActiveItemService } from '../common/item.service';
import { i18n } from '../common/locale.service';
@@ -20,7 +20,7 @@ import { DriverFormComponent } from '../drivers/driver-form.component';
@Service()
export class RepositoriesStateService {
private _state = inject(ActiveItemService);
- private _dialog = inject(MatDialog);
+ private _dialog = inject(Dialog);
private _loading = signal(false);
private _driver_list_error = signal('');
diff --git a/src/app/repositories/repositories.component.ts b/src/app/repositories/repositories.component.ts
index f0dee9ddb..9311554f8 100644
--- a/src/app/repositories/repositories.component.ts
+++ b/src/app/repositories/repositories.component.ts
@@ -9,11 +9,7 @@ import { RepositoriesStateService } from './repositories-state.service';
@Component({
selector: 'new-repositories-view',
template: `
-
+
`,
imports: [ItemShellComponent],
})
diff --git a/src/app/repositories/repositories.utilities.ts b/src/app/repositories/repositories.utilities.ts
index eccd57c07..8b9f37dbb 100644
--- a/src/app/repositories/repositories.utilities.ts
+++ b/src/app/repositories/repositories.utilities.ts
@@ -1,5 +1,5 @@
-import { PlaceRepository, PlaceRepositoryType } from '@placeos/ts-client';
import { required, SchemaFn, validate } from '@angular/forms/signals';
+import { PlaceRepository, PlaceRepositoryType } from '@placeos/ts-client';
export interface RepositoryFormModel {
id: string;
diff --git a/src/app/repositories/repository-about.component.ts b/src/app/repositories/repository-about.component.ts
index 4ad2b82fa..f33e37822 100644
--- a/src/app/repositories/repository-about.component.ts
+++ b/src/app/repositories/repository-about.component.ts
@@ -1,12 +1,10 @@
import { AsyncPipe } from '@angular/common';
import { Component, computed, inject, signal } from '@angular/core';
-import { MatRippleModule } from '@angular/material/core';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { PlaceRepositoryType } from '@placeos/ts-client';
+import { SpinnerComponent } from '../ui/controls/spinner.component';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { IconComponent } from '../ui/icon.component';
import { ItemTimestampsComponent } from '../ui/item-timestamps.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { MarkdownPipe } from '../ui/pipes/markdown.pipe';
import { TranslatePipe } from '../ui/translate.pipe';
import { RepositoriesStateService } from './repositories-state.service';
@@ -94,7 +92,7 @@ import { maskUriCredentials } from './repositories.utilities';
{{ item().branch }}
@@ -105,7 +103,7 @@ import { maskUriCredentials } from './repositories.utilities';
+
}
}
@@ -195,13 +193,11 @@ import { maskUriCredentials } from './repositories.utilities';
`,
],
imports: [
+ SpinnerComponent,
+ TooltipDirective,
ItemTimestampsComponent,
- LoadingLabelDirective,
MarkdownPipe,
TranslatePipe,
- MatProgressSpinnerModule,
- MatRippleModule,
- MatTooltipModule,
AsyncPipe,
IconComponent,
],
diff --git a/src/app/repositories/repository-changelog.component.ts b/src/app/repositories/repository-changelog.component.ts
index 4fb425aff..74ad23212 100644
--- a/src/app/repositories/repository-changelog.component.ts
+++ b/src/app/repositories/repository-changelog.component.ts
@@ -1,8 +1,7 @@
import { AsyncPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { SpinnerComponent } from '../ui/controls/spinner.component';
import { IconComponent } from '../ui/icon.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { MarkdownPipe } from '../ui/pipes/markdown.pipe';
import { TranslatePipe } from '../ui/translate.pipe';
import { RepositoriesStateService } from './repositories-state.service';
@@ -17,7 +16,7 @@ import { RepositoriesStateService } from './repositories-state.service';
role="status"
aria-label="Loading changelog"
>
-
+
} @else if (changelog()) {
{{ 'REPOS.AVAILABLE_DRIVERS' | translate }}
-
add
@@ -92,14 +90,12 @@ import { RepositoriesStateService } from './repositories-state.service';
`,
],
imports: [
- LoadingLabelDirective,
+ ProgressBarComponent,
+ TooltipDirective,
IconComponent,
TranslatePipe,
- MatTooltipModule,
DriverFormatPipe,
SimpleTableComponent,
- MatProgressBarModule,
- MatRippleModule,
],
})
export class RepositoryDriversComponent {
diff --git a/src/app/repositories/repository-form.component.ts b/src/app/repositories/repository-form.component.ts
index 6f45190ba..ce98bcffa 100644
--- a/src/app/repositories/repository-form.component.ts
+++ b/src/app/repositories/repository-form.component.ts
@@ -15,13 +15,8 @@ import {
import { FormsModule } from '@angular/forms';
import { FormField, disabled, form } from '@angular/forms/signals';
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import { DatePipe, SlicePipe } from '@angular/common';
-import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatSelectModule } from '@angular/material/select';
-import { MatTooltipModule } from '@angular/material/tooltip';
import {
GitCommitDetails,
PlaceRepository,
@@ -41,9 +36,16 @@ import { i18n } from '../common/locale.service';
import { notifyError } from '../common/notifications';
import { DialogEvent, Identity } from '../common/types';
import { isValidUrl } from '../common/validation';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
+import { OptionComponent } from '../ui/controls/option.component';
+import {
+ SelectComponent,
+ SelectTriggerDirective,
+} from '../ui/controls/select.component';
+import { SpinnerComponent } from '../ui/controls/spinner.component';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component';
import { IconComponent } from '../ui/icon.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { SettingsToggleComponent } from '../ui/settings-toggle.component';
import { TranslatePipe } from '../ui/translate.pipe';
import {
@@ -78,19 +80,18 @@ interface RepositoryCommit extends Partial {
{{ 'COMMON.FIELD_NAME' | translate
}}*
-
+
- {{
- 'REPOS.NAME_REQUIRED' | translate
- }}
-
+
+ {{ 'REPOS.NAME_REQUIRED' | translate }}
+
+
}
@@ -101,8 +102,8 @@ interface RepositoryCommit extends Partial
{
{{ 'REPOS.TYPE' | translate }}
-
-
+ {
type of repo_types();
track type
) {
-
+
{{ type.name }}
-
+
}
-
-
+
+
}
@if (!is_editing() && form.folder_name) {
@@ -131,19 +132,21 @@ interface RepositoryCommit extends Partial
{
{{ 'REPOS.FOLDER_NAME' | translate
}}*
-
+
- {{
- 'REPOS.FOLDER_NAME_REQUIRED' | translate
- }}
-
+
+ {{
+ 'REPOS.FOLDER_NAME_REQUIRED'
+ | translate
+ }}
+
+
}
@@ -157,18 +160,17 @@ interface RepositoryCommit extends Partial {
>
{{ 'REPOS.URI' | translate }}*
-
+
- {{
- 'REPOS.URI_REQUIRED' | translate
- }}
-
+
+ {{ 'REPOS.URI_REQUIRED' | translate }}
+
+
}
}
@if (form.password) {
@@ -196,9 +197,8 @@ interface RepositoryCommit extends Partial {
{{ 'REPOS.PASSWORD' | translate }}
-
+
{
/>
{
>
visibility
-
+
}
@@ -239,17 +239,15 @@ interface RepositoryCommit extends Partial {
>
{{ 'REPOS.BRANCH' | translate }}*
-
-
+
-
+
{
"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
- class="option-search-input focus:bg-info/10 pointer-events-auto absolute inset-1 h-auto cursor-text rounded-sm p-4"
[placeholder]="
'COMMON.SEARCH_FOR'
| translate
@@ -278,25 +275,32 @@ interface RepositoryCommit extends Partial {
: { name: 'branches' }
"
/>
-
+
+
+ {{
+ 'COMMON.SEARCH_EMPTY'
+ | translate
+ : { name: 'branches' }
+ }}
+
@for (
branch of filtered_branch_list();
track branch
) {
-
+
{{ branch }}
-
+
}
-
+
@if (loading_branches()) {
-
-
+
}
-
{{
- 'REPOS.BRANCH_REQUIRED' | translate
- }}
-
+
+ {{ 'REPOS.BRANCH_REQUIRED' | translate }}
+
+
}
@if (is_interface()) {
@@ -304,15 +308,15 @@ interface RepositoryCommit extends Partial {
{{ 'REPOS.COMMIT' | translate }}
-
-
+
-
+
@@ -331,10 +335,8 @@ interface RepositoryCommit extends Partial {
}}
-
-
+
+
{
"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
- class="option-search-input focus:bg-info/10 pointer-events-auto absolute inset-1 h-auto cursor-text rounded-sm p-4"
[placeholder]="
'COMMON.SEARCH_FOR'
| translate
@@ -363,12 +364,18 @@ interface RepositoryCommit extends Partial {
: { name: 'commits' }
"
/>
-
+
+
+ {{
+ 'COMMON.SEARCH_EMPTY'
+ | translate: { name: 'commits' }
+ }}
+
@for (
commit of filtered_commit_list();
track commit
) {
-
+
{
>
@@ -416,15 +423,15 @@ interface RepositoryCommit extends Partial {
}}
-
+
}
-
+
@if (loading_commits()) {
-
{
{{ 'COMMON.FIELD_DESCRIPTION' | translate }}
-
+
-
+
}
@if (form.root_path) {
@@ -458,45 +464,31 @@ interface RepositoryCommit extends Partial
{
{{ 'REPOS.ROOT_PATH' | translate }}
-
+
-
+
}
}
`,
- styles: [
- `
- .option-search-input {
- width: calc(100% - 0.5rem);
- outline: none;
- }
-
- .option-search-input::placeholder {
- color: var(--base-400, currentColor);
- opacity: 0.65;
- }
- `,
- ],
imports: [
- LoadingLabelDirective,
- MatFormFieldModule,
- MatInputModule,
- MatProgressSpinnerModule,
+ SpinnerComponent,
+ TooltipDirective,
+ SelectTriggerDirective,
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
FormField,
TranslatePipe,
FormsModule,
- MatSelectModule,
- MatTooltipModule,
IconComponent,
SettingsToggleComponent,
FullscreenModalShellComponent,
@@ -506,9 +498,9 @@ interface RepositoryCommit extends Partial {
})
export class RepositoryFormComponent extends AsyncHandler implements OnInit {
private _dialog_ref =
- inject>(MatDialogRef);
+ inject>(DialogRef);
private _data = inject<{ item: PlaceRepository; readonly?: string }>(
- MAT_DIALOG_DATA,
+ DIALOG_DATA,
);
private _injector = inject(Injector);
private readonly _name = 'REPOS';
diff --git a/src/app/systems/system-about.component.ts b/src/app/systems/system-about.component.ts
index 26b87d8f9..373d80efd 100644
--- a/src/app/systems/system-about.component.ts
+++ b/src/app/systems/system-about.component.ts
@@ -1,12 +1,10 @@
import { AsyncPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
-import { MatRippleModule } from '@angular/material/core';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatTooltipModule } from '@angular/material/tooltip';
+import { SpinnerComponent } from '../ui/controls/spinner.component';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { SettingsFormComponent } from '../ui/forms/settings-form.component';
import { IconComponent } from '../ui/icon.component';
import { ItemTimestampsComponent } from '../ui/item-timestamps.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { MarkdownPipe } from '../ui/pipes/markdown.pipe';
import { TranslatePipe } from '../ui/translate.pipe';
import { SystemStateService } from './system-state.service';
@@ -141,9 +139,9 @@ import { SystemStateService } from './system-state.service';
icon
default
start
- matRipple
+ ripple
(click)="start()"
- [matTooltip]="'SYSTEMS.START' | translate"
+ [appTooltip]="'SYSTEMS.START' | translate"
>
play_arrow
@@ -152,9 +150,9 @@ import { SystemStateService } from './system-state.service';
default
error
stop
- matRipple
+ ripple
(click)="stop()"
- [matTooltip]="'SYSTEMS.STOP' | translate"
+ [appTooltip]="'SYSTEMS.STOP' | translate"
>
stop
@@ -189,13 +187,13 @@ import { SystemStateService } from './system-state.service';
} @else {
-
+
{{ 'SYSTEMS.LOADING_SETTINGS' | translate }}
}
} @placeholder {
-
+
{{ 'SYSTEMS.LOADING_SETTINGS' | translate }}
}
@@ -210,14 +208,12 @@ import { SystemStateService } from './system-state.service';
`,
],
imports: [
+ SpinnerComponent,
+ TooltipDirective,
ItemTimestampsComponent,
- LoadingLabelDirective,
TranslatePipe,
MarkdownPipe,
- MatProgressSpinnerModule,
SettingsFormComponent,
- MatRippleModule,
- MatTooltipModule,
IconComponent,
AsyncPipe,
],
diff --git a/src/app/systems/system-form.component.ts b/src/app/systems/system-form.component.ts
index 4df0fe4ac..306c5a3b1 100644
--- a/src/app/systems/system-form.component.ts
+++ b/src/app/systems/system-form.component.ts
@@ -1,4 +1,3 @@
-import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import {
Component,
computed,
@@ -9,12 +8,6 @@ import {
signal,
} from '@angular/core';
import { form, FormField } from '@angular/forms/signals';
-import {
- MatChipEditedEvent,
- MatChipInputEvent,
- MatChipsModule,
-} from '@angular/material/chips';
-import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
addSystem,
PlaceResource,
@@ -23,21 +16,19 @@ import {
} from '@placeos/ts-client';
import { querySupportZones as queryZones } from '../common/support-access';
-import { MatAutocompleteModule } from '@angular/material/autocomplete';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import {
- addSignalChipItem,
- editSignalChipItem,
- mergeFormItem,
- onSaveHotkey,
- removeSignalChipItem,
- submitFormDialog,
-} from '../common/forms';
+import { mergeFormItem, onSaveHotkey, submitFormDialog } from '../common/forms';
import { i18n } from '../common/locale.service';
import { TIMEZONES_IANA } from '../common/timezones';
import { DialogEvent } from '../common/types';
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
+import {
+ AutocompleteComponent,
+ AutocompleteTriggerDirective,
+} from '../ui/controls/autocomplete.component';
+import { ChipListComponent } from '../ui/controls/chip-list.component';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
+import { OptionComponent } from '../ui/controls/option.component';
import { CounterComponent } from '../ui/counter.component';
import { ImageListFieldComponent } from '../ui/custom-fields/image-list-field.component';
import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component';
@@ -104,19 +95,21 @@ const TIMEZONE_OPTION_LIMIT = 50;
{{ 'COMMON.FIELD_NAME' | translate
}}*
-
+
@if (form.name().invalid()) {
- {{
- 'SYSTEMS.NAME_REQUIRED' | translate
- }}
+
+ {{
+ 'SYSTEMS.NAME_REQUIRED'
+ | translate
+ }}
+
}
-
+
}
@if (form.email) {
@@ -130,9 +123,8 @@ const TIMEZONE_OPTION_LIMIT = 50;
>
{{ 'COMMON.FIELD_EMAIL' | translate }}
-
+
@if (form.email().invalid()) {
- {{
- 'SYSTEMS.EMAIL_REQUIRED' | translate
- }}
+
+ {{
+ 'SYSTEMS.EMAIL_REQUIRED'
+ | translate
+ }}
+
}
-
+
}
@@ -154,16 +149,15 @@ const TIMEZONE_OPTION_LIMIT = 50;
{{ 'SYSTEMS.DISPLAY_NAME' | translate }}
-
+
-
+
}
@if (form.code) {
@@ -171,16 +165,15 @@ const TIMEZONE_OPTION_LIMIT = 50;
{{ 'SYSTEMS.CODE' | translate }}
-
+
-
+
}
@@ -195,19 +188,18 @@ const TIMEZONE_OPTION_LIMIT = 50;
>
{{ 'SYSTEMS.SUPPORT_URL' | translate }}
-
+
-
+
{{ 'SYSTEMS.URL_VALID' | translate }}
-
-
+
+
}
@@ -278,16 +270,15 @@ const TIMEZONE_OPTION_LIMIT = 50;
{{ 'COMMON.FIELD_DESCRIPTION' | translate }}
-
+
-
+
}
@if (form.features) {
@@ -301,45 +292,19 @@ const TIMEZONE_OPTION_LIMIT = 50;
>
{{ 'SYSTEMS.FEATURES' | translate }}
-
-
+
- @for (item of feature_list(); track item) {
-
-
- {{ item }}
-
-
- cancel
-
-
- }
-
-
-
+
}
@if (form.security_groups) {
@@ -353,50 +318,17 @@ const TIMEZONE_OPTION_LIMIT = 50;
>
{{ 'SYSTEMS.SECURITY_GROUPS' | translate }}
-
-
+
- @for (
- item of security_group_list();
- track item
- ) {
-
-
- {{ item }}
-
-
- cancel
-
-
- }
-
-
-
+
}
@if (form.map_id) {
@@ -404,46 +336,44 @@ const TIMEZONE_OPTION_LIMIT = 50;
{{
'SYSTEMS.MAP_ID' | translate
}}
-
+
-
+
}
}
@if (form.camera_snapshot_urls) {
@@ -521,75 +449,24 @@ const TIMEZONE_OPTION_LIMIT = 50;
>
{{ 'SYSTEMS.CAMERA_SNAPSHOT_URL' | translate }}
-
-
+
- @for (
- item of camera_snapshot_url_list();
- track item
- ) {
-
-
- edit
-
-
- {{ item }}
-
-
- cancel
-
-
- }
-
-
@if (form.camera_snapshot_urls().invalid()) {
-
+
{{ 'SYSTEMS.URL_VALID' | translate }}
-
+
}
-
+
}
@if (form.room_booking_url) {
@@ -603,64 +480,45 @@ const TIMEZONE_OPTION_LIMIT = 50;
>
{{ 'SYSTEMS.ROOM_BOOKING_URL' | translate }}
-
+
-
+
{{ 'SYSTEMS.URL_VALID' | translate }}
-
-
+
+
}
}
`,
- styles: [
- `
- mat-checkbox {
- margin-top: 2.5em;
- margin-bottom: 1.5em;
- }
-
- /* Material's chip touch target paints over the leading edit button */
- .mat-mdc-chip-edit {
- z-index: 1;
- }
-
- @media screen and (max-width: 640px) {
- mat-checkbox {
- margin-top: 0;
- }
- }
- `,
- ],
imports: [
+ OptionComponent,
+ FormFieldComponent,
ImageListFieldComponent,
FormField,
TranslatePipe,
- MatFormFieldModule,
- MatInputModule,
IconComponent,
- MatChipsModule,
+ ChipListComponent,
SettingsToggleComponent,
CounterComponent,
ItemSearchFieldComponent,
- MatAutocompleteModule,
+ AutocompleteComponent,
+ AutocompleteTriggerDirective,
FullscreenModalShellComponent,
],
})
export class SystemFormComponent {
private _dialog_ref =
- inject>(MatDialogRef);
+ inject>(DialogRef);
private _data = inject<{ item: PlaceSystem; readonly?: string }>(
- MAT_DIALOG_DATA,
+ DIALOG_DATA,
);
private readonly _name = 'SYSTEMS';
public readonly item = signal(this._data.item);
@@ -676,19 +534,10 @@ export class SystemFormComponent {
public heading = i18n(
`${this._name}.${this._data.item.id ? 'EDIT' : 'NEW'}`,
);
- public feature_list = computed(() => this.formModel().features || []);
- public camera_snapshot_url_list = computed(
- () => this.formModel().camera_snapshot_urls || [],
- );
- public security_group_list = computed(
- () => this.formModel().security_groups || [],
- );
/** Function for querying zones */
public readonly query_fn = (_: string) =>
queryZones({ q: _ }).then((resp) => resp.data);
- /** List of separator characters for features */
- public readonly separators: number[] = [ENTER, COMMA, SPACE];
private _timezone = computed(() => this.formModel().timezone || '');
public filtered_timezones = computed(() => {
@@ -747,73 +596,6 @@ export class SystemFormComponent {
);
}
- /**
- * Add a feature to the list of features for the item
- * @param event Input event
- */
- public addFeature(event: MatChipInputEvent): void {
- this.formModel.update((value) => ({
- ...value,
- features: addSignalChipItem(value.features, event),
- }));
- }
-
- /**
- * Remove feature from the list
- * @param existing_feature Feature to remove
- */
- public removeFeature(existing_feature: string): void {
- this.formModel.update((value) => ({
- ...value,
- features: removeSignalChipItem(value.features, existing_feature),
- }));
- }
-
- public addCameraSnapshotUrl(event: MatChipInputEvent): void {
- this.formModel.update((value) => ({
- ...value,
- camera_snapshot_urls: addSignalChipItem(
- value.camera_snapshot_urls,
- event,
- ),
- }));
- }
-
- public editCameraSnapshotUrl(url: string, event: MatChipEditedEvent): void {
- this.formModel.update((value) => ({
- ...value,
- camera_snapshot_urls: editSignalChipItem(
- value.camera_snapshot_urls,
- url,
- event,
- ),
- }));
- }
-
- public removeCameraSnapshotUrl(url: string): void {
- this.formModel.update((value) => ({
- ...value,
- camera_snapshot_urls: removeSignalChipItem(
- value.camera_snapshot_urls,
- url,
- ),
- }));
- }
-
- public addSecurityGroup(event: MatChipInputEvent): void {
- this.formModel.update((value) => ({
- ...value,
- security_groups: addSignalChipItem(value.security_groups, event),
- }));
- }
-
- public removeSecurityGroup(group: string): void {
- this.formModel.update((value) => ({
- ...value,
- security_groups: removeSignalChipItem(value.security_groups, group),
- }));
- }
-
private processURL(system: PlaceSystem, url: string): string {
for (const key in system) {
url = url.replace(
diff --git a/src/app/systems/system-modules.component.ts b/src/app/systems/system-modules.component.ts
index 88bbd2591..aef760264 100644
--- a/src/app/systems/system-modules.component.ts
+++ b/src/app/systems/system-modules.component.ts
@@ -1,3 +1,5 @@
+import { Dialog } from '@angular/cdk/dialog';
+import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
import {
Component,
computed,
@@ -7,13 +9,6 @@ import {
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
-import { MatCheckboxModule } from '@angular/material/checkbox';
-import { MatRippleModule } from '@angular/material/core';
-import { MatDialog } from '@angular/material/dialog';
-import { MatMenuModule } from '@angular/material/menu';
-import { MatProgressBarModule } from '@angular/material/progress-bar';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import {
loadModule,
@@ -33,10 +28,13 @@ import {
} from '../overlays/view-module-state.component';
import { BindingDirective } from '../ui/binding.directive';
import { ContextMenuComponent } from '../ui/context-menu.component';
+import { MENU_POSITIONS } from '../ui/controls/overlay';
+import { ProgressBarComponent } from '../ui/controls/progress-bar.component';
+import { SpinnerComponent } from '../ui/controls/spinner.component';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component';
import { ExecuteMethodFieldComponent } from '../ui/custom-fields/system-exec/execute-method-field.component';
import { IconComponent } from '../ui/icon.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { ModuleRuntimeErrorsModalComponent } from '../ui/module-runtime-errors-modal.component';
import { SimpleTableComponent } from '../ui/simple-table.component';
import { TranslatePipe } from '../ui/translate.pipe';
@@ -76,7 +74,7 @@ import { SystemStateService } from './system-state.service';
/>
@@ -96,44 +94,47 @@ import { SystemStateService } from './system-state.service';
device-list
class="min-h-[calc(100%-20rem)] overflow-y-auto p-4"
>
-
-
- @if (active_item()) {
- @for (
- m_item of active_item().running
- ? menu_options
- : offline_options;
- track m_item
- ) {
-
-
-
-
- {{ m_item.name | translate }}
+
+
+ @if (menu_item()) {
+ @for (
+ m_item of menu_item().running
+ ? menu_options
+ : offline_options;
+ track m_item
+ ) {
+
+
+
+
+ {{ m_item.name | translate }}
+
-
-
+
+ }
}
- }
-
+
+
@if (row.running && row.connected === undefined) {
-
@@ -252,9 +255,9 @@ import { SystemStateService } from './system-state.service';
@if (row.edge_id) {
-
@@ -321,7 +326,7 @@ import { SystemStateService } from './system-state.service';
@@ -330,9 +335,11 @@ import { SystemStateService } from './system-state.service';
sort
@@ -362,11 +371,6 @@ import { SystemStateService } from './system-state.service';
min-width: 8rem;
}
- button.mat-menu-item {
- display: flex;
- align-items: center;
- }
-
button .text {
margin-left: 1rem;
}
@@ -381,34 +385,31 @@ import { SystemStateService } from './system-state.service';
height 200ms,
width 200ms;
}
-
- mat-checkbox.mat-checkbox-disabled {
- pointer-events: none;
- }
`,
],
imports: [
- LoadingLabelDirective,
- MatMenuModule,
+ ProgressBarComponent,
+ SpinnerComponent,
+ TooltipDirective,
+ CdkMenu,
+ CdkMenuItem,
+ CdkMenuTrigger,
IconComponent,
- MatRippleModule,
TranslatePipe,
- MatCheckboxModule,
- MatTooltipModule,
SimpleTableComponent,
ExecuteMethodFieldComponent,
BindingDirective,
- MatProgressSpinnerModule,
ContextMenuComponent,
RouterModule,
ItemSearchFieldComponent,
FormsModule,
- MatProgressBarModule,
],
})
export class SystemModulesComponent {
+ /** Menu positions with a gap from the trigger */
+ public readonly menu_positions = MENU_POSITIONS;
private _service = inject(SystemStateService);
- private _dialog = inject(MatDialog);
+ private _dialog = inject(Dialog);
/** Store for ID of new module to add to system */
public readonly new_module = signal('');
@@ -416,6 +417,11 @@ export class SystemModulesComponent {
public hide_exec: boolean;
public readonly active_item = signal(null);
+ /**
+ * Module of the open menu. Set when the menu opens, so hovering another
+ * row while it is open does not change it
+ */
+ public readonly menu_item = signal(null);
public readonly item_signal = computed(
() => this._service.item() as PlaceSystem,
@@ -616,10 +622,13 @@ export class SystemModulesComponent {
public async viewState(device: PlaceModule) {
const modules = this._service.getModules();
- this._dialog.open(
- ViewModuleStateModalComponent,
- { data: { system: this.item, module: device, devices: modules } },
- );
+ this._dialog.open<
+ unknown,
+ ModuleStateModalData,
+ ViewModuleStateModalComponent
+ >(ViewModuleStateModalComponent, {
+ data: { system: this.item, module: device, devices: modules },
+ });
}
public async setActive(idx: number) {
@@ -666,7 +675,7 @@ export class SystemModulesComponent {
}
public viewRuntimeError(device: PlaceModule) {
- this._dialog.open(
+ this._dialog.open(
ModuleRuntimeErrorsModalComponent,
{ data: device.id },
);
diff --git a/src/app/systems/system-state.service.ts b/src/app/systems/system-state.service.ts
index c4eac6323..c6b3a3a2c 100644
--- a/src/app/systems/system-state.service.ts
+++ b/src/app/systems/system-state.service.ts
@@ -1,5 +1,4 @@
import { computed, inject, resource, Service, signal } from '@angular/core';
-import { MatDialog } from '@angular/material/dialog';
import {
addSystemModule,
addSystemTrigger,
@@ -43,6 +42,7 @@ import { notifyError, notifySuccess } from '../common/notifications';
import { waitForDialog } from '../common/signals';
import { HashMap, Identity } from '../common/types';
// Modals load on demand to keep them out of the first page load
+import { Dialog } from '@angular/cdk/dialog';
import type { ConfirmModalData } from '../overlays/confirm-modal.component';
import type {
SelectItemModalComponent,
@@ -59,7 +59,7 @@ async function selectItemModal() {
export class SystemStateService extends AsyncHandler {
private _state = inject(ActiveItemService);
private _debug = inject(PlaceDebugService);
- private _dialog = inject(MatDialog);
+ private _dialog = inject(Dialog);
/** Signal of the active item */
public readonly item = computed(
@@ -393,8 +393,9 @@ export class SystemStateService extends AsyncHandler {
);
const item = this.item();
const ref = this._dialog.open<
- SelectItemModalComponent,
- SelectItemModalData
+ unknown,
+ SelectItemModalData,
+ SelectItemModalComponent
>(await selectItemModal(), {
data: {
service_name: 'module to system',
@@ -430,8 +431,9 @@ export class SystemStateService extends AsyncHandler {
public async selectTrigger() {
const ref = this._dialog.open<
- SelectItemModalComponent,
- SelectItemModalData
+ unknown,
+ SelectItemModalData,
+ SelectItemModalComponent
>(await selectItemModal(), {
data: {
service_name: 'Triggers',
diff --git a/src/app/systems/system-trigger-form.component.ts b/src/app/systems/system-trigger-form.component.ts
index a21ecef47..38350450f 100644
--- a/src/app/systems/system-trigger-form.component.ts
+++ b/src/app/systems/system-trigger-form.component.ts
@@ -1,3 +1,4 @@
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import {
Component,
EventEmitter,
@@ -8,8 +9,6 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormField, form, submit } from '@angular/forms/signals';
-import { MatRippleModule } from '@angular/material/core';
-import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
PlaceTrigger,
SignagePlaylist,
@@ -131,7 +130,7 @@ import { TranslatePipe } from '../ui/translate.pipe';
>(MatDialogRef);
+ inject>(DialogRef);
private _data = inject<{ item: PlaceTrigger; readonly?: string }>(
- MAT_DIALOG_DATA,
+ DIALOG_DATA,
);
private _hotkey = inject(HotkeysService);
diff --git a/src/app/systems/system-triggers.component.ts b/src/app/systems/system-triggers.component.ts
index b3ac9c6ee..11447324a 100644
--- a/src/app/systems/system-triggers.component.ts
+++ b/src/app/systems/system-triggers.component.ts
@@ -4,17 +4,14 @@ import { copyToClipboard, unique } from '../common/general';
import { notifyInfo } from '../common/notifications';
import { FormsModule } from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatProgressBarModule } from '@angular/material/progress-bar';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { i18n } from '../common/locale.service';
import { HashMap } from '../common/types';
import { BindingDirective } from '../ui/binding.directive';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
+import { ProgressBarComponent } from '../ui/controls/progress-bar.component';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { IconComponent } from '../ui/icon.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { DateFromPipe } from '../ui/pipes/date-from.pipe';
import { SimpleTableComponent } from '../ui/simple-table.component';
import { TranslatePipe } from '../ui/translate.pipe';
@@ -33,25 +30,24 @@ export interface TriggerInstanceState {
template: `
-
-
+
+
search
-
-
+
+
{{ 'SYSTEMS.TRIGGER_ADD' | translate }}
-
link
@@ -172,8 +168,8 @@ export interface TriggerInstanceState {
edit
@@ -182,8 +178,8 @@ export interface TriggerInstanceState {
icon
default
error
- matRipple
- [matTooltip]="'SYSTEMS.TRIGGER_REMOVE' | translate"
+ ripple
+ [appTooltip]="'SYSTEMS.TRIGGER_REMOVE' | translate"
(click)="deleteTrigger(row)"
>
delete
@@ -211,17 +207,14 @@ export interface TriggerInstanceState {
`,
],
imports: [
- LoadingLabelDirective,
+ ProgressBarComponent,
+ TooltipDirective,
+ FormFieldComponent,
IconComponent,
TranslatePipe,
- MatRippleModule,
- MatTooltipModule,
DateFromPipe,
- MatFormFieldModule,
- MatInputModule,
FormsModule,
SimpleTableComponent,
- MatProgressBarModule,
RouterModule,
BindingDirective,
],
diff --git a/src/app/systems/system-zones.component.ts b/src/app/systems/system-zones.component.ts
index ca1efc500..56fea1140 100644
--- a/src/app/systems/system-zones.component.ts
+++ b/src/app/systems/system-zones.component.ts
@@ -12,13 +12,11 @@ import { querySupportZones as queryZones } from '../common/support-access';
import { moveItemInArray } from '@angular/cdk/drag-drop';
import { FormsModule } from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
-import { MatProgressBarModule } from '@angular/material/progress-bar';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
+import { ProgressBarComponent } from '../ui/controls/progress-bar.component';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component';
import { IconComponent } from '../ui/icon.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { SimpleTableComponent } from '../ui/simple-table.component';
import { TranslatePipe } from '../ui/translate.pipe';
import { SystemStateService } from './system-state.service';
@@ -41,7 +39,7 @@ import { SystemStateService } from './system-state.service';
}
-
delete
@@ -172,13 +170,11 @@ import { SystemStateService } from './system-state.service';
`,
],
imports: [
- LoadingLabelDirective,
+ ProgressBarComponent,
+ TooltipDirective,
IconComponent,
TranslatePipe,
- MatRippleModule,
- MatTooltipModule,
SimpleTableComponent,
- MatProgressBarModule,
ItemSearchFieldComponent,
FormsModule,
RouterModule,
diff --git a/src/app/triggers/trigger-about.component.ts b/src/app/triggers/trigger-about.component.ts
index 7a72cbae8..123cac0ac 100644
--- a/src/app/triggers/trigger-about.component.ts
+++ b/src/app/triggers/trigger-about.component.ts
@@ -11,8 +11,7 @@ import {
import { AsyncPipe, JsonPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
-import { MatTooltipModule } from '@angular/material/tooltip';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { ItemSearchFieldComponent } from '../ui/custom-fields/item-search-field.component';
import { IconComponent } from '../ui/icon.component';
import { ItemTimestampsComponent } from '../ui/item-timestamps.component';
@@ -56,7 +55,7 @@ import { TriggerStateService } from './trigger-state.service';
for="reference-system"
id="reference-system-label"
class="max-w-[50%]"
- [matTooltip]="'TRIGGERS.REFERENCE_SYSTEM_MSG' | translate"
+ [appTooltip]="'TRIGGERS.REFERENCE_SYSTEM_MSG' | translate"
>
{{ 'TRIGGERS.REFERENCE_SYSTEM' | translate }}
@@ -77,11 +76,11 @@ import { TriggerStateService } from './trigger-state.service';
add
@@ -157,7 +156,7 @@ import { TriggerStateService } from './trigger-state.service';
@@ -184,11 +183,11 @@ import { TriggerStateService } from './trigger-state.service';
add
@@ -251,7 +250,7 @@ import { TriggerStateService } from './trigger-state.service';
@@ -297,11 +296,10 @@ import { TriggerStateService } from './trigger-state.service';
`,
],
imports: [
+ TooltipDirective,
ItemTimestampsComponent,
TranslatePipe,
IconComponent,
- MatRippleModule,
- MatTooltipModule,
SimpleTableComponent,
ItemSearchFieldComponent,
FormsModule,
diff --git a/src/app/triggers/trigger-form.component.ts b/src/app/triggers/trigger-form.component.ts
index 8556b6021..bfdb74370 100644
--- a/src/app/triggers/trigger-form.component.ts
+++ b/src/app/triggers/trigger-form.component.ts
@@ -1,3 +1,4 @@
+import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
import {
Component,
EventEmitter,
@@ -7,14 +8,13 @@ import {
signal,
} from '@angular/core';
import { FormField, form } from '@angular/forms/signals';
-import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatSelectModule } from '@angular/material/select';
import { PlaceTrigger, addTrigger, updateTrigger } from '@placeos/ts-client';
import { mergeFormItem, onSaveHotkey, submitFormDialog } from '../common/forms';
import { i18n } from '../common/locale.service';
import { DialogEvent } from '../common/types';
+import { FormFieldComponent } from '../ui/controls/form-field.component';
+import { OptionComponent } from '../ui/controls/option.component';
+import { SelectComponent } from '../ui/controls/select.component';
import { CounterComponent } from '../ui/counter.component';
import { FullscreenModalShellComponent } from '../ui/fullscreen-modal-shell.component';
import { SettingsToggleComponent } from '../ui/settings-toggle.component';
@@ -49,17 +49,16 @@ import {
{{ 'COMMON.FIELD_NAME' | translate
}}*
-
+
- Trigger name is required
-
+ Trigger name is required
+
}
@if (form.description) {
@@ -67,16 +66,15 @@ import {
{{
'COMMON.FIELD_DESCRIPTION' | translate
}}
-
+
-
+
}
@if (form.enable_webhook) {
@@ -123,29 +121,26 @@ import {
'TRIGGERS.SUPPORTED_METHODS' | translate
}}
-
-
+
- GET
- POST GET
+ POST
- PUT
- PATCH PUT
+ PATCH
- DELETE DELETE
-
-
+
+
}
@@ -155,21 +150,21 @@ import {
`,
styles: [``],
imports: [
- MatFormFieldModule,
- MatSelectModule,
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
FormField,
CounterComponent,
SettingsToggleComponent,
- MatInputModule,
TranslatePipe,
FullscreenModalShellComponent,
],
})
export class TriggerFormComponent implements OnInit {
private _dialog_ref =
- inject
>(MatDialogRef);
+ inject>(DialogRef);
private _data = inject<{ item: PlaceTrigger; readonly?: string }>(
- MAT_DIALOG_DATA,
+ DIALOG_DATA,
);
private readonly _name = 'TRIGGERS';
diff --git a/src/app/triggers/trigger-instances.component.ts b/src/app/triggers/trigger-instances.component.ts
index 380defc9e..9beae029c 100644
--- a/src/app/triggers/trigger-instances.component.ts
+++ b/src/app/triggers/trigger-instances.component.ts
@@ -2,12 +2,10 @@ import { Component, inject } from '@angular/core';
import { HashMap } from '../common/types';
-import { MatRippleModule } from '@angular/material/core';
-import { MatProgressBarModule } from '@angular/material/progress-bar';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
+import { ProgressBarComponent } from '../ui/controls/progress-bar.component';
+import { TooltipDirective } from '../ui/controls/tooltip.directive';
import { IconComponent } from '../ui/icon.component';
-import { LoadingLabelDirective } from '../ui/loading-label.directive';
import { DateFromPipe } from '../ui/pipes/date-from.pipe';
import { SimpleTableComponent } from '../ui/simple-table.component';
import { TranslatePipe } from '../ui/translate.pipe';
@@ -17,7 +15,7 @@ import { TriggerStateService } from './trigger-state.service';
selector: 'trigger-systems',
template: `
-
{{ row.name || row.zone_id || row.control_system_id }}
@@ -81,8 +79,8 @@ import { TriggerStateService } from './trigger-state.service';
icon
default
error
- matRipple
- [matTooltip]="'TRIGGERS.DELETE_INSTANCE' | translate"
+ ripple
+ [appTooltip]="'TRIGGERS.DELETE_INSTANCE' | translate"
(click)="deleteTrigger(row)"
>
delete
@@ -100,14 +98,12 @@ import { TriggerStateService } from './trigger-state.service';
`,
],
imports: [
- LoadingLabelDirective,
+ ProgressBarComponent,
+ TooltipDirective,
IconComponent,
TranslatePipe,
- MatRippleModule,
SimpleTableComponent,
- MatProgressBarModule,
RouterModule,
- MatTooltipModule,
DateFromPipe,
],
})
diff --git a/src/app/triggers/trigger-state.service.ts b/src/app/triggers/trigger-state.service.ts
index 2ca229fbd..9f266df37 100644
--- a/src/app/triggers/trigger-state.service.ts
+++ b/src/app/triggers/trigger-state.service.ts
@@ -1,5 +1,4 @@
import { Service, computed, inject, resource, signal } from '@angular/core';
-import { MatDialog } from '@angular/material/dialog';
import {
PlaceSystem,
PlaceTrigger,
@@ -14,6 +13,7 @@ import {
updateZone,
} from '@placeos/ts-client';
+import { Dialog } from '@angular/cdk/dialog';
import { describeError } from '../common/errors';
import { escapeHtml, moveItemInArray } from '../common/general';
import { ActiveItemService } from '../common/item.service';
@@ -35,7 +35,7 @@ import {
@Service()
export class TriggerStateService {
private _service = inject(ActiveItemService);
- private _dialog = inject(MatDialog);
+ private _dialog = inject(Dialog);
private _change = signal(0);
public readonly item = computed(
@@ -70,8 +70,9 @@ export class TriggerStateService {
) {
if (!template) return;
const ref = this._dialog.open<
- TriggerConditionModalComponent,
- TriggerConditionData
+ unknown,
+ TriggerConditionData,
+ TriggerConditionModalComponent
>(TriggerConditionModalComponent, {
width: 'auto',
height: 'auto',
@@ -103,8 +104,9 @@ export class TriggerStateService {
) {
if (!template) return;
const ref = this._dialog.open<
- TriggerActionModalComponent,
- TriggerActionModalData
+ unknown,
+ TriggerActionModalData,
+ TriggerActionModalComponent
>(TriggerActionModalComponent, {
data: {
trigger: this.active_item,
diff --git a/src/app/triggers/triggers.utilities.ts b/src/app/triggers/triggers.utilities.ts
index 7954a8243..fad4f3340 100644
--- a/src/app/triggers/triggers.utilities.ts
+++ b/src/app/triggers/triggers.utilities.ts
@@ -97,7 +97,7 @@ export function generateTriggerConditionFormModel(
left:
typeof left === 'object'
? { ...(left as TriggerStatusVariable) }
- : left ?? ({} as TriggerStatusVariable),
+ : (left ?? ({} as TriggerStatusVariable)),
operator:
(condition as TriggerComparison).operator ||
TriggerConditionOperator.EQ,
diff --git a/src/app/ui/application-picker-tooltip.component.ts b/src/app/ui/application-picker-tooltip.component.ts
index 6f9eb265b..86987f028 100644
--- a/src/app/ui/application-picker-tooltip.component.ts
+++ b/src/app/ui/application-picker-tooltip.component.ts
@@ -1,5 +1,4 @@
import { Component, computed, inject, OnInit, signal } from '@angular/core';
-import { MatRippleModule } from '@angular/material/core';
import {
authority,
PlaceApplication,
@@ -52,7 +51,7 @@ export function isSupportedRedirectUri(uri: string) {
@for (app of visible_applications(); track app.id) {
= total_pages() - 1"
[class.opacity-40]="page() >= total_pages() - 1"
@@ -125,7 +124,7 @@ export function isSupportedRedirectUri(uri: string) {
}
`,
],
- imports: [MatRippleModule],
+ imports: [],
})
export class ApplicationPickerTooltipComponent implements OnInit {
private _tooltip = inject(CustomTooltipData, { optional: true });
diff --git a/src/app/ui/context-menu.component.ts b/src/app/ui/context-menu.component.ts
index 7373ef328..a17245fe2 100644
--- a/src/app/ui/context-menu.component.ts
+++ b/src/app/ui/context-menu.component.ts
@@ -1,31 +1,14 @@
-import {
- Component,
- HostListener,
- OnInit,
- input,
- signal,
- viewChild,
-} from '@angular/core';
-import {
- MatMenuModule,
- MatMenuPanel,
- MatMenuTrigger,
-} from '@angular/material/menu';
+import { CdkContextMenuTrigger } from '@angular/cdk/menu';
+import { Component } from '@angular/core';
+/**
+ * Opens a CDK menu at the pointer when the user right-clicks the host.
+ * Usage: `…
`, where
+ * `menu_template` is an `` that holds a `cdkMenu`.
+ */
@Component({
selector: '[context-menu]',
- template: `
-
-
-
- `,
+ template: ` `,
styles: [
`
:host {
@@ -33,35 +16,11 @@ import {
}
`,
],
- imports: [MatMenuModule],
+ hostDirectives: [
+ {
+ directive: CdkContextMenuTrigger,
+ inputs: ['cdkContextMenuTriggerFor: context-menu'],
+ },
+ ],
})
-export class ContextMenuComponent implements OnInit {
- /** List of context menu items */
- public readonly menu = input(undefined, {
- alias: 'context-menu',
- });
- /** Offset of the context menu on the x axis */
- public readonly offset_x = input(0);
- /** Offset of the context menu on the y axis */
- public readonly offset_y = input(0);
- /** Location of the menu */
- public readonly position = signal<{ top: number; left: number } | null>(
- null,
- );
-
- private readonly trigger = viewChild(MatMenuTrigger);
-
- @HostListener('contextmenu', ['$event']) public onEvent(event) {
- event.preventDefault();
- this.position.set({
- top: event.clientY + this.offset_y(),
- left: event.clientX + this.offset_x(),
- });
- const trigger = this.trigger();
- if (trigger) trigger.openMenu();
- }
-
- public ngOnInit(): void {
- this.position.set({ top: 0, left: 0 });
- }
-}
+export class ContextMenuComponent {}
diff --git a/src/app/ui/controls/autocomplete.component.ts b/src/app/ui/controls/autocomplete.component.ts
new file mode 100644
index 000000000..83a9a019e
--- /dev/null
+++ b/src/app/ui/controls/autocomplete.component.ts
@@ -0,0 +1,197 @@
+import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
+import { Overlay, OverlayRef } from '@angular/cdk/overlay';
+import { TemplatePortal } from '@angular/cdk/portal';
+import {
+ Component,
+ contentChildren,
+ Directive,
+ ElementRef,
+ forwardRef,
+ inject,
+ Injector,
+ input,
+ OnDestroy,
+ output,
+ signal,
+ Signal,
+ TemplateRef,
+ viewChild,
+ ViewContainerRef,
+} from '@angular/core';
+import { OPTION_HOST, OptionComponent, OptionHost } from './option.component';
+import { PANEL_POSITIONS, VIEWPORT_MARGIN } from './overlay';
+import { SelectChange } from './select.component';
+
+let next_id = 0;
+
+/**
+ * List of suggestions for an input. Link it to the input with
+ * `[appAutocomplete]`. The panel shows while the input has focus and the
+ * list has options.
+ */
+@Component({
+ selector: 'app-autocomplete',
+ exportAs: 'appAutocomplete',
+ template: `
+
+
+
+
+
+ `,
+ providers: [
+ {
+ provide: OPTION_HOST,
+ useExisting: forwardRef(() => AutocompleteComponent),
+ },
+ ],
+})
+export class AutocompleteComponent implements OptionHost {
+ public readonly panel_id = `app-autocomplete-${next_id++}`;
+ public readonly template = viewChild.required(TemplateRef);
+ public readonly options = contentChildren(OptionComponent, {
+ descendants: true,
+ });
+ /** Emitted when the user picks an option */
+ public readonly optionSelected = output>();
+ /** Set by the trigger to receive picked options */
+ public onPick: (option: OptionComponent) => void = () => undefined;
+
+ public readonly multiple = () => false;
+
+ public isSelected() {
+ return false;
+ }
+
+ public pick(option: OptionComponent) {
+ this.onPick(option);
+ this.optionSelected.emit({ value: option.value() });
+ }
+}
+
+/**
+ * Show the suggestions of an `app-autocomplete` for the input.
+ * A picked text or number value is written into the input,
+ * so `ngModel` and `[formField]` get it.
+ */
+@Directive({
+ selector: 'input[appAutocomplete], textarea[appAutocomplete]',
+ host: {
+ '[attr.role]': 'panel() ? "combobox" : null',
+ '[attr.autocomplete]': 'panel() ? "off" : null',
+ '[attr.aria-autocomplete]': 'panel() ? "list" : null',
+ '[attr.aria-expanded]': 'panel() ? opened() : null',
+ '[attr.aria-controls]': 'opened() ? panel()?.panel_id : null',
+ '[attr.aria-activedescendant]': 'active_id()',
+ '(focus)': 'open()',
+ '(click)': 'open()',
+ '(input)': 'onInput()',
+ '(blur)': 'close()',
+ '(keydown)': 'onKeydown($event)',
+ },
+})
+export class AutocompleteTriggerDirective implements OnDestroy {
+ private readonly _element =
+ inject>(ElementRef);
+ private readonly _overlay = inject(Overlay);
+ private readonly _injector = inject(Injector);
+ private readonly _view_container = inject(ViewContainerRef);
+ private _overlay_ref: OverlayRef;
+ private _key_manager: ActiveDescendantKeyManager>;
+ /** Set while the input event of a picked value is dispatched */
+ private _writing = false;
+
+ /** Suggestions to show. Nothing shows while it is unset */
+ public readonly panel = input | undefined>(
+ undefined,
+ { alias: 'appAutocomplete' },
+ );
+ public readonly opened = signal(false);
+ public readonly active_id = signal(null);
+
+ public open() {
+ const panel = this.panel();
+ if (!panel || this.opened() || this._element.nativeElement.readOnly) {
+ return;
+ }
+ const host = this._element.nativeElement;
+ const box = host.closest('.field-box') ?? host;
+ this._overlay_ref ??= this._overlay.create({
+ positionStrategy: this._overlay
+ .position()
+ .flexibleConnectedTo(box)
+ .withPositions(PANEL_POSITIONS)
+ .withViewportMargin(VIEWPORT_MARGIN)
+ .withFlexibleDimensions(false)
+ .withPush(true)
+ // A popover next to the input, so the panel stays in the
+ // DOM of a dialog that the input is in
+ .withPopoverLocation('inline'),
+ scrollStrategy: this._overlay.scrollStrategies.reposition(),
+ });
+ this._overlay_ref.updateSize({
+ width: box.getBoundingClientRect().width,
+ });
+ this._overlay_ref.attach(
+ new TemplatePortal(panel.template(), this._view_container),
+ );
+ panel.onPick = (option) => this._pick(option);
+ this._key_manager = new ActiveDescendantKeyManager(
+ panel.options as Signal[]>,
+ this._injector,
+ ).withWrap();
+ this._key_manager.change.subscribe(() =>
+ this.active_id.set(this._key_manager.activeItem?.id ?? null),
+ );
+ this.opened.set(true);
+ }
+
+ public close() {
+ if (!this.opened()) return;
+ this._overlay_ref?.detach();
+ // Clear the active option's highlight before the manager goes
+ this._key_manager?.setActiveItem(-1);
+ this._key_manager?.destroy();
+ this._key_manager = undefined;
+ this.active_id.set(null);
+ this.opened.set(false);
+ }
+
+ public onInput() {
+ if (this._writing) return;
+ this.open();
+ this._key_manager?.setActiveItem(-1);
+ }
+
+ public onKeydown(keyboard_event: Event) {
+ const event = keyboard_event as KeyboardEvent;
+ if (event.key === 'Escape' && this.opened()) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.close();
+ } else if (event.key === 'Enter' && this._key_manager?.activeItem) {
+ event.preventDefault();
+ this._key_manager.activeItem.pickOption();
+ } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ this.open();
+ this._key_manager?.onKeydown(event);
+ }
+ }
+
+ public ngOnDestroy() {
+ this._key_manager?.destroy();
+ this._overlay_ref?.dispose();
+ }
+
+ private _pick(option: OptionComponent) {
+ const value = option.value();
+ if (typeof value === 'string' || typeof value === 'number') {
+ const element = this._element.nativeElement;
+ element.value = `${value}`;
+ this._writing = true;
+ element.dispatchEvent(new Event('input', { bubbles: true }));
+ this._writing = false;
+ }
+ this.close();
+ }
+}
diff --git a/src/app/ui/controls/chip-list.component.ts b/src/app/ui/controls/chip-list.component.ts
new file mode 100644
index 000000000..7026ace48
--- /dev/null
+++ b/src/app/ui/controls/chip-list.component.ts
@@ -0,0 +1,220 @@
+import {
+ booleanAttribute,
+ Component,
+ computed,
+ effect,
+ ElementRef,
+ forwardRef,
+ input,
+ model,
+ output,
+ signal,
+ viewChild,
+} from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { IconComponent } from '../icon.component';
+import { TranslatePipe } from '../translate.pipe';
+import {
+ AutocompleteComponent,
+ AutocompleteTriggerDirective,
+} from './autocomplete.component';
+
+/**
+ * Editable list of text items, shown as chips with a text input after them.
+ * The user adds an item with a separator key or when the input loses focus.
+ * Works with `ngModel`, `[formField]` and `[(value)]`.
+ * Put it inside an `app-form-field` for the outlined box.
+ */
+@Component({
+ selector: 'app-chip-list',
+ template: `
+ @for (item of list(); track item) {
+
+ @if (editing() === item) {
+
+ } @else {
+ {{ item }}
+ @if (editable() && !is_disabled()) {
+
+ edit
+
+ }
+ @if (!is_disabled()) {
+
+ cancel
+
+ }
+ }
+
+ }
+
+ `,
+ imports: [IconComponent, TranslatePipe, AutocompleteTriggerDirective],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => ChipListComponent),
+ multi: true,
+ },
+ ],
+ host: { class: 'chip-list', role: 'list' },
+})
+export class ChipListComponent implements ControlValueAccessor {
+ private _onChange: (value: string[]) => void = () => undefined;
+ private _onTouched: () => void = () => undefined;
+
+ /** List of items */
+ public readonly value = model([]);
+ public readonly placeholder = input('');
+ /** `id` of the text input, for a `label[for]` */
+ public readonly inputId = input(null);
+ /** Keys that add the text in the input as an item */
+ public readonly separators = input(['Enter', ',']);
+ /** Whether the user can change the text of an item */
+ public readonly editable = input(false, { transform: booleanAttribute });
+ public readonly disabled = input(false, { transform: booleanAttribute });
+ /** Suggestions for the text input. A picked suggestion is added */
+ public readonly autocomplete = input();
+ /** Emitted when the text in the input changes, e.g. to filter suggestions */
+ public readonly textChange = output();
+
+ public readonly editing = signal(null);
+ public readonly list = computed(() => this.value() || []);
+ private readonly _form_disabled = signal(false);
+ public readonly is_disabled = computed(
+ () => this.disabled() || this._form_disabled(),
+ );
+ private readonly _text_input =
+ viewChild.required>('text_input');
+ private readonly _edit_input =
+ viewChild>('edit_input');
+
+ constructor() {
+ effect((on_cleanup) => {
+ const panel = this.autocomplete();
+ if (!panel) return;
+ const sub = panel.optionSelected.subscribe(({ value }) => {
+ this.add(`${value ?? ''}`);
+ this._text_input().nativeElement.value = '';
+ this.textChange.emit('');
+ });
+ on_cleanup(() => sub.unsubscribe());
+ });
+ // Focus the inline input when the user starts to edit an item
+ effect(() => this._edit_input()?.nativeElement.focus());
+ }
+
+ public onKeydown(event: KeyboardEvent, element: HTMLInputElement) {
+ if (this.separators().includes(event.key)) {
+ if (!element.value.trim()) return;
+ event.preventDefault();
+ this.addText(element);
+ } else if (event.key === 'Backspace' && !element.value) {
+ const items = this.list();
+ if (items.length) this.remove(items[items.length - 1]);
+ }
+ }
+
+ /** Add the text of the input as an item and clear the input */
+ public addText(element: HTMLInputElement) {
+ this._onTouched();
+ if (this.add(element.value)) {
+ element.value = '';
+ this.textChange.emit('');
+ }
+ }
+
+ /** Add an item. Returns whether the text was not empty */
+ public add(text: string): boolean {
+ const item = text.trim();
+ if (!item) return false;
+ if (!this.list().includes(item)) this._set([...this.list(), item]);
+ return true;
+ }
+
+ public remove(item: string) {
+ this._set(this.list().filter((existing) => existing !== item));
+ }
+
+ public startEdit(item: string) {
+ this.editing.set(item);
+ }
+
+ /**
+ * Replace an item with its edited text, keeping its position.
+ * Empty text removes the item. Text of another item merges into it.
+ */
+ public finishEdit(item: string, text: string) {
+ if (this.editing() !== item) return;
+ this.editing.set(null);
+ const value = text.trim();
+ const items = this.list();
+ if (!value || (value !== item && items.includes(value))) {
+ this.remove(item);
+ } else if (value !== item) {
+ this._set(
+ items.map((existing) => (existing === item ? value : existing)),
+ );
+ }
+ }
+
+ public writeValue(value: string[]) {
+ this.value.set(value || []);
+ }
+
+ public registerOnChange(fn: (value: string[]) => void) {
+ this._onChange = fn;
+ }
+
+ public registerOnTouched(fn: () => void) {
+ this._onTouched = fn;
+ }
+
+ public setDisabledState(disabled: boolean) {
+ this._form_disabled.set(disabled);
+ }
+
+ private _set(value: string[]) {
+ this.value.set(value);
+ this._onChange(value);
+ }
+}
diff --git a/src/app/ui/controls/dialog.ts b/src/app/ui/controls/dialog.ts
new file mode 100644
index 000000000..6ea65f6a7
--- /dev/null
+++ b/src/app/ui/controls/dialog.ts
@@ -0,0 +1,30 @@
+import { DialogRef } from '@angular/cdk/dialog';
+import { Directive, inject, input } from '@angular/core';
+
+/**
+ * Close the dialog that the element is in on click.
+ * The value of `appDialogClose` is the result of the dialog.
+ * A bare attribute closes it with an empty string.
+ */
+@Directive({
+ selector: '[appDialogClose]',
+ host: {
+ '[attr.type]': 'type()',
+ '(click)': 'close()',
+ },
+})
+export class DialogCloseDirective {
+ private readonly _ref = inject>(DialogRef, {
+ optional: true,
+ });
+
+ /** Result of the dialog */
+ public readonly result = input(undefined, {
+ alias: 'appDialogClose',
+ });
+ public readonly type = input<'button' | 'submit' | 'reset'>('button');
+
+ public close() {
+ this._ref?.close(this.result());
+ }
+}
diff --git a/src/app/ui/controls/form-field.component.ts b/src/app/ui/controls/form-field.component.ts
new file mode 100644
index 000000000..76a638bb3
--- /dev/null
+++ b/src/app/ui/controls/form-field.component.ts
@@ -0,0 +1,90 @@
+import {
+ afterEveryRender,
+ Component,
+ computed,
+ contentChild,
+ ElementRef,
+ inject,
+} from '@angular/core';
+import { FormField } from '@angular/forms/signals';
+
+let next_id = 0;
+
+/**
+ * Outlined box for an input, textarea, `app-select` or `app-chip-list`.
+ *
+ * Content slots:
+ * - `[prefix]` and `[suffix]` go before and after the control
+ * - `[error]` shows below the box when the control is touched and invalid
+ * - `[hint]` shows below the box otherwise
+ *
+ * The error state comes from a `[formField]` control, or from the
+ * `ng-touched` and `ng-invalid` classes that `ngModel` adds.
+ * The control is described by the error or hint that shows.
+ * Add the `no-subscript` class to remove the space for errors and hints.
+ * The styles are in `styles.css` so that utility classes can change them.
+ */
+@Component({
+ selector: 'app-form-field',
+ template: `
+
+
+
+
+
+
+
+
+ `,
+ host: {
+ '[class.field-invalid]': 'invalid()',
+ '(click)': 'focusControl($event)',
+ },
+})
+export class FormFieldComponent {
+ private readonly _element = inject>(ElementRef);
+
+ public readonly subscript_id = `app-form-field-subscript-${next_id++}`;
+
+ constructor() {
+ // Bindings on the control can replace the attribute, so check after
+ // each render
+ afterEveryRender(() => this._describeControl());
+ }
+ private readonly _field = contentChild(FormField, { descendants: true });
+
+ /** Whether the signal form control is touched and invalid */
+ public readonly invalid = computed(() => {
+ const state = this._field()?.state();
+ return !!state && state.touched() && state.invalid();
+ });
+
+ /**
+ * Add the error and hint area to the control's `aria-describedby`. Screen
+ * readers skip the error or hint that is hidden, so only the one that
+ * shows is read. Other ids on the control are kept.
+ */
+ private _describeControl() {
+ const control = this._element.nativeElement.querySelector(
+ '.field-box :is(input, textarea, app-select)',
+ );
+ if (!control) return;
+ const ids = (control.getAttribute('aria-describedby') || '')
+ .split(/\s+/)
+ .filter(Boolean);
+ if (ids.includes(this.subscript_id)) return;
+ control.setAttribute(
+ 'aria-describedby',
+ [...ids, this.subscript_id].join(' '),
+ );
+ }
+
+ /** Focus the control on a click on the box outside of it */
+ public focusControl(event: MouseEvent) {
+ const target = event.target as HTMLElement;
+ if (!target.classList.contains('field-box')) return;
+ this._element.nativeElement
+ .querySelector('input, textarea, app-select')
+ ?.focus();
+ }
+}
diff --git a/src/app/ui/controls/option.component.ts b/src/app/ui/controls/option.component.ts
new file mode 100644
index 000000000..d2535ecd3
--- /dev/null
+++ b/src/app/ui/controls/option.component.ts
@@ -0,0 +1,97 @@
+import { Highlightable } from '@angular/cdk/a11y';
+import {
+ booleanAttribute,
+ Component,
+ computed,
+ ElementRef,
+ inject,
+ InjectionToken,
+ Input,
+ input,
+ signal,
+} from '@angular/core';
+
+/** Parent of a list of options, i.e. a select or an autocomplete */
+export interface OptionHost {
+ /** Whether more than one option can be selected */
+ readonly multiple: () => boolean;
+ /** Whether the option has the selected value */
+ isSelected(option: OptionComponent): boolean;
+ /** Called when the user picks the option */
+ pick(option: OptionComponent): void;
+}
+
+export const OPTION_HOST = new InjectionToken('OPTION_HOST');
+
+let next_id = 0;
+
+/**
+ * Option of an `app-select` or `app-autocomplete`.
+ * The text content of the option is its display label.
+ */
+@Component({
+ selector: 'app-option',
+ template: `
+ @if (multiple()) {
+
+ }
+
+ `,
+ host: {
+ role: 'option',
+ '[id]': 'id',
+ '[attr.aria-selected]': 'selected()',
+ '[attr.aria-disabled]': 'disabled',
+ '[class.active]': 'active()',
+ // Keep the focus on the select or input that owns the option
+ '(mousedown)': '$event.preventDefault()',
+ '(click)': 'pickOption()',
+ },
+})
+export class OptionComponent implements Highlightable {
+ private readonly _host = inject(OPTION_HOST);
+ private readonly _element = inject>(ElementRef);
+
+ public readonly id = `app-option-${next_id++}`;
+ /** Value that the option sets */
+ public readonly value = input();
+ private readonly _disabled = signal(false);
+
+ /**
+ * Whether the user can pick the option. A plain property, as the
+ * key manager reads it to skip the option.
+ */
+ @Input({ transform: booleanAttribute })
+ public set disabled(value: boolean) {
+ this._disabled.set(value);
+ }
+ public get disabled() {
+ return this._disabled();
+ }
+ /** Whether the keyboard focus is on the option */
+ public readonly active = signal(false);
+
+ public readonly multiple = computed(() => this._host.multiple());
+ public readonly selected = computed(() => {
+ this.value();
+ return this._host.isSelected(this);
+ });
+
+ /** Display text of the option */
+ public getLabel(): string {
+ return this._element.nativeElement.textContent?.trim() ?? '';
+ }
+
+ public pickOption() {
+ if (!this.disabled) this._host.pick(this);
+ }
+
+ public setActiveStyles() {
+ this.active.set(true);
+ this._element.nativeElement.scrollIntoView?.({ block: 'nearest' });
+ }
+
+ public setInactiveStyles() {
+ this.active.set(false);
+ }
+}
diff --git a/src/app/ui/controls/overlay.ts b/src/app/ui/controls/overlay.ts
new file mode 100644
index 000000000..81e4ea34b
--- /dev/null
+++ b/src/app/ui/controls/overlay.ts
@@ -0,0 +1,72 @@
+import { ConnectedPosition } from '@angular/cdk/overlay';
+
+/** Least space between a floating panel or tooltip and the viewport edge */
+export const VIEWPORT_MARGIN = 4;
+
+/** Clear space between a floating panel and the element that opened it, so their edges do not merge */
+export const PANEL_GAP = 1;
+
+/** Height of the solid shadow below a floating panel or tooltip (`.select-panel`, `.cdk-menu`, `.tooltip-panel`) */
+export const PANEL_SHADOW = 2;
+
+/** How far the tooltip arrow reaches past the bubble. Matches `--arrow` in styles.css */
+export const TOOLTIP_ARROW = 6;
+
+/** Width of the focus ring outside a focused form field (`.field-box`) */
+const FOCUS_RING = 2;
+
+/** How far a trigger draws outside its box, above it and below it */
+interface TriggerReach {
+ above: number;
+ below: number;
+}
+
+/** A focused form field draws its focus ring on all sides */
+const FIELD_REACH: TriggerReach = { above: FOCUS_RING, below: FOCUS_RING };
+/** A raised button with an open menu stays at rest, inside its box */
+const BUTTON_REACH: TriggerReach = { above: 0, below: 0 };
+
+/**
+ * Positions below the trigger, then above it, with `PANEL_GAP` of clear
+ * space between the edges that show: the trigger's reach outside its box,
+ * and the panel's shadow when the panel is above the trigger.
+ */
+function positions(
+ reach: TriggerReach,
+ align: ConnectedPosition['originX'],
+): ConnectedPosition[] {
+ return [
+ {
+ originX: align,
+ originY: 'bottom',
+ overlayX: align,
+ overlayY: 'top',
+ offsetY: reach.below + PANEL_GAP,
+ },
+ {
+ originX: align,
+ originY: 'top',
+ overlayX: align,
+ overlayY: 'bottom',
+ offsetY: -(reach.above + PANEL_SHADOW + PANEL_GAP),
+ },
+ ];
+}
+
+/** Positions of a panel that a form field opens, e.g. a select or autocomplete */
+export const PANEL_POSITIONS = positions(FIELD_REACH, 'start');
+
+/**
+ * CDK menu positions for a button trigger: below, then above, aligned to
+ * the start of the trigger and then its end. Use with `[cdkMenuPosition]`.
+ */
+export const MENU_POSITIONS: ConnectedPosition[] = [
+ ...positions(BUTTON_REACH, 'start'),
+ ...positions(BUTTON_REACH, 'end'),
+];
+
+/** Menu positions above the trigger, then below it */
+export const MENU_POSITIONS_ABOVE: ConnectedPosition[] = [
+ MENU_POSITIONS[1],
+ MENU_POSITIONS[0],
+];
diff --git a/src/app/ui/controls/progress-bar.component.ts b/src/app/ui/controls/progress-bar.component.ts
new file mode 100644
index 000000000..d1c045614
--- /dev/null
+++ b/src/app/ui/controls/progress-bar.component.ts
@@ -0,0 +1,41 @@
+import { Component, computed, inject, input } from '@angular/core';
+
+import { LocaleService } from '../../common/locale.service';
+
+/**
+ * Linear progress indicator.
+ * In `determinate` mode it shows `value` (0 to 100). In other modes it loops.
+ */
+@Component({
+ selector: 'app-progress-bar',
+ template: `
+ @if (determinate()) {
+
+ } @else {
+
+ }
+ `,
+ host: {
+ class: 'progress-bar',
+ role: 'progressbar',
+ '[attr.aria-label]': 'label() || default_label',
+ 'aria-valuemin': '0',
+ 'aria-valuemax': '100',
+ '[attr.aria-valuenow]': 'determinate() ? clamped() : null',
+ },
+})
+export class ProgressBarComponent {
+ /** Accessible name. It defaults to "Loading" */
+ public readonly label = input(undefined);
+ public readonly default_label = inject(LocaleService).get('COMMON.LOADING');
+ public readonly mode = input<'determinate' | 'indeterminate' | 'query'>(
+ 'determinate',
+ );
+ /** Progress from 0 to 100, used in `determinate` mode */
+ public readonly value = input(0);
+
+ public readonly determinate = computed(() => this.mode() === 'determinate');
+ public readonly clamped = computed(() =>
+ Math.min(100, Math.max(0, this.value() || 0)),
+ );
+}
diff --git a/src/app/ui/controls/ripple.ts b/src/app/ui/controls/ripple.ts
new file mode 100644
index 000000000..c2d6af93e
--- /dev/null
+++ b/src/app/ui/controls/ripple.ts
@@ -0,0 +1,34 @@
+/** Time a ripple stays in the DOM. It matches the `ripple` animation in styles.css */
+const RIPPLE_MS = 500;
+
+/**
+ * Show a ripple from the pointer on press of any element with a `ripple`
+ * attribute. One listener on the document serves the whole app, so elements
+ * only need the attribute. Call once at startup.
+ */
+export function installRipple(doc: Document) {
+ const reduce_motion = doc.defaultView?.matchMedia?.(
+ '(prefers-reduced-motion: reduce)',
+ );
+ doc.addEventListener(
+ 'pointerdown',
+ (event) => {
+ const target = event.target as Element | null;
+ const host = target?.closest?.('[ripple]');
+ if (!host || host.matches(':disabled') || reduce_motion?.matches) {
+ return;
+ }
+ const rect = host.getBoundingClientRect();
+ // Big enough to cover the element from any start point
+ const size = Math.hypot(rect.width, rect.height) * 2;
+ const wave = doc.createElement('span');
+ wave.className = 'ripple-wave';
+ wave.style.width = wave.style.height = `${size}px`;
+ wave.style.left = `${event.clientX - rect.left - size / 2}px`;
+ wave.style.top = `${event.clientY - rect.top - size / 2}px`;
+ host.appendChild(wave);
+ setTimeout(() => wave.remove(), RIPPLE_MS);
+ },
+ { passive: true },
+ );
+}
diff --git a/src/app/ui/controls/select.component.ts b/src/app/ui/controls/select.component.ts
new file mode 100644
index 000000000..04f0f6012
--- /dev/null
+++ b/src/app/ui/controls/select.component.ts
@@ -0,0 +1,324 @@
+import { ActiveDescendantKeyManager } from '@angular/cdk/a11y';
+import {
+ CdkConnectedOverlay,
+ FlexibleConnectedPositionStrategyOrigin,
+} from '@angular/cdk/overlay';
+import {
+ afterRenderEffect,
+ booleanAttribute,
+ Component,
+ computed,
+ contentChild,
+ contentChildren,
+ Directive,
+ ElementRef,
+ forwardRef,
+ inject,
+ Injector,
+ input,
+ model,
+ output,
+ signal,
+ Signal,
+} from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+import { OPTION_HOST, OptionComponent, OptionHost } from './option.component';
+import { PANEL_POSITIONS, VIEWPORT_MARGIN } from './overlay';
+
+/** Custom content for the closed select. Put it inside `app-select`. */
+@Directive({ selector: 'app-select-trigger' })
+export class SelectTriggerDirective {}
+
+/** Emitted when the user changes the value of a select */
+export interface SelectChange {
+ value: T;
+}
+
+let next_id = 0;
+
+/**
+ * Drop-down list of `app-option`s.
+ * Works with `ngModel`, `[formField]` and `[(value)]`.
+ * Add `multiple` to select more than one value. The value is then an array.
+ * Put a search input in an element with the `select-search` attribute. It
+ * shows above the options and stays in view as they scroll. Put the text to
+ * show when there are no options in an element with `select-empty`.
+ */
+@Component({
+ selector: 'app-select',
+ template: `
+
+ @if (empty()) {
+ {{ placeholder() }}
+ } @else if (custom_trigger()) {
+
+ } @else {
+ {{ display() }}
+ }
+
+
+
+
+
+
+
+
+
+
+ @if (!options().length) {
+
+ }
+
+
+
+ `,
+ imports: [CdkConnectedOverlay],
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => SelectComponent),
+ multi: true,
+ },
+ {
+ provide: OPTION_HOST,
+ useExisting: forwardRef(() => SelectComponent),
+ },
+ ],
+ host: {
+ role: 'combobox',
+ 'aria-haspopup': 'listbox',
+ '[attr.tabindex]': 'is_disabled() ? -1 : 0',
+ '[attr.aria-disabled]': 'is_disabled()',
+ '[attr.aria-expanded]': 'opened()',
+ '[attr.aria-controls]': 'opened() ? panel_id : null',
+ '[attr.aria-activedescendant]': 'active_id()',
+ '[class.disabled]': 'is_disabled()',
+ '(click)': 'toggle()',
+ '(keydown)': 'onKeydown($event)',
+ '(blur)': 'onBlur()',
+ },
+})
+export class SelectComponent
+ implements ControlValueAccessor, OptionHost
+{
+ private readonly _element = inject>(ElementRef);
+ private readonly _injector = inject(Injector);
+ private _key_manager: ActiveDescendantKeyManager;
+ private _onChange: (value: T) => void = () => undefined;
+ private _onTouched: () => void = () => undefined;
+
+ public readonly panel_id = `app-select-panel-${next_id++}`;
+ public readonly positions = PANEL_POSITIONS;
+ public readonly viewport_margin = VIEWPORT_MARGIN;
+
+ /** Selected value. An array of option values when `multiple` is set */
+ public readonly value = model();
+ public readonly placeholder = input('');
+ public readonly multiple = input(false, { transform: booleanAttribute });
+ public readonly disabled = input(false, { transform: booleanAttribute });
+ /** Emitted when the user changes the value */
+ public readonly selectionChange = output>();
+ /** Emitted when the panel opens or closes */
+ public readonly openedChange = output();
+
+ public readonly options = contentChildren(OptionComponent, {
+ descendants: true,
+ });
+ public readonly custom_trigger = contentChild(SelectTriggerDirective);
+ public readonly opened = signal(false);
+ public readonly width = signal(0);
+ public readonly origin = signal(
+ this._element,
+ );
+ public readonly active_id = signal(null);
+ private readonly _form_disabled = signal(false);
+ public readonly is_disabled = computed(
+ () => this.disabled() || this._form_disabled(),
+ );
+
+ public readonly selected = computed(() =>
+ this.options().filter((option) => this.isSelected(option)),
+ );
+ public readonly empty = computed(() => {
+ const value: unknown = this.value();
+ return Array.isArray(value)
+ ? !value.length
+ : value === undefined || value === null || value === '';
+ });
+ public readonly display = computed(() =>
+ this.selected()
+ .map((option) => option.getLabel())
+ .join(', '),
+ );
+
+ constructor() {
+ // A combobox does not take its name from its text, so use the
+ // placeholder when the page gives the select no label
+ afterRenderEffect(() => {
+ const host = this._element.nativeElement;
+ const text = this.placeholder();
+ const own = host.hasAttribute('data-placeholder-label');
+ const labelled =
+ host.hasAttribute('aria-label') ||
+ host.hasAttribute('aria-labelledby');
+ if (!text || (labelled && !own)) return;
+ host.setAttribute('aria-label', text);
+ host.setAttribute('data-placeholder-label', '');
+ });
+ }
+
+ public isSelected(option: OptionComponent): boolean {
+ const value: unknown = this.value();
+ return this.multiple() && Array.isArray(value)
+ ? value.includes(option.value())
+ : option.value() === value;
+ }
+
+ public pick(option: OptionComponent) {
+ const option_value = option.value();
+ if (this.multiple()) {
+ const value: unknown = this.value();
+ const list: unknown[] = Array.isArray(value) ? value : [];
+ const next = list.includes(option_value)
+ ? list.filter((item) => item !== option_value)
+ : [...list, option_value];
+ this._setValue(next as T);
+ this._key_manager?.setActiveItem(option);
+ return;
+ }
+ this._setValue(option_value as T);
+ this.close();
+ }
+
+ public open() {
+ if (this.opened() || this.is_disabled()) return;
+ const host = this._element.nativeElement;
+ const box = host.closest('.field-box') ?? host;
+ this.origin.set(box);
+ this.width.set(box.getBoundingClientRect().width);
+ this._key_manager = new ActiveDescendantKeyManager(
+ this.options as Signal,
+ this._injector,
+ )
+ .withWrap()
+ .withTypeAhead();
+ this._key_manager.change.subscribe(() =>
+ this.active_id.set(this._key_manager.activeItem?.id ?? null),
+ );
+ this.opened.set(true);
+ this.openedChange.emit(true);
+ // Options are in the DOM once the overlay renders
+ requestAnimationFrame(() => {
+ // The panel can close before this frame
+ const first = this.selected()[0];
+ if (first) this._key_manager?.setActiveItem(first);
+ else this._key_manager?.setFirstItemActive();
+ });
+ }
+
+ public close() {
+ if (!this.opened()) return;
+ // Focus in the panel, e.g. on its search input, goes back to the
+ // select. A click elsewhere has already moved focus out of it.
+ const doc = this._element.nativeElement.ownerDocument;
+ const panel = doc.getElementById(this.panel_id)?.parentElement;
+ const refocus = !!panel?.contains(doc.activeElement);
+ this.opened.set(false);
+ this.active_id.set(null);
+ // Clear the active option's highlight before the manager goes
+ this._key_manager?.setActiveItem(-1);
+ this._key_manager?.destroy();
+ this._key_manager = undefined;
+ // The user has been to the select, so its errors may show
+ this._onTouched();
+ this.openedChange.emit(false);
+ if (refocus) this.focus();
+ }
+
+ public toggle() {
+ if (this.opened()) this.close();
+ else this.open();
+ }
+
+ public focus() {
+ this._element.nativeElement.focus();
+ }
+
+ /** Close on a click outside the panel. A click on the select toggles it */
+ public onOutsideClick(event: MouseEvent) {
+ const target = event.target as Node;
+ if (!this._element.nativeElement.contains(target)) this.close();
+ }
+
+ public onBlur() {
+ if (!this.opened()) this._onTouched();
+ }
+
+ public onKeydown(keyboard_event: Event) {
+ const event = keyboard_event as KeyboardEvent;
+ if (this.is_disabled()) return;
+ const key = event.key;
+ if (!this.opened()) {
+ if (['Enter', ' ', 'ArrowDown', 'ArrowUp'].includes(key)) {
+ event.preventDefault();
+ this.open();
+ }
+ return;
+ }
+ if (key === 'Escape' || key === 'Tab') {
+ if (key === 'Escape') {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ this.close();
+ } else if (key === 'Enter' || (key === ' ' && !this._typing())) {
+ event.preventDefault();
+ this._key_manager?.activeItem?.pickOption();
+ } else {
+ this._key_manager?.onKeydown(event);
+ }
+ }
+
+ public writeValue(value: T) {
+ this.value.set(value);
+ }
+
+ public registerOnChange(fn: (value: T) => void) {
+ this._onChange = fn;
+ }
+
+ public registerOnTouched(fn: () => void) {
+ this._onTouched = fn;
+ }
+
+ public setDisabledState(disabled: boolean) {
+ this._form_disabled.set(disabled);
+ }
+
+ private _setValue(value: T) {
+ this.value.set(value);
+ this._onChange(value);
+ this.selectionChange.emit({ value });
+ }
+
+ /** Whether a typeahead search is in progress, so space is part of it */
+ private _typing() {
+ return !!this._key_manager?.isTyping();
+ }
+}
diff --git a/src/app/ui/controls/spinner.component.ts b/src/app/ui/controls/spinner.component.ts
new file mode 100644
index 000000000..8700c01a2
--- /dev/null
+++ b/src/app/ui/controls/spinner.component.ts
@@ -0,0 +1,58 @@
+import {
+ Component,
+ computed,
+ inject,
+ input,
+ numberAttribute,
+} from '@angular/core';
+
+import { LocaleService } from '../../common/locale.service';
+
+/** Circumference of the circle in the 100 by 100 view box */
+const CIRCUMFERENCE = 2 * Math.PI * 45;
+
+/**
+ * Circular progress indicator.
+ * Without a `value` it spins. With a `value` (0 to 100) it shows the amount.
+ */
+@Component({
+ selector: 'app-spinner',
+ template: `
+
+
+
+ `,
+ host: {
+ class: 'spinner',
+ role: 'progressbar',
+ '[attr.aria-label]': 'label() || default_label',
+ '[style.width.px]': 'diameter()',
+ '[style.height.px]': 'diameter()',
+ '[attr.aria-valuemin]': 'value() === undefined ? null : 0',
+ '[attr.aria-valuemax]': 'value() === undefined ? null : 100',
+ '[attr.aria-valuenow]': 'value() ?? null',
+ },
+})
+export class SpinnerComponent {
+ /** Accessible name. It defaults to "Loading" */
+ public readonly label = input(undefined);
+ public readonly default_label = inject(LocaleService).get('COMMON.LOADING');
+ /** Size of the spinner in pixels */
+ public readonly diameter = input(48, { transform: numberAttribute });
+ /** Progress from 0 to 100. Leave it unset for an indeterminate spinner */
+ public readonly value = input(undefined);
+
+ public readonly circumference = CIRCUMFERENCE;
+ public readonly offset = computed(() => {
+ const value = this.value();
+ if (value === undefined) return null;
+ const clamped = Math.min(100, Math.max(0, value));
+ return CIRCUMFERENCE * (1 - clamped / 100);
+ });
+}
diff --git a/src/app/ui/controls/tabs.component.ts b/src/app/ui/controls/tabs.component.ts
new file mode 100644
index 000000000..970e86403
--- /dev/null
+++ b/src/app/ui/controls/tabs.component.ts
@@ -0,0 +1,55 @@
+import { Component, ElementRef, inject, input, model } from '@angular/core';
+
+/**
+ * Row of tabs. The parent shows the content of the selected tab.
+ * Arrow keys, Home and End move between the tabs.
+ */
+@Component({
+ selector: 'app-tabs',
+ template: `
+ @for (label of labels(); track $index) {
+
+ {{ label }}
+
+ }
+ `,
+ host: {
+ role: 'tablist',
+ class: 'tabs',
+ '(keydown)': 'onKeydown($event)',
+ },
+})
+export class TabsComponent {
+ private readonly _element = inject>(ElementRef);
+
+ /** Text of each tab */
+ public readonly labels = input([]);
+ /** Index of the selected tab */
+ public readonly selectedIndex = model(0);
+
+ public onKeydown(event: KeyboardEvent) {
+ const count = this.labels().length;
+ const current = this.selectedIndex();
+ const next =
+ {
+ ArrowRight: (current + 1) % count,
+ ArrowLeft: (current - 1 + count) % count,
+ Home: 0,
+ End: count - 1,
+ }[event.key] ?? -1;
+ if (next < 0 || !count) return;
+ event.preventDefault();
+ this.selectedIndex.set(next);
+ this._element.nativeElement
+ .querySelectorAll('[role=tab]')
+ [next]?.focus();
+ }
+}
diff --git a/src/app/ui/controls/toast-outlet.component.ts b/src/app/ui/controls/toast-outlet.component.ts
new file mode 100644
index 000000000..244c33174
--- /dev/null
+++ b/src/app/ui/controls/toast-outlet.component.ts
@@ -0,0 +1,92 @@
+import { LiveAnnouncer } from '@angular/cdk/a11y';
+import { Overlay } from '@angular/cdk/overlay';
+import { TemplatePortal } from '@angular/cdk/portal';
+import {
+ Component,
+ DestroyRef,
+ effect,
+ inject,
+ TemplateRef,
+ untracked,
+ viewChild,
+ ViewContainerRef,
+} from '@angular/core';
+import { dismissToast, Toast, toasts } from '../../common/notifications';
+
+/**
+ * Shows the toasts made with `notify()` and its variants.
+ * Put it once in the root component.
+ *
+ * The toasts are in a CDK overlay pane, so they show above open dialogs and
+ * are not hidden from assistive tech while a modal is open. Screen readers
+ * get each message from the live announcer; errors interrupt at once.
+ */
+@Component({
+ selector: 'app-toast-outlet',
+ template: `
+
+
+ @for (toast of list(); track toast.id) {
+
+ {{ toast.message }}
+ @if (toast.action) {
+
+ {{ toast.action }}
+
+ }
+
+ }
+
+
+ `,
+})
+export class ToastOutletComponent {
+ private readonly _overlay = inject(Overlay);
+ private readonly _announcer = inject(LiveAnnouncer);
+ private readonly _panel = viewChild.required('panel', {
+ read: TemplateRef,
+ });
+ private readonly _overlay_ref = this._overlay.create({
+ positionStrategy: this._overlay
+ .position()
+ .global()
+ .centerHorizontally()
+ .bottom('0'),
+ panelClass: 'toast-pane',
+ });
+ /** Id of the newest toast shown so far */
+ private _last_id = -1;
+
+ public readonly list = toasts;
+
+ constructor() {
+ const view_container = inject(ViewContainerRef);
+ effect(() => {
+ const list = this.list();
+ const newest = list[list.length - 1];
+ if (!newest) {
+ this._overlay_ref.detach();
+ return;
+ }
+ if (newest.id === this._last_id) return;
+ this._last_id = newest.id;
+ // Attach again so the pane moves above dialogs opened since
+ if (this._overlay_ref.hasAttached()) this._overlay_ref.detach();
+ this._overlay_ref.attach(
+ new TemplatePortal(this._panel(), view_container),
+ );
+ untracked(() =>
+ this._announcer.announce(
+ newest.message,
+ newest.type === 'error' ? 'assertive' : 'polite',
+ ),
+ );
+ });
+ inject(DestroyRef).onDestroy(() => this._overlay_ref.dispose());
+ }
+
+ public act(toast: Toast) {
+ toast.on_action();
+ dismissToast(toast.id);
+ }
+}
diff --git a/src/app/ui/controls/tooltip.directive.ts b/src/app/ui/controls/tooltip.directive.ts
new file mode 100644
index 000000000..1ece50c2a
--- /dev/null
+++ b/src/app/ui/controls/tooltip.directive.ts
@@ -0,0 +1,309 @@
+import { AriaDescriber, FocusMonitor } from '@angular/cdk/a11y';
+import {
+ ConnectedPosition,
+ FlexibleConnectedPositionStrategy,
+ Overlay,
+ OverlayRef,
+} from '@angular/cdk/overlay';
+import { ComponentPortal } from '@angular/cdk/portal';
+import { ViewportRuler } from '@angular/cdk/scrolling';
+import {
+ Component,
+ ComponentRef,
+ Directive,
+ effect,
+ ElementRef,
+ inject,
+ input,
+ OnDestroy,
+} from '@angular/core';
+import { Subscription } from 'rxjs';
+import {
+ PANEL_GAP,
+ PANEL_SHADOW,
+ TOOLTIP_ARROW,
+ VIEWPORT_MARGIN,
+} from './overlay';
+
+export type TooltipPosition =
+ | 'above'
+ | 'below'
+ | 'left'
+ | 'right'
+ | 'before'
+ | 'after';
+
+/** Least space between the arrow and the ends of the tooltip */
+const ARROW_INSET = 12;
+
+/** Where each position puts the tooltip, without its offset */
+const PLACES: Record = {
+ above: {
+ originX: 'center',
+ originY: 'top',
+ overlayX: 'center',
+ overlayY: 'bottom',
+ },
+ below: {
+ originX: 'center',
+ originY: 'bottom',
+ overlayX: 'center',
+ overlayY: 'top',
+ },
+ left: {
+ originX: 'start',
+ originY: 'center',
+ overlayX: 'end',
+ overlayY: 'center',
+ },
+ right: {
+ originX: 'end',
+ originY: 'center',
+ overlayX: 'start',
+ overlayY: 'center',
+ },
+ before: {
+ originX: 'start',
+ originY: 'center',
+ overlayX: 'end',
+ overlayY: 'center',
+ },
+ after: {
+ originX: 'end',
+ originY: 'center',
+ overlayX: 'start',
+ overlayY: 'center',
+ },
+};
+
+/**
+ * The position with its offset from the trigger: the arrow, then a gap to its
+ * tip. Above the trigger, the shadow under the tip needs room, and so does
+ * `raise`, how far the trigger's top edge rises on hover.
+ */
+function positionOf(
+ position: TooltipPosition,
+ raise: number,
+): ConnectedPosition {
+ const place = PLACES[position];
+ const offset = TOOLTIP_ARROW + PANEL_GAP;
+ switch (sideOf(place)) {
+ case 'above':
+ return {
+ ...place,
+ offsetY: -(offset + PANEL_SHADOW + raise),
+ };
+ case 'below':
+ return { ...place, offsetY: offset };
+ case 'left':
+ return { ...place, offsetX: -offset };
+ default:
+ return { ...place, offsetX: offset };
+ }
+}
+
+/** Opposite side, used when the preferred side has no space */
+const FALLBACK: Record = {
+ above: 'below',
+ below: 'above',
+ left: 'right',
+ right: 'left',
+ before: 'after',
+ after: 'before',
+};
+
+/** Vertical move of a computed CSS transform, e.g. `matrix(1, 0, 0, 1, 0, -1)` */
+function translateY(transform: string): number {
+ const values = /matrix\(([^)]+)\)/.exec(transform)?.[1].split(',');
+ return values ? parseFloat(values[5]) || 0 : 0;
+}
+
+/** Side of the trigger that a tooltip in this position is on */
+function sideOf(
+ position: ConnectedPosition,
+): 'above' | 'below' | 'left' | 'right' {
+ if (position.overlayY === 'bottom' && position.originY === 'top') {
+ return 'above';
+ }
+ if (position.overlayY === 'top' && position.originY === 'bottom') {
+ return 'below';
+ }
+ return position.overlayX === 'end' ? 'left' : 'right';
+}
+
+@Component({
+ selector: 'app-tooltip-panel',
+ template: `{{ text }}`,
+ host: {
+ class: 'tooltip-panel',
+ 'aria-hidden': 'true',
+ '[attr.data-side]': 'side',
+ },
+})
+export class TooltipPanelComponent {
+ public text = '';
+ /** Side of the trigger the tooltip is on, which places its arrow */
+ public side = 'below';
+}
+
+/**
+ * Show a short text next to the element on mouse hover and keyboard focus.
+ * Screen readers get the text as the description of the element.
+ * For rich content use `customTooltip` instead.
+ */
+@Directive({
+ selector: '[appTooltip]',
+ host: {
+ class: 'tooltip-trigger',
+ '(mouseenter)': 'show()',
+ '(mouseleave)': 'hide()',
+ '(pointerdown)': 'hide()',
+ '(keydown.escape)': 'hide()',
+ },
+})
+export class TooltipDirective implements OnDestroy {
+ private readonly _element = inject>(ElementRef);
+ private readonly _overlay = inject(Overlay);
+ private readonly _describer = inject(AriaDescriber);
+ private readonly _focus = inject(FocusMonitor);
+ private readonly _viewport = inject(ViewportRuler);
+ private _resize: Subscription;
+ private _overlay_ref: OverlayRef;
+ private _panel: ComponentRef;
+ private _strategy: FlexibleConnectedPositionStrategy;
+ /** Box of the trigger at rest, when the tooltip was shown */
+ private _origin: { x: number; y: number; width: number; height: number };
+ private _side = 'below';
+ private _described = '';
+
+ /** Text of the tooltip. Nothing shows when it is empty */
+ public readonly text = input('', {
+ alias: 'appTooltip',
+ });
+ public readonly position = input('below', {
+ alias: 'appTooltipPosition',
+ });
+
+ constructor() {
+ effect(() => {
+ const text = this.text() || '';
+ const element = this._element.nativeElement;
+ if (this._described) {
+ this._describer.removeDescription(element, this._described);
+ }
+ if (text) this._describer.describe(element, text);
+ this._described = text;
+ if (this._panel && text) this._setText(text);
+ else if (this._panel) this.hide();
+ });
+ // Show for keyboard focus only, so a click does not leave it open
+ this._focus.monitor(this._element).subscribe((origin) => {
+ if (origin === 'keyboard') this.show();
+ else if (!origin) this.hide();
+ });
+ }
+
+ public show() {
+ const text = this.text();
+ if (!text || this._panel) return;
+ const position = this.position();
+ if (!this._overlay_ref) {
+ const strategy = (this._strategy = this._overlay
+ .position()
+ .flexibleConnectedTo(this._element)
+ .withViewportMargin(VIEWPORT_MARGIN)
+ // Keep its size and push it inside the viewport margin
+ .withFlexibleDimensions(false)
+ .withPush(true));
+ // Point the arrow at the trigger from the side used
+ strategy.positionChanges.subscribe(({ connectionPair }) => {
+ this._side = sideOf(connectionPair);
+ if (!this._panel) return;
+ this._setText(this._panel.instance.text);
+ this._placeArrow();
+ });
+ this._overlay_ref = this._overlay.create({
+ positionStrategy: strategy,
+ scrollStrategy: this._overlay.scrollStrategies.close(),
+ panelClass: 'tooltip-pane',
+ });
+ // The scroll strategy detaches the tooltip without a call to hide
+ this._overlay_ref.detachments().subscribe(() => {
+ this._panel = undefined;
+ this._resize?.unsubscribe();
+ });
+ }
+ // A hovered raised button moves while its transition runs, so place
+ // the tooltip from its box at rest. Only a hovered one has risen.
+ const element = this._element.nativeElement;
+ const style = getComputedStyle(element);
+ const box = element.getBoundingClientRect();
+ this._origin = {
+ x: box.left,
+ y: box.top - translateY(style.transform),
+ width: box.width,
+ height: box.height,
+ };
+ const raise = element.matches(':hover')
+ ? parseFloat(style.getPropertyValue('--hover-raise')) || 0
+ : 0;
+ this._strategy
+ .setOrigin(this._origin)
+ .withPositions([
+ positionOf(position, raise),
+ positionOf(FALLBACK[position], raise),
+ ]);
+ this._side = sideOf(PLACES[position]);
+ this._panel = this._overlay_ref.attach(
+ new ComponentPortal(TooltipPanelComponent),
+ );
+ this._setText(text);
+ this._overlay_ref.updatePosition();
+ this._placeArrow();
+ // The tooltip is placed from the trigger's box when it showed, which
+ // a resize can move, so close it like a scroll does
+ this._resize = this._viewport.change().subscribe(() => this.hide());
+ }
+
+ public hide() {
+ this._overlay_ref?.detach();
+ this._panel = undefined;
+ }
+
+ public ngOnDestroy() {
+ this._resize?.unsubscribe();
+ this._focus.stopMonitoring(this._element);
+ if (this._described) {
+ this._describer.removeDescription(
+ this._element.nativeElement,
+ this._described,
+ );
+ }
+ this._overlay_ref?.dispose();
+ }
+
+ /**
+ * Point the arrow at the centre of the trigger. The tooltip is pushed
+ * inside the viewport near its edges, so its own centre may be off the
+ * trigger. The arrow stays clear of the rounded corners.
+ */
+ private _placeArrow() {
+ const panel: HTMLElement = this._panel?.location.nativeElement;
+ const trigger = this._origin;
+ if (!panel || !trigger) return;
+ const box = panel.getBoundingClientRect();
+ const vertical = this._side === 'above' || this._side === 'below';
+ const centre = vertical
+ ? trigger.x + trigger.width / 2 - box.left
+ : trigger.y + trigger.height / 2 - box.top;
+ const size = vertical ? box.width : box.height;
+ const at = Math.min(Math.max(centre, ARROW_INSET), size - ARROW_INSET);
+ panel.style.setProperty('--arrow-at', `${at}px`);
+ }
+
+ private _setText(text: string) {
+ this._panel.instance.text = text;
+ this._panel.instance.side = this._side;
+ this._panel.changeDetectorRef.detectChanges();
+ }
+}
diff --git a/src/app/ui/counter.component.ts b/src/app/ui/counter.component.ts
index 292a073b2..cb43a7042 100644
--- a/src/app/ui/counter.component.ts
+++ b/src/app/ui/counter.component.ts
@@ -10,20 +10,20 @@ import {
FormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
import { IconComponent } from './icon.component';
import { TranslatePipe } from './translate.pipe';
@Component({
selector: 'a-counter',
template: `
-
+
+
@if (!focused) {
@@ -44,7 +44,7 @@ import { TranslatePipe } from './translate.pipe';
}
to()"
(click)="changeMonth(1)"
@@ -94,12 +94,12 @@ interface DateItem {
@if (today === day.id) {
}
}
diff --git a/src/app/ui/custom-fields/date-field.component.ts b/src/app/ui/custom-fields/date-field.component.ts
index dee36bc20..520c2ba00 100644
--- a/src/app/ui/custom-fields/date-field.component.ts
+++ b/src/app/ui/custom-fields/date-field.component.ts
@@ -29,7 +29,7 @@ import { DateCalendarComponent } from './date-calendar.component';
yPosition="top"
[disabled]="disabled()"
[class.opacity-30]="disabled()"
- matRipple
+ ripple
>
@if (!item.error) {
- = length() - view_space()"
class="bg-base-100 absolute top-1/2 right-0 -translate-y-1/2 transform"
(click)="increment()"
@@ -162,31 +159,15 @@ import { TranslatePipe } from '../translate.pipe';
}
-
-
- @for (item of list(); track item) {
-
- {{ item }}
-
- cancel
-
-
- }
-
-
+
-
+
`,
styles: [
`
@@ -225,15 +206,13 @@ import { TranslatePipe } from '../translate.pipe';
},
],
imports: [
- LoadingLabelDirective,
+ SpinnerComponent,
+ TooltipDirective,
+ FormFieldComponent,
TranslatePipe,
IconComponent,
- MatFormFieldModule,
- MatChipsModule,
- MatRippleModule,
- MatProgressSpinnerModule,
+ ChipListComponent,
AuthenticatedImageDirective,
- MatTooltipModule,
],
})
export class ImageListFieldComponent
@@ -252,7 +231,6 @@ export class ImageListFieldComponent
public readonly offset = signal(0);
public readonly view_space = signal(0);
public readonly disabled = signal(false);
- public readonly separators = [COMMA, ENTER];
public readonly length = computed(
() => this.list().length + this.upload_list().length + 1,
);
@@ -314,13 +292,6 @@ export class ImageListFieldComponent
this.setValue(this.list().filter((_) => _ !== url));
}
- public addImage(event: MatChipInputEvent) {
- if (this.disabled()) return;
- if (!event.value) return;
- this.setValue(unique([...this.list(), event.value]) as string[]);
- event.chipInput.inputElement.value = '';
- }
-
public addImageUrl(url: string) {
if (this.disabled()) return;
this.setValue(unique([...this.list(), url]) as string[]);
diff --git a/src/app/ui/custom-fields/item-search-field.component.ts b/src/app/ui/custom-fields/item-search-field.component.ts
index 3edee2648..81e4cef89 100644
--- a/src/app/ui/custom-fields/item-search-field.component.ts
+++ b/src/app/ui/custom-fields/item-search-field.component.ts
@@ -23,15 +23,17 @@ import {
import { PlaceDriverRole, PlaceModule } from '@placeos/ts-client';
import { NgTemplateOutlet } from '@angular/common';
-import { MatAutocompleteModule } from '@angular/material/autocomplete';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { AsyncHandler } from '../../common/async-handler.class';
import { escapeHtml } from '../../common/general';
import { HashMap } from '../../common/types';
+import {
+ AutocompleteComponent,
+ AutocompleteTriggerDirective,
+} from '../controls/autocomplete.component';
+import { FormFieldComponent } from '../controls/form-field.component';
+import { OptionComponent } from '../controls/option.component';
+import { SpinnerComponent } from '../controls/spinner.component';
import { IconComponent } from '../icon.component';
-import { LoadingLabelDirective } from '../loading-label.directive';
interface SearchItem {
id: string;
@@ -50,9 +52,8 @@ interface SearchItem {
form-field
[class.disabled]="is_disabled()"
>
-
+
-
+
search
@if (loading()) {
-
-
+
}
-
+
@if (display_list()) {
@if (item_list()?.length) {
@for (option of item_list(); track option?.id) {
}
}
-
+
@for (option of item_list(); track option.id) {
-
+
-
+
}
-
+
@@ -159,8 +155,7 @@ interface SearchItem {
`,
styles: [
`
- :host,
- mat-form-field {
+ :host {
width: 100%;
}
@@ -190,12 +185,12 @@ interface SearchItem {
},
],
imports: [
- LoadingLabelDirective,
- MatAutocompleteModule,
- MatFormFieldModule,
- MatInputModule,
+ SpinnerComponent,
+ OptionComponent,
+ FormFieldComponent,
+ AutocompleteComponent,
+ AutocompleteTriggerDirective,
FormsModule,
- MatProgressSpinnerModule,
IconComponent,
NgTemplateOutlet,
],
@@ -334,6 +329,14 @@ export class ItemSearchFieldComponent
);
}
+ /** Select the item that the user picked in the autocomplete */
+ public pickOption(value: unknown) {
+ const item = this.item_list().find((option) => option === value);
+ if (!item) return;
+ this.search_str.set(item.name || '' + item.id);
+ this.setValue(item);
+ }
+
/**
* Update the form field value
* @param new_value New value to set on the form field
diff --git a/src/app/ui/custom-fields/object-list-field.component.ts b/src/app/ui/custom-fields/object-list-field.component.ts
index 564facf61..97804ff02 100644
--- a/src/app/ui/custom-fields/object-list-field.component.ts
+++ b/src/app/ui/custom-fields/object-list-field.component.ts
@@ -5,10 +5,8 @@ import {
NG_VALUE_ACCESSOR,
} from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
import { HashMap } from '../../common/types';
+import { FormFieldComponent } from '../controls/form-field.component';
import { IconComponent } from '../icon.component';
import { TranslatePipe } from '../translate.pipe';
@@ -31,9 +29,8 @@ import { TranslatePipe } from '../translate.pipe';
@for (field of fields(); track field) {
}
{{ 'COMMON.EXECUTE_PERFORM' | translate }}
@@ -81,7 +79,7 @@ import { ModuleLike, SelectModuleComponent } from './select-module.component';
class="absolute -inset-2 flex flex-col items-center justify-center rounded-sm"
>
-
+
{{ 'COMMON.EXECUTE_LOADING' | translate }}
}
@@ -96,10 +94,8 @@ import { ModuleLike, SelectModuleComponent } from './select-module.component';
},
],
imports: [
- LoadingLabelDirective,
+ SpinnerComponent,
TranslatePipe,
- MatProgressSpinnerModule,
- MatRippleModule,
FormsModule,
SelectModuleComponent,
SelectMethodComponent,
@@ -107,7 +103,7 @@ import { ModuleLike, SelectModuleComponent } from './select-module.component';
],
})
export class ExecuteMethodFieldComponent implements ControlValueAccessor {
- private _dialog = inject(MatDialog);
+ private _dialog = inject(Dialog);
public readonly zone = input(undefined);
/** ID of the system to select the module from */
@@ -267,7 +263,7 @@ export class ExecuteMethodFieldComponent implements ControlValueAccessor {
/** View Results of the execute */
private async viewDetails(details: unknown) {
- this._dialog.open(
+ this._dialog.open(
ViewResponseModalComponent,
{
data: {
diff --git a/src/app/ui/custom-fields/system-exec/function-argument.component.ts b/src/app/ui/custom-fields/system-exec/function-argument.component.ts
index f02e024a7..2954d2ec4 100644
--- a/src/app/ui/custom-fields/system-exec/function-argument.component.ts
+++ b/src/app/ui/custom-fields/system-exec/function-argument.component.ts
@@ -13,10 +13,9 @@ import {
FormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
import { PlaceModuleFunction } from '@placeos/ts-client';
import { HashMap } from '../../../common/types';
+import { FormFieldComponent } from '../../controls/form-field.component';
import { TranslatePipe } from '../../translate.pipe';
const validateType = (type: string, raw_value: unknown) => {
@@ -55,12 +54,8 @@ const validateType = (type: string, raw_value: unknown) => {
[class.h-14]="i !== 0"
>
-
+
{
(defaults[key] ? ' = ' + defaults[key] : '')
"
/>
-
+
{
multi: true,
},
],
- imports: [TranslatePipe, MatFormFieldModule, MatInputModule, FormsModule],
+ imports: [FormFieldComponent, TranslatePipe, FormsModule],
})
export class FunctionArgumentComponent
implements OnChanges, ControlValueAccessor
diff --git a/src/app/ui/custom-fields/system-exec/select-method.component.ts b/src/app/ui/custom-fields/system-exec/select-method.component.ts
index 6d1396c44..b293c6fc8 100644
--- a/src/app/ui/custom-fields/system-exec/select-method.component.ts
+++ b/src/app/ui/custom-fields/system-exec/select-method.component.ts
@@ -17,17 +17,16 @@ import {
FormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatSelectModule } from '@angular/material/select';
import {
functionList,
PlaceModuleFunction,
PlaceSystem,
} from '@placeos/ts-client';
import { AsyncHandler } from '../../../common/async-handler.class';
-import { LoadingLabelDirective } from '../../loading-label.directive';
+import { FormFieldComponent } from '../../controls/form-field.component';
+import { OptionComponent } from '../../controls/option.component';
+import { SelectComponent } from '../../controls/select.component';
+import { SpinnerComponent } from '../../controls/spinner.component';
import { TranslatePipe } from '../../translate.pipe';
import { ModuleLike } from './select-module.component';
@@ -40,8 +39,8 @@ interface MethodOption extends PlaceModuleFunction {
template: `
@if (!loading()) {
@if (method_list().length) {
-
-
+
-
+
-
+
+
+ {{
+ 'COMMON.SEARCH_EMPTY'
+ | translate: { name: 'methods' }
+ }}
+
@for (method of filtered_method_list(); track method) {
-
+
{{ method.name }}
-
+
}
-
-
+
+
} @else {
@@ -82,24 +86,11 @@ interface MethodOption extends PlaceModuleFunction {
}
} @else {
-
+
{{ 'COMMON.EXECUTE_METHOD_LOADING' | translate }}
}
`,
- styles: [
- `
- .method-search-input {
- width: 100%;
- outline: none;
- }
-
- .method-search-input::placeholder {
- color: var(--base-400, currentColor);
- opacity: 0.65;
- }
- `,
- ],
providers: [
{
provide: NG_VALUE_ACCESSOR,
@@ -108,12 +99,11 @@ interface MethodOption extends PlaceModuleFunction {
},
],
imports: [
- LoadingLabelDirective,
- MatProgressSpinnerModule,
+ SpinnerComponent,
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
TranslatePipe,
- MatFormFieldModule,
- MatInputModule,
- MatSelectModule,
FormsModule,
],
})
diff --git a/src/app/ui/custom-fields/system-exec/select-module.component.ts b/src/app/ui/custom-fields/system-exec/select-module.component.ts
index fdf9c4d28..4234eae39 100644
--- a/src/app/ui/custom-fields/system-exec/select-module.component.ts
+++ b/src/app/ui/custom-fields/system-exec/select-module.component.ts
@@ -17,14 +17,13 @@ import {
FormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatSelectModule } from '@angular/material/select';
import { PlaceSystem, queryModules } from '@placeos/ts-client';
import { calculateModuleIndex } from '../../../common/api';
import { AsyncHandler } from '../../../common/async-handler.class';
-import { LoadingLabelDirective } from '../../loading-label.directive';
+import { FormFieldComponent } from '../../controls/form-field.component';
+import { OptionComponent } from '../../controls/option.component';
+import { SelectComponent } from '../../controls/select.component';
+import { SpinnerComponent } from '../../controls/spinner.component';
import { TranslatePipe } from '../../translate.pipe';
export interface ModuleLike {
@@ -38,58 +37,49 @@ export interface ModuleLike {
selector: `select-system-module`,
template: `
@if (!loading()) {
-
-
+
-
+
-
+
+
+ {{
+ 'COMMON.SEARCH_EMPTY'
+ | translate: { name: 'modules' }
+ }}
+
@for (mod of filtered_modules(); track mod) {
-
+
{{ mod.module }} {{ mod.index }}
-
+
}
-
-
+
+
} @else {
-
+
{{ 'COMMON.EXECUTE_MODULE_LOADING' | translate }}
}
`,
- styles: [
- `
- .module-search-input {
- width: 100%;
- outline: none;
- }
-
- .module-search-input::placeholder {
- color: var(--base-400, currentColor);
- opacity: 0.65;
- }
- `,
- ],
providers: [
{
provide: NG_VALUE_ACCESSOR,
@@ -98,11 +88,10 @@ export interface ModuleLike {
},
],
imports: [
- LoadingLabelDirective,
- MatProgressSpinnerModule,
- MatFormFieldModule,
- MatInputModule,
- MatSelectModule,
+ SpinnerComponent,
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
TranslatePipe,
FormsModule,
],
diff --git a/src/app/ui/custom-fields/time-field.component.ts b/src/app/ui/custom-fields/time-field.component.ts
index 045fb5a38..b4e8a12ff 100644
--- a/src/app/ui/custom-fields/time-field.component.ts
+++ b/src/app/ui/custom-fields/time-field.component.ts
@@ -15,10 +15,6 @@ import {
FormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms';
-import { MatRippleModule } from '@angular/material/core';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatSelect, MatSelectModule } from '@angular/material/select';
import {
addMinutes,
endOfDay,
@@ -32,18 +28,19 @@ import {
} from 'date-fns';
import { AsyncHandler } from '../../common/async-handler.class';
import { Identity } from '../../common/types';
+import { FormFieldComponent } from '../controls/form-field.component';
+import { OptionComponent } from '../controls/option.component';
+import { SelectComponent } from '../controls/select.component';
import { IconComponent } from '../icon.component';
@Component({
selector: 'a-time-field',
template: `
-
@@ -61,28 +58,29 @@ import { IconComponent } from '../icon.component';
arrow_drop_down
-
-
+
+
@if (show_select()) {
-
-
+
@for (option of time_options; track option) {
-
+
{{ option.name }}
-
+
}
-
-
+
+
}
`,
styles: [
`
- mat-form-field {
+ app-form-field {
width: 100%;
}
`,
@@ -95,12 +93,11 @@ import { IconComponent } from '../icon.component';
},
],
imports: [
- MatFormFieldModule,
- MatSelectModule,
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
IconComponent,
- MatRippleModule,
FormsModule,
- MatInputModule,
],
})
export class TimeFieldComponent
@@ -129,7 +126,7 @@ export class TimeFieldComponent
private _onTouch: (_: number) => void;
/** Select field for selecting the time */
- private readonly select_field = viewChild
('select');
+ private readonly select_field = viewChild('select');
public ngOnInit(): void {
this.show_select.set(true);
@@ -236,14 +233,6 @@ export class TimeFieldComponent
if (select_field) {
select_field.focus();
select_field.open();
- this.subscription(
- 'listen_close',
- select_field.openedChange.subscribe((state) => {
- if (!state) {
- this.show_select.set(false);
- }
- }),
- );
}
});
}
diff --git a/src/app/ui/custom-tooltip.component.ts b/src/app/ui/custom-tooltip.component.ts
index a3b23645c..769b0ec26 100644
--- a/src/app/ui/custom-tooltip.component.ts
+++ b/src/app/ui/custom-tooltip.component.ts
@@ -19,6 +19,7 @@ import {
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { AsyncHandler } from '../common/async-handler.class';
+import { PANEL_GAP, VIEWPORT_MARGIN } from './controls/overlay';
/** Injected into tooltip content so it can close its tooltip */
export abstract class CustomTooltipData {
@@ -139,8 +140,16 @@ export class CustomTooltipComponent
: y_position) || default_y,
overlayX: this.xPosition() || default_x,
overlayY: this.yPosition() || default_y,
+ // A gap, so the edges of the panel and trigger do not merge
+ offsetY:
+ y_position === 'top'
+ ? PANEL_GAP
+ : y_position === 'bottom'
+ ? -PANEL_GAP
+ : 0,
},
- ]),
+ ])
+ .withViewportMargin(VIEWPORT_MARGIN),
});
this._overlay_ref.attach(_portal);
// Close like a menu: on Escape and when the page changes
diff --git a/src/app/ui/debug-info.component.ts b/src/app/ui/debug-info.component.ts
index 2e25d5f3a..c646b54a2 100644
--- a/src/app/ui/debug-info.component.ts
+++ b/src/app/ui/debug-info.component.ts
@@ -1,8 +1,8 @@
+import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
import { Component, inject, input } from '@angular/core';
-import { MatRippleModule } from '@angular/material/core';
-import { MatMenuModule } from '@angular/material/menu';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { PlaceDebugService } from '../common/debug.service';
+import { MENU_POSITIONS } from './controls/overlay';
+import { TooltipDirective } from './controls/tooltip.directive';
import { IconComponent } from './icon.component';
import { TranslatePipe } from './translate.pipe';
@@ -16,20 +16,20 @@ import { TranslatePipe } from './translate.pipe';
@if (compact()) {
bug_report
sdk
business_messages
more_horiz
-
-
- {{
- debug_position() === 'side'
- ? 'border_bottom'
- : 'border_right'
- }}
- Toggle Position
-
-
- clear_all
- Clear Messages
-
-
- hearing_disabled
- Unbind Modules
-
-
- open_in_browser
- Open Console
-
-
+
+
+
+ {{
+ debug_position() === 'side'
+ ? 'border_bottom'
+ : 'border_right'
+ }}
+ Toggle Position
+
+
+ clear_all
+ Clear Messages
+
+
+ hearing_disabled
+ Unbind Modules
+
+
+ open_in_browser
+ Open Console
+
+
+
} @else {
- {{
+ {{
debug_position() === 'side'
? 'border_bottom'
: 'border_right'
@@ -127,23 +130,18 @@ import { TranslatePipe } from './translate.pipe';
- clear_all
+ clear_all
-
-
+ hearing_disabled
-
-
+ open_in_browser
@@ -154,14 +152,17 @@ import { TranslatePipe } from './translate.pipe';
`,
styles: [],
imports: [
+ TooltipDirective,
IconComponent,
TranslatePipe,
- MatRippleModule,
- MatTooltipModule,
- MatMenuModule,
+ CdkMenu,
+ CdkMenuItem,
+ CdkMenuTrigger,
],
})
export class DebugInfoComponent {
+ /** Menu positions with a gap from the trigger */
+ public readonly menu_positions = MENU_POSITIONS;
private _debug = inject(PlaceDebugService);
public readonly compact = input(false);
diff --git a/src/app/ui/debug-output.component.ts b/src/app/ui/debug-output.component.ts
index b86001a8e..2a53bb282 100644
--- a/src/app/ui/debug-output.component.ts
+++ b/src/app/ui/debug-output.component.ts
@@ -6,12 +6,11 @@ import {
signal,
viewChild,
} from '@angular/core';
-import { MatRippleModule } from '@angular/material/core';
-import { MatTooltipModule } from '@angular/material/tooltip';
import { AsyncHandler } from '../common/async-handler.class';
import { PlaceDebugService } from '../common/debug.service';
import { eventToPoint } from '../common/general';
import { Point } from '../common/types';
+import { TooltipDirective } from './controls/tooltip.directive';
import { IconComponent } from './icon.component';
import { NewTerminalComponent } from './new-terminal.component';
import { TranslatePipe } from './translate.pipe';
@@ -75,9 +74,9 @@ import { TranslatePipe } from './translate.pipe';
actions
class="absolute right-2 bottom-2 flex items-center gap-1"
>
-
+
@@ -87,11 +86,11 @@ import { TranslatePipe } from './translate.pipe';
{{
@@ -104,11 +103,11 @@ import { TranslatePipe } from './translate.pipe';
@@ -118,9 +117,9 @@ import { TranslatePipe } from './translate.pipe';
@@ -129,9 +128,9 @@ import { TranslatePipe } from './translate.pipe';
@@ -184,10 +183,9 @@ import { TranslatePipe } from './translate.pipe';
`,
],
imports: [
+ TooltipDirective,
IconComponent,
TranslatePipe,
- MatTooltipModule,
- MatRippleModule,
NewTerminalComponent,
],
})
diff --git a/src/app/ui/dialog-title.directive.ts b/src/app/ui/dialog-title.directive.ts
index 0d487664c..15ba6b8e0 100644
--- a/src/app/ui/dialog-title.directive.ts
+++ b/src/app/ui/dialog-title.directive.ts
@@ -1,12 +1,11 @@
+import { CdkDialogContainer, DialogRef } from '@angular/cdk/dialog';
import { Directive, inject, OnDestroy, OnInit } from '@angular/core';
-import { MatDialogRef } from '@angular/material/dialog';
let next_id = 0;
/**
* Use the element as the accessible name of the dialog it is in.
- * Put it on the dialog heading. It does the same as `mat-dialog-title`
- * without the Material title styles. Outside a dialog it does nothing.
+ * Put it on the dialog heading. Outside a dialog it does nothing.
*/
@Directive({
selector: '[appDialogTitle]',
@@ -14,11 +13,11 @@ let next_id = 0;
})
export class DialogTitleDirective implements OnInit, OnDestroy {
public readonly id = `app-dialog-title-${next_id++}`;
- private readonly _container = inject(MatDialogRef, { optional: true })
- ?._containerInstance;
+ private readonly _container = inject(DialogRef, { optional: true })
+ ?.containerInstance as CdkDialogContainer | undefined;
- // Deferred like MatDialogTitle, so the dialog's host binding does not
- // change while it is being checked
+ // Deferred so the dialog's host binding does not change while it is
+ // being checked
public ngOnInit() {
Promise.resolve().then(() =>
this._container?._addAriaLabelledBy(this.id),
diff --git a/src/app/ui/forms/ldap-source-form.component.ts b/src/app/ui/forms/ldap-source-form.component.ts
index cf6cb4bf1..a08788ab6 100644
--- a/src/app/ui/forms/ldap-source-form.component.ts
+++ b/src/app/ui/forms/ldap-source-form.component.ts
@@ -1,43 +1,42 @@
import { Component, input } from '@angular/core';
import { FieldTree, FormField } from '@angular/forms/signals';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatSelectModule } from '@angular/material/select';
import { Identity } from '../../common/types';
import { LDAPSourceFormModel } from '../../domains/auth-sources.utilities';
+import { FormFieldComponent } from '../controls/form-field.component';
+import { OptionComponent } from '../controls/option.component';
+import { SelectComponent } from '../controls/select.component';
import { TranslatePipe } from '../translate.pipe';
@Component({
selector: 'ldap-source-form',
template: `
@if (form()) {
-
}
@@ -98,15 +95,14 @@ import { TranslatePipe } from '../translate.pipe';
'DOMAINS.LDAP_USER_ID' | translate
}}:
-
+
-
+
}
@if (form().auth_method) {
@@ -114,17 +110,15 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.LDAP_AUTH_METHOD' | translate }}:
-
-
+
+
@for (type of auth_methods; track type) {
-
+
{{ type.name }}
-
+
}
-
-
+
+
}
@@ -141,23 +135,22 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.LDAP_BASE' | translate
}}
* :
-
+
@if (form().base().invalid()) {
-
+
{{
'DOMAINS.LDAP_BASE_REQUIRED'
| translate
}}
-
+
}
-
+
}
@if (form().bind_dn) {
@@ -167,15 +160,14 @@ import { TranslatePipe } from '../translate.pipe';
'DOMAINS.LDAP_BIND_DN' | translate
}}:
-
+
-
+
}
@@ -185,15 +177,14 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'COMMON.PASSWORD' | translate }}:
-
+
-
+
}
@if (form().filter) {
@@ -201,15 +192,14 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.LDAP_FILTER' | translate }}:
-
+
-
+
}
@@ -224,9 +214,9 @@ import { TranslatePipe } from '../translate.pipe';
`,
],
imports: [
- MatFormFieldModule,
- MatInputModule,
- MatSelectModule,
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
FormField,
TranslatePipe,
],
diff --git a/src/app/ui/forms/oauth-source-form.component.ts b/src/app/ui/forms/oauth-source-form.component.ts
index cdc0df993..13fa95ac5 100644
--- a/src/app/ui/forms/oauth-source-form.component.ts
+++ b/src/app/ui/forms/oauth-source-form.component.ts
@@ -9,11 +9,11 @@ import {
import { FormsModule } from '@angular/forms';
import { FieldTree, FormField } from '@angular/forms/signals';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
-import { MatSelectModule } from '@angular/material/select';
import { HashMap, Identity } from '../../common/types';
import { OAuthSourceFormModel } from '../../domains/auth-sources.utilities';
+import { FormFieldComponent } from '../controls/form-field.component';
+import { OptionComponent } from '../controls/option.component';
+import { SelectComponent } from '../controls/select.component';
import { ObjectListFieldComponent } from '../custom-fields/object-list-field.component';
import { TranslatePipe } from '../translate.pipe';
@@ -33,21 +33,20 @@ import { TranslatePipe } from '../translate.pipe';
>
{{ 'COMMON.FIELD_NAME' | translate }}
* :
-
+
@if (form().name().invalid()) {
-
+
{{
'DOMAINS.AUTHENTICATION_NAME_REQUIRED'
| translate
}}
-
+
}
-
+
}
@@ -56,15 +55,14 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.CLIENT_ID' | translate }}:
-
+
-
+
}
@if (form().client_secret) {
@@ -72,15 +70,14 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.CLIENT_SECRET' | translate }}:
-
+
-
+
}
@@ -90,16 +87,15 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.OAUTH_SITE' | translate }}:
-
+
-
+
}
@if (form().scope) {
@@ -109,15 +105,14 @@ import { TranslatePipe } from '../translate.pipe';
'DOMAINS.OAUTH_SCOPES' | translate
}}:
-
+
-
+
}
@@ -127,15 +122,15 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.OAUTH_TOKEN_METHOD' | translate }}:
-
-
+
+
@for (type of token_methods; track type) {
-
+
{{ type.name }}
-
+
}
-
-
+
+
}
@if (form().auth_scheme) {
@@ -143,20 +138,20 @@ import { TranslatePipe } from '../translate.pipe';
Authentication Scheme:
-
-
+
+
@for (type of auth_schemes; track type) {
-
+
{{
(type.id === 'request_body'
? 'DOMAINS.OAUTH_SCHEME_BODY'
: 'DOMAINS.OAUTH_SCHEME_BASIC'
) | translate
}}
-
+
}
-
-
+
+
}
@@ -165,15 +160,14 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.OAUTH_TOKEN_URL' | translate }}:
-
+
-
+
}
}
@if (form().raw_info_url) {
@@ -203,15 +196,14 @@ import { TranslatePipe } from '../translate.pipe';
'DOMAINS.OAUTH_PROFILE_URL' | translate
}}:
-
+
-
+
}
@@ -287,13 +279,13 @@ import { TranslatePipe } from '../translate.pipe';
`,
],
imports: [
+ OptionComponent,
+ SelectComponent,
+ FormFieldComponent,
ObjectListFieldComponent,
FormsModule,
TranslatePipe,
FormField,
- MatFormFieldModule,
- MatInputModule,
- MatSelectModule,
],
})
export class OauthSourceFormComponent implements OnChanges {
diff --git a/src/app/ui/forms/saml-source-form.component.ts b/src/app/ui/forms/saml-source-form.component.ts
index d79727cb8..e9a93f0ff 100644
--- a/src/app/ui/forms/saml-source-form.component.ts
+++ b/src/app/ui/forms/saml-source-form.component.ts
@@ -8,12 +8,11 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FieldTree, FormField } from '@angular/forms/signals';
-import { MatFormFieldModule } from '@angular/material/form-field';
-import { MatInputModule } from '@angular/material/input';
import { AsyncHandler } from '../../common/async-handler.class';
import { HashMap } from '../../common/types';
import { SAMLSourceFormModel } from '../../domains/auth-sources.utilities';
+import { FormFieldComponent } from '../controls/form-field.component';
import { ObjectListFieldComponent } from '../custom-fields/object-list-field.component';
import { TranslatePipe } from '../translate.pipe';
@@ -35,23 +34,22 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'COMMON.FIELD_NAME' | translate
}}* :
-
+
@if (form().name().invalid()) {
-
+
{{
'DOMAINS.AUTHENTICATION_NAME_REQUIRED'
| translate
}}
-
+
}
-
+
}
@if (form().issuer) {
@@ -66,23 +64,22 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.SAML_ISSUER' | translate
}}* :
-
+
@if (form().issuer().invalid()) {
-
+
{{
'DOMAINS.SAML_ISSUER_REQUIRED'
| translate
}}
-
+
}
-
+
}
@@ -99,9 +96,8 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.SAML_IDP_TARGET_URL' | translate
}}* :
-
+
@if (form().idp_sso_target_url().invalid()) {
-
+
{{
'DOMAINS.SAML_IDP_TARGET_URL_REQUIRED'
| translate
}}
-
+
}
-
+
}
@if (form().name_identifier_format) {
@@ -130,9 +126,8 @@ import { TranslatePipe } from '../translate.pipe';
>
{{ 'DOMAINS.SAML_NAME_ID_FORMAT' | translate }}:
-
+
+
{{
'DOMAINS.SAML_NAME_ID_FORMAT_REQUIRED'
| translate
}}
-
+
}
-
+
}
@@ -198,9 +193,8 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.SAML_ASSERTION_URL' | translate
}}* :
-
+
+
{{
'DOMAINS.SAML_ASSERTION_URL_REQUIRED'
| translate
}}
-
+
}
-
+
}
@if (form().idp_cert_fingerprint) {
@@ -230,16 +224,15 @@ import { TranslatePipe } from '../translate.pipe';
'DOMAINS.SAML_CERT_FINGERPRINT' | translate
}}:
-
+
-
+
}
@@ -248,15 +241,14 @@ import { TranslatePipe } from '../translate.pipe';
{{ 'DOMAINS.SAML_CERT_FULL' | translate }}:
-
+
-
+
}
}
@if (form().attribute_service_name) {
@@ -286,16 +277,15 @@ import { TranslatePipe } from '../translate.pipe';
| translate
}}:
-
+
-
+
}
@@ -339,16 +329,15 @@ import { TranslatePipe } from '../translate.pipe';
| translate
}}:
-
+
-
+
}
@if (form().slo_default_relay_state) {
@@ -359,16 +348,15 @@ import { TranslatePipe } from '../translate.pipe';
| translate
}}:
-
+
-
+
}
@@ -390,12 +378,11 @@ import { TranslatePipe } from '../translate.pipe';
`,
],
imports: [
- MatFormFieldModule,
+ FormFieldComponent,
FormField,
TranslatePipe,
FormsModule,
ObjectListFieldComponent,
- MatInputModule,
],
})
export class SamlSourceFormComponent extends AsyncHandler implements OnChanges {
diff --git a/src/app/ui/forms/settings-form.component.ts b/src/app/ui/forms/settings-form.component.ts
index dd5a77786..e1881d154 100644
--- a/src/app/ui/forms/settings-form.component.ts
+++ b/src/app/ui/forms/settings-form.component.ts
@@ -27,16 +27,14 @@ import { validateYAML } from '../../systems/systems.utilities';
import { BackofficeUsersService } from '../../users/users.service';
import { AsyncPipe, DatePipe } from '@angular/common';
-import { MatRippleModule } from '@angular/material/core';
-import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
-import { MatTabsModule } from '@angular/material/tabs';
-import { MatTooltipModule } from '@angular/material/tooltip';
import * as yaml from 'js-yaml';
import { readError } from '../../common/errors';
import { i18n } from '../../common/locale.service';
+import { SpinnerComponent } from '../controls/spinner.component';
+import { TabsComponent } from '../controls/tabs.component';
+import { TooltipDirective } from '../controls/tooltip.directive';
import { SettingsFieldComponent } from '../custom-fields/settings-field.component';
import { IconComponent } from '../icon.component';
-import { LoadingLabelDirective } from '../loading-label.directive';
import { DateFromPipe } from '../pipes/date-from.pipe';
import { UserPipe } from '../pipes/user.pipe';
import { TranslatePipe } from '../translate.pipe';
@@ -61,32 +59,32 @@ type SettingsArray = [
icon
default
error
- matRipple
+ ripple
[disabled]="edited_count() <= 0"
- [matTooltip]="'COMMON.CLEAR' | translate"
+ [appTooltip]="'COMMON.CLEAR' | translate"
(click)="clearChanges()"
>
@if (!saving()[shown_option().id]) {
delete_sweep
} @else {
-
+
}
@if (!saving()[shown_option().id]) {
save
} @else {
-
+
}
@@ -94,24 +92,14 @@ type SettingsArray = [
@if (used_settings() && used_settings().length) {