Compare commits

...

23 Commits

Author SHA1 Message Date
Ilia Mashkov
20f6e193f2 chore: minor changes 2026-01-18 15:01:19 +03:00
Ilia Mashkov
c04518300b chore: remove unused code 2026-01-18 15:00:54 +03:00
Ilia Mashkov
ee074036f6 chore: add import shortcuts 2026-01-18 15:00:26 +03:00
Ilia Mashkov
ba883ef9a8 fix(motion): edit MotionPreference to avoid errors 2026-01-18 15:00:07 +03:00
Ilia Mashkov
28a71452d1 fix(FontListItem): edit FontListItem to work with selectedFontsStore 2026-01-18 14:59:00 +03:00
Ilia Mashkov
b7ce100407 fix(FontSearch): edit component to render suggested fonts 2026-01-18 14:58:05 +03:00
Ilia Mashkov
96b26fb055 feat(FontDisplay): create a FontDisplay component to show selected font samples 2026-01-18 14:57:15 +03:00
Ilia Mashkov
5ef8d609ab feat(SuggestedFonts): create a component for Suggested Virtualized Font List 2026-01-18 14:56:25 +03:00
Ilia Mashkov
f457e5116f feat(displayedFontsStore): create store to manage displayed fonts sample and its content 2026-01-18 14:55:00 +03:00
Ilia Mashkov
e0e0d929bb chore: add import shortcuts 2026-01-18 14:53:14 +03:00
Ilia Mashkov
37ab7f795e feat(selectedFontsStore): create selectedFontsStore to manage selected fonts collection 2026-01-18 14:52:12 +03:00
Ilia Mashkov
af2ef77c30 feat(FontSampler): edit FontSampler to applt font-family through FontApplicator component 2026-01-18 14:48:36 +03:00
Ilia Mashkov
ad18a19c4b chore(FontSampler): delete unused prop 2026-01-18 14:47:31 +03:00
Ilia Mashkov
ef259c6fce chore: add import shortcuts 2026-01-18 14:39:38 +03:00
Ilia Mashkov
5d23a2af55 feat(EntityStore): create a helper for creation of an Entity Store to store and operate over values that have ids 2026-01-18 14:38:58 +03:00
Ilia Mashkov
df8eca6ef2 feat(splitArray): create a util to split an array based on a boolean resulting callback 2026-01-18 14:37:23 +03:00
Ilia Mashkov
7e62acce49 fix(ContentEditable): change logic to support controlled state 2026-01-18 14:35:35 +03:00
Ilia Mashkov
86e7b2c1ec feat(FontListItem): create FontListItem component that visualize selection of a certain font 2026-01-18 12:59:12 +03:00
Ilia Mashkov
da0612942c feat(FontApplicator): create FontApplicator component that register certain font and applies it to the children 2026-01-18 12:57:56 +03:00
Ilia Mashkov
0444f8c114 chore(FontVirtualList): transform FontList into reusable FontVirtualList component with appliedFontsManager support 2026-01-18 12:55:25 +03:00
Ilia Mashkov
6b4e0dbbd0 feat(ContentEditable): create ContentEditable shared component that displays text and allows editing 2026-01-18 12:51:55 +03:00
Ilia Mashkov
7389ec779d feat:(VirtualList) add onVisibleItemsChange prop that triggers when visibleItems list changes 2026-01-18 12:50:17 +03:00
Ilia Mashkov
4d04761d88 feat(appliedFontsStore): create Applied Fonts Manager to manage fonts download 2026-01-18 12:46:11 +03:00
31 changed files with 611 additions and 95 deletions

View File

@@ -24,6 +24,8 @@ let { children } = $props();
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="preconnect" href="https://api.fontshare.com" />
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
</svelte:head>
<div id="app-root">

View File

@@ -60,9 +60,11 @@ export type {
} from './model';
export {
appliedFontsManager,
createFontshareStore,
fetchFontshareFontsQuery,
fontshareStore,
selectedFontsStore,
} from './model';
// Stores
@@ -72,4 +74,8 @@ export {
} from './model/services/fetchGoogleFonts.svelte';
// UI elements
export { FontList } from './ui';
export {
FontApplicator,
FontListItem,
FontVirtualList,
} from './ui';

View File

@@ -37,7 +37,9 @@ export type {
export { fetchFontshareFontsQuery } from './services';
export {
appliedFontsManager,
createFontshareStore,
type FontshareStore,
fontshareStore,
selectedFontsStore,
} from './store';

View File

@@ -0,0 +1,143 @@
import { SvelteMap } from 'svelte/reactivity';
export type FontStatus = 'loading' | 'loaded' | 'error';
class AppliedFontsManager {
// Stores: slug -> timestamp of last visibility
#usageTracker = new Map<string, number>();
// Stores: slug -> batchId
#slugToBatch = new Map<string, string>();
// Stores: batchId -> HTMLLinkElement (for physical cleanup)
#batchElements = new Map<string, HTMLLinkElement>();
#queue = new Set<string>();
#timeoutId: ReturnType<typeof setTimeout> | null = null;
#PURGE_INTERVAL = 60000; // Check every minute
#TTL = 5 * 60 * 1000; // 5 minutes
#CHUNK_SIZE = 3;
statuses = new SvelteMap<string, FontStatus>();
constructor() {
if (typeof window !== 'undefined') {
// Start the "Janitor" loop
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
}
}
/**
* Updates the 'last seen' timestamp for fonts.
* Prevents them from being purged while they are on screen.
*/
touch(slugs: string[]) {
const now = Date.now();
const toRegister: string[] = [];
slugs.forEach(slug => {
this.#usageTracker.set(slug, now);
if (!this.#slugToBatch.has(slug)) {
toRegister.push(slug);
}
});
if (toRegister.length > 0) this.registerFonts(toRegister);
}
registerFonts(slugs: string[]) {
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s));
if (newSlugs.length === 0) return;
newSlugs.forEach(s => this.#queue.add(s));
if (this.#timeoutId) clearTimeout(this.#timeoutId);
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
}
getFontStatus(slug: string) {
return this.statuses.get(slug);
}
#processQueue() {
const fullQueue = Array.from(this.#queue);
if (fullQueue.length === 0) return;
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) {
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE));
}
this.#queue.clear();
this.#timeoutId = null;
}
#createBatch(slugs: string[]) {
if (typeof document === 'undefined') return;
const batchId = crypto.randomUUID();
// font-display=swap included for better UX
const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&');
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
// Mark all as loading immediately
slugs.forEach(slug => this.statuses.set(slug, 'loading'));
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = url;
link.dataset.batchId = batchId;
document.head.appendChild(link);
this.#batchElements.set(batchId, link);
slugs.forEach(slug => {
this.#slugToBatch.set(slug, batchId);
// Use the Native Font Loading API
// format: "font-size font-family"
document.fonts.load(`1em "${slug}"`)
.then(loadedFonts => {
if (loadedFonts.length > 0) {
this.statuses.set(slug, 'loaded');
} else {
this.statuses.set(slug, 'error');
}
})
.catch(() => {
this.statuses.set(slug, 'error');
});
});
}
#purgeUnused() {
const now = Date.now();
const batchesToPotentialDelete = new Set<string>();
const slugsToDelete: string[] = [];
// Identify expired slugs
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
if (now - lastUsed > this.#TTL) {
const batchId = this.#slugToBatch.get(slug);
if (batchId) batchesToPotentialDelete.add(batchId);
slugsToDelete.push(slug);
}
}
// Only remove a batch if ALL fonts in that batch are expired
batchesToPotentialDelete.forEach(batchId => {
const batchSlugs = Array.from(this.#slugToBatch.entries())
.filter(([_, bId]) => bId === batchId)
.map(([slug]) => slug);
const allExpired = batchSlugs.every(s => slugsToDelete.includes(s));
if (allExpired) {
this.#batchElements.get(batchId)?.remove();
this.#batchElements.delete(batchId);
batchSlugs.forEach(s => {
this.#slugToBatch.delete(s);
this.#usageTracker.delete(s);
});
}
});
}
}
export const appliedFontsManager = new AppliedFontsManager();

View File

@@ -17,3 +17,7 @@ export {
type FontshareStore,
fontshareStore,
} from './fontshareStore.svelte';
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';

View File

@@ -0,0 +1,4 @@
import { createEntityStore } from '$shared/lib';
import type { UnifiedFont } from '../../types';
export const selectedFontsStore = createEntityStore<UnifiedFont>([]);

View File

@@ -0,0 +1,46 @@
<!--
Component: FontApplicator
Loads fonts from fontshare with link tag
-->
<script lang="ts">
import {
appliedFontsManager,
} from '$entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
interface Props {
name: string;
id: string;
className?: string;
children?: Snippet;
}
let { name, id, className, children }: Props = $props();
let element: Element;
$effect(() => {
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) {
appliedFontsManager.touch([id]);
}
});
observer.observe(element);
return () => observer.disconnect();
});
const isLoading = $derived(appliedFontsManager.getFontStatus(id) === 'loading');
</script>
<div
bind:this={element}
style:font-family={name}
class={cn(
'transition-all duration-200 ease-out',
isLoading ? 'opacity-0 translate-y-1' : 'opacity-100 translate-y-0',
className,
)}
>
{@render children?.()}
</div>

View File

@@ -1,32 +0,0 @@
<!--
Component: FontList
- Displays a virtualized list of fonts with loading, empty, and error states.
- Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
-->
<script lang="ts">
import FontView from '$features/ShowFont/ui/FontView.svelte';
import {
Content as ItemContent,
Root as ItemRoot,
Title as ItemTitle,
} from '$shared/shadcn/ui/item';
import { VirtualList } from '$shared/ui';
import { fontshareStore } from '../../model';
$inspect(fontshareStore.fonts);
</script>
<VirtualList items={fontshareStore.fonts}>
{#snippet children({ item: font })}
<ItemRoot>
<ItemContent>
<!-- <ItemTitle></ItemTitle> -->
<span class="text-xs text-muted-foreground">
{font.provider}{font.category}
</span>
<FontView id={font.id} slug={font.id} name={font.name}>{font.name}</FontView>
</ItemContent>
</ItemRoot>
{/snippet}
</VirtualList>

View File

@@ -0,0 +1,81 @@
<!--
Component: FontListItem
Displays a font item with a checkbox and its characteristics in badges.
-->
<script lang="ts">
import { Badge } from '$shared/shadcn/ui/badge';
import { Checkbox } from '$shared/shadcn/ui/checkbox';
import { Label } from '$shared/shadcn/ui/label';
import {
type UnifiedFont,
selectedFontsStore,
} from '../../model';
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
interface Props {
font: UnifiedFont;
}
const { font }: Props = $props();
const handleChange = (checked: boolean) => {
if (checked) {
selectedFontsStore.addOne(font);
} else {
selectedFontsStore.removeOne(font.id);
}
};
const selected = $derived(selectedFontsStore.has(font.id));
</script>
<div class="pb-1">
<Label
for={font.id}
class="
w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3
active:scale-[0.98] active:transition-transform active:duration-75
has-aria-checked:border-blue-600
has-aria-checked:bg-blue-50
dark:has-aria-checked:border-blue-900
dark:has-aria-checked:bg-blue-950
"
>
<div class="w-full">
<div class="flex flex-row gap-1 w-full items-center justify-between">
<div class="flex flex-col gap-1 transition-all duration-150 ease-out">
<div class="flex flex-row gap-1">
<Badge variant="outline" class="text-[0.5rem]">
{font.provider}
</Badge>
<Badge variant="outline" class="text-[0.5rem]">
{font.category}
</Badge>
</div>
<FontApplicator
id={font.id}
className="text-2xl"
name={font.name}
>
{font.name}
</FontApplicator>
</div>
<Checkbox
id={font.id}
checked={selected}
onCheckedChange={handleChange}
class="
transition-all duration-150 ease-out
data-[state=checked]:scale-100
data-[state=checked]:border-blue-600
data-[state=checked]:bg-blue-600
data-[state=checked]:text-white
dark:data-[state=checked]:border-blue-700
dark:data-[state=checked]:bg-blue-700
"
/>
</div>
</div>
</Label>
</div>

View File

@@ -0,0 +1,30 @@
<script lang="ts" generics="T extends { id: string }">
import { VirtualList } from '$shared/ui';
import type { ComponentProps } from 'svelte';
import { appliedFontsManager } from '../../model/store/appliedFontsStore/appliedFontsStore.svelte';
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
onVisibleItemsChange?: (items: T[]) => void;
}
let { items, children, onVisibleItemsChange, ...rest }: Props = $props();
function handleInternalVisibleChange(visibleItems: T[]) {
// Auto-register fonts with the manager
const slugs = visibleItems.map(item => item.id);
appliedFontsManager.registerFonts(slugs);
// Forward the call to any external listener
onVisibleItemsChange?.(visibleItems);
}
</script>
<VirtualList
{items}
{...rest}
onVisibleItemsChange={handleInternalVisibleChange}
>
{#snippet children(scope)}
{@render children(scope)}
{/snippet}
</VirtualList>

View File

@@ -1,3 +1,9 @@
import FontList from './FontList/FontList.svelte';
import FontApplicator from './FontApplicator/FontApplicator.svelte';
import FontListItem from './FontListItem/FontListItem.svelte';
import FontVirtualList from './FontVirtualList/FontVirtualList.svelte';
export { FontList };
export {
FontApplicator,
FontListItem,
FontVirtualList,
};

View File

View File

@@ -0,0 +1 @@
export { displayedFontsStore } from './store';

View File

@@ -0,0 +1,23 @@
import { selectedFontsStore } from '$entities/Font';
export class DisplayedFontsStore {
#sampleText = $state('The quick brown fox jumps over the lazy dog');
#displayedFonts = $derived.by(() => {
return selectedFontsStore.all;
});
get fonts() {
return this.#displayedFonts;
}
get text() {
return this.#sampleText;
}
set text(text: string) {
this.#sampleText = text;
}
}
export const displayedFontsStore = new DisplayedFontsStore();

View File

@@ -0,0 +1 @@
export { displayedFontsStore } from './displayedFontsStore.svelte';

View File

@@ -0,0 +1,14 @@
<!--
Component: FontDisplay
Displays a grid of FontSampler components for each displayed font.
-->
<script>
import { displayedFontsStore } from '../../model';
import FontSampler from '../FontSampler/FontSampler.svelte';
</script>
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
{#each displayedFontsStore.fonts as font (font.id)}
<FontSampler font={font} bind:text={displayedFontsStore.text} />
{/each}
</div>

View File

@@ -0,0 +1,37 @@
<!--
Component: FontSampler
Displays a sample text with a given font in a contenteditable element.
-->
<script lang="ts">
import {
FontApplicator,
type UnifiedFont,
} from '$entities/Font';
import { ContentEditable } from '$shared/ui';
interface Props {
font: UnifiedFont;
text: string;
fontSize?: number;
lineHeight?: number;
letterSpacing?: number;
}
let {
font,
text = $bindable(),
...restProps
}: Props = $props();
</script>
<div
class="
w-full rounded-xl
bg-white p-6 border border-slate-200
shadow-sm dark:border-slate-800 dark:bg-slate-950
"
>
<FontApplicator id={font.id} name={font.name}>
<ContentEditable bind:text={text} {...restProps} />
</FontApplicator>
</div>

View File

@@ -4,14 +4,12 @@
Combines search input with font list display
-->
<script lang="ts">
import {
FontList,
fontshareStore,
} from '$entities/Font';
import { fontshareStore } from '$entities/Font';
import { SearchBar } from '$shared/ui';
import { onMount } from 'svelte';
import { mapManagerToParams } from '../../lib';
import { filterManager } from '../../model';
import SuggestedFonts from '../SuggestedFonts/SuggestedFonts.svelte';
onMount(() => {
/**
@@ -31,5 +29,5 @@ onMount(() => {
placeholder="Search fonts by name..."
bind:value={filterManager.queryValue}
>
<FontList />
<SuggestedFonts />
</SearchBar>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import {
FontListItem,
FontVirtualList,
fontshareStore,
} from '$entities/Font';
</script>
<FontVirtualList items={fontshareStore.fonts}>
{#snippet children({ item: font })}
<FontListItem {font} />
{/snippet}
</FontVirtualList>

View File

@@ -1,40 +0,0 @@
<!--
Component: FontView
Loads fonts from fontshare with link tag
-->
<script lang="ts">
interface Props {
name: string;
slug: string;
id: string;
children?: import('svelte').Snippet;
}
let { name, slug, id, children }: Props = $props();
let isLoaded = $state(false);
// Construct the Fontshare API CSS URL
// We specify the weight (400) or 'all'
const cssUrl = $derived(`https://api.fontshare.com/v2/css?f[]=${id}&display=swap`);
$effect(() => {
// Even though we use a link tag, we can still "watch"
// for the font to be ready for a smooth fade-in
document.fonts.load(`1em "${name}"`).then(() => {
isLoaded = true;
});
});
</script>
<svelte:head>
<link rel="stylesheet" href={cssUrl} />
</svelte:head>
<div
style:--f={name}
style:font-family={name ? `'${name}', sans-serif` : 'inherit'}
class="transition-opacity duration-500 {isLoaded ? 'font-[var(--f)] opacity-100' : 'font-sans opacity-0'}"
>
{@render children?.()}
</div>

View File

@@ -1,8 +1,12 @@
<script lang="ts">
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
/**
* Page Component
*/
</script>
<!-- Font List -->
<div />
<div class="p-2">
<FontDisplay />
</div>

View File

@@ -4,11 +4,6 @@ const isBrowser = typeof window !== 'undefined';
class MotionPreference {
// Reactive state
#reduced = $state(false);
#mediaQuery: MediaQueryList = new MediaQueryList();
private handleChange = (e: MediaQueryListEvent) => {
this.#reduced = e.matches;
};
constructor() {
if (isBrowser) {
@@ -17,9 +12,12 @@ class MotionPreference {
// Set initial value immediately
this.#reduced = mediaQuery.matches;
mediaQuery.addEventListener('change', this.handleChange);
// Simple listener that updates the reactive state
const handleChange = (e: MediaQueryListEvent) => {
this.#reduced = e.matches;
};
this.#mediaQuery = mediaQuery;
mediaQuery.addEventListener('change', handleChange);
}
}
@@ -27,10 +25,6 @@ class MotionPreference {
get reduced() {
return this.#reduced;
}
destroy() {
this.#mediaQuery.removeEventListener('change', this.handleChange);
}
}
// Export a single instance to be used everywhere

View File

@@ -0,0 +1,80 @@
import { SvelteMap } from 'svelte/reactivity';
export interface Entity {
id: string;
}
/**
* Svelte 5 Entity Store
* Uses SvelteMap for O(1) lookups and granular reactivity.
*/
export class EntityStore<T extends Entity> {
// SvelteMap is a reactive version of the native Map
#entities = new SvelteMap<string, T>();
constructor(initialEntities: T[] = []) {
this.setAll(initialEntities);
}
// --- Selectors (Equivalent to Selectors) ---
/** Get all entities as an array */
get all() {
return Array.from(this.#entities.values());
}
/** Select a single entity by ID */
getById(id: string) {
return this.#entities.get(id);
}
/** Select multiple entities by IDs */
getByIds(ids: string[]) {
return ids.map(id => this.#entities.get(id)).filter((e): e is T => !!e);
}
// --- Actions (CRUD) ---
addOne(entity: T) {
this.#entities.set(entity.id, entity);
}
addMany(entities: T[]) {
entities.forEach(e => this.addOne(e));
}
updateOne(id: string, changes: Partial<T>) {
const entity = this.#entities.get(id);
if (entity) {
// In Svelte 5, updating the object property directly is reactive
// if the object itself was made reactive, but here we replace
// the reference to ensure top-level map triggers.
this.#entities.set(id, { ...entity, ...changes });
}
}
removeOne(id: string) {
this.#entities.delete(id);
}
removeMany(ids: string[]) {
ids.forEach(id => this.#entities.delete(id));
}
setAll(entities: T[]) {
this.#entities.clear();
this.addMany(entities);
}
has(id: string) {
return this.#entities.has(id);
}
clear() {
this.#entities.clear();
}
}
export function createEntityStore<T extends Entity>(initialEntities: T[] = []) {
return new EntityStore<T>(initialEntities);
}

View File

@@ -79,7 +79,11 @@ export interface VirtualizerOptions {
* </div>
* ```
*/
export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & { data: T[] }) {
export function createVirtualizer<T>(
optionsGetter: () => VirtualizerOptions & {
data: T[];
},
) {
let scrollOffset = $state(0);
let containerHeight = $state(0);
let measuredSizes = $state<Record<number, number>>({});
@@ -149,6 +153,7 @@ export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & {
key: options.getItemKey?.(i) ?? i,
});
}
return result;
});
// Svelte Actions (The DOM Interface)

View File

@@ -20,3 +20,9 @@ export {
} from './createVirtualizer/createVirtualizer.svelte';
export { createDebouncedState } from './createDebouncedState/createDebouncedState.svelte';
export {
createEntityStore,
type Entity,
type EntityStore,
} from './createEntityStore/createEntityStore.svelte';

View File

@@ -2,9 +2,12 @@ export {
type ControlDataModel,
type ControlModel,
createDebouncedState,
createEntityStore,
createFilter,
createTypographyControl,
createVirtualizer,
type Entity,
type EntityStore,
type Filter,
type FilterModel,
type Property,
@@ -15,3 +18,4 @@ export {
} from './helpers';
export { motion } from './accessibility/motion.svelte';
export { splitArray } from './utils';

View File

@@ -11,3 +11,4 @@ export { clampNumber } from './clampNumber/clampNumber';
export { debounce } from './debounce/debounce';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
export { splitArray } from './splitArray/splitArray';

View File

@@ -0,0 +1,8 @@
export function splitArray<T>(array: T[], callback: (item: T) => boolean) {
return array.reduce<[T[], T[]]>(
([pass, fail], item) => (
callback(item) ? pass.push(item) : fail.push(item), [pass, fail]
),
[[], []],
);
}

View File

@@ -0,0 +1,62 @@
<!--
Component: ContentEditable
Provides a contenteditable div with custom font and text properties.
-->
<script lang="ts">
interface Props {
/**
* Visible text
*/
text: string;
/**
* Font settings
*/
fontSize?: number;
lineHeight?: number;
letterSpacing?: number;
}
let {
text = $bindable('The quick brown fox jumps over the lazy dog.'),
fontSize = 48,
lineHeight = 1.2,
letterSpacing = 0,
}: Props = $props();
let element: HTMLDivElement | undefined = $state();
// Initial Sync: Set the text ONLY ONCE when the element is created.
// This prevents Svelte from "owning" the innerHTML/innerText.
$effect(() => {
if (element && element.innerText !== text) {
element.innerText = text;
}
});
// Handle changes: Update the outer state without re-rendering the div.
function handleInput(e: Event) {
const target = e.target as HTMLDivElement;
// Update the bindable prop directly
text = target.innerText;
}
</script>
<div
bind:this={element}
contenteditable="plaintext-only"
spellcheck="false"
role="textbox"
tabindex="0"
data-placeholder="Type something to test..."
oninput={handleInput}
class="
w-full min-h-[1.2em] outline-none transition-all duration-200
empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400
selection:bg-indigo-100 selection:text-indigo-900
caret-indigo-500
"
style:font-size="{fontSize}px"
style:line-height={lineHeight}
style:letter-spacing="{letterSpacing}em"
>
</div>

View File

@@ -35,6 +35,11 @@ interface Props {
* (follows shadcn convention for className prop)
*/
class?: string;
/**
* An optional callback that will be called for each new set of loaded items
* @param items - Loaded items
*/
onVisibleItemsChange?: (items: T[]) => void;
/**
* Snippet for rendering individual list items.
*
@@ -50,7 +55,8 @@ interface Props {
children: Snippet<[{ item: T; index: number }]>;
}
let { items, itemHeight = 80, overscan = 5, class: className, children }: Props = $props();
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }:
Props = $props();
const virtualizer = createVirtualizer(() => ({
count: items.length,
@@ -58,12 +64,17 @@ const virtualizer = createVirtualizer(() => ({
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
overscan,
}));
$effect(() => {
const visibleItems = virtualizer.items.map(item => items[item.index]);
onVisibleItemsChange?.(visibleItems);
});
</script>
<div
use:virtualizer.container
class={cn(
'relative overflow-auto border rounded-md bg-background',
'relative overflow-auto rounded-md bg-background',
'h-150 w-full',
className,
)}

View File

@@ -6,12 +6,14 @@
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
import ComboControl from './ComboControl/ComboControl.svelte';
import ContentEditable from './ContentEditable/ContentEditable.svelte';
import SearchBar from './SearchBar/SearchBar.svelte';
import VirtualList from './VirtualList/VirtualList.svelte';
export {
CheckboxFilter,
ComboControl,
ContentEditable,
SearchBar,
VirtualList,
};