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

[UI]: Add component max-lines with toggle #729

Merged
merged 9 commits into from
Dec 20, 2023
2 changes: 2 additions & 0 deletions apps/datahub-e2e/src/e2e/datasetDetailPage.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ describe('dataset pages', () => {
.find('[id="about"]')
.find('gn-ui-metadata-info')
.find('gn-ui-content-ghost')
.find('gn-ui-max-lines')
.children('div')
.children('p')
.should(($element) => {
const text = $element.text().trim()
Expand Down
Empty file.
15 changes: 15 additions & 0 deletions libs/ui/elements/src/lib/max-lines/max-lines.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div
#container
class="max-lines overflow-hidden transition-[max-height] duration-300"
[ngClass]="isExpanded ? 'ease-in' : 'ease-out'"
[style.maxHeight]="maxHeight"
>
<ng-content></ng-content>
</div>
<div
*ngIf="showToggleButton"
(click)="toggleDisplay()"
class="text-primary cursor-pointer pt-2.5"
>
{{ (isExpanded ? 'ui.readLess' : 'ui.readMore') | translate }}
</div>
119 changes: 119 additions & 0 deletions libs/ui/elements/src/lib/max-lines/max-lines.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'

import { MaxLinesComponent } from './max-lines.component'
import { Component } from '@angular/core'

@Component({
template: `
<gn-ui-max-lines [maxLines]="maxLines">
<div class="test-content" style="height: 70px; max-height:80px;">
<p style="height: 40px;">Lorem ipsum dolor sit amet</p>
</div>
</gn-ui-max-lines>
`,
})
class TestHostComponent {
maxLines: number
}
describe('MaxLinesComponent', () => {
let fixture: ComponentFixture<TestHostComponent>
let hostComponent: TestHostComponent
let maxLinesComponent: MaxLinesComponent

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [MaxLinesComponent, TestHostComponent],
})
fixture = TestBed.createComponent(TestHostComponent)
hostComponent = fixture.componentInstance
maxLinesComponent = fixture.debugElement.children[0].componentInstance
})

it('should create', () => {
fixture.detectChanges()
expect(maxLinesComponent).toBeTruthy()
})

it('should render content correctly', () => {
hostComponent.maxLines = 10
fixture.detectChanges()

const maxLinesElement: HTMLElement =
fixture.nativeElement.querySelector('.max-lines')
expect(maxLinesElement.childNodes[0].textContent).toBe(
'Lorem ipsum dolor sit amet'
)
})

describe('should adjust maxHeight based on content height', () => {
const contentHeight = 120
beforeEach(() => {
jest
.spyOn(
fixture.nativeElement.querySelector('.test-content'),
'getBoundingClientRect'
)
.mockReturnValueOnce({
left: 100,
top: 50,
right: 20,
bottom: 10,
x: 30,
y: 40,
widht: 150,
height: contentHeight,
})
})
it('use content height if content height is smaller than max space', () => {
hostComponent.maxLines = 10

fixture.detectChanges()

expect(maxLinesComponent.maxHeight).toBe(`${contentHeight}px`)
})

it('use max space height if content height is bigger than max space', () => {
hostComponent.maxLines = 2

const contentElement =
fixture.nativeElement.querySelector('.test-content')
fixture.detectChanges()

const maxSpace =
maxLinesComponent.getLineHeight(contentElement) *
maxLinesComponent.maxLines

expect(maxLinesComponent.maxHeight).toBe(`${maxSpace}px`)
})

it('should show "Show More" button for long content', () => {
hostComponent.maxLines = 2

fixture.detectChanges()

expect(maxLinesComponent.showToggleButton).toBeTruthy()
})

it('should toggle display when "Show More" button is clicked', () => {
hostComponent.maxLines = 2

const contentElement =
fixture.nativeElement.querySelector('.test-content')
fixture.detectChanges()

maxLinesComponent.toggleDisplay()

expect(maxLinesComponent.isExpanded).toBeTruthy()
expect(maxLinesComponent.maxHeight).toBe(
`${
maxLinesComponent.maxLines *
maxLinesComponent.getLineHeight(contentElement)
}px`
)
})
})

afterEach(() => {
fixture.destroy()
})
})
40 changes: 40 additions & 0 deletions libs/ui/elements/src/lib/max-lines/max-lines.component.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
applicationConfig,
componentWrapperDecorator,
Meta,
moduleMetadata,
StoryObj,
} from '@storybook/angular'
import { MaxLinesComponent } from './max-lines.component'

export default {
title: 'Elements/MaxLinesComponent',
component: MaxLinesComponent,
decorators: [
moduleMetadata({
declarations: [MaxLinesComponent],
imports: [],
}),
applicationConfig({
providers: [],
}),
componentWrapperDecorator(
(story) => `<div style="max-width: 800px">${story}</div>`
),
],
} as Meta<MaxLinesComponent>

const largeContent = `<div><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin purus elit, tincidunt et gravida sit amet, mattis eget orci. Suspendisse dignissim magna sed neque rutrum lobortis. Aenean vitae quam sapien. Phasellus eleifend tortor ac imperdiet tristique. Curabitur aliquet mauris tristique, iaculis est sit amet, pulvinar ipsum. Maecenas lacinia varius felis sit amet tempor. Curabitur pulvinar ipsum eros, quis accumsan odio hendrerit sit amet.

Vestibulum placerat posuere lectus, sed lacinia orci sagittis consectetur. Duis eget eros consectetur, pretium nulla semper, pretium justo. Nullam facilisis maximus ipsum, a tempus erat eleifend non. Nulla nec lorem sed lorem porttitor ornare. Aliquam condimentum ante at laoreet dignissim. Vestibulum vel laoreet libero. Nam finibus augue ut ligula vulputate porta. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aliquam erat volutpat. Nunc lorem nunc, interdum sed leo vel, vestibulum venenatis diam. Nam eget dignissim purus. Cras convallis leo sed porta tristique.</p></div>`

export const Primary: StoryObj<MaxLinesComponent> = {
args: {
maxLines: 6,
},
render: (args) => ({
template: `<div>
<gn-ui-max-lines [maxLines]=${args.maxLines}>${largeContent}</gn-ui-max-lines>
</div>`,
}),
}
83 changes: 83 additions & 0 deletions libs/ui/elements/src/lib/max-lines/max-lines.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {
Component,
Input,
ElementRef,
ChangeDetectionStrategy,
AfterViewInit,
ViewChild,
OnDestroy,
ChangeDetectorRef,
} from '@angular/core'

@Component({
selector: 'gn-ui-max-lines',
templateUrl: './max-lines.component.html',
styleUrls: ['./max-lines.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MaxLinesComponent implements AfterViewInit, OnDestroy {
@Input() maxLines = 6

isExpanded = false
maxHeight = ''
showToggleButton = false
observer: ResizeObserver

@ViewChild('container') container!: ElementRef

constructor(private cdr: ChangeDetectorRef) {}

ngAfterViewInit() {
this.calculateMaxHeight()

this.observer = new ResizeObserver((mutations) => {
mutations.forEach(() => {
this.calculateMaxHeight()
})
})

this.observer.observe(this.container.nativeElement.children[0])
}

toggleDisplay() {
this.isExpanded = !this.isExpanded
this.calculateMaxHeight()
}

calculateMaxHeight() {
const containerElement = this.container.nativeElement
const contentElement = containerElement.children[0]
const contentHeight = contentElement.getBoundingClientRect().height

if (contentHeight) {
if (contentHeight > this.maxLines * this.getLineHeight(contentElement)) {
this.showToggleButton = true

this.maxHeight = this.isExpanded
? `${contentHeight}px`
: `${this.maxLines * this.getLineHeight(contentElement)}px`
} else {
this.showToggleButton = false
this.maxHeight = `${contentHeight}px`
}
containerElement.setAttribute(
'style',
`height: ${this.maxHeight}; max-height: ${this.maxHeight}; overflow: hidden`
)

this.cdr.detectChanges()
}
}

getLineHeight(element: HTMLElement): number {
const computedStyle = window.getComputedStyle(element)
const lineHeight = parseFloat(computedStyle.lineHeight)
const fontSize = parseFloat(computedStyle.fontSize || '14')
const result = isNaN(lineHeight) ? fontSize * 1.2 : lineHeight // Use a default if line height is not specified
return result
}

ngOnDestroy(): void {
this.observer.unobserve(this.container.nativeElement.children[0])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
</p>
<div class="mb-6 md-description sm:mb-4 sm:pr-16">
<gn-ui-content-ghost ghostClass="h-32" [showContent]="fieldReady('abstract')">
<p
class="whitespace-pre-line break-words"
[innerHTML]="metadata.abstract"
*ngIf="metadata.abstract"
></p>
<gn-ui-max-lines [maxLines]="6" *ngIf="metadata.abstract">
<p
class="whitespace-pre-line break-words"
[innerHTML]="metadata.abstract"
></p>
</gn-ui-max-lines>
</gn-ui-content-ghost>
</div>

Expand Down
2 changes: 2 additions & 0 deletions libs/ui/elements/src/lib/ui-elements.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { AvatarComponent } from './avatar/avatar.component'
import { UserPreviewComponent } from './user-preview/user-preview.component'
import { GnUiLinkifyDirective } from './metadata-info/linkify.directive'
import { PaginationButtonsComponent } from './pagination-buttons/pagination-buttons.component'
import { MaxLinesComponent } from './max-lines/max-lines.component'

@NgModule({
imports: [
Expand Down Expand Up @@ -61,6 +62,7 @@ import { PaginationButtonsComponent } from './pagination-buttons/pagination-butt
UserPreviewComponent,
GnUiLinkifyDirective,
PaginationButtonsComponent,
MaxLinesComponent,
],
exports: [
MetadataInfoComponent,
Expand Down
7 changes: 7 additions & 0 deletions libs/ui/elements/src/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ getTestBed().initTestEnvironment(
platformBrowserDynamicTesting(),
{ teardown: { destroyAfterEach: false } }
)

class ResizeObserverMock {
observe = jest.fn()
unobserve = jest.fn()
}

;(window as any).ResizeObserver = ResizeObserverMock
2 changes: 2 additions & 0 deletions translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@
"tooltip.html.copy": "HTML kopieren",
"tooltip.url.copy": "URL kopieren",
"tooltip.url.open": "URL öffnen",
"ui.readLess": "Weniger lesen",
"ui.readMore": "Weiterlesen",
"wfs.featuretype.notfound": "Kein passender Feature-Typ wurde im Dienst gefunden",
"wfs.geojsongml.notsupported": "Dieser Dienst unterstützt das GeoJSON- oder GML-Format nicht",
"wfs.unreachable.cors": "Der Dienst konnte aufgrund von CORS-Beschränkungen nicht erreicht werden",
Expand Down
2 changes: 2 additions & 0 deletions translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@
"tooltip.html.copy": "Copy HTML",
"tooltip.url.copy": "Copy URL",
"tooltip.url.open": "Open URL",
"ui.readLess": "Read less",
"ui.readMore": "Read more",
"wfs.featuretype.notfound": "No matching feature type was found in the service",
"wfs.geojsongml.notsupported": "This service does not support the GeoJSON or GML format",
"wfs.unreachable.cors": "The service could not be reached because of CORS limitations",
Expand Down
2 changes: 2 additions & 0 deletions translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@
"tooltip.html.copy": "",
"tooltip.url.copy": "",
"tooltip.url.open": "",
"ui.readLess": "",
"ui.readMore": "",
"wfs.featuretype.notfound": "",
"wfs.geojsongml.notsupported": "",
"wfs.unreachable.cors": "",
Expand Down
2 changes: 2 additions & 0 deletions translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@
"tooltip.html.copy": "Copier le HTML",
"tooltip.url.copy": "Copier l'URL",
"tooltip.url.open": "Ouvrir l'URL",
"ui.readLess": "Réduire",
"ui.readMore": "Lire la suite",
"wfs.featuretype.notfound": "La classe d'objet n'a pas été trouvée dans le service",
"wfs.geojsongml.notsupported": "Le service ne supporte pas le format GeoJSON ou GML",
"wfs.unreachable.cors": "Le service n'est pas accessible en raison de limitations CORS",
Expand Down
2 changes: 2 additions & 0 deletions translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@
"tooltip.html.copy": "",
"tooltip.url.copy": "",
"tooltip.url.open": "",
"ui.readLess": "",
"ui.readMore": "",
"wfs.featuretype.notfound": "",
"wfs.geojsongml.notsupported": "",
"wfs.unreachable.cors": "",
Expand Down
2 changes: 2 additions & 0 deletions translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@
"tooltip.html.copy": "",
"tooltip.url.copy": "",
"tooltip.url.open": "",
"ui.readLess": "",
"ui.readMore": "",
"wfs.featuretype.notfound": "",
"wfs.geojsongml.notsupported": "",
"wfs.unreachable.cors": "",
Expand Down
2 changes: 2 additions & 0 deletions translations/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@
"tooltip.html.copy": "",
"tooltip.url.copy": "",
"tooltip.url.open": "",
"ui.readLess": "",
"ui.readMore": "",
"wfs.featuretype.notfound": "",
"wfs.geojsongml.notsupported": "",
"wfs.unreachable.cors": "",
Expand Down
2 changes: 2 additions & 0 deletions translations/sk.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@
"tooltip.html.copy": "",
"tooltip.url.copy": "",
"tooltip.url.open": "",
"ui.readLess": "",
"ui.readMore": "",
"wfs.featuretype.notfound": "",
"wfs.geojsongml.notsupported": "",
"wfs.unreachable.cors": "",
Expand Down
Loading