improve code, add ignoreMutation option

This commit is contained in:
Rene Haas
2022-07-07 09:36:22 +02:00
parent 95c0d50ddf
commit 66c55f98e9
13 changed files with 235 additions and 92 deletions
@@ -20,20 +20,23 @@ type DOMTargetObserverCallback = (targetChangedAttrs: string[], targetStyleChang
interface DOMObserverOptionsBase {
_attributes?: string[];
_styleChangingAttributes?: string[];
/**
* A function which can ignore a changed attribute if it returns true.
* for DOMTargetObserver this applies to the changes to the observed target
* for DOMContentObserver this applies to changes to nested targets -> nested targets are elements which match the "_nestedTargetSelector" selector
*/
_ignoreTargetChange?: DOMObserverIgnoreTargetChange;
}
interface DOMContentObserverOptions extends DOMObserverOptionsBase {
_eventContentChange?: DOMObserverEventContentChange; // [selector, eventname(s) | function returning eventname(s)] -> eventnames divided by whitespaces
_nestedTargetSelector?: string;
_ignoreContentChange?: DOMObserverIgnoreContentChange; // function which will prevent marking certain dom changes as content change if it returns true
_ignoreNestedTargetChange?: DOMObserverIgnoreTargetChange; // a function which will prevent marking certain attributes as changed on nested targets if it returns true
}
interface DOMTargetObserverOptions extends DOMObserverOptionsBase {
_ignoreTargetChange?: DOMObserverIgnoreTargetChange; // a function which will prevent marking certain attributes as changed if it returns true
}
type DOMTargetObserverOptions = DOMObserverOptionsBase;
type ContentChangeArrayItem = [string?, string?] | null | undefined;
type ContentChangeArrayItem = [selector?: string, eventNames?: string] | null | undefined;
export type DOMObserverEventContentChange =
| Array<ContentChangeArrayItem>
@@ -161,7 +164,6 @@ export const createDOMObserver = <ContentObserver extends boolean>(
_eventContentChange,
_nestedTargetSelector,
_ignoreTargetChange,
_ignoreNestedTargetChange,
_ignoreContentChange,
} = (options as DOMContentObserverOptions & DOMTargetObserverOptions) || {};
const [destroyEventContentChange, updateEventContentChangeElements] = createEventContentChange(
@@ -182,8 +184,7 @@ export const createDOMObserver = <ContentObserver extends boolean>(
const finalStyleChangingAttributes = _styleChangingAttributes || [];
const observedAttributes = finalAttributes.concat(finalStyleChangingAttributes);
const observerCallback = (mutations: MutationRecord[]) => {
const ignoreTargetChange =
(isContentObserver ? _ignoreNestedTargetChange : _ignoreTargetChange) || noop;
const ignoreTargetChange = _ignoreTargetChange || noop;
const ignoreContentChange = _ignoreContentChange || noop;
const targetChangedAttrs: string[] = [];
const totalAddedNodes: Node[] = [];
+5 -3
View File
@@ -41,9 +41,10 @@ export type UpdatedCallback = (this: any, args?: UpdatedArgs) => void;
export interface OSOptions {
paddingAbsolute: boolean;
updating: {
elementEvents: Array<[string, string]> | null;
elementEvents: Array<[elementSelector: string, eventNames: string]> | null;
attributes: string[] | null;
debounce: number | [number, number] | null;
debounce: [timeout: number, maxWait: number] | number | null; // (if tuple: [timeout: 0, maxWait: 33], if number: [timeout: number, maxWait: false]) debounce for content Changes
ignoreMutation: ((mutation: MutationRecord) => any) | null;
};
overflow: {
x: OverflowBehavior;
@@ -97,8 +98,9 @@ export const defaultOptions: OSOptions = {
paddingAbsolute: false, // true || false
updating: {
elementEvents: [['img', 'load']], // array of tuples || null
attributes: null,
debounce: [0, 33], // number || number array || null
attributes: null, // string array || null
ignoreMutation: null, // () => any || null
},
overflow: {
x: 'scroll', // visible-hidden || visible-scroll || hidden || scroll || v-h || v-s || h || s
@@ -62,6 +62,14 @@ export interface OverlayScrollbarsState {
hasOverflow: XY<boolean>;
}
export interface OverlayScrollbarsElements {
target: HTMLElement;
host: HTMLElement;
padding: HTMLElement;
viewport: HTMLElement;
content: HTMLElement;
}
export interface OverlayScrollbars {
options(): OSOptions;
options(newOptions?: PartialOptions<OSOptions>): OSOptions;
@@ -70,6 +78,7 @@ export interface OverlayScrollbars {
destroy(): void;
state(): OverlayScrollbarsState;
elements(): OverlayScrollbarsElements;
on: AddOSEventListener;
off: RemoveOSEventListener;
@@ -177,7 +186,7 @@ export const OverlayScrollbars: OverlayScrollbarsStatic = (
},
on: addEvent,
off: removeEvent,
state: () => {
state() {
const { _overflowAmount, _overflowStyle, _hasOverflow, _padding, _paddingAbsolute } =
structureState();
return assignDeep(
@@ -191,6 +200,19 @@ export const OverlayScrollbars: OverlayScrollbarsStatic = (
}
);
},
elements() {
const { _target, _host, _padding, _viewport, _content } = structureState._elements;
return assignDeep(
{},
{
target: _target,
host: _host,
padding: _padding || _viewport,
viewport: _viewport,
content: _content || _viewport,
}
);
},
update(force?: boolean) {
update({}, force);
},
@@ -25,6 +25,7 @@ const optionsTemplate: OptionsTemplate<OSOptions> = {
elementEvents: arrayNullValues, // array of tuples || null
attributes: arrayNullValues,
debounce: [oTypes.number, oTypes.array, oTypes.null], // number || number array || null
ignoreMutation: [oTypes.function, oTypes.null], // function || null
},
overflow: {
x: overflowAllowedValues, // visible-hidden || visible-scroll || hidden || scrol
@@ -3,7 +3,7 @@ import { type, isArray, isUndefined, isPlainObject, isString } from 'support/uti
import { PlainObject, PartialOptions } from 'typings';
export type OptionsObjectType = Record<string, unknown>;
export type OptionsFunctionType = (this: unknown, ...args: unknown[]) => unknown;
export type OptionsFunctionType = (this: any, ...args: any[]) => any;
export type OptionsTemplateType<T extends OptionsTemplateNativeTypes> = ExtractPropsKey<
OptionsTemplateTypeMap,
T
@@ -1,5 +1,4 @@
import {
diffClass,
debounce,
isArray,
isNumber,
@@ -19,14 +18,10 @@ import {
removeClass,
addClass,
hasClass,
isFunction,
} from 'support';
import { getEnvironment } from 'environment';
import {
dataAttributeHost,
classNameViewport,
classNameContent,
classNameOverflowVisible,
} from 'classnames';
import { dataAttributeHost, classNameViewport, classNameOverflowVisible } from 'classnames';
import { createSizeObserver, SizeObserverCallbackParams } from 'observers/sizeObserver';
import { createTrinsicObserver } from 'observers/trinsicObserver';
import { createDOMObserver, DOMObserver } from 'observers/domObserver';
@@ -54,29 +49,12 @@ type ExcludeFromTuple<T extends readonly any[], E> = T extends [infer F, ...infe
const hostSelector = `[${dataAttributeHost}]`;
// TODO: observer textarea attrs if textarea
// TODO: test _ignoreContentChange & _ignoreNestedTargetChange for content dom observer
// TODO: test _ignoreTargetChange for target dom observer
const viewportSelector = `.${classNameViewport}`;
const contentSelector = `.${classNameContent}`;
const ignorePrefix = 'os-';
const viewportAttrsFromTarget = ['tabindex'];
const baseStyleChangingAttrsTextarea = ['wrap', 'cols', 'rows'];
const baseStyleChangingAttrs = ['id', 'class', 'style', 'open'];
const ignoreTargetChange = (
target: Node,
attrName: string,
oldValue: string | null,
newValue: string | null
) => {
if (attrName === 'class' && oldValue && newValue) {
const diff = diffClass(oldValue, newValue);
return !!diff.find((addedOrRemovedClass) => addedOrRemovedClass.indexOf(ignorePrefix) !== 0);
}
return false;
};
export const createStructureSetupObservers = (
structureSetupElements: StructureSetupElementsObj,
state: SetupState<StructureSetupState>,
@@ -198,21 +176,23 @@ export const createStructureSetupObservers = (
const [destroyHostMutationObserver] = createDOMObserver(_host, false, onHostMutation, {
_styleChangingAttributes: baseStyleChangingAttrs,
_attributes: baseStyleChangingAttrs.concat(viewportAttrsFromTarget),
_ignoreTargetChange: ignoreTargetChange,
});
updateViewportAttrsFromHost();
return [
(checkOption) => {
const [ignoreMutation] = checkOption<string[] | null>('updating.ignoreMutation');
const [attributes, attributesChanged] = checkOption<string[] | null>('updating.attributes');
const [elementEvents, elementEventsChanged] = checkOption<Array<[string, string]> | null>(
'updating.elementEvents'
);
const [attributes, attributesChanged] = checkOption<string[] | null>('updating.attributes');
const [debounceValue, debounceChanged] = checkOption<Array<number> | number | null>(
'updating.debounce'
);
const updateContentMutationObserver = elementEventsChanged || attributesChanged;
const ignoreMutationFromOptions = (mutation: MutationRecord) =>
isFunction(ignoreMutation) && ignoreMutation(mutation);
if (updateContentMutationObserver) {
if (contentMutationObserver) {
@@ -227,17 +207,14 @@ export const createStructureSetupObservers = (
_styleChangingAttributes: contentMutationObserverAttr.concat(attributes || []),
_attributes: contentMutationObserverAttr.concat(attributes || []),
_eventContentChange: elementEvents,
_ignoreNestedTargetChange: ignoreTargetChange,
_nestedTargetSelector: hostSelector,
_ignoreContentChange: (mutation, isNestedTarget) => {
const { target, attributeName } = mutation;
return !isNestedTarget && attributeName
? liesBetween(
target as Element,
hostSelector,
_content ? contentSelector : viewportSelector
)
: false;
const ignore =
!isNestedTarget && attributeName
? liesBetween(target as Element, hostSelector, viewportSelector)
: false;
return ignore || !!ignoreMutationFromOptions(mutation);
},
}
);
@@ -137,12 +137,6 @@ const targetDomObserver = createDOMObserver(
should.ok(false, 'A target dom observer must not call the _ignoreContentChange method.');
return true;
},
// @ts-ignore
_ignoreNestedTargetChange: () => {
// if param: isContentObserver = false, this function should never be called.
should.ok(false, 'A target dom observer must not call the _ignoreNestedTargetChange method.');
return true;
},
}
);
@@ -182,7 +176,7 @@ const createContentDomOserver = (
? liesBetween(target as Element, hostSelector, '.content')
: false;
},
_ignoreNestedTargetChange: (_target, attrName, oldValue, newValue) => {
_ignoreTargetChange: (_target, attrName, oldValue, newValue) => {
if (attrName === 'class' && oldValue && newValue) {
const diff = diffClass(oldValue, newValue);
const ignore = diff.length === 1 && diff[0].startsWith(ignorePrefix);
@@ -190,12 +184,6 @@ const createContentDomOserver = (
}
return false;
},
// @ts-ignore
_ignoreTargetChange: () => {
// if param: isContentObserver = true, this function should never be called.
should.ok(false, 'A content dom observer must not call the _ignoreTargetChange method.');
return true;
},
}
);
@@ -503,7 +491,8 @@ const addRemoveImgElmsFn = async (changeless = false) => {
const addChanged = async (
newEventContentChange: Array<[string?, string?] | null | undefined>
) => {
contentDomObserver._destroy();
const [destroyA] = contentDomObserver;
destroyA();
contentDomObserver = createContentDomOserver(newEventContentChange);
const img = new Image(1, 1);
@@ -523,7 +512,8 @@ const addRemoveImgElmsFn = async (changeless = false) => {
compare(1);
});
contentDomObserver._destroy();
const [destroyB] = contentDomObserver;
destroyB();
contentDomObserver = createContentDomOserver(contentChange);
};
@@ -604,7 +594,8 @@ const addRemoveTransitionElmsFn = async () => {
});
await startTransition(elm, expectTransitionEndContentChange && true);
contentDomObserver._destroy();
const [destroy] = contentDomObserver;
destroy();
contentDomObserver = createContentDomOserver(contentChange);
await startTransition(elm, expectTransitionEndContentChange && false);
@@ -615,7 +606,8 @@ const addRemoveTransitionElmsFn = async () => {
await add(false);
contentDomObserver._destroy();
const [destroy] = contentDomObserver;
destroy();
contentDomObserver = createContentDomOserver(
contentChange.concat([['.transition', 'transitionend']])
);
@@ -734,15 +726,17 @@ const start = async () => {
await addRemoveImgElmsFn();
targetDomObserver._update();
targetDomObserver._destroy();
targetDomObserver._destroy();
targetDomObserver._update();
const [destroyTarget, updateTarget] = targetDomObserver;
updateTarget();
destroyTarget();
destroyTarget();
updateTarget();
contentDomObserver._update();
contentDomObserver._destroy();
contentDomObserver._destroy();
contentDomObserver._update();
const [destroyContent, updateContent] = contentDomObserver;
updateContent();
destroyContent();
destroyContent();
updateContent();
await addRemoveImgElmsFn(true); // won't trigger changes after destroy
@@ -5,7 +5,7 @@ import { OverlayScrollbars } from 'overlayscrollbars';
import { resize } from '@/testing-browser/Resize';
import { timeout } from '@/testing-browser/timeout';
import { setTestResult, waitForOrFailTest } from '@/testing-browser/TestResult';
import { addClass, removeAttr, style } from 'support';
import { addClass, each, isArray, removeAttr, style } from 'support';
OverlayScrollbars.env().setDefaultOptions({
nativeScrollbarsOverlaid: { initialize: true },
@@ -28,14 +28,16 @@ const resizeBetweenB: HTMLElement | null = document.createElement('div');
let rootUpdateCount = 0;
let aUpdateCount = 0;
let bUpdateCount = 0;
OverlayScrollbars(
targetRoot!,
const rootInstance = OverlayScrollbars(
{ target: targetRoot!, padding: true },
{},
{
initialized() {
addClass(targetRoot!.querySelector('.os-viewport'), 'flex');
addClass(resizeBetweenRoot, 'resize resizeBetween');
targetRoot!.append(resizeBetweenRoot);
requestAnimationFrame(() => {
addClass(rootInstance.elements().content, 'flex');
addClass(resizeBetweenRoot, 'resize resizeBetween');
targetRoot!.append(resizeBetweenRoot);
});
},
updated() {
rootUpdateCount++;
@@ -47,17 +49,18 @@ OverlayScrollbars(
},
}
);
OverlayScrollbars(
const aInstance = OverlayScrollbars(
{ target: targetA!, content: true },
{},
{
initialized() {
addClass(targetA!.querySelector('.os-content'), 'flex');
addClass(resizeBetweenA, 'resize resizeBetween');
targetA!.append(resizeBetweenA);
requestAnimationFrame(() => {
addClass(aInstance.elements().content, 'flex');
addClass(resizeBetweenA, 'resize resizeBetween');
targetA!.append(resizeBetweenA);
});
},
updated(args) {
console.log(args);
updated() {
aUpdateCount++;
requestAnimationFrame(() => {
if (updatesASlot) {
@@ -67,7 +70,7 @@ OverlayScrollbars(
},
}
);
OverlayScrollbars(
const bInstance = OverlayScrollbars(
targetB!,
{},
{
@@ -131,7 +134,26 @@ const resizeResize = async (resizeElm: HTMLElement) => {
removeAttr(resizeElm, 'style');
};
const overwriteScrollHeight = (elm: HTMLElement | HTMLElement[]) => {
const elements = isArray(elm) ? elm : [elm];
each(elements, (currElm) => {
Object.defineProperty(currElm, 'scrollHeight', {
configurable: true,
get() {
setTestResult(false);
throw new Error('accessed scrollHeight');
},
});
});
};
const testBetweenElements = async () => {
overwriteScrollHeight([
rootInstance.elements().viewport,
aInstance.elements().viewport,
bInstance.elements().viewport,
]);
await waitForOrFailTest(async () => {
await resizeBetween(resizeBetweenRoot);
await resizeBetween(resizeBetweenA);
@@ -150,8 +172,8 @@ const testResizeElements = async () => {
const start = async () => {
setTestResult(null);
await testBetweenElements();
await testResizeElements();
await testBetweenElements(); // has to be last
setTestResult(true);
};
@@ -0,0 +1,47 @@
import './index.scss';
import 'styles/overlayscrollbars.scss';
import should from 'should';
import { OverlayScrollbars } from 'overlayscrollbars';
import { resize } from '@/testing-browser/Resize';
import { timeout } from '@/testing-browser/timeout';
import { setTestResult, waitForOrFailTest } from '@/testing-browser/TestResult';
import { addClass, each, isArray, removeAttr, style } from 'support';
OverlayScrollbars.env().setDefaultOptions({
nativeScrollbarsOverlaid: { initialize: true },
});
const startBtn: HTMLButtonElement | null = document.querySelector('#start');
const target: HTMLElement | null = document.querySelector('#target');
const updatesSlot: HTMLElement | null = document.querySelector('#update');
let updateCount = 0;
const osInstance = OverlayScrollbars(
{ target: target! },
{
updating: {
ignoreMutation(mutation) {
console.log(mutation);
},
},
},
{
updated() {
updateCount++;
requestAnimationFrame(() => {
if (updatesSlot) {
updatesSlot.textContent = `${updateCount}`;
}
});
},
}
);
const start = async () => {
setTestResult(null);
setTestResult(true);
};
startBtn?.addEventListener('click', start);
@@ -0,0 +1,10 @@
<div id="controls">
<button id="start">start</button>
</div>
<div id="stage">
<div>
<div id="target" class="container">
<span>Hello</span>
</div>
</div>
</div>
@@ -0,0 +1,57 @@
body {
display: flex;
flex-direction: column;
overflow: scroll;
}
#controls {
flex: none;
}
#stage {
flex: auto;
position: relative;
& > div {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: lightgoldenrodyellow;
}
}
.container {
border: 1px solid red;
width: 60%;
height: 60%;
padding: 10px;
margin: 10px;
}
.resize {
overflow: hidden;
background: lime;
border: 1px solid green;
padding: 10px;
}
.resizer {
position: relative;
}
.resizeBetween {
background: tomato;
position: absolute;
bottom: 0;
left: 0;
}
.resizeBtn {
position: absolute;
bottom: 0;
right: 0;
height: 20px;
width: 20px;
background: blue;
opacity: 0.3;
}
@@ -0,0 +1,12 @@
// @ts-ignore
import { playwrightRollup, expectSuccess } from '@/playwright/rollup';
import { test } from '@playwright/test';
playwrightRollup();
test.describe('StructureSetup.elements', () => {
test('nesting updates', async ({ page }) => {
await page.click('#start');
await expectSuccess(page);
});
});
@@ -202,7 +202,7 @@ const metricsDimensionsEqual = (a: Metrics, b: Metrics) => {
return JSON.stringify(aDimensions) === JSON.stringify(bDimensions);
};
target!.querySelector('.os-viewport')?.addEventListener('scroll', (e) => {
osInstance.elements().viewport.addEventListener('scroll', (e) => {
const viewport: HTMLElement | null = e.currentTarget as HTMLElement;
comparison!.scrollLeft = viewport.scrollLeft;
comparison!.scrollTop = viewport.scrollTop;
@@ -265,15 +265,13 @@ const checkMetrics = async (checkComparison: CheckComparisonObj) => {
await waitForOrFailTest(async () => {
const comparisonMetrics = getMetrics(comparison!);
const targetMetrics = getMetrics(target!);
const targetViewport = target!.querySelector<HTMLElement>('.os-viewport');
const targetPadding = target!.querySelector<HTMLElement>('.os-padding');
const targetViewport = osInstance.elements().viewport;
const targetPadding = osInstance.elements().padding;
const { x: overflowOptionX, y: overflowOptionY } = osInstance.options().overflow;
const overflowOptionXVisible = isVisibleOverflow(overflowOptionX);
const overflowOptionYVisible = isVisibleOverflow(overflowOptionY);
const hostOverflowStyle = style(target, 'overflow');
const paddingOverflowStyle = targetPadding
? style(targetPadding, 'overflow')
: hostOverflowStyle;
const paddingOverflowStyle = style(targetPadding, 'overflow');
const viewportOverflowXStyle = style(targetViewport!, 'overflowX');
const viewportOverflowYStyle = style(targetViewport!, 'overflowY');