From a0a7f358f6e42153e17c968b404bbcc22caef703 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:07:54 -0400 Subject: [PATCH 01/10] feat(grid, col): resolve breakpoints from config --- BREAKING.md | 176 ++++--- core/api.txt | 6 +- core/src/components.d.ts | 56 ++- core/src/components/col/col.interface.ts | 14 +- core/src/components/col/col.scss | 3 +- core/src/components/col/col.tsx | 473 +++++++++++++----- core/src/components/grid/grid.interface.ts | 8 +- core/src/components/grid/grid.mixins.scss | 74 ++- core/src/components/grid/grid.scss | 8 +- core/src/components/grid/grid.tsx | 15 +- .../grid/test/breakpoints/index.html | 240 +++++++++ .../grid/test/offsets-pull-push/grid.e2e.ts | 2 +- 12 files changed, 817 insertions(+), 258 deletions(-) create mode 100644 core/src/components/grid/test/breakpoints/index.html diff --git a/BREAKING.md b/BREAKING.md index f9e61176941..6c022d272dd 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. @@ -332,6 +300,84 @@ To reorder two columns where column 1 has `size="9" push="3"` and column 2 has ` ``` +
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..82ce8e0add3 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. */ - "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. */ - "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 }`). */ - "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; } @@ -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. */ - "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. */ - "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 }`). */ - "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; } 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..e3b631e500b 100644 --- a/core/src/components/col/col.scss +++ b/core/src/components/col/col.scss @@ -41,7 +41,6 @@ (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.margin(0); box-sizing: border-box; @@ -55,6 +54,8 @@ overflow: auto; } +@include grid.make-breakpoint-padding(vars.$grid-column-paddings); + :host(.col-auto) { flex: 0 0 auto; } diff --git a/core/src/components/col/col.tsx b/core/src/components/col/col.tsx index 3c86d4956bf..827d09fa0c9 100644 --- a/core/src/components/col/col.tsx +++ b/core/src/components/col/col.tsx @@ -1,20 +1,24 @@ import type { ComponentInterface } from '@stencil/core'; -import { Component, Element, Host, Listen, Prop, forceUpdate, h } from '@stencil/core'; -import { matchBreakpoint } from '@utils/breakpoints'; +import { Component, Element, Host, Prop, forceUpdate, h } from '@stencil/core'; +import { + getActiveBreakpoint, + isBreakpointMap, + matchBreakpoint, + onBreakpointChange, + resolveBreakpointMap, +} from '@utils/breakpoints'; import { printIonWarning } from '@utils/logging'; -import type { IonColProperty, IonColStyle } from './col.interface'; -import { ION_COL_BREAKPOINTS } from './col.interface'; - -const BREAKPOINTS = ['', ...ION_COL_BREAKPOINTS] as const; +import type { IonColBreakpointValues, IonColProperty, IonColStyle, IonColValue } from './col.interface'; +import { ION_COL_PROPERTIES } from './col.interface'; +// TODO(FW-7557): Remove this in v11. /** - * 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. + * 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 RESIZE_DEBOUNCE = 100; +const LEGACY_BREAKPOINTS = ['xs', 'sm', 'md', 'lg', 'xl'] as const; /** * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component. @@ -25,272 +29,454 @@ const RESIZE_DEBOUNCE = 100; shadow: true, }) export class Col implements ComponentInterface { - private resizeTimeout: ReturnType | null = null; + private unsubscribeBreakpoint?: () => void; + + // TODO(FW-7557): Remove these in v11. + // Keep track of which deprecation warnings have been printed so they are + // not repeated on every re-render or screen resize. + private hasWarnedDeprecatedProps = false; + private hasWarnedIgnoredProps = false; @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. */ - @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. */ - @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 }`). */ - @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; - } - - this.resizeTimeout = setTimeout(() => { - forceUpdate(this); - }, RESIZE_DEBOUNCE); + connectedCallback() { + this.unsubscribeBreakpoint = onBreakpointChange(() => forceUpdate(this)); } disconnectedCallback() { - if (this.resizeTimeout) { - clearTimeout(this.resizeTimeout); - this.resizeTimeout = null; + this.unsubscribeBreakpoint?.(); + this.unsubscribeBreakpoint = undefined; + } + + // TODO(FW-7557): Remove this in v11. + /** + * Collect the values set through the deprecated suffixed properties (e.g. + * `size-md`) into a breakpoint object. + */ + private getLegacyBreakpointValues(property: IonColProperty): IonColBreakpointValues { + const values: IonColBreakpointValues = {}; + + for (const breakpoint of LEGACY_BREAKPOINTS) { + const suffixed = `${property}${breakpoint.charAt(0).toUpperCase()}${breakpoint.slice(1)}` as keyof this; + const value = this[suffixed] as string | undefined; + + if (value !== undefined) { + values[breakpoint] = value; + } } + + return values; } - // 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; + /** + * 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 (isBreakpointMap(value)) { + return resolveBreakpointMap(value, matchBreakpoint); + } - for (const breakpoint of BREAKPOINTS) { - const matches = matchBreakpoint(breakpoint); + const legacyValues = this.getLegacyBreakpointValues(property); + const matchedLegacy = resolveBreakpointMap(legacyValues, matchBreakpoint); - // 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; + return matchedLegacy !== undefined ? matchedLegacy : (value as string | number | undefined); + } - if (matches && columns !== undefined) { - matched = columns; + // TODO(FW-7557): Remove this in v11. + /** + * Warn when a deprecated suffixed property is set, and again if one is being + * silently ignored because the matching property is also set to a breakpoint + * object. + */ + private warnDeprecatedBreakpointProps() { + const used: string[] = []; + const ignored: string[] = []; + + for (const property of ION_COL_PROPERTIES) { + for (const breakpoint of Object.keys(this.getLegacyBreakpointValues(property))) { + const name = `${property}-${breakpoint}`; + used.push(name); + + if (isBreakpointMap(this[property])) { + ignored.push(name); + } } } - // Return the last matched columns since the breakpoints - // increase in size and we want to return the largest match - return matched; + if (used.length > 0 && !this.hasWarnedDeprecatedProps) { + this.hasWarnedDeprecatedProps = true; + printIonWarning( + `[ion-col] - The ${used.join( + ', ' + )} properties are deprecated. Set the "size", "order" and "offset" properties to an object of screen breakpoint values instead (e.g. size={{ xs: 12, md: 6 }}), which is also the only way to target the "xxl" breakpoint.`, + this.el + ); + } + + if (ignored.length > 0 && !this.hasWarnedIgnoredProps) { + this.hasWarnedIgnoredProps = true; + printIonWarning( + `[ion-col] - The ${ignored.join( + ', ' + )} properties are ignored because the matching property is set to an object of screen breakpoint values, which takes precedence.`, + this.el + ); + } } + /** + * 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,6 +494,14 @@ 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() { + this.warnDeprecatedBreakpointProps(); + } + + // TODO(FW-7557): Remove this in v11 — it exists only to warn about + // the deprecated pull and push properties. componentDidLoad() { if ( this.pull || @@ -338,6 +532,7 @@ export class Col implements ComponentInterface { return ( void; + /** * If `true`, the grid will have a fixed width based on the screen size. */ @Prop() fixed = false; + connectedCallback() { + this.unsubscribeBreakpoint = onBreakpointChange(() => forceUpdate(this)); + } + + disconnectedCallback() { + this.unsubscribeBreakpoint?.(); + this.unsubscribeBreakpoint = undefined; + } + render() { return ( + + + + Grid - Breakpoints + + + + + + + + + + + + + + Grid - Breakpoints + + + + +

+ Configured breakpoints: xs 0, sm 200, md 400, lg 600, xl 800, xxl 1000. Resize the window to step through + them. +

+ +

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/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'; From 63e7e3aade7daa94fad752f995090501010dc200 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:10:58 -0400 Subject: [PATCH 02/10] test(grid, col): add test coverage for the new screenBreakpoints usage --- core/src/components/col/test/col.spec.ts | 543 +++++++++++++++--- .../grid/test/breakpoints/grid.e2e.ts | 186 ++++++ .../components/grid/test/fixed/grid.e2e.ts | 16 +- core/src/components/grid/test/grid.spec.ts | 141 +++++ .../components/grid/test/padding/grid.e2e.ts | 41 ++ core/src/utils/test/breakpoints.spec.ts | 27 +- 6 files changed, 869 insertions(+), 85 deletions(-) create mode 100644 core/src/components/grid/test/breakpoints/grid.e2e.ts create mode 100644 core/src/components/grid/test/grid.spec.ts diff --git a/core/src/components/col/test/col.spec.ts b/core/src/components/col/test/col.spec.ts index 8bdbfa0d5e3..71e0916c806 100644 --- a/core/src/components/col/test/col.spec.ts +++ b/core/src/components/col/test/col.spec.ts @@ -1,119 +1,504 @@ +import { config } from '@global/config'; +import { forceUpdate } from '@stencil/core'; import { newSpecPage } from '@stencil/core/testing'; +import { resetBreakpointListeners, resetScreenBreakpoints } from '@utils/breakpoints'; 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: ``, +describe('ion-col', () => { + 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-size')).toBe(true); - expect(col.style.getPropertyValue('--internal-col-span')).toBe('3'); - }); + it('applies col-auto for size="auto"', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('applies col-auto for size="auto"', 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')!; - expect(col.classList.contains('col-auto')).toBe(true); - }); + it('applies no sizing class for a valueless size attribute', 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')!; + const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); + expect(sizeClasses).toEqual([]); }); - const col = page.body.querySelector('ion-col')!; - const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); - expect(sizeClasses).toEqual([]); - }); + it('sets --internal-col-margin for offset="N"', 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')!; + 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.classList.contains('col-offset')).toBe(true); - expect(col.style.getPropertyValue('--internal-col-margin')).toBe('2'); - }); + it('sets the order style for order="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.style.order).toBe('5'); }); - const col = page.body.querySelector('ion-col')!; - expect(col.style.order).toBe('5'); - }); + it('applies no sizing class for a non-numeric size value', 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')!; + const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); + expect(sizeClasses).toEqual([]); }); - const col = page.body.querySelector('ion-col')!; - const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); - expect(sizeClasses).toEqual([]); - }); -}); + /** + * 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: ``, + }); -// 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')!; + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); - beforeEach(() => { - warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - }); + (col as any).size = null; + forceUpdate(col); + await page.waitForChanges(); - afterEach(() => { - warnSpy.mockRestore(); - }); + expect(col.classList.contains('col-size')).toBe(false); + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + }); + + it('clears the sizing when size is set to undefined', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('warns when push is set', async () => { - 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(''); }); - expect(warnSpy).toHaveBeenCalled(); - expect(warnSpy.mock.calls[0][0]).toEqual(expect.stringContaining('pull and push properties are deprecated')); - }); + it('clears the offset when it is set to null', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('warns when pull is set', async () => { - await newSpecPage({ - components: [Col], - html: ``, + const col = page.body.querySelector('ion-col')!; + expect(col.style.getPropertyValue('--internal-col-margin')).toBe('2'); + + (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) }; + }; + }; + + /** + * 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(); }); - expect(warnSpy).not.toHaveBeenCalled(); + 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'); + }); + }); + + 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 properties are deprecated'), + expect.anything() + ); + }); + + it('does not warn when only the unsuffixed property is set', async () => { + await renderCol(800, ``); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + 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 properties are ignored'), + expect.anything() + ); + }); + }); + + describe('with overridden screen breakpoints', () => { + const setScreenBreakpoints = (breakpoints: Record) => { + config.set('screenBreakpoints', breakpoints as any); + resetScreenBreakpoints(); + }; + + it('resolves a breakpoint object against the configured widths', async () => { + setScreenBreakpoints({ xs: 0, 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({ xs: 0, 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({ xs: 0, 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('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/test/breakpoints/grid.e2e.ts b/core/src/components/grid/test/breakpoints/grid.e2e.ts new file mode 100644 index 00000000000..1f6c8ff1d22 --- /dev/null +++ b/core/src/components/grid/test/breakpoints/grid.e2e.ts @@ -0,0 +1,186 @@ +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' }, + // sm under the override, xs by default + { width: 350, breakpoint: 'sm', padding: '12px', fixedWidth: 180, size: '6' }, + // md under the override, xs by default (below the 576 default sm) + { width: 500, breakpoint: 'md', padding: '28px', fixedWidth: 360, size: '4' }, + // lg under the override, sm by default + { width: 650, breakpoint: 'lg', padding: '48px', fixedWidth: 560, size: '3' }, + // xl under the override, md by default + { width: 850, breakpoint: 'xl', padding: '72px', fixedWidth: 760, size: '2' }, + // xxl under the override, lg by default + { width: 1100, breakpoint: 'xxl', padding: '100px', fixedWidth: 960, size: '1' }, + ]; + + for (const { width, breakpoint, padding, fixedWidth, size } of cases) { + test(`should resolve the ${breakpoint} breakpoint at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + + await expect(page.locator('#padding-grid')).toHaveAttribute('screen-breakpoint', breakpoint); + }); + + 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); + }); + + 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 the breakpoint without reloading', async ({ page }) => { + const grid = page.locator('#padding-grid'); + await expect(grid).toHaveAttribute('screen-breakpoint', 'sm'); + + await page.setViewportSize({ width: 1100, height: 800 }); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'xxl'); + }); + + 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(col).toHaveAttribute('screen-breakpoint', 'xxl'); + + expect(await 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(grid).toHaveAttribute('screen-breakpoint', 'xxl'); + + expect(await 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).toHaveAttribute('screen-breakpoint', 'xxl'); + + await page.setViewportSize({ width: 350, height: 800 }); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'sm'); + }); + }); +}); + +/** + * Each rule is emitted twice: a `@media` copy scoped to + * `:host(:not([screen-breakpoint]))`, and an attribute copy. The first is the + * baseline for before a component hydrates and for when JavaScript never + * runs, so it has to resolve from the *compiled* thresholds rather than the + * `screenBreakpoints` config. + * + * Removing the attribute is how that state is reached here: the harness + * cannot load a page with JavaScript disabled, because its `goto` waits for a + * flag that scripts set. + * + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('grid: breakpoints before hydration'), () => { + test.beforeEach(async ({ page }) => { + // 800px resolves to the configured xl, but to the compiled md (768px) + await page.setViewportSize({ width: 800, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + }); + + test('should fall back to the compiled media query when no breakpoint is reported', async ({ page }) => { + const grid = page.locator('#padding-grid'); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'xl'); + expect(await grid.evaluate((el) => getComputedStyle(el).paddingTop)).toBe('72px'); + + await grid.evaluate((el) => el.removeAttribute('screen-breakpoint')); + + // md, because the baseline uses the compiled 768px and ignores the config + expect(await grid.evaluate((el) => getComputedStyle(el).paddingTop)).toBe('28px'); + }); + + test('should fall back for the fixed width too', async ({ page }) => { + const grid = page.locator('#fixed-grid'); + const width = () => grid.evaluate((el) => Math.round(el.getBoundingClientRect().width)); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'xl'); + expect(await width()).toBe(760); + + await grid.evaluate((el) => el.removeAttribute('screen-breakpoint')); + + expect(await width()).toBe(360); + }); + }); +}); diff --git a/core/src/components/grid/test/fixed/grid.e2e.ts b/core/src/components/grid/test/fixed/grid.e2e.ts index c3eb9a8d3e7..4e8511f561f 100644 --- a/core/src/components/grid/test/fixed/grid.e2e.ts +++ b/core/src/components/grid/test/fixed/grid.e2e.ts @@ -25,16 +25,24 @@ 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'); + + // Assert the breakpoint before measuring. A grid that has not resolved + // one yet still has the xs token applied, which is 100%, so the width + // assertion below would fail with the viewport width instead of + // naming the breakpoint. + await expect(grid).toHaveAttribute('screen-breakpoint', breakpoint); + 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..33619af1284 --- /dev/null +++ b/core/src/components/grid/test/grid.spec.ts @@ -0,0 +1,141 @@ +import { config } from '@global/config'; +import { forceUpdate } from '@stencil/core'; +import { newSpecPage } from '@stencil/core/testing'; + +import { resetBreakpointListeners, resetScreenBreakpoints } from '@utils/breakpoints'; +import { Grid } from '../grid'; + +/** + * The default widths the assertions below are written against: + * + * | xs | sm | md | lg | xl | xxl | + * | -- | --- | --- | --- | ---- | ---- | + * | 0 | 576 | 768 | 992 | 1200 | 1400 | + */ + +describe('ion-grid', () => { + let listeners: Array<() => void>; + let width: number; + + /** + * `newSpecPage` replaces `window.matchMedia` with its own mock, so the grid + * has to be appended after this is installed for `connectedCallback` to + * subscribe. + */ + const installMatchMedia = () => { + 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()); + }; + + const renderGrid = async (screenWidth: number) => { + width = screenWidth; + const page = await newSpecPage({ components: [Grid], html: `
` }); + + installMatchMedia(); + + const grid = page.doc.createElement('ion-grid'); + page.body.querySelector('#host')!.appendChild(grid); + await page.waitForChanges(); + + return { page, grid }; + }; + + beforeEach(() => { + config.set('screenBreakpoints', undefined as any); + resetScreenBreakpoints(); + resetBreakpointListeners(); + }); + + afterEach(() => { + config.set('screenBreakpoints', undefined as any); + resetScreenBreakpoints(); + resetBreakpointListeners(); + }); + + describe('screen breakpoint', () => { + it.each([ + [320, 'xs'], + [600, 'sm'], + [800, 'md'], + [1000, 'lg'], + [1300, 'xl'], + [1500, 'xxl'], + ])('reflects the active breakpoint at %ipx as "%s"', async (screenWidth, expected) => { + const { grid } = await renderGrid(screenWidth as number); + + expect(grid.getAttribute('screen-breakpoint')).toBe(expected); + }); + + it('reflects the configured widths rather than the defaults', async () => { + // Lower sm too, so the override stays in ascending order + config.set('screenBreakpoints', { sm: 300, md: 400 } as any); + resetScreenBreakpoints(); + + const { grid } = await renderGrid(500); + + // 500 is below the default md of 768, but at or above the configured 400 + expect(grid.getAttribute('screen-breakpoint')).toBe('md'); + }); + + it('omits the attribute when no breakpoint matches', async () => { + // xs normally matches at any width, so raise every breakpoint above the + // viewport, keeping them in ascending order + config.set('screenBreakpoints', { xs: 2000, sm: 2100, md: 2200, lg: 2300, xl: 2400, xxl: 2500 } as any); + resetScreenBreakpoints(); + + const { grid } = await renderGrid(100); + + expect(grid.hasAttribute('screen-breakpoint')).toBe(false); + }); + + it('re-renders with the new breakpoint when a threshold is crossed', async () => { + const { page, grid } = await renderGrid(320); + + expect(grid.getAttribute('screen-breakpoint')).toBe('xs'); + + resize(800); + await page.waitForChanges(); + + expect(grid.getAttribute('screen-breakpoint')).toBe('md'); + }); + + it('stops re-rendering once the grid is disconnected', async () => { + const { page, grid } = await renderGrid(320); + + grid.remove(); + await page.waitForChanges(); + + resize(800); + await page.waitForChanges(); + + expect(grid.getAttribute('screen-breakpoint')).toBe('xs'); + }); + }); + + it('applies grid-fixed only when fixed is set', async () => { + const { page, grid } = await renderGrid(800); + + 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/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/utils/test/breakpoints.spec.ts b/core/src/utils/test/breakpoints.spec.ts index f102a12576b..3455d5285ce 100644 --- a/core/src/utils/test/breakpoints.spec.ts +++ b/core/src/utils/test/breakpoints.spec.ts @@ -21,7 +21,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 `getActiveBreakpoint()`, which is on the edges on purpose. */ const EXPECTED_DEFAULT_SCREEN_BREAKPOINTS = { xs: 0, @@ -389,7 +390,7 @@ describe('screen breakpoints against the window', () => { describe('getActiveBreakpoint()', () => { it.each([ [500, 'xs'], - [576, 'sm'], + [600, 'sm'], [800, 'md'], [1000, 'lg'], [1300, 'xl'], @@ -400,6 +401,28 @@ describe('screen breakpoints against the window', () => { expect(getActiveBreakpoint()).toBe(expected); }); + /** + * `min-width` is inclusive, so the width a breakpoint activates at belongs + * to that breakpoint and the pixel below it belongs to the one under it. + * Every other case sits inside a band, so these are the only assertions + * covering the edges. + */ + it.each([ + ['sm', 576, 'xs'], + ['md', 768, 'sm'], + ['lg', 992, 'md'], + ['xl', 1200, 'lg'], + ['xxl', 1400, 'xl'], + ])('should activate %p at exactly %ipx and %p below it', (breakpoint, threshold, below) => { + setWidth(threshold as number); + + expect(getActiveBreakpoint()).toBe(breakpoint); + + setWidth((threshold as number) - 1); + + expect(getActiveBreakpoint()).toBe(below); + }); + it('should return undefined when no breakpoint matches', () => { // xs normally matches at any width, so raise it above the viewport too config.set('screenBreakpoints', { xs: 2000 } as any); From 207a16b7f31d8c015900f540ef10a42fdd0f9c11 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:47:40 -0400 Subject: [PATCH 03/10] docs(grid, col): mention the screenBreakpoints config --- core/src/components.d.ts | 16 ++++++++-------- core/src/components/col/col.tsx | 9 +++++++++ core/src/components/grid/grid.tsx | 3 +++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 82ce8e0add3..c3d14b58506 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -918,7 +918,7 @@ 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. 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 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"?: IonColValue; /** @@ -947,7 +947,7 @@ export namespace Components { */ "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. 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 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"?: IonColValue; /** @@ -1036,7 +1036,7 @@ 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. 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 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"?: IonColValue; /** @@ -1546,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. * @default false */ "fixed": boolean; @@ -6809,7 +6809,7 @@ 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. 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 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"?: IonColValue; /** @@ -6838,7 +6838,7 @@ declare namespace LocalJSX { */ "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. 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 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"?: IonColValue; /** @@ -6927,7 +6927,7 @@ 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. 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 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"?: IonColValue; /** @@ -7418,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. * @default false */ "fixed"?: boolean; diff --git a/core/src/components/col/col.tsx b/core/src/components/col/col.tsx index 827d09fa0c9..01317b6cb9b 100644 --- a/core/src/components/col/col.tsx +++ b/core/src/components/col/col.tsx @@ -46,6 +46,9 @@ export class Col implements ComponentInterface { * 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?: IonColValue; @@ -107,6 +110,9 @@ export class Col implements ComponentInterface { * 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?: IonColValue; @@ -296,6 +302,9 @@ export class Col implements ComponentInterface { * * 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?: IonColValue; diff --git a/core/src/components/grid/grid.tsx b/core/src/components/grid/grid.tsx index 45b79aca995..b379ad3e18b 100644 --- a/core/src/components/grid/grid.tsx +++ b/core/src/components/grid/grid.tsx @@ -15,6 +15,9 @@ 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. */ @Prop() fixed = false; From f5617142c283977ffd0e81f663a8420934858b86 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:48:18 -0400 Subject: [PATCH 04/10] test(grid, col): clean up for consistency --- core/src/components/col/test/col.spec.ts | 12 +++++- .../grid/test/breakpoints/grid.e2e.ts | 14 +++++++ core/src/components/grid/test/grid.spec.ts | 41 ++++++++++--------- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/core/src/components/col/test/col.spec.ts b/core/src/components/col/test/col.spec.ts index 71e0916c806..5b16d41f302 100644 --- a/core/src/components/col/test/col.spec.ts +++ b/core/src/components/col/test/col.spec.ts @@ -6,6 +6,14 @@ import { resetBreakpointListeners, resetScreenBreakpoints } from '@utils/breakpo import { Col } from '../col'; import type { IonColValue } from '../col.interface'; +/** + * The default widths the assertions below are written against: + * + * | xs | sm | md | lg | xl | xxl | + * | -- | --- | --- | --- | ---- | ---- | + * | 0 | 576 | 768 | 992 | 1200 | 1400 | + */ + describe('ion-col', () => { describe('class', () => { it('sets --internal-col-span for size="N"', async () => { @@ -392,8 +400,8 @@ describe('ion-col', () => { }); describe('with overridden screen breakpoints', () => { - const setScreenBreakpoints = (breakpoints: Record) => { - config.set('screenBreakpoints', breakpoints as any); + const setScreenBreakpoints = (value: unknown) => { + config.set('screenBreakpoints', value as any); resetScreenBreakpoints(); }; diff --git a/core/src/components/grid/test/breakpoints/grid.e2e.ts b/core/src/components/grid/test/breakpoints/grid.e2e.ts index 1f6c8ff1d22..92c24e15fda 100644 --- a/core/src/components/grid/test/breakpoints/grid.e2e.ts +++ b/core/src/components/grid/test/breakpoints/grid.e2e.ts @@ -125,6 +125,20 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { expect(await paddingTop()).toBe('100px'); }); + /** + * The subscription listens on `matchMedia`, so crossing a threshold has to + * take effect on the next frame rather than after a resize debounce. + */ + test('should update without a resize debounce when a threshold is crossed', async ({ page }) => { + const grid = page.locator('#padding-grid'); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'sm'); + + await page.setViewportSize({ width: 500, height: 800 }); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'md', { timeout: 250 }); + }); + test('should resolve back down when the screen narrows again', async ({ page }) => { const grid = page.locator('#padding-grid'); diff --git a/core/src/components/grid/test/grid.spec.ts b/core/src/components/grid/test/grid.spec.ts index 33619af1284..61a5564b344 100644 --- a/core/src/components/grid/test/grid.spec.ts +++ b/core/src/components/grid/test/grid.spec.ts @@ -17,6 +17,11 @@ describe('ion-grid', () => { let listeners: Array<() => void>; let width: number; + const setScreenBreakpoints = (value: unknown) => { + config.set('screenBreakpoints', value as any); + resetScreenBreakpoints(); + }; + /** * `newSpecPage` replaces `window.matchMedia` with its own mock, so the grid * has to be appended after this is installed for `connectedCallback` to @@ -56,14 +61,12 @@ describe('ion-grid', () => { }; beforeEach(() => { - config.set('screenBreakpoints', undefined as any); - resetScreenBreakpoints(); + setScreenBreakpoints(undefined); resetBreakpointListeners(); }); afterEach(() => { - config.set('screenBreakpoints', undefined as any); - resetScreenBreakpoints(); + setScreenBreakpoints(undefined); resetBreakpointListeners(); }); @@ -81,26 +84,26 @@ describe('ion-grid', () => { expect(grid.getAttribute('screen-breakpoint')).toBe(expected); }); - it('reflects the configured widths rather than the defaults', async () => { - // Lower sm too, so the override stays in ascending order - config.set('screenBreakpoints', { sm: 300, md: 400 } as any); - resetScreenBreakpoints(); + describe('with overridden screen breakpoints', () => { + it('reflects the configured widths rather than the defaults', async () => { + // Lower sm too, so the override stays in ascending order + setScreenBreakpoints({ sm: 300, md: 400 }); - const { grid } = await renderGrid(500); + const { grid } = await renderGrid(500); - // 500 is below the default md of 768, but at or above the configured 400 - expect(grid.getAttribute('screen-breakpoint')).toBe('md'); - }); + // 500 is below the default md of 768, but at or above the configured 400 + expect(grid.getAttribute('screen-breakpoint')).toBe('md'); + }); - it('omits the attribute when no breakpoint matches', async () => { - // xs normally matches at any width, so raise every breakpoint above the - // viewport, keeping them in ascending order - config.set('screenBreakpoints', { xs: 2000, sm: 2100, md: 2200, lg: 2300, xl: 2400, xxl: 2500 } as any); - resetScreenBreakpoints(); + it('omits the attribute when no breakpoint matches', async () => { + // xs normally matches at any width, so raise every breakpoint above the + // viewport, keeping them in ascending order + setScreenBreakpoints({ xs: 2000, sm: 2100, md: 2200, lg: 2300, xl: 2400, xxl: 2500 }); - const { grid } = await renderGrid(100); + const { grid } = await renderGrid(100); - expect(grid.hasAttribute('screen-breakpoint')).toBe(false); + expect(grid.hasAttribute('screen-breakpoint')).toBe(false); + }); }); it('re-renders with the new breakpoint when a threshold is crossed', async () => { From 232ea6dd50b3678a304cbfe9708471b2d3597de3 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:21:28 -0400 Subject: [PATCH 05/10] docs(grid): note the vars should update with the fixed property --- core/src/components.d.ts | 4 +- core/src/components/grid/grid.tsx | 5 +- .../grid/test/breakpoints/index.html | 80 ++++++++++++++++++- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/core/src/components.d.ts b/core/src/components.d.ts index c3d14b58506..4b93daad7f8 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1546,7 +1546,7 @@ export namespace Components { } interface IonGrid { /** - * 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. + * 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; @@ -7418,7 +7418,7 @@ declare namespace LocalJSX { } interface IonGrid { /** - * 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. + * 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/grid/grid.tsx b/core/src/components/grid/grid.tsx index b379ad3e18b..f7bc7d3e571 100644 --- a/core/src/components/grid/grid.tsx +++ b/core/src/components/grid/grid.tsx @@ -17,7 +17,10 @@ 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. + * `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/test/breakpoints/index.html b/core/src/components/grid/test/breakpoints/index.html index edb61bc5da5..4d30693645c 100644 --- a/core/src/components/grid/test/breakpoints/index.html +++ b/core/src/components/grid/test/breakpoints/index.html @@ -43,10 +43,59 @@ -

- Configured breakpoints: xs 0, sm 200, md 400, lg 600, xl 800, xxl 1000. Resize the window to step through - them. -

+

Resize the window to step through the configured breakpoints.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BreakpointActivates atPadding (top/bottom)Fixed widthColumns
xs0px0px100%12
sm200px12px180px6
md400px28px360px4
lg600px48px560px3
xl800px72px760px2
xxl1000px100px960px1

Responsive padding

@@ -194,6 +243,29 @@

Responsive column sizes

font-size: 0.8rem; } + .legend { + /** + * The global stylesheet collapses tables, and padding does not apply + * to a collapsed table, so `ion-padding-start` would be ignored. + */ + border-collapse: separate; + border-spacing: 0; + + margin-bottom: 16px; + font-size: 0.85em; + } + + .legend th, + .legend td { + padding: 2px 16px 2px 0; + text-align: start; + } + + .legend th { + color: #666; + font-weight: normal; + } + .note { color: #666; font-size: 0.8em; From e081f55a56bd74630f9484ade3f15fa5d9f22e13 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:37:49 -0400 Subject: [PATCH 06/10] style: update the warns to use JS syntax --- core/src/components/col/col.tsx | 15 +++++++++------ core/src/components/col/test/col.spec.ts | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/core/src/components/col/col.tsx b/core/src/components/col/col.tsx index 01317b6cb9b..f4618483444 100644 --- a/core/src/components/col/col.tsx +++ b/core/src/components/col/col.tsx @@ -420,6 +420,9 @@ export class Col implements ComponentInterface { const used: string[] = []; const ignored: string[] = []; + const describeProperties = (names: string[]) => + `The ${names.join(', ')} ${names.length === 1 ? 'property is' : 'properties are'}`; + for (const property of ION_COL_PROPERTIES) { for (const breakpoint of Object.keys(this.getLegacyBreakpointValues(property))) { const name = `${property}-${breakpoint}`; @@ -434,9 +437,9 @@ export class Col implements ComponentInterface { if (used.length > 0 && !this.hasWarnedDeprecatedProps) { this.hasWarnedDeprecatedProps = true; printIonWarning( - `[ion-col] - The ${used.join( - ', ' - )} properties are deprecated. Set the "size", "order" and "offset" properties to an object of screen breakpoint values instead (e.g. size={{ xs: 12, md: 6 }}), which is also the only way to target the "xxl" breakpoint.`, + `[ion-col] - ${describeProperties( + used + )} deprecated. Set the "size", "order" and "offset" properties to an object of screen breakpoint values instead (e.g. col.size = { xs: 12, md: 6 }), which is also the only way to target the "xxl" breakpoint.`, this.el ); } @@ -444,9 +447,9 @@ export class Col implements ComponentInterface { if (ignored.length > 0 && !this.hasWarnedIgnoredProps) { this.hasWarnedIgnoredProps = true; printIonWarning( - `[ion-col] - The ${ignored.join( - ', ' - )} properties are ignored because the matching property is set to an object of screen breakpoint values, which takes precedence.`, + `[ion-col] - ${describeProperties( + ignored + )} ignored because the matching property is set to an object of screen breakpoint values, which takes precedence.`, this.el ); } diff --git a/core/src/components/col/test/col.spec.ts b/core/src/components/col/test/col.spec.ts index 5b16d41f302..8bb8716d6a2 100644 --- a/core/src/components/col/test/col.spec.ts +++ b/core/src/components/col/test/col.spec.ts @@ -377,7 +377,7 @@ describe('ion-col', () => { await renderCol(800, ``); expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('[ion-col] - The size-md properties are deprecated'), + expect.stringContaining('[ion-col] - The size-md property is deprecated'), expect.anything() ); }); @@ -393,7 +393,7 @@ describe('ion-col', () => { expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('[ion-col] - The size-md properties are ignored'), + expect.stringContaining('[ion-col] - The size-md property is ignored'), expect.anything() ); }); From 401cd205644c2caab42283ee544dbbe9f5da0631 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:41:12 -0400 Subject: [PATCH 07/10] docs(breaking): update order example --- BREAKING.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/BREAKING.md b/BREAKING.md index 6c022d272dd..940912eacfc 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -290,11 +290,11 @@ 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"
From f4742ccef78a380ec1ff97bead99b6b9efbb6171 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:42:53 -0400 Subject: [PATCH 08/10] refactor(col): print each deprecation warning once with its replacement --- core/src/components/col/col.deprecations.ts | 166 ++++++++++++++++++++ core/src/components/col/col.tsx | 113 ++----------- core/src/components/col/test/col.spec.ts | 89 +++++++++++ 3 files changed, 265 insertions(+), 103 deletions(-) create mode 100644 core/src/components/col/col.deprecations.ts 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.tsx b/core/src/components/col/col.tsx index f4618483444..9f43c4609f6 100644 --- a/core/src/components/col/col.tsx +++ b/core/src/components/col/col.tsx @@ -7,18 +7,14 @@ import { onBreakpointChange, resolveBreakpointMap, } from '@utils/breakpoints'; -import { printIonWarning } from '@utils/logging'; -import type { IonColBreakpointValues, IonColProperty, IonColStyle, IonColValue } from './col.interface'; -import { ION_COL_PROPERTIES } from './col.interface'; - -// TODO(FW-7557): Remove this in v11. -/** - * 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; +// 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. @@ -31,12 +27,6 @@ const LEGACY_BREAKPOINTS = ['xs', 'sm', 'md', 'lg', 'xl'] as const; export class Col implements ComponentInterface { private unsubscribeBreakpoint?: () => void; - // TODO(FW-7557): Remove these in v11. - // Keep track of which deprecation warnings have been printed so they are - // not repeated on every re-render or screen resize. - private hasWarnedDeprecatedProps = false; - private hasWarnedIgnoredProps = false; - @Element() el!: HTMLIonColElement; /** @@ -372,26 +362,6 @@ export class Col implements ComponentInterface { this.unsubscribeBreakpoint = undefined; } - // TODO(FW-7557): Remove this in v11. - /** - * Collect the values set through the deprecated suffixed properties (e.g. - * `size-md`) into a breakpoint object. - */ - private getLegacyBreakpointValues(property: IonColProperty): IonColBreakpointValues { - const values: IonColBreakpointValues = {}; - - for (const breakpoint of LEGACY_BREAKPOINTS) { - const suffixed = `${property}${breakpoint.charAt(0).toUpperCase()}${breakpoint.slice(1)}` as keyof this; - const value = this[suffixed] as string | undefined; - - if (value !== undefined) { - values[breakpoint] = value; - } - } - - return values; - } - /** * Resolve the value of a responsive property for the current screen size. A * breakpoint object takes precedence over the deprecated suffixed properties, @@ -404,57 +374,12 @@ export class Col implements ComponentInterface { return resolveBreakpointMap(value, matchBreakpoint); } - const legacyValues = this.getLegacyBreakpointValues(property); + const legacyValues = getLegacyBreakpointValues(this, property); const matchedLegacy = resolveBreakpointMap(legacyValues, matchBreakpoint); return matchedLegacy !== undefined ? matchedLegacy : (value as string | number | undefined); } - // TODO(FW-7557): Remove this in v11. - /** - * Warn when a deprecated suffixed property is set, and again if one is being - * silently ignored because the matching property is also set to a breakpoint - * object. - */ - private warnDeprecatedBreakpointProps() { - const used: string[] = []; - const ignored: string[] = []; - - const describeProperties = (names: string[]) => - `The ${names.join(', ')} ${names.length === 1 ? 'property is' : 'properties are'}`; - - for (const property of ION_COL_PROPERTIES) { - for (const breakpoint of Object.keys(this.getLegacyBreakpointValues(property))) { - const name = `${property}-${breakpoint}`; - used.push(name); - - if (isBreakpointMap(this[property])) { - ignored.push(name); - } - } - } - - if (used.length > 0 && !this.hasWarnedDeprecatedProps) { - this.hasWarnedDeprecatedProps = true; - printIonWarning( - `[ion-col] - ${describeProperties( - used - )} deprecated. Set the "size", "order" and "offset" properties to an object of screen breakpoint values instead (e.g. col.size = { xs: 12, md: 6 }), which is also the only way to target the "xxl" breakpoint.`, - this.el - ); - } - - if (ignored.length > 0 && !this.hasWarnedIgnoredProps) { - this.hasWarnedIgnoredProps = true; - printIonWarning( - `[ion-col] - ${describeProperties( - ignored - )} ignored because the matching property is set to an object of screen breakpoint values, which takes precedence.`, - this.el - ); - } - } - /** * Resolve a responsive property to the column count it represents. * @@ -509,31 +434,13 @@ export class Col implements ComponentInterface { // TODO(FW-7557): Remove this in v11 — it exists only to warn about // the deprecated breakpoint properties. componentWillRender() { - this.warnDeprecatedBreakpointProps(); + 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/test/col.spec.ts b/core/src/components/col/test/col.spec.ts index 8bb8716d6a2..c60543f115b 100644 --- a/core/src/components/col/test/col.spec.ts +++ b/core/src/components/col/test/col.spec.ts @@ -3,6 +3,8 @@ 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'; @@ -15,6 +17,13 @@ import type { IonColValue } from '../col.interface'; */ 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(); + }); + describe('class', () => { it('sets --internal-col-span for size="N"', async () => { const page = await newSpecPage({ @@ -223,6 +232,18 @@ describe('ion-col', () => { }; }; + // 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 @@ -388,6 +409,74 @@ describe('ion-col', () => { 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 } }); From aaa5d7e058bbb3b2d715460b4a470ca0f6c5f5eb Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:16:43 -0400 Subject: [PATCH 09/10] refactor(grid, col): force a component update on re-connect --- core/src/components/col/col.tsx | 6 ++++++ core/src/components/col/test/col.spec.ts | 18 ++++++++++++++++++ core/src/components/grid/grid.tsx | 6 ++++++ core/src/components/grid/test/grid.spec.ts | 18 ++++++++++++++++++ 4 files changed, 48 insertions(+) diff --git a/core/src/components/col/col.tsx b/core/src/components/col/col.tsx index 9f43c4609f6..6bcec3e4fdc 100644 --- a/core/src/components/col/col.tsx +++ b/core/src/components/col/col.tsx @@ -355,6 +355,12 @@ export class Col implements ComponentInterface { connectedCallback() { this.unsubscribeBreakpoint = onBreakpointChange(() => forceUpdate(this)); + + /** + * Re-resolve the breakpoint in case the screen size changed to a new + * breakpoint while the component was disconnected. + */ + forceUpdate(this); } disconnectedCallback() { diff --git a/core/src/components/col/test/col.spec.ts b/core/src/components/col/test/col.spec.ts index c60543f115b..d77a3f4f66f 100644 --- a/core/src/components/col/test/col.spec.ts +++ b/core/src/components/col/test/col.spec.ts @@ -584,6 +584,24 @@ describe('ion-col', () => { 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(); diff --git a/core/src/components/grid/grid.tsx b/core/src/components/grid/grid.tsx index f7bc7d3e571..f3d100972e1 100644 --- a/core/src/components/grid/grid.tsx +++ b/core/src/components/grid/grid.tsx @@ -26,6 +26,12 @@ export class Grid implements ComponentInterface { connectedCallback() { this.unsubscribeBreakpoint = onBreakpointChange(() => forceUpdate(this)); + + /** + * Re-resolve the breakpoint in case the screen size changed to a new + * breakpoint while the component was disconnected. + */ + forceUpdate(this); } disconnectedCallback() { diff --git a/core/src/components/grid/test/grid.spec.ts b/core/src/components/grid/test/grid.spec.ts index 61a5564b344..595aac4eca4 100644 --- a/core/src/components/grid/test/grid.spec.ts +++ b/core/src/components/grid/test/grid.spec.ts @@ -130,6 +130,24 @@ describe('ion-grid', () => { }); }); + it('re-resolves when reconnected after a threshold was crossed', async () => { + const { page, grid } = await renderGrid(320); + const host = page.body.querySelector('#host')!; + + expect(grid.getAttribute('screen-breakpoint')).toBe('xs'); + + grid.remove(); + await page.waitForChanges(); + + resize(1300); + await page.waitForChanges(); + + host.appendChild(grid); + await page.waitForChanges(); + + expect(grid.getAttribute('screen-breakpoint')).toBe('xl'); + }); + it('applies grid-fixed only when fixed is set', async () => { const { page, grid } = await renderGrid(800); From 61b4b9f92723e9c13e4ead89651022730fc0c9d0 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:35:53 -0400 Subject: [PATCH 10/10] test(col): verify screen-breakpoint is updating properly --- core/src/components/col/test/col.spec.ts | 20 ++++++ .../grid/test/breakpoints/grid.e2e.ts | 31 ++++++--- .../grid/test/breakpoints/index.html | 63 ++++++++++++++++++- 3 files changed, 105 insertions(+), 9 deletions(-) diff --git a/core/src/components/col/test/col.spec.ts b/core/src/components/col/test/col.spec.ts index d77a3f4f66f..8a6d4ba0319 100644 --- a/core/src/components/col/test/col.spec.ts +++ b/core/src/components/col/test/col.spec.ts @@ -584,6 +584,26 @@ describe('ion-col', () => { expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); }); + /** + * The column's per-breakpoint padding is selected on in CSS through this + * attribute, so losing it would silently fall back to the compiled + * `@media` thresholds and ignore the `screenBreakpoints` config. + */ + it('reflects the resolved breakpoint on the host', async () => { + const { col } = await renderSubscribedCol(); + + expect(col.getAttribute('screen-breakpoint')).toBe('xs'); + }); + + it('updates the reflected breakpoint when a threshold is crossed', async () => { + const { page, col } = await renderSubscribedCol(); + + resize(800); + await page.waitForChanges(); + + expect(col.getAttribute('screen-breakpoint')).toBe('md'); + }); + it('re-resolves when reconnected after a threshold was crossed', async () => { const { page, col } = await renderSubscribedCol(); const host = page.body.querySelector('#host')!; diff --git a/core/src/components/grid/test/breakpoints/grid.e2e.ts b/core/src/components/grid/test/breakpoints/grid.e2e.ts index 92c24e15fda..fc978c2a411 100644 --- a/core/src/components/grid/test/breakpoints/grid.e2e.ts +++ b/core/src/components/grid/test/breakpoints/grid.e2e.ts @@ -22,20 +22,20 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { 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' }, + { 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' }, + { 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' }, + { 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' }, + { 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' }, + { 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' }, + { width: 1100, breakpoint: 'xxl', padding: '100px', fixedWidth: 960, size: '1', colPadding: '50px' }, ]; - for (const { width, breakpoint, padding, fixedWidth, size } of cases) { + for (const { width, breakpoint, padding, fixedWidth, size, colPadding } of cases) { test(`should resolve the ${breakpoint} breakpoint at ${width}px`, async ({ page }) => { await page.setViewportSize({ width, height: 800 }); await page.goto('/src/components/grid/test/breakpoints', config); @@ -63,6 +63,23 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { expect(measuredWidth).toBeLessThanOrEqual(fixedWidth + 1); }); + /** + * `ion-col` selects its padding on the `screen-breakpoint` attribute it + * reflects, so this covers the attribute reaching CSS rather than just + * the values resolved in JavaScript. + */ + 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); diff --git a/core/src/components/grid/test/breakpoints/index.html b/core/src/components/grid/test/breakpoints/index.html index 4d30693645c..535773c2ed5 100644 --- a/core/src/components/grid/test/breakpoints/index.html +++ b/core/src/components/grid/test/breakpoints/index.html @@ -49,7 +49,8 @@ Breakpoint Activates at - Padding (top/bottom) + Grid padding + Column padding Fixed width Columns @@ -57,6 +58,7 @@ xs 0px 0px + 0px 100% 12 @@ -64,6 +66,7 @@ sm 200px 12px + 6px 180px 6 @@ -71,6 +74,7 @@ md 400px 28px + 14px 360px 4 @@ -78,6 +82,7 @@ lg 600px 48px + 24px 560px 3 @@ -85,6 +90,7 @@ xl 800px 72px + 36px 760px 2 @@ -92,6 +98,7 @@ xxl 1000px 100px + 50px 960px 1 @@ -176,24 +183,76 @@

Responsive column sizes