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

Content

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

Datetime

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

Grid

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

Content

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

Datetime

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

Grid

+ +The following breaking changes apply to `ion-grid`: + +1. `--ion-grid-padding-*` CSS variables have been replaced with per-side, per-breakpoint tokens. [1](#version-10x-grid-padding-variables) +2. `--ion-grid-width-*` CSS variables for the fixed grid have been replaced with per-breakpoint tokens. [2](#version-10x-grid-fixed-width-variables) +3. Theme classes (`ion-grid.md`, `ion-grid.ios`) are no longer supported. [3](#version-10x-grid-theme-classes) + +
Padding variables
+ +Grid padding was a single value per breakpoint and is now set per-side. Use the new token structure for global styles, or the corresponding CSS variable for component-specific overrides: + +| Old (9.x) | New token (global) | New CSS variable (component-specific) | +|---|---|---| +| `--ion-grid-padding-{bp}` | `IonGrid.breakpoint.{bp}.padding.{top\|end\|bottom\|start}` | `--ion-grid-breakpoint-{bp}-padding-{top\|end\|bottom\|start}` | + +
Fixed width variables
+ +| Old (9.x) | New token (global) | New CSS variable (component-specific) | +|---|---|---| +| `--ion-grid-width-{bp}` | `IonGrid.breakpoint.{bp}.width` | `--ion-grid-breakpoint-{bp}-width` | +
Theme classes
Remove any instances that target the theme classes: `ion-grid.md`, `ion-grid.ios`. diff --git a/core/api.txt b/core/api.txt index e910f671bc4..e9f5eb4d403 100644 --- a/core/api.txt +++ b/core/api.txt @@ -749,13 +749,13 @@ ion-chip,css-prop,--ion-chip-state-focus-ring-width ion-col,shadow ion-col,prop,mode,"ios" | "md",undefined,false,false -ion-col,prop,offset,string | undefined,undefined,false,false +ion-col,prop,offset,BreakpointMap | number | string | undefined,undefined,false,false ion-col,prop,offsetLg,string | undefined,undefined,false,false ion-col,prop,offsetMd,string | undefined,undefined,false,false ion-col,prop,offsetSm,string | undefined,undefined,false,false ion-col,prop,offsetXl,string | undefined,undefined,false,false ion-col,prop,offsetXs,string | undefined,undefined,false,false -ion-col,prop,order,string | undefined,undefined,false,false +ion-col,prop,order,BreakpointMap | number | string | undefined,undefined,false,false ion-col,prop,orderLg,string | undefined,undefined,false,false ion-col,prop,orderMd,string | undefined,undefined,false,false ion-col,prop,orderSm,string | undefined,undefined,false,false @@ -773,7 +773,7 @@ ion-col,prop,pushMd,string | undefined,undefined,false,false ion-col,prop,pushSm,string | undefined,undefined,false,false ion-col,prop,pushXl,string | undefined,undefined,false,false ion-col,prop,pushXs,string | undefined,undefined,false,false -ion-col,prop,size,string | undefined,undefined,false,false +ion-col,prop,size,BreakpointMap | number | string | undefined,undefined,false,false ion-col,prop,sizeLg,string | undefined,undefined,false,false ion-col,prop,sizeMd,string | undefined,undefined,false,false ion-col,prop,sizeSm,string | undefined,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index a25784da787..c3d14b58506 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. * @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. * @default false */ "fixed"?: boolean; diff --git a/core/src/components/col/col.interface.ts b/core/src/components/col/col.interface.ts index 57472fa7274..fe6c682379f 100644 --- a/core/src/components/col/col.interface.ts +++ b/core/src/components/col/col.interface.ts @@ -1,19 +1,21 @@ +import type { BreakpointMap, ScreenBreakpoint } from '@utils/breakpoints'; + import type { IonPadding } from '../../themes/themes.interfaces'; -import { ION_GRID_BREAKPOINTS } from '../grid/grid.interface'; export type IonColRecipe = { breakpoint?: { - [K in IonColBreakpoint]?: { + [K in ScreenBreakpoint]?: { padding?: IonPadding; }; }; }; -// TODO(FW-7285): Replace with global breakpoints -export const ION_COL_BREAKPOINTS = ION_GRID_BREAKPOINTS; -export type IonColBreakpoint = (typeof ION_COL_BREAKPOINTS)[number]; +export type IonColBreakpointValues = BreakpointMap; + +export type IonColValue = string | number | IonColBreakpointValues; -export type IonColProperty = 'size' | 'order' | 'offset'; +export const ION_COL_PROPERTIES = ['size', 'order', 'offset'] as const; +export type IonColProperty = (typeof ION_COL_PROPERTIES)[number]; export type IonColStyle = { '--internal-col-margin'?: string; diff --git a/core/src/components/col/col.scss b/core/src/components/col/col.scss index a3889df490d..e3b631e500b 100644 --- a/core/src/components/col/col.scss +++ b/core/src/components/col/col.scss @@ -41,7 +41,6 @@ (100% - (var(--ion-grid-columns, 12) - 1) * var(--ion-row-gap, 0px)) / var(--ion-grid-columns, 12) ); - @include grid.make-breakpoint-padding(vars.$grid-column-paddings); @include mixins.margin(0); box-sizing: border-box; @@ -55,6 +54,8 @@ overflow: auto; } +@include grid.make-breakpoint-padding(vars.$grid-column-paddings); + :host(.col-auto) { flex: 0 0 auto; } diff --git a/core/src/components/col/col.tsx b/core/src/components/col/col.tsx index 3c86d4956bf..01317b6cb9b 100644 --- a/core/src/components/col/col.tsx +++ b/core/src/components/col/col.tsx @@ -1,20 +1,24 @@ import type { ComponentInterface } from '@stencil/core'; -import { Component, Element, Host, Listen, Prop, forceUpdate, h } from '@stencil/core'; -import { matchBreakpoint } from '@utils/breakpoints'; +import { Component, Element, Host, Prop, forceUpdate, h } from '@stencil/core'; +import { + getActiveBreakpoint, + isBreakpointMap, + matchBreakpoint, + onBreakpointChange, + resolveBreakpointMap, +} from '@utils/breakpoints'; import { printIonWarning } from '@utils/logging'; -import type { IonColProperty, IonColStyle } from './col.interface'; -import { ION_COL_BREAKPOINTS } from './col.interface'; - -const BREAKPOINTS = ['', ...ION_COL_BREAKPOINTS] as const; +import type { IonColBreakpointValues, IonColProperty, IonColStyle, IonColValue } from './col.interface'; +import { ION_COL_PROPERTIES } from './col.interface'; +// TODO(FW-7557): Remove this in v11. /** - * How long to wait (in ms) after the last `resize` event before re-rendering. - * `resize` fires rapidly while a window is dragged, so instead of re-rendering - * on every event we wait for it to settle and render once. 100ms is short - * enough to feel instant but long enough to batch the burst. + * The breakpoints the deprecated suffixed properties (e.g. `size-md`) cover. + * `xxl` is absent by design: it is only reachable through the breakpoint object + * form, so no new suffixed properties are introduced for it. */ -const RESIZE_DEBOUNCE = 100; +const LEGACY_BREAKPOINTS = ['xs', 'sm', 'md', 'lg', 'xl'] as const; /** * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component. @@ -25,272 +29,463 @@ const RESIZE_DEBOUNCE = 100; shadow: true, }) export class Col implements ComponentInterface { - private resizeTimeout: ReturnType | null = null; + private unsubscribeBreakpoint?: () => void; + + // TODO(FW-7557): Remove these in v11. + // Keep track of which deprecation warnings have been printed so they are + // not repeated on every re-render or screen resize. + private hasWarnedDeprecatedProps = false; + private hasWarnedIgnoredProps = false; @Element() el!: HTMLIonColElement; + /** - * The amount to offset the column, in terms of how many columns it should shift to the end - * of the total available. + * The amount to offset the column, in terms of how many columns it should + * shift to the end of the total available. + * + * Can be a single value that applies at every screen size, or an object of + * screen breakpoint values (e.g. `{ xs: 0, md: 2 }`), in which case the value + * for the largest matching breakpoint is used. + * + * 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; - } - - this.resizeTimeout = setTimeout(() => { - forceUpdate(this); - }, RESIZE_DEBOUNCE); + connectedCallback() { + this.unsubscribeBreakpoint = onBreakpointChange(() => forceUpdate(this)); } disconnectedCallback() { - if (this.resizeTimeout) { - clearTimeout(this.resizeTimeout); - this.resizeTimeout = null; + this.unsubscribeBreakpoint?.(); + this.unsubscribeBreakpoint = undefined; + } + + // TODO(FW-7557): Remove this in v11. + /** + * Collect the values set through the deprecated suffixed properties (e.g. + * `size-md`) into a breakpoint object. + */ + private getLegacyBreakpointValues(property: IonColProperty): IonColBreakpointValues { + const values: IonColBreakpointValues = {}; + + for (const breakpoint of LEGACY_BREAKPOINTS) { + const suffixed = `${property}${breakpoint.charAt(0).toUpperCase()}${breakpoint.slice(1)}` as keyof this; + const value = this[suffixed] as string | undefined; + + if (value !== undefined) { + values[breakpoint] = value; + } } + + return values; } - // Loop through all of the breakpoints to see if the media query - // matches and grab the column value from the relevant prop if so - private getColumns(property: IonColProperty): string | undefined { - let matched: string | undefined; + /** + * Resolve the value of a responsive property for the current screen size. A + * breakpoint object takes precedence over the deprecated suffixed properties, + * which in turn narrow the unsuffixed value. + */ + private getColumns(property: IonColProperty): string | number | null | undefined { + const value: IonColValue | undefined = this[property]; + + if (isBreakpointMap(value)) { + return resolveBreakpointMap(value, matchBreakpoint); + } - for (const breakpoint of BREAKPOINTS) { - const matches = matchBreakpoint(breakpoint); + const legacyValues = this.getLegacyBreakpointValues(property); + const matchedLegacy = resolveBreakpointMap(legacyValues, matchBreakpoint); - // Grab the value of the property, if it exists and our - // media query matches we return the value - const columns = this[(property + breakpoint.charAt(0).toUpperCase() + breakpoint.slice(1)) as keyof this] as - | string - | undefined; + return matchedLegacy !== undefined ? matchedLegacy : (value as string | number | undefined); + } - if (matches && columns !== undefined) { - matched = columns; + // TODO(FW-7557): Remove this in v11. + /** + * Warn when a deprecated suffixed property is set, and again if one is being + * silently ignored because the matching property is also set to a breakpoint + * object. + */ + private warnDeprecatedBreakpointProps() { + const used: string[] = []; + const ignored: string[] = []; + + for (const property of ION_COL_PROPERTIES) { + for (const breakpoint of Object.keys(this.getLegacyBreakpointValues(property))) { + const name = `${property}-${breakpoint}`; + used.push(name); + + if (isBreakpointMap(this[property])) { + ignored.push(name); + } } } - // Return the last matched columns since the breakpoints - // increase in size and we want to return the largest match - return matched; + if (used.length > 0 && !this.hasWarnedDeprecatedProps) { + this.hasWarnedDeprecatedProps = true; + printIonWarning( + `[ion-col] - The ${used.join( + ', ' + )} properties are deprecated. Set the "size", "order" and "offset" properties to an object of screen breakpoint values instead (e.g. size={{ xs: 12, md: 6 }}), which is also the only way to target the "xxl" breakpoint.`, + this.el + ); + } + + if (ignored.length > 0 && !this.hasWarnedIgnoredProps) { + this.hasWarnedIgnoredProps = true; + printIonWarning( + `[ion-col] - The ${ignored.join( + ', ' + )} properties are ignored because the matching property is set to an object of screen breakpoint values, which takes precedence.`, + this.el + ); + } } + /** + * Resolve a responsive property to the column count it represents. + * + * @param property The responsive property to resolve. + * @return The column count, or `undefined` when the property is unset or + * does not resolve to a number. + */ private getColumnValue(property: IonColProperty): number | undefined { const colPropertyValue = this.getColumns(property); /** - * Return early when no value matched any breakpoint, or when the - * matched value is an empty string. The empty-string case comes - * from a value-less HTML attribute (e.g. ``) — - * the attribute is present but carries no width, so the column - * falls back to the default flex layout. + * Return early when no value matched any breakpoint, or when the matched + * value is empty. An empty value (`''` or `null`) carries no width, so the + * column falls back to the default flex layout. */ - if (!colPropertyValue || colPropertyValue === '') { + if (colPropertyValue === undefined || colPropertyValue === null || colPropertyValue === '') { return; } - const valueNumber = parseInt(colPropertyValue, 10); + const valueNumber = typeof colPropertyValue === 'number' ? colPropertyValue : parseInt(colPropertyValue, 10); return isNaN(valueNumber) ? undefined : valueNumber; } /** - * Builds the inline custom properties that drive the token based calc() - * in the styles. + * Builds the inline custom properties that drive the token based calc() in + * the styles. * - * @param size The number of columns the column should span, or `undefined` for default flex. + * @param size The number of columns the column should span, or `undefined` + * for default flex. * @param order The flex order position of the column. * @param offset The number of columns to offset (margin) the column by. - * @return An object containing the custom properties to apply to the column's style. + * @return An object containing the custom properties to apply to the column's + * style. */ private getColumnStyle(size: number | undefined, order: number | undefined, offset: number | undefined): IonColStyle { const style: IonColStyle = {}; @@ -308,6 +503,14 @@ export class Col implements ComponentInterface { return style; } + // TODO(FW-7557): Remove this in v11 — it exists only to warn about + // the deprecated breakpoint properties. + componentWillRender() { + this.warnDeprecatedBreakpointProps(); + } + + // TODO(FW-7557): Remove this in v11 — it exists only to warn about + // the deprecated pull and push properties. componentDidLoad() { if ( this.pull || @@ -338,6 +541,7 @@ export class Col implements ComponentInterface { return ( { - 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 | + */ + +describe('ion-col', () => { + describe('class', () => { + it('sets --internal-col-span for size="N"', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); + + const col = page.body.querySelector('ion-col')!; + expect(col.classList.contains('col-size')).toBe(true); + expect(col.style.getPropertyValue('--internal-col-span')).toBe('3'); }); - const col = page.body.querySelector('ion-col')!; - expect(col.classList.contains('col-size')).toBe(true); - expect(col.style.getPropertyValue('--internal-col-span')).toBe('3'); - }); + it('applies col-auto for size="auto"', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('applies col-auto for size="auto"', async () => { - const page = await newSpecPage({ - components: [Col], - html: ``, + const col = page.body.querySelector('ion-col')!; + expect(col.classList.contains('col-auto')).toBe(true); }); - const col = page.body.querySelector('ion-col')!; - expect(col.classList.contains('col-auto')).toBe(true); - }); + it('applies no sizing class for a valueless size attribute', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('applies no sizing class for a valueless size attribute', async () => { - const page = await newSpecPage({ - components: [Col], - html: ``, + const col = page.body.querySelector('ion-col')!; + const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); + expect(sizeClasses).toEqual([]); }); - const col = page.body.querySelector('ion-col')!; - const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); - expect(sizeClasses).toEqual([]); - }); + it('sets --internal-col-margin for offset="N"', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('sets --internal-col-margin for offset="N"', async () => { - const page = await newSpecPage({ - components: [Col], - html: ``, + const col = page.body.querySelector('ion-col')!; + expect(col.classList.contains('col-offset')).toBe(true); + expect(col.style.getPropertyValue('--internal-col-margin')).toBe('2'); }); - const col = page.body.querySelector('ion-col')!; - expect(col.classList.contains('col-offset')).toBe(true); - expect(col.style.getPropertyValue('--internal-col-margin')).toBe('2'); - }); + it('sets the order style for order="N"', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('sets the order style for order="N"', async () => { - const page = await newSpecPage({ - components: [Col], - html: ``, + const col = page.body.querySelector('ion-col')!; + expect(col.style.order).toBe('5'); }); - const col = page.body.querySelector('ion-col')!; - expect(col.style.order).toBe('5'); - }); + it('applies no sizing class for a non-numeric size value', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); - it('applies no sizing class for a non-numeric size value', async () => { - const page = await newSpecPage({ - components: [Col], - html: ``, + const col = page.body.querySelector('ion-col')!; + const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); + expect(sizeClasses).toEqual([]); }); - const col = page.body.querySelector('ion-col')!; - const sizeClasses = Array.from(col.classList).filter((c) => c.startsWith('col-')); - expect(sizeClasses).toEqual([]); - }); -}); + /** + * Frameworks pass `null` for an unset binding, so clearing a value has to + * fall back to the default flex layout instead of leaving the previous + * inline style behind. `null` is not part of `IonColValue`, which only + * allows it per breakpoint, hence the cast. + */ + it('clears the sizing when size is set to null', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); -// TODO(FW-7557): Remove this when the push/pull props are removed. -describe('ion-col: deprecated push/pull props', () => { - let warnSpy: jest.SpyInstance; + const col = page.body.querySelector('ion-col')!; + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); - beforeEach(() => { - warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - }); + (col as any).size = null; + forceUpdate(col); + await page.waitForChanges(); - afterEach(() => { - warnSpy.mockRestore(); - }); + expect(col.classList.contains('col-size')).toBe(false); + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + }); + + it('clears the sizing when size is set to undefined', async () => { + const page = await newSpecPage({ + components: [Col], + html: ``, + }); + + const col = page.body.querySelector('ion-col')!; + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + + col.size = undefined; + 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 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(); + }); + + afterEach(() => { + warnSpy.mockRestore(); }); - expect(warnSpy).toHaveBeenCalled(); + it('warns when push is set', async () => { + await newSpecPage({ + components: [Col], + html: ``, + }); + + expect(warnSpy).toHaveBeenCalled(); + expect(warnSpy.mock.calls[0][0]).toEqual(expect.stringContaining('pull and push properties are deprecated')); + }); + + it('warns when pull is set', async () => { + await newSpecPage({ + components: [Col], + html: ``, + }); + + expect(warnSpy).toHaveBeenCalled(); + }); + + it('warns when a breakpoint-suffixed push variant is set', async () => { + await newSpecPage({ + components: [Col], + html: ``, + }); + + expect(warnSpy).toHaveBeenCalled(); + }); + + it('does not warn when only non-deprecated props are set', async () => { + await newSpecPage({ + components: [Col], + html: ``, + }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); }); - it('does not warn when only non-deprecated props are set', async () => { - await newSpecPage({ - components: [Col], - html: ``, + describe('responsive properties', () => { + let consoleWarnSpy: jest.SpyInstance; + + /** + * Answer `window.matchMedia` as a screen of the given width would, so + * `ion-col` resolves its responsive properties the same way it does in a + * browser at that size. + * + * `newSpecPage` installs its own mock window, so this has to be applied + * after the page is created rather than before. + */ + const setScreenWidth = (width: number) => { + (window as any).matchMedia = (query: string) => { + const minWidth = query.match(/\(min-width:\s*(\d+)px\)/); + + return { matches: minWidth !== null && width >= parseInt(minWidth[1], 10) }; + }; + }; + + /** + * Render a column at the given screen width. Breakpoint objects passed + * in `props` are applied as JavaScript properties, which is the only way + * to set them. + */ + const renderCol = async (screenWidth: number, html: string, props: Record = {}) => { + const page = await newSpecPage({ components: [Col], html }); + const col = page.body.querySelector('ion-col')!; + + setScreenWidth(screenWidth); + Object.assign(col, props); + forceUpdate(col); + await page.waitForChanges(); + + return col; + }; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + config.set('screenBreakpoints', undefined as any); + resetScreenBreakpoints(); + resetBreakpointListeners(); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + config.set('screenBreakpoints', undefined as any); + resetScreenBreakpoints(); + resetBreakpointListeners(); + }); + + describe('with a single value', () => { + it('applies the size at every screen width', async () => { + const narrow = await renderCol(320, ``); + const wide = await renderCol(1600, ``); + + expect(narrow.style.getPropertyValue('--internal-col-span')).toBe('6'); + expect(wide.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + }); + + describe('with a breakpoint object', () => { + it('uses the smallest breakpoint on a narrow screen', async () => { + const col = await renderCol(320, ``, { size: { xs: 12, md: 6, xxl: 3 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + }); + + it('uses the largest matching breakpoint', async () => { + const col = await renderCol(800, ``, { size: { xs: 12, md: 6, xxl: 3 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + + it('supports the xxl breakpoint', async () => { + const col = await renderCol(1440, ``, { size: { xs: 12, md: 6, xxl: 3 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('3'); + }); + + it('skips breakpoints that are not set', async () => { + const col = await renderCol(1000, ``, { size: { xs: 12, xxl: 3 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + }); + + it('accepts values as strings', async () => { + const col = await renderCol(800, ``, { size: { xs: '12', md: '6' } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + + it('resolves offset and order independently of size', async () => { + const col = await renderCol(800, ``, { + size: { xs: 12, md: 6 }, + offset: { xs: 0, md: 2 }, + order: { xs: 2, md: 1 }, + }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + expect(col.style.getPropertyValue('--internal-col-margin')).toBe('2'); + expect(col.style.getPropertyValue('order')).toBe('1'); + }); + + it('resets to the default flex layout when the value is an empty string', async () => { + const col = await renderCol(800, ``, { size: { xs: 12, md: '' } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + expect(col).not.toHaveClass('col-size'); + }); + + it('resets to the default flex layout when the value is null', async () => { + const col = await renderCol(800, ``, { size: { xs: 12, md: null } as IonColValue }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + expect(col).not.toHaveClass('col-size'); + }); + + it('supports "auto" at a breakpoint', async () => { + const col = await renderCol(800, ``, { size: { xs: 12, md: 'auto' } }); + + expect(col).toHaveClass('col-auto'); + }); + + it('is not settable as an HTML attribute', async () => { + const col = await renderCol(800, ``); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + }); + + it('clears the sizing when the object is replaced with null', async () => { + const page = await newSpecPage({ components: [Col], html: `` }); + const col = page.body.querySelector('ion-col')!; + + setScreenWidth(800); + col.size = { xs: 12, md: 6 }; + forceUpdate(col); + await page.waitForChanges(); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + + (col as any).size = null; + forceUpdate(col); + await page.waitForChanges(); + + expect(col.classList.contains('col-size')).toBe(false); + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + }); + }); + + // TODO(FW-7557): Remove this when the suffixed props are removed. + describe('with the deprecated suffixed properties', () => { + it('still narrows the unsuffixed value at a breakpoint', async () => { + const col = await renderCol(800, ``); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + + it('falls back to the unsuffixed value below the breakpoint', async () => { + const col = await renderCol(320, ``); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + }); + + it('resets to the default flex layout for a value-less attribute', async () => { + const col = await renderCol(800, ``); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe(''); + }); + + it('warns that they are deprecated', async () => { + await renderCol(800, ``); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-col] - The size-md properties are deprecated'), + expect.anything() + ); + }); + + it('does not warn when only the unsuffixed property is set', async () => { + await renderCol(800, ``); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('is ignored, with a warning, when the property is a breakpoint object', async () => { + const col = await renderCol(800, ``, { size: { xs: 12 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-col] - The size-md properties are ignored'), + expect.anything() + ); + }); }); - expect(warnSpy).not.toHaveBeenCalled(); + 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({ xs: 0, sm: 200, md: 400, lg: 600, xl: 800, xxl: 1000 }); + + // 500px is above the overridden md (400) but below the default md (768) + const col = await renderCol(500, ``, { size: { xs: 12, md: 6 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + + it('does not match a breakpoint that was moved above the screen width', async () => { + setScreenBreakpoints({ xs: 0, sm: 900, md: 1000, lg: 1100, xl: 1200, xxl: 1400 }); + + // 800px matches the default md (768) but not the overridden one + const col = await renderCol(800, ``, { size: { xs: 12, md: 6 } }); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + }); + + // TODO(FW-7557): Remove this when the suffixed props are removed. + it('applies to the deprecated suffixed properties too', async () => { + setScreenBreakpoints({ xs: 0, sm: 200, md: 400, lg: 600, xl: 800, xxl: 1000 }); + + const col = await renderCol(500, ``); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + }); + + /** + * The helper above installs `matchMedia` after the page is created, so the + * subscription in `connectedCallback` short-circuits. These install a + * listener-capable mock first, so crossing a threshold is observable. + */ + describe('when the screen crosses a breakpoint', () => { + let listeners: Array<() => void>; + let width: number; + + const installLiveMatchMedia = () => { + listeners = []; + (window as any).matchMedia = (query: string) => { + const min = Number(/\(min-width:\s*(\d+)px\)/.exec(query)?.[1] ?? NaN); + + return { + get matches() { + return Number.isFinite(min) && width >= min; + }, + addEventListener: (_type: string, listener: () => void) => listeners.push(listener), + removeEventListener: () => undefined, + }; + }; + }; + + const resize = (next: number) => { + width = next; + listeners.forEach((listener) => listener()); + }; + + /** + * `newSpecPage` replaces `window.matchMedia` with its own mock, so the + * column has to be appended after the live mock is installed for its + * `connectedCallback` to subscribe. + */ + const renderSubscribedCol = async () => { + width = 320; + const page = await newSpecPage({ components: [Col], html: `
` }); + + installLiveMatchMedia(); + + const col = page.doc.createElement('ion-col'); + page.body.querySelector('#host')!.appendChild(col); + await page.waitForChanges(); + + col.size = { xs: 12, md: 6 }; + forceUpdate(col); + await page.waitForChanges(); + + return { page, col }; + }; + + it('re-renders with the value for the new breakpoint', async () => { + const { page, col } = await renderSubscribedCol(); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + + resize(800); + await page.waitForChanges(); + + expect(col.style.getPropertyValue('--internal-col-span')).toBe('6'); + }); + + it('stops re-rendering once the column is disconnected', async () => { + const { page, col } = await renderSubscribedCol(); + + col.remove(); + await page.waitForChanges(); + + resize(800); + await page.waitForChanges(); + + // still the narrow value, because the subscription was removed + expect(col.style.getPropertyValue('--internal-col-span')).toBe('12'); + }); + }); }); }); diff --git a/core/src/components/grid/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 index c657396b444..7e9a4cd5a38 100644 --- a/core/src/components/grid/grid.mixins.scss +++ b/core/src/components/grid/grid.mixins.scss @@ -5,33 +5,65 @@ // Responsive Mixins // -------------------------------------------------- -// Creates a fixed width for the grid based on the screen size -// --------------------------------------------------------------------------------- +/// Breakpoint styles are emitted twice. +/// +/// CSS media queries cannot read custom properties, so the `screenBreakpoints` +/// config cannot change a `@media` threshold. The component resolves the active +/// breakpoint in JavaScript and reflects it on the host as a `screen-breakpoint` +/// attribute, which this mixin uses to select the appropriate styles. +/// +/// The `@media` copy provides a baseline before the component hydrates and when +/// JavaScript does not run. It is scoped to hosts that have not reported a +/// breakpoint yet, so the two copies do not conflict. +/// +/// @param {list} $breakpoints - Breakpoint names to emit rules for, in +/// ascending order so the last matching rule wins. +/// @param {string} $host - Additional compound selector for the host, +/// e.g. `.grid-fixed`. +/// @content Declarations for the matched breakpoint, which is passed to +/// the block. +@mixin each-breakpoint($breakpoints, $host: "") { + @each $breakpoint in $breakpoints { + @include mixins.media-breakpoint-up($breakpoint, globals.$screen-breakpoints) { + :host(#{$host}:not([screen-breakpoint])) { + @content ($breakpoint); + } + } -@mixin make-grid-widths($widths, $breakpoints: globals.$screen-breakpoints) { - @each $breakpoint, $width in $widths { - @include mixins.media-breakpoint-up($breakpoint, $breakpoints) { - width: $width; + :host(#{$host}[screen-breakpoint="#{$breakpoint}"]) { + @content ($breakpoint); } } - - max-width: 100%; } -// Adds padding to the element based on breakpoints -// --------------------------------------------------------------------------------- +/// Creates a fixed width for the grid based on the screen size. +/// +/// @param {map} $widths - Width to apply, keyed by breakpoint name. +/// @param {string} $host - Additional compound selector for the host. +@mixin make-grid-widths($widths, $host: ".grid-fixed") { + @include each-breakpoint(map.keys($widths), $host) using ($breakpoint) { + width: map.get($widths, $breakpoint); + } -@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); + :host(#{$host}) { + max-width: 100%; + } +} - @include mixins.padding( - map.get($padding, top), - map.get($padding, end), - map.get($padding, bottom), - map.get($padding, start) - ); - } +/// Adds padding to the element based on breakpoints. +/// +/// @param {map} $paddings - Padding to apply, keyed by breakpoint name, each +/// a map of `top`, `end`, `bottom` and `start`. +/// @param {string} $host - Additional compound selector for the host. +@mixin make-breakpoint-padding($paddings, $host: "") { + @include each-breakpoint(map.keys($paddings), $host) using ($breakpoint) { + $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..d7c49c85362 100644 --- a/core/src/components/grid/grid.scss +++ b/core/src/components/grid/grid.scss @@ -46,18 +46,18 @@ * @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); - display: block; } +@include grid.make-breakpoint-padding(vars.$grid-paddings); + // Fixed Grid // -------------------------------------------------- :host(.grid-fixed) { @include mixins.margin-horizontal(auto); - @include grid.make-grid-widths(vars.$grid-widths); flex: 1; } + +@include grid.make-grid-widths(vars.$grid-widths); diff --git a/core/src/components/grid/grid.tsx b/core/src/components/grid/grid.tsx index cf854a3dc31..b379ad3e18b 100644 --- a/core/src/components/grid/grid.tsx +++ b/core/src/components/grid/grid.tsx @@ -1,5 +1,6 @@ import type { ComponentInterface } from '@stencil/core'; -import { Component, Host, Prop, h } from '@stencil/core'; +import { Component, Host, Prop, forceUpdate, h } from '@stencil/core'; +import { getActiveBreakpoint, onBreakpointChange } from '@utils/breakpoints'; /** * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component. @@ -10,14 +11,29 @@ import { Component, Host, Prop, h } from '@stencil/core'; shadow: true, }) export class Grid implements ComponentInterface { + private unsubscribeBreakpoint?: () => void; + /** * If `true`, the grid will have a fixed width based on the screen size. + * + * The width each breakpoint activates at can be changed with the + * `screenBreakpoints` config. */ @Prop() fixed = false; + connectedCallback() { + this.unsubscribeBreakpoint = onBreakpointChange(() => forceUpdate(this)); + } + + disconnectedCallback() { + this.unsubscribeBreakpoint?.(); + this.unsubscribeBreakpoint = undefined; + } + render() { return ( { + test.describe(title('grid: breakpoints'), () => { + /** + * One width per breakpoint. Every width except the `xs` one resolves to a + * smaller breakpoint under the default thresholds, so each case fails if + * the config is ignored. + */ + const cases = [ + // xs is 0 under both, but its fixed width is 100% so it tracks the + // viewport + { width: 150, breakpoint: 'xs', padding: '0px', fixedWidth: 150, size: '12' }, + // sm under the override, xs by default + { width: 350, breakpoint: 'sm', padding: '12px', fixedWidth: 180, size: '6' }, + // md under the override, xs by default (below the 576 default sm) + { width: 500, breakpoint: 'md', padding: '28px', fixedWidth: 360, size: '4' }, + // lg under the override, sm by default + { width: 650, breakpoint: 'lg', padding: '48px', fixedWidth: 560, size: '3' }, + // xl under the override, md by default + { width: 850, breakpoint: 'xl', padding: '72px', fixedWidth: 760, size: '2' }, + // xxl under the override, lg by default + { width: 1100, breakpoint: 'xxl', padding: '100px', fixedWidth: 960, size: '1' }, + ]; + + for (const { width, breakpoint, padding, fixedWidth, size } of cases) { + test(`should resolve the ${breakpoint} breakpoint at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + + await expect(page.locator('#padding-grid')).toHaveAttribute('screen-breakpoint', breakpoint); + }); + + test(`should apply the ${breakpoint} padding at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + + const paddingTop = await page.locator('#padding-grid').evaluate((grid) => getComputedStyle(grid).paddingTop); + + expect(paddingTop).toBe(padding); + }); + + test(`should apply the ${breakpoint} fixed width at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + + const measuredWidth = await page.locator('#fixed-grid').evaluate((grid) => grid.getBoundingClientRect().width); + + // Allow 1px tolerance for sub-pixel rounding in the browser. + expect(measuredWidth).toBeGreaterThanOrEqual(fixedWidth - 1); + expect(measuredWidth).toBeLessThanOrEqual(fixedWidth + 1); + }); + + test(`should resolve the column size object at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + + const span = await page + .locator('.responsive-col') + .first() + .evaluate((col) => col.style.getPropertyValue('--internal-col-span')); + + expect(span).toBe(size); + }); + } + }); +}); + +/** + * Every case above loads the page at a fixed width. These resize an open page + * instead, which is what actually happens when a window is dragged: the + * components re-render from `onBreakpointChange` rather than on load. + * + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('grid: breakpoints on resize'), () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 350, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + }); + + test('should re-resolve the breakpoint without reloading', async ({ page }) => { + const grid = page.locator('#padding-grid'); + await expect(grid).toHaveAttribute('screen-breakpoint', 'sm'); + + await page.setViewportSize({ width: 1100, height: 800 }); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'xxl'); + }); + + test('should re-resolve column sizes without reloading', async ({ page }) => { + const col = page.locator('.responsive-col').first(); + const span = () => col.evaluate((el) => el.style.getPropertyValue('--internal-col-span')); + + expect(await span()).toBe('6'); + + await page.setViewportSize({ width: 1100, height: 800 }); + await expect(col).toHaveAttribute('screen-breakpoint', 'xxl'); + + expect(await span()).toBe('1'); + }); + + test('should re-resolve padding without reloading', async ({ page }) => { + const grid = page.locator('#padding-grid'); + const paddingTop = () => grid.evaluate((el) => getComputedStyle(el).paddingTop); + + expect(await paddingTop()).toBe('12px'); + + await page.setViewportSize({ width: 1100, height: 800 }); + await expect(grid).toHaveAttribute('screen-breakpoint', 'xxl'); + + expect(await paddingTop()).toBe('100px'); + }); + + /** + * The subscription listens on `matchMedia`, so crossing a threshold has to + * take effect on the next frame rather than after a resize debounce. + */ + test('should update without a resize debounce when a threshold is crossed', async ({ page }) => { + const grid = page.locator('#padding-grid'); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'sm'); + + await page.setViewportSize({ width: 500, height: 800 }); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'md', { timeout: 250 }); + }); + + test('should resolve back down when the screen narrows again', async ({ page }) => { + const grid = page.locator('#padding-grid'); + + await page.setViewportSize({ width: 1100, height: 800 }); + await expect(grid).toHaveAttribute('screen-breakpoint', 'xxl'); + + await page.setViewportSize({ width: 350, height: 800 }); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'sm'); + }); + }); +}); + +/** + * Each rule is emitted twice: a `@media` copy scoped to + * `:host(:not([screen-breakpoint]))`, and an attribute copy. The first is the + * baseline for before a component hydrates and for when JavaScript never + * runs, so it has to resolve from the *compiled* thresholds rather than the + * `screenBreakpoints` config. + * + * Removing the attribute is how that state is reached here: the harness + * cannot load a page with JavaScript disabled, because its `goto` waits for a + * flag that scripts set. + * + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('grid: breakpoints before hydration'), () => { + test.beforeEach(async ({ page }) => { + // 800px resolves to the configured xl, but to the compiled md (768px) + await page.setViewportSize({ width: 800, height: 800 }); + await page.goto('/src/components/grid/test/breakpoints', config); + }); + + test('should fall back to the compiled media query when no breakpoint is reported', async ({ page }) => { + const grid = page.locator('#padding-grid'); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'xl'); + expect(await grid.evaluate((el) => getComputedStyle(el).paddingTop)).toBe('72px'); + + await grid.evaluate((el) => el.removeAttribute('screen-breakpoint')); + + // md, because the baseline uses the compiled 768px and ignores the config + expect(await grid.evaluate((el) => getComputedStyle(el).paddingTop)).toBe('28px'); + }); + + test('should fall back for the fixed width too', async ({ page }) => { + const grid = page.locator('#fixed-grid'); + const width = () => grid.evaluate((el) => Math.round(el.getBoundingClientRect().width)); + + await expect(grid).toHaveAttribute('screen-breakpoint', 'xl'); + expect(await width()).toBe(760); + + await grid.evaluate((el) => el.removeAttribute('screen-breakpoint')); + + expect(await width()).toBe(360); + }); + }); +}); diff --git a/core/src/components/grid/test/breakpoints/index.html b/core/src/components/grid/test/breakpoints/index.html new file mode 100644 index 00000000000..edb61bc5da5 --- /dev/null +++ b/core/src/components/grid/test/breakpoints/index.html @@ -0,0 +1,240 @@ + + + + + Grid - Breakpoints + + + + + + + + + + + + + + Grid - Breakpoints + + + + +

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

+ +

Responsive padding

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

Fixed width

+

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

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

Responsive column sizes

+

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

+ + + +
1
+
+ +
2
+
+ +
3
+
+ +
4
+
+ +
5
+
+ +
6
+
+ +
7
+
+ +
8
+
+ +
9
+
+ +
10
+
+ +
11
+
+ +
12
+
+
+
+
+ +
+ … + –px +
+ + +
+ + + + diff --git a/core/src/components/grid/test/fixed/grid.e2e.ts b/core/src/components/grid/test/fixed/grid.e2e.ts index c3eb9a8d3e7..4e8511f561f 100644 --- a/core/src/components/grid/test/fixed/grid.e2e.ts +++ b/core/src/components/grid/test/fixed/grid.e2e.ts @@ -25,16 +25,24 @@ const VIEWPORT_AT_BREAKPOINT = Object.fromEntries( */ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('grid: fixed'), () => { - test.beforeEach(async ({ page }) => { - await page.goto('/src/components/grid/test/fixed', config); - }); - for (const breakpoint of SCREEN_BREAKPOINT_NAMES) { test(`fixed grid matches the ${breakpoint} width token`, async ({ page }) => { const viewportWidth = VIEWPORT_AT_BREAKPOINT[breakpoint]; + + // Size the viewport before loading so the first render already + // resolves to this breakpoint. Resizing an open page would instead + // depend on the re-render landing before the measurement below. await page.setViewportSize({ width: viewportWidth, height: 800 }); + await page.goto('/src/components/grid/test/fixed', config); const grid = page.locator('ion-grid'); + + // Assert the breakpoint before measuring. A grid that has not resolved + // one yet still has the xs token applied, which is 100%, so the width + // assertion below would fail with the viewport width instead of + // naming the breakpoint. + await expect(grid).toHaveAttribute('screen-breakpoint', breakpoint); + const measuredWidth = await grid.evaluate((el) => el.getBoundingClientRect().width); const expected = ionGridBreakpoints[breakpoint]!.width!; diff --git a/core/src/components/grid/test/grid.spec.ts b/core/src/components/grid/test/grid.spec.ts new file mode 100644 index 00000000000..61a5564b344 --- /dev/null +++ b/core/src/components/grid/test/grid.spec.ts @@ -0,0 +1,144 @@ +import { config } from '@global/config'; +import { forceUpdate } from '@stencil/core'; +import { newSpecPage } from '@stencil/core/testing'; + +import { resetBreakpointListeners, resetScreenBreakpoints } from '@utils/breakpoints'; +import { Grid } from '../grid'; + +/** + * The default widths the assertions below are written against: + * + * | xs | sm | md | lg | xl | xxl | + * | -- | --- | --- | --- | ---- | ---- | + * | 0 | 576 | 768 | 992 | 1200 | 1400 | + */ + +describe('ion-grid', () => { + let listeners: Array<() => void>; + let width: number; + + const setScreenBreakpoints = (value: unknown) => { + config.set('screenBreakpoints', value as any); + resetScreenBreakpoints(); + }; + + /** + * `newSpecPage` replaces `window.matchMedia` with its own mock, so the grid + * has to be appended after this is installed for `connectedCallback` to + * subscribe. + */ + const installMatchMedia = () => { + listeners = []; + (window as any).matchMedia = (query: string) => { + const min = Number(/\(min-width:\s*(\d+)px\)/.exec(query)?.[1] ?? NaN); + + return { + get matches() { + return Number.isFinite(min) && width >= min; + }, + addEventListener: (_type: string, listener: () => void) => listeners.push(listener), + removeEventListener: () => undefined, + }; + }; + }; + + const resize = (next: number) => { + width = next; + listeners.forEach((listener) => listener()); + }; + + const renderGrid = async (screenWidth: number) => { + width = screenWidth; + const page = await newSpecPage({ components: [Grid], html: `
` }); + + installMatchMedia(); + + const grid = page.doc.createElement('ion-grid'); + page.body.querySelector('#host')!.appendChild(grid); + await page.waitForChanges(); + + return { page, grid }; + }; + + beforeEach(() => { + setScreenBreakpoints(undefined); + resetBreakpointListeners(); + }); + + afterEach(() => { + setScreenBreakpoints(undefined); + resetBreakpointListeners(); + }); + + describe('screen breakpoint', () => { + it.each([ + [320, 'xs'], + [600, 'sm'], + [800, 'md'], + [1000, 'lg'], + [1300, 'xl'], + [1500, 'xxl'], + ])('reflects the active breakpoint at %ipx as "%s"', async (screenWidth, expected) => { + const { grid } = await renderGrid(screenWidth as number); + + expect(grid.getAttribute('screen-breakpoint')).toBe(expected); + }); + + describe('with overridden screen breakpoints', () => { + it('reflects the configured widths rather than the defaults', async () => { + // Lower sm too, so the override stays in ascending order + setScreenBreakpoints({ sm: 300, md: 400 }); + + const { grid } = await renderGrid(500); + + // 500 is below the default md of 768, but at or above the configured 400 + expect(grid.getAttribute('screen-breakpoint')).toBe('md'); + }); + + it('omits the attribute when no breakpoint matches', async () => { + // xs normally matches at any width, so raise every breakpoint above the + // viewport, keeping them in ascending order + setScreenBreakpoints({ xs: 2000, sm: 2100, md: 2200, lg: 2300, xl: 2400, xxl: 2500 }); + + const { grid } = await renderGrid(100); + + expect(grid.hasAttribute('screen-breakpoint')).toBe(false); + }); + }); + + it('re-renders with the new breakpoint when a threshold is crossed', async () => { + const { page, grid } = await renderGrid(320); + + expect(grid.getAttribute('screen-breakpoint')).toBe('xs'); + + resize(800); + await page.waitForChanges(); + + expect(grid.getAttribute('screen-breakpoint')).toBe('md'); + }); + + it('stops re-rendering once the grid is disconnected', async () => { + const { page, grid } = await renderGrid(320); + + grid.remove(); + await page.waitForChanges(); + + resize(800); + await page.waitForChanges(); + + expect(grid.getAttribute('screen-breakpoint')).toBe('xs'); + }); + }); + + it('applies grid-fixed only when fixed is set', async () => { + const { page, grid } = await renderGrid(800); + + expect(grid.classList.contains('grid-fixed')).toBe(false); + + grid.fixed = true; + forceUpdate(grid); + await page.waitForChanges(); + + expect(grid.classList.contains('grid-fixed')).toBe(true); + }); +}); diff --git a/core/src/components/grid/test/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/utils/test/breakpoints.spec.ts b/core/src/utils/test/breakpoints.spec.ts index 0061949d9c9..28d11bb27d6 100644 --- a/core/src/utils/test/breakpoints.spec.ts +++ b/core/src/utils/test/breakpoints.spec.ts @@ -21,7 +21,8 @@ import { * too. * * Viewport widths in the window tests are picked to sit inside a single band, - * so 800 resolves to `md` because 768 <= 800 < 992. + * so 800 resolves to `md` because 768 <= 800 < 992. The one exception is the + * boundary test in `getActiveBreakpoint()`, which is on the edges on purpose. */ const EXPECTED_DEFAULT_SCREEN_BREAKPOINTS = { xs: 0, @@ -389,7 +390,7 @@ describe('screen breakpoints against the window', () => { describe('getActiveBreakpoint()', () => { it.each([ [500, 'xs'], - [576, 'sm'], + [600, 'sm'], [800, 'md'], [1000, 'lg'], [1300, 'xl'], @@ -400,6 +401,28 @@ describe('screen breakpoints against the window', () => { expect(getActiveBreakpoint()).toBe(expected); }); + /** + * `min-width` is inclusive, so the width a breakpoint activates at belongs + * to that breakpoint and the pixel below it belongs to the one under it. + * Every other case sits inside a band, so these are the only assertions + * covering the edges. + */ + it.each([ + ['sm', 576, 'xs'], + ['md', 768, 'sm'], + ['lg', 992, 'md'], + ['xl', 1200, 'lg'], + ['xxl', 1400, 'xl'], + ])('activates %p at exactly %ipx and %p below it', (breakpoint, threshold, below) => { + setWidth(threshold as number); + + expect(getActiveBreakpoint()).toBe(breakpoint); + + setWidth((threshold as number) - 1); + + expect(getActiveBreakpoint()).toBe(below); + }); + it('returns undefined when no breakpoint matches', () => { // xs normally matches at any width, so raise it above the viewport too config.set('screenBreakpoints', { xs: 2000 } as any);