diff --git a/core/api.txt b/core/api.txt index 3d38bfce8ee..8147e65814d 100644 --- a/core/api.txt +++ b/core/api.txt @@ -912,6 +912,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 845b5569a9e..7e8f2ae44f8 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -1500,6 +1500,20 @@ export namespace Components { */ "theme"?: "ios" | "md" | "ionic"; } + interface IonGalleryItem { + /** + * The mode determines the platform behaviors of the component. + */ + "mode"?: "ios" | "md"; + /** + * Resolve the layout from the parent `ion-gallery`. Called internally on load and connect, and by the gallery when its layout changes. + */ + "syncGalleryLayout": () => Promise; + /** + * 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. @@ -5054,6 +5068,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: { @@ -6012,6 +6032,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; @@ -7556,6 +7577,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. @@ -11557,6 +11588,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] }; @@ -11662,6 +11694,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..8812392aedd --- /dev/null +++ b/core/src/components/gallery-item/gallery-item.scss @@ -0,0 +1,51 @@ +@use "../../themes/mixins" as mixins; + +// Gallery Item +// -------------------------------------------------- + +:host { + display: block; + + width: 100%; +} + +// Slotted content +// -------------------------------------------------- + +// Reset the default margin for slotted elements so wrapper elements +// (such as
) align properly with other gallery items. +::slotted(*) { + @include mixins.margin(0); + + width: 100%; +} + +::slotted(img) { + display: block; + + object-fit: cover; + object-position: center; +} + +// Layout: Uniform +// -------------------------------------------------- + +// In the uniform layout each cell is square. The aspect ratio is applied to +// the slotted content so it fills the cell and a wrapper such as `
` +// carries the ratio for a nested `img` to `inherit`; it is also applied to +// the item itself so the cell stays square even when it is empty or holds +// non-element content (e.g. bare text). An explicit `height` on the content +// overrides the ratio for that content. +:host(.in-gallery-layout-uniform), +: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..f208f576269 --- /dev/null +++ b/core/src/components/gallery-item/gallery-item.spec.ts @@ -0,0 +1,160 @@ +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 not have the gallery layout classes when not inside a gallery', async () => { + // Suppress the warning for the missing gallery parent. + jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + 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(false); + expect(item.classList.contains('in-gallery-layout-masonry')).toBe(false); + }); + + 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); + }); + + it('should update the layout class when the parent gallery layout changes', async () => { + const page = await newSpecPage({ + components: [Gallery, GalleryItem], + html: ``, + }); + + const gallery = page.body.querySelector('ion-gallery')!; + const item = page.body.querySelector('ion-gallery-item')!; + + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(true); + + // Update the parent gallery's layout at runtime. + gallery.layout = 'masonry'; + await page.waitForChanges(); + + // Verify that the item reflects the new layout class. + expect(item.classList.contains('in-gallery-layout-masonry')).toBe(true); + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(false); + }); + + it('should keep its layout class after being detached and reattached', async () => { + const page = await newSpecPage({ + components: [Gallery, GalleryItem], + html: ``, + }); + + const gallery = page.body.querySelector('ion-gallery')!; + const item = page.body.querySelector('ion-gallery-item')!; + + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(true); + + // Detach and reattach the item, e.g. when a framework re-renders the DOM. + item.remove(); + gallery.appendChild(item); + await page.waitForChanges(); + + // Verify that the item still reflects the correct layout class. + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(true); + }); + + it('should reflect the new gallery layout after being moved between galleries', async () => { + const page = await newSpecPage({ + components: [Gallery, GalleryItem], + html: ` + + + `, + }); + + const item = page.body.querySelector('ion-gallery-item')!; + const masonryGallery = page.body.querySelectorAll('ion-gallery')[1]; + + expect(item.classList.contains('in-gallery-layout-uniform')).toBe(true); + + // Move the item out of the uniform gallery and into the masonry gallery. + masonryGallery.appendChild(item); + await page.waitForChanges(); + + 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..abe25fc8f2e --- /dev/null +++ b/core/src/components/gallery-item/gallery-item.tsx @@ -0,0 +1,87 @@ +import type { ComponentInterface } from '@stencil/core'; +import { Component, Element, Host, Method, 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; + + @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.syncGalleryLayout(); + } + + componentDidLoad() { + this.warnInvalidParent(); + } + + connectedCallback() { + this.syncGalleryLayout(); + } + + /** + * Resolve the layout from the parent `ion-gallery`. Called internally on + * load and connect, and by the gallery when its layout changes. + * @internal + */ + @Method() + async syncGalleryLayout() { + this.galleryLayout = this.el.closest('ion-gallery')?.layout; + } + + private onSlotChange = () => { + this.warnInvalidParent(); + }; + + /** + * Warn when the item is not a descendant of an `ion-gallery`. + */ + private warnInvalidParent() { + if (this.hasWarnedInvalidParent || this.el.closest('ion-gallery') !== null) { + return; + } + + printIonWarning( + '[ion-gallery-item] - This component should be used as a child of an "ion-gallery" component.', + this.el + ); + this.hasWarnedInvalidParent = true; + } + + 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..5ed9c1e40b9 100644 --- a/core/src/components/gallery/gallery.spec.ts +++ b/core/src/components/gallery/gallery.spec.ts @@ -746,53 +746,189 @@ 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 return items found inside a wrapper element', () => { + 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([itemOne, itemTwo]); + }); + + it('should not return items that belong to a nested gallery', () => { + const ownedItem = document.createElement('ion-gallery-item'); + + const nestedGallery = document.createElement('ion-gallery'); + nestedGallery.appendChild(document.createElement('ion-gallery-item')); + + // A wrapper holds one of our items alongside a nested gallery. + const wrapper = document.createElement('div'); + wrapper.appendChild(ownedItem); + wrapper.appendChild(nestedGallery); + el.appendChild(wrapper); const items = (sharedGallery as any).getItems(); - expect(items).toEqual([included]); + // Only the item whose nearest gallery ancestor is this gallery is + // returned; the nested gallery's item is left to the nested gallery. + expect(items).toEqual([ownedItem]); }); - 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 return no items when a gallery only contains a nested gallery', () => { + const nestedGallery = document.createElement('ion-gallery'); + nestedGallery.appendChild(document.createElement('ion-gallery-item')); + nestedGallery.appendChild(document.createElement('ion-gallery-item')); + el.appendChild(nestedGallery); const items = (sharedGallery as any).getItems(); - jest.spyOn(div, 'getBoundingClientRect').mockReturnValue({ height: 20 } as DOMRect); - jest.spyOn(svg, 'getBoundingClientRect').mockReturnValue({ height: 30 } as DOMRect); + // The nested gallery's items belong to it, not the outer gallery. + expect(items).toEqual([]); + }); + }); + + describe('collapseWrappers()', () => { + it('should collapse a wrapper that owns items with display: contents', () => { + const wrapper = document.createElement('div'); + wrapper.appendChild(document.createElement('ion-gallery-item')); + el.appendChild(wrapper); + + (sharedGallery as any).collapseWrappers(); + + expect(wrapper.style.display).toBe('contents'); + }); + + it('should restore the box of a wrapper that no longer owns items', () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + const wrapper = document.createElement('div'); + const item = document.createElement('ion-gallery-item'); + wrapper.appendChild(item); + el.appendChild(wrapper); + + // Wrapper is initially collapsed with display: contents. + (sharedGallery as any).collapseWrappers(); + expect(wrapper.style.display).toBe('contents'); + + // Remove the item, leaving the wrapper empty. + wrapper.removeChild(item); + + // The wrapper is no longer collapsed and a warning is issued about the + // now-invalid child element. + (sharedGallery as any).collapseWrappers(); + expect(wrapper.style.display).toBe(''); + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-gallery] - Gallery items must be wrapped in "ion-gallery-item" components.'), + el + ); + + warningSpy.mockRestore(); + }); + + it('should not clear an unrelated inline display on an invalid child', () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + // A non-item child with its own inline display and no gallery items. + const stray = document.createElement('div'); + stray.style.display = 'flex'; + el.appendChild(stray); + + (sharedGallery as any).collapseWrappers(); + + // We only undo a `display: contents` we set ourselves; an inline + // display the consumer set must be left intact. + expect(stray.style.display).toBe('flex'); + + warningSpy.mockRestore(); + }); + + it('should warn and not collapse when a gallery only contains a nested gallery', () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + // Nesting a gallery directly inside a gallery is invalid: the outer + // gallery has no items of its own to place. + const nestedGallery = document.createElement('ion-gallery'); + nestedGallery.appendChild(document.createElement('ion-gallery-item')); + el.appendChild(nestedGallery); + + (sharedGallery as any).collapseWrappers(); + + // The nested gallery is left untouched and the outer gallery warns. + expect(nestedGallery.style.display).toBe(''); + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-gallery] - Gallery items must be wrapped in "ion-gallery-item" components.'), + el + ); + + warningSpy.mockRestore(); + }); + + it('should warn about children that do not contain an ion-gallery-item', () => { + const warningSpy = jest.spyOn(logging, 'printIonWarning').mockImplementation(() => {}); + + const img = document.createElement('img'); + el.appendChild(img); + + (sharedGallery as any).collapseWrappers(); + + expect(warningSpy).toHaveBeenCalledWith( + expect.stringContaining('[ion-gallery] - Gallery items must be wrapped in "ion-gallery-item" components.'), + el + ); + + // Only wrappers that own items are collapsed, so an ignored child's + // inline display must be left untouched. + expect(img.style.display).toBe(''); + + 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).collapseWrappers(); + (sharedGallery as any).collapseWrappers(); + + expect(warningSpy).toHaveBeenCalledTimes(1); + + warningSpy.mockRestore(); + }); + }); + + describe('layoutMasonry()', () => { + 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); - (sharedGallery as any).layoutMasonry(items, 10, 0, 2); + (sharedGallery as any).layoutMasonry([itemOne, itemTwo], 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..3b5b7168cea 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 @@ -96,6 +97,7 @@ export class Gallery implements ComponentInterface { @Watch('order') protected onLayoutOrOrderChanged() { this.syncResponsiveLayout(); + this.syncItemLayout(); // Wait until the next animation frame to warn about unused order // to avoid erroneous warnings when the layout and order are updated @@ -105,13 +107,24 @@ export class Gallery implements ComponentInterface { }); } + /** + * Sync the current layout with each item when the gallery's `layout` + * changes. + */ + private syncItemLayout() { + this.getItems().forEach((item) => { + item.syncGalleryLayout(); + }); + } + componentDidLoad() { + this.collapseWrappers(); this.updateResponsiveStyles(true); this.resizeObserver = new ResizeObserver(() => { this.updateResponsiveStyles(); this.scheduleMasonryResize(); }); - this.resizeObserver.observe(this.el); + this.observeResizes(); this.scheduleMasonryResize(); @@ -128,6 +141,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 @@ -147,12 +178,13 @@ export class Gallery implements ComponentInterface { } /** - * Listen for the slotchange event on the slot. - * When the layout is `masonry`, this listener is used to schedule a resize - * of the masonry grid when the slot changes. This is useful for when items - * are added or removed from the gallery. + * Listen for the slotchange event on the slot. When the gallery's items are + * added or removed, re-collapse wrappers, re-observe items for size changes, + * and recompute the masonry grid. */ private onSlotChange = () => { + this.collapseWrappers(); + this.observeResizes(); this.scheduleMasonryResize(); }; @@ -450,20 +482,67 @@ 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, whether a direct child or nested inside a pass-through + * wrapper (e.g. a layout `
`). Items belonging to a nested `ion-gallery` + * are excluded. */ - private getItems(): GalleryItemElement[] { - return Array.from(this.el.children).filter( - (child): child is GalleryItemElement => typeof (child as any).style?.setProperty === 'function' + private getItems(): HTMLIonGalleryItemElement[] { + return Array.from(this.el.querySelectorAll(GALLERY_ITEM_SELECTOR)).filter( + (item) => item.closest('ion-gallery') === this.el ); } + /** + * Collapse each pass-through wrapper's box with `display: contents` so its + * items participate in the gallery grid. Restore the box of a wrapper that + * no longer contains items, and warn about children that contain none. + */ + private collapseWrappers() { + const items = this.getItems(); + + Array.from(this.el.children as HTMLCollectionOf).forEach((child) => { + if (child.matches(GALLERY_ITEM_SELECTOR)) { + return; + } + + if (!items.some((item) => child.contains(item))) { + // If the wrapper was previously collapsed with `display: contents` + // but now contains no items, clear the display style. + if (child.style.display === 'contents') { + child.style.display = ''; + } + this.warnInvalidItems(); + return; + } + + // Collapse the wrapper's box so its items sit directly in the grid. + child.style.display = 'contents'; + }); + } + + /** + * Warn when the gallery has content that is not wrapped in an + * `ion-gallery-item` component. Items belonging to a nested + * gallery are considered invalid content for the parent gallery. + */ + 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 +556,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 +610,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..9c422741305 100644 --- a/core/src/components/gallery/test/basic/index.html +++ b/core/src/components/gallery/test/basic/index.html @@ -24,33 +24,48 @@ - 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 +
+ + + One + Two + Three + Four + Five + Six + Seven + Eight + Nine + Ten + Eleven + Twelve
@@ -60,64 +75,64 @@ margin-bottom: 16px; } - ion-gallery img, - ion-gallery div { + ion-gallery-item, + ion-gallery-item img { border-radius: 16px; } - ion-gallery div { + ion-gallery-item { display: flex; align-items: center; justify-content: center; color: #fff; } - ion-gallery div:nth-child(1) { + ion-gallery-item:nth-child(1) { background: #ff6b6b; } - ion-gallery div:nth-child(2) { + ion-gallery-item:nth-child(2) { background: #4ecdc4; } - ion-gallery div:nth-child(3) { + ion-gallery-item:nth-child(3) { background: #ffe66d; color: #333; } - ion-gallery div:nth-child(4) { + ion-gallery-item:nth-child(4) { background: #5f27cd; } - ion-gallery div:nth-child(5) { + ion-gallery-item:nth-child(5) { background: #7f8c8d; } - ion-gallery div:nth-child(6) { + ion-gallery-item:nth-child(6) { background: #ff9f43; } - ion-gallery div:nth-child(7) { + ion-gallery-item:nth-child(7) { background: #ff3f34; } - ion-gallery div:nth-child(8) { + ion-gallery-item:nth-child(8) { background: #2ecc71; } - ion-gallery div:nth-child(9) { + ion-gallery-item:nth-child(9) { background: #34495e; } - ion-gallery div:nth-child(10) { + ion-gallery-item:nth-child(10) { background: #1abc9c; } - ion-gallery div:nth-child(11) { + ion-gallery-item:nth-child(11) { background: #e67e22; } - ion-gallery div:nth-child(12) { + ion-gallery-item:nth-child(12) { 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..32a7472147b 100644 --- a/core/src/components/gallery/test/layout/gallery.e2e.ts +++ b/core/src/components/gallery/test/layout/gallery.e2e.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; -import { numberToWords, sharedStyles } from '../utils'; +import { numberToWords, sharedGalleryStyles, sharedGalleryItemStyles } from '../utils'; const LAYOUT_OPTIONS = ['uniform', 'masonry']; const ORDER_OPTIONS = ['sequential', 'best-fit']; @@ -18,28 +18,29 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t const orderSuffix = layout === 'masonry' ? `-${order}` : ''; test.describe(title(`gallery: ${layout} layout${layout === 'masonry' ? ` (${order})` : ''}`), () => { - test(`should properly display same height divs with ${layout} layout${ + test(`should properly display same height items with ${layout} layout${ layout === 'masonry' ? ` and ${order} order` : '' }`, async ({ page }) => { 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 @@ -58,28 +59,29 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await expect(gallery).toHaveScreenshot(screenshot(`gallery-${layout}${orderSuffix}-divs-same-height`)); }); - test(`should properly display variable height divs with ${layout} layout${ + test(`should properly display variable height items with ${layout} layout${ layout === 'masonry' ? ` and ${order} order` : '' }`, async ({ page }) => { 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 @@ -104,7 +106,7 @@ 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 @@ -148,22 +150,22 @@ 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 @@ -183,20 +185,21 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t }); if (layout === 'masonry') { - test(`should properly display dynamically appended divs with ${order} order`, async ({ page }) => { + test(`should properly display dynamically appended items with ${order} order`, async ({ page }) => { await page.setContent( ` -
One
-
Two
-
Three
-
Four
-
Five
-
Six
+ One + Two + Three + Four + Five + Six
`, config @@ -204,18 +207,18 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t const gallery = page.locator('ion-gallery'); - const divHeights = [130, 80, 110, 90, 100, 150]; - const appendedItems = divHeights.map((height, i) => ({ + const itemHeights = [130, 80, 110, 90, 100, 150]; + const appendedItems = itemHeights.map((height, i) => ({ itemLabel: numberToWords(7 + i), itemHeight: height, })); await gallery.evaluate((galleryEl, items) => { items.forEach(({ itemLabel, itemHeight }) => { - const divEl = document.createElement('div'); - divEl.style.height = `${itemHeight}px`; - divEl.textContent = itemLabel; - galleryEl.append(divEl); + const galleryItemEl = document.createElement('ion-gallery-item'); + galleryItemEl.style.height = `${itemHeight}px`; + galleryItemEl.textContent = itemLabel; + galleryEl.append(galleryItemEl); }); }, appendedItems); @@ -236,16 +239,16 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` - One - Two - Three - Four - Five - Six + One + Two + Three + Four + Five + Six `, config @@ -259,11 +262,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); @@ -292,11 +297,12 @@ configs({ directions: ['ltr'], modes: ['md'] }).forEach(({ config, screenshot, t await page.setContent( ` -
- One -
-
- Two -
-
- Three -
-
- Four -
-
- Five -
-
- Six -
+ +
+ One +
+
+ +
+ Two +
+
+ +
+ Three +
+
+ +
+ Four +
+
+ +
+ Five +
+
+ +
+ Six +
+
`, config @@ -338,6 +356,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 +365,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 +396,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..65c9ceda27b 100644 --- a/core/src/components/gallery/test/utils.ts +++ b/core/src/components/gallery/test/utils.ts @@ -1,59 +1,60 @@ -export const sharedStyles = ` +export const sharedGalleryStyles = ` ion-gallery { width: 343px; } +`; - div { +export const sharedGalleryItemStyles = ` + ion-gallery-item { color: #fff; - height: 150px; } - div:nth-child(1) { + ion-gallery-item:nth-child(1) { background: #ff6b6b; } - div:nth-child(2) { + ion-gallery-item:nth-child(2) { background: #4ecdc4; } - div:nth-child(3) { + ion-gallery-item:nth-child(3) { background: #ffe66d; color: #333; } - div:nth-child(4) { + ion-gallery-item:nth-child(4) { background: #5f27cd; } - div:nth-child(5) { + ion-gallery-item:nth-child(5) { background: #7f8c8d; } - div:nth-child(6) { + ion-gallery-item:nth-child(6) { background: #ff9f43; } - div:nth-child(7) { + ion-gallery-item:nth-child(7) { background: #ff3f34; } - div:nth-child(8) { + ion-gallery-item:nth-child(8) { background: #2ecc71; } - div:nth-child(9) { + ion-gallery-item:nth-child(9) { background: #34495e; } - div:nth-child(10) { + ion-gallery-item:nth-child(10) { background: #1abc9c; } - div:nth-child(11) { + ion-gallery-item:nth-child(11) { background: #e67e22; } - div:nth-child(12) { + ion-gallery-item:nth-child(12) { 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..2ac57a820fb --- /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 { sharedGalleryStyles } 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 1675c9be471..02001f41b45 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 ba3f53b5633..8ea4fbda890 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 6df720f8d8f..a377c714433 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' ]);