diff --git a/core/api.txt b/core/api.txt index 83677057335..ec85c5dc9ec 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1056,11 +1056,11 @@ ion-gallery,prop,columns,BreakpointMap | number | string,{ xl: 8, xxl: 10, },false,false -ion-gallery,prop,gap,BreakpointMap | number | string,'16px',false,false +ion-gallery,prop,gap,BreakpointMap | number | string | undefined,undefined,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 -ion-gallery,prop,theme,"ios" | "md" | "ionic",undefined,false,false +ion-gallery,css-prop,--ion-gallery-gap ion-gallery-item,shadow ion-gallery-item,prop,mode,"ios" | "md",undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index c82becfa0cd..22bc8292237 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -21,7 +21,7 @@ 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"; -import { GalleryColumns, GalleryGap } from "./components/gallery/gallery-interface"; +import { IonGalleryColumns, IonGalleryGap } from "./components/gallery/gallery.interface"; import { HeaderScrollEffect } from "./components/header/header-interface"; import { SpinnerTypes } from "./components/spinner/spinner-configs"; import { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; @@ -68,7 +68,7 @@ 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"; -export { GalleryColumns, GalleryGap } from "./components/gallery/gallery-interface"; +export { IonGalleryColumns, IonGalleryGap } from "./components/gallery/gallery.interface"; export { HeaderScrollEffect } from "./components/header/header-interface"; export { SpinnerTypes } from "./components/spinner/spinner-configs"; export { InputChangeEventDetail, InputInputEventDetail } from "./components/input/input-interface"; @@ -1506,12 +1506,11 @@ export namespace Components { * 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; + "columns": IonGalleryColumns; /** - * 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' + * 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. When unset, invalid, or missing for the active breakpoint, the gap comes from the theme's `--ion-gallery-gap` token. */ - "gap": GalleryGap; + "gap"?: IonGalleryGap; /** * The visual layout of the gallery. When `uniform`, rows take up the height of the tallest item and are spaced evenly across the gallery. Additionally, items will have an aspect ratio of 1/1, forcing them to be square unless a height is explicitly set. When `masonry`, items will be positioned under each other with only the specified gap between them. * @default 'uniform' @@ -1525,10 +1524,6 @@ export namespace Components { * The order in which items are positioned. Only applies when layout is `masonry`. When `sequential`, items are positioned in the order they are placed in the DOM. When `best-fit`, items are positioned under the column with the most available space. Defaults to `sequential` when layout is `masonry` and `order` is not explicitly set. */ "order"?: 'sequential' | 'best-fit'; - /** - * The theme determines the visual appearance of the component. - */ - "theme"?: "ios" | "md" | "ionic"; } interface IonGalleryItem { /** @@ -7378,12 +7373,11 @@ declare namespace LocalJSX { * 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; + "columns"?: IonGalleryColumns; /** - * 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' + * 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. When unset, invalid, or missing for the active breakpoint, the gap comes from the theme's `--ion-gallery-gap` token. */ - "gap"?: GalleryGap; + "gap"?: IonGalleryGap; /** * The visual layout of the gallery. When `uniform`, rows take up the height of the tallest item and are spaced evenly across the gallery. Additionally, items will have an aspect ratio of 1/1, forcing them to be square unless a height is explicitly set. When `masonry`, items will be positioned under each other with only the specified gap between them. * @default 'uniform' @@ -7397,10 +7391,6 @@ declare namespace LocalJSX { * The order in which items are positioned. Only applies when layout is `masonry`. When `sequential`, items are positioned in the order they are placed in the DOM. When `best-fit`, items are positioned under the column with the most available space. Defaults to `sequential` when layout is `masonry` and `order` is not explicitly set. */ "order"?: 'sequential' | 'best-fit'; - /** - * The theme determines the visual appearance of the component. - */ - "theme"?: "ios" | "md" | "ionic"; } interface IonGalleryItem { /** diff --git a/core/src/components/gallery-item/gallery-item.scss b/core/src/components/gallery-item/gallery-item.scss index 8812392aedd..171ce1371bd 100644 --- a/core/src/components/gallery-item/gallery-item.scss +++ b/core/src/components/gallery-item/gallery-item.scss @@ -47,5 +47,5 @@ :host(.in-gallery-layout-masonry) { // The spacing between stacked items. Applies to all items except // for the last item in each column to remove any trailing space. - margin-bottom: var(--internal-gallery-gap, 16px); + margin-bottom: var(--internal-gallery-gap); } diff --git a/core/src/components/gallery/gallery-constants.ts b/core/src/components/gallery/gallery-constants.ts deleted file mode 100644 index d3a43fd716f..00000000000 --- a/core/src/components/gallery/gallery-constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { GalleryColumns, GalleryGap } from './gallery-interface'; - -export const DEFAULT_COLUMNS = { - xs: 2, - sm: 3, - md: 4, - lg: 6, - xl: 8, - xxl: 10, -} satisfies GalleryColumns; - -export const DEFAULT_GAP = '16px' satisfies GalleryGap; diff --git a/core/src/components/gallery/gallery-interface.ts b/core/src/components/gallery/gallery-interface.ts deleted file mode 100644 index cd7f0d4141c..00000000000 --- a/core/src/components/gallery/gallery-interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { BreakpointMap } from '@utils/breakpoints'; - -export type GalleryBreakpoints = BreakpointMap; - -export type GalleryColumns = string | number | GalleryBreakpoints; -export type GalleryGap = string | number | GalleryBreakpoints; diff --git a/core/src/components/gallery/gallery.constant.ts b/core/src/components/gallery/gallery.constant.ts new file mode 100644 index 00000000000..10867c8341b --- /dev/null +++ b/core/src/components/gallery/gallery.constant.ts @@ -0,0 +1,10 @@ +import type { IonGalleryColumns } from './gallery.interface'; + +export const DEFAULT_COLUMNS = { + xs: 2, + sm: 3, + md: 4, + lg: 6, + xl: 8, + xxl: 10, +} satisfies IonGalleryColumns; diff --git a/core/src/components/gallery/gallery.interface.ts b/core/src/components/gallery/gallery.interface.ts new file mode 100644 index 00000000000..40209620084 --- /dev/null +++ b/core/src/components/gallery/gallery.interface.ts @@ -0,0 +1,10 @@ +import type { BreakpointMap } from '@utils/breakpoints'; + +export type IonGalleryRecipe = { + gap?: string; +}; + +export type IonGalleryBreakpointValues = BreakpointMap; + +export type IonGalleryColumns = string | number | IonGalleryBreakpointValues; +export type IonGalleryGap = string | number | IonGalleryBreakpointValues; diff --git a/core/src/components/gallery/gallery.scss b/core/src/components/gallery/gallery.scss index 7a1879d2fed..e04eaefe88f 100644 --- a/core/src/components/gallery/gallery.scss +++ b/core/src/components/gallery/gallery.scss @@ -2,6 +2,15 @@ // -------------------------------------------------- :host { + /** + * @prop --ion-gallery-gap: The space between gallery items when the `gap` property is not set + */ + + // The gap comes from the theme's token unless the `gap` property is set, + // in which case the component sets this variable inline. Gallery items + // inherit it for the spacing between stacked items in the masonry layout. + --internal-gallery-gap: var(--ion-gallery-gap); + display: grid; grid-template-columns: repeat(var(--internal-gallery-columns, 2), minmax(0, 1fr)); } @@ -10,7 +19,7 @@ // -------------------------------------------------- :host(.gallery-layout-uniform) { - gap: var(--internal-gallery-gap, 16px); + gap: var(--internal-gallery-gap); } // Layout: Masonry @@ -19,7 +28,7 @@ :host(.gallery-layout-masonry) { align-items: start; - column-gap: var(--internal-gallery-gap, 16px); + column-gap: var(--internal-gallery-gap); row-gap: 0; // Each item's row span is computed from its height, so the row track must be diff --git a/core/src/components/gallery/gallery.spec.ts b/core/src/components/gallery/gallery.spec.ts index a03aaad4c83..fe52d6ec549 100644 --- a/core/src/components/gallery/gallery.spec.ts +++ b/core/src/components/gallery/gallery.spec.ts @@ -5,31 +5,31 @@ import * as helpers from '@utils/helpers'; import * as logging from '@utils/logging'; import { Gallery } from './gallery'; -import { DEFAULT_COLUMNS, DEFAULT_GAP } from './gallery-constants'; +import { DEFAULT_COLUMNS } from './gallery.constant'; let sharedGallery: Gallery; let el: HTMLIonGalleryElement; -// The expected columns and gap for each breakpoint when the columns and gap -// properties are not set or are set to an invalid value. +// The widths to test at each breakpoint, with the expected columns when the +// columns property is not set or is set to an invalid value. const DEFAULT_BREAKPOINTS = [ // xs - { width: 0, expectedColumns: DEFAULT_COLUMNS['xs'], expectedGap: DEFAULT_GAP }, - { width: 575, expectedColumns: DEFAULT_COLUMNS['xs'], expectedGap: DEFAULT_GAP }, + { width: 0, expectedColumns: DEFAULT_COLUMNS['xs'] }, + { width: 575, expectedColumns: DEFAULT_COLUMNS['xs'] }, // sm - { width: 576, expectedColumns: DEFAULT_COLUMNS['sm'], expectedGap: DEFAULT_GAP }, - { width: 767, expectedColumns: DEFAULT_COLUMNS['sm'], expectedGap: DEFAULT_GAP }, + { width: 576, expectedColumns: DEFAULT_COLUMNS['sm'] }, + { width: 767, expectedColumns: DEFAULT_COLUMNS['sm'] }, // md - { width: 768, expectedColumns: DEFAULT_COLUMNS['md'], expectedGap: DEFAULT_GAP }, - { width: 991, expectedColumns: DEFAULT_COLUMNS['md'], expectedGap: DEFAULT_GAP }, + { width: 768, expectedColumns: DEFAULT_COLUMNS['md'] }, + { width: 991, expectedColumns: DEFAULT_COLUMNS['md'] }, // lg - { width: 992, expectedColumns: DEFAULT_COLUMNS['lg'], expectedGap: DEFAULT_GAP }, - { width: 1199, expectedColumns: DEFAULT_COLUMNS['lg'], expectedGap: DEFAULT_GAP }, + { width: 992, expectedColumns: DEFAULT_COLUMNS['lg'] }, + { width: 1199, expectedColumns: DEFAULT_COLUMNS['lg'] }, // xl - { width: 1200, expectedColumns: DEFAULT_COLUMNS['xl'], expectedGap: DEFAULT_GAP }, - { width: 1399, expectedColumns: DEFAULT_COLUMNS['xl'], expectedGap: DEFAULT_GAP }, + { width: 1200, expectedColumns: DEFAULT_COLUMNS['xl'] }, + { width: 1399, expectedColumns: DEFAULT_COLUMNS['xl'] }, // xxl - { width: 1400, expectedColumns: DEFAULT_COLUMNS['xxl'], expectedGap: DEFAULT_GAP }, + { width: 1400, expectedColumns: DEFAULT_COLUMNS['xxl'] }, ]; describe('gallery', () => { @@ -446,23 +446,21 @@ describe('gallery', () => { }); describe('getGapForWidth()', () => { - it('should resolve to the default gap for each breakpoint', () => { - const breakpoints = DEFAULT_BREAKPOINTS; + it("should use the theme's gap when the gap property is not set", () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); - breakpoints.forEach(({ width, expectedGap }) => { - expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); - }); + expect((sharedGallery as any).getGapForWidth(768)).toBeUndefined(); + expect(warningSpy).not.toHaveBeenCalled(); + + warningSpy.mockRestore(); }); - it('should fallback to the default gap when the gap property is set to a negative number', () => { - const breakpoints = DEFAULT_BREAKPOINTS; + it("should warn and use the theme's gap when the gap property is set to a negative number", () => { const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); sharedGallery.gap = -3; - breakpoints.forEach(({ width, expectedGap }) => { - expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); - }); + expect((sharedGallery as any).getGapForWidth(768)).toBeUndefined(); expect(warningSpy).toHaveBeenCalledWith( expect.stringContaining('[ion-gallery] - Invalid "gap" value (-3).'), @@ -472,15 +470,12 @@ describe('gallery', () => { warningSpy.mockRestore(); }); - it('should fallback to the default gap when the gap property is set to an invalid string', () => { - const breakpoints = DEFAULT_BREAKPOINTS; + it("should warn and use the theme's gap when the gap property is set to an invalid string", () => { const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); sharedGallery.gap = ''; - breakpoints.forEach(({ width, expectedGap }) => { - expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); - }); + expect((sharedGallery as any).getGapForWidth(768)).toBeUndefined(); expect(warningSpy).toHaveBeenCalledWith( expect.stringContaining('[ion-gallery] - Invalid "gap" value ("").'), @@ -490,14 +485,14 @@ describe('gallery', () => { warningSpy.mockRestore(); }); - it('should fallback to the default gap when the gap property is set to an object with all invalid breakpoint values', () => { + it("should warn and use the theme's gap when the gap property is set to an object with all invalid breakpoint values", () => { const breakpoints = DEFAULT_BREAKPOINTS; const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); sharedGallery.gap = { xs: '', sm: -3 }; - breakpoints.forEach(({ width, expectedGap }) => { - expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); + breakpoints.forEach(({ width }) => { + expect((sharedGallery as any).getGapForWidth(width)).toBeUndefined(); }); expect(warningSpy).toHaveBeenCalledWith(expect.stringContaining('[ion-gallery] - Invalid "gap" value'), el); @@ -505,14 +500,14 @@ describe('gallery', () => { warningSpy.mockRestore(); }); - it('should warn and fallback when gap is an empty breakpoint map object', () => { + it("should warn and use the theme's gap when gap is an empty breakpoint map object", () => { const breakpoints = DEFAULT_BREAKPOINTS; const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); sharedGallery.gap = {} as any; - breakpoints.forEach(({ width, expectedGap }) => { - expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); + breakpoints.forEach(({ width }) => { + expect((sharedGallery as any).getGapForWidth(width)).toBeUndefined(); }); expect(warningSpy).toHaveBeenCalledWith( @@ -523,14 +518,14 @@ describe('gallery', () => { warningSpy.mockRestore(); }); - it('should warn and fallback when gap breakpoint map has only unrecognized keys', () => { + it("should warn and use the theme's gap when the gap breakpoint map has only unrecognized keys", () => { const breakpoints = DEFAULT_BREAKPOINTS; const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); sharedGallery.gap = { gapp: '8px', sm_typo: '1rem' } as any; - breakpoints.forEach(({ width, expectedGap }) => { - expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); + breakpoints.forEach(({ width }) => { + expect((sharedGallery as any).getGapForWidth(width)).toBeUndefined(); }); expect(warningSpy).toHaveBeenCalledWith(expect.stringContaining('[ion-gallery] - Invalid "gap" value'), el); @@ -538,19 +533,19 @@ describe('gallery', () => { warningSpy.mockRestore(); }); - it('should properly set gap for the md breakpoint but fallback to the default gap for all others when the gap property is set to an object with one valid breakpoint and the rest invalid', () => { + it("should properly set gap for the md breakpoint but use the theme's gap for all others when the gap property is set to an object with one valid breakpoint and the rest invalid", () => { const breakpoints = [ - { width: 0, expectedGap: DEFAULT_GAP }, - { width: 575, expectedGap: DEFAULT_GAP }, - { width: 576, expectedGap: DEFAULT_GAP }, - { width: 767, expectedGap: DEFAULT_GAP }, + { width: 0, expectedGap: undefined }, + { width: 575, expectedGap: undefined }, + { width: 576, expectedGap: undefined }, + { width: 767, expectedGap: undefined }, { width: 768, expectedGap: '1rem' }, { width: 991, expectedGap: '1rem' }, - { width: 992, expectedGap: DEFAULT_GAP }, - { width: 1199, expectedGap: DEFAULT_GAP }, - { width: 1200, expectedGap: DEFAULT_GAP }, - { width: 1399, expectedGap: DEFAULT_GAP }, - { width: 1400, expectedGap: DEFAULT_GAP }, + { width: 992, expectedGap: undefined }, + { width: 1199, expectedGap: undefined }, + { width: 1200, expectedGap: undefined }, + { width: 1399, expectedGap: undefined }, + { width: 1400, expectedGap: undefined }, ]; const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); @@ -565,15 +560,12 @@ describe('gallery', () => { warningSpy.mockRestore(); }); - it('should fallback to the default gap when the gap property is set to an unaccepted type', () => { - const breakpoints = DEFAULT_BREAKPOINTS; + it("should warn and use the theme's gap when the gap property is set to an unaccepted type", () => { const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); sharedGallery.gap = [{ xs: 'invalid' }] as any; - breakpoints.forEach(({ width, expectedGap }) => { - expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); - }); + expect((sharedGallery as any).getGapForWidth(768)).toBeUndefined(); expect(warningSpy).toHaveBeenCalledWith( expect.stringContaining('[ion-gallery] - Invalid "gap" value ([{"xs":"invalid"}]).'), @@ -652,17 +644,17 @@ describe('gallery', () => { it('should resolve to the CSS variable for breakpoints that set one when gap is a breakpoint map', () => { const breakpoints = [ - { width: 0, expectedGap: DEFAULT_GAP }, - { width: 575, expectedGap: DEFAULT_GAP }, - { width: 576, expectedGap: DEFAULT_GAP }, - { width: 767, expectedGap: DEFAULT_GAP }, + { width: 0, expectedGap: undefined }, + { width: 575, expectedGap: undefined }, + { width: 576, expectedGap: undefined }, + { width: 767, expectedGap: undefined }, { width: 768, expectedGap: 'var(--app-gap)' }, { width: 991, expectedGap: 'var(--app-gap)' }, - { width: 992, expectedGap: DEFAULT_GAP }, - { width: 1199, expectedGap: DEFAULT_GAP }, - { width: 1200, expectedGap: DEFAULT_GAP }, - { width: 1399, expectedGap: DEFAULT_GAP }, - { width: 1400, expectedGap: DEFAULT_GAP }, + { width: 992, expectedGap: undefined }, + { width: 1199, expectedGap: undefined }, + { width: 1200, expectedGap: undefined }, + { width: 1399, expectedGap: undefined }, + { width: 1400, expectedGap: undefined }, ]; const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); @@ -685,11 +677,11 @@ describe('gallery', () => { { width: 767, expectedGap: 'var(--g-sm)' }, { width: 768, expectedGap: 'var(--g-md)' }, { width: 991, expectedGap: 'var(--g-md)' }, - { width: 992, expectedGap: DEFAULT_GAP }, - { width: 1199, expectedGap: DEFAULT_GAP }, + { width: 992, expectedGap: undefined }, + { width: 1199, expectedGap: undefined }, { width: 1200, expectedGap: '2rem' }, { width: 1399, expectedGap: '2rem' }, - { width: 1400, expectedGap: DEFAULT_GAP }, + { width: 1400, expectedGap: undefined }, ]; const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); @@ -704,15 +696,12 @@ describe('gallery', () => { warningSpy.mockRestore(); }); - it('should warn and fallback to the default gap when gap is a malformed CSS variable', () => { - const breakpoints = DEFAULT_BREAKPOINTS; + it("should warn and use the theme's gap when gap is a malformed CSS variable", () => { const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); sharedGallery.gap = 'var(--app-gap. 16px)'; - breakpoints.forEach(({ width, expectedGap }) => { - expect((sharedGallery as any).getGapForWidth(width)).toBe(expectedGap); - }); + expect((sharedGallery as any).getGapForWidth(768)).toBeUndefined(); expect(warningSpy).toHaveBeenCalledWith( expect.stringContaining('[ion-gallery] - Invalid "gap" value ("var(--app-gap. 16px)").'), @@ -979,6 +968,29 @@ describe('gallery', () => { expect(sharedGallery.el.style.getPropertyValue('--internal-gallery-columns')).toBe('5'); expect(sharedGallery.el.style.getPropertyValue('--internal-gallery-gap')).toBe('8px'); }); + + it('should remove the gap CSS variable when the gap property is unset', () => { + sharedGallery.gap = 8; + + jest.spyOn(sharedGallery.el, 'getBoundingClientRect').mockReturnValue({ + width: 768, + height: 0, + top: 0, + left: 0, + right: 768, + bottom: 0, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect); + + (sharedGallery as any).updateResponsiveStyles(true); + expect(sharedGallery.el.style.getPropertyValue('--internal-gallery-gap')).toBe('8px'); + + sharedGallery.gap = undefined; + (sharedGallery as any).updateResponsiveStyles(true); + expect(sharedGallery.el.style.getPropertyValue('--internal-gallery-gap')).toBe(''); + }); }); describe('scheduleMasonryResize()', () => { diff --git a/core/src/components/gallery/gallery.tsx b/core/src/components/gallery/gallery.tsx index 9974e3c2137..b62c87a4d88 100644 --- a/core/src/components/gallery/gallery.tsx +++ b/core/src/components/gallery/gallery.tsx @@ -6,10 +6,8 @@ import { isCssVariable, isValidLengthPercentage } from '@utils/css-value-validat import { raf } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; -import { getIonTheme } from '../../global/ionic-global'; - -import { DEFAULT_COLUMNS, DEFAULT_GAP } from './gallery-constants'; -import type { GalleryBreakpoints, GalleryColumns, GalleryGap } from './gallery-interface'; +import { DEFAULT_COLUMNS } from './gallery.constant'; +import type { IonGalleryBreakpointValues, IonGalleryColumns, IonGalleryGap } from './gallery.interface'; /** * The tag of the component used to wrap each gallery item. @@ -18,7 +16,6 @@ const GALLERY_ITEM_SELECTOR = 'ion-gallery-item'; /** * @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component. - * @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component. * * @slot - One or more `ion-gallery-item` components, placed in a responsive * gallery layout. @@ -68,7 +65,7 @@ export class Gallery implements ComponentInterface { * screen width. The width each breakpoint activates at can be changed with * the `screenBreakpoints` config. */ - @Prop() columns: GalleryColumns = DEFAULT_COLUMNS; + @Prop() columns: IonGalleryColumns = DEFAULT_COLUMNS; /** * The space between gallery items. Accepts valid CSS [length-percentage](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/length-percentage) @@ -81,8 +78,11 @@ export class Gallery implements ComponentInterface { * 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. + * + * When unset, invalid, or missing for the active breakpoint, the gap comes + * from the theme's `--ion-gallery-gap` token. */ - @Prop() gap: GalleryGap = DEFAULT_GAP; + @Prop() gap?: IonGalleryGap; @Watch('columns') @Watch('gap') @@ -271,7 +271,7 @@ export class Gallery implements ComponentInterface { * breakpoint key fails the sanitizer (e.g. `{ xs: -3 }`, `{ sm: 'foo' }`). */ private hasInvalidBreakpointMap( - breakpointMap: GalleryBreakpoints, + breakpointMap: IonGalleryBreakpointValues, sanitizeValue: (value: string | number | undefined) => unknown ) { let hasBreakpointEntry = false; @@ -295,7 +295,7 @@ export class Gallery implements ComponentInterface { */ private resolveFromBreakpoints( width: number, - breakpointMap: GalleryBreakpoints, + breakpointMap: IonGalleryBreakpointValues, sanitizeProvided: (value: string | number | undefined) => T | undefined, getSanitizedDefault: (breakpoint: ScreenBreakpoint) => T | undefined ) { @@ -321,7 +321,7 @@ export class Gallery implements ComponentInterface { * Get the columns from a responsive breakpoint map. * Returns the columns for the last matching breakpoint. */ - private getColumnsFromBreakpointMap(width: number, breakpointMap: GalleryBreakpoints) { + private getColumnsFromBreakpointMap(width: number, breakpointMap: IonGalleryBreakpointValues) { return this.resolveFromBreakpoints( width, breakpointMap, @@ -332,14 +332,15 @@ export class Gallery implements ComponentInterface { /** * Get the gap from a responsive breakpoint map. - * Returns the gap for the last matching breakpoint. + * Returns the gap for the last matching breakpoint, or `null` when that + * breakpoint has no valid value so the theme's gap token applies. */ - private getGapFromBreakpointMap(width: number, breakpointMap: GalleryBreakpoints) { - return this.resolveFromBreakpoints( + private getGapFromBreakpointMap(width: number, breakpointMap: IonGalleryBreakpointValues) { + return this.resolveFromBreakpoints( width, breakpointMap, (value) => this.sanitizeGap(value), - () => this.sanitizeGap(DEFAULT_GAP) + () => null ); } @@ -348,7 +349,7 @@ export class Gallery implements ComponentInterface { * an empty breakpoint map, a map with no supported breakpoint keys, * or a map with invalid breakpoint values. */ - private warnInvalidColumns(columns: GalleryColumns) { + private warnInvalidColumns(columns: IonGalleryColumns) { if (this.hasWarnedInvalidColumns) { return; } @@ -367,7 +368,7 @@ export class Gallery implements ComponentInterface { * an empty breakpoint map, a map with no supported breakpoint keys, * or a map with invalid breakpoint values. */ - private warnInvalidGap(gap: GalleryGap) { + private warnInvalidGap(gap: IonGalleryGap) { if (this.hasWarnedInvalidGap) { return; } @@ -425,28 +426,27 @@ export class Gallery implements ComponentInterface { } /** - * Resolve the active gap value for the current width. + * Resolve the active gap value for the current width. Returns `undefined` + * when the theme's gap token should apply: the `gap` property is unset, + * invalid, or has no value for the active breakpoint. */ - private getGapForWidth(width: number) { + private getGapForWidth(width: number): string | undefined { const { gap } = this; - const providedGap = gap ?? DEFAULT_GAP; - const isBreakpointGap = isBreakpointMap(providedGap); + if (gap === undefined) { + return undefined; + } + + const isBreakpointGap = isBreakpointMap(gap); const hasInvalidBreakpointGap = - isBreakpointGap && this.hasInvalidBreakpointMap(providedGap, (value) => this.sanitizeGap(value)); - const sanitizedGap = isBreakpointGap - ? this.getGapFromBreakpointMap(width, providedGap) - : this.sanitizeGap(providedGap); + isBreakpointGap && this.hasInvalidBreakpointMap(gap, (value) => this.sanitizeGap(value)); + const sanitizedGap = isBreakpointGap ? this.getGapFromBreakpointMap(width, gap) : this.sanitizeGap(gap); if (hasInvalidBreakpointGap || (!isBreakpointGap && sanitizedGap === undefined)) { - this.warnInvalidGap(providedGap); - } - - if (sanitizedGap !== undefined) { - return sanitizedGap; + this.warnInvalidGap(gap); } - return this.sanitizeGap(DEFAULT_GAP); + return sanitizedGap ?? undefined; } /** @@ -468,8 +468,14 @@ export class Gallery implements ComponentInterface { const columns = this.getColumnsForWidth(width); this.el.style.setProperty('--internal-gallery-columns', `${columns}`); + // Without a gap from the property, remove the inline value so the + // stylesheet's default (the theme's gap token) applies. const gap = this.getGapForWidth(width); - this.el.style.setProperty('--internal-gallery-gap', `${gap}`); + if (gap === undefined) { + this.el.style.removeProperty('--internal-gallery-gap'); + } else { + this.el.style.setProperty('--internal-gallery-gap', gap); + } } /** @@ -692,12 +698,10 @@ export class Gallery implements ComponentInterface { render() { const { layout } = this; const order = this.getOrder(); - const theme = getIonTheme(this); return ( { - await page.setViewportSize({ width: breakpoint.width, height: 900 }); - - await page.setContent( - ` - - One - Two - Three - Four - - `, - config - ); - - const gallery = page.locator('ion-gallery'); - - await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).rowGap)).toBe(DEFAULT_GAP); - await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).columnGap)).toBe(DEFAULT_GAP); - }); - test(`should resolve the gap CSS variable on ${breakpoint.name} screens`, async ({ page }) => { await page.setViewportSize({ width: breakpoint.width, height: 900 }); @@ -236,5 +215,104 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).rowGap)).toBe('8px'); await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).columnGap)).toBe('8px'); }); + + test("should use the theme's gap when the gap property is not set", async ({ page }) => { + await page.setViewportSize({ width: 768, height: 900 }); + + // The token is set to a value no theme uses, so a 7px gap can only + // come from `--ion-gallery-gap`. + await page.setContent( + ` + + One + Two + Three + Four + + `, + config + ); + + const gallery = page.locator('ion-gallery'); + + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).rowGap)).toBe('7px'); + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).columnGap)).toBe('7px'); + }); + + test("should use the theme's gap for breakpoints the gap property does not set", async ({ page }) => { + await page.setViewportSize({ width: 576, height: 900 }); + + // The token is set to a value no theme uses, so a 7px gap can only + // come from `--ion-gallery-gap`. + await page.setContent( + ` + + One + Two + Three + Four + + `, + config + ); + + const gallery = page.locator('ion-gallery'); + + // Breakpoint maps are objects, so they are set as a property rather + // than an attribute. Only `md` is set, so smaller breakpoints use the + // token and `md` and larger use 24px. + await gallery.evaluate((el) => { + (el as HTMLIonGalleryElement).gap = { md: 24 }; + }); + + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).rowGap)).toBe('7px'); + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).columnGap)).toBe('7px'); + + await page.setViewportSize({ width: 768, height: 900 }); + + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).rowGap)).toBe('24px'); + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).columnGap)).toBe('24px'); + + // Returning to `sm` removes the inline 24px so the token applies again. + await page.setViewportSize({ width: 576, height: 900 }); + + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).rowGap)).toBe('7px'); + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).columnGap)).toBe('7px'); + }); + }); +}); + +/** + * Each theme sets its own gap token, so this runs in every theme. + */ +configs({ directions: ['ltr'], modes: ['ios', 'md', 'ionic-md'] }).forEach(({ config, title }) => { + test.describe(title('gallery: theme gap'), () => { + test('should use the gap from each theme by default', async ({ page }) => { + await page.setContent( + ` + + One + Two + Three + Four + + `, + config + ); + + const gallery = page.locator('ion-gallery'); + + /** + * A theme without a gap token leaves the gap at 0. Checking that the + * token resolves to a value catches a theme that never sets it. + */ + const themeGap = await gallery.evaluate((el) => + getComputedStyle(el).getPropertyValue('--ion-gallery-gap').trim() + ); + expect(themeGap).not.toBe(''); + + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).rowGap)).toBe(themeGap); + await expect.poll(() => gallery.evaluate((el) => getComputedStyle(el).columnGap)).toBe(themeGap); + }); }); }); diff --git a/core/src/themes/ionic/default.tokens.ts b/core/src/themes/ionic/default.tokens.ts index cc3521d91ad..efd99b69a49 100644 --- a/core/src/themes/ionic/default.tokens.ts +++ b/core/src/themes/ionic/default.tokens.ts @@ -553,6 +553,10 @@ export const defaultTheme: DefaultTheme = { }, }, + IonGallery: { + gap: 'var(--ion-spacing-lg)', + }, + IonGrid: { breakpoint: { xs: { diff --git a/core/src/themes/ios/default.tokens.ts b/core/src/themes/ios/default.tokens.ts index 94e95411369..4e4ad1db6d8 100644 --- a/core/src/themes/ios/default.tokens.ts +++ b/core/src/themes/ios/default.tokens.ts @@ -688,6 +688,10 @@ export const defaultTheme: DefaultTheme = { }, }, + IonGallery: { + gap: 'var(--ion-spacing-lg)', + }, + IonGrid: { breakpoint: { xs: { diff --git a/core/src/themes/md/default.tokens.ts b/core/src/themes/md/default.tokens.ts index 6baaae6884a..e5dbf931ce8 100644 --- a/core/src/themes/md/default.tokens.ts +++ b/core/src/themes/md/default.tokens.ts @@ -691,6 +691,10 @@ export const defaultTheme: DefaultTheme = { }, }, + IonGallery: { + gap: 'var(--ion-spacing-lg)', + }, + IonGrid: { breakpoint: { xs: { diff --git a/core/src/themes/themes.interfaces.ts b/core/src/themes/themes.interfaces.ts index 2dd54367014..343c06e676f 100644 --- a/core/src/themes/themes.interfaces.ts +++ b/core/src/themes/themes.interfaces.ts @@ -2,6 +2,7 @@ import type { IonBadgeConfig, IonBadgeRecipe } from '../components/badge/badge.i import type { IonChipConfig, IonChipRecipe } from '../components/chip/chip.interfaces'; import type { IonColRecipe } from '../components/col/col.interface'; import type { IonContentRecipe } from '../components/content/content.interfaces'; +import type { IonGalleryRecipe } from '../components/gallery/gallery.interface'; import type { IonGridRecipe } from '../components/grid/grid.interface'; import type { IonItemDividerRecipe } from '../components/item-divider/item-divider.interfaces'; import type { IonProgressBarConfig, IonProgressBarRecipe } from '../components/progress-bar/progress-bar.interfaces'; @@ -298,6 +299,7 @@ type Components = { IonChip?: IonChipRecipe; IonCol?: IonColRecipe; IonContent?: IonContentRecipe; + IonGallery?: IonGalleryRecipe; IonGrid?: IonGridRecipe; IonItemDivider?: IonItemDividerRecipe; IonProgressBar?: IonProgressBarRecipe; diff --git a/packages/angular/src/lazy/directives/proxies.ts b/packages/angular/src/lazy/directives/proxies.ts index 3614f4e88f4..779761a6af0 100644 --- a/packages/angular/src/lazy/directives/proxies.ts +++ b/packages/angular/src/lazy/directives/proxies.ts @@ -904,14 +904,14 @@ export declare interface IonFooter extends Components.IonFooter {} @ProxyCmp({ - inputs: ['columns', 'gap', 'layout', 'mode', 'order', 'theme'] + inputs: ['columns', 'gap', 'layout', 'mode', 'order'] }) @Component({ selector: 'ion-gallery', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['columns', 'gap', 'layout', 'mode', 'order', 'theme'], + inputs: ['columns', 'gap', 'layout', 'mode', 'order'], standalone: false }) export class IonGallery { diff --git a/packages/angular/src/standalone/directives/ion-gallery.ts b/packages/angular/src/standalone/directives/ion-gallery.ts index d6327c40749..3dfda1279f9 100644 --- a/packages/angular/src/standalone/directives/ion-gallery.ts +++ b/packages/angular/src/standalone/directives/ion-gallery.ts @@ -10,14 +10,14 @@ import { defineCustomElement as defineIonGallery } from '@ionic/core/components/ @ProxyCmp({ defineCustomElementFn: defineIonGallery, - inputs: ['columns', 'gap', 'layout', 'mode', 'order', 'theme'] + inputs: ['columns', 'gap', 'layout', 'mode', 'order'] }) @Component({ selector: 'ion-gallery', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['columns', 'gap', 'layout', 'mode', 'order', 'theme'], + inputs: ['columns', 'gap', 'layout', 'mode', 'order'], }) export class IonGallery { protected el: HTMLIonGalleryElement;