From 4f1657e12d59ea073bc53a3211bd9c5a9feda7af Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 6 Oct 2026 00:31:23 +0400 Subject: [PATCH 1/6] Pagination: ignore layout measurements taken before the theme CSS is applied --- .../pagination/__tests__/pages-small.test.ts | 38 ++++++ .../__tests__/resizable-container.test.ts | 119 ++++++++++++++++++ .../js/__internal/pagination/pages/small.tsx | 13 +- .../pagination/resizable_container.tsx | 38 +++++- .../pagination/utils/get_element_width.ts | 9 ++ .../DevExpress.ui.widgets/pagination.tests.js | 70 +++++++++++ 6 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts create mode 100644 packages/devextreme/js/__internal/pagination/__tests__/resizable-container.test.ts diff --git a/packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts b/packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts new file mode 100644 index 000000000000..12addb35c86c --- /dev/null +++ b/packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts @@ -0,0 +1,38 @@ +import { + afterEach, describe, expect, it, +} from '@jest/globals'; +import NumberBox from '@js/ui/number_box'; + +import Pagination from '../wrappers/pagination'; + +const FALLBACK_MIN_WIDTH = 10; +const ONE_DIGIT_WIDTH = 10; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('PagesSmall page index width', () => { + it('re-reads the css min-width of the page index after the theme css is applied', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const pagination = new Pagination(container, { + displayMode: 'compact', + pageCount: 10, + pageIndex: 1, + pageSize: 5, + itemCount: 46, + pagesCountText: 'of', + }); + const pageIndex = container.querySelector('.dx-page-index') as HTMLElement; + const numberBox = NumberBox.getInstance(pageIndex) as NumberBox; + + expect(numberBox.option('width')).toBe(FALLBACK_MIN_WIDTH + 2 * ONE_DIGIT_WIDTH); + + pageIndex.style.minWidth = '32px'; + pagination.option('pageIndex', 2); + await new Promise((resolve) => { setTimeout(resolve); }); + + expect(numberBox.option('width')).toBe(32 + 2 * ONE_DIGIT_WIDTH); + }); +}); diff --git a/packages/devextreme/js/__internal/pagination/__tests__/resizable-container.test.ts b/packages/devextreme/js/__internal/pagination/__tests__/resizable-container.test.ts new file mode 100644 index 000000000000..2e4a3766a469 --- /dev/null +++ b/packages/devextreme/js/__internal/pagination/__tests__/resizable-container.test.ts @@ -0,0 +1,119 @@ +import { + afterEach, describe, expect, it, jest, +} from '@jest/globals'; + +import { isLayoutApplied, ResizableContainer } from '../resizable_container'; + +function createElement(style: Partial): HTMLDivElement { + const element = document.createElement('div'); + Object.assign(element.style, style); + document.body.appendChild(element); + return element; +} + +interface Elements { + parent: HTMLDivElement; + pages: HTMLDivElement; + allowedPageSizes?: HTMLDivElement; +} + +function createContainer({ parent, pages, allowedPageSizes }: Elements): ResizableContainer { + const container = new ResizableContainer({ + paginationProps: {}, + contentTemplate: (): null => null, + } as never); + + jest.spyOn(container, 'setState').mockImplementation((updater): void => { + const update = typeof updater === 'function' ? updater(container.state, container.props) : updater; + container.state = { ...container.state, ...update }; + }); + + Object.assign(container, { + parentRef: { current: parent }, + pagesRef: { current: pages }, + allowedPageSizesRef: { current: allowedPageSizes ?? null }, + }); + + return container; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('isLayoutApplied', () => { + it('is false while the pager containers are plain block-level elements', () => { + const pages = createElement({ width: '1000px' }); + const allowedPageSizes = createElement({ width: '1000px' }); + + expect(isLayoutApplied({ pages, allowedPageSizes })).toBe(false); + + pages.style.display = 'inline-block'; + + expect(isLayoutApplied({ pages, allowedPageSizes })).toBe(false); + + allowedPageSizes.style.cssFloat = 'left'; + + expect(isLayoutApplied({ pages, allowedPageSizes })).toBe(true); + }); + + it('ignores containers that are not rendered', () => { + expect(isLayoutApplied({ pages: null, allowedPageSizes: undefined })).toBe(true); + }); +}); + +describe('ResizableContainer adaptivity', () => { + it('does not switch to compact mode from a measurement taken before the theme css is applied', () => { + const parent = createElement({ width: '1000px' }); + const pages = createElement({ width: '1000px' }); + const container = createContainer({ parent, pages }); + + container.updateAdaptivityProps(); + + expect(container.state.isLargeDisplayMode).toBe(true); + + pages.style.display = 'inline-block'; + pages.style.width = '300px'; + container.updateAdaptivityProps(); + + expect(container.state.isLargeDisplayMode).toBe(true); + }); + + it('keeps switching between the modes once the theme css is applied', () => { + const parent = createElement({ width: '1000px' }); + const pages = createElement({ width: '1000px' }); + const container = createContainer({ parent, pages }); + + container.updateAdaptivityProps(); + pages.style.display = 'inline-block'; + pages.style.width = '300px'; + container.updateAdaptivityProps(); + + parent.style.width = '200px'; + container.updateAdaptivityProps(); + + expect(container.state.isLargeDisplayMode).toBe(false); + + parent.style.width = '1000px'; + container.updateAdaptivityProps(); + + expect(container.state.isLargeDisplayMode).toBe(true); + }); + + it('keeps the current mode while the theme css is unavailable', () => { + const parent = createElement({ width: '200px' }); + const pages = createElement({ width: '300px', display: 'inline-block' }); + const container = createContainer({ parent, pages }); + + container.updateAdaptivityProps(); + + expect(container.state.isLargeDisplayMode).toBe(false); + + pages.style.display = 'block'; + pages.style.width = '200px'; + parent.style.width = '1000px'; + container.updateAdaptivityProps(); + + expect(container.state.isLargeDisplayMode).toBe(false); + }); +}); diff --git a/packages/devextreme/js/__internal/pagination/pages/small.tsx b/packages/devextreme/js/__internal/pagination/pages/small.tsx index b8f02fe96d3a..41fc83323e89 100644 --- a/packages/devextreme/js/__internal/pagination/pages/small.tsx +++ b/packages/devextreme/js/__internal/pagination/pages/small.tsx @@ -46,11 +46,20 @@ export class PagesSmall extends InfernoComponent { } createEffects(): InfernoEffect[] { - return [new InfernoEffect(this.updateWidth, [this.state.minWidth])]; + return [new InfernoEffect(this.updateWidth, this.getEffectDependencies())]; } updateEffects(): void { - this._effects[0]?.update([this.state.minWidth]); + this._effects[0]?.update(this.getEffectDependencies()); + } + + getEffectDependencies(): unknown[] { + return [ + this.props, + this.state.minWidth, + this.props.pageCount, + this.props.pageIndex, + ]; } updateWidth(): void { diff --git a/packages/devextreme/js/__internal/pagination/resizable_container.tsx b/packages/devextreme/js/__internal/pagination/resizable_container.tsx index 5fc4e85a99ac..c17f26a400a5 100644 --- a/packages/devextreme/js/__internal/pagination/resizable_container.tsx +++ b/packages/devextreme/js/__internal/pagination/resizable_container.tsx @@ -2,15 +2,19 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { InfernoComponent, InfernoEffect } from '@ts/core/r1/runtime/inferno/index'; import type { JSXTemplate } from '@ts/core/r1/types'; +import { themeReadyCallback } from '@ts/ui/m_themes_callback'; +import { isPendingThemeLoaded } from '@ts/ui/themes'; import { createRef as infernoCreateRef } from 'inferno'; import resizeCallbacks from '../../core/utils/resize_callbacks'; import { isDefined } from '../../core/utils/type'; -import type { DisposeEffectReturn } from '../core/r1/utils/effect_return'; +import type { DisposeEffectReturn, EffectReturn } from '../core/r1/utils/effect_return'; import { PaginationDefaultProps, type PaginationProps } from './common/pagination_props'; import type { RefObject } from './common/types'; import type { PaginationContentProps } from './content'; -import { getElementContentWidth, getElementStyle, getElementWidth } from './utils/get_element_width'; +import { + getElementContentWidth, getElementStyle, getElementWidth, isElementBlockLevel, +} from './utils/get_element_width'; interface ChildElements { allowedPageSizes: T; pages: T; info: T } interface MainElements { parent: T; allowedPageSizes: T; pages: T } @@ -32,6 +36,12 @@ export function calculateInfoTextVisible({ return parentWidth - minimalWidth > 0; } +export function isLayoutApplied({ + allowedPageSizes, pages, +}: Omit, 'parent'>): boolean { + return [allowedPageSizes, pages].every((element) => !element || !isElementBlockLevel(element)); +} + function getElementsWidth({ parent, allowedPageSizes, pages, info, }: AllElements): AllElements { @@ -82,6 +92,7 @@ export class ResizableContainer extends InfernoComponent { resizeCallbacks.remove(callback); }; } + subscribeToThemeReady(): EffectReturn { + if (isPendingThemeLoaded()) { + return undefined; + } + const callback = (): void => { + if (this.getParentWidth() > 0) { + this.updateAdaptivityProps(); + } + }; + themeReadyCallback.add(callback); + return (): void => { themeReadyCallback.remove(callback); }; + } + effectUpdateChildProps(): void { if (this.getParentWidth() > 0) { this.updateAdaptivityProps(); @@ -230,6 +256,12 @@ export class ResizableContainer extends InfernoComponent; diff --git a/packages/devextreme/js/__internal/pagination/utils/get_element_width.ts b/packages/devextreme/js/__internal/pagination/utils/get_element_width.ts index dffbc6939826..0cbdad8b55a2 100644 --- a/packages/devextreme/js/__internal/pagination/utils/get_element_width.ts +++ b/packages/devextreme/js/__internal/pagination/utils/get_element_width.ts @@ -22,3 +22,12 @@ export function getElementWidth(element: Element | null | undefined): number { export function getElementMinWidth(element: Element | null | undefined): number { return getElementStyle('minWidth', element); } + +export function isElementBlockLevel(element: Element | null | undefined): boolean { + const computedStyle = getElementComputedStyle(element); + if (!computedStyle) { + return false; + } + const float = computedStyle.getPropertyValue('float'); + return computedStyle.getPropertyValue('display') === 'block' && (!float || float === 'none'); +} diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/pagination.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/pagination.tests.js index c0361b8c941e..ce8f14f46182 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/pagination.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/pagination.tests.js @@ -1107,6 +1107,42 @@ function() { assert.equal(numberBox.option('width'), Number($('.dx-page-index').css('min-width').replace('px', '')) + 40); }); + // devextreme-private#5261 + QUnit.test('Light mode. Page index width is recalculated after the theme css is applied', function(assert) { + const styleSheets = Array.from(document.styleSheets); + styleSheets.forEach(sheet => { sheet.disabled = true; }); + + let $pagination; + try { + $pagination = $('#container').width(PAGER_LIGHT_MODE_WIDTH).dxPagination({ + maxPagesCount: 8, + pageCount: 10, + itemCount: 46, + pageIndex: 1, + pagesCountText: 'of', + displayMode: 'compact' + }); + } finally { + styleSheets.forEach(sheet => { sheet.disabled = false; }); + } + + const pagination = $pagination.dxPagination('instance'); + const $pageIndex = $pagination.find('.dx-page-index'); + const numberBox = $pageIndex.dxNumberBox('instance'); + const minWidth = Number($pageIndex.css('min-width').replace('px', '')); + + assert.ok(minWidth > 10, 'the theme defines a min-width for the page index'); + assert.equal(numberBox.option('width'), 10 + 20, 'the fallback min-width is used while the css is missing'); + + const done = assert.async(); + pagination.option('pageIndex', 2); + + Promise.resolve().then(() => { + assert.equal(numberBox.option('width'), minWidth + 20, 'the css min-width is used after the update'); + done(); + }); + }); + QUnit.test('Light mode. Change page index', function(assert) { let pageIndex; @@ -1279,6 +1315,40 @@ function() { assert.ok($pagination.find('.dx-info').length === 1 && $pagination.find('.dx-info').css('display') !== 'none', 'info element is visible'); }); + // devextreme-private#5261 + QUnit.test('Light mode is not applied from a measurement taken before the theme css is applied', function(assert) { + const styleSheets = Array.from(document.styleSheets); + styleSheets.forEach(sheet => { sheet.disabled = true; }); + + let $pagination; + try { + $pagination = $('#container').width(1000).dxPagination({ + maxPagesCount: 8, + pageCount: 10, + allowedPageSizes: [5, 10, 20], + showInfo: true, + itemCount: 86, + infoText: 'Page {0} of {1} ({2} items)', + pagesCountText: 'of', + showNavigationButtons: true + }); + } finally { + styleSheets.forEach(sheet => { sheet.disabled = false; }); + } + const pagination = $pagination.dxPagination('instance'); + + assert.equal(isLightMode(pagination), false, 'the unstyled measurement is ignored'); + + resizeCallbacks.fire(); + + assert.equal(isLightMode(pagination), false, 'full mode after the css is applied'); + + $pagination.width(100); + resizeCallbacks.fire(); + + assert.equal(isLightMode(pagination), true, 'light mode is applied for a narrow pagination'); + }); + QUnit.test('Apply light mode when pagination is first rendered', function(assert) { const $pagination = $('#container').width(100).dxPagination({ maxPagesCount: 8, From d149b9ee6ab400f08dc6d9f31cdb0c4719348baa Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 6 Oct 2026 00:49:00 +0400 Subject: [PATCH 2/6] Pagination: re-read the compact editors' min-width when the theme is loaded --- .../pagination/__tests__/pages-small.test.ts | 68 +++++++++++++++---- .../__internal/pagination/page_size/small.tsx | 12 +++- .../js/__internal/pagination/pages/small.tsx | 12 +++- .../pagination/resizable_container.tsx | 12 +--- .../pagination/utils/on_theme_ready.ts | 11 +++ 5 files changed, 91 insertions(+), 24 deletions(-) create mode 100644 packages/devextreme/js/__internal/pagination/utils/on_theme_ready.ts diff --git a/packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts b/packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts index 12addb35c86c..ad15fc04773c 100644 --- a/packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts +++ b/packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts @@ -1,29 +1,44 @@ import { - afterEach, describe, expect, it, + afterEach, describe, expect, it, jest, } from '@jest/globals'; import NumberBox from '@js/ui/number_box'; +import SelectBox from '@js/ui/select_box'; +import { themeReadyCallback } from '@ts/ui/m_themes_callback'; import Pagination from '../wrappers/pagination'; +jest.mock('@ts/ui/themes', () => ({ + ...jest.requireActual('@ts/ui/themes'), + isPendingThemeLoaded: (): boolean => false, +})); + const FALLBACK_MIN_WIDTH = 10; const ONE_DIGIT_WIDTH = 10; +function createCompactPagination(config: object = {}): HTMLElement { + const container = document.createElement('div'); + document.body.appendChild(container); + // eslint-disable-next-line no-new + new Pagination(container, { + displayMode: 'compact', + pageCount: 10, + pageIndex: 1, + pageSize: 5, + itemCount: 46, + pagesCountText: 'of', + ...config, + }); + return container; +} + afterEach(() => { document.body.innerHTML = ''; }); -describe('PagesSmall page index width', () => { - it('re-reads the css min-width of the page index after the theme css is applied', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const pagination = new Pagination(container, { - displayMode: 'compact', - pageCount: 10, - pageIndex: 1, - pageSize: 5, - itemCount: 46, - pagesCountText: 'of', - }); +describe('compact pagination editors', () => { + it('re-reads the css min-width of the page index after the next update', async () => { + const container = createCompactPagination(); + const pagination = Pagination.getInstance(container) as Pagination; const pageIndex = container.querySelector('.dx-page-index') as HTMLElement; const numberBox = NumberBox.getInstance(pageIndex) as NumberBox; @@ -35,4 +50,31 @@ describe('PagesSmall page index width', () => { expect(numberBox.option('width')).toBe(32 + 2 * ONE_DIGIT_WIDTH); }); + + it('re-reads the css min-width of the page index when the theme is loaded', () => { + const container = createCompactPagination(); + const pageIndex = container.querySelector('.dx-page-index') as HTMLElement; + const numberBox = NumberBox.getInstance(pageIndex) as NumberBox; + + pageIndex.style.minWidth = '32px'; + themeReadyCallback.fire(); + + expect(numberBox.option('width')).toBe(32 + 2 * ONE_DIGIT_WIDTH); + }); + + it('re-reads the css min-width of the page sizes when the theme is loaded', () => { + const container = createCompactPagination({ + showPageSizeSelector: true, + allowedPageSizes: [5, 10, 20], + }); + const pageSizes = container.querySelector('.dx-page-sizes') as HTMLElement; + const selectBox = SelectBox.getInstance(pageSizes.querySelector('.dx-selectbox') as HTMLElement) as SelectBox; + + expect(selectBox.option('width')).toBe(FALLBACK_MIN_WIDTH + 2 * ONE_DIGIT_WIDTH); + + pageSizes.style.minWidth = '62px'; + themeReadyCallback.fire(); + + expect(selectBox.option('width')).toBe(62 + 2 * ONE_DIGIT_WIDTH); + }); }); diff --git a/packages/devextreme/js/__internal/pagination/page_size/small.tsx b/packages/devextreme/js/__internal/pagination/page_size/small.tsx index 39dd771c58ae..10d69df3ddad 100644 --- a/packages/devextreme/js/__internal/pagination/page_size/small.tsx +++ b/packages/devextreme/js/__internal/pagination/page_size/small.tsx @@ -3,12 +3,14 @@ import type { RefObject } from '@ts/core/r1/runtime/inferno/index'; import { InfernoComponent, InfernoEffect } from '@ts/core/r1/runtime/inferno/index'; +import type { EffectReturn } from '../../core/r1/utils/effect_return'; import { PaginationDefaultProps, type PaginationProps } from '../common/pagination_props'; import type { FullPageSize } from '../common/types'; import { SelectBox } from '../drop_down_editors/select_box'; import { calculateValuesFittedWidth } from '../utils/calculate_values_fitted_width'; import { getLocalizationMessage } from '../utils/compatibility_utils'; import { getElementMinWidth } from '../utils/get_element_width'; +import { onThemeReady } from '../utils/on_theme_ready'; export interface PaginationSmallProps { parentRef?: RefObject; @@ -38,6 +40,7 @@ export class PageSizeSmall extends InfernoComponent { constructor(props) { super(props); this.updateWidth = this.updateWidth.bind(this); + this.subscribeToThemeReady = this.subscribeToThemeReady.bind(this); } componentWillUpdate(nextProps: PageSizeSmallPropsType, nextState, context): void { @@ -52,7 +55,10 @@ export class PageSizeSmall extends InfernoComponent { this.props.pageSizeChangedInternal, this.props.allowedPageSizes, ]; - return [new InfernoEffect(this.updateWidth, dependency)]; + return [ + new InfernoEffect(this.updateWidth, dependency), + new InfernoEffect(this.subscribeToThemeReady, []), + ]; } updateEffects(): void { @@ -66,6 +72,10 @@ export class PageSizeSmall extends InfernoComponent { this._effects[0]?.update(dependency); } + subscribeToThemeReady(): EffectReturn { + return onThemeReady(this.updateWidth); + } + updateWidth(): void { const minWidth = getElementMinWidth(this.props.parentRef?.current); this.setState((state) => ({ diff --git a/packages/devextreme/js/__internal/pagination/pages/small.tsx b/packages/devextreme/js/__internal/pagination/pages/small.tsx index 41fc83323e89..6a969c111592 100644 --- a/packages/devextreme/js/__internal/pagination/pages/small.tsx +++ b/packages/devextreme/js/__internal/pagination/pages/small.tsx @@ -4,12 +4,14 @@ import { InfernoComponent, InfernoEffect } from '@ts/core/r1/runtime/inferno/ind import type { RefObject } from 'inferno'; import { createRef } from 'inferno'; +import type { EffectReturn } from '../../core/r1/utils/effect_return'; import { PaginationDefaultProps, type PaginationProps } from '../common/pagination_props'; import { NumberBox } from '../editors/number_box'; import { PAGER_INFO_CLASS } from '../info'; import { calculateValuesFittedWidth } from '../utils/calculate_values_fitted_width'; import { getLocalizationMessage } from '../utils/compatibility_utils'; import { getElementMinWidth } from '../utils/get_element_width'; +import { onThemeReady } from '../utils/on_theme_ready'; import { Page } from './page'; const PAGER_INFO_TEXT_CLASS = `${PAGER_INFO_CLASS} dx-info-text`; @@ -37,6 +39,7 @@ export class PagesSmall extends InfernoComponent { constructor(props) { super(props); this.updateWidth = this.updateWidth.bind(this); + this.subscribeToThemeReady = this.subscribeToThemeReady.bind(this); this.selectLastPageIndex = this.selectLastPageIndex.bind(this); this.valueChange = this.valueChange.bind(this); } @@ -46,7 +49,10 @@ export class PagesSmall extends InfernoComponent { } createEffects(): InfernoEffect[] { - return [new InfernoEffect(this.updateWidth, this.getEffectDependencies())]; + return [ + new InfernoEffect(this.updateWidth, this.getEffectDependencies()), + new InfernoEffect(this.subscribeToThemeReady, []), + ]; } updateEffects(): void { @@ -62,6 +68,10 @@ export class PagesSmall extends InfernoComponent { ]; } + subscribeToThemeReady(): EffectReturn { + return onThemeReady(this.updateWidth); + } + updateWidth(): void { const el = this.pageIndexRef.current?.querySelector(`.${PAGER_PAGE_INDEX_CLASS}`); const minWidth = el ? getElementMinWidth(el) : 0; diff --git a/packages/devextreme/js/__internal/pagination/resizable_container.tsx b/packages/devextreme/js/__internal/pagination/resizable_container.tsx index c17f26a400a5..658e5db8f943 100644 --- a/packages/devextreme/js/__internal/pagination/resizable_container.tsx +++ b/packages/devextreme/js/__internal/pagination/resizable_container.tsx @@ -2,8 +2,6 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { InfernoComponent, InfernoEffect } from '@ts/core/r1/runtime/inferno/index'; import type { JSXTemplate } from '@ts/core/r1/types'; -import { themeReadyCallback } from '@ts/ui/m_themes_callback'; -import { isPendingThemeLoaded } from '@ts/ui/themes'; import { createRef as infernoCreateRef } from 'inferno'; import resizeCallbacks from '../../core/utils/resize_callbacks'; @@ -15,6 +13,7 @@ import type { PaginationContentProps } from './content'; import { getElementContentWidth, getElementStyle, getElementWidth, isElementBlockLevel, } from './utils/get_element_width'; +import { onThemeReady } from './utils/on_theme_ready'; interface ChildElements { allowedPageSizes: T; pages: T; info: T } interface MainElements { parent: T; allowedPageSizes: T; pages: T } @@ -140,16 +139,11 @@ export class ResizableContainer extends InfernoComponent { + return onThemeReady((): void => { if (this.getParentWidth() > 0) { this.updateAdaptivityProps(); } - }; - themeReadyCallback.add(callback); - return (): void => { themeReadyCallback.remove(callback); }; + }); } effectUpdateChildProps(): void { diff --git a/packages/devextreme/js/__internal/pagination/utils/on_theme_ready.ts b/packages/devextreme/js/__internal/pagination/utils/on_theme_ready.ts new file mode 100644 index 000000000000..50ae97881ef0 --- /dev/null +++ b/packages/devextreme/js/__internal/pagination/utils/on_theme_ready.ts @@ -0,0 +1,11 @@ +import type { EffectReturn } from '@ts/core/r1/utils/effect_return'; +import { themeReadyCallback } from '@ts/ui/m_themes_callback'; +import { isPendingThemeLoaded } from '@ts/ui/themes'; + +export function onThemeReady(callback: () => void): EffectReturn { + if (isPendingThemeLoaded()) { + return undefined; + } + themeReadyCallback.add(callback); + return (): void => { themeReadyCallback.remove(callback); }; +} From 68a7984c9980c7532d8f2046f2c40d2a041a0e20 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 6 Oct 2026 10:03:50 +0400 Subject: [PATCH 3/6] Pagination: address review notes (effect dependencies, test cleanup, helper coverage) --- .../__tests__/on-theme-ready.test.ts | 44 +++++++++++++++++++ .../pagination/__tests__/pages-small.test.ts | 25 +++++++---- .../js/__internal/pagination/pages/small.tsx | 7 +-- .../pagination/utils/get_element_width.ts | 4 +- .../pagination/utils/on_theme_ready.ts | 3 +- 5 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 packages/devextreme/js/__internal/pagination/__tests__/on-theme-ready.test.ts diff --git a/packages/devextreme/js/__internal/pagination/__tests__/on-theme-ready.test.ts b/packages/devextreme/js/__internal/pagination/__tests__/on-theme-ready.test.ts new file mode 100644 index 000000000000..dbe2bbef0a21 --- /dev/null +++ b/packages/devextreme/js/__internal/pagination/__tests__/on-theme-ready.test.ts @@ -0,0 +1,44 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; +import { themeReadyCallback } from '@ts/ui/m_themes_callback'; + +import { onThemeReady } from '../utils/on_theme_ready'; + +const mockThemeState = { loaded: false }; + +jest.mock('@ts/ui/themes', () => ({ + ...jest.requireActual('@ts/ui/themes'), + isPendingThemeLoaded: (): boolean => mockThemeState.loaded, +})); + +describe('onThemeReady', () => { + it('runs the callback once the pending theme is loaded and unsubscribes on dispose', () => { + mockThemeState.loaded = false; + const callback = jest.fn(); + + const dispose = onThemeReady(callback); + + expect(callback).not.toHaveBeenCalled(); + + themeReadyCallback.fire(); + + expect(callback).toHaveBeenCalledTimes(1); + + dispose?.(); + themeReadyCallback.fire(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('does not subscribe when the theme is already loaded', () => { + mockThemeState.loaded = true; + const callback = jest.fn(); + + expect(onThemeReady(callback)).toBeUndefined(); + + themeReadyCallback.fire(); + + expect(callback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts b/packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts index ad15fc04773c..682918eae450 100644 --- a/packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts +++ b/packages/devextreme/js/__internal/pagination/__tests__/pages-small.test.ts @@ -15,11 +15,17 @@ jest.mock('@ts/ui/themes', () => ({ const FALLBACK_MIN_WIDTH = 10; const ONE_DIGIT_WIDTH = 10; -function createCompactPagination(config: object = {}): HTMLElement { +const instances: Pagination[] = []; + +function createCompactPagination(config: object = {}): { + container: HTMLElement; + pagination: Pagination; +} { + const root = document.createElement('div'); + document.body.appendChild(root); const container = document.createElement('div'); - document.body.appendChild(container); - // eslint-disable-next-line no-new - new Pagination(container, { + root.appendChild(container); + const pagination = new Pagination(container, { displayMode: 'compact', pageCount: 10, pageIndex: 1, @@ -28,17 +34,18 @@ function createCompactPagination(config: object = {}): HTMLElement { pagesCountText: 'of', ...config, }); - return container; + instances.push(pagination); + return { container, pagination }; } afterEach(() => { + instances.splice(0).forEach((pagination) => pagination.dispose()); document.body.innerHTML = ''; }); describe('compact pagination editors', () => { it('re-reads the css min-width of the page index after the next update', async () => { - const container = createCompactPagination(); - const pagination = Pagination.getInstance(container) as Pagination; + const { container, pagination } = createCompactPagination(); const pageIndex = container.querySelector('.dx-page-index') as HTMLElement; const numberBox = NumberBox.getInstance(pageIndex) as NumberBox; @@ -52,7 +59,7 @@ describe('compact pagination editors', () => { }); it('re-reads the css min-width of the page index when the theme is loaded', () => { - const container = createCompactPagination(); + const { container } = createCompactPagination(); const pageIndex = container.querySelector('.dx-page-index') as HTMLElement; const numberBox = NumberBox.getInstance(pageIndex) as NumberBox; @@ -63,7 +70,7 @@ describe('compact pagination editors', () => { }); it('re-reads the css min-width of the page sizes when the theme is loaded', () => { - const container = createCompactPagination({ + const { container } = createCompactPagination({ showPageSizeSelector: true, allowedPageSizes: [5, 10, 20], }); diff --git a/packages/devextreme/js/__internal/pagination/pages/small.tsx b/packages/devextreme/js/__internal/pagination/pages/small.tsx index 6a969c111592..9c735b61ba02 100644 --- a/packages/devextreme/js/__internal/pagination/pages/small.tsx +++ b/packages/devextreme/js/__internal/pagination/pages/small.tsx @@ -60,12 +60,7 @@ export class PagesSmall extends InfernoComponent { } getEffectDependencies(): unknown[] { - return [ - this.props, - this.state.minWidth, - this.props.pageCount, - this.props.pageIndex, - ]; + return [this.state.minWidth, this.props.pageCount, this.props.pageIndex]; } subscribeToThemeReady(): EffectReturn { diff --git a/packages/devextreme/js/__internal/pagination/utils/get_element_width.ts b/packages/devextreme/js/__internal/pagination/utils/get_element_width.ts index 0cbdad8b55a2..002104e29ee3 100644 --- a/packages/devextreme/js/__internal/pagination/utils/get_element_width.ts +++ b/packages/devextreme/js/__internal/pagination/utils/get_element_width.ts @@ -28,6 +28,6 @@ export function isElementBlockLevel(element: Element | null | undefined): boolea if (!computedStyle) { return false; } - const float = computedStyle.getPropertyValue('float'); - return computedStyle.getPropertyValue('display') === 'block' && (!float || float === 'none'); + const cssFloat = computedStyle.getPropertyValue('float'); + return computedStyle.getPropertyValue('display') === 'block' && (!cssFloat || cssFloat === 'none'); } diff --git a/packages/devextreme/js/__internal/pagination/utils/on_theme_ready.ts b/packages/devextreme/js/__internal/pagination/utils/on_theme_ready.ts index 50ae97881ef0..6818cde6cf4a 100644 --- a/packages/devextreme/js/__internal/pagination/utils/on_theme_ready.ts +++ b/packages/devextreme/js/__internal/pagination/utils/on_theme_ready.ts @@ -3,7 +3,8 @@ import { themeReadyCallback } from '@ts/ui/m_themes_callback'; import { isPendingThemeLoaded } from '@ts/ui/themes'; export function onThemeReady(callback: () => void): EffectReturn { - if (isPendingThemeLoaded()) { + const isThemeLoaded = isPendingThemeLoaded(); + if (isThemeLoaded) { return undefined; } themeReadyCallback.add(callback); From d434ad773d40f824b4b827f16c5e40496a8a1140 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 6 Oct 2026 12:27:37 +0400 Subject: [PATCH 4/6] Pagination: drop the card references from the tests --- .../testing/tests/DevExpress.ui.widgets/pagination.tests.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/devextreme/testing/tests/DevExpress.ui.widgets/pagination.tests.js b/packages/devextreme/testing/tests/DevExpress.ui.widgets/pagination.tests.js index ce8f14f46182..34a59151a2e5 100644 --- a/packages/devextreme/testing/tests/DevExpress.ui.widgets/pagination.tests.js +++ b/packages/devextreme/testing/tests/DevExpress.ui.widgets/pagination.tests.js @@ -1107,7 +1107,6 @@ function() { assert.equal(numberBox.option('width'), Number($('.dx-page-index').css('min-width').replace('px', '')) + 40); }); - // devextreme-private#5261 QUnit.test('Light mode. Page index width is recalculated after the theme css is applied', function(assert) { const styleSheets = Array.from(document.styleSheets); styleSheets.forEach(sheet => { sheet.disabled = true; }); @@ -1315,7 +1314,6 @@ function() { assert.ok($pagination.find('.dx-info').length === 1 && $pagination.find('.dx-info').css('display') !== 'none', 'info element is visible'); }); - // devextreme-private#5261 QUnit.test('Light mode is not applied from a measurement taken before the theme css is applied', function(assert) { const styleSheets = Array.from(document.styleSheets); styleSheets.forEach(sheet => { sheet.disabled = true; }); From 24090e0b2d6748d6f41bfa87c1604e2f840f6dd0 Mon Sep 17 00:00:00 2001 From: EugeniyKiyashko Date: Tue, 6 Oct 2026 14:01:50 +0400 Subject: [PATCH 5/6] Pagination: include the info text in the readiness check and cover the theme-ready re-measure --- .../__tests__/adaptive-theme-ready.test.ts | 66 +++++++++++++++++++ .../__tests__/resizable-container.test.ts | 49 ++++++++++++-- .../pagination/resizable_container.tsx | 8 ++- .../DevExpress.ui.widgets/pagination.tests.js | 29 ++++++++ 4 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 packages/devextreme/js/__internal/pagination/__tests__/adaptive-theme-ready.test.ts diff --git a/packages/devextreme/js/__internal/pagination/__tests__/adaptive-theme-ready.test.ts b/packages/devextreme/js/__internal/pagination/__tests__/adaptive-theme-ready.test.ts new file mode 100644 index 000000000000..7c8c432328be --- /dev/null +++ b/packages/devextreme/js/__internal/pagination/__tests__/adaptive-theme-ready.test.ts @@ -0,0 +1,66 @@ +import { + afterEach, describe, expect, it, jest, +} from '@jest/globals'; +import { themeReadyCallback } from '@ts/ui/m_themes_callback'; + +import Pagination from '../wrappers/pagination'; + +jest.mock('@ts/ui/themes', () => ({ + ...jest.requireActual('@ts/ui/themes'), + isPendingThemeLoaded: (): boolean => false, +})); + +const instances: Pagination[] = []; + +function createAdaptivePagination(width: string): HTMLElement { + const root = document.createElement('div'); + document.body.appendChild(root); + const container = document.createElement('div'); + container.style.width = width; + root.appendChild(container); + instances.push(new Pagination(container, { + pageCount: 10, + pageIndex: 1, + pageSize: 5, + itemCount: 46, + showPageSizeSelector: true, + allowedPageSizes: [5, 10], + })); + return container; +} + +function applyThemeLayout(container: HTMLElement): void { + const pageIndexes = container.querySelector('.dx-page-indexes') as HTMLElement; + const pageSizes = container.querySelector('.dx-page-sizes') as HTMLElement; + pageIndexes.style.display = 'inline-block'; + pageIndexes.style.width = '300px'; + pageSizes.style.cssFloat = 'left'; + pageSizes.style.width = '100px'; +} + +afterEach(() => { + instances.splice(0).forEach((pagination) => pagination.dispose()); + document.body.innerHTML = ''; +}); + +describe('adaptive pagination and the theme loading', () => { + it('switches a narrow pagination to the compact mode when the theme is loaded', () => { + const container = createAdaptivePagination('200px'); + + expect(container.classList.contains('dx-light-mode')).toBe(false); + + applyThemeLayout(container); + themeReadyCallback.fire(); + + expect(container.classList.contains('dx-light-mode')).toBe(true); + }); + + it('keeps a wide pagination in the full mode when the theme is loaded', () => { + const container = createAdaptivePagination('1000px'); + + applyThemeLayout(container); + themeReadyCallback.fire(); + + expect(container.classList.contains('dx-light-mode')).toBe(false); + }); +}); diff --git a/packages/devextreme/js/__internal/pagination/__tests__/resizable-container.test.ts b/packages/devextreme/js/__internal/pagination/__tests__/resizable-container.test.ts index 2e4a3766a469..872b320bd8a0 100644 --- a/packages/devextreme/js/__internal/pagination/__tests__/resizable-container.test.ts +++ b/packages/devextreme/js/__internal/pagination/__tests__/resizable-container.test.ts @@ -13,11 +13,14 @@ function createElement(style: Partial): HTMLDivElement { interface Elements { parent: HTMLDivElement; - pages: HTMLDivElement; + pages?: HTMLDivElement; allowedPageSizes?: HTMLDivElement; + info?: HTMLDivElement; } -function createContainer({ parent, pages, allowedPageSizes }: Elements): ResizableContainer { +function createContainer({ + parent, pages, allowedPageSizes, info, +}: Elements): ResizableContainer { const container = new ResizableContainer({ paginationProps: {}, contentTemplate: (): null => null, @@ -30,8 +33,9 @@ function createContainer({ parent, pages, allowedPageSizes }: Elements): Resizab Object.assign(container, { parentRef: { current: parent }, - pagesRef: { current: pages }, + pagesRef: { current: pages ?? null }, allowedPageSizesRef: { current: allowedPageSizes ?? null }, + infoTextRef: { current: info ?? null }, }); return container; @@ -46,19 +50,29 @@ describe('isLayoutApplied', () => { const pages = createElement({ width: '1000px' }); const allowedPageSizes = createElement({ width: '1000px' }); - expect(isLayoutApplied({ pages, allowedPageSizes })).toBe(false); + expect(isLayoutApplied({ pages, allowedPageSizes, info: null })).toBe(false); pages.style.display = 'inline-block'; - expect(isLayoutApplied({ pages, allowedPageSizes })).toBe(false); + expect(isLayoutApplied({ pages, allowedPageSizes, info: null })).toBe(false); allowedPageSizes.style.cssFloat = 'left'; - expect(isLayoutApplied({ pages, allowedPageSizes })).toBe(true); + expect(isLayoutApplied({ pages, allowedPageSizes, info: null })).toBe(true); + }); + + it('is false while the info text is a plain block-level element', () => { + const info = createElement({ width: '1000px' }); + + expect(isLayoutApplied({ pages: null, allowedPageSizes: null, info })).toBe(false); + + info.style.display = 'inline-block'; + + expect(isLayoutApplied({ pages: null, allowedPageSizes: null, info })).toBe(true); }); it('ignores containers that are not rendered', () => { - expect(isLayoutApplied({ pages: null, allowedPageSizes: undefined })).toBe(true); + expect(isLayoutApplied({ pages: null, allowedPageSizes: undefined, info: null })).toBe(true); }); }); @@ -116,4 +130,25 @@ describe('ResizableContainer adaptivity', () => { expect(container.state.isLargeDisplayMode).toBe(false); }); + + it('does not hide the info text from a measurement taken before the theme css is applied', () => { + const parent = createElement({ width: '1000px' }); + const info = createElement({ width: '1000px' }); + const container = createContainer({ parent, info }); + + container.updateAdaptivityProps(); + + expect(container.state.infoTextVisible).toBe(true); + + info.style.display = 'inline-block'; + info.style.width = '150px'; + container.updateAdaptivityProps(); + + expect(container.state.infoTextVisible).toBe(true); + + parent.style.width = '100px'; + container.updateAdaptivityProps(); + + expect(container.state.infoTextVisible).toBe(false); + }); }); diff --git a/packages/devextreme/js/__internal/pagination/resizable_container.tsx b/packages/devextreme/js/__internal/pagination/resizable_container.tsx index 658e5db8f943..e11b92e7e0a3 100644 --- a/packages/devextreme/js/__internal/pagination/resizable_container.tsx +++ b/packages/devextreme/js/__internal/pagination/resizable_container.tsx @@ -36,9 +36,10 @@ export function calculateInfoTextVisible({ } export function isLayoutApplied({ - allowedPageSizes, pages, -}: Omit, 'parent'>): boolean { - return [allowedPageSizes, pages].every((element) => !element || !isElementBlockLevel(element)); + allowedPageSizes, pages, info, +}: ChildElements): boolean { + return [allowedPageSizes, pages, info] + .every((element) => !element || !isElementBlockLevel(element)); } function getElementsWidth({ @@ -253,6 +254,7 @@ export class ResizableContainer extends InfernoComponent