Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): shellbar redesign, add branding and additional context area #12694

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions libs/core/shellbar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ export * from './product-menu/product-menu.component';
export * from './shellbar-action/shellbar-action.component';
export * from './shellbar-actions-mobile/shellbar-actions-mobile.component';
export * from './shellbar-actions/shellbar-actions.component';
export * from './shellbar-branding/shellbar-branding.component';
export * from './shellbar-context-area/shellbar-context-area.component';
export * from './shellbar-logo/shellbar-logo.component';
export * from './shellbar-overflow-priority.directive';
export * from './shellbar-separator/shellbar-separator.component';
export * from './shellbar-sidenav.directive';
export * from './shellbar-spacer/shellbar-spacer.component';
export * from './shellbar-subtitle/shellbar-subtitle.component';
export * from './shellbar-title/shellbar-title.component';
export * from './shellbar.component';
Expand Down
19 changes: 15 additions & 4 deletions libs/core/shellbar/shellbar-actions/shellbar-actions.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ import { ShellbarUser } from '../model/shellbar-user';
import { ShellbarUserMenu } from '../model/shellbar-user-menu';
import { ShellbarActionComponent } from '../shellbar-action/shellbar-action.component';
import { ShellbarActionsMobileComponent } from '../shellbar-actions-mobile/shellbar-actions-mobile.component';
import { FD_SHELLBAR_ACTION_COMPONENT, FD_SHELLBAR_COMPONENT } from '../tokens';
import {
FD_SHELLBAR_ACTIONS_COMPONENT,
FD_SHELLBAR_ACTION_COMPONENT,
FD_SHELLBAR_COMPONENT,
FD_SHELLBAR_USER_MENU_COMPONENT
} from '../tokens';
import { ShellbarUserMenuComponent } from '../user-menu/shellbar-user-menu.component';

/**
Expand Down Expand Up @@ -59,7 +64,13 @@ import { ShellbarUserMenuComponent } from '../user-menu/shellbar-user-menu.compo
'[class.fd-shellbar__group--actions]': 'true'
},
standalone: true,
imports: [PortalModule, ShellbarActionsMobileComponent, ShellbarActionComponent, ShellbarUserMenuComponent]
imports: [PortalModule, ShellbarActionsMobileComponent, ShellbarActionComponent, ShellbarUserMenuComponent],
providers: [
{
provide: FD_SHELLBAR_ACTIONS_COMPONENT,
useExisting: ShellbarActionsComponent
}
]
})
export class ShellbarActionsComponent implements OnDestroy {
/** The user data. */
Expand All @@ -85,11 +96,11 @@ export class ShellbarActionsComponent implements OnDestroy {
shellbarActions: QueryList<ShellbarActionComponent>;

/** @hidden */
@ContentChild(ShellbarUserMenuComponent)
@ContentChild(FD_SHELLBAR_USER_MENU_COMPONENT)
userComponent: ShellbarUserMenuComponent;

/** @hidden */
@ViewChild(ShellbarUserMenuComponent)
@ViewChild(FD_SHELLBAR_USER_MENU_COMPONENT)
userComponentView: ShellbarUserMenuComponent;

/** @hidden */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<div class="fd-shellbar__branding" role="link" tabindex="0" [attr.aria-label]="ariaLabel" (click)="callback?.($event)">
<ng-content select="fd-shellbar-logo"></ng-content>

@if (titleContent || subtitleContent) {
<div class="fd-shellbar__product">
<ng-content select="fd-shellbar-title"></ng-content>
<ng-content select="fd-shellbar-subtitle"></ng-content>
</div>
}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ShellbarBrandingComponent } from './shellbar-branding.component';

describe('ShellbarBrandingComponent', () => {
let component: ShellbarBrandingComponent;
let fixture: ComponentFixture<ShellbarBrandingComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ShellbarBrandingComponent]
}).compileComponents();

fixture = TestBed.createComponent(ShellbarBrandingComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, ContentChild, Input, ViewEncapsulation } from '@angular/core';
import { Nullable } from '@fundamental-ngx/cdk/utils';
import { FD_SHELLBAR_BRANDING_COMPONENT, FD_SHELLBAR_SUBTITLE_COMPONENT, FD_SHELLBAR_TITLE_COMPONENT } from '../tokens';

@Component({
selector: 'fd-shellbar-branding',
standalone: true,
templateUrl: './shellbar-branding.component.html',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: FD_SHELLBAR_BRANDING_COMPONENT,
useExisting: ShellbarBrandingComponent
}
]
})
export class ShellbarBrandingComponent {
/** Callback that hanldles the response to clicks on any of the actions. */
@Input()
callback: Nullable<(event: MouseEvent) => void>;

/** add aria label dynamically to add to the button */
@Input()
ariaLabel: Nullable<string>;
Comment on lines +20 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signal inputs


/** hidden */
@ContentChild(FD_SHELLBAR_TITLE_COMPONENT) titleContent?: HTMLElement;

/** hidden */
@ContentChild(FD_SHELLBAR_SUBTITLE_COMPONENT) subtitleContent?: HTMLElement;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ShellbarContextAreaComponent } from './shellbar-context-area.component';
import { ResizeObserverService } from '@fundamental-ngx/core/utils';
import { ElementRef } from '@angular/core';
import { Subject } from 'rxjs';

export class ResizeObservableServiceMock {
private readonly _observerMap = new Map<Element | ElementRef<Element>, Subject<ResizeObserverEntry[]>>();

observe(elementOrRef: Element | ElementRef<Element>): Subject<ResizeObserverEntry[]> {
const subj = new Subject<ResizeObserverEntry[]>();
this._observerMap.set(elementOrRef, subj);
return subj;
}

trigger(elementOrRef: Element | ElementRef<Element>, data: ResizeObserverEntry[]): void {
this._observerMap.get(elementOrRef)?.next(data);
}
}

describe('ShellbarContextAreaComponent', () => {
let component: ShellbarContextAreaComponent;
let fixture: ComponentFixture<ShellbarContextAreaComponent>;
let resizeObserverServiceMock: ResizeObservableServiceMock;

beforeEach(async () => {
resizeObserverServiceMock = new ResizeObservableServiceMock();

await TestBed.configureTestingModule({
imports: [ShellbarContextAreaComponent],
providers: [
{
provide: ResizeObserverService,
useValue: resizeObserverServiceMock
}
]
}).compileComponents();

fixture = TestBed.createComponent(ShellbarContextAreaComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should call updateVisibility on resize event', async () => {
const updateVisibilitySpy = jest.spyOn(component, 'updateVisibility');

await fixture.whenRenderingDone();

resizeObserverServiceMock.trigger(component.el.nativeElement, []);

fixture.detectChanges();

expect(updateVisibilitySpy).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { AfterViewInit, ChangeDetectionStrategy, Component, DestroyRef, ElementRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ResizeObserverService } from '@fundamental-ngx/cdk/utils';

/**
* Component representing the context area of the shellbar.
* It manages the visibility of its child elements based on the available width.
*/
@Component({
selector: 'fd-shellbar-context-area',
standalone: true,
template: ` <ng-content></ng-content>`,
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.fd-shellbar__group]': 'true',
'[class.fd-shellbar__group--context-area]': 'true'
},
styles: [
`
:host {
min-width: 0;
}
`
]
})
export class ShellbarContextAreaComponent implements AfterViewInit {
/** @hidden */
constructor(
public el: ElementRef,
private _resizeObserverService: ResizeObserverService,
private _destroyRef: DestroyRef
) {}

/** @hidden */
ngAfterViewInit(): void {
this._resizeObserverService
.observe(this.el)
.pipe(takeUntilDestroyed(this._destroyRef))
.subscribe(() => this.updateVisibility());
requestAnimationFrame(() => this.updateVisibility());
}

/**
* Updates the visibility of the child elements based on the available width.
* This method ensures that the elements with the highest priority are shown
* while hiding the lower priority elements if there is not enough space.
*
* The method works by:
* 1. Sorting the elements based on their priority.
* 2. Calculating the total width of the currently shown elements.
* 3. Comparing the total width with the available width.
* 4. Iteratively hiding the last shown element if the total width exceeds the available width.
* 5. Iteratively showing the first hidden element if there is enough space.
* 6. Ensuring all elements fit within the available width without recursion.
*/
updateVisibility(): void {
const elements: { el: HTMLElement; priority: number }[] = this._getElementsWithPriority();
const availableWidth = this._getAvailableWidth();
const allItemsWidth = this._calculateShownElementsWidth(elements);

this._hideElementsIfNeeded(elements, availableWidth, allItemsWidth);
this._showElementsIfNeeded(elements, availableWidth, allItemsWidth);
}

/**
* Retrieves the child elements with their respective priority values.
* The elements are sorted based on their priority, with elements having
* higher priority shown first.
*/
private _getElementsWithPriority(): { el: HTMLElement; priority: number }[] {
return [...this.el.nativeElement.childNodes]
.map((element: HTMLElement, index) => {
const hasPriorityAttribute = element.hasAttribute && element.hasAttribute('fdShellbarHidePriority');
const priority = hasPriorityAttribute
? parseInt(element.getAttribute('fdShellbarHidePriority')!, 10)
: index + 1;

return { el: element, priority };
})
.sort((a, b) => a.priority - b.priority);
}

/**
* Hides elements if the total width exceeds the available width.
* This method will hide the last shown element iteratively until the total width
* fits within the available width.
*/
private _hideElementsIfNeeded(
elements: {
el: HTMLElement;
priority: number;
}[],
availableWidth: number,
allItemsWidth: number
): void {
while (allItemsWidth > availableWidth) {
const shownElements = elements.filter((el) => el.el.style.display !== 'none');
if (shownElements.length === 0) {
break;
}
shownElements[shownElements.length - 1].el.style.display = 'none';
allItemsWidth = this._calculateShownElementsWidth(elements);
}
}

/**
* Shows elements if there is enough space available.
* This method will show the first hidden element iteratively as long as there
* is sufficient space, and hide the element again if the space is exceeded.
*/
private _showElementsIfNeeded(
elements: {
el: HTMLElement;
priority: number;
}[],
availableWidth: number,
allItemsWidth: number
): void {
let hiddenElements = elements.filter((el) => el.el.style.display === 'none');
while (hiddenElements.length > 0 && allItemsWidth <= availableWidth) {
hiddenElements[0].el.style.display = '';
allItemsWidth = this._calculateShownElementsWidth(elements);
if (allItemsWidth > availableWidth) {
hiddenElements[0].el.style.display = 'none';
break;
}
hiddenElements = elements.filter((el) => el.el.style.display === 'none');
}
}

/**
* Calculates the total gap between the visible elements.
* Avoids negative gap for single or no elements.
*/
private _calculateTotalGap(elementsLength: number): number {
const gap = parseFloat(window.getComputedStyle(this.el.nativeElement).gap || '0');
return gap * Math.max(elementsLength - 1, 0);
}

/**
* Calculates the total width of the shown elements, including the gaps.
*/
private _calculateShownElementsWidth(elements: { el: HTMLElement; priority: number }[]): number {
const shownElements = elements.filter((el) => el.el.style.display !== 'none');
const totalWidth = shownElements.reduce((acc, el) => acc + el.el.clientWidth, 0);
return totalWidth + this._calculateTotalGap(shownElements.length);
}

/**
* Gets the available width of the container.
*/
private _getAvailableWidth(): number {
return this.el.nativeElement.offsetWidth;
}
}
33 changes: 30 additions & 3 deletions libs/core/shellbar/shellbar-logo/shellbar-logo.component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input, ViewEncapsulation } from '@angular/core';
import { Nullable } from '@fundamental-ngx/cdk/utils';

/**
* The component that represents a shellbar logo.
Expand All @@ -11,9 +13,34 @@ import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/
*/
@Component({
selector: 'fd-shellbar-logo',
template: `<ng-content></ng-content>`,
template: `
<span class="fd-shellbar__logo">
@if (src) {
<img [ngSrc]="src" [srcset]="srcset" [width]="width" [height]="height" [alt]="alt" />
} @else {
<ng-content></ng-content>
}
</span>
`,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgOptimizedImage],
standalone: true
})
export class ShellbarLogoComponent {}
export class ShellbarLogoComponent {
/** Source of the logo image */
@Input() src: Nullable<string>;

/** Srcset for responsive images */
@Input()
srcset: Nullable<string>;

/** Width of the logo */
@Input() width: Nullable<number | string>;

/** Height of the logo */
@Input() height: Nullable<number | string>;

/** Alt text for the image */
@Input() alt: Nullable<string>;
}
13 changes: 13 additions & 0 deletions libs/core/shellbar/shellbar-overflow-priority.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Directive, ElementRef, inject, Input } from '@angular/core';

@Directive({
selector: '[fdShellbarHidePriority]',
standalone: true
})
export class ShellbarHidePriorityDirective {
/** @hidden */
@Input('fdShellbarHidePriority') priority: any;

/** @hidden */
el = inject(ElementRef);
}
Loading
Loading