Skip to content

Commit

Permalink
Merge branch '11.x.x' into toast-harness
Browse files Browse the repository at this point in the history
  • Loading branch information
Blackbaud-SandhyaRajasabeson authored Feb 12, 2025
2 parents 6ac91e5 + 61269c8 commit 29b6664
Show file tree
Hide file tree
Showing 12 changed files with 295 additions and 38 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## [11.41.0](https://github.com/blackbaud/skyux/compare/11.40.0...11.41.0) (2025-02-11)


### Features

* **components/core:** add SkyViewkeeper support for SkyAppViewportService properties ([#3120](https://github.com/blackbaud/skyux/issues/3120)) ([f994ff7](https://github.com/blackbaud/skyux/commit/f994ff75cb27c00a5cfdadad5c55aebabb001f8c))


### Bug Fixes

* **components/datetime:** address flaky display behavior of key date tooltips ([#3128](https://github.com/blackbaud/skyux/issues/3128)) ([bd94278](https://github.com/blackbaud/skyux/commit/bd942789232b3affeee8303190ee234aee7d27ef))

## [11.40.0](https://github.com/blackbaud/skyux/compare/11.39.0...11.40.0) (2025-02-10)


Expand Down
15 changes: 11 additions & 4 deletions libs/components/core/src/lib/modules/dock/dock.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ const STYLE_ELEMENT_SELECTOR =

const isIE = window.navigator.userAgent.indexOf('rv:11.0') >= 0;

// 16ms is the fakeAsync time for requestAnimationFrame, simulating ~60fps.
// https://github.com/angular/angular/blob/19.1.x/packages/zone.js/lib/zone-spec/fake-async-test.ts#L682-L693
function tickForAnimationFrame(): void {
tick(16);
}

describe('Dock component', () => {
let fixture: ComponentFixture<DockFixtureComponent>;
let mutationCallbacks: (() => void)[];
Expand All @@ -38,7 +44,7 @@ describe('Dock component', () => {
fixture.detectChanges();
fixture.componentInstance.itemConfigs = itemConfigs;
fixture.detectChanges();
tick();
tickForAnimationFrame();
}

/**
Expand Down Expand Up @@ -70,7 +76,7 @@ describe('Dock component', () => {
fixture.detectChanges();
tick(250); // Respect the RxJS debounceTime.
fixture.detectChanges();
tick();
tickForAnimationFrame();
}

function getStyleElement(): HTMLStyleElement {
Expand Down Expand Up @@ -343,6 +349,7 @@ describe('Dock component', () => {
reserveSpace('left', 20);

fixture.detectChanges();
tickForAnimationFrame();

const dockEl = getDockEl();
const actionBarBounds = dockEl.getBoundingClientRect();
Expand Down Expand Up @@ -370,7 +377,7 @@ describe('Dock component', () => {
]);

fixture.detectChanges();
tick();
tickForAnimationFrame();

const dockStyle = getDockStyle();

Expand Down Expand Up @@ -398,7 +405,7 @@ describe('Dock component', () => {
]);

fixture.detectChanges();
tick();
tickForAnimationFrame();

const dockStyle = getDockStyle();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ export class SkyViewkeeperHostOptions implements SkyViewkeeperOptions {
public verticalOffsetEl?: HTMLElement;

public viewportMarginTop?: number;

public viewportMarginProperty?: `--${string}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,9 @@ export interface SkyViewkeeperOptions {
* Reserved space in pixels at the top of the viewport.
*/
viewportMarginTop?: number;

/**
* Custom CSS property for reserved space at the top of the viewport.
*/
viewportMarginProperty?: `--${string}`;
}
38 changes: 36 additions & 2 deletions libs/components/core/src/lib/modules/viewkeeper/viewkeeper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@ describe('Viewkeeper', () => {
let vks: SkyViewkeeper[];

function scrollWindowTo(x: number, y: number): void {
window.scrollTo(x, y);
window.scrollTo({
top: y,
left: x,
behavior: 'instant',
});
SkyAppTestUtility.fireDomEvent(window, 'scroll');
}

function scrollScrollableHost(x: number, y: number): void {
scrollableHostEl.scrollTo(x, y);
scrollableHostEl.scrollTo({
top: y,
left: x,
behavior: 'instant',
});
SkyAppTestUtility.fireDomEvent(scrollableHostEl, 'scroll');
}

Expand Down Expand Up @@ -213,6 +221,32 @@ describe('Viewkeeper', () => {
);
});

it('should support viewportMarginProperty', () => {
vks.push(
new SkyViewkeeper({
el,
boundaryEl,
viewportMarginProperty: '--test-viewport-top',
setWidth: true,
}),
);

scrollWindowTo(0, 10);
validatePinned(el, false);
document.documentElement.style.setProperty('--test-viewport-top', '2px');
scrollWindowTo(40, 100);
validatePinned(el, true, 0, 2);
expect(el.style.marginTop).toBe(
'calc(0px + var(--test-viewport-top, 0))',
);

document.documentElement.style.setProperty('--test-viewport-top', '12px');
validatePinned(el, true, 0, 12);

document.documentElement.style.removeProperty('--test-viewport-top');
document.body.style.removeProperty('--test-viewport-top');
});

describe('ResizeObserver', () => {
const NativeResizeObserver = ResizeObserver;

Expand Down
24 changes: 20 additions & 4 deletions libs/components/core/src/lib/modules/viewkeeper/viewkeeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,15 @@ function setElPosition(
top: number | string,
width: number | string,
marginTop: number | string,
marginTopProperty: string | undefined,
clipTop: number | string,
clipLeft: number | string,
): void {
el.style.top = px(top);
el.style.left = px(left);
el.style.marginTop = px(marginTop);
el.style.marginTop = marginTopProperty
? `calc(${px(marginTop)} + var(${marginTopProperty}, 0))`
: px(marginTop);
el.style.clipPath =
clipTop || clipLeft ? `inset(${px(clipTop)} 0 0 ${px(clipLeft)})` : 'none';

Expand Down Expand Up @@ -117,6 +120,8 @@ export class SkyViewkeeper {

#viewportMarginTop = 0;

#viewportMarginProperty: `--${string}` | undefined;

#currentElFixedLeft: number | undefined;

#currentElFixedTop: number | undefined;
Expand Down Expand Up @@ -163,6 +168,7 @@ export class SkyViewkeeper {
// Only set viewport margin if the scrollable host is undefined.
if (!this.#scrollableHost) {
this.#viewportMarginTop = options.viewportMarginTop ?? 0;
this.#viewportMarginProperty = options.viewportMarginProperty;
}

this.#syncElPositionHandler = (): void =>
Expand Down Expand Up @@ -272,7 +278,7 @@ export class SkyViewkeeper {
width = 'auto';
}

setElPosition(el, '', '', width, '', 0, 0);
setElPosition(el, '', '', width, '', '', 0, 0);
}

#calculateVerticalOffset(): number {
Expand Down Expand Up @@ -303,9 +309,18 @@ export class SkyViewkeeper {
anchorTop = getOffset(el, this.#scrollableHost).top;
}

let viewportMarginTop = this.#viewportMarginTop;
const viewportMarginProperty =
this.#viewportMarginProperty &&
getComputedStyle(document.body).getPropertyValue(
this.#viewportMarginProperty,
);
if (viewportMarginProperty) {
viewportMarginTop += parseInt(viewportMarginProperty, 10);
}

const doFixEl =
boundaryInfo.scrollTop + verticalOffset + this.#viewportMarginTop >
anchorTop;
boundaryInfo.scrollTop + verticalOffset + viewportMarginTop > anchorTop;

return doFixEl;
}
Expand Down Expand Up @@ -413,6 +428,7 @@ export class SkyViewkeeper {
fixedStyles.elFixedTop,
width,
this.#viewportMarginTop,
this.#viewportMarginProperty,
fixedStyles.elClipTop,
fixedStyles.elClipLeft,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ export interface SkyAppViewportReserveArgs {
* The number of pixels to reserve.
*/
size: number;

/**
* Only reserve space when this element is in view.
*/
reserveForElement?: HTMLElement;
}
102 changes: 100 additions & 2 deletions libs/components/theme/src/lib/viewport/viewport.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { TestBed, waitForAsync } from '@angular/core/testing';

import { ReplaySubject } from 'rxjs';

import { SkyAppViewportReservedPositionType } from './viewport-reserve-position-type';
Expand All @@ -18,14 +20,14 @@ describe('Viewport service', () => {
}

beforeEach(() => {
svc = new SkyAppViewportService(document);
svc = TestBed.inject(SkyAppViewportService);
});

it('should return an observable when the content is visible', () => {
expect(svc.visible instanceof ReplaySubject).toEqual(true);
});

it('should reserve and unreserve space', () => {
it('should reserve and unreserve space', async () => {
svc.reserveSpace({
id: 'left-test',
position: 'left',
Expand All @@ -50,6 +52,7 @@ describe('Viewport service', () => {
size: 50,
});

await new Promise((resolve) => requestAnimationFrame(resolve));
validateViewportSpace('left', 20);
validateViewportSpace('top', 30);
validateViewportSpace('right', 40);
Expand All @@ -60,9 +63,104 @@ describe('Viewport service', () => {
svc.unreserveSpace('right-test');
svc.unreserveSpace('bottom-test');

await new Promise((resolve) => requestAnimationFrame(resolve));
validateViewportSpace('left', 0);
validateViewportSpace('top', 0);
validateViewportSpace('right', 0);
validateViewportSpace('bottom', 0);
});

it('should reserve and unreserve space for elements that scroll out of view', waitForAsync(async () => {
const viewportHeight = window.innerHeight;

expect(viewportHeight).toBeGreaterThan(50);

function isInViewport(element: Element): boolean {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
rect.right <=
(window.innerWidth || document.documentElement.clientWidth)
);
}

const container = document.createElement('div');
container.style.height = '200vh';
container.style.position = 'relative';
container.style.marginTop = '20px';
container.appendChild(document.createTextNode('Scroll down'));

const item1 = document.createElement('div');
item1.style.backgroundColor = 'lightblue';
item1.style.height = '50px';
item1.style.width = '100px';
item1.style.overflow = 'hidden';
item1.style.position = 'absolute';
item1.style.top = '0';
item1.style.left = '0';
item1.appendChild(document.createTextNode('Item 1'));
container.appendChild(item1);

const item2 = document.createElement('div');
// eslint-disable-next-line @cspell/spellchecker
item2.style.backgroundColor = 'lightgreen';
item2.style.height = '54px';
item2.style.width = '100px';
item2.style.overflow = 'hidden';
item2.style.position = 'absolute';
item2.style.top = `${viewportHeight + 50}px`;
item2.style.left = '0';
item2.appendChild(document.createTextNode('Item 2'));
container.appendChild(item2);

document.body.appendChild(container);

svc.reserveSpace({
id: 'item1-test',
position: 'top',
size: 50,
reserveForElement: item1,
});

svc.reserveSpace({
id: 'item2-test',
position: 'top',
size: 54,
reserveForElement: item2,
});

await new Promise((resolve) => setTimeout(resolve, 32));
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(isInViewport(item1)).toBeTrue();
validateViewportSpace('top', 50);
window.scrollTo({
top: viewportHeight + 50,
behavior: 'instant',
});
await new Promise((resolve) => requestAnimationFrame(resolve));
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(isInViewport(item1)).toBeFalse();
expect(isInViewport(item2)).toBeTrue();
validateViewportSpace('top', 54);

svc.unreserveSpace('item2-test');
await new Promise((resolve) => requestAnimationFrame(resolve));
validateViewportSpace('top', 0);
window.scrollTo({
top: 0,
behavior: 'instant',
});
await new Promise((resolve) => setTimeout(resolve, 32));
await new Promise((resolve) => requestAnimationFrame(resolve));
expect(isInViewport(item1)).toBeTrue();
validateViewportSpace('top', 50);
svc.unreserveSpace('item1-test');
await new Promise((resolve) => requestAnimationFrame(resolve));
validateViewportSpace('top', 0);

document.body.removeChild(container);
}));
});
Loading

0 comments on commit 29b6664

Please sign in to comment.