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> <svelte:head>
<link rel="icon" href={favicon} /> <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> </svelte:head>
<div id="app-root"> <div id="app-root">

View File

@@ -60,9 +60,11 @@ export type {
} from './model'; } from './model';
export { export {
appliedFontsManager,
createFontshareStore, createFontshareStore,
fetchFontshareFontsQuery, fetchFontshareFontsQuery,
fontshareStore, fontshareStore,
selectedFontsStore,
} from './model'; } from './model';
// Stores // Stores
@@ -72,4 +74,8 @@ export {
} from './model/services/fetchGoogleFonts.svelte'; } from './model/services/fetchGoogleFonts.svelte';
// UI elements // 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 { fetchFontshareFontsQuery } from './services';
export { export {
appliedFontsManager,
createFontshareStore, createFontshareStore,
type FontshareStore, type FontshareStore,
fontshareStore, fontshareStore,
selectedFontsStore,
} from './store'; } 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, type FontshareStore,
fontshareStore, fontshareStore,
} from './fontshareStore.svelte'; } 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 Combines search input with font list display
--> -->
<script lang="ts"> <script lang="ts">
import { import { fontshareStore } from '$entities/Font';
FontList,
fontshareStore,
} from '$entities/Font';
import { SearchBar } from '$shared/ui'; import { SearchBar } from '$shared/ui';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { mapManagerToParams } from '../../lib'; import { mapManagerToParams } from '../../lib';
import { filterManager } from '../../model'; import { filterManager } from '../../model';
import SuggestedFonts from '../SuggestedFonts/SuggestedFonts.svelte';
onMount(() => { onMount(() => {
/** /**
@@ -31,5 +29,5 @@ onMount(() => {
placeholder="Search fonts by name..." placeholder="Search fonts by name..."
bind:value={filterManager.queryValue} bind:value={filterManager.queryValue}
> >
<FontList /> <SuggestedFonts />
</SearchBar> </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"> <script lang="ts">
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
/** /**
* Page Component * Page Component
*/ */
</script> </script>
<!-- Font List --> <!-- Font List -->
<div /> <div class="p-2">
<FontDisplay />
</div>

View File

@@ -4,11 +4,6 @@ const isBrowser = typeof window !== 'undefined';
class MotionPreference { class MotionPreference {
// Reactive state // Reactive state
#reduced = $state(false); #reduced = $state(false);
#mediaQuery: MediaQueryList = new MediaQueryList();
private handleChange = (e: MediaQueryListEvent) => {
this.#reduced = e.matches;
};
constructor() { constructor() {
if (isBrowser) { if (isBrowser) {
@@ -17,9 +12,12 @@ class MotionPreference {
// Set initial value immediately // Set initial value immediately
this.#reduced = mediaQuery.matches; 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() { get reduced() {
return this.#reduced; return this.#reduced;
} }
destroy() {
this.#mediaQuery.removeEventListener('change', this.handleChange);
}
} }
// Export a single instance to be used everywhere // 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> * </div>
* ``` * ```
*/ */
export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & { data: T[] }) { export function createVirtualizer<T>(
optionsGetter: () => VirtualizerOptions & {
data: T[];
},
) {
let scrollOffset = $state(0); let scrollOffset = $state(0);
let containerHeight = $state(0); let containerHeight = $state(0);
let measuredSizes = $state<Record<number, number>>({}); let measuredSizes = $state<Record<number, number>>({});
@@ -149,6 +153,7 @@ export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & {
key: options.getItemKey?.(i) ?? i, key: options.getItemKey?.(i) ?? i,
}); });
} }
return result; return result;
}); });
// Svelte Actions (The DOM Interface) // Svelte Actions (The DOM Interface)

View File

@@ -20,3 +20,9 @@ export {
} from './createVirtualizer/createVirtualizer.svelte'; } from './createVirtualizer/createVirtualizer.svelte';
export { createDebouncedState } from './createDebouncedState/createDebouncedState.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 ControlDataModel,
type ControlModel, type ControlModel,
createDebouncedState, createDebouncedState,
createEntityStore,
createFilter, createFilter,
createTypographyControl, createTypographyControl,
createVirtualizer, createVirtualizer,
type Entity,
type EntityStore,
type Filter, type Filter,
type FilterModel, type FilterModel,
type Property, type Property,
@@ -15,3 +18,4 @@ export {
} from './helpers'; } from './helpers';
export { motion } from './accessibility/motion.svelte'; 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 { debounce } from './debounce/debounce';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces'; export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision'; 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) * (follows shadcn convention for className prop)
*/ */
class?: string; 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. * Snippet for rendering individual list items.
* *
@@ -50,7 +55,8 @@ interface Props {
children: Snippet<[{ item: T; index: number }]>; 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(() => ({ const virtualizer = createVirtualizer(() => ({
count: items.length, count: items.length,
@@ -58,12 +64,17 @@ const virtualizer = createVirtualizer(() => ({
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight, estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
overscan, overscan,
})); }));
$effect(() => {
const visibleItems = virtualizer.items.map(item => items[item.index]);
onVisibleItemsChange?.(visibleItems);
});
</script> </script>
<div <div
use:virtualizer.container use:virtualizer.container
class={cn( class={cn(
'relative overflow-auto border rounded-md bg-background', 'relative overflow-auto rounded-md bg-background',
'h-150 w-full', 'h-150 w-full',
className, className,
)} )}

View File

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