Browse Source

Menu (#1275)

* Menu

* Move to components.
pull/1276/head
Sebastian Stehle 8 months ago
committed by GitHub
parent
commit
239884275d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      backend/i18n/frontend_de.json
  2. 3
      backend/i18n/frontend_en.json
  3. 3
      backend/i18n/frontend_fr.json
  4. 3
      backend/i18n/frontend_it.json
  5. 3
      backend/i18n/frontend_nl.json
  6. 3
      backend/i18n/frontend_pt.json
  7. 3
      backend/i18n/frontend_zh.json
  8. 3
      backend/i18n/source/frontend_en.json
  9. 1
      frontend/.storybook/main.js
  10. 51
      frontend/src/app/features/content/shared/forms/content-field.component.html
  11. 11
      frontend/src/app/features/content/shared/forms/content-field.component.ts
  12. 84
      frontend/src/app/features/content/shared/forms/field-editor.component.html
  13. 19
      frontend/src/app/features/content/shared/forms/field-editor.component.scss
  14. 17
      frontend/src/app/features/content/shared/forms/field-editor.component.ts
  15. 5
      frontend/src/app/framework/angular/forms/confirm-click.directive.ts
  16. 30
      frontend/src/app/framework/angular/menu-item.component.html
  17. 8
      frontend/src/app/framework/angular/menu-item.component.scss
  18. 65
      frontend/src/app/framework/angular/menu-item.component.ts
  19. 29
      frontend/src/app/framework/angular/menu.component.html
  20. 24
      frontend/src/app/framework/angular/menu.component.scss
  21. 87
      frontend/src/app/framework/angular/menu.component.ts
  22. 130
      frontend/src/app/framework/angular/menu.stories.ts
  23. 2
      frontend/src/app/framework/index.ts

3
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",

3
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",

3
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",

3
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",

3
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",

3
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",

3
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",

3
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",

1
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"

51
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">
<ng-container beforeButtons>
<ng-container>
@if (i === 0) {
<ng-container *ngTemplateOutlet="sharedButtons" />
}
</ng-container>
<ng-container afterButtons>
@if (i === 0) {
<ng-container *ngTemplateOutlet="toggleButton" />
}
</ng-container>
</sqx-field-editor>
</div>
}
@ -40,16 +34,13 @@
[formLevel]="formLevel"
[formModel]="getControl()"
[hasChatBot]="hasChatBot"
[isComparing]="!!formModelCompare"
[isCollapsed]="isCollapsed"
[isComparing]="!!formModelCompare"
[language]="language"
[languages]="languages">
<ng-container beforeButtons>
<ng-container>
<ng-container *ngTemplateOutlet="sharedButtons" />
</ng-container>
<ng-container afterButtons>
<ng-container *ngTemplateOutlet="toggleButton" />
</ng-container>
</sqx-field-editor>
}
</div>
@ -81,8 +72,8 @@
[formLevel]="formLevel"
[formModel]="formModelCompare.get(language)"
[hasChatBot]="hasChatBot"
[isComparing]="!!formModelCompare"
[isCollapsed]="isCollapsed"
[isComparing]="!!formModelCompare"
[language]="language"
[languages]="languages" />
</div>
@ -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)" />
<sqx-field-copy-button [formModel]="formModel" [languages]="languages" />
@if (isTranslatable) {
<button
class="btn btn-sm btn-outline-secondary force no-focus-shadow"
(click)="translate()"
<sqx-menu-item
(action)="translate()"
[disabled]="formModel.field.isDisabled"
tabindex="-1"
title="i18n:contents.autotranslate"
type="button">
<i class="icon-translate"></i>
</button>
icon="translate"
menuLabel="i18n:contents.autotranslateMenu"
small
tabIndex="-1"
tooltip="i18n:contents.autotranslate" />
}
</ng-template>
<ng-template #toggleButton>
<button class="btn btn-sm btn-square btn-outline-secondary" (click)="toggle()" type="button"
title="i18n:contents.arrayCollapseItem">
<i [class.icon-minus2]="!isCollapsed" [class.icon-plus2]="isCollapsed"></i>
</button>
</ng-template>
@if (isCollapsed) {
<sqx-menu-item (action)="toggle()" icon="minus2" menuLabel="i18n:common.collapse" small tooltip="i18n:contents.arrayCollapseItem" />
} @else {
<sqx-menu-item (action)="toggle()" icon="plus2" menuLabel="i18n:common.expand" small tooltip="i18n:contents.arrayCollapseItem" />
}
</ng-template>

11
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,

84
frontend/src/app/features/content/shared/forms/field-editor.component.html

@ -1,53 +1,55 @@
@if (formModel) {
<div class="field" [class.expanded]="isExpanded && !isCollapsed">
<div class="buttons-container">
<div class="buttons d-flex gap-1">
<fieldset class="d-flex gap-1" [disabled]="(isDisabled | async) || isCollapsed">
<ng-content select="[beforeButtons]"></ng-content>
<div class="row">
<div class="col-4">
<div class="truncate">
<label class="mb-1">
{{ field.displayName }} {{ displaySuffix }} <span class="field-required" [class.hidden]="!field.properties.isRequired">*</span>
</label>
@if (field.isDisabled) {
<small class="field-disabled ps-1">Disabled</small>
}
</div>
</div>
<div class="col-8 buttons">
<sqx-menu alignment="right" small>
<fieldset class="d-flex gap-1" [disabled]="(isDisabled | async) || isCollapsed">
<ng-content></ng-content>
</fieldset>
@if (isString) {
<button
class="btn btn-sm btn-outline-secondary force no-focus-shadow"
(click)="chatDialog.show()"
[disabled]="!hasChatBot || isCollapsed"
tabindex="-1"
type="button">
AI
</button>
<sqx-menu-item
[disabled]="(isDisabled | async) || isCollapsed || !hasChatBot"
label="AI"
menuLabel="i18n:contents.fieldAIMenu"
small
tabIndex="-1" />
}
<button
class="btn btn-sm btn-outline-secondary force no-focus-shadow"
(click)="toggleExpanded()"
[disabled]="isCollapsed"
tabindex="-1"
title="i18n:contents.fieldFullscreen"
type="button">
<i class="icon-fullscreen"></i>
</button>
<button
class="btn btn-sm btn-outline-secondary btn-clear force no-focus-shadow"
<sqx-menu-item
(action)="this.toggleExpanded()"
[disabled]="(isDisabled | async) || isCollapsed"
icon="fullscreen"
menuLabel="i18n:contents.fieldFullscreenMenu"
small
tabIndex="-1"
tooltip="i18n:contents.fieldFullscreen" />
<sqx-menu-item
(action)="this.unset()"
confirmRememberKey="unsetValue"
confirmText="i18n:contents.unsetValueConfirmText"
confirmTitle="i18n:contents.unsetValueConfirmTitle"
[disabled]="isEmpty | async"
(sqxConfirmClick)="unset()"
tabindex="-1"
title="i18n:contents.unsetValue"
type="button">
<i class="icon-close"></i>
</button>
</fieldset>
<ng-content select="[afterButtons]"></ng-content>
[disabled]="(isDisabled | async) || isCollapsed || (isEmpty | async)"
icon="close"
menuLabel="i18n:contents.unsetValue"
small
tabIndex="-1"
tooltip="i18n:contents.unsetValue" />
</sqx-menu>
</div>
</div>
<label class="mb-1">
{{ field.displayName }} {{ displaySuffix }} <span class="field-required" [class.hidden]="!field.properties.isRequired">*</span>
</label>
@if (field.isDisabled) {
<small class="field-disabled ps-1">Disabled</small>
}
@if (form) {
<sqx-control-errors [fieldName]="field.displayName" [for]="$any(fieldForm)" />

19
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%;
}
}

17
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<boolean>;
public isDisabled!: Observable<boolean>;
public isEmpty!: Observable<boolean>;
public isExpanded = false;
public isDisabled?: Observable<boolean>;
public chatDialog = new DialogModel();
public annotations?: Observable<ReadonlyArray<Annotation>>;
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<this>) {
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();

5
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) {

30
frontend/src/app/framework/angular/menu-item.component.html

@ -0,0 +1,30 @@
<ng-template #dropdownTemplate>
<a
class="dropdown-item"
[attr.disabled]="disabled"
[class.disabled]="disabled"
[confirmRememberKey]="confirmRememberKey"
[confirmText]="confirmText | sqxTranslate"
[confirmTitle]="confirmTitle | sqxTranslate"
(sqxConfirmClick)="action.emit()"
[title]="tooltip | sqxTranslate">
{{ menuLabel || label | sqxTranslate }}
</a>
</ng-template>
<button
class="btn btn-outline-secondary"
[class.btn-sm]="small"
[confirmRememberKey]="confirmRememberKey"
[confirmText]="confirmText | sqxTranslate"
[confirmTitle]="confirmTitle | sqxTranslate"
[disabled]="disabled"
(sqxConfirmClick)="action.emit()"
[tabIndex]="tabIndex"
[title]="tooltip | sqxTranslate">
@if (icon) {
<i class="icon-{{ icon }}"></i>
}
{{ label | sqxTranslate }}
</button>

8
frontend/src/app/framework/angular/menu-item.component.scss

@ -0,0 +1,8 @@
@import 'mixins';
@import 'vars';
.btn-sm {
i {
font-size: $font-smallest;
}
}

65
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<any>;
public get showInDropdown() {
return this.label || this.menuLabel;
}
}

29
frontend/src/app/framework/angular/menu.component.html

@ -0,0 +1,29 @@
<div class="d-flex flex-nowrap relative" #container [class.gap-1]="small" [class.gap-2]="!small" [class.justify-content-end]="isRightAligned">
@if (overflowMenuItems) {
<button
class="btn btn-text-secondary"
#buttonOptions
attr.aria-label="{{ 'common.options' | sqxTranslate }}"
[class.btn-sm]="small"
[class.order-1]="isRightAligned"
(click)="overflowDropdown.toggle()"
sqxStopClick
type="button">
<i class="icon-dots"></i>
</button>
<sqx-dropdown-menu
[position]="isRightAligned ? 'bottom-end' : 'bottom-start'"
scrollY="true"
[sqxAnchoredTo]="buttonOptions"
*sqxModal="overflowDropdown; closeAlways: true">
@for (item of overflowMenuItems; track item) {
<ng-container *ngTemplateOutlet="item.template"></ng-container>
}
</sqx-dropdown-menu>
}
<div class="d-flex flex-nowrap" #menu [class.gap-1]="small" [class.gap-2]="!small" [class.overflown-right]="!!overflowMenuItems && isRightAligned" [class.overflown-left]="!!overflowMenuItems && !isRightAligned">
<ng-content />
</div>
</div>

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

87
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<HTMLDivElement>;
@ViewChild('menu', { static: true })
public menu!: ElementRef<HTMLDivElement>;
@ContentChildren(MenuItemComponent)
public menuItems!: QueryList<MenuItemComponent>;
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();
}
}

130
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<MenuComponent>;
export const Simple: Story = {
render: args => ({
props: args,
template: `
<sqx-root-view>
<div style="width: 400px; padding: 10px; border: 1px solid #ddd;">
<sqx-menu [alignment]="alignment" [items]="items" [small]="small">
<sqx-menu-item label="Menu Item1" />
<sqx-menu-item label="Menu Item2" icon="close" />
<sqx-menu-item label="Menu Item3" disabled />
</sqx-menu>
</div>
</sqx-root-view>
`,
}),
};
export const Overlap: Story = {
render: args => ({
props: args,
template: `
<sqx-root-view>
<div style="width: 400px; padding: 10px; border: 1px solid #ddd;">
<sqx-menu [alignment]="alignment" [items]="items" [small]="small">
<sqx-menu-item label="Menu Item1" />
<sqx-menu-item label="Menu Item2" icon="close" />
<sqx-menu-item label="Menu Item3" disabled />
<sqx-menu-item label="Menu Item4" />
<sqx-menu-item label="Menu Item5" />
</sqx-menu>
</div>
</sqx-root-view>
`,
}),
};
export const Custom: Story = {
render: args => ({
props: args,
template: `
<sqx-root-view>
<div style="width: 500px; padding: 10px; border: 1px solid #ddd;">
<sqx-menu [alignment]="alignment" [items]="items" [small]="small">
<sqx-menu-item label="Menu Item1" />
<button class="btn btn-primary">Custom 1</button>
<button class="btn btn-primary">Custom 2</button>
<sqx-menu-item label="Menu Item2" />
</sqx-menu>
</div>
</sqx-root-view>
`,
}),
};
export const CustomOverlap: Story = {
render: args => ({
props: args,
template: `
<sqx-root-view>
<div style="width: 400px; padding: 10px; border: 1px solid #ddd;">
<sqx-menu [alignment]="alignment" [items]="items" [small]="small">
<sqx-menu-item label="Menu Item1" />
<sqx-menu-item label="Menu Item2" icon="close" />
<sqx-menu-item label="Menu Item3" disabled />
<button class="btn btn-primary">Custom 1</button>
<button class="btn btn-primary">Custom 2</button>
<sqx-menu-item label="Menu Item4" />
<sqx-menu-item label="Menu Item5" />
</sqx-menu>
</div>
</sqx-root-view>
`,
}),
};

2
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';

Loading…
Cancel
Save