diff --git a/examples/stackblitz/src/main.ts b/examples/stackblitz/src/main.ts index 07169fe..bcc0d9a 100644 --- a/examples/stackblitz/src/main.ts +++ b/examples/stackblitz/src/main.ts @@ -22,11 +22,36 @@ interface Photo { /** Deterministic sizes, so the grid looks the same every time it boots. */ const HEIGHTS = [300, 520, 260, 440, 310, 580, 350, 420, 280, 500, 330, 460]; +/** + * A generated image, as a data URI. + * + * Deliberately not a photo service. An external image host makes the example + * depend on someone else's uptime, rate limits and CORS policy — and when it is + * slow, the very first thing a visitor sees is an empty grid. These render + * instantly, work offline, and still exercise the decode path the grid waits on. + */ +function artwork(id: number, width: number, height: number): string { + const hue = (id * 47) % 360; + const svg = + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``; + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + function makePhoto(id: number): Photo { const height = HEIGHTS[id % HEIGHTS.length]; return { id, - url: `https://picsum.photos/seed/masonry-${id}/400/${height}`, + url: artwork(id, 400, height), title: `Photo ${id}`, width: 400, height, diff --git a/projects/demo/src/app/app.css b/projects/demo/src/app/app.css index 53dfbef..080607e 100644 --- a/projects/demo/src/app/app.css +++ b/projects/demo/src/app/app.css @@ -1,19 +1,12 @@ -:host { - display: block; - max-width: 1600px; - margin: 0 auto; - padding: 32px 24px 96px; -} - .masthead { display: flex; flex-wrap: wrap; gap: 20px; - align-items: flex-end; + align-items: center; justify-content: space-between; - padding-bottom: 18px; margin-bottom: 26px; border-bottom: 1px solid var(--line); + padding: 10px 10px; } .masthead h1 { @@ -52,3 +45,7 @@ nav a.active { color: var(--accent); font-weight: 600; } + +main { + padding: 20px 10px; +} diff --git a/projects/demo/src/app/example-frame.ts b/projects/demo/src/app/example-frame.ts new file mode 100644 index 0000000..662b83a --- /dev/null +++ b/projects/demo/src/app/example-frame.ts @@ -0,0 +1,149 @@ +import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core'; + +type Panel = 'none' | 'config' | 'code'; + +/** + * The shell every example sits in: a description, a toggle for the controls, a + * toggle for the source, and a copy button. + * + * The preview is never hidden. Tabs that swap the grid out would take it to + * `display: none`, where it measures zero and has to lay out again on the way + * back — a flicker caused entirely by the demo, in the one place a visitor is + * judging whether the layout is steady. So the panels open *above* a preview + * that stays mounted, which also lets you read the code and watch the result at + * the same time. + */ +@Component({ + selector: 'demo-example', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ +
+ + + + @if (panel() === 'code') { + + } +
+ + +
+ +
+ + @if (panel() === 'code') { +
{{ code() }}
+ } + + + `, + styles: ` + :host { + display: block; + } + + .toolbar { + display: flex; + gap: 6px; + align-items: center; + margin: 0 0 14px; + } + + .toolbar button { + padding: 5px 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + color: var(--muted); + font: inherit; + font-size: 0.82rem; + cursor: pointer; + } + + .toolbar button:hover { + color: var(--text); + } + + .toolbar button.on { + border-color: var(--accent); + color: var(--accent); + font-weight: 600; + } + + .toolbar .copy { + margin-left: auto; + } + + .code { + margin: 0 0 18px; + padding: 14px 16px; + max-height: 420px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--panel); + font-size: 0.8rem; + line-height: 1.55; + tab-size: 2; + } + + .controls { + margin-bottom: 18px; + } + `, +}) +export class DemoExample { + /** The snippet shown under "Code", and what the copy button puts on the clipboard. */ + readonly code = input.required(); + + readonly panel = signal('none'); + readonly copied = signal(false); + + toggle(which: Panel): void { + this.panel.update((current) => (current === which ? 'none' : which)); + } + + async copy(): Promise { + try { + await navigator.clipboard.writeText(this.code()); + } catch { + // Clipboard access is refused in some embedded contexts (an iframe + // without permission, or plain http). Fall back to a selection the + // visitor can copy with the keyboard rather than failing silently. + const area = document.createElement('textarea'); + area.value = this.code(); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.append(area); + area.select(); + try { + document.execCommand('copy'); + } finally { + area.remove(); + } + } + this.copied.set(true); + setTimeout(() => this.copied.set(false), 1600); + } +} diff --git a/projects/demo/src/app/examples/dashboard.ts b/projects/demo/src/app/examples/dashboard.ts index 8a72966..9036efe 100644 --- a/projects/demo/src/app/examples/dashboard.ts +++ b/projects/demo/src/app/examples/dashboard.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { DemoExample } from '../example-frame'; import { NG_MASONRY_GRID, type MasonryGridOptions } from 'masonry-angular'; import { WIDGETS } from './dashboard-data'; @@ -19,91 +20,111 @@ import { DashboardWidget } from './dashboard-widget'; */ @Component({ selector: 'dashboard-example', - imports: [NG_MASONRY_GRID, DashboardWidget], + imports: [NG_MASONRY_GRID, DashboardWidget, DemoExample], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

- Twenty tiles, each a static size: height in pixels, width in whole columns via - masonryColSpan. No body is rendered up front — every one sits behind - @defer (on viewport) and loads as its placeholder scrolls into view. Because - the tile already owns its height, the swap costs no relayout: the grid positioned the board - from the skeletons, and the real content lands in exactly the same box. -

+ +

+ Twenty tiles, each a static size: height in pixels, width in whole columns via + masonryColSpan. No body is rendered up front — every one sits behind + @defer (on viewport) and loads as its placeholder scrolls into view. + Because the tile already owns its height, the swap costs no relayout: the grid positioned + the board from the skeletons, and the real content lands in exactly the same box. +

-

- Tiles are wider than article cards, so this board also redefines what the breakpoint names - mean — breakpoints: { sm: 620, md: 980, … } — while - columns still reads as { xs: 1, sm: 2, md: 3, … }. The - override merges over the defaults and is scoped to this grid. -

+

+ Tiles are wider than article cards, so this board also redefines what the breakpoint names + mean — breakpoints: { sm: 620, md: 980, … } — while + columns still reads as { xs: 1, sm: 2, md: 3, … }. The + override merges over the defaults and is scoped to this grid. +

-
-
- packing - -
+
- - @for (widget of widgets; track widget.id) { -
-
-

{{ widget.title }}

- {{ widget.span }} col · {{ widget.height }}px -
+ + @for (widget of widgets; track widget.id) { +
+
+

{{ widget.title }}

+ {{ widget.span }} col · {{ widget.height }}px +
- - @defer (on viewport) { - - } @placeholder (minimum 400ms) { - - } -
- } -
+ + @defer (on viewport) { + + } @placeholder (minimum 400ms) { + + } +
+ } +
+
`, }) export class DashboardExample { + readonly code = ` + + @for (w of widgets(); track w.id) { +
+

{{ w.title }}

+ @defer (on viewport) { + + } @placeholder { +
+ } +
+ } +
`; + readonly widgets = WIDGETS; readonly horizontalOrder = signal(false); diff --git a/projects/demo/src/app/examples/dynamic.ts b/projects/demo/src/app/examples/dynamic.ts index e273421..069731b 100644 --- a/projects/demo/src/app/examples/dynamic.ts +++ b/projects/demo/src/app/examples/dynamic.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { DemoExample } from '../example-frame'; import { NG_MASONRY_GRID, type MasonryGridOptions } from 'masonry-angular'; import { makeCards, type DemoCard } from './cards'; @@ -15,41 +16,59 @@ const OPTIONS: MasonryGridOptions = { */ @Component({ selector: 'dynamic-example', - imports: [NG_MASONRY_GRID], + imports: [NG_MASONRY_GRID, DemoExample], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

- Every button below mutates the source array and nothing else. Item order is derived from the - DOM at layout time, so a prepend really lands first — the ordering bug that makes other - masonry wrappers expose a manual reloadItems() cannot occur here. -

+ +

+ Every button below mutates the source array and nothing else. Item order is derived from the + DOM at layout time, so a prepend really lands first — the ordering bug that makes other + masonry wrappers expose a manual reloadItems() cannot occur here. +

-
-
- items — {{ cards().length }} - - - - - - - -
-
+
+
+ items — {{ cards().length }} + + + + + + + +
+
- - @for (card of cards(); track card.id) { -
-
-

{{ card.title }}

-

{{ card.body }}

-
-
- } -
+ + @for (card of cards(); track card.id) { +
+
+

{{ card.title }}

+

{{ card.body }}

+
+
+ } +
+
`, }) export class DynamicExample { + readonly code = ` + + @for (card of cards(); track card.id) { +
+ {{ card.title }} +
+ } +
+ + +add() { this.cards.update((c) => [...c, makeCard(this.next++)]); } +prepend() { this.cards.update((c) => [makeCard(this.next++), ...c]); } +remove(id: number) { this.cards.update((c) => c.filter((x) => x.id !== id)); }`; + readonly options = OPTIONS; readonly cards = signal(makeCards(15)); diff --git a/projects/demo/src/app/examples/gallery.ts b/projects/demo/src/app/examples/gallery.ts index 811cadd..f2a1c24 100644 --- a/projects/demo/src/app/examples/gallery.ts +++ b/projects/demo/src/app/examples/gallery.ts @@ -1,5 +1,6 @@ import { DecimalPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { DemoExample } from '../example-frame'; import { NG_MASONRY_GRID, type MasonryGridOptions, type MasonryLayoutEvent } from 'masonry-angular'; import { artwork, makeCards, type DemoCard } from './cards'; @@ -12,111 +13,136 @@ type SizingMode = 'breakpoints' | 'fixed' | 'columnWidth'; */ @Component({ selector: 'gallery-example', - imports: [NG_MASONRY_GRID, DecimalPipe], + imports: [NG_MASONRY_GRID, DecimalPipe, DemoExample], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

- Column count follows the container width through a breakpoint map — - { xs: 1, sm: 2, md: 3, xl: 4, '2xl': 5 }, named against the default - scale, and matched against this panel rather than the viewport. Cards with images are held - back until decode() resolves, so they never land at the wrong height and shove - their neighbours around. -

+ +

+ Column count follows the container width through a breakpoint map — + { xs: 1, sm: 2, md: 3, xl: 4, '2xl': 5 }, named against the default + scale, and matched against this panel rather than the viewport. Cards with images are held + back until decode() resolves, so they never land at the wrong height and shove + their neighbours around. +

-
-
- sizing - - - -
- -
- gutter — {{ gutter() }}px - -
+
+
+ sizing + + + +
-
- entry animation -
- @if (stats(); as stat) { -
-
-
columns
-
{{ stat.columns }}
-
-
-
column width
-
{{ stat.columnWidth | number: '1.0-0' }}px
-
-
-
items
-
{{ stat.itemCount }}
-
-
-
pass
-
{{ stat.durationMs | number: '1.2-2' }}ms
-
-
- } -
+
+ entry animation + + +
- - @for (card of cards(); track card.id) { -
- @if (card.image) { - - } -
-

{{ card.title }}

-

{{ card.body }}

-
-
- } -
+ @if (stats(); as stat) { +
+
+
columns
+
{{ stat.columns }}
+
+
+
column width
+
{{ stat.columnWidth | number: '1.0-0' }}px
+
+
+
items
+
{{ stat.itemCount }}
+
+
+
pass
+
{{ stat.durationMs | number: '1.2-2' }}ms
+
+
+ } +
+ + + @for (card of cards(); track card.id) { +
+ @if (card.image) { + + } +
+

{{ card.title }}

+

{{ card.body }}

+
+
+ } +
+
`, }) export class GalleryExample { + readonly code = ` + + @for (card of cards(); track card.id) { +
+ @if (card.image) { + + + } +
+

{{ card.title }}

+

{{ card.body }}

+
+
+ } +
+ + +{ + columns: { xs: 1, sm: 2, md: 3, xl: 4, '2xl': 5 }, + gutter: 16, + entryAnimation: {}, +}`; + readonly cards = signal(makeCards(24)); readonly sizing = signal('breakpoints'); readonly gutter = signal(16); diff --git a/projects/demo/src/app/examples/performance.ts b/projects/demo/src/app/examples/performance.ts index b21a54f..4765011 100644 --- a/projects/demo/src/app/examples/performance.ts +++ b/projects/demo/src/app/examples/performance.ts @@ -1,5 +1,6 @@ import { DecimalPipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { DemoExample } from '../example-frame'; import { NG_MASONRY_GRID, type MasonryGridOptions, type MasonryLayoutEvent } from 'masonry-angular'; interface Tile { @@ -22,94 +23,115 @@ function makeTiles(count: number): Tile[] { */ @Component({ selector: 'performance-example', - imports: [NG_MASONRY_GRID, DecimalPipe], + imports: [NG_MASONRY_GRID, DecimalPipe, DemoExample], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

- Solve time is measured across the whole pass — reading measurements, running the solver and - writing every transform. Sizes arrive pre-computed from one shared - ResizeObserver, so a pass performs no forced reflow, and repeated passes with - identical input return before touching the DOM. Turn on contentVisibility to let - the browser skip rendering off-screen tiles. -

+ +

+ Solve time is measured across the whole pass — reading measurements, running the solver and + writing every transform. Sizes arrive pre-computed from one shared + ResizeObserver, so a pass performs no forced reflow, and repeated passes with + identical input return before touching the DOM. Turn on contentVisibility to + let the browser skip rendering off-screen tiles. +

-
-
- tiles — {{ count() }} - -
- -
- options - -
- @if (stats(); as stat) { -
-
-
columns
-
{{ stat.columns }}
-
-
-
items
-
{{ stat.itemCount }}
-
-
-
height
-
{{ stat.height | number: '1.0-0' }}px
-
-
-
last pass
-
{{ stat.durationMs | number: '1.2-2' }}ms
-
-
-
passes
-
{{ stat.pass }}
-
-
- } -
+
+ options + + + +
- - @for (tile of tiles(); track tile.id) { -
- {{ tile.id }} -
- } -
+ @if (stats(); as stat) { +
+
+
columns
+
{{ stat.columns }}
+
+
+
items
+
{{ stat.itemCount }}
+
+
+
height
+
{{ stat.height | number: '1.0-0' }}px
+
+
+
last pass
+
{{ stat.durationMs | number: '1.2-2' }}ms
+
+
+
passes
+
{{ stat.pass }}
+
+
+ } + + + + @for (tile of tiles(); track tile.id) { +
+ {{ tile.id }} +
+ } +
+
`, }) export class PerformanceExample { + readonly code = ` + + @for (card of cards(); track card.id) { +
{{ card.title }}
+ } +
`; + readonly count = signal(600); readonly contentVisibility = signal(true); readonly transitions = signal(true); diff --git a/projects/demo/src/app/examples/spans.ts b/projects/demo/src/app/examples/spans.ts index 8b0df33..c4d5386 100644 --- a/projects/demo/src/app/examples/spans.ts +++ b/projects/demo/src/app/examples/spans.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { DemoExample } from '../example-frame'; import { NG_MASONRY_GRID, type MasonryGridOptions } from 'masonry-angular'; import { makeCards, type DemoCard } from './cards'; @@ -9,84 +10,99 @@ import { makeCards, type DemoCard } from './cards'; */ @Component({ selector: 'spans-example', - imports: [NG_MASONRY_GRID], + imports: [NG_MASONRY_GRID, DemoExample], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -

- masonryColSpan widens an item across whole columns; the solver drops it into the - group of columns with the lowest shared top edge. A masonryGridStamp element is - positioned by you and treated as an obstacle. horizontalOrder switches from - shortest-column packing to strict row order. -

+ +

+ masonryColSpan widens an item across whole columns; the solver drops it into + the group of columns with the lowest shared top edge. A + masonryGridStamp element is positioned by you and treated as an obstacle. + horizontalOrder switches from shortest-column packing to strict row order. +

-
-
- placement - - -
+
-
- wide cards — every {{ spanEvery() }}th - -
- + + @if (stamped()) { + + } - - @if (stamped()) { - - } - - @for (card of cards(); track card.id) { -
-
-

{{ card.title }}

- @if (spanOf(card) > 1) { - spans {{ spanOf(card) }} columns - } -
-
- } -
+ @for (card of cards(); track card.id) { +
+
+

{{ card.title }}

+ @if (spanOf(card) > 1) { + spans {{ spanOf(card) }} columns + } +
+
+ } +
+
`, }) export class SpansExample { + readonly code = ` + + + + @for (card of cards(); track card.id) { +
+ {{ card.title }} +
+ } +
`; + readonly cards = signal(makeCards(22)); readonly horizontalOrder = signal(false); readonly rtl = signal(false);