Compare commits

...

10 Commits

11 changed files with 198 additions and 113 deletions

View File

@@ -1,27 +1,31 @@
<!--
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 { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
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';
/**
* 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.
*/
import { fontshareStore } from '../../model';
$inspect(fontshareStore.fonts);
</script>
<VirtualList items={fontshareStore.fonts} itemHeight={30}>
<VirtualList items={fontshareStore.fonts}>
{#snippet children({ item: font })}
<ItemRoot>
<ItemContent>
<ItemTitle>{font.name}</ItemTitle>
<!-- <ItemTitle></ItemTitle> -->
<span class="text-xs text-muted-foreground">
{font.category}{font.provider}
{font.provider}{font.category}
</span>
<FontView id={font.id} slug={font.id} name={font.name}>{font.name}</FontView>
</ItemContent>
</ItemRoot>
{/snippet}

View File

@@ -1,3 +1,8 @@
<!--
Component: FontSearch
Combines search input with font list display
-->
<script lang="ts">
import {
FontList,
@@ -8,12 +13,6 @@ import { onMount } from 'svelte';
import { mapManagerToParams } from '../../lib';
import { filterManager } from '../../model';
/**
* FontSearch
*
* Font search component with search input and font list display.
* Uses unifiedFontStore for all font operations and search state.
*/
onMount(() => {
/**
* The Pairing:
@@ -24,8 +23,6 @@ onMount(() => {
return unbind;
});
$inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
</script>
<SearchBar

View File

@@ -0,0 +1,40 @@
<!--
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

@@ -0,0 +1,37 @@
// Check if we are in a browser environment
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) {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// Set initial value immediately
this.#reduced = mediaQuery.matches;
mediaQuery.addEventListener('change', this.handleChange);
this.#mediaQuery = mediaQuery;
}
}
// Getter allows us to use 'motion.reduced' reactively in components
get reduced() {
return this.#reduced;
}
destroy() {
this.#mediaQuery.removeEventListener('change', this.handleChange);
}
}
// Export a single instance to be used everywhere
export const motion = new MotionPreference();

View File

@@ -37,41 +37,43 @@ export function createFilter<TValue extends string>(initialState: FilterModel<TV
})),
);
// Helper to find a property by ID
const findProp = (id: string) => properties.find(p => p.id === id);
return {
get properties() {
return properties;
},
get selectedProperties() {
return properties.filter(p => p.selected);
},
get selectedCount() {
return this.selectedProperties.length;
return properties.filter(p => p.selected)?.length;
},
// 3. Methods mutate the reactive state directly
toggleProperty(id: string) {
const prop = properties.find(p => p.id === id);
if (prop) prop.selected = !prop.selected;
const property = findProp(id);
if (property) {
property.selected = !property.selected;
}
},
selectProperty(id: string) {
const prop = properties.find(p => p.id === id);
if (prop) prop.selected = true;
const property = findProp(id);
if (property) {
property.selected = true;
}
},
deselectProperty(id: string) {
const prop = properties.find(p => p.id === id);
if (prop) prop.selected = false;
const property = findProp(id);
if (property) {
property.selected = false;
}
},
selectAll() {
properties.forEach(p => p.selected = true);
properties.forEach(property => property.selected = true);
},
deselectAll() {
properties.forEach(p => p.selected = false);
properties.forEach(property => property.selected = false);
},
};
}

View File

@@ -79,27 +79,23 @@ export interface VirtualizerOptions {
* </div>
* ```
*/
export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
// Reactive State
export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & { data: T[] }) {
let scrollOffset = $state(0);
let containerHeight = $state(0);
let measuredSizes = $state<Record<number, number>>({});
// Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking)
let elementRef: HTMLElement | null = null;
// Reactive Options
// By wrapping the getter in $derived, we track everything inside it
const options = $derived(optionsGetter());
// Optimized Memoization (The Cache Layer)
// Only recalculates when item count or measured sizes change.
// This derivation now tracks: count, measuredSizes, AND the data array itself
const offsets = $derived.by(() => {
const count = options.count;
const result = Array.from<number>({ length: count });
const result = new Float64Array(count);
let accumulated = 0;
for (let i = 0; i < count; i++) {
result[i] = accumulated;
// Accessing measuredSizes here creates the subscription
accumulated += measuredSizes[i] ?? options.estimateSize(i);
}
return result;
@@ -112,24 +108,30 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
: 0,
);
// Visible Range Calculation
// Svelte tracks dependencies automatically here.
const items = $derived.by((): VirtualItem[] => {
const count = options.count;
if (count === 0 || containerHeight === 0) return [];
// We MUST read options.data here so Svelte knows to re-run
// this derivation when the items array is replaced!
const { count, data } = options;
if (count === 0 || containerHeight === 0 || !data) return [];
const overscan = options.overscan ?? 5;
const viewportStart = scrollOffset;
const viewportEnd = scrollOffset + containerHeight;
// Find Start (Linear Scan)
// Binary search for efficiency
let low = 0;
let high = count - 1;
let startIdx = 0;
while (startIdx < count && offsets[startIdx + 1] < viewportStart) {
startIdx++;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (offsets[mid] <= scrollOffset) {
startIdx = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
// Find End
let endIdx = startIdx;
const viewportEnd = scrollOffset + containerHeight;
while (endIdx < count && offsets[endIdx] < viewportEnd) {
endIdx++;
}
@@ -139,18 +141,16 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
const result: VirtualItem[] = [];
for (let i = start; i < end; i++) {
const size = measuredSizes[i] ?? options.estimateSize(i);
result.push({
index: i,
start: offsets[i],
size,
end: offsets[i] + size,
size: measuredSizes[i] ?? options.estimateSize(i),
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
key: options.getItemKey?.(i) ?? i,
});
}
return result;
});
// Svelte Actions (The DOM Interface)
/**
@@ -185,6 +185,8 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
};
}
let measurementBuffer: Record<number, number> = {};
let frameId: number | null = null;
/**
* Svelte action to measure individual item elements for dynamic height support.
*
@@ -195,23 +197,32 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
* @returns Object with destroy method for cleanup
*/
function measureElement(node: HTMLElement) {
// Use a ResizeObserver on individual items for dynamic height support
const resizeObserver = new ResizeObserver(([entry]) => {
if (entry) {
const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
if (!entry) return;
const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
// Only update if height actually changed to prevent loops
if (!isNaN(index) && measuredSizes[index] !== height) {
measuredSizes[index] = height;
if (!isNaN(index) && measuredSizes[index] !== height) {
// 1. Stuff the measurement into a temporary buffer
measurementBuffer[index] = height;
// 2. Schedule a single update for the next animation frame
if (frameId === null) {
frameId = requestAnimationFrame(() => {
// 3. Update the state once for all collected measurements
// We use spread to trigger a single fine-grained update
measuredSizes = { ...measuredSizes, ...measurementBuffer };
// 4. Reset the buffer
measurementBuffer = {};
frameId = null;
});
}
}
});
resizeObserver.observe(node);
return {
destroy: () => resizeObserver.disconnect(),
};
return { destroy: () => resizeObserver.disconnect() };
}
// Programmatic Scroll

View File

@@ -13,3 +13,5 @@ export {
type Virtualizer,
type VirtualizerOptions,
} from './helpers';
export { motion } from './accessibility/motion.svelte';

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { Filter } from '$shared/lib';
import { motion } from '$shared/lib';
import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox';
@@ -37,29 +38,11 @@ const { displayedLabel, filter }: PropertyFilterProps = $props();
// Toggle state - defaults to open for better discoverability
let isOpen = $state(true);
// Accessibility preference to disable animations
let prefersReducedMotion = $state(false);
// Check reduced motion preference on mount (window access required)
// Event listener allows responding to system preference changes
onMount(() => {
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
prefersReducedMotion = mediaQuery.matches;
const handleChange = (e: MediaQueryListEvent) => {
prefersReducedMotion = e.matches;
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}
});
// Animation config respects user preferences - zero duration if reduced motion enabled
// Local modifier prevents animation on initial render, only animates user interactions
const slideConfig = $derived({
duration: prefersReducedMotion ? 0 : 250,
duration: motion.reduced ? 0 : 250,
easing: cubicOut,
});

View File

@@ -1,3 +1,10 @@
<!--
Component: SearchBar
Search input with popover dropdown for results/suggestions
- Features keyboard navigation (ArrowDown/Up/Enter) and auto-focus prevention on popover open.
- The input field serves as the popover trigger.
-->
<script lang="ts">
import { Input } from '$shared/shadcn/ui/input';
import { Label } from '$shared/shadcn/ui/label';
@@ -7,17 +14,20 @@ import {
Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover';
import { useId } from 'bits-ui';
import {
type Snippet,
tick,
} from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/** Unique identifier for the input element */
id: string;
/** Current search value (bindable) */
value: string;
/** Additional CSS classes for the container */
class?: string;
/** Placeholder text for the input */
placeholder?: string;
/** Optional label displayed above the input */
label?: string;
/** Content to render inside the popover (receives unique content ID) */
children: Snippet<[{ id: string }]> | undefined;
}
@@ -35,13 +45,6 @@ let triggerRef = $state<HTMLInputElement>(null!);
// svelte-ignore state_referenced_locally
const contentId = useId(id);
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef?.focus();
});
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
event.preventDefault();
@@ -50,16 +53,14 @@ function handleKeyDown(event: KeyboardEvent) {
function handleInputClick() {
open = true;
tick().then(() => {
triggerRef?.focus();
});
}
</script>
<PopoverRoot>
<PopoverRoot bind:open>
<PopoverTrigger bind:ref={triggerRef}>
{#snippet child({ props })}
<div {...props} class="flex flex-row flex-1 w-full">
{@const { onclick, ...rest } = props}
<div {...rest} class="flex flex-row flex-1 w-full">
{#if label}
<Label for={id}>{label}</Label>
{/if}
@@ -68,6 +69,7 @@ function handleInputClick() {
placeholder={placeholder}
bind:value={value}
onkeydown={handleKeyDown}
onclick={handleInputClick}
class="flex flex-row flex-1"
/>
</div>
@@ -76,7 +78,12 @@ function handleInputClick() {
<PopoverContent
onOpenAutoFocus={e => e.preventDefault()}
class="w-max"
onInteractOutside={(e => {
if (e.target === triggerRef) {
e.preventDefault();
}
})}
class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width)"
>
{@render children?.({ id: contentId })}
</PopoverContent>

View File

@@ -10,10 +10,7 @@
<script lang="ts" generics="T">
import { createVirtualizer } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
type Snippet,
tick,
} from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/**
@@ -57,6 +54,7 @@ let { items, itemHeight = 80, overscan = 5, class: className, children }: Props
const virtualizer = createVirtualizer(() => ({
count: items.length,
data: items,
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
overscan,
}));
@@ -66,19 +64,22 @@ const virtualizer = createVirtualizer(() => ({
use:virtualizer.container
class={cn(
'relative overflow-auto border rounded-md bg-background',
'outline-none focus-visible:ring-2 ring-ring ring-offset-2',
'h-full w-full',
'h-150 w-full',
className,
)}
role="listbox"
tabindex="0"
>
<div
style:height="{virtualizer.totalSize}px"
class="w-full pointer-events-none"
>
</div>
{#each virtualizer.items as item (item.key)}
<div
use:virtualizer.measureElement
data-index={item.index}
class="absolute top-0 left-0 w-full translate-y-[var(--offset)] will-change-transform"
style:--offset="{item.start}px"
class="absolute top-0 left-0 w-full"
style:transform="translateY({item.start}px)"
>
{@render children({ item: items[item.index], index: item.index })}
</div>

View File

@@ -53,6 +53,7 @@ export default defineConfig({
},
resolve: {
conditions: process.env.VITEST ? ['browser'] : undefined,
alias: {
$lib: path.resolve(__dirname, './src/lib'),
$app: path.resolve(__dirname, './src/app'),