Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1048,15 +1048,15 @@ 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<string | number> | number | string,{
ion-gallery,prop,columns,BreakpointMap<string | number> | number | string,{
xs: 2,
sm: 3,
md: 4,
lg: 6,
xl: 8,
xxl: 10,
},false,false
ion-gallery,prop,gap,GalleryBreakpoints<string | number> | number | string,'16px',false,false
ion-gallery,prop,gap,BreakpointMap<string | number> | 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
Expand Down
8 changes: 4 additions & 4 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 5 additions & 10 deletions core/src/components/gallery/gallery-interface.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
export interface GalleryBreakpoints<T = string | number> {
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<string | number>;

export type GalleryColumns = string | number | GalleryBreakpoints;
export type GalleryGap = string | number | GalleryBreakpoints;
114 changes: 114 additions & 0 deletions core/src/components/gallery/gallery.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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', () => {
Expand Down
47 changes: 21 additions & 26 deletions core/src/components/gallery/gallery.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -414,7 +409,7 @@ export class Gallery implements ComponentInterface {
*/
private getColumnsForWidth(width: number) {
const { columns } = this;
const isBreakpointColumns = this.isBreakpointMap(columns);
const isBreakpointColumns = isBreakpointMap<string | number>(columns);
const hasInvalidBreakpointColumns =
isBreakpointColumns && this.hasInvalidBreakpointMap(columns, (value) => this.sanitizeColumns(value));

Expand All @@ -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<string | number>(providedGap);
const hasInvalidBreakpointGap =
isBreakpointGap && this.hasInvalidBreakpointMap(providedGap, (value) => this.sanitizeGap(value));
const sanitizedGap = isBreakpointGap
Expand Down
46 changes: 46 additions & 0 deletions core/src/components/gallery/test/breakpoints/gallery.e2e.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Comment on lines +5 to +17

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "each width below resolves to a different breakpoint" part only holds for the first test, since the second resolves to xxl and then xs under the default breakpoints too, and it still passes with the defaults. That's fine because it's checking the gallery's own width against the viewport, but I think the comment should say so, so nobody expects it to catch the config being ignored.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');
});
});
});
Loading
Loading