perf: Improve performance of the carousel auto-height functionality (#2094)

This commit is contained in:
Dermot Duffy
2025-06-08 15:05:40 -07:00
committed by GitHub
parent 70f1d5fc8e
commit 2b92f84665
14 changed files with 316 additions and 488 deletions
+1 -1
View File
@@ -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;
}
}
+8 -2
View File
@@ -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 {
+7 -2
View File
@@ -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);
}
}
-3
View File
@@ -2,9 +2,6 @@
display: block;
height: 100%;
width: 100%;
// Keep carousel controls relative to the media carousel itself.
position: relative;
}
.embla {
+5
View File
@@ -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
+6 -1
View File
@@ -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;
-60
View File
@@ -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();
});
});
+5 -5
View File
@@ -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