From 95a000a832f3896cf260b3f7be796d1131d5fa6c Mon Sep 17 00:00:00 2001 From: Brandy Smith Date: Wed, 14 Oct 2026 10:08:43 -0400 Subject: [PATCH 1/5] feat(breakpoints): add xxl screen breakpoint (#31501) Adds the `xxl` breakpoint to `ion-grid` and `ion-col`. --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> --- core/api.txt | 9 +++++++++ core/src/components/col/col.scss | 5 +++++ core/src/components/col/col.vars.scss | 6 ++++++ core/src/components/grid/grid.interface.ts | 2 +- core/src/components/grid/grid.scss | 6 ++++++ core/src/components/grid/grid.vars.scss | 7 +++++++ .../components/grid/test/fixed/grid.e2e.ts | 5 +++-- core/src/themes/common.globals.scss | 1 + core/src/themes/ionic/default.tokens.ts | 20 +++++++++++++++++++ core/src/themes/ios/default.tokens.ts | 20 +++++++++++++++++++ core/src/themes/md/default.tokens.ts | 20 +++++++++++++++++++ core/src/themes/mixins.scss | 14 ++++++------- core/src/themes/native/native.globals.scss | 1 + core/src/utils/media.ts | 1 + 14 files changed, 107 insertions(+), 10 deletions(-) diff --git a/core/api.txt b/core/api.txt index 5b4a827ab45..e910f671bc4 100644 --- a/core/api.txt +++ b/core/api.txt @@ -799,6 +799,10 @@ ion-col,css-prop,--ion-col-breakpoint-xs-padding-bottom ion-col,css-prop,--ion-col-breakpoint-xs-padding-end ion-col,css-prop,--ion-col-breakpoint-xs-padding-start ion-col,css-prop,--ion-col-breakpoint-xs-padding-top +ion-col,css-prop,--ion-col-breakpoint-xxl-padding-bottom +ion-col,css-prop,--ion-col-breakpoint-xxl-padding-end +ion-col,css-prop,--ion-col-breakpoint-xxl-padding-start +ion-col,css-prop,--ion-col-breakpoint-xxl-padding-top ion-content,shadow ion-content,prop,color,"danger" | "dark" | "light" | "medium" | "primary" | "secondary" | "success" | "tertiary" | "warning" | string & Record | undefined,undefined,false,true @@ -1090,6 +1094,11 @@ ion-grid,css-prop,--ion-grid-breakpoint-xs-padding-end ion-grid,css-prop,--ion-grid-breakpoint-xs-padding-start ion-grid,css-prop,--ion-grid-breakpoint-xs-padding-top ion-grid,css-prop,--ion-grid-breakpoint-xs-width +ion-grid,css-prop,--ion-grid-breakpoint-xxl-padding-bottom +ion-grid,css-prop,--ion-grid-breakpoint-xxl-padding-end +ion-grid,css-prop,--ion-grid-breakpoint-xxl-padding-start +ion-grid,css-prop,--ion-grid-breakpoint-xxl-padding-top +ion-grid,css-prop,--ion-grid-breakpoint-xxl-width ion-grid,css-prop,--ion-grid-columns ion-header,none diff --git a/core/src/components/col/col.scss b/core/src/components/col/col.scss index 28de29f184c..a3889df490d 100644 --- a/core/src/components/col/col.scss +++ b/core/src/components/col/col.scss @@ -31,6 +31,11 @@ * @prop --ion-col-breakpoint-xl-padding-end: End padding on xl screens * @prop --ion-col-breakpoint-xl-padding-bottom: Bottom padding on xl screens * @prop --ion-col-breakpoint-xl-padding-start: Start padding on xl screens + * + * @prop --ion-col-breakpoint-xxl-padding-top: Top padding on xxl screens + * @prop --ion-col-breakpoint-xxl-padding-end: End padding on xxl screens + * @prop --ion-col-breakpoint-xxl-padding-bottom: Bottom padding on xxl screens + * @prop --ion-col-breakpoint-xxl-padding-start: Start padding on xxl screens */ --internal-col-unit-size: calc( (100% - (var(--ion-grid-columns, 12) - 1) * var(--ion-row-gap, 0px)) / var(--ion-grid-columns, 12) diff --git a/core/src/components/col/col.vars.scss b/core/src/components/col/col.vars.scss index b6a004e29ad..6898a649c46 100644 --- a/core/src/components/col/col.vars.scss +++ b/core/src/components/col/col.vars.scss @@ -39,4 +39,10 @@ $grid-column-paddings: ( bottom: var(--padding-bottom, var(--ion-col-breakpoint-xl-padding-bottom)), start: var(--padding-start, var(--ion-col-breakpoint-xl-padding-start)), ), + xxl: ( + top: var(--padding-top, var(--ion-col-breakpoint-xxl-padding-top)), + end: var(--padding-end, var(--ion-col-breakpoint-xxl-padding-end)), + bottom: var(--padding-bottom, var(--ion-col-breakpoint-xxl-padding-bottom)), + start: var(--padding-start, var(--ion-col-breakpoint-xxl-padding-start)), + ), ); diff --git a/core/src/components/grid/grid.interface.ts b/core/src/components/grid/grid.interface.ts index ce34a346902..1273fbaf388 100644 --- a/core/src/components/grid/grid.interface.ts +++ b/core/src/components/grid/grid.interface.ts @@ -12,5 +12,5 @@ export type IonGridRecipe = { }; // TODO(FW-7285): Replace with global breakpoints -export const ION_GRID_BREAKPOINTS = ['xs', 'sm', 'md', 'lg', 'xl'] as const; +export const ION_GRID_BREAKPOINTS = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as const; export type IonGridBreakpoint = (typeof ION_GRID_BREAKPOINTS)[number]; diff --git a/core/src/components/grid/grid.scss b/core/src/components/grid/grid.scss index 3a9d4d172b9..a4d6a02235a 100644 --- a/core/src/components/grid/grid.scss +++ b/core/src/components/grid/grid.scss @@ -34,11 +34,17 @@ * @prop --ion-grid-breakpoint-xl-padding-bottom: Bottom padding on xl screens * @prop --ion-grid-breakpoint-xl-padding-start: Start padding on xl screens * + * @prop --ion-grid-breakpoint-xxl-padding-top: Top padding on xxl screens + * @prop --ion-grid-breakpoint-xxl-padding-end: End padding on xxl screens + * @prop --ion-grid-breakpoint-xxl-padding-bottom: Bottom padding on xxl screens + * @prop --ion-grid-breakpoint-xxl-padding-start: Start padding on xxl screens + * * @prop --ion-grid-breakpoint-xs-width: Width of the fixed grid on xs screens * @prop --ion-grid-breakpoint-sm-width: Width of the fixed grid on sm screens * @prop --ion-grid-breakpoint-md-width: Width of the fixed grid on md screens * @prop --ion-grid-breakpoint-lg-width: Width of the fixed grid on lg screens * @prop --ion-grid-breakpoint-xl-width: Width of the fixed grid on xl screens + * @prop --ion-grid-breakpoint-xxl-width: Width of the fixed grid on xxl screens */ @include grid.make-breakpoint-padding(vars.$grid-paddings); diff --git a/core/src/components/grid/grid.vars.scss b/core/src/components/grid/grid.vars.scss index fd4bb710e60..fe52ae158fb 100644 --- a/core/src/components/grid/grid.vars.scss +++ b/core/src/components/grid/grid.vars.scss @@ -33,6 +33,12 @@ $grid-paddings: ( bottom: var(--ion-grid-breakpoint-xl-padding-bottom), start: var(--ion-grid-breakpoint-xl-padding-start), ), + xxl: ( + top: var(--ion-grid-breakpoint-xxl-padding-top), + end: var(--ion-grid-breakpoint-xxl-padding-end), + bottom: var(--ion-grid-breakpoint-xxl-padding-bottom), + start: var(--ion-grid-breakpoint-xxl-padding-start), + ), ); /// @prop - Per-breakpoint fixed grid width values @@ -42,4 +48,5 @@ $grid-widths: ( md: var(--ion-grid-breakpoint-md-width), lg: var(--ion-grid-breakpoint-lg-width), xl: var(--ion-grid-breakpoint-xl-width), + xxl: var(--ion-grid-breakpoint-xxl-width), ); diff --git a/core/src/components/grid/test/fixed/grid.e2e.ts b/core/src/components/grid/test/fixed/grid.e2e.ts index b90e7b5972c..a1b9c96ccd2 100644 --- a/core/src/components/grid/test/fixed/grid.e2e.ts +++ b/core/src/components/grid/test/fixed/grid.e2e.ts @@ -19,8 +19,9 @@ const minWidthFor = (bp: (typeof ION_GRID_BREAKPOINTS)[number]): number => { /** * Viewport width that activates each breakpoint. `max(400, minWidth)` lands - * exactly on the threshold for sm/md/lg/xl (firing only that breakpoint's - * rule and nothing above it) while giving xs a renderable non-zero viewport. + * exactly on the threshold for every breakpoint above xs (firing only that + * breakpoint's rule and nothing above it) while giving xs a renderable non-zero + * viewport. */ const VIEWPORT_AT_BREAKPOINT = Object.fromEntries( ION_GRID_BREAKPOINTS.map((bp) => [bp, Math.max(400, minWidthFor(bp))]) diff --git a/core/src/themes/common.globals.scss b/core/src/themes/common.globals.scss index ce5d6a47f29..02ca084f482 100644 --- a/core/src/themes/common.globals.scss +++ b/core/src/themes/common.globals.scss @@ -14,6 +14,7 @@ $screen-breakpoints: ( md: 768px, lg: 992px, xl: 1200px, + xxl: 1400px, ); // Z-Index diff --git a/core/src/themes/ionic/default.tokens.ts b/core/src/themes/ionic/default.tokens.ts index a150554be8e..cc3521d91ad 100644 --- a/core/src/themes/ionic/default.tokens.ts +++ b/core/src/themes/ionic/default.tokens.ts @@ -514,6 +514,15 @@ export const defaultTheme: DefaultTheme = { top: '5px', }, }, + + xxl: { + padding: { + bottom: '5px', + end: '5px', + start: '5px', + top: '5px', + }, + }, }, }, @@ -600,6 +609,17 @@ export const defaultTheme: DefaultTheme = { width: '1140px', }, + + xxl: { + padding: { + bottom: '5px', + end: '5px', + start: '5px', + top: '5px', + }, + + width: '1320px', + }, }, columns: 12, diff --git a/core/src/themes/ios/default.tokens.ts b/core/src/themes/ios/default.tokens.ts index 6a0a32a35dc..94e95411369 100644 --- a/core/src/themes/ios/default.tokens.ts +++ b/core/src/themes/ios/default.tokens.ts @@ -649,6 +649,15 @@ export const defaultTheme: DefaultTheme = { top: '5px', }, }, + + xxl: { + padding: { + bottom: '5px', + end: '5px', + start: '5px', + top: '5px', + }, + }, }, }, @@ -735,6 +744,17 @@ export const defaultTheme: DefaultTheme = { width: '1140px', }, + + xxl: { + padding: { + bottom: '5px', + end: '5px', + start: '5px', + top: '5px', + }, + + width: '1320px', + }, }, columns: 12, diff --git a/core/src/themes/md/default.tokens.ts b/core/src/themes/md/default.tokens.ts index 9e7626e67cb..6baaae6884a 100644 --- a/core/src/themes/md/default.tokens.ts +++ b/core/src/themes/md/default.tokens.ts @@ -652,6 +652,15 @@ export const defaultTheme: DefaultTheme = { top: '5px', }, }, + + xxl: { + padding: { + bottom: '5px', + end: '5px', + start: '5px', + top: '5px', + }, + }, }, }, @@ -738,6 +747,17 @@ export const defaultTheme: DefaultTheme = { width: '1140px', }, + + xxl: { + padding: { + bottom: '5px', + end: '5px', + start: '5px', + top: '5px', + }, + + width: '1320px', + }, }, columns: 12, diff --git a/core/src/themes/mixins.scss b/core/src/themes/mixins.scss index 1a2afeb58e9..a9460a402b9 100644 --- a/core/src/themes/mixins.scss +++ b/core/src/themes/mixins.scss @@ -137,7 +137,7 @@ // // Breakpoints are defined as a map of (name: minimum width), order from small to large: // -// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px) +// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px) // // The map defined in the `$screen-breakpoints` global variable is used as the `$breakpoints` argument by default. @@ -145,7 +145,7 @@ // Minimum breakpoint width. Null for the smallest (first) breakpoint. // -// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) +// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)) // 576px @function breakpoint-min($name, $breakpoints: $screen-breakpoints) { $min: map-get($breakpoints, $name); @@ -156,9 +156,9 @@ // Returns a blank string if smallest breakpoint, otherwise returns the name with a dash infront. // Useful for making responsive utilities. // -// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) +// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)) // "" (Returns a blank string) -// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) +// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)) // "-sm" @function breakpoint-infix($name, $breakpoints: $screen-breakpoints) { @return if(breakpoint-min($name, $breakpoints) == null, "", "-#{$name}"); @@ -182,9 +182,9 @@ // // >> breakpoint-next(sm) // md -// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) +// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)) // md -// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl)) +// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl)) // md @function breakpoint-next($name, $breakpoints: $screen-breakpoints, $breakpoint-names: map-keys($breakpoints)) { $n: index($breakpoint-names, $name); @@ -200,7 +200,7 @@ // Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari. // Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari. // See https://bugs.webkit.org/show_bug.cgi?id=178261 // See https://bugs.webkit.org/show_bug.cgi?id=178261 // -// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) +// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)) // 767.98px @function breakpoint-max($name, $breakpoints: $screen-breakpoints) { $max: map-get($breakpoints, $name); diff --git a/core/src/themes/native/native.globals.scss b/core/src/themes/native/native.globals.scss index f80cc0b4bbe..da620679256 100644 --- a/core/src/themes/native/native.globals.scss +++ b/core/src/themes/native/native.globals.scss @@ -29,6 +29,7 @@ $screen-breakpoints: ( md: 768px, lg: 992px, xl: 1200px, + xxl: 1400px, ); // Input placeholder opacity diff --git a/core/src/utils/media.ts b/core/src/utils/media.ts index 858dbe608fe..727e56c10ae 100644 --- a/core/src/utils/media.ts +++ b/core/src/utils/media.ts @@ -5,6 +5,7 @@ export const SIZE_TO_MEDIA: any = { md: '(min-width: 768px)', lg: '(min-width: 992px)', xl: '(min-width: 1200px)', + xxl: '(min-width: 1400px)', }; // Check if the window matches the media query From 67ddedbb1c85db8f236e60f362dec28974cf15b0 Mon Sep 17 00:00:00 2001 From: Brandy Smith Date: Wed, 14 Oct 2026 10:08:44 -0400 Subject: [PATCH 2/5] feat(config): add screenBreakpoints config option (#31502) Adds a shared breakpoints utility that resolves the screen breakpoints from config, validating each value and falling back to the default per key. The `xs` screen breakpoint defaults to `0` and cannot be changed. --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> --- core/src/components/col/col.tsx | 2 +- .../components/grid/test/fixed/grid.e2e.ts | 29 +- core/src/global/ionic-global.ts | 9 + core/src/global/test/ionic-global.spec.ts | 71 +- core/src/utils/breakpoints.ts | 446 ++++++++++++ core/src/utils/config.ts | 14 + core/src/utils/media.ts | 23 - core/src/utils/test/breakpoints.spec.ts | 634 ++++++++++++++++++ 8 files changed, 1184 insertions(+), 44 deletions(-) create mode 100644 core/src/utils/breakpoints.ts delete mode 100644 core/src/utils/media.ts create mode 100644 core/src/utils/test/breakpoints.spec.ts diff --git a/core/src/components/col/col.tsx b/core/src/components/col/col.tsx index 7a544f213e2..3c86d4956bf 100644 --- a/core/src/components/col/col.tsx +++ b/core/src/components/col/col.tsx @@ -1,7 +1,7 @@ import type { ComponentInterface } from '@stencil/core'; import { Component, Element, Host, Listen, Prop, forceUpdate, h } from '@stencil/core'; +import { matchBreakpoint } from '@utils/breakpoints'; import { printIonWarning } from '@utils/logging'; -import { matchBreakpoint } from '@utils/media'; import type { IonColProperty, IonColStyle } from './col.interface'; import { ION_COL_BREAKPOINTS } from './col.interface'; diff --git a/core/src/components/grid/test/fixed/grid.e2e.ts b/core/src/components/grid/test/fixed/grid.e2e.ts index a1b9c96ccd2..c3eb9a8d3e7 100644 --- a/core/src/components/grid/test/fixed/grid.e2e.ts +++ b/core/src/components/grid/test/fixed/grid.e2e.ts @@ -1,31 +1,24 @@ import { expect } from '@playwright/test'; +import type { ScreenBreakpoint } from '@utils/breakpoints'; +import { DEFAULT_SCREEN_BREAKPOINTS, SCREEN_BREAKPOINT_NAMES } from '@utils/breakpoints'; import { configs, test } from '@utils/test/playwright'; import { defaultTheme as mdTheme } from '../../../../themes/md/default.tokens'; -import { SIZE_TO_MEDIA } from '../../../../utils/media'; -import { ION_GRID_BREAKPOINTS } from '../../grid.interface'; const ionGridBreakpoints = mdTheme.components!.IonGrid!.breakpoint!; -/** - * Parse the min-width (in px) out of each `SIZE_TO_MEDIA` entry. These are - * the same activation thresholds `matchBreakpoint` evaluates at runtime and - * that `$screen-breakpoints` defines on the SCSS side. - */ -const minWidthFor = (bp: (typeof ION_GRID_BREAKPOINTS)[number]): number => { - const match = (SIZE_TO_MEDIA[bp] as string).match(/\(min-width:\s*(\d+)px\)/); - return match ? parseInt(match[1], 10) : 0; -}; - /** * Viewport width that activates each breakpoint. `max(400, minWidth)` lands - * exactly on the threshold for every breakpoint above xs (firing only that - * breakpoint's rule and nothing above it) while giving xs a renderable non-zero - * viewport. + * exactly on the threshold for sm and up (firing only that breakpoint's rule + * and nothing above it) while giving xs a renderable non-zero viewport. + * + * The default screen breakpoints are used rather than the resolved ones, + * since the test page does not override them and `$screen-breakpoints` + * defines the same values on the SCSS side. */ const VIEWPORT_AT_BREAKPOINT = Object.fromEntries( - ION_GRID_BREAKPOINTS.map((bp) => [bp, Math.max(400, minWidthFor(bp))]) -) as Record<(typeof ION_GRID_BREAKPOINTS)[number], number>; + SCREEN_BREAKPOINT_NAMES.map((bp) => [bp, Math.max(400, DEFAULT_SCREEN_BREAKPOINTS[bp])]) +) as Record; /** * This behavior does not vary across modes/directions. @@ -36,7 +29,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { await page.goto('/src/components/grid/test/fixed', config); }); - for (const breakpoint of ION_GRID_BREAKPOINTS) { + for (const breakpoint of SCREEN_BREAKPOINT_NAMES) { test(`fixed grid matches the ${breakpoint} width token`, async ({ page }) => { const viewportWidth = VIEWPORT_AT_BREAKPOINT[breakpoint]; await page.setViewportSize({ width: viewportWidth, height: 800 }); diff --git a/core/src/global/ionic-global.ts b/core/src/global/ionic-global.ts index b9167d2619f..8c276decc21 100644 --- a/core/src/global/ionic-global.ts +++ b/core/src/global/ionic-global.ts @@ -5,6 +5,7 @@ import { applyComponentsTheme, applyGlobalTheme, getCustomTheme } from '@utils/t import type { IonicConfig, Mode, Theme } from '../interface'; import { defaultTheme as baseTheme } from '../themes/base/default.tokens'; import type { DefaultTheme } from '../themes/themes.interfaces'; +import { refreshBreakpointListeners } from '../utils/breakpoints'; import { shouldUseCloseWatcher } from '../utils/hardware-back-button'; import { isPlatform, setupPlatforms } from '../utils/platform'; @@ -120,6 +121,7 @@ export const initialize = (userConfig: IonicConfig = {}) => { }; config.reset(configObj); + if (config.getBoolean('persistConfig')) { saveConfig(win, configObj); } @@ -199,6 +201,13 @@ export const initialize = (userConfig: IonicConfig = {}) => { config.set(configKey, value); }); + /** + * The breakpoint listeners are built from the configured widths, so they + * have to be rebuilt once the configuration is settled. This runs after the + * theme's config is merged, since a theme can set the widths itself. + */ + refreshBreakpointListeners(); + if (config.getBoolean('_testing')) { config.set('animated', false); } diff --git a/core/src/global/test/ionic-global.spec.ts b/core/src/global/test/ionic-global.spec.ts index ba2a8e519ed..7b8b399c7a4 100644 --- a/core/src/global/test/ionic-global.spec.ts +++ b/core/src/global/test/ionic-global.spec.ts @@ -13,8 +13,10 @@ jest.mock('@stencil/core', () => { /** * The implementation needs to be mocked before the implementation is imported. */ -// eslint-disable-next-line import/first -import { getIonTheme, getIonMode } from '../ionic-global'; +import { onBreakpointChange, resetBreakpointListeners, resetScreenBreakpoints } from '@utils/breakpoints'; + +import { config } from '../config'; +import { getIonTheme, getIonMode, initialize } from '../ionic-global'; describe('Ionic Global', () => { describe('getIonMode', () => { @@ -107,4 +109,69 @@ describe('Ionic Global', () => { expect(getIonTheme()).toBe('md'); }); }); + + describe('initialize', () => { + const originalMatchMedia = (window as any).matchMedia; + + /** Every media query the breakpoint listeners have asked for, in order. */ + let requested: string[]; + + beforeEach(() => { + requested = []; + + (window as any).matchMedia = (media: string) => { + requested.push(media); + + return { media, matches: false, addEventListener: () => {}, removeEventListener: () => {} }; + }; + + config.set('screenBreakpoints', undefined as any); + resetScreenBreakpoints(); + resetBreakpointListeners(); + }); + + afterEach(() => { + resetBreakpointListeners(); + resetScreenBreakpoints(); + config.set('screenBreakpoints', undefined as any); + (window as any).matchMedia = originalMatchMedia; + }); + + /** + * A theme can set the screen breakpoints in its own config block, which is + * merged in after the application config, so the listeners can only be + * rebuilt once both have been applied. + */ + it('should rebuild the breakpoint listeners from a theme that sets the widths', () => { + // Subscribing first is what creates the listeners there are to rebuild. + const unsubscribe = onBreakpointChange(() => {}); + requested.length = 0; + + initialize({ customTheme: { config: { screenBreakpoints: { lg: 700 } } } } as any); + + expect(requested).toContain('(min-width: 700px)'); + expect(requested).not.toContain('(min-width: 992px)'); + + unsubscribe(); + }); + + /** + * The theme's config is merged over the application's, so widths set in + * either are kept and the listeners are built from the result. + */ + it('should keep widths set by the application alongside those from the theme', () => { + const unsubscribe = onBreakpointChange(() => {}); + requested.length = 0; + + initialize({ + screenBreakpoints: { sm: 300 }, + customTheme: { config: { screenBreakpoints: { lg: 700 } } }, + } as any); + + expect(requested).toContain('(min-width: 300px)'); + expect(requested).toContain('(min-width: 700px)'); + + unsubscribe(); + }); + }); }); diff --git a/core/src/utils/breakpoints.ts b/core/src/utils/breakpoints.ts new file mode 100644 index 00000000000..284bdd4701c --- /dev/null +++ b/core/src/utils/breakpoints.ts @@ -0,0 +1,446 @@ +import { config } from '@global/config'; + +import { printIonWarning } from './logging'; + +/** + * The supported screen breakpoint names, ordered from smallest to largest. + * + * The ordering is significant: responsive values are resolved by walking this + * list and retaining the value from the largest breakpoint that matches. + * + * The names are part of the public API. Applications may customize the width + * at which a breakpoint activates through `screenBreakpoints`, but they cannot + * rename these breakpoints or add new ones. + */ +export const SCREEN_BREAKPOINT_NAMES = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as const; +export type ScreenBreakpoint = (typeof SCREEN_BREAKPOINT_NAMES)[number]; + +/** + * The width, in pixels, at which each screen breakpoint activates. Values are + * always unitless numbers so they can be used both in a `min-width` media + * query and in a direct comparison against a measured element width. + */ +export type ScreenBreakpoints = Record; + +/** + * The screen breakpoints used when an application does not override them. + * These match the `$screen-breakpoints` Sass map used by the stylesheets. + */ +export const DEFAULT_SCREEN_BREAKPOINTS: Readonly = Object.freeze({ + xs: 0, + sm: 576, + md: 768, + lg: 992, + xl: 1200, + xxl: 1400, +}); + +/** + * A responsive property value keyed by screen breakpoint. + * + * This must remain an interface with explicitly declared keys for Stencil's + * generated component types: + * + * - Stencil inlines interfaces into generated property signatures, but does + * not preserve the equivalent shape reliably when expressed as a type alias. + * - Mapped keys are omitted from generated property signatures, so the + * breakpoint keys must be declared explicitly. + * - Components must reference this through a non-generic alias rather than a + * generic type that depends on a default type argument. + * + * Keeping the keys explicit also keeps this type synchronized with + * `SCREEN_BREAKPOINT_NAMES`: indexing it with those names causes a compile-time + * error if a breakpoint is added or renamed without updating this interface. + */ +export interface BreakpointMap { + xs?: T; + sm?: T; + md?: T; + lg?: T; + xl?: T; + xxl?: T; +} + +/** + * Check whether a value is a plain object. + * + * Arrays are excluded: they are objects, but never a valid breakpoint map or + * `screenBreakpoints` config. + * + * @param value The value to check. + * @return Whether the value is a plain object. + */ +const isPlainObject = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null && !Array.isArray(value); +}; + +/** + * Check whether a value is one of the screen breakpoint names. + * + * @param value The value to check. + * @return Whether the value is a screen breakpoint name. + */ +const isScreenBreakpoint = (value: unknown): value is ScreenBreakpoint => { + return SCREEN_BREAKPOINT_NAMES.includes(value as ScreenBreakpoint); +}; + +/** + * Check whether a value is a breakpoint map. + * + * Breakpoint maps are JavaScript properties rather than HTML attributes because + * HTML attributes are always strings. + * + * @param value The value to check. + * @return Whether the value is a breakpoint map. + */ +export const isBreakpointMap = (value: unknown): value is BreakpointMap => { + return isPlainObject(value); +}; + +/** + * Find the largest breakpoint satisfying the predicate. + * + * @param matches Whether the breakpoint applies. + * @return The largest matching breakpoint, or `undefined` if none match. + */ +const lastMatchingBreakpoint = (matches: (breakpoint: ScreenBreakpoint) => boolean): ScreenBreakpoint | undefined => { + let matched: ScreenBreakpoint | undefined; + + /** + * The names are ordered smallest to largest, so keeping the last match + * returns the largest breakpoint that applies. + */ + for (const breakpoint of SCREEN_BREAKPOINT_NAMES) { + if (matches(breakpoint)) { + matched = breakpoint; + } + } + + return matched; +}; + +/** + * Resolve a responsive value from a breakpoint map. When several breakpoints + * match, the value from the largest one wins. + * + * @param breakpointMap The value for each breakpoint. Breakpoints left out are skipped. + * @param matches Whether the breakpoint applies. + * @return The resolved value, or `undefined` if no matching breakpoint has one. + */ +export const resolveBreakpointMap = ( + breakpointMap: BreakpointMap, + matches: (breakpoint: ScreenBreakpoint) => boolean +): T | undefined => { + const breakpoint = lastMatchingBreakpoint((name) => breakpointMap[name] !== undefined && matches(name)); + + return breakpoint === undefined ? undefined : breakpointMap[breakpoint]; +}; + +/** + * Validate an application's `screenBreakpoints` config and merge it over the + * defaults. Each breakpoint is validated on its own, so a single bad value + * falls back to its default instead of discarding the whole override. + * + * @param configValue The raw `screenBreakpoints` value from the config. + * @return The complete set of screen breakpoints to use. + */ +const resolveScreenBreakpoints = (configValue: unknown): ScreenBreakpoints => { + if (configValue === undefined) { + return { ...DEFAULT_SCREEN_BREAKPOINTS }; + } + + if (!isPlainObject(configValue)) { + printIonWarning( + `Invalid "screenBreakpoints" config (${JSON.stringify( + configValue + )}). Expected an object of breakpoint names to unitless pixel values (e.g. { md: 768 }). Falling back to the default screen breakpoints.` + ); + return { ...DEFAULT_SCREEN_BREAKPOINTS }; + } + + const breakpoints = { ...DEFAULT_SCREEN_BREAKPOINTS }; + const unknownNames: string[] = []; + + for (const [name, value] of Object.entries(configValue)) { + if (!isScreenBreakpoint(name)) { + unknownNames.push(name); + continue; + } + + /** + * `xs` is the baseline every other breakpoint is measured from, so it + * always starts at 0. Raising it would leave the widths below it with no + * breakpoint at all. Restating the default changes nothing, so it falls + * through and is assigned the value it already had. + */ + if (name === 'xs' && value !== DEFAULT_SCREEN_BREAKPOINTS.xs) { + printIonWarning( + `The "xs" screen breakpoint cannot be changed. It is the base breakpoint and always activates at ${DEFAULT_SCREEN_BREAKPOINTS.xs}px. Move the breakpoint above it instead.` + ); + continue; + } + + /** + * Only unitless numbers are accepted. The value is used to build a + * `min-width` media query and is also compared directly against measured + * element widths, so a string such as `'768px'` cannot be used as-is. + */ + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + printIonWarning( + `Invalid value for the "${name}" screen breakpoint (${JSON.stringify( + value + )}). Expected a non-negative number of pixels with no unit (e.g. 768, not "768px"). Falling back to the default value of ${ + DEFAULT_SCREEN_BREAKPOINTS[name] + }.` + ); + continue; + } + + breakpoints[name] = value; + } + + if (unknownNames.length > 0) { + printIonWarning( + `Unknown screen breakpoint name(s) in the "screenBreakpoints" config: ${unknownNames + .map((name) => `"${name}"`) + .join( + ', ' + )}. Screen breakpoints cannot be renamed or added. The supported names are: ${SCREEN_BREAKPOINT_NAMES.join( + ', ' + )}.` + ); + } + + /** + * Responsive resolution assumes breakpoint values increase from `xs` through + * `xxl`, because `resolveBreakpointMap` keeps the last matching breakpoint. + * + * Keep the configured values even when they are out of order—the application + * may have supplied them intentionally—but warn because responsive resolution + * may not behave as expected. + */ + const isAscending = SCREEN_BREAKPOINT_NAMES.every( + (name, index) => index === 0 || breakpoints[name] >= breakpoints[SCREEN_BREAKPOINT_NAMES[index - 1]] + ); + + if (!isAscending) { + printIonWarning( + `The "screenBreakpoints" config is not in ascending order (${JSON.stringify( + breakpoints + )}). Screen breakpoints must increase from "xs" to "xxl" for responsive values to resolve correctly.` + ); + } + + return breakpoints; +}; + +/** + * The config value from which the cached breakpoints were resolved. + * + * Configuration is read lazily because components may render before + * `initialize()` runs. The resolved breakpoints are cached so validation + * warnings are emitted once for each config object rather than on every render. + */ +let lastConfigValue: unknown; +let cachedBreakpoints: Readonly | undefined; + +/** + * Get the screen breakpoints for the application, merging any + * `screenBreakpoints` config over the defaults. + * + * Config is expected to be set once at startup. The resolved result is cached + * per config object, so replacing the config object picks up the new values + * but mutating the existing one in place does not. + * + * @return The width, in pixels, that each screen breakpoint activates at. + */ +export const getScreenBreakpoints = (): Readonly => { + const configValue = config.get('screenBreakpoints'); + + if (cachedBreakpoints !== undefined && configValue === lastConfigValue) { + return cachedBreakpoints; + } + + lastConfigValue = configValue; + /** + * The same object is handed to every caller, so freezing it keeps one of + * them from moving a breakpoint for the whole page. `Readonly` alone would + * only stop TypeScript callers. + */ + cachedBreakpoints = Object.freeze(resolveScreenBreakpoints(configValue)); + + return cachedBreakpoints; +}; + +/** + * Get the width, in pixels, that a screen breakpoint activates at. + * + * @param breakpoint The name of the screen breakpoint. + * @return The activation width, or `undefined` if the name is not a screen breakpoint. + */ +export const getScreenBreakpointValue = (breakpoint: unknown): number | undefined => { + return isScreenBreakpoint(breakpoint) ? getScreenBreakpoints()[breakpoint] : undefined; +}; + +/** + * Get the `min-width` media query that a screen breakpoint activates at. + * + * @param breakpoint The name of the screen breakpoint. + * @return The media query, e.g. `(min-width: 768px)`, or `undefined` if the + * name is not a screen breakpoint. + */ +export const getScreenBreakpointMediaQuery = (breakpoint: unknown): string | undefined => { + const value = getScreenBreakpointValue(breakpoint); + + return value === undefined ? undefined : `(min-width: ${value}px)`; +}; + +/** + * Reset the cached screen breakpoints. + * + * @internal Used by tests that change the config between assertions. + */ +export const resetScreenBreakpoints = () => { + lastConfigValue = undefined; + cachedBreakpoints = undefined; +}; + +/** + * Check whether the window matches a screen breakpoint's `min-width` media + * query, e.g. `matchBreakpoint('sm')` is `true` once the screen is at least as + * wide as the `sm` breakpoint. + * + * @param breakpoint The name of the screen breakpoint. An empty or `undefined` + * breakpoint always matches, so an unqualified responsive value applies at + * every screen size. + * @return Whether the breakpoint currently applies. + */ +export const matchBreakpoint = (breakpoint: string | undefined) => { + if (breakpoint === undefined || breakpoint === '') { + return true; + } + + if ((window as any).matchMedia) { + const mediaQuery = getScreenBreakpointMediaQuery(breakpoint); + + // Not one of the screen breakpoints, so it never matches + if (mediaQuery === undefined) { + return false; + } + + return window.matchMedia(mediaQuery).matches; + } + + return false; +}; + +/** + * Get the largest screen breakpoint that currently matches. + * + * @return The active breakpoint, or `undefined` when none match. `xs` always + * activates at `0px`, so this only happens before hydration, where components + * treat it as "no breakpoint reported". + */ +export const getActiveBreakpoint = (): ScreenBreakpoint | undefined => lastMatchingBreakpoint(matchBreakpoint); + +/** + * Subscribers notified when the active screen breakpoint changes, and the + * `MediaQueryList` per breakpoint that drives them. + * + * Both are created on first subscribe rather than at module scope: a page can + * hold hundreds of columns, so the listeners are shared, and importing this + * module for the pure resolution helpers allocates nothing. + */ +let breakpointSubscribers: Set<() => void> | undefined; +let breakpointQueries: MediaQueryList[] | undefined; + +/** + * Notify every subscriber that the active breakpoint may have changed. The set + * is absent until the first subscriber registers. + */ +const notifyBreakpointSubscribers = () => { + breakpointSubscribers?.forEach((callback) => callback()); +}; + +/** + * Listen on the breakpoint media queries built from the configured widths. + */ +const createBreakpointQueries = () => { + breakpointQueries = SCREEN_BREAKPOINT_NAMES.map((breakpoint) => + window.matchMedia(getScreenBreakpointMediaQuery(breakpoint)!) + ); + + breakpointQueries.forEach((query) => query.addEventListener('change', notifyBreakpointSubscribers)); +}; + +/** + * Stop listening on the current breakpoint media queries. + */ +const destroyBreakpointQueries = () => { + breakpointQueries?.forEach((query) => query.removeEventListener('change', notifyBreakpointSubscribers)); + breakpointQueries = undefined; +}; + +/** + * Subscribe to changes in the active screen breakpoint. + * + * Listening on the breakpoint media queries rather than on `resize` means the + * callback fires only when a threshold is actually crossed, and at the same + * moment the CSS media queries change rather than after a debounce. + * + * The queries are built from the configured widths on first subscribe, and + * rebuilt by `refreshBreakpointListeners` if the config is ever reset again. + * + * @param callback Called when the active breakpoint changes. + * @return A function that removes the subscription. + */ +export const onBreakpointChange = (callback: () => void): (() => void) => { + if (typeof window === 'undefined' || !(window as any).matchMedia) { + return () => {}; + } + + if (breakpointQueries === undefined) { + createBreakpointQueries(); + } + + breakpointSubscribers ??= new Set(); + breakpointSubscribers.add(callback); + + return () => { + breakpointSubscribers?.delete(callback); + }; +}; + +/** + * Rebuild the breakpoint media queries from the configured widths, keeping the + * current subscribers. + * + * The queries are built on first subscribe, so they would otherwise keep the + * widths that were configured then. Subscribers are notified because the + * breakpoint they are on may have changed without the screen moving. + * + * @internal Called when the config is reset, since that is the only point the + * widths can change. + */ +export const refreshBreakpointListeners = () => { + if (breakpointQueries === undefined) { + return; + } + + destroyBreakpointQueries(); + createBreakpointQueries(); + + notifyBreakpointSubscribers(); +}; + +/** + * Tear down the shared breakpoint listeners so the next subscribe rebuilds + * them from the current config. + * + * @internal Used by tests that change the configured breakpoints, since the + * media queries are built from them on first subscribe. + */ +export const resetBreakpointListeners = () => { + destroyBreakpointQueries(); + breakpointSubscribers = undefined; +}; diff --git a/core/src/utils/config.ts b/core/src/utils/config.ts index b26d1009385..ae3f43cd88f 100644 --- a/core/src/utils/config.ts +++ b/core/src/utils/config.ts @@ -2,6 +2,7 @@ import type { SpinnerTypes } from '../components/spinner/spinner-configs'; import type { TabButtonLayout } from '../components/tab-bar/tab-bar-interface'; import type { AnimationBuilder, Mode, Theme } from '../interface'; +import type { ScreenBreakpoints } from './breakpoints'; import type { LogLevel } from './logging'; import type { PlatformConfig } from './platform'; @@ -45,6 +46,19 @@ export interface IonicConfig { */ theme?: Theme; + /** + * Overrides the widths at which the global screen breakpoints activate, + * used by responsive components such as `ion-col` and `ion-gallery`. + * + * Values must be unitless pixel widths (`768`, not `'768px'`) and must + * increase from `sm` through `xxl`. Only the breakpoints being customized + * need to be provided. Unspecified breakpoints retain their default values. + * + * `xs` is the base breakpoint and always activates at `0px`, so it cannot be + * changed. Move the breakpoint above it instead. + */ + screenBreakpoints?: Partial>; + /** * Wherever ionic will respond to hardware go back buttons in an Android device. * Defaults to `true` when ionic runs in a mobile device. diff --git a/core/src/utils/media.ts b/core/src/utils/media.ts deleted file mode 100644 index 727e56c10ae..00000000000 --- a/core/src/utils/media.ts +++ /dev/null @@ -1,23 +0,0 @@ -// TODO(FW-7285): Replace with global breakpoints -export const SIZE_TO_MEDIA: any = { - xs: '(min-width: 0px)', - sm: '(min-width: 576px)', - md: '(min-width: 768px)', - lg: '(min-width: 992px)', - xl: '(min-width: 1200px)', - xxl: '(min-width: 1400px)', -}; - -// Check if the window matches the media query -// at the breakpoint passed -// e.g. matchBreakpoint('sm') => true if screen width exceeds 576px -export const matchBreakpoint = (breakpoint: string | undefined) => { - if (breakpoint === undefined || breakpoint === '') { - return true; - } - if ((window as any).matchMedia) { - const mediaQuery = SIZE_TO_MEDIA[breakpoint]; - return window.matchMedia(mediaQuery).matches; - } - return false; -}; diff --git a/core/src/utils/test/breakpoints.spec.ts b/core/src/utils/test/breakpoints.spec.ts new file mode 100644 index 00000000000..ccf1894c9b2 --- /dev/null +++ b/core/src/utils/test/breakpoints.spec.ts @@ -0,0 +1,634 @@ +import { config } from '@global/config'; + +import { + DEFAULT_SCREEN_BREAKPOINTS, + getActiveBreakpoint, + getScreenBreakpointMediaQuery, + getScreenBreakpointValue, + getScreenBreakpoints, + isBreakpointMap, + matchBreakpoint, + onBreakpointChange, + refreshBreakpointListeners, + resetBreakpointListeners, + resetScreenBreakpoints, + resolveBreakpointMap, + SCREEN_BREAKPOINT_NAMES, + ScreenBreakpoints, +} from '../breakpoints'; + +/** + * The default widths every assertion below is written against. Spelled out + * rather than derived, so changing a default has to be a deliberate edit here + * too. + * + * Viewport widths in the window tests are picked to sit inside a single band, + * so 800 resolves to `md` because 768 <= 800 < 992. + */ +const EXPECTED_DEFAULT_SCREEN_BREAKPOINTS = { + xs: 0, + sm: 576, + md: 768, + lg: 992, + xl: 1200, + xxl: 1400, +}; + +describe('screen breakpoints', () => { + let consoleWarnSpy: jest.SpyInstance; + + const setScreenBreakpoints = (value: unknown) => { + config.set('screenBreakpoints', value as any); + resetScreenBreakpoints(); + }; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + setScreenBreakpoints(undefined); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + setScreenBreakpoints(undefined); + }); + + describe('getScreenBreakpoints()', () => { + /** + * Every caller is handed the same cached object, so a write would move the + * breakpoint for the whole page. `Readonly` only stops TypeScript callers, + * which is why it is frozen. + */ + it('should return a frozen object that callers cannot change', () => { + const breakpoints = getScreenBreakpoints(); + + expect(Object.isFrozen(breakpoints)).toBe(true); + expect(() => ((breakpoints as ScreenBreakpoints).md = 600)).toThrow(TypeError); + expect(getScreenBreakpoints().md).toBe(EXPECTED_DEFAULT_SCREEN_BREAKPOINTS.md); + }); + + it('should freeze the defaults so the fallback cannot be corrupted', () => { + expect(Object.isFrozen(DEFAULT_SCREEN_BREAKPOINTS)).toBe(true); + }); + + it('should return the defaults when no config is set', () => { + expect(getScreenBreakpoints()).toEqual(EXPECTED_DEFAULT_SCREEN_BREAKPOINTS); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should merge a partial override over the defaults', () => { + setScreenBreakpoints({ md: 720, lg: 1024 }); + + expect(getScreenBreakpoints()).toEqual({ + ...DEFAULT_SCREEN_BREAKPOINTS, + md: 720, + lg: 1024, + }); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should accept an override of every configurable breakpoint', () => { + const breakpoints = { sm: 600, md: 900, lg: 1200, xl: 1536, xxl: 1920 }; + setScreenBreakpoints(breakpoints); + + expect(getScreenBreakpoints()).toEqual({ ...DEFAULT_SCREEN_BREAKPOINTS, ...breakpoints }); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should leave the defaults untouched given a partial override', () => { + setScreenBreakpoints({ md: 720 }); + getScreenBreakpoints(); + + expect(DEFAULT_SCREEN_BREAKPOINTS).toEqual(EXPECTED_DEFAULT_SCREEN_BREAKPOINTS); + }); + + it('should leave the defaults untouched given no config at all', () => { + setScreenBreakpoints(undefined); + getScreenBreakpoints(); + + expect(DEFAULT_SCREEN_BREAKPOINTS).toEqual(EXPECTED_DEFAULT_SCREEN_BREAKPOINTS); + }); + + it('should leave the defaults untouched given a config that is not an object', () => { + setScreenBreakpoints('nonsense'); + getScreenBreakpoints(); + + expect(DEFAULT_SCREEN_BREAKPOINTS).toEqual(EXPECTED_DEFAULT_SCREEN_BREAKPOINTS); + }); + + it('should cache the resolved breakpoints so warnings are only printed once', () => { + setScreenBreakpoints({ md: '768px' }); + + getScreenBreakpoints(); + getScreenBreakpoints(); + getScreenBreakpoints(); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('should re-resolve when the config object is replaced', () => { + config.set('screenBreakpoints', { md: 700 } as any); + expect(getScreenBreakpoints().md).toBe(700); + + config.set('screenBreakpoints', { md: 800 } as any); + expect(getScreenBreakpoints().md).toBe(800); + }); + + it('should not re-resolve when the existing config object is mutated in place', () => { + const screenBreakpoints = { md: 700 }; + config.set('screenBreakpoints', screenBreakpoints as any); + expect(getScreenBreakpoints().md).toBe(700); + + screenBreakpoints.md = 800; + + expect(getScreenBreakpoints().md).toBe(700); + }); + + describe('with an invalid value for a breakpoint', () => { + it.each([ + ['a CSS length', '768px'], + ['a numeric string', '768'], + ['a media query', '(min-width: 768px)'], + ['a negative number', -1], + ['NaN', NaN], + ['Infinity', Infinity], + ['null', null], + ['a boolean', true], + ])('should fall back to the default when the value is %s', (_description, value) => { + setScreenBreakpoints({ md: value }); + + expect(getScreenBreakpoints().md).toBe(DEFAULT_SCREEN_BREAKPOINTS.md); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Invalid value for the "md" screen breakpoint') + ); + }); + + it('should keep the other valid overrides', () => { + setScreenBreakpoints({ md: '768px', lg: 1024 }); + + expect(getScreenBreakpoints()).toEqual({ + ...DEFAULT_SCREEN_BREAKPOINTS, + lg: 1024, + }); + }); + }); + + describe('with an unknown breakpoint name', () => { + it('should ignore the name and warns that breakpoints cannot be renamed', () => { + setScreenBreakpoints({ small: 500, xxxl: 2000 }); + + expect(getScreenBreakpoints()).toEqual(DEFAULT_SCREEN_BREAKPOINTS); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Unknown screen breakpoint name(s) in the "screenBreakpoints" config: "small", "xxxl"' + ) + ); + }); + }); + + /** + * The `xs` styles apply unconditionally rather than behind a `min-width` + * query, so a non-zero value would be ignored by CSS while still narrowing + * the range that responsive JavaScript values resolve in. The config type + * excludes `xs`, so this only reaches plain JavaScript applications. + */ + describe('with a value for xs', () => { + it('should keep xs at zero and warn that it cannot be changed', () => { + setScreenBreakpoints({ xs: 400 }); + + expect(getScreenBreakpoints().xs).toBe(0); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('The "xs" screen breakpoint cannot be changed') + ); + }); + + it('should keep the other overrides', () => { + setScreenBreakpoints({ xs: 400, md: 700 }); + + expect(getScreenBreakpoints()).toEqual({ ...DEFAULT_SCREEN_BREAKPOINTS, md: 700 }); + }); + + it('should not warn when xs is set to its default of zero', () => { + setScreenBreakpoints({ xs: 0 }); + + expect(getScreenBreakpoints()).toEqual(DEFAULT_SCREEN_BREAKPOINTS); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('with a non-object config', () => { + it.each([ + ['a string', '768px'], + ['a number', 768], + ['an array', [0, 576, 768]], + ['null', null], + ])('should fall back to the defaults when the config is %s', (_description, value) => { + setScreenBreakpoints(value); + + expect(getScreenBreakpoints()).toEqual(DEFAULT_SCREEN_BREAKPOINTS); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid "screenBreakpoints" config')); + }); + }); + + describe('with breakpoints that are not in ascending order', () => { + it('should use the provided values but warns', () => { + setScreenBreakpoints({ md: 1000 }); + + expect(getScreenBreakpoints().md).toBe(1000); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('The "screenBreakpoints" config is not in ascending order') + ); + }); + + it('should not warn when adjacent breakpoints are equal', () => { + setScreenBreakpoints({ md: 576 }); + + expect(getScreenBreakpoints().md).toBe(576); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('getScreenBreakpointValue()', () => { + it('should return the width the breakpoint activates at', () => { + expect(getScreenBreakpointValue('lg')).toBe(992); + }); + + it('should reflect an override', () => { + setScreenBreakpoints({ lg: 1024 }); + + expect(getScreenBreakpointValue('lg')).toBe(1024); + }); + + it.each([['xxxl'], [''], [undefined], [null], [0]])('should return undefined for %p', (value) => { + expect(getScreenBreakpointValue(value)).toBeUndefined(); + }); + }); + + describe('getScreenBreakpointMediaQuery()', () => { + it('should build a min-width query from the breakpoint width', () => { + expect(getScreenBreakpointMediaQuery('md')).toBe('(min-width: 768px)'); + }); + + it('should reflect an override', () => { + setScreenBreakpoints({ md: 720 }); + + expect(getScreenBreakpointMediaQuery('md')).toBe('(min-width: 720px)'); + }); + + it('should return undefined when the name is not a screen breakpoint', () => { + expect(getScreenBreakpointMediaQuery('xxxl')).toBeUndefined(); + }); + }); + + describe('isBreakpointMap()', () => { + it('should return true for an object', () => { + expect(isBreakpointMap({ xs: 12, md: 6 })).toBe(true); + }); + + it('should return true for an empty object', () => { + expect(isBreakpointMap({})).toBe(true); + }); + + it.each([ + ['a plain string', '12'], + ['the "auto" keyword', 'auto'], + ['an empty string', ''], + ['a JSON object string', '{"xs": 12}'], + ['a number', 12], + ['undefined', undefined], + ['null', null], + ['an array', [1, 2]], + ])('should return false for %s', (_description, value) => { + expect(isBreakpointMap(value)).toBe(false); + }); + }); + + describe('resolveBreakpointMap()', () => { + it('should return the value for the largest matching breakpoint', () => { + const resolved = resolveBreakpointMap({ xs: 12, md: 6, xl: 3 }, (bp) => ['xs', 'sm', 'md'].includes(bp)); + + expect(resolved).toBe(6); + }); + + it('should skip breakpoints with no value set', () => { + const resolved = resolveBreakpointMap({ xs: 12, xxl: 1 }, () => true); + + expect(resolved).toBe(1); + }); + + it('should return undefined when no breakpoint matches', () => { + expect(resolveBreakpointMap({ lg: 4 }, () => false)).toBeUndefined(); + }); + + it('should return undefined for an empty map', () => { + expect(resolveBreakpointMap({}, () => true)).toBeUndefined(); + }); + + it('should resolve regardless of the order the keys were written in', () => { + const resolved = resolveBreakpointMap({ xl: 3, xs: 12, md: 6 }, () => true); + + expect(resolved).toBe(3); + }); + }); +}); + +/** + * A minimal `MediaQueryList` stand-in. Only `(min-width: Npx)` is understood, + * which is the only form the breakpoints build. + */ +interface MockQuery { + media: string; + minWidth: number; + matches: boolean; + listeners: Set<() => void>; + addEventListener: (type: string, listener: () => void) => void; + removeEventListener: (type: string, listener: () => void) => void; +} + +describe('screen breakpoints against the window', () => { + const originalMatchMedia = (window as any).matchMedia; + + let queries: MockQuery[]; + let width: number; + + /** Set the viewport width, firing `change` on the queries whose result flipped. */ + const setWidth = (next: number) => { + const before = queries.map((query) => query.matches); + width = next; + + queries.forEach((query, i) => { + query.matches = width >= query.minWidth; + + if (query.matches !== before[i]) { + query.listeners.forEach((listener) => listener()); + } + }); + }; + + beforeEach(() => { + queries = []; + width = 1024; + + (window as any).matchMedia = (media: string) => { + const minWidth = Number(/\(min-width:\s*(\d+)px\)/.exec(media)?.[1] ?? NaN); + const query: MockQuery = { + media, + minWidth, + matches: Number.isFinite(minWidth) && width >= minWidth, + listeners: new Set(), + addEventListener: (_type, listener) => query.listeners.add(listener), + removeEventListener: (_type, listener) => query.listeners.delete(listener), + }; + + queries.push(query); + return query; + }; + + config.set('screenBreakpoints', undefined as any); + resetScreenBreakpoints(); + resetBreakpointListeners(); + }); + + afterEach(() => { + resetBreakpointListeners(); + resetScreenBreakpoints(); + config.set('screenBreakpoints', undefined as any); + (window as any).matchMedia = originalMatchMedia; + }); + + describe('matchBreakpoint()', () => { + it.each([[undefined], ['']])('should always match for %p, so unqualified values apply everywhere', (value) => { + expect(matchBreakpoint(value)).toBe(true); + }); + + it('should match a breakpoint at or below the current width', () => { + setWidth(800); + + expect(matchBreakpoint('xs')).toBe(true); + expect(matchBreakpoint('md')).toBe(true); + }); + + it('should not match a breakpoint above the current width', () => { + setWidth(800); + + expect(matchBreakpoint('lg')).toBe(false); + expect(matchBreakpoint('xxl')).toBe(false); + }); + + it('should not match a name that is not a screen breakpoint', () => { + expect(matchBreakpoint('xxxl')).toBe(false); + }); + + /** + * `xs` starts at 0 and cannot be configured away from it, so it matches at + * any width. Responsive values set on `xs` always apply. + */ + it('should always match xs, even when the config tries to raise it', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + config.set('screenBreakpoints', { xs: 2000 } as any); + resetScreenBreakpoints(); + setWidth(100); + + expect(matchBreakpoint('xs')).toBe(true); + + consoleWarnSpy.mockRestore(); + }); + + it('should reflect the configured widths rather than the defaults', () => { + config.set('screenBreakpoints', { md: 400 } as any); + resetScreenBreakpoints(); + setWidth(500); + + // 500 is below the default md of 768, but above the configured 400 + expect(matchBreakpoint('md')).toBe(true); + }); + + it('should not match when matchMedia is unavailable', () => { + (window as any).matchMedia = undefined; + + expect(matchBreakpoint('md')).toBe(false); + }); + }); + + describe('getActiveBreakpoint()', () => { + it.each([ + [500, 'xs'], + [576, 'sm'], + [800, 'md'], + [1000, 'lg'], + [1300, 'xl'], + [1500, 'xxl'], + ])('should return %p -> %p', (viewport, expected) => { + setWidth(viewport); + + expect(getActiveBreakpoint()).toBe(expected); + }); + + /** + * `xs` activates at 0px and cannot be configured away from it, so some + * breakpoint always matches once the window can be queried. + */ + it('should fall back to xs at any width', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + config.set('screenBreakpoints', { xs: 2000 } as any); + resetScreenBreakpoints(); + setWidth(100); + + expect(getActiveBreakpoint()).toBe('xs'); + + consoleWarnSpy.mockRestore(); + }); + + it('should return undefined when the window cannot be queried', () => { + (window as any).matchMedia = undefined; + + expect(getActiveBreakpoint()).toBeUndefined(); + }); + + it('should reflect the configured widths', () => { + config.set('screenBreakpoints', { xxl: 900 } as any); + resetScreenBreakpoints(); + setWidth(1000); + + expect(getActiveBreakpoint()).toBe('xxl'); + }); + }); + + describe('onBreakpointChange()', () => { + it('should notify the subscriber when a threshold is crossed', () => { + const callback = jest.fn(); + onBreakpointChange(callback); + + setWidth(500); + + expect(callback).toHaveBeenCalled(); + }); + + it('should not notify when the width changes without crossing a threshold', () => { + const callback = jest.fn(); + onBreakpointChange(callback); + + // 1024 and 1100 are both between lg (992) and xl (1200) + setWidth(1100); + + expect(callback).not.toHaveBeenCalled(); + }); + + /** + * The queries are built from the configured widths when the first + * subscriber arrives, which is the supported order: the config is in place + * before any component connects. 700 and 730 sit between the default sm + * (576) and md (768), so this only fires if the configured md is used. + */ + it('should notify at the configured width rather than the default', () => { + config.set('screenBreakpoints', { md: 720 } as any); + + const callback = jest.fn(); + onBreakpointChange(callback); + + setWidth(700); + callback.mockClear(); + + setWidth(730); + + expect(callback).toHaveBeenCalled(); + }); + + /** + * The queries are built on first subscribe, so a later config reset has to + * rebuild them. 700 and 730 both sit between the default sm (576) and md + * (768), so this only fires if the rebuilt queries use the configured md. + */ + it('should notify at the new width after the config is reset', () => { + const callback = jest.fn(); + onBreakpointChange(callback); + + setWidth(700); + + config.set('screenBreakpoints', { md: 720 } as any); + refreshBreakpointListeners(); + callback.mockClear(); + + setWidth(730); + + expect(callback).toHaveBeenCalled(); + }); + + it('should notify once when the listeners are rebuilt, so subscribers re-resolve', () => { + const callback = jest.fn(); + onBreakpointChange(callback); + + config.set('screenBreakpoints', { md: 720 } as any); + refreshBreakpointListeners(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should do nothing when nothing has subscribed', () => { + expect(() => refreshBreakpointListeners()).not.toThrow(); + }); + + it('should notify every subscriber', () => { + const first = jest.fn(); + const second = jest.fn(); + onBreakpointChange(first); + onBreakpointChange(second); + + setWidth(500); + + expect(first).toHaveBeenCalled(); + expect(second).toHaveBeenCalled(); + }); + + it('should stop notifying after the returned function is called', () => { + const callback = jest.fn(); + const unsubscribe = onBreakpointChange(callback); + + unsubscribe(); + setWidth(500); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should keep notifying the remaining subscribers after one unsubscribes', () => { + const staying = jest.fn(); + const leaving = jest.fn(); + onBreakpointChange(staying); + onBreakpointChange(leaving)(); + + setWidth(500); + + expect(staying).toHaveBeenCalled(); + expect(leaving).not.toHaveBeenCalled(); + }); + + it('should create one media query per breakpoint however many subscribers there are', () => { + onBreakpointChange(jest.fn()); + onBreakpointChange(jest.fn()); + onBreakpointChange(jest.fn()); + + expect(queries).toHaveLength(SCREEN_BREAKPOINT_NAMES.length); + }); + + it('should survive unsubscribing after the listeners have been torn down', () => { + const unsubscribe = onBreakpointChange(jest.fn()); + + resetBreakpointListeners(); + + // A component disconnecting after teardown still calls its unsubscribe + expect(() => unsubscribe()).not.toThrow(); + }); + + it('should return a callable no-op when matchMedia is unavailable', () => { + (window as any).matchMedia = undefined; + const callback = jest.fn(); + + const unsubscribe = onBreakpointChange(callback); + + expect(() => unsubscribe()).not.toThrow(); + expect(callback).not.toHaveBeenCalled(); + }); + }); +}); From c56e8d727421c79e4399573d2120c9e957b3a609 Mon Sep 17 00:00:00 2001 From: Brandy Smith Date: Wed, 14 Oct 2026 10:08:44 -0400 Subject: [PATCH 3/5] feat(grid, col): resolve breakpoints from config (#31518) Issue number: internal --------- ## What is the current behavior? The previous PR in this stack added the `screenBreakpoints` config option and the `@utils/breakpoints` helpers, but nothing consumes them yet. `ion-grid` gets its responsive padding and fixed widths purely from `@media` queries, with the thresholds compiled into the CSS. `ion-col` only expresses per-breakpoint values through breakpoint-suffixed properties (`size-xs` through `size-xl`, plus the `order-*` and `offset-*` equivalents). Neither reads the configured breakpoints, and neither can target `xxl`. `ion-grid` also declared its own `ION_GRID_BREAKPOINTS` / `IonGridBreakpoint` (carrying a `TODO(FW-7285)`) rather than using the shared type. ## What is the new behavior? - Grid and column padding and fixed widths now follow the `screenBreakpoints` config. They change at the configured widths rather than at thresholds fixed when the framework was built, so moving a breakpoint moves the layout with it. - The default fixed widths assume the default thresholds. Lowering a threshold without lowering its width leaves the grid wider than the screen, where `max-width: 100%` clamps it and it is no longer fixed. Set the matching `--ion-grid-breakpoint-*-width` variables alongside the config. - `ion-col` accepts an object of breakpoint values for `size`, `order` and `offset`: `col.size = { xs: 12, md: 6 }`. This is resolved against the configured breakpoints. `xxl` is added to this object. An empty string or `null` at a breakpoint resets that column to the default flex layout. - All suffixed properties are **deprecated**, but not removed. They still work and log a deprecation warning. If both forms are set for the same property, the object form wins and the suffixed ones are ignored with a warning. They will be removed in a future major release. - `ION_GRID_BREAKPOINTS` / `IonGridBreakpoint` and `ION_COL_BREAKPOINTS` / `IonColBreakpoint` are replaced by the shared `ScreenBreakpoint`. ## Does this introduce a breaking change? - [ ] Yes - [x] No The `size`, `order`, and `offset` prop types have been widened from `string | undefined` to `BreakpointMap | number | string | undefined`. The suffixed properties continue to work as before. `BREAKING.md` has been updated under 10.x with migration guidance for these deprecations. The `push`/`pull` entry has also been moved from the Grid section to Col, where the properties are defined. ## Other information [Preview](https://ionic-framework-git-fw-7285-3-grid-col-ionic1.vercel.app/src/components/grid/test/breakpoints/) --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> --- BREAKING.md | 184 +++-- core/api.txt | 6 +- core/src/components.d.ts | 60 +- core/src/components/col/col.deprecations.ts | 166 +++++ core/src/components/col/col.interface.ts | 14 +- core/src/components/col/col.scss | 9 +- core/src/components/col/col.tsx | 443 +++++++----- core/src/components/col/col.vars.scss | 48 -- core/src/components/col/test/col.spec.ts | 658 +++++++++++++++--- core/src/components/grid/grid.interface.ts | 8 +- core/src/components/grid/grid.mixins.scss | 37 - core/src/components/grid/grid.scss | 15 +- core/src/components/grid/grid.tsx | 6 + core/src/components/grid/grid.vars.scss | 52 -- .../grid/test/breakpoints/grid.e2e.ts | 138 ++++ .../grid/test/breakpoints/index.html | 371 ++++++++++ .../components/grid/test/fixed/grid.e2e.ts | 10 +- core/src/components/grid/test/grid.spec.ts | 19 + .../grid/test/offsets-pull-push/grid.e2e.ts | 2 +- .../components/grid/test/padding/grid.e2e.ts | 41 ++ core/src/global/ionic-global.ts | 11 +- core/src/global/test/ionic-global.spec.ts | 16 + core/src/utils/breakpoints.ts | 9 - core/src/utils/test/breakpoints.spec.ts | 69 +- core/src/utils/test/theme.spec.ts | 89 +++ core/src/utils/theme.ts | 50 ++ 26 files changed, 1980 insertions(+), 551 deletions(-) create mode 100644 core/src/components/col/col.deprecations.ts delete mode 100644 core/src/components/col/col.vars.scss delete mode 100644 core/src/components/grid/grid.mixins.scss delete mode 100644 core/src/components/grid/grid.vars.scss create mode 100644 core/src/components/grid/test/breakpoints/grid.e2e.ts create mode 100644 core/src/components/grid/test/breakpoints/index.html create mode 100644 core/src/components/grid/test/grid.spec.ts diff --git a/BREAKING.md b/BREAKING.md index f9e61176941..940912eacfc 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -123,7 +123,9 @@ This is a comprehensive list of the breaking changes introduced in the major ver The following breaking changes apply to `ion-col`: 1. `--ion-grid-column-padding-*` CSS variables have been replaced with per-side, per-breakpoint tokens in the `col` namespace. [1](#version-10x-col-padding-variables) -2. Theme classes (`ion-col.md`, `ion-col.ios`) are no longer supported. [2](#version-10x-col-theme-classes) +2. The breakpoint-suffixed `size`, `order` and `offset` properties have been deprecated. [2](#version-10x-col-deprecated-breakpoint-properties) +3. The `push` and `pull` properties have been deprecated. [3](#version-10x-col-deprecated-push-and-pull-properties) +4. Theme classes (`ion-col.md`, `ion-col.ios`) are no longer supported. [4](#version-10x-col-theme-classes)
Padding variables
@@ -133,86 +135,52 @@ Column padding was a single value per breakpoint and is now set per-side, and th |---|---|---| | `--ion-grid-column-padding-{bp}` | `IonCol.breakpoint.{bp}.padding.{top\|end\|bottom\|start}` | `--ion-col-breakpoint-{bp}-padding-{top\|end\|bottom\|start}` | -
Theme classes
- -Remove any instances that target the theme classes: `ion-col.md`, `ion-col.ios`. - -

Content

- -The following breaking changes apply to `ion-content`: - -1. `--background` and `--color` CSS variables have been replaced. -2. `--padding-*` CSS variables are no longer part of the documented public API (but remain functional). -3. `--keyboard-offset`, `--offset-top`, and `--offset-bottom` have been renamed to the `--internal-*` namespace with no replacement. -4. Theme classes (`ion-content.md`, `ion-content.ios`) are no longer supported. - -
Removed CSS variables
- -`--background` and `--color` have been removed. Use the new token structure for global styles, or the corresponding CSS variable for component-specific overrides: - -| Old (9.x) | New token (global) | New CSS variable (component-specific) | -|---|---|---| -| `--background` | `IonContent.background` | `--ion-content-default-background` | -| `--color` | `IonContent.color` | `--ion-content-default-color` | +
Deprecated breakpoint properties
-
Padding variables
+The breakpoint-specific properties (`size-xs` through `size-xl`, along with the `order-*` and `offset-*` equivalents) have been deprecated in favor of setting `size`, `order`, and `offset` to an object containing screen breakpoint values. They continue to work and log a deprecation warning, but will be removed in a future major version. -New code should use the token-based API: +The object form is the only way to target the `xxl` breakpoint. -| Old (9.x) | New token (global) | New CSS variable (component-specific) | -|---|---|---| -| `--padding-top` | `IonContent.padding.top` | `--ion-content-padding-top` | -| `--padding-end` | `IonContent.padding.end` | `--ion-content-padding-end` | -| `--padding-bottom` | `IonContent.padding.bottom` | `--ion-content-padding-bottom` | -| `--padding-start` | `IonContent.padding.start` | `--ion-content-padding-start` | - -> [!NOTE] -> The `--padding-*` overrides and `.ion-padding`, `.ion-padding-*` utility classes in `css/padding.scss` continue to work — `ion-content` honors them as a fallback when the new token is unset. They are no longer part of the documented public API (only `--ion-content-padding-*` is listed in `core/api.txt`), but existing usage will not break. - -
Internal-only variables
- -The following CSS variables were previously documented `@prop`s on `ion-content` and have been renamed to the `--internal-*` namespace, removing them from the public API: - -| Old (9.x) | New | -|---|---| -| `--keyboard-offset` | `--internal-keyboard-offset` | -| `--offset-top` | `--internal-offset-top` | -| `--offset-bottom` | `--internal-offset-bottom` | - -These are managed by `ion-content` itself (keyboard avoidance and header/footer offsets) and were never intended for consumer override. There is no replacement — any code that was setting them directly should be removed. +**Version up to 9.x** -
Theme classes
+```html +Column +``` -Remove any instances that target the theme classes: `ion-content.md`, `ion-content.ios`. +**Version 10.x+** -

Datetime

+A breakpoint object can only be set as a JavaScript property, since an HTML attribute is always a string. -- The `ion-buttons` component has been removed from the internal implementation of `ion-datetime` and is no longer required when passing custom buttons to the `slot="buttons"`. When providing custom buttons, use a `div` element instead of `ion-buttons`. While existing code using `ion-buttons` may continue to work visually, future updates to the `ion-buttons` component may cause any styles you rely on to break. +```html +Column +``` -

Grid

+```ts +const col = document.querySelector('ion-col'); +col.size = { xs: 12, md: 6, xl: 3 }; +``` -The following breaking changes apply to `ion-grid`: +In a framework, bind the object directly: -1. `--ion-grid-padding-*` CSS variables have been replaced with per-side, per-breakpoint tokens. [1](#version-10x-grid-padding-variables) -2. `--ion-grid-width-*` CSS variables for the fixed grid have been replaced with per-breakpoint tokens. [2](#version-10x-grid-fixed-width-variables) -3. The `push` and `pull` properties have been deprecated. [3](#version-10x-grid-deprecated-push-and-pull-properties) -4. Theme classes (`ion-grid.md`, `ion-grid.ios`) are no longer supported. [4](#version-10x-grid-theme-classes) +```tsx +Column +``` -
Padding variables
+The unsuffixed single-value form is unchanged and still applies at every screen size: -Grid padding was a single value per breakpoint and is now set per-side. Use the new token structure for global styles, or the corresponding CSS variable for component-specific overrides: +```html +Column +``` -| Old (9.x) | New token (global) | New CSS variable (component-specific) | -|---|---|---| -| `--ion-grid-padding-{bp}` | `IonGrid.breakpoint.{bp}.padding.{top\|end\|bottom\|start}` | `--ion-grid-breakpoint-{bp}-padding-{top\|end\|bottom\|start}` | +To reset a column to the default flex layout at a breakpoint and above, use an empty string or `null` — the equivalent of the old value-less attribute (``): -
Fixed width variables
+```ts +col.size = { xs: 12, md: null }; +``` -| Old (9.x) | New token (global) | New CSS variable (component-specific) | -|---|---|---| -| `--ion-grid-width-{bp}` | `IonGrid.breakpoint.{bp}.width` | `--ion-grid-breakpoint-{bp}-width` | +If both forms are set for the same property, the object form takes precedence, and the suffixed properties are ignored with a warning. -
Deprecated push and pull properties
+
Deprecated push and pull properties
The `push` and `pull` properties have been disabled. They now log a deprecation warning and no longer affect layout. Use the `order` property to achieve a similar result. @@ -322,16 +290,94 @@ To reorder two columns where column 1 has `size="9" push="3"` and column 2 has ` ```html - -
ion-col size="auto" order="2" order-md="2"
+ +
ion-col size="auto" order="2"
- -
ion-col size="auto" order="1" order-md="1"
+ +
ion-col size="auto" order="1"
``` +
Theme classes
+ +Remove any instances that target the theme classes: `ion-col.md`, `ion-col.ios`. + +

Content

+ +The following breaking changes apply to `ion-content`: + +1. `--background` and `--color` CSS variables have been replaced. +2. `--padding-*` CSS variables are no longer part of the documented public API (but remain functional). +3. `--keyboard-offset`, `--offset-top`, and `--offset-bottom` have been renamed to the `--internal-*` namespace with no replacement. +4. Theme classes (`ion-content.md`, `ion-content.ios`) are no longer supported. + +
Removed CSS variables
+ +`--background` and `--color` have been removed. Use the new token structure for global styles, or the corresponding CSS variable for component-specific overrides: + +| Old (9.x) | New token (global) | New CSS variable (component-specific) | +|---|---|---| +| `--background` | `IonContent.background` | `--ion-content-default-background` | +| `--color` | `IonContent.color` | `--ion-content-default-color` | + +
Padding variables
+ +New code should use the token-based API: + +| Old (9.x) | New token (global) | New CSS variable (component-specific) | +|---|---|---| +| `--padding-top` | `IonContent.padding.top` | `--ion-content-padding-top` | +| `--padding-end` | `IonContent.padding.end` | `--ion-content-padding-end` | +| `--padding-bottom` | `IonContent.padding.bottom` | `--ion-content-padding-bottom` | +| `--padding-start` | `IonContent.padding.start` | `--ion-content-padding-start` | + +> [!NOTE] +> The `--padding-*` overrides and `.ion-padding`, `.ion-padding-*` utility classes in `css/padding.scss` continue to work — `ion-content` honors them as a fallback when the new token is unset. They are no longer part of the documented public API (only `--ion-content-padding-*` is listed in `core/api.txt`), but existing usage will not break. + +
Internal-only variables
+ +The following CSS variables were previously documented `@prop`s on `ion-content` and have been renamed to the `--internal-*` namespace, removing them from the public API: + +| Old (9.x) | New | +|---|---| +| `--keyboard-offset` | `--internal-keyboard-offset` | +| `--offset-top` | `--internal-offset-top` | +| `--offset-bottom` | `--internal-offset-bottom` | + +These are managed by `ion-content` itself (keyboard avoidance and header/footer offsets) and were never intended for consumer override. There is no replacement — any code that was setting them directly should be removed. + +
Theme classes
+ +Remove any instances that target the theme classes: `ion-content.md`, `ion-content.ios`. + +

Datetime

+ +- The `ion-buttons` component has been removed from the internal implementation of `ion-datetime` and is no longer required when passing custom buttons to the `slot="buttons"`. When providing custom buttons, use a `div` element instead of `ion-buttons`. While existing code using `ion-buttons` may continue to work visually, future updates to the `ion-buttons` component may cause any styles you rely on to break. + +

Grid

+ +The following breaking changes apply to `ion-grid`: + +1. `--ion-grid-padding-*` CSS variables have been replaced with per-side, per-breakpoint tokens. [1](#version-10x-grid-padding-variables) +2. `--ion-grid-width-*` CSS variables for the fixed grid have been replaced with per-breakpoint tokens. [2](#version-10x-grid-fixed-width-variables) +3. Theme classes (`ion-grid.md`, `ion-grid.ios`) are no longer supported. [3](#version-10x-grid-theme-classes) + +
Padding variables
+ +Grid padding was a single value per breakpoint and is now set per-side. Use the new token structure for global styles, or the corresponding CSS variable for component-specific overrides: + +| Old (9.x) | New token (global) | New CSS variable (component-specific) | +|---|---|---| +| `--ion-grid-padding-{bp}` | `IonGrid.breakpoint.{bp}.padding.{top\|end\|bottom\|start}` | `--ion-grid-breakpoint-{bp}-padding-{top\|end\|bottom\|start}` | + +
Fixed width variables
+ +| Old (9.x) | New token (global) | New CSS variable (component-specific) | +|---|---|---| +| `--ion-grid-width-{bp}` | `IonGrid.breakpoint.{bp}.width` | `--ion-grid-breakpoint-{bp}-width` | +
Theme classes
Remove any instances that target the theme classes: `ion-grid.md`, `ion-grid.ios`. diff --git a/core/api.txt b/core/api.txt index e910f671bc4..e9f5eb4d403 100644 --- a/core/api.txt +++ b/core/api.txt @@ -749,13 +749,13 @@ ion-chip,css-prop,--ion-chip-state-focus-ring-width ion-col,shadow ion-col,prop,mode,"ios" | "md",undefined,false,false -ion-col,prop,offset,string | undefined,undefined,false,false +ion-col,prop,offset,BreakpointMap | number | string | undefined,undefined,false,false ion-col,prop,offsetLg,string | undefined,undefined,false,false ion-col,prop,offsetMd,string | undefined,undefined,false,false ion-col,prop,offsetSm,string | undefined,undefined,false,false ion-col,prop,offsetXl,string | undefined,undefined,false,false ion-col,prop,offsetXs,string | undefined,undefined,false,false -ion-col,prop,order,string | undefined,undefined,false,false +ion-col,prop,order,BreakpointMap | number | string | undefined,undefined,false,false ion-col,prop,orderLg,string | undefined,undefined,false,false ion-col,prop,orderMd,string | undefined,undefined,false,false ion-col,prop,orderSm,string | undefined,undefined,false,false @@ -773,7 +773,7 @@ ion-col,prop,pushMd,string | undefined,undefined,false,false ion-col,prop,pushSm,string | undefined,undefined,false,false ion-col,prop,pushXl,string | undefined,undefined,false,false ion-col,prop,pushXs,string | undefined,undefined,false,false -ion-col,prop,size,string | undefined,undefined,false,false +ion-col,prop,size,BreakpointMap | number | string | undefined,undefined,false,false ion-col,prop,sizeLg,string | undefined,undefined,false,false ion-col,prop,sizeMd,string | undefined,undefined,false,false ion-col,prop,sizeSm,string | undefined,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index a25784da787..4b93daad7f8 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -17,6 +17,7 @@ import { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo import { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface"; import { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; import { IonChipFill, IonChipShape, IonChipSize } from "./components/chip/chip.interfaces"; +import { IonColValue } from "./components/col/col.interface"; import { ScrollBaseDetail, ScrollDetail } from "./components/content/content.interfaces"; import { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; import { FooterScrollEffect } from "./components/footer/footer-interface"; @@ -63,6 +64,7 @@ export { RouteID, RouterDirection, RouterEventDetail, RouteWrite } from "./compo export { BreadcrumbCollapsedClickEventDetail } from "./components/breadcrumb/breadcrumb-interface"; export { CheckboxChangeEventDetail } from "./components/checkbox/checkbox-interface"; export { IonChipFill, IonChipShape, IonChipSize } from "./components/chip/chip.interfaces"; +export { IonColValue } from "./components/col/col.interface"; export { ScrollBaseDetail, ScrollDetail } from "./components/content/content.interfaces"; export { DatetimeChangeEventDetail, DatetimeHighlight, DatetimeHighlightCallback, DatetimeHourCycle, DatetimeParts, DatetimePresentation, FormatOptions, TitleSelectedDatesFormatter } from "./components/datetime/datetime-interface"; export { FooterScrollEffect } from "./components/footer/footer-interface"; @@ -916,51 +918,61 @@ export namespace Components { */ "mode"?: "ios" | "md"; /** - * The amount to offset the column, in terms of how many columns it should shift to the end of the total available. + * The amount to offset the column, in terms of how many columns it should shift to the end of the total available. Can be a single value that applies at every screen size, or an object of screen breakpoint values (e.g. `{ xs: 0, md: 2 }`), in which case the value for the largest matching breakpoint is used. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. */ - "offset"?: string; + "offset"?: IonColValue; /** * The amount to offset the column for lg screens, in terms of how many columns it should shift to the end of the total available. + * @deprecated Set `offset` to an object of screen breakpoint values instead (e.g. `{ lg: 2 }`). */ "offsetLg"?: string; /** * The amount to offset the column for md screens, in terms of how many columns it should shift to the end of the total available. + * @deprecated Set `offset` to an object of screen breakpoint values instead (e.g. `{ md: 2 }`). */ "offsetMd"?: string; /** * The amount to offset the column for sm screens, in terms of how many columns it should shift to the end of the total available. + * @deprecated Set `offset` to an object of screen breakpoint values instead (e.g. `{ sm: 2 }`). */ "offsetSm"?: string; /** * The amount to offset the column for xl screens, in terms of how many columns it should shift to the end of the total available. + * @deprecated Set `offset` to an object of screen breakpoint values instead (e.g. `{ xl: 2 }`). */ "offsetXl"?: string; /** * The amount to offset the column for xs screens, in terms of how many columns it should shift to the end of the total available. + * @deprecated Set `offset` to an object of screen breakpoint values instead (e.g. `{ xs: 2 }`). */ "offsetXs"?: string; /** - * The order of the column, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * The order of the column, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. Can be a single value that applies at every screen size, or an object of screen breakpoint values (e.g. `{ xs: 2, md: 1 }`), in which case the value for the largest matching breakpoint is used. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. */ - "order"?: string; + "order"?: IonColValue; /** * The order of the column for lg screens, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * @deprecated Set `order` to an object of screen breakpoint values instead (e.g. `{ lg: 1 }`). */ "orderLg"?: string; /** * The order of the column for md screens, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * @deprecated Set `order` to an object of screen breakpoint values instead (e.g. `{ md: 1 }`). */ "orderMd"?: string; /** * The order of the column for sm screens, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * @deprecated Set `order` to an object of screen breakpoint values instead (e.g. `{ sm: 1 }`). */ "orderSm"?: string; /** * The order of the column for xl screens, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * @deprecated Set `order` to an object of screen breakpoint values instead (e.g. `{ xl: 1 }`). */ "orderXl"?: string; /** * The order of the column for xs screens, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * @deprecated Set `order` to an object of screen breakpoint values instead (e.g. `{ xs: 1 }`). */ "orderXs"?: string; /** @@ -1024,27 +1036,32 @@ export namespace Components { */ "pushXs"?: string; /** - * The size of the column, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * The size of the column, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. Can be a single value that applies at every screen size, or an object of screen breakpoint values (e.g. `{ xs: 12, md: 6 }`), in which case the value for the largest matching breakpoint is used. An empty string or `null` at a breakpoint resets the column to the default flex layout from that breakpoint up (e.g. `{ xs: 12, md: null }`). The width each breakpoint activates at can be changed with the `screenBreakpoints` config. */ - "size"?: string; + "size"?: IonColValue; /** * The size of the column for lg screens, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * @deprecated Set `size` to an object of screen breakpoint values instead (e.g. `{ lg: 4 }`). */ "sizeLg"?: string; /** * The size of the column for md screens, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * @deprecated Set `size` to an object of screen breakpoint values instead (e.g. `{ md: 6 }`). */ "sizeMd"?: string; /** * The size of the column for sm screens, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * @deprecated Set `size` to an object of screen breakpoint values instead (e.g. `{ sm: 6 }`). */ "sizeSm"?: string; /** * The size of the column for xl screens, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * @deprecated Set `size` to an object of screen breakpoint values instead (e.g. `{ xl: 3 }`). */ "sizeXl"?: string; /** * The size of the column for xs screens, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * @deprecated Set `size` to an object of screen breakpoint values instead (e.g. `{ xs: 12 }`). */ "sizeXs"?: string; } @@ -1529,7 +1546,7 @@ export namespace Components { } interface IonGrid { /** - * If `true`, the grid will have a fixed width based on the screen size. + * If `true`, the grid will have a fixed width based on the screen size. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. The default widths assume the default thresholds, so lowering a threshold without lowering its width leaves the grid clamped by `max-width: 100%` and no longer fixed. Set the matching `--ion-grid-breakpoint-*-width` variables alongside the config. * @default false */ "fixed": boolean; @@ -6792,51 +6809,61 @@ declare namespace LocalJSX { */ "mode"?: "ios" | "md"; /** - * The amount to offset the column, in terms of how many columns it should shift to the end of the total available. + * The amount to offset the column, in terms of how many columns it should shift to the end of the total available. Can be a single value that applies at every screen size, or an object of screen breakpoint values (e.g. `{ xs: 0, md: 2 }`), in which case the value for the largest matching breakpoint is used. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. */ - "offset"?: string; + "offset"?: IonColValue; /** * The amount to offset the column for lg screens, in terms of how many columns it should shift to the end of the total available. + * @deprecated Set `offset` to an object of screen breakpoint values instead (e.g. `{ lg: 2 }`). */ "offsetLg"?: string; /** * The amount to offset the column for md screens, in terms of how many columns it should shift to the end of the total available. + * @deprecated Set `offset` to an object of screen breakpoint values instead (e.g. `{ md: 2 }`). */ "offsetMd"?: string; /** * The amount to offset the column for sm screens, in terms of how many columns it should shift to the end of the total available. + * @deprecated Set `offset` to an object of screen breakpoint values instead (e.g. `{ sm: 2 }`). */ "offsetSm"?: string; /** * The amount to offset the column for xl screens, in terms of how many columns it should shift to the end of the total available. + * @deprecated Set `offset` to an object of screen breakpoint values instead (e.g. `{ xl: 2 }`). */ "offsetXl"?: string; /** * The amount to offset the column for xs screens, in terms of how many columns it should shift to the end of the total available. + * @deprecated Set `offset` to an object of screen breakpoint values instead (e.g. `{ xs: 2 }`). */ "offsetXs"?: string; /** - * The order of the column, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * The order of the column, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. Can be a single value that applies at every screen size, or an object of screen breakpoint values (e.g. `{ xs: 2, md: 1 }`), in which case the value for the largest matching breakpoint is used. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. */ - "order"?: string; + "order"?: IonColValue; /** * The order of the column for lg screens, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * @deprecated Set `order` to an object of screen breakpoint values instead (e.g. `{ lg: 1 }`). */ "orderLg"?: string; /** * The order of the column for md screens, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * @deprecated Set `order` to an object of screen breakpoint values instead (e.g. `{ md: 1 }`). */ "orderMd"?: string; /** * The order of the column for sm screens, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * @deprecated Set `order` to an object of screen breakpoint values instead (e.g. `{ sm: 1 }`). */ "orderSm"?: string; /** * The order of the column for xl screens, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * @deprecated Set `order` to an object of screen breakpoint values instead (e.g. `{ xl: 1 }`). */ "orderXl"?: string; /** * The order of the column for xs screens, in terms of where the column should position itself in the columns renderer. If no value is passed, the column order implicit value will be the order in the html structure. + * @deprecated Set `order` to an object of screen breakpoint values instead (e.g. `{ xs: 1 }`). */ "orderXs"?: string; /** @@ -6900,27 +6927,32 @@ declare namespace LocalJSX { */ "pushXs"?: string; /** - * The size of the column, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * The size of the column, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. Can be a single value that applies at every screen size, or an object of screen breakpoint values (e.g. `{ xs: 12, md: 6 }`), in which case the value for the largest matching breakpoint is used. An empty string or `null` at a breakpoint resets the column to the default flex layout from that breakpoint up (e.g. `{ xs: 12, md: null }`). The width each breakpoint activates at can be changed with the `screenBreakpoints` config. */ - "size"?: string; + "size"?: IonColValue; /** * The size of the column for lg screens, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * @deprecated Set `size` to an object of screen breakpoint values instead (e.g. `{ lg: 4 }`). */ "sizeLg"?: string; /** * The size of the column for md screens, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * @deprecated Set `size` to an object of screen breakpoint values instead (e.g. `{ md: 6 }`). */ "sizeMd"?: string; /** * The size of the column for sm screens, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * @deprecated Set `size` to an object of screen breakpoint values instead (e.g. `{ sm: 6 }`). */ "sizeSm"?: string; /** * The size of the column for xl screens, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * @deprecated Set `size` to an object of screen breakpoint values instead (e.g. `{ xl: 3 }`). */ "sizeXl"?: string; /** * The size of the column for xs screens, in terms of how many columns it should take up out of the total available. If `"auto"` is passed, the column will be the size of its content. + * @deprecated Set `size` to an object of screen breakpoint values instead (e.g. `{ xs: 12 }`). */ "sizeXs"?: string; } @@ -7386,7 +7418,7 @@ declare namespace LocalJSX { } interface IonGrid { /** - * If `true`, the grid will have a fixed width based on the screen size. + * If `true`, the grid will have a fixed width based on the screen size. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. The default widths assume the default thresholds, so lowering a threshold without lowering its width leaves the grid clamped by `max-width: 100%` and no longer fixed. Set the matching `--ion-grid-breakpoint-*-width` variables alongside the config. * @default false */ "fixed"?: boolean; diff --git a/core/src/components/col/col.deprecations.ts b/core/src/components/col/col.deprecations.ts new file mode 100644 index 00000000000..bcb8e018225 --- /dev/null +++ b/core/src/components/col/col.deprecations.ts @@ -0,0 +1,166 @@ +// TODO(FW-7557): Remove this file in v11, along with the deprecated +// properties it supports and the calls to it in `col.tsx`. + +import { isBreakpointMap } from '@utils/breakpoints'; +import { printIonWarning } from '@utils/logging'; + +import type { Col } from './col'; +import type { IonColBreakpointValues, IonColProperty } from './col.interface'; +import { ION_COL_PROPERTIES } from './col.interface'; + +/** + * The breakpoints the deprecated suffixed properties (e.g. `size-md`) cover. + * `xxl` is absent by design: it is only reachable through the breakpoint object + * form, so no new suffixed properties are introduced for it. + */ +const LEGACY_BREAKPOINTS = ['xs', 'sm', 'md', 'lg', 'xl'] as const; + +/** + * Deprecation warnings are tracked for the page rather than per column. Most + * applications use the deprecated properties on every column in a grid, so + * warning per instance floods the console with the same message. + * + * Messages are tracked rather than a single flag, so columns using different + * properties each still report which ones they use. + */ +const printedWarnings = new Set(); + +/** + * Print a deprecation warning the first time it is seen. + * + * @param message The warning to print. + * @param el The column the warning refers to. + */ +const warnOnce = (message: string, el: HTMLElement) => { + if (printedWarnings.has(message)) { + return; + } + + printedWarnings.add(message); + + printIonWarning(message, el); +}; + +/** + * Render a breakpoint object the way it would be written in code, so the + * deprecation warning can show the exact replacement. + * + * @param values The breakpoint values to render. + * @return The object as it would be written in JavaScript. + */ +const formatBreakpointValues = (values: IonColBreakpointValues): string => { + const entries = Object.entries(values).map(([breakpoint, value]) => { + const literal = typeof value === 'string' && /^\d+$/.test(value) ? value : JSON.stringify(value); + + return `${breakpoint}: ${literal}`; + }); + + return `{ ${entries.join(', ')} }`; +}; + +/** + * Collect the values set through the deprecated suffixed properties (e.g. + * `size-md`) into a breakpoint object. + * + * @param col The column to read the properties from. + * @param property The responsive property to collect values for. + * @return The values keyed by breakpoint. + */ +export const getLegacyBreakpointValues = (col: Col, property: IonColProperty): IonColBreakpointValues => { + const values: IonColBreakpointValues = {}; + + for (const breakpoint of LEGACY_BREAKPOINTS) { + const suffixed = `${property}${breakpoint.charAt(0).toUpperCase()}${breakpoint.slice(1)}` as keyof Col; + const value = col[suffixed] as string | undefined; + + if (value !== undefined) { + values[breakpoint] = value; + } + } + + return values; +}; + +/** + * Warn that the suffixed properties set on a column are deprecated, showing + * the breakpoint object that replaces them, or that they are being ignored + * because the unsuffixed property is set to an object. + * + * @param col The column to check. + * @param el The column element the warning refers to. + */ +export const warnDeprecatedBreakpointProps = (col: Col, el: HTMLElement) => { + for (const property of ION_COL_PROPERTIES) { + const legacyValues = getLegacyBreakpointValues(col, property); + const breakpoints = Object.keys(legacyValues); + + if (breakpoints.length === 0) { + continue; + } + + const names = breakpoints.map((breakpoint) => `${property}-${breakpoint}`); + const subject = `The ${names.join(', ')} ${names.length === 1 ? 'property is' : 'properties are'}`; + + if (isBreakpointMap(col[property])) { + warnOnce( + `[ion-col] - ${subject} ignored because "${property}" is set to an object of screen breakpoint values, which takes precedence.`, + el + ); + + continue; + } + + /** + * Build the equivalent object from the values actually set, so the example + * is the replacement for this column rather than a generic one. The + * unsuffixed value applies from the smallest screen up, so it becomes the + * `xs` entry. + */ + const base = col[property]; + const equivalent: IonColBreakpointValues = { + ...(typeof base === 'string' || typeof base === 'number' ? { xs: base } : {}), + ...legacyValues, + }; + + warnOnce( + `[ion-col] - ${subject} deprecated. Set "${property}" to an object of screen breakpoint values instead (e.g. col.${property} = ${formatBreakpointValues( + equivalent + )}).`, + el + ); + } +}; + +/** + * Warn that the `push` and `pull` properties no longer do anything. + * + * @param col The column to check. + * @param el The column element the warning refers to. + */ +export const warnDeprecatedPushPullProps = (col: Col, el: HTMLElement) => { + if ( + col.pull || + col.pullLg || + col.pullMd || + col.pullSm || + col.pullXl || + col.pullXs || + col.push || + col.pushLg || + col.pushMd || + col.pushSm || + col.pushXl || + col.pushXs + ) { + warnOnce( + '[ion-col] - The pull and push properties are deprecated and no longer work, in favor of the order and size properties.', + el + ); + } +}; + +/** + * Forget which warnings have been printed. Only needed by tests, since the + * page would otherwise carry them between cases. + */ +export const resetColDeprecationWarnings = () => printedWarnings.clear(); diff --git a/core/src/components/col/col.interface.ts b/core/src/components/col/col.interface.ts index 57472fa7274..fe6c682379f 100644 --- a/core/src/components/col/col.interface.ts +++ b/core/src/components/col/col.interface.ts @@ -1,19 +1,21 @@ +import type { BreakpointMap, ScreenBreakpoint } from '@utils/breakpoints'; + import type { IonPadding } from '../../themes/themes.interfaces'; -import { ION_GRID_BREAKPOINTS } from '../grid/grid.interface'; export type IonColRecipe = { breakpoint?: { - [K in IonColBreakpoint]?: { + [K in ScreenBreakpoint]?: { padding?: IonPadding; }; }; }; -// TODO(FW-7285): Replace with global breakpoints -export const ION_COL_BREAKPOINTS = ION_GRID_BREAKPOINTS; -export type IonColBreakpoint = (typeof ION_COL_BREAKPOINTS)[number]; +export type IonColBreakpointValues = BreakpointMap; + +export type IonColValue = string | number | IonColBreakpointValues; -export type IonColProperty = 'size' | 'order' | 'offset'; +export const ION_COL_PROPERTIES = ['size', 'order', 'offset'] as const; +export type IonColProperty = (typeof ION_COL_PROPERTIES)[number]; export type IonColStyle = { '--internal-col-margin'?: string; diff --git a/core/src/components/col/col.scss b/core/src/components/col/col.scss index a3889df490d..a9869909b5b 100644 --- a/core/src/components/col/col.scss +++ b/core/src/components/col/col.scss @@ -1,6 +1,4 @@ @use "../../themes/mixins" as mixins; -@use "../grid/grid.mixins" as grid; -@use "./col.vars" as vars; // Column: Common Styles // -------------------------------------------------- @@ -41,7 +39,12 @@ (100% - (var(--ion-grid-columns, 12) - 1) * var(--ion-row-gap, 0px)) / var(--ion-grid-columns, 12) ); - @include grid.make-breakpoint-padding(vars.$grid-column-paddings); + @include mixins.padding( + var(--padding-top, var(--internal-col-padding-top)), + var(--padding-end, var(--internal-col-padding-end)), + var(--padding-bottom, var(--internal-col-padding-bottom)), + var(--padding-start, var(--internal-col-padding-start)) + ); @include mixins.margin(0); box-sizing: border-box; diff --git a/core/src/components/col/col.tsx b/core/src/components/col/col.tsx index 3c86d4956bf..53d66e763cb 100644 --- a/core/src/components/col/col.tsx +++ b/core/src/components/col/col.tsx @@ -1,20 +1,14 @@ import type { ComponentInterface } from '@stencil/core'; -import { Component, Element, Host, Listen, Prop, forceUpdate, h } from '@stencil/core'; -import { matchBreakpoint } from '@utils/breakpoints'; -import { printIonWarning } from '@utils/logging'; +import { Component, Element, Host, Prop, forceUpdate, h } from '@stencil/core'; +import { isBreakpointMap, matchBreakpoint, onBreakpointChange, resolveBreakpointMap } from '@utils/breakpoints'; -import type { IonColProperty, IonColStyle } from './col.interface'; -import { ION_COL_BREAKPOINTS } from './col.interface'; - -const BREAKPOINTS = ['', ...ION_COL_BREAKPOINTS] as const; - -/** - * How long to wait (in ms) after the last `resize` event before re-rendering. - * `resize` fires rapidly while a window is dragged, so instead of re-rendering - * on every event we wait for it to settle and render once. 100ms is short - * enough to feel instant but long enough to batch the burst. - */ -const RESIZE_DEBOUNCE = 100; +// TODO(FW-7557): Remove this import in v11. +import { + getLegacyBreakpointValues, + warnDeprecatedBreakpointProps, + warnDeprecatedPushPullProps, +} from './col.deprecations'; +import type { IonColProperty, IonColStyle, IonColValue } from './col.interface'; /** * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component. @@ -25,272 +19,401 @@ const RESIZE_DEBOUNCE = 100; shadow: true, }) export class Col implements ComponentInterface { - private resizeTimeout: ReturnType | null = null; + private unsubscribeBreakpoint?: () => void; @Element() el!: HTMLIonColElement; + /** - * The amount to offset the column, in terms of how many columns it should shift to the end - * of the total available. + * The amount to offset the column, in terms of how many columns it should + * shift to the end of the total available. + * + * Can be a single value that applies at every screen size, or an object of + * screen breakpoint values (e.g. `{ xs: 0, md: 2 }`), in which case the value + * for the largest matching breakpoint is used. + * + * The width each breakpoint activates at can be changed with the + * `screenBreakpoints` config. */ - @Prop() offset?: string; + @Prop() offset?: IonColValue; + // TODO(FW-7557): Remove this in v11. /** - * The amount to offset the column for xs screens, in terms of how many columns it should shift - * to the end of the total available. + * The amount to offset the column for xs screens, in terms of how many + * columns it should shift to the end of the total available. + * + * @deprecated Set `offset` to an object of screen breakpoint values + * instead (e.g. `{ xs: 2 }`). */ @Prop() offsetXs?: string; + // TODO(FW-7557): Remove this in v11. /** - * The amount to offset the column for sm screens, in terms of how many columns it should shift - * to the end of the total available. + * The amount to offset the column for sm screens, in terms of how many + * columns it should shift to the end of the total available. + * + * @deprecated Set `offset` to an object of screen breakpoint values + * instead (e.g. `{ sm: 2 }`). */ @Prop() offsetSm?: string; + // TODO(FW-7557): Remove this in v11. /** - * The amount to offset the column for md screens, in terms of how many columns it should shift - * to the end of the total available. + * The amount to offset the column for md screens, in terms of how many + * columns it should shift to the end of the total available. + * + * @deprecated Set `offset` to an object of screen breakpoint values + * instead (e.g. `{ md: 2 }`). */ @Prop() offsetMd?: string; + // TODO(FW-7557): Remove this in v11. /** - * The amount to offset the column for lg screens, in terms of how many columns it should shift - * to the end of the total available. + * The amount to offset the column for lg screens, in terms of how many + * columns it should shift to the end of the total available. + * + * @deprecated Set `offset` to an object of screen breakpoint values + * instead (e.g. `{ lg: 2 }`). */ @Prop() offsetLg?: string; + // TODO(FW-7557): Remove this in v11. /** - * The amount to offset the column for xl screens, in terms of how many columns it should shift - * to the end of the total available. + * The amount to offset the column for xl screens, in terms of how many + * columns it should shift to the end of the total available. + * + * @deprecated Set `offset` to an object of screen breakpoint values + * instead (e.g. `{ xl: 2 }`). */ @Prop() offsetXl?: string; /** - * The order of the column, in terms of where the column should position itself in the columns renderer. - * If no value is passed, the column order implicit value will be the order in the html structure. + * The order of the column, in terms of where the column should position + * itself in the columns renderer. If no value is passed, the column order + * implicit value will be the order in the html structure. + * + * Can be a single value that applies at every screen size, or an object of + * screen breakpoint values (e.g. `{ xs: 2, md: 1 }`), in which case the value + * for the largest matching breakpoint is used. + * + * The width each breakpoint activates at can be changed with the + * `screenBreakpoints` config. */ - @Prop() order?: string; + @Prop() order?: IonColValue; + // TODO(FW-7557): Remove this in v11. /** - * The order of the column for xs screens, in terms of where the column should position itself in the columns renderer. - * If no value is passed, the column order implicit value will be the order in the html structure. + * The order of the column for xs screens, in terms of where the column should + * position itself in the columns renderer. If no value is passed, the column + * order implicit value will be the order in the html structure. + * + * @deprecated Set `order` to an object of screen breakpoint values + * instead (e.g. `{ xs: 1 }`). */ @Prop() orderXs?: string; + // TODO(FW-7557): Remove this in v11. /** - * The order of the column for sm screens, in terms of where the column should position itself in the columns renderer. - * If no value is passed, the column order implicit value will be the order in the html structure. + * The order of the column for sm screens, in terms of where the column should + * position itself in the columns renderer. If no value is passed, the column + * order implicit value will be the order in the html structure. + * + * @deprecated Set `order` to an object of screen breakpoint values + * instead (e.g. `{ sm: 1 }`). */ @Prop() orderSm?: string; + // TODO(FW-7557): Remove this in v11. /** - * The order of the column for md screens, in terms of where the column should position itself in the columns renderer. - * If no value is passed, the column order implicit value will be the order in the html structure. + * The order of the column for md screens, in terms of where the column should + * position itself in the columns renderer. If no value is passed, the column + * order implicit value will be the order in the html structure. + * + * @deprecated Set `order` to an object of screen breakpoint values + * instead (e.g. `{ md: 1 }`). */ @Prop() orderMd?: string; + // TODO(FW-7557): Remove this in v11. /** - * The order of the column for lg screens, in terms of where the column should position itself in the columns renderer. - * If no value is passed, the column order implicit value will be the order in the html structure. + * The order of the column for lg screens, in terms of where the column should + * position itself in the columns renderer. If no value is passed, the column + * order implicit value will be the order in the html structure. + * + * @deprecated Set `order` to an object of screen breakpoint values + * instead (e.g. `{ lg: 1 }`). */ @Prop() orderLg?: string; + // TODO(FW-7557): Remove this in v11. /** - * The order of the column for xl screens, in terms of where the column should position itself in the columns renderer. - * If no value is passed, the column order implicit value will be the order in the html structure. + * The order of the column for xl screens, in terms of where the column should + * position itself in the columns renderer. If no value is passed, the column + * order implicit value will be the order in the html structure. + * + * @deprecated Set `order` to an object of screen breakpoint values + * instead (e.g. `{ xl: 1 }`). */ @Prop() orderXl?: string; - // TODO(FW-7557): Remove this in a major release. + // TODO(FW-7557): Remove this in v11. /** - * The amount to pull the column, in terms of how many columns it should shift to the start of - * the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to pull the column, in terms of how many columns it should shift + * to the start of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() pull?: string; - // TODO(FW-7557): Remove this in a major release. + + // TODO(FW-7557): Remove this in v11. /** - * The amount to pull the column for xs screens, in terms of how many columns it should shift - * to the start of the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to pull the column for xs screens, in terms of how many columns + * it should shift to the start of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() pullXs?: string; - // TODO(FW-7557): Remove this in a major release. + + // TODO(FW-7557): Remove this in v11. /** - * The amount to pull the column for sm screens, in terms of how many columns it should shift - * to the start of the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to pull the column for sm screens, in terms of how many columns + * it should shift to the start of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() pullSm?: string; - // TODO(FW-7557): Remove this in a major release. + + // TODO(FW-7557): Remove this in v11. /** - * The amount to pull the column for md screens, in terms of how many columns it should shift - * to the start of the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to pull the column for md screens, in terms of how many columns + * it should shift to the start of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() pullMd?: string; - // TODO(FW-7557): Remove this in a major release. + + // TODO(FW-7557): Remove this in v11. /** - * The amount to pull the column for lg screens, in terms of how many columns it should shift - * to the start of the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to pull the column for lg screens, in terms of how many columns + * it should shift to the start of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() pullLg?: string; - // TODO(FW-7557): Remove this in a major release. + + // TODO(FW-7557): Remove this in v11. /** - * The amount to pull the column for xl screens, in terms of how many columns it should shift - * to the start of the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to pull the column for xl screens, in terms of how many columns + * it should shift to the start of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() pullXl?: string; - // TODO(FW-7557): Remove this in a major release. + + // TODO(FW-7557): Remove this in v11. /** - * The amount to push the column, in terms of how many columns it should shift to the end - * of the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to push the column, in terms of how many columns it should shift + * to the end of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() push?: string; - // TODO(FW-7557): Remove this in a major release. + + // TODO(FW-7557): Remove this in v11. /** - * The amount to push the column for xs screens, in terms of how many columns it should shift - * to the end of the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to push the column for xs screens, in terms of how many columns + * it should shift to the end of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() pushXs?: string; - // TODO(FW-7557): Remove this in a major release. + + // TODO(FW-7557): Remove this in v11. /** - * The amount to push the column for sm screens, in terms of how many columns it should shift - * to the end of the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to push the column for sm screens, in terms of how many columns + * it should shift to the end of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() pushSm?: string; - // TODO(FW-7557): Remove this in a major release. + + // TODO(FW-7557): Remove this in v11. /** - * The amount to push the column for md screens, in terms of how many columns it should shift - * to the end of the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to push the column for md screens, in terms of how many columns + * it should shift to the end of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() pushMd?: string; - // TODO(FW-7557): Remove this in a major release. + + // TODO(FW-7557): Remove this in v11. /** - * The amount to push the column for lg screens, in terms of how many columns it should shift - * to the end of the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to push the column for lg screens, in terms of how many columns + * it should shift to the end of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() pushLg?: string; - // TODO(FW-7557): Remove this in a major release. + + // TODO(FW-7557): Remove this in v11. /** - * The amount to push the column for xl screens, in terms of how many columns it should shift - * to the end of the total available. - * @deprecated Use the combination of `size` and `order` properties to achieve the same effect. + * The amount to push the column for xl screens, in terms of how many columns + * it should shift to the end of the total available. + * + * @deprecated Use the combination of `size` and `order` properties to achieve + * the same effect. */ @Prop() pushXl?: string; /** - * The size of the column, in terms of how many columns it should take up out of the total - * available. If `"auto"` is passed, the column will be the size of its content. + * The size of the column, in terms of how many columns it should take up out + * of the total available. If `"auto"` is passed, the column will be the size + * of its content. + * + * Can be a single value that applies at every screen size, or an object of + * screen breakpoint values (e.g. `{ xs: 12, md: 6 }`), in which case the + * value for the largest matching breakpoint is used. + * + * An empty string or `null` at a breakpoint resets the column to the default + * flex layout from that breakpoint up (e.g. `{ xs: 12, md: null }`). + * + * The width each breakpoint activates at can be changed with the + * `screenBreakpoints` config. */ - @Prop() size?: string; + @Prop() size?: IonColValue; + // TODO(FW-7557): Remove this in v11. /** - * The size of the column for xs screens, in terms of how many columns it should take up out - * of the total available. If `"auto"` is passed, the column will be the size of its content. + * The size of the column for xs screens, in terms of how many columns it + * should take up out of the total available. If `"auto"` is passed, the + * column will be the size of its content. + * + * @deprecated Set `size` to an object of screen breakpoint values + * instead (e.g. `{ xs: 12 }`). */ @Prop() sizeXs?: string; + // TODO(FW-7557): Remove this in v11. /** - * The size of the column for sm screens, in terms of how many columns it should take up out - * of the total available. If `"auto"` is passed, the column will be the size of its content. + * The size of the column for sm screens, in terms of how many columns it + * should take up out of the total available. If `"auto"` is passed, the + * column will be the size of its content. + * + * @deprecated Set `size` to an object of screen breakpoint values + * instead (e.g. `{ sm: 6 }`). */ @Prop() sizeSm?: string; + // TODO(FW-7557): Remove this in v11. /** - * The size of the column for md screens, in terms of how many columns it should take up out - * of the total available. If `"auto"` is passed, the column will be the size of its content. + * The size of the column for md screens, in terms of how many columns it + * should take up out of the total available. If `"auto"` is passed, the + * column will be the size of its content. + * + * @deprecated Set `size` to an object of screen breakpoint values + * instead (e.g. `{ md: 6 }`). */ @Prop() sizeMd?: string; + // TODO(FW-7557): Remove this in v11. /** - * The size of the column for lg screens, in terms of how many columns it should take up out - * of the total available. If `"auto"` is passed, the column will be the size of its content. + * The size of the column for lg screens, in terms of how many columns it + * should take up out of the total available. If `"auto"` is passed, the + * column will be the size of its content. + * + * @deprecated Set `size` to an object of screen breakpoint values + * instead (e.g. `{ lg: 4 }`). */ @Prop() sizeLg?: string; + // TODO(FW-7557): Remove this in v11. /** - * The size of the column for xl screens, in terms of how many columns it should take up out - * of the total available. If `"auto"` is passed, the column will be the size of its content. + * The size of the column for xl screens, in terms of how many columns it + * should take up out of the total available. If `"auto"` is passed, the + * column will be the size of its content. + * + * @deprecated Set `size` to an object of screen breakpoint values + * instead (e.g. `{ xl: 3 }`). */ @Prop() sizeXl?: string; - @Listen('resize', { target: 'window' }) - onResize() { - if (this.resizeTimeout) { - clearTimeout(this.resizeTimeout); - this.resizeTimeout = null; - } + connectedCallback() { + this.unsubscribeBreakpoint = onBreakpointChange(() => forceUpdate(this)); - this.resizeTimeout = setTimeout(() => { - forceUpdate(this); - }, RESIZE_DEBOUNCE); + /** + * Re-resolve the breakpoint in case the screen size changed to a new + * breakpoint while the component was disconnected. + */ + forceUpdate(this); } disconnectedCallback() { - if (this.resizeTimeout) { - clearTimeout(this.resizeTimeout); - this.resizeTimeout = null; - } + this.unsubscribeBreakpoint?.(); + this.unsubscribeBreakpoint = undefined; } - // Loop through all of the breakpoints to see if the media query - // matches and grab the column value from the relevant prop if so - private getColumns(property: IonColProperty): string | undefined { - let matched: string | undefined; - - for (const breakpoint of BREAKPOINTS) { - const matches = matchBreakpoint(breakpoint); - - // Grab the value of the property, if it exists and our - // media query matches we return the value - const columns = this[(property + breakpoint.charAt(0).toUpperCase() + breakpoint.slice(1)) as keyof this] as - | string - | undefined; + /** + * Resolve the value of a responsive property for the current screen size. A + * breakpoint object takes precedence over the deprecated suffixed properties, + * which in turn narrow the unsuffixed value. + */ + private getColumns(property: IonColProperty): string | number | null | undefined { + const value: IonColValue | undefined = this[property]; - if (matches && columns !== undefined) { - matched = columns; - } + if (isBreakpointMap(value)) { + return resolveBreakpointMap(value, matchBreakpoint); } - // Return the last matched columns since the breakpoints - // increase in size and we want to return the largest match - return matched; + const legacyValues = getLegacyBreakpointValues(this, property); + const matchedLegacy = resolveBreakpointMap(legacyValues, matchBreakpoint); + + return matchedLegacy !== undefined ? matchedLegacy : (value as string | number | undefined); } + /** + * Resolve a responsive property to the column count it represents. + * + * @param property The responsive property to resolve. + * @return The column count, or `undefined` when the property is unset or + * does not resolve to a number. + */ private getColumnValue(property: IonColProperty): number | undefined { const colPropertyValue = this.getColumns(property); /** - * Return early when no value matched any breakpoint, or when the - * matched value is an empty string. The empty-string case comes - * from a value-less HTML attribute (e.g. ``) — - * the attribute is present but carries no width, so the column - * falls back to the default flex layout. + * Return early when no value matched any breakpoint, or when the matched + * value is empty. An empty value (`''` or `null`) carries no width, so the + * column falls back to the default flex layout. */ - if (!colPropertyValue || colPropertyValue === '') { + if (colPropertyValue === undefined || colPropertyValue === null || colPropertyValue === '') { return; } - const valueNumber = parseInt(colPropertyValue, 10); + const valueNumber = typeof colPropertyValue === 'number' ? colPropertyValue : parseInt(colPropertyValue, 10); return isNaN(valueNumber) ? undefined : valueNumber; } /** - * Builds the inline custom properties that drive the token based calc() - * in the styles. + * Builds the inline custom properties that drive the token based calc() in + * the styles. * - * @param size The number of columns the column should span, or `undefined` for default flex. + * @param size The number of columns the column should span, or `undefined` + * for default flex. * @param order The flex order position of the column. * @param offset The number of columns to offset (margin) the column by. - * @return An object containing the custom properties to apply to the column's style. + * @return An object containing the custom properties to apply to the column's + * style. */ private getColumnStyle(size: number | undefined, order: number | undefined, offset: number | undefined): IonColStyle { const style: IonColStyle = {}; @@ -308,26 +431,16 @@ export class Col implements ComponentInterface { return style; } + // TODO(FW-7557): Remove this in v11 — it exists only to warn about + // the deprecated breakpoint properties. + componentWillRender() { + warnDeprecatedBreakpointProps(this, this.el); + } + + // TODO(FW-7557): Remove this in v11 — it exists only to warn about + // the deprecated pull and push properties. componentDidLoad() { - if ( - this.pull || - this.pullLg || - this.pullMd || - this.pullSm || - this.pullXl || - this.pullXs || - this.push || - this.pushLg || - this.pushMd || - this.pushSm || - this.pushXl || - this.pushXs - ) { - printIonWarning( - '[ion-col] - The pull and push properties are deprecated and no longer work, in favor of the order and size properties.', - this.el - ); - } + warnDeprecatedPushPullProps(this, this.el); } render() { diff --git a/core/src/components/col/col.vars.scss b/core/src/components/col/col.vars.scss deleted file mode 100644 index 6898a649c46..00000000000 --- a/core/src/components/col/col.vars.scss +++ /dev/null @@ -1,48 +0,0 @@ -// Grid Column -// -------------------------------------------------- - -/// @prop - Per-breakpoint, per-side padding values -/// -/// `.ion-padding` / `.ion-no-padding` utility classes set `--padding-*` -/// directly on the host, so we must read `--padding-*` first and only fall -/// back to the per-breakpoint, per-side recipe token. When the utility is -/// applied on a parent (e.g. ``), the -/// `--padding-*` vars cascade-inherit into the slotted col descendants. -$grid-column-paddings: ( - xs: ( - top: var(--padding-top, var(--ion-col-breakpoint-xs-padding-top)), - end: var(--padding-end, var(--ion-col-breakpoint-xs-padding-end)), - bottom: var(--padding-bottom, var(--ion-col-breakpoint-xs-padding-bottom)), - start: var(--padding-start, var(--ion-col-breakpoint-xs-padding-start)), - ), - sm: ( - top: var(--padding-top, var(--ion-col-breakpoint-sm-padding-top)), - end: var(--padding-end, var(--ion-col-breakpoint-sm-padding-end)), - bottom: var(--padding-bottom, var(--ion-col-breakpoint-sm-padding-bottom)), - start: var(--padding-start, var(--ion-col-breakpoint-sm-padding-start)), - ), - md: ( - top: var(--padding-top, var(--ion-col-breakpoint-md-padding-top)), - end: var(--padding-end, var(--ion-col-breakpoint-md-padding-end)), - bottom: var(--padding-bottom, var(--ion-col-breakpoint-md-padding-bottom)), - start: var(--padding-start, var(--ion-col-breakpoint-md-padding-start)), - ), - lg: ( - top: var(--padding-top, var(--ion-col-breakpoint-lg-padding-top)), - end: var(--padding-end, var(--ion-col-breakpoint-lg-padding-end)), - bottom: var(--padding-bottom, var(--ion-col-breakpoint-lg-padding-bottom)), - start: var(--padding-start, var(--ion-col-breakpoint-lg-padding-start)), - ), - xl: ( - top: var(--padding-top, var(--ion-col-breakpoint-xl-padding-top)), - end: var(--padding-end, var(--ion-col-breakpoint-xl-padding-end)), - bottom: var(--padding-bottom, var(--ion-col-breakpoint-xl-padding-bottom)), - start: var(--padding-start, var(--ion-col-breakpoint-xl-padding-start)), - ), - xxl: ( - top: var(--padding-top, var(--ion-col-breakpoint-xxl-padding-top)), - end: var(--padding-end, var(--ion-col-breakpoint-xxl-padding-end)), - bottom: var(--padding-bottom, var(--ion-col-breakpoint-xxl-padding-bottom)), - start: var(--padding-start, var(--ion-col-breakpoint-xxl-padding-start)), - ), -); diff --git a/core/src/components/col/test/col.spec.ts b/core/src/components/col/test/col.spec.ts index 8bdbfa0d5e3..8a6a37df763 100644 --- a/core/src/components/col/test/col.spec.ts +++ b/core/src/components/col/test/col.spec.ts @@ -1,119 +1,619 @@ +import { config } from '@global/config'; +import { forceUpdate } from '@stencil/core'; import { newSpecPage } from '@stencil/core/testing'; +import { resetBreakpointListeners, resetScreenBreakpoints } from '@utils/breakpoints'; +// TODO(FW-7557): Remove this when the deprecated props are removed. +import { resetColDeprecationWarnings } from '../col.deprecations'; import { Col } from '../col'; +import type { IonColValue } from '../col.interface'; -describe('ion-col: class', () => { - it('sets --internal-col-span for size="N"', async () => { - const page = await newSpecPage({ - components: [Col], - html: ``, - }); +/** + * The default widths the assertions below are written against: + * + * | xs | sm | md | lg | xl | xxl | + * | -- | --- | --- | --- | ---- | ---- | + * | 0 | 576 | 768 | 992 | 1200 | 1400 | + */ - const col = page.body.querySelector('ion-col')!; - expect(col.classList.contains('col-size')).toBe(true); - expect(col.style.getPropertyValue('--internal-col-span')).toBe('3'); +describe('ion-col', () => { + // TODO(FW-7557): Remove this when the deprecated props are removed. + // Warnings are printed once per page, so they must be forgotten between + // cases or only the first test to trigger one would see it. + beforeEach(() => { + resetColDeprecationWarnings(); }); - it('applies col-auto for size="auto"', async () => { - const page = await newSpecPage({ - components: [Col], - html: ``, + describe('class', () => { + it('sets --internal-col-span for size="N"', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); + + const col = page.body.querySelector('ion-col')!; + expect(col.classList.contains('col-size')).toBe(true); + expect(col.style.getPropertyValue('--internal-col-span')).toBe('3'); }); - const col = page.body.querySelector('ion-col')!; - expect(col.classList.contains('col-auto')).toBe(true); - }); + it('applies col-auto for size="auto"', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('applies no sizing class for a valueless size attribute', async () => { - const page = await newSpecPage({ - components: [Col], - html: ``, + const col = page.body.querySelector('ion-col')!; + expect(col.classList.contains('col-auto')).toBe(true); }); - const col = page.body.querySelector('ion-col')!; - const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); - expect(sizeClasses).toEqual([]); - }); + it('applies no sizing class for a valueless size attribute', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('sets --internal-col-margin for offset="N"', async () => { - const page = await newSpecPage({ - components: [Col], - html: ``, + const col = page.body.querySelector('ion-col')!; + const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); + expect(sizeClasses).toEqual([]); }); - const col = page.body.querySelector('ion-col')!; - expect(col.classList.contains('col-offset')).toBe(true); - expect(col.style.getPropertyValue('--internal-col-margin')).toBe('2'); - }); + it('sets --internal-col-margin for offset="N"', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('sets the order style for order="N"', async () => { - const page = await newSpecPage({ - components: [Col], - html: ``, + const col = page.body.querySelector('ion-col')!; + expect(col.classList.contains('col-offset')).toBe(true); + expect(col.style.getPropertyValue('--internal-col-margin')).toBe('2'); }); - const col = page.body.querySelector('ion-col')!; - expect(col.style.order).toBe('5'); - }); + it('sets the order style for order="N"', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('applies no sizing class for a non-numeric size value', async () => { - const page = await newSpecPage({ - components: [Col], - html: ``, + const col = page.body.querySelector('ion-col')!; + expect(col.style.order).toBe('5'); }); - const col = page.body.querySelector('ion-col')!; - const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); - expect(sizeClasses).toEqual([]); - }); -}); + it('applies no sizing class for a non-numeric size value', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); -// TODO(FW-7557): Remove this when the push/pull props are removed. -describe('ion-col: deprecated push/pull props', () => { - let warnSpy: jest.SpyInstance; + const col = page.body.querySelector('ion-col')!; + const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); + expect(sizeClasses).toEqual([]); + }); - beforeEach(() => { - warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - }); + /** + * Frameworks pass `null` for an unset binding, so clearing a value has to + * fall back to the default flex layout instead of leaving the previous + * inline style behind. `null` is not part of `IonColValue`, which only + * allows it per breakpoint, hence the cast. + */ + it('clears the sizing when size is set to null', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - afterEach(() => { - warnSpy.mockRestore(); - }); + const col = page.body.querySelector('ion-col')!; + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + + (col as any).size = null; + forceUpdate(col); + await page.waitForChanges(); - it('warns when push is set', async () => { - await newSpecPage({ - components: [Col], - html: ``, + expect(col.classList.contains('col-size')).toBe(false); + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); }); - expect(warnSpy).toHaveBeenCalled(); - expect(warnSpy.mock.calls[0][0]).toEqual(expect.stringContaining('pull and push properties are deprecated')); - }); + it('clears the sizing when size is set to undefined', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); + + const col = page.body.querySelector('ion-col')!; + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + + col.size = undefined; + forceUpdate(col); + await page.waitForChanges(); + + expect(col.classList.contains('col-size')).toBe(false); + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + }); + + it('clears the offset when it is set to null', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); + + const col = page.body.querySelector('ion-col')!; + expect(col.style.getPropertyValue('--internal-col-margin')).toBe('2'); - it('warns when pull is set', async () => { - await newSpecPage({ - components: [Col], - html: ``, + (col as any).offset = null; + forceUpdate(col); + await page.waitForChanges(); + + expect(col.classList.contains('col-offset')).toBe(false); + expect(col.style.getPropertyValue('--internal-col-margin')).toBe(''); }); - expect(warnSpy).toHaveBeenCalled(); + it('clears the order when it is set to null', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); + + const col = page.body.querySelector('ion-col')!; + expect(col.style.order).toBe('5'); + + (col as any).order = null; + forceUpdate(col); + await page.waitForChanges(); + + expect(col.style.order).toBe(''); + }); }); - it('warns when a breakpoint-suffixed push variant is set', async () => { - await newSpecPage({ - components: [Col], - html: ``, + // TODO(FW-7557): Remove this when the push/pull props are removed. + describe('deprecated push/pull props', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(); }); - expect(warnSpy).toHaveBeenCalled(); + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('warns when push is set', async () => { + await newSpecPage({ + components: [Col], + html: ``, + }); + + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][0]).toEqual(expect.stringContaining('pull and push properties are deprecated')); + }); + + it('warns when pull is set', async () => { + await newSpecPage({ + components: [Col], + html: ``, + }); + + expect(warnSpy).toHaveBeenCalled(); + }); + + it('warns when a breakpoint-suffixed push variant is set', async () => { + await newSpecPage({ + components: [Col], + html: ``, + }); + + expect(warnSpy).toHaveBeenCalled(); + }); + + it('does not warn when only non-deprecated props are set', async () => { + await newSpecPage({ + components: [Col], + html: ``, + }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); }); - it('does not warn when only non-deprecated props are set', async () => { - await newSpecPage({ - components: [Col], - html: ``, + describe('responsive properties', () => { + let consoleWarnSpy: jest.SpyInstance; + + /** + * Answer `window.matchMedia` as a screen of the given width would, so + * `ion-col` resolves its responsive properties the same way it does in a + * browser at that size. + * + * `newSpecPage` installs its own mock window, so this has to be applied + * after the page is created rather than before. + */ + const setScreenWidth = (width: number) => { + (window as any).matchMedia = (query: string) => { + const minWidth = query.match(/\(min-width:\s*(\d+)px\)/); + + return { matches: minWidth !== null && width >= parseInt(minWidth[1], 10) }; + }; + }; + + // TODO(FW-7557): Remove this when the deprecated props are removed. + /** Render several columns at one screen width, for the page-level warnings. */ + const renderCols = async (screenWidth: number, html: string) => { + const page = await newSpecPage({ components: [Col], html: `${html}` }); + + setScreenWidth(screenWidth); + page.body.querySelectorAll('ion-col').forEach((col) => forceUpdate(col)); + await page.waitForChanges(); + + return page; + }; + + /** + * Render a column at the given screen width. Breakpoint objects passed + * in `props` are applied as JavaScript properties, which is the only way + * to set them. + */ + const renderCol = async (screenWidth: number, html: string, props: Record = {}) => { + const page = await newSpecPage({ components: [Col], html }); + const col = page.body.querySelector('ion-col')!; + + setScreenWidth(screenWidth); + Object.assign(col, props); + forceUpdate(col); + await page.waitForChanges(); + + return col; + }; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + config.set('screenBreakpoints', undefined as any); + resetScreenBreakpoints(); + resetBreakpointListeners(); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + config.set('screenBreakpoints', undefined as any); + resetScreenBreakpoints(); + resetBreakpointListeners(); + }); + + describe('with a single value', () => { + it('applies the size at every screen width', async () => { + const narrow = await renderCol(320, ``); + const wide = await renderCol(1600, ``); + + expect(narrow.style.getPropertyValue('--internal-col-span')).toBe('6'); + expect(wide.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); }); - expect(warnSpy).not.toHaveBeenCalled(); + describe('with a breakpoint object', () => { + it('uses the smallest breakpoint on a narrow screen', async () => { + const col = await renderCol(320, ``, { size: { xs: 12, md: 6, xxl: 3 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + }); + + it('uses the largest matching breakpoint', async () => { + const col = await renderCol(800, ``, { size: { xs: 12, md: 6, xxl: 3 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + + it('supports the xxl breakpoint', async () => { + const col = await renderCol(1440, ``, { size: { xs: 12, md: 6, xxl: 3 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('3'); + }); + + it('skips breakpoints that are not set', async () => { + const col = await renderCol(1000, ``, { size: { xs: 12, xxl: 3 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + }); + + it('accepts values as strings', async () => { + const col = await renderCol(800, ``, { size: { xs: '12', md: '6' } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + + it('resolves offset and order independently of size', async () => { + const col = await renderCol(800, ``, { + size: { xs: 12, md: 6 }, + offset: { xs: 0, md: 2 }, + order: { xs: 2, md: 1 }, + }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + expect(col.style.getPropertyValue('--internal-col-margin')).toBe('2'); + expect(col.style.getPropertyValue('order')).toBe('1'); + }); + + it('resets to the default flex layout when the value is an empty string', async () => { + const col = await renderCol(800, ``, { size: { xs: 12, md: '' } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + expect(col).not.toHaveClass('col-size'); + }); + + it('resets to the default flex layout when the value is null', async () => { + const col = await renderCol(800, ``, { size: { xs: 12, md: null } as IonColValue }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + expect(col).not.toHaveClass('col-size'); + }); + + it('supports "auto" at a breakpoint', async () => { + const col = await renderCol(800, ``, { size: { xs: 12, md: 'auto' } }); + + expect(col).toHaveClass('col-auto'); + }); + + it('is not settable as an HTML attribute', async () => { + const col = await renderCol(800, ``); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + }); + + it('clears the sizing when the object is replaced with null', async () => { + const page = await newSpecPage({ components: [Col], html: `` }); + const col = page.body.querySelector('ion-col')!; + + setScreenWidth(800); + col.size = { xs: 12, md: 6 }; + forceUpdate(col); + await page.waitForChanges(); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + + (col as any).size = null; + forceUpdate(col); + await page.waitForChanges(); + + expect(col.classList.contains('col-size')).toBe(false); + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + }); + }); + + // TODO(FW-7557): Remove this when the suffixed props are removed. + describe('with the deprecated suffixed properties', () => { + it('still narrows the unsuffixed value at a breakpoint', async () => { + const col = await renderCol(800, ``); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + + it('falls back to the unsuffixed value below the breakpoint', async () => { + const col = await renderCol(320, ``); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + }); + + it('resets to the default flex layout for a value-less attribute', async () => { + const col = await renderCol(800, ``); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + }); + + it('warns that they are deprecated', async () => { + await renderCol(800, ``); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-col] - The size-md property is deprecated'), + expect.anything() + ); + }); + + it('does not warn when only the unsuffixed property is set', async () => { + await renderCol(800, ``); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + /** + * The warning offers the breakpoint object that replaces the properties + * actually set on the column, so these assert the generated example + * rather than just that something was printed. + */ + it('offers the unsuffixed value as the xs entry', async () => { + await renderCol(800, ``); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('col.size = { xs: 3, md: 6 }'), + expect.anything() + ); + }); + + it('omits the xs entry when no unsuffixed value is set', async () => { + await renderCol(800, ``); + + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('col.size = { lg: 4 }'), expect.anything()); + }); + + it('quotes a value that is not a number', async () => { + await renderCol(800, ``); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('col.size = { xs: "auto", md: 6 }'), + expect.anything() + ); + }); + + it('names every breakpoint set for one property', async () => { + await renderCol(800, ``); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('The size-sm, size-lg properties are deprecated'), + expect.anything() + ); + }); + + it('warns once per property so each example matches its own', async () => { + await renderCol(800, ``); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('col.size = { xs: 3, md: 6 }'), + expect.anything() + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('col.order = { xs: 2, md: 1 }'), + expect.anything() + ); + }); + + /** + * Applications tend to use the deprecated properties on every column in + * a grid, so the same warning is printed once for the page rather than + * once per column. + */ + it('prints one warning for columns that would repeat it', async () => { + await renderCols(800, ``); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); + }); + + it('still warns separately for columns with different values', async () => { + await renderCols(800, ``); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(2); + }); + + it('is ignored, with a warning, when the property is a breakpoint object', async () => { + const col = await renderCol(800, ``, { size: { xs: 12 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-col] - The size-md property is ignored'), + expect.anything() + ); + }); + }); + + describe('with overridden screen breakpoints', () => { + const setScreenBreakpoints = (value: unknown) => { + config.set('screenBreakpoints', value as any); + resetScreenBreakpoints(); + }; + + it('resolves a breakpoint object against the configured widths', async () => { + setScreenBreakpoints({ sm: 200, md: 400, lg: 600, xl: 800, xxl: 1000 }); + + // 500px is above the overridden md (400) but below the default md (768) + const col = await renderCol(500, ``, { size: { xs: 12, md: 6 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + + it('does not match a breakpoint that was moved above the screen width', async () => { + setScreenBreakpoints({ sm: 900, md: 1000, lg: 1100, xl: 1200, xxl: 1400 }); + + // 800px matches the default md (768) but not the overridden one + const col = await renderCol(800, ``, { size: { xs: 12, md: 6 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + }); + + // TODO(FW-7557): Remove this when the suffixed props are removed. + it('applies to the deprecated suffixed properties too', async () => { + setScreenBreakpoints({ sm: 200, md: 400, lg: 600, xl: 800, xxl: 1000 }); + + const col = await renderCol(500, ``); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + }); + + /** + * The helper above installs `matchMedia` after the page is created, so the + * subscription in `connectedCallback` short-circuits. These install a + * listener-capable mock first, so crossing a threshold is observable. + */ + describe('when the screen crosses a breakpoint', () => { + let listeners: Array<() => void>; + let width: number; + + const installLiveMatchMedia = () => { + listeners = []; + (window as any).matchMedia = (query: string) => { + const min = Number(/\(min-width:\s*(\d+)px\)/.exec(query)?.[1] ?? NaN); + + return { + get matches() { + return Number.isFinite(min) && width >= min; + }, + addEventListener: (_type: string, listener: () => void) => listeners.push(listener), + removeEventListener: () => undefined, + }; + }; + }; + + const resize = (next: number) => { + width = next; + listeners.forEach((listener) => listener()); + }; + + /** + * `newSpecPage` replaces `window.matchMedia` with its own mock, so the + * column has to be appended after the live mock is installed for its + * `connectedCallback` to subscribe. + */ + const renderSubscribedCol = async () => { + width = 320; + const page = await newSpecPage({ components: [Col], html: `
` }); + + installLiveMatchMedia(); + + const col = page.doc.createElement('ion-col'); + page.body.querySelector('#host')!.appendChild(col); + await page.waitForChanges(); + + col.size = { xs: 12, md: 6 }; + forceUpdate(col); + await page.waitForChanges(); + + return { page, col }; + }; + + it('re-renders with the value for the new breakpoint', async () => { + const { page, col } = await renderSubscribedCol(); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + + resize(800); + await page.waitForChanges(); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + + it('re-resolves when reconnected after a threshold was crossed', async () => { + const { page, col } = await renderSubscribedCol(); + const host = page.body.querySelector('#host')!; + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + + col.remove(); + await page.waitForChanges(); + + resize(800); + await page.waitForChanges(); + + host.appendChild(col); + await page.waitForChanges(); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + + it('stops re-rendering once the column is disconnected', async () => { + const { page, col } = await renderSubscribedCol(); + + col.remove(); + await page.waitForChanges(); + + resize(800); + await page.waitForChanges(); + + // still the narrow value, because the subscription was removed + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + }); + }); }); }); diff --git a/core/src/components/grid/grid.interface.ts b/core/src/components/grid/grid.interface.ts index 1273fbaf388..ede58c67204 100644 --- a/core/src/components/grid/grid.interface.ts +++ b/core/src/components/grid/grid.interface.ts @@ -1,8 +1,10 @@ +import type { ScreenBreakpoint } from '@utils/breakpoints'; + import type { IonPadding } from '../../themes/themes.interfaces'; export type IonGridRecipe = { breakpoint?: { - [K in IonGridBreakpoint]?: { + [K in ScreenBreakpoint]?: { padding?: IonPadding; width?: string; }; @@ -10,7 +12,3 @@ export type IonGridRecipe = { columns?: number; }; - -// TODO(FW-7285): Replace with global breakpoints -export const ION_GRID_BREAKPOINTS = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl'] as const; -export type IonGridBreakpoint = (typeof ION_GRID_BREAKPOINTS)[number]; diff --git a/core/src/components/grid/grid.mixins.scss b/core/src/components/grid/grid.mixins.scss deleted file mode 100644 index c657396b444..00000000000 --- a/core/src/components/grid/grid.mixins.scss +++ /dev/null @@ -1,37 +0,0 @@ -@use "sass:map"; -@use "../../themes/common.globals" as globals; -@use "../../themes/mixins" as mixins; - -// Responsive Mixins -// -------------------------------------------------- - -// Creates a fixed width for the grid based on the screen size -// --------------------------------------------------------------------------------- - -@mixin make-grid-widths($widths, $breakpoints: globals.$screen-breakpoints) { - @each $breakpoint, $width in $widths { - @include mixins.media-breakpoint-up($breakpoint, $breakpoints) { - width: $width; - } - } - - max-width: 100%; -} - -// Adds padding to the element based on breakpoints -// --------------------------------------------------------------------------------- - -@mixin make-breakpoint-padding($paddings) { - @each $breakpoint in map.keys($paddings) { - @include mixins.media-breakpoint-up($breakpoint, globals.$screen-breakpoints) { - $padding: map.get($paddings, $breakpoint); - - @include mixins.padding( - map.get($padding, top), - map.get($padding, end), - map.get($padding, bottom), - map.get($padding, start) - ); - } - } -} diff --git a/core/src/components/grid/grid.scss b/core/src/components/grid/grid.scss index a4d6a02235a..98e6156917b 100644 --- a/core/src/components/grid/grid.scss +++ b/core/src/components/grid/grid.scss @@ -1,6 +1,4 @@ @use "../../themes/mixins" as mixins; -@use "./grid.mixins" as grid; -@use "./grid.vars" as vars; // Grid: Common Styles // -------------------------------------------------- @@ -46,8 +44,12 @@ * @prop --ion-grid-breakpoint-xl-width: Width of the fixed grid on xl screens * @prop --ion-grid-breakpoint-xxl-width: Width of the fixed grid on xxl screens */ - - @include grid.make-breakpoint-padding(vars.$grid-paddings); + @include mixins.padding( + var(--internal-grid-padding-top), + var(--internal-grid-padding-end), + var(--internal-grid-padding-bottom), + var(--internal-grid-padding-start) + ); display: block; } @@ -57,7 +59,10 @@ :host(.grid-fixed) { @include mixins.margin-horizontal(auto); - @include grid.make-grid-widths(vars.$grid-widths); flex: 1; + + width: var(--internal-grid-width); + + max-width: 100%; } diff --git a/core/src/components/grid/grid.tsx b/core/src/components/grid/grid.tsx index cf854a3dc31..145bea375ce 100644 --- a/core/src/components/grid/grid.tsx +++ b/core/src/components/grid/grid.tsx @@ -12,6 +12,12 @@ import { Component, Host, Prop, h } from '@stencil/core'; export class Grid implements ComponentInterface { /** * If `true`, the grid will have a fixed width based on the screen size. + * + * The width each breakpoint activates at can be changed with the + * `screenBreakpoints` config. The default widths assume the default + * thresholds, so lowering a threshold without lowering its width leaves the + * grid clamped by `max-width: 100%` and no longer fixed. Set the matching + * `--ion-grid-breakpoint-*-width` variables alongside the config. */ @Prop() fixed = false; diff --git a/core/src/components/grid/grid.vars.scss b/core/src/components/grid/grid.vars.scss deleted file mode 100644 index fe52ae158fb..00000000000 --- a/core/src/components/grid/grid.vars.scss +++ /dev/null @@ -1,52 +0,0 @@ -// Grid -// -------------------------------------------------- - -/// @prop - Per-breakpoint, per-side padding values -$grid-paddings: ( - xs: ( - top: var(--ion-grid-breakpoint-xs-padding-top), - end: var(--ion-grid-breakpoint-xs-padding-end), - bottom: var(--ion-grid-breakpoint-xs-padding-bottom), - start: var(--ion-grid-breakpoint-xs-padding-start), - ), - sm: ( - top: var(--ion-grid-breakpoint-sm-padding-top), - end: var(--ion-grid-breakpoint-sm-padding-end), - bottom: var(--ion-grid-breakpoint-sm-padding-bottom), - start: var(--ion-grid-breakpoint-sm-padding-start), - ), - md: ( - top: var(--ion-grid-breakpoint-md-padding-top), - end: var(--ion-grid-breakpoint-md-padding-end), - bottom: var(--ion-grid-breakpoint-md-padding-bottom), - start: var(--ion-grid-breakpoint-md-padding-start), - ), - lg: ( - top: var(--ion-grid-breakpoint-lg-padding-top), - end: var(--ion-grid-breakpoint-lg-padding-end), - bottom: var(--ion-grid-breakpoint-lg-padding-bottom), - start: var(--ion-grid-breakpoint-lg-padding-start), - ), - xl: ( - top: var(--ion-grid-breakpoint-xl-padding-top), - end: var(--ion-grid-breakpoint-xl-padding-end), - bottom: var(--ion-grid-breakpoint-xl-padding-bottom), - start: var(--ion-grid-breakpoint-xl-padding-start), - ), - xxl: ( - top: var(--ion-grid-breakpoint-xxl-padding-top), - end: var(--ion-grid-breakpoint-xxl-padding-end), - bottom: var(--ion-grid-breakpoint-xxl-padding-bottom), - start: var(--ion-grid-breakpoint-xxl-padding-start), - ), -); - -/// @prop - Per-breakpoint fixed grid width values -$grid-widths: ( - xs: var(--ion-grid-breakpoint-xs-width), - sm: var(--ion-grid-breakpoint-sm-width), - md: var(--ion-grid-breakpoint-md-width), - lg: var(--ion-grid-breakpoint-lg-width), - xl: var(--ion-grid-breakpoint-xl-width), - xxl: var(--ion-grid-breakpoint-xxl-width), -); diff --git a/core/src/components/grid/test/breakpoints/grid.e2e.ts b/core/src/components/grid/test/breakpoints/grid.e2e.ts new file mode 100644 index 00000000000..ff6d095fc58 --- /dev/null +++ b/core/src/components/grid/test/breakpoints/grid.e2e.ts @@ -0,0 +1,138 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * The test page overrides every screen breakpoint to well below its default + * (xs 0, sm 200, md 400, lg 600, xl 800, xxl 1000). Each width below resolves + * to a different breakpoint under the defaults than it does under the + * override, so passing proves the config is driving the styles. + * + * The padding and fixed width values are the ones the page sets through the + * per-breakpoint CSS variables, not the theme defaults. + * + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('grid: breakpoints'), () => { + /** + * One width per breakpoint. Every width except the `xs` one resolves to a + * smaller breakpoint under the default thresholds, so each case fails if + * the config is ignored. + */ + const cases = [ + // xs is 0 under both, but its fixed width is 100% so it tracks the + // viewport + { width: 150, breakpoint: 'xs', padding: '0px', fixedWidth: 150, size: '12', colPadding: '0px' }, + // sm under the override, xs by default + { width: 350, breakpoint: 'sm', padding: '12px', fixedWidth: 180, size: '6', colPadding: '6px' }, + // md under the override, xs by default (below the 576 default sm) + { width: 500, breakpoint: 'md', padding: '28px', fixedWidth: 360, size: '4', colPadding: '14px' }, + // lg under the override, sm by default + { width: 650, breakpoint: 'lg', padding: '48px', fixedWidth: 560, size: '3', colPadding: '24px' }, + // xl under the override, md by default + { width: 850, breakpoint: 'xl', padding: '72px', fixedWidth: 760, size: '2', colPadding: '36px' }, + // xxl under the override, lg by default + { width: 1100, breakpoint: 'xxl', padding: '100px', fixedWidth: 960, size: '1', colPadding: '50px' }, + ]; + + for (const { width, breakpoint, padding, fixedWidth, size, colPadding } of cases) { + test(`should apply the ${breakpoint} padding at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + + const paddingTop = await page.locator('#padding-grid').evaluate((grid) => getComputedStyle(grid).paddingTop); + + expect(paddingTop).toBe(padding); + }); + + test(`should apply the ${breakpoint} fixed width at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + + const measuredWidth = await page.locator('#fixed-grid').evaluate((grid) => grid.getBoundingClientRect().width); + + // Allow 1px tolerance for sub-pixel rounding in the browser. + expect(measuredWidth).toBeGreaterThanOrEqual(fixedWidth - 1); + expect(measuredWidth).toBeLessThanOrEqual(fixedWidth + 1); + }); + + /** + * `ion-col` resolves its padding from the same media queries the grid + * does, so this covers both components being mapped by the injected + * breakpoint CSS. + */ + test(`should apply the ${breakpoint} column padding at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + + const paddingTop = await page + .locator('#padding-grid ion-col') + .first() + .evaluate((col) => getComputedStyle(col).paddingTop); + + expect(paddingTop).toBe(colPadding); + }); + + test(`should resolve the column size object at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + + const span = await page + .locator('.responsive-col') + .first() + .evaluate((col) => col.style.getPropertyValue('--internal-col-span')); + + expect(span).toBe(size); + }); + } + }); +}); + +/** + * Every case above loads the page at a fixed width. These resize an open page + * instead, which is what actually happens when a window is dragged: the + * components re-render from `onBreakpointChange` rather than on load. + * + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('grid: breakpoints on resize'), () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 350, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + }); + + test('should re-resolve column sizes without reloading', async ({ page }) => { + const col = page.locator('.responsive-col').first(); + const span = () => col.evaluate((el) => el.style.getPropertyValue('--internal-col-span')); + + expect(await span()).toBe('6'); + + await page.setViewportSize({ width: 1100, height: 800 }); + + await expect.poll(span).toBe('1'); + }); + + test('should re-resolve padding without reloading', async ({ page }) => { + const grid = page.locator('#padding-grid'); + const paddingTop = () => grid.evaluate((el) => getComputedStyle(el).paddingTop); + + expect(await paddingTop()).toBe('12px'); + + await page.setViewportSize({ width: 1100, height: 800 }); + + await expect.poll(paddingTop).toBe('100px'); + }); + + test('should resolve back down when the screen narrows again', async ({ page }) => { + const grid = page.locator('#padding-grid'); + + await page.setViewportSize({ width: 1100, height: 800 }); + await expect(grid).toHaveCSS('padding-top', '100px'); + + await page.setViewportSize({ width: 350, height: 800 }); + + await expect(grid).toHaveCSS('padding-top', '12px'); + }); + }); +}); diff --git a/core/src/components/grid/test/breakpoints/index.html b/core/src/components/grid/test/breakpoints/index.html new file mode 100644 index 00000000000..c526d7cca05 --- /dev/null +++ b/core/src/components/grid/test/breakpoints/index.html @@ -0,0 +1,371 @@ + + + + + Grid - Breakpoints + + + + + + + + + + + + + + Grid - Breakpoints + + + + +

Resize the window to step through the configured breakpoints.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BreakpointActivates atGrid paddingColumn paddingFixed widthColumns
xs0px0px0px100%12
sm200px12px6px180px6
md400px28px14px360px4
lg600px48px24px560px3
xl800px72px36px760px2
xxl1000px100px50px960px1
+ +

Responsive padding

+ + + +
ion-col
+
+ +
ion-col
+
+
+
+ +

Fixed width

+

+ The default fixed widths (540px at sm, up to 1320px at xxl) are sized for the default thresholds. Moving the + thresholds down without also moving these widths would leave the grid wider than the screen, where + max-width: 100% clamps it, so they are overridden to match. +

+ + + +
ion-grid[fixed]
+
+
+
+ +

Responsive column sizes

+

+ Each column spans xs 12, sm 6, md 4, lg 3, xl 2, xxl 1 of the 12 column grid, so the row goes from one column + per line to twelve as the screen grows. +

+ + + +
1
+
+ +
2
+
+ +
3
+
+ +
4
+
+ +
5
+
+ +
6
+
+ +
7
+
+ +
8
+
+ +
9
+
+ +
10
+
+ +
11
+
+ +
12
+
+
+
+
+ +
+ … + –px +
+ + +
+ + + + diff --git a/core/src/components/grid/test/fixed/grid.e2e.ts b/core/src/components/grid/test/fixed/grid.e2e.ts index c3eb9a8d3e7..553fac6be9a 100644 --- a/core/src/components/grid/test/fixed/grid.e2e.ts +++ b/core/src/components/grid/test/fixed/grid.e2e.ts @@ -25,16 +25,18 @@ const VIEWPORT_AT_BREAKPOINT = Object.fromEntries( */ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('grid: fixed'), () => { - test.beforeEach(async ({ page }) => { - await page.goto('/src/components/grid/test/fixed', config); - }); - for (const breakpoint of SCREEN_BREAKPOINT_NAMES) { test(`fixed grid matches the ${breakpoint} width token`, async ({ page }) => { const viewportWidth = VIEWPORT_AT_BREAKPOINT[breakpoint]; + + // Size the viewport before loading so the first render already + // resolves to this breakpoint. Resizing an open page would instead + // depend on the re-render landing before the measurement below. await page.setViewportSize({ width: viewportWidth, height: 800 }); + await page.goto('/src/components/grid/test/fixed', config); const grid = page.locator('ion-grid'); + const measuredWidth = await grid.evaluate((el) => el.getBoundingClientRect().width); const expected = ionGridBreakpoints[breakpoint]!.width!; diff --git a/core/src/components/grid/test/grid.spec.ts b/core/src/components/grid/test/grid.spec.ts new file mode 100644 index 00000000000..a824c036c3c --- /dev/null +++ b/core/src/components/grid/test/grid.spec.ts @@ -0,0 +1,19 @@ +import { forceUpdate } from '@stencil/core'; +import { newSpecPage } from '@stencil/core/testing'; + +import { Grid } from '../grid'; + +describe('ion-grid', () => { + it('applies grid-fixed only when fixed is set', async () => { + const page = await newSpecPage({ components: [Grid], html: `` }); + const grid = page.body.querySelector('ion-grid')!; + + expect(grid.classList.contains('grid-fixed')).toBe(false); + + grid.fixed = true; + forceUpdate(grid); + await page.waitForChanges(); + + expect(grid.classList.contains('grid-fixed')).toBe(true); + }); +}); diff --git a/core/src/components/grid/test/offsets-pull-push/grid.e2e.ts b/core/src/components/grid/test/offsets-pull-push/grid.e2e.ts index 078b855d083..3ff357d1fac 100644 --- a/core/src/components/grid/test/offsets-pull-push/grid.e2e.ts +++ b/core/src/components/grid/test/offsets-pull-push/grid.e2e.ts @@ -1,4 +1,4 @@ -// TODO(FW-7557): Remove this in a major release. +// TODO(FW-7557): Remove this in v11. import { expect } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; diff --git a/core/src/components/grid/test/padding/grid.e2e.ts b/core/src/components/grid/test/padding/grid.e2e.ts index 438f58bb440..37c77692321 100644 --- a/core/src/components/grid/test/padding/grid.e2e.ts +++ b/core/src/components/grid/test/padding/grid.e2e.ts @@ -188,5 +188,46 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => { expect(padding).toEqual({ top: '2px', end: '2px', bottom: '2px', start: '2px' }); }); + + test('should apply custom column padding at the xxl breakpoint', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 800 }); + await page.setContent( + ` + + + + + col + + + `, + config + ); + + const padding = await page.locator('ion-col').evaluate((col) => { + const styles = getComputedStyle(col); + + return { + top: styles.paddingTop, + end: styles.paddingInlineEnd, + bottom: styles.paddingBottom, + start: styles.paddingInlineStart, + }; + }); + + expect(padding).toEqual({ top: '9px', end: '9px', bottom: '9px', start: '9px' }); + }); }); }); diff --git a/core/src/global/ionic-global.ts b/core/src/global/ionic-global.ts index 8c276decc21..2a4b49a93a7 100644 --- a/core/src/global/ionic-global.ts +++ b/core/src/global/ionic-global.ts @@ -1,6 +1,6 @@ import { Build, getMode, setMode, getElement } from '@stencil/core'; import { printIonWarning } from '@utils/logging'; -import { applyComponentsTheme, applyGlobalTheme, getCustomTheme } from '@utils/theme'; +import { applyComponentsTheme, applyGlobalTheme, generateBreakpointCSS, getCustomTheme, injectCSS } from '@utils/theme'; import type { IonicConfig, Mode, Theme } from '../interface'; import { defaultTheme as baseTheme } from '../themes/base/default.tokens'; @@ -202,10 +202,13 @@ export const initialize = (userConfig: IonicConfig = {}) => { }); /** - * The breakpoint listeners are built from the configured widths, so they - * have to be rebuilt once the configuration is settled. This runs after the - * theme's config is merged, since a theme can set the widths itself. + * The media queries and the breakpoint listeners are both built from the + * configured widths, so they are left until the configuration has settled, + * which is after the theme's config is merged since a theme can set the + * widths itself. They are emitted whether or not a theme supplies the + * tokens, since an application can define the public variables itself. */ + injectCSS(generateBreakpointCSS()); refreshBreakpointListeners(); if (config.getBoolean('_testing')) { diff --git a/core/src/global/test/ionic-global.spec.ts b/core/src/global/test/ionic-global.spec.ts index 7b8b399c7a4..25f431de538 100644 --- a/core/src/global/test/ionic-global.spec.ts +++ b/core/src/global/test/ionic-global.spec.ts @@ -173,5 +173,21 @@ describe('Ionic Global', () => { unsubscribe(); }); + + /** + * The media queries are generated from the configured widths too, so they + * have to be emitted once the theme's config has been merged as well. + */ + it('should generate the breakpoint CSS from a theme that sets the widths', () => { + initialize({ customTheme: { config: { screenBreakpoints: { lg: 700 } } } } as any); + + const injected = Array.from(document.head.querySelectorAll('style')) + .map((style) => style.innerHTML) + .join('') + .replace(/\s+/g, ' '); + + expect(injected).toContain('@media (min-width: 700px) { ion-grid {'); + expect(injected).not.toContain('@media (min-width: 992px) { ion-grid {'); + }); }); }); diff --git a/core/src/utils/breakpoints.ts b/core/src/utils/breakpoints.ts index 284bdd4701c..0b074d875c5 100644 --- a/core/src/utils/breakpoints.ts +++ b/core/src/utils/breakpoints.ts @@ -334,15 +334,6 @@ export const matchBreakpoint = (breakpoint: string | undefined) => { return false; }; -/** - * Get the largest screen breakpoint that currently matches. - * - * @return The active breakpoint, or `undefined` when none match. `xs` always - * activates at `0px`, so this only happens before hydration, where components - * treat it as "no breakpoint reported". - */ -export const getActiveBreakpoint = (): ScreenBreakpoint | undefined => lastMatchingBreakpoint(matchBreakpoint); - /** * Subscribers notified when the active screen breakpoint changes, and the * `MediaQueryList` per breakpoint that drives them. diff --git a/core/src/utils/test/breakpoints.spec.ts b/core/src/utils/test/breakpoints.spec.ts index ccf1894c9b2..4d33fef1465 100644 --- a/core/src/utils/test/breakpoints.spec.ts +++ b/core/src/utils/test/breakpoints.spec.ts @@ -2,7 +2,6 @@ import { config } from '@global/config'; import { DEFAULT_SCREEN_BREAKPOINTS, - getActiveBreakpoint, getScreenBreakpointMediaQuery, getScreenBreakpointValue, getScreenBreakpoints, @@ -23,7 +22,8 @@ import { * too. * * Viewport widths in the window tests are picked to sit inside a single band, - * so 800 resolves to `md` because 768 <= 800 < 992. + * so 800 resolves to `md` because 768 <= 800 < 992. The one exception is the + * boundary test in `matchBreakpoint()`, which is on the edges on purpose. */ const EXPECTED_DEFAULT_SCREEN_BREAKPOINTS = { xs: 0, @@ -419,6 +419,26 @@ describe('screen breakpoints against the window', () => { expect(matchBreakpoint('xxxl')).toBe(false); }); + /** + * `min-width` is inclusive, so the width a breakpoint activates at belongs + * to that breakpoint and the pixel below it does not. + */ + it.each([ + ['sm', 576], + ['md', 768], + ['lg', 992], + ['xl', 1200], + ['xxl', 1400], + ])('should match %p at exactly %ipx but not below it', (breakpoint, threshold) => { + setWidth(threshold as number); + + expect(matchBreakpoint(breakpoint as string)).toBe(true); + + setWidth((threshold as number) - 1); + + expect(matchBreakpoint(breakpoint as string)).toBe(false); + }); + /** * `xs` starts at 0 and cannot be configured away from it, so it matches at * any width. Responsive values set on `xs` always apply. @@ -451,51 +471,6 @@ describe('screen breakpoints against the window', () => { }); }); - describe('getActiveBreakpoint()', () => { - it.each([ - [500, 'xs'], - [576, 'sm'], - [800, 'md'], - [1000, 'lg'], - [1300, 'xl'], - [1500, 'xxl'], - ])('should return %p -> %p', (viewport, expected) => { - setWidth(viewport); - - expect(getActiveBreakpoint()).toBe(expected); - }); - - /** - * `xs` activates at 0px and cannot be configured away from it, so some - * breakpoint always matches once the window can be queried. - */ - it('should fall back to xs at any width', () => { - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - - config.set('screenBreakpoints', { xs: 2000 } as any); - resetScreenBreakpoints(); - setWidth(100); - - expect(getActiveBreakpoint()).toBe('xs'); - - consoleWarnSpy.mockRestore(); - }); - - it('should return undefined when the window cannot be queried', () => { - (window as any).matchMedia = undefined; - - expect(getActiveBreakpoint()).toBeUndefined(); - }); - - it('should reflect the configured widths', () => { - config.set('screenBreakpoints', { xxl: 900 } as any); - resetScreenBreakpoints(); - setWidth(1000); - - expect(getActiveBreakpoint()).toBe('xxl'); - }); - }); - describe('onBreakpointChange()', () => { it('should notify the subscriber when a threshold is crossed', () => { const callback = jest.fn(); diff --git a/core/src/utils/test/theme.spec.ts b/core/src/utils/test/theme.spec.ts index 4302a20c351..03a8ffb1183 100644 --- a/core/src/utils/test/theme.spec.ts +++ b/core/src/utils/test/theme.spec.ts @@ -3,7 +3,11 @@ import { newSpecPage } from '@stencil/core/testing'; import { Buttons } from '../../components/buttons/buttons'; import { CardContent } from '../../components/card-content/card-content'; import { Chip } from '../../components/chip/chip'; +import { config } from '@global/config'; + +import { SCREEN_BREAKPOINT_NAMES, resetScreenBreakpoints } from '../breakpoints'; import { + generateBreakpointCSS, generateColorClasses, generateComponentsThemeCSS, generateCSSVars, @@ -844,6 +848,91 @@ describe('generateComponentsThemeCSS', () => { }); }); +describe('generateBreakpointCSS', () => { + const setScreenBreakpoints = (value: unknown) => { + config.set('screenBreakpoints', value as any); + resetScreenBreakpoints(); + }; + + afterEach(() => { + setScreenBreakpoints(undefined); + }); + + it('should map the public breakpoint tokens onto internal variables', () => { + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + for (const breakpoint of SCREEN_BREAKPOINT_NAMES) { + for (const side of ['top', 'end', 'bottom', 'start']) { + expect(css).toContain( + `--internal-grid-padding-${side}: var(--ion-grid-breakpoint-${breakpoint}-padding-${side});` + ); + expect(css).toContain( + `--internal-col-padding-${side}: var(--ion-col-breakpoint-${breakpoint}-padding-${side});` + ); + } + + expect(css).toContain(`--internal-grid-width: var(--ion-grid-breakpoint-${breakpoint}-width);`); + } + }); + + /** + * The smallest breakpoint always applies, so wrapping it would add a query + * that is always true and leave the others nothing to override. + */ + it('should emit the smallest breakpoint without a media query', () => { + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + expect(css).toContain('ion-grid { --internal-grid-padding-top: var(--ion-grid-breakpoint-xs-padding-top);'); + expect(css).not.toContain('@media (min-width: 0px)'); + }); + + /** + * The rules have equal specificity, so the media queries only override the + * smallest breakpoint by coming after it. + */ + it('should emit the smallest breakpoint before the queries that override it', () => { + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + expect(css.indexOf('--internal-grid-padding-top: var(--ion-grid-breakpoint-xs-padding-top);')).toBeLessThan( + css.indexOf('@media') + ); + }); + + it('should emit the remaining breakpoints at their default widths', () => { + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + expect(css).toContain('@media (min-width: 768px) { ion-grid {'); + expect(css).toContain('@media (min-width: 1400px) { ion-grid {'); + }); + + /** + * This is the reason the queries are built at runtime: a `@media` rule in a + * stylesheet cannot read the configured widths. + */ + it('should emit the configured widths rather than the defaults', () => { + setScreenBreakpoints({ md: 400 }); + + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + expect(css).toContain('@media (min-width: 400px) { ion-grid {'); + expect(css).not.toContain('@media (min-width: 768px) { ion-grid {'); + }); + + it('should emit the mapping for every component that resolves per breakpoint', () => { + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + expect(css).toContain('@media (min-width: 768px) { ion-col {'); + }); + + /** + * An application can set the public variables in its own CSS, so the mapping + * cannot depend on a theme supplying tokens for the component. + */ + it('should not depend on a theme supplying tokens', () => { + expect(generateBreakpointCSS()).not.toBe(''); + }); +}); + describe('generateColorClasses', () => { let consoleWarnSpy: jest.SpyInstance; diff --git a/core/src/utils/theme.ts b/core/src/utils/theme.ts index a9df8660f0b..36d3080ef3a 100644 --- a/core/src/utils/theme.ts +++ b/core/src/utils/theme.ts @@ -1,3 +1,4 @@ +import { SCREEN_BREAKPOINT_NAMES, getScreenBreakpoints } from '@utils/breakpoints'; import { printIonWarning } from '@utils/logging'; import type { Color, CssClassMap } from '../interface'; @@ -434,6 +435,55 @@ export const applyGlobalTheme = (baseTheme: any, userTheme?: any): any => { return mergedTheme; }; +/** + * The properties each component resolves per screen breakpoint, named after + * the public token they come from (e.g. `--ion-grid-breakpoint-md-width`). + */ +const BREAKPOINT_SCOPED_PROPERTIES: Record = { + 'ion-grid': ['padding-top', 'padding-end', 'padding-bottom', 'padding-start', 'width'], + 'ion-col': ['padding-top', 'padding-end', 'padding-bottom', 'padding-start'], +}; + +/** + * Map the public breakpoint tokens onto internal variables, inside a media + * query at the width each breakpoint is configured to activate at. + * + * Component styles read these internal variables instead of duplicating + * property rules for each breakpoint. The media query widths follow the + * `screenBreakpoints` config, which cannot be referenced dynamically by + * static stylesheet media queries. + * + * This CSS is generated regardless of whether a theme supplies the public + * tokens, since applications can define those tokens themselves. + * + * @returns CSS mapping public breakpoint tokens to internal variables. + */ +export const generateBreakpointCSS = (): string => { + const breakpoints = getScreenBreakpoints(); + + return Object.entries(BREAKPOINT_SCOPED_PROPERTIES) + .flatMap(([tag, properties]) => { + const name = tag.replace(/^ion-/, ''); + + return SCREEN_BREAKPOINT_NAMES.map((breakpoint) => { + const vars = properties + .map((property) => `--internal-${name}-${property}: var(--${tag}-breakpoint-${breakpoint}-${property});`) + .join('\n'); + + const rule = `${tag} { ${vars} }`; + + /** + * The smallest breakpoint applies without a media query, providing the + * default values that larger breakpoints override. + */ + return breakpoint === SCREEN_BREAKPOINT_NAMES[0] + ? rule + : `@media (min-width: ${breakpoints[breakpoint]}px) { ${rule} }`; + }); + }) + .join('\n'); +}; + /** * Generates component's themed CSS class with CSS variables * from its theme object From 4f2d4064f729058fdd1da991a7b0c90f7e120862 Mon Sep 17 00:00:00 2001 From: Brandy Smith Date: Wed, 14 Oct 2026 10:08:45 -0400 Subject: [PATCH 4/5] feat(gallery): resolve breakpoints from config (#31519) The gallery now uses the shared screen breakpoints and respects the `screenBreakpoints` config. Breakpoints are still matched against the gallery's own width rather than the width of the screen, which is now noted in the properties. --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> --- core/api.txt | 4 +- core/src/components.d.ts | 8 +- .../components/gallery/gallery-interface.ts | 15 +- core/src/components/gallery/gallery.spec.ts | 79 +++++ core/src/components/gallery/gallery.tsx | 43 +-- .../gallery/test/breakpoints/gallery.e2e.ts | 46 +++ .../gallery/test/breakpoints/index.html | 292 ++++++++++++++++++ 7 files changed, 445 insertions(+), 42 deletions(-) create mode 100644 core/src/components/gallery/test/breakpoints/gallery.e2e.ts create mode 100644 core/src/components/gallery/test/breakpoints/index.html diff --git a/core/api.txt b/core/api.txt index e9f5eb4d403..88d97384026 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1048,7 +1048,7 @@ ion-footer,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-footer,prop,translucent,boolean,false,false,false ion-gallery,shadow -ion-gallery,prop,columns,GalleryBreakpoints | number | string,{ +ion-gallery,prop,columns,BreakpointMap | number | string,{ xs: 2, sm: 3, md: 4, @@ -1056,7 +1056,7 @@ ion-gallery,prop,columns,GalleryBreakpoints | number | string,{ xl: 8, xxl: 10, },false,false -ion-gallery,prop,gap,GalleryBreakpoints | number | string,'16px',false,false +ion-gallery,prop,gap,BreakpointMap | number | string,'16px',false,false ion-gallery,prop,layout,"masonry" | "uniform",'uniform',false,false ion-gallery,prop,mode,"ios" | "md",undefined,false,false ion-gallery,prop,order,"best-fit" | "sequential" | undefined,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 4b93daad7f8..9e43ab8c87d 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1503,12 +1503,12 @@ export namespace Components { } interface IonGallery { /** - * The number of columns to display. Can be set as a number or an object of breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). + * The number of columns to display. Can be set as a number or an object of breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). Breakpoints are matched against the gallery's own width rather than the screen width. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. * @default { xs: 2, sm: 3, md: 4, lg: 6, xl: 8, xxl: 10, } */ "columns": GalleryColumns; /** - * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, CSS variables like `var(--app-gallery-gap)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. + * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, CSS variables like `var(--app-gallery-gap)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. Breakpoints are matched against the gallery's own width rather than the screen width. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. * @default '16px' */ "gap": GalleryGap; @@ -7379,12 +7379,12 @@ declare namespace LocalJSX { } interface IonGallery { /** - * The number of columns to display. Can be set as a number or an object of breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). + * The number of columns to display. Can be set as a number or an object of breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). Breakpoints are matched against the gallery's own width rather than the screen width. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. * @default { xs: 2, sm: 3, md: 4, lg: 6, xl: 8, xxl: 10, } */ "columns"?: GalleryColumns; /** - * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, CSS variables like `var(--app-gallery-gap)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. + * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, CSS variables like `var(--app-gallery-gap)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. Breakpoints are matched against the gallery's own width rather than the screen width. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. * @default '16px' */ "gap"?: GalleryGap; diff --git a/core/src/components/gallery/gallery-interface.ts b/core/src/components/gallery/gallery-interface.ts index 6ce4417bfca..cd7f0d4141c 100644 --- a/core/src/components/gallery/gallery-interface.ts +++ b/core/src/components/gallery/gallery-interface.ts @@ -1,11 +1,6 @@ -export interface GalleryBreakpoints { - xs?: T; - sm?: T; - md?: T; - lg?: T; - xl?: T; - xxl?: T; -} +import type { BreakpointMap } from '@utils/breakpoints'; -export type GalleryColumns = GalleryBreakpoints | string | number; -export type GalleryGap = GalleryBreakpoints | string | number; +export type GalleryBreakpoints = BreakpointMap; + +export type GalleryColumns = string | number | GalleryBreakpoints; +export type GalleryGap = string | number | GalleryBreakpoints; diff --git a/core/src/components/gallery/gallery.spec.ts b/core/src/components/gallery/gallery.spec.ts index 5ed9c1e40b9..a03aaad4c83 100644 --- a/core/src/components/gallery/gallery.spec.ts +++ b/core/src/components/gallery/gallery.spec.ts @@ -1,4 +1,6 @@ +import { config } from '@global/config'; import { newSpecPage } from '@stencil/core/testing'; +import { resetScreenBreakpoints } from '@utils/breakpoints'; import * as helpers from '@utils/helpers'; import * as logging from '@utils/logging'; @@ -1151,6 +1153,83 @@ describe('gallery', () => { }); }); }); + + /** + * The thresholds come from the `screenBreakpoints` config rather than a + * hardcoded map, so apps can override them. Each width below maps to a + * different breakpoint with the override than it does with the defaults, + * ensuring these tests fail if the config is ignored. + */ + describe('gallery: screenBreakpoints config', () => { + const OVERRIDE = { sm: 200, md: 400, lg: 600, xl: 800, xxl: 1000 }; + + const setScreenBreakpoints = (value: unknown) => { + config.set('screenBreakpoints', value as any); + resetScreenBreakpoints(); + }; + + beforeEach(() => { + setScreenBreakpoints(OVERRIDE); + }); + + afterEach(() => { + setScreenBreakpoints(undefined); + }); + + it('should resolve columns against the configured widths', () => { + const breakpoints = [ + // xs under both + { width: 150, expectedColumns: 3 }, + // sm under the override, xs by default + { width: 350, expectedColumns: 4 }, + // md under the override, xs by default + { width: 500, expectedColumns: 5 }, + // lg under the override, sm by default + { width: 650, expectedColumns: 7 }, + // xl under the override, md by default + { width: 850, expectedColumns: 9 }, + // xxl under the override, lg by default + { width: 1100, expectedColumns: 12 }, + ]; + + sharedGallery.columns = { xs: 3, sm: 4, md: 5, lg: 7, xl: 9, xxl: 12 }; + + breakpoints.forEach(({ width, expectedColumns }) => { + expect((sharedGallery as any).getColumnsForWidth(width)).toBe(expectedColumns); + }); + }); + + it('should resolve the gap against the configured widths', () => { + const breakpoints = [ + { width: 150, expectedGap: '2px' }, + { width: 350, expectedGap: '4px' }, + { width: 500, expectedGap: '8px' }, + { width: 650, expectedGap: '16px' }, + { width: 850, expectedGap: '32px' }, + { width: 1100, expectedGap: '64px' }, + ]; + + sharedGallery.gap = { xs: '2px', sm: '4px', md: '8px', lg: '16px', xl: '32px', xxl: '64px' }; + + breakpoints.forEach(({ width, expectedGap }) => { + expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); + }); + }); + + it('should resolve the default columns against the configured widths', () => { + // 500 is below the default md of 768, but at or above the configured 400 + expect((sharedGallery as any).getColumnsForWidth(500)).toBe(DEFAULT_COLUMNS['md']); + }); + + it('should fall back to the defaults when the config is removed', () => { + setScreenBreakpoints(undefined); + + sharedGallery.columns = { xs: 3, md: 5 }; + + // 500 resolves to xs again, since the default md is 768 + expect((sharedGallery as any).getColumnsForWidth(500)).toBe(3); + }); + }); }); describe('gallery: classes', () => { diff --git a/core/src/components/gallery/gallery.tsx b/core/src/components/gallery/gallery.tsx index 3b5b7168cea..9974e3c2137 100644 --- a/core/src/components/gallery/gallery.tsx +++ b/core/src/components/gallery/gallery.tsx @@ -1,5 +1,7 @@ import type { ComponentInterface } from '@stencil/core'; import { Component, Element, Host, Listen, Prop, Watch, h } from '@stencil/core'; +import type { ScreenBreakpoint } from '@utils/breakpoints'; +import { SCREEN_BREAKPOINT_NAMES, getScreenBreakpoints, isBreakpointMap } from '@utils/breakpoints'; import { isCssVariable, isValidLengthPercentage } from '@utils/css-value-validation'; import { raf } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; @@ -9,19 +11,6 @@ import { getIonTheme } from '../../global/ionic-global'; import { DEFAULT_COLUMNS, DEFAULT_GAP } from './gallery-constants'; import type { GalleryBreakpoints, GalleryColumns, GalleryGap } from './gallery-interface'; -// TODO(FW-7285): Replace with global breakpoints -const BREAKPOINTS = { - xs: 0, - sm: 576, - md: 768, - lg: 992, - xl: 1200, - xxl: 1400, -}; - -type GalleryBreakpoint = keyof typeof BREAKPOINTS; -const BREAKPOINT_ORDER: GalleryBreakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']; - /** * The tag of the component used to wrap each gallery item. */ @@ -74,6 +63,10 @@ export class Gallery implements ComponentInterface { /** * The number of columns to display. Can be set as a number or an object of * breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). + * + * Breakpoints are matched against the gallery's own width rather than the + * screen width. The width each breakpoint activates at can be changed with + * the `screenBreakpoints` config. */ @Prop() columns: GalleryColumns = DEFAULT_COLUMNS; @@ -84,6 +77,10 @@ export class Gallery implements ComponentInterface { * values). Can also be set as a breakpoint map * (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept * space-separated values or CSS keyword values like `inherit`, `auto`, etc. + * + * Breakpoints are matched against the gallery's own width rather than the + * screen width. The width each breakpoint activates at can be changed with + * the `screenBreakpoints` config. */ @Prop() gap: GalleryGap = DEFAULT_GAP; @@ -267,13 +264,6 @@ export class Gallery implements ComponentInterface { return isValidCssLength ? normalizedGap : undefined; } - /** - * Check if the value is a breakpoint map object. - */ - private isBreakpointMap(value: unknown): value is GalleryBreakpoints { - return typeof value === 'object' && value !== null && !Array.isArray(value); - } - /** * Check if the breakpoint map has any invalid values for the provided * sanitizer. A breakpoint map is invalid when there are no valid breakpoint @@ -286,7 +276,7 @@ export class Gallery implements ComponentInterface { ) { let hasBreakpointEntry = false; - for (const breakpoint of BREAKPOINT_ORDER) { + for (const breakpoint of SCREEN_BREAKPOINT_NAMES) { const value = breakpointMap[breakpoint]; if (value !== undefined) { hasBreakpointEntry = true; @@ -307,18 +297,19 @@ export class Gallery implements ComponentInterface { width: number, breakpointMap: GalleryBreakpoints, sanitizeProvided: (value: string | number | undefined) => T | undefined, - getSanitizedDefault: (breakpoint: GalleryBreakpoint) => T | undefined + getSanitizedDefault: (breakpoint: ScreenBreakpoint) => T | undefined ) { + const breakpoints = getScreenBreakpoints(); let resolvedValue: T | undefined; - for (const bp of BREAKPOINT_ORDER) { + for (const bp of SCREEN_BREAKPOINT_NAMES) { const providedValue = breakpointMap[bp]; const sanitizedProvided = sanitizeProvided(providedValue); const sanitizedDefault = getSanitizedDefault(bp); const resolved = providedValue === undefined || sanitizedProvided === undefined ? sanitizedDefault : sanitizedProvided; - if (resolved !== undefined && width >= BREAKPOINTS[bp]) { + if (resolved !== undefined && width >= breakpoints[bp]) { resolvedValue = resolved; } } @@ -414,7 +405,7 @@ export class Gallery implements ComponentInterface { */ private getColumnsForWidth(width: number) { const { columns } = this; - const isBreakpointColumns = this.isBreakpointMap(columns); + const isBreakpointColumns = isBreakpointMap(columns); const hasInvalidBreakpointColumns = isBreakpointColumns && this.hasInvalidBreakpointMap(columns, (value) => this.sanitizeColumns(value)); @@ -440,7 +431,7 @@ export class Gallery implements ComponentInterface { const { gap } = this; const providedGap = gap ?? DEFAULT_GAP; - const isBreakpointGap = this.isBreakpointMap(providedGap); + const isBreakpointGap = isBreakpointMap(providedGap); const hasInvalidBreakpointGap = isBreakpointGap && this.hasInvalidBreakpointMap(providedGap, (value) => this.sanitizeGap(value)); const sanitizedGap = isBreakpointGap diff --git a/core/src/components/gallery/test/breakpoints/gallery.e2e.ts b/core/src/components/gallery/test/breakpoints/gallery.e2e.ts new file mode 100644 index 00000000000..39d28f2be1b --- /dev/null +++ b/core/src/components/gallery/test/breakpoints/gallery.e2e.ts @@ -0,0 +1,46 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; +import type { E2EPage } from '@utils/test/playwright'; + +/** + * The test page configures every breakpoint below its default + * (xs 0, sm 200, md 400, lg 600, xl 800, xxl 1000). + * + * Only the first test verifies that the configured breakpoints are honored: + * 500px resolves to md with the override but xs with the defaults. The second + * verifies that the gallery resolves against its own width rather than the + * viewport, which is why it also passes with the defaults, both widths + * resolving to the same breakpoint; the gallery fills the viewport by default, + * so its width tracks the window unless a test explicitly narrows it. + * + * This behavior is the same across modes and directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('gallery: breakpoints'), () => { + const columnsFor = (page: E2EPage) => + page.locator('#gallery').evaluate((el: HTMLElement) => el.style.getPropertyValue('--internal-gallery-columns')); + + test('should resolve columns against the configured breakpoints', async ({ page }) => { + // 500px is above the configured md (400), so it resolves to md and the + // page's md value of 3. With the defaults it would be xs, since sm does + // not start until 576, and give 1 instead. + await page.setViewportSize({ width: 500, height: 800 }); + await page.goto('/src/components/gallery/test/breakpoints', config); + + expect(await columnsFor(page)).toBe('3'); + }); + + test('should resolve against its own width rather than the viewport', async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 800 }); + await page.goto('/src/components/gallery/test/breakpoints', config); + + // Filling the viewport, the gallery is xxl and takes the default columns. + expect(await columnsFor(page)).toBe('10'); + + // Narrowing only the gallery drops it to xs, without the viewport moving. + await page.locator('#gallery-sizer').evaluate((el: HTMLElement) => (el.style.width = '150px')); + + await expect.poll(() => columnsFor(page)).toBe('1'); + }); + }); +}); diff --git a/core/src/components/gallery/test/breakpoints/index.html b/core/src/components/gallery/test/breakpoints/index.html new file mode 100644 index 00000000000..26d5dea55c3 --- /dev/null +++ b/core/src/components/gallery/test/breakpoints/index.html @@ -0,0 +1,292 @@ + + + + + Gallery - Breakpoints + + + + + + + + + + + + + +

+ Resize the window to step through the breakpoints, or drag the handle in the gallery's lower corner to narrow + the gallery on its own — it resolves against its own width, not the window's. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BreakpointActivates atColumns
xs0px1
sm200px3
md400px3
lg600px6
xl800px8
xxl1000px10
+ +

+ Columns come from { xs: 1, md: 3 }. The breakpoints it does not set fall back to the gallery's + own defaults. +

+ + +
+ +
+ … + –px +
+
+ + + + From c4a1a405b51896ceff55ba1168a32bb957e1018d Mon Sep 17 00:00:00 2001 From: Brandy Smith Date: Wed, 14 Oct 2026 10:08:45 -0400 Subject: [PATCH 5/5] feat(split-pane): resolve breakpoints from config (#31521) Issue number: internal --------- ## What is the current behavior? The `when` property accepts a CSS media query, a boolean, or one of the shortcut expressions `"xs"` through `"xl"` and `"never"`. The shortcuts map to fixed widths that cannot be changed. The default value is `'(min-width: 992px)'`. ## What is the new behavior? - The shortcuts resolve through the global screen breakpoints, so changing a breakpoint with the `screenBreakpoints` config also changes the width at which the split pane appears. - `"xxl"` is now a supported shortcut. - The default value is now the `"lg"` shortcut. It resolves to the same `992px` unless `lg` is configured otherwise. - Media queries, booleans and `"never"` behave exactly as before. ## Does this introduce a breaking change? - [x] Yes - [ ] No The default value of `when` changed from `'(min-width: 992px)'` to `'lg'`. The default behavior is unchanged, but code that reads or compares `when` against the old literal needs updating. ## Other information [Preview](https://ionic-framework-git-fw-7285-5-split-pane-ionic1.vercel.app/src/components/split-pane/test/when/) --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> --- BREAKING.md | 5 + core/api.txt | 2 +- core/src/components.d.ts | 8 +- core/src/components/split-pane/split-pane.tsx | 66 +++++- .../split-pane/test/split-pane.spec.ts | 66 ++++++ .../split-pane/test/when/index.html | 127 ++++++++++++ .../split-pane/test/when/split-pane.e2e.ts | 188 ++++++++++++++++++ 7 files changed, 447 insertions(+), 15 deletions(-) create mode 100644 core/src/components/split-pane/test/split-pane.spec.ts 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..8399a3cbf69 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, onBreakpointChange } 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; }; /** @@ -33,6 +49,7 @@ const QUERY: { [key: string]: string } = { }) export class SplitPane implements ComponentInterface { private rmL?: () => void; + private unsubscribeBreakpoint?: () => void; @Element() el!: HTMLElement; @State() visible = false; @@ -55,8 +72,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 @@ -82,11 +105,34 @@ 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()); + + /** + * 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; @@ -118,7 +164,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/split-pane.spec.ts b/core/src/components/split-pane/test/split-pane.spec.ts new file mode 100644 index 00000000000..a33c326eca9 --- /dev/null +++ b/core/src/components/split-pane/test/split-pane.spec.ts @@ -0,0 +1,66 @@ +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(); + }); + + /** + * `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(); + }); +}); 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..5c746f11348 --- /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 `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: HTMLIonSplitPaneElement) => 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: HTMLIonSplitPaneElement) => (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: HTMLIonSplitPaneElement) => (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/); + }); + }); + }); +});