Skip to content

Commit

Permalink
NAS-132667 / 25.04 / Come up with a reusable component for master-det…
Browse files Browse the repository at this point in the history
…ail view (#11096)

* NAS-132667: Come up with a reusable component for master-detail view

* NAS-132667: Come up with a reusable component for master-detail view

* NAS-132667: Come up with a reusable component for master-detail view

* NAS-132667: Come up with a reusable component for master-detail view

* NAS-132667: Come up with a reusable component for master-detail view

* NAS-132667: PR update

* NAS-132667: PR update

* NAS-132667: PR update
  • Loading branch information
AlexKarpov98 authored Nov 26, 2024
1 parent 89e16bd commit 95d6d15
Show file tree
Hide file tree
Showing 32 changed files with 462 additions and 582 deletions.
109 changes: 71 additions & 38 deletions src/app/directives/details-height/details-height.directive.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
Directive, ElementRef, HostListener, Inject, OnChanges, OnDestroy, OnInit,
Directive, ElementRef, HostListener, Inject, OnDestroy, OnInit, OnChanges,
} from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Store } from '@ngrx/store';
Expand All @@ -25,12 +25,13 @@ export class DetailsHeightDirective implements OnInit, OnDestroy, OnChanges {
private headerHeight = headerHeight;
private footerHeight = footerHeight;

private readonly onScrollHandler = this.onScroll.bind(this);

private parentPadding = 0;
private heightBaseOffset = 0;
private scrollBreakingPoint = 0;
private heightCssValue = `calc(100vh - ${this.heightBaseOffset}px)`;
private heightCssValue = '';

private resizeObserver: ResizeObserver | null = null;
private scrollAnimationFrame: number | null = null;

constructor(
@Inject(WINDOW) private window: Window,
Expand All @@ -40,66 +41,92 @@ export class DetailsHeightDirective implements OnInit, OnDestroy, OnChanges {
) {}

ngOnInit(): void {
this.setupResizeObserver();
this.listenForConsoleFooterChanges();

this.element.nativeElement.style.height = this.heightCssValue;
this.window.addEventListener('scroll', this.onScrollHandler, true);
setTimeout(() => this.onScroll());
this.precalculateHeights();
this.applyHeight();
this.window.addEventListener('scroll', this.onScroll.bind(this), true);
setTimeout(() => this.onResize());
}

ngOnChanges(changes: IxSimpleChanges<this>): void {
if ('hasConsoleFooter' in changes) {
delete this.heightBaseOffset;
this.precalculateHeights();
this.applyHeight();
}
}

ngOnDestroy(): void {
this.window.removeEventListener('scroll', this.onScrollHandler, true);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
if (this.scrollAnimationFrame) {
cancelAnimationFrame(this.scrollAnimationFrame);
}
this.window.removeEventListener('scroll', this.onScroll.bind(this), true);
}

@HostListener('window:resize')
listenForScreenSizeChanges(): void {
this.heightBaseOffset = this.getBaseOffset();
this.scrollBreakingPoint = this.getScrollBreakingPoint();
onResize(): void {
this.precalculateHeights();
this.applyHeight();
}

onScroll(): void {
const parentElement = this.layoutService.getContentContainer();

if (!parentElement) {
return;
if (this.scrollAnimationFrame) {
cancelAnimationFrame(this.scrollAnimationFrame);
}

if (!this.parentPadding) {
this.parentPadding = parseFloat(
this.window
.getComputedStyle(parentElement, null)
.getPropertyValue('padding-bottom'),
);
}
this.scrollAnimationFrame = requestAnimationFrame(() => {
const parentElement = this.layoutService.getContentContainer();
if (!parentElement) {
return;
}

if (!this.heightBaseOffset) {
this.heightBaseOffset = this.getBaseOffset();
}
const scrollTop = parentElement.scrollTop;

if (scrollTop < this.scrollBreakingPoint) {
this.heightCssValue = `calc(100vh - ${this.heightBaseOffset + 18}px + ${scrollTop}px)`;
} else {
this.heightCssValue = `calc(100vh - ${this.heightBaseOffset}px + ${this.scrollBreakingPoint}px)`;
}

this.element.nativeElement.style.height = this.heightCssValue;
});
}

if (!this.scrollBreakingPoint) {
this.scrollBreakingPoint = this.getScrollBreakingPoint();
private setupResizeObserver(): void {
this.resizeObserver = new ResizeObserver(() => {
this.precalculateHeights();
this.applyHeight();
});

const parentElement = this.layoutService.getContentContainer();
if (parentElement) {
this.resizeObserver.observe(parentElement);
}
}

if (parentElement.scrollTop < this.scrollBreakingPoint) {
this.heightCssValue = `calc(100vh - ${this.heightBaseOffset + 18}px + ${parentElement.scrollTop}px)`;
} else {
this.heightCssValue = `calc(100vh - ${this.heightBaseOffset}px + ${this.scrollBreakingPoint}px)`;
private precalculateHeights(): void {
const parentElement = this.layoutService.getContentContainer();
if (!parentElement) {
return;
}

this.element.nativeElement.style.height = this.heightCssValue;
this.parentPadding = parseFloat(
this.window.getComputedStyle(parentElement, null).getPropertyValue('padding-bottom'),
) || 0;

this.heightBaseOffset = this.calculateBaseOffset();
this.scrollBreakingPoint = this.calculateScrollBreakingPoint();
this.heightCssValue = `calc(100vh - ${this.heightBaseOffset}px)`;
}

private getInitialTopPosition(element: HTMLElement): number {
return Math.floor(element.getBoundingClientRect().top);
private applyHeight(): void {
this.element.nativeElement.style.height = this.heightCssValue;
}

private getBaseOffset(): number {
private calculateBaseOffset(): number {
let result = this.getInitialTopPosition(this.element.nativeElement);
result += this.parentPadding;
if (this.hasConsoleFooter) {
Expand All @@ -108,18 +135,24 @@ export class DetailsHeightDirective implements OnInit, OnDestroy, OnChanges {
return Math.floor(result);
}

private getScrollBreakingPoint(): number {
private calculateScrollBreakingPoint(): number {
let result = this.getInitialTopPosition(this.element.nativeElement);
result -= this.parentPadding;
result -= this.headerHeight;
return Math.max(Math.floor(result), 0);
}

private getInitialTopPosition(element: HTMLElement): number {
return Math.floor(element.getBoundingClientRect().top);
}

private listenForConsoleFooterChanges(): void {
this.store$
.pipe(waitForAdvancedConfig, untilDestroyed(this))
.subscribe((advancedConfig) => {
this.hasConsoleFooter = advancedConfig.consolemsg;
this.precalculateHeights();
this.applyHeight();
});
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<button
tabindex="0"
mat-icon-button
class="mobile-back-button"
id="mobile-back-button"
ixTest="disk-details-back"
[attr.aria-label]="'Back' | translate"
(click)="onClose.emit()"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,21 @@ describe('MobileBackButtonComponent', () => {
});

it('should render a button with the correct classes and attributes', () => {
const button = spectator.query('.mobile-back-button');
const button = spectator.query('#mobile-back-button');
expect(button).toBeTruthy();
expect(button).toHaveAttribute('tabindex', '0');
expect(button).toHaveAttribute('aria-label', 'Back');
});

it('should emit onClose when the button is clicked', () => {
const onCloseSpy = jest.spyOn(spectator.component.onClose, 'emit');
spectator.click('.mobile-back-button');
spectator.click('#mobile-back-button');
expect(onCloseSpy).toHaveBeenCalled();
});

it('should emit onClose when the Enter key is pressed', () => {
const onCloseSpy = jest.spyOn(spectator.component.onClose, 'emit');
spectator.dispatchKeyboardEvent('.mobile-back-button', 'keydown', 'Enter');
spectator.dispatchKeyboardEvent('#mobile-back-button', 'keydown', 'Enter');
expect(onCloseSpy).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<div class="container">
<div class="table-container">
<ng-content select="[master]"></ng-content>
</div>

@if (selectedItem()) {
<div
class="details-container"
ixDetailsHeight
[class.details-container-mobile]="showMobileDetails()"
>
<div class="header">
<h3 class="title">
<div class="mobile-prefix">
<ix-mobile-back-button
(onClose)="toggleShowMobileDetails(false)"
></ix-mobile-back-button>
{{ 'Details for' | translate }}
</div>

<span class="prefix">
{{ 'Details for' | translate }}
</span>

<span class="name">
<ng-content select="[detail-name]"></ng-content>
</span>
</h3>
</div>

<div>
<ng-content select="[detail]"></ng-content>
</div>
</div>
}
</div>

Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
@import 'scss-imports/variables';
@import 'mixins/layout';
@import 'mixins/cards';

:host {
&::ng-deep {
@include tree-node-with-details-container;

.cards {
@include details-cards();

@media (max-width: $breakpoint-tablet) {
overflow: hidden;
}

.card {
@include details-card();
margin: 0;
}
}
}
}

.details-container {
flex-direction: column;
overflow: auto;
}

.header {
background: var(--bg1);
color: var(--fg1);
position: sticky;
top: 0;
z-index: 5;

@media (max-width: calc($breakpoint-hidden - 1px)) {
border-bottom: solid 1px var(--lines);
margin: 0 16px 16px 0;
position: static;
}
}

.title {
align-items: center;
color: var(--fg2);
display: flex;
gap: 8px;
margin-bottom: 12px;
margin-top: 20px;
min-height: 36px;

@media (max-width: $breakpoint-tablet) {
align-items: flex-start;
flex-direction: column;
gap: unset;
max-width: 100%;
width: 100%;
}

@media (max-width: calc($breakpoint-hidden - 1px)) {
margin-top: 0;
}

.mobile-prefix {
align-items: center;
display: none;

@media (max-width: $breakpoint-hidden) {
display: flex;
max-width: 50%;
opacity: 0.85;
}

@media (max-width: $breakpoint-tablet) {
max-width: 100%;
width: 100%;
}
}

.prefix {
display: inline;

@media (max-width: $breakpoint-hidden) {
display: none;
}
}

.name {
@media (max-width: $breakpoint-tablet) {
margin-left: 40px;
}
}
}
Loading

0 comments on commit 95d6d15

Please sign in to comment.