Skip to content

Commit 1dfa009

Browse files
committed
Dynamic list!
1 parent 5f2dd13 commit 1dfa009

File tree

4 files changed

+226
-3
lines changed

4 files changed

+226
-3
lines changed

src/lib/components/DynamicList.svelte

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<script lang="ts">
2+
import { NumericalIdGenerator, type Id } from '$lib/ids.js';
3+
import { mount, onMount, unmount, type Component, type Snippet } from 'svelte';
4+
5+
const ID_ATTR = 'data-clui-list-item-id';
6+
const ID_GENERATOR = new NumericalIdGenerator();
7+
8+
declare type ItemData = any;
9+
10+
declare type ItemRenderer = Component<() => ItemData, any, any> | Snippet<[ItemData]>;
11+
12+
interface Props {
13+
itemsVisible?: number;
14+
startWith?: ItemData[];
15+
itemRenderer: ItemRenderer;
16+
}
17+
18+
let { itemRenderer, startWith, itemsVisible = $bindable(0) }: Props = $props();
19+
20+
let unorderedList: HTMLElement;
21+
let observer: IntersectionObserver;
22+
23+
let allItems: Record<Id, TrackedItem> = {};
24+
let visibleItems: Record<Id, TrackedItem> = {};
25+
let hiddenItems: Record<Id, TrackedItem> = {};
26+
27+
class TrackedItem {
28+
public id: Id;
29+
30+
public data: ItemData;
31+
private li: HTMLLIElement;
32+
private component?: Record<string, any>;
33+
34+
constructor(li: HTMLLIElement, data: ItemData) {
35+
this.id = ID_GENERATOR.generate();
36+
this.data = data;
37+
38+
this.li = li;
39+
this.li.setAttribute(ID_ATTR, this.id);
40+
41+
allItems[this.id] = this;
42+
}
43+
44+
get mounted() {
45+
return this.component ? true : false;
46+
}
47+
48+
public mount(intro?: boolean): void {
49+
if (this.component) {
50+
// console.debug(`[ListRenderer | ${this.id}]`, 'Element is already mounted!');
51+
return;
52+
}
53+
// console.debug(`[ListRenderer | ${this.id}]`, 'Mounting element.');
54+
55+
visibleItems[this.id] = this;
56+
delete hiddenItems[this.id];
57+
58+
this.li.style.height = 'auto';
59+
60+
this.component = mount(itemRenderer, {
61+
target: this.li,
62+
props: () => this.data,
63+
intro: intro
64+
});
65+
}
66+
67+
public unmount(height: number): void {
68+
if (!this.component) {
69+
// console.debug(`[ListRenderer | ${this.id}]`, "Element isn't mounted!");
70+
return;
71+
}
72+
// console.debug(`[ListRenderer | ${this.id}]`, 'Unmounting element.');
73+
74+
delete visibleItems[this.id];
75+
hiddenItems[this.id] = this;
76+
77+
this.li.style.height = height + 'px';
78+
79+
unmount(this.component, {
80+
outro: false
81+
});
82+
this.component = undefined;
83+
}
84+
85+
public destroy() {
86+
this.unmount(0);
87+
88+
delete allItems[this.id];
89+
delete visibleItems[this.id];
90+
delete hiddenItems[this.id];
91+
92+
observer.unobserve(this.li);
93+
this.li.remove();
94+
}
95+
}
96+
97+
onMount(() => {
98+
const callback: IntersectionObserverCallback = (entries) => {
99+
entries.forEach((entry) => {
100+
const id = (entry.target as HTMLLIElement).getAttribute(ID_ATTR) as string;
101+
const isVisible = entry.isIntersecting;
102+
103+
if (isVisible) {
104+
const trackedItem = hiddenItems[id];
105+
if (!trackedItem) return; // Already mounted.
106+
107+
trackedItem.mount();
108+
} else {
109+
const trackedItem = visibleItems[id];
110+
if (!trackedItem) return; // Already unmounted.
111+
112+
const height = entry.boundingClientRect.height; // The height of the element when it is fully visible.
113+
trackedItem.unmount(height);
114+
}
115+
116+
itemsVisible = Object.keys(visibleItems).length;
117+
});
118+
};
119+
120+
observer = new IntersectionObserver(callback, {
121+
root: unorderedList
122+
});
123+
124+
startWith?.forEach(addItem);
125+
126+
return () => {
127+
observer.disconnect();
128+
129+
for (const item of Object.values(visibleItems)) {
130+
// console.debug("We're going away! Unmounting:", item.id);
131+
item.unmount(0);
132+
}
133+
};
134+
});
135+
136+
export function addItem(data: ItemData) {
137+
const li = document.createElement('li');
138+
const tracked = new TrackedItem(li, data);
139+
140+
unorderedList.appendChild(li);
141+
tracked.mount(true);
142+
observer.observe(li);
143+
}
144+
145+
/**
146+
* Note that this function is useless if `data` is an object. It is only recommended to use this function if you're using some form of identifier, such as a string or a number.
147+
*/
148+
export function removeItem(data: ItemData) {
149+
for (const tracked of Object.values(allItems)) {
150+
if (tracked.data === data) {
151+
tracked.destroy();
152+
return;
153+
}
154+
}
155+
}
156+
</script>
157+
158+
<ul bind:this={unorderedList}></ul>
159+
160+
<style>
161+
ul {
162+
overflow-y: auto;
163+
height: 100%;
164+
margin: 0;
165+
}
166+
</style>

src/lib/ids.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export declare type Id = any;
2+
3+
export declare interface IdGenerator {
4+
generate(): Id;
5+
}
6+
7+
export class NumericalIdGenerator implements IdGenerator {
8+
private idx: number;
9+
10+
constructor() {
11+
this.idx = 0;
12+
}
13+
14+
generate(): number {
15+
return this.idx++;
16+
}
17+
}

src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { default as Box } from '$lib/components/Box.svelte';
22
export { default as Button } from '$lib/components/Button.svelte';
33
export { default as Divider } from '$lib/components/Divider.svelte';
4+
export { default as DynamicList } from '$lib/components/DynamicList.svelte';
45
export { default as Input } from '$lib/components/Input.svelte';
56
export { default as InvertedScroller } from '$lib/components/InvertedScroller.svelte';

src/routes/+page.svelte

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<script lang="ts">
2-
import { Box, Button, Divider, Input, InvertedScroller } from '$lib/index.js';
2+
import { Box, Button, Divider, Input, InvertedScroller, DynamicList } from '$lib/index.js';
33
44
const SCROLL_ITEMS = 1000;
5+
const DYNAMIC_STARTING_ITEMS = 100;
56
67
let numberInputValue = 0;
78
let numberInputStep = 1;
@@ -14,6 +15,10 @@
1415
1516
let isScrollerAtBottom = true;
1617
let scroller: InvertedScroller;
18+
19+
let dynamicList: DynamicList;
20+
let dynamicListItemIdx = DYNAMIC_STARTING_ITEMS;
21+
let dynamicListItemsVisible = 0;
1722
</script>
1823

1924
<h1>Casterlabs UI Test Page</h1>
@@ -26,7 +31,7 @@ Page Zoom:
2631
placeholder="16"
2732
borderless
2833
style="width: 8ch;"
29-
oninput={(e) => {
34+
oninput={(e: Event) => {
3035
// @ts-ignore
3136
const newValue = e.target.value ?? 16;
3237
document.documentElement.style.fontSize = newValue + 'px';
@@ -40,7 +45,7 @@ Roundness:
4045
placeholder="0"
4146
borderless
4247
style="width: 8ch;"
43-
oninput={(e) => {
48+
oninput={(e: Event) => {
4449
// @ts-ignore
4550
const newValue = e.target.value ?? 4;
4651
// @ts-ignore
@@ -185,6 +190,40 @@ Is at bottom?
185190
</Box>
186191
</div>
187192

193+
<h2>Dynamic List</h2>
194+
195+
<p>
196+
This element unmounts the given snippet if the item isn't visible in the viewport and remounts it
197+
when it becomes visible. This is useful for heavy list-based UIs that have a lot of items. You can
198+
pass it either a Component to mount or a snipper (which gets wrapped and mounted).
199+
</p>
200+
201+
<p>
202+
Items visible: {dynamicListItemsVisible}
203+
<Button
204+
onclick={() => {
205+
dynamicList.addItem(dynamicListItemIdx++);
206+
}}
207+
>
208+
+
209+
</Button>
210+
</p>
211+
212+
<Box
213+
style="flex: 1; height: 10rem; padding: 0; overflow:hidden;"
214+
sides={['top', 'bottom', 'left', 'right']}
215+
resize="horizontal"
216+
>
217+
<DynamicList
218+
bind:this={dynamicList}
219+
startWith={Array.from(Array(DYNAMIC_STARTING_ITEMS).keys())}
220+
bind:itemsVisible={dynamicListItemsVisible}
221+
>
222+
{#snippet itemRenderer(num: number)}
223+
This is item #{num}
224+
{/snippet}
225+
</DynamicList>
226+
</Box>
188227
<br />
189228
<br />
190229
<br />

0 commit comments

Comments
 (0)