Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
1753968
feat(gallery): support CSS variables in gap
brandyscarney Jun 2, 2026
27f80d0
test(gallery): add spec test for css variables in gap
brandyscarney Jun 2, 2026
d8e1a89
test(gallery): add e2e test for css variables in gap
brandyscarney Jun 2, 2026
caf9565
test(gallery): add a test for gap making sure the css var fallback works
brandyscarney Jun 4, 2026
bdeef48
feat(gallery-item): add new gallery item component
brandyscarney Jun 5, 2026
b62a302
test(gallery): update demos with new structure
brandyscarney Jun 5, 2026
db0249e
test(gallery): update e2e tests with new structure
brandyscarney Jun 5, 2026
6a1f496
chore(): add updated snapshots
brandyscarney Jun 5, 2026
d849773
test(gallery): update spec test with new structure
brandyscarney Jun 5, 2026
376fb25
test(gallery): add new wrapper test verifying the items work the same
brandyscarney Jun 5, 2026
8e12285
test(gallery-item): add new spec test for gallery-item
brandyscarney Jun 5, 2026
c5e9d9b
refactor(gallery): type gallery items as HTMLIonGalleryItemElement
brandyscarney Jun 5, 2026
0ede958
style(gallery): remove no longer needed style
brandyscarney Jun 5, 2026
9d0d667
Merge branch 'next' into FW-7301
brandyscarney Jun 5, 2026
8ab0fc5
fix(gallery): assign aspect-ratio to host in uniform layout
brandyscarney Jun 8, 2026
a0f52e6
test(gallery): update tests to remove divs and style the item
brandyscarney Jun 8, 2026
4080cb4
chore(): add updated snapshots
brandyscarney Jun 8, 2026
b8d6a3b
test(gallery): split gallery and item styles
brandyscarney Jun 8, 2026
4fc0080
chore(): add updated snapshots
brandyscarney Jun 8, 2026
2a6af46
chore(): add updated snapshots
brandyscarney Jun 8, 2026
d4032af
fix(gallery): remove display contents from wrapper when it has no items
brandyscarney Jun 10, 2026
20a1e7f
fix(gallery): account for nested galleries when querying items
brandyscarney Jun 10, 2026
253ff44
refactor(gallery): separate getItems so it is only responsible for reads
brandyscarney Jun 10, 2026
9a25d30
refactor(gallery-item): improve the syncing of the galleryLayout state
brandyscarney Jun 11, 2026
6cd971e
test(gallery): ensure non-wrapper children do not have display contents
brandyscarney Jun 11, 2026
b6e8398
fix(gallery): sync layout on load
brandyscarney Jun 11, 2026
461bfd0
Merge branch 'next' into FW-7301
brandyscarney Jun 11, 2026
3c1dc32
refactor(gallery-item): import mixins instead of globals
brandyscarney Jun 12, 2026
e96350b
refactor(gallery-item): assign any layout class when undefined
brandyscarney Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
/**
* 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.
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -11557,6 +11588,7 @@ declare namespace LocalJSX {
"ion-fab-list": Omit<IonFabList, keyof IonFabListAttributes> & { [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<IonFooter, keyof IonFooterAttributes> & { [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<IonGallery, keyof IonGalleryAttributes> & { [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<IonGrid, keyof IonGridAttributes> & { [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<IonHeader, keyof IonHeaderAttributes> & { [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<IonImg, keyof IonImgAttributes> & { [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] };
Expand Down Expand Up @@ -11662,6 +11694,7 @@ declare module "@stencil/core" {
"ion-fab-list": LocalJSX.IntrinsicElements["ion-fab-list"] & JSXBase.HTMLAttributes<HTMLIonFabListElement>;
"ion-footer": LocalJSX.IntrinsicElements["ion-footer"] & JSXBase.HTMLAttributes<HTMLIonFooterElement>;
"ion-gallery": LocalJSX.IntrinsicElements["ion-gallery"] & JSXBase.HTMLAttributes<HTMLIonGalleryElement>;
"ion-gallery-item": LocalJSX.IntrinsicElements["ion-gallery-item"] & JSXBase.HTMLAttributes<HTMLIonGalleryItemElement>;
"ion-grid": LocalJSX.IntrinsicElements["ion-grid"] & JSXBase.HTMLAttributes<HTMLIonGridElement>;
"ion-header": LocalJSX.IntrinsicElements["ion-header"] & JSXBase.HTMLAttributes<HTMLIonHeaderElement>;
"ion-img": LocalJSX.IntrinsicElements["ion-img"] & JSXBase.HTMLAttributes<HTMLIonImgElement>;
Expand Down
51 changes: 51 additions & 0 deletions core/src/components/gallery-item/gallery-item.scss
Original file line number Diff line number Diff line change
@@ -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 <figure>) 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 `<figure>`
// 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);
}
160 changes: 160 additions & 0 deletions core/src/components/gallery-item/gallery-item.spec.ts
Original file line number Diff line number Diff line change
@@ -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: `<ion-gallery-item></ion-gallery-item>`,
});

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: `<ion-gallery><ion-gallery-item></ion-gallery-item></ion-gallery>`,
});

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: `<ion-gallery-item></ion-gallery-item>`,
});

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: `<ion-gallery><ion-gallery-item></ion-gallery-item></ion-gallery>`,
});

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: `<ion-gallery layout="masonry"><ion-gallery-item></ion-gallery-item></ion-gallery>`,
});

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: `<ion-gallery layout="uniform"><ion-gallery-item></ion-gallery-item></ion-gallery>`,
});

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: `<ion-gallery layout="uniform"><ion-gallery-item></ion-gallery-item></ion-gallery>`,
});

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: `
<ion-gallery layout="uniform"><ion-gallery-item></ion-gallery-item></ion-gallery>
<ion-gallery layout="masonry"></ion-gallery>
`,
});

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);
});
});
87 changes: 87 additions & 0 deletions core/src/components/gallery-item/gallery-item.tsx
Original file line number Diff line number Diff line change
@@ -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',
Comment thread
ShaneK marked this conversation as resolved.
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() {
Comment thread
ShaneK marked this conversation as resolved.
const { galleryLayout } = this;
const theme = getIonTheme(this);

return (
<Host
class={{
[theme]: true,
[`in-gallery-layout-${galleryLayout}`]: galleryLayout != undefined,
}}
>
<slot onSlotchange={this.onSlotChange} />
</Host>
);
}
}
Loading
Loading