perf: Improve performance of the carousel auto-height functionality (#2094)
This commit is contained in:
+1
-1
@@ -31,7 +31,7 @@
|
||||
"crypto": "^1.0.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"date-fns-tz": "^3.1.3",
|
||||
"embla-carousel": "^8.3.0",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"embla-carousel-wheel-gestures": "^8.0.1",
|
||||
"ha-nunjucks": "^1.3.0",
|
||||
"home-assistant-js-websocket": "^9.4.0",
|
||||
|
||||
@@ -233,6 +233,7 @@ export class MediaActionsController {
|
||||
child.addEventListener('advanced-camera-card:media:loaded', eventListener);
|
||||
}
|
||||
}
|
||||
|
||||
protected async _intersectionHandler(
|
||||
entries: IntersectionObserverEntry[],
|
||||
): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { debounce, isEqual } from 'lodash-es';
|
||||
|
||||
export class MediaHeightController {
|
||||
private _host: HTMLElement;
|
||||
private _selector: string;
|
||||
|
||||
private _root: HTMLElement | DocumentFragment | null = null;
|
||||
private _children: HTMLElement[] = [];
|
||||
private _selectedChild: HTMLElement | null = null;
|
||||
|
||||
private _mutationObserver = new MutationObserver(() => this._initializeRoot());
|
||||
private _resizeObserver = new ResizeObserver(() => this._debouncedSetHeight());
|
||||
|
||||
private _debouncedSetHeight = debounce(
|
||||
() => this._setHeight(),
|
||||
// Balancing act: Debounce to avoid excessive calls to setHeight, when new
|
||||
// media is loading the player may be a much smaller height momentarily.
|
||||
300,
|
||||
{
|
||||
trailing: true,
|
||||
leading: false,
|
||||
},
|
||||
);
|
||||
|
||||
constructor(host: HTMLElement, selector: string) {
|
||||
this._host = host;
|
||||
this._selector = selector;
|
||||
}
|
||||
|
||||
public setRoot(root: HTMLElement | DocumentFragment): void {
|
||||
if (root === this._root) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._root = root;
|
||||
this._mutationObserver.disconnect();
|
||||
this._mutationObserver.observe(this._root, {
|
||||
childList: true,
|
||||
});
|
||||
this._initializeRoot();
|
||||
}
|
||||
|
||||
public setSelected(selectedIndex: number): void {
|
||||
const selectedChild: HTMLElement | undefined = this._children[selectedIndex];
|
||||
if (!selectedChild || selectedChild === this._selectedChild) {
|
||||
return;
|
||||
}
|
||||
this._selectedChild = selectedChild;
|
||||
|
||||
this._resizeObserver.disconnect();
|
||||
this._resizeObserver.observe(selectedChild);
|
||||
|
||||
this._debouncedSetHeight();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._mutationObserver.disconnect();
|
||||
this._resizeObserver.disconnect();
|
||||
|
||||
this._root = null;
|
||||
this._children = [];
|
||||
this._selectedChild = null;
|
||||
}
|
||||
|
||||
private _setHeight(): void {
|
||||
if (!this._selectedChild) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalHeight = this._host.style.maxHeight;
|
||||
|
||||
// Remove the height restriction to ensure the full max height. Example of
|
||||
// behavior without this: Chrome on Android will not correctly size if the
|
||||
// card is in fullscreen mode.
|
||||
this._host.style.maxHeight = '';
|
||||
|
||||
// Calculate the true height.
|
||||
const selectedHeight = this._selectedChild.getBoundingClientRect().height;
|
||||
|
||||
// Reset the original height so that browser transition animation can be
|
||||
// applied from the current to the target.
|
||||
this._host.style.maxHeight = originalHeight;
|
||||
|
||||
// Force the browser to reflow.
|
||||
this._selectedChild.getBoundingClientRect();
|
||||
|
||||
if (selectedHeight && !isNaN(selectedHeight) && selectedHeight > 0) {
|
||||
this._host.style.maxHeight = `${selectedHeight}px`;
|
||||
}
|
||||
}
|
||||
|
||||
private _initializeRoot(): void {
|
||||
const children = [
|
||||
...(this._root?.querySelectorAll<HTMLElement>(this._selector) ??
|
||||
/* istanbul ignore next: this path cannot be reached as root will always
|
||||
exist by the time the mutation observer is observing -- @preserve */
|
||||
[]),
|
||||
];
|
||||
if (isEqual(children, this._children)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._children = children;
|
||||
this._selectedChild = null;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { CameraManagerCameraMetadata } from '../../camera-manager/types.js';
|
||||
import { MicrophoneState } from '../../card-controller/types.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
||||
import { MediaHeightController } from '../../components-lib/media-height-controller.js';
|
||||
import { ZoomSettingsObserved } from '../../components-lib/zoom/types.js';
|
||||
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
||||
import { CameraConfig } from '../../config/schema/cameras.js';
|
||||
@@ -25,7 +26,6 @@ import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
||||
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
|
||||
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
|
||||
import { getStreamCameraID } from '../../utils/substream.js';
|
||||
import { getTextDirection } from '../../utils/text-direction.js';
|
||||
import { View } from '../../view/view.js';
|
||||
@@ -77,6 +77,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
protected _refCarousel: Ref<HTMLElement> = createRef();
|
||||
|
||||
protected _mediaActionsController = new MediaActionsController();
|
||||
protected _mediaHeightController = new MediaHeightController(this, '.embla__slide');
|
||||
|
||||
@state()
|
||||
protected _mediaHasLoaded = false;
|
||||
@@ -84,12 +85,15 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this._mediaHeightController.setRoot(this.renderRoot);
|
||||
|
||||
// Request update in order to reinitialize the media action controller.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._mediaActionsController.destroy();
|
||||
this._mediaHeightController.destroy();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -138,7 +142,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
}
|
||||
|
||||
protected _getPlugins(): EmblaCarouselPlugins {
|
||||
return [AutoMediaLoadedInfo(), AutoSize()];
|
||||
return [AutoMediaLoadedInfo()];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -389,6 +393,8 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
// Carousel is not filtered, so the targeted camera is always selected.
|
||||
this._mediaActionsController.setTarget(selectedCameraIndex, true);
|
||||
}
|
||||
|
||||
this._mediaHeightController.setSelected(selectedCameraIndex);
|
||||
}
|
||||
|
||||
public updated(changedProperties: PropertyValues): void {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { RemoveContextPropertyViewModifier } from '../../card-controller/view/modifiers/remove-context-property.js';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
||||
import { MediaHeightController } from '../../components-lib/media-height-controller.js';
|
||||
import { TransitionEffect } from '../../config/schema/common/transition-effect.js';
|
||||
import { CardWideConfig, configDefaults } from '../../config/schema/types.js';
|
||||
import { ViewerConfig } from '../../config/schema/viewer.js';
|
||||
@@ -26,7 +27,6 @@ import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { contentsChanged, setOrRemoveAttribute } from '../../utils/basic.js';
|
||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
||||
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
|
||||
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
|
||||
import { getTextDirection } from '../../utils/text-direction.js';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||
import { ViewMedia } from '../../view/item.js';
|
||||
@@ -85,18 +85,22 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
|
||||
protected _media: ViewMedia[] | null = null;
|
||||
protected _mediaActionsController = new MediaActionsController();
|
||||
protected _mediaHeightController = new MediaHeightController(this, '.embla__slide');
|
||||
protected _loadedMediaPlayerController: MediaPlayerController | null = null;
|
||||
protected _refCarousel: Ref<HTMLElement> = createRef();
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this._mediaHeightController.setRoot(this.renderRoot);
|
||||
|
||||
// Request update in order to reinitialize the media action controller.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._mediaActionsController.destroy();
|
||||
this._mediaHeightController.destroy();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -116,7 +120,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
* @returns A list of EmblaOptionsTypes.
|
||||
*/
|
||||
protected _getPlugins(): EmblaCarouselPlugins {
|
||||
return [AutoMediaLoadedInfo(), AutoSize()];
|
||||
return [AutoMediaLoadedInfo()];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,6 +412,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
? this.viewManagerEpoch?.manager.getView()?.camera === this.viewFilterCameraID
|
||||
: true,
|
||||
);
|
||||
this._mediaHeightController.setSelected(this._selected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
// Keep carousel controls relative to the media carousel itself.
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.embla {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
--video-max-height: none;
|
||||
|
||||
transition: max-height 0.1s ease-in-out;
|
||||
|
||||
// Keep carousel controls relative to the media carousel itself.
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// When the carousel is not part of a grid ensure its height matches its
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
:host {
|
||||
// Center unseekable icon.
|
||||
display: block;
|
||||
|
||||
// Keep carousel controls + unseekable icon relative to the media carousel
|
||||
// itself.
|
||||
position: relative;
|
||||
|
||||
--video-max-height: none;
|
||||
|
||||
transition: max-height 0.2s ease-in;
|
||||
}
|
||||
|
||||
// If the carousel has an unselected attribute set on it, do not let the
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { EmblaCarouselType } from 'embla-carousel';
|
||||
import { LooseOptionsType } from 'embla-carousel/components/Options';
|
||||
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { EmblaReInitController } from '../../reinit-controller';
|
||||
|
||||
declare module 'embla-carousel/components/Plugins' {
|
||||
interface EmblaPluginsType {
|
||||
AutoSize?: AutoSizeType;
|
||||
}
|
||||
}
|
||||
|
||||
type AutoSizeType = CreatePluginType<LoosePluginType, LooseOptionsType>;
|
||||
interface SlideDimensions {
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* This plugin offers the following functionality:
|
||||
* - Auto-height: Automatically resize the container to fit the largest slide on
|
||||
* view. Unlike the stock `auto-height` plugin, this version will use active
|
||||
* DOM sizing vs the internal engine sizes to account for pre-reinit resize
|
||||
* detection.
|
||||
* - Resize and intersection re-initializing: Re-initialize the carousel on
|
||||
* slide or container resizes, or container intersection changes.
|
||||
*/
|
||||
|
||||
function AutoSize(): AutoSizeType {
|
||||
let emblaApi: EmblaCarouselType;
|
||||
let reInitController: EmblaReInitController | null = null;
|
||||
|
||||
let previousContainerIntersecting: boolean | null = null;
|
||||
const previousDimensions: Map<Element, SlideDimensions> = new Map();
|
||||
|
||||
const resizeObserver: ResizeObserver = new ResizeObserver(resizeHandler);
|
||||
const intersectionObserver: IntersectionObserver = new IntersectionObserver(
|
||||
intersectionHandler,
|
||||
);
|
||||
|
||||
const debouncedSetContainerHeight = debounce(
|
||||
() => setContainerHeightAndReInit(),
|
||||
200,
|
||||
{
|
||||
trailing: true,
|
||||
},
|
||||
);
|
||||
|
||||
function init(emblaApiInstance: EmblaCarouselType): void {
|
||||
emblaApi = emblaApiInstance;
|
||||
reInitController = new EmblaReInitController(emblaApi);
|
||||
|
||||
intersectionObserver.observe(emblaApi.containerNode());
|
||||
resizeObserver.observe(emblaApi.containerNode());
|
||||
for (const slide of emblaApi.slideNodes()) {
|
||||
resizeObserver.observe(slide);
|
||||
}
|
||||
|
||||
// Need to examine container size on both settle and media load, as settle
|
||||
// may happen before the media is loaded (which they subsequently changes
|
||||
// the size to large than the maxHeight is set).
|
||||
emblaApi
|
||||
.containerNode()
|
||||
.addEventListener(
|
||||
'advanced-camera-card:media:loaded',
|
||||
debouncedSetContainerHeight,
|
||||
);
|
||||
emblaApi.on('settle', debouncedSetContainerHeight);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
intersectionObserver.disconnect();
|
||||
resizeObserver.disconnect();
|
||||
reInitController?.destroy();
|
||||
|
||||
emblaApi
|
||||
.containerNode()
|
||||
.removeEventListener(
|
||||
'advanced-camera-card:media:loaded',
|
||||
debouncedSetContainerHeight,
|
||||
);
|
||||
emblaApi.off('settle', debouncedSetContainerHeight);
|
||||
}
|
||||
|
||||
function intersectionHandler(entries: IntersectionObserverEntry[]): void {
|
||||
/**
|
||||
* - If the DOM that contains this carousel changes such that it causes
|
||||
* slides to entirely appear/disappear (e.g. `display: none` or hidden),
|
||||
* then the displayed slide sizes will significantly change and the
|
||||
* carousel will need to be reinitialized. Without this, odd bugs may
|
||||
* occur for some users in some circumstances causing the carousel to
|
||||
* appear 'stuck'.
|
||||
* - Example bug when this reinitialization is not performed:
|
||||
* https://github.com/dermotduffy/advanced-camera-card/issues/651
|
||||
*/
|
||||
const isContainerIntersectingNow = entries.some((entry) => entry.isIntersecting);
|
||||
|
||||
if (isContainerIntersectingNow !== previousContainerIntersecting) {
|
||||
// Don't reinitialize on first call (intersectionHandler is always called
|
||||
// on initial observation), nor when the viewport is not intersecting.
|
||||
const callReInit =
|
||||
isContainerIntersectingNow && previousContainerIntersecting !== null;
|
||||
previousContainerIntersecting = isContainerIntersectingNow;
|
||||
if (callReInit) {
|
||||
reInitController?.reinit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resizeHandler(entries: ResizeObserverEntry[]): void {
|
||||
let resize = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
const newDimensions: SlideDimensions = {
|
||||
height: entry.contentRect.height,
|
||||
width: entry.contentRect.width,
|
||||
};
|
||||
|
||||
const oldDimensions = previousDimensions.get(entry.target);
|
||||
if (
|
||||
newDimensions.width &&
|
||||
newDimensions.height &&
|
||||
(oldDimensions?.height !== newDimensions.height ||
|
||||
oldDimensions?.width !== newDimensions.width)
|
||||
) {
|
||||
previousDimensions.set(entry.target, newDimensions);
|
||||
resize = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (resize) {
|
||||
debouncedSetContainerHeight();
|
||||
}
|
||||
}
|
||||
|
||||
function setContainerHeightAndReInit(): void {
|
||||
const {
|
||||
slideRegistry,
|
||||
options: { axis },
|
||||
} = emblaApi.internalEngine();
|
||||
|
||||
if (axis === 'y') {
|
||||
return;
|
||||
}
|
||||
|
||||
emblaApi.containerNode().style.removeProperty('max-height');
|
||||
|
||||
const selectedIndexes = slideRegistry[emblaApi.selectedScrollSnap()];
|
||||
const slides = emblaApi.slideNodes();
|
||||
const highest = Math.max(
|
||||
...selectedIndexes.map((i) => slides[i].getBoundingClientRect().height),
|
||||
);
|
||||
|
||||
if (!isNaN(highest) && highest > 0) {
|
||||
emblaApi.containerNode().style.maxHeight = `${highest}px`;
|
||||
}
|
||||
|
||||
reInitController?.reinit();
|
||||
}
|
||||
|
||||
const self: AutoSizeType = {
|
||||
name: 'autoSize',
|
||||
options: {},
|
||||
init,
|
||||
destroy,
|
||||
};
|
||||
return self;
|
||||
}
|
||||
|
||||
export default AutoSize;
|
||||
@@ -1,60 +0,0 @@
|
||||
import { EmblaCarouselType } from 'embla-carousel';
|
||||
import { debounce } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* This class takes care of "safe re-initializing": Only re-initializing the
|
||||
* carousel when it is not scrolling (unlike the builtin Embla reinitializations,
|
||||
* e.g. slide additions or resizes). Without this class the carousel is visually
|
||||
* jarring as in-progress transitions are skipped (vs completing prior to
|
||||
* reinit).
|
||||
*/
|
||||
|
||||
export class EmblaReInitController {
|
||||
protected _emblaApi: EmblaCarouselType;
|
||||
protected _scrolling = false;
|
||||
protected _shouldReInitOnScrollStop = false;
|
||||
|
||||
constructor(emblaApi: EmblaCarouselType) {
|
||||
this._emblaApi = emblaApi;
|
||||
this._emblaApi.on('scroll', this._scrollingStart);
|
||||
this._emblaApi.on('settle', this._scrollingStop);
|
||||
this._emblaApi.on('destroy', this.destroy);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._emblaApi.off('scroll', this._scrollingStart);
|
||||
this._emblaApi.off('settle', this._scrollingStop);
|
||||
this._emblaApi.off('destroy', this.destroy);
|
||||
}
|
||||
|
||||
public reinit(): void {
|
||||
if (this._scrolling) {
|
||||
this._shouldReInitOnScrollStop = true;
|
||||
} else {
|
||||
this._debouncedReInit();
|
||||
}
|
||||
}
|
||||
|
||||
protected _scrollingStart = (): void => {
|
||||
this._scrolling = true;
|
||||
};
|
||||
|
||||
protected _scrollingStop = (): void => {
|
||||
this._scrolling = false;
|
||||
|
||||
if (this._shouldReInitOnScrollStop) {
|
||||
this._shouldReInitOnScrollStop = false;
|
||||
this._debouncedReInit();
|
||||
}
|
||||
};
|
||||
|
||||
protected _debouncedReInit = debounce(
|
||||
() => {
|
||||
this._scrolling = false;
|
||||
this._shouldReInitOnScrollStop = false;
|
||||
this._emblaApi?.reInit();
|
||||
},
|
||||
500,
|
||||
{ trailing: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MediaHeightController } from '../../src/components-lib/media-height-controller';
|
||||
import {
|
||||
callMutationHandler,
|
||||
MutationObserverMock,
|
||||
ResizeObserverMock,
|
||||
} from '../test-utils';
|
||||
import { callResizeHandler } from '../utils/embla/test-utils';
|
||||
|
||||
vi.mock('lodash-es', async () => ({
|
||||
...(await vi.importActual('lodash-es')),
|
||||
debounce: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MediaHeightController', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('MutationObserver', MutationObserverMock);
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('should set height', () => {
|
||||
it('should set height on selection', () => {
|
||||
const host = document.createElement('div');
|
||||
const controller = new MediaHeightController(host, 'div');
|
||||
|
||||
const root = document.createElement('div');
|
||||
const child = document.createElement('div');
|
||||
child.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
height: 600,
|
||||
});
|
||||
root.appendChild(child);
|
||||
|
||||
controller.setRoot(root);
|
||||
|
||||
// Calling a second time has no effect.
|
||||
controller.setRoot(root);
|
||||
|
||||
controller.setSelected(0);
|
||||
|
||||
expect(host.style.maxHeight).toBe(`600px`);
|
||||
});
|
||||
|
||||
it('should not set height without children', () => {
|
||||
const host = document.createElement('div');
|
||||
const controller = new MediaHeightController(host, 'div');
|
||||
|
||||
const root = document.createElement('div');
|
||||
|
||||
controller.setRoot(root);
|
||||
|
||||
controller.setSelected(10);
|
||||
|
||||
expect(host.style.maxHeight).toBe('');
|
||||
});
|
||||
|
||||
it('should respond to resize observer of selected child', () => {
|
||||
const host = document.createElement('div');
|
||||
const controller = new MediaHeightController(host, 'div');
|
||||
|
||||
const root = document.createElement('div');
|
||||
const child = document.createElement('div');
|
||||
root.appendChild(child);
|
||||
|
||||
controller.setRoot(root);
|
||||
controller.setSelected(0);
|
||||
|
||||
child.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
height: 800,
|
||||
});
|
||||
|
||||
callResizeHandler([
|
||||
{
|
||||
target: child,
|
||||
height: 800,
|
||||
width: 400,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(host.style.maxHeight).toBe('800px');
|
||||
});
|
||||
|
||||
it('should not respond to resize observer without a selected child', () => {
|
||||
const host = document.createElement('div');
|
||||
const controller = new MediaHeightController(host, 'div');
|
||||
|
||||
const root = document.createElement('div');
|
||||
const child = document.createElement('div');
|
||||
root.appendChild(child);
|
||||
|
||||
controller.setRoot(root);
|
||||
|
||||
child.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
height: 800,
|
||||
});
|
||||
|
||||
callResizeHandler([
|
||||
{
|
||||
target: child,
|
||||
height: 800,
|
||||
width: 400,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(host.style.maxHeight).toBe('');
|
||||
});
|
||||
|
||||
it('should respond to new children being added', () => {
|
||||
const host = document.createElement('div');
|
||||
const controller = new MediaHeightController(host, 'div');
|
||||
|
||||
const root = document.createElement('div');
|
||||
const child_0 = document.createElement('div');
|
||||
child_0.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
height: 100,
|
||||
});
|
||||
root.appendChild(child_0);
|
||||
|
||||
controller.setRoot(root);
|
||||
|
||||
const child_1 = document.createElement('div');
|
||||
child_1.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
height: 200,
|
||||
});
|
||||
root.appendChild(child_1);
|
||||
|
||||
callMutationHandler();
|
||||
|
||||
controller.setSelected(1);
|
||||
|
||||
expect(host.style.maxHeight).toBe('200px');
|
||||
});
|
||||
|
||||
it('should ignore new chil to new children being added', () => {
|
||||
const host = document.createElement('div');
|
||||
const controller = new MediaHeightController(host, 'div');
|
||||
|
||||
const root = document.createElement('div');
|
||||
const child_0 = document.createElement('div');
|
||||
child_0.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
height: 100,
|
||||
});
|
||||
root.appendChild(child_0);
|
||||
|
||||
controller.setRoot(root);
|
||||
|
||||
const child_1 = document.createElement('div');
|
||||
child_1.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
height: 200,
|
||||
});
|
||||
root.appendChild(child_1);
|
||||
|
||||
callMutationHandler();
|
||||
|
||||
controller.setSelected(1);
|
||||
|
||||
expect(host.style.maxHeight).toBe('200px');
|
||||
});
|
||||
});
|
||||
|
||||
it('should destroy', () => {
|
||||
const host = document.createElement('div');
|
||||
const controller = new MediaHeightController(host, 'div');
|
||||
|
||||
controller.destroy();
|
||||
|
||||
// No observable effect.
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import AutoSize from '../../../../../src/utils/embla/plugins/auto-size/auto-size';
|
||||
import {
|
||||
IntersectionObserverMock,
|
||||
ResizeObserverMock,
|
||||
callIntersectionHandler,
|
||||
createParent,
|
||||
requestAnimationFrameMock,
|
||||
} from '../../../../test-utils';
|
||||
import {
|
||||
callEmblaHandler,
|
||||
callResizeHandler,
|
||||
createEmblaApiInstance,
|
||||
createTestEmblaOptionHandler,
|
||||
createTestSlideNodes,
|
||||
} from '../../test-utils';
|
||||
|
||||
vi.mock('lodash-es', () => ({
|
||||
debounce: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('AutoSize', () => {
|
||||
beforeAll(() => {
|
||||
// Mock out requestAnimationFrame (used in the reinit controller).
|
||||
window.requestAnimationFrame = requestAnimationFrameMock;
|
||||
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should construct', () => {
|
||||
const plugin = AutoSize();
|
||||
expect(plugin.name).toBe('autoSize');
|
||||
});
|
||||
|
||||
it('should destroy', () => {
|
||||
const plugin = AutoSize();
|
||||
const emblaApi = createEmblaApiInstance();
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
plugin.destroy();
|
||||
|
||||
expect(emblaApi.off).toBeCalledWith('settle', expect.anything());
|
||||
|
||||
expect(
|
||||
vi.mocked(IntersectionObserver).mock.results[0].value.disconnect,
|
||||
).toBeCalled();
|
||||
expect(vi.mocked(ResizeObserver).mock.results[0].value.disconnect).toBeCalled();
|
||||
});
|
||||
|
||||
it('should correctly handle intersection', () => {
|
||||
const plugin = AutoSize();
|
||||
const emblaApi = createEmblaApiInstance();
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
// First intersection handler call sets the state only.
|
||||
callIntersectionHandler(true);
|
||||
|
||||
// When not visible, will not re-init.
|
||||
callIntersectionHandler(false);
|
||||
callIntersectionHandler(false);
|
||||
callIntersectionHandler(false);
|
||||
|
||||
expect(emblaApi.reInit).not.toBeCalled();
|
||||
|
||||
// When visible, will re-initialize once.
|
||||
callIntersectionHandler(true);
|
||||
callIntersectionHandler(true);
|
||||
|
||||
expect(emblaApi.reInit).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should correctly handle resize', () => {
|
||||
const plugin = AutoSize();
|
||||
const parent = createParent();
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
containerNode: parent,
|
||||
selectedScrollSnap: 0,
|
||||
slideNodes: children,
|
||||
slideRegistry: [[0]],
|
||||
});
|
||||
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
children[0].getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
width: 200,
|
||||
height: 800,
|
||||
});
|
||||
|
||||
callResizeHandler([{ target: parent, width: 10, height: 20 }]);
|
||||
callResizeHandler([{ target: parent, width: 10, height: 20 }]);
|
||||
callResizeHandler([{ target: parent, width: 10, height: 20 }]);
|
||||
|
||||
expect(parent.style.maxHeight).toBe('800px');
|
||||
expect(emblaApi.reInit).toBeCalledTimes(1);
|
||||
|
||||
children[0].getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
width: 200,
|
||||
height: 600,
|
||||
});
|
||||
|
||||
callResizeHandler([{ target: parent, width: 20, height: 40 }]);
|
||||
|
||||
expect(parent.style.maxHeight).toBe('600px');
|
||||
expect(emblaApi.reInit).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should set container height on slide settle', () => {
|
||||
const plugin = AutoSize();
|
||||
const parent = createParent();
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
containerNode: parent,
|
||||
selectedScrollSnap: 0,
|
||||
slideNodes: children,
|
||||
// 0th scroll snap shows the 0th slide only.
|
||||
slideRegistry: [[0]],
|
||||
});
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
children[0].getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
width: 200,
|
||||
height: 800,
|
||||
});
|
||||
|
||||
// select should not do anything, we wait for it to have settled for
|
||||
// smoothness.
|
||||
callEmblaHandler(emblaApi, 'select');
|
||||
expect(parent.style.maxHeight).toBeFalsy();
|
||||
|
||||
callEmblaHandler(emblaApi, 'settle');
|
||||
expect(parent.style.maxHeight).toBe('800px');
|
||||
});
|
||||
|
||||
it('should not set container height on horizontal carousel', () => {
|
||||
const plugin = AutoSize();
|
||||
const parent = createParent();
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
containerNode: parent,
|
||||
selectedScrollSnap: 0,
|
||||
slideNodes: children,
|
||||
axis: 'y',
|
||||
// 0th scroll snap shows the 0th slide only.
|
||||
slideRegistry: [[0]],
|
||||
});
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
children[0].getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
width: 200,
|
||||
height: 800,
|
||||
});
|
||||
|
||||
callEmblaHandler(emblaApi, 'settle');
|
||||
|
||||
expect(parent.style.maxHeight).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not set container height when slide dimensions are invalid', () => {
|
||||
const plugin = AutoSize();
|
||||
const parent = createParent();
|
||||
const children = createTestSlideNodes();
|
||||
const emblaApi = createEmblaApiInstance({
|
||||
containerNode: parent,
|
||||
selectedScrollSnap: 0,
|
||||
slideNodes: children,
|
||||
axis: 'x',
|
||||
// 0th scroll snap shows the 0th slide only.
|
||||
slideRegistry: [[0]],
|
||||
});
|
||||
plugin.init(emblaApi, createTestEmblaOptionHandler());
|
||||
|
||||
children[0].getBoundingClientRect = vi.fn().mockReturnValue(NaN);
|
||||
callEmblaHandler(emblaApi, 'settle');
|
||||
|
||||
children[0].getBoundingClientRect = vi.fn().mockReturnValue(0);
|
||||
callEmblaHandler(emblaApi, 'settle');
|
||||
|
||||
expect(parent.style.maxHeight).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EmblaReInitController } from '../../../src/utils/embla/reinit-controller';
|
||||
import { requestAnimationFrameMock } from '../../test-utils';
|
||||
import { callEmblaHandler, createEmblaApiInstance } from './test-utils';
|
||||
|
||||
vi.mock('lodash-es', () => ({
|
||||
debounce: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('EmblaReInitController', () => {
|
||||
beforeAll(() => {
|
||||
window.requestAnimationFrame = requestAnimationFrameMock;
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should construct', () => {
|
||||
const emblaApi = createEmblaApiInstance();
|
||||
new EmblaReInitController(emblaApi);
|
||||
expect(emblaApi.on).toBeCalledWith('scroll', expect.anything());
|
||||
expect(emblaApi.on).toBeCalledWith('settle', expect.anything());
|
||||
expect(emblaApi.on).toBeCalledWith('destroy', expect.anything());
|
||||
});
|
||||
|
||||
it('should destroy', () => {
|
||||
const emblaApi = createEmblaApiInstance();
|
||||
const controller = new EmblaReInitController(emblaApi);
|
||||
|
||||
controller.destroy();
|
||||
|
||||
expect(emblaApi.off).toBeCalledWith('scroll', expect.anything());
|
||||
expect(emblaApi.off).toBeCalledWith('settle', expect.anything());
|
||||
expect(emblaApi.off).toBeCalledWith('destroy', expect.anything());
|
||||
});
|
||||
|
||||
it('should reinit when not scrolling', () => {
|
||||
const emblaApi = createEmblaApiInstance();
|
||||
const controller = new EmblaReInitController(emblaApi);
|
||||
|
||||
controller.reinit();
|
||||
|
||||
expect(emblaApi.reInit).toBeCalled();
|
||||
});
|
||||
|
||||
it('should carefully reinit when scrolling', () => {
|
||||
const emblaApi = createEmblaApiInstance();
|
||||
const controller = new EmblaReInitController(emblaApi);
|
||||
|
||||
callEmblaHandler(emblaApi, 'scroll');
|
||||
|
||||
controller.reinit();
|
||||
expect(emblaApi.reInit).not.toBeCalled();
|
||||
|
||||
callEmblaHandler(emblaApi, 'settle');
|
||||
expect(emblaApi.reInit).toBeCalled();
|
||||
});
|
||||
});
|
||||
@@ -2508,7 +2508,7 @@ __metadata:
|
||||
date-fns: "npm:^3.6.0"
|
||||
date-fns-tz: "npm:^3.1.3"
|
||||
docsify-cli: "npm:^4.4.4"
|
||||
embla-carousel: "npm:^8.3.0"
|
||||
embla-carousel: "npm:^8.6.0"
|
||||
embla-carousel-wheel-gestures: "npm:^8.0.1"
|
||||
eslint: "npm:^9.24.0"
|
||||
eslint-config-airbnb-base: "npm:^15.0.0"
|
||||
@@ -4451,10 +4451,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"embla-carousel@npm:^8.3.0":
|
||||
version: 8.3.0
|
||||
resolution: "embla-carousel@npm:8.3.0"
|
||||
checksum: 10c0/0240156d6a736603d82ddfe93b03ce296e385e9c18ed2cca9465634c8adb7560bfc2fbef6368a4da2a54926c4ba6de1012ebb33d2fc92c052030ea2288e4cc92
|
||||
"embla-carousel@npm:^8.6.0":
|
||||
version: 8.6.0
|
||||
resolution: "embla-carousel@npm:8.6.0"
|
||||
checksum: 10c0/f4c598e7be28b70340d31ffd2bebb2472db370b0c81d9b089bf9555cf618695f35dc4a0694565c994c9ab972731123063f945aa09ff485df0df761d79c6a08ef
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user