svelte-bricks 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,54 +1,221 @@
1
- <script lang="ts">import { flip } from 'svelte/animate';
1
+ <script lang="ts" generics="Item">import { flip } from 'svelte/animate';
2
2
  import { fade } from 'svelte/transition';
3
- let { animate = true, calcCols = (masonryWidth, minColWidth, gap) => {
3
+ // On non-primitive types, we need a property to tell masonry items apart. The name of this attribute can be customized with idKey which defaults to 'id'. See https://svelte.dev/docs/svelte/each#Keyed-each-blocks.
4
+ let { animate = true, balance = true, calcCols = (masonryWidth, minColWidth, gap) => {
4
5
  return Math.min(items.length, Math.floor((masonryWidth + gap) / (minColWidth + gap)) || 1);
5
- }, columnClass = ``, duration = 200, gap = 20, getId = (item) => {
6
+ }, duration = 200, gap = 20, getId = (item) => {
6
7
  if (typeof item === `number`)
7
8
  return item;
8
9
  if (typeof item === `string`)
9
10
  return item;
10
11
  return item[idKey];
11
- }, idKey = `id`, items, masonryHeight = $bindable(0), masonryWidth = $bindable(0), maxColWidth = 500, minColWidth = 330, style = ``, class: className = ``, children, div = $bindable(undefined), // TODO add unit test for this prop
12
- } = $props();
12
+ }, idKey = `id`, items, masonryHeight = $bindable(0), masonryWidth = $bindable(0), maxColWidth = 500, minColWidth = 330, columnStyle = ``, columnClass = ``, children, div = $bindable(),
13
+ // Virtualization props
14
+ virtualize = false, getEstimatedHeight = undefined, overscan = 5, height = undefined, ...rest } = $props();
15
+ // Height tracking for column balancing and virtualization
16
+ let item_heights = $state(new Map());
17
+ let measured_count = $state(0); // trigger reactivity on height updates
18
+ let measured_sum = $state(0); // running sum for average calculation
19
+ let avg_measured_height = $derived(measured_count > 0 ? measured_sum / measured_count : null);
20
+ // Clean up stale heights when items change (prevents memory leak)
21
+ $effect(() => {
22
+ const current_ids = new Set(items.map(getId));
23
+ let removed_sum = 0;
24
+ for (const [id, height] of item_heights.entries()) {
25
+ if (!current_ids.has(id)) {
26
+ removed_sum += height;
27
+ item_heights.delete(id);
28
+ }
29
+ }
30
+ if (removed_sum > 0) {
31
+ measured_sum -= removed_sum;
32
+ measured_count = item_heights.size;
33
+ }
34
+ });
35
+ // Unified height getter with fallback chain
36
+ const get_height = (item) => {
37
+ const id = getId(item);
38
+ // 1. Actual measured height (most accurate)
39
+ const measured = item_heights.get(id);
40
+ if (measured !== undefined)
41
+ return measured;
42
+ // 2. User-provided estimate (if custom function provided)
43
+ if (getEstimatedHeight)
44
+ return getEstimatedHeight(item);
45
+ // 3. Average of measured items
46
+ if (avg_measured_height)
47
+ return avg_measured_height;
48
+ // 4. Hard fallback
49
+ return 150;
50
+ };
51
+ // Measure item heights via ResizeObserver
52
+ const measure_height = (node, item_id) => {
53
+ if (!balance && !virtualize)
54
+ return {};
55
+ const observer = new ResizeObserver(() => {
56
+ const new_height = node.offsetHeight;
57
+ if (new_height > 0 && item_heights.get(item_id) !== new_height) {
58
+ const old_height = item_heights.get(item_id) ?? 0;
59
+ measured_sum += new_height - old_height;
60
+ item_heights.set(item_id, new_height);
61
+ measured_count = item_heights.size;
62
+ }
63
+ });
64
+ observer.observe(node);
65
+ return { destroy: () => observer.disconnect() };
66
+ };
67
+ // Derive if we have enough measurements
68
+ let can_balance = $derived(balance && measured_count >= items.length);
69
+ // Distribute items to shortest column (uses get_height for estimates when not fully measured)
70
+ function balance_to_cols(num_cols) {
71
+ const cols = Array.from({ length: num_cols }, () => []);
72
+ const heights = Array(num_cols).fill(0);
73
+ for (const [idx, item] of items.entries()) {
74
+ const shortest = heights.indexOf(Math.min(...heights));
75
+ cols[shortest].push([item, idx]);
76
+ heights[shortest] += get_height(item) + gap;
77
+ }
78
+ return cols;
79
+ }
13
80
  $effect.pre(() => {
14
81
  if (maxColWidth < minColWidth) {
15
82
  console.warn(`svelte-bricks: maxColWidth (${maxColWidth}) < minColWidth (${minColWidth}).`);
16
83
  }
17
84
  });
18
- let nCols = $derived(calcCols(masonryWidth, minColWidth, gap));
19
- let itemsToCols = $derived(items.reduce((cols, item, idx) => {
20
- cols[idx % cols.length].push([item, idx]);
21
- return cols;
22
- }, Array(nCols).fill(null).map(() => [])));
85
+ // CSS container queries hide excess columns for CLS-free SSR
86
+ // When masonryWidth is 0 (SSR), calculate max cols for 1920px viewport
87
+ let nCols = $derived(calcCols(masonryWidth || 1920, minColWidth, gap));
88
+ // Container query rules: breakpoint(n) = (minColWidth + gap) * n - gap
89
+ let container_query_css = $derived(Array.from({ length: nCols - 1 }, (_, idx) => {
90
+ const col = idx + 1;
91
+ const max_w = (minColWidth + gap) * (col + 1) - gap - 1;
92
+ const min_w = col === 1
93
+ ? ``
94
+ : `(min-width: ${(minColWidth + gap) * col - gap}px) and `;
95
+ return `@container ${min_w}(max-width: ${max_w}px) { .masonry > .col:nth-child(n+${col + 1}) { display: none; } }`;
96
+ }).join(`\n`));
97
+ // Balanced distribution when measured, naive round-robin for SSR
98
+ let itemsToCols = $derived.by(() => {
99
+ if (can_balance)
100
+ return balance_to_cols(nCols);
101
+ // SSR/initial: round-robin distribution
102
+ return items.reduce((cols, item, idx) => (cols[idx % nCols].push([item, idx]), cols), Array.from({ length: nCols }, () => []));
103
+ });
104
+ // Virtualization logic
105
+ // Warn if virtualize=true but no height provided (only once)
106
+ let warned_missing_height = false;
107
+ $effect.pre(() => {
108
+ if (virtualize && height === undefined && !warned_missing_height) {
109
+ warned_missing_height = true;
110
+ console.warn(`svelte-bricks: virtualize=true requires a height prop. Falling back to 400px.`);
111
+ }
112
+ });
113
+ // Binary search: find first index where arr[i] >= target
114
+ function binary_search_ge(arr, target) {
115
+ let lo = 0;
116
+ let hi = arr.length;
117
+ while (lo < hi) {
118
+ const mid = (lo + hi) >>> 1;
119
+ if (arr[mid] < target)
120
+ lo = mid + 1;
121
+ else
122
+ hi = mid;
123
+ }
124
+ return lo;
125
+ }
126
+ // Prefix height arrays per column: prefix_heights[col][i] = cumulative height of items 0..i
127
+ let prefix_heights = $derived(itemsToCols.map((col) => {
128
+ let sum = 0;
129
+ return col.map(([item]) => {
130
+ sum += get_height(item) + gap;
131
+ return sum;
132
+ });
133
+ }));
134
+ // Total height per column (for padding calculation)
135
+ let col_total_heights = $derived(prefix_heights.map((ph) => ph.at(-1) ?? 0));
136
+ // Scroll state with requestAnimationFrame throttling
137
+ let scroll_top = $state(0);
138
+ let ticking = false;
139
+ function on_scroll(event) {
140
+ if (ticking)
141
+ return;
142
+ ticking = true;
143
+ requestAnimationFrame(() => {
144
+ scroll_top = event.target.scrollTop;
145
+ ticking = false;
146
+ });
147
+ }
148
+ // Container height for virtualization viewport
149
+ // For numeric height, use directly; for string (CSS units like "80vh"), use measured clientHeight
150
+ let container_height = $derived(typeof height === `number` ? height : masonryHeight || 400);
151
+ // Only enable virtualization once we have a valid container height measurement
152
+ // This prevents flicker when using CSS units like "80vh" that need DOM measurement
153
+ let can_virtualize = $derived(virtualize && (typeof height === `number` || masonryHeight > 0));
154
+ // Visible ranges per column: [start_idx, end_idx]
155
+ let visible_ranges = $derived(can_virtualize
156
+ ? prefix_heights.map((ph) => {
157
+ const start = Math.max(0, binary_search_ge(ph, scroll_top) - 1 - overscan);
158
+ const end = Math.min(ph.length, binary_search_ge(ph, scroll_top + container_height) + overscan);
159
+ return [start, end];
160
+ })
161
+ : itemsToCols.map((col) => [0, col.length]));
162
+ // Padding to replace culled items (only when actively virtualizing)
163
+ const zero_padding = () => itemsToCols.map(() => 0);
164
+ let col_padding_top = $derived(can_virtualize
165
+ ? visible_ranges.map(([start], idx) => (start > 0 ? prefix_heights[idx][start - 1] : 0))
166
+ : zero_padding());
167
+ let col_padding_bottom = $derived(can_virtualize
168
+ ? visible_ranges.map(([, end], idx) => {
169
+ const visible_end = end > 0 ? (prefix_heights[idx][end - 1] ?? 0) : 0;
170
+ return Math.max(0, col_total_heights[idx] - visible_end);
171
+ })
172
+ : zero_padding());
173
+ // Auto-disable animations when actively virtualizing (FLIP doesn't work well)
174
+ let effective_animate = $derived(animate && !can_virtualize);
23
175
  </script>
24
176
 
177
+ <!-- Dynamic container query styles for CLS-free SSR -->
178
+ <svelte:element this={`style`}>{container_query_css}</svelte:element>
179
+
25
180
  <!-- deno-fmt-ignore -->
26
181
  <div
27
- class="masonry {className}"
28
182
  bind:clientWidth={masonryWidth}
29
183
  bind:clientHeight={masonryHeight}
30
184
  bind:this={div}
31
- style="gap: {gap}px; {style}"
185
+ onscroll={virtualize ? on_scroll : undefined}
186
+ style:gap="{gap}px"
187
+ style:overflow-y={virtualize ? `auto` : undefined}
188
+ style:height={virtualize ? (typeof height === `number` ? `${height}px` : height ?? `400px`) : undefined}
189
+ {...rest}
190
+ class="masonry {rest.class ?? ``}"
32
191
  >
33
- {#each itemsToCols as col, idx}
34
- <div class="col col-{idx} {columnClass}" style="gap: {gap}px; max-width: {maxColWidth}px;">
35
- {#if animate}
36
- {#each col as [item, idx] (getId(item))}
192
+ {#each itemsToCols as col, col_idx}
193
+ {@const [start, end] = visible_ranges[col_idx]}
194
+ {@const visible_items = col.slice(start, end)}
195
+ <div
196
+ class="col col-{col_idx} {columnClass}"
197
+ style="gap: {gap}px; max-width: {maxColWidth}px;{can_virtualize ? ` padding-top: ${col_padding_top[col_idx]}px; padding-bottom: ${col_padding_bottom[col_idx]}px;` : ``} {columnStyle}"
198
+ >
199
+ {#if effective_animate}
200
+ {#each visible_items as [item, item_idx] (getId(item))}
37
201
  <div
202
+ use:measure_height={getId(item)}
38
203
  in:fade={{ delay: 100, duration }}
39
204
  out:fade={{ delay: 0, duration }}
40
205
  animate:flip={{ duration }}
41
206
  >
42
- {#if children}{@render children({ idx, item })}{:else}
207
+ {#if children}{@render children({ idx: item_idx, item })}{:else}
43
208
  <span>{item}</span>
44
209
  {/if}
45
210
  </div>
46
211
  {/each}
47
212
  {:else}
48
- {#each col as [item, idx] (getId(item))}
49
- {#if children}{@render children({ idx, item })}{:else}
50
- <span>{item}</span>
51
- {/if}
213
+ {#each visible_items as [item, item_idx] (getId(item))}
214
+ <div use:measure_height={getId(item)}>
215
+ {#if children}{@render children({ idx: item_idx, item })}{:else}
216
+ <span>{item}</span>
217
+ {/if}
218
+ </div>
52
219
  {/each}
53
220
  {/if}
54
221
  </div>
@@ -57,6 +224,7 @@ let itemsToCols = $derived(items.reduce((cols, item, idx) => {
57
224
 
58
225
  <style>
59
226
  :where(div.masonry) {
227
+ container-type: inline-size;
60
228
  display: flex;
61
229
  justify-content: center;
62
230
  overflow-wrap: anywhere;
@@ -1,9 +1,10 @@
1
1
  import type { Snippet } from 'svelte';
2
- declare class __sveltets_Render<Item> {
3
- props(): {
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ declare function $$render<Item>(): {
4
+ props: Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
4
5
  animate?: boolean;
6
+ balance?: boolean;
5
7
  calcCols?: (masonryWidth: number, minColWidth: number, gap: number) => number;
6
- columnClass?: string;
7
8
  duration?: number;
8
9
  gap?: number;
9
10
  getId?: (item: Item) => string | number;
@@ -15,15 +16,28 @@ declare class __sveltets_Render<Item> {
15
16
  minColWidth?: number;
16
17
  style?: string;
17
18
  class?: string;
19
+ columnStyle?: string;
20
+ columnClass?: string;
18
21
  children?: Snippet<[{
19
22
  idx: number;
20
23
  item: Item;
21
24
  }]>;
22
25
  div?: HTMLDivElement;
26
+ virtualize?: boolean;
27
+ getEstimatedHeight?: (item: Item) => number;
28
+ overscan?: number;
29
+ height?: number | string;
23
30
  };
24
- events(): {};
25
- slots(): {};
26
- bindings(): "div" | "masonryHeight" | "masonryWidth";
31
+ exports: {};
32
+ bindings: "masonryHeight" | "masonryWidth" | "div";
33
+ slots: {};
34
+ events: {};
35
+ };
36
+ declare class __sveltets_Render<Item> {
37
+ props(): ReturnType<typeof $$render<Item>>['props'];
38
+ events(): ReturnType<typeof $$render<Item>>['events'];
39
+ slots(): ReturnType<typeof $$render<Item>>['slots'];
40
+ bindings(): "masonryHeight" | "masonryWidth" | "div";
27
41
  exports(): {};
28
42
  }
29
43
  interface $$IsomorphicComponent {
package/package.json CHANGED
@@ -1,40 +1,31 @@
1
1
  {
2
2
  "name": "svelte-bricks",
3
- "description": "Simple masonry implementation without column balancing",
3
+ "description": "Svelte masonry component with SSR support and column balancing",
4
4
  "author": "Janosh Riebesell <janosh.riebesell@gmail.com>",
5
5
  "homepage": "https://janosh.github.io/svelte-bricks",
6
6
  "repository": "https://github.com/janosh/svelte-bricks",
7
7
  "license": "MIT",
8
- "version": "0.3.1",
8
+ "version": "0.4.0",
9
9
  "type": "module",
10
10
  "svelte": "./dist/index.js",
11
11
  "bugs": "https://github.com/janosh/svelte-bricks/issues",
12
- "scripts": {
13
- "dev": "vite dev",
14
- "build": "vite build",
15
- "preview": "vite preview",
16
- "package": "svelte-package",
17
- "serve": "vite build && vite preview",
18
- "check": "svelte-check --ignore dist",
19
- "test": "vitest test",
20
- "changelog": "npx auto-changelog --package --output changelog.md --hide-credit --commit-limit false"
21
- },
22
12
  "dependencies": {
23
- "svelte": "^5.22.6"
13
+ "svelte": "^5.46.1"
24
14
  },
25
15
  "devDependencies": {
26
- "@sveltejs/adapter-static": "^3.0.8",
27
- "@sveltejs/kit": "^2.19.0",
28
- "@sveltejs/package": "^2.3.10",
29
- "jsdom": "^26.0.0",
30
- "mdsvex": "^0.12.3",
31
- "svelte-check": "^4.1.5",
16
+ "@sveltejs/adapter-static": "^3.0.10",
17
+ "@sveltejs/kit": "^2.49.3",
18
+ "@sveltejs/package": "^2.5.7",
19
+ "@vitest/coverage-v8": "^4.0.16",
20
+ "jsdom": "^27.4.0",
21
+ "mdsvex": "^0.12.6",
22
+ "svelte-check": "^4.3.5",
23
+ "svelte-multiselect": "^11.5.1",
32
24
  "svelte-preprocess": "^6.0.3",
33
- "svelte-toc": "^0.5.9",
34
- "svelte-zoo": "^0.4.16",
35
- "svelte2tsx": "^0.7.35",
36
- "vite": "^6.2.1",
37
- "vitest": "^3.0.8"
25
+ "svelte-toc": "^0.6.2",
26
+ "svelte2tsx": "^0.7.46",
27
+ "vite": "^7.3.1",
28
+ "vitest": "^4.0.16"
38
29
  },
39
30
  "keywords": [
40
31
  "svelte",
@@ -57,6 +48,11 @@
57
48
  "default": "./dist/index.js"
58
49
  }
59
50
  },
51
+ "prettier": {
52
+ "semi": false,
53
+ "singleQuote": true,
54
+ "printWidth": 90
55
+ },
60
56
  "types": "./dist/index.d.ts",
61
57
  "files": [
62
58
  "dist"
package/readme.md CHANGED
@@ -10,19 +10,18 @@
10
10
  [![Tests](https://github.com/janosh/svelte-bricks/actions/workflows/test.yml/badge.svg)](https://github.com/janosh/svelte-bricks/actions/workflows/test.yml)
11
11
  [![NPM version](https://img.shields.io/npm/v/svelte-bricks?color=blue&logo=NPM)](https://npmjs.com/package/svelte-bricks)
12
12
  [![GitHub Pages](https://github.com/janosh/svelte-bricks/actions/workflows/gh-pages.yml/badge.svg)](https://github.com/janosh/svelte-bricks/actions/workflows/gh-pages.yml)
13
- [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/janosh/svelte-bricks/main.svg)](https://results.pre-commit.ci/latest/github/janosh/svelte-bricks/main)
14
13
  [![Open in StackBlitz](https://img.shields.io/badge/Open%20in-StackBlitz-darkblue?logo=stackblitz)](https://stackblitz.com/github/janosh/svelte-bricks)
15
14
 
16
15
  </h4>
17
16
 
18
- Naive implementation in Svelte without column balancing. **[Live demo](https://janosh.github.io/svelte-bricks)**
17
+ Svelte masonry component with SSR support (via CSS container queries) and automatic column balancing. **[Live demo](https://janosh.github.io/svelte-bricks)**
19
18
 
20
19
  </div>
21
20
 
22
21
  ## Installation
23
22
 
24
23
  ```sh
25
- npm install --dev svelte-bricks
24
+ pnpm add -D svelte-bricks
26
25
  ```
27
26
 
28
27
  ## Usage
@@ -47,7 +46,8 @@ Masonry size: <span>{width}px</span> &times; <span>{height}px</span> (w &times;
47
46
  {minColWidth}
48
47
  {maxColWidth}
49
48
  {gap}
50
-
49
+ style="padding: 20px;"
50
+ columnStyle="background-color: rgba(0, 0, 0, 0.1);"
51
51
  bind:masonryWidth={width}
52
52
  bind:masonryHeight={height}
53
53
  >
@@ -57,9 +57,7 @@ Masonry size: <span>{width}px</span> &times; <span>{height}px</span> (w &times;
57
57
  </Masonry>
58
58
  ```
59
59
 
60
- **Note**: If `items` is an array of objects, this component tries to access an `id` property on each item. This value is used to tell items apart in the keyed `{#each}` block that creates the masonry layout. Without it, Svelte could not avoid duplicates when new items are added or existing ones rearranged. Read the [Svelte docs](https://svelte.dev/tutorial/keyed-each-blocks) for details. To change the name of the identifier key, pass `idKey="some-uniq-key`. Or pass a function `getId = (item: Item) => string | number` that maps items to unique IDs.
61
-
62
- **Hint**: Balanced columns can be achieved even with this simple implementation if masonry items are allowed to stretch to the column height.
60
+ **Note**: If `items` is an array of objects, this component tries to access an `id` property on each item. This value is used to tell items apart in the keyed `{#each}` block that creates the masonry layout. Without it, Svelte could not avoid duplicates when new items are added or existing ones rearranged. Read the [Svelte docs](https://svelte.dev/docs/svelte/each#Keyed-each-blocks) for details. To change the name of the identifier key, pass `idKey="some-uniq-key`. Or pass a function `getId = (item: Item) => string | number` that maps items to unique IDs.
63
61
 
64
62
  ## Props
65
63
 
@@ -71,7 +69,13 @@ Additional optional props are:
71
69
  animate: boolean = true
72
70
  ```
73
71
 
74
- Whether to [FLIP-animate](https://svelte.dev/tutorial/animate) masonry items when viewport resizing or other events cause `items` to rearrange.
72
+ Whether to [FLIP-animate](https://svelte.dev/docs/svelte/svelte-animate) masonry items when viewport resizing or other events cause `items` to rearrange.
73
+
74
+ 1. ```ts
75
+ balance: boolean = true
76
+ ```
77
+
78
+ Enable height-based column balancing. Items are distributed to the shortest column for a more even layout. Set to `false` for simple round-robin distribution.
75
79
 
76
80
  1. ```ts
77
81
  calcCols = (
@@ -132,7 +136,7 @@ Additional optional props are:
132
136
  items: Item[]
133
137
  ```
134
138
 
135
- The only required prop are the list of items to render where `Item = $$Generic` is a generic type which usually will be `object` but can also be simple types `string` or `number`.
139
+ The only required prop is the list of items to render where `Item` is a generic type (via `generics="Item"`) which usually will be `object` but can also be simple types `string` or `number`.
136
140
 
137
141
  1. ```ts
138
142
  masonryHeight: number = 0
@@ -164,6 +168,56 @@ Additional optional props are:
164
168
 
165
169
  Inline styles that will be applied to the top-level `div.masonry`.
166
170
 
171
+ ## Virtual Scrolling
172
+
173
+ For large lists (1000+ items), enable virtual scrolling to render only visible items:
174
+
175
+ ```svelte
176
+ <Masonry
177
+ {items}
178
+ virtualize={true}
179
+ height={600}
180
+ getEstimatedHeight={(item) => item.height ?? 150}
181
+ overscan={5}
182
+ >
183
+ {#snippet children({ item })}
184
+ <Card {item} />
185
+ {/snippet}
186
+ </Masonry>
187
+ ```
188
+
189
+ ### Virtualization Props
190
+
191
+ 1. ```ts
192
+ virtualize: boolean = false
193
+ ```
194
+
195
+ Enable virtual scrolling. When `true`, only visible items are rendered. Requires the `height` prop.
196
+
197
+ 1. ```ts
198
+ height: number | string
199
+ ```
200
+
201
+ Required when `virtualize=true`. Sets the scroll container height (e.g., `500` for pixels or `"80vh"`).
202
+
203
+ 1. ```ts
204
+ getEstimatedHeight?: (item: Item) => number
205
+ ```
206
+
207
+ Optional function that returns an estimated height for items before they're measured. Defaults to 150px if not provided. Better estimates = less layout shift.
208
+
209
+ 1. ```ts
210
+ overscan: number = 5
211
+ ```
212
+
213
+ Number of items to render above and below the visible area. Higher values reduce flicker during fast scrolling but render more items.
214
+
215
+ **Notes:**
216
+
217
+ - FLIP animations are automatically disabled when virtualizing
218
+ - Balance mode works with estimated heights until items are measured
219
+ - The masonry div becomes a scroll container (`overflow-y: auto`)
220
+
167
221
  ## Styling
168
222
 
169
223
  Besides inline CSS which you can apply through the `style` prop, the following `:global()` CSS selectors can be used for fine-grained control of wrapper and column styles: