Skip to content

Commit

Permalink
feat(components/core): add scroll shadow directive (#2537) (#2543)
Browse files Browse the repository at this point in the history
  • Loading branch information
blackbaud-sky-build-user authored Jul 23, 2024
1 parent 03e6062 commit e173771
Show file tree
Hide file tree
Showing 10 changed files with 499 additions and 143 deletions.
3 changes: 3 additions & 0 deletions libs/components/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ export { SkyResizeObserverService } from './lib/modules/resize-observer/resize-o

export { SkyScreenReaderLabelDirective } from './lib/modules/screen-reader-label/screen-reader-label.directive';

export { SkyScrollShadowDirective } from './lib/modules/scroll-shadow/scroll-shadow.directive';
export { SkyScrollShadowEventArgs } from './lib/modules/scroll-shadow/scroll-shadow-event-args';

export { SkyScrollableHostService } from './lib/modules/scrollable-host/scrollable-host.service';

export { SkyStackingContext } from './lib/modules/stacking-context/stacking-context';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<div class="scroll-shadow-test-wrapper">
<div
class="scroll-shadow-test-header"
[ngStyle]="{
'box-shadow': scrollShadow?.topShadow ?? 'none'
}"
></div>
<div
class="scroll-shadow-test-body"
(skyScrollShadow)="scrollShadowChange($event)"
[skyScrollShadowEnabled]="enabled"
>
<span
class="scroll-shadow-test-content"
[ngStyle]="{
'height.px': height
}"
></span>
</div>
<div
class="scroll-shadow-test-footer"
[ngStyle]="{
'box-shadow': scrollShadow?.bottomShadow ?? 'none'
}"
></div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
:is(.scroll-shadow-test-header, .scroll-shadow-test-footer) {
height: 100px;
}

.scroll-shadow-test-body {
overflow-y: scroll;
display: block;
max-height: calc(100vh - 200px);
}

.scroll-shadow-test-content {
display: block;
}

.scroll-shadow-test-wrapper {
max-height: 100vh;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, inject } from '@angular/core';

import { SkyScrollShadowEventArgs } from '../scroll-shadow-event-args';
import { SkyScrollShadowDirective } from '../scroll-shadow.directive';

@Component({
selector: 'sky-scroll-shadow-fixture',
styleUrls: ['./scroll-shadow.component.fixture.scss'],
templateUrl: './scroll-shadow.component.fixture.html',
imports: [CommonModule, SkyScrollShadowDirective],
standalone: true,
})
export class ScrollShadowFixtureComponent {
public enabled = true;
public height = 400;
public scrollShadow: SkyScrollShadowEventArgs | undefined;

#changeDetector = inject(ChangeDetectorRef);

public scrollShadowChange(args: SkyScrollShadowEventArgs): void {
this.scrollShadow = args;
this.#changeDetector.markForCheck();
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @internal
*/
export interface SkyModalScrollShadowEventArgs {
export interface SkyScrollShadowEventArgs {
bottomShadow: string;

topShadow: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing';
import { SkyAppTestUtility } from '@skyux-sdk/testing';

import { ScrollShadowFixtureComponent } from './fixtures/scroll-shadow.component.fixture';

// Wait for the next change detection cycle. This avoids having nested setTimeout() calls
// and using the Jasmine done() function.
function waitForMutationObserver(): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => resolve());
});
}

describe('Scroll shadow directive', () => {
function getScrollBody(): HTMLElement | null {
return document.querySelector<HTMLElement>('.scroll-shadow-test-body');
}

function getScrollFooter(): HTMLElement | null {
return document.querySelector<HTMLElement>('.scroll-shadow-test-footer');
}

function getScrollHeader(): HTMLElement | null {
return document.querySelector<HTMLElement>('.scroll-shadow-test-header');
}

function scrollElement(element: HTMLElement | null, yDistance: number): void {
if (element) {
element.scrollTop = yDistance;
SkyAppTestUtility.fireDomEvent(element, 'scroll');
fixture.detectChanges();
}
}

function validateShadow(
el: HTMLElement | null,
expectedAlpha?: number,
): void {
if (!el) {
fail('Element not provided');
return;
}

const boxShadowStyle = getComputedStyle(el).boxShadow;

if (expectedAlpha) {
const rgbaMatch = boxShadowStyle.match(
/rgba\(0,\s*0,\s*0,\s*([0-9.]*)\)/,
);

if (!(rgbaMatch && rgbaMatch[1])) {
fail('No shadow found');
} else {
const alpha = parseFloat(rgbaMatch[1]);

expect(expectedAlpha).toBeCloseTo(alpha, 2);
}
} else {
expect(boxShadowStyle).toBe('none');
}
}

let fixture: ComponentFixture<ScrollShadowFixtureComponent>;
let cmp: ScrollShadowFixtureComponent;

beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
imports: [ScrollShadowFixtureComponent],
});

fixture = TestBed.createComponent(ScrollShadowFixtureComponent);
cmp = fixture.componentInstance;
fixture.detectChanges();
tick();
fixture.detectChanges();
}));

it('should not show a shadow when the body is not scrollable when disabled', async () => {
cmp.enabled = false;
fixture.detectChanges();
await waitForMutationObserver();
fixture.detectChanges();

validateShadow(getScrollFooter());
validateShadow(getScrollHeader());
});

it('should not show a shadow when the body is scrollable when disabled', async () => {
cmp.height = 800;
cmp.enabled = false;
fixture.detectChanges();
await waitForMutationObserver();
fixture.detectChanges();

validateShadow(getScrollFooter());
validateShadow(getScrollHeader());
});

it('should not show a shadow when the body is not scrollable', async () => {
await waitForMutationObserver();
fixture.detectChanges();

validateShadow(getScrollFooter());
validateShadow(getScrollHeader());
});

it('should progressively show a drop shadow as the modal content scrolls', async () => {
const headerEl = getScrollHeader();
const contentEl = getScrollBody();
const footerEl = getScrollFooter();

if (!contentEl) {
fail('Content element not found');
return;
}

cmp.height = 800;
fixture.detectChanges();
await waitForMutationObserver();
fixture.detectChanges();

scrollElement(contentEl, 0);
validateShadow(headerEl);
validateShadow(footerEl, 0.3);

scrollElement(contentEl, 15);
validateShadow(headerEl, 0.15);
validateShadow(footerEl, 0.3);

scrollElement(contentEl, 30);
validateShadow(headerEl, 0.3);
validateShadow(footerEl, 0.3);

scrollElement(contentEl, 31);
validateShadow(headerEl, 0.3);
validateShadow(footerEl, 0.3);

scrollElement(
contentEl,
contentEl.scrollHeight - 15 - contentEl.clientHeight,
);
validateShadow(headerEl, 0.3);
validateShadow(footerEl, 0.15);

scrollElement(contentEl, contentEl.scrollHeight - contentEl.clientHeight);
validateShadow(headerEl, 0.3);
validateShadow(footerEl);
});

it('should update the shadow on window resize', async () => {
const headerEl = getScrollHeader();
const contentEl = getScrollBody();
const footerEl = getScrollFooter();

if (!contentEl) {
fail('Content element not found');
return;
}

cmp.height = 800;
fixture.detectChanges();
await waitForMutationObserver();
fixture.detectChanges();

validateShadow(headerEl);
validateShadow(footerEl, 0.3);

spyOnProperty(Element.prototype, 'scrollTop').and.returnValue(15);
SkyAppTestUtility.fireDomEvent(window, 'resize');
fixture.detectChanges();

validateShadow(headerEl, 0.15);
validateShadow(footerEl, 0.3);
});
});
Loading

0 comments on commit e173771

Please sign in to comment.