diff --git a/package.json b/package.json index ac627455..27517a1b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components-lib/media-actions-controller.ts b/src/components-lib/media-actions-controller.ts index 5a913a2d..54cf136d 100644 --- a/src/components-lib/media-actions-controller.ts +++ b/src/components-lib/media-actions-controller.ts @@ -233,6 +233,7 @@ export class MediaActionsController { child.addEventListener('advanced-camera-card:media:loaded', eventListener); } } + protected async _intersectionHandler( entries: IntersectionObserverEntry[], ): Promise { diff --git a/src/components-lib/media-height-controller.ts b/src/components-lib/media-height-controller.ts new file mode 100644 index 00000000..06ae0341 --- /dev/null +++ b/src/components-lib/media-height-controller.ts @@ -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(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; + } +} diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index 94478aa8..56c4780c 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -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 = 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 { diff --git a/src/components/viewer/carousel.ts b/src/components/viewer/carousel.ts index 422b3594..36b42b42 100644 --- a/src/components/viewer/carousel.ts +++ b/src/components/viewer/carousel.ts @@ -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 = 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); } } diff --git a/src/scss/carousel.scss b/src/scss/carousel.scss index 2ea19de8..167146a8 100644 --- a/src/scss/carousel.scss +++ b/src/scss/carousel.scss @@ -2,9 +2,6 @@ display: block; height: 100%; width: 100%; - - // Keep carousel controls relative to the media carousel itself. - position: relative; } .embla { diff --git a/src/scss/live-carousel.scss b/src/scss/live-carousel.scss index 8589935a..f8ecadb4 100644 --- a/src/scss/live-carousel.scss +++ b/src/scss/live-carousel.scss @@ -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 diff --git a/src/scss/viewer-carousel.scss b/src/scss/viewer-carousel.scss index e0c4a889..9cefc6c7 100644 --- a/src/scss/viewer-carousel.scss +++ b/src/scss/viewer-carousel.scss @@ -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 diff --git a/src/utils/embla/plugins/auto-size/auto-size.ts b/src/utils/embla/plugins/auto-size/auto-size.ts deleted file mode 100644 index 69fe9b43..00000000 --- a/src/utils/embla/plugins/auto-size/auto-size.ts +++ /dev/null @@ -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; -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 = 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; diff --git a/src/utils/embla/reinit-controller.ts b/src/utils/embla/reinit-controller.ts deleted file mode 100644 index d6b1e22a..00000000 --- a/src/utils/embla/reinit-controller.ts +++ /dev/null @@ -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 }, - ); -} diff --git a/tests/components-lib/media-height-controller.test.ts b/tests/components-lib/media-height-controller.test.ts new file mode 100644 index 00000000..3254e25b --- /dev/null +++ b/tests/components-lib/media-height-controller.test.ts @@ -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. + }); +}); diff --git a/tests/utils/embla/plugins/auto-size/auto-size.test.ts b/tests/utils/embla/plugins/auto-size/auto-size.test.ts deleted file mode 100644 index 66642c3e..00000000 --- a/tests/utils/embla/plugins/auto-size/auto-size.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/tests/utils/embla/reinit-controller.test.ts b/tests/utils/embla/reinit-controller.test.ts deleted file mode 100644 index 20717b6e..00000000 --- a/tests/utils/embla/reinit-controller.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/yarn.lock b/yarn.lock index ae75e320..60b45d3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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