Compare commits
50 Commits
8fda47ed57
...
d64de6f06b
| Author | SHA1 | Date | |
|---|---|---|---|
| d64de6f06b | |||
|
|
10788cf754 | ||
|
|
8eca240982 | ||
|
|
6f840fbad8 | ||
|
|
a7d08a9329 | ||
|
|
df2d6bae3b | ||
|
|
ce9665a842 | ||
|
|
b4e97da3a0 | ||
|
|
b3c0898735 | ||
|
|
f4875d7324 | ||
|
|
b16928ac80 | ||
|
|
7f01a9cc85 | ||
|
|
a1bc359c7f | ||
|
|
662d4ac626 | ||
|
|
4d7ae6c1c6 | ||
|
|
cb0e89b257 | ||
|
|
204aa75959 | ||
|
|
b72ec8afdf | ||
|
|
fa08986d60 | ||
|
|
359617212d | ||
|
|
beff194e5b | ||
|
|
f24c93c105 | ||
|
|
c16ef4acbf | ||
|
|
c91ced3617 | ||
|
|
a48c9bce0c | ||
|
|
152be85e34 | ||
|
|
b09b89f4fc | ||
|
|
1a23ec2f28 | ||
|
|
86ea9cd887 | ||
|
|
10919a9881 | ||
|
|
180abd150d | ||
|
|
c4bfb1db56 | ||
|
|
98a94e91ed | ||
|
|
a1b7f78fc4 | ||
|
|
41c5ceb848 | ||
|
|
780d76dced | ||
|
|
49f5564cc9 | ||
|
|
0ff8aec8f9 | ||
|
|
597ff7ec90 | ||
|
|
46a3c3e8fc | ||
|
|
4891cd3bbd | ||
|
|
70f2f82df0 | ||
|
|
0d572708c0 | ||
|
|
492c3573d0 | ||
|
|
a1080d3b34 | ||
|
|
fedf3f88e7 | ||
|
|
a26bcbecff | ||
|
|
352f30a558 | ||
|
|
8580884896 | ||
|
|
84417e440f |
@@ -61,6 +61,7 @@
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vaul-svelte": "^1.0.0-next.7",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.16",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<!--
|
||||
Component: QueryProvider
|
||||
Provides a QueryClientProvider for child components.
|
||||
|
||||
All components that use useQueryClient() or createQuery() must be
|
||||
descendants of this provider.
|
||||
-->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Query Provider Component
|
||||
*
|
||||
* All components that use useQueryClient() or createQuery() must be
|
||||
* descendants of this provider.
|
||||
*/
|
||||
import { queryClient } from '$shared/api/queryClient';
|
||||
import { QueryClientProvider } from '@tanstack/svelte-query';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
/** Slot content for child components */
|
||||
let { children } = $props();
|
||||
interface Props {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
* - Footer area (currently empty, reserved for future use)
|
||||
*/
|
||||
import { BreadcrumbHeader } from '$entities/Breadcrumb';
|
||||
import favicon from '$shared/assets/favicon.svg';
|
||||
import GD from '$shared/assets/GD.svg';
|
||||
import { ResponsiveProvider } from '$shared/lib';
|
||||
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||
import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
|
||||
import { TypographyMenu } from '$widgets/TypographySettings';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -26,7 +26,7 @@ let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="icon" href={GD} />
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||
|
||||
@@ -40,20 +40,25 @@ let { children }: Props = $props();
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Karla:wght@300&display=swap"
|
||||
rel="stylesheet"
|
||||
>
|
||||
|
||||
<title>
|
||||
Compare Typography & Typefaces | GlyphDiff
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
||||
<header>
|
||||
<BreadcrumbHeader />
|
||||
</header>
|
||||
<ResponsiveProvider>
|
||||
<div id="app-root" class="min-h-screen flex flex-col bg-background">
|
||||
<header>
|
||||
<BreadcrumbHeader />
|
||||
</header>
|
||||
|
||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||
<main class="flex-1 h-full w-full max-w-6xl mx-auto px-4 pt-6 pb-10 md:px-8 lg:pt-10 lg:pb-20 relative">
|
||||
<TooltipProvider>
|
||||
<TypographyMenu />
|
||||
{@render children?.()}
|
||||
</TooltipProvider>
|
||||
</main>
|
||||
<!-- </ScrollArea> -->
|
||||
<footer></footer>
|
||||
</div>
|
||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||
<main class="flex-1 h-full w-full max-w-6xl mx-auto px-0 pt-0 pb-10 sm:px-6 sm:pt-8 sm:pb-12 md:px-8 md:pt-10 md:pb-16 lg:px-10 lg:pt-12 lg:pb-20 xl:px-16 relative overflow-x-hidden">
|
||||
<TooltipProvider>
|
||||
{@render children?.()}
|
||||
</TooltipProvider>
|
||||
</main>
|
||||
<!-- </ScrollArea> -->
|
||||
<footer></footer>
|
||||
</div>
|
||||
</ResponsiveProvider>
|
||||
|
||||
@@ -19,29 +19,29 @@ import { scrollBreadcrumbsStore } from '../../model';
|
||||
backdrop-blur-lg bg-white/20
|
||||
border-b border-gray-300/50
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
h-12
|
||||
h-10 sm:h-12
|
||||
"
|
||||
>
|
||||
<div class="max-w-8xl mx-auto px-6 h-full flex items-center gap-4">
|
||||
<h1 class={cn('font-[Barlow] font-extralight')}>
|
||||
<div class="max-w-8xl mx-auto px-4 sm:px-6 h-full flex items-center gap-2 sm:gap-4">
|
||||
<h1 class={cn('font-[Barlow] font-extralight text-sm sm:text-base')}>
|
||||
GLYPHDIFF
|
||||
</h1>
|
||||
|
||||
<div class="h-4 w-px bg-gray-300/60"></div>
|
||||
<div class="h-3.5 sm:h-4 w-px bg-gray-300/60 hidden sm:block"></div>
|
||||
|
||||
<nav class="flex items-center gap-3 overflow-x-auto scrollbar-hide flex-1">
|
||||
<nav class="flex items-center gap-2 sm:gap-3 overflow-x-auto scrollbar-hide flex-1">
|
||||
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
|
||||
<div
|
||||
in:fly={{ duration: 300, y: -10, x: 100, opacity: 0 }}
|
||||
out:fly={{ duration: 300, y: 10, x: 100, opacity: 0 }}
|
||||
class="flex items-center gap-3 whitespace-nowrap shrink-0"
|
||||
class="flex items-center gap-2 sm:gap-3 whitespace-nowrap shrink-0"
|
||||
>
|
||||
<span class="font-mono text-[9px] text-gray-400 tracking-wider">
|
||||
<span class="font-mono text-[8px] sm:text-[9px] text-gray-400 tracking-wider">
|
||||
{String(item.index).padStart(2, '0')}
|
||||
</span>
|
||||
|
||||
{@render item.title({
|
||||
className: 'text-[10px] md:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-gray-900',
|
||||
className: 'text-[9px] sm:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-gray-900',
|
||||
})}
|
||||
|
||||
{#if idx < scrollBreadcrumbsStore.items.length - 1}
|
||||
@@ -55,9 +55,9 @@ import { scrollBreadcrumbsStore } from '../../model';
|
||||
{/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">
|
||||
<div class="flex items-center gap-1.5 sm:gap-2 opacity-50 ml-auto">
|
||||
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60 hidden sm:block"></div>
|
||||
<span class="font-mono text-[7px] sm:text-[8px] text-gray-400 tracking-wider">
|
||||
[{scrollBreadcrumbsStore.items.length}]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -86,14 +86,14 @@ function handleNearBottom(lastVisibleIndex: number) {
|
||||
{#key isLoading}
|
||||
<div class="relative w-full h-full" transition:fade={{ duration: 300 }}>
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex flex-col gap-3 sm:gap-4 p-3 sm:p-4">
|
||||
{#each Array(5) as _, i}
|
||||
<div class="flex flex-col gap-2 p-4 border rounded-xl border-gray-200/50 bg-white/40">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Skeleton class="h-8 w-1/3" />
|
||||
<Skeleton class="h-8 w-8 rounded-full" />
|
||||
<div class="flex flex-col gap-1.5 sm:gap-2 p-3 sm:p-4 border rounded-lg sm:rounded-xl border-gray-200/50 bg-white/40">
|
||||
<div class="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<Skeleton class="h-6 sm:h-8 w-1/3" />
|
||||
<Skeleton class="h-6 sm:h-8 w-6 sm:w-8 rounded-full" />
|
||||
</div>
|
||||
<Skeleton class="h-32 w-full" />
|
||||
<Skeleton class="h-24 sm:h-32 w-full" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import {
|
||||
ContentEditable,
|
||||
Footnote,
|
||||
// IconButton,
|
||||
} from '$shared/ui';
|
||||
// import XIcon from '@lucide/svelte/icons/x';
|
||||
@@ -44,7 +45,7 @@ let {
|
||||
}: Props = $props();
|
||||
|
||||
const fontWeight = $derived(controlManager.weight);
|
||||
const fontSize = $derived(controlManager.size);
|
||||
const fontSize = $derived(controlManager.renderedSize);
|
||||
const lineHeight = $derived(controlManager.height);
|
||||
const letterSpacing = $derived(controlManager.spacing);
|
||||
|
||||
@@ -55,7 +56,7 @@ function removeSample() {
|
||||
|
||||
<div
|
||||
class="
|
||||
w-full h-full rounded-2xl
|
||||
w-full h-full rounded-xl sm:rounded-2xl
|
||||
flex flex-col
|
||||
backdrop-blur-md bg-white/80
|
||||
border border-gray-300/50
|
||||
@@ -64,15 +65,15 @@ function removeSample() {
|
||||
"
|
||||
style:font-weight={fontWeight}
|
||||
>
|
||||
<div class="px-6 py-3 border-b border-gray-200/60 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="font-mono text-[9px] uppercase tracking-[0.25em] text-gray-500 font-medium">
|
||||
<div class="px-4 sm:px-5 md:px-6 py-2.5 sm:py-3 border-b border-gray-200/60 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 sm:gap-2.5">
|
||||
<Footnote>
|
||||
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">
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60"></div>
|
||||
<Footnote class="tracking-[0.15em] font-bold text-gray-900">
|
||||
{font.name}
|
||||
</span>
|
||||
</Footnote>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
@@ -87,7 +88,7 @@ function removeSample() {
|
||||
-->
|
||||
</div>
|
||||
|
||||
<div class="p-8 relative z-10">
|
||||
<div class="p-4 sm:p-5 md:p-8 relative z-10">
|
||||
<!-- TODO: Fix this ! -->
|
||||
<FontApplicator id={font.id} name={font.name} url={font.styles.regular!}>
|
||||
<ContentEditable
|
||||
@@ -100,21 +101,21 @@ function removeSample() {
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-2 border-t border-gray-200/40 w-full flex gap-4 bg-gray-50/30 mt-auto">
|
||||
<span class="text-[8px] font-mono text-gray-400 uppercase tracking-wider ml-auto">
|
||||
<div class="px-4 sm:px-5 md:px-6 py-1.5 sm:py-2 border-t border-gray-200/40 w-full flex flex-row gap-2 sm:gap-4 bg-gray-50/30 mt-auto">
|
||||
<Footnote class="text-[7px] sm:text-[8px] 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">
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
|
||||
<Footnote class="text-[7px] sm:text-[8px] 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">
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
|
||||
<Footnote class="text-[7px] sm:text-[8px] 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">
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
|
||||
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider">
|
||||
LTR:{letterSpacing}
|
||||
</span>
|
||||
</Footnote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import SetupFontMenu from './ui/SetupFontMenu.svelte';
|
||||
export { TypographyMenu } from './ui';
|
||||
|
||||
export {
|
||||
type ControlId,
|
||||
controlManager,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
@@ -15,5 +17,12 @@ export {
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LINE_HEIGHT,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from './model';
|
||||
export { SetupFontMenu };
|
||||
|
||||
export {
|
||||
createTypographyControlManager,
|
||||
type TypographyControlManager,
|
||||
} from './lib';
|
||||
|
||||
@@ -1,60 +1,215 @@
|
||||
import {
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
type PersistentStore,
|
||||
type TypographyControl,
|
||||
createPersistentStore,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
type ControlId,
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
} from '../../model';
|
||||
|
||||
export interface Control {
|
||||
id: string;
|
||||
increaseLabel?: string;
|
||||
decreaseLabel?: string;
|
||||
controlLabel?: string;
|
||||
type ControlOnlyFields<T extends string = string> = Omit<ControlModel<T>, keyof ControlDataModel>;
|
||||
|
||||
export interface Control extends ControlOnlyFields<ControlId> {
|
||||
instance: TypographyControl;
|
||||
}
|
||||
|
||||
export class TypographyControlManager {
|
||||
#controls = new SvelteMap<string, Control>();
|
||||
#multiplier = $state(1);
|
||||
#storage: PersistentStore<TypographySettings>;
|
||||
#baseSize = $state(DEFAULT_FONT_SIZE);
|
||||
|
||||
constructor(configs: ControlModel[]) {
|
||||
configs.forEach(({ id, increaseLabel, decreaseLabel, controlLabel, ...config }) => {
|
||||
this.#controls.set(id, {
|
||||
id,
|
||||
increaseLabel,
|
||||
decreaseLabel,
|
||||
controlLabel,
|
||||
instance: createTypographyControl(config),
|
||||
constructor(configs: ControlModel<ControlId>[], storage: PersistentStore<TypographySettings>) {
|
||||
this.#storage = storage;
|
||||
|
||||
// Initial Load
|
||||
const saved = storage.value;
|
||||
this.#baseSize = saved.fontSize;
|
||||
|
||||
// Setup Controls
|
||||
configs.forEach(config => {
|
||||
const initialValue = this.#getInitialValue(config.id, saved);
|
||||
|
||||
this.#controls.set(config.id, {
|
||||
...config,
|
||||
instance: createTypographyControl({
|
||||
...config,
|
||||
value: initialValue,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// The Sync Effect (UI -> Storage)
|
||||
// We access .value explicitly to ensure Svelte 5 tracks the dependency
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
// EXPLICIT DEPENDENCIES: Accessing these triggers the effect
|
||||
const fontSize = this.#baseSize;
|
||||
const fontWeight = this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
||||
const lineHeight = this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
||||
const letterSpacing = this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||
|
||||
// Syncing back to storage
|
||||
this.#storage.value = {
|
||||
fontSize,
|
||||
fontWeight,
|
||||
lineHeight,
|
||||
letterSpacing,
|
||||
};
|
||||
});
|
||||
|
||||
// The Font Size Proxy Effect
|
||||
// This handles the "Multiplier" logic specifically for the Font Size Control
|
||||
$effect(() => {
|
||||
const ctrl = this.#controls.get('font_size')?.instance;
|
||||
if (!ctrl) return;
|
||||
|
||||
// If the user moves the slider/clicks buttons in the UI:
|
||||
// We update the baseSize (User Intent)
|
||||
const currentDisplayValue = ctrl.value;
|
||||
const calculatedBase = currentDisplayValue / this.#multiplier;
|
||||
|
||||
// Only update if the difference is significant (prevents rounding jitter)
|
||||
if (Math.abs(this.#baseSize - calculatedBase) > 0.01) {
|
||||
this.#baseSize = calculatedBase;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#getInitialValue(id: string, saved: TypographySettings): number {
|
||||
if (id === 'font_size') return saved.fontSize * this.#multiplier;
|
||||
if (id === 'font_weight') return saved.fontWeight;
|
||||
if (id === 'line_height') return saved.lineHeight;
|
||||
if (id === 'letter_spacing') return saved.letterSpacing;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// --- Getters / Setters ---
|
||||
|
||||
get multiplier() {
|
||||
return this.#multiplier;
|
||||
}
|
||||
set multiplier(value: number) {
|
||||
if (this.#multiplier === value) return;
|
||||
this.#multiplier = value;
|
||||
|
||||
// When multiplier changes, we must update the Font Size Control's display value
|
||||
const ctrl = this.#controls.get('font_size')?.instance;
|
||||
if (ctrl) {
|
||||
ctrl.value = this.#baseSize * this.#multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
/** The scaled size for CSS usage */
|
||||
get renderedSize() {
|
||||
return this.#baseSize * this.#multiplier;
|
||||
}
|
||||
|
||||
/** The base size (User Preference) */
|
||||
get baseSize() {
|
||||
return this.#baseSize;
|
||||
}
|
||||
set baseSize(val: number) {
|
||||
this.#baseSize = val;
|
||||
const ctrl = this.#controls.get('font_size')?.instance;
|
||||
if (ctrl) ctrl.value = val * this.#multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getters for controls
|
||||
*/
|
||||
get controls() {
|
||||
return this.#controls.values();
|
||||
return Array.from(this.#controls.values());
|
||||
}
|
||||
|
||||
get weightControl() {
|
||||
return this.#controls.get('font_weight')?.instance;
|
||||
}
|
||||
|
||||
get sizeControl() {
|
||||
return this.#controls.get('font_size')?.instance;
|
||||
}
|
||||
|
||||
get heightControl() {
|
||||
return this.#controls.get('line_height')?.instance;
|
||||
}
|
||||
|
||||
get spacingControl() {
|
||||
return this.#controls.get('letter_spacing')?.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getters for values (besides font-size)
|
||||
*/
|
||||
get weight() {
|
||||
return this.#controls.get('font_weight')?.instance.value ?? 400;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.#controls.get('font_size')?.instance.value;
|
||||
return this.#controls.get('font_weight')?.instance.value ?? DEFAULT_FONT_WEIGHT;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.#controls.get('line_height')?.instance.value;
|
||||
return this.#controls.get('line_height')?.instance.value ?? DEFAULT_LINE_HEIGHT;
|
||||
}
|
||||
|
||||
get spacing() {
|
||||
return this.#controls.get('letter_spacing')?.instance.value;
|
||||
return this.#controls.get('letter_spacing')?.instance.value ?? DEFAULT_LETTER_SPACING;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#storage.clear();
|
||||
const defaults = this.#storage.value;
|
||||
|
||||
this.#baseSize = defaults.fontSize;
|
||||
|
||||
// Reset all control instances
|
||||
this.#controls.forEach(c => {
|
||||
if (c.id === 'font_size') {
|
||||
c.instance.value = defaults.fontSize * this.#multiplier;
|
||||
} else {
|
||||
// Map storage key to control id
|
||||
const key = c.id.replace('_', '') as keyof TypographySettings;
|
||||
// Simplified for brevity, you'd map these properly:
|
||||
if (c.id === 'font_weight') c.instance.value = defaults.fontWeight;
|
||||
if (c.id === 'line_height') c.instance.value = defaults.lineHeight;
|
||||
if (c.id === 'letter_spacing') c.instance.value = defaults.letterSpacing;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage schema for typography settings
|
||||
*/
|
||||
export interface TypographySettings {
|
||||
fontSize: number;
|
||||
fontWeight: number;
|
||||
lineHeight: number;
|
||||
letterSpacing: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typography control manager that handles a collection of typography controls.
|
||||
*
|
||||
* @param configs - Array of control configurations.
|
||||
* @param storageId - Persistent storage identifier.
|
||||
* @returns - Typography control manager instance.
|
||||
*/
|
||||
export function createTypographyControlManager(configs: ControlModel[]) {
|
||||
return new TypographyControlManager(configs);
|
||||
export function createTypographyControlManager(
|
||||
configs: ControlModel<ControlId>[],
|
||||
storageId: string = 'glyphdiff:typography',
|
||||
) {
|
||||
const storage = createPersistentStore<TypographySettings>(storageId, {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
fontWeight: DEFAULT_FONT_WEIGHT,
|
||||
lineHeight: DEFAULT_LINE_HEIGHT,
|
||||
letterSpacing: DEFAULT_LETTER_SPACING,
|
||||
});
|
||||
return new TypographyControlManager(configs, storage);
|
||||
}
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export { createTypographyControlManager } from './controlManager/controlManager.svelte';
|
||||
export {
|
||||
createTypographyControlManager,
|
||||
type TypographyControlManager,
|
||||
} from './controlManager/controlManager.svelte';
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { ControlModel } from '$shared/lib';
|
||||
import type { ControlId } from '..';
|
||||
|
||||
/**
|
||||
* Font size constants
|
||||
*/
|
||||
@@ -29,3 +32,57 @@ 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;
|
||||
|
||||
export const DEFAULT_TYPOGRAPHY_CONTROLS_DATA: ControlModel<ControlId>[] = [
|
||||
{
|
||||
id: 'font_size',
|
||||
value: DEFAULT_FONT_SIZE,
|
||||
max: MAX_FONT_SIZE,
|
||||
min: MIN_FONT_SIZE,
|
||||
step: FONT_SIZE_STEP,
|
||||
|
||||
increaseLabel: 'Increase Font Size',
|
||||
decreaseLabel: 'Decrease Font Size',
|
||||
controlLabel: 'Font Size',
|
||||
},
|
||||
{
|
||||
id: 'font_weight',
|
||||
value: DEFAULT_FONT_WEIGHT,
|
||||
max: MAX_FONT_WEIGHT,
|
||||
min: MIN_FONT_WEIGHT,
|
||||
step: FONT_WEIGHT_STEP,
|
||||
|
||||
increaseLabel: 'Increase Font Weight',
|
||||
decreaseLabel: 'Decrease Font Weight',
|
||||
controlLabel: 'Font Weight',
|
||||
},
|
||||
{
|
||||
id: 'line_height',
|
||||
value: DEFAULT_LINE_HEIGHT,
|
||||
max: MAX_LINE_HEIGHT,
|
||||
min: MIN_LINE_HEIGHT,
|
||||
step: LINE_HEIGHT_STEP,
|
||||
|
||||
increaseLabel: 'Increase Line Height',
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Line Height',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
value: DEFAULT_LETTER_SPACING,
|
||||
max: MAX_LETTER_SPACING,
|
||||
min: MIN_LETTER_SPACING,
|
||||
step: LETTER_SPACING_STEP,
|
||||
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Letter Spacing',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Font size multipliers
|
||||
*/
|
||||
export const MULTIPLIER_S = 0.5;
|
||||
export const MULTIPLIER_M = 0.75;
|
||||
export const MULTIPLIER_L = 1;
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
@@ -12,6 +13,12 @@ export {
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LINE_HEIGHT,
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
} from './const/const';
|
||||
|
||||
export { controlManager } from './state/manager.svelte';
|
||||
export {
|
||||
type ControlId,
|
||||
controlManager,
|
||||
} from './state/manager.svelte';
|
||||
|
||||
@@ -1,69 +1,6 @@
|
||||
import type { ControlModel } from '$shared/lib';
|
||||
import { createTypographyControlManager } from '../../lib';
|
||||
import {
|
||||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_FONT_WEIGHT,
|
||||
DEFAULT_LETTER_SPACING,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_STEP,
|
||||
FONT_WEIGHT_STEP,
|
||||
LETTER_SPACING_STEP,
|
||||
LINE_HEIGHT_STEP,
|
||||
MAX_FONT_SIZE,
|
||||
MAX_FONT_WEIGHT,
|
||||
MAX_LETTER_SPACING,
|
||||
MAX_LINE_HEIGHT,
|
||||
MIN_FONT_SIZE,
|
||||
MIN_FONT_WEIGHT,
|
||||
MIN_LETTER_SPACING,
|
||||
MIN_LINE_HEIGHT,
|
||||
} from '../const/const';
|
||||
import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA } from '../const/const';
|
||||
|
||||
const controlData: ControlModel[] = [
|
||||
{
|
||||
id: 'font_size',
|
||||
value: DEFAULT_FONT_SIZE,
|
||||
max: MAX_FONT_SIZE,
|
||||
min: MIN_FONT_SIZE,
|
||||
step: FONT_SIZE_STEP,
|
||||
export type ControlId = 'font_size' | 'font_weight' | 'line_height' | 'letter_spacing';
|
||||
|
||||
increaseLabel: 'Increase Font Size',
|
||||
decreaseLabel: 'Decrease Font Size',
|
||||
controlLabel: 'Font Size',
|
||||
},
|
||||
{
|
||||
id: 'font_weight',
|
||||
value: DEFAULT_FONT_WEIGHT,
|
||||
max: MAX_FONT_WEIGHT,
|
||||
min: MIN_FONT_WEIGHT,
|
||||
step: FONT_WEIGHT_STEP,
|
||||
|
||||
increaseLabel: 'Increase Font Weight',
|
||||
decreaseLabel: 'Decrease Font Weight',
|
||||
controlLabel: 'Font Weight',
|
||||
},
|
||||
{
|
||||
id: 'line_height',
|
||||
value: DEFAULT_LINE_HEIGHT,
|
||||
max: MAX_LINE_HEIGHT,
|
||||
min: MIN_LINE_HEIGHT,
|
||||
step: LINE_HEIGHT_STEP,
|
||||
|
||||
increaseLabel: 'Increase Line Height',
|
||||
decreaseLabel: 'Decrease Line Height',
|
||||
controlLabel: 'Line Height',
|
||||
},
|
||||
{
|
||||
id: 'letter_spacing',
|
||||
value: DEFAULT_LETTER_SPACING,
|
||||
max: MAX_LETTER_SPACING,
|
||||
min: MIN_LETTER_SPACING,
|
||||
step: LETTER_SPACING_STEP,
|
||||
|
||||
increaseLabel: 'Increase Letter Spacing',
|
||||
decreaseLabel: 'Decrease Letter Spacing',
|
||||
controlLabel: 'Letter Spacing',
|
||||
},
|
||||
];
|
||||
|
||||
export const controlManager = createTypographyControlManager(controlData);
|
||||
export const controlManager = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<!--
|
||||
Component: SetupFontMenu
|
||||
Contains controls for setting up font properties.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ComboControl } from '$shared/ui';
|
||||
import { controlManager } from '../model';
|
||||
</script>
|
||||
|
||||
<div class="py-2 px-10 flex flex-row items-center gap-2">
|
||||
<div class="flex flex-row gap-3">
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
<ComboControl
|
||||
control={control.instance}
|
||||
increaseLabel={control.increaseLabel}
|
||||
decreaseLabel={control.decreaseLabel}
|
||||
controlLabel={control.controlLabel}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
121
src/features/SetupFont/ui/TypographyMenu.svelte
Normal file
121
src/features/SetupFont/ui/TypographyMenu.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<!--
|
||||
Component: TypographyMenu
|
||||
Provides a menu for selecting and configuring typography settings
|
||||
- On mobile the menu is displayed as a drawer
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import {
|
||||
Content as ItemContent,
|
||||
Root as ItemRoot,
|
||||
} from '$shared/shadcn/ui/item';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
ComboControlV2,
|
||||
Drawer,
|
||||
IconButton,
|
||||
} from '$shared/ui';
|
||||
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { crossfade } from 'svelte/transition';
|
||||
import {
|
||||
MULTIPLIER_L,
|
||||
MULTIPLIER_M,
|
||||
MULTIPLIER_S,
|
||||
controlManager,
|
||||
} from '../model';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className }: Props = $props();
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
const [send, receive] = crossfade({
|
||||
duration: 300,
|
||||
easing: cubicOut,
|
||||
fallback(node, params) {
|
||||
// If it can't find a pair, it falls back to a simple fade/slide
|
||||
return {
|
||||
duration: 300,
|
||||
css: t => `opacity: ${t}; transform: translateY(${(1 - t) * 10}px);`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets the common font size multiplier based on the current responsive state.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (!responsive) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case responsive.isMobile:
|
||||
controlManager.multiplier = MULTIPLIER_S;
|
||||
break;
|
||||
case responsive.isTablet:
|
||||
controlManager.multiplier = MULTIPLIER_M;
|
||||
break;
|
||||
case responsive.isDesktop:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
break;
|
||||
default:
|
||||
controlManager.multiplier = MULTIPLIER_L;
|
||||
break;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('w-auto max-screen z-10 flex justify-center', className)}
|
||||
in:receive={{ key: 'panel' }}
|
||||
out:send={{ key: 'panel' }}
|
||||
>
|
||||
{#if responsive.isMobile}
|
||||
<Drawer>
|
||||
{#snippet trigger({ onClick })}
|
||||
<IconButton onclick={onClick}>
|
||||
{#snippet icon({ className })}
|
||||
<SlidersIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, 'flex flex-col gap-6')}>
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
<ComboControlV2
|
||||
control={control.instance}
|
||||
orientation="horizontal"
|
||||
reduced
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Drawer>
|
||||
{:else}
|
||||
<ItemRoot
|
||||
variant="outline"
|
||||
class="w-full sm:w-auto max-w-full sm:max-w-max p-2 sm:p-2.5 rounded-xl sm:rounded-2xl backdrop-blur-lg"
|
||||
>
|
||||
<ItemContent class="flex flex-row justify-center items-center max-w-full sm:max-w-max">
|
||||
<div class="sm:py-2 sm:px-10 flex flex-row items-center gap-2">
|
||||
<div class="flex flex-row gap-3">
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
<ComboControlV2
|
||||
control={control.instance}
|
||||
increaseLabel={control.increaseLabel}
|
||||
decreaseLabel={control.decreaseLabel}
|
||||
controlLabel={control.controlLabel}
|
||||
orientation="vertical"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</ItemContent>
|
||||
</ItemRoot>
|
||||
{/if}
|
||||
</div>
|
||||
1
src/features/SetupFont/ui/index.ts
Normal file
1
src/features/SetupFont/ui/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TypographyMenu } from './TypographyMenu.svelte';
|
||||
@@ -5,7 +5,10 @@
|
||||
<script lang="ts">
|
||||
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Section } from '$shared/ui';
|
||||
import {
|
||||
Logo,
|
||||
Section,
|
||||
} from '$shared/ui';
|
||||
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
||||
import { FontSearch } from '$widgets/FontSearch';
|
||||
import { SampleList } from '$widgets/SampleList';
|
||||
@@ -42,8 +45,8 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
|
||||
</script>
|
||||
|
||||
<!-- Font List -->
|
||||
<div class="p-2 h-full flex flex-col gap-3">
|
||||
<Section class="my-12 gap-8" onTitleStatusChange={handleTitleStatusChanged}>
|
||||
<div class="p-2 sm:p-3 md:p-4 h-full flex flex-col gap-3 sm:gap-4">
|
||||
<Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" onTitleStatusChange={handleTitleStatusChanged}>
|
||||
{#snippet icon({ className })}
|
||||
<CodeIcon class={className} />
|
||||
{/snippet}
|
||||
@@ -52,9 +55,7 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
|
||||
Project_Codename
|
||||
</span>
|
||||
{/snippet}
|
||||
<h2 class={cn('font-[Barlow] font-thin text-7xl md:text-8xl text-justify [text-align-last:justify] [text-justify:inter-character]')}>
|
||||
GLYPHDIFF
|
||||
</h2>
|
||||
<Logo />
|
||||
</Section>
|
||||
|
||||
<Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
@@ -69,7 +70,7 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
|
||||
<ComparisonSlider />
|
||||
</Section>
|
||||
|
||||
<Section class="my-12 gap-8" index={2} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
<Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" index={2} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
{#snippet icon({ className })}
|
||||
<ScanSearchIcon class={className} />
|
||||
{/snippet}
|
||||
@@ -81,7 +82,7 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
|
||||
<FontSearch bind:showFilters={isExpanded} />
|
||||
</Section>
|
||||
|
||||
<Section class="my-12 gap-8" index={3} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
<Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" index={3} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
{#snippet icon({ className })}
|
||||
<LineSquiggleIcon class={className} />
|
||||
{/snippet}
|
||||
|
||||
4
src/shared/assets/GD.svg
Normal file
4
src/shared/assets/GD.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="52" height="35" viewBox="0 0 52 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.608 34.368C8.496 34.368 6.64 33.968 5.04 33.168C3.44 32.336 2.192 31.184 1.296 29.712C0.432 28.208 0 26.48 0 24.528V9.84C0 7.888 0.432 6.176 1.296 4.704C2.192 3.2 3.44 2.048 5.04 1.248C6.64 0.415999 8.496 0 10.608 0C12.688 0 14.528 0.415999 16.128 1.248C17.728 2.048 18.96 3.2 19.824 4.704C20.688 6.176 21.12 7.872 21.12 9.792V10.512C21.12 10.832 20.96 10.992 20.64 10.992H20.16C19.84 10.992 19.68 10.832 19.68 10.512V9.744C19.68 7.216 18.848 5.184 17.184 3.648C15.52 2.112 13.328 1.344 10.608 1.344C7.856 1.344 5.632 2.128 3.936 3.696C2.272 5.232 1.44 7.264 1.44 9.792V24.576C1.44 27.104 2.272 29.152 3.936 30.72C5.632 32.256 7.856 33.024 10.608 33.024C13.328 33.024 15.52 32.272 17.184 30.768C18.848 29.232 19.68 27.2 19.68 24.672V19.152C19.68 19.024 19.616 18.96 19.488 18.96H11.472C11.152 18.96 10.992 18.8 10.992 18.48V18.144C10.992 17.824 11.152 17.664 11.472 17.664H20.64C20.96 17.664 21.12 17.824 21.12 18.144V24.48C21.12 26.464 20.688 28.208 19.824 29.712C18.96 31.184 17.728 32.336 16.128 33.168C14.528 33.968 12.688 34.368 10.608 34.368Z" fill="white"/>
|
||||
<path d="M31.2124 33.984C30.8924 33.984 30.7324 33.824 30.7324 33.504V0.863997C30.7324 0.543998 30.8924 0.383998 31.2124 0.383998H42.1084C45.0204 0.383998 47.3084 1.168 48.9724 2.736C50.6684 4.272 51.5164 6.4 51.5164 9.12V25.248C51.5164 27.968 50.6684 30.112 48.9724 31.68C47.3084 33.216 45.0204 33.984 42.1084 33.984H31.2124ZM32.1724 32.448C32.1724 32.576 32.2364 32.64 32.3644 32.64H42.2044C44.6364 32.64 46.5564 31.984 47.9644 30.672C49.3724 29.328 50.0764 27.504 50.0764 25.2V9.216C50.0764 6.88 49.3724 5.056 47.9644 3.744C46.5564 2.4 44.6364 1.728 42.2044 1.728H32.3644C32.2364 1.728 32.1724 1.792 32.1724 1.92V32.448Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -49,3 +49,5 @@ export function createPersistentStore<T>(key: string, defaultValue: T) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type PersistentStore<T> = ReturnType<typeof createPersistentStore<T>>;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// $shared/lib/createResponsiveManager.svelte.ts
|
||||
|
||||
/**
|
||||
* Breakpoint definitions following common device sizes
|
||||
* Customize these values to match your design system
|
||||
*/
|
||||
export interface Breakpoints {
|
||||
/** Mobile devices (portrait phones) */
|
||||
mobile: number;
|
||||
/** Tablet portrait */
|
||||
tabletPortrait: number;
|
||||
/** Tablet landscape */
|
||||
tablet: number;
|
||||
/** Desktop */
|
||||
desktop: number;
|
||||
/** Large desktop */
|
||||
desktopLarge: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default breakpoints (matches common Tailwind-like breakpoints)
|
||||
*/
|
||||
const DEFAULT_BREAKPOINTS: Breakpoints = {
|
||||
mobile: 640, // sm
|
||||
tabletPortrait: 768, // md
|
||||
tablet: 1024, // lg
|
||||
desktop: 1280, // xl
|
||||
desktopLarge: 1536, // 2xl
|
||||
};
|
||||
|
||||
/**
|
||||
* Orientation type
|
||||
*/
|
||||
export type Orientation = 'portrait' | 'landscape';
|
||||
|
||||
/**
|
||||
* Creates a reactive responsive manager that tracks viewport size and breakpoints.
|
||||
*
|
||||
* Provides reactive getters for:
|
||||
* - Current breakpoint detection (isMobile, isTablet, etc.)
|
||||
* - Viewport dimensions (width, height)
|
||||
* - Device orientation (portrait/landscape)
|
||||
* - Custom breakpoint matching
|
||||
*
|
||||
* @param customBreakpoints - Optional custom breakpoint values
|
||||
* @returns Responsive manager instance with reactive properties
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* const responsive = createResponsiveManager();
|
||||
* </script>
|
||||
*
|
||||
* {#if responsive.isMobile}
|
||||
* <MobileNav />
|
||||
* {:else if responsive.isTablet}
|
||||
* <TabletNav />
|
||||
* {:else}
|
||||
* <DesktopNav />
|
||||
* {/if}
|
||||
*
|
||||
* <p>Width: {responsive.width}px</p>
|
||||
* <p>Orientation: {responsive.orientation}</p>
|
||||
* ```
|
||||
*/
|
||||
export function createResponsiveManager(customBreakpoints?: Partial<Breakpoints>) {
|
||||
const breakpoints: Breakpoints = {
|
||||
...DEFAULT_BREAKPOINTS,
|
||||
...customBreakpoints,
|
||||
};
|
||||
|
||||
// Reactive state
|
||||
let width = $state(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
let height = $state(typeof window !== 'undefined' ? window.innerHeight : 0);
|
||||
|
||||
// Derived breakpoint states
|
||||
const isMobile = $derived(width < breakpoints.mobile);
|
||||
const isTabletPortrait = $derived(
|
||||
width >= breakpoints.mobile && width < breakpoints.tabletPortrait,
|
||||
);
|
||||
const isTablet = $derived(
|
||||
width >= breakpoints.tabletPortrait && width < breakpoints.desktop,
|
||||
);
|
||||
const isDesktop = $derived(
|
||||
width >= breakpoints.desktop && width < breakpoints.desktopLarge,
|
||||
);
|
||||
const isDesktopLarge = $derived(width >= breakpoints.desktopLarge);
|
||||
|
||||
// Convenience groupings
|
||||
const isMobileOrTablet = $derived(width < breakpoints.desktop);
|
||||
const isTabletOrDesktop = $derived(width >= breakpoints.tabletPortrait);
|
||||
|
||||
// Orientation
|
||||
const orientation = $derived<Orientation>(height > width ? 'portrait' : 'landscape');
|
||||
const isPortrait = $derived(orientation === 'portrait');
|
||||
const isLandscape = $derived(orientation === 'landscape');
|
||||
|
||||
// Touch device detection (best effort)
|
||||
const isTouchDevice = $derived(
|
||||
typeof window !== 'undefined'
|
||||
&& ('ontouchstart' in window || navigator.maxTouchPoints > 0),
|
||||
);
|
||||
|
||||
/**
|
||||
* Initialize responsive tracking
|
||||
* Call this in an $effect or component mount
|
||||
*/
|
||||
function init() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleResize = () => {
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
};
|
||||
|
||||
// Use ResizeObserver for more accurate tracking
|
||||
const resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(document.documentElement);
|
||||
|
||||
// Fallback to window resize event
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
|
||||
// Initial measurement
|
||||
handleResize();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current width matches a custom breakpoint
|
||||
* @param min - Minimum width (inclusive)
|
||||
* @param max - Maximum width (exclusive)
|
||||
*/
|
||||
function matches(min: number, max?: number): boolean {
|
||||
if (max !== undefined) {
|
||||
return width >= min && width < max;
|
||||
}
|
||||
return width >= min;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current breakpoint name
|
||||
*/
|
||||
const currentBreakpoint = $derived<keyof Breakpoints | 'xs'>(
|
||||
(() => {
|
||||
if (isMobile) return 'mobile';
|
||||
if (isTabletPortrait) return 'tabletPortrait';
|
||||
if (isTablet) return 'tablet';
|
||||
if (isDesktop) return 'desktop';
|
||||
if (isDesktopLarge) return 'desktopLarge';
|
||||
return 'xs'; // Fallback for very small screens
|
||||
})(),
|
||||
);
|
||||
|
||||
return {
|
||||
// Dimensions
|
||||
get width() {
|
||||
return width;
|
||||
},
|
||||
get height() {
|
||||
return height;
|
||||
},
|
||||
|
||||
// Standard breakpoints
|
||||
get isMobile() {
|
||||
return isMobile;
|
||||
},
|
||||
get isTabletPortrait() {
|
||||
return isTabletPortrait;
|
||||
},
|
||||
get isTablet() {
|
||||
return isTablet;
|
||||
},
|
||||
get isDesktop() {
|
||||
return isDesktop;
|
||||
},
|
||||
get isDesktopLarge() {
|
||||
return isDesktopLarge;
|
||||
},
|
||||
|
||||
// Convenience groupings
|
||||
get isMobileOrTablet() {
|
||||
return isMobileOrTablet;
|
||||
},
|
||||
get isTabletOrDesktop() {
|
||||
return isTabletOrDesktop;
|
||||
},
|
||||
|
||||
// Orientation
|
||||
get orientation() {
|
||||
return orientation;
|
||||
},
|
||||
get isPortrait() {
|
||||
return isPortrait;
|
||||
},
|
||||
get isLandscape() {
|
||||
return isLandscape;
|
||||
},
|
||||
|
||||
// Device capabilities
|
||||
get isTouchDevice() {
|
||||
return isTouchDevice;
|
||||
},
|
||||
|
||||
// Current breakpoint
|
||||
get currentBreakpoint() {
|
||||
return currentBreakpoint;
|
||||
},
|
||||
|
||||
// Methods
|
||||
init,
|
||||
matches,
|
||||
|
||||
// Breakpoint values (for custom logic)
|
||||
breakpoints,
|
||||
};
|
||||
}
|
||||
|
||||
export const responsiveManager = createResponsiveManager();
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
responsiveManager.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for the responsive manager instance
|
||||
*/
|
||||
export type ResponsiveManager = ReturnType<typeof createResponsiveManager>;
|
||||
@@ -22,11 +22,11 @@ export interface ControlDataModel {
|
||||
step: number;
|
||||
}
|
||||
|
||||
export interface ControlModel extends ControlDataModel {
|
||||
export interface ControlModel<T extends string = string> extends ControlDataModel {
|
||||
/**
|
||||
* Control identifier
|
||||
*/
|
||||
id: string;
|
||||
id: T;
|
||||
/**
|
||||
* Area label for increase button
|
||||
*/
|
||||
@@ -59,10 +59,10 @@ export function createTypographyControl<T extends ControlDataModel>(
|
||||
return value;
|
||||
},
|
||||
set value(newValue) {
|
||||
value = roundToStepPrecision(
|
||||
clampNumber(newValue, min, max),
|
||||
step,
|
||||
);
|
||||
const rounded = roundToStepPrecision(clampNumber(newValue, min, max), step);
|
||||
if (value !== rounded) {
|
||||
value = rounded;
|
||||
}
|
||||
},
|
||||
get max() {
|
||||
return max;
|
||||
|
||||
@@ -202,6 +202,16 @@ export function createVirtualizer<T>(
|
||||
});
|
||||
}
|
||||
|
||||
// console.log('🎯 Virtual Items Calculation:', {
|
||||
// scrollOffset,
|
||||
// containerHeight,
|
||||
// viewportEnd,
|
||||
// startIdx,
|
||||
// endIdx,
|
||||
// withOverscan: { start, end },
|
||||
// itemCount: end - start,
|
||||
// });
|
||||
|
||||
return result;
|
||||
});
|
||||
// Svelte Actions (The DOM Interface)
|
||||
@@ -225,32 +235,48 @@ export function createVirtualizer<T>(
|
||||
return rect.top + window.scrollY;
|
||||
};
|
||||
|
||||
let cachedOffsetTop = getElementOffset();
|
||||
let cachedOffsetTop = 0;
|
||||
let rafId: number | null = null;
|
||||
containerHeight = window.innerHeight;
|
||||
|
||||
const handleScroll = () => {
|
||||
// Use cached offset for scroll calculations
|
||||
scrollOffset = Math.max(0, window.scrollY - cachedOffsetTop);
|
||||
if (rafId !== null) return;
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
// Get current position of element relative to viewport
|
||||
const rect = node.getBoundingClientRect();
|
||||
// Calculate how much of the element has scrolled past the top of viewport
|
||||
// When element.top is 0, element is at top of viewport
|
||||
// When element.top is -100, element has scrolled up 100px past viewport top
|
||||
const scrolledPastTop = Math.max(0, -rect.top);
|
||||
scrollOffset = scrolledPastTop;
|
||||
rafId = null;
|
||||
});
|
||||
|
||||
// 🔍 DIAGNOSTIC
|
||||
// console.log('📜 Scroll Event:', {
|
||||
// windowScrollY: window.scrollY,
|
||||
// elementRectTop: rect.top,
|
||||
// scrolledPastTop,
|
||||
// containerHeight
|
||||
// });
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
cachedOffsetTop = getElementOffset();
|
||||
handleScroll();
|
||||
};
|
||||
|
||||
// Initial setup
|
||||
requestAnimationFrame(() => {
|
||||
cachedOffsetTop = getElementOffset();
|
||||
handleScroll();
|
||||
});
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Initial calculation
|
||||
handleScroll();
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
@@ -259,6 +285,10 @@ export function createVirtualizer<T>(
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = null;
|
||||
}
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
elementRef = null;
|
||||
},
|
||||
};
|
||||
@@ -366,6 +396,12 @@ export function createVirtualizer<T>(
|
||||
}
|
||||
|
||||
return {
|
||||
get scrollOffset() {
|
||||
return scrollOffset;
|
||||
},
|
||||
get containerHeight() {
|
||||
return containerHeight;
|
||||
},
|
||||
/** Computed array of visible items to render (reactive) */
|
||||
get items() {
|
||||
return items;
|
||||
|
||||
@@ -32,4 +32,13 @@ export {
|
||||
type LineData,
|
||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||
|
||||
export { createPersistentStore } from './createPersistentStore/createPersistentStore.svelte';
|
||||
export {
|
||||
createPersistentStore,
|
||||
type PersistentStore,
|
||||
} from './createPersistentStore/createPersistentStore.svelte';
|
||||
|
||||
export {
|
||||
createResponsiveManager,
|
||||
type ResponsiveManager,
|
||||
responsiveManager,
|
||||
} from './createResponsiveManager/createResponsiveManager.svelte';
|
||||
|
||||
@@ -6,6 +6,7 @@ export {
|
||||
createEntityStore,
|
||||
createFilter,
|
||||
createPersistentStore,
|
||||
createResponsiveManager,
|
||||
createTypographyControl,
|
||||
createVirtualizer,
|
||||
type Entity,
|
||||
@@ -13,7 +14,10 @@ export {
|
||||
type Filter,
|
||||
type FilterModel,
|
||||
type LineData,
|
||||
type PersistentStore,
|
||||
type Property,
|
||||
type ResponsiveManager,
|
||||
responsiveManager,
|
||||
type TypographyControl,
|
||||
type VirtualItem,
|
||||
type Virtualizer,
|
||||
@@ -23,3 +27,5 @@ export {
|
||||
export { splitArray } from './utils';
|
||||
|
||||
export { springySlideFade } from './transitions';
|
||||
|
||||
export { ResponsiveProvider } from './providers';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<!--
|
||||
Component: ResponsiveProvider
|
||||
Provides a responsive manager to all children
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
type ResponsiveManager,
|
||||
createResponsiveManager,
|
||||
} from '$shared/lib/helpers';
|
||||
import { setContext } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
const responsive = createResponsiveManager();
|
||||
|
||||
// Initialize and cleanup
|
||||
$effect(() => {
|
||||
return responsive.init();
|
||||
});
|
||||
|
||||
// Provide to all children
|
||||
setContext('responsive', responsive);
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
1
src/shared/lib/providers/index.ts
Normal file
1
src/shared/lib/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ResponsiveProvider } from './ResponsiveProvider/ResponsiveProvider.svelte';
|
||||
7
src/shared/shadcn/ui/drawer/drawer-close.svelte
Normal file
7
src/shared/shadcn/ui/drawer/drawer-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Close bind:ref data-slot="drawer-close" {...restProps} />
|
||||
39
src/shared/shadcn/ui/drawer/drawer-content.svelte
Normal file
39
src/shared/shadcn/ui/drawer/drawer-content.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import type { WithoutChildrenOrChild } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
import DrawerOverlay from './drawer-overlay.svelte';
|
||||
import DrawerPortal from './drawer-portal.svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: DrawerPrimitive.ContentProps & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DrawerPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPortal {...portalProps}>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="drawer-content"
|
||||
class={cn(
|
||||
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
|
||||
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
|
||||
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
|
||||
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:end-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-s data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:start-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-e data-[vaul-drawer-direction=left]:sm:max-w-sm',
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<div class="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block">
|
||||
</div>
|
||||
{@render children?.()}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
17
src/shared/shadcn/ui/drawer/drawer-description.svelte
Normal file
17
src/shared/shadcn/ui/drawer/drawer-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DrawerPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="drawer-description"
|
||||
class={cn('text-muted-foreground text-sm', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
23
src/shared/shadcn/ui/drawer/drawer-footer.svelte
Normal file
23
src/shared/shadcn/ui/drawer/drawer-footer.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="drawer-footer"
|
||||
class={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
src/shared/shadcn/ui/drawer/drawer-header.svelte
Normal file
23
src/shared/shadcn/ui/drawer/drawer-header.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="drawer-header"
|
||||
class={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
12
src/shared/shadcn/ui/drawer/drawer-nested.svelte
Normal file
12
src/shared/shadcn/ui/drawer/drawer-nested.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let {
|
||||
shouldScaleBackground = true,
|
||||
open = $bindable(false),
|
||||
activeSnapPoint = $bindable(null),
|
||||
...restProps
|
||||
}: DrawerPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
|
||||
20
src/shared/shadcn/ui/drawer/drawer-overlay.svelte
Normal file
20
src/shared/shadcn/ui/drawer/drawer-overlay.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DrawerPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="drawer-overlay"
|
||||
class={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
src/shared/shadcn/ui/drawer/drawer-portal.svelte
Normal file
7
src/shared/shadcn/ui/drawer/drawer-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let { ...restProps }: DrawerPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Portal {...restProps} />
|
||||
17
src/shared/shadcn/ui/drawer/drawer-title.svelte
Normal file
17
src/shared/shadcn/ui/drawer/drawer-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: DrawerPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="drawer-title"
|
||||
class={cn('text-foreground font-semibold', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
src/shared/shadcn/ui/drawer/drawer-trigger.svelte
Normal file
7
src/shared/shadcn/ui/drawer/drawer-trigger.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: DrawerPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Trigger bind:ref data-slot="drawer-trigger" {...restProps} />
|
||||
12
src/shared/shadcn/ui/drawer/drawer.svelte
Normal file
12
src/shared/shadcn/ui/drawer/drawer.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from 'vaul-svelte';
|
||||
|
||||
let {
|
||||
shouldScaleBackground = true,
|
||||
open = $bindable(false),
|
||||
activeSnapPoint = $bindable(null),
|
||||
...restProps
|
||||
}: DrawerPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...restProps} />
|
||||
37
src/shared/shadcn/ui/drawer/index.ts
Normal file
37
src/shared/shadcn/ui/drawer/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import Close from './drawer-close.svelte';
|
||||
import Content from './drawer-content.svelte';
|
||||
import Description from './drawer-description.svelte';
|
||||
import Footer from './drawer-footer.svelte';
|
||||
import Header from './drawer-header.svelte';
|
||||
import NestedRoot from './drawer-nested.svelte';
|
||||
import Overlay from './drawer-overlay.svelte';
|
||||
import Portal from './drawer-portal.svelte';
|
||||
import Title from './drawer-title.svelte';
|
||||
import Trigger from './drawer-trigger.svelte';
|
||||
import Root from './drawer.svelte';
|
||||
|
||||
export {
|
||||
Close,
|
||||
Close as DrawerClose,
|
||||
Content,
|
||||
Content as DrawerContent,
|
||||
Description,
|
||||
Description as DrawerDescription,
|
||||
Footer,
|
||||
Footer as DrawerFooter,
|
||||
Header,
|
||||
Header as DrawerHeader,
|
||||
NestedRoot,
|
||||
NestedRoot as DrawerNestedRoot,
|
||||
Overlay,
|
||||
Overlay as DrawerOverlay,
|
||||
Portal,
|
||||
Portal as DrawerPortal,
|
||||
Root,
|
||||
//
|
||||
Root as Drawer,
|
||||
Title,
|
||||
Title as DrawerTitle,
|
||||
Trigger,
|
||||
Trigger as DrawerTrigger,
|
||||
};
|
||||
@@ -54,7 +54,7 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
class="w-full bg-card transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<!-- Trigger row: title, expand indicator, and optional count badge -->
|
||||
<div class="flex items-center justify-between px-4 py-2">
|
||||
<div class="flex items-center justify-between px-3 sm:px-4 py-2">
|
||||
<CollapsibleTrigger
|
||||
class={buttonVariants({
|
||||
variant: 'ghost',
|
||||
@@ -62,14 +62,14 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
class: 'flex-1 justify-between gap-2 hover:bg-transparent focus-visible:ring-1 focus-visible:ring-ring',
|
||||
})}
|
||||
>
|
||||
<h4 class="text-sm font-semibold">{displayedLabel}</h4>
|
||||
<h4 class="text-xs sm:text-sm font-semibold">{displayedLabel}</h4>
|
||||
|
||||
<!-- Badge only appears when items are selected to avoid clutter -->
|
||||
{#if hasSelection}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
data-testid="badge"
|
||||
class="mr-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums"
|
||||
class="mr-auto h-4 sm:h-5 min-w-4 sm:min-w-5 px-1 sm:px-1.5 text-[10px] sm:text-xs font-medium tabular-nums"
|
||||
>
|
||||
{selectedCount}
|
||||
</Badge>
|
||||
@@ -81,7 +81,7 @@ const hasSelection = $derived(selectedCount > 0);
|
||||
class="shrink-0 transition-transform duration-200 ease-out"
|
||||
style:transform={isOpen ? 'rotate(0deg)' : 'rotate(-90deg)'}
|
||||
>
|
||||
<ChevronDownIcon class="h-4 w-4" />
|
||||
<ChevronDownIcon class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import type { ChangeEventHandler } from 'svelte/elements';
|
||||
import IconButton from '../IconButton/IconButton.svelte';
|
||||
|
||||
interface ComboControlProps {
|
||||
interface Props {
|
||||
/**
|
||||
* Text for increase button aria-label
|
||||
*/
|
||||
@@ -43,6 +43,10 @@ interface ComboControlProps {
|
||||
* Control instance
|
||||
*/
|
||||
control: TypographyControl;
|
||||
/**
|
||||
* Reduced amount of controls
|
||||
*/
|
||||
reduced?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -50,7 +54,8 @@ const {
|
||||
decreaseLabel,
|
||||
increaseLabel,
|
||||
controlLabel,
|
||||
}: ComboControlProps = $props();
|
||||
reduced = false,
|
||||
}: Props = $props();
|
||||
|
||||
// Local state for the slider to prevent infinite loops
|
||||
// svelte-ignore state_referenced_locally - $state captures initial value, $effect syncs updates
|
||||
@@ -80,16 +85,18 @@ const handleSliderChange = (newValue: number) => {
|
||||
<TooltipRoot>
|
||||
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
|
||||
<TooltipTrigger class="flex items-center">
|
||||
<IconButton
|
||||
onclick={control.decrease}
|
||||
disabled={control.isAtMin}
|
||||
aria-label={decreaseLabel}
|
||||
rotation="counterclockwise"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<MinusIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
{#if !reduced}
|
||||
<IconButton
|
||||
onclick={control.decrease}
|
||||
disabled={control.isAtMin}
|
||||
aria-label={decreaseLabel}
|
||||
rotation="counterclockwise"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<MinusIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
{/if}
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
@@ -127,16 +134,18 @@ const handleSliderChange = (newValue: number) => {
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
|
||||
<IconButton
|
||||
aria-label={increaseLabel}
|
||||
onclick={control.increase}
|
||||
disabled={control.isAtMax}
|
||||
rotation="clockwise"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<PlusIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
{#if !reduced}
|
||||
<IconButton
|
||||
aria-label={increaseLabel}
|
||||
onclick={control.increase}
|
||||
disabled={control.isAtMax}
|
||||
rotation="clockwise"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<PlusIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
{/if}
|
||||
</TooltipTrigger>
|
||||
</ButtonGroupRoot>
|
||||
{#if controlLabel}
|
||||
|
||||
43
src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte
Normal file
43
src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script module>
|
||||
import { createTypographyControl } from '$shared/lib';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ComboControlV2 from './ComboControlV2.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/ComboControlV2',
|
||||
component: ComboControlV2,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'ComboControl with input field and slider',
|
||||
},
|
||||
story: { inline: false }, // Render stories in iframe for state isolation
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
orientation: {
|
||||
control: 'select',
|
||||
options: ['horizontal', 'vertical'],
|
||||
description: 'Orientation of the ComboControl',
|
||||
defaultValue: 'vertical',
|
||||
},
|
||||
label: {
|
||||
control: 'text',
|
||||
description: 'Label for the ComboControl',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
const control = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
|
||||
</script>
|
||||
|
||||
<Story name="Horizontal" args={{ orientation: 'horizontal', control }}>
|
||||
<ComboControlV2 control={control} orientation="horizontal" />
|
||||
</Story>
|
||||
|
||||
<Story name="Vertical" args={{ orientation: 'vertical', control, class: 'h-48' }}>
|
||||
<ComboControlV2 control={control} orientation="vertical" />
|
||||
</Story>
|
||||
@@ -4,69 +4,226 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { TypographyControl } from '$shared/lib';
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { Slider } from '$shared/shadcn/ui/slider';
|
||||
import { Button } from '$shared/shadcn/ui/button';
|
||||
import { Root as ButtonGroupRoot } from '$shared/shadcn/ui/button-group';
|
||||
import {
|
||||
Content as PopoverContent,
|
||||
Root as PopoverRoot,
|
||||
Trigger as PopoverTrigger,
|
||||
} from '$shared/shadcn/ui/popover';
|
||||
import {
|
||||
Content as TooltipContent,
|
||||
Root as TooltipRoot,
|
||||
Trigger as TooltipTrigger,
|
||||
} from '$shared/shadcn/ui/tooltip';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Input } from '$shared/ui';
|
||||
import { Slider } from '$shared/ui';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import {
|
||||
type Orientation,
|
||||
REGEXP_ONLY_DIGITS,
|
||||
} from 'bits-ui';
|
||||
import type { ChangeEventHandler } from 'svelte/elements';
|
||||
import IconButton from '../IconButton/IconButton.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Control instance
|
||||
*/
|
||||
control: TypographyControl;
|
||||
ref?: Snippet;
|
||||
/**
|
||||
* Orientation
|
||||
*/
|
||||
orientation?: Orientation;
|
||||
/**
|
||||
* Label text
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* CSS class
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Show scale flag
|
||||
*/
|
||||
showScale?: boolean;
|
||||
/**
|
||||
* Flag that change component appearance
|
||||
* from the one with increase/decrease buttons and popover with input + slider
|
||||
* to just input + slider
|
||||
*/
|
||||
reduced?: boolean;
|
||||
/**
|
||||
* Text for increase button aria-label
|
||||
*/
|
||||
increaseLabel?: string;
|
||||
/**
|
||||
* Text for decrease button aria-label
|
||||
*/
|
||||
decreaseLabel?: string;
|
||||
/**
|
||||
* Text for control button aria-label
|
||||
*/
|
||||
controlLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
control,
|
||||
ref = $bindable(),
|
||||
orientation = 'vertical',
|
||||
label,
|
||||
class: className,
|
||||
showScale = true,
|
||||
reduced = false,
|
||||
increaseLabel = 'Increase',
|
||||
decreaseLabel = 'Decrease',
|
||||
controlLabel,
|
||||
}: Props = $props();
|
||||
|
||||
let sliderValue = $state(Number(control.value));
|
||||
let inputValue = $state(String(control.value));
|
||||
|
||||
$effect(() => {
|
||||
sliderValue = Number(control.value);
|
||||
inputValue = String(control.value);
|
||||
});
|
||||
|
||||
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
||||
const parsedValue = parseFloat(event.currentTarget.value);
|
||||
if (!isNaN(parsedValue)) {
|
||||
control.value = parsedValue;
|
||||
inputValue = String(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',
|
||||
// );
|
||||
function calculateScale(index: number): number | string {
|
||||
const calculate = () =>
|
||||
orientation === 'horizontal'
|
||||
? (control.min + (index * (control.max - control.min) / 4))
|
||||
: (control.max - (index * (control.max - control.min) / 4));
|
||||
return Number.isInteger(control.step)
|
||||
? Math.round(calculate())
|
||||
: (calculate()).toFixed(2);
|
||||
}
|
||||
</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>
|
||||
{#snippet ComboControl()}
|
||||
<div
|
||||
class={cn(
|
||||
'flex gap-4 sm:p-4 rounded-xl transition-all duration-300',
|
||||
'backdrop-blur-md',
|
||||
orientation === 'horizontal' ? 'flex-row items-end w-full' : 'flex-col items-center h-full',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div class={cn('relative', orientation === 'horizontal' ? 'w-full' : 'h-full')}>
|
||||
{#if showScale}
|
||||
<div
|
||||
class={cn(
|
||||
'absolute flex justify-between',
|
||||
orientation === 'horizontal' ? 'flex-row w-full -top-5 px-0.5' : 'flex-col h-full -left-5 py-0.5',
|
||||
)}
|
||||
>
|
||||
{#each Array(5) as _, i}
|
||||
<div
|
||||
class={cn(
|
||||
'flex items-center gap-1.5',
|
||||
orientation === 'horizontal' ? 'flex-col' : 'flex-row',
|
||||
)}
|
||||
>
|
||||
<span class="font-mono text-[0.375rem] text-gray-400 tabular-nums">
|
||||
{calculateScale(i)}
|
||||
</span>
|
||||
<div class={cn('bg-gray-300', orientation === 'horizontal' ? 'w-px h-1' : 'h-px w-1')}>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Slider
|
||||
class={cn(orientation === 'horizontal' ? 'w-full' : 'h-full')}
|
||||
bind:value={control.value}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
{orientation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center"
|
||||
value={inputValue}
|
||||
onchange={handleInputChange}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
variant="ghost"
|
||||
/>
|
||||
|
||||
{#if label}
|
||||
<div class="flex items-center gap-2 opacity-70">
|
||||
<div class="w-1 h-1 rounded-full bg-gray-900"></div>
|
||||
<div class="w-px h-2 bg-gray-400/50"></div>
|
||||
<span class="font-mono text-[8px] uppercase tracking-[0.2em] text-gray-500 font-medium">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if reduced}
|
||||
{@render ComboControl()}
|
||||
{:else}
|
||||
<TooltipRoot>
|
||||
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
|
||||
<TooltipTrigger class="flex items-center">
|
||||
<IconButton
|
||||
onclick={control.decrease}
|
||||
disabled={control.isAtMin}
|
||||
aria-label={decreaseLabel}
|
||||
rotation="counterclockwise"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<MinusIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
class="hover:bg-white/50 hover:font-bold bg-white/20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
|
||||
size="icon"
|
||||
aria-label={controlLabel}
|
||||
>
|
||||
{control.value}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto h-64 sm:px-1 py-0">
|
||||
{@render ComboControl()}
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
|
||||
<IconButton
|
||||
aria-label={increaseLabel}
|
||||
onclick={control.increase}
|
||||
disabled={control.isAtMax}
|
||||
rotation="clockwise"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<PlusIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
</TooltipTrigger>
|
||||
</ButtonGroupRoot>
|
||||
{#if controlLabel}
|
||||
<TooltipContent>
|
||||
{controlLabel}
|
||||
</TooltipContent>
|
||||
{/if}
|
||||
</TooltipRoot>
|
||||
{/if}
|
||||
|
||||
41
src/shared/ui/Drawer/Drawer.svelte
Normal file
41
src/shared/ui/Drawer/Drawer.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<!-- Component: Drawer -->
|
||||
<script lang="ts">
|
||||
import { Button } from '$shared/shadcn/ui/button';
|
||||
import {
|
||||
Content as DrawerContent,
|
||||
Footer as DrawerFooter,
|
||||
Header as DrawerHeader,
|
||||
Root as DrawerRoot,
|
||||
Trigger as DrawerTrigger,
|
||||
} from '$shared/shadcn/ui/drawer';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
isOpen?: boolean;
|
||||
trigger?: Snippet<[{ isOpen: boolean; onClick: () => void }]>;
|
||||
content?: Snippet<[{ isOpen: boolean; className?: string }]>;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
let { isOpen = $bindable(false), trigger, content, contentClassName }: Props = $props();
|
||||
|
||||
function handleClick() {
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DrawerRoot bind:open={isOpen}>
|
||||
<DrawerTrigger>
|
||||
{#if trigger}
|
||||
{@render trigger({ isOpen, onClick: handleClick })}
|
||||
{:else}
|
||||
<Button onclick={handleClick}>
|
||||
Open
|
||||
</Button>
|
||||
{/if}
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
{@render content?.({ isOpen, className: cn('min-h-60 px-2 pt-4 pb-8', contentClassName) })}
|
||||
</DrawerContent>
|
||||
</DrawerRoot>
|
||||
@@ -173,7 +173,7 @@ $effect(() => {
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
|
||||
'relative p-0.5 sm:p-2 rounded-lg sm: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)]',
|
||||
|
||||
31
src/shared/ui/Footnote/Footnote.stories.svelte
Normal file
31
src/shared/ui/Footnote/Footnote.stories.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Footnote from './Footnote.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/Footnote',
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Styles footnote text',
|
||||
},
|
||||
story: { inline: false }, // Render stories in iframe for state isolation
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story name="Default">
|
||||
<Footnote>
|
||||
Footnote
|
||||
</Footnote>
|
||||
</Story>
|
||||
|
||||
<Story name="With custom render">
|
||||
<Footnote>
|
||||
{#snippet render({ class: className })}
|
||||
<span class={className}>Footnote</span>
|
||||
{/snippet}
|
||||
</Footnote>
|
||||
</Story>
|
||||
30
src/shared/ui/Footnote/Footnote.svelte
Normal file
30
src/shared/ui/Footnote/Footnote.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<!--
|
||||
Component: Footnote
|
||||
Provides classes for styling footnotes
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
/**
|
||||
* Custom render function for full control
|
||||
*/
|
||||
render?: Snippet<[{ class: string }]>;
|
||||
}
|
||||
|
||||
const { children, class: className, render }: Props = $props();
|
||||
|
||||
const baseClasses = 'font-mono text-[0.5625rem] sm:text-[0.625rem] uppercase tracking-[0.2em] text-gray-500 opacity-60';
|
||||
const combinedClasses = cn(baseClasses, className);
|
||||
</script>
|
||||
|
||||
{#if render}
|
||||
{@render render({ class: combinedClasses })}
|
||||
{:else if children}
|
||||
<span class={combinedClasses}>
|
||||
{@render children()}
|
||||
</span>
|
||||
{/if}
|
||||
42
src/shared/ui/Input/Input.stories.svelte
Normal file
42
src/shared/ui/Input/Input.stories.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Input from './Input.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/Input',
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Styles Input component',
|
||||
},
|
||||
story: { inline: false }, // Render stories in iframe for state isolation
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: "input's placeholder",
|
||||
},
|
||||
value: {
|
||||
control: 'text',
|
||||
description: "input's value",
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let value = $state('Initial value');
|
||||
const placeholder = 'Enter text';
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{
|
||||
placeholder,
|
||||
value,
|
||||
}}
|
||||
>
|
||||
<Input value={value} placeholder={placeholder} />
|
||||
</Story>
|
||||
61
src/shared/ui/Input/Input.svelte
Normal file
61
src/shared/ui/Input/Input.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<!--
|
||||
Component: Input
|
||||
Provides styled input component with all the shadcn input props
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
type Props = ComponentProps<typeof Input> & {
|
||||
/**
|
||||
* Current search value (bindable)
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Additional CSS classes for the container
|
||||
*/
|
||||
class?: string;
|
||||
|
||||
variant?: 'default' | 'ghost';
|
||||
};
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
class: className,
|
||||
variant = 'default',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const isGhost = $derived(variant === 'ghost');
|
||||
</script>
|
||||
|
||||
<Input
|
||||
bind:value={value}
|
||||
class={cn(
|
||||
'h-12 sm:h-14 md:h-16 w-full text-sm sm:text-base',
|
||||
'backdrop-blur-md',
|
||||
isGhost ? 'bg-transparent' : 'bg-white/80',
|
||||
'border border-gray-300/50',
|
||||
isGhost ? 'border-transparent' : 'border-gray-300/50',
|
||||
isGhost ? 'shadow-none' : '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-xs sm:placeholder:text-sm',
|
||||
'placeholder:tracking-wide',
|
||||
'pl-4 sm:pl-6 pr-4 sm:pr-6',
|
||||
'rounded-xl',
|
||||
'transition-all duration-200',
|
||||
'font-medium',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
21
src/shared/ui/Logo/Logo.stories.svelte
Normal file
21
src/shared/ui/Logo/Logo.stories.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Logo from './Logo.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/Logo',
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Projec Logo',
|
||||
},
|
||||
story: { inline: false }, // Render stories in iframe for state isolation
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story name="Default">
|
||||
<Logo />
|
||||
</Story>
|
||||
19
src/shared/ui/Logo/Logo.svelte
Normal file
19
src/shared/ui/Logo/Logo.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<!--
|
||||
Component: Logo
|
||||
Project logo with apropriate styles
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { class: className }: Props = $props();
|
||||
|
||||
const baseClasses =
|
||||
'font-[Barlow] font-thin text-5xl sm:text-6xl md:text-7xl lg:text-8xl text-justify [text-align-last:justify] [text-justify:inter-character]';
|
||||
</script>
|
||||
<h2 class={cn(baseClasses, className)}>
|
||||
GLYPHDIFF
|
||||
</h2>
|
||||
@@ -24,18 +24,12 @@ const { Story } = defineMeta({
|
||||
control: 'text',
|
||||
description: 'Placeholder text for the input',
|
||||
},
|
||||
label: {
|
||||
control: 'text',
|
||||
description: 'Optional label displayed above the input',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let defaultSearchValue = $state('');
|
||||
let withLabelValue = $state('');
|
||||
let noChildrenValue = $state('');
|
||||
</script>
|
||||
|
||||
<Story
|
||||
@@ -45,26 +39,5 @@ let noChildrenValue = $state('');
|
||||
placeholder: 'Type here...',
|
||||
}}
|
||||
>
|
||||
<SearchBar bind:value={defaultSearchValue} placeholder="Type here..."> </SearchBar>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="With Label"
|
||||
args={{
|
||||
value: withLabelValue,
|
||||
placeholder: 'Search products...',
|
||||
label: 'Search',
|
||||
}}
|
||||
>
|
||||
<SearchBar bind:value={withLabelValue} placeholder="Search products..." label="Search"> </SearchBar>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Minimal Content"
|
||||
args={{
|
||||
value: noChildrenValue,
|
||||
placeholder: 'Quick search...',
|
||||
}}
|
||||
>
|
||||
<SearchBar bind:value={noChildrenValue} placeholder="Quick search..."> </SearchBar>
|
||||
<SearchBar bind:value={defaultSearchValue} placeholder="Type here..." />
|
||||
</Story>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<!-- Component: SearchBar -->
|
||||
<script lang="ts">
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Input } from '$shared/ui';
|
||||
import AsteriskIcon from '@lucide/svelte/icons/asterisk';
|
||||
|
||||
interface Props {
|
||||
@@ -20,10 +21,6 @@ interface Props {
|
||||
* Placeholder text for the input
|
||||
*/
|
||||
placeholder?: string;
|
||||
/**
|
||||
* Optional label displayed above the input
|
||||
*/
|
||||
label?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -32,44 +29,11 @@ let {
|
||||
class: className,
|
||||
placeholder,
|
||||
}: Props = $props();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-full">
|
||||
<div class="absolute left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<AsteriskIcon class="size-4 stroke-gray-400 stroke-[1.5]" />
|
||||
<div class="absolute left-4 sm:left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<AsteriskIcon class="size-3 sm:size-4 stroke-gray-400 stroke-[1.5]" />
|
||||
</div>
|
||||
<Input
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
bind:value={value}
|
||||
onkeydown={handleKeyDown}
|
||||
class="
|
||||
h-16 w-full text-base
|
||||
backdrop-blur-md bg-white/80
|
||||
border border-gray-300/50
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
focus-visible:border-gray-400/60
|
||||
focus-visible:outline-none
|
||||
focus-visible:ring-1
|
||||
focus-visible:ring-gray-400/30
|
||||
focus-visible:bg-white/90
|
||||
hover:bg-white/90
|
||||
hover:border-gray-400/60
|
||||
text-gray-900
|
||||
placeholder:text-gray-400
|
||||
placeholder:font-mono
|
||||
placeholder:text-sm
|
||||
placeholder:tracking-wide
|
||||
pl-14 pr-6
|
||||
rounded-xl
|
||||
transition-all duration-200
|
||||
font-medium
|
||||
"
|
||||
/>
|
||||
<Input {id} class={cn('pl-11 sm:pl-14', className)} bind:value={value} placeholder={placeholder} />
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type FlyParams,
|
||||
fly,
|
||||
} from 'svelte/transition';
|
||||
import { Footnote } from '..';
|
||||
|
||||
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
||||
/**
|
||||
@@ -90,23 +91,30 @@ $effect(() => {
|
||||
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">
|
||||
<div class="flex flex-col gap-2 sm:gap-3" bind:this={titleContainer}>
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
{#if icon}
|
||||
{@render icon({ className: 'size-4 stroke-gray-900 stroke-1' })}
|
||||
<div class="w-px h-3 bg-gray-400/50"></div>
|
||||
{@render icon({ className: 'size-3 sm:size-4 stroke-gray-900 stroke-1 opacity-60' })}
|
||||
<div class="w-px h-2.5 sm:h-3 bg-gray-300/60"></div>
|
||||
{/if}
|
||||
{#if description}
|
||||
{@render description({ className: 'font-mono text-[10px] uppercase tracking-[0.2em] text-gray-600' })}
|
||||
<Footnote>
|
||||
{#snippet render({ class: className })}
|
||||
{@render description({ className })}
|
||||
{/snippet}
|
||||
</Footnote>
|
||||
{:else if typeof index === 'number'}
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-gray-600">
|
||||
<Footnote>
|
||||
Component_{String(index).padStart(3, '0')}
|
||||
</span>
|
||||
</Footnote>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if title}
|
||||
{@render title({ className: 'text-5xl md:text-6xl font-semibold tracking-tighter text-gray-900 leading-[0.9]' })}
|
||||
{@render title({
|
||||
className:
|
||||
'text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tighter text-gray-900 leading-[0.9]',
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
51
src/shared/ui/Slider/Slider.stories.svelte
Normal file
51
src/shared/ui/Slider/Slider.stories.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Slider from './Slider.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/Slider',
|
||||
component: Slider,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Styled bits-ui slider component for selecting a value within a range.',
|
||||
},
|
||||
story: { inline: false }, // Render stories in iframe for state isolation
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'number',
|
||||
description: 'Current value (two-way bindable)',
|
||||
},
|
||||
min: {
|
||||
control: 'number',
|
||||
description: 'Minimum value',
|
||||
},
|
||||
max: {
|
||||
control: 'number',
|
||||
description: 'Maximum value',
|
||||
},
|
||||
step: {
|
||||
control: 'number',
|
||||
description: 'Step size for value increments',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let minValue = 0;
|
||||
let maxValue = 100;
|
||||
let stepValue = 1;
|
||||
let value = $state(50);
|
||||
</script>
|
||||
|
||||
<Story name="Horizontal" args={{ orientation: 'horizontal', min: minValue, max: maxValue, step: stepValue, value }}>
|
||||
<Slider bind:value min={minValue} max={maxValue} step={stepValue} />
|
||||
</Story>
|
||||
|
||||
<Story name="Vertical" args={{ orientation: 'vertical', min: minValue, max: maxValue, step: stepValue, value }}>
|
||||
<Slider bind:value min={minValue} max={maxValue} step={stepValue} orientation="vertical" />
|
||||
</Story>
|
||||
105
src/shared/ui/Slider/Slider.svelte
Normal file
105
src/shared/ui/Slider/Slider.svelte
Normal file
@@ -0,0 +1,105 @@
|
||||
<!--
|
||||
Component: Slider
|
||||
Styled bits-ui slider component with single value.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
Slider,
|
||||
type SliderRootProps,
|
||||
} from 'bits-ui';
|
||||
|
||||
type Props = Omit<SliderRootProps, 'type' | 'onValueChange' | 'onValueCommit'> & {
|
||||
/**
|
||||
* Slider value, numeric.
|
||||
*/
|
||||
value: number;
|
||||
/**
|
||||
* A callback function called when the value changes.
|
||||
* @param newValue - number
|
||||
*/
|
||||
onValueChange?: (newValue: number) => void;
|
||||
/**
|
||||
* A callback function called when the user stops dragging the thumb and the value is committed.
|
||||
* @param newValue - number
|
||||
*/
|
||||
onValueCommit?: (newValue: number) => void;
|
||||
};
|
||||
|
||||
let { value = $bindable(), orientation = 'horizontal', class: className, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Slider.Root
|
||||
bind:value={value}
|
||||
class={cn(
|
||||
'relative flex h-full w-6 touch-none select-none items-center justify-center',
|
||||
orientation === 'horizontal' ? 'w-48 h-6' : 'w-6 h-48',
|
||||
className,
|
||||
)}
|
||||
type="single"
|
||||
{orientation}
|
||||
{...rest}
|
||||
>
|
||||
{#snippet children(props)}
|
||||
<span
|
||||
{...props}
|
||||
class={cn('relative bg-gray-200 rounded-full', orientation === 'horizontal' ? 'w-full h-px' : 'h-full w-px')}
|
||||
>
|
||||
<!-- Filled range with NO transition -->
|
||||
<Slider.Range
|
||||
class={cn('absolute bg-gray-900 rounded-full', orientation === 'horizontal' ? 'h-full' : 'w-full')}
|
||||
/>
|
||||
|
||||
<Slider.Thumb
|
||||
index={0}
|
||||
class={cn(
|
||||
'group/thumb relative block',
|
||||
orientation === 'horizontal' ? '-top-1 w-2 h-2.25' : '-left-1 h-2 w-2.25',
|
||||
'rounded-sm',
|
||||
'bg-gray-900',
|
||||
// Glow shadow
|
||||
'shadow-[0_0_6px_rgba(0,0,0,0.4)]',
|
||||
// Smooth transitions only for size/position
|
||||
'duration-200 ease-out',
|
||||
orientation === 'horizontal' ? 'transition-[height,top,left,box-shadow]' : 'transition-[width,top,left,box-shadow]',
|
||||
// Hover: bigger glow
|
||||
'hover:shadow-[0_0_10px_rgba(0,0,0,0.5)]',
|
||||
orientation === 'horizontal' ? 'hover:h-3 hover:-top-[5.5px]' : 'hover:w-3 hover:-left-[5.5px]',
|
||||
// Active: smaller glow
|
||||
'active:shadow-[0_0_4px_rgba(0,0,0,0.3)]',
|
||||
orientation === 'horizontal' ? 'active:h-2.5 active:-top-[4.5px]' : 'active:w-2.5 active:-left-[4.5px]',
|
||||
'focus:outline-none',
|
||||
'cursor-grab active:cursor-grabbing',
|
||||
)}
|
||||
>
|
||||
<!-- Soft glow on hover -->
|
||||
<div
|
||||
class="
|
||||
absolute inset-0 rounded-sm
|
||||
bg-white/20
|
||||
opacity-0 group-hover/thumb:opacity-100
|
||||
transition-opacity duration-200
|
||||
"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Value label -->
|
||||
<span
|
||||
class={cn(
|
||||
'absolute',
|
||||
orientation === 'horizontal' ? '-top-8 left-1/2 -translate-x-1/2' : 'left-5 top-1/2 -translate-y-1/2',
|
||||
'px-1.5 py-0.5 rounded-md',
|
||||
'bg-gray-900/90 backdrop-blur-sm',
|
||||
'font-mono text-[0.625rem] font-medium text-white ',
|
||||
'opacity-0 group-hover/thumb:opacity-100',
|
||||
'transition-all duration-300',
|
||||
'pointer-events-none',
|
||||
'shadow-sm',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</Slider.Thumb>
|
||||
</span>
|
||||
{/snippet}
|
||||
</Slider.Root>
|
||||
@@ -178,7 +178,7 @@ $effect(() => {
|
||||
<div
|
||||
use:virtualizer.measureElement
|
||||
data-index={item.index}
|
||||
class="absolute top-0 left-0 w-full"
|
||||
class="absolute top-0 left-0 w-full will-change-transform"
|
||||
style:transform="translateY({item.start}px)"
|
||||
>
|
||||
{#if item.index < items.length}
|
||||
@@ -210,7 +210,7 @@ $effect(() => {
|
||||
<div
|
||||
use:virtualizer.measureElement
|
||||
data-index={item.index}
|
||||
class="absolute top-0 left-0 w-full"
|
||||
class="absolute top-0 left-0 w-full will-change-transform"
|
||||
style:transform="translateY({item.start}px)"
|
||||
animate:flip={{ delay: 0, duration: 300, easing: quintOut }}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
export { default as CheckboxFilter } from './CheckboxFilter/CheckboxFilter.svelte';
|
||||
export { default as ComboControl } from './ComboControl/ComboControl.svelte';
|
||||
// ComboControlV2 might vary, assuming pattern holds or I'll fix later if build fails
|
||||
export { default as ComboControlV2 } from './ComboControlV2/ComboControlV2.svelte';
|
||||
export { default as ContentEditable } from './ContentEditable/ContentEditable.svelte';
|
||||
export { default as Drawer } from './Drawer/Drawer.svelte';
|
||||
export { default as ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte';
|
||||
export { default as Footnote } from './Footnote/Footnote.svelte';
|
||||
export { default as IconButton } from './IconButton/IconButton.svelte';
|
||||
export { default as Input } from './Input/Input.svelte';
|
||||
export { default as Loader } from './Loader/Loader.svelte';
|
||||
export { default as Logo } from './Logo/Logo.svelte';
|
||||
export { default as SearchBar } from './SearchBar/SearchBar.svelte';
|
||||
export { default as Section } from './Section/Section.svelte';
|
||||
export { default as Skeleton } from './Skeleton/Skeleton.svelte';
|
||||
export { default as Slider } from './Slider/Slider.svelte';
|
||||
export { default as VirtualList } from './VirtualList/VirtualList.svelte';
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
fetchFontsByIds,
|
||||
unifiedFontStore,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
createTypographyControlManager,
|
||||
} from '$features/SetupFont';
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
|
||||
/**
|
||||
@@ -30,6 +34,7 @@ class ComparisonStore {
|
||||
#fontB = $state<UnifiedFont | undefined>();
|
||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
||||
#isRestoring = $state(true);
|
||||
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
|
||||
|
||||
constructor() {
|
||||
this.restoreFromStorage();
|
||||
@@ -102,6 +107,9 @@ class ComparisonStore {
|
||||
}
|
||||
|
||||
// --- Getters & Setters ---
|
||||
get typography() {
|
||||
return this.#typography;
|
||||
}
|
||||
|
||||
get fontA() {
|
||||
return this.#fontA;
|
||||
@@ -149,6 +157,13 @@ class ComparisonStore {
|
||||
this.restoreFromStorage();
|
||||
}
|
||||
}
|
||||
|
||||
resetAll() {
|
||||
this.#fontA = undefined;
|
||||
this.#fontB = undefined;
|
||||
storage.clear();
|
||||
this.#typography.reset();
|
||||
}
|
||||
}
|
||||
|
||||
export const comparisonStore = new ComparisonStore();
|
||||
|
||||
@@ -14,14 +14,17 @@ import {
|
||||
createCharacterComparison,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import type { LineData } from '$shared/lib';
|
||||
import type {
|
||||
LineData,
|
||||
ResponsiveManager,
|
||||
} from '$shared/lib';
|
||||
import { Loader } from '$shared/ui';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import { getContext } from 'svelte';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { fade } from 'svelte/transition';
|
||||
import CharacterSlot from './components/CharacterSlot.svelte';
|
||||
import ControlsWrapper from './components/ControlsWrapper.svelte';
|
||||
import Labels from './components/Labels.svelte';
|
||||
import Controls from './components/Controls.svelte';
|
||||
import SliderLine from './components/SliderLine.svelte';
|
||||
|
||||
// Pair of fonts to compare
|
||||
@@ -30,31 +33,13 @@ const fontB = $derived(comparisonStore.fontB);
|
||||
|
||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
||||
|
||||
let container: HTMLElement | undefined = $state();
|
||||
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
|
||||
let measureCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let container = $state<HTMLElement>();
|
||||
let typographyControls = $state<HTMLDivElement | null>(null);
|
||||
let measureCanvas = $state<HTMLCanvasElement>();
|
||||
let isDragging = $state(false);
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
|
||||
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,
|
||||
});
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
/**
|
||||
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
||||
@@ -64,8 +49,8 @@ const charComparison = createCharacterComparison(
|
||||
() => comparisonStore.text,
|
||||
() => fontA,
|
||||
() => fontB,
|
||||
() => weightControl.value,
|
||||
() => sizeControl.value,
|
||||
() => typography.weight,
|
||||
() => typography.renderedSize,
|
||||
);
|
||||
|
||||
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
||||
@@ -88,8 +73,8 @@ function handleMove(e: PointerEvent) {
|
||||
|
||||
function startDragging(e: PointerEvent) {
|
||||
if (
|
||||
e.target === controlsWrapperElement
|
||||
|| controlsWrapperElement?.contains(e.target as Node)
|
||||
e.target === typographyControls
|
||||
|| typographyControls?.contains(e.target as Node)
|
||||
) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
@@ -99,6 +84,30 @@ function startDragging(e: PointerEvent) {
|
||||
handleMove(e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the multiplier for slider font size based on the current responsive state
|
||||
*/
|
||||
$effect(() => {
|
||||
if (!responsive) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case responsive.isMobile:
|
||||
typography.multiplier = 0.5;
|
||||
break;
|
||||
case responsive.isTablet:
|
||||
typography.multiplier = 0.75;
|
||||
break;
|
||||
case responsive.isDesktop:
|
||||
typography.multiplier = 1;
|
||||
break;
|
||||
default:
|
||||
typography.multiplier = 1;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
@@ -115,9 +124,9 @@ $effect(() => {
|
||||
$effect(() => {
|
||||
// React on text and typography settings changes
|
||||
const _text = comparisonStore.text;
|
||||
const _weight = weightControl.value;
|
||||
const _size = sizeControl.value;
|
||||
const _height = heightControl.value;
|
||||
const _weight = typography.weight;
|
||||
const _size = typography.renderedSize;
|
||||
const _height = typography.height;
|
||||
|
||||
if (container && measureCanvas && fontA && fontB) {
|
||||
// Using rAF to ensure DOM is ready/stabilized
|
||||
@@ -143,8 +152,8 @@ $effect(() => {
|
||||
<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`}
|
||||
style:height={`${typography.height}em`}
|
||||
style:line-height={`${typography.height}em`}
|
||||
>
|
||||
{#each line.text.split('') as char, charIndex}
|
||||
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
|
||||
@@ -158,8 +167,8 @@ $effect(() => {
|
||||
{char}
|
||||
{proximity}
|
||||
{isPast}
|
||||
weight={weightControl.value}
|
||||
size={sizeControl.value}
|
||||
weight={typography.weight}
|
||||
size={typography.renderedSize}
|
||||
fontAName={fontA.name}
|
||||
fontBName={fontB.name}
|
||||
/>
|
||||
@@ -180,14 +189,14 @@ $effect(() => {
|
||||
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
|
||||
group relative w-full py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24 overflow-hidden
|
||||
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
|
||||
select-none touch-none cursor-ew-resize min-h-72 sm:min-h-96 flex flex-col justify-center
|
||||
backdrop-blur-lg bg-linear-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:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
|
||||
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
|
||||
before:-z-10 before:blur-sm
|
||||
"
|
||||
>
|
||||
@@ -197,8 +206,8 @@ $effect(() => {
|
||||
{:else}
|
||||
<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]
|
||||
relative flex flex-col items-center gap-3 sm:gap-4
|
||||
text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl: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)]
|
||||
"
|
||||
@@ -209,7 +218,7 @@ $effect(() => {
|
||||
{#each charComparison.lines as line, lineIndex}
|
||||
<div
|
||||
class="relative w-full whitespace-nowrap"
|
||||
style:height={`${heightControl.value}em`}
|
||||
style:height={`${typography.height}em`}
|
||||
style:display="flex"
|
||||
style:align-items="center"
|
||||
style:justify-content="center"
|
||||
@@ -222,19 +231,6 @@ $effect(() => {
|
||||
<SliderLine {sliderPos} {isDragging} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if fontA && fontB && !isLoading}
|
||||
<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}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Since there're slider controls inside we put them outside the main one -->
|
||||
<Controls {sliderPos} {isDragging} {typographyControls} {container} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
Drawer,
|
||||
IconButton,
|
||||
} from '$shared/ui';
|
||||
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
|
||||
import { getContext } from 'svelte';
|
||||
import { comparisonStore } from '../../../model';
|
||||
import SelectComparedFonts from './SelectComparedFonts.svelte';
|
||||
import TypographyControls from './TypographyControls.svelte';
|
||||
|
||||
interface Props {
|
||||
sliderPos: number;
|
||||
isDragging: boolean;
|
||||
typographyControls?: HTMLDivElement | null;
|
||||
container: HTMLElement;
|
||||
}
|
||||
|
||||
let { sliderPos, isDragging, typographyControls = $bindable<HTMLDivElement | null>(null), container }: Props = $props();
|
||||
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
</script>
|
||||
|
||||
{#if responsive.isMobile}
|
||||
<Drawer>
|
||||
{#snippet trigger({ isOpen, onClick })}
|
||||
<IconButton class="absolute right-3 top-3" onclick={onClick}>
|
||||
{#snippet icon({ className })}
|
||||
<SlidersIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
{/snippet}
|
||||
|
||||
{#snippet content({ isOpen, className })}
|
||||
<div class={cn(className, 'flex flex-col gap-6')}>
|
||||
<SelectComparedFonts {sliderPos} />
|
||||
<TypographyControls
|
||||
{sliderPos}
|
||||
{isDragging}
|
||||
isActive={isOpen}
|
||||
bind:wrapper={typographyControls}
|
||||
containerWidth={container?.clientWidth}
|
||||
staticPosition
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Drawer>
|
||||
{:else}
|
||||
{#if !isLoading}
|
||||
<div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50">
|
||||
<TypographyControls
|
||||
{sliderPos}
|
||||
{isDragging}
|
||||
bind:wrapper={typographyControls}
|
||||
containerWidth={container?.clientWidth}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isLoading}
|
||||
<div class="absolute bottom-3 sm:bottom-6 md:bottom-8 inset-x-3 sm:inset-x-6 md:inset-x-12">
|
||||
<SelectComparedFonts {sliderPos} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -1,177 +0,0 @@
|
||||
<!--
|
||||
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';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
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)
|
||||
"
|
||||
in:fade={{ duration: 300, delay: 300 }}
|
||||
out:fade={{ duration: 300, delay: 300 }}
|
||||
>
|
||||
<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>
|
||||
@@ -1,157 +0,0 @@
|
||||
<!--
|
||||
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';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
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())}
|
||||
in:fade={{ duration: 300, delay: 300 }}
|
||||
out:fade={{ duration: 300, delay: 300 }}
|
||||
>
|
||||
<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,147 @@
|
||||
<!--
|
||||
Component: SelectComparedFonts
|
||||
Displays selects that change the compared fonts
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontVirtualList,
|
||||
type UnifiedFont,
|
||||
unifiedFontStore,
|
||||
} from '$entities/Font';
|
||||
import { getFontUrl } from '$entities/Font/lib';
|
||||
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';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Position of the slider
|
||||
*/
|
||||
sliderPos: number;
|
||||
}
|
||||
let { sliderPos }: Props = $props();
|
||||
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontAUrl = $derived(fontA && getFontUrl(fontA, typography.weight));
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
const fontBUrl = $derived(fontB && getFontUrl(fontB, typography.weight));
|
||||
|
||||
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(
|
||||
font: UnifiedFont,
|
||||
fonts: UnifiedFont[],
|
||||
url: string,
|
||||
onSelect: (f: UnifiedFont) => void,
|
||||
align: 'start' | 'end',
|
||||
)}
|
||||
<div
|
||||
class="z-50 pointer-events-auto"
|
||||
onpointerdown={(e => e.stopPropagation())}
|
||||
in:fade={{ duration: 300, delay: 300 }}
|
||||
out:fade={{ duration: 300, delay: 300 }}
|
||||
>
|
||||
<SelectRoot type="single" disabled={!fontList.length}>
|
||||
<SelectTrigger
|
||||
class={cn(
|
||||
'w-36 sm:w-44 md:w-52 h-8 sm:h-9 border border-gray-300/40 bg-white/60 backdrop-blur-sm',
|
||||
'px-2 sm:px-3 rounded-lg transition-all flex items-center justify-between gap-2',
|
||||
'font-mono text-[10px] sm: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={font.name} id={font.id} {url}>
|
||||
{font.name}
|
||||
</FontApplicator>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
class={cn(
|
||||
'bg-white/95 backdrop-blur-xl border border-gray-300/50 shadow-xl',
|
||||
'w-44 sm:w-52 max-h-60 sm:max-h-64 overflow-hidden rounded-lg',
|
||||
)}
|
||||
side="top"
|
||||
{align}
|
||||
sideOffset={8}
|
||||
size="small"
|
||||
>
|
||||
<div class="p-1 sm:p-1.5">
|
||||
<FontVirtualList items={fonts} weight={typography.weight}>
|
||||
{#snippet children({ item: fontListItem })}
|
||||
{@const handleClick = () => onSelect(fontListItem)}
|
||||
<SelectItem
|
||||
value={fontListItem.id}
|
||||
class="data-highlighted:bg-gray-100 font-mono text-[10px] sm:text-[11px] px-2 sm:px-3 py-2 sm:py-2.5 rounded-md cursor-pointer transition-colors"
|
||||
onclick={handleClick}
|
||||
>
|
||||
<FontApplicator
|
||||
name={fontListItem.name}
|
||||
id={fontListItem.id}
|
||||
url={getFontUrl(fontListItem, typography.weight) ?? ''}
|
||||
>
|
||||
{fontListItem.name}
|
||||
</FontApplicator>
|
||||
</SelectItem>
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex justify-between items-end pointer-events-none z-20">
|
||||
<div
|
||||
class="flex flex-col gap-1.5 sm: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 sm: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 sm:h-2.5 bg-gray-300/60"></div>
|
||||
<span class="font-mono text-[8px] sm:text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
|
||||
ch_01
|
||||
</span>
|
||||
</div>
|
||||
{#if fontB && fontBUrl}
|
||||
{@render fontSelector(fontB, fontList, fontBUrl, selectFontB, 'start')}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-end text-right gap-1.5 sm: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 sm:gap-2.5 px-1">
|
||||
<span class="font-mono text-[8px] sm:text-[9px] uppercase tracking-[0.2em] text-gray-500 font-medium">
|
||||
ch_02
|
||||
</span>
|
||||
<div class="w-px h-2 sm: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>
|
||||
{#if fontA && fontAUrl}
|
||||
{@render fontSelector(fontA, fontList, fontAUrl, selectFontA, 'end')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,14 +18,14 @@ interface Props {
|
||||
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"
|
||||
class="absolute inset-y-2 sm: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',
|
||||
isDragging ? 'size-6 sm:size-12' : 'size-4 sm:size-8',
|
||||
)}
|
||||
viewBox="0 0 24 12"
|
||||
fill="none"
|
||||
@@ -41,12 +41,12 @@ let { sliderPos, isDragging }: Props = $props();
|
||||
<div
|
||||
class={cn(
|
||||
'relative h-full rounded-sm transition-all duration-500',
|
||||
'bg-white/[0.03] backdrop-blur-md',
|
||||
'bg-white/3 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',
|
||||
isDragging ? 'w-16 sm:w-32' : 'w-12 sm:w-16',
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@ let { sliderPos, isDragging }: Props = $props();
|
||||
<svg
|
||||
class={cn(
|
||||
'transition-all relative duration-500 text-black/80 drop-shadow-sm',
|
||||
isDragging ? 'w-12 h-12' : 'w-8 h-8',
|
||||
isDragging ? 'size-6 sm:size-12' : 'size-4 sm:size-8',
|
||||
)}
|
||||
viewBox="0 0 24 12"
|
||||
fill="none"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<!--
|
||||
Component: TypographyControls
|
||||
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 { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
ComboControlV2,
|
||||
ExpandableWrapper,
|
||||
Input,
|
||||
} from '$shared/ui';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
|
||||
import { type Orientation } from 'bits-ui';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Ref
|
||||
*/
|
||||
wrapper?: HTMLDivElement | null;
|
||||
/**
|
||||
* Slider position
|
||||
*/
|
||||
sliderPos: number;
|
||||
/**
|
||||
* Whether slider is being dragged
|
||||
*/
|
||||
isDragging: boolean;
|
||||
/** */
|
||||
isActive?: boolean;
|
||||
/**
|
||||
* Container width
|
||||
*/
|
||||
containerWidth: number;
|
||||
/**
|
||||
* Reduced animations flag
|
||||
*/
|
||||
staticPosition?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
sliderPos,
|
||||
isDragging,
|
||||
isActive = $bindable(false),
|
||||
wrapper = $bindable(null),
|
||||
containerWidth = 0,
|
||||
staticPosition = false,
|
||||
}: Props = $props();
|
||||
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
const panelWidth = $derived(wrapper?.clientWidth ?? 0);
|
||||
const margin = 24;
|
||||
let side = $state<'left' | 'right'>('left');
|
||||
// Unified active state for the entire wrapper
|
||||
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 || staticPosition) 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>
|
||||
|
||||
{#snippet InputComponent(className: string)}
|
||||
<Input
|
||||
class={className}
|
||||
bind:value={comparisonStore.text}
|
||||
disabled={isDragging}
|
||||
onfocusin={handleInputFocus}
|
||||
placeholder="The quick brown fox..."
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet Controls(className: string, orientation: Orientation)}
|
||||
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
|
||||
<div class={className}>
|
||||
<ComboControlV2 control={typography.weightControl} {orientation} reduced />
|
||||
<ComboControlV2 control={typography.sizeControl} {orientation} reduced />
|
||||
<ComboControlV2 control={typography.heightControl} {orientation} reduced />
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div
|
||||
class="z-50 will-change-transform"
|
||||
style:transform="
|
||||
translateX({xSpring.current}px)
|
||||
rotateZ({rotateSpring.current}deg)
|
||||
"
|
||||
in:fade={{ duration: 300, delay: 300 }}
|
||||
out:fade={{ duration: 300, delay: 300 }}
|
||||
>
|
||||
{#if staticPosition}
|
||||
<div class="flex flex-col gap-6">
|
||||
{@render InputComponent?.('p-6')}
|
||||
{@render Controls?.('flex flex-col justify-between items-center-safe gap-6', 'horizontal')}
|
||||
</div>
|
||||
{:else}
|
||||
<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',
|
||||
)}
|
||||
containerClassName={cn(!isActive && 'p-2 sm:p-0')}
|
||||
>
|
||||
{#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()}
|
||||
{@render InputComponent(cn(
|
||||
'pl-1 sm:pl-3 pr-1 sm:pr-3',
|
||||
'h-6 sm:h-8 md:h-10',
|
||||
'rounded-lg',
|
||||
isActive
|
||||
? 'h-7 sm:h-8 text-[0.825rem]'
|
||||
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm sm:text-base font-medium focus-visible:ring-0 text-slate-900/50',
|
||||
))}
|
||||
{/snippet}
|
||||
|
||||
{#snippet hiddenContent()}
|
||||
{@render Controls?.('flex flex-row justify-between items-center-safe gap-2 sm:gap-0 h-64', 'vertical')}
|
||||
{/snippet}
|
||||
</ExpandableWrapper>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { springySlideFade } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
Footnote,
|
||||
IconButton,
|
||||
SearchBar,
|
||||
} from '$shared/ui';
|
||||
@@ -71,7 +72,6 @@ function toggleFilters() {
|
||||
id="font-search"
|
||||
class="w-full"
|
||||
placeholder="search_typefaces..."
|
||||
label="query_input"
|
||||
bind:value={filterManager.queryValue}
|
||||
/>
|
||||
|
||||
@@ -101,25 +101,25 @@ function toggleFilters() {
|
||||
>
|
||||
<div
|
||||
class="
|
||||
p-4 rounded-xl
|
||||
p-3 sm:p-4 md:p-5 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">
|
||||
<div class="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
|
||||
<div class="w-1 h-1 rounded-full bg-gray-900 opacity-70"></div>
|
||||
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
||||
<Footnote>
|
||||
filter_params
|
||||
</span>
|
||||
</Footnote>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 grid-cols-[repeat(auto-fit,minmax(8em,14em))]">
|
||||
<div class="grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Filters />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-gray-300/40">
|
||||
<div class="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-300/40">
|
||||
<FilterControls class="m-auto w-fit" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<!--
|
||||
Component: SampleList
|
||||
Renders a list of fonts in a virtualized list to improve performance.
|
||||
Includes pagination with auto-loading when scrolling near the bottom.
|
||||
- Includes pagination with auto-loading when scrolling near the bottom.
|
||||
- Provides a typography menu for font setup.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
@@ -10,9 +11,19 @@ import {
|
||||
unifiedFontStore,
|
||||
} from '$entities/Font';
|
||||
import { FontSampler } from '$features/DisplayFont';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import {
|
||||
TypographyMenu,
|
||||
controlManager,
|
||||
} from '$features/SetupFont';
|
||||
|
||||
let text = $state('The quick brown fox jumps over the lazy dog...');
|
||||
let wrapper = $state<HTMLDivElement | null>(null);
|
||||
// Binds to the actual window height
|
||||
let innerHeight = $state(0);
|
||||
// Is the component above the middle of the viewport?
|
||||
let isAboveMiddle = $state(false);
|
||||
|
||||
const isLoading = $derived(unifiedFontStore.isFetching || unifiedFontStore.isLoading);
|
||||
|
||||
/**
|
||||
* Load more fonts by moving to the next page
|
||||
@@ -51,27 +62,46 @@ const displayRange = $derived.by(() => {
|
||||
return `Showing ${loadedCount} of ${total} fonts`;
|
||||
});
|
||||
|
||||
const isLoading = $derived(unifiedFontStore.isFetching || unifiedFontStore.isLoading);
|
||||
function checkPosition() {
|
||||
if (!wrapper) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const viewportMiddle = innerHeight / 2;
|
||||
|
||||
isAboveMiddle = rect.top < viewportMiddle;
|
||||
}
|
||||
</script>
|
||||
|
||||
<FontVirtualList
|
||||
items={unifiedFontStore.fonts}
|
||||
total={unifiedFontStore.pagination.total}
|
||||
onNearBottom={handleNearBottom}
|
||||
itemHeight={280}
|
||||
useWindowScroll={true}
|
||||
weight={controlManager.weight}
|
||||
{isLoading}
|
||||
>
|
||||
{#snippet children({
|
||||
<svelte:window
|
||||
bind:innerHeight
|
||||
onscroll={checkPosition}
|
||||
onresize={checkPosition}
|
||||
/>
|
||||
|
||||
<div bind:this={wrapper}>
|
||||
<FontVirtualList
|
||||
items={unifiedFontStore.fonts}
|
||||
total={unifiedFontStore.pagination.total}
|
||||
onNearBottom={handleNearBottom}
|
||||
itemHeight={220}
|
||||
useWindowScroll={true}
|
||||
weight={controlManager.weight}
|
||||
{isLoading}
|
||||
>
|
||||
{#snippet children({
|
||||
item: font,
|
||||
isFullyVisible,
|
||||
isPartiallyVisible,
|
||||
proximity,
|
||||
index,
|
||||
})}
|
||||
<FontListItem {font} {isFullyVisible} {isPartiallyVisible} {proximity}>
|
||||
<FontSampler {font} bind:text {index} />
|
||||
</FontListItem>
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
<FontListItem {font} {isFullyVisible} {isPartiallyVisible} {proximity}>
|
||||
<FontSampler {font} bind:text {index} />
|
||||
</FontListItem>
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
|
||||
{#if isAboveMiddle}
|
||||
<TypographyMenu class="fixed bottom-4 sm:bottom-5 right-4 sm:left-1/2 sm:right-[unset] sm:-translate-x-1/2" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import TypographyMenu from './ui/TypographyMenu.svelte';
|
||||
|
||||
export { TypographyMenu };
|
||||
@@ -1,41 +0,0 @@
|
||||
<!--
|
||||
Component: TypographyMenu
|
||||
Provides a menu for selecting and configuring typography settings
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { SetupFontMenu } from '$features/SetupFont';
|
||||
import {
|
||||
Content as ItemContent,
|
||||
Root as ItemRoot,
|
||||
} from '$shared/shadcn/ui/item';
|
||||
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { crossfade } from 'svelte/transition';
|
||||
|
||||
const [send, receive] = crossfade({
|
||||
duration: 400,
|
||||
easing: cubicOut,
|
||||
fallback(node, params) {
|
||||
// If it can't find a pair, it falls back to a simple fade/slide
|
||||
return {
|
||||
duration: 400,
|
||||
css: t => `opacity: ${t}; transform: translateY(${(1 - t) * 10}px);`,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-auto fixed bottom-5 inset-x-0 max-screen z-10 flex justify-center"
|
||||
in:receive={{ key: 'panel' }}
|
||||
out:send={{ key: 'panel' }}
|
||||
>
|
||||
<ItemRoot
|
||||
variant="outline"
|
||||
class="w-auto max-w-max p-2.5 rounded-2xl backdrop-blur-lg"
|
||||
>
|
||||
<ItemContent class="flex flex-row justify-center items-center max-w-max">
|
||||
<SetupFontMenu />
|
||||
</ItemContent>
|
||||
</ItemRoot>
|
||||
</div>
|
||||
@@ -1,3 +1,2 @@
|
||||
export { ComparisonSlider } from './ComparisonSlider';
|
||||
export { FontSearch } from './FontSearch';
|
||||
export { TypographyMenu } from './TypographySettings';
|
||||
|
||||
37
yarn.lock
37
yarn.lock
@@ -2470,6 +2470,7 @@ __metadata:
|
||||
tailwindcss: "npm:^4.1.18"
|
||||
tw-animate-css: "npm:^1.4.0"
|
||||
typescript: "npm:^5.9.3"
|
||||
vaul-svelte: "npm:^1.0.0-next.7"
|
||||
vite: "npm:^7.2.6"
|
||||
vitest: "npm:^4.0.16"
|
||||
vitest-browser-svelte: "npm:^2.0.1"
|
||||
@@ -3625,6 +3626,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"runed@npm:^0.23.2":
|
||||
version: 0.23.4
|
||||
resolution: "runed@npm:0.23.4"
|
||||
dependencies:
|
||||
esm-env: "npm:^1.0.0"
|
||||
peerDependencies:
|
||||
svelte: ^5.7.0
|
||||
checksum: 10c0/e27400af9e69b966dca449b851e82e09b3d2ddde4095ba72237599aa80fc248a23d0737c0286f751ca6c12721a5e09eb21b9d8cc872cbd70e7b161442818eece
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"runed@npm:^0.35.1":
|
||||
version: 0.35.1
|
||||
resolution: "runed@npm:0.35.1"
|
||||
@@ -3908,6 +3920,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"svelte-toolbelt@npm:^0.7.1":
|
||||
version: 0.7.1
|
||||
resolution: "svelte-toolbelt@npm:0.7.1"
|
||||
dependencies:
|
||||
clsx: "npm:^2.1.1"
|
||||
runed: "npm:^0.23.2"
|
||||
style-to-object: "npm:^1.0.8"
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
checksum: 10c0/a50db97c851fa65af7fbf77007bd76730a179ac0239c0121301bd26682c1078a4ffea77835492550b133849a42d3dffee0714ae076154d86be8d0b3a84c9a9bf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"svelte2tsx@npm:^0.7.44, svelte2tsx@npm:~0.7.46":
|
||||
version: 0.7.46
|
||||
resolution: "svelte2tsx@npm:0.7.46"
|
||||
@@ -4232,6 +4257,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vaul-svelte@npm:^1.0.0-next.7":
|
||||
version: 1.0.0-next.7
|
||||
resolution: "vaul-svelte@npm:1.0.0-next.7"
|
||||
dependencies:
|
||||
runed: "npm:^0.23.2"
|
||||
svelte-toolbelt: "npm:^0.7.1"
|
||||
peerDependencies:
|
||||
svelte: ^5.0.0
|
||||
checksum: 10c0/7a459122b39c9ef6bd830b525d5f6acbc07575491e05c758d9dfdb993cc98ab4dee4a9c022e475760faaf1d7bd8460a1434965431d36885a3ee48315ffa54eb3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vite@npm:^6.0.0 || ^7.0.0, vite@npm:^7.2.6":
|
||||
version: 7.3.0
|
||||
resolution: "vite@npm:7.3.0"
|
||||
|
||||
Reference in New Issue
Block a user