mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
23 changed files with 486 additions and 101 deletions
@ -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> |
|||
@ -0,0 +1,8 @@ |
|||
@import 'mixins'; |
|||
@import 'vars'; |
|||
|
|||
.btn-sm { |
|||
i { |
|||
font-size: $font-smallest; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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> |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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> |
|||
`,
|
|||
}), |
|||
}; |
|||
Loading…
Reference in new issue