diff --git a/BREAKING.md b/BREAKING.md index f9e61176941..940912eacfc 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -123,7 +123,9 @@ This is a comprehensive list of the breaking changes introduced in the major ver The following breaking changes apply to `ion-col`: 1. `--ion-grid-column-padding-*` CSS variables have been replaced with per-side, per-breakpoint tokens in the `col` namespace. [1](#version-10x-col-padding-variables) -2. Theme classes (`ion-col.md`, `ion-col.ios`) are no longer supported. [2](#version-10x-col-theme-classes) +2. The breakpoint-suffixed `size`, `order` and `offset` properties have been deprecated. [2](#version-10x-col-deprecated-breakpoint-properties) +3. The `push` and `pull` properties have been deprecated. [3](#version-10x-col-deprecated-push-and-pull-properties) +4. Theme classes (`ion-col.md`, `ion-col.ios`) are no longer supported. [4](#version-10x-col-theme-classes)
Padding variables
@@ -133,86 +135,52 @@ Column padding was a single value per breakpoint and is now set per-side, and th |---|---|---| | `--ion-grid-column-padding-{bp}` | `IonCol.breakpoint.{bp}.padding.{top\|end\|bottom\|start}` | `--ion-col-breakpoint-{bp}-padding-{top\|end\|bottom\|start}` | -
Theme classes
- -Remove any instances that target the theme classes: `ion-col.md`, `ion-col.ios`. - -

Content

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

Datetime

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

Grid

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

Content

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

Datetime

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

Grid

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

Resize the window to step through the configured breakpoints.

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

Responsive padding

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

Fixed width

+

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

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

Responsive column sizes

+

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

+ + + +
1
+
+ +
2
+
+ +
3
+
+ +
4
+
+ +
5
+
+ +
6
+
+ +
7
+
+ +
8
+
+ +
9
+
+ +
10
+
+ +
11
+
+ +
12
+
+
+
+
+ +
+ … + –px +
+ + +
+ + + + diff --git a/core/src/components/grid/test/fixed/grid.e2e.ts b/core/src/components/grid/test/fixed/grid.e2e.ts index c3eb9a8d3e7..553fac6be9a 100644 --- a/core/src/components/grid/test/fixed/grid.e2e.ts +++ b/core/src/components/grid/test/fixed/grid.e2e.ts @@ -25,16 +25,18 @@ const VIEWPORT_AT_BREAKPOINT = Object.fromEntries( */ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('grid: fixed'), () => { - test.beforeEach(async ({ page }) => { - await page.goto('/src/components/grid/test/fixed', config); - }); - for (const breakpoint of SCREEN_BREAKPOINT_NAMES) { test(`fixed grid matches the ${breakpoint} width token`, async ({ page }) => { const viewportWidth = VIEWPORT_AT_BREAKPOINT[breakpoint]; + + // Size the viewport before loading so the first render already + // resolves to this breakpoint. Resizing an open page would instead + // depend on the re-render landing before the measurement below. await page.setViewportSize({ width: viewportWidth, height: 800 }); + await page.goto('/src/components/grid/test/fixed', config); const grid = page.locator('ion-grid'); + const measuredWidth = await grid.evaluate((el) => el.getBoundingClientRect().width); const expected = ionGridBreakpoints[breakpoint]!.width!; diff --git a/core/src/components/grid/test/grid.spec.ts b/core/src/components/grid/test/grid.spec.ts new file mode 100644 index 00000000000..a824c036c3c --- /dev/null +++ b/core/src/components/grid/test/grid.spec.ts @@ -0,0 +1,19 @@ +import { forceUpdate } from '@stencil/core'; +import { newSpecPage } from '@stencil/core/testing'; + +import { Grid } from '../grid'; + +describe('ion-grid', () => { + it('applies grid-fixed only when fixed is set', async () => { + const page = await newSpecPage({ components: [Grid], html: `` }); + const grid = page.body.querySelector('ion-grid')!; + + expect(grid.classList.contains('grid-fixed')).toBe(false); + + grid.fixed = true; + forceUpdate(grid); + await page.waitForChanges(); + + expect(grid.classList.contains('grid-fixed')).toBe(true); + }); +}); diff --git a/core/src/components/grid/test/offsets-pull-push/grid.e2e.ts b/core/src/components/grid/test/offsets-pull-push/grid.e2e.ts index 078b855d083..3ff357d1fac 100644 --- a/core/src/components/grid/test/offsets-pull-push/grid.e2e.ts +++ b/core/src/components/grid/test/offsets-pull-push/grid.e2e.ts @@ -1,4 +1,4 @@ -// TODO(FW-7557): Remove this in a major release. +// TODO(FW-7557): Remove this in v11. import { expect } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; diff --git a/core/src/components/grid/test/padding/grid.e2e.ts b/core/src/components/grid/test/padding/grid.e2e.ts index 438f58bb440..37c77692321 100644 --- a/core/src/components/grid/test/padding/grid.e2e.ts +++ b/core/src/components/grid/test/padding/grid.e2e.ts @@ -188,5 +188,46 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => { expect(padding).toEqual({ top: '2px', end: '2px', bottom: '2px', start: '2px' }); }); + + test('should apply custom column padding at the xxl breakpoint', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 800 }); + await page.setContent( + ` + + + + + col + + + `, + config + ); + + const padding = await page.locator('ion-col').evaluate((col) => { + const styles = getComputedStyle(col); + + return { + top: styles.paddingTop, + end: styles.paddingInlineEnd, + bottom: styles.paddingBottom, + start: styles.paddingInlineStart, + }; + }); + + expect(padding).toEqual({ top: '9px', end: '9px', bottom: '9px', start: '9px' }); + }); }); }); diff --git a/core/src/global/ionic-global.ts b/core/src/global/ionic-global.ts index 8c276decc21..2a4b49a93a7 100644 --- a/core/src/global/ionic-global.ts +++ b/core/src/global/ionic-global.ts @@ -1,6 +1,6 @@ import { Build, getMode, setMode, getElement } from '@stencil/core'; import { printIonWarning } from '@utils/logging'; -import { applyComponentsTheme, applyGlobalTheme, getCustomTheme } from '@utils/theme'; +import { applyComponentsTheme, applyGlobalTheme, generateBreakpointCSS, getCustomTheme, injectCSS } from '@utils/theme'; import type { IonicConfig, Mode, Theme } from '../interface'; import { defaultTheme as baseTheme } from '../themes/base/default.tokens'; @@ -202,10 +202,13 @@ export const initialize = (userConfig: IonicConfig = {}) => { }); /** - * The breakpoint listeners are built from the configured widths, so they - * have to be rebuilt once the configuration is settled. This runs after the - * theme's config is merged, since a theme can set the widths itself. + * The media queries and the breakpoint listeners are both built from the + * configured widths, so they are left until the configuration has settled, + * which is after the theme's config is merged since a theme can set the + * widths itself. They are emitted whether or not a theme supplies the + * tokens, since an application can define the public variables itself. */ + injectCSS(generateBreakpointCSS()); refreshBreakpointListeners(); if (config.getBoolean('_testing')) { diff --git a/core/src/global/test/ionic-global.spec.ts b/core/src/global/test/ionic-global.spec.ts index 7b8b399c7a4..25f431de538 100644 --- a/core/src/global/test/ionic-global.spec.ts +++ b/core/src/global/test/ionic-global.spec.ts @@ -173,5 +173,21 @@ describe('Ionic Global', () => { unsubscribe(); }); + + /** + * The media queries are generated from the configured widths too, so they + * have to be emitted once the theme's config has been merged as well. + */ + it('should generate the breakpoint CSS from a theme that sets the widths', () => { + initialize({ customTheme: { config: { screenBreakpoints: { lg: 700 } } } } as any); + + const injected = Array.from(document.head.querySelectorAll('style')) + .map((style) => style.innerHTML) + .join('') + .replace(/\s+/g, ' '); + + expect(injected).toContain('@media (min-width: 700px) { ion-grid {'); + expect(injected).not.toContain('@media (min-width: 992px) { ion-grid {'); + }); }); }); diff --git a/core/src/utils/breakpoints.ts b/core/src/utils/breakpoints.ts index 284bdd4701c..0b074d875c5 100644 --- a/core/src/utils/breakpoints.ts +++ b/core/src/utils/breakpoints.ts @@ -334,15 +334,6 @@ export const matchBreakpoint = (breakpoint: string | undefined) => { return false; }; -/** - * Get the largest screen breakpoint that currently matches. - * - * @return The active breakpoint, or `undefined` when none match. `xs` always - * activates at `0px`, so this only happens before hydration, where components - * treat it as "no breakpoint reported". - */ -export const getActiveBreakpoint = (): ScreenBreakpoint | undefined => lastMatchingBreakpoint(matchBreakpoint); - /** * Subscribers notified when the active screen breakpoint changes, and the * `MediaQueryList` per breakpoint that drives them. diff --git a/core/src/utils/test/breakpoints.spec.ts b/core/src/utils/test/breakpoints.spec.ts index ccf1894c9b2..4d33fef1465 100644 --- a/core/src/utils/test/breakpoints.spec.ts +++ b/core/src/utils/test/breakpoints.spec.ts @@ -2,7 +2,6 @@ import { config } from '@global/config'; import { DEFAULT_SCREEN_BREAKPOINTS, - getActiveBreakpoint, getScreenBreakpointMediaQuery, getScreenBreakpointValue, getScreenBreakpoints, @@ -23,7 +22,8 @@ import { * too. * * Viewport widths in the window tests are picked to sit inside a single band, - * so 800 resolves to `md` because 768 <= 800 < 992. + * so 800 resolves to `md` because 768 <= 800 < 992. The one exception is the + * boundary test in `matchBreakpoint()`, which is on the edges on purpose. */ const EXPECTED_DEFAULT_SCREEN_BREAKPOINTS = { xs: 0, @@ -419,6 +419,26 @@ describe('screen breakpoints against the window', () => { expect(matchBreakpoint('xxxl')).toBe(false); }); + /** + * `min-width` is inclusive, so the width a breakpoint activates at belongs + * to that breakpoint and the pixel below it does not. + */ + it.each([ + ['sm', 576], + ['md', 768], + ['lg', 992], + ['xl', 1200], + ['xxl', 1400], + ])('should match %p at exactly %ipx but not below it', (breakpoint, threshold) => { + setWidth(threshold as number); + + expect(matchBreakpoint(breakpoint as string)).toBe(true); + + setWidth((threshold as number) - 1); + + expect(matchBreakpoint(breakpoint as string)).toBe(false); + }); + /** * `xs` starts at 0 and cannot be configured away from it, so it matches at * any width. Responsive values set on `xs` always apply. @@ -451,51 +471,6 @@ describe('screen breakpoints against the window', () => { }); }); - describe('getActiveBreakpoint()', () => { - it.each([ - [500, 'xs'], - [576, 'sm'], - [800, 'md'], - [1000, 'lg'], - [1300, 'xl'], - [1500, 'xxl'], - ])('should return %p -> %p', (viewport, expected) => { - setWidth(viewport); - - expect(getActiveBreakpoint()).toBe(expected); - }); - - /** - * `xs` activates at 0px and cannot be configured away from it, so some - * breakpoint always matches once the window can be queried. - */ - it('should fall back to xs at any width', () => { - const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - - config.set('screenBreakpoints', { xs: 2000 } as any); - resetScreenBreakpoints(); - setWidth(100); - - expect(getActiveBreakpoint()).toBe('xs'); - - consoleWarnSpy.mockRestore(); - }); - - it('should return undefined when the window cannot be queried', () => { - (window as any).matchMedia = undefined; - - expect(getActiveBreakpoint()).toBeUndefined(); - }); - - it('should reflect the configured widths', () => { - config.set('screenBreakpoints', { xxl: 900 } as any); - resetScreenBreakpoints(); - setWidth(1000); - - expect(getActiveBreakpoint()).toBe('xxl'); - }); - }); - describe('onBreakpointChange()', () => { it('should notify the subscriber when a threshold is crossed', () => { const callback = jest.fn(); diff --git a/core/src/utils/test/theme.spec.ts b/core/src/utils/test/theme.spec.ts index 4302a20c351..03a8ffb1183 100644 --- a/core/src/utils/test/theme.spec.ts +++ b/core/src/utils/test/theme.spec.ts @@ -3,7 +3,11 @@ import { newSpecPage } from '@stencil/core/testing'; import { Buttons } from '../../components/buttons/buttons'; import { CardContent } from '../../components/card-content/card-content'; import { Chip } from '../../components/chip/chip'; +import { config } from '@global/config'; + +import { SCREEN_BREAKPOINT_NAMES, resetScreenBreakpoints } from '../breakpoints'; import { + generateBreakpointCSS, generateColorClasses, generateComponentsThemeCSS, generateCSSVars, @@ -844,6 +848,91 @@ describe('generateComponentsThemeCSS', () => { }); }); +describe('generateBreakpointCSS', () => { + const setScreenBreakpoints = (value: unknown) => { + config.set('screenBreakpoints', value as any); + resetScreenBreakpoints(); + }; + + afterEach(() => { + setScreenBreakpoints(undefined); + }); + + it('should map the public breakpoint tokens onto internal variables', () => { + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + for (const breakpoint of SCREEN_BREAKPOINT_NAMES) { + for (const side of ['top', 'end', 'bottom', 'start']) { + expect(css).toContain( + `--internal-grid-padding-${side}: var(--ion-grid-breakpoint-${breakpoint}-padding-${side});` + ); + expect(css).toContain( + `--internal-col-padding-${side}: var(--ion-col-breakpoint-${breakpoint}-padding-${side});` + ); + } + + expect(css).toContain(`--internal-grid-width: var(--ion-grid-breakpoint-${breakpoint}-width);`); + } + }); + + /** + * The smallest breakpoint always applies, so wrapping it would add a query + * that is always true and leave the others nothing to override. + */ + it('should emit the smallest breakpoint without a media query', () => { + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + expect(css).toContain('ion-grid { --internal-grid-padding-top: var(--ion-grid-breakpoint-xs-padding-top);'); + expect(css).not.toContain('@media (min-width: 0px)'); + }); + + /** + * The rules have equal specificity, so the media queries only override the + * smallest breakpoint by coming after it. + */ + it('should emit the smallest breakpoint before the queries that override it', () => { + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + expect(css.indexOf('--internal-grid-padding-top: var(--ion-grid-breakpoint-xs-padding-top);')).toBeLessThan( + css.indexOf('@media') + ); + }); + + it('should emit the remaining breakpoints at their default widths', () => { + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + expect(css).toContain('@media (min-width: 768px) { ion-grid {'); + expect(css).toContain('@media (min-width: 1400px) { ion-grid {'); + }); + + /** + * This is the reason the queries are built at runtime: a `@media` rule in a + * stylesheet cannot read the configured widths. + */ + it('should emit the configured widths rather than the defaults', () => { + setScreenBreakpoints({ md: 400 }); + + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + expect(css).toContain('@media (min-width: 400px) { ion-grid {'); + expect(css).not.toContain('@media (min-width: 768px) { ion-grid {'); + }); + + it('should emit the mapping for every component that resolves per breakpoint', () => { + const css = generateBreakpointCSS().replace(/\s+/g, ' '); + + expect(css).toContain('@media (min-width: 768px) { ion-col {'); + }); + + /** + * An application can set the public variables in its own CSS, so the mapping + * cannot depend on a theme supplying tokens for the component. + */ + it('should not depend on a theme supplying tokens', () => { + expect(generateBreakpointCSS()).not.toBe(''); + }); +}); + describe('generateColorClasses', () => { let consoleWarnSpy: jest.SpyInstance; diff --git a/core/src/utils/theme.ts b/core/src/utils/theme.ts index a9df8660f0b..36d3080ef3a 100644 --- a/core/src/utils/theme.ts +++ b/core/src/utils/theme.ts @@ -1,3 +1,4 @@ +import { SCREEN_BREAKPOINT_NAMES, getScreenBreakpoints } from '@utils/breakpoints'; import { printIonWarning } from '@utils/logging'; import type { Color, CssClassMap } from '../interface'; @@ -434,6 +435,55 @@ export const applyGlobalTheme = (baseTheme: any, userTheme?: any): any => { return mergedTheme; }; +/** + * The properties each component resolves per screen breakpoint, named after + * the public token they come from (e.g. `--ion-grid-breakpoint-md-width`). + */ +const BREAKPOINT_SCOPED_PROPERTIES: Record = { + 'ion-grid': ['padding-top', 'padding-end', 'padding-bottom', 'padding-start', 'width'], + 'ion-col': ['padding-top', 'padding-end', 'padding-bottom', 'padding-start'], +}; + +/** + * Map the public breakpoint tokens onto internal variables, inside a media + * query at the width each breakpoint is configured to activate at. + * + * Component styles read these internal variables instead of duplicating + * property rules for each breakpoint. The media query widths follow the + * `screenBreakpoints` config, which cannot be referenced dynamically by + * static stylesheet media queries. + * + * This CSS is generated regardless of whether a theme supplies the public + * tokens, since applications can define those tokens themselves. + * + * @returns CSS mapping public breakpoint tokens to internal variables. + */ +export const generateBreakpointCSS = (): string => { + const breakpoints = getScreenBreakpoints(); + + return Object.entries(BREAKPOINT_SCOPED_PROPERTIES) + .flatMap(([tag, properties]) => { + const name = tag.replace(/^ion-/, ''); + + return SCREEN_BREAKPOINT_NAMES.map((breakpoint) => { + const vars = properties + .map((property) => `--internal-${name}-${property}: var(--${tag}-breakpoint-${breakpoint}-${property});`) + .join('\n'); + + const rule = `${tag} { ${vars} }`; + + /** + * The smallest breakpoint applies without a media query, providing the + * default values that larger breakpoints override. + */ + return breakpoint === SCREEN_BREAKPOINT_NAMES[0] + ? rule + : `@media (min-width: ${breakpoints[breakpoint]}px) { ${rule} }`; + }); + }) + .join('\n'); +}; + /** * Generates component's themed CSS class with CSS variables * from its theme object