feature/comparison-slider #19
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,6 +35,8 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
/docs
|
/docs
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
|
||||||
*storybook.log
|
*storybook.log
|
||||||
storybook-static
|
storybook-static
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
"https://plugins.dprint.dev/g-plane/markup_fmt-v0.25.3.wasm"
|
||||||
],
|
],
|
||||||
"typescript": {
|
"typescript": {
|
||||||
"lineWidth": 100,
|
"lineWidth": 120,
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"semiColons": "prefer",
|
"semiColons": "prefer",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"lineWidth": 100
|
"lineWidth": 100
|
||||||
},
|
},
|
||||||
"markup": {
|
"markup": {
|
||||||
"printWidth": 100,
|
"printWidth": 120,
|
||||||
"indentWidth": 4,
|
"indentWidth": 4,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"quotes": "double",
|
"quotes": "double",
|
||||||
|
|||||||
@@ -117,6 +117,8 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
font-family: 'Karla', system-ui, sans-serif;
|
||||||
|
font-optical-sizing: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,3 +140,25 @@
|
|||||||
.peer:focus-visible ~ * {
|
.peer:focus-visible ~ * {
|
||||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
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
|
* Layout Component
|
||||||
*
|
*
|
||||||
* Root layout wrapper that provides the application shell structure. Handles favicon,
|
* 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:
|
* Layout structure:
|
||||||
* - Header area (currently empty, reserved for future use)
|
* - 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
|
* - Footer area (currently empty, reserved for future use)
|
||||||
* throughout the application.
|
|
||||||
*/
|
*/
|
||||||
import favicon from '$shared/assets/favicon.svg';
|
import favicon from '$shared/assets/favicon.svg';
|
||||||
import * as Sidebar from '$shared/shadcn/ui/sidebar/index';
|
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||||
import { FiltersSidebar } from '$widgets/FiltersSidebar';
|
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||||
import TypographyMenu from '$widgets/TypographySettings/ui/TypographyMenu.svelte';
|
import { TypographyMenu } from '$widgets/TypographySettings';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
/** Slot content for route pages to render */
|
/** Slot content for route pages to render */
|
||||||
let { children } = $props();
|
let { children }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||||
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
<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>
|
</svelte:head>
|
||||||
|
|
||||||
<div id="app-root">
|
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
||||||
<header></header>
|
<header></header>
|
||||||
|
|
||||||
<Sidebar.Provider>
|
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||||
<FiltersSidebar />
|
<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">
|
||||||
<main class="w-dvw">
|
<TooltipProvider>
|
||||||
<TypographyMenu />
|
<TypographyMenu />
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
</TooltipProvider>
|
||||||
</main>
|
</main>
|
||||||
</Sidebar.Provider>
|
<!-- </ScrollArea> -->
|
||||||
<footer></footer>
|
<footer></footer>
|
||||||
</div>
|
</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
|
* 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 {
|
export {
|
||||||
fetchGoogleFontFamily,
|
fetchGoogleFontFamily,
|
||||||
fetchGoogleFonts,
|
fetchGoogleFonts,
|
||||||
@@ -14,6 +26,7 @@ export type {
|
|||||||
GoogleFontsResponse,
|
GoogleFontsResponse,
|
||||||
} from './google/googleFonts';
|
} from './google/googleFonts';
|
||||||
|
|
||||||
|
// Fontshare API (DEPRECATED - kept for backward compatibility)
|
||||||
export {
|
export {
|
||||||
fetchAllFontshareFonts,
|
fetchAllFontshareFonts,
|
||||||
fetchFontshareFontBySlug,
|
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 {
|
export {
|
||||||
fetchAllFontshareFonts,
|
fetchAllFontshareFonts,
|
||||||
fetchFontshareFontBySlug,
|
fetchFontshareFontBySlug,
|
||||||
@@ -7,6 +19,8 @@ export type {
|
|||||||
FontshareParams,
|
FontshareParams,
|
||||||
FontshareResponse,
|
FontshareResponse,
|
||||||
} from './api/fontshare/fontshare';
|
} from './api/fontshare/fontshare';
|
||||||
|
|
||||||
|
// Google Fonts API (DEPRECATED)
|
||||||
export {
|
export {
|
||||||
fetchGoogleFontFamily,
|
fetchGoogleFontFamily,
|
||||||
fetchGoogleFonts,
|
fetchGoogleFonts,
|
||||||
@@ -42,7 +56,6 @@ export type {
|
|||||||
FontshareFont,
|
FontshareFont,
|
||||||
FontshareLink,
|
FontshareLink,
|
||||||
FontsharePublisher,
|
FontsharePublisher,
|
||||||
FontshareStore,
|
|
||||||
FontshareStyle,
|
FontshareStyle,
|
||||||
FontshareStyleProperties,
|
FontshareStyleProperties,
|
||||||
FontshareTag,
|
FontshareTag,
|
||||||
@@ -61,18 +74,11 @@ export type {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
createFontshareStore,
|
createUnifiedFontStore,
|
||||||
fetchFontshareFontsQuery,
|
|
||||||
fontshareStore,
|
|
||||||
selectedFontsStore,
|
selectedFontsStore,
|
||||||
|
unifiedFontStore,
|
||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
// Stores
|
|
||||||
export {
|
|
||||||
createGoogleFontsStore,
|
|
||||||
GoogleFontsStore,
|
|
||||||
} from './model/services/fetchGoogleFonts.svelte';
|
|
||||||
|
|
||||||
// UI elements
|
// UI elements
|
||||||
export {
|
export {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
|
|||||||
@@ -24,11 +24,9 @@ describe('Font Normalization', () => {
|
|||||||
subsets: ['latin', 'latin-ext'],
|
subsets: ['latin', 'latin-ext'],
|
||||||
files: {
|
files: {
|
||||||
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2',
|
||||||
'700':
|
'700': 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
||||||
'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2',
|
|
||||||
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
italic: 'https://fonts.gstatic.com/s/roboto/v30/KFOkCnqEu92Fr1Mu51xIIzI.woff2',
|
||||||
'700italic':
|
'700italic': 'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
||||||
'https://fonts.gstatic.com/s/roboto/v30/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2',
|
|
||||||
},
|
},
|
||||||
version: 'v30',
|
version: 'v30',
|
||||||
lastModified: '2022-01-01',
|
lastModified: '2022-01-01',
|
||||||
|
|||||||
@@ -34,12 +34,10 @@ export type {
|
|||||||
UnifiedFontVariant,
|
UnifiedFontVariant,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export { fetchFontshareFontsQuery } from './services';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
createFontshareStore,
|
createUnifiedFontStore,
|
||||||
type FontshareStore,
|
|
||||||
fontshareStore,
|
|
||||||
selectedFontsStore,
|
selectedFontsStore,
|
||||||
|
type UnifiedFontStore,
|
||||||
|
unifiedFontStore,
|
||||||
} from './store';
|
} 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 type FontStatus = 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
|
export interface FontConfigRequest {
|
||||||
/**
|
/**
|
||||||
* Manager that handles loading of the fonts
|
* Font id
|
||||||
* Adds <link /> tags to <head />
|
*/
|
||||||
* - Uses batch loading to reduce the number of requests
|
id: string;
|
||||||
* - Uses a queue to prevent too many requests at once
|
/**
|
||||||
* - Purges unused fonts after a certain time
|
* 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 fonts.
|
||||||
|
* Logic:
|
||||||
|
* - Variable fonts: Loaded once per id (covers all weights).
|
||||||
|
* - Static fonts: Loaded per id + weight combination.
|
||||||
*/
|
*/
|
||||||
class AppliedFontsManager {
|
class AppliedFontsManager {
|
||||||
// Stores: slug -> timestamp of last visibility
|
|
||||||
#usageTracker = new Map<string, number>();
|
#usageTracker = new Map<string, number>();
|
||||||
// Stores: slug -> batchId
|
#idToBatch = new Map<string, string>();
|
||||||
#slugToBatch = new Map<string, string>();
|
// Changed to HTMLStyleElement
|
||||||
// Stores: batchId -> HTMLLinkElement (for physical cleanup)
|
#batchElements = new Map<string, HTMLStyleElement>();
|
||||||
#batchElements = new Map<string, HTMLLinkElement>();
|
|
||||||
|
|
||||||
#queue = new Set<string>();
|
#queue = new Map<string, FontConfigRequest>(); // Track config in queue
|
||||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
#PURGE_INTERVAL = 60000; // Check every minute
|
|
||||||
#TTL = 5 * 60 * 1000; // 5 minutes
|
#PURGE_INTERVAL = 60000;
|
||||||
#CHUNK_SIZE = 3;
|
#TTL = 5 * 60 * 1000;
|
||||||
|
#CHUNK_SIZE = 5; // Can be larger since we're just injecting strings
|
||||||
|
|
||||||
statuses = new SvelteMap<string, FontStatus>();
|
statuses = new SvelteMap<string, FontStatus>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Start the "Janitor" loop
|
|
||||||
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
#getFontKey(id: string, weight: number): string {
|
||||||
* Updates the 'last seen' timestamp for fonts.
|
return `${id.toLowerCase()}@${weight}`;
|
||||||
* Prevents them from being purged while they are on screen.
|
}
|
||||||
*/
|
|
||||||
touch(slugs: string[]) {
|
touch(configs: FontConfigRequest[]) {
|
||||||
const now = Date.now();
|
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 => {
|
if (!this.#idToBatch.has(key) && !this.#queue.has(key)) {
|
||||||
this.#usageTracker.set(slug, now);
|
this.#queue.set(key, config);
|
||||||
if (!this.#slugToBatch.has(slug)) {
|
|
||||||
toRegister.push(slug);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (toRegister.length > 0) this.registerFonts(toRegister);
|
|
||||||
}
|
|
||||||
|
|
||||||
registerFonts(slugs: string[]) {
|
|
||||||
const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s));
|
|
||||||
if (newSlugs.length === 0) return;
|
|
||||||
|
|
||||||
newSlugs.forEach(s => this.#queue.add(s));
|
|
||||||
|
|
||||||
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
if (this.#timeoutId) clearTimeout(this.#timeoutId);
|
||||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
this.#timeoutId = setTimeout(() => this.#processQueue(), 50);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getFontStatus(slug: string) {
|
getFontStatus(id: string, weight: number) {
|
||||||
return this.statuses.get(slug);
|
return this.statuses.get(this.#getFontKey(id, weight));
|
||||||
}
|
}
|
||||||
|
|
||||||
#processQueue() {
|
#processQueue() {
|
||||||
const fullQueue = Array.from(this.#queue);
|
const entries = Array.from(this.#queue.entries());
|
||||||
if (fullQueue.length === 0) return;
|
if (entries.length === 0) return;
|
||||||
|
|
||||||
for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) {
|
for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
|
||||||
this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE));
|
this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE));
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#queue.clear();
|
this.#queue.clear();
|
||||||
this.#timeoutId = null;
|
this.#timeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
#createBatch(slugs: string[]) {
|
#createBatch(batchEntries: [string, FontConfigRequest][]) {
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
const batchId = crypto.randomUUID();
|
const batchId = crypto.randomUUID();
|
||||||
// font-display=swap included for better UX
|
let cssRules = '';
|
||||||
const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&');
|
|
||||||
const url = `https://api.fontshare.com/v2/css?${query}&display=swap`;
|
|
||||||
|
|
||||||
// Mark all as loading immediately
|
batchEntries.forEach(([key, config]) => {
|
||||||
slugs.forEach(slug => this.statuses.set(slug, 'loading'));
|
this.statuses.set(key, 'loading');
|
||||||
|
this.#idToBatch.set(key, batchId);
|
||||||
|
|
||||||
const link = document.createElement('link');
|
// Construct the @font-face rule
|
||||||
link.rel = 'stylesheet';
|
// Using format('truetype') for .ttf
|
||||||
link.href = url;
|
cssRules += `
|
||||||
link.dataset.batchId = batchId;
|
@font-face {
|
||||||
document.head.appendChild(link);
|
font-family: '${config.name}';
|
||||||
|
src: url('${config.url}') format('truetype');
|
||||||
this.#batchElements.set(batchId, link);
|
font-weight: ${config.weight};
|
||||||
slugs.forEach(slug => {
|
font-style: normal;
|
||||||
this.#slugToBatch.set(slug, batchId);
|
font-display: swap;
|
||||||
|
|
||||||
// Use the Native Font Loading API
|
|
||||||
// format: "font-size font-family"
|
|
||||||
document.fonts.load(`1em "${slug}"`)
|
|
||||||
.then(loadedFonts => {
|
|
||||||
if (loadedFonts.length > 0) {
|
|
||||||
this.statuses.set(slug, 'loaded');
|
|
||||||
} else {
|
|
||||||
this.statuses.set(slug, 'error');
|
|
||||||
}
|
}
|
||||||
})
|
`;
|
||||||
.catch(() => {
|
|
||||||
this.statuses.set(slug, 'error');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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() {
|
#purgeUnused() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const batchesToPotentialDelete = new Set<string>();
|
const batchesToRemove = new Set<string>();
|
||||||
const slugsToDelete: string[] = [];
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
// Identify expired slugs
|
for (const [key, lastUsed] of this.#usageTracker.entries()) {
|
||||||
for (const [slug, lastUsed] of this.#usageTracker.entries()) {
|
|
||||||
if (now - lastUsed > this.#TTL) {
|
if (now - lastUsed > this.#TTL) {
|
||||||
const batchId = this.#slugToBatch.get(slug);
|
const batchId = this.#idToBatch.get(key);
|
||||||
if (batchId) batchesToPotentialDelete.add(batchId);
|
if (batchId) {
|
||||||
slugsToDelete.push(slug);
|
// Check if EVERY font in this batch is expired
|
||||||
}
|
const batchKeys = Array.from(this.#idToBatch.entries())
|
||||||
}
|
|
||||||
|
|
||||||
// Only remove a batch if ALL fonts in that batch are expired
|
|
||||||
batchesToPotentialDelete.forEach(batchId => {
|
|
||||||
const batchSlugs = Array.from(this.#slugToBatch.entries())
|
|
||||||
.filter(([_, bId]) => bId === batchId)
|
.filter(([_, bId]) => bId === batchId)
|
||||||
.map(([slug]) => slug);
|
.map(([k]) => k);
|
||||||
|
|
||||||
const allExpired = batchSlugs.every(s => slugsToDelete.includes(s));
|
const canDeleteBatch = batchKeys.every(k => {
|
||||||
|
const lastK = this.#usageTracker.get(k);
|
||||||
if (allExpired) {
|
return lastK && (now - lastK > this.#TTL);
|
||||||
this.#batchElements.get(batchId)?.remove();
|
|
||||||
this.#batchElements.delete(batchId);
|
|
||||||
batchSlugs.forEach(s => {
|
|
||||||
this.#slugToBatch.delete(s);
|
|
||||||
this.#usageTracker.delete(s);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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),
|
queryKey: this.getQueryKey(params),
|
||||||
queryFn: () => this.fetchFn(params),
|
queryFn: () => this.fetchFn(params),
|
||||||
staleTime: 5 * 60 * 1000,
|
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.
|
* Single export point for the unified font store infrastructure.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// export {
|
// Primary store (unified)
|
||||||
// createUnifiedFontStore,
|
|
||||||
// UNIFIED_FONT_STORE_KEY,
|
|
||||||
// type UnifiedFontStore,
|
|
||||||
// } from './unifiedFontStore.svelte';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createFontshareStore,
|
createUnifiedFontStore,
|
||||||
type FontshareStore,
|
type UnifiedFontStore,
|
||||||
fontshareStore,
|
unifiedFontStore,
|
||||||
} from './fontshareStore.svelte';
|
} from './unifiedFontStore.svelte';
|
||||||
|
|
||||||
|
// Applied fonts manager (CSS loading - unchanged)
|
||||||
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
|
||||||
|
|
||||||
|
// Selected fonts store (user selection - unchanged)
|
||||||
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';
|
export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte';
|
||||||
|
|||||||
@@ -1,25 +1,354 @@
|
|||||||
import { type Filter } from '$shared/lib';
|
/**
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
* Unified font store
|
||||||
import type { FontProvider } from '../types';
|
*
|
||||||
import type { CheckboxFilter } from '../types/common';
|
* Single source of truth for font data, powered by the proxy API.
|
||||||
import type { BaseFontStore } from './baseFontStore.svelte';
|
* Extends BaseFontStore for TanStack Query integration and reactivity.
|
||||||
import { createFontshareStore } from './fontshareStore.svelte';
|
*
|
||||||
import type { ProviderParams } from './types';
|
* 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 {
|
import type { ProxyFontsParams } from '../../api';
|
||||||
private sources: Partial<Record<FontProvider, BaseFontStore<ProviderParams>>>;
|
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 = {
|
* Accumulated fonts from all pages (for infinite scroll)
|
||||||
fontshare: createFontshareStore(initialConfig?.fontshare),
|
*/
|
||||||
|
#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
|
- Adds smooth transition when font appears
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { motion } from '$shared/lib';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { prefersReducedMotion } from 'svelte/motion';
|
||||||
import { appliedFontsManager } from '../../model';
|
import { appliedFontsManager } from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -20,6 +20,12 @@ interface Props {
|
|||||||
* Font id to load
|
* Font id to load
|
||||||
*/
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* Font weight
|
||||||
|
*/
|
||||||
|
weight?: number;
|
||||||
/**
|
/**
|
||||||
* Additional classes
|
* Additional classes
|
||||||
*/
|
*/
|
||||||
@@ -30,7 +36,7 @@ interface Props {
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { name, id, className, children }: Props = $props();
|
let { name, id, url, weight = 400, className, children }: Props = $props();
|
||||||
let element: Element;
|
let element: Element;
|
||||||
|
|
||||||
// Track if the user has actually scrolled this into view
|
// Track if the user has actually scrolled this into view
|
||||||
@@ -40,7 +46,7 @@ $effect(() => {
|
|||||||
const observer = new IntersectionObserver(entries => {
|
const observer = new IntersectionObserver(entries => {
|
||||||
if (entries[0].isIntersecting) {
|
if (entries[0].isIntersecting) {
|
||||||
hasEnteredViewport = true;
|
hasEnteredViewport = true;
|
||||||
appliedFontsManager.touch([id]);
|
appliedFontsManager.touch([{ id, weight, name, url }]);
|
||||||
|
|
||||||
// Once it has entered, we can stop observing to save CPU
|
// Once it has entered, we can stop observing to save CPU
|
||||||
observer.unobserve(element);
|
observer.unobserve(element);
|
||||||
@@ -50,12 +56,12 @@ $effect(() => {
|
|||||||
return () => observer.disconnect();
|
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)
|
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
|
||||||
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
|
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error'));
|
||||||
|
|
||||||
const transitionClasses = $derived(
|
const transitionClasses = $derived(
|
||||||
motion.reduced
|
prefersReducedMotion.current
|
||||||
? 'transition-none' // Disable CSS transitions if motion is reduced
|
? 'transition-none' // Disable CSS transitions if motion is reduced
|
||||||
: 'transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
: 'transition-all duration-700 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||||
);
|
);
|
||||||
@@ -67,8 +73,9 @@ const transitionClasses = $derived(
|
|||||||
class={cn(
|
class={cn(
|
||||||
transitionClasses,
|
transitionClasses,
|
||||||
// If reduced motion is on, we skip the transform/blur entirely
|
// 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 && !prefersReducedMotion.current
|
||||||
!shouldReveal && motion.reduced && 'opacity-0', // Still hide until font is ready, but no movement
|
&& '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',
|
shouldReveal && 'opacity-100 translate-y-0 scale-100 blur-0',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,84 +1,85 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: FontListItem
|
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">
|
<script lang="ts">
|
||||||
import { Badge } from '$shared/shadcn/ui/badge';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
import type { Snippet } from 'svelte';
|
||||||
import { Label } from '$shared/shadcn/ui/label';
|
import { Spring } from 'svelte/motion';
|
||||||
import {
|
import {
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
selectedFontsStore,
|
selectedFontsStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
import FontApplicator from '../FontApplicator/FontApplicator.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Object with information about font
|
* Object with information about font
|
||||||
*/
|
*/
|
||||||
font: UnifiedFont;
|
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 { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props();
|
||||||
|
|
||||||
const handleChange = (checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
selectedFontsStore.addOne(font);
|
|
||||||
} else {
|
|
||||||
selectedFontsStore.removeOne(font.id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selected = $derived(selectedFontsStore.has(font.id));
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="pb-1">
|
<div
|
||||||
<Label
|
class={cn('pb-1 will-change-transform')}
|
||||||
for={font.id}
|
style:opacity={bloom.current}
|
||||||
class="
|
style:transform="
|
||||||
w-full hover:bg-accent/50 flex items-start gap-3 rounded-lg border border-transparent p-3
|
scale({0.92 + (bloom.current * 0.08)})
|
||||||
active:scale-[0.98] active:transition-transform active:duration-75
|
translateY({(1 - bloom.current) * 10}px)
|
||||||
has-aria-checked:border-blue-600
|
|
||||||
has-aria-checked:bg-blue-50
|
|
||||||
dark:has-aria-checked:border-blue-900
|
|
||||||
dark:has-aria-checked:bg-blue-950
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="w-full">
|
{@render children?.(font)}
|
||||||
<div class="flex flex-row gap-1 w-full items-center justify-between">
|
|
||||||
<div class="flex flex-col gap-1 transition-all duration-150 ease-out">
|
|
||||||
<div class="flex flex-row gap-1">
|
|
||||||
<Badge variant="outline" class="text-[0.5rem]">
|
|
||||||
{font.provider}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" class="text-[0.5rem]">
|
|
||||||
{font.category}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<FontApplicator
|
|
||||||
id={font.id}
|
|
||||||
className="text-2xl"
|
|
||||||
name={font.name}
|
|
||||||
>
|
|
||||||
{font.name}
|
|
||||||
</FontApplicator>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
id={font.id}
|
|
||||||
checked={selected}
|
|
||||||
onCheckedChange={handleChange}
|
|
||||||
class="
|
|
||||||
transition-all duration-150 ease-out
|
|
||||||
data-[state=checked]:scale-100
|
|
||||||
data-[state=checked]:border-blue-600
|
|
||||||
data-[state=checked]:bg-blue-600
|
|
||||||
data-[state=checked]:text-white
|
|
||||||
dark:data-[state=checked]:border-blue-700
|
|
||||||
dark:data-[state=checked]:bg-blue-700
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,24 +3,40 @@
|
|||||||
- Renders a virtualized list of fonts
|
- Renders a virtualized list of fonts
|
||||||
- Handles font registration with the manager
|
- 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 { VirtualList } from '$shared/ui';
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
import { appliedFontsManager } from '../../model';
|
import {
|
||||||
|
type UnifiedFont,
|
||||||
|
appliedFontsManager,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
|
interface Props extends Omit<ComponentProps<typeof VirtualList<T>>, 'onVisibleItemsChange'> {
|
||||||
onVisibleItemsChange?: (items: T[]) => void;
|
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[]) {
|
function handleInternalVisibleChange(visibleItems: T[]) {
|
||||||
// Auto-register fonts with the manager
|
// Auto-register fonts with the manager
|
||||||
const slugs = visibleItems.map(item => item.id);
|
const configs = visibleItems.map<FontConfigRequest>(item => ({
|
||||||
appliedFontsManager.registerFonts(slugs);
|
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
|
// Forward the call to any external listener
|
||||||
onVisibleItemsChange?.(visibleItems);
|
onNearBottom?.(lastVisibleIndex);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -28,6 +44,7 @@ function handleInternalVisibleChange(visibleItems: T[]) {
|
|||||||
{items}
|
{items}
|
||||||
{...rest}
|
{...rest}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
|
onNearBottom={handleNearBottom}
|
||||||
>
|
>
|
||||||
{#snippet children(scope)}
|
{#snippet children(scope)}
|
||||||
{@render 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 {
|
import {
|
||||||
FontApplicator,
|
FontApplicator,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
|
selectedFontsStore,
|
||||||
} from '$entities/Font';
|
} 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 {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +24,10 @@ interface Props {
|
|||||||
* Text to display
|
* Text to display
|
||||||
*/
|
*/
|
||||||
text: string;
|
text: string;
|
||||||
|
/**
|
||||||
|
* Index of the font sampler
|
||||||
|
*/
|
||||||
|
index?: number;
|
||||||
/**
|
/**
|
||||||
* Font settings
|
* Font settings
|
||||||
*/
|
*/
|
||||||
@@ -29,18 +39,80 @@ interface Props {
|
|||||||
let {
|
let {
|
||||||
font,
|
font,
|
||||||
text = $bindable(),
|
text = $bindable(),
|
||||||
|
index = 0,
|
||||||
...restProps
|
...restProps
|
||||||
}: Props = $props();
|
}: 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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
w-full rounded-xl
|
w-full h-full rounded-2xl
|
||||||
bg-white p-6 border border-slate-200
|
flex flex-col
|
||||||
shadow-sm dark:border-slate-800 dark:bg-slate-950
|
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}>
|
<div class="px-6 py-3 border-b border-gray-200/60 flex items-center justify-between">
|
||||||
<ContentEditable bind:text={text} {...restProps} />
|
<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>
|
</FontApplicator>
|
||||||
</div>
|
</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 {
|
export {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
FontSearch,
|
|
||||||
} from './ui';
|
} 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';
|
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.
|
* Transforms UI filter state into proxy API query parameters.
|
||||||
* @returns - Partial fontshare params.
|
* 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 {
|
return {
|
||||||
q: manager.debouncedQueryValue,
|
// Search query (debounced)
|
||||||
// Map groups to specific API keys
|
q: manager.debouncedQueryValue || undefined,
|
||||||
categories: manager.getGroup('categories')?.instance.selectedProperties.map(p => p.value)
|
|
||||||
?? [],
|
// Provider filter (single value - proxy API doesn't support array)
|
||||||
tags: manager.getGroup('tags')?.instance.selectedProperties.map(p => p.value) ?? [],
|
// 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">
|
<script lang="ts">
|
||||||
import { Button } from '$shared/shadcn/ui/button';
|
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';
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-row gap-2">
|
<div
|
||||||
<Button
|
class={cn('flex flex-row gap-2', className)}
|
||||||
variant="outline"
|
style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
|
||||||
class="flex-1 cursor-pointer"
|
|
||||||
onclick={filterManager.deselectAllGlobal}
|
|
||||||
>
|
>
|
||||||
|
<Button
|
||||||
|
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
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 Filters from './Filters/Filters.svelte';
|
||||||
import FilterControls from './FiltersControl/FilterControls.svelte';
|
import FilterControls from './FiltersControl/FilterControls.svelte';
|
||||||
import FontSearch from './FontSearch/FontSearch.svelte';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
FontSearch,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export {
|
|||||||
controlManager,
|
controlManager,
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
FONT_SIZE_STEP,
|
FONT_SIZE_STEP,
|
||||||
FONT_WEIGHT_STEP,
|
FONT_WEIGHT_STEP,
|
||||||
|
|||||||
@@ -1,7 +1,53 @@
|
|||||||
import {
|
import {
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
|
type TypographyControl,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
} from '$shared/lib';
|
} 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.
|
* Creates a typography control manager that handles a collection of typography controls.
|
||||||
@@ -10,19 +56,5 @@ import {
|
|||||||
* @returns - Typography control manager instance.
|
* @returns - Typography control manager instance.
|
||||||
*/
|
*/
|
||||||
export function createTypographyControlManager(configs: ControlModel[]) {
|
export function createTypographyControlManager(configs: ControlModel[]) {
|
||||||
const controls = $state(
|
return new TypographyControlManager(configs);
|
||||||
configs.map(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => ({
|
|
||||||
id,
|
|
||||||
increaseLabel,
|
|
||||||
decreaseLabel,
|
|
||||||
controlLabel,
|
|
||||||
instance: createTypographyControl(config),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
get controls() {
|
|
||||||
return controls;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Font size constants
|
* Font size constants
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_FONT_SIZE = 16;
|
export const DEFAULT_FONT_SIZE = 48;
|
||||||
export const MIN_FONT_SIZE = 8;
|
export const MIN_FONT_SIZE = 8;
|
||||||
export const MAX_FONT_SIZE = 100;
|
export const MAX_FONT_SIZE = 100;
|
||||||
export const FONT_SIZE_STEP = 1;
|
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 MIN_LINE_HEIGHT = 1;
|
||||||
export const MAX_LINE_HEIGHT = 2;
|
export const MAX_LINE_HEIGHT = 2;
|
||||||
export const LINE_HEIGHT_STEP = 0.05;
|
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 {
|
export {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
FONT_SIZE_STEP,
|
FONT_SIZE_STEP,
|
||||||
FONT_WEIGHT_STEP,
|
FONT_WEIGHT_STEP,
|
||||||
|
|||||||
@@ -3,15 +3,19 @@ import { createTypographyControlManager } from '../../lib';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_FONT_SIZE,
|
DEFAULT_FONT_SIZE,
|
||||||
DEFAULT_FONT_WEIGHT,
|
DEFAULT_FONT_WEIGHT,
|
||||||
|
DEFAULT_LETTER_SPACING,
|
||||||
DEFAULT_LINE_HEIGHT,
|
DEFAULT_LINE_HEIGHT,
|
||||||
FONT_SIZE_STEP,
|
FONT_SIZE_STEP,
|
||||||
FONT_WEIGHT_STEP,
|
FONT_WEIGHT_STEP,
|
||||||
|
LETTER_SPACING_STEP,
|
||||||
LINE_HEIGHT_STEP,
|
LINE_HEIGHT_STEP,
|
||||||
MAX_FONT_SIZE,
|
MAX_FONT_SIZE,
|
||||||
MAX_FONT_WEIGHT,
|
MAX_FONT_WEIGHT,
|
||||||
|
MAX_LETTER_SPACING,
|
||||||
MAX_LINE_HEIGHT,
|
MAX_LINE_HEIGHT,
|
||||||
MIN_FONT_SIZE,
|
MIN_FONT_SIZE,
|
||||||
MIN_FONT_WEIGHT,
|
MIN_FONT_WEIGHT,
|
||||||
|
MIN_LETTER_SPACING,
|
||||||
MIN_LINE_HEIGHT,
|
MIN_LINE_HEIGHT,
|
||||||
} from '../const/const';
|
} from '../const/const';
|
||||||
|
|
||||||
@@ -49,6 +53,17 @@ const controlData: ControlModel[] = [
|
|||||||
decreaseLabel: 'Decrease Line Height',
|
decreaseLabel: 'Decrease Line Height',
|
||||||
controlLabel: '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);
|
export const controlManager = createTypographyControlManager(controlData);
|
||||||
|
|||||||
@@ -3,18 +3,19 @@
|
|||||||
Contains controls for setting up font properties.
|
Contains controls for setting up font properties.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<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 { ComboControl } from '$shared/ui';
|
||||||
import { controlManager } from '../model';
|
import { controlManager } from '../model';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-2 flex flex-row items-center gap-2">
|
<div class="py-2 px-10 flex flex-row items-center gap-2">
|
||||||
<SidebarTrigger />
|
<div class="flex flex-row gap-3">
|
||||||
<Separator orientation="vertical" class="h-full" />
|
|
||||||
<div class="flex flex-row gap-2">
|
|
||||||
{#each controlManager.controls as control (control.id)}
|
{#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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,94 @@
|
|||||||
|
<!--
|
||||||
|
Component: Page
|
||||||
|
Description: The main page component of the application.
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<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';
|
||||||
|
|
||||||
/**
|
let searchContainer: HTMLElement;
|
||||||
* Page Component
|
|
||||||
*/
|
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>
|
</script>
|
||||||
|
|
||||||
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
<!-- Font List -->
|
<!-- Font List -->
|
||||||
<div class="p-2">
|
<div class="p-2 h-full flex flex-col gap-3">
|
||||||
<FontDisplay />
|
<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>
|
</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),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: <T>(url: string, options?: RequestInit) =>
|
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
||||||
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>) {
|
updateOne(id: string, changes: Partial<T>) {
|
||||||
const entity = this.#entities.get(id);
|
const entity = this.#entities.get(id);
|
||||||
if (entity) {
|
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 });
|
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
|
* Area label for increase button
|
||||||
*/
|
*/
|
||||||
increaseLabel: string;
|
increaseLabel?: string;
|
||||||
/**
|
/**
|
||||||
* Area label for decrease button
|
* Area label for decrease button
|
||||||
*/
|
*/
|
||||||
decreaseLabel: string;
|
decreaseLabel?: string;
|
||||||
/**
|
/**
|
||||||
* Control area label
|
* Control area label
|
||||||
*/
|
*/
|
||||||
controlLabel: string;
|
controlLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTypographyControl<T extends ControlDataModel>(
|
export function createTypographyControl<T extends ControlDataModel>(
|
||||||
|
|||||||
@@ -4,16 +4,38 @@
|
|||||||
* Used to render visible items with absolute positioning based on computed offsets.
|
* Used to render visible items with absolute positioning based on computed offsets.
|
||||||
*/
|
*/
|
||||||
export interface VirtualItem {
|
export interface VirtualItem {
|
||||||
/** Index of the item in the data array */
|
/**
|
||||||
|
* Index of the item in the data array
|
||||||
|
*/
|
||||||
index: number;
|
index: number;
|
||||||
/** Offset from the top of the list in pixels */
|
/**
|
||||||
|
* Offset from the top of the list in pixels
|
||||||
|
*/
|
||||||
start: number;
|
start: number;
|
||||||
/** Height/size of the item in pixels */
|
/**
|
||||||
|
* Height/size of the item in pixels
|
||||||
|
*/
|
||||||
size: number;
|
size: number;
|
||||||
/** End position in pixels (start + size) */
|
/**
|
||||||
|
* End position in pixels (start + size)
|
||||||
|
*/
|
||||||
end: number;
|
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;
|
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.
|
* Can be useful for handling sticky headers or other UI elements.
|
||||||
*/
|
*/
|
||||||
scrollMargin?: number;
|
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 containerHeight = $state(0);
|
||||||
let measuredSizes = $state<Record<number, number>>({});
|
let measuredSizes = $state<Record<number, number>>({});
|
||||||
let elementRef: HTMLElement | null = null;
|
let elementRef: HTMLElement | null = null;
|
||||||
|
let elementOffsetTop = 0;
|
||||||
|
|
||||||
// By wrapping the getter in $derived, we track everything inside it
|
// By wrapping the getter in $derived, we track everything inside it
|
||||||
const options = $derived(optionsGetter());
|
const options = $derived(optionsGetter());
|
||||||
@@ -136,6 +164,8 @@ export function createVirtualizer<T>(
|
|||||||
|
|
||||||
let endIdx = startIdx;
|
let endIdx = startIdx;
|
||||||
const viewportEnd = scrollOffset + containerHeight;
|
const viewportEnd = scrollOffset + containerHeight;
|
||||||
|
const viewportCenter = scrollOffset + (containerHeight / 2);
|
||||||
|
|
||||||
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
||||||
endIdx++;
|
endIdx++;
|
||||||
}
|
}
|
||||||
@@ -144,13 +174,31 @@ export function createVirtualizer<T>(
|
|||||||
const end = Math.min(count, endIdx + overscan);
|
const end = Math.min(count, endIdx + overscan);
|
||||||
|
|
||||||
const result: VirtualItem[] = [];
|
const result: VirtualItem[] = [];
|
||||||
|
|
||||||
for (let i = start; i < end; i++) {
|
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({
|
result.push({
|
||||||
index: i,
|
index: i,
|
||||||
start: offsets[i],
|
start: itemStart,
|
||||||
size: measuredSizes[i] ?? options.estimateSize(i),
|
size: itemSize,
|
||||||
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
|
end: itemEnd,
|
||||||
key: options.getItemKey?.(i) ?? i,
|
key: options.getItemKey?.(i) ?? i,
|
||||||
|
isPartiallyVisible,
|
||||||
|
isFullyVisible,
|
||||||
|
proximity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +216,53 @@ export function createVirtualizer<T>(
|
|||||||
*/
|
*/
|
||||||
function container(node: HTMLElement) {
|
function container(node: HTMLElement) {
|
||||||
elementRef = node;
|
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;
|
containerHeight = node.offsetHeight;
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
@@ -189,6 +284,7 @@ export function createVirtualizer<T>(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let measurementBuffer: Record<number, number> = {};
|
let measurementBuffer: Record<number, number> = {};
|
||||||
let frameId: number | null = null;
|
let frameId: number | null = null;
|
||||||
@@ -207,23 +303,25 @@ export function createVirtualizer<T>(
|
|||||||
const index = parseInt(node.dataset.index || '', 10);
|
const index = parseInt(node.dataset.index || '', 10);
|
||||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
||||||
|
|
||||||
if (!isNaN(index) && measuredSizes[index] !== height) {
|
if (!isNaN(index)) {
|
||||||
// 1. Stuff the measurement into a temporary buffer
|
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;
|
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) {
|
if (frameId === null) {
|
||||||
frameId = requestAnimationFrame(() => {
|
frameId = requestAnimationFrame(() => {
|
||||||
// 3. Update the state once for all collected measurements
|
|
||||||
// We use spread to trigger a single fine-grained update
|
|
||||||
measuredSizes = { ...measuredSizes, ...measurementBuffer };
|
measuredSizes = { ...measuredSizes, ...measurementBuffer };
|
||||||
|
// Reset the buffer
|
||||||
// 4. Reset the buffer
|
|
||||||
measurementBuffer = {};
|
measurementBuffer = {};
|
||||||
frameId = null;
|
frameId = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(node);
|
resizeObserver.observe(node);
|
||||||
@@ -249,12 +347,23 @@ export function createVirtualizer<T>(
|
|||||||
const itemStart = offsets[index];
|
const itemStart = offsets[index];
|
||||||
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
|
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
|
||||||
let target = itemStart;
|
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 === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
|
||||||
if (align === 'end') target = itemStart - containerHeight + itemSize;
|
if (align === 'end') target = itemStart - containerHeight + itemSize;
|
||||||
|
|
||||||
elementRef.scrollTo({ top: target, behavior: 'smooth' });
|
elementRef.scrollTo({ top: target, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Computed array of visible items to render (reactive) */
|
/** Computed array of visible items to render (reactive) */
|
||||||
|
|||||||
@@ -26,3 +26,10 @@ export {
|
|||||||
type Entity,
|
type Entity,
|
||||||
type EntityStore,
|
type EntityStore,
|
||||||
} from './createEntityStore/createEntityStore.svelte';
|
} from './createEntityStore/createEntityStore.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createCharacterComparison,
|
||||||
|
type LineData,
|
||||||
|
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||||
|
|
||||||
|
export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte';
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
export {
|
export {
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
|
createCharacterComparison,
|
||||||
createDebouncedState,
|
createDebouncedState,
|
||||||
createEntityStore,
|
createEntityStore,
|
||||||
createFilter,
|
createFilter,
|
||||||
|
createPersistentStore,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
createVirtualizer,
|
createVirtualizer,
|
||||||
type Entity,
|
type Entity,
|
||||||
type EntityStore,
|
type EntityStore,
|
||||||
type Filter,
|
type Filter,
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
|
type LineData,
|
||||||
type Property,
|
type Property,
|
||||||
type TypographyControl,
|
type TypographyControl,
|
||||||
type VirtualItem,
|
type VirtualItem,
|
||||||
@@ -17,5 +20,6 @@ export {
|
|||||||
type VirtualizerOptions,
|
type VirtualizerOptions,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
|
||||||
export { motion } from './accessibility/motion.svelte';
|
|
||||||
export { splitArray } from './utils';
|
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',
|
'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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
|
||||||
'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',
|
||||||
secondary:
|
|
||||||
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
|
|
||||||
destructive:
|
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',
|
'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',
|
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);
|
onOpenChange(value);
|
||||||
|
|
||||||
// This sets the cookie to keep the sidebar state.
|
// This sets the cookie to keep the sidebar state.
|
||||||
document.cookie =
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||||
`${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
- Local transition prevents animation when component first renders
|
- Local transition prevents animation when component first renders
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Filter } from '$shared/lib';
|
import {
|
||||||
import { motion } from '$shared/lib';
|
type Filter,
|
||||||
|
springySlideFade,
|
||||||
|
} from '$shared/lib';
|
||||||
import { Badge } from '$shared/shadcn/ui/badge';
|
import { Badge } from '$shared/shadcn/ui/badge';
|
||||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||||
@@ -20,7 +22,7 @@ import {
|
|||||||
import { Label } from '$shared/shadcn/ui/label';
|
import { Label } from '$shared/shadcn/ui/label';
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { slide } from 'svelte/transition';
|
import { prefersReducedMotion } from 'svelte/motion';
|
||||||
|
|
||||||
interface PropertyFilterProps {
|
interface PropertyFilterProps {
|
||||||
/** Label for this filter group (e.g., "Properties", "Tags") */
|
/** 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
|
// Animation config respects user preferences - zero duration if reduced motion enabled
|
||||||
// Local modifier prevents animation on initial render, only animates user interactions
|
// Local modifier prevents animation on initial render, only animates user interactions
|
||||||
const slideConfig = $derived({
|
const slideConfig = $derived({
|
||||||
duration: motion.reduced ? 0 : 250,
|
duration: prefersReducedMotion.current ? 0 : 150,
|
||||||
easing: cubicOut,
|
easing: cubicOut,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ const hasSelection = $derived(selectedCount > 0);
|
|||||||
<!-- Collapsible card wrapper with subtle hover state for affordance -->
|
<!-- Collapsible card wrapper with subtle hover state for affordance -->
|
||||||
<CollapsibleRoot
|
<CollapsibleRoot
|
||||||
bind:open={isOpen}
|
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 -->
|
<!-- Trigger row: title, expand indicator, and optional count badge -->
|
||||||
<div class="flex items-center justify-between px-4 py-2">
|
<div class="flex items-center justify-between px-4 py-2">
|
||||||
@@ -57,8 +59,7 @@ const hasSelection = $derived(selectedCount > 0);
|
|||||||
class={buttonVariants({
|
class={buttonVariants({
|
||||||
variant: 'ghost',
|
variant: 'ghost',
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
class:
|
class: 'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
|
||||||
'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>
|
<h4 class="text-sm font-semibold">{displayedLabel}</h4>
|
||||||
@@ -88,8 +89,8 @@ const hasSelection = $derived(selectedCount > 0);
|
|||||||
<!-- Expandable content with slide animation -->
|
<!-- Expandable content with slide animation -->
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<div
|
<div
|
||||||
transition:slide|local={slideConfig}
|
transition:springySlideFade|local={slideConfig}
|
||||||
class="border-t"
|
class="will-change-[height,opacity]"
|
||||||
>
|
>
|
||||||
<div class="px-4 py-3">
|
<div class="px-4 py-3">
|
||||||
<div class="flex flex-col gap-0.5">
|
<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
|
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
|
<Checkbox
|
||||||
id={property.id}
|
id={property.id}
|
||||||
bind:checked={property.selected}
|
bind:checked={property.selected}
|
||||||
|
|||||||
@@ -8,13 +8,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TypographyControl } from '$shared/lib';
|
import type { TypographyControl } from '$shared/lib';
|
||||||
import { Button } from '$shared/shadcn/ui/button';
|
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 { 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 { 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 MinusIcon from '@lucide/svelte/icons/minus';
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||||
import type { ChangeEventHandler } from 'svelte/elements';
|
import type { ChangeEventHandler } from 'svelte/elements';
|
||||||
|
import IconButton from '../IconButton/IconButton.svelte';
|
||||||
|
|
||||||
interface ComboControlProps {
|
interface ComboControlProps {
|
||||||
/**
|
/**
|
||||||
@@ -67,30 +77,34 @@ const handleSliderChange = (newValue: number) => {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ButtonGroup.Root>
|
<TooltipRoot>
|
||||||
<Button
|
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
|
||||||
variant="outline"
|
<TooltipTrigger class="flex items-center">
|
||||||
size="icon"
|
<IconButton
|
||||||
aria-label={decreaseLabel}
|
|
||||||
onclick={control.decrease}
|
onclick={control.decrease}
|
||||||
disabled={control.isAtMin}
|
disabled={control.isAtMin}
|
||||||
|
aria-label={decreaseLabel}
|
||||||
|
rotation="counterclockwise"
|
||||||
>
|
>
|
||||||
<MinusIcon />
|
{#snippet icon({ className })}
|
||||||
</Button>
|
<MinusIcon class={className} />
|
||||||
<Popover.Root>
|
{/snippet}
|
||||||
<Popover.Trigger>
|
</IconButton>
|
||||||
|
<PopoverRoot>
|
||||||
|
<PopoverTrigger>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<Button
|
<Button
|
||||||
{...props}
|
{...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"
|
size="icon"
|
||||||
aria-label={controlLabel}
|
aria-label={controlLabel}
|
||||||
>
|
>
|
||||||
{control.value}
|
{control.value}
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Popover.Trigger>
|
</PopoverTrigger>
|
||||||
<Popover.Content class="w-auto p-4">
|
<PopoverContent class="w-auto p-4">
|
||||||
<div class="flex flex-col items-center gap-3">
|
<div class="flex flex-col items-center gap-3">
|
||||||
<Slider
|
<Slider
|
||||||
min={control.min}
|
min={control.min}
|
||||||
@@ -110,15 +124,24 @@ const handleSliderChange = (newValue: number) => {
|
|||||||
class="w-16 text-center"
|
class="w-16 text-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Content>
|
</PopoverContent>
|
||||||
</Popover.Root>
|
</PopoverRoot>
|
||||||
<Button
|
|
||||||
variant="outline"
|
<IconButton
|
||||||
size="icon"
|
|
||||||
aria-label={increaseLabel}
|
aria-label={increaseLabel}
|
||||||
onclick={control.increase}
|
onclick={control.increase}
|
||||||
disabled={control.isAtMax}
|
disabled={control.isAtMax}
|
||||||
|
rotation="clockwise"
|
||||||
>
|
>
|
||||||
<PlusIcon />
|
{#snippet icon({ className })}
|
||||||
</Button>
|
<PlusIcon class={className} />
|
||||||
</ButtonGroup.Root>
|
{/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">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Visible text
|
* Visible text (bindable)
|
||||||
*/
|
*/
|
||||||
text: string;
|
text: string;
|
||||||
/**
|
/**
|
||||||
* Font settings
|
* Font size in pixels
|
||||||
*/
|
*/
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
|
/**
|
||||||
|
* Line height
|
||||||
|
*/
|
||||||
lineHeight?: number;
|
lineHeight?: number;
|
||||||
|
/**
|
||||||
|
* Letter spacing in pixels
|
||||||
|
*/
|
||||||
letterSpacing?: number;
|
letterSpacing?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +59,7 @@ function handleInput(e: Event) {
|
|||||||
w-full min-h-[1.2em] outline-none transition-all duration-200
|
w-full min-h-[1.2em] outline-none transition-all duration-200
|
||||||
empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400
|
empty:before:content-[attr(data-placeholder)] empty:before:text-slate-400
|
||||||
selection:bg-indigo-100 selection:text-indigo-900
|
selection:bg-indigo-100 selection:text-indigo-900
|
||||||
caret-indigo-500
|
caret-indigo-500 focus:outline-none
|
||||||
"
|
"
|
||||||
style:font-size="{fontSize}px"
|
style:font-size="{fontSize}px"
|
||||||
style:line-height={lineHeight}
|
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...',
|
placeholder: 'Type here...',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SearchBar bind:value={defaultSearchValue} placeholder="Type here...">
|
<SearchBar bind:value={defaultSearchValue} placeholder="Type here..."> </SearchBar>
|
||||||
Here will be the search result
|
|
||||||
<br />
|
|
||||||
Popover closes only when the user clicks outside the search bar or presses the Escape key.
|
|
||||||
</SearchBar>
|
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
@@ -60,11 +56,7 @@ let noChildrenValue = $state('');
|
|||||||
label: 'Search',
|
label: 'Search',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search">
|
<SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search"> </SearchBar>
|
||||||
<div class="p-4">
|
|
||||||
<p class="text-sm text-muted-foreground">No results found</p>
|
|
||||||
</div>
|
|
||||||
</SearchBar>
|
|
||||||
</Story>
|
</Story>
|
||||||
|
|
||||||
<Story
|
<Story
|
||||||
@@ -74,9 +66,5 @@ let noChildrenValue = $state('');
|
|||||||
placeholder: 'Quick search...',
|
placeholder: 'Quick search...',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SearchBar bind:value={noChildrenValue} placeholder="Quick search...">
|
<SearchBar bind:value={noChildrenValue} placeholder="Quick search..."> </SearchBar>
|
||||||
<div class="p-4 text-center text-sm text-muted-foreground">
|
|
||||||
Start typing to see results
|
|
||||||
</div>
|
|
||||||
</SearchBar>
|
|
||||||
</Story>
|
</Story>
|
||||||
|
|||||||
@@ -1,90 +1,75 @@
|
|||||||
<!--
|
<!-- Component: SearchBar -->
|
||||||
Component: SearchBar
|
|
||||||
|
|
||||||
Search input with popover dropdown for results/suggestions
|
|
||||||
- Features keyboard navigation (ArrowDown/Up/Enter) and auto-focus prevention on popover open.
|
|
||||||
- The input field serves as the popover trigger.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Input } from '$shared/shadcn/ui/input';
|
import { Input } from '$shared/shadcn/ui/input';
|
||||||
import { Label } from '$shared/shadcn/ui/label';
|
import AsteriskIcon from '@lucide/svelte/icons/asterisk';
|
||||||
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';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Unique identifier for the input element */
|
/**
|
||||||
|
* Unique identifier for the input element
|
||||||
|
*/
|
||||||
id?: string;
|
id?: string;
|
||||||
/** Current search value (bindable) */
|
/**
|
||||||
|
* Current search value (bindable)
|
||||||
|
*/
|
||||||
value: string;
|
value: string;
|
||||||
/** Additional CSS classes for the container */
|
/**
|
||||||
|
* Additional CSS classes for the container
|
||||||
|
*/
|
||||||
class?: string;
|
class?: string;
|
||||||
/** Placeholder text for the input */
|
/**
|
||||||
|
* Placeholder text for the input
|
||||||
|
*/
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** Optional label displayed above the input */
|
/**
|
||||||
|
* Optional label displayed above the input
|
||||||
|
*/
|
||||||
label?: string;
|
label?: string;
|
||||||
/** Content to render inside the popover (receives unique content ID) */
|
|
||||||
children: Snippet<[{ id: string }]> | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
id = 'search-bar',
|
id = 'search-bar',
|
||||||
value = $bindable(),
|
value = $bindable(''),
|
||||||
class: className,
|
class: className,
|
||||||
placeholder,
|
placeholder,
|
||||||
label,
|
|
||||||
children,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
let triggerRef = $state<HTMLInputElement>(null!);
|
|
||||||
// svelte-ignore state_referenced_locally
|
|
||||||
const contentId = useId(id);
|
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInputClick() {
|
|
||||||
open = true;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverRoot bind:open>
|
<div class="relative w-full">
|
||||||
<PopoverTrigger bind:ref={triggerRef}>
|
<div class="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||||
{#snippet child({ props })}
|
<AsteriskIcon class="size-4 stroke-gray-400 stroke-[1.5]" />
|
||||||
{@const { onclick, ...rest } = props}
|
</div>
|
||||||
<div {...rest} class="flex flex-row flex-1 w-full">
|
|
||||||
{#if label}
|
|
||||||
<Label for={id}>{label}</Label>
|
|
||||||
{/if}
|
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
bind:value={value}
|
bind:value={value}
|
||||||
onkeydown={handleKeyDown}
|
onkeydown={handleKeyDown}
|
||||||
onclick={handleInputClick}
|
class="
|
||||||
class="flex flex-row flex-1"
|
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>
|
</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>
|
|
||||||
|
|||||||
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)
|
- Keyboard navigation (ArrowUp/Down, Home, End)
|
||||||
- Fixed or dynamic item heights
|
- Fixed or dynamic item heights
|
||||||
- ARIA listbox/option pattern with single tab stop
|
- ARIA listbox/option pattern with single tab stop
|
||||||
|
- Custom shadcn ScrollArea scrollbar
|
||||||
-->
|
-->
|
||||||
<script lang="ts" generics="T">
|
<script lang="ts" generics="T">
|
||||||
import { createVirtualizer } from '$shared/lib';
|
import { createVirtualizer } from '$shared/lib';
|
||||||
|
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { quintOut } from 'svelte/easing';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +23,20 @@ interface Props {
|
|||||||
* @template T - The type of items in the list
|
* @template T - The type of items in the list
|
||||||
*/
|
*/
|
||||||
items: T[];
|
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
|
* Height for each item, either as a fixed number
|
||||||
* or a function that returns height per index.
|
* or a function that returns height per index.
|
||||||
@@ -40,6 +58,24 @@ interface Props {
|
|||||||
* @param items - Loaded items
|
* @param items - Loaded items
|
||||||
*/
|
*/
|
||||||
onVisibleItemsChange?: (items: T[]) => void;
|
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.
|
* Snippet for rendering individual list items.
|
||||||
*
|
*
|
||||||
@@ -52,39 +88,79 @@ interface Props {
|
|||||||
*
|
*
|
||||||
* @template T - The type of items in the list
|
* @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 }:
|
let {
|
||||||
Props = $props();
|
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(() => ({
|
const virtualizer = createVirtualizer(() => ({
|
||||||
count: items.length,
|
count: items.length,
|
||||||
data: items,
|
data: items,
|
||||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||||
overscan,
|
overscan,
|
||||||
|
useWindowScroll,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Attach virtualizer.container action to the viewport when it becomes available
|
||||||
|
$effect(() => {
|
||||||
|
if (viewportRef) {
|
||||||
|
const { destroy } = virtualizer.container(viewportRef);
|
||||||
|
return destroy;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const visibleItems = virtualizer.items.map(item => items[item.index]);
|
const visibleItems = virtualizer.items.map(item => items[item.index]);
|
||||||
onVisibleItemsChange?.(visibleItems);
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
{#if useWindowScroll}
|
||||||
use:virtualizer.container
|
<div class={cn('relative w-full', className)} bind:this={viewportRef}>
|
||||||
class={cn(
|
<div style:height="{virtualizer.totalSize}px" class="relative w-full">
|
||||||
'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>
|
|
||||||
|
|
||||||
{#each virtualizer.items as item (item.key)}
|
{#each virtualizer.items as item (item.key)}
|
||||||
<div
|
<div
|
||||||
use:virtualizer.measureElement
|
use:virtualizer.measureElement
|
||||||
@@ -92,7 +168,51 @@ $effect(() => {
|
|||||||
class="absolute top-0 left-0 w-full"
|
class="absolute top-0 left-0 w-full"
|
||||||
style:transform="translateY({item.start}px)"
|
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>
|
</div>
|
||||||
{/each}
|
{/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 CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
|
||||||
import ComboControl from './ComboControl/ComboControl.svelte';
|
import ComboControl from './ComboControl/ComboControl.svelte';
|
||||||
|
import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte';
|
||||||
import ContentEditable from './ContentEditable/ContentEditable.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 SearchBar from './SearchBar/SearchBar.svelte';
|
||||||
|
import Section from './Section/Section.svelte';
|
||||||
import VirtualList from './VirtualList/VirtualList.svelte';
|
import VirtualList from './VirtualList/VirtualList.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CheckboxFilter,
|
CheckboxFilter,
|
||||||
ComboControl,
|
ComboControl,
|
||||||
|
ComboControlV2,
|
||||||
ContentEditable,
|
ContentEditable,
|
||||||
|
ExpandableWrapper,
|
||||||
|
IconButton,
|
||||||
SearchBar,
|
SearchBar,
|
||||||
|
Section,
|
||||||
VirtualList,
|
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