Complete carousel refactor.
Reduces one layer of DOM nesting for simplication, uses the latest Embla version, unittests for everything.
This commit is contained in:
@@ -8,8 +8,10 @@ import {
|
||||
} from '../../src/utils/media-grid-controller';
|
||||
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info';
|
||||
import {
|
||||
createMutationObserverImplementation,
|
||||
createResizeObserverImplementation,
|
||||
MutationObserverMock,
|
||||
ResizeObserverMock,
|
||||
createSlot,
|
||||
createSlotHost,
|
||||
} from '../test-utils';
|
||||
|
||||
vi.mock('lodash-es/throttle', () => ({
|
||||
@@ -41,7 +43,7 @@ const setElementWidth = (element: HTMLElement, width: number): void => {
|
||||
});
|
||||
};
|
||||
|
||||
const createHost = (options?: {
|
||||
const createParent = (options?: {
|
||||
children?: HTMLElement[];
|
||||
width?: number;
|
||||
}): HTMLElement => {
|
||||
@@ -54,28 +56,6 @@ const createHost = (options?: {
|
||||
return host;
|
||||
};
|
||||
|
||||
const createSlotParent = (): HTMLElement => {
|
||||
const parent = document.createElement('div');
|
||||
parent.attachShadow({ mode: 'open' });
|
||||
return parent;
|
||||
};
|
||||
|
||||
const createSlotHost = (options?: {
|
||||
children?: HTMLElement[];
|
||||
parent?: HTMLElement;
|
||||
}): HTMLSlotElement => {
|
||||
const parent = options?.parent ?? createSlotParent();
|
||||
const slot = document.createElement('slot');
|
||||
parent.shadowRoot?.append(slot);
|
||||
|
||||
if (options?.children) {
|
||||
// Children will automatically be slotted into the default slot.
|
||||
parent.append(...options.children);
|
||||
}
|
||||
|
||||
return slot;
|
||||
};
|
||||
|
||||
const createController = (host: HTMLElement, options?: MediaGridConstructorOptions) => {
|
||||
return new MediaGridController(host, options);
|
||||
};
|
||||
@@ -101,30 +81,20 @@ describe('MediaGridController', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
global.ResizeObserver = vi
|
||||
.fn()
|
||||
// Caution: Order must match the order of initialization in
|
||||
// media-grid-controller.ts .
|
||||
.mockImplementationOnce(createResizeObserverImplementation())
|
||||
.mockImplementationOnce(createResizeObserverImplementation());
|
||||
|
||||
global.MutationObserver = vi
|
||||
.fn()
|
||||
.mockImplementation(createMutationObserverImplementation());
|
||||
//global.MutationObserver = mock<MutationObserver>();
|
||||
vi.stubGlobal('MutationObserver', MutationObserverMock);
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
});
|
||||
|
||||
it('should be constructable', () => {
|
||||
const controller = createController(createHost());
|
||||
const controller = createController(createParent());
|
||||
expect(controller).toBeTruthy();
|
||||
expect(masonry.layout).toBeCalled();
|
||||
});
|
||||
|
||||
it('should set grid contents correctly from regular elements', () => {
|
||||
const children = createChildren();
|
||||
const host = createHost({ children: children });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: children });
|
||||
const controller = createController(parent);
|
||||
expect(controller.getGridContents()).toEqual(
|
||||
new Map([
|
||||
['0', children[0]],
|
||||
@@ -138,7 +108,8 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should set grid contents correctly from slotted elements', () => {
|
||||
const children = createChildren();
|
||||
const host = createSlotHost({ children: children });
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(host);
|
||||
expect(controller.getGridContents()).toEqual(
|
||||
new Map([
|
||||
@@ -152,7 +123,10 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should select element', () => {
|
||||
const children = createChildren();
|
||||
const controller = createController(createSlotHost({ children: children }));
|
||||
const slot = createSlot();
|
||||
createSlotHost({ slot: slot, children: children });
|
||||
|
||||
const controller = createController(slot);
|
||||
|
||||
// All children should be unselected.
|
||||
expect(controller.getSelected()).toBeNull();
|
||||
@@ -176,7 +150,10 @@ describe('MediaGridController', () => {
|
||||
});
|
||||
|
||||
it('should re-select element', () => {
|
||||
const controller = createController(createSlotHost({ children: createChildren() }));
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(slot);
|
||||
|
||||
// All children should be unselected.
|
||||
expect(controller.getSelected()).toBeNull();
|
||||
@@ -190,16 +167,14 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should dispatch media loaded info on selection', () => {
|
||||
const children = createChildren();
|
||||
const host = createSlotHost({ children: children });
|
||||
const controller = createController(host);
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(slot);
|
||||
|
||||
const mediaLoadedInfoHandler = vi.fn();
|
||||
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
// Nothing is selected, so the event should not have propagated.
|
||||
expect(mediaLoadedInfoHandler).not.toBeCalled();
|
||||
|
||||
controller.selectCell('0');
|
||||
expect(mediaLoadedInfoHandler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -208,9 +183,45 @@ describe('MediaGridController', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should dispatch media loaded info when cell is selected', () => {
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(slot);
|
||||
|
||||
controller.selectCell('0');
|
||||
|
||||
const mediaLoadedInfoHandler = vi.fn();
|
||||
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
expect(mediaLoadedInfoHandler).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: mediaLoadedInfo,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not dispatch media loaded info when cell is not selected', () => {
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(host);
|
||||
|
||||
controller.selectCell('1');
|
||||
|
||||
const mediaLoadedInfoHandler = vi.fn();
|
||||
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
// Another element is selected, so the event should not have propagated.
|
||||
expect(mediaLoadedInfoHandler).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should unselect', () => {
|
||||
const children = createChildren();
|
||||
const host = createSlotHost({ children: children });
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(host);
|
||||
|
||||
const unselectedHandler = vi.fn();
|
||||
@@ -234,20 +245,29 @@ describe('MediaGridController', () => {
|
||||
}
|
||||
|
||||
// Expect handlers to have been called.
|
||||
expect(unselectedHandler).toBeCalled();
|
||||
expect(unloadMediaHandler).toBeCalled();
|
||||
expect(unselectedHandler).toBeCalledTimes(1);
|
||||
expect(unloadMediaHandler).toBeCalledTimes(1);
|
||||
|
||||
// Unselecting a second time should do nothing.
|
||||
controller.unselectAll();
|
||||
|
||||
expect(unselectedHandler).toBeCalledTimes(1);
|
||||
expect(unloadMediaHandler).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should select in constructor', () => {
|
||||
const children = createChildren();
|
||||
const host = createSlotHost({ children: children });
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(host, { selected: '2' });
|
||||
|
||||
expect(controller.getSelected()).toBe('2');
|
||||
});
|
||||
|
||||
it('should respect grid attribute option', () => {
|
||||
const children = createChildren(['one', 'two', 'three'], 'test-id');
|
||||
const host = createSlotHost({ children: children });
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(host, { idAttribute: 'test-id' });
|
||||
expect(controller.getGridContents()).toEqual(
|
||||
new Map([
|
||||
@@ -258,10 +278,21 @@ describe('MediaGridController', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should destroy', () => {
|
||||
it('should destroy with regular elements', () => {
|
||||
const children = createChildren();
|
||||
const host = createSlotHost({ children: children });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: children });
|
||||
const controller = createController(parent);
|
||||
expect(controller.getGridSize()).toBe(3);
|
||||
controller.destroy();
|
||||
expect(controller.getGridSize()).toBe(0);
|
||||
});
|
||||
|
||||
it('should destroy with slotted elements', () => {
|
||||
const children = createChildren();
|
||||
const slot = createSlot();
|
||||
createSlotHost({ slot: slot, children: children });
|
||||
const controller = createController(slot);
|
||||
|
||||
expect(controller.getGridSize()).toBe(3);
|
||||
controller.destroy();
|
||||
expect(controller.getGridSize()).toBe(0);
|
||||
@@ -269,16 +300,16 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should replace children when they change', () => {
|
||||
const children = createChildren();
|
||||
const host = createHost({ children: children });
|
||||
const controller = createController(host, { selected: '1' });
|
||||
const parent = createParent({ children: children });
|
||||
const controller = createController(parent, { selected: '1' });
|
||||
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
|
||||
|
||||
expect(controller.getSelected()).toBe('1');
|
||||
expect(controller.getGridSize()).toBe(3);
|
||||
|
||||
children.forEach((child) => host.removeChild(child));
|
||||
children.forEach((child) => parent.removeChild(child));
|
||||
const newChildren = createChildren(['one', 'two', 'three']);
|
||||
newChildren.forEach((child) => host.appendChild(child));
|
||||
newChildren.forEach((child) => parent.appendChild(child));
|
||||
|
||||
triggerMutationObserver();
|
||||
|
||||
@@ -294,19 +325,19 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should replace children of a slot when they change', () => {
|
||||
const children = createChildren();
|
||||
const slotParent = createSlotParent();
|
||||
const host = createSlotHost({ children: children, parent: slotParent });
|
||||
const slot = createSlot();
|
||||
const host = createSlotHost({ slot: slot, children: children });
|
||||
|
||||
const controller = createController(host, { selected: '1' });
|
||||
const controller = createController(slot, { selected: '1' });
|
||||
|
||||
expect(controller.getSelected()).toBe('1');
|
||||
expect(controller.getGridSize()).toBe(3);
|
||||
|
||||
children.forEach((child) => slotParent.removeChild(child));
|
||||
children.forEach((child) => host.removeChild(child));
|
||||
const newChildren = createChildren(['one', 'two', 'three']);
|
||||
newChildren.forEach((child) => slotParent.append(child));
|
||||
newChildren.forEach((child) => host.append(child));
|
||||
|
||||
host.dispatchEvent(new Event('slotchange'));
|
||||
slot.dispatchEvent(new Event('slotchange'));
|
||||
|
||||
expect(controller.getGridContents()).toEqual(
|
||||
new Map([
|
||||
@@ -320,10 +351,10 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should construct masonry correctly', () => {
|
||||
const children = createChildren();
|
||||
const host = createHost({ children: children });
|
||||
createController(host);
|
||||
const parent = createParent({ children: children });
|
||||
createController(parent);
|
||||
expect(Masonry).toBeCalledWith(
|
||||
host,
|
||||
parent,
|
||||
expect.objectContaining({
|
||||
initLayout: false,
|
||||
percentPosition: true,
|
||||
@@ -333,57 +364,85 @@ describe('MediaGridController', () => {
|
||||
});
|
||||
|
||||
it('should set default column size correctly', () => {
|
||||
const host = createHost({ children: createChildren() });
|
||||
createController(host);
|
||||
const parent = createParent({ children: createChildren() });
|
||||
createController(parent);
|
||||
expect(Masonry).toBeCalledWith(
|
||||
host,
|
||||
parent,
|
||||
expect.objectContaining({
|
||||
columnWidth: 246,
|
||||
}),
|
||||
);
|
||||
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
|
||||
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
|
||||
});
|
||||
|
||||
it('should respect exact columns', () => {
|
||||
const host = createHost({ children: createChildren(), width: 2000 });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: createChildren(), width: 2000 });
|
||||
const controller = createController(parent);
|
||||
controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 });
|
||||
|
||||
// Will have been called once on construction, and then again when the
|
||||
// number of columns changes.
|
||||
expect(Masonry).toBeCalledTimes(2);
|
||||
expect(Masonry).toBeCalledWith(
|
||||
host,
|
||||
parent,
|
||||
expect.objectContaining({
|
||||
columnWidth: 1000,
|
||||
}),
|
||||
);
|
||||
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
|
||||
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
|
||||
'1000px',
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect selected width factor', () => {
|
||||
const host = createHost({ children: createChildren(), width: 2000 });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: createChildren(), width: 2000 });
|
||||
const controller = createController(parent);
|
||||
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
|
||||
expect(
|
||||
host.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
|
||||
parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
|
||||
).toBe('3');
|
||||
|
||||
// Setting the same config again should do nothing.
|
||||
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
|
||||
expect(
|
||||
parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
|
||||
).toBe('3');
|
||||
});
|
||||
|
||||
it('should select cell with interacted with', () => {
|
||||
it('should select cell when interacted with', () => {
|
||||
const children = createChildren();
|
||||
const host = createHost({ children: children, width: 2000 });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: children, width: 2000 });
|
||||
const controller = createController(parent);
|
||||
|
||||
expect(controller.getSelected()).toBeNull();
|
||||
|
||||
const clickHandler = vi.fn();
|
||||
parent.addEventListener('click', clickHandler);
|
||||
|
||||
children[1].click();
|
||||
|
||||
// Click will not be allowed through.
|
||||
expect(clickHandler).not.toBeCalled();
|
||||
expect(controller.getSelected()).toBe('1');
|
||||
});
|
||||
|
||||
it('should ignore interaction events on already selected cell', () => {
|
||||
const children = createChildren();
|
||||
const parent = createParent({ children: children, width: 2000 });
|
||||
const controller = createController(parent);
|
||||
controller.selectCell('1');
|
||||
|
||||
const clickHandler = vi.fn();
|
||||
parent.addEventListener('click', clickHandler);
|
||||
children[1].click();
|
||||
|
||||
// Click will be allowed through.
|
||||
expect(clickHandler).toBeCalled();
|
||||
expect(controller.getSelected()).toBe('1');
|
||||
});
|
||||
|
||||
it('should re-layout when child size changes', () => {
|
||||
createController(createHost({ children: createChildren() }));
|
||||
createController(createParent({ children: createChildren() }));
|
||||
|
||||
vi.mocked(masonry.layout)?.mockClear();
|
||||
triggerResizeObserver('cell');
|
||||
@@ -392,32 +451,41 @@ describe('MediaGridController', () => {
|
||||
|
||||
it('should re-create masonry when host size changes', () => {
|
||||
const children = createChildren();
|
||||
const host = createHost({ children: children });
|
||||
const controller = createController(host);
|
||||
const parent = createParent({ children: children });
|
||||
createController(parent);
|
||||
expect(Masonry).toBeCalledWith(
|
||||
host,
|
||||
parent,
|
||||
expect.objectContaining({
|
||||
columnWidth: 246,
|
||||
}),
|
||||
);
|
||||
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
|
||||
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('246px');
|
||||
|
||||
// Clear mock state.
|
||||
vi.mocked(Masonry).mockClear();
|
||||
vi.mocked(masonry.layout)?.mockClear();
|
||||
|
||||
// Resize the host.
|
||||
setElementWidth(host, 2000);
|
||||
setElementWidth(parent, 2000);
|
||||
triggerResizeObserver('host');
|
||||
|
||||
// Masonry should be reconstructed, styles set and layout called.
|
||||
expect(Masonry).toBeCalledWith(
|
||||
host,
|
||||
parent,
|
||||
expect.objectContaining({
|
||||
columnWidth: 667,
|
||||
}),
|
||||
);
|
||||
expect(host.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px');
|
||||
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe('667px');
|
||||
expect(masonry.layout).toBeCalled();
|
||||
|
||||
// Clear mock state.
|
||||
vi.mocked(Masonry).mockClear();
|
||||
vi.mocked(masonry.layout)?.mockClear();
|
||||
|
||||
// Triger with the same sizes.
|
||||
triggerResizeObserver('host');
|
||||
expect(Masonry).not.toBeCalled();
|
||||
expect(masonry.layout).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user