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"> <script lang="ts">
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte'; import FontView from '$features/ShowFont/ui/FontView.svelte';
import { import {
Content as ItemContent, Content as ItemContent,
Root as ItemRoot, Root as ItemRoot,
Title as ItemTitle, Title as ItemTitle,
} from '$shared/shadcn/ui/item'; } from '$shared/shadcn/ui/item';
import { VirtualList } from '$shared/ui'; import { VirtualList } from '$shared/ui';
/** import { fontshareStore } from '../../model';
* FontList
* $inspect(fontshareStore.fonts);
* 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> </script>
<VirtualList items={fontshareStore.fonts} itemHeight={30}> <VirtualList items={fontshareStore.fonts}>
{#snippet children({ item: font })} {#snippet children({ item: font })}
<ItemRoot> <ItemRoot>
<ItemContent> <ItemContent>
<ItemTitle>{font.name}</ItemTitle> <!-- <ItemTitle></ItemTitle> -->
<span class="text-xs text-muted-foreground"> <span class="text-xs text-muted-foreground">
{font.category}{font.provider} {font.provider}{font.category}
</span> </span>
<FontView id={font.id} slug={font.id} name={font.name}>{font.name}</FontView>
</ItemContent> </ItemContent>
</ItemRoot> </ItemRoot>
{/snippet} {/snippet}

View File

@@ -1,3 +1,8 @@
<!--
Component: FontSearch
Combines search input with font list display
-->
<script lang="ts"> <script lang="ts">
import { import {
FontList, FontList,
@@ -8,12 +13,6 @@ import { onMount } from 'svelte';
import { mapManagerToParams } from '../../lib'; import { mapManagerToParams } from '../../lib';
import { filterManager } from '../../model'; 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(() => { onMount(() => {
/** /**
* The Pairing: * The Pairing:
@@ -24,8 +23,6 @@ onMount(() => {
return unbind; return unbind;
}); });
$inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
</script> </script>
<SearchBar <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 { return {
get properties() { get properties() {
return properties; return properties;
}, },
get selectedProperties() { get selectedProperties() {
return properties.filter(p => p.selected); return properties.filter(p => p.selected);
}, },
get selectedCount() { get selectedCount() {
return this.selectedProperties.length; return properties.filter(p => p.selected)?.length;
}, },
// 3. Methods mutate the reactive state directly
toggleProperty(id: string) { toggleProperty(id: string) {
const prop = properties.find(p => p.id === id); const property = findProp(id);
if (prop) prop.selected = !prop.selected; if (property) {
property.selected = !property.selected;
}
}, },
selectProperty(id: string) { selectProperty(id: string) {
const prop = properties.find(p => p.id === id); const property = findProp(id);
if (prop) prop.selected = true; if (property) {
property.selected = true;
}
}, },
deselectProperty(id: string) { deselectProperty(id: string) {
const prop = properties.find(p => p.id === id); const property = findProp(id);
if (prop) prop.selected = false; if (property) {
property.selected = false;
}
}, },
selectAll() { selectAll() {
properties.forEach(p => p.selected = true); properties.forEach(property => property.selected = true);
}, },
deselectAll() { deselectAll() {
properties.forEach(p => p.selected = false); properties.forEach(property => property.selected = false);
}, },
}; };
} }

View File

@@ -79,27 +79,23 @@ export interface VirtualizerOptions {
* </div> * </div>
* ``` * ```
*/ */
export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & { data: T[] }) {
// Reactive State
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>>({});
// Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking)
let elementRef: HTMLElement | null = null; let elementRef: HTMLElement | null = null;
// Reactive Options // By wrapping the getter in $derived, we track everything inside it
const options = $derived(optionsGetter()); const options = $derived(optionsGetter());
// Optimized Memoization (The Cache Layer) // This derivation now tracks: count, measuredSizes, AND the data array itself
// Only recalculates when item count or measured sizes change.
const offsets = $derived.by(() => { const offsets = $derived.by(() => {
const count = options.count; const count = options.count;
const result = Array.from<number>({ length: count }); const result = new Float64Array(count);
let accumulated = 0; let accumulated = 0;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
result[i] = accumulated; result[i] = accumulated;
// Accessing measuredSizes here creates the subscription
accumulated += measuredSizes[i] ?? options.estimateSize(i); accumulated += measuredSizes[i] ?? options.estimateSize(i);
} }
return result; return result;
@@ -112,24 +108,30 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
: 0, : 0,
); );
// Visible Range Calculation
// Svelte tracks dependencies automatically here.
const items = $derived.by((): VirtualItem[] => { const items = $derived.by((): VirtualItem[] => {
const count = options.count; // We MUST read options.data here so Svelte knows to re-run
if (count === 0 || containerHeight === 0) return []; // 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 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; let startIdx = 0;
while (startIdx < count && offsets[startIdx + 1] < viewportStart) { while (low <= high) {
startIdx++; 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; let endIdx = startIdx;
const viewportEnd = scrollOffset + containerHeight;
while (endIdx < count && offsets[endIdx] < viewportEnd) { while (endIdx < count && offsets[endIdx] < viewportEnd) {
endIdx++; endIdx++;
} }
@@ -139,18 +141,16 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
const result: VirtualItem[] = []; const result: VirtualItem[] = [];
for (let i = start; i < end; i++) { for (let i = start; i < end; i++) {
const size = measuredSizes[i] ?? options.estimateSize(i);
result.push({ result.push({
index: i, index: i,
start: offsets[i], start: offsets[i],
size, size: measuredSizes[i] ?? options.estimateSize(i),
end: offsets[i] + size, end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
key: options.getItemKey?.(i) ?? i, key: options.getItemKey?.(i) ?? i,
}); });
} }
return result; return result;
}); });
// Svelte Actions (The DOM Interface) // 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. * 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 * @returns Object with destroy method for cleanup
*/ */
function measureElement(node: HTMLElement) { function measureElement(node: HTMLElement) {
// Use a ResizeObserver on individual items for dynamic height support
const resizeObserver = new ResizeObserver(([entry]) => { const resizeObserver = new ResizeObserver(([entry]) => {
if (entry) { if (!entry) return;
const index = parseInt(node.dataset.index || '', 10); const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
// Only update if height actually changed to prevent loops if (!isNaN(index) && measuredSizes[index] !== height) {
if (!isNaN(index) && measuredSizes[index] !== height) { // 1. Stuff the measurement into a temporary buffer
measuredSizes[index] = height; 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); resizeObserver.observe(node);
return { return { destroy: () => resizeObserver.disconnect() };
destroy: () => resizeObserver.disconnect(),
};
} }
// Programmatic Scroll // Programmatic Scroll

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Filter } from '$shared/lib'; import type { Filter } from '$shared/lib';
import { motion } from '$shared/lib';
import { Badge } from '$shared/shadcn/ui/badge'; import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button'; import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox'; import { Checkbox } from '$shared/shadcn/ui/checkbox';
@@ -37,29 +38,11 @@ const { displayedLabel, filter }: PropertyFilterProps = $props();
// Toggle state - defaults to open for better discoverability // Toggle state - defaults to open for better discoverability
let isOpen = $state(true); 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 // Animation config respects user preferences - zero duration if reduced motion enabled
// Local modifier prevents animation on initial render, only animates user interactions // Local modifier prevents animation on initial render, only animates user interactions
const slideConfig = $derived({ const slideConfig = $derived({
duration: prefersReducedMotion ? 0 : 250, duration: motion.reduced ? 0 : 250,
easing: cubicOut, 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"> <script lang="ts">
import { Input } from '$shared/shadcn/ui/input'; import { Input } from '$shared/shadcn/ui/input';
import { Label } from '$shared/shadcn/ui/label'; import { Label } from '$shared/shadcn/ui/label';
@@ -7,17 +14,20 @@ import {
Trigger as PopoverTrigger, Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover'; } from '$shared/shadcn/ui/popover';
import { useId } from 'bits-ui'; import { useId } from 'bits-ui';
import { import type { Snippet } from 'svelte';
type Snippet,
tick,
} from 'svelte';
interface Props { interface Props {
/** Unique identifier for the input element */
id: string; id: string;
/** Current search value (bindable) */
value: string; value: string;
/** Additional CSS classes for the container */
class?: string; class?: string;
/** Placeholder text for the input */
placeholder?: string; placeholder?: string;
/** Optional label displayed above the input */
label?: string; label?: string;
/** Content to render inside the popover (receives unique content ID) */
children: Snippet<[{ id: string }]> | undefined; children: Snippet<[{ id: string }]> | undefined;
} }
@@ -35,13 +45,6 @@ let triggerRef = $state<HTMLInputElement>(null!);
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
const contentId = useId(id); const contentId = useId(id);
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef?.focus();
});
}
function handleKeyDown(event: KeyboardEvent) { function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') { if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
@@ -50,16 +53,14 @@ function handleKeyDown(event: KeyboardEvent) {
function handleInputClick() { function handleInputClick() {
open = true; open = true;
tick().then(() => {
triggerRef?.focus();
});
} }
</script> </script>
<PopoverRoot> <PopoverRoot bind:open>
<PopoverTrigger bind:ref={triggerRef}> <PopoverTrigger bind:ref={triggerRef}>
{#snippet child({ props })} {#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} {#if label}
<Label for={id}>{label}</Label> <Label for={id}>{label}</Label>
{/if} {/if}
@@ -68,6 +69,7 @@ function handleInputClick() {
placeholder={placeholder} placeholder={placeholder}
bind:value={value} bind:value={value}
onkeydown={handleKeyDown} onkeydown={handleKeyDown}
onclick={handleInputClick}
class="flex flex-row flex-1" class="flex flex-row flex-1"
/> />
</div> </div>
@@ -76,7 +78,12 @@ function handleInputClick() {
<PopoverContent <PopoverContent
onOpenAutoFocus={e => e.preventDefault()} 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 })} {@render children?.({ id: contentId })}
</PopoverContent> </PopoverContent>

View File

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

View File

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