From 239884275d17953dd3d61532cf1b88fe3eb1c6c1 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 6 Jan 2026 12:47:39 +0100 Subject: [PATCH] Menu (#1275) * Menu * Move to components. --- backend/i18n/frontend_de.json | 3 + backend/i18n/frontend_en.json | 3 + backend/i18n/frontend_fr.json | 3 + backend/i18n/frontend_it.json | 3 + backend/i18n/frontend_nl.json | 3 + backend/i18n/frontend_pt.json | 3 + backend/i18n/frontend_zh.json | 3 + backend/i18n/source/frontend_en.json | 3 + frontend/.storybook/main.js | 1 + .../shared/forms/content-field.component.html | 51 +++---- .../shared/forms/content-field.component.ts | 11 +- .../shared/forms/field-editor.component.html | 84 +++++------ .../shared/forms/field-editor.component.scss | 19 +-- .../shared/forms/field-editor.component.ts | 17 ++- .../angular/forms/confirm-click.directive.ts | 5 +- .../angular/menu-item.component.html | 30 ++++ .../angular/menu-item.component.scss | 8 ++ .../framework/angular/menu-item.component.ts | 65 +++++++++ .../app/framework/angular/menu.component.html | 29 ++++ .../app/framework/angular/menu.component.scss | 24 ++++ .../app/framework/angular/menu.component.ts | 87 ++++++++++++ .../src/app/framework/angular/menu.stories.ts | 130 ++++++++++++++++++ frontend/src/app/framework/index.ts | 2 + 23 files changed, 486 insertions(+), 101 deletions(-) create mode 100644 frontend/src/app/framework/angular/menu-item.component.html create mode 100644 frontend/src/app/framework/angular/menu-item.component.scss create mode 100644 frontend/src/app/framework/angular/menu-item.component.ts create mode 100644 frontend/src/app/framework/angular/menu.component.html create mode 100644 frontend/src/app/framework/angular/menu.component.scss create mode 100644 frontend/src/app/framework/angular/menu.component.ts create mode 100644 frontend/src/app/framework/angular/menu.stories.ts diff --git a/backend/i18n/frontend_de.json b/backend/i18n/frontend_de.json index 13d657f05..38b268162 100644 --- a/backend/i18n/frontend_de.json +++ b/backend/i18n/frontend_de.json @@ -270,6 +270,7 @@ "common.event": "Ereignis", "common.events": "Ereignisse", "common.executed": "Ausgeführt", + "common.expand": "Expand", "common.expertMode": "Expertenmodus", "common.extension": "Erweiterung", "common.failed": "Fehlgeschlagen", @@ -465,7 +466,9 @@ "contents.draftNew": "Neuer Entwurf", "contents.draftStatus": "Neue Version", "contents.editPageTitle": "Inhalt bearbeiten", + "contents.fieldAIMenu": "Generate with AI", "contents.fieldFullscreen": "Auf dieses Feld fokussieren und Vollbildmodus umschalten.", + "contents.fieldFullscreenMenu": "Fullscreen mode", "contents.idPlaceholder": "Definieren Sie eine benutzerdefinierte ID oder lassen Sie das Feld leer, damit Squidex eine generiert.", "contents.inspectContent": "Inhalt", "contents.inspectData": "Daten", diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 641e90f08..e4d52db8c 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -270,6 +270,7 @@ "common.event": "Event", "common.events": "Events", "common.executed": "Executed", + "common.expand": "Expand", "common.expertMode": "Expert Mode", "common.extension": "Extension", "common.failed": "Failed", @@ -465,7 +466,9 @@ "contents.draftNew": "New Draft", "contents.draftStatus": "New Version", "contents.editPageTitle": "Edit Content", + "contents.fieldAIMenu": "Generate with AI", "contents.fieldFullscreen": "Focus on this field and toggle fullscreen mode.", + "contents.fieldFullscreenMenu": "Fullscreen mode", "contents.idPlaceholder": "Define a custom ID or leave empty to let Squidex generate one.", "contents.inspectContent": "Content", "contents.inspectData": "Data", diff --git a/backend/i18n/frontend_fr.json b/backend/i18n/frontend_fr.json index 3ace078c7..3da5bd9d1 100644 --- a/backend/i18n/frontend_fr.json +++ b/backend/i18n/frontend_fr.json @@ -270,6 +270,7 @@ "common.event": "Événement", "common.events": "Événements", "common.executed": "Réalisé", + "common.expand": "Expand", "common.expertMode": "Mode expert", "common.extension": "Extension", "common.failed": "Échoué", @@ -465,7 +466,9 @@ "contents.draftNew": "Nouveau brouillon", "contents.draftStatus": "Nouvelle version", "contents.editPageTitle": "Modifier le contenu", + "contents.fieldAIMenu": "Generate with AI", "contents.fieldFullscreen": "Concentrez-vous sur ce champ et basculez en mode plein écran.", + "contents.fieldFullscreenMenu": "Fullscreen mode", "contents.idPlaceholder": "Définissez un ID personnalisé ou laissez vide pour laisser Squidex en générer un.", "contents.inspectContent": "Contenu", "contents.inspectData": "Données", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index f6e3d292d..e088d7075 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -270,6 +270,7 @@ "common.event": "Evento", "common.events": "Eventi", "common.executed": "Eseguito", + "common.expand": "Expand", "common.expertMode": "Modalità esperto", "common.extension": "Extension", "common.failed": "Fallito", @@ -465,7 +466,9 @@ "contents.draftNew": "Nuova bozza", "contents.draftStatus": "Nuova versione", "contents.editPageTitle": "Modifica contenuto", + "contents.fieldAIMenu": "Generate with AI", "contents.fieldFullscreen": "Focus on this field and toggle fullscreen mode.", + "contents.fieldFullscreenMenu": "Fullscreen mode", "contents.idPlaceholder": "Define a custom ID or leave empty to let Squidex generate one.", "contents.inspectContent": "Content", "contents.inspectData": "Data", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 748e73fd9..4f850e5cd 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -270,6 +270,7 @@ "common.event": "Evenement", "common.events": "Evenementen", "common.executed": "Uitgevoerd", + "common.expand": "Expand", "common.expertMode": "Expert-modus", "common.extension": "Uitbreiding", "common.failed": "Mislukt", @@ -465,7 +466,9 @@ "contents.draftNew": "Nieuw concept", "contents.draftStatus": "Nieuwe versie", "contents.editPageTitle": "Inhoud bewerken", + "contents.fieldAIMenu": "Generate with AI", "contents.fieldFullscreen": "Focus on this field and toggle fullscreen mode.", + "contents.fieldFullscreenMenu": "Fullscreen mode", "contents.idPlaceholder": "Definieer een aangepaste ID of laat leeg om Squidex er een te laten genereren.", "contents.inspectContent": "Inhoud", "contents.inspectData": "Data", diff --git a/backend/i18n/frontend_pt.json b/backend/i18n/frontend_pt.json index 644008a9a..c5667a5af 100644 --- a/backend/i18n/frontend_pt.json +++ b/backend/i18n/frontend_pt.json @@ -270,6 +270,7 @@ "common.event": "Evento", "common.events": "Eventos", "common.executed": "Executado", + "common.expand": "Expand", "common.expertMode": "Modo Especialista", "common.extension": "Extensão", "common.failed": "Falhou", @@ -465,7 +466,9 @@ "contents.draftNew": "Novo Esboço", "contents.draftStatus": "Nova versão", "contents.editPageTitle": "Editar Conteúdo", + "contents.fieldAIMenu": "Generate with AI", "contents.fieldFullscreen": "Concentre-se neste campo e altere o modo de ecrã completo.", + "contents.fieldFullscreenMenu": "Fullscreen mode", "contents.idPlaceholder": "Defina um ID personalizado ou deixe vazio para permitir que o Squidex gere um.", "contents.inspectContent": "Conteúdo", "contents.inspectData": "Dados", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index dea090f0c..08f8305cf 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -270,6 +270,7 @@ "common.event": "事件", "common.events": "事件", "common.executed": "已执行", + "common.expand": "Expand", "common.expertMode": "专家模式", "common.extension": "扩展名", "common.failed": "失败", @@ -465,7 +466,9 @@ "contents.draftNew": "新草稿", "contents.draftStatus": "新版本", "contents.editPageTitle": "编辑内容", + "contents.fieldAIMenu": "Generate with AI", "contents.fieldFullscreen": "Focus on this field and toggle fullscreen mode.", + "contents.fieldFullscreenMenu": "Fullscreen mode", "contents.idPlaceholder": "Define a custom ID or leave empty to let Squidex generate one.", "contents.inspectContent": "Content", "contents.inspectData": "Data", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 641e90f08..e4d52db8c 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -270,6 +270,7 @@ "common.event": "Event", "common.events": "Events", "common.executed": "Executed", + "common.expand": "Expand", "common.expertMode": "Expert Mode", "common.extension": "Extension", "common.failed": "Failed", @@ -465,7 +466,9 @@ "contents.draftNew": "New Draft", "contents.draftStatus": "New Version", "contents.editPageTitle": "Edit Content", + "contents.fieldAIMenu": "Generate with AI", "contents.fieldFullscreen": "Focus on this field and toggle fullscreen mode.", + "contents.fieldFullscreenMenu": "Fullscreen mode", "contents.idPlaceholder": "Define a custom ID or leave empty to let Squidex generate one.", "contents.inspectContent": "Content", "contents.inspectData": "Data", diff --git a/frontend/.storybook/main.js b/frontend/.storybook/main.js index d98dabba8..80e69043e 100644 --- a/frontend/.storybook/main.js +++ b/frontend/.storybook/main.js @@ -21,6 +21,7 @@ class FilterSassWarningsPlugin { module.exports = { stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"], addons: [ + '@storybook/addon-actions', "@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-interactions" diff --git a/frontend/src/app/features/content/shared/forms/content-field.component.html b/frontend/src/app/features/content/shared/forms/content-field.component.html index 8ab6c19be..e92870e40 100644 --- a/frontend/src/app/features/content/shared/forms/content-field.component.html +++ b/frontend/src/app/features/content/shared/forms/content-field.component.html @@ -14,21 +14,15 @@ [formLevel]="formLevel" [formModel]="formModel.get(language)" [hasChatBot]="hasChatBot" - [isComparing]="!!formModelCompare" [isCollapsed]="isCollapsed" + [isComparing]="!!formModelCompare" [language]="language" [languages]="languages"> - + @if (i === 0) { } - - - @if (i === 0) { - - } - } @@ -40,16 +34,13 @@ [formLevel]="formLevel" [formModel]="getControl()" [hasChatBot]="hasChatBot" - [isComparing]="!!formModelCompare" [isCollapsed]="isCollapsed" + [isComparing]="!!formModelCompare" [language]="language" [languages]="languages"> - + - - - } @@ -81,8 +72,8 @@ [formLevel]="formLevel" [formModel]="formModelCompare.get(language)" [hasChatBot]="hasChatBot" - [isComparing]="!!formModelCompare" [isCollapsed]="isCollapsed" + [isComparing]="!!formModelCompare" [language]="language" [languages]="languages" /> @@ -94,8 +85,8 @@ [formLevel]="formLevel" [formModel]="getControlCompare()!" [hasChatBot]="hasChatBot" - [isComparing]="!!formModelCompare" [isCollapsed]="isCollapsed" + [isComparing]="!!formModelCompare" [language]="language" [languages]="languages" /> } @@ -113,23 +104,23 @@ [languages]="languages" [showAllControls]="showAllControls" (showAllControlsChange)="changeShowAllControls($event)" /> + + @if (isTranslatable) { - + icon="translate" + menuLabel="i18n:contents.autotranslateMenu" + small + tabIndex="-1" + tooltip="i18n:contents.autotranslate" /> } - - - - \ No newline at end of file + @if (isCollapsed) { + + } @else { + + } + diff --git a/frontend/src/app/features/content/shared/forms/content-field.component.ts b/frontend/src/app/features/content/shared/forms/content-field.component.ts index 924a8a44e..e576a17b1 100644 --- a/frontend/src/app/features/content/shared/forms/content-field.component.ts +++ b/frontend/src/app/features/content/shared/forms/content-field.component.ts @@ -8,7 +8,7 @@ import { AsyncPipe, NgTemplateOutlet } from '@angular/common'; import { booleanAttribute, Component, EventEmitter, HostBinding, inject, Input, numberAttribute, Optional, Output } from '@angular/core'; import { Observable } from 'rxjs'; -import { AppLanguageDto, AppsState, changed$, CommentsState, disabled$, EditContentForm, FieldForm, FocusMarkerComponent, invalid$, LocalStoreService, SchemaDto, Settings, TooltipDirective, TranslateDto, TranslationsService, TypedSimpleChanges, UIOptions } from '@app/shared'; +import { AppLanguageDto, AppsState, changed$, CommentsState, disabled$, EditContentForm, FieldForm, FocusMarkerComponent, invalid$, LocalStoreService, MenuItemComponent, SchemaDto, Settings, TranslateDto, TranslationsService, TypedSimpleChanges, UIOptions } from '@app/shared'; import { FieldCopyButtonComponent } from './field-copy-button.component'; import { FieldEditorComponent } from './field-editor.component'; import { FieldLanguagesComponent } from './field-languages.component'; @@ -23,8 +23,8 @@ import { FieldLanguagesComponent } from './field-languages.component'; FieldEditorComponent, FieldLanguagesComponent, FocusMarkerComponent, + MenuItemComponent, NgTemplateOutlet, - TooltipDirective, ], }) export class ContentFieldComponent { @@ -80,10 +80,13 @@ export class ContentFieldComponent { return this.formModel.field.properties.isHalfWidth && !this.isCompact && !this.formCompare; } - public get isTranslatable() { - return this.formModel.field.properties.fieldType === 'String' && this.hasTranslator && this.formModel.field.isLocalizable && this.languages.length > 1; + public get isLocalizable() { + return this.formModel.field.isLocalizable; } + public get isTranslatable() { + return this.formModel.field.properties.fieldType === 'String' && this.hasTranslator && this.isLocalizable && this.languages.length > 1; + } constructor( @Optional() public readonly commentsState: CommentsState, private readonly appsState: AppsState, diff --git a/frontend/src/app/features/content/shared/forms/field-editor.component.html b/frontend/src/app/features/content/shared/forms/field-editor.component.html index 3c99ec315..dd2d4eb36 100644 --- a/frontend/src/app/features/content/shared/forms/field-editor.component.html +++ b/frontend/src/app/features/content/shared/forms/field-editor.component.html @@ -1,53 +1,55 @@ @if (formModel) {
-
-
-
- - +
+
+
+ + @if (field.isDisabled) { + Disabled + } +
+
+ +
+ +
+ +
+ @if (isString) { - + } - - - -
- - + [disabled]="(isDisabled | async) || isCollapsed || (isEmpty | async)" + icon="close" + menuLabel="i18n:contents.unsetValue" + small + tabIndex="-1" + tooltip="i18n:contents.unsetValue" /> +
- - @if (field.isDisabled) { - Disabled - } @if (form) { diff --git a/frontend/src/app/features/content/shared/forms/field-editor.component.scss b/frontend/src/app/features/content/shared/forms/field-editor.component.scss index 3ff77d95b..d224c26b2 100644 --- a/frontend/src/app/features/content/shared/forms/field-editor.component.scss +++ b/frontend/src/app/features/content/shared/forms/field-editor.component.scss @@ -27,22 +27,11 @@ } .buttons { - & { - @include absolute(100%, 0); - margin: 0; - margin-top: -9px; - } - - &-container { - position: relative; - } - - .btn-clear i { - font-size: $font-smallest; - } + margin: 0; + margin-top: -9px; - .btn { - width: 2rem; + sqx-menu { + max-width: 100%; } } diff --git a/frontend/src/app/features/content/shared/forms/field-editor.component.ts b/frontend/src/app/features/content/shared/forms/field-editor.component.ts index 6cfc24f5a..06eb50f19 100644 --- a/frontend/src/app/features/content/shared/forms/field-editor.component.ts +++ b/frontend/src/app/features/content/shared/forms/field-editor.component.ts @@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common'; import { booleanAttribute, Component, ElementRef, EventEmitter, Input, numberAttribute, Output, ViewChild } from '@angular/core'; import { AbstractControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { Observable } from 'rxjs'; -import { AbstractContentForm, AnnotationCreate, AnnotationsSelect, AnyFieldDto, AppLanguageDto, ChatDialogComponent, CheckboxGroupComponent, CodeEditorComponent, ColorPickerComponent, CommentsState, ConfirmClickDirective, ControlErrorsComponent, DateTimeEditorComponent, DialogModel, disabled$, EditContentForm, FormHintComponent, GeolocationEditorComponent, hasNoValue$, HTTP, IndeterminateValueDirective, MarkdownDirective, MathHelper, MessageBus, ModalDirective, RadioGroupComponent, ReferenceInputComponent, ReferencesFieldPropertiesDto, RichEditorComponent, StarsComponent, TagEditorComponent, ToggleComponent, TooltipDirective, TransformInputDirective, TypedSimpleChanges, Types } from '@app/shared'; +import { AbstractContentForm, AnnotationCreate, AnnotationsSelect, AnyFieldDto, AppLanguageDto, ChatDialogComponent, CheckboxGroupComponent, CodeEditorComponent, ColorPickerComponent, CommentsState, ControlErrorsComponent, DateTimeEditorComponent, DialogModel, disabled$, EditContentForm, FormHintComponent, GeolocationEditorComponent, hasNoValue$, HTTP, IndeterminateValueDirective, MarkdownDirective, MathHelper, MenuComponent, MenuItemComponent, MessageBus, ModalDirective, RadioGroupComponent, ReferenceInputComponent, ReferencesFieldPropertiesDto, RichEditorComponent, StarsComponent, TagEditorComponent, ToggleComponent, TransformInputDirective, TypedSimpleChanges, Types } from '@app/shared'; import { ReferenceDropdownComponent } from '../references/reference-dropdown.component'; import { ReferencesCheckboxesComponent } from '../references/references-checkboxes.component'; import { ReferencesEditorComponent } from '../references/references-editor.component'; @@ -34,7 +34,6 @@ import { StockPhotoEditorComponent } from './stock-photo-editor.component'; CodeEditorComponent, ColorPickerComponent, ComponentComponent, - ConfirmClickDirective, ControlErrorsComponent, DateTimeEditorComponent, FormHintComponent, @@ -44,6 +43,8 @@ import { StockPhotoEditorComponent } from './stock-photo-editor.component'; IndeterminateValueDirective, MarkdownDirective, ModalDirective, + MenuComponent, + MenuItemComponent, RadioGroupComponent, ReactiveFormsModule, ReferenceDropdownComponent, @@ -57,7 +58,6 @@ import { StockPhotoEditorComponent } from './stock-photo-editor.component'; StockPhotoEditorComponent, TagEditorComponent, ToggleComponent, - TooltipDirective, TransformInputDirective, ], }) @@ -106,12 +106,10 @@ export class FieldEditorComponent { @ViewChild('editor', { static: false }) public editor!: ElementRef; - public isEmpty?: Observable; + public isDisabled!: Observable; + public isEmpty!: Observable; public isExpanded = false; - public isDisabled?: Observable; - public chatDialog = new DialogModel(); - public annotations?: Observable>; public get field() { @@ -123,7 +121,7 @@ export class FieldEditorComponent { } public get isString() { - return this.field?.properties.fieldType === 'String'; + return this.field.properties.fieldType === 'String'; } public get schemaIds() { @@ -137,8 +135,8 @@ export class FieldEditorComponent { public ngOnChanges(changes: TypedSimpleChanges) { if (changes.formModel) { - this.isEmpty = hasNoValue$(this.formModel.form); this.isDisabled = disabled$(this.formModel.form); + this.isEmpty = hasNoValue$(this.formModel.form); } if (changes.formModel || changes.comments) { @@ -182,6 +180,7 @@ export class FieldEditorComponent { public unset() { this.formModel.unset(); } + public setValue(content: string | HTTP.UploadFile | null | undefined) { this.chatDialog.hide(); diff --git a/frontend/src/app/framework/angular/forms/confirm-click.directive.ts b/frontend/src/app/framework/angular/forms/confirm-click.directive.ts index dc386c90f..1e310804c 100644 --- a/frontend/src/app/framework/angular/forms/confirm-click.directive.ts +++ b/frontend/src/app/framework/angular/forms/confirm-click.directive.ts @@ -20,7 +20,7 @@ export class ConfirmClickDirective { public confirmText: string | undefined | null; @Input() - public confirmRememberKey = ''; + public confirmRememberKey: string | undefined | null; @Input({ transform: booleanAttribute }) public confirmRequired?: boolean | null = true; @@ -48,7 +48,8 @@ export class ConfirmClickDirective { this.beforeClick.emit(); - this.dialogs.confirm(this.confirmTitle, this.confirmText, this.confirmRememberKey).pipe(take(1)) + const rememberKey = this.confirmRememberKey || ''; + this.dialogs.confirm(this.confirmTitle, this.confirmText, rememberKey).pipe(take(1)) .subscribe(confirmed => { if (confirmed) { for (const destination of destinations) { diff --git a/frontend/src/app/framework/angular/menu-item.component.html b/frontend/src/app/framework/angular/menu-item.component.html new file mode 100644 index 000000000..8018a3b4b --- /dev/null +++ b/frontend/src/app/framework/angular/menu-item.component.html @@ -0,0 +1,30 @@ + + + {{ menuLabel || label | sqxTranslate }} + + + + diff --git a/frontend/src/app/framework/angular/menu-item.component.scss b/frontend/src/app/framework/angular/menu-item.component.scss new file mode 100644 index 000000000..c1eb6524f --- /dev/null +++ b/frontend/src/app/framework/angular/menu-item.component.scss @@ -0,0 +1,8 @@ +@import 'mixins'; +@import 'vars'; + +.btn-sm { + i { + font-size: $font-smallest; + } +} \ No newline at end of file diff --git a/frontend/src/app/framework/angular/menu-item.component.ts b/frontend/src/app/framework/angular/menu-item.component.ts new file mode 100644 index 000000000..b44e238be --- /dev/null +++ b/frontend/src/app/framework/angular/menu-item.component.ts @@ -0,0 +1,65 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + + +import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, numberAttribute, Output, TemplateRef, ViewChild } from '@angular/core'; +import { ConfirmClickDirective } from './forms/confirm-click.directive'; +import { TooltipDirective } from './modals/tooltip.directive'; +import { TranslatePipe } from './pipes/translate.pipe'; + +@Component({ + selector: 'sqx-menu-item', + styleUrls: ['./menu-item.component.scss'], + templateUrl: './menu-item.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ConfirmClickDirective, + TooltipDirective, + TranslatePipe, + ], +}) +export class MenuItemComponent { + @Input() + public label = ''; + + @Input() + public menuLabel = ''; + + @Input() + public icon = ''; + + @Input() + public tooltip = ''; + + @Input({ transform: numberAttribute }) + public tabIndex = -1; + + @Input({ transform: booleanAttribute }) + public disabled = false; + + @Input({ transform: booleanAttribute }) + public small = false; + + @Input() + public confirmRememberKey = ''; + + @Input() + public confirmTitle = ''; + + @Input() + public confirmText = ''; + + @Output() + public action = new EventEmitter(); + + @ViewChild('dropdownTemplate', { static: true }) + template!: TemplateRef; + + public get showInDropdown() { + return this.label || this.menuLabel; + } +} \ No newline at end of file diff --git a/frontend/src/app/framework/angular/menu.component.html b/frontend/src/app/framework/angular/menu.component.html new file mode 100644 index 000000000..51375f2c2 --- /dev/null +++ b/frontend/src/app/framework/angular/menu.component.html @@ -0,0 +1,29 @@ +
+ @if (overflowMenuItems) { + + + + @for (item of overflowMenuItems; track item) { + + } + + } + +
+ +
+
diff --git a/frontend/src/app/framework/angular/menu.component.scss b/frontend/src/app/framework/angular/menu.component.scss new file mode 100644 index 000000000..c0a8d95ea --- /dev/null +++ b/frontend/src/app/framework/angular/menu.component.scss @@ -0,0 +1,24 @@ +@import 'mixins'; +@import 'vars'; + +:host ::ng-deep { + .overflown-left { + sqx-menu-item { + order: 5; + visibility: hidden; + } + } + + .overflown-right { + sqx-menu-item { + order: -5; + visibility: hidden; + } + } +} + +.btn-sm { + i { + font-size: $font-smallest; + } +} \ No newline at end of file diff --git a/frontend/src/app/framework/angular/menu.component.ts b/frontend/src/app/framework/angular/menu.component.ts new file mode 100644 index 000000000..394223a0e --- /dev/null +++ b/frontend/src/app/framework/angular/menu.component.ts @@ -0,0 +1,87 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + + +import { NgTemplateOutlet } from '@angular/common'; +import { AfterViewInit, booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, ElementRef, Input, QueryList, ViewChild } from '@angular/core'; +import { ModalModel, ResizeListener, ResizeService, Subscriptions } from '@app/framework/internal'; +import { DropdownMenuComponent } from './dropdown-menu.component'; +import { MenuItemComponent } from './menu-item.component'; +import { ModalPlacementDirective } from './modals/modal-placement.directive'; +import { ModalDirective } from './modals/modal.directive'; +import { TranslatePipe } from './pipes/translate.pipe'; + +@Component({ + selector: 'sqx-menu', + styleUrls: ['./menu.component.scss'], + templateUrl: './menu.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + DropdownMenuComponent, + ModalDirective, + ModalPlacementDirective, + NgTemplateOutlet, + TranslatePipe, + ], +}) +export class MenuComponent implements AfterViewInit, ResizeListener { + private readonly subscriptions = new Subscriptions(); + private measuredContainer?: number; + private measuredMenu?: number; + + @Input() + public alignment: 'left' | 'right' = 'left'; + + @Input({ transform: booleanAttribute }) + public small = false; + + @Input({ transform: booleanAttribute }) + public showCustom = true; + + @ViewChild('container', { static: true }) + public container!: ElementRef; + + @ViewChild('menu', { static: true }) + public menu!: ElementRef; + + @ContentChildren(MenuItemComponent) + public menuItems!: QueryList; + + public overflowMenuItems: MenuItemComponent[] | null = null; + public overflowDropdown = new ModalModel(); + + public get isRightAligned() { + return this.alignment === 'right'; + } + + constructor( + private readonly resizeService: ResizeService, + private readonly changeDetector: ChangeDetectorRef, + ) { + } + + public ngAfterViewInit() { + this.subscriptions.add(this.resizeService.listen(this.container.nativeElement, this)); + this.subscriptions.add(this.resizeService.listen(this.menu.nativeElement, this)); + } + + public onResize(rect: DOMRect, element: Element): void { + if (element === this.container.nativeElement) { + this.measuredContainer = rect.width; + } else { + this.measuredMenu = Math.max(rect.width, element.scrollWidth); + } + + if (!this.measuredContainer || !this.measuredMenu) { + return; + } + + const isOverlapping = this.measuredMenu > this.measuredContainer; + this.overflowMenuItems = isOverlapping ? this.menuItems.toArray().filter(x => x.showInDropdown) : null; + this.changeDetector.detectChanges(); + } +} \ No newline at end of file diff --git a/frontend/src/app/framework/angular/menu.stories.ts b/frontend/src/app/framework/angular/menu.stories.ts new file mode 100644 index 000000000..95acc2cb6 --- /dev/null +++ b/frontend/src/app/framework/angular/menu.stories.ts @@ -0,0 +1,130 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { LocalizerService, ResizeService } from '@app/framework/internal'; +import { MenuItemComponent } from './menu-item.component'; +import { MenuComponent } from './menu.component'; +import { RootViewComponent } from './modals/root-view.component'; + +export default { + title: 'Framework/Menu', + component: MenuComponent, + args: { + small: false, + }, + argTypes: { + alignment: { + control: 'radio', + options: [ + 'left', + 'right', + ], + }, + small: { + control: 'boolean', + }, + }, + decorators: [ + moduleMetadata({ + imports: [ + BrowserAnimationsModule, + MenuItemComponent, + RootViewComponent, + ], + providers: [ + { + provide: LocalizerService, + useFactory: () => new LocalizerService({}), + }, + ResizeService, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Simple: Story = { + render: args => ({ + props: args, + template: ` + +
+ + + + + +
+
+ `, + }), +}; + +export const Overlap: Story = { + render: args => ({ + props: args, + template: ` + +
+ + + + + + + +
+
+ `, + }), +}; + +export const Custom: Story = { + render: args => ({ + props: args, + template: ` + +
+ + + + + + + + +
+
+ `, + }), +}; + +export const CustomOverlap: Story = { + render: args => ({ + props: args, + template: ` + +
+ + + + + + + + + + + +
+
+ `, + }), +}; \ No newline at end of file diff --git a/frontend/src/app/framework/index.ts b/frontend/src/app/framework/index.ts index 7bf8ab508..04c4bf1fe 100644 --- a/frontend/src/app/framework/index.ts +++ b/frontend/src/app/framework/index.ts @@ -60,6 +60,8 @@ export * from './angular/list-view.component'; export * from './angular/loader.component'; export * from './angular/long-hover.directive'; export * from './angular/markdown.directive'; +export * from './angular/menu.component'; +export * from './angular/menu-item.component'; export * from './angular/modals/dialog-renderer.component'; export * from './angular/modals/modal-dialog.component'; export * from './angular/modals/modal-placement.directive';