Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
47 changes: 47 additions & 0 deletions core/src/components/gallery-item/gallery-item.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
@use "../../themes/native/native.globals" as globals;

// Gallery Item
// --------------------------------------------------

:host {
display: block;
}

// Slotted content
// --------------------------------------------------

// Reset the default margin for slotted elements so wrapper elements
// (such as <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 by default. The aspect ratio is
// applied to the slotted content (rather than the item) so that an explicit
// height on the content takes precedence — a set `height` overrides
// `aspect-ratio` on the same element. The item then sizes to its content,
// allowing items with an explicit height to opt out of the square.
:host(.in-gallery-layout-uniform) ::slotted(*) {
aspect-ratio: 1 / 1;
}

// Layout: Masonry
// --------------------------------------------------

:host(.in-gallery-layout-masonry) {
// The spacing between stacked items. Applies to all items except
// for the last item in each column to remove any trailing space.
margin-bottom: var(--internal-gallery-gap, 16px);
}
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