Compare commits
10 Commits
14f9b87680
...
32da012b26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32da012b26 | ||
|
|
71d320535e | ||
|
|
71c068bad2 | ||
|
|
247b683c87 | ||
|
|
8c0c91deb7 | ||
|
|
261c19db69 | ||
|
|
a85b3cf217 | ||
|
|
f02b19eff5 | ||
|
|
4dbf91f600 | ||
|
|
0daf0bf3bf |
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
40
src/features/ShowFont/ui/FontView.svelte
Normal file
40
src/features/ShowFont/ui/FontView.svelte
Normal 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>
|
||||
37
src/shared/lib/accessibility/motion.svelte.ts
Normal file
37
src/shared/lib/accessibility/motion.svelte.ts
Normal 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();
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,3 +13,5 @@ export {
|
||||
type Virtualizer,
|
||||
type VirtualizerOptions,
|
||||
} from './helpers';
|
||||
|
||||
export { motion } from './accessibility/motion.svelte';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user