diff --git a/packages/overlayscrollbars/src/options.ts b/packages/overlayscrollbars/src/options.ts index be07f03..8d7a9e3 100644 --- a/packages/overlayscrollbars/src/options.ts +++ b/packages/overlayscrollbars/src/options.ts @@ -1,4 +1,13 @@ -import { assignDeep, each, isObject, keys, isArray, hasOwnProperty, isFunction } from 'support'; +import { + assignDeep, + each, + isObject, + keys, + isArray, + hasOwnProperty, + isFunction, + isEmptyObject, +} from 'support'; import { DeepPartial, DeepReadonly } from 'typings'; const opsStringify = (value: any) => @@ -80,6 +89,10 @@ export const getOptionsDiff = (currOptions: T, newOptions: DeepPartial): D if (isObject(currOptionValue) && isObject(newOptionValue)) { assignDeep((diff[optionKey] = {}), getOptionsDiff(currOptionValue, newOptionValue)); + // delete empty nested objects + if (isEmptyObject(diff[optionKey])) { + delete diff[optionKey]; + } } else if (hasOwnProperty(newOptions, optionKey) && newOptionValue !== currOptionValue) { let isDiff = true; diff --git a/packages/overlayscrollbars/src/overlayscrollbars.ts b/packages/overlayscrollbars/src/overlayscrollbars.ts index 0da5243..85d2b56 100644 --- a/packages/overlayscrollbars/src/overlayscrollbars.ts +++ b/packages/overlayscrollbars/src/overlayscrollbars.ts @@ -13,7 +13,7 @@ import { getEnvironment } from 'environment'; import { cancelInitialization } from 'initialization'; import { addInstance, getInstance, removeInstance } from 'instances'; import { createStructureSetup, createScrollbarsSetup } from 'setups'; -import { getPlugins, addPlugin, optionsValidationPluginName } from 'plugins'; +import { getPlugins, addPlugin, optionsValidationPluginName, PluginInstance } from 'plugins'; import type { XY, TRBL } from 'support'; import type { Options, ReadonlyOptions } from 'options'; import type { Plugin, OptionsValidationPluginInstance } from 'plugins'; @@ -109,6 +109,16 @@ export interface OverlayScrollbars { destroy(): void; } +const invokePluginInstance = ( + pluginInstance: PluginInstance, + staticObj?: OverlayScrollbarsStatic | false | null | undefined | 0, + instanceObj?: OverlayScrollbars | false | null | undefined | 0 +) => { + if (isFunction(pluginInstance)) { + pluginInstance(staticObj || undefined, instanceObj || undefined); + } +}; + // eslint-disable-next-line @typescript-eslint/no-redeclare export const OverlayScrollbars: OverlayScrollbarsStatic = ( target: InitializationTarget, @@ -122,13 +132,12 @@ export const OverlayScrollbars: OverlayScrollbarsStatic = ( const potentialInstance = getInstance(instanceTarget); if (options && !potentialInstance) { let destroyed = false; - const optionsValidationPlugin = plugins[ - optionsValidationPluginName - ] as OptionsValidationPluginInstance; - const validateOptions = (newOptions?: DeepPartial) => { - const opts = newOptions || {}; + const validateOptions = (newOptions: DeepPartial) => { + const optionsValidationPlugin = getPlugins()[ + optionsValidationPluginName + ] as OptionsValidationPluginInstance; const validate = optionsValidationPlugin && optionsValidationPlugin._; - return validate ? validate(opts, true) : opts; + return validate ? validate(newOptions, true) : newOptions; }; const currentOptions: ReadonlyOptions = assignDeep( {}, @@ -166,7 +175,6 @@ export const OverlayScrollbars: OverlayScrollbarsStatic = ( options(newOptions?: DeepPartial) { if (newOptions) { const changedOptions = getOptionsDiff(currentOptions, validateOptions(newOptions)); - if (!isEmptyObject(changedOptions)) { assignDeep(currentOptions, changedOptions); update(changedOptions); @@ -260,12 +268,11 @@ export const OverlayScrollbars: OverlayScrollbarsStatic = ( updateScrollbars(changedOptions, force, updateHints); }); - each(keys(plugins), (pluginName) => { - const pluginInstance = plugins[pluginName]; - if (isFunction(pluginInstance)) { - pluginInstance(OverlayScrollbars, instance); - } - }); + // valid inside plugins + addInstance(instanceTarget, instance); + + // init plugins + each(keys(plugins), (pluginName) => invokePluginInstance(plugins[pluginName], 0, instance)); if ( cancelInitialization( @@ -281,7 +288,6 @@ export const OverlayScrollbars: OverlayScrollbarsStatic = ( structureState._appendElements(); scrollbarsState._appendElements(); - addInstance(instanceTarget, instance); triggerEvent('initialized', [instance]); structureState._addOnUpdatedListener((updateHints, changedOptions, force) => { @@ -322,7 +328,11 @@ export const OverlayScrollbars: OverlayScrollbarsStatic = ( return potentialInstance!; }; -OverlayScrollbars.plugin = addPlugin; +OverlayScrollbars.plugin = (plugins: Plugin | Plugin[]) => { + each(addPlugin(plugins), (pluginInstance) => + invokePluginInstance(pluginInstance, OverlayScrollbars) + ); +}; OverlayScrollbars.valid = (osInstance: any): osInstance is OverlayScrollbars => { const hasElmsFn = osInstance && (osInstance as OverlayScrollbars).elements; const elements = isFunction(hasElmsFn) && hasElmsFn(); diff --git a/packages/overlayscrollbars/src/plugins/plugins.ts b/packages/overlayscrollbars/src/plugins/plugins.ts index 8518645..0b80517 100644 --- a/packages/overlayscrollbars/src/plugins/plugins.ts +++ b/packages/overlayscrollbars/src/plugins/plugins.ts @@ -1,9 +1,9 @@ -import { each, isArray, keys } from 'support'; +import { each, isArray, keys, push } from 'support'; import { OverlayScrollbars, OverlayScrollbarsStatic } from 'overlayscrollbars'; export type PluginInstance = | Record - | ((staticObj: OverlayScrollbarsStatic, instanceObj: OverlayScrollbars) => void); + | ((staticObj?: OverlayScrollbarsStatic, instanceObj?: OverlayScrollbars) => void); export type Plugin = { [pluginName: string]: T; }; @@ -12,9 +12,14 @@ const pluginRegistry: Record = {}; export const getPlugins = () => pluginRegistry; -export const addPlugin = (addedPlugin: Plugin | Plugin[]): void => { +export const addPlugin = (addedPlugin: Plugin | Plugin[]): Plugin[] => { + const result: Plugin[] = []; each((isArray(addedPlugin) ? addedPlugin : [addedPlugin]) as Plugin[], (plugin) => { - const pluginName = keys(plugin)[0]; - pluginRegistry[pluginName] = plugin[pluginName]; + // multiple "sub-plugins" per plugin object possible to support "static", "instanceObj" and "staticObj" sub-plugins per plugin + const pluginNameKeys = keys(plugin); + each(pluginNameKeys, (key) => { + push(result, (pluginRegistry[key] = plugin[key])); + }); }); + return result; }; diff --git a/packages/overlayscrollbars/src/setups/structureSetup/structureSetup.elements.ts b/packages/overlayscrollbars/src/setups/structureSetup/structureSetup.elements.ts index ccddff8..12dc3af 100644 --- a/packages/overlayscrollbars/src/setups/structureSetup/structureSetup.elements.ts +++ b/packages/overlayscrollbars/src/setups/structureSetup/structureSetup.elements.ts @@ -45,7 +45,7 @@ import type { } from 'initialization'; export type StructureSetupElements = [ - targetObj: StructureSetupElementsObj, + elements: StructureSetupElementsObj, appendElements: () => void, destroy: () => void ]; diff --git a/packages/overlayscrollbars/tests/jest-jsdom/options.test.ts b/packages/overlayscrollbars/tests/jest-jsdom/options.test.ts index ce77859..f7329b5 100644 --- a/packages/overlayscrollbars/tests/jest-jsdom/options.test.ts +++ b/packages/overlayscrollbars/tests/jest-jsdom/options.test.ts @@ -41,6 +41,7 @@ describe('options', () => { b: 0, }; + expect(getOptionsDiff(options, options)).toEqual({}); expect(getOptionsDiff(options, changed)).toEqual(changed); expect( @@ -74,6 +75,7 @@ describe('options', () => { }, }; + expect(getOptionsDiff(options, options)).toEqual({}); expect(getOptionsDiff(options, changed)).toEqual(changed); expect( diff --git a/packages/overlayscrollbars/tests/jest-jsdom/overlayscrollbars.test.ts b/packages/overlayscrollbars/tests/jest-jsdom/overlayscrollbars.test.ts index ba1d5c5..37a7566 100644 --- a/packages/overlayscrollbars/tests/jest-jsdom/overlayscrollbars.test.ts +++ b/packages/overlayscrollbars/tests/jest-jsdom/overlayscrollbars.test.ts @@ -1,6 +1,7 @@ import { DeepPartial } from 'typings'; import { defaultOptions, Options } from 'options'; import { assignDeep } from 'support'; +import { optionsValidationPlugin } from 'plugins'; import { OverlayScrollbars as originalOverlayScrollbars } from '../../src/overlayscrollbars'; const bodyElm = document.body; @@ -155,26 +156,144 @@ describe('overlayscrollbars', () => { }); }); - test('options', () => { - const customOptions: DeepPartial = { - paddingAbsolute: !defaultOptions.paddingAbsolute, - overflow: { x: 'hidden' }, - }; - const osInstance = OverlayScrollbars(div, {}); - expect(osInstance.options()).not.toBe(defaultOptions); - expect(osInstance.options()).toEqual(defaultOptions); - OverlayScrollbars(div)!.destroy(); + describe('elements', () => { + test('get elements', () => { + const osInstance = OverlayScrollbars(div, {}); + const elements = osInstance.elements(); + const elementsObj = { + target: div, + host: div, + padding: expect.any(HTMLElement), + viewport: expect.any(HTMLElement), + content: expect.any(HTMLElement), + scrollOffsetElement: expect.any(HTMLElement), + scrollEventElement: expect.any(HTMLElement), + scrollbarHorizontal: { + scrollbar: expect.any(HTMLElement), + track: expect.any(HTMLElement), + handle: expect.any(HTMLElement), + clone: expect.any(Function), + }, + scrollbarVertical: { + scrollbar: expect.any(HTMLElement), + track: expect.any(HTMLElement), + handle: expect.any(HTMLElement), + clone: expect.any(Function), + }, + }; - expect(OverlayScrollbars(div, customOptions).options()).toEqual( - assignDeep({}, defaultOptions, customOptions) - ); - OverlayScrollbars(div)!.destroy(); + expect(elements).not.toBe(osInstance.elements()); + // clone function identity is always different + expect( + assignDeep({}, elements, { + scrollbarHorizontal: { + clone: null, + }, + scrollbarVertical: { + clone: null, + }, + }) + ).toEqual( + assignDeep({}, osInstance.elements(), { + scrollbarHorizontal: { + clone: null, + }, + scrollbarVertical: { + clone: null, + }, + }) + ); + expect(elements).toEqual(elementsObj); - const osInstance2 = OverlayScrollbars(div, {}); - expect(osInstance2.options(customOptions)).toEqual( - assignDeep({}, defaultOptions, customOptions) - ); - expect(osInstance2.options()).toEqual(assignDeep({}, defaultOptions, customOptions)); + osInstance.destroy(); + + expect(elements).toEqual(elementsObj); + }); + + test('clone scrollbars', () => { + const osInstance = OverlayScrollbars(div, {}); + const elements = osInstance.elements(); + + const hClone = elements.scrollbarHorizontal.clone(); + const vClone = elements.scrollbarVertical.clone(); + + expect(hClone).toEqual({ + scrollbar: expect.any(HTMLElement), + track: expect.any(HTMLElement), + handle: expect.any(HTMLElement), + }); + expect(vClone).toEqual({ + scrollbar: expect.any(HTMLElement), + track: expect.any(HTMLElement), + handle: expect.any(HTMLElement), + }); + + div.append(hClone.scrollbar); + div.append(vClone.scrollbar); + + osInstance.destroy(); + + expect(div.innerHTML).toBe(''); + }); + }); + + describe('options', () => { + [false, true].forEach((withValidationPlugin) => { + describe(`${withValidationPlugin ? 'with' : 'without'} optionsValidationPlugin`, () => { + beforeEach(() => { + if (withValidationPlugin) { + OverlayScrollbars.plugin(optionsValidationPlugin); + } + }); + + test('equality', () => { + const osInstance = OverlayScrollbars(div, {}); + expect(osInstance.options()).not.toBe(defaultOptions); + expect(osInstance.options()).toEqual(defaultOptions); + OverlayScrollbars(div)!.destroy(); + }); + + test('initial options', () => { + const customOptions: DeepPartial = { + paddingAbsolute: !defaultOptions.paddingAbsolute, + overflow: { x: 'hidden' }, + }; + + const osInstance = OverlayScrollbars(div, customOptions); + expect(osInstance.options()).toEqual(assignDeep({}, defaultOptions, customOptions)); + }); + + test('changed options', () => { + const customOptions: DeepPartial = { + paddingAbsolute: !defaultOptions.paddingAbsolute, + overflow: { x: 'hidden' }, + }; + + const osInstance = OverlayScrollbars(div, {}); + expect(osInstance.options(customOptions)).toEqual( + assignDeep({}, defaultOptions, customOptions) + ); + expect(osInstance.options()).toEqual(assignDeep({}, defaultOptions, customOptions)); + osInstance.destroy(); + }); + + test('unchanged wont trigger update', () => { + const updated = jest.fn(); + const initialOpts: DeepPartial = { + paddingAbsolute: true, + overflow: { + y: 'hidden', + }, + }; + const osInstance4 = OverlayScrollbars(div, initialOpts, { + updated, + }); + expect(updated).toHaveBeenCalledTimes(1); + osInstance4.options(initialOpts); + expect(updated).toHaveBeenCalledTimes(1); + }); + }); + }); }); test('on', () => { @@ -296,59 +415,6 @@ describe('overlayscrollbars', () => { ); }); - test('elements', () => { - const osInstance = OverlayScrollbars(div, {}); - const elements = osInstance.elements(); - const elementsObj = { - target: div, - host: div, - padding: expect.any(HTMLElement), - viewport: expect.any(HTMLElement), - content: expect.any(HTMLElement), - scrollOffsetElement: expect.any(HTMLElement), - scrollEventElement: expect.any(HTMLElement), - scrollbarHorizontal: { - scrollbar: expect.any(HTMLElement), - track: expect.any(HTMLElement), - handle: expect.any(HTMLElement), - clone: expect.any(Function), - }, - scrollbarVertical: { - scrollbar: expect.any(HTMLElement), - track: expect.any(HTMLElement), - handle: expect.any(HTMLElement), - clone: expect.any(Function), - }, - }; - - expect(elements).not.toBe(osInstance.elements()); - // clone function identity is always different - expect( - assignDeep({}, elements, { - scrollbarHorizontal: { - clone: null, - }, - scrollbarVertical: { - clone: null, - }, - }) - ).toEqual( - assignDeep({}, osInstance.elements(), { - scrollbarHorizontal: { - clone: null, - }, - scrollbarVertical: { - clone: null, - }, - }) - ); - expect(elements).toEqual(elementsObj); - - osInstance.destroy(); - - expect(elements).toEqual(elementsObj); - }); - test('update', () => { const osInstance = OverlayScrollbars(div, {}); expect(osInstance.update()).toBe(false); @@ -405,4 +471,64 @@ describe('overlayscrollbars', () => { expect(OverlayScrollbars.valid(osInstance)).toBe(false); }); }); + + test('plugins', () => { + const staticPluginFn = jest.fn(); + const staticObjPluginFn = jest.fn(); + const instanceObjPluginFn = jest.fn(); + const staticPlugin = { + staticPlugin: { + staticFn: staticPluginFn, + }, + }; + expect( + OverlayScrollbars.plugin([ + { + expect: (staticObj, instanceObj) => { + if (instanceObj) { + expect(staticObj).toBe(undefined); + expect(OverlayScrollbars.valid(instanceObj)).toBe(true); + } + if (staticObj) { + expect(staticObj).toBe(OverlayScrollbars); + expect(instanceObj).toBe(undefined); + } + }, + }, + { + staticPlugin, + staticObjPlugin: (staticObj, instanceObj) => { + if (staticObj) { + expect(instanceObj).toBe(undefined); + // @ts-ignore + staticObj.staticObjPluginFn = staticObjPluginFn; + } + }, + instanceObjPlugin: (_, instanceObj) => { + if (instanceObj) { + // @ts-ignore + instanceObj.instanceObjPluginFn = instanceObjPluginFn; + } + }, + }, + ]) + ).toBe(undefined); + + expect(staticPluginFn).not.toHaveBeenCalled(); + // @ts-ignore + staticPlugin.staticPlugin.staticFn(); + expect(staticPluginFn).toHaveBeenCalledTimes(1); + + // staticObj plugin must be available before any initialization + expect(staticObjPluginFn).not.toHaveBeenCalled(); + // @ts-ignore + OverlayScrollbars.staticObjPluginFn(); + expect(staticObjPluginFn).toHaveBeenCalledTimes(1); + + const osInstance = OverlayScrollbars(div, {}); + expect(instanceObjPluginFn).not.toHaveBeenCalled(); + // @ts-ignore + osInstance.instanceObjPluginFn(); + expect(instanceObjPluginFn).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/overlayscrollbars/tests/jest-jsdom/plugins/plugins.test.ts b/packages/overlayscrollbars/tests/jest-jsdom/plugins/plugins.test.ts index c298f75..da26936 100644 --- a/packages/overlayscrollbars/tests/jest-jsdom/plugins/plugins.test.ts +++ b/packages/overlayscrollbars/tests/jest-jsdom/plugins/plugins.test.ts @@ -9,19 +9,24 @@ describe('plugins', () => { test('addPlugin single', () => { const myPlugin = {}; const myPlugin2 = {}; - addPlugin({ + const addedPlugins = addPlugin({ myPlugin, myPlugin2, }); + expect(addedPlugins.length).toBe(2); + expect(addedPlugins[0]).toBe(myPlugin); + expect(addedPlugins[1]).toBe(myPlugin2); + const plugins = getPlugins(); expect(plugins.myPlugin).toBe(myPlugin); - expect(plugins.myPlugin2).toBe(undefined); // one plugin per object + // multiple "sub-plugins" per plugin object possible to support "static", "instanceObj" and "staticObj" sub-plugins per plugin + expect(plugins.myPlugin2).toBe(myPlugin2); }); test('addPlugin multiple', () => { const myPlugin = {}; const myPlugin2 = {}; - addPlugin([ + const addedPlugins = addPlugin([ { myPlugin, }, @@ -29,6 +34,11 @@ describe('plugins', () => { myPlugin2, }, ]); + + expect(addedPlugins.length).toBe(2); + expect(addedPlugins[0]).toBe(myPlugin); + expect(addedPlugins[1]).toBe(myPlugin2); + const plugins = getPlugins(); expect(plugins.myPlugin).toBe(myPlugin); expect(plugins.myPlugin2).toBe(myPlugin2); diff --git a/packages/overlayscrollbars/tests/jest-jsdom/setups/scrollbarsSetup/scrollbarsSetup.elements.test.ts b/packages/overlayscrollbars/tests/jest-jsdom/setups/scrollbarsSetup/scrollbarsSetup.elements.test.ts new file mode 100644 index 0000000..7d160a2 --- /dev/null +++ b/packages/overlayscrollbars/tests/jest-jsdom/setups/scrollbarsSetup/scrollbarsSetup.elements.test.ts @@ -0,0 +1,363 @@ +import { + createScrollbarsSetupElements, + ScrollbarsSetupElement, + ScrollbarsSetupElementsObj, + ScrollbarStructure, +} from 'setups/scrollbarsSetup/scrollbarsSetup.elements'; +import { + createStructureSetupElements, + StructureSetupElementsObj, +} from 'setups/structureSetup/structureSetup.elements'; +import { + classNameScrollbar, + classNameScrollbarHorizontal, + classNameScrollbarVertical, + classNameScrollbarTrack, + classNameScrollbarHandle, + classNamesScrollbarTransitionless, +} from 'classnames'; +import type { InitializationTarget } from 'initialization'; + +jest.useFakeTimers(); + +jest.mock('support/compatibility/apis', () => { + const originalModule = jest.requireActual('support/compatibility/apis'); + return { + ...originalModule, + // @ts-ignore + setT: jest.fn().mockImplementation((...args) => setTimeout(...args)), + clearT: jest.fn().mockImplementation((...args) => clearTimeout(...args)), + }; +}); + +const getTarget = () => document.body.firstElementChild as HTMLElement; + +const domSnapshot = (element?: HTMLElement) => { + const getResult = () => (element ? element.innerHTML : document.documentElement.outerHTML); + return [getResult(), () => getResult()] as [string, () => string]; +}; + +const createStructureSetupElementsProxy = (target: InitializationTarget) => { + const [structureElements, , destroyStructureElements] = createStructureSetupElements(target); + const [elements, appendElements, destroy] = createScrollbarsSetupElements( + target, + structureElements, + () => () => {} + ); + + appendElements(); + + return [ + elements, + () => { + destroyStructureElements(); + destroy(); + }, + structureElements, + ] as [ + elements: ScrollbarsSetupElementsObj, + destroy: () => void, + structureElements: StructureSetupElementsObj + ]; +}; + +const assertCorrectDOMStructure = ( + elements: ScrollbarsSetupElementsObj, + target: HTMLElement | ((isHorizontal?: boolean) => HTMLElement), + getTargetStructure?: ( + structures: ScrollbarStructure[], + isHorizontal?: boolean + ) => ScrollbarStructure +) => { + const getStructure = (isHorizontal?: boolean) => + isHorizontal + ? elements._horizontal._scrollbarStructures + : elements._vertical._scrollbarStructures; + const assertScrollbarStructure = (isHorizontal?: boolean) => { + const resolvedTarget = typeof target === 'function' ? target(isHorizontal) : target; + const domScrollbar = resolvedTarget.querySelector( + `.${isHorizontal ? classNameScrollbarHorizontal : classNameScrollbarVertical}` + ) as HTMLElement; + const structures = getStructure(isHorizontal); + + expect(structures.length).toBeGreaterThanOrEqual(1); + + const targetStructure = getTargetStructure?.(structures, isHorizontal) || structures[0]; + const isMainStructure = targetStructure === structures[0]; + const { _scrollbar, _track, _handle } = targetStructure; + + // classnames + expect(domScrollbar).toEqual(expect.any(HTMLElement)); + expect(domScrollbar.classList.contains(classNameScrollbar)).toBe(true); + expect(_track.classList.contains(classNameScrollbarTrack)).toBe(true); + expect(_handle.classList.contains(classNameScrollbarHandle)).toBe(true); + expect(_track.classList.length).toBe(1); + expect(_handle.classList.length).toBe(1); + if (isMainStructure) { + expect(domScrollbar.classList.contains(classNamesScrollbarTransitionless)).toBe(true); + } + + // structure + expect(_scrollbar).toBe(domScrollbar); + expect(_track.closest(`.${classNameScrollbar}`)).toBe(_scrollbar); + expect(_handle.closest(`.${classNameScrollbarTrack}`)).toBe(_track); + }; + + assertScrollbarStructure(true); + assertScrollbarStructure(); +}; + +describe('scrollbarsSetup.elements', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + }); + + [false, true].forEach((targetIsObj) => { + describe(`as target ${targetIsObj ? 'object' : 'element'}`, () => { + test('initialization and destruction', () => { + const target = getTarget(); + const [beforeInitSnapshot, beforeInitSnapshotFn] = domSnapshot(); + const [elements, destroy] = createStructureSetupElementsProxy( + targetIsObj ? { target } : target + ); + + assertCorrectDOMStructure(elements, target); + + destroy(); + + expect(beforeInitSnapshot).toBe(beforeInitSnapshotFn()); + }); + + test('cloning', () => { + const target = getTarget(); + const [beforeInitSnapshot, beforeInitSnapshotFn] = domSnapshot(); + const [elements, destroy] = createStructureSetupElementsProxy( + targetIsObj ? { target } : target + ); + const clonedHorizontalSlot = document.createElement('div'); + const clonedVerticalSlot = document.createElement('div'); + + const [beforeCloneHorizontalSnapshot, beforeCloneHorizontalSnapshotFn] = + domSnapshot(clonedHorizontalSlot); + const [beforeCloneVerticalSnapshot, beforeCloneVerticalSnapshotFn] = + domSnapshot(clonedVerticalSlot); + + const clonedHorizontal = elements._horizontal._clone(); + const clonedVertical = elements._vertical._clone(); + + clonedHorizontalSlot.append(clonedHorizontal._scrollbar); + clonedVerticalSlot.append(clonedVertical._scrollbar); + + target.append(clonedHorizontalSlot); + target.append(clonedVerticalSlot); + + assertCorrectDOMStructure(elements, target); + assertCorrectDOMStructure( + elements, + (isHorizontal) => (isHorizontal ? clonedHorizontalSlot : clonedVerticalSlot), + (structures, isHorizontal) => { + const clonedStructure = isHorizontal ? clonedHorizontal : clonedVertical; + return structures.find( + (structure) => structure._scrollbar === clonedStructure._scrollbar + )!; + } + ); + + destroy(); + + // destroy cleans up clones as well + expect(beforeCloneHorizontalSnapshot).toBe(beforeCloneHorizontalSnapshotFn()); + expect(beforeCloneVerticalSnapshot).toBe(beforeCloneVerticalSnapshotFn()); + + clonedHorizontalSlot.remove(); + clonedVerticalSlot.remove(); + + expect(beforeInitSnapshot).toBe(beforeInitSnapshotFn()); + }); + }); + }); + + describe('addRemoveClass', () => { + test('add & remove classes to both axis', () => { + const target = getTarget(); + const [elements] = createStructureSetupElementsProxy(target); + + const horizontalStructures = elements._horizontal._scrollbarStructures; + const verticalStructures = elements._vertical._scrollbarStructures; + const className = 'aksjhdkasjd'; + + // clones before have the class + elements._horizontal._clone(); + elements._vertical._clone(); + + elements._scrollbarsAddRemoveClass(className, true); + + // clones after do not have the class + elements._horizontal._clone(); + elements._vertical._clone(); + + horizontalStructures.forEach(({ _scrollbar }, i, arr) => { + if (i === arr.length - 1) { + expect(_scrollbar.classList.contains(className)).toBe(false); + } else { + expect(_scrollbar.classList.contains(className)).toBe(true); + } + }); + verticalStructures.forEach(({ _scrollbar }, i, arr) => { + if (i === arr.length - 1) { + expect(_scrollbar.classList.contains(className)).toBe(false); + } else { + expect(_scrollbar.classList.contains(className)).toBe(true); + } + }); + + elements._scrollbarsAddRemoveClass(className); + + horizontalStructures.forEach(({ _scrollbar }) => { + expect(_scrollbar.classList.contains(className)).toBe(false); + }); + verticalStructures.forEach(({ _scrollbar }) => { + expect(_scrollbar.classList.contains(className)).toBe(false); + }); + }); + + test('add & remove classes to individual axis', () => { + const target = getTarget(); + const [elements] = createStructureSetupElementsProxy(target); + + const horizontalStructures = elements._horizontal._scrollbarStructures; + const verticalStructures = elements._vertical._scrollbarStructures; + const classNameHorizontal = 'hhhhhhhhh12sdsdf'; + const classNameVertical = 'vvvvvvv12sdsdf'; + + // clones before have the class + elements._horizontal._clone(); + elements._vertical._clone(); + + elements._scrollbarsAddRemoveClass(classNameHorizontal, true, true); + elements._scrollbarsAddRemoveClass(classNameVertical, true, false); + + // clones after do not have the class + elements._horizontal._clone(); + elements._vertical._clone(); + + horizontalStructures.forEach(({ _scrollbar }, i, arr) => { + if (i === arr.length - 1) { + expect(_scrollbar.classList.contains(classNameHorizontal)).toBe(false); + expect(_scrollbar.classList.contains(classNameVertical)).toBe(false); + } else { + expect(_scrollbar.classList.contains(classNameHorizontal)).toBe(true); + expect(_scrollbar.classList.contains(classNameVertical)).toBe(false); + } + }); + verticalStructures.forEach(({ _scrollbar }, i, arr) => { + if (i === arr.length - 1) { + expect(_scrollbar.classList.contains(classNameHorizontal)).toBe(false); + expect(_scrollbar.classList.contains(classNameVertical)).toBe(false); + } else { + expect(_scrollbar.classList.contains(classNameHorizontal)).toBe(false); + expect(_scrollbar.classList.contains(classNameVertical)).toBe(true); + } + }); + + elements._scrollbarsAddRemoveClass(classNameHorizontal, false, true); + elements._scrollbarsAddRemoveClass(classNameVertical, false, false); + + horizontalStructures.forEach(({ _scrollbar }) => { + expect(_scrollbar.classList.contains(classNameHorizontal)).toBe(false); + expect(_scrollbar.classList.contains(classNameVertical)).toBe(false); + }); + verticalStructures.forEach(({ _scrollbar }) => { + expect(_scrollbar.classList.contains(classNameHorizontal)).toBe(false); + expect(_scrollbar.classList.contains(classNameVertical)).toBe(false); + }); + }); + }); + + test('initialization and destruction in custom slot', () => { + const target = getTarget(); + const slotFn = jest.fn(() => document.body); + const [beforeInitSnapshot, beforeInitSnapshotFn] = domSnapshot(); + const [elements, destroy, structureElements] = createStructureSetupElementsProxy({ + target, + scrollbars: { + slot: slotFn, + }, + }); + + expect(slotFn).toHaveBeenCalledTimes(1); + expect(slotFn).toHaveBeenCalledWith( + structureElements._target, + structureElements._host, + structureElements._viewport + ); + + assertCorrectDOMStructure(elements, document.body); + + destroy(); + + expect(beforeInitSnapshot).toBe(beforeInitSnapshotFn()); + }); + + test('handleStyle', () => { + const target = getTarget(); + const [elements] = createStructureSetupElementsProxy(target); + const testHandleStyle = (scrollbarSetupElement: ScrollbarsSetupElement) => { + // before cloned elements have the style + scrollbarSetupElement._clone(); + + scrollbarSetupElement._handleStyle((structure) => { + const { _scrollbar } = structure; + return [_scrollbar, { width: '0px' }]; + }); + scrollbarSetupElement._handleStyle((structure) => { + const { _track } = structure; + return [_track, { width: '1px' }]; + }); + scrollbarSetupElement._handleStyle((structure) => { + const { _handle } = structure; + return [_handle, { width: '2px' }]; + }); + + // before cloned elements don not have the style + scrollbarSetupElement._clone(); + + scrollbarSetupElement._scrollbarStructures.forEach( + ({ _scrollbar, _track, _handle }, i, arr) => { + if (i === arr.length - 1) { + expect(_scrollbar.style.width).not.toBe('0px'); + expect(_track.style.width).not.toBe('1px'); + expect(_handle.style.width).not.toBe('2px'); + } else { + expect(_scrollbar.style.width).toBe('0px'); + expect(_track.style.width).toBe('1px'); + expect(_handle.style.width).toBe('2px'); + } + } + ); + }; + + testHandleStyle(elements._horizontal); + testHandleStyle(elements._vertical); + }); + + test('removes transitionless class', () => { + const target = getTarget(); + const [elements] = createStructureSetupElementsProxy(target); + const testHasTransitionlessClass = ( + setupElement: ScrollbarsSetupElement, + expected: boolean + ) => { + const { _scrollbar } = setupElement._scrollbarStructures[0]; + expect(_scrollbar.classList.contains(classNamesScrollbarTransitionless)).toBe(expected); + }; + + testHasTransitionlessClass(elements._horizontal, true); + testHasTransitionlessClass(elements._vertical, true); + + jest.runAllTimers(); + + testHasTransitionlessClass(elements._horizontal, false); + testHasTransitionlessClass(elements._vertical, false); + }); +}); diff --git a/packages/overlayscrollbars/tests/jest-jsdom/setups/structureSetup/structureSetup.elements.test.ts b/packages/overlayscrollbars/tests/jest-jsdom/setups/structureSetup/structureSetup.elements.test.ts index 7006039..67c592e 100644 --- a/packages/overlayscrollbars/tests/jest-jsdom/setups/structureSetup/structureSetup.elements.test.ts +++ b/packages/overlayscrollbars/tests/jest-jsdom/setups/structureSetup/structureSetup.elements.test.ts @@ -1,6 +1,11 @@ import { hasClass, is, isFunction, isHTMLElement } from 'support'; -import { dataAttributeHost } from 'classnames'; -import { InternalEnvironment } from 'environment'; +import { + dataAttributeHost, + classNamePadding, + classNameViewport, + classNameContent, +} from 'classnames'; +import { getEnvironment, InternalEnvironment } from 'environment'; import { createStructureSetupElements, StructureSetupElementsObj, @@ -12,9 +17,8 @@ import type { InitializationTargetObject, } from 'initialization'; -const mockGetEnvironment = jest.fn(); jest.mock('environment', () => ({ - getEnvironment: jest.fn().mockImplementation(() => mockGetEnvironment()), + getEnvironment: jest.fn(), })); jest.mock('support/compatibility/apis', () => { @@ -81,10 +85,10 @@ const clearBody = () => { const getElements = (targetType: TargetType) => { const target = getTarget(targetType); - const host = document.querySelector('[data-overlayscrollbars]')!; - const padding = document.querySelector('.os-padding')!; - const viewport = document.querySelector('.os-viewport')!; - const content = document.querySelector('.os-content')!; + const host = document.querySelector(`[${dataAttributeHost}]`)!; + const padding = document.querySelector(`.${classNamePadding}`)!; + const viewport = document.querySelector(`.${classNameViewport}`)!; + const content = document.querySelector(`.${classNameContent}`)!; return { target, @@ -133,7 +137,9 @@ const assertCorrectDOMStructure = (targetType: TargetType, viewportIsTarget: boo } }; -const createStructureSetupProxy = (target: InitializationTarget): StructureSetupElementsProxy => { +const createStructureSetupElementsProxy = ( + target: InitializationTarget +): StructureSetupElementsProxy => { const [elements, appendElements, destroy] = createStructureSetupElements(target); appendElements(); return { @@ -422,9 +428,15 @@ const envInitStrategyViewportIsTarget = { }, }; -describe('structureSetup', () => { +describe('structureSetup.elements', () => { afterEach(() => clearBody()); + beforeEach(() => { + (getEnvironment as jest.Mock).mockImplementation(() => + jest.requireActual('environment').getEnvironment() + ); + }); + [ envDefault, envNativeScrollbarStyling, @@ -436,8 +448,8 @@ describe('structureSetup', () => { ].forEach((envWithName) => { const { env: currEnv, name } = envWithName; describe(`Environment: ${name}`, () => { - beforeAll(() => { - mockGetEnvironment.mockImplementation(() => currEnv); + beforeEach(() => { + (getEnvironment as jest.Mock).mockImplementation(() => currEnv); }); (['element', 'textarea', 'body'] as TargetType[]).forEach((targetType) => { @@ -447,7 +459,7 @@ describe('structureSetup', () => { const snapshot = fillBody(targetType); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy(getTarget(targetType)), + createStructureSetupElementsProxy(getTarget(targetType)), currEnv ); assertCorrectDOMStructure(targetType, elements._viewportIsTarget); @@ -458,7 +470,7 @@ describe('structureSetup', () => { const snapshot = fillBody(targetType); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ target: getTarget(targetType) }), + createStructureSetupElementsProxy({ target: getTarget(targetType) }), currEnv ); assertCorrectDOMStructure(targetType, elements._viewportIsTarget); @@ -476,7 +488,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -497,7 +509,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: () => document.querySelector('#host'), @@ -518,7 +530,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -541,7 +553,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -564,7 +576,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: () => document.querySelector('#host'), @@ -586,7 +598,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -608,7 +620,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -628,7 +640,7 @@ describe('structureSetup', () => { const snapshot = fillBody(targetType); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { padding: false, @@ -644,7 +656,7 @@ describe('structureSetup', () => { const snapshot = fillBody(targetType); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { content: () => false, @@ -662,7 +674,7 @@ describe('structureSetup', () => { const snapshot = fillBody(targetType); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { padding: () => true, @@ -678,7 +690,7 @@ describe('structureSetup', () => { const snapshot = fillBody(targetType); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { content: true, @@ -696,7 +708,7 @@ describe('structureSetup', () => { const snapshot = fillBody(targetType); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { padding: false, @@ -715,7 +727,7 @@ describe('structureSetup', () => { const snapshot = fillBody(targetType); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { padding: true, @@ -738,7 +750,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -761,7 +773,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -784,7 +796,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -807,7 +819,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -830,7 +842,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -852,7 +864,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -874,7 +886,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -896,7 +908,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -918,7 +930,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -941,7 +953,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -964,7 +976,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -986,7 +998,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: () => document.querySelector('#host'), @@ -1008,7 +1020,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -1030,7 +1042,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -1052,7 +1064,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: document.querySelector('#host'), @@ -1075,7 +1087,7 @@ describe('structureSetup', () => { ); const [elements, destroy] = assertCorrectSetupElements( targetType, - createStructureSetupProxy({ + createStructureSetupElementsProxy({ target: getTarget(targetType), elements: { host: () => document.querySelector('#host'), @@ -1095,4 +1107,50 @@ describe('structureSetup', () => { }); }); }); + + describe('focus', () => { + describe('shift tabindex to viewport', () => { + test('with pointerdown on body', () => { + const { elements } = createStructureSetupElementsProxy(document.body); + expect(elements._viewport.getAttribute('tabindex')).toBe('-1'); + expect(document.activeElement).toBe(elements._viewport); + + elements._documentElm.dispatchEvent(new Event('pointerdown')); + + expect(elements._viewport.getAttribute('tabindex')).toBe(null); + }); + + test('with keydown on element', () => { + document.body.innerHTML = '
'; + const target = document.body.firstElementChild as HTMLElement; + target.focus(); + + const { elements } = createStructureSetupElementsProxy(target); + expect(elements._viewport.getAttribute('tabindex')).toBe('-1'); + expect(document.activeElement).toBe(elements._viewport); + + elements._documentElm.dispatchEvent(new Event('keydown')); + + expect(elements._viewport.getAttribute('tabindex')).toBe(null); + }); + }); + + test('shift tabindex back to original activeElement', () => { + document.body.innerHTML = ''; + const input = document.querySelector('input') as HTMLInputElement; + const target = document.body; + + input.focus(); + const preInitFocus = document.activeElement; + + const { elements } = createStructureSetupElementsProxy(target); + + expect(preInitFocus).toBe(document.activeElement); + + elements._documentElm.dispatchEvent(new Event('pointerdown')); + elements._documentElm.dispatchEvent(new Event('keydown')); + + expect(preInitFocus).toBe(document.activeElement); + }); + }); });