diff --git a/README.md b/README.md index 29682e6f..9d8b9181 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,10 @@ Every `Workflow` requires a datasource object to supply items on request and, op | [`bufferSize`](docs/configuration.md#settings) | `5` | Minimum fetch batch target, not a limit on buffered items. | | [`itemSize`](docs/configuration.md#size-estimates-and-layout) | `NaN` | Initial item-size estimate in pixels; measured automatically when omitted. | | [`sizeStrategy`](docs/configuration.md#size-estimates-and-layout) | `'average'` | Estimate unknown item sizes using `'average'`, `'frequent'` or `'constant'`. | - | [`viewportElement`](docs/configuration.md#viewport-and-horizontal-scrolling) | `null` | Custom viewport element or element factory; defaults to the content element's parent. Experimental. | - | [`windowViewport`](docs/configuration.md#viewport-and-horizontal-scrolling) | `false` | Use the browser window as the viewport. | - | [`horizontal`](docs/configuration.md#viewport-and-horizontal-scrolling) | `false` | Scroll horizontally instead of vertically. | - | [`inverse`](docs/configuration.md#other-settings) | `false` | Align short content to the bottom or right without reversing item order. Experimental. | + | [`viewportElement`](docs/configuration.md#viewport-scrolling-axis-and-alignment) | `null` | Custom viewport element or element factory; defaults to the content element's parent. Experimental. | + | [`windowViewport`](docs/configuration.md#viewport-scrolling-axis-and-alignment) | `false` | Use the browser window as the viewport. | + | [`horizontal`](docs/configuration.md#viewport-scrolling-axis-and-alignment) | `false` | Scroll horizontally instead of vertically. | + | [`alignShortContentToEnd`](docs/configuration.md#viewport-scrolling-axis-and-alignment) | `false` | Align short content to the bottom or right without reversing item order. | | [`infinite`](docs/configuration.md#settings) | `false` | Keep loaded items instead of clipping them automatically. | | [`onBeforeClip`](docs/configuration.md#settings) | `null` | Receive clipped items just before they leave the buffer. Experimental. | diff --git a/docs/configuration.md b/docs/configuration.md index 350c646e..e12ec7c8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -51,23 +51,25 @@ Before the first render, the scroller estimates how many rows to request for the After rendering, the scroller measures actual row sizes. With `'average'` (the default) or `'frequent'`, later requests use those measurements rather than the initial `itemSize` estimate. With `'constant'`, `itemSize` remains the estimate for unseen rows; if omitted, the first measured size takes its place. No strategy forces equal CSS sizes. In TypeScript, use the exported `SizeStrategy` enum (for example, `SizeStrategy.Frequent`). See [Rendering](rendering.md) for the DOM and layout requirements. -### Viewport and horizontal scrolling +### Viewport, scrolling axis and alignment -The viewport is the scrollable area whose position and size the scroller tracks; by default, it is the content element's parent. `windowViewport` and `viewportElement` choose a different scroll target, while `horizontal` changes the scroll axis. +The viewport is the scrollable area whose position and size the scroller tracks; by default, it is the content element's parent. `windowViewport` and `viewportElement` choose a different scroll target, while `horizontal` changes the scroll axis and `alignShortContentToEnd` controls the alignment of short content. | Setting | Type | Default | Effect | | --- | --- | --- | --- | | `windowViewport` | `boolean` | `false` | Use the browser window for scrolling and viewport dimensions. | | `viewportElement` | `HTMLElement`, synchronous function returning one, or `null` | `null` | Use an explicit viewport instead of the content element's parent. Experimental. | | `horizontal` | `boolean` | `false` | Scroll and measure along the horizontal axis; the DOM/CSS layout must also be horizontal. | +| `alignShortContentToEnd` | `boolean` | `false` | Align content shorter than the viewport to the bottom/right. | `windowViewport` takes precedence over `viewportElement`. A viewport factory is evaluated when the scroller is created; it must return an element synchronously. An invalid result falls back to the parent viewport. +With `alignShortContentToEnd: true`, content shorter than the viewport is aligned to the bottom in vertical mode or to the right in horizontal mode; `false` aligns it to the top or left. For content that fills or exceeds the viewport, scrolling and the initial position selected by `startIndex` are unchanged. The setting neither reverses item order nor automatically keeps the viewport at the end when items are added. + ### Other settings | Setting | Type | Default | Effect | | --- | --- | --- | --- | -| `inverse` | `boolean` | `false` | Align content shorter than the viewport to the bottom/right without reversing item order. Experimental. | | `onBeforeClip` | `(items: IAdapterItem[]) => void` or `null` | `null` | Called synchronously after clipped rows are hidden, before their items leave the buffer. Experimental. | ## Development settings diff --git a/docs/rendering.md b/docs/rendering.md index 80b2c553..d53ba1f6 100644 --- a/docs/rendering.md +++ b/docs/rendering.md @@ -31,7 +31,7 @@ const element = document.getElementById('content'); const workflow = new Workflow({ element, ... }); ``` -By default, the Scroller uses the content element's parent as its viewport; [Configuration](configuration.md#viewport-and-horizontal-scrolling) explains how to select a different scroll target. +By default, the Scroller uses the content element's parent as its viewport; [Configuration](configuration.md#viewport-scrolling-axis-and-alignment) explains how to select a different scroll target. The padding elements remain mounted as rows come and go; the Scroller sizes them to represent virtual space. Rows must be measurable. By default, their size comes from `getBoundingClientRect()`, which excludes margins; unaccounted margins or gaps distort the virtual geometry. Row measurement can be customized through [Routines](routines.md#geometry-and-padding-sizes). diff --git a/src/classes/paddings.ts b/src/classes/paddings.ts index e087582c..9808c831 100644 --- a/src/classes/paddings.ts +++ b/src/classes/paddings.ts @@ -52,22 +52,12 @@ export class Paddings { reset(viewportSize: number, startIndex: number, offset: number): void { const positive = this.getPositiveSize(startIndex, viewportSize, offset); const negative = this.getNegativeSize(startIndex); - if (this.settings.inverse) { - this.forward.reset(negative); - this.backward.reset(positive); - const diff = viewportSize - this.backward.size - offset; - if (diff > 0) { - this.backward.size += diff; - this.forward.size -= diff; - } - } else { - this.forward.reset(positive); - this.backward.reset(negative); - const diff = viewportSize - this.forward.size - offset; - if (diff > 0) { - this.backward.size -= diff; - this.forward.size += diff; - } + this.forward.reset(positive); + this.backward.reset(negative); + const diff = viewportSize - this.forward.size - offset; + if (diff > 0) { + this.backward.size -= diff; + this.forward.size += diff; } } diff --git a/src/classes/settings.ts b/src/classes/settings.ts index d758b2f5..bb3bf826 100644 --- a/src/classes/settings.ts +++ b/src/classes/settings.ts @@ -27,7 +27,7 @@ export class Settings implements ISettings, IDevSettings { horizontal: boolean; windowViewport: boolean; viewportElement: HTMLElement | (() => void) | null; - inverse: boolean; // if true, bwd padding element will have a priority when filling the viewport (if lack of items) + alignShortContentToEnd: boolean; // align content shorter than the viewport to the bottom/right onBeforeClip: ItemsProcessor | null; // if set, it will be run before clipping items from Buffer after they are hidden sizeStrategy: SizeStrategy; // "average" | "frequent", determines behavior of unknown items @@ -150,6 +150,15 @@ export class Settings implements ISettings, IDevSettings { */ viewport: HTMLElement | null; + /** @deprecated Use alignShortContentToEnd. */ + get inverse(): boolean { + return this.alignShortContentToEnd; + } + + set inverse(value: boolean) { + this.alignShortContentToEnd = value; + } + constructor( settings: ISettings | undefined, devSettings: IDevSettings | undefined, @@ -160,6 +169,7 @@ export class Settings implements ISettings, IDevSettings { this.instanceIndex = instanceIndex; this.initializeDelay = this.getInitializeDelay(); this.viewport = this.getViewport(); + this.alignShortContentToEnd = this.getAlignShortContentToEnd(settings); // todo: min/max indexes must be ignored if infinite mode is enabled ?? } @@ -192,10 +202,24 @@ export class Settings implements ISettings, IDevSettings { return this.viewportElement; } const value = this.viewportElement(); - const result = validateOne({ value }, 'value', { validators: [VALIDATORS.ELEMENT] }); + const result = validateOne({ value }, 'value', { + validators: [VALIDATORS.ELEMENT], + mandatory: true + }); if (!result.isValid) { return null; // fallback to default (null) if Function didn't return HTML element synchronously } return result.value as HTMLElement; } + + getAlignShortContentToEnd(settings: ISettings | undefined): boolean { + if (settings?.alignShortContentToEnd !== undefined || settings?.inverse === undefined) { + return this.alignShortContentToEnd; + } + return validateOne( + { inverse: settings.inverse }, + 'inverse', + SETTINGS.alignShortContentToEnd + ).value as boolean; + } } diff --git a/src/inputs/settings.ts b/src/inputs/settings.ts index 9cebcb4c..9b042f5c 100644 --- a/src/inputs/settings.ts +++ b/src/inputs/settings.ts @@ -17,7 +17,7 @@ enum Settings { horizontal = 'horizontal', windowViewport = 'windowViewport', viewportElement = 'viewportElement', - inverse = 'inverse', + alignShortContentToEnd = 'alignShortContentToEnd', onBeforeClip = 'onBeforeClip', sizeStrategy = 'sizeStrategy' } @@ -91,7 +91,7 @@ export const SETTINGS: ICommonProps = { validators: [OR([ELEMENT, FUNC])], defaultValue: null }, - [Settings.inverse]: { + [Settings.alignShortContentToEnd]: { validators: [BOOLEAN], defaultValue: false }, diff --git a/src/interfaces/settings.ts b/src/interfaces/settings.ts index 12a27694..3b78c755 100644 --- a/src/interfaces/settings.ts +++ b/src/interfaces/settings.ts @@ -13,6 +13,8 @@ export interface Settings { horizontal?: boolean; windowViewport?: boolean; viewportElement?: HTMLElement | (() => void) | null; + alignShortContentToEnd?: boolean; + /** @deprecated Use alignShortContentToEnd. */ inverse?: boolean; onBeforeClip?: ItemsProcessor | null; sizeStrategy?: SizeStrategy; diff --git a/src/processes/adjust.ts b/src/processes/adjust.ts index e8b063f2..79476cd1 100644 --- a/src/processes/adjust.ts +++ b/src/processes/adjust.ts @@ -56,13 +56,13 @@ export default class Adjust extends BaseProcessFactory(CommonProcess.adjust) { const scrollSize = bwdSize + bufferSize + fwdSize; const viewportSizeDiff = viewport.getSize() - scrollSize; if (viewportSizeDiff > 0) { - if (settings.inverse) { + if (settings.alignShortContentToEnd) { bwdSize += viewportSizeDiff; } else { fwdSize += viewportSizeDiff; } scroller.logger.log(() => - settings.inverse + settings.alignShortContentToEnd ? 'backward' : 'forward' + ` padding will be increased by ${viewportSizeDiff} to fill the viewport` ); diff --git a/tests/e2e/specs/align-short-content.spec.ts b/tests/e2e/specs/align-short-content.spec.ts new file mode 100644 index 00000000..78872586 --- /dev/null +++ b/tests/e2e/specs/align-short-content.spec.ts @@ -0,0 +1,219 @@ +import { + getDatasource, + makeItems, + makeTest, + Misc, + MutableDatasource, + TestConfig +} from '../scaffolding'; + +const maxIndex = 1000; +const middleIndex = 501; +const itemSize = 20; +const axes = [false, true]; + +const createConfig = ( + horizontal: boolean, + settings: TestConfig['datasourceSettings'] = {}, + max = maxIndex +): TestConfig => ({ + datasource: () => new MutableDatasource(1, max), + datasourceSettings: { + startIndex: 1, + minIndex: 1, + maxIndex: max, + itemSize, + bufferSize: 5, + padding: 0.5, + alignShortContentToEnd: true, + horizontal, + ...settings + }, + templateSettings: { + viewportHeight: 120, + viewportWidth: 120, + itemHeight: itemSize, + itemWidth: itemSize, + horizontal + } +}); + +const initialConfigs = axes.flatMap(horizontal => + [undefined, itemSize].map(size => + createConfig(horizontal, { itemSize: size }) + ) +); +const partialConfigs = axes.map(horizontal => + createConfig(horizontal, { startIndex: middleIndex, minIndex: undefined }) +); +const shortConfigs = axes.map(horizontal => createConfig(horizontal, {}, 3)); + +const oversizedConfig: TestConfig = { + datasource: () => getDatasource({ min: 1, max: 10 }), + datasourceSettings: { + startIndex: -1, + bufferSize: 7, + itemSize, + alignShortContentToEnd: true + }, + templateSettings: { viewportHeight: 300, dynamicSize: 'size' } +}; + +const registerInitialization = ( + config: TestConfig, + title: string, + startIndex = 1 +): void => + makeTest({ + config, + title, + it: misc => async () => { + misc.trackVisibleItems(); + await misc.relaxNext(); + + const source = misc.source(); + expect(source.requests[0].index).toBe(startIndex); + source.requests.forEach(({ count }) => + expect(count).toBeLessThan(maxIndex - startIndex + 1) + ); + expect(misc.adapter.itemsCount).toBeLessThan(maxIndex); + misc.expect.startVisible(startIndex); + misc.expect.viewportFilled(); + } + }); + +const expectShortContent = (misc: Misc): void => { + misc.expect.bufferRange([1, 3]); + misc.expect.domMatchesBuffer(); + misc.expect.noForwardGap(); + expect(misc.getScrollableSize()).toBe(misc.getViewportSize()); + expect(misc.padding.backward.getSize()).toBeGreaterThan(0); +}; + +const registerShortContent = (config: TestConfig): void => + makeTest({ + config, + title: + 'should align short content and readjust as items are added and removed', + it: misc => async () => { + misc.trackVisibleItems(); + await misc.relaxNext(); + expectShortContent(misc); + + const source = misc.source(); + const fillItems = makeItems(4, 3); + source.insert(fillItems, 3, false); + await misc.adapter.append({ items: fillItems, eof: true }); + + misc.expect.bufferRange([1, 6]); + misc.expect.viewportFilled(); + misc.expect.noForwardGap(); + expect(misc.padding.backward.getSize()).toBe(0); + expect(misc.getScrollableSize()).toBe(misc.getViewportSize()); + + const overflowItems = makeItems(7, 6); + source.insert(overflowItems, 6, false); + await misc.adapter.append({ items: overflowItems, eof: true }); + + expect(misc.padding.backward.getSize()).toBe(0); + expect(misc.getScrollableSize()).toBeGreaterThan(misc.getViewportSize()); + misc.expect.startVisible(1); + misc.expect.viewportFilled(); + + const indexes = [...fillItems, ...overflowItems].map(({ id }) => id); + source.remove(indexes); + await misc.adapter.remove({ indexes }); + expectShortContent(misc); + + source.remove([1, 2, 3]); + await misc.adapter.remove({ indexes: [1, 2, 3] }); + expect(misc.adapter.itemsCount).toBe(0); + expect(misc.getElements()).toEqual([]); + expect(misc.getScrollableSize()).toBe(misc.getViewportSize()); + expect(misc.getScrollPosition()).toBe(0); + misc.expect.domMatchesBuffer(); + } + }); + +const resizeRows = (misc: Misc, sizes: number[]): void => + misc.getElements().forEach((element, index) => { + element.style[misc.horizontal ? 'width' : 'height'] = `${sizes[index]}px`; + }); + +const registerSizeChanges = (config: TestConfig): void => + makeTest({ + config, + title: 'should readjust short content after row sizes change', + it: misc => async () => { + misc.trackVisibleItems(); + await misc.relaxNext(); + expectShortContent(misc); + + resizeRows(misc, [40, 80, 60]); + await misc.adapter.check(); + expect(misc.padding.backward.getSize()).toBe(0); + expect(misc.getScrollableSize()).toBeGreaterThan(misc.getViewportSize()); + misc.expect.bufferRange([1, 3]); + misc.expect.startVisible(1); + misc.expect.viewportFilled(); + + resizeRows(misc, [10, 30, 20]); + await misc.adapter.check(); + expectShortContent(misc); + } + }); + +describe('Short Content Alignment Spec', () => { + describe('Initialization', () => + initialConfigs.forEach(config => + registerInitialization( + config, + `should preserve the starting position ${ + config.datasourceSettings?.itemSize ? 'with' : 'without' + } an initial size estimate` + ) + )); + + describe('Partial bounds', () => + partialConfigs.forEach(config => + registerInitialization( + config, + 'should preserve the starting position with only an upper bound', + middleIndex + ) + )); + + describe('Short content', () => shortConfigs.forEach(registerShortContent)); + + describe('Changed row sizes', () => + shortConfigs.forEach(registerSizeChanges)); + + makeTest({ + config: createConfig( + false, + { alignShortContentToEnd: undefined, inverse: true }, + 3 + ), + title: 'should align short content with the legacy inverse setting', + it: misc => async () => { + await misc.relaxNext(); + expectShortContent(misc); + } + }); + + makeTest({ + config: oversizedConfig, + title: 'should not extend forward padding for an oversized item', + before: misc => + misc.setItemProcessor(({ $index, data }) => { + data.size = $index === 4 ? 120 : 20; + }), + it: misc => async () => { + await misc.relaxNext(); + + expect(misc.padding.backward.getSize()).toBe(0); + expect(misc.padding.forward.getSize()).toBe(0); + misc.expect.domIndexesMatchBuffer(); + } + }); +}); diff --git a/tests/e2e/specs/bug.spec.ts b/tests/e2e/specs/bug.spec.ts index 6fec2dfe..b58f25ae 100644 --- a/tests/e2e/specs/bug.spec.ts +++ b/tests/e2e/specs/bug.spec.ts @@ -49,17 +49,6 @@ const reloadConfig: TestConfig = { timeout: 4000 }; -const inverseConfig: TestConfig = { - datasource: () => getDatasource({ min: 1, max: 10 }), - datasourceSettings: { - startIndex: -1, - bufferSize: 7, - itemSize: 20, - inverse: true - }, - templateSettings: { viewportHeight: 300, dynamicSize: 'size' } -}; - const replacementConfig: TestConfig = { datasource: () => getDatasource({ min: -99, max: 100 }), datasourceSettings: { startIndex: replacementMin - 1 } @@ -193,22 +182,6 @@ describe('Bug Spec', () => { } }); - makeTest({ - config: inverseConfig, - title: 'should not extend forward padding for an oversized inverse item', - before: misc => - misc.setItemProcessor(({ $index, data }) => { - data.size = $index === 4 ? 120 : 20; - }), - it: misc => async () => { - await misc.relaxNext(); - - expect(misc.padding.backward.getSize()).toBe(0); - expect(misc.padding.forward.getSize()).toBe(0); - misc.expect.domIndexesMatchBuffer(); - } - }); - test('should deliver firstVisible to an early subscriber', async () => { const source = getDatasource({ min: 1, max: 100 }); const datasource = new Datasource({ diff --git a/tests/e2e/specs/common.spec.ts b/tests/e2e/specs/common.spec.ts index 823db9f6..904c802e 100644 --- a/tests/e2e/specs/common.spec.ts +++ b/tests/e2e/specs/common.spec.ts @@ -24,71 +24,8 @@ const baseConfig: TestConfig = { const parseSettings = (settings?: unknown): ParsedSettings => new ParsedSettings(settings as Settings | undefined, undefined, 1); -const takeCoreSettings = (settings: ParsedSettings) => ({ - startIndex: settings.startIndex, - bufferSize: settings.bufferSize, - padding: settings.padding, - itemSize: settings.itemSize, - infinite: settings.infinite, - horizontal: settings.horizontal -}); - describe('Common Spec', () => { - describe('Settings', () => { - test('uses defaults for missing or malformed input', () => { - [undefined, null, 'invalid', 42].forEach(input => - expect(takeCoreSettings(parseSettings(input))).toEqual({ - startIndex: 1, - bufferSize: 5, - padding: 0.5, - itemSize: NaN, - infinite: false, - horizontal: false - }) - ); - }); - - test('applies valid overrides', () => { - expect( - takeCoreSettings( - parseSettings({ startIndex: 99, bufferSize: 11, infinite: true }) - ) - ).toEqual({ - startIndex: 99, - bufferSize: 11, - padding: 0.5, - itemSize: NaN, - infinite: true, - horizontal: false - }); - }); - - test('normalizes invalid and below-limit values', () => { - const cases: Array<{ - key: keyof Settings; - input: unknown; - expected: unknown; - }> = [ - { key: 'startIndex', input: false, expected: 1 }, - { key: 'bufferSize', input: { invalid: true }, expected: 5 }, - { key: 'bufferSize', input: 5.5, expected: 5 }, - { key: 'bufferSize', input: -1, expected: 1 }, - { key: 'padding', input: 'invalid', expected: 0.5 }, - { key: 'padding', input: -0.1, expected: 0.01 }, - { key: 'itemSize', input: -5, expected: 1 }, - { key: 'itemSize', input: 1.5, expected: NaN }, - { key: 'infinite', input: 'invalid', expected: false }, - { key: 'horizontal', input: null, expected: false } - ]; - - cases.forEach(({ key, input, expected }) => - expect({ key, value: parseSettings({ [key]: input })[key] }).toEqual({ - key, - value: expected - }) - ); - }); - + describe('Settings with an HTML viewport element', () => { test('accepts an HTML element', () => { const element = document.createElement('div'); const settings = parseSettings({ viewportElement: element }); @@ -96,13 +33,6 @@ describe('Common Spec', () => { expect(settings.viewportElement).toBe(element); expect(settings.viewport).toBe(element); }); - - test('rejects an element-like object', () => { - const settings = parseSettings({ viewportElement: { nodeType: 1 } }); - - expect(settings.viewportElement).toBeNull(); - expect(settings.viewport).toBeNull(); - }); }); describe('Delayed initialization', () => { diff --git a/tests/unit/settings.spec.ts b/tests/unit/settings.spec.ts new file mode 100644 index 00000000..b61ba03c --- /dev/null +++ b/tests/unit/settings.spec.ts @@ -0,0 +1,276 @@ +import { Settings } from '../../src/classes/settings'; +import { Routines } from '../../src/classes/domRoutines'; +import { + Settings as ISettings, + DevSettings +} from '../../src/interfaces/settings'; + +// Settings validation needs DOM constructors, but no DOM is used in these tests. +Object.assign(globalThis, { Element: class {}, Document: class {} }); + +const parseSettings = ( + settings?: unknown, + devSettings?: unknown, + instanceIndex = 0 +): Settings => + new Settings( + settings as ISettings | undefined, + devSettings as DevSettings | undefined, + instanceIndex + ); + +const delayScenarios: [ + title: string, + windowViewport: boolean, + scrollRestoration: boolean, + initDelay: number | undefined, + initWindowDelay: number | undefined, + expected: number +][] = [ + ['default delay', false, false, undefined, undefined, 1], + ['element viewport', false, false, 10, 100, 10], + ['browser scroll restoration', true, true, 10, 100, 10], + ['longer window delay', true, false, 10, 100, 100], + ['longer general delay', true, false, 100, 10, 100], + ['delays disabled', true, false, 0, 0, 0] +]; + +const alignmentScenarios: [string, ISettings | undefined, boolean][] = [ + ['default', undefined, false], + ['new name enabled', { alignShortContentToEnd: true }, true], + ['new name disabled', { alignShortContentToEnd: false }, false], + ['legacy name enabled', { inverse: true }, true], + ['legacy name disabled', { inverse: false }, false], + [ + 'new true overrides legacy false', + { alignShortContentToEnd: true, inverse: false }, + true + ], + [ + 'new false overrides legacy true', + { alignShortContentToEnd: false, inverse: true }, + false + ], + [ + 'undefined new name falls back', + { alignShortContentToEnd: undefined, inverse: true }, + true + ] +]; + +describe('Settings', () => { + describe('Input', () => { + it('uses defaults for missing or malformed input', () => { + [undefined, null, 'invalid', 42].forEach(input => + expect(parseSettings(input, input)).toMatchObject({ + startIndex: 1, + bufferSize: 5, + padding: 0.5, + itemSize: NaN, + infinite: false, + horizontal: false, + debug: false, + initDelay: 1, + throttle: 40 + }) + ); + }); + + it('applies settings and devSettings without changing the input', () => { + const input = Object.freeze({ + startIndex: 99, + bufferSize: 11, + infinite: true + }); + const devInput = Object.freeze({ + debug: true, + initDelay: 0, + throttle: 10 + }); + expect(parseSettings(input, devInput, 7)).toMatchObject({ + startIndex: 99, + bufferSize: 11, + padding: 0.5, + itemSize: NaN, + infinite: true, + horizontal: false, + debug: true, + initDelay: 0, + throttle: 10, + instanceIndex: 7 + }); + }); + + it('normalizes invalid and below-limit values', () => { + const cases: [keyof ISettings, unknown, unknown][] = [ + ['startIndex', false, 1], + ['bufferSize', { invalid: true }, 5], + ['bufferSize', 5.5, 5], + ['bufferSize', -1, 1], + ['padding', 'invalid', 0.5], + ['padding', -0.1, 0.01], + ['itemSize', -5, 1], + ['itemSize', 1.5, NaN], + ['infinite', 'invalid', false], + ['horizontal', null, false] + ]; + cases.forEach(([key, input, expected]) => + expect({ key, value: parseSettings({ [key]: input })[key] }).toEqual({ + key, + value: expected + }) + ); + expect( + parseSettings(undefined, { + debug: 'invalid', + throttle: -1, + initDelay: -1 + }) + ).toMatchObject({ + debug: false, + throttle: 0, + initDelay: 0 + }); + }); + + it('accepts only fields belonging to each settings group', () => { + const settings = parseSettings( + { debug: true, extra: true }, + { startIndex: 99, extra: true, instanceIndex: 99 }, + 7 + ); + expect(settings).toMatchObject({ + startIndex: 1, + debug: false, + instanceIndex: 7 + }); + expect(settings).not.toHaveProperty('extra'); + }); + + it('should preserve prototype getters with the legacy name', () => { + const input = new (class { + inverse = true; + get startIndex(): number { + return 50; + } + })(); + const settings = parseSettings(Object.freeze(input)); + expect(settings.startIndex).toBe(50); + expect(settings.alignShortContentToEnd).toBe(true); + }); + }); + + describe('Initialization delay', () => { + delayScenarios.forEach( + ([ + title, + windowViewport, + scrollRestoration, + initDelay, + initWindowDelay, + expected + ]) => + it(title, () => { + Object.assign(globalThis, { + history: scrollRestoration ? { scrollRestoration: 'auto' } : {} + }); + expect( + parseSettings({ windowViewport }, { initDelay, initWindowDelay }) + .initializeDelay + ).toBe(expected); + }) + ); + }); + + describe('Viewport', () => { + it('keeps the supplied viewport reference', () => { + const element = new Element(); + const settings = parseSettings({ viewportElement: element }); + expect(settings.viewportElement).toBe(element); + expect(settings.viewport).toBe(element); + }); + + it('rejects an element-like object', () => { + const settings = parseSettings({ viewportElement: { nodeType: 1 } }); + + expect(settings.viewportElement).toBeNull(); + expect(settings.viewport).toBeNull(); + }); + + it('resolves the viewport factory once during construction', () => { + const element = new Element(); + let calls = 0; + const viewportElement = () => { + calls++; + return element; + }; + const settings = parseSettings({ viewportElement }); + expect(settings.viewportElement).toBe(viewportElement); + expect(settings.viewport).toBe(element); + expect(calls).toBe(1); + }); + + it('falls back to null for invalid or asynchronous factory results', () => { + [ + undefined, + null, + { nodeType: 1 }, + Promise.resolve(new Element()) + ].forEach(value => { + const viewportElement = () => value; + const settings = parseSettings({ viewportElement }); + expect(settings.viewportElement).toBe(viewportElement); + expect(settings.viewport).toBeNull(); + }); + }); + }); + + describe('Short content alignment', () => { + alignmentScenarios.forEach(([title, input, expected]) => + it(title, () => { + const settings = parseSettings(input && Object.freeze(input)); + expect(settings.alignShortContentToEnd).toBe(expected); + expect(settings.inverse).toBe(expected); + }) + ); + + it('should preserve boolean parsing and fallback for both names', () => { + ['alignShortContentToEnd', 'inverse'].forEach(name => + [ + ['true', true], + ['false', false], + [null, false], + [1, false] + ].forEach(([value, expected]) => { + const settings = parseSettings({ [name]: value }); + expect(settings.alignShortContentToEnd).toBe(expected); + }) + ); + }); + + it('should not use the legacy value when the new value is invalid', () => { + const settings = parseSettings({ + alignShortContentToEnd: null, + inverse: true + }); + expect(settings.alignShortContentToEnd).toBe(false); + }); + + it('should preserve the legacy settings alias in custom onInit', () => { + const settings = parseSettings({ alignShortContentToEnd: true }); + const element = { + parentElement: { style: {} } + } as unknown as HTMLElement; + new (class extends Routines { + onInit(settings: Settings): void { + expect(settings.inverse).toBe(true); + settings.inverse = false; + expect(settings.alignShortContentToEnd).toBe(false); + settings.alignShortContentToEnd = true; + expect(settings.inverse).toBe(true); + } + })(element, settings); + expect(settings.alignShortContentToEnd).toBe(true); + }); + }); +});