diff --git a/core/api.txt b/core/api.txt index dafa0080ccf..76779d7f0d4 100644 --- a/core/api.txt +++ b/core/api.txt @@ -905,6 +905,10 @@ 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-item,shadow +ion-gallery-item,prop,mode,"ios" | "md",undefined,false,false +ion-gallery-item,prop,theme,"ios" | "md" | "ionic",undefined,false,false + ion-grid,shadow ion-grid,prop,fixed,boolean,false,false,false ion-grid,prop,mode,"ios" | "md",undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 302c22a4b27..9dc80144edd 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1500,6 +1500,16 @@ export namespace Components { */ "theme"?: "ios" | "md" | "ionic"; } + interface IonGalleryItem { + /** + * The mode determines the platform behaviors of the component. + */ + "mode"?: "ios" | "md"; + /** + * The theme determines the visual appearance of the component. + */ + "theme"?: "ios" | "md" | "ionic"; + } interface IonGrid { /** * If `true`, the grid will have a fixed width based on the screen size. @@ -5046,6 +5056,12 @@ declare global { prototype: HTMLIonGalleryElement; new (): HTMLIonGalleryElement; }; + interface HTMLIonGalleryItemElement extends Components.IonGalleryItem, HTMLStencilElement { + } + var HTMLIonGalleryItemElement: { + prototype: HTMLIonGalleryItemElement; + new (): HTMLIonGalleryItemElement; + }; interface HTMLIonGridElement extends Components.IonGrid, HTMLStencilElement { } var HTMLIonGridElement: { @@ -6004,6 +6020,7 @@ declare global { "ion-fab-list": HTMLIonFabListElement; "ion-footer": HTMLIonFooterElement; "ion-gallery": HTMLIonGalleryElement; + "ion-gallery-item": HTMLIonGalleryItemElement; "ion-grid": HTMLIonGridElement; "ion-header": HTMLIonHeaderElement; "ion-img": HTMLIonImgElement; @@ -7548,6 +7565,16 @@ declare namespace LocalJSX { */ "theme"?: "ios" | "md" | "ionic"; } + interface IonGalleryItem { + /** + * The mode determines the platform behaviors of the component. + */ + "mode"?: "ios" | "md"; + /** + * The theme determines the visual appearance of the component. + */ + "theme"?: "ios" | "md" | "ionic"; + } interface IonGrid { /** * If `true`, the grid will have a fixed width based on the screen size. @@ -11539,6 +11566,7 @@ declare namespace LocalJSX { "ion-fab-list": Omit & { [K in keyof IonFabList & keyof IonFabListAttributes]?: IonFabList[K] } & { [K in keyof IonFabList & keyof IonFabListAttributes as `attr:${K}`]?: IonFabListAttributes[K] } & { [K in keyof IonFabList & keyof IonFabListAttributes as `prop:${K}`]?: IonFabList[K] }; "ion-footer": Omit & { [K in keyof IonFooter & keyof IonFooterAttributes]?: IonFooter[K] } & { [K in keyof IonFooter & keyof IonFooterAttributes as `attr:${K}`]?: IonFooterAttributes[K] } & { [K in keyof IonFooter & keyof IonFooterAttributes as `prop:${K}`]?: IonFooter[K] }; "ion-gallery": Omit & { [K in keyof IonGallery & keyof IonGalleryAttributes]?: IonGallery[K] } & { [K in keyof IonGallery & keyof IonGalleryAttributes as `attr:${K}`]?: IonGalleryAttributes[K] } & { [K in keyof IonGallery & keyof IonGalleryAttributes as `prop:${K}`]?: IonGallery[K] }; + "ion-gallery-item": IonGalleryItem; "ion-grid": Omit & { [K in keyof IonGrid & keyof IonGridAttributes]?: IonGrid[K] } & { [K in keyof IonGrid & keyof IonGridAttributes as `attr:${K}`]?: IonGridAttributes[K] } & { [K in keyof IonGrid & keyof IonGridAttributes as `prop:${K}`]?: IonGrid[K] }; "ion-header": Omit & { [K in keyof IonHeader & keyof IonHeaderAttributes]?: IonHeader[K] } & { [K in keyof IonHeader & keyof IonHeaderAttributes as `attr:${K}`]?: IonHeaderAttributes[K] } & { [K in keyof IonHeader & keyof IonHeaderAttributes as `prop:${K}`]?: IonHeader[K] }; "ion-img": Omit & { [K in keyof IonImg & keyof IonImgAttributes]?: IonImg[K] } & { [K in keyof IonImg & keyof IonImgAttributes as `attr:${K}`]?: IonImgAttributes[K] } & { [K in keyof IonImg & keyof IonImgAttributes as `prop:${K}`]?: IonImg[K] }; @@ -11644,6 +11672,7 @@ declare module "@stencil/core" { "ion-fab-list": LocalJSX.IntrinsicElements["ion-fab-list"] & JSXBase.HTMLAttributes; "ion-footer": LocalJSX.IntrinsicElements["ion-footer"] & JSXBase.HTMLAttributes; "ion-gallery": LocalJSX.IntrinsicElements["ion-gallery"] & JSXBase.HTMLAttributes; + "ion-gallery-item": LocalJSX.IntrinsicElements["ion-gallery-item"] & JSXBase.HTMLAttributes; "ion-grid": LocalJSX.IntrinsicElements["ion-grid"] & JSXBase.HTMLAttributes; "ion-header": LocalJSX.IntrinsicElements["ion-header"] & JSXBase.HTMLAttributes; "ion-img": LocalJSX.IntrinsicElements["ion-img"] & JSXBase.HTMLAttributes; diff --git a/core/src/components/gallery-item/gallery-item.scss b/core/src/components/gallery-item/gallery-item.scss new file mode 100644 index 00000000000..06f82363b6a --- /dev/null +++ b/core/src/components/gallery-item/gallery-item.scss @@ -0,0 +1,47 @@ +@use "../../themes/native/native.globals" as globals; + +// Gallery Item +// -------------------------------------------------- + +:host { + display: block; +} + +// Slotted content +// -------------------------------------------------- + +// Reset the default margin for slotted elements so wrapper elements +// (such as
) align properly with other gallery items. +::slotted(*) { + @include globals.margin(0); + + width: 100%; +} + +::slotted(img) { + display: block; + + object-fit: cover; + object-position: center; +} + +// Layout: Uniform +// -------------------------------------------------- + +// In the uniform layout each cell is square by default. The aspect ratio is +// applied to the slotted content (rather than the item) so that an explicit +// height on the content takes precedence — a set `height` overrides +// `aspect-ratio` on the same element. The item then sizes to its content, +// allowing items with an explicit height to opt out of the square. +:host(.in-gallery-layout-uniform) ::slotted(*) { + aspect-ratio: 1 / 1; +} + +// Layout: Masonry +// -------------------------------------------------- + +: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); +} diff --git a/core/src/components/gallery-item/gallery-item.spec.ts b/core/src/components/gallery-item/gallery-item.spec.ts new file mode 100644 index 00000000000..9ae10b15371 --- /dev/null +++ b/core/src/components/gallery-item/gallery-item.spec.ts @@ -0,0 +1,83 @@ +import { newSpecPage } from '@stencil/core/testing'; +import * as logging from '@utils/logging'; + +import { Gallery } from '../gallery/gallery'; + +import { GalleryItem } from './gallery-item'; + +describe('gallery-item', () => { + let originalMutationObserver: typeof globalThis.MutationObserver | undefined; + let originalResizeObserver: typeof globalThis.ResizeObserver | undefined; + + beforeEach(() => { + // The spec environment does not implement these observers, which the + // components rely on. Provide no-op stand-ins for the duration of the test. + originalMutationObserver = globalThis.MutationObserver; + originalResizeObserver = globalThis.ResizeObserver; + (globalThis as any).MutationObserver = class { + observe() {} + disconnect() {} + }; + (globalThis as any).ResizeObserver = class { + observe() {} + disconnect() {} + }; + }); + + afterEach(() => { + (globalThis as any).MutationObserver = originalMutationObserver; + (globalThis as any).ResizeObserver = originalResizeObserver; + jest.restoreAllMocks(); + }); + + it('should warn when not used inside an ion-gallery', async () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + await newSpecPage({ + components: [GalleryItem], + html: ``, + }); + + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining( + '[ion-gallery-item] - This component should be used as a child of an "ion-gallery" component.' + ), + expect.anything() + ); + }); + + it('should not warn when used inside an ion-gallery', async () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + await newSpecPage({ + components: [Gallery, GalleryItem], + html: ``, + }); + + expect(warningSpy).not.toHaveBeenCalled(); + }); + + it('should reflect the parent gallery uniform layout as a class', async () => { + const page = await newSpecPage({ + components: [Gallery, GalleryItem], + html: ``, + }); + + const item = page.body.querySelector('ion-gallery-item')!; + + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(true); + expect(item.classList.contains('in-gallery-layout-masonry')).toBe(false); + }); + + it('should reflect the parent gallery masonry layout as a class', async () => { + const page = await newSpecPage({ + components: [Gallery, GalleryItem], + html: ``, + }); + + const item = page.body.querySelector('ion-gallery-item')!; + + expect(item.classList.contains('in-gallery-layout-masonry')).toBe(true); + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(false); + }); +}); diff --git a/core/src/components/gallery-item/gallery-item.tsx b/core/src/components/gallery-item/gallery-item.tsx new file mode 100644 index 00000000000..e3feadcc19a --- /dev/null +++ b/core/src/components/gallery-item/gallery-item.tsx @@ -0,0 +1,107 @@ +import type { ComponentInterface } from '@stencil/core'; +import { Component, Element, Host, State, h } from '@stencil/core'; +import { printIonWarning } from '@utils/logging'; + +import { getIonTheme } from '../../global/ionic-global'; + +/** + * @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 - The content placed inside of the gallery item. This is typically an + * `img`, but can be any element (e.g. a `figure` wrapping an image and caption). + */ +@Component({ + tag: 'ion-gallery-item', + styleUrl: 'gallery-item.scss', + shadow: true, +}) +export class GalleryItem implements ComponentInterface { + private hasWarnedInvalidParent = false; + private galleryEl?: HTMLIonGalleryElement; + private galleryClassObserver?: MutationObserver; + + @Element() el!: HTMLIonGalleryItemElement; + + /** + * The layout of the parent `ion-gallery`, mirrored as a class so the item + * can apply layout-specific styles (e.g. a square aspect ratio in the + * `uniform` layout, a bottom margin in the `masonry` layout). + */ + @State() galleryLayout?: 'uniform' | 'masonry'; + + componentWillLoad() { + this.galleryEl = this.el.closest('ion-gallery') ?? undefined; + this.syncLayoutClasses(); + } + + componentDidLoad() { + this.watchGalleryLayoutClasses(); + this.warnInvalidParent(); + } + + disconnectedCallback() { + this.galleryClassObserver?.disconnect(); + this.galleryClassObserver = undefined; + this.galleryEl = undefined; + } + + private onSlotChange = () => { + this.warnInvalidParent(); + }; + + /** + * Warn when the item is not a descendant of an `ion-gallery`. + */ + private warnInvalidParent() { + if (this.hasWarnedInvalidParent || this.galleryEl !== undefined) { + return; + } + + printIonWarning( + '[ion-gallery-item] - This component should be used as a child of an "ion-gallery" component.', + this.el + ); + this.hasWarnedInvalidParent = true; + } + + /** + * Watch the parent gallery's class list so the item can react to layout + * changes (the gallery reflects its layout as a `gallery-layout-*` class). + */ + private watchGalleryLayoutClasses() { + const galleryEl = this.galleryEl; + if (galleryEl === undefined) { + return; + } + + this.galleryClassObserver?.disconnect(); + this.galleryClassObserver = new MutationObserver(() => this.syncLayoutClasses()); + this.galleryClassObserver.observe(galleryEl, { + attributes: true, + attributeFilter: ['class'], + }); + } + + private syncLayoutClasses() { + const layout = this.galleryEl?.layout; + this.galleryLayout = layout === 'masonry' || layout === 'uniform' ? layout : undefined; + } + + render() { + const { galleryLayout } = this; + const theme = getIonTheme(this); + + return ( + + + + ); + } +} diff --git a/core/src/components/gallery/gallery.scss b/core/src/components/gallery/gallery.scss index f9ea282f52a..7a1879d2fed 100644 --- a/core/src/components/gallery/gallery.scss +++ b/core/src/components/gallery/gallery.scss @@ -1,5 +1,3 @@ -@use "../../themes/native/native.globals" as globals; - // Gallery // -------------------------------------------------- @@ -15,13 +13,6 @@ gap: var(--internal-gallery-gap, 16px); } -// Target all slotted elements in the uniform layout. This ensures that divs -// and images have an aspect ratio of 1/1. Nested images must inherit the -// aspect ratio of their parent. -:host(.gallery-layout-uniform) ::slotted(*) { - aspect-ratio: 1/1; -} - // Layout: Masonry // -------------------------------------------------- @@ -31,32 +22,9 @@ column-gap: var(--internal-gallery-gap, 16px); row-gap: 0; - grid-auto-rows: 2px; -} - -:host(.gallery-layout-masonry) ::slotted(*) { - display: block; - - // Clear min-height so items size to their content - min-height: unset; - - margin-bottom: var(--internal-gallery-gap, 16px); -} - -// Slotted elements -// -------------------------------------------------- - -// Reset the default margin for slotted elements so wrapper elements -// (such as
) align properly with other gallery items. -::slotted(*) { - @include globals.margin(0); - - width: 100%; -} - -::slotted(img) { - display: block; - - object-fit: cover; - object-position: center; + // Each item's row span is computed from its height, so the row track must be + // as small as possible to keep the gap between stacked items accurate. A + // larger track quantizes the span and can inflate the gap by up to (track - 1) + // pixels. 1px keeps the rounding error sub-pixel. + grid-auto-rows: 1px; } diff --git a/core/src/components/gallery/gallery.spec.ts b/core/src/components/gallery/gallery.spec.ts index 609bf22dc5e..45799fcee2e 100644 --- a/core/src/components/gallery/gallery.spec.ts +++ b/core/src/components/gallery/gallery.spec.ts @@ -746,53 +746,78 @@ describe('gallery', () => { describe('gallery: layout', () => { describe('getItems()', () => { - it('should include direct child SVG elements with HTML elements', () => { - const div = document.createElement('div'); - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - el.appendChild(div); - el.appendChild(svg); + it('should collect direct ion-gallery-item children as items', () => { + const itemOne = document.createElement('ion-gallery-item'); + const itemTwo = document.createElement('ion-gallery-item'); + el.appendChild(itemOne); + el.appendChild(itemTwo); const items = (sharedGallery as any).getItems(); - expect(items).toEqual([div, svg]); - expect(items[1].namespaceURI).toBe('http://www.w3.org/2000/svg'); + expect(items).toEqual([itemOne, itemTwo]); }); - it('should exclude direct children without a usable CSSStyleDeclaration (no setProperty)', () => { - const included = document.createElement('div'); - const excluded = document.createElement('div'); - Object.defineProperty(excluded, 'style', { - configurable: true, - enumerable: true, - get() { - return { cssText: '' } as unknown as CSSStyleDeclaration; - }, - }); - el.appendChild(included); - el.appendChild(excluded); + it('should flatten a wrapper element and collapse its box with display: contents', () => { + const wrapper = document.createElement('div'); + const itemOne = document.createElement('ion-gallery-item'); + const itemTwo = document.createElement('ion-gallery-item'); + wrapper.appendChild(itemOne); + wrapper.appendChild(itemTwo); + el.appendChild(wrapper); const items = (sharedGallery as any).getItems(); - expect(items).toEqual([included]); + expect(items).toEqual([itemOne, itemTwo]); + expect(wrapper.style.display).toBe('contents'); }); - it('should apply masonry grid placement styles to slotted SVG elements', () => { - const div = document.createElement('div'); - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - el.appendChild(div); - el.appendChild(svg); + it('should warn and ignore children that do not contain an ion-gallery-item', () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + el.appendChild(document.createElement('img')); const items = (sharedGallery as any).getItems(); - jest.spyOn(div, 'getBoundingClientRect').mockReturnValue({ height: 20 } as DOMRect); - jest.spyOn(svg, 'getBoundingClientRect').mockReturnValue({ height: 30 } as DOMRect); + expect(items).toEqual([]); + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-gallery] - Gallery items must be wrapped in "ion-gallery-item" components.'), + el + ); + + warningSpy.mockRestore(); + }); + + it('should only warn once about invalid children', () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + el.appendChild(document.createElement('img')); + el.appendChild(document.createElement('span')); + + (sharedGallery as any).getItems(); + (sharedGallery as any).getItems(); + + expect(warningSpy).toHaveBeenCalledTimes(1); + + warningSpy.mockRestore(); + }); + + it('should apply masonry grid placement styles to items', () => { + const itemOne = document.createElement('ion-gallery-item'); + const itemTwo = document.createElement('ion-gallery-item'); + el.appendChild(itemOne); + el.appendChild(itemTwo); + + jest.spyOn(itemOne, 'getBoundingClientRect').mockReturnValue({ height: 20 } as DOMRect); + jest.spyOn(itemTwo, 'getBoundingClientRect').mockReturnValue({ height: 30 } as DOMRect); + + const items = (sharedGallery as any).getItems(); (sharedGallery as any).layoutMasonry(items, 10, 0, 2); - expect(div.style.gridColumn).toBe('1'); - expect(svg.style.gridColumn).toBe('2'); - expect(svg.style.gridRowStart).not.toBe(''); - expect(svg.style.gridRowEnd).not.toBe(''); + expect(itemOne.style.gridColumn).toBe('1'); + expect(itemTwo.style.gridColumn).toBe('2'); + expect(itemTwo.style.gridRowStart).not.toBe(''); + expect(itemTwo.style.gridRowEnd).not.toBe(''); }); }); diff --git a/core/src/components/gallery/gallery.tsx b/core/src/components/gallery/gallery.tsx index 61d56ef6d07..889572f5606 100644 --- a/core/src/components/gallery/gallery.tsx +++ b/core/src/components/gallery/gallery.tsx @@ -23,16 +23,16 @@ type GalleryBreakpoint = keyof typeof BREAKPOINTS; const BREAKPOINT_ORDER: GalleryBreakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl', 'xxl']; /** - * Direct slotted children that support CSS grid placement and inline `style`. - * This is a union of `HTMLElement` and `SVGElement` to support both HTML and SVG elements. + * The tag of the component used to wrap each gallery item. */ -type GalleryItemElement = HTMLElement | SVGElement; +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 - Content is placed in a responsive gallery layout. + * @slot - One or more `ion-gallery-item` components, placed in a responsive + * gallery layout. */ @Component({ tag: 'ion-gallery', @@ -51,6 +51,7 @@ export class Gallery implements ComponentInterface { private hasWarnedInvalidColumns = false; private hasWarnedInvalidGap = false; private hasWarnedUnusedOrder = false; + private hasWarnedInvalidItems = false; /** * The visual layout of the gallery. When `uniform`, rows take up the height @@ -111,7 +112,7 @@ export class Gallery implements ComponentInterface { this.updateResponsiveStyles(); this.scheduleMasonryResize(); }); - this.resizeObserver.observe(this.el); + this.observeResizes(); this.scheduleMasonryResize(); @@ -128,6 +129,24 @@ export class Gallery implements ComponentInterface { this.resizeObserver = undefined; } + /** + * Observe the host and each item for size changes. Items are observed in + * addition to the host so masonry placement is recomputed when an item's + * rendered height changes — most importantly when a dynamically added + * `ion-gallery-item` finishes hydrating, which (unlike an ``) emits no + * `load` event and does not change the host's measured size while collapsed. + */ + private observeResizes() { + const observer = this.resizeObserver; + if (observer === undefined) { + return; + } + + observer.disconnect(); + observer.observe(this.el); + this.getItems().forEach((item) => observer.observe(item)); + } + /** * Listen for the load event on child elements. * When the layout is `masonry`, this listener is used to schedule a resize @@ -153,6 +172,9 @@ export class Gallery implements ComponentInterface { * are added or removed from the gallery. */ private onSlotChange = () => { + // Re-observe so newly added items are watched for size changes (e.g. a + // freshly appended item finishing hydration), then recompute placement. + this.observeResizes(); this.scheduleMasonryResize(); }; @@ -450,20 +472,60 @@ export class Gallery implements ComponentInterface { } /** - * Return all directly slotted children of the gallery that can be grid items - * with inline placement styles (HTML elements and SVG elements). + * Return the `ion-gallery-item` elements to place in the grid. Each item is a + * direct grid cell. A direct child that is not an `ion-gallery-item` is + * treated as a pass-through wrapper (e.g. a layout `
`): its box is + * collapsed with `display: contents` so the nested items participate in the + * gallery grid. Children that contain no `ion-gallery-item` are ignored. + */ + private getItems(): HTMLIonGalleryItemElement[] { + const items: HTMLIonGalleryItemElement[] = []; + + Array.from(this.el.children).forEach((child) => { + // Standard path: is a direct child of . + if (child.matches(GALLERY_ITEM_SELECTOR)) { + items.push(child as HTMLIonGalleryItemElement); + return; + } + + // Compatibility path: a wrapper element may contain + // components. Collapse the wrapper's box so the items participate in the + // gallery grid. + const nestedItems = Array.from(child.querySelectorAll(GALLERY_ITEM_SELECTOR)); + + if (nestedItems.length === 0) { + this.warnInvalidItems(); + return; + } + + (child as HTMLElement).style.display = 'contents'; + items.push(...nestedItems); + }); + + return items; + } + + /** + * Warn when the gallery has content that is not wrapped in an + * `ion-gallery-item` component. */ - private getItems(): GalleryItemElement[] { - return Array.from(this.el.children).filter( - (child): child is GalleryItemElement => typeof (child as any).style?.setProperty === 'function' + private warnInvalidItems() { + if (this.hasWarnedInvalidItems) { + return; + } + + printIonWarning( + `[ion-gallery] - Gallery items must be wrapped in "ion-gallery-item" components. Direct children that are not "ion-gallery-item" (and do not contain one) are ignored.`, + this.el ); + this.hasWarnedInvalidItems = true; } /** * Clear the item styles for the given item element. * This is used to switch between uniform and masonry layouts. */ - private clearItemStyles(itemEl: GalleryItemElement) { + private clearItemStyles(itemEl: HTMLIonGalleryItemElement) { itemEl.style.gridRowStart = ''; itemEl.style.gridRowEnd = ''; itemEl.style.gridColumn = ''; @@ -477,12 +539,20 @@ export class Gallery implements ComponentInterface { this.getItems().forEach((itemEl) => this.clearItemStyles(itemEl)); } + /** + * Whether the item contains any images that have not finished loading. + * Used to defer masonry placement until the rendered height is final. + */ + private hasUnloadedImages(itemEl: HTMLIonGalleryItemElement): boolean { + return Array.from(itemEl.querySelectorAll('img')).some((img) => !img.complete || img.naturalHeight === 0); + } + /** * Convert a rendered item height to the number of grid rows it should span. - * Returns undefined for images that are not fully loaded yet. + * Returns undefined when the item has images that are not fully loaded yet. */ - private calculateRowSpan(itemEl: GalleryItemElement, rowHeight: number, rowGap: number) { - if (itemEl instanceof HTMLImageElement && (!itemEl.complete || itemEl.naturalHeight === 0)) { + private calculateRowSpan(itemEl: HTMLIonGalleryItemElement, rowHeight: number, rowGap: number) { + if (this.hasUnloadedImages(itemEl)) { return undefined; } @@ -523,9 +593,9 @@ export class Gallery implements ComponentInterface { /** * Apply masonry placement by assigning each item a column and row span. */ - private layoutMasonry(items: GalleryItemElement[], rowHeight: number, rowGap: number, columns: number) { + private layoutMasonry(items: HTMLIonGalleryItemElement[], rowHeight: number, rowGap: number, columns: number) { const columnHeights = new Array(columns).fill(0); - const lastItemsByColumn = new Array(columns).fill(undefined); + const lastItemsByColumn = new Array(columns).fill(undefined); items.forEach((itemEl, i) => { itemEl.style.marginBottom = ''; diff --git a/core/src/components/gallery/test/basic/gallery.e2e.ts b/core/src/components/gallery/test/basic/gallery.e2e.ts index 460c7228881..2ead83d28cf 100644 --- a/core/src/components/gallery/test/basic/gallery.e2e.ts +++ b/core/src/components/gallery/test/basic/gallery.e2e.ts @@ -26,18 +26,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve `, config @@ -64,18 +64,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve `, config @@ -98,18 +98,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve `, config @@ -130,10 +130,10 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four + One + Two + Three + Four `, config @@ -151,10 +151,10 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four + One + Two + Three + Four `, config @@ -172,10 +172,10 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four + One + Two + Three + Four `, config @@ -222,10 +222,10 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four + One + Two + Three + Four `, config diff --git a/core/src/components/gallery/test/basic/index.html b/core/src/components/gallery/test/basic/index.html index 65b186fb795..f69b3fcb700 100644 --- a/core/src/components/gallery/test/basic/index.html +++ b/core/src/components/gallery/test/basic/index.html @@ -24,33 +24,33 @@ - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve -
One
-
Two
-
Three
-
Four
-
Five
-
Six
-
Seven
-
Eight
-
Nine
-
Ten
-
Eleven
-
Twelve
+
One
+
Two
+
Three
+
Four
+
Five
+
Six
+
Seven
+
Eight
+
Nine
+
Ten
+
Eleven
+
Twelve
@@ -60,64 +60,64 @@ margin-bottom: 16px; } - ion-gallery img, - ion-gallery div { + ion-gallery-item img, + ion-gallery-item div { border-radius: 16px; } - ion-gallery div { + ion-gallery-item div { display: flex; align-items: center; justify-content: center; color: #fff; } - ion-gallery div:nth-child(1) { + ion-gallery-item:nth-child(1) div { background: #ff6b6b; } - ion-gallery div:nth-child(2) { + ion-gallery-item:nth-child(2) div { background: #4ecdc4; } - ion-gallery div:nth-child(3) { + ion-gallery-item:nth-child(3) div { background: #ffe66d; color: #333; } - ion-gallery div:nth-child(4) { + ion-gallery-item:nth-child(4) div { background: #5f27cd; } - ion-gallery div:nth-child(5) { + ion-gallery-item:nth-child(5) div { background: #7f8c8d; } - ion-gallery div:nth-child(6) { + ion-gallery-item:nth-child(6) div { background: #ff9f43; } - ion-gallery div:nth-child(7) { + ion-gallery-item:nth-child(7) div { background: #ff3f34; } - ion-gallery div:nth-child(8) { + ion-gallery-item:nth-child(8) div { background: #2ecc71; } - ion-gallery div:nth-child(9) { + ion-gallery-item:nth-child(9) div { background: #34495e; } - ion-gallery div:nth-child(10) { + ion-gallery-item:nth-child(10) div { background: #1abc9c; } - ion-gallery div:nth-child(11) { + ion-gallery-item:nth-child(11) div { background: #e67e22; } - ion-gallery div:nth-child(12) { + ion-gallery-item:nth-child(12) div { background: #9b59b6; } diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts b/core/src/components/gallery/test/layout/gallery.e2e.ts index 407648cebfd..20e7194ccbe 100644 --- a/core/src/components/gallery/test/layout/gallery.e2e.ts +++ b/core/src/components/gallery/test/layout/gallery.e2e.ts @@ -28,18 +28,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t -
One
-
Two
-
Three
-
Four
-
Five
-
Six
-
Seven
-
Eight
-
Nine
-
Ten
-
Eleven
-
Twelve
+
One
+
Two
+
Three
+
Four
+
Five
+
Six
+
Seven
+
Eight
+
Nine
+
Ten
+
Eleven
+
Twelve
`, config @@ -68,18 +68,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t -
One
-
Two
-
Three
-
Four
-
Five
-
Six
-
Seven
-
Eight
-
Nine
-
Ten
-
Eleven
-
Twelve
+
One
+
Two
+
Three
+
Four
+
Five
+
Six
+
Seven
+
Eight
+
Nine
+
Ten
+
Eleven
+
Twelve
`, config @@ -112,18 +112,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve `, config @@ -152,18 +152,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t - One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve `, config @@ -191,12 +191,12 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t -
One
-
Two
-
Three
-
Four
-
Five
-
Six
+
One
+
Two
+
Three
+
Four
+
Five
+
Six
`, config @@ -212,10 +212,12 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await gallery.evaluate((galleryEl, items) => { items.forEach(({ itemLabel, itemHeight }) => { + const galleryItemEl = document.createElement('ion-gallery-item'); const divEl = document.createElement('div'); divEl.style.height = `${itemHeight}px`; divEl.textContent = itemLabel; - galleryEl.append(divEl); + galleryItemEl.append(divEl); + galleryEl.append(galleryItemEl); }); }, appendedItems); @@ -240,12 +242,12 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t - One - Two - Three - Four - Five - Six + One + Two + Three + Four + Five + Six `, config @@ -259,11 +261,13 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await gallery.evaluate((galleryEl, items) => { items.forEach(({ itemLabel, itemSrc }) => { + const galleryItemEl = document.createElement('ion-gallery-item'); const imageEl = document.createElement('img'); imageEl.src = itemSrc; imageEl.alt = itemLabel; - galleryEl.append(imageEl); + galleryItemEl.append(imageEl); + galleryEl.append(galleryItemEl); }); }, appendedItems); @@ -295,8 +299,9 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t ${sharedStyles} /** - * Redefine the ::slotted(img) styles from gallery.scss - * because the nested img does not receive slotted styles. + * The gallery item's ::slotted(img) styles only reach a + * directly slotted img, not one nested inside a
, + * so redefine them for the nested image here. */ ion-gallery figure img { display: block; @@ -307,24 +312,36 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t -
- One -
-
- Two -
-
- Three -
-
- Four -
-
- Five -
-
- Six -
+ +
+ One +
+
+ +
+ Two +
+
+ +
+ Three +
+
+ +
+ Four +
+
+ +
+ Five +
+
+ +
+ Six +
+
`, config @@ -338,6 +355,7 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await gallery.evaluate((galleryEl, items) => { items.forEach(({ itemLabel, itemSrc }) => { + const galleryItemEl = document.createElement('ion-gallery-item'); const figureEl = document.createElement('figure'); figureEl.className = 'gallery-image-item'; @@ -346,7 +364,8 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t imageEl.alt = itemLabel; figureEl.append(imageEl); - galleryEl.append(figureEl); + galleryItemEl.append(figureEl); + galleryEl.append(galleryItemEl); }); }, appendedItems); @@ -376,18 +395,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` -
One
-
Two
-
Three
-
Four
-
Five
-
Six
-
Seven
-
Eight
-
Nine
-
Ten
-
Eleven
-
Twelve
+
One
+
Two
+
Three
+
Four
+
Five
+
Six
+
Seven
+
Eight
+
Nine
+
Ten
+
Eleven
+
Twelve
`, config diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png index dcb8b8e60e5..4e920dd5853 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png index 22afe4afd0a..c65fb378f1f 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png index ecab78ae12f..f2c03927b75 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Chrome-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Chrome-linux.png index dcb8b8e60e5..4e920dd5853 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Firefox-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Firefox-linux.png index 22afe4afd0a..c65fb378f1f 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Safari-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Safari-linux.png index ecab78ae12f..f2c03927b75 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Safari-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-best-fit-divs-variable-height-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png index 49911676b12..bc87b1b7b5e 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png index 8278407501d..e812584e1da 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png index 232203ba278..7a5b0a30745 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-dynamically-appended-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Chrome-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Chrome-linux.png index 49911676b12..bc87b1b7b5e 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Chrome-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Firefox-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Firefox-linux.png index 8278407501d..e812584e1da 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Firefox-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Safari-linux.png b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Safari-linux.png index 232203ba278..7a5b0a30745 100644 Binary files a/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Safari-linux.png and b/core/src/components/gallery/test/layout/gallery.e2e.ts-snapshots/gallery-masonry-sequential-divs-variable-height-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/gallery/test/layout/index.html b/core/src/components/gallery/test/layout/index.html index feffc05f6b6..9a7df7a5b53 100644 --- a/core/src/components/gallery/test/layout/index.html +++ b/core/src/components/gallery/test/layout/index.html @@ -39,52 +39,72 @@

Uniform

Divs

-
One
-
Two
-
Three
-
Four
-
Five
-
Six
-
Seven
-
Eight
-
Nine
-
Ten
-
Eleven
-
Twelve
+
One
+
Two
+
Three
+
Four
+
Five
+
Six
+
Seven
+
Eight
+
Nine
+
Ten
+
Eleven
+
Twelve

Images

- One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve

Same Height Images

- One - Two - Three - Four - Five - Six - Seven - Eight - Nine - Ten - Eleven - Twelve + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve
@@ -210,6 +230,7 @@

Same Height Images

galleries.forEach((galleryEl) => { const isImageGallery = galleryEl.querySelector('img') !== null; + const galleryItemEl = document.createElement('ion-gallery-item'); if (isImageGallery) { const photoId = 100 + ((nextItemNumber - 1) % 100); @@ -218,7 +239,8 @@

Same Height Images

`https://picsum.photos/id/${photoId}/164/${alternatingImgHeight}`, labelText ); - galleryEl.append(imageItemEl); + galleryItemEl.append(imageItemEl); + galleryEl.append(galleryItemEl); return; } @@ -227,7 +249,8 @@

Same Height Images

divEl.textContent = numberToWords(nextItemNumber); divEl.style.height = `${randomDivHeight}px`; divEl.style.background = randomColor; - galleryEl.append(divEl); + galleryItemEl.append(divEl); + galleryEl.append(galleryItemEl); }); nextItemNumber++; @@ -267,16 +290,16 @@

Same Height Images

margin: 0 auto; } - ion-gallery img, - ion-gallery div { + ion-gallery-item img, + ion-gallery-item div { border-radius: 16px; } - .same-height-gallery img { + .same-height-gallery ion-gallery-item img { height: 164px; } - ion-gallery .gallery-image-item img { + ion-gallery-item .gallery-image-item img { display: block; /** @@ -290,11 +313,11 @@

Same Height Images

object-position: center; } - ion-gallery .gallery-image-item { + ion-gallery-item .gallery-image-item { position: relative; } - ion-gallery .gallery-image-label { + ion-gallery-item .gallery-image-label { position: absolute; inset: 0; display: flex; @@ -307,70 +330,70 @@

Same Height Images

pointer-events: none; } - ion-gallery div { + ion-gallery-item div { display: flex; align-items: center; justify-content: center; color: #fff; } - ion-gallery div:nth-child(1) { + ion-gallery-item:nth-child(1) div { background: #ff6b6b; height: 175px; } - ion-gallery div:nth-child(2) { + ion-gallery-item:nth-child(2) div { background: #4ecdc4; height: 30px; } - ion-gallery div:nth-child(3) { + ion-gallery-item:nth-child(3) div { background: #ffe66d; color: #333; height: 90px; } - ion-gallery div:nth-child(4) { + ion-gallery-item:nth-child(4) div { background: #5f27cd; height: 50px; } - ion-gallery div:nth-child(5) { + ion-gallery-item:nth-child(5) div { background: #7f8c8d; height: 110px; } - ion-gallery div:nth-child(6) { + ion-gallery-item:nth-child(6) div { background: #ff9f43; height: 175px; } - ion-gallery div:nth-child(7) { + ion-gallery-item:nth-child(7) div { background: #ff3f34; height: 130px; } - ion-gallery div:nth-child(8) { + ion-gallery-item:nth-child(8) div { background: #2ecc71; height: 80px; } - ion-gallery div:nth-child(9) { + ion-gallery-item:nth-child(9) div { background: #34495e; height: 110px; } - ion-gallery div:nth-child(10) { + ion-gallery-item:nth-child(10) div { background: #1abc9c; height: 90px; } - ion-gallery div:nth-child(11) { + ion-gallery-item:nth-child(11) div { background: #e67e22; height: 100px; } - ion-gallery div:nth-child(12) { + ion-gallery-item:nth-child(12) div { background: #9b59b6; height: 150px; } diff --git a/core/src/components/gallery/test/utils.ts b/core/src/components/gallery/test/utils.ts index 3ffc0b076b5..1f56da7e13f 100644 --- a/core/src/components/gallery/test/utils.ts +++ b/core/src/components/gallery/test/utils.ts @@ -3,57 +3,57 @@ export const sharedStyles = ` width: 343px; } - div { + ion-gallery-item div { color: #fff; height: 150px; } - div:nth-child(1) { + ion-gallery-item:nth-child(1) div { background: #ff6b6b; } - div:nth-child(2) { + ion-gallery-item:nth-child(2) div { background: #4ecdc4; } - div:nth-child(3) { + ion-gallery-item:nth-child(3) div { background: #ffe66d; color: #333; } - div:nth-child(4) { + ion-gallery-item:nth-child(4) div { background: #5f27cd; } - div:nth-child(5) { + ion-gallery-item:nth-child(5) div { background: #7f8c8d; } - div:nth-child(6) { + ion-gallery-item:nth-child(6) div { background: #ff9f43; } - div:nth-child(7) { + ion-gallery-item:nth-child(7) div { background: #ff3f34; } - div:nth-child(8) { + ion-gallery-item:nth-child(8) div { background: #2ecc71; } - div:nth-child(9) { + ion-gallery-item:nth-child(9) div { background: #34495e; } - div:nth-child(10) { + ion-gallery-item:nth-child(10) div { background: #1abc9c; } - div:nth-child(11) { + ion-gallery-item:nth-child(11) div { background: #e67e22; } - div:nth-child(12) { + ion-gallery-item:nth-child(12) div { background: #9b59b6; } `; diff --git a/core/src/components/gallery/test/wrapper/gallery.e2e.ts b/core/src/components/gallery/test/wrapper/gallery.e2e.ts new file mode 100644 index 00000000000..7d5cdd92fe4 --- /dev/null +++ b/core/src/components/gallery/test/wrapper/gallery.e2e.ts @@ -0,0 +1,84 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +import { sharedStyles } from '../utils'; + +const LAYOUT_OPTIONS = ['uniform', 'masonry']; +const ITEM_HEIGHTS = [175, 30, 90, 50, 110, 175, 130, 80, 110, 90, 100, 150]; + +const buildItems = () => + ITEM_HEIGHTS.map( + (height, i) => `
${i + 1}
` + ).join(''); + +/** + * A wrapper element that contains gallery items (e.g. a layout `
` + * or a framework-generated wrapper) must be transparent to the gallery + * layout. The gallery collapses the wrapper with `display: contents` + * so the nested items participate in the grid as if the wrapper were + * not present. + * + * Rather than rely on a screenshot, this asserts that a wrapped gallery lays + * its items out identically to an unwrapped one. + * + * This behavior does not vary across modes/directions. + */ +configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, title }) => { + LAYOUT_OPTIONS.forEach((layout) => { + test.describe(title(`gallery: wrapper (${layout})`), () => { + test('should lay out wrapped items identically to unwrapped items', async ({ page }) => { + const items = buildItems(); + + await page.setContent( + ` + + + ${items} + + +
${items}
+
+ `, + config + ); + + // The wrapper's box is collapsed so it does not affect the grid. + await expect + .poll(() => page.locator('#wrapped .some-wrapper').evaluate((el) => getComputedStyle(el).display)) + .toBe('contents'); + + const measure = () => + page.evaluate(() => { + const itemRects = (gallerySelector: string) => { + const gallery = document.querySelector(gallerySelector)!; + const galleryRect = gallery.getBoundingClientRect(); + return Array.from(gallery.querySelectorAll('ion-gallery-item')).map((item) => { + const rect = item.getBoundingClientRect(); + return { + left: Math.round(rect.left - galleryRect.left), + top: Math.round(rect.top - galleryRect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + }); + }; + + return { unwrapped: itemRects('#unwrapped'), wrapped: itemRects('#wrapped') }; + }); + + // Wait for both layouts to settle, then confirm they match exactly. + await expect + .poll(async () => { + const { unwrapped, wrapped } = await measure(); + return JSON.stringify(unwrapped) === JSON.stringify(wrapped); + }) + .toBe(true); + + const { unwrapped, wrapped } = await measure(); + expect(wrapped).toEqual(unwrapped); + }); + }); + }); +}); diff --git a/core/src/components/gallery/test/wrapper/index.html b/core/src/components/gallery/test/wrapper/index.html new file mode 100644 index 00000000000..28aaa7ab656 --- /dev/null +++ b/core/src/components/gallery/test/wrapper/index.html @@ -0,0 +1,79 @@ + + + + + Gallery - Wrapper + + + + + + + + + + + + + Gallery - Wrapper + + Toggle Layout + + + + + +

Layout: Uniform

+ +
+ One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve +
+
+ + +
+
+ + diff --git a/packages/angular/src/directives/proxies-list.ts b/packages/angular/src/directives/proxies-list.ts index 9e7f5b18565..3945113e710 100644 --- a/packages/angular/src/directives/proxies-list.ts +++ b/packages/angular/src/directives/proxies-list.ts @@ -31,6 +31,7 @@ export const DIRECTIVES = [ d.IonFabList, d.IonFooter, d.IonGallery, + d.IonGalleryItem, d.IonGrid, d.IonHeader, d.IonIcon, diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index c1dfbec20a0..77de41cbbca 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -846,6 +846,28 @@ export class IonGallery { export declare interface IonGallery extends Components.IonGallery {} +@ProxyCmp({ + inputs: ['mode', 'theme'] +}) +@Component({ + selector: 'ion-gallery-item', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['mode', 'theme'], +}) +export class IonGalleryItem { + protected el: HTMLIonGalleryItemElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IonGalleryItem extends Components.IonGalleryItem {} + + @ProxyCmp({ inputs: ['fixed', 'mode', 'theme'] }) diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index ef1a118f4f7..3a938c58b94 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -33,6 +33,7 @@ import { defineCustomElement as defineIonFabButton } from '@ionic/core/component import { defineCustomElement as defineIonFabList } from '@ionic/core/components/ion-fab-list.js'; import { defineCustomElement as defineIonFooter } from '@ionic/core/components/ion-footer.js'; import { defineCustomElement as defineIonGallery } from '@ionic/core/components/ion-gallery.js'; +import { defineCustomElement as defineIonGalleryItem } from '@ionic/core/components/ion-gallery-item.js'; import { defineCustomElement as defineIonGrid } from '@ionic/core/components/ion-grid.js'; import { defineCustomElement as defineIonHeader } from '@ionic/core/components/ion-header.js'; import { defineCustomElement as defineIonImg } from '@ionic/core/components/ion-img.js'; @@ -889,6 +890,30 @@ export class IonGallery { export declare interface IonGallery extends Components.IonGallery {} +@ProxyCmp({ + defineCustomElementFn: defineIonGalleryItem, + inputs: ['mode', 'theme'] +}) +@Component({ + selector: 'ion-gallery-item', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['mode', 'theme'], + standalone: true +}) +export class IonGalleryItem { + protected el: HTMLIonGalleryItemElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IonGalleryItem extends Components.IonGalleryItem {} + + @ProxyCmp({ defineCustomElementFn: defineIonGrid, inputs: ['fixed', 'mode', 'theme'] diff --git a/packages/react/src/components/proxies.ts b/packages/react/src/components/proxies.ts index 47a8c6ac371..24fd85c7186 100644 --- a/packages/react/src/components/proxies.ts +++ b/packages/react/src/components/proxies.ts @@ -27,6 +27,7 @@ import { defineCustomElement as defineIonFab } from '@ionic/core/components/ion- import { defineCustomElement as defineIonFabList } from '@ionic/core/components/ion-fab-list.js'; import { defineCustomElement as defineIonFooter } from '@ionic/core/components/ion-footer.js'; import { defineCustomElement as defineIonGallery } from '@ionic/core/components/ion-gallery.js'; +import { defineCustomElement as defineIonGalleryItem } from '@ionic/core/components/ion-gallery-item.js'; import { defineCustomElement as defineIonGrid } from '@ionic/core/components/ion-grid.js'; import { defineCustomElement as defineIonHeader } from '@ionic/core/components/ion-header.js'; import { defineCustomElement as defineIonImg } from '@ionic/core/components/ion-img.js'; @@ -102,6 +103,7 @@ export const IonFab = /*@__PURE__*/createReactComponent('ion-fab-list', undefined, undefined, defineIonFabList); export const IonFooter = /*@__PURE__*/createReactComponent('ion-footer', undefined, undefined, defineIonFooter); export const IonGallery = /*@__PURE__*/createReactComponent('ion-gallery', undefined, undefined, defineIonGallery); +export const IonGalleryItem = /*@__PURE__*/createReactComponent('ion-gallery-item', undefined, undefined, defineIonGalleryItem); export const IonGrid = /*@__PURE__*/createReactComponent('ion-grid', undefined, undefined, defineIonGrid); export const IonHeader = /*@__PURE__*/createReactComponent('ion-header', undefined, undefined, defineIonHeader); export const IonImg = /*@__PURE__*/createReactComponent('ion-img', undefined, undefined, defineIonImg); diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 116c608e061..2122beb1cda 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -31,6 +31,7 @@ import { defineCustomElement as defineIonFabButton } from '@ionic/core/component import { defineCustomElement as defineIonFabList } from '@ionic/core/components/ion-fab-list.js'; import { defineCustomElement as defineIonFooter } from '@ionic/core/components/ion-footer.js'; import { defineCustomElement as defineIonGallery } from '@ionic/core/components/ion-gallery.js'; +import { defineCustomElement as defineIonGalleryItem } from '@ionic/core/components/ion-gallery-item.js'; import { defineCustomElement as defineIonGrid } from '@ionic/core/components/ion-grid.js'; import { defineCustomElement as defineIonHeader } from '@ionic/core/components/ion-header.js'; import { defineCustomElement as defineIonImg } from '@ionic/core/components/ion-img.js'; @@ -441,6 +442,9 @@ export const IonGallery: StencilVueComponent = /*@__PURE__*/ def ]); +export const IonGalleryItem: StencilVueComponent = /*@__PURE__*/ defineContainer('ion-gallery-item', defineIonGalleryItem); + + export const IonGrid: StencilVueComponent = /*@__PURE__*/ defineContainer('ion-grid', defineIonGrid, [ 'fixed' ]);