feature/comparison-slider #19
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ vite.config.ts.timestamp-*
|
||||
|
||||
/docs
|
||||
AGENTS.md
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
||||
],
|
||||
"typescript": {
|
||||
"lineWidth": 100,
|
||||
"lineWidth": 120,
|
||||
"indentWidth": 4,
|
||||
"useTabs": false,
|
||||
"semiColons": "prefer",
|
||||
@@ -41,7 +41,7 @@
|
||||
"lineWidth": 100
|
||||
},
|
||||
"markup": {
|
||||
"printWidth": 100,
|
||||
"printWidth": 120,
|
||||
"indentWidth": 4,
|
||||
"useTabs": false,
|
||||
"quotes": "double",
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Karla', system-ui, sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,3 +140,25 @@
|
||||
.peer:focus-visible ~ * {
|
||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes nudge {
|
||||
0%, 100% {
|
||||
transform: translateY(0) scale(1) rotate(0deg);
|
||||
}
|
||||
2% {
|
||||
transform: translateY(-2px) scale(1.1) rotate(-1deg);
|
||||
}
|
||||
4% {
|
||||
transform: translateY(0) scale(1) rotate(1deg);
|
||||
}
|
||||
6% {
|
||||
transform: translateY(-2px) scale(1.1) rotate(0deg);
|
||||
}
|
||||
8% {
|
||||
transform: translateY(0) scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-nudge {
|
||||
animation: nudge 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -3,47 +3,50 @@
|
||||
* Layout Component
|
||||
*
|
||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
||||
* sidebar provider initialization, and renders child routes with consistent structure.
|
||||
* toolbar provider initialization, and renders child routes with consistent structure.
|
||||
*
|
||||
* Layout structure:
|
||||
* - Header area (currently empty, reserved for future use)
|
||||
* - Collapsible sidebar with main content area
|
||||
* - Footer area (currently empty, reserved for future use)
|
||||
*
|
||||
* Uses Sidebar.Provider to enable mobile-responsive collapsible sidebar behavior
|
||||
* throughout the application.
|
||||
* - Footer area (currently empty, reserved for future use)
|
||||
*/
|
||||
import favicon from '$shared/assets/favicon.svg';
|
||||
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
|
||||
import { FiltersSidebar } from '$widgets/FiltersSidebar';
|
||||
import TypographyMenu from '$widgets/TypographySettings/ui/TypographyMenu.svelte';
|
||||
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||
import { TypographyMenu } from '$widgets/TypographySettings';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
/** Slot content for route pages to render */
|
||||
let { children } = $props();
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Karla:ital,wght@0,200..800;1,200..800&display=swap"
|
||||
rel="stylesheet"
|
||||
>
|
||||
</svelte:head>
|
||||
|
||||
<div id="app-root">
|
||||
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
||||
<header></header>
|
||||
|
||||
<Sidebar.Provider>
|
||||
<FiltersSidebar />
|
||||
<main class="w-dvw">
|
||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||
<main class="flex-1 h-full w-full max-w-6xl mx-auto px-4 pt-6 pb-10 md:px-8 lg:pt-10 lg:pb-20 relative">
|
||||
<TooltipProvider>
|
||||
<TypographyMenu />
|
||||
{@render children?.()}
|
||||
</TooltipProvider>
|
||||
</main>
|
||||
</Sidebar.Provider>
|
||||
<!-- </ScrollArea> -->
|
||||
<footer></footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#app-root {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
2
src/entities/Breadcrumb/index.ts
Normal file
2
src/entities/Breadcrumb/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { scrollBreadcrumbsStore } from './model';
|
||||
export { BreadcrumbHeader } from './ui';
|
||||
1
src/entities/Breadcrumb/model/index.ts
Normal file
1
src/entities/Breadcrumb/model/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './store/scrollBreadcrumbsStore.svelte';
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
index: number;
|
||||
title: Snippet<[{ className?: string }]>;
|
||||
}
|
||||
|
||||
class ScrollBreadcrumbsStore {
|
||||
#items = $state<BreadcrumbItem[]>([]);
|
||||
|
||||
get items() {
|
||||
// Keep them sorted by index for Swiss orderliness
|
||||
return this.#items.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
add(item: BreadcrumbItem) {
|
||||
if (!this.#items.find(i => i.index === item.index)) {
|
||||
this.#items.push(item);
|
||||
}
|
||||
}
|
||||
remove(index: number) {
|
||||
this.#items = this.#items.filter(i => i.index !== index);
|
||||
}
|
||||
}
|
||||
|
||||
export function createScrollBreadcrumbsStore() {
|
||||
return new ScrollBreadcrumbsStore();
|
||||
}
|
||||
|
||||
export const scrollBreadcrumbsStore = createScrollBreadcrumbsStore();
|
||||
@@ -0,0 +1,78 @@
|
||||
<!--
|
||||
Component: BreadcrumbHeader
|
||||
Fixed header for breadcrumbs navigation for sections in the page
|
||||
-->
|
||||
<script lang="ts">
|
||||
import Icon from '@lucide/svelte/icons/align-vertical-justify-center';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { scrollBreadcrumbsStore } from '../../model';
|
||||
</script>
|
||||
|
||||
{#if scrollBreadcrumbsStore.items.length > 0}
|
||||
<div
|
||||
transition:slide={{ duration: 200 }}
|
||||
class="
|
||||
fixed top-0 left-0 right-0 z-100
|
||||
backdrop-blur-lg bg-white/20
|
||||
border-b border-gray-300/50
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
h-12
|
||||
"
|
||||
>
|
||||
<div class="max-w-8xl mx-auto px-6 h-full flex items-center gap-4">
|
||||
<div class="flex items-center gap-2.5 opacity-70">
|
||||
<Icon class="size-4 stroke-gray-900 stroke-1" />
|
||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
||||
<span class="font-mono text-[9px] uppercase tracking-[0.25em] text-gray-500 font-medium">
|
||||
nav_trace
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-4 w-px bg-gray-300/60"></div>
|
||||
|
||||
<nav class="flex items-center gap-3 overflow-x-auto scrollbar-hide flex-1">
|
||||
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
|
||||
<div
|
||||
animate:flip={{ duration: 200 }}
|
||||
class="flex items-center gap-3 whitespace-nowrap shrink-0"
|
||||
>
|
||||
<span class="font-mono text-[9px] text-gray-400 tracking-wider">
|
||||
{String(item.index).padStart(2, '0')}
|
||||
</span>
|
||||
|
||||
{@render item.title({
|
||||
className: 'font-mono text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-gray-900',
|
||||
})}
|
||||
|
||||
{#if idx < scrollBreadcrumbsStore.items.length - 1}
|
||||
<div class="flex items-center gap-0.5 opacity-40">
|
||||
<div class="w-1 h-px bg-gray-400"></div>
|
||||
<div class="w-1 h-px bg-gray-400"></div>
|
||||
<div class="w-1 h-px bg-gray-400"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-2 opacity-50 ml-auto">
|
||||
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
||||
<span class="font-mono text-[8px] text-gray-400 tracking-wider">
|
||||
[{scrollBreadcrumbsStore.items.length}]
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
3
src/entities/Breadcrumb/ui/index.ts
Normal file
3
src/entities/Breadcrumb/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import BreadcrumbHeader from './BreadcrumbHeader/BreadcrumbHeader.svelte';
|
||||
|
||||
export { BreadcrumbHeader };
|
||||
@@ -4,6 +4,18 @@
|
||||
* Exports API clients and normalization utilities
|
||||
*/
|
||||
|
||||
// Proxy API (PRIMARY - NEW)
|
||||
export {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
fetchProxyFonts,
|
||||
} from './proxy/proxyFonts';
|
||||
export type {
|
||||
ProxyFontsParams,
|
||||
ProxyFontsResponse,
|
||||
} from './proxy/proxyFonts';
|
||||
|
||||
// Google Fonts API (DEPRECATED - kept for backward compatibility)
|
||||
export {
|
||||
fetchGoogleFontFamily,
|
||||
fetchGoogleFonts,
|
||||
@@ -14,6 +26,7 @@ export type {
|
||||
GoogleFontsResponse,
|
||||
} from './google/googleFonts';
|
||||
|
||||
// Fontshare API (DEPRECATED - kept for backward compatibility)
|
||||
export {
|
||||
fetchAllFontshareFonts,
|
||||
fetchFontshareFontBySlug,
|
||||
|
||||
279
src/entities/Font/api/proxy/proxyFonts.ts
Normal file
279
src/entities/Font/api/proxy/proxyFonts.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Proxy API client
|
||||
*
|
||||
* Handles API requests to GlyphDiff proxy API for fetching font metadata.
|
||||
* Provides error handling, pagination support, and type-safe responses.
|
||||
*
|
||||
* Proxy API normalizes font data from Google Fonts and Fontshare into a single
|
||||
* unified format, eliminating the need for client-side normalization.
|
||||
*
|
||||
* Fallback: If proxy API fails, falls back to Fontshare API for development.
|
||||
*
|
||||
* @see https://api.glyphdiff.com/api/v1/fonts
|
||||
*/
|
||||
|
||||
import { api } from '$shared/api/api';
|
||||
import { buildQueryString } from '$shared/lib/utils';
|
||||
import type { QueryParams } from '$shared/lib/utils';
|
||||
import type { UnifiedFont } from '../../model/types';
|
||||
import type {
|
||||
FontCategory,
|
||||
FontSubset,
|
||||
} from '../../model/types';
|
||||
|
||||
/**
|
||||
* Proxy API base URL
|
||||
*/
|
||||
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts' as const;
|
||||
|
||||
/**
|
||||
* Whether to use proxy API (true) or fallback (false)
|
||||
*
|
||||
* Set to true when your proxy API is ready:
|
||||
* const USE_PROXY_API = true;
|
||||
*
|
||||
* Set to false to use Fontshare API as fallback during development:
|
||||
* const USE_PROXY_API = false;
|
||||
*
|
||||
* The app will automatically fall back to Fontshare API if the proxy fails.
|
||||
*/
|
||||
const USE_PROXY_API = true;
|
||||
|
||||
/**
|
||||
* Proxy API parameters
|
||||
*
|
||||
* Maps directly to the proxy API query parameters
|
||||
*/
|
||||
export interface ProxyFontsParams extends QueryParams {
|
||||
/**
|
||||
* Font provider filter ("google" or "fontshare")
|
||||
* Omit to fetch from both providers
|
||||
*/
|
||||
provider?: 'google' | 'fontshare';
|
||||
|
||||
/**
|
||||
* Font category filter
|
||||
*/
|
||||
category?: FontCategory;
|
||||
|
||||
/**
|
||||
* Character subset filter
|
||||
*/
|
||||
subset?: FontSubset;
|
||||
|
||||
/**
|
||||
* Search query (e.g., "roboto", "satoshi")
|
||||
*/
|
||||
q?: string;
|
||||
|
||||
/**
|
||||
* Sort order for results
|
||||
* "name" - Alphabetical by font name
|
||||
* "popularity" - Most popular first
|
||||
* "lastModified" - Recently updated first
|
||||
*/
|
||||
sort?: 'name' | 'popularity' | 'lastModified';
|
||||
|
||||
/**
|
||||
* Number of items to return (pagination)
|
||||
*/
|
||||
limit?: number;
|
||||
|
||||
/**
|
||||
* Number of items to skip (pagination)
|
||||
* Use for pagination: offset = (page - 1) * limit
|
||||
*/
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy API response
|
||||
*
|
||||
* Includes pagination metadata alongside font data
|
||||
*/
|
||||
export interface ProxyFontsResponse {
|
||||
/** Array of unified font objects */
|
||||
fonts: UnifiedFont[];
|
||||
|
||||
/** Total number of fonts matching the query */
|
||||
total: number;
|
||||
|
||||
/** Limit used for this request */
|
||||
limit: number;
|
||||
|
||||
/** Offset used for this request */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fonts from proxy API
|
||||
*
|
||||
* If proxy API fails or is unavailable, falls back to Fontshare API for development.
|
||||
*
|
||||
* @param params - Query parameters for filtering and pagination
|
||||
* @returns Promise resolving to proxy API response
|
||||
* @throws ApiError when request fails
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Fetch all sans-serif fonts from Google
|
||||
* const response = await fetchProxyFonts({
|
||||
* provider: 'google',
|
||||
* category: 'sans-serif',
|
||||
* limit: 50,
|
||||
* offset: 0
|
||||
* });
|
||||
*
|
||||
* // Search fonts across all providers
|
||||
* const searchResponse = await fetchProxyFonts({
|
||||
* q: 'roboto',
|
||||
* limit: 20
|
||||
* });
|
||||
*
|
||||
* // Fetch fonts with pagination
|
||||
* const page1 = await fetchProxyFonts({ limit: 50, offset: 0 });
|
||||
* const page2 = await fetchProxyFonts({ limit: 50, offset: 50 });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchProxyFonts(
|
||||
params: ProxyFontsParams = {},
|
||||
): Promise<ProxyFontsResponse> {
|
||||
// Try proxy API first if enabled
|
||||
if (USE_PROXY_API) {
|
||||
try {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${PROXY_API_URL}${queryString}`;
|
||||
|
||||
console.log('[fetchProxyFonts] Fetching from proxy API', { params, url });
|
||||
|
||||
const response = await api.get<ProxyFontsResponse>(url);
|
||||
|
||||
// Validate response has fonts array
|
||||
if (!response.data || !Array.isArray(response.data.fonts)) {
|
||||
console.error('[fetchProxyFonts] Invalid response from proxy API', response.data);
|
||||
throw new Error('Proxy API returned invalid response');
|
||||
}
|
||||
|
||||
console.log('[fetchProxyFonts] Proxy API success', {
|
||||
count: response.data.fonts.length,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.warn('[fetchProxyFonts] Proxy API failed, using fallback', error);
|
||||
|
||||
// Check if it's a network error or proxy not available
|
||||
const isNetworkError = error instanceof Error
|
||||
&& (error.message.includes('Failed to fetch')
|
||||
|| error.message.includes('Network')
|
||||
|| error.message.includes('404')
|
||||
|| error.message.includes('500'));
|
||||
|
||||
if (isNetworkError) {
|
||||
// Fall back to Fontshare API
|
||||
console.log('[fetchProxyFonts] Using Fontshare API as fallback');
|
||||
return await fetchFontshareFallback(params);
|
||||
}
|
||||
|
||||
// Re-throw other errors
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Failed to fetch fonts from proxy API: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Use Fontshare API directly
|
||||
console.log('[fetchProxyFonts] Using Fontshare API (proxy disabled)');
|
||||
return await fetchFontshareFallback(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback to Fontshare API when proxy is unavailable
|
||||
*
|
||||
* Maps proxy API params to Fontshare API params and normalizes response
|
||||
*/
|
||||
async function fetchFontshareFallback(
|
||||
params: ProxyFontsParams,
|
||||
): Promise<ProxyFontsResponse> {
|
||||
// Import dynamically to avoid circular dependency
|
||||
const { fetchFontshareFonts } = await import('$entities/Font/api/fontshare/fontshare');
|
||||
const { normalizeFontshareFonts } = await import('$entities/Font/lib/normalize/normalize');
|
||||
|
||||
// Map proxy params to Fontshare params
|
||||
const fontshareParams = {
|
||||
q: params.q,
|
||||
categories: params.category ? [params.category] : undefined,
|
||||
page: params.offset ? Math.floor(params.offset / (params.limit || 50)) + 1 : undefined,
|
||||
limit: params.limit,
|
||||
};
|
||||
|
||||
const response = await fetchFontshareFonts(fontshareParams);
|
||||
const normalizedFonts = normalizeFontshareFonts(response.fonts);
|
||||
|
||||
return {
|
||||
fonts: normalizedFonts,
|
||||
total: response.count_total,
|
||||
limit: params.limit || response.count,
|
||||
offset: params.offset || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch font by ID
|
||||
*
|
||||
* Convenience function for fetching a single font by ID
|
||||
* Note: This fetches a page and filters client-side, which is not ideal
|
||||
* For production, consider adding a dedicated endpoint to the proxy API
|
||||
*
|
||||
* @param id - Font ID (family name for Google, slug for Fontshare)
|
||||
* @returns Promise resolving to font or undefined
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const roboto = await fetchProxyFontById('Roboto');
|
||||
* const satoshi = await fetchProxyFontById('satoshi');
|
||||
* ```
|
||||
*/
|
||||
export async function fetchProxyFontById(
|
||||
id: string,
|
||||
): Promise<UnifiedFont | undefined> {
|
||||
const response = await fetchProxyFonts({ limit: 1000, q: id });
|
||||
|
||||
if (!response || !response.fonts) {
|
||||
console.error('[fetchProxyFontById] No fonts in response', { response });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return response.fonts.find(font => font.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch multiple fonts by their IDs
|
||||
*
|
||||
* @param ids - Array of font IDs to fetch
|
||||
* @returns Promise resolving to an array of fonts
|
||||
*/
|
||||
export async function fetchFontsByIds(ids: string[]): Promise<UnifiedFont[]> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
// Use proxy API if enabled
|
||||
if (USE_PROXY_API) {
|
||||
const queryString = ids.join(',');
|
||||
const url = `${PROXY_API_URL}/batch?ids=${queryString}`;
|
||||
|
||||
try {
|
||||
const response = await api.get<UnifiedFont[]>(url);
|
||||
return response.data ?? [];
|
||||
} catch (error) {
|
||||
console.warn('[fetchFontsByIds] Proxy API batch fetch failed, falling back', error);
|
||||
// Fallthrough to fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Fetch individually (not efficient but functional for fallback)
|
||||
const results = await Promise.all(
|
||||
ids.map(id => fetchProxyFontById(id)),
|
||||
);
|
||||
|
||||
return results.filter((f): f is UnifiedFont => !!f);
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
// Proxy API (PRIMARY)
|
||||
export {
|
||||
fetchFontsByIds,
|
||||
fetchProxyFontById,
|
||||
fetchProxyFonts,
|
||||
} from './api/proxy/proxyFonts';
|
||||
export type {
|
||||
ProxyFontsParams,
|
||||
ProxyFontsResponse,
|
||||
} from './api/proxy/proxyFonts';
|
||||
|
||||
// Fontshare API (DEPRECATED)
|
||||
export {
|
||||
fetchAllFontshareFonts,
|
||||
fetchFontshareFontBySlug,
|
||||
@@ -7,6 +19,8 @@ export type {
|
||||
FontshareParams,
|
||||
FontshareResponse,
|
||||
} from './api/fontshare/fontshare';
|
||||
|
||||
// Google Fonts API (DEPRECATED)
|
||||
export {
|
||||
fetchGoogleFontFamily,
|
||||
fetchGoogleFonts,
|
||||
@@ -42,7 +56,6 @@ export type {
|
||||
FontshareFont,
|
||||
FontshareLink,
|
||||
FontsharePublisher,
|
||||
FontshareStore,
|
||||
FontshareStyle,
|
||||
FontshareStyleProperties,
|
||||
FontshareTag,
|
||||
@@ -61,18 +74,11 @@ export type {
|
||||
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createFontshareStore,
|
||||
fetchFontshareFontsQuery,
|
||||
fontshareStore,
|
||||
createUnifiedFontStore,
|
||||
selectedFontsStore,
|
||||
unifiedFontStore,
|
||||
} from './model';
|
||||
|
||||
// Stores
|
||||
export {
|
||||
createGoogleFontsStore,
|
||||
GoogleFontsStore,
|
||||
} from './model/services/fetchGoogleFonts.svelte';
|
||||
|
||||
// UI elements
|
||||
export {
|
||||
FontApplicator,
|
||||
|
||||
@@ -24,11 +24,9 @@ describe('Font Normalization', () => {
|
||||
subsets: ['latin', 'latin-ext'],
|
||||
files: {
|
||||
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||
'700':
|
||||
'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
||||
'700italic':
|
||||
'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
||||
'700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
||||
},
|
||||
version: 'v30',
|
||||
lastModified: '2022-01-01',
|
||||
|
||||
@@ -34,12 +34,10 @@ export type {
|
||||
UnifiedFontVariant,
|
||||
} from './types';
|
||||
|
||||
export { fetchFontshareFontsQuery } from './services';
|
||||
|
||||
export {
|
||||
appliedFontsManager,
|
||||
createFontshareStore,
|
||||
type FontshareStore,
|
||||
fontshareStore,
|
||||
createUnifiedFontStore,
|
||||
selectedFontsStore,
|
||||
type UnifiedFontStore,
|
||||
unifiedFontStore,
|
||||
} from './store';
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import {
|
||||
type FontshareParams,
|
||||
fetchFontshareFonts,
|
||||
} from '../../api';
|
||||
import { normalizeFontshareFonts } from '../../lib';
|
||||
import type { UnifiedFont } from '../types';
|
||||
|
||||
/**
|
||||
* Query function for fetching fonts from Fontshare.
|
||||
*
|
||||
* @param params - The parameters for fetching fonts from Fontshare (E.g. search query, page number, etc.).
|
||||
* @returns A promise that resolves with an array of UnifiedFont objects representing the fonts found in Fontshare.
|
||||
*/
|
||||
export async function fetchFontshareFontsQuery(params: FontshareParams): Promise<UnifiedFont[]> {
|
||||
try {
|
||||
const response = await fetchFontshareFonts(params);
|
||||
return normalizeFontshareFonts(response.fonts);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Failed to fetch')) {
|
||||
throw new Error(
|
||||
'Unable to connect to Fontshare. Please check your internet connection.',
|
||||
);
|
||||
}
|
||||
if (error.message.includes('404')) {
|
||||
throw new Error('Font not found in Fontshare catalog.');
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to load fonts from Fontshare.');
|
||||
}
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
/**
|
||||
* Service for fetching Google Fonts with Svelte 5 runes + TanStack Query
|
||||
*/
|
||||
import {
|
||||
type CreateQueryResult,
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from '@tanstack/svelte-query';
|
||||
import {
|
||||
type GoogleFontsParams,
|
||||
fetchGoogleFonts,
|
||||
} from '../../api';
|
||||
import { normalizeGoogleFonts } from '../../lib';
|
||||
import type {
|
||||
FontCategory,
|
||||
FontSubset,
|
||||
} from '../types';
|
||||
import type { UnifiedFont } from '../types/normalize';
|
||||
|
||||
/**
|
||||
* Query key factory
|
||||
*/
|
||||
function getGoogleFontsQueryKey(params: GoogleFontsParams) {
|
||||
return ['googleFonts', params] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query function
|
||||
*/
|
||||
export async function fetchGoogleFontsQuery(params: GoogleFontsParams): Promise<UnifiedFont[]> {
|
||||
try {
|
||||
const response = await fetchGoogleFonts({
|
||||
category: params.category,
|
||||
subset: params.subset,
|
||||
sort: params.sort,
|
||||
});
|
||||
return normalizeGoogleFonts(response.items);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Failed to fetch')) {
|
||||
throw new Error(
|
||||
'Unable to connect to Google Fonts. Please check your internet connection.',
|
||||
);
|
||||
}
|
||||
if (error.message.includes('404')) {
|
||||
throw new Error('Font not found in Google Fonts catalog.');
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to load fonts from Google Fonts.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Fonts store wrapping TanStack Query with runes
|
||||
*/
|
||||
export class GoogleFontsStore {
|
||||
params = $state<GoogleFontsParams>({});
|
||||
private query: CreateQueryResult<UnifiedFont[], Error>;
|
||||
private queryClient = useQueryClient();
|
||||
|
||||
constructor(initialParams: GoogleFontsParams = {}) {
|
||||
this.params = initialParams;
|
||||
|
||||
// Create the query - automatically reactive
|
||||
this.query = createQuery(() => ({
|
||||
queryKey: getGoogleFontsQueryKey(this.params),
|
||||
queryFn: () => fetchGoogleFontsQuery(this.params),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
}));
|
||||
}
|
||||
|
||||
// Proxy TanStack Query's reactive state
|
||||
get fonts() {
|
||||
return this.query.data ?? [];
|
||||
}
|
||||
|
||||
get isLoading() {
|
||||
return this.query.isLoading;
|
||||
}
|
||||
|
||||
get isFetching() {
|
||||
return this.query.isFetching;
|
||||
}
|
||||
|
||||
get isRefetching() {
|
||||
return this.query.isRefetching;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this.query.error;
|
||||
}
|
||||
|
||||
get isError() {
|
||||
return this.query.isError;
|
||||
}
|
||||
|
||||
get isSuccess() {
|
||||
return this.query.isSuccess;
|
||||
}
|
||||
|
||||
get status() {
|
||||
return this.query.status;
|
||||
}
|
||||
|
||||
// Derived helpers
|
||||
get hasData() {
|
||||
return this.fonts.length > 0;
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return !this.isLoading && this.fonts.length === 0;
|
||||
}
|
||||
|
||||
get fontCount() {
|
||||
return this.fonts.length;
|
||||
}
|
||||
|
||||
// Filtered fonts by category (if you need additional client-side filtering)
|
||||
get sansSerifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'sans-serif');
|
||||
}
|
||||
|
||||
get serifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'serif');
|
||||
}
|
||||
|
||||
get displayFonts() {
|
||||
return this.fonts.filter(f => f.category === 'display');
|
||||
}
|
||||
|
||||
get handwritingFonts() {
|
||||
return this.fonts.filter(f => f.category === 'handwriting');
|
||||
}
|
||||
|
||||
get monospaceFonts() {
|
||||
return this.fonts.filter(f => f.category === 'monospace');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update parameters - TanStack Query will automatically refetch
|
||||
*/
|
||||
setParams(newParams: Partial<GoogleFontsParams>) {
|
||||
this.params = { ...this.params, ...newParams };
|
||||
}
|
||||
|
||||
setCategory(category: FontCategory | undefined) {
|
||||
this.setParams({ category });
|
||||
}
|
||||
|
||||
setSubset(subset: FontSubset | undefined) {
|
||||
this.setParams({ subset });
|
||||
}
|
||||
|
||||
setSort(sort: 'popularity' | 'alpha' | 'date' | undefined) {
|
||||
this.setParams({ sort });
|
||||
}
|
||||
|
||||
setSearch(search: string) {
|
||||
this.setParams({ search });
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.setParams({ search: undefined });
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
this.params = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually refetch
|
||||
*/
|
||||
async refetch() {
|
||||
await this.query.refetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache and refetch
|
||||
*/
|
||||
invalidate() {
|
||||
this.queryClient.invalidateQueries({
|
||||
queryKey: getGoogleFontsQueryKey(this.params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all Google Fonts queries
|
||||
*/
|
||||
invalidateAll() {
|
||||
this.queryClient.invalidateQueries({
|
||||
queryKey: ['googleFonts'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch with different params (for hover states, pagination, etc.)
|
||||
*/
|
||||
async prefetch(params: GoogleFontsParams) {
|
||||
await this.queryClient.prefetchQuery({
|
||||
queryKey: getGoogleFontsQueryKey(params),
|
||||
queryFn: () => fetchGoogleFontsQuery(params),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch next category (useful for tab switching)
|
||||
*/
|
||||
async prefetchCategory(category: FontCategory) {
|
||||
await this.prefetch({ ...this.params, category });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel ongoing queries
|
||||
*/
|
||||
cancel() {
|
||||
this.queryClient.cancelQueries({
|
||||
queryKey: getGoogleFontsQueryKey(this.params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for current params
|
||||
*/
|
||||
clearCache() {
|
||||
this.queryClient.removeQueries({
|
||||
queryKey: getGoogleFontsQueryKey(this.params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data without triggering fetch
|
||||
*/
|
||||
getCachedData() {
|
||||
return this.queryClient.getQueryData<UnifiedFont[]>(
|
||||
getGoogleFontsQueryKey(this.params),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data exists in cache
|
||||
*/
|
||||
hasCache(params?: GoogleFontsParams) {
|
||||
const key = params ? getGoogleFontsQueryKey(params) : getGoogleFontsQueryKey(this.params);
|
||||
return this.queryClient.getQueryData(key) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set data manually (optimistic updates)
|
||||
*/
|
||||
setQueryData(updater: (old: UnifiedFont[] | undefined) => UnifiedFont[]) {
|
||||
this.queryClient.setQueryData(
|
||||
getGoogleFontsQueryKey(this.params),
|
||||
updater,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get query state for debugging
|
||||
*/
|
||||
getQueryState() {
|
||||
return this.queryClient.getQueryState(
|
||||
getGoogleFontsQueryKey(this.params),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create Google Fonts store
|
||||
*/
|
||||
export function createGoogleFontsStore(params: GoogleFontsParams = {}) {
|
||||
return new GoogleFontsStore(params);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { fetchFontshareFontsQuery } from './fetchFontshareFonts.svelte';
|
||||
export { fetchGoogleFontsQuery } from './fetchGoogleFonts.svelte';
|
||||
@@ -2,147 +2,167 @@ import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||
|
||||
export interface FontConfigRequest {
|
||||
/**
|
||||
* Font id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Real font name (e.g. "Lato")
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The .ttf URL
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Font weight
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Flag of the variable weight
|
||||
*/
|
||||
isVariable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager that handles loading of the fonts
|
||||
* Adds <link /> tags to <head />
|
||||
* - Uses batch loading to reduce the number of requests
|
||||
* - Uses a queue to prevent too many requests at once
|
||||
* - Purges unused fonts after a certain time
|
||||
* Manager that handles loading of fonts.
|
||||
* Logic:
|
||||
* - Variable fonts: Loaded once per id (covers all weights).
|
||||
* - Static fonts: Loaded per id + weight combination.
|
||||
*/
|
||||
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>();
|
||||
#idToBatch = new Map<string, string>();
|
||||
// Changed to HTMLStyleElement
|
||||
#batchElements = new Map<string, HTMLStyleElement>();
|
||||
|
||||
#queue = new Set<string>();
|
||||
#queue = new Map<string, FontConfigRequest>(); // Track config in queue
|
||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
#PURGE_INTERVAL = 60000; // Check every minute
|
||||
#TTL = 5 * 60 * 1000; // 5 minutes
|
||||
#CHUNK_SIZE = 3;
|
||||
|
||||
#PURGE_INTERVAL = 60000;
|
||||
#TTL = 5 * 60 * 1000;
|
||||
#CHUNK_SIZE = 5; // Can be larger since we're just injecting strings
|
||||
|
||||
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[]) {
|
||||
#getFontKey(id: string, weight: number): string {
|
||||
return `${id.toLowerCase()}@${weight}`;
|
||||
}
|
||||
|
||||
touch(configs: FontConfigRequest[]) {
|
||||
const now = Date.now();
|
||||
const toRegister: string[] = [];
|
||||
configs.forEach(config => {
|
||||
const key = this.#getFontKey(config.id, config.weight);
|
||||
this.#usageTracker.set(key, now);
|
||||
|
||||
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.#idToBatch.has(key) && !this.#queue.has(key)) {
|
||||
this.#queue.set(key, config);
|
||||
|
||||
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getFontStatus(slug: string) {
|
||||
return this.statuses.get(slug);
|
||||
getFontStatus(id: string, weight: number) {
|
||||
return this.statuses.get(this.#getFontKey(id, weight));
|
||||
}
|
||||
|
||||
#processQueue() {
|
||||
const fullQueue = Array.from(this.#queue);
|
||||
if (fullQueue.length === 0) return;
|
||||
const entries = Array.from(this.#queue.entries());
|
||||
if (entries.length === 0) return;
|
||||
|
||||
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) {
|
||||
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE));
|
||||
for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
|
||||
this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE));
|
||||
}
|
||||
|
||||
this.#queue.clear();
|
||||
this.#timeoutId = null;
|
||||
}
|
||||
|
||||
#createBatch(slugs: string[]) {
|
||||
#createBatch(batchEntries: [string, FontConfigRequest][]) {
|
||||
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`;
|
||||
let cssRules = '';
|
||||
|
||||
// Mark all as loading immediately
|
||||
slugs.forEach(slug => this.statuses.set(slug, 'loading'));
|
||||
batchEntries.forEach(([key, config]) => {
|
||||
this.statuses.set(key, 'loading');
|
||||
this.#idToBatch.set(key, batchId);
|
||||
|
||||
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');
|
||||
// Construct the @font-face rule
|
||||
// Using format('truetype') for .ttf
|
||||
cssRules += `
|
||||
@font-face {
|
||||
font-family: '${config.name}';
|
||||
src: url('${config.url}') format('truetype');
|
||||
font-weight: ${config.weight};
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.statuses.set(slug, 'error');
|
||||
`;
|
||||
});
|
||||
|
||||
// Create and inject the style tag
|
||||
const style = document.createElement('style');
|
||||
style.dataset.batchId = batchId;
|
||||
style.innerHTML = cssRules;
|
||||
document.head.appendChild(style);
|
||||
this.#batchElements.set(batchId, style);
|
||||
|
||||
// Verify loading via Font Loading API
|
||||
batchEntries.forEach(([key, config]) => {
|
||||
document.fonts.load(`${config.weight} 1em "${config.name}"`)
|
||||
.then(loaded => {
|
||||
this.statuses.set(key, loaded.length > 0 ? 'loaded' : 'error');
|
||||
})
|
||||
.catch(() => this.statuses.set(key, 'error'));
|
||||
});
|
||||
}
|
||||
|
||||
#purgeUnused() {
|
||||
const now = Date.now();
|
||||
const batchesToPotentialDelete = new Set<string>();
|
||||
const slugsToDelete: string[] = [];
|
||||
const batchesToRemove = new Set<string>();
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
// Identify expired slugs
|
||||
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
|
||||
for (const [key, 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())
|
||||
const batchId = this.#idToBatch.get(key);
|
||||
if (batchId) {
|
||||
// Check if EVERY font in this batch is expired
|
||||
const batchKeys = Array.from(this.#idToBatch.entries())
|
||||
.filter(([_, bId]) => bId === batchId)
|
||||
.map(([slug]) => slug);
|
||||
.map(([k]) => k);
|
||||
|
||||
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);
|
||||
const canDeleteBatch = batchKeys.every(k => {
|
||||
const lastK = this.#usageTracker.get(k);
|
||||
return lastK && (now - lastK > this.#TTL);
|
||||
});
|
||||
|
||||
if (canDeleteBatch) {
|
||||
batchesToRemove.add(batchId);
|
||||
keysToRemove.push(...batchKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
batchesToRemove.forEach(id => {
|
||||
this.#batchElements.get(id)?.remove();
|
||||
this.#batchElements.delete(id);
|
||||
});
|
||||
|
||||
keysToRemove.forEach(k => {
|
||||
this.#idToBatch.delete(k);
|
||||
this.#usageTracker.delete(k);
|
||||
this.statuses.delete(k);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export abstract class BaseFontStore<TParams extends Record<string, any>> {
|
||||
queryKey: this.getQueryKey(params),
|
||||
queryFn: () => this.fetchFn(params),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { FontshareParams } from '../../api';
|
||||
import { fetchFontshareFontsQuery } from '../services';
|
||||
import type { UnifiedFont } from '../types';
|
||||
import { BaseFontStore } from './baseFontStore.svelte';
|
||||
|
||||
/**
|
||||
* Fontshare store wrapping TanStack Query with runes
|
||||
*/
|
||||
export class FontshareStore extends BaseFontStore<FontshareParams> {
|
||||
constructor(initialParams: FontshareParams = {}) {
|
||||
super(initialParams);
|
||||
}
|
||||
|
||||
protected getQueryKey(params: FontshareParams) {
|
||||
return ['fontshare', params] as const;
|
||||
}
|
||||
|
||||
protected async fetchFn(params: FontshareParams): Promise<UnifiedFont[]> {
|
||||
return fetchFontshareFontsQuery(params);
|
||||
}
|
||||
|
||||
// Provider-specific methods (shortcuts)
|
||||
setSearch(search: string) {
|
||||
this.setParams({ q: search } as any);
|
||||
}
|
||||
}
|
||||
|
||||
export function createFontshareStore(params: FontshareParams = {}) {
|
||||
return new FontshareStore(params);
|
||||
}
|
||||
|
||||
export const fontshareStore = new FontshareStore();
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { GoogleFontsParams } from '../../api';
|
||||
import { fetchGoogleFontsQuery } from '../services';
|
||||
import type { UnifiedFont } from '../types';
|
||||
import { BaseFontStore } from './baseFontStore.svelte';
|
||||
|
||||
/**
|
||||
* Google Fonts store wrapping TanStack Query with runes
|
||||
*/
|
||||
export class GoogleFontsStore extends BaseFontStore<GoogleFontsParams> {
|
||||
constructor(initialParams: GoogleFontsParams = {}) {
|
||||
super(initialParams);
|
||||
}
|
||||
|
||||
protected getQueryKey(params: GoogleFontsParams) {
|
||||
return ['googleFonts', params] as const;
|
||||
}
|
||||
|
||||
protected async fetchFn(params: GoogleFontsParams): Promise<UnifiedFont[]> {
|
||||
return fetchGoogleFontsQuery(params);
|
||||
}
|
||||
}
|
||||
|
||||
export function createFontshareStore(params: GoogleFontsParams = {}) {
|
||||
return new GoogleFontsStore(params);
|
||||
}
|
||||
|
||||
export const googleFontsStore = new GoogleFontsStore();
|
||||
@@ -6,18 +6,15 @@
|
||||
* Single export point for the unified font store infrastructure.
|
||||
*/
|
||||
|
||||
// export {
|
||||
// createUnifiedFontStore,
|
||||
// UNIFIED_FONT_STORE_KEY,
|
||||
// type UnifiedFontStore,
|
||||
// } from './unifiedFontStore.svelte';
|
||||
|
||||
// Primary store (unified)
|
||||
export {
|
||||
createFontshareStore,
|
||||
type FontshareStore,
|
||||
fontshareStore,
|
||||
} from './fontshareStore.svelte';
|
||||
createUnifiedFontStore,
|
||||
type UnifiedFontStore,
|
||||
unifiedFontStore,
|
||||
} from './unifiedFontStore.svelte';
|
||||
|
||||
// Applied fonts manager (CSS loading - unchanged)
|
||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
||||
|
||||
// Selected fonts store (user selection - unchanged)
|
||||
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';
|
||||
|
||||
@@ -1,25 +1,354 @@
|
||||
import { type Filter } from '$shared/lib';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { FontProvider } from '../types';
|
||||
import type { CheckboxFilter } from '../types/common';
|
||||
import type { BaseFontStore } from './baseFontStore.svelte';
|
||||
import { createFontshareStore } from './fontshareStore.svelte';
|
||||
import type { ProviderParams } from './types';
|
||||
/**
|
||||
* Unified font store
|
||||
*
|
||||
* Single source of truth for font data, powered by the proxy API.
|
||||
* Extends BaseFontStore for TanStack Query integration and reactivity.
|
||||
*
|
||||
* Key features:
|
||||
* - Provider-agnostic (proxy API handles provider logic)
|
||||
* - Reactive to filter changes
|
||||
* - Optimistic updates via TanStack Query
|
||||
* - Pagination support
|
||||
* - Provider-specific shortcuts for common operations
|
||||
*/
|
||||
|
||||
export class UnitedFontStore {
|
||||
private sources: Partial<Record<FontProvider, BaseFontStore<ProviderParams>>>;
|
||||
import type { ProxyFontsParams } from '../../api';
|
||||
import { fetchProxyFonts } from '../../api';
|
||||
import type { UnifiedFont } from '../types';
|
||||
import { BaseFontStore } from './baseFontStore.svelte';
|
||||
|
||||
filters: SvelteMap<CheckboxFilter, Filter>;
|
||||
queryValue = $state('');
|
||||
/**
|
||||
* Unified font store wrapping TanStack Query with Svelte 5 runes
|
||||
*
|
||||
* Extends BaseFontStore to provide:
|
||||
* - Reactive state management
|
||||
* - TanStack Query integration for caching
|
||||
* - Dynamic parameter binding for filters
|
||||
* - Pagination support
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const store = new UnifiedFontStore({
|
||||
* provider: 'google',
|
||||
* category: 'sans-serif',
|
||||
* limit: 50
|
||||
* });
|
||||
*
|
||||
* // Access reactive state
|
||||
* $effect(() => {
|
||||
* console.log(store.fonts);
|
||||
* console.log(store.isLoading);
|
||||
* console.log(store.pagination);
|
||||
* });
|
||||
*
|
||||
* // Update parameters
|
||||
* store.setCategory('serif');
|
||||
* store.nextPage();
|
||||
* ```
|
||||
*/
|
||||
export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||
/**
|
||||
* Store pagination metadata separately from fonts
|
||||
* This is a workaround for TanStack Query's type system
|
||||
*/
|
||||
#paginationMetadata = $state<
|
||||
{
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
} | null
|
||||
>(null);
|
||||
|
||||
constructor(initialConfig: Partial<Record<FontProvider, ProviderParams>> = {}) {
|
||||
this.sources = {
|
||||
fontshare: createFontshareStore(initialConfig?.fontshare),
|
||||
/**
|
||||
* Accumulated fonts from all pages (for infinite scroll)
|
||||
*/
|
||||
#accumulatedFonts = $state<UnifiedFont[]>([]);
|
||||
|
||||
/**
|
||||
* Pagination metadata (derived from proxy API response)
|
||||
*/
|
||||
readonly pagination = $derived.by(() => {
|
||||
if (this.#paginationMetadata) {
|
||||
const { total, limit, offset } = this.#paginationMetadata;
|
||||
return {
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + limit < total,
|
||||
page: Math.floor(offset / limit) + 1,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
this.filters = new SvelteMap();
|
||||
}
|
||||
return {
|
||||
total: 0,
|
||||
limit: this.params.limit || 50,
|
||||
offset: this.params.offset || 0,
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
totalPages: 0,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Track previous filter params to detect changes and reset pagination
|
||||
*/
|
||||
#previousFilterParams = $state<string>('');
|
||||
|
||||
/**
|
||||
* Cleanup function for the filter tracking effect
|
||||
*/
|
||||
#filterCleanup: (() => void) | null = null;
|
||||
|
||||
constructor(initialParams: ProxyFontsParams = {}) {
|
||||
super(initialParams);
|
||||
|
||||
// Track filter params (excluding pagination params)
|
||||
// Wrapped in $effect.root() to prevent effect_orphan error
|
||||
this.#filterCleanup = $effect.root(() => {
|
||||
$effect(() => {
|
||||
const filterParams = JSON.stringify({
|
||||
provider: this.params.provider,
|
||||
category: this.params.category,
|
||||
subset: this.params.subset,
|
||||
q: this.params.q,
|
||||
});
|
||||
|
||||
// If filters changed, reset offset to 0
|
||||
if (filterParams !== this.#previousFilterParams) {
|
||||
if (this.#previousFilterParams && this.params.offset !== 0) {
|
||||
this.setParams({ offset: 0 });
|
||||
}
|
||||
this.#previousFilterParams = filterParams;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get fonts() {
|
||||
return Object.values(this.sources).map(store => store.fonts).flat();
|
||||
/**
|
||||
* Clean up both parent and child effects
|
||||
*/
|
||||
destroy() {
|
||||
// Call parent cleanup (TanStack observer effect)
|
||||
super.destroy();
|
||||
|
||||
// Call filter tracking effect cleanup
|
||||
if (this.#filterCleanup) {
|
||||
this.#filterCleanup();
|
||||
this.#filterCleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key for TanStack Query caching
|
||||
* Normalizes params to treat empty arrays/strings as undefined
|
||||
*/
|
||||
protected getQueryKey(params: ProxyFontsParams) {
|
||||
// Normalize params to treat empty arrays/strings as undefined
|
||||
const normalized = Object.entries(params).reduce((acc, [key, value]) => {
|
||||
if (value === '' || (Array.isArray(value) && value.length === 0)) {
|
||||
return acc;
|
||||
}
|
||||
return { ...acc, [key]: value };
|
||||
}, {});
|
||||
|
||||
return ['unifiedFonts', normalized] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch function that calls the proxy API
|
||||
* Returns the full response including pagination metadata
|
||||
*/
|
||||
protected async fetchFn(params: ProxyFontsParams): Promise<UnifiedFont[]> {
|
||||
const response = await fetchProxyFonts(params);
|
||||
|
||||
// Validate response structure
|
||||
if (!response) {
|
||||
console.error('[UnifiedFontStore] fetchProxyFonts returned undefined', { params });
|
||||
throw new Error('Proxy API returned undefined response');
|
||||
}
|
||||
|
||||
if (!response.fonts) {
|
||||
console.error('[UnifiedFontStore] response.fonts is undefined', { response });
|
||||
throw new Error('Proxy API response missing fonts array');
|
||||
}
|
||||
|
||||
if (!Array.isArray(response.fonts)) {
|
||||
console.error('[UnifiedFontStore] response.fonts is not an array', {
|
||||
fonts: response.fonts,
|
||||
});
|
||||
throw new Error('Proxy API fonts is not an array');
|
||||
}
|
||||
|
||||
// Store pagination metadata separately for derived values
|
||||
this.#paginationMetadata = {
|
||||
total: response.total ?? 0,
|
||||
limit: response.limit ?? this.params.limit ?? 50,
|
||||
offset: response.offset ?? this.params.offset ?? 0,
|
||||
};
|
||||
|
||||
// Accumulate fonts for infinite scroll
|
||||
if (params.offset === 0) {
|
||||
// Reset when starting from beginning (new search/filter)
|
||||
this.#accumulatedFonts = response.fonts;
|
||||
} else {
|
||||
// Append new fonts to existing ones
|
||||
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||
}
|
||||
|
||||
return response.fonts;
|
||||
}
|
||||
|
||||
// --- Getters (proxied from BaseFontStore) ---
|
||||
|
||||
/**
|
||||
* Get all accumulated fonts (for infinite scroll)
|
||||
*/
|
||||
get fonts(): UnifiedFont[] {
|
||||
return this.#accumulatedFonts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if loading initial data
|
||||
*/
|
||||
get isLoading(): boolean {
|
||||
return this.result.isLoading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fetching (including background refetches)
|
||||
*/
|
||||
get isFetching(): boolean {
|
||||
return this.result.isFetching;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error occurred
|
||||
*/
|
||||
get isError(): boolean {
|
||||
return this.result.isError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if result is empty (not loading and no fonts)
|
||||
*/
|
||||
get isEmpty(): boolean {
|
||||
return !this.isLoading && this.fonts.length === 0;
|
||||
}
|
||||
|
||||
// --- Provider-specific shortcuts ---
|
||||
|
||||
/**
|
||||
* Set provider filter
|
||||
*/
|
||||
setProvider(provider: 'google' | 'fontshare' | undefined) {
|
||||
this.setParams({ provider });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set category filter
|
||||
*/
|
||||
setCategory(category: ProxyFontsParams['category']) {
|
||||
this.setParams({ category });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set subset filter
|
||||
*/
|
||||
setSubset(subset: ProxyFontsParams['subset']) {
|
||||
this.setParams({ subset });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set search query
|
||||
*/
|
||||
setSearch(search: string) {
|
||||
this.setParams({ q: search || undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set sort order
|
||||
*/
|
||||
setSort(sort: ProxyFontsParams['sort']) {
|
||||
this.setParams({ sort });
|
||||
}
|
||||
|
||||
// --- Pagination methods ---
|
||||
|
||||
/**
|
||||
* Go to next page
|
||||
*/
|
||||
nextPage() {
|
||||
if (this.pagination.hasMore) {
|
||||
this.setParams({
|
||||
offset: this.pagination.offset + this.pagination.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to previous page
|
||||
*/
|
||||
prevPage() {
|
||||
if (this.pagination.page > 1) {
|
||||
this.setParams({
|
||||
offset: this.pagination.offset - this.pagination.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to specific page
|
||||
*/
|
||||
goToPage(page: number) {
|
||||
if (page >= 1 && page <= this.pagination.totalPages) {
|
||||
this.setParams({
|
||||
offset: (page - 1) * this.pagination.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set limit (items per page)
|
||||
*/
|
||||
setLimit(limit: number) {
|
||||
this.setParams({ limit });
|
||||
}
|
||||
|
||||
// --- Category shortcuts (for convenience) ---
|
||||
|
||||
get sansSerifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'sans-serif');
|
||||
}
|
||||
|
||||
get serifFonts() {
|
||||
return this.fonts.filter(f => f.category === 'serif');
|
||||
}
|
||||
|
||||
get displayFonts() {
|
||||
return this.fonts.filter(f => f.category === 'display');
|
||||
}
|
||||
|
||||
get handwritingFonts() {
|
||||
return this.fonts.filter(f => f.category === 'handwriting');
|
||||
}
|
||||
|
||||
get monospaceFonts() {
|
||||
return this.fonts.filter(f => f.category === 'monospace');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create unified font store
|
||||
*/
|
||||
export function createUnifiedFontStore(params: ProxyFontsParams = {}) {
|
||||
return new UnifiedFontStore(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance for global use
|
||||
* Initialized with a default limit to prevent fetching all fonts at once
|
||||
*/
|
||||
export const unifiedFontStore = new UnifiedFontStore({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
- Adds smooth transition when font appears
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { motion } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
import { appliedFontsManager } from '../../model';
|
||||
|
||||
interface Props {
|
||||
@@ -20,6 +20,12 @@ interface Props {
|
||||
* Font id to load
|
||||
*/
|
||||
id: string;
|
||||
|
||||
url: string;
|
||||
/**
|
||||
* Font weight
|
||||
*/
|
||||
weight?: number;
|
||||
/**
|
||||
* Additional classes
|
||||
*/
|
||||
@@ -30,7 +36,7 @@ interface Props {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { name, id, className, children }: Props = $props();
|
||||
let { name, id, url, weight = 400, className, children }: Props = $props();
|
||||
let element: Element;
|
||||
|
||||
// Track if the user has actually scrolled this into view
|
||||
@@ -40,7 +46,7 @@ $effect(() => {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) {
|
||||
hasEnteredViewport = true;
|
||||
appliedFontsManager.touch([id]);
|
||||
appliedFontsManager.touch([{ id, weight, name, url }]);
|
||||
|
||||
// Once it has entered, we can stop observing to save CPU
|
||||
observer.unobserve(element);
|
||||
@@ -50,12 +56,12 @@ $effect(() => {
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
const status = $derived(appliedFontsManager.getFontStatus(id));
|
||||
const status = $derived(appliedFontsManager.getFontStatus(id, weight));
|
||||
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
|
||||
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
|
||||
|
||||
const transitionClasses = $derived(
|
||||
motion.reduced
|
||||
prefersReducedMotion.current
|
||||
? 'transition-none' // Disable CSS transitions if motion is reduced
|
||||
: 'transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||
);
|
||||
@@ -67,8 +73,9 @@ const transitionClasses = $derived(
|
||||
class={cn(
|
||||
transitionClasses,
|
||||
// If reduced motion is on, we skip the transform/blur entirely
|
||||
!shouldReveal && !motion.reduced && 'opacity-0 translate-y-8 scale-[0.98] blur-sm',
|
||||
!shouldReveal && motion.reduced && 'opacity-0', // Still hide until font is ready, but no movement
|
||||
!shouldReveal && !prefersReducedMotion.current
|
||||
&& 'opacity-0 translate-y-8 scale-[0.98] blur-sm',
|
||||
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
||||
shouldReveal && 'opacity-100 translate-y-0 scale-100 blur-0',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -1,84 +1,85 @@
|
||||
<!--
|
||||
Component: FontListItem
|
||||
Displays a font item with a checkbox and its characteristics in badges.
|
||||
Displays a font item and manages its animations
|
||||
-->
|
||||
<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 { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import {
|
||||
type UnifiedFont,
|
||||
selectedFontsStore,
|
||||
} from '../../model';
|
||||
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Object with information about font
|
||||
*/
|
||||
font: UnifiedFont;
|
||||
/**
|
||||
* Is element fully visible
|
||||
*/
|
||||
isFullyVisible: boolean;
|
||||
/**
|
||||
* Is element partially visible
|
||||
*/
|
||||
isPartiallyVisible: boolean;
|
||||
/**
|
||||
* From 0 to 1
|
||||
*/
|
||||
proximity: number;
|
||||
/**
|
||||
* Children snippet
|
||||
*/
|
||||
children: Snippet<[font: UnifiedFont]>;
|
||||
}
|
||||
|
||||
const { font }: Props = $props();
|
||||
|
||||
const handleChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
selectedFontsStore.addOne(font);
|
||||
} else {
|
||||
selectedFontsStore.removeOne(font.id);
|
||||
}
|
||||
};
|
||||
const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props();
|
||||
|
||||
const selected = $derived(selectedFontsStore.has(font.id));
|
||||
let timeoutId = $state<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Create a spring for smooth scale animation
|
||||
const scale = new Spring(1, {
|
||||
stiffness: 0.3,
|
||||
damping: 0.7,
|
||||
});
|
||||
|
||||
// Springs react to the virtualizer's computed state
|
||||
const bloom = new Spring(0, {
|
||||
stiffness: 0.15,
|
||||
damping: 0.6,
|
||||
});
|
||||
|
||||
// Sync spring to proximity for a "Lens" effect
|
||||
$effect(() => {
|
||||
bloom.target = isPartiallyVisible ? 1 : 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function animateSelection() {
|
||||
scale.target = 0.98;
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
scale.target = 1;
|
||||
}, 150);
|
||||
}
|
||||
</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={cn('pb-1 will-change-transform')}
|
||||
style:opacity={bloom.current}
|
||||
style:transform="
|
||||
scale({0.92 + (bloom.current * 0.08)})
|
||||
translateY({(1 - bloom.current) * 10}px)
|
||||
"
|
||||
>
|
||||
<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>
|
||||
>
|
||||
{@render children?.(font)}
|
||||
</div>
|
||||
|
||||
@@ -3,24 +3,40 @@
|
||||
- Renders a virtualized list of fonts
|
||||
- Handles font registration with the manager
|
||||
-->
|
||||
<script lang="ts" generics="T extends { id: string }">
|
||||
<script lang="ts" generics="T extends UnifiedFont">
|
||||
import type { FontConfigRequest } from '$entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte';
|
||||
import { VirtualList } from '$shared/ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { appliedFontsManager } from '../../model';
|
||||
import {
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
} from '../../model';
|
||||
|
||||
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
|
||||
onVisibleItemsChange?: (items: T[]) => void;
|
||||
onNearBottom?: (lastVisibleIndex: number) => void;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
let { items, children, onVisibleItemsChange, ...rest }: Props = $props();
|
||||
let { items, children, onVisibleItemsChange, onNearBottom, weight, ...rest }: Props = $props();
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: T[]) {
|
||||
// Auto-register fonts with the manager
|
||||
const slugs = visibleItems.map(item => item.id);
|
||||
appliedFontsManager.registerFonts(slugs);
|
||||
const configs = visibleItems.map<FontConfigRequest>(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
weight,
|
||||
url: item.styles.regular!,
|
||||
}));
|
||||
appliedFontsManager.touch(configs);
|
||||
|
||||
// // Forward the call to any external listener
|
||||
// onVisibleItemsChange?.(visibleItems);
|
||||
}
|
||||
|
||||
function handleNearBottom(lastVisibleIndex: number) {
|
||||
// Forward the call to any external listener
|
||||
onVisibleItemsChange?.(visibleItems);
|
||||
onNearBottom?.(lastVisibleIndex);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -28,6 +44,7 @@ function handleInternalVisibleChange(visibleItems: T[]) {
|
||||
{items}
|
||||
{...rest}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
onNearBottom={handleNearBottom}
|
||||
>
|
||||
{#snippet children(scope)}
|
||||
{@render children(scope)}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { FontDisplay } from './ui';
|
||||
export { FontSampler } from './ui';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { displayedFontsStore } from './store';
|
||||
@@ -1,28 +0,0 @@
|
||||
import { selectedFontsStore } from '$entities/Font';
|
||||
|
||||
/**
|
||||
* Store for displayed font samples
|
||||
* - Handles shown text
|
||||
* - Stores selected fonts for display
|
||||
*/
|
||||
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();
|
||||
@@ -1 +0,0 @@
|
||||
export { displayedFontsStore } from './displayedFontsStore.svelte';
|
||||
@@ -1,14 +0,0 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -6,8 +6,14 @@
|
||||
import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
selectedFontsStore,
|
||||
} from '$entities/Font';
|
||||
import { ContentEditable } from '$shared/ui';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import {
|
||||
ContentEditable,
|
||||
IconButton,
|
||||
} from '$shared/ui';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -18,6 +24,10 @@ interface Props {
|
||||
* Text to display
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Index of the font sampler
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* Font settings
|
||||
*/
|
||||
@@ -29,18 +39,80 @@ interface Props {
|
||||
let {
|
||||
font,
|
||||
text = $bindable(),
|
||||
index = 0,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
|
||||
const fontWeight = $derived(controlManager.weight);
|
||||
const fontSize = $derived(controlManager.size);
|
||||
const lineHeight = $derived(controlManager.height);
|
||||
const letterSpacing = $derived(controlManager.spacing);
|
||||
|
||||
function removeSample() {
|
||||
selectedFontsStore.removeOne(font.id);
|
||||
}
|
||||
</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
|
||||
w-full h-full rounded-2xl
|
||||
flex flex-col
|
||||
backdrop-blur-md bg-white/80
|
||||
border border-gray-300/50
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
relative overflow-hidden
|
||||
"
|
||||
style:font-weight={fontWeight}
|
||||
>
|
||||
<FontApplicator id={font.id} name={font.name}>
|
||||
<ContentEditable bind:text={text} {...restProps} />
|
||||
<div class="px-6 py-3 border-b border-gray-200/60 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="font-mono text-[9px] uppercase tracking-[0.25em] text-gray-500 font-medium">
|
||||
typeface_{String(index).padStart(3, '0')}
|
||||
</span>
|
||||
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
||||
<span class="font-mono text-[10px] tracking-[0.15em] font-bold uppercase text-gray-900">
|
||||
{font.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
onclick={removeSample}
|
||||
class="w-5 h-5 rounded-full hover:bg-transparent flex items-center justify-center transition-colors group translate-x-1/2 cursor-pointer"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<XIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div class="p-8 relative z-10">
|
||||
<!-- TODO: Fix this ! -->
|
||||
<FontApplicator id={font.id} name={font.name} url={font.styles.regular!}>
|
||||
<ContentEditable
|
||||
bind:text={text}
|
||||
{...restProps}
|
||||
fontSize={fontSize}
|
||||
lineHeight={lineHeight}
|
||||
letterSpacing={letterSpacing}
|
||||
/>
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-2 border-t border-gray-200/40 w-full flex gap-4 bg-gray-50/30 mt-auto">
|
||||
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider ml-auto">
|
||||
SZ:{fontSize}PX
|
||||
</span>
|
||||
<div class="w-px h-2.5 self-center bg-gray-300/40"></div>
|
||||
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider">
|
||||
WGT:{fontWeight}
|
||||
</span>
|
||||
<div class="w-px h-2.5 self-center bg-gray-300/40"></div>
|
||||
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider">
|
||||
LH:{lineHeight?.toFixed(2)}
|
||||
</span>
|
||||
<div class="w-px h-2.5 self-center bg-gray-300/40"></div>
|
||||
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider">
|
||||
LTR:{letterSpacing}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import FontDisplay from './FontDisplay/FontDisplay.svelte';
|
||||
import FontSampler from './FontSampler/FontSampler.svelte';
|
||||
|
||||
export { FontDisplay };
|
||||
export { FontSampler };
|
||||
|
||||
@@ -15,5 +15,4 @@ export { filterManager } from './model/state/manager.svelte';
|
||||
export {
|
||||
FilterControls,
|
||||
Filters,
|
||||
FontSearch,
|
||||
} from './ui';
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
import type { FontshareParams } from '$entities/Font';
|
||||
import type { ProxyFontsParams } from '$entities/Font/api';
|
||||
import type { FilterManager } from '../filterManager/filterManager.svelte';
|
||||
|
||||
/**
|
||||
* Maps filter manager to fontshare params.
|
||||
* Maps filter manager to proxy API parameters.
|
||||
*
|
||||
* @param manager - Filter manager instance.
|
||||
* @returns - Partial fontshare params.
|
||||
* Transforms UI filter state into proxy API query parameters.
|
||||
* Handles conversion from filter groups to API-specific parameters.
|
||||
*
|
||||
* @param manager - Filter manager instance with reactive state
|
||||
* @returns - Partial proxy API parameters ready for API call
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Example filter manager state:
|
||||
* // {
|
||||
* // queryValue: 'roboto',
|
||||
* // providers: ['google'],
|
||||
* // categories: ['sans-serif'],
|
||||
* // subsets: ['latin']
|
||||
* // }
|
||||
*
|
||||
* const params = mapManagerToParams(manager);
|
||||
* // Returns: { provider: 'google', category: 'sans-serif', subset: 'latin', q: 'roboto' }
|
||||
* ```
|
||||
*/
|
||||
export function mapManagerToParams(manager: FilterManager): Partial<FontshareParams> {
|
||||
export function mapManagerToParams(manager: FilterManager): Partial<ProxyFontsParams> {
|
||||
const providers = manager.getGroup('providers')?.instance.selectedProperties.map(p => p.value);
|
||||
const categories = manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value);
|
||||
const subsets = manager.getGroup('subsets')?.instance.selectedProperties.map(p => p.value);
|
||||
|
||||
return {
|
||||
q: manager.debouncedQueryValue,
|
||||
// Map groups to specific API keys
|
||||
categories: manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value)
|
||||
?? [],
|
||||
tags: manager.getGroup('tags')?.instance.selectedProperties.map(p => p.value) ?? [],
|
||||
// Search query (debounced)
|
||||
q: manager.debouncedQueryValue || undefined,
|
||||
|
||||
// Provider filter (single value - proxy API doesn't support array)
|
||||
// Use first provider if multiple selected, or undefined if none/all selected
|
||||
provider: providers && providers.length === 1
|
||||
? (providers[0] as 'google' | 'fontshare')
|
||||
: undefined,
|
||||
|
||||
// Category filter (single value - proxy API doesn't support array)
|
||||
// Use first category if multiple selected, or undefined if none/all selected
|
||||
category: categories && categories.length === 1
|
||||
? (categories[0] as ProxyFontsParams['category'])
|
||||
: undefined,
|
||||
|
||||
// Subset filter (single value - proxy API doesn't support array)
|
||||
// Use first subset if multiple selected, or undefined if none/all selected
|
||||
subset: subsets && subsets.length === 1
|
||||
? (subsets[0] as ProxyFontsParams['subset'])
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,15 +5,42 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button } from '$shared/shadcn/ui/button';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import Rotate from '@lucide/svelte/icons/rotate-ccw';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { Tween } from 'svelte/motion';
|
||||
import { filterManager } from '../../model';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className }: Props = $props();
|
||||
|
||||
const transform = new Tween(
|
||||
{ scale: 1, rotate: 0 },
|
||||
{ duration: 150, easing: cubicOut },
|
||||
);
|
||||
|
||||
function handleClick() {
|
||||
filterManager.deselectAllGlobal();
|
||||
|
||||
transform.set({ scale: 0.98, rotate: 1 }).then(() => {
|
||||
transform.set({ scale: 1, rotate: 0 });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<div
|
||||
class={cn('flex flex-row gap-2', className)}
|
||||
style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1 cursor-pointer"
|
||||
onclick={filterManager.deselectAllGlobal}
|
||||
variant="ghost"
|
||||
class="group flex flex-1 cursor-pointer gap-1"
|
||||
onclick={handleClick}
|
||||
>
|
||||
<Rotate class="size-4 group-hover:-rotate-180 transition-transform duration-300" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<!--
|
||||
Component: FontSearch
|
||||
|
||||
Combines search input with font list display
|
||||
-->
|
||||
<script lang="ts">
|
||||
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(() => {
|
||||
/**
|
||||
* The Pairing:
|
||||
* We "plug" this manager into the global store.
|
||||
* addBinding returns a function that removes this binding when the component unmounts.
|
||||
*/
|
||||
const unbind = fontshareStore.addBinding(() => mapManagerToParams(filterManager));
|
||||
|
||||
return unbind;
|
||||
});
|
||||
</script>
|
||||
|
||||
<SearchBar
|
||||
id="font-search"
|
||||
class="w-full"
|
||||
placeholder="Search fonts by name..."
|
||||
bind:value={filterManager.queryValue}
|
||||
>
|
||||
<SuggestedFonts />
|
||||
</SearchBar>
|
||||
@@ -1,17 +0,0 @@
|
||||
<!--
|
||||
Component: SuggestedFonts
|
||||
Renders a list of suggested fonts in a virtualized list to improve performance.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontListItem,
|
||||
FontVirtualList,
|
||||
fontshareStore,
|
||||
} from '$entities/Font';
|
||||
</script>
|
||||
|
||||
<FontVirtualList items={fontshareStore.fonts}>
|
||||
{#snippet children({ item: font })}
|
||||
<FontListItem {font} />
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
@@ -1,9 +1,7 @@
|
||||
import Filters from './Filters/Filters.svelte';
|
||||
import FilterControls from './FiltersControl/FilterControls.svelte';
|
||||
import FontSearch from './FontSearch/FontSearch.svelte';
|
||||
|
||||
export {
|
||||
FilterControls,
|
||||
Filters,
|
||||
FontSearch,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
controlManager,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
|
||||
@@ -1,7 +1,53 @@
|
||||
import {
|
||||
type ControlModel,
|
||||
type TypographyControl,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
export interface Control {
|
||||
id: string;
|
||||
increaseLabel?: string;
|
||||
decreaseLabel?: string;
|
||||
controlLabel?: string;
|
||||
instance: TypographyControl;
|
||||
}
|
||||
|
||||
export class TypographyControlManager {
|
||||
#controls = new SvelteMap<string, Control>();
|
||||
|
||||
constructor(configs: ControlModel[]) {
|
||||
configs.forEach(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => {
|
||||
this.#controls.set(id, {
|
||||
id,
|
||||
increaseLabel,
|
||||
decreaseLabel,
|
||||
controlLabel,
|
||||
instance: createTypographyControl(config),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get controls() {
|
||||
return this.#controls.values();
|
||||
}
|
||||
|
||||
get weight() {
|
||||
return this.#controls.get('font_weight')?.instance.value ?? 400;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.#controls.get('font_size')?.instance.value;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.#controls.get('line_height')?.instance.value;
|
||||
}
|
||||
|
||||
get spacing() {
|
||||
return this.#controls.get('letter_spacing')?.instance.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typography control manager that handles a collection of typography controls.
|
||||
@@ -10,19 +56,5 @@ import {
|
||||
* @returns - Typography control manager instance.
|
||||
*/
|
||||
export function createTypographyControlManager(configs: ControlModel[]) {
|
||||
const controls = $state(
|
||||
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
|
||||
id,
|
||||
increaseLabel,
|
||||
decreaseLabel,
|
||||
controlLabel,
|
||||
instance: createTypographyControl(config),
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
get controls() {
|
||||
return controls;
|
||||
},
|
||||
};
|
||||
return new TypographyControlManager(configs);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Font size constants
|
||||
*/
|
||||
export const DEFAULT_FONT_SIZE = 16;
|
||||
export const DEFAULT_FONT_SIZE = 48;
|
||||
export const MIN_FONT_SIZE = 8;
|
||||
export const MAX_FONT_SIZE = 100;
|
||||
export const FONT_SIZE_STEP = 1;
|
||||
@@ -21,3 +21,11 @@ export const DEFAULT_LINE_HEIGHT = 1.5;
|
||||
export const MIN_LINE_HEIGHT = 1;
|
||||
export const MAX_LINE_HEIGHT = 2;
|
||||
export const LINE_HEIGHT_STEP = 0.05;
|
||||
|
||||
/**
|
||||
* Letter spacing constants
|
||||
*/
|
||||
export const DEFAULT_LETTER_SPACING = 0;
|
||||
export const MIN_LETTER_SPACING = -0.1;
|
||||
export const MAX_LETTER_SPACING = 0.5;
|
||||
export const LETTER_SPACING_STEP = 0.01;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
|
||||
@@ -3,15 +3,19 @@ import { createTypographyControlManager } from '../../lib';
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
} from '../const/const';
|
||||
|
||||
@@ -49,6 +53,17 @@ const controlData: ControlModel[] = [
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Line Height',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
value: DEFAULT_LETTER_SPACING,
|
||||
max: MAX_LETTER_SPACING,
|
||||
min: MIN_LETTER_SPACING,
|
||||
step: LETTER_SPACING_STEP,
|
||||
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Letter Spacing',
|
||||
},
|
||||
];
|
||||
|
||||
export const controlManager = createTypographyControlManager(controlData);
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
Contains controls for setting up font properties.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Separator } from '$shared/shadcn/ui/separator/index';
|
||||
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
|
||||
import { ComboControl } from '$shared/ui';
|
||||
import { controlManager } from '../model';
|
||||
</script>
|
||||
|
||||
<div class="p-2 flex flex-row items-center gap-2">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" class="h-full" />
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="py-2 px-10 flex flex-row items-center gap-2">
|
||||
<div class="flex flex-row gap-3">
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
<ComboControl control={control.instance} />
|
||||
<ComboControl
|
||||
control={control.instance}
|
||||
increaseLabel={control.increaseLabel}
|
||||
decreaseLabel={control.decreaseLabel}
|
||||
controlLabel={control.controlLabel}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,94 @@
|
||||
<!--
|
||||
Component: Page
|
||||
Description: The main page component of the application.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
|
||||
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
||||
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
import { Section } from '$shared/ui';
|
||||
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
||||
import { FontSearch } from '$widgets/FontSearch';
|
||||
import { SampleList } from '$widgets/SampleList';
|
||||
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
|
||||
import ScanEyeIcon from '@lucide/svelte/icons/scan-eye';
|
||||
import ScanSearchIcon from '@lucide/svelte/icons/search';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
/**
|
||||
* Page Component
|
||||
*/
|
||||
let searchContainer: HTMLElement;
|
||||
|
||||
let isExpanded = $state(false);
|
||||
|
||||
function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) {
|
||||
if (isPast && title) {
|
||||
scrollBreadcrumbsStore.add({ index, title });
|
||||
} else {
|
||||
scrollBreadcrumbsStore.remove(index);
|
||||
}
|
||||
|
||||
return () => {
|
||||
scrollBreadcrumbsStore.remove(index);
|
||||
};
|
||||
}
|
||||
|
||||
// $effect(() => {
|
||||
// appliedFontsManager.touch(
|
||||
// selectedFontsStore.all.map(font => ({
|
||||
// slug: font.id,
|
||||
// weight: controlManager.weight,
|
||||
// })),
|
||||
// );
|
||||
// });
|
||||
</script>
|
||||
|
||||
<BreadcrumbHeader />
|
||||
|
||||
<!-- Font List -->
|
||||
<div class="p-2">
|
||||
<FontDisplay />
|
||||
<div class="p-2 h-full flex flex-col gap-3">
|
||||
<Section class="my-12 gap-8" index={0} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
{#snippet icon({ className })}
|
||||
<ScanEyeIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h1 class={className}>
|
||||
Optical<br />Comparator
|
||||
</h1>
|
||||
{/snippet}
|
||||
<ComparisonSlider />
|
||||
</Section>
|
||||
|
||||
<Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
{#snippet icon({ className })}
|
||||
<ScanSearchIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h2 class={className}>
|
||||
Query<br />Module
|
||||
</h2>
|
||||
{/snippet}
|
||||
<FontSearch bind:showFilters={isExpanded} />
|
||||
</Section>
|
||||
|
||||
<Section class="my-12 gap-8" index={2} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
{#snippet icon({ className })}
|
||||
<LineSquiggleIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet title({ className })}
|
||||
<h2 class={className}>
|
||||
Sample<br />Set
|
||||
</h2>
|
||||
{/snippet}
|
||||
<SampleList />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
/* Tells the browser to skip rendering off-screen content */
|
||||
content-visibility: auto;
|
||||
/* Helps the browser reserve space without calculating everything */
|
||||
contain-intrinsic-size: 1px 1000px;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,6 +56,5 @@ export const api = {
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
delete: <T>(url: string, options?: RequestInit) =>
|
||||
request<T>(url, { ...options, method: 'DELETE' }),
|
||||
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
||||
};
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// Check if we are in a browser environment
|
||||
const isBrowser = typeof window !== 'undefined';
|
||||
|
||||
// A class to manage motion preference and provide a single instance for use everywhere
|
||||
class MotionPreference {
|
||||
// Reactive state
|
||||
#reduced = $state(false);
|
||||
|
||||
constructor() {
|
||||
if (isBrowser) {
|
||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
// Set initial value immediately
|
||||
this.#reduced = mediaQuery.matches;
|
||||
|
||||
// Simple listener that updates the reactive state
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
this.#reduced = e.matches;
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
}
|
||||
}
|
||||
|
||||
// Getter allows us to use 'motion.reduced' reactively in components
|
||||
get reduced() {
|
||||
return this.#reduced;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a single instance to be used everywhere
|
||||
export const motion = new MotionPreference();
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Interface representing a line of text with its measured width.
|
||||
*/
|
||||
export interface LineData {
|
||||
text: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a helper for splitting text into lines and calculating character proximity.
|
||||
* This is used by the ComparisonSlider (TestTen) to render morphing text.
|
||||
*
|
||||
* @param text - The text to split and measure
|
||||
* @param fontA - The first font definition
|
||||
* @param fontB - The second font definition
|
||||
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
|
||||
*/
|
||||
export function createCharacterComparison<
|
||||
T extends { name: string; id: string } | undefined = undefined,
|
||||
>(
|
||||
text: () => string,
|
||||
fontA: () => T,
|
||||
fontB: () => T,
|
||||
weight: () => number,
|
||||
size: () => number,
|
||||
) {
|
||||
let lines = $state<LineData[]>([]);
|
||||
let containerWidth = $state(0);
|
||||
|
||||
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
|
||||
return font !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures text width using a canvas context.
|
||||
* @param ctx - Canvas rendering context
|
||||
* @param text - Text string to measure
|
||||
* @param fontFamily - Font family name
|
||||
* @param fontSize - Font size in pixels
|
||||
* @param fontWeight - Font weight
|
||||
*/
|
||||
function measureText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
fontSize: number,
|
||||
fontWeight: number,
|
||||
fontFamily?: string,
|
||||
): number {
|
||||
if (!fontFamily) return 0;
|
||||
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the appropriate font size based on window width.
|
||||
* Matches the Tailwind breakpoints used in the component.
|
||||
*/
|
||||
function getFontSize() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 64;
|
||||
}
|
||||
return window.innerWidth >= 1024
|
||||
? 112
|
||||
: window.innerWidth >= 768
|
||||
? 96
|
||||
: window.innerWidth >= 640
|
||||
? 80
|
||||
: 64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks the text into lines based on the container width and measure canvas.
|
||||
* Populates the `lines` state.
|
||||
*
|
||||
* @param container - The container element to measure width from
|
||||
* @param measureCanvas - The canvas element used for text measurement
|
||||
*/
|
||||
|
||||
function breakIntoLines(
|
||||
container: HTMLElement | undefined,
|
||||
measureCanvas: HTMLCanvasElement | undefined,
|
||||
) {
|
||||
if (!container || !measureCanvas || !fontA() || !fontB()) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
containerWidth = rect.width;
|
||||
|
||||
// Padding considerations - matches the container padding
|
||||
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||
const availableWidth = rect.width - padding;
|
||||
const ctx = measureCanvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const controlledFontSize = size();
|
||||
const fontSize = getFontSize();
|
||||
const currentWeight = weight(); // Get current weight
|
||||
const words = text().split(' ');
|
||||
const newLines: LineData[] = [];
|
||||
let currentLineWords: string[] = [];
|
||||
|
||||
function pushLine(words: string[]) {
|
||||
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
|
||||
return;
|
||||
}
|
||||
const lineText = words.join(' ');
|
||||
// Measure both fonts at the CURRENT weight
|
||||
const widthA = measureText(
|
||||
ctx!,
|
||||
lineText,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
fontA()?.name,
|
||||
);
|
||||
const widthB = measureText(
|
||||
ctx!,
|
||||
lineText,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
fontB()?.name,
|
||||
);
|
||||
const maxWidth = Math.max(widthA, widthB);
|
||||
newLines.push({ text: lineText, width: maxWidth });
|
||||
}
|
||||
|
||||
for (const word of words) {
|
||||
const testLine = currentLineWords.length > 0
|
||||
? currentLineWords.join(' ') + ' ' + word
|
||||
: word;
|
||||
// Measure with both fonts and use the wider one to prevent layout shifts
|
||||
const widthA = measureText(
|
||||
ctx,
|
||||
testLine,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
fontA()?.name,
|
||||
);
|
||||
const widthB = measureText(
|
||||
ctx,
|
||||
testLine,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
fontB()?.name,
|
||||
);
|
||||
const maxWidth = Math.max(widthA, widthB);
|
||||
const isContainerOverflown = maxWidth > availableWidth;
|
||||
|
||||
if (isContainerOverflown) {
|
||||
if (currentLineWords.length > 0) {
|
||||
pushLine(currentLineWords);
|
||||
currentLineWords = [];
|
||||
}
|
||||
|
||||
let remainingWord = word;
|
||||
while (remainingWord.length > 0) {
|
||||
let low = 1;
|
||||
let high = remainingWord.length;
|
||||
let bestBreak = 1;
|
||||
|
||||
// Binary Search to find the maximum characters that fit
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const testFragment = remainingWord.slice(0, mid);
|
||||
|
||||
const wA = measureText(
|
||||
ctx,
|
||||
testFragment,
|
||||
fontSize,
|
||||
currentWeight,
|
||||
fontA()?.name,
|
||||
);
|
||||
const wB = measureText(
|
||||
ctx,
|
||||
testFragment,
|
||||
fontSize,
|
||||
currentWeight,
|
||||
fontB()?.name,
|
||||
);
|
||||
|
||||
if (Math.max(wA, wB) <= availableWidth) {
|
||||
bestBreak = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
pushLine([remainingWord.slice(0, bestBreak)]);
|
||||
remainingWord = remainingWord.slice(bestBreak);
|
||||
}
|
||||
} else if (maxWidth > availableWidth && currentLineWords.length > 0) {
|
||||
pushLine(currentLineWords);
|
||||
currentLineWords = [word];
|
||||
} else {
|
||||
currentLineWords.push(word);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLineWords.length > 0) {
|
||||
pushLine(currentLineWords);
|
||||
}
|
||||
lines = newLines;
|
||||
}
|
||||
|
||||
/**
|
||||
* precise calculation of character state based on global slider position.
|
||||
*
|
||||
* @param charIndex - Index of the character in the line
|
||||
* @param sliderPos - Current slider position (0-100)
|
||||
* @param lineElement - The line element
|
||||
* @param container - The container element
|
||||
* @returns Object containing proximity (0-1) and isPast (boolean)
|
||||
*/
|
||||
function getCharState(
|
||||
charIndex: number,
|
||||
sliderPos: number,
|
||||
lineElement?: HTMLElement,
|
||||
container?: HTMLElement,
|
||||
) {
|
||||
if (!containerWidth || !container) {
|
||||
return {
|
||||
proximity: 0,
|
||||
isPast: false,
|
||||
};
|
||||
}
|
||||
const charElement = lineElement?.children[charIndex] as HTMLElement;
|
||||
|
||||
if (!charElement) {
|
||||
return { proximity: 0, isPast: false };
|
||||
}
|
||||
|
||||
// Get the actual bounding box of the character
|
||||
const charRect = charElement.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Calculate character center relative to container
|
||||
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
|
||||
const charGlobalPercent = (charCenter / containerWidth) * 100;
|
||||
|
||||
const distance = Math.abs(sliderPos - charGlobalPercent);
|
||||
const range = 5;
|
||||
const proximity = Math.max(0, 1 - distance / range);
|
||||
const isPast = sliderPos > charGlobalPercent;
|
||||
|
||||
return { proximity, isPast };
|
||||
}
|
||||
|
||||
return {
|
||||
get lines() {
|
||||
return lines;
|
||||
},
|
||||
get containerWidth() {
|
||||
return containerWidth;
|
||||
},
|
||||
breakIntoLines,
|
||||
getCharState,
|
||||
};
|
||||
}
|
||||
@@ -46,9 +46,6 @@ export class EntityStore<T extends Entity> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Reusable persistent storage utility using Svelte 5 runes
|
||||
*
|
||||
* Automatically syncs state with localStorage.
|
||||
*/
|
||||
export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||
// Initialize from storage or default
|
||||
const loadFromStorage = (): T => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultValue;
|
||||
}
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch (error) {
|
||||
console.warn(`[createPersistentStore] Error loading ${key}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
let value = $state<T>(loadFromStorage());
|
||||
|
||||
// Sync to storage whenever value changes
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn(`[createPersistentStore] Error saving ${key}:`, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
set value(v: T) {
|
||||
value = v;
|
||||
},
|
||||
clear() {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
value = defaultValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -30,15 +30,15 @@ export interface ControlModel extends ControlDataModel {
|
||||
/**
|
||||
* Area label for increase button
|
||||
*/
|
||||
increaseLabel: string;
|
||||
increaseLabel?: string;
|
||||
/**
|
||||
* Area label for decrease button
|
||||
*/
|
||||
decreaseLabel: string;
|
||||
decreaseLabel?: string;
|
||||
/**
|
||||
* Control area label
|
||||
*/
|
||||
controlLabel: string;
|
||||
controlLabel?: string;
|
||||
}
|
||||
|
||||
export function createTypographyControl<T extends ControlDataModel>(
|
||||
|
||||
@@ -4,16 +4,38 @@
|
||||
* Used to render visible items with absolute positioning based on computed offsets.
|
||||
*/
|
||||
export interface VirtualItem {
|
||||
/** Index of the item in the data array */
|
||||
/**
|
||||
* Index of the item in the data array
|
||||
*/
|
||||
index: number;
|
||||
/** Offset from the top of the list in pixels */
|
||||
/**
|
||||
* Offset from the top of the list in pixels
|
||||
*/
|
||||
start: number;
|
||||
/** Height/size of the item in pixels */
|
||||
/**
|
||||
* Height/size of the item in pixels
|
||||
*/
|
||||
size: number;
|
||||
/** End position in pixels (start + size) */
|
||||
/**
|
||||
* End position in pixels (start + size)
|
||||
*/
|
||||
end: number;
|
||||
/** Unique key for the item (for Svelte's {#each} keying) */
|
||||
/**
|
||||
* Unique key for the item (for Svelte's {#each} keying)
|
||||
*/
|
||||
key: string | number;
|
||||
/**
|
||||
* Whether the item is currently fully visible in the viewport
|
||||
*/
|
||||
isFullyVisible: boolean;
|
||||
/**
|
||||
* Whether the item is currently partially visible in the viewport
|
||||
*/
|
||||
isPartiallyVisible: boolean;
|
||||
/**
|
||||
* Proximity of the item to the center of the viewport
|
||||
*/
|
||||
proximity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +63,11 @@ export interface VirtualizerOptions {
|
||||
* Can be useful for handling sticky headers or other UI elements.
|
||||
*/
|
||||
scrollMargin?: number;
|
||||
/**
|
||||
* Whether to use the window as the scroll container.
|
||||
* @default false
|
||||
*/
|
||||
useWindowScroll?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +115,7 @@ export function createVirtualizer<T>(
|
||||
let containerHeight = $state(0);
|
||||
let measuredSizes = $state<Record<number, number>>({});
|
||||
let elementRef: HTMLElement | null = null;
|
||||
let elementOffsetTop = 0;
|
||||
|
||||
// By wrapping the getter in $derived, we track everything inside it
|
||||
const options = $derived(optionsGetter());
|
||||
@@ -136,6 +164,8 @@ export function createVirtualizer<T>(
|
||||
|
||||
let endIdx = startIdx;
|
||||
const viewportEnd = scrollOffset + containerHeight;
|
||||
const viewportCenter = scrollOffset + (containerHeight / 2);
|
||||
|
||||
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
||||
endIdx++;
|
||||
}
|
||||
@@ -144,13 +174,31 @@ export function createVirtualizer<T>(
|
||||
const end = Math.min(count, endIdx + overscan);
|
||||
|
||||
const result: VirtualItem[] = [];
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const itemStart = offsets[i];
|
||||
const itemSize = measuredSizes[i] ?? options.estimateSize(i);
|
||||
const itemEnd = itemStart + itemSize;
|
||||
|
||||
// Visibility check: Does the item overlap the viewport?
|
||||
const isPartiallyVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
|
||||
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
||||
|
||||
// Proximity calculation: 1.0 at center, 0.0 at edges
|
||||
const itemCenter = itemStart + (itemSize / 2);
|
||||
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
|
||||
const maxDistance = containerHeight / 2;
|
||||
const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance));
|
||||
|
||||
result.push({
|
||||
index: i,
|
||||
start: offsets[i],
|
||||
size: measuredSizes[i] ?? options.estimateSize(i),
|
||||
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
|
||||
start: itemStart,
|
||||
size: itemSize,
|
||||
end: itemEnd,
|
||||
key: options.getItemKey?.(i) ?? i,
|
||||
isPartiallyVisible,
|
||||
isFullyVisible,
|
||||
proximity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,6 +216,53 @@ export function createVirtualizer<T>(
|
||||
*/
|
||||
function container(node: HTMLElement) {
|
||||
elementRef = node;
|
||||
const { useWindowScroll } = optionsGetter();
|
||||
|
||||
if (useWindowScroll) {
|
||||
// Calculate initial offset ONCE
|
||||
const getElementOffset = () => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return rect.top + window.scrollY;
|
||||
};
|
||||
|
||||
let cachedOffsetTop = getElementOffset();
|
||||
containerHeight = window.innerHeight;
|
||||
|
||||
const handleScroll = () => {
|
||||
// Use cached offset for scroll calculations
|
||||
scrollOffset = Math.max(0, window.scrollY - cachedOffsetTop);
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
const oldHeight = containerHeight;
|
||||
containerHeight = window.innerHeight;
|
||||
|
||||
// Recalculate offset on resize (layout may have shifted)
|
||||
const newOffsetTop = getElementOffset();
|
||||
if (Math.abs(newOffsetTop - cachedOffsetTop) > 0.5) {
|
||||
cachedOffsetTop = newOffsetTop;
|
||||
handleScroll(); // Recalculate scroll position
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Initial calculation
|
||||
handleScroll();
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
if (frameId !== null) {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
}
|
||||
elementRef = null;
|
||||
},
|
||||
};
|
||||
} else {
|
||||
containerHeight = node.offsetHeight;
|
||||
|
||||
const handleScroll = () => {
|
||||
@@ -189,6 +284,7 @@ export function createVirtualizer<T>(
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let measurementBuffer: Record<number, number> = {};
|
||||
let frameId: number | null = null;
|
||||
@@ -207,23 +303,25 @@ export function createVirtualizer<T>(
|
||||
const index = parseInt(node.dataset.index || '', 10);
|
||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
||||
|
||||
if (!isNaN(index) && measuredSizes[index] !== height) {
|
||||
// 1. Stuff the measurement into a temporary buffer
|
||||
if (!isNaN(index)) {
|
||||
const oldHeight = measuredSizes[index];
|
||||
// Only update if the height difference is significant (> 0.5px)
|
||||
// This prevents "jitter" from focus rings or sub-pixel border changes
|
||||
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
||||
// Stuff the measurement into a temporary buffer
|
||||
measurementBuffer[index] = height;
|
||||
|
||||
// 2. Schedule a single update for the next animation frame
|
||||
// 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
|
||||
// Reset the buffer
|
||||
measurementBuffer = {};
|
||||
frameId = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(node);
|
||||
@@ -249,12 +347,23 @@ export function createVirtualizer<T>(
|
||||
const itemStart = offsets[index];
|
||||
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
|
||||
let target = itemStart;
|
||||
const { useWindowScroll } = optionsGetter();
|
||||
|
||||
if (useWindowScroll) {
|
||||
if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2;
|
||||
if (align === 'end') target = itemStart - window.innerHeight + itemSize;
|
||||
|
||||
// Add container offset to target to get absolute document position
|
||||
const absoluteTarget = target + elementOffsetTop;
|
||||
|
||||
window.scrollTo({ top: absoluteTarget, behavior: 'smooth' });
|
||||
} else {
|
||||
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
|
||||
if (align === 'end') target = itemStart - containerHeight + itemSize;
|
||||
|
||||
elementRef.scrollTo({ top: target, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** Computed array of visible items to render (reactive) */
|
||||
|
||||
@@ -26,3 +26,10 @@ export {
|
||||
type Entity,
|
||||
type EntityStore,
|
||||
} from './createEntityStore/createEntityStore.svelte';
|
||||
|
||||
export {
|
||||
createCharacterComparison,
|
||||
type LineData,
|
||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||
|
||||
export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte';
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
export {
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
createCharacterComparison,
|
||||
createDebouncedState,
|
||||
createEntityStore,
|
||||
createFilter,
|
||||
createPersistentStore,
|
||||
createTypographyControl,
|
||||
createVirtualizer,
|
||||
type Entity,
|
||||
type EntityStore,
|
||||
type Filter,
|
||||
type FilterModel,
|
||||
type LineData,
|
||||
type Property,
|
||||
type TypographyControl,
|
||||
type VirtualItem,
|
||||
@@ -17,5 +20,6 @@ export {
|
||||
type VirtualizerOptions,
|
||||
} from './helpers';
|
||||
|
||||
export { motion } from './accessibility/motion.svelte';
|
||||
export { splitArray } from './utils';
|
||||
|
||||
export { springySlideFade } from './transitions';
|
||||
|
||||
1
src/shared/lib/transitions/index.ts
Normal file
1
src/shared/lib/transitions/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { springySlideFade } from './springySlideFade/springySlideFade';
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
SlideParams,
|
||||
TransitionConfig,
|
||||
} from 'svelte/transition';
|
||||
|
||||
function elasticOut(t: number) {
|
||||
return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1;
|
||||
}
|
||||
|
||||
function gentleSpring(t: number) {
|
||||
return 1 - Math.pow(1 - t, 3) * Math.cos(t * Math.PI * 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Svelte slide transition function for custom slide+fade
|
||||
* @param node - The element to apply the transition to
|
||||
* @param params - Transition parameters
|
||||
* @returns Transition configuration
|
||||
*/
|
||||
export function springySlideFade(
|
||||
node: HTMLElement,
|
||||
params: SlideParams = {},
|
||||
): TransitionConfig {
|
||||
const { duration = 400 } = params;
|
||||
const height = node.scrollHeight;
|
||||
|
||||
// Check if the browser is Firefox to work around specific rendering issues
|
||||
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
|
||||
|
||||
return {
|
||||
duration,
|
||||
// We use 'tick' for the most precise control over the
|
||||
// coordination with the elements below.
|
||||
css: t => {
|
||||
// Use elastic easing
|
||||
const eased = gentleSpring(t);
|
||||
|
||||
return `
|
||||
height: ${eased * height}px;
|
||||
opacity: ${t};
|
||||
transform: translateY(${(1 - t) * -10}px);
|
||||
transform-origin: top;
|
||||
overflow: hidden;
|
||||
contain: size layout style;
|
||||
will-change: max-height, opacity, transform;
|
||||
backface-visibility: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
${
|
||||
isFirefox
|
||||
? `
|
||||
perspective: 1000px;
|
||||
isolation: isolate;
|
||||
`
|
||||
: ''
|
||||
}
|
||||
`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,10 +9,8 @@ export const badgeVariants = tv({
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
|
||||
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
|
||||
secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
|
||||
destructive:
|
||||
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
|
||||
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
|
||||
10
src/shared/shadcn/ui/scroll-area/index.ts
Normal file
10
src/shared/shadcn/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import Scrollbar from './scroll-area-scrollbar.svelte';
|
||||
import Root from './scroll-area.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
// ,
|
||||
Root as ScrollArea,
|
||||
Scrollbar,
|
||||
Scrollbar as ScrollAreaScrollbar,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
orientation = 'vertical',
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
|
||||
</script>
|
||||
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
bind:ref
|
||||
data-slot="scroll-area-scrollbar"
|
||||
{orientation}
|
||||
class={cn(
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-s border-s-transparent',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
class="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
46
src/shared/shadcn/ui/scroll-area/scroll-area.svelte
Normal file
46
src/shared/shadcn/ui/scroll-area/scroll-area.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
|
||||
import { Scrollbar } from './index.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
viewportRef = $bindable(null),
|
||||
class: className,
|
||||
orientation = 'vertical',
|
||||
scrollbarXClasses = '',
|
||||
scrollbarYClasses = '',
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
|
||||
orientation?: 'vertical' | 'horizontal' | 'both' | undefined;
|
||||
scrollbarXClasses?: string | undefined;
|
||||
scrollbarYClasses?: string | undefined;
|
||||
viewportRef?: HTMLElement | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ScrollAreaPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="scroll-area"
|
||||
class={cn('relative', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
bind:ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
class="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
||||
>
|
||||
{@render children?.()}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
{#if orientation === 'vertical' || orientation === 'both'}
|
||||
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
|
||||
{/if}
|
||||
{#if orientation === 'horizontal' || orientation === 'both'}
|
||||
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
|
||||
{/if}
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
37
src/shared/shadcn/ui/select/index.ts
Normal file
37
src/shared/shadcn/ui/select/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import Content from './select-content.svelte';
|
||||
import GroupHeading from './select-group-heading.svelte';
|
||||
import Group from './select-group.svelte';
|
||||
import Item from './select-item.svelte';
|
||||
import Label from './select-label.svelte';
|
||||
import Portal from './select-portal.svelte';
|
||||
import ScrollDownButton from './select-scroll-down-button.svelte';
|
||||
import ScrollUpButton from './select-scroll-up-button.svelte';
|
||||
import Separator from './select-separator.svelte';
|
||||
import Trigger from './select-trigger.svelte';
|
||||
import Root from './select.svelte';
|
||||
|
||||
export {
|
||||
Content,
|
||||
Content as SelectContent,
|
||||
Group,
|
||||
Group as SelectGroup,
|
||||
GroupHeading,
|
||||
GroupHeading as SelectGroupHeading,
|
||||
Item,
|
||||
Item as SelectItem,
|
||||
Label,
|
||||
Label as SelectLabel,
|
||||
Portal,
|
||||
Portal as SelectPortal,
|
||||
Root,
|
||||
//
|
||||
Root as Select,
|
||||
ScrollDownButton,
|
||||
ScrollDownButton as SelectScrollDownButton,
|
||||
ScrollUpButton,
|
||||
ScrollUpButton as SelectScrollUpButton,
|
||||
Separator,
|
||||
Separator as SelectSeparator,
|
||||
Trigger,
|
||||
Trigger as SelectTrigger,
|
||||
};
|
||||
48
src/shared/shadcn/ui/select/select-content.svelte
Normal file
48
src/shared/shadcn/ui/select/select-content.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import type { WithoutChildrenOrChild } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import SelectPortal from './select-portal.svelte';
|
||||
import SelectScrollDownButton from './select-scroll-down-button.svelte';
|
||||
import SelectScrollUpButton from './select-scroll-up-button.svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
children,
|
||||
preventScroll = true,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPortal {...portalProps}>
|
||||
<SelectPrimitive.Content
|
||||
bind:ref
|
||||
{sideOffset}
|
||||
{preventScroll}
|
||||
data-slot="select-content"
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
class={cn(
|
||||
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1',
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPortal>
|
||||
21
src/shared/shadcn/ui/select/select-group-heading.svelte
Normal file
21
src/shared/shadcn/ui/select/select-group-heading.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="select-group-heading"
|
||||
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.GroupHeading>
|
||||
7
src/shared/shadcn/ui/select/select-group.svelte
Normal file
7
src/shared/shadcn/ui/select/select-group.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />
|
||||
41
src/shared/shadcn/ui/select/select-item.svelte
Normal file
41
src/shared/shadcn/ui/select/select-item.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
label,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Item
|
||||
bind:ref
|
||||
{value}
|
||||
data-slot="select-item"
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ selected, highlighted })}
|
||||
<span class="absolute end-2 flex size-3.5 items-center justify-center">
|
||||
{#if selected}
|
||||
<CheckIcon class="size-4" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if childrenProp}
|
||||
{@render childrenProp({ selected, highlighted })}
|
||||
{:else}
|
||||
{label || value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SelectPrimitive.Item>
|
||||
23
src/shared/shadcn/ui/select/select-label.svelte
Normal file
23
src/shared/shadcn/ui/select/select-label.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithElementRef,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="select-label"
|
||||
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
7
src/shared/shadcn/ui/select/select-portal.svelte
Normal file
7
src/shared/shadcn/ui/select/select-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let { ...restProps }: SelectPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Portal {...restProps} />
|
||||
23
src/shared/shadcn/ui/select/select-scroll-down-button.svelte
Normal file
23
src/shared/shadcn/ui/select/select-scroll-down-button.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChildrenOrChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-down-button"
|
||||
class={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
23
src/shared/shadcn/ui/select/select-scroll-up-button.svelte
Normal file
23
src/shared/shadcn/ui/select/select-scroll-up-button.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChildrenOrChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-up-button"
|
||||
class={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronUpIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
18
src/shared/shadcn/ui/select/select-separator.svelte
Normal file
18
src/shared/shadcn/ui/select/select-separator.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '$shared/shadcn/ui/separator/index.js';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import type { Separator as SeparatorPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<Separator
|
||||
bind:ref
|
||||
data-slot="select-separator"
|
||||
class={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
32
src/shared/shadcn/ui/select/select-trigger.svelte
Normal file
32
src/shared/shadcn/ui/select/select-trigger.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
size = 'default',
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||
size?: 'sm' | 'default';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
class={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon class="size-4 opacity-50" />
|
||||
</SelectPrimitive.Trigger>
|
||||
11
src/shared/shadcn/ui/select/select.svelte
Normal file
11
src/shared/shadcn/ui/select/select.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: SelectPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />
|
||||
@@ -33,8 +33,7 @@ const sidebar = setSidebar({
|
||||
onOpenChange(value);
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie =
|
||||
`${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
- Local transition prevents animation when component first renders
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Filter } from '$shared/lib';
|
||||
import { motion } from '$shared/lib';
|
||||
import {
|
||||
type Filter,
|
||||
springySlideFade,
|
||||
} from '$shared/lib';
|
||||
import { Badge } from '$shared/shadcn/ui/badge';
|
||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
import { Label } from '$shared/shadcn/ui/label';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
|
||||
interface PropertyFilterProps {
|
||||
/** Label for this filter group (e.g., "Properties", "Tags") */
|
||||
@@ -37,7 +39,7 @@ let isOpen = $state(true);
|
||||
// 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: motion.reduced ? 0 : 250,
|
||||
duration: prefersReducedMotion.current ? 0 : 150,
|
||||
easing: cubicOut,
|
||||
});
|
||||
|
||||
@@ -49,7 +51,7 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
<!-- Collapsible card wrapper with subtle hover state for affordance -->
|
||||
<CollapsibleRoot
|
||||
bind:open={isOpen}
|
||||
class="w-full rounded-lg border bg-card transition-colors hover:bg-accent/5"
|
||||
class="w-full bg-card transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<!-- Trigger row: title, expand indicator, and optional count badge -->
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
@@ -57,8 +59,7 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
class={buttonVariants({
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
class:
|
||||
'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
|
||||
class: 'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
|
||||
})}
|
||||
>
|
||||
<h4 class="text-sm font-semibold">{displayedLabel}</h4>
|
||||
@@ -88,8 +89,8 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
<!-- Expandable content with slide animation -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
transition:slide|local={slideConfig}
|
||||
class="border-t"
|
||||
transition:springySlideFade|local={slideConfig}
|
||||
class="will-change-[height,opacity]"
|
||||
>
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
@@ -105,9 +106,7 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
active:scale-[0.98] active:transition-transform active:duration-75
|
||||
"
|
||||
>
|
||||
<!--
|
||||
Checkbox handles toggle, styled for accessibility with focus rings
|
||||
-->
|
||||
<!-- Checkbox handles toggle, styled for accessibility with focus rings -->
|
||||
<Checkbox
|
||||
id={property.id}
|
||||
bind:checked={property.selected}
|
||||
|
||||
@@ -8,13 +8,23 @@
|
||||
<script lang="ts">
|
||||
import type { TypographyControl } from '$shared/lib';
|
||||
import { Button } from '$shared/shadcn/ui/button';
|
||||
import * as ButtonGroup from '$shared/shadcn/ui/button-group';
|
||||
import { Root as ButtonGroupRoot } from '$shared/shadcn/ui/button-group';
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import * as Popover from '$shared/shadcn/ui/popover';
|
||||
import {
|
||||
Content as PopoverContent,
|
||||
Root as PopoverRoot,
|
||||
Trigger as PopoverTrigger,
|
||||
} from '$shared/shadcn/ui/popover';
|
||||
import { Slider } from '$shared/shadcn/ui/slider';
|
||||
import {
|
||||
Content as TooltipContent,
|
||||
Root as TooltipRoot,
|
||||
Trigger as TooltipTrigger,
|
||||
} from '$shared/shadcn/ui/tooltip';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import type { ChangeEventHandler } from 'svelte/elements';
|
||||
import IconButton from '../IconButton/IconButton.svelte';
|
||||
|
||||
interface ComboControlProps {
|
||||
/**
|
||||
@@ -67,30 +77,34 @@ const handleSliderChange = (newValue: number) => {
|
||||
};
|
||||
</script>
|
||||
|
||||
<ButtonGroup.Root>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={decreaseLabel}
|
||||
<TooltipRoot>
|
||||
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
|
||||
<TooltipTrigger class="flex items-center">
|
||||
<IconButton
|
||||
onclick={control.decrease}
|
||||
disabled={control.isAtMin}
|
||||
aria-label={decreaseLabel}
|
||||
rotation="counterclockwise"
|
||||
>
|
||||
<MinusIcon />
|
||||
</Button>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
{#snippet icon({ className })}
|
||||
<MinusIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
class="hover:bg-white/50 hover:font-bold bg-white/20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
|
||||
size="icon"
|
||||
aria-label={controlLabel}
|
||||
>
|
||||
{control.value}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-auto p-4">
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-4">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Slider
|
||||
min={control.min}
|
||||
@@ -110,15 +124,24 @@ const handleSliderChange = (newValue: number) => {
|
||||
class="w-16 text-center"
|
||||
/>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
|
||||
<IconButton
|
||||
aria-label={increaseLabel}
|
||||
onclick={control.increase}
|
||||
disabled={control.isAtMax}
|
||||
rotation="clockwise"
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</ButtonGroup.Root>
|
||||
{#snippet icon({ className })}
|
||||
<PlusIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
</TooltipTrigger>
|
||||
</ButtonGroupRoot>
|
||||
{#if controlLabel}
|
||||
<TooltipContent>
|
||||
{controlLabel}
|
||||
</TooltipContent>
|
||||
{/if}
|
||||
</TooltipRoot>
|
||||
|
||||
72
src/shared/ui/ComboControlV2/ComboControlV2.svelte
Normal file
72
src/shared/ui/ComboControlV2/ComboControlV2.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<!--
|
||||
Component: ComboControl
|
||||
Provides the same functionality as the original ComboControl but lacks increase/decrease buttons.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { TypographyControl } from '$shared/lib';
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { Slider } from '$shared/shadcn/ui/slider';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { ChangeEventHandler } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
control: TypographyControl;
|
||||
ref?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
control,
|
||||
ref = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let sliderValue = $state(Number(control.value));
|
||||
|
||||
$effect(() => {
|
||||
sliderValue = Number(control.value);
|
||||
});
|
||||
|
||||
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
||||
const parsedValue = parseFloat(event.currentTarget.value);
|
||||
if (!isNaN(parsedValue)) {
|
||||
control.value = parsedValue;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSliderChange = (newValue: number) => {
|
||||
control.value = newValue;
|
||||
};
|
||||
|
||||
// Shared glass button class for consistency
|
||||
// const glassBtnClass = cn(
|
||||
// 'border-none transition-all duration-200',
|
||||
// 'bg-white/10 hover:bg-white/40 active:scale-90',
|
||||
// 'text-slate-900 font-medium',
|
||||
// );
|
||||
|
||||
// const ghostStyle = cn(
|
||||
// 'flex items-center justify-center transition-all duration-300 ease-out',
|
||||
// 'text-slate-900/40 hover:text-slate-950 hover:bg-white/20 active:scale-90',
|
||||
// 'disabled:opacity-10 disabled:pointer-events-none',
|
||||
// );
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Input
|
||||
value={control.value}
|
||||
onchange={handleInputChange}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
class="w-14 h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50"
|
||||
/>
|
||||
<Slider
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
value={sliderValue}
|
||||
onValueChange={handleSliderChange}
|
||||
type="single"
|
||||
orientation="vertical"
|
||||
class="h-30"
|
||||
/>
|
||||
</div>
|
||||
@@ -5,14 +5,20 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
/**
|
||||
* Visible text
|
||||
* Visible text (bindable)
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Font settings
|
||||
* Font size in pixels
|
||||
*/
|
||||
fontSize?: number;
|
||||
/**
|
||||
* Line height
|
||||
*/
|
||||
lineHeight?: number;
|
||||
/**
|
||||
* Letter spacing in pixels
|
||||
*/
|
||||
letterSpacing?: number;
|
||||
}
|
||||
|
||||
@@ -53,7 +59,7 @@ function handleInput(e: Event) {
|
||||
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
|
||||
caret-indigo-500 focus:outline-none
|
||||
"
|
||||
style:font-size="{fontSize}px"
|
||||
style:line-height={lineHeight}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import ExpandableWrapper from './ExpandableWrapper.svelte';
|
||||
|
||||
const visibleSnippet = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<div class="w-48 p-2 font-bold text-indigo-600">
|
||||
Always visible
|
||||
</div>`,
|
||||
}));
|
||||
|
||||
const hiddenSnippet = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<div class="p-4 space-y-2 border-t border-indigo-100 mt-2">
|
||||
<div class="h-4 w-full bg-indigo-100 rounded animate-pulse"></div>
|
||||
<div class="h-4 w-2/3 bg-indigo-50 rounded animate-pulse"></div>
|
||||
</div>`,
|
||||
}));
|
||||
|
||||
const badgeSnippet = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<div class="">
|
||||
<span class="badge badge-primary">*</span>
|
||||
</div>`,
|
||||
}));
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/ExpandableWrapper',
|
||||
component: ExpandableWrapper,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Animated styled wrapper for content that can be expanded and collapsed.',
|
||||
},
|
||||
story: { inline: false }, // Render stories in iframe for state isolation
|
||||
},
|
||||
},
|
||||
args: {
|
||||
expanded: false,
|
||||
disabled: false,
|
||||
rotation: 'clockwise',
|
||||
visibleContent: visibleSnippet,
|
||||
hiddenContent: hiddenSnippet,
|
||||
},
|
||||
argTypes: {
|
||||
expanded: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
rotation: {
|
||||
control: 'select',
|
||||
options: ['clockwise', 'counterclockwise'],
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
</script>
|
||||
{/* @ts-ignore */ null}
|
||||
<Story name="With hidden content">
|
||||
{#snippet children(args)}
|
||||
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
|
||||
<ExpandableWrapper
|
||||
{...args}
|
||||
bind:expanded={args.expanded}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
{/* @ts-ignore */ null}
|
||||
<Story name="Disabled" args={{ disabled: true }}>
|
||||
{#snippet children(args)}
|
||||
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
|
||||
<ExpandableWrapper
|
||||
{...args}
|
||||
bind:expanded={args.expanded}
|
||||
disabled={args.disabled}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
{/* @ts-ignore */ null}
|
||||
<Story name="With badge" args={{ badge: badgeSnippet }}>
|
||||
{#snippet children(args)}
|
||||
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
|
||||
<ExpandableWrapper
|
||||
{...args}
|
||||
bind:expanded={args.expanded}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
195
src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte
Normal file
195
src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte
Normal file
@@ -0,0 +1,195 @@
|
||||
<!--
|
||||
Component: ExpandableWrapper
|
||||
Animated wrapper for content that can be expanded and collapsed.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Bindable property to control the expanded state of the wrapper.
|
||||
* @default false
|
||||
*/
|
||||
expanded?: boolean;
|
||||
/**
|
||||
* Disabled flag
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Bindable property to bind:this
|
||||
* @default null
|
||||
*/
|
||||
element?: HTMLElement | null;
|
||||
/**
|
||||
* Content that's always visible
|
||||
*/
|
||||
visibleContent?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
|
||||
/**
|
||||
* Content that's hidden when the wrapper is collapsed
|
||||
*/
|
||||
hiddenContent?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
|
||||
/**
|
||||
* Optional badge to render
|
||||
*/
|
||||
badge?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
|
||||
/**
|
||||
* Rotation animation direction
|
||||
* @default 'clockwise'
|
||||
*/
|
||||
rotation?: 'clockwise' | 'counterclockwise';
|
||||
/**
|
||||
* Classes for intermnal container
|
||||
*/
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
expanded = $bindable(false),
|
||||
disabled = false,
|
||||
element = $bindable(null),
|
||||
visibleContent,
|
||||
hiddenContent,
|
||||
badge,
|
||||
rotation = 'clockwise',
|
||||
class: className = '',
|
||||
containerClassName = '',
|
||||
...props
|
||||
}: Props = $props();
|
||||
|
||||
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
export const xSpring = new Spring(0, {
|
||||
stiffness: 0.14, // Lower is slower
|
||||
damping: 0.5, // Settle
|
||||
});
|
||||
|
||||
const ySpring = new Spring(0, {
|
||||
stiffness: 0.32,
|
||||
damping: 0.65,
|
||||
});
|
||||
|
||||
const scaleSpring = new Spring(1, {
|
||||
stiffness: 0.32,
|
||||
damping: 0.65,
|
||||
});
|
||||
|
||||
export const rotateSpring = new Spring(0, {
|
||||
stiffness: 0.12,
|
||||
damping: 0.55,
|
||||
});
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (element && !element.contains(e.target as Node)) {
|
||||
expanded = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWrapperClick() {
|
||||
if (!disabled) {
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || (e.key === ' ' && !expanded)) {
|
||||
e.preventDefault();
|
||||
handleWrapperClick();
|
||||
}
|
||||
|
||||
if (expanded && e.key === 'Escape') {
|
||||
expanded = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Elevation and scale on activation
|
||||
$effect(() => {
|
||||
if (expanded && !disabled) {
|
||||
// Lift up
|
||||
ySpring.target = 8;
|
||||
// Slightly bigger
|
||||
scaleSpring.target = 1.1;
|
||||
|
||||
rotateSpring.target = rotation === 'clockwise' ? -0.5 : 0.5;
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
rotateSpring.target = 0;
|
||||
scaleSpring.target = 1.05;
|
||||
}, 300);
|
||||
} else {
|
||||
ySpring.target = 0;
|
||||
scaleSpring.target = 1;
|
||||
rotateSpring.target = 0;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Click outside handling
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (disabled) {
|
||||
expanded = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
onclick={handleWrapperClick}
|
||||
onkeydown={handleKeyDown}
|
||||
role="button"
|
||||
tabindex={0}
|
||||
class={cn(
|
||||
'will-change-transform duration-300',
|
||||
disabled ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
className,
|
||||
)}
|
||||
style:transform="
|
||||
translate({xSpring.current}px, {ySpring.current}px)
|
||||
scale({scaleSpring.current})
|
||||
rotateZ({rotateSpring.current}deg)
|
||||
"
|
||||
{...props}
|
||||
>
|
||||
{@render badge?.({ expanded, disabled })}
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
|
||||
expanded
|
||||
? 'bg-white/5 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
|
||||
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
|
||||
disabled && 'opacity-80 grayscale-[0.2]',
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
{@render visibleContent?.({ expanded, disabled })}
|
||||
|
||||
{#if expanded}
|
||||
<div
|
||||
in:slide={{ duration: 250, easing: cubicOut }}
|
||||
out:slide={{ duration: 250, easing: cubicOut }}
|
||||
>
|
||||
{@render hiddenContent?.({ expanded, disabled })}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
50
src/shared/ui/IconButton/IconButton.svelte
Normal file
50
src/shared/ui/IconButton/IconButton.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<!--
|
||||
Component: IconButton
|
||||
Shadcn button size="icon" variant="ghost" with custom styling and icon snippet
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button } from '$shared/shadcn/ui/button';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type {
|
||||
ComponentProps,
|
||||
Snippet,
|
||||
} from 'svelte';
|
||||
|
||||
interface Props extends ComponentProps<typeof Button> {
|
||||
/**
|
||||
* Direction of the rotation effect on click
|
||||
* @default clockwise
|
||||
*/
|
||||
rotation?: 'clockwise' | 'counterclockwise';
|
||||
/**
|
||||
* Icon
|
||||
*/
|
||||
icon: Snippet<[{ className: string }]>;
|
||||
}
|
||||
|
||||
let { rotation = 'clockwise', icon, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="
|
||||
group relative border-none size-9
|
||||
bg-white/20 hover:bg-white/60
|
||||
backdrop-blur-3xl
|
||||
transition-all duration-200 ease-out
|
||||
will-change-transform
|
||||
hover:-translate-y-0.5
|
||||
active:translate-y-0 active:scale-95 active:shadow-none
|
||||
cursor-pointer
|
||||
disabled:opacity-50 disabled:pointer-events-none
|
||||
"
|
||||
size="icon"
|
||||
{...rest}
|
||||
>
|
||||
{@render icon({
|
||||
className: cn(
|
||||
'size-4 transition-all duration-200 stroke-[1.5] stroke-gray-500 group-hover:stroke-gray-900 group-hover:scale-110 group-hover:stroke-3 group-active:scale-90 group-disabled:stroke-transparent',
|
||||
rotation === 'clockwise' ? 'group-active:rotate-6' : 'group-active:-rotate-6',
|
||||
),
|
||||
})}
|
||||
</Button>
|
||||
@@ -45,11 +45,7 @@ let noChildrenValue = $state('');
|
||||
placeholder: 'Type here...',
|
||||
}}
|
||||
>
|
||||
<SearchBar bind:value={defaultSearchValue} placeholder="Type here...">
|
||||
Here will be the search result
|
||||
<br />
|
||||
Popover closes only when the user clicks outside the search bar or presses the Escape key.
|
||||
</SearchBar>
|
||||
<SearchBar bind:value={defaultSearchValue} placeholder="Type here..."> </SearchBar>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
@@ -60,11 +56,7 @@ let noChildrenValue = $state('');
|
||||
label: 'Search',
|
||||
}}
|
||||
>
|
||||
<SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search">
|
||||
<div class="p-4">
|
||||
<p class="text-sm text-muted-foreground">No results found</p>
|
||||
</div>
|
||||
</SearchBar>
|
||||
<SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search"> </SearchBar>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
@@ -74,9 +66,5 @@ let noChildrenValue = $state('');
|
||||
placeholder: 'Quick search...',
|
||||
}}
|
||||
>
|
||||
<SearchBar bind:value={noChildrenValue} placeholder="Quick search...">
|
||||
<div class="p-4 text-center text-sm text-muted-foreground">
|
||||
Start typing to see results
|
||||
</div>
|
||||
</SearchBar>
|
||||
<SearchBar bind:value={noChildrenValue} placeholder="Quick search..."> </SearchBar>
|
||||
</Story>
|
||||
|
||||
@@ -1,90 +1,75 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<!-- Component: SearchBar -->
|
||||
<script lang="ts">
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { Label } from '$shared/shadcn/ui/label';
|
||||
import {
|
||||
Content as PopoverContent,
|
||||
Root as PopoverRoot,
|
||||
Trigger as PopoverTrigger,
|
||||
} from '$shared/shadcn/ui/popover';
|
||||
import { useId } from 'bits-ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import AsteriskIcon from '@lucide/svelte/icons/asterisk';
|
||||
|
||||
interface Props {
|
||||
/** Unique identifier for the input element */
|
||||
/**
|
||||
* Unique identifier for the input element
|
||||
*/
|
||||
id?: string;
|
||||
/** Current search value (bindable) */
|
||||
/**
|
||||
* Current search value (bindable)
|
||||
*/
|
||||
value: string;
|
||||
/** Additional CSS classes for the container */
|
||||
/**
|
||||
* Additional CSS classes for the container
|
||||
*/
|
||||
class?: string;
|
||||
/** Placeholder text for the input */
|
||||
/**
|
||||
* Placeholder text for the input
|
||||
*/
|
||||
placeholder?: string;
|
||||
/** Optional label displayed above the input */
|
||||
/**
|
||||
* Optional label displayed above the input
|
||||
*/
|
||||
label?: string;
|
||||
/** Content to render inside the popover (receives unique content ID) */
|
||||
children: Snippet<[{ id: string }]> | undefined;
|
||||
}
|
||||
|
||||
let {
|
||||
id = 'search-bar',
|
||||
value = $bindable(),
|
||||
value = $bindable(''),
|
||||
class: className,
|
||||
placeholder,
|
||||
label,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let triggerRef = $state<HTMLInputElement>(null!);
|
||||
// svelte-ignore state_referenced_locally
|
||||
const contentId = useId(id);
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputClick() {
|
||||
open = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<PopoverRoot bind:open>
|
||||
<PopoverTrigger bind:ref={triggerRef}>
|
||||
{#snippet child({ props })}
|
||||
{@const { onclick, ...rest } = props}
|
||||
<div {...rest} class="flex flex-row flex-1 w-full">
|
||||
{#if label}
|
||||
<Label for={id}>{label}</Label>
|
||||
{/if}
|
||||
<div class="relative w-full">
|
||||
<div class="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<AsteriskIcon class="size-4 stroke-gray-400 stroke-[1.5]" />
|
||||
</div>
|
||||
<Input
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
bind:value={value}
|
||||
onkeydown={handleKeyDown}
|
||||
onclick={handleInputClick}
|
||||
class="flex flex-row flex-1"
|
||||
class="
|
||||
h-16 w-full text-base
|
||||
backdrop-blur-md bg-white/80
|
||||
border border-gray-300/50
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
focus-visible:border-gray-400/60
|
||||
focus-visible:outline-none
|
||||
focus-visible:ring-1
|
||||
focus-visible:ring-gray-400/30
|
||||
focus-visible:bg-white/90
|
||||
hover:bg-white/90
|
||||
hover:border-gray-400/60
|
||||
text-gray-900
|
||||
placeholder:text-gray-400
|
||||
placeholder:font-mono
|
||||
placeholder:text-sm
|
||||
placeholder:tracking-wide
|
||||
pl-14 pr-6
|
||||
rounded-xl
|
||||
transition-all duration-200
|
||||
font-medium
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
onOpenAutoFocus={e => e.preventDefault()}
|
||||
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>
|
||||
</PopoverRoot>
|
||||
</div>
|
||||
|
||||
108
src/shared/ui/Section/Section.svelte
Normal file
108
src/shared/ui/Section/Section.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<!--
|
||||
Component: Section
|
||||
Provides a container for a page widget with snippets for a title
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import {
|
||||
type FlyParams,
|
||||
fly,
|
||||
} from 'svelte/transition';
|
||||
|
||||
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
||||
/**
|
||||
* Additional CSS classes to apply to the section container.
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Snippet for a title itself
|
||||
*/
|
||||
title?: Snippet<[{ className?: string }]>;
|
||||
/**
|
||||
* Snippet for a title icon
|
||||
*/
|
||||
icon?: Snippet<[{ className?: string }]>;
|
||||
/**
|
||||
* Index of the section
|
||||
*/
|
||||
index?: number;
|
||||
/**
|
||||
* Callback function to notify when the title visibility status changes
|
||||
*
|
||||
* @param index - Index of the section
|
||||
* @param isPast - Whether the section is past the current scroll position
|
||||
* @param title - Snippet for a title itself
|
||||
* @returns Cleanup callback
|
||||
*/
|
||||
onTitleStatusChange?: (index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) => () => void;
|
||||
/**
|
||||
* Snippet for the section content
|
||||
*/
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
const { class: className, title, icon, index = 0, onTitleStatusChange, children }: Props = $props();
|
||||
|
||||
let titleContainer = $state<HTMLElement>();
|
||||
const flyParams: FlyParams = { y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 };
|
||||
|
||||
// Track if the user has actually scrolled away from view
|
||||
let isScrolledPast = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!titleContainer) {
|
||||
return;
|
||||
}
|
||||
let cleanup: ((index: number) => void) | undefined;
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
const entry = entries[0];
|
||||
const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0;
|
||||
|
||||
if (isPast !== isScrolledPast) {
|
||||
isScrolledPast = isPast;
|
||||
cleanup = onTitleStatusChange?.(index, isPast, title);
|
||||
}
|
||||
}, {
|
||||
// Set threshold to 0 to trigger exactly when the last pixel leaves
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
observer.observe(titleContainer);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cleanup?.(index);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<section
|
||||
class={cn(
|
||||
'flex flex-col',
|
||||
className,
|
||||
)}
|
||||
in:fly={flyParams}
|
||||
out:fly={flyParams}
|
||||
>
|
||||
<div class="flex flex-col gap-2" bind:this={titleContainer}>
|
||||
<div class="flex items-center gap-3 opacity-60">
|
||||
{#if icon}
|
||||
{@render icon({ className: 'size-4 stroke-gray-900 stroke-1' })}
|
||||
<div class="w-px h-3 bg-gray-400/50"></div>
|
||||
{/if}
|
||||
{#if typeof index === 'number'}
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-gray-600">
|
||||
Component_{String(index).padStart(3, '0')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if title}
|
||||
{@render title({ className: 'text-5xl md:text-6xl font-semibold tracking-tighter text-gray-900 leading-[0.9]' })}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{@render children?.()}
|
||||
</section>
|
||||
@@ -6,11 +6,15 @@
|
||||
- Keyboard navigation (ArrowUp/Down, Home, End)
|
||||
- Fixed or dynamic item heights
|
||||
- ARIA listbox/option pattern with single tab stop
|
||||
- Custom shadcn ScrollArea scrollbar
|
||||
-->
|
||||
<script lang="ts" generics="T">
|
||||
import { createVirtualizer } from '$shared/lib';
|
||||
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -19,6 +23,20 @@ interface Props {
|
||||
* @template T - The type of items in the list
|
||||
*/
|
||||
items: T[];
|
||||
/**
|
||||
* Total number of items (including not-yet-loaded items for pagination).
|
||||
* If not provided, defaults to items.length.
|
||||
*
|
||||
* Use this when implementing pagination to ensure the scrollbar
|
||||
* reflects the total count of items, not just the loaded ones.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Pagination scenario: 1920 total fonts, but only 50 loaded
|
||||
* <VirtualList items={loadedFonts} total={1920}>
|
||||
* ```
|
||||
*/
|
||||
total?: number;
|
||||
/**
|
||||
* Height for each item, either as a fixed number
|
||||
* or a function that returns height per index.
|
||||
@@ -40,6 +58,24 @@ interface Props {
|
||||
* @param items - Loaded items
|
||||
*/
|
||||
onVisibleItemsChange?: (items: T[]) => void;
|
||||
/**
|
||||
* An optional callback that will be called when user scrolls near the end of the list.
|
||||
* Useful for triggering auto-pagination.
|
||||
*
|
||||
* The callback receives the index of the last visible item. You can use this
|
||||
* to determine if you should load more data.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* onNearBottom={(lastVisibleIndex) => {
|
||||
* const itemsRemaining = total - lastVisibleIndex;
|
||||
* if (itemsRemaining < 5 && hasMore && !isFetching) {
|
||||
* loadMore();
|
||||
* }
|
||||
* }}
|
||||
* ```
|
||||
*/
|
||||
onNearBottom?: (lastVisibleIndex: number) => void;
|
||||
/**
|
||||
* Snippet for rendering individual list items.
|
||||
*
|
||||
@@ -52,39 +88,79 @@ interface Props {
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
*/
|
||||
children: Snippet<[{ item: T; index: number }]>;
|
||||
/**
|
||||
* Snippet for rendering individual list items.
|
||||
*
|
||||
* The snippet receives an object containing:
|
||||
* - `item`: The item from the items array (type T)
|
||||
* - `index`: The current item's index in the array
|
||||
*
|
||||
* This pattern provides type safety and flexibility for
|
||||
* rendering different item types without prop drilling.
|
||||
*
|
||||
* @template T - The type of items in the list
|
||||
*/
|
||||
children: Snippet<
|
||||
[{ item: T; index: number; isFullyVisible: boolean; isPartiallyVisible: boolean; proximity: number }]
|
||||
>;
|
||||
/**
|
||||
* Whether to use the window as the scroll container.
|
||||
* @default false
|
||||
*/
|
||||
useWindowScroll?: boolean;
|
||||
}
|
||||
|
||||
let { items, itemHeight = 80, overscan = 5, class: className, onVisibleItemsChange, children }:
|
||||
Props = $props();
|
||||
let {
|
||||
items,
|
||||
total = items.length,
|
||||
itemHeight = 80,
|
||||
overscan = 5,
|
||||
class: className,
|
||||
onVisibleItemsChange,
|
||||
onNearBottom,
|
||||
children,
|
||||
useWindowScroll = false,
|
||||
}: Props = $props();
|
||||
|
||||
// Reference to the ScrollArea viewport element for attaching the virtualizer
|
||||
let viewportRef = $state<HTMLElement | null>(null);
|
||||
|
||||
const virtualizer = createVirtualizer(() => ({
|
||||
count: items.length,
|
||||
data: items,
|
||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||
overscan,
|
||||
useWindowScroll,
|
||||
}));
|
||||
|
||||
// Attach virtualizer.container action to the viewport when it becomes available
|
||||
$effect(() => {
|
||||
if (viewportRef) {
|
||||
const { destroy } = virtualizer.container(viewportRef);
|
||||
return destroy;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const visibleItems = virtualizer.items.map(item => items[item.index]);
|
||||
onVisibleItemsChange?.(visibleItems);
|
||||
|
||||
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
|
||||
if (virtualizer.items.length > 0 && onNearBottom) {
|
||||
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
|
||||
// Compare against loaded items length, not total
|
||||
const itemsRemaining = items.length - lastVisibleItem.index;
|
||||
|
||||
if (itemsRemaining <= 5) {
|
||||
onNearBottom(lastVisibleItem.index);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
use:virtualizer.container
|
||||
class={cn(
|
||||
'relative overflow-auto rounded-md bg-background',
|
||||
'h-150 w-full',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
style:height="{virtualizer.totalSize}px"
|
||||
class="w-full pointer-events-none"
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if useWindowScroll}
|
||||
<div class={cn('relative w-full', className)} bind:this={viewportRef}>
|
||||
<div style:height="{virtualizer.totalSize}px" class="relative w-full">
|
||||
{#each virtualizer.items as item (item.key)}
|
||||
<div
|
||||
use:virtualizer.measureElement
|
||||
@@ -92,7 +168,51 @@ $effect(() => {
|
||||
class="absolute top-0 left-0 w-full"
|
||||
style:transform="translateY({item.start}px)"
|
||||
>
|
||||
{@render children({ item: items[item.index], index: item.index })}
|
||||
{#if item.index < items.length}
|
||||
{@render children({
|
||||
// TODO: Fix indenation rule for this case
|
||||
item: items[item.index],
|
||||
index: item.index,
|
||||
isFullyVisible: item.isFullyVisible,
|
||||
isPartiallyVisible: item.isPartiallyVisible,
|
||||
proximity: item.proximity,
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ScrollArea
|
||||
bind:viewportRef
|
||||
class={cn(
|
||||
'relative rounded-md bg-background',
|
||||
'h-150 w-full',
|
||||
className,
|
||||
)}
|
||||
orientation="vertical"
|
||||
>
|
||||
<div style:height="{virtualizer.totalSize}px" class="relative w-full">
|
||||
{#each virtualizer.items as item (item.key)}
|
||||
<div
|
||||
use:virtualizer.measureElement
|
||||
data-index={item.index}
|
||||
class="absolute top-0 left-0 w-full"
|
||||
style:transform="translateY({item.start}px)"
|
||||
animate:flip={{ delay: 0, duration: 300, easing: quintOut }}
|
||||
>
|
||||
{#if item.index < items.length}
|
||||
{@render children({
|
||||
// TODO: Fix indenation rule for this case
|
||||
item: items[item.index],
|
||||
index: item.index,
|
||||
isFullyVisible: item.isFullyVisible,
|
||||
isPartiallyVisible: item.isPartiallyVisible,
|
||||
proximity: item.proximity,
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
|
||||
@@ -6,14 +6,22 @@
|
||||
|
||||
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
|
||||
import ComboControl from './ComboControl/ComboControl.svelte';
|
||||
import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte';
|
||||
import ContentEditable from './ContentEditable/ContentEditable.svelte';
|
||||
import ExpandableWrapper from './ExpandableWrapper/ExpandableWrapper.svelte';
|
||||
import IconButton from './IconButton/IconButton.svelte';
|
||||
import SearchBar from './SearchBar/SearchBar.svelte';
|
||||
import Section from './Section/Section.svelte';
|
||||
import VirtualList from './VirtualList/VirtualList.svelte';
|
||||
|
||||
export {
|
||||
CheckboxFilter,
|
||||
ComboControl,
|
||||
ComboControlV2,
|
||||
ContentEditable,
|
||||
ExpandableWrapper,
|
||||
IconButton,
|
||||
SearchBar,
|
||||
Section,
|
||||
VirtualList,
|
||||
};
|
||||
|
||||
2
src/widgets/ComparisonSlider/index.ts
Normal file
2
src/widgets/ComparisonSlider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './model';
|
||||
export { ComparisonSlider } from './ui';
|
||||
1
src/widgets/ComparisonSlider/model/index.ts
Normal file
1
src/widgets/ComparisonSlider/model/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { comparisonStore } from './stores/comparisonStore.svelte';
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
type UnifiedFont,
|
||||
fetchFontsByIds,
|
||||
unifiedFontStore,
|
||||
} from '$entities/Font';
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
|
||||
/**
|
||||
* Storage schema for comparison state
|
||||
*/
|
||||
interface ComparisonState {
|
||||
fontAId: string | null;
|
||||
fontBId: string | null;
|
||||
}
|
||||
|
||||
// Persistent storage for selected comparison fonts
|
||||
const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
|
||||
fontAId: null,
|
||||
fontBId: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Store for managing font comparison state
|
||||
* - Persists selection to localStorage
|
||||
* - Handles font fetching on initialization
|
||||
* - Manages sample text
|
||||
*/
|
||||
class ComparisonStore {
|
||||
#fontA = $state<UnifiedFont | undefined>();
|
||||
#fontB = $state<UnifiedFont | undefined>();
|
||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
||||
#isRestoring = $state(true);
|
||||
|
||||
constructor() {
|
||||
this.restoreFromStorage();
|
||||
|
||||
// Reactively set defaults if we aren't restoring and have no selection
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
// Wait until we are done checking storage
|
||||
if (this.#isRestoring) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already have a selection, do nothing
|
||||
if (this.#fontA && this.#fontB) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if fonts are available to set as defaults
|
||||
const fonts = unifiedFontStore.fonts;
|
||||
if (fonts.length >= 2) {
|
||||
// Only set if we really have nothing (fallback)
|
||||
if (!this.#fontA) this.#fontA = fonts[0];
|
||||
if (!this.#fontB) this.#fontB = fonts[fonts.length - 1];
|
||||
|
||||
// Sync defaults to storage so they persist if the user leaves
|
||||
this.updateStorage();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore state from persistent storage
|
||||
*/
|
||||
async restoreFromStorage() {
|
||||
this.#isRestoring = true;
|
||||
const { fontAId, fontBId } = storage.value;
|
||||
|
||||
if (fontAId && fontBId) {
|
||||
try {
|
||||
// Batch fetch the saved fonts
|
||||
const fonts = await fetchFontsByIds([fontAId, fontBId]);
|
||||
const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId);
|
||||
const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId);
|
||||
|
||||
if (loadedFontA && loadedFontB) {
|
||||
this.#fontA = loadedFontA;
|
||||
this.#fontB = loadedFontB;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[ComparisonStore] Failed to restore fonts:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark restoration as complete (whether success or fail)
|
||||
this.#isRestoring = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update storage with current state
|
||||
*/
|
||||
private updateStorage() {
|
||||
// Don't save if we are currently restoring (avoid race)
|
||||
if (this.#isRestoring) return;
|
||||
|
||||
storage.value = {
|
||||
fontAId: this.#fontA?.id ?? null,
|
||||
fontBId: this.#fontB?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Getters & Setters ---
|
||||
|
||||
get fontA() {
|
||||
return this.#fontA;
|
||||
}
|
||||
|
||||
set fontA(font: UnifiedFont | undefined) {
|
||||
this.#fontA = font;
|
||||
this.updateStorage();
|
||||
}
|
||||
|
||||
get fontB() {
|
||||
return this.#fontB;
|
||||
}
|
||||
|
||||
set fontB(font: UnifiedFont | undefined) {
|
||||
this.#fontB = font;
|
||||
this.updateStorage();
|
||||
}
|
||||
|
||||
get text() {
|
||||
return this.#sampleText;
|
||||
}
|
||||
|
||||
set text(value: string) {
|
||||
this.#sampleText = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if both fonts are selected
|
||||
*/
|
||||
get isReady() {
|
||||
return !!this.#fontA && !!this.#fontB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public initializer (optional, as constructor starts it)
|
||||
* Kept for compatibility if manual re-init is needed
|
||||
*/
|
||||
initialize() {
|
||||
if (!this.#isRestoring && !this.#fontA && !this.#fontB) {
|
||||
this.restoreFromStorage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const comparisonStore = new ComparisonStore();
|
||||
@@ -0,0 +1,230 @@
|
||||
<!--
|
||||
Component: ComparisonSlider (Ultimate Comparison Slider)
|
||||
|
||||
A multiline text comparison slider that morphs between two fonts.
|
||||
|
||||
Features:
|
||||
- Multiline support with precise line breaking matching container width.
|
||||
- Character-level morphing: Font changes exactly when the slider passes the character's global position.
|
||||
- Responsive layout with Tailwind breakpoints for font sizing.
|
||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
createCharacterComparison,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import type { LineData } from '$shared/lib';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import CharacterSlot from './components/CharacterSlot.svelte';
|
||||
import ControlsWrapper from './components/ControlsWrapper.svelte';
|
||||
import Labels from './components/Labels.svelte';
|
||||
import SliderLine from './components/SliderLine.svelte';
|
||||
|
||||
// Pair of fonts to compare
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
|
||||
let container: HTMLElement | undefined = $state();
|
||||
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
|
||||
let measureCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let isDragging = $state(false);
|
||||
|
||||
const weightControl = createTypographyControl({
|
||||
min: 100,
|
||||
max: 700,
|
||||
step: 100,
|
||||
value: 400,
|
||||
});
|
||||
|
||||
const heightControl = createTypographyControl({
|
||||
min: 1,
|
||||
max: 2,
|
||||
step: 0.05,
|
||||
value: 1.2,
|
||||
});
|
||||
|
||||
const sizeControl = createTypographyControl({
|
||||
min: 1,
|
||||
max: 112,
|
||||
step: 1,
|
||||
value: 64,
|
||||
});
|
||||
|
||||
/**
|
||||
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
||||
* Manages line breaking and character state based on fonts and container dimensions.
|
||||
*/
|
||||
const charComparison = createCharacterComparison(
|
||||
() => comparisonStore.text,
|
||||
() => fontA,
|
||||
() => fontB,
|
||||
() => weightControl.value,
|
||||
() => sizeControl.value,
|
||||
);
|
||||
|
||||
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
||||
|
||||
/** Physics-based spring for smooth handle movement */
|
||||
const sliderSpring = new Spring(50, {
|
||||
stiffness: 0.2, // Balanced for responsiveness
|
||||
damping: 0.7, // No bounce, just smooth stop
|
||||
});
|
||||
const sliderPos = $derived(sliderSpring.current);
|
||||
|
||||
/** Updates spring target based on pointer position */
|
||||
function handleMove(e: PointerEvent) {
|
||||
if (!isDragging || !container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const percentage = (x / rect.width) * 100;
|
||||
sliderSpring.target = percentage;
|
||||
}
|
||||
|
||||
function startDragging(e: PointerEvent) {
|
||||
if (
|
||||
e.target === controlsWrapperElement
|
||||
|| controlsWrapperElement?.contains(e.target as Node)
|
||||
) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
handleMove(e);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
const stop = () => (isDragging = false);
|
||||
window.addEventListener('pointerup', stop);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Re-run line breaking when container resizes or dependencies change
|
||||
$effect(() => {
|
||||
// React on text and typography settings changes
|
||||
const _text = comparisonStore.text;
|
||||
const _weight = weightControl.value;
|
||||
const _size = sizeControl.value;
|
||||
const _height = heightControl.value;
|
||||
|
||||
if (container && measureCanvas && fontA && fontB) {
|
||||
// Using rAF to ensure DOM is ready/stabilized
|
||||
requestAnimationFrame(() => {
|
||||
charComparison.breakIntoLines(container, measureCanvas);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handleResize = () => {
|
||||
if (container && measureCanvas) {
|
||||
charComparison.breakIntoLines(container, measureCanvas);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet renderLine(line: LineData, index: number)}
|
||||
<div
|
||||
bind:this={lineElements[index]}
|
||||
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
||||
style:height={`${heightControl.value}em`}
|
||||
style:line-height={`${heightControl.value}em`}
|
||||
>
|
||||
{#each line.text.split('') as char, charIndex}
|
||||
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
|
||||
<!--
|
||||
Single Character Span
|
||||
- Font Family switches based on `isPast`
|
||||
- Transitions/Transforms provide the "morph" feel
|
||||
-->
|
||||
{#if fontA && fontB}
|
||||
<CharacterSlot
|
||||
{char}
|
||||
{proximity}
|
||||
{isPast}
|
||||
weight={weightControl.value}
|
||||
size={sizeControl.value}
|
||||
fontAName={fontA.name}
|
||||
fontBName={fontB.name}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if fontA && fontB}
|
||||
<!-- Hidden canvas used for text measurement by the helper -->
|
||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
bind:this={container}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={Math.round(sliderPos)}
|
||||
aria-label="Font comparison slider"
|
||||
onpointerdown={startDragging}
|
||||
class="
|
||||
group relative w-full py-16 px-24 sm:py-24 sm:px-24 overflow-hidden
|
||||
rounded-[2.5rem]
|
||||
select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center
|
||||
backdrop-blur-lg bg-gradient-to-br from-gray-100/70 via-white/50 to-gray-100/60
|
||||
border border-gray-300/40
|
||||
shadow-[inset_0_4px_12px_0_rgba(0,0,0,0.12),inset_0_2px_4px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(255,255,255,0.8)]
|
||||
before:absolute before:inset-0 before:rounded-[2.5rem] before:p-[1px]
|
||||
before:bg-gradient-to-br before:from-black/5 before:via-black/2 before:to-transparent
|
||||
before:-z-10 before:blur-sm
|
||||
"
|
||||
>
|
||||
<!-- Text Rendering Container -->
|
||||
<div
|
||||
class="
|
||||
relative flex flex-col items-center gap-4
|
||||
text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15]
|
||||
z-10 pointer-events-none text-center
|
||||
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
|
||||
"
|
||||
style:perspective="1000px"
|
||||
>
|
||||
{#each charComparison.lines as line, lineIndex}
|
||||
<div
|
||||
class="relative w-full whitespace-nowrap"
|
||||
style:height={`${heightControl.value}em`}
|
||||
style:display="flex"
|
||||
style:align-items="center"
|
||||
style:justify-content="center"
|
||||
>
|
||||
{@render renderLine(line, lineIndex)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<SliderLine {sliderPos} {isDragging} />
|
||||
</div>
|
||||
|
||||
<Labels {fontA} {fontB} {sliderPos} weight={weightControl.value} />
|
||||
<!-- Since there're slider controls inside we put them outside the main one -->
|
||||
<ControlsWrapper
|
||||
bind:wrapper={controlsWrapperElement}
|
||||
{sliderPos}
|
||||
{isDragging}
|
||||
bind:text={comparisonStore.text}
|
||||
containerWidth={container?.clientWidth}
|
||||
{weightControl}
|
||||
{sizeControl}
|
||||
{heightControl}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
fontName: string;
|
||||
isAnimating: boolean;
|
||||
onAnimationComplete?: () => void;
|
||||
}
|
||||
|
||||
let { text, fontName, isAnimating, onAnimationComplete }: Props = $props();
|
||||
|
||||
// Split text into characters, preserving spaces
|
||||
const chars = $derived(text.split('').map(c => c === ' ' ? '\u00A0' : c));
|
||||
|
||||
let completedCount = 0;
|
||||
|
||||
function handleTransitionEnd() {
|
||||
completedCount++;
|
||||
if (completedCount === chars.length) {
|
||||
onAnimationComplete?.();
|
||||
completedCount = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative inline-flex flex-wrap leading-tight">
|
||||
{#each chars as char, i}
|
||||
<span
|
||||
class={cn(
|
||||
'inline-block transition-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
|
||||
isAnimating ? 'opacity-0 -translate-y-4 rotate-x-90' : 'opacity-100 translate-y-0 rotate-x-0',
|
||||
)}
|
||||
style:font-family={fontName}
|
||||
style:transition-delay="{i * 25}ms"
|
||||
ontransitionend={i === chars.length - 1 ? handleTransitionEnd : null}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Necessary for the "Flip" feel */
|
||||
div {
|
||||
perspective: 1000px;
|
||||
}
|
||||
span {
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<!--
|
||||
Component: CharacterSlot
|
||||
Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Displayed character
|
||||
*/
|
||||
char: string;
|
||||
/**
|
||||
* Proximity of the character to the center of the slider
|
||||
*/
|
||||
proximity: number;
|
||||
/**
|
||||
* Flag indicating whether character needed to be changed
|
||||
*/
|
||||
isPast: boolean;
|
||||
/**
|
||||
* Font weight of the character
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Font size of the character
|
||||
*/
|
||||
size: number;
|
||||
/**
|
||||
* Name of the font for the character after the change
|
||||
*/
|
||||
fontAName: string;
|
||||
/**
|
||||
* Name of the font for the character before the change
|
||||
*/
|
||||
fontBName: string;
|
||||
}
|
||||
|
||||
let { char, proximity, isPast, weight, size, fontAName, fontBName }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
'inline-block transition-all duration-300 ease-out will-change-transform',
|
||||
isPast ? 'text-indigo-500' : 'text-neutral-950',
|
||||
)}
|
||||
style:font-family={isPast ? fontBName : fontAName}
|
||||
style:font-weight={weight}
|
||||
style:font-size={`${size}px`}
|
||||
style:transform="
|
||||
scale({1 + proximity * 0.3})
|
||||
translateY({-proximity * 12}px)
|
||||
rotateY({proximity * 25 * (isPast ? -1 : 1)}deg)
|
||||
"
|
||||
style:filter="brightness({1 + proximity * 0.2}) contrast({1 + proximity * 0.1})"
|
||||
style:text-shadow={proximity > 0.5 ? '0 0 15px rgba(99,102,241,0.3)' : 'none'}
|
||||
style:will-change={proximity > 0 ? 'transform, font-family, color' : 'auto'}
|
||||
>
|
||||
{char === ' ' ? '\u00A0' : char}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
span {
|
||||
/*
|
||||
Optimize for performance and smooth transitions.
|
||||
step-end logic is effectively handled by binary font switching in JS.
|
||||
*/
|
||||
transition:
|
||||
font-family 0.15s ease-out,
|
||||
color 0.2s ease-out,
|
||||
transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<!--
|
||||
Component: ControlsWrapper
|
||||
Wrapper for the controls of the slider.
|
||||
- Input to change the text
|
||||
- Three combo controls with inputs and sliders for font-weight, font-size, and line-height
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { TypographyControl } from '$shared/lib';
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { ComboControlV2 } from '$shared/ui';
|
||||
import { ExpandableWrapper } from '$shared/ui';
|
||||
import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
|
||||
import { Spring } from 'svelte/motion';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Ref
|
||||
*/
|
||||
wrapper?: HTMLDivElement | null;
|
||||
/**
|
||||
* Slider position
|
||||
*/
|
||||
sliderPos: number;
|
||||
/**
|
||||
* Whether slider is being dragged
|
||||
*/
|
||||
isDragging: boolean;
|
||||
/**
|
||||
* Text to display
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Container width
|
||||
*/
|
||||
containerWidth: number;
|
||||
/**
|
||||
* Weight control
|
||||
*/
|
||||
weightControl: TypographyControl;
|
||||
/**
|
||||
* Size control
|
||||
*/
|
||||
sizeControl: TypographyControl;
|
||||
/**
|
||||
* Height control
|
||||
*/
|
||||
heightControl: TypographyControl;
|
||||
}
|
||||
|
||||
let {
|
||||
sliderPos,
|
||||
isDragging,
|
||||
wrapper = $bindable(null),
|
||||
text = $bindable(),
|
||||
containerWidth = 0,
|
||||
weightControl,
|
||||
sizeControl,
|
||||
heightControl,
|
||||
}: Props = $props();
|
||||
|
||||
let panelWidth = $derived(wrapper?.clientWidth ?? 0);
|
||||
const margin = 24;
|
||||
let side = $state<'left' | 'right'>('left');
|
||||
// Unified active state for the entire wrapper
|
||||
let isActive = $state(false);
|
||||
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const xSpring = new Spring(0, {
|
||||
stiffness: 0.14, // Lower is slower
|
||||
damping: 0.5, // Settle
|
||||
});
|
||||
|
||||
const rotateSpring = new Spring(0, {
|
||||
stiffness: 0.12,
|
||||
damping: 0.55,
|
||||
});
|
||||
|
||||
function handleInputFocus() {
|
||||
isActive = true;
|
||||
}
|
||||
|
||||
// Movement Logic
|
||||
$effect(() => {
|
||||
if (containerWidth === 0 || panelWidth === 0) return;
|
||||
const sliderX = (sliderPos / 100) * containerWidth;
|
||||
const buffer = 40;
|
||||
const leftTrigger = margin + panelWidth + buffer;
|
||||
const rightTrigger = containerWidth - (margin + panelWidth + buffer);
|
||||
|
||||
if (side === 'left' && sliderX < leftTrigger) {
|
||||
side = 'right';
|
||||
} else if (side === 'right' && sliderX > rightTrigger) {
|
||||
side = 'left';
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
|
||||
if (containerWidth > 0 && panelWidth > 0) {
|
||||
// On side change set the position and the rotation
|
||||
xSpring.target = targetX;
|
||||
rotateSpring.target = side === 'right' ? 3.5 : -3.5;
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
rotateSpring.target = 0;
|
||||
}, 600);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute top-6 left-6 z-50 will-change-transform"
|
||||
style:transform="
|
||||
translateX({xSpring.current}px)
|
||||
rotateZ({rotateSpring.current}deg)
|
||||
"
|
||||
>
|
||||
<ExpandableWrapper
|
||||
bind:element={wrapper}
|
||||
bind:expanded={isActive}
|
||||
disabled={isDragging}
|
||||
aria-label="Font controls"
|
||||
rotation={side === 'right' ? 'counterclockwise' : 'clockwise'}
|
||||
class={cn(
|
||||
'transition-opacity flex items-top gap-1.5',
|
||||
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
>
|
||||
{#snippet badge()}
|
||||
<div
|
||||
class={cn(
|
||||
'animate-nudge relative transition-all',
|
||||
side === 'left' ? 'order-2' : 'order-0',
|
||||
isActive ? 'opacity-0' : 'opacity-100',
|
||||
isDragging && 'opacity-80 grayscale-[0.2]',
|
||||
)}
|
||||
>
|
||||
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet visibleContent()}
|
||||
<div class="relative px-2 py-1">
|
||||
<Input
|
||||
bind:value={text}
|
||||
disabled={isDragging}
|
||||
onfocusin={handleInputFocus}
|
||||
class={cn(
|
||||
isActive
|
||||
? 'h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50 text-slate-900'
|
||||
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm font-medium focus-visible:ring-0 text-slate-900/50',
|
||||
' placeholder:text-slate-400 selection:bg-indigo-100 flex-1 transition-all duration-350 w-56',
|
||||
)}
|
||||
placeholder="The quick brown fox..."
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet hiddenContent()}
|
||||
<div class="flex justify-between items-center-safe">
|
||||
<ComboControlV2 control={weightControl} />
|
||||
<ComboControlV2 control={sizeControl} />
|
||||
<ComboControlV2 control={heightControl} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</ExpandableWrapper>
|
||||
</div>
|
||||
@@ -0,0 +1,154 @@
|
||||
<!--
|
||||
Component: Labels
|
||||
Displays labels for font selection in the comparison slider.
|
||||
-->
|
||||
<script lang="ts" generics="T extends UnifiedFont">
|
||||
import {
|
||||
FontVirtualList,
|
||||
type UnifiedFont,
|
||||
unifiedFontStore,
|
||||
} from '$entities/Font';
|
||||
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
|
||||
import {
|
||||
Content as SelectContent,
|
||||
Item as SelectItem,
|
||||
Root as SelectRoot,
|
||||
Trigger as SelectTrigger,
|
||||
} from '$shared/shadcn/ui/select';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
|
||||
interface Props<T> {
|
||||
/**
|
||||
* First font to compare
|
||||
*/
|
||||
fontA: T;
|
||||
/**
|
||||
* Second font to compare
|
||||
*/
|
||||
fontB: T;
|
||||
/**
|
||||
* Position of the slider
|
||||
*/
|
||||
sliderPos: number;
|
||||
|
||||
weight: number;
|
||||
}
|
||||
let { fontA, fontB, sliderPos, weight }: Props<T> = $props();
|
||||
|
||||
const fontList = $derived(unifiedFontStore.fonts);
|
||||
|
||||
function selectFontA(font: UnifiedFont) {
|
||||
if (!font) return;
|
||||
comparisonStore.fontA = font;
|
||||
}
|
||||
|
||||
function selectFontB(font: UnifiedFont) {
|
||||
if (!font) return;
|
||||
comparisonStore.fontB = font;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet fontSelector(
|
||||
name: string,
|
||||
id: string,
|
||||
url: string,
|
||||
fonts: UnifiedFont[],
|
||||
selectFont: (font: UnifiedFont) => void,
|
||||
align: 'start' | 'end',
|
||||
)}
|
||||
<div
|
||||
class="z-50 pointer-events-auto"
|
||||
onpointerdown={(e => e.stopPropagation())}
|
||||
>
|
||||
<SelectRoot type="single" disabled={!fontList.length}>
|
||||
<SelectTrigger
|
||||
class={cn(
|
||||
'w-44 sm:w-52 h-9 border border-gray-300/40 bg-white/60 backdrop-blur-sm',
|
||||
'px-3 rounded-lg transition-all flex items-center justify-between gap-2',
|
||||
'font-mono text-[11px] tracking-tight font-medium text-gray-900',
|
||||
'hover:bg-white/80 hover:border-gray-400/60 hover:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<div class="text-left flex-1 min-w-0">
|
||||
<FontApplicator {name} {id} {url}>
|
||||
{name}
|
||||
</FontApplicator>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
class={cn(
|
||||
'bg-white/95 backdrop-blur-xl border border-gray-300/50 shadow-xl',
|
||||
'w-52 max-h-[280px] overflow-hidden rounded-lg',
|
||||
)}
|
||||
side="top"
|
||||
{align}
|
||||
sideOffset={8}
|
||||
size="small"
|
||||
>
|
||||
<div class="p-1.5">
|
||||
<FontVirtualList items={fonts} {weight}>
|
||||
{#snippet children({ item: font })}
|
||||
{@const handleClick = () => selectFont(font)}
|
||||
<SelectItem
|
||||
value={font.id}
|
||||
class="data-[highlighted]:bg-gray-100 font-mono text-[11px] px-3 py-2.5 rounded-md cursor-pointer transition-colors"
|
||||
onclick={handleClick}
|
||||
>
|
||||
<FontApplicator name={font.name} id={font.id} url={font.styles.regular!}>
|
||||
{font.name}
|
||||
</FontApplicator>
|
||||
</SelectItem>
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="absolute bottom-8 inset-x-6 sm:inset-x-12 flex justify-between items-end pointer-events-none z-20">
|
||||
<div
|
||||
class="flex flex-col gap-2 transition-all duration-500 items-start"
|
||||
style:opacity={sliderPos < 20 ? 0 : 1}
|
||||
style:transform="translateY({sliderPos < 20 ? '8px' : '0px'})"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 px-1">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.6)]"></div>
|
||||
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
||||
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
|
||||
ch_01
|
||||
</span>
|
||||
</div>
|
||||
{@render fontSelector(
|
||||
fontB.name,
|
||||
fontB.id,
|
||||
fontB.styles.regular!,
|
||||
fontList,
|
||||
selectFontB,
|
||||
'start',
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-end text-right gap-2 transition-all duration-500"
|
||||
style:opacity={sliderPos > 80 ? 0 : 1}
|
||||
style:transform="translateY({sliderPos > 80 ? '8px' : '0px'})"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 px-1">
|
||||
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
|
||||
ch_02
|
||||
</span>
|
||||
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-gray-900 shadow-[0_0_6px_rgba(0,0,0,0.4)]"></div>
|
||||
</div>
|
||||
{@render fontSelector(
|
||||
fontA.name,
|
||||
fontA.id,
|
||||
fontA.styles.regular!,
|
||||
fontList,
|
||||
selectFontA,
|
||||
'end',
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
<!--
|
||||
Component: SliderLine
|
||||
Visual representation of the slider line.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Position of the slider
|
||||
*/
|
||||
sliderPos: number;
|
||||
/**
|
||||
* Whether the slider is being dragged
|
||||
*/
|
||||
isDragging: boolean;
|
||||
}
|
||||
let { sliderPos, isDragging }: Props = $props();
|
||||
</script>
|
||||
<div
|
||||
class="absolute inset-y-4 pointer-events-none -translate-x-1/2 z-50 transition-all duration-300 ease-out flex flex-col justify-center items-center"
|
||||
style:left="{sliderPos}%"
|
||||
>
|
||||
<!-- We use part of lucide cursor svg icon as a handle -->
|
||||
<svg
|
||||
class={cn(
|
||||
'transition-all relative duration-300 text-black/80 drop-shadow-sm',
|
||||
isDragging ? 'w-12 h-12' : 'w-8 h-8',
|
||||
)}
|
||||
viewBox="0 0 24 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17 10h-1a4 4 0 0 1-4-4V0" />
|
||||
<path d="M7 10h1a4 4 0 0 0 4-4V0" />
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'relative h-full rounded-sm transition-all duration-500',
|
||||
'bg-white/[0.03] backdrop-blur-md',
|
||||
// These are the visible "edges" of the glass
|
||||
'shadow-[0_0_40px_rgba(0,0,0,0.1)_inset_0_0_20px_rgba(255,255,255,0.1)]',
|
||||
'shadow-[0_10px_30px_-10px_rgba(0,0,0,0.2),inset_0_1px_1px_rgba(255,255,255,0.4)]',
|
||||
'rounded-full',
|
||||
isDragging ? 'w-32' : 'w-16',
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- We use part of lucide cursor svg icon as a handle -->
|
||||
<svg
|
||||
class={cn(
|
||||
'transition-all relative duration-500 text-black/80 drop-shadow-sm',
|
||||
isDragging ? 'w-12 h-12' : 'w-8 h-8',
|
||||
)}
|
||||
viewBox="0 0 24 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17 2h-1a4 4 0 0 0-4 4v6" />
|
||||
<path d="M7 2h1a4 4 0 0 1 4 4v6" />
|
||||
</svg>
|
||||
</div>
|
||||
3
src/widgets/ComparisonSlider/ui/index.ts
Normal file
3
src/widgets/ComparisonSlider/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ComparisonSlider from './ComparisonSlider/ComparisonSlider.svelte';
|
||||
|
||||
export { ComparisonSlider };
|
||||
@@ -1,3 +0,0 @@
|
||||
import FiltersSidebar from './ui/FiltersSidebar.svelte';
|
||||
|
||||
export { FiltersSidebar };
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FilterControls,
|
||||
Filters,
|
||||
} from '$features/GetFonts';
|
||||
import {
|
||||
Content as SidebarContent,
|
||||
Root as SidebarRoot,
|
||||
} from '$shared/shadcn/ui/sidebar/index';
|
||||
/**
|
||||
* FiltersSidebar Component
|
||||
*
|
||||
* Main application sidebar widget. Contains filter controls and action buttons
|
||||
* for font filtering operations. Organized into two sections:
|
||||
*
|
||||
* - Filters: Category-based filter groups (providers, subsets, categories)
|
||||
* - Controls: Reset button for filter actions
|
||||
*
|
||||
* Features:
|
||||
* - Loading indicator during font fetch operations
|
||||
* - Empty state message when no fonts match filters
|
||||
* - Error display for failed font operations
|
||||
* - Responsive sidebar behavior via shadcn Sidebar component
|
||||
*
|
||||
* Uses Sidebar.Root from shadcn for responsive sidebar behavior including
|
||||
* mobile drawer and desktop persistent sidebar modes.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<SidebarRoot>
|
||||
<SidebarContent class="p-2">
|
||||
<!-- Filter groups -->
|
||||
<Filters />
|
||||
|
||||
<!-- Action buttons -->
|
||||
<FilterControls />
|
||||
</SidebarContent>
|
||||
</SidebarRoot>
|
||||
1
src/widgets/FontSearch/index.ts
Normal file
1
src/widgets/FontSearch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FontSearch } from './ui';
|
||||
128
src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte
Normal file
128
src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<!--
|
||||
Component: FontSearch
|
||||
Provides a search input and filtration for fonts
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { unifiedFontStore } from '$entities/Font';
|
||||
import {
|
||||
FilterControls,
|
||||
Filters,
|
||||
filterManager,
|
||||
mapManagerToParams,
|
||||
} from '$features/GetFonts';
|
||||
import { springySlideFade } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
IconButton,
|
||||
SearchBar,
|
||||
} from '$shared/ui';
|
||||
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import {
|
||||
Tween,
|
||||
prefersReducedMotion,
|
||||
} from 'svelte/motion';
|
||||
import { type SlideParams } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Controllable flag to show/hide filters (bindable)
|
||||
*/
|
||||
showFilters?: boolean;
|
||||
}
|
||||
|
||||
let { showFilters = $bindable(false) }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
* The Pairing:
|
||||
* We "plug" this manager into the global store.
|
||||
* addBinding returns a function that removes this binding when the component unmounts.
|
||||
*/
|
||||
const unbind = unifiedFontStore.addBinding(() => mapManagerToParams(filterManager));
|
||||
|
||||
return unbind;
|
||||
});
|
||||
|
||||
const transform = new Tween(
|
||||
{ scale: 1, rotate: 0 },
|
||||
{ duration: 250, easing: cubicOut },
|
||||
);
|
||||
|
||||
const slideConfig = $derived<SlideParams>({
|
||||
duration: prefersReducedMotion.current ? 0 : 200,
|
||||
easing: cubicOut,
|
||||
axis: 'y',
|
||||
});
|
||||
|
||||
function toggleFilters() {
|
||||
showFilters = !showFilters;
|
||||
|
||||
transform.set({ scale: 0.9, rotate: 2 }).then(() => {
|
||||
transform.set({ scale: 1, rotate: 0 });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 relative">
|
||||
<div class="relative">
|
||||
<SearchBar
|
||||
id="font-search"
|
||||
class="w-full"
|
||||
placeholder="search_typefaces..."
|
||||
label="query_input"
|
||||
bind:value={filterManager.queryValue}
|
||||
/>
|
||||
|
||||
<div class="absolute right-4 top-1/2 translate-y-[-50%] z-10">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-px h-5 bg-gray-300/60"></div>
|
||||
<div style:transform="scale({transform.current.scale})">
|
||||
<IconButton onclick={toggleFilters}>
|
||||
{#snippet icon({ className })}
|
||||
<SlidersHorizontalIcon
|
||||
class={cn(
|
||||
className,
|
||||
showFilters ? 'stroke-gray-900 stroke-3' : 'stroke-gray-500',
|
||||
)}
|
||||
/>
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showFilters}
|
||||
<div
|
||||
transition:springySlideFade|local={slideConfig}
|
||||
class="will-change-[height,opacity] contain-layout overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="
|
||||
p-4 rounded-xl
|
||||
backdrop-blur-md bg-white/80
|
||||
border border-gray-300/50
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 mb-4 opacity-70">
|
||||
<div class="w-1 h-1 rounded-full bg-gray-900"></div>
|
||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
||||
<span class="font-mono text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
|
||||
filter_params
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 grid-cols-[repeat(auto-fit,minmax(8em,14em))]">
|
||||
<Filters />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-gray-300/40">
|
||||
<FilterControls class="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user