From bdf00862d32dc85c0d3f5f79a4bc6aaa59976908 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:00:13 -0400 Subject: [PATCH 1/4] feat(split-pane): resolve breakpoints from config --- BREAKING.md | 5 + core/api.txt | 2 +- core/src/components.d.ts | 8 +- core/src/components/split-pane/split-pane.tsx | 42 +++- .../split-pane/test/when/index.html | 127 ++++++++++++ .../split-pane/test/when/split-pane.e2e.ts | 188 ++++++++++++++++++ 6 files changed, 357 insertions(+), 15 deletions(-) create mode 100644 core/src/components/split-pane/test/when/index.html create mode 100644 core/src/components/split-pane/test/when/split-pane.e2e.ts diff --git a/BREAKING.md b/BREAKING.md index 940912eacfc..524e584a472 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -34,6 +34,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver - [Row](#version-10x-row) - [Skeleton Text](#version-10x-skeleton-text) - [Spinner](#version-10x-spinner) + - [Split Pane](#version-10x-split-pane) - [Text](#version-10x-text) - [Textarea](#version-10x-textarea) - [Thumbnail](#version-10x-thumbnail) @@ -496,6 +497,10 @@ Remove any instances that target the theme classes: `ion-skeleton-text.md`, `ion - `.spinner-[spinner-name]` → `.spinner-name-[spinner-name]` - Specific theme classes (e.g., `ion-spinner.md`) are no longer supported. Style modifications based on the active theme must be implemented using theme tokens rather than direct class targeting. +

Split Pane

+ +- The default value of the `when` property changed from `'(min-width: 992px)'` to the equivalent `'lg'` shortcut. The default behavior is unchanged, but the shortcut now resolves through the global `screenBreakpoints` config, so overriding `lg` also changes when the split pane becomes visible. Code that compares `when` against the literal `'(min-width: 992px)'` should be updated. +

Text

The following breaking changes apply to `ion-text`: diff --git a/core/api.txt b/core/api.txt index 88d97384026..f70e4bb3493 100644 --- a/core/api.txt +++ b/core/api.txt @@ -2605,7 +2605,7 @@ ion-split-pane,prop,contentId,string | undefined,undefined,false,true ion-split-pane,prop,disabled,boolean,false,false,false ion-split-pane,prop,mode,"ios" | "md",undefined,false,false ion-split-pane,prop,theme,"ios" | "md" | "ionic",undefined,false,false -ion-split-pane,prop,when,boolean | string,'(min-width: 992px)',false,false +ion-split-pane,prop,when,boolean | string,'lg',false,false ion-split-pane,event,ionSplitPaneVisible,{ visible: boolean; },true ion-split-pane,css-prop,--border,ionic ion-split-pane,css-prop,--border,ios diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 9e43ab8c87d..b82f2e79119 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -3878,8 +3878,8 @@ export namespace Components { */ "theme"?: "ios" | "md" | "ionic"; /** - * When the split-pane should be shown. Can be a CSS media query expression, or a shortcut expression. Can also be a boolean expression. - * @default '(min-width: 992px)' + * When the split-pane should be shown. Can be a CSS media query expression, or a shortcut expression. Can also be a boolean expression. The shortcut expressions are the names of the global screen breakpoints (`"xs"`, `"sm"`, `"md"`, `"lg"`, `"xl"` and `"xxl"`), which expand to the `min-width` media query for that breakpoint, plus `"never"`, which keeps the split pane hidden at every size. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. + * @default 'lg' */ "when": string | boolean; } @@ -9848,8 +9848,8 @@ declare namespace LocalJSX { */ "theme"?: "ios" | "md" | "ionic"; /** - * When the split-pane should be shown. Can be a CSS media query expression, or a shortcut expression. Can also be a boolean expression. - * @default '(min-width: 992px)' + * When the split-pane should be shown. Can be a CSS media query expression, or a shortcut expression. Can also be a boolean expression. The shortcut expressions are the names of the global screen breakpoints (`"xs"`, `"sm"`, `"md"`, `"lg"`, `"xl"` and `"xxl"`), which expand to the `min-width` media query for that breakpoint, plus `"never"`, which keeps the split pane hidden at every size. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. + * @default 'lg' */ "when"?: string | boolean; } diff --git a/core/src/components/split-pane/split-pane.tsx b/core/src/components/split-pane/split-pane.tsx index 95c8995f8ef..d12d44c6232 100644 --- a/core/src/components/split-pane/split-pane.tsx +++ b/core/src/components/split-pane/split-pane.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core'; +import { getScreenBreakpointMediaQuery } from '@utils/breakpoints'; import { printIonWarning } from '@utils/logging'; import { getIonTheme } from '../../global/ionic-global'; @@ -8,14 +9,29 @@ import { getIonTheme } from '../../global/ionic-global'; const SPLIT_PANE_MAIN = 'split-pane-main'; const SPLIT_PANE_SIDE = 'split-pane-side'; -// TODO(FW-7285): Replace with global breakpoints -const QUERY: { [key: string]: string } = { - xs: '(min-width: 0px)', - sm: '(min-width: 576px)', - md: '(min-width: 768px)', - lg: '(min-width: 992px)', - xl: '(min-width: 1200px)', - never: '', + +/** + * The shortcut expression that keeps the split pane hidden at every size. + */ +const NEVER = 'never'; + +/** + * Resolve the `when` property to the media query to listen on. A shortcut + * expression is expanded to the `min-width` query of the matching global + * screen breakpoint, so it reflects any `screenBreakpoints` config the + * application has set. Anything else is treated as a media query and used + * as-is. + * + * @param when The `when` property value. + * @return The media query to listen on, or an empty string when the split + * pane should never be shown. + */ +const getMediaQuery = (when: string): string => { + if (when === NEVER) { + return ''; + } + + return getScreenBreakpointMediaQuery(when) ?? when; }; /** @@ -55,8 +71,14 @@ export class SplitPane implements ComponentInterface { * When the split-pane should be shown. * Can be a CSS media query expression, or a shortcut expression. * Can also be a boolean expression. + * + * The shortcut expressions are the names of the global screen breakpoints + * (`"xs"`, `"sm"`, `"md"`, `"lg"`, `"xl"` and `"xxl"`), which expand to the + * `min-width` media query for that breakpoint, plus `"never"`, which keeps + * the split pane hidden at every size. The width each breakpoint activates + * at can be changed with the `screenBreakpoints` config. */ - @Prop() when: string | boolean = QUERY['lg']; + @Prop() when: string | boolean = 'lg'; /** * Expression to be called when the split-pane visibility has changed @@ -118,7 +140,7 @@ export class SplitPane implements ComponentInterface { } // When query is a string, let's find first if it is a shortcut - const mediaQuery = QUERY[query] || query; + const mediaQuery = getMediaQuery(query); // Media query is empty or null, we hide it if (mediaQuery.length === 0) { diff --git a/core/src/components/split-pane/test/when/index.html b/core/src/components/split-pane/test/when/index.html new file mode 100644 index 00000000000..cccdacbbe2c --- /dev/null +++ b/core/src/components/split-pane/test/when/index.html @@ -0,0 +1,127 @@ + + + + + Split Pane - When + + + + + + + + + + + + + + + Menu + + +
+ + Main content. The split pane is shown at md and above, which this page configures as 400px. + +
+
+ +
+ … + –px +
+
+ + + + diff --git a/core/src/components/split-pane/test/when/split-pane.e2e.ts b/core/src/components/split-pane/test/when/split-pane.e2e.ts new file mode 100644 index 00000000000..2f93db1c4ed --- /dev/null +++ b/core/src/components/split-pane/test/when/split-pane.e2e.ts @@ -0,0 +1,188 @@ +import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; + +/** + * `when` accepts a raw CSS media query, one of the global screen breakpoint + * shortcuts, the `never` shortcut, or a boolean. + * + * Cases that set the markup inline default to an 800px viewport, which sits + * between the default `md` (768) and `lg` (992) breakpoints. The configured + * breakpoint cases load the test page instead, which moves `md` to 400px. + * + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('split-pane: when'), () => { + const setUpSplitPane = async (page: E2EPage, when: string | undefined, width = 800) => { + // Omit the prop entirely when `when` is undefined rather than set + // it to "", so the prop keeps its default + const whenAttribute = when !== undefined ? `when="${when}"` : ''; + + await page.setViewportSize({ width, height: 600 }); + await page.setContent( + ` + + + + Menu + +
+ Main +
+
+
+ `, + config + ); + + return page.locator('ion-split-pane'); + }; + + /** + * The default is the `lg` shortcut rather than the `(min-width: 992px)` + * query it used to be. Both resolve to the same width by default, so + * these pin the shortcut itself as well as where it activates. + */ + test.describe('with no value', () => { + test('should default to the lg shortcut', async ({ page }) => { + const splitPane = await setUpSplitPane(page, undefined); + + expect(await splitPane.evaluate((el: any) => el.when)).toBe('lg'); + }); + + test('should not be visible below the default lg width', async ({ page }) => { + const splitPane = await setUpSplitPane(page, undefined, 900); + + await expect(splitPane).not.toHaveClass(/split-pane-visible/); + }); + + test('should be visible at the default lg width', async ({ page }) => { + const splitPane = await setUpSplitPane(page, undefined, 1000); + + await expect(splitPane).toHaveClass(/split-pane-visible/); + }); + }); + + test.describe('with a raw media query', () => { + test('should be visible when the query matches', async ({ page }) => { + const splitPane = await setUpSplitPane(page, '(min-width: 500px)'); + + await expect(splitPane).toHaveClass(/split-pane-visible/); + }); + + test('should not be visible when the query does not match', async ({ page }) => { + const splitPane = await setUpSplitPane(page, '(min-width: 900px)'); + + await expect(splitPane).not.toHaveClass(/split-pane-visible/); + }); + + test('should support a query that is not a min-width', async ({ page }) => { + const splitPane = await setUpSplitPane(page, '(orientation: landscape)'); + + await expect(splitPane).toHaveClass(/split-pane-visible/); + }); + }); + + test.describe('with a breakpoint shortcut', () => { + test('should be visible at or above the breakpoint', async ({ page }) => { + const splitPane = await setUpSplitPane(page, 'md'); + + await expect(splitPane).toHaveClass(/split-pane-visible/); + }); + + test('should not be visible below the breakpoint', async ({ page }) => { + const splitPane = await setUpSplitPane(page, 'lg'); + + await expect(splitPane).not.toHaveClass(/split-pane-visible/); + }); + + test('should support the xxl breakpoint', async ({ page }) => { + // Below the 1400px xxl breakpoint + const narrow = await setUpSplitPane(page, 'xxl', 1300); + await expect(narrow).not.toHaveClass(/split-pane-visible/); + + // At the xxl breakpoint + const wide = await setUpSplitPane(page, 'xxl', 1440); + await expect(wide).toHaveClass(/split-pane-visible/); + }); + }); + + test.describe('with the "never" shortcut', () => { + test('should not be visible on a narrow screen', async ({ page }) => { + const splitPane = await setUpSplitPane(page, 'never', 400); + + await expect(splitPane).not.toHaveClass(/split-pane-visible/); + }); + + test('should not be visible on a wide screen', async ({ page }) => { + const splitPane = await setUpSplitPane(page, 'never', 1600); + + await expect(splitPane).not.toHaveClass(/split-pane-visible/); + }); + }); + + test.describe('with an unrecognized value', () => { + test('should not be visible', async ({ page }) => { + const splitPane = await setUpSplitPane(page, 'not-a-breakpoint'); + + await expect(splitPane).not.toHaveClass(/split-pane-visible/); + }); + }); + + /** + * A boolean can only be set as a JavaScript property, since an HTML + * attribute is always a string. Each case starts from a shortcut that + * puts the split pane in the opposite state at the 800px viewport, so + * the assertion proves the boolean caused the change rather than the + * split pane already being in that state. + */ + test.describe('with a boolean', () => { + test('should be visible when true', async ({ page }) => { + // lg (992px) does not match at 800px, so this starts hidden + const splitPane = await setUpSplitPane(page, 'lg'); + + await expect(splitPane).not.toHaveClass(/split-pane-visible/); + + await splitPane.evaluate((el: any) => (el.when = true)); + await page.waitForChanges(); + + await expect(splitPane).toHaveClass(/split-pane-visible/); + }); + + test('should not be visible when false', async ({ page }) => { + // md (768px) matches at 800px, so this starts visible + const splitPane = await setUpSplitPane(page, 'md'); + + await expect(splitPane).toHaveClass(/split-pane-visible/); + + await splitPane.evaluate((el: any) => (el.when = false)); + await page.waitForChanges(); + + await expect(splitPane).not.toHaveClass(/split-pane-visible/); + }); + }); + + /** + * The shortcut expands to the `min-width` query of the matching global + * screen breakpoint, so moving that breakpoint moves when the split pane + * appears. The page sets `md` to 400px, well below its default of 768px. + */ + test.describe('with a configured screen breakpoint', () => { + test('should not be visible below the configured width', async ({ page }) => { + await page.setViewportSize({ width: 350, height: 600 }); + await page.goto('/src/components/split-pane/test/when', config); + + await expect(page.locator('#split-pane')).not.toHaveClass(/split-pane-visible/); + }); + + test('should be visible at the configured width', async ({ page }) => { + // 500px is above the configured md (400) but below the default md (768) + await page.setViewportSize({ width: 500, height: 600 }); + await page.goto('/src/components/split-pane/test/when', config); + + await expect(page.locator('#split-pane')).toHaveClass(/split-pane-visible/); + }); + }); + }); +}); From d1b2654dd32e8fd82201da2e37f5921a335ebf3a Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:50:34 -0400 Subject: [PATCH 2/4] test(split-pane): update type and comment --- .../split-pane/test/when/split-pane.e2e.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/core/src/components/split-pane/test/when/split-pane.e2e.ts b/core/src/components/split-pane/test/when/split-pane.e2e.ts index 2f93db1c4ed..5c746f11348 100644 --- a/core/src/components/split-pane/test/when/split-pane.e2e.ts +++ b/core/src/components/split-pane/test/when/split-pane.e2e.ts @@ -40,15 +40,15 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { }; /** - * The default is the `lg` shortcut rather than the `(min-width: 992px)` - * query it used to be. Both resolve to the same width by default, so - * these pin the shortcut itself as well as where it activates. + * The default `lg` shortcut resolves to the same width as the old + * literal unless it's configured, so these pin the shortcut itself as + * well as where it activates. */ test.describe('with no value', () => { test('should default to the lg shortcut', async ({ page }) => { const splitPane = await setUpSplitPane(page, undefined); - expect(await splitPane.evaluate((el: any) => el.when)).toBe('lg'); + expect(await splitPane.evaluate((el: HTMLIonSplitPaneElement) => el.when)).toBe('lg'); }); test('should not be visible below the default lg width', async ({ page }) => { @@ -144,7 +144,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(splitPane).not.toHaveClass(/split-pane-visible/); - await splitPane.evaluate((el: any) => (el.when = true)); + await splitPane.evaluate((el: HTMLIonSplitPaneElement) => (el.when = true)); await page.waitForChanges(); await expect(splitPane).toHaveClass(/split-pane-visible/); @@ -156,7 +156,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await expect(splitPane).toHaveClass(/split-pane-visible/); - await splitPane.evaluate((el: any) => (el.when = false)); + await splitPane.evaluate((el: HTMLIonSplitPaneElement) => (el.when = false)); await page.waitForChanges(); await expect(splitPane).not.toHaveClass(/split-pane-visible/); From 341171bb53d41c433011fc4270a6ac14f07f89e6 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:39:09 -0400 Subject: [PATCH 3/4] refactor(split-pane): rebuild the query when the breakpoints change --- core/src/components/split-pane/split-pane.tsx | 12 ++++- .../split-pane/test/split-pane.spec.ts | 44 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 core/src/components/split-pane/test/split-pane.spec.ts diff --git a/core/src/components/split-pane/split-pane.tsx b/core/src/components/split-pane/split-pane.tsx index d12d44c6232..21e691da8f9 100644 --- a/core/src/components/split-pane/split-pane.tsx +++ b/core/src/components/split-pane/split-pane.tsx @@ -1,6 +1,6 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core'; import { Build, Component, Element, Event, Host, Method, Prop, State, Watch, h } from '@stencil/core'; -import { getScreenBreakpointMediaQuery } from '@utils/breakpoints'; +import { getScreenBreakpointMediaQuery, onBreakpointChange } from '@utils/breakpoints'; import { printIonWarning } from '@utils/logging'; import { getIonTheme } from '../../global/ionic-global'; @@ -49,6 +49,7 @@ const getMediaQuery = (when: string): string => { }) export class SplitPane implements ComponentInterface { private rmL?: () => void; + private unsubscribeBreakpoint?: () => void; @Element() el!: HTMLElement; @State() visible = false; @@ -105,10 +106,19 @@ export class SplitPane implements ComponentInterface { await customElements.whenDefined('ion-split-pane'); } this.styleMainElement(); + this.unsubscribeBreakpoint = onBreakpointChange(() => this.updateState()); + + /** + * Rebuild the media query in case the configured widths changed, or the + * screen size changed, while the component was disconnected. + */ this.updateState(); } disconnectedCallback() { + this.unsubscribeBreakpoint?.(); + this.unsubscribeBreakpoint = undefined; + if (this.rmL) { this.rmL(); this.rmL = undefined; diff --git a/core/src/components/split-pane/test/split-pane.spec.ts b/core/src/components/split-pane/test/split-pane.spec.ts new file mode 100644 index 00000000000..ab224640cf1 --- /dev/null +++ b/core/src/components/split-pane/test/split-pane.spec.ts @@ -0,0 +1,44 @@ +import { newSpecPage } from '@stencil/core/testing'; +import * as breakpoints from '@utils/breakpoints'; + +import { SplitPane } from '../split-pane'; + +/** + * A `when` shortcut is resolved to a media query string once, so the query is + * rebuilt when the configured widths change. + */ +describe('ion-split-pane', () => { + const renderSubscribedSplitPane = async () => { + const unsubscribe = jest.fn(); + const subscribe = jest.spyOn(breakpoints, 'onBreakpointChange').mockReturnValue(unsubscribe); + + const page = await newSpecPage({ components: [SplitPane], html: `
` }); + + const splitPane = page.doc.createElement('ion-split-pane'); + splitPane.when = 'lg'; + page.body.querySelector('#host')!.appendChild(splitPane); + await page.waitForChanges(); + + return { page, splitPane, subscribe, unsubscribe }; + }; + + it('subscribes to breakpoint changes while connected', async () => { + const { subscribe, unsubscribe } = await renderSubscribedSplitPane(); + + expect(subscribe).toHaveBeenCalledTimes(1); + expect(unsubscribe).not.toHaveBeenCalled(); + + subscribe.mockRestore(); + }); + + it('unsubscribes once the split pane is disconnected', async () => { + const { page, splitPane, subscribe, unsubscribe } = await renderSubscribedSplitPane(); + + splitPane.remove(); + await page.waitForChanges(); + + expect(unsubscribe).toHaveBeenCalledTimes(1); + + subscribe.mockRestore(); + }); +}); From e10a375c4066eb7730d53fb7cfc2658feab8b076 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:20:44 -0400 Subject: [PATCH 4/4] refactor(split-pane): do not subscribe once disconnected --- core/src/components/split-pane/split-pane.tsx | 14 ++++++++++++ .../split-pane/test/split-pane.spec.ts | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/core/src/components/split-pane/split-pane.tsx b/core/src/components/split-pane/split-pane.tsx index 21e691da8f9..8399a3cbf69 100644 --- a/core/src/components/split-pane/split-pane.tsx +++ b/core/src/components/split-pane/split-pane.tsx @@ -105,7 +105,21 @@ export class SplitPane implements ComponentInterface { if (typeof customElements !== 'undefined' && customElements != null) { await customElements.whenDefined('ion-split-pane'); } + + /** + * Awaiting above yields, so this element may have been disconnected since, + * in which case `disconnectedCallback` has already run and would not run + * again to remove anything set up here. + */ + if (!this.el.isConnected) { + return; + } + this.styleMainElement(); + + // Reconnecting before the await settles would otherwise leave the earlier + // subscription with nothing holding its removal. + this.unsubscribeBreakpoint?.(); this.unsubscribeBreakpoint = onBreakpointChange(() => this.updateState()); /** diff --git a/core/src/components/split-pane/test/split-pane.spec.ts b/core/src/components/split-pane/test/split-pane.spec.ts index ab224640cf1..a33c326eca9 100644 --- a/core/src/components/split-pane/test/split-pane.spec.ts +++ b/core/src/components/split-pane/test/split-pane.spec.ts @@ -41,4 +41,26 @@ describe('ion-split-pane', () => { subscribe.mockRestore(); }); + + /** + * `connectedCallback` awaits the element being defined, so it can resolve + * after the element has already been disconnected, when nothing is left to + * tear down what it sets up. + */ + it('does not subscribe when disconnected before connecting finishes', async () => { + const unsubscribe = jest.fn(); + const subscribe = jest.spyOn(breakpoints, 'onBreakpointChange').mockReturnValue(unsubscribe); + + const page = await newSpecPage({ components: [SplitPane], html: `
` }); + const host = page.body.querySelector('#host')!; + + const splitPane = page.doc.createElement('ion-split-pane'); + host.appendChild(splitPane); + splitPane.remove(); + await page.waitForChanges(); + + expect(subscribe).not.toHaveBeenCalled(); + + subscribe.mockRestore(); + }); });