Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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 @@ -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
Expand Down
29 changes: 29 additions & 0 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -11539,6 +11566,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 @@ -11644,6 +11672,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/native/native.globals" as globals;

// 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 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. 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);
}
83 changes: 83 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,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: `<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 reflect the parent gallery uniform layout as a class', async () => {
const page = await newSpecPage({
components: [Gallery, GalleryItem],
html: `<ion-gallery layout="uniform"><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);
});
});
107 changes: 107 additions & 0 deletions core/src/components/gallery-item/gallery-item.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Host
class={{
[theme]: true,
'in-gallery-layout-uniform': galleryLayout === 'uniform',
'in-gallery-layout-masonry': galleryLayout === 'masonry',
}}
>
<slot onSlotchange={this.onSlotChange} />
</Host>
);
}
}
42 changes: 5 additions & 37 deletions core/src/components/gallery/gallery.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
@use "../../themes/native/native.globals" as globals;

// Gallery
// --------------------------------------------------

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

Expand All @@ -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 <figure>) 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;
}
Loading
Loading