diff --git a/core/api.txt b/core/api.txt index e9f5eb4d403..88d97384026 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1048,7 +1048,7 @@ ion-footer,prop,theme,"ios" | "md" | "ionic",undefined,false,false ion-footer,prop,translucent,boolean,false,false,false ion-gallery,shadow -ion-gallery,prop,columns,GalleryBreakpoints | number | string,{ +ion-gallery,prop,columns,BreakpointMap | number | string,{ xs: 2, sm: 3, md: 4, @@ -1056,7 +1056,7 @@ ion-gallery,prop,columns,GalleryBreakpoints | number | string,{ xl: 8, xxl: 10, },false,false -ion-gallery,prop,gap,GalleryBreakpoints | number | string,'16px',false,false +ion-gallery,prop,gap,BreakpointMap | number | string,'16px',false,false ion-gallery,prop,layout,"masonry" | "uniform",'uniform',false,false ion-gallery,prop,mode,"ios" | "md",undefined,false,false ion-gallery,prop,order,"best-fit" | "sequential" | undefined,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 4b93daad7f8..9e43ab8c87d 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1503,12 +1503,12 @@ export namespace Components { } interface IonGallery { /** - * The number of columns to display. Can be set as a number or an object of breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). + * The number of columns to display. Can be set as a number or an object of breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). Breakpoints are matched against the gallery's own width rather than the screen width. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. * @default { xs: 2, sm: 3, md: 4, lg: 6, xl: 8, xxl: 10, } */ "columns": GalleryColumns; /** - * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, CSS variables like `var(--app-gallery-gap)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. + * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, CSS variables like `var(--app-gallery-gap)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. Breakpoints are matched against the gallery's own width rather than the screen width. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. * @default '16px' */ "gap": GalleryGap; @@ -7379,12 +7379,12 @@ declare namespace LocalJSX { } interface IonGallery { /** - * The number of columns to display. Can be set as a number or an object of breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). + * The number of columns to display. Can be set as a number or an object of breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). Breakpoints are matched against the gallery's own width rather than the screen width. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. * @default { xs: 2, sm: 3, md: 4, lg: 6, xl: 8, xxl: 10, } */ "columns"?: GalleryColumns; /** - * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, CSS variables like `var(--app-gallery-gap)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. + * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) values like `16px`, `1rem`, `20%`, math functions like `calc(10px + 20%)`, CSS variables like `var(--app-gallery-gap)`, or numbers (treated as pixel values). Can also be set as a breakpoint map (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept space-separated values or CSS keyword values like `inherit`, `auto`, etc. Breakpoints are matched against the gallery's own width rather than the screen width. The width each breakpoint activates at can be changed with the `screenBreakpoints` config. * @default '16px' */ "gap"?: GalleryGap; diff --git a/core/src/components/gallery/gallery-interface.ts b/core/src/components/gallery/gallery-interface.ts index 6ce4417bfca..cd7f0d4141c 100644 --- a/core/src/components/gallery/gallery-interface.ts +++ b/core/src/components/gallery/gallery-interface.ts @@ -1,11 +1,6 @@ -export interface GalleryBreakpoints { - xs?: T; - sm?: T; - md?: T; - lg?: T; - xl?: T; - xxl?: T; -} +import type { BreakpointMap } from '@utils/breakpoints'; -export type GalleryColumns = GalleryBreakpoints | string | number; -export type GalleryGap = GalleryBreakpoints | string | number; +export type GalleryBreakpoints = BreakpointMap; + +export type GalleryColumns = string | number | GalleryBreakpoints; +export type GalleryGap = string | number | GalleryBreakpoints; diff --git a/core/src/components/gallery/gallery.spec.ts b/core/src/components/gallery/gallery.spec.ts index 5ed9c1e40b9..9c3950d830c 100644 --- a/core/src/components/gallery/gallery.spec.ts +++ b/core/src/components/gallery/gallery.spec.ts @@ -1,4 +1,6 @@ +import { config } from '@global/config'; import { newSpecPage } from '@stencil/core/testing'; +import { resetScreenBreakpoints } from '@utils/breakpoints'; import * as helpers from '@utils/helpers'; import * as logging from '@utils/logging'; @@ -1151,6 +1153,118 @@ describe('gallery', () => { }); }); }); + + /** + * The thresholds come from the `screenBreakpoints` config rather than a + * hardcoded map, so apps can override them. Each width below maps to a + * different breakpoint with the override than it does with the defaults, + * ensuring these tests fail if the config is ignored. + */ + describe('gallery: screenBreakpoints config', () => { + const OVERRIDE = { xs: 0, sm: 200, md: 400, lg: 600, xl: 800, xxl: 1000 }; + + const setScreenBreakpoints = (value: unknown) => { + config.set('screenBreakpoints', value as any); + resetScreenBreakpoints(); + }; + + beforeEach(() => { + setScreenBreakpoints(OVERRIDE); + }); + + afterEach(() => { + setScreenBreakpoints(undefined); + }); + + /** + * `xs` is no longer pinned to 0, so a gallery narrower than the configured + * `xs` matches no breakpoint and would otherwise resolve to `undefined`. + */ + it('should resolve to the xs value when narrower than the configured xs', () => { + setScreenBreakpoints({ xs: 400 }); + + sharedGallery.columns = { xs: 1, md: 3 }; + + expect((sharedGallery as any).getColumnsForWidth(300)).toBe(1); + }); + + it('should resolve to the xs default when narrower than the configured xs', () => { + setScreenBreakpoints({ xs: 400 }); + + expect((sharedGallery as any).getColumnsForWidth(300)).toBe(DEFAULT_COLUMNS['xs']); + }); + + /** + * An unresolved value lands in the custom property as the string + * "undefined". The stylesheet's `var(--internal-gallery-columns, 2)` + * fallback cannot catch that, because the property is set. + */ + it('should not write an unresolved value into the columns custom property', () => { + setScreenBreakpoints({ xs: 400 }); + + sharedGallery.columns = { xs: 1, md: 3 }; + + jest.spyOn(sharedGallery.el, 'getBoundingClientRect').mockReturnValue({ width: 300 } as DOMRect); + + (sharedGallery as any).updateResponsiveStyles(); + + expect(sharedGallery.el.style.getPropertyValue('--internal-gallery-columns')).toBe('1'); + }); + + it('should resolve columns against the configured widths', () => { + const breakpoints = [ + // xs under both + { width: 150, expectedColumns: 3 }, + // sm under the override, xs by default + { width: 350, expectedColumns: 4 }, + // md under the override, xs by default + { width: 500, expectedColumns: 5 }, + // lg under the override, sm by default + { width: 650, expectedColumns: 7 }, + // xl under the override, md by default + { width: 850, expectedColumns: 9 }, + // xxl under the override, lg by default + { width: 1100, expectedColumns: 12 }, + ]; + + sharedGallery.columns = { xs: 3, sm: 4, md: 5, lg: 7, xl: 9, xxl: 12 }; + + breakpoints.forEach(({ width, expectedColumns }) => { + expect((sharedGallery as any).getColumnsForWidth(width)).toBe(expectedColumns); + }); + }); + + it('should resolve the gap against the configured widths', () => { + const breakpoints = [ + { width: 150, expectedGap: '2px' }, + { width: 350, expectedGap: '4px' }, + { width: 500, expectedGap: '8px' }, + { width: 650, expectedGap: '16px' }, + { width: 850, expectedGap: '32px' }, + { width: 1100, expectedGap: '64px' }, + ]; + + sharedGallery.gap = { xs: '2px', sm: '4px', md: '8px', lg: '16px', xl: '32px', xxl: '64px' }; + + breakpoints.forEach(({ width, expectedGap }) => { + expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); + }); + }); + + it('should resolve the default columns against the configured widths', () => { + // 500 is below the default md of 768, but at or above the configured 400 + expect((sharedGallery as any).getColumnsForWidth(500)).toBe(DEFAULT_COLUMNS['md']); + }); + + it('should fall back to the defaults when the config is removed', () => { + setScreenBreakpoints(undefined); + + sharedGallery.columns = { xs: 3, md: 5 }; + + // 500 resolves to xs again, since the default md is 768 + expect((sharedGallery as any).getColumnsForWidth(500)).toBe(3); + }); + }); }); describe('gallery: classes', () => { diff --git a/core/src/components/gallery/gallery.tsx b/core/src/components/gallery/gallery.tsx index 3b5b7168cea..1d810c6c8d1 100644 --- a/core/src/components/gallery/gallery.tsx +++ b/core/src/components/gallery/gallery.tsx @@ -1,5 +1,7 @@ import type { ComponentInterface } from '@stencil/core'; import { Component, Element, Host, Listen, Prop, Watch, h } from '@stencil/core'; +import type { ScreenBreakpoint } from '@utils/breakpoints'; +import { SCREEN_BREAKPOINT_NAMES, getScreenBreakpoints, isBreakpointMap } from '@utils/breakpoints'; import { isCssVariable, isValidLengthPercentage } from '@utils/css-value-validation'; import { raf } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; @@ -9,19 +11,6 @@ import { getIonTheme } from '../../global/ionic-global'; import { DEFAULT_COLUMNS, DEFAULT_GAP } from './gallery-constants'; import type { GalleryBreakpoints, GalleryColumns, GalleryGap } from './gallery-interface'; -// TODO(FW-7285): Replace with global breakpoints -const BREAKPOINTS = { - xs: 0, - sm: 576, - md: 768, - lg: 992, - xl: 1200, - xxl: 1400, -}; - -type GalleryBreakpoint = keyof typeof BREAKPOINTS; -const BREAKPOINT_ORDER: GalleryBreakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']; - /** * The tag of the component used to wrap each gallery item. */ @@ -74,6 +63,10 @@ export class Gallery implements ComponentInterface { /** * The number of columns to display. Can be set as a number or an object of * breakpoint values (e.g. `{ xs: 2, sm: 3, md: 4 }`). + * + * Breakpoints are matched against the gallery's own width rather than the + * screen width. The width each breakpoint activates at can be changed with + * the `screenBreakpoints` config. */ @Prop() columns: GalleryColumns = DEFAULT_COLUMNS; @@ -84,6 +77,10 @@ export class Gallery implements ComponentInterface { * values). Can also be set as a breakpoint map * (e.g. `{ xs: '8px', sm: '1rem', md: '24px' }`). Does not accept * space-separated values or CSS keyword values like `inherit`, `auto`, etc. + * + * Breakpoints are matched against the gallery's own width rather than the + * screen width. The width each breakpoint activates at can be changed with + * the `screenBreakpoints` config. */ @Prop() gap: GalleryGap = DEFAULT_GAP; @@ -267,13 +264,6 @@ export class Gallery implements ComponentInterface { return isValidCssLength ? normalizedGap : undefined; } - /** - * Check if the value is a breakpoint map object. - */ - private isBreakpointMap(value: unknown): value is GalleryBreakpoints { - return typeof value === 'object' && value !== null && !Array.isArray(value); - } - /** * Check if the breakpoint map has any invalid values for the provided * sanitizer. A breakpoint map is invalid when there are no valid breakpoint @@ -286,7 +276,7 @@ export class Gallery implements ComponentInterface { ) { let hasBreakpointEntry = false; - for (const breakpoint of BREAKPOINT_ORDER) { + for (const breakpoint of SCREEN_BREAKPOINT_NAMES) { const value = breakpointMap[breakpoint]; if (value !== undefined) { hasBreakpointEntry = true; @@ -307,18 +297,23 @@ export class Gallery implements ComponentInterface { width: number, breakpointMap: GalleryBreakpoints, sanitizeProvided: (value: string | number | undefined) => T | undefined, - getSanitizedDefault: (breakpoint: GalleryBreakpoint) => T | undefined + getSanitizedDefault: (breakpoint: ScreenBreakpoint) => T | undefined ) { + const breakpoints = getScreenBreakpoints(); let resolvedValue: T | undefined; - for (const bp of BREAKPOINT_ORDER) { + for (const bp of SCREEN_BREAKPOINT_NAMES) { const providedValue = breakpointMap[bp]; const sanitizedProvided = sanitizeProvided(providedValue); const sanitizedDefault = getSanitizedDefault(bp); const resolved = providedValue === undefined || sanitizedProvided === undefined ? sanitizedDefault : sanitizedProvided; - if (resolved !== undefined && width >= BREAKPOINTS[bp]) { + /** + * `xs` is the default when the gallery is narrower than the configured + * `xs` breakpoint, since no other breakpoint can match. + */ + if (resolved !== undefined && (bp === 'xs' || width >= breakpoints[bp])) { resolvedValue = resolved; } } @@ -414,7 +409,7 @@ export class Gallery implements ComponentInterface { */ private getColumnsForWidth(width: number) { const { columns } = this; - const isBreakpointColumns = this.isBreakpointMap(columns); + const isBreakpointColumns = isBreakpointMap(columns); const hasInvalidBreakpointColumns = isBreakpointColumns && this.hasInvalidBreakpointMap(columns, (value) => this.sanitizeColumns(value)); @@ -440,7 +435,7 @@ export class Gallery implements ComponentInterface { const { gap } = this; const providedGap = gap ?? DEFAULT_GAP; - const isBreakpointGap = this.isBreakpointMap(providedGap); + const isBreakpointGap = isBreakpointMap(providedGap); const hasInvalidBreakpointGap = isBreakpointGap && this.hasInvalidBreakpointMap(providedGap, (value) => this.sanitizeGap(value)); const sanitizedGap = isBreakpointGap diff --git a/core/src/components/gallery/test/breakpoints/gallery.e2e.ts b/core/src/components/gallery/test/breakpoints/gallery.e2e.ts new file mode 100644 index 00000000000..39d28f2be1b --- /dev/null +++ b/core/src/components/gallery/test/breakpoints/gallery.e2e.ts @@ -0,0 +1,46 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; +import type { E2EPage } from '@utils/test/playwright'; + +/** + * The test page configures every breakpoint below its default + * (xs 0, sm 200, md 400, lg 600, xl 800, xxl 1000). + * + * Only the first test verifies that the configured breakpoints are honored: + * 500px resolves to md with the override but xs with the defaults. The second + * verifies that the gallery resolves against its own width rather than the + * viewport, which is why it also passes with the defaults, both widths + * resolving to the same breakpoint; the gallery fills the viewport by default, + * so its width tracks the window unless a test explicitly narrows it. + * + * This behavior is the same across modes and directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('gallery: breakpoints'), () => { + const columnsFor = (page: E2EPage) => + page.locator('#gallery').evaluate((el: HTMLElement) => el.style.getPropertyValue('--internal-gallery-columns')); + + test('should resolve columns against the configured breakpoints', async ({ page }) => { + // 500px is above the configured md (400), so it resolves to md and the + // page's md value of 3. With the defaults it would be xs, since sm does + // not start until 576, and give 1 instead. + await page.setViewportSize({ width: 500, height: 800 }); + await page.goto('/src/components/gallery/test/breakpoints', config); + + expect(await columnsFor(page)).toBe('3'); + }); + + test('should resolve against its own width rather than the viewport', async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 800 }); + await page.goto('/src/components/gallery/test/breakpoints', config); + + // Filling the viewport, the gallery is xxl and takes the default columns. + expect(await columnsFor(page)).toBe('10'); + + // Narrowing only the gallery drops it to xs, without the viewport moving. + await page.locator('#gallery-sizer').evaluate((el: HTMLElement) => (el.style.width = '150px')); + + await expect.poll(() => columnsFor(page)).toBe('1'); + }); + }); +}); diff --git a/core/src/components/gallery/test/breakpoints/index.html b/core/src/components/gallery/test/breakpoints/index.html new file mode 100644 index 00000000000..26d5dea55c3 --- /dev/null +++ b/core/src/components/gallery/test/breakpoints/index.html @@ -0,0 +1,292 @@ + + + + + Gallery - Breakpoints + + + + + + + + + + + + + +

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

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

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

+ + +
+ +
+ … + –px +
+
+ + + +