Compare commits

...

18 Commits

Author SHA1 Message Date
Ilia Mashkov
c07800cc96 chore: add export 2026-01-25 00:00:13 +03:00
Ilia Mashkov
b49bf0d397 feat(ScrollArea): add shadcn scroll area to layout 2026-01-24 23:58:10 +03:00
Ilia Mashkov
ed4ee8bb44 chore(ControlsWrapper): use new reusable wrapper 2026-01-24 23:57:16 +03:00
Ilia Mashkov
8a2059ac4a feat(ExtendableWrapper): create reusable extendable wrapper with animations 2026-01-24 23:56:26 +03:00
Ilia Mashkov
7ffc5d6a34 feat(Page): move search to page 2026-01-24 15:39:38 +03:00
Ilia Mashkov
08cccc5ede chore: add export 2026-01-24 15:39:10 +03:00
Ilia Mashkov
71266f8b22 chore(TypographyMenu): remove search from typography menu 2026-01-24 15:38:50 +03:00
Ilia Mashkov
d5221ad449 feat(SearchBar): improve styling 2026-01-24 15:38:01 +03:00
Ilia Mashkov
873b697e8c feat(ComboControl): Add tooltips and enhance intraction effects 2026-01-24 15:37:06 +03:00
Ilia Mashkov
3dce409034 feat(SetupFontMenu): add props 2026-01-24 15:36:13 +03:00
Ilia Mashkov
cf08f7adfa chore(FontSearch): move to widgets layer 2026-01-24 15:35:26 +03:00
Ilia Mashkov
4b01b1592d feat(ControlsWrapper): close ControlsWrapper on escape click 2026-01-24 15:34:17 +03:00
Ilia Mashkov
ecb4bea642 feat(FlterControls): enhance control with ux effects 2026-01-24 15:33:12 +03:00
Ilia Mashkov
e89c6369cb feat(Layout): add tooltip provider 2026-01-24 15:32:01 +03:00
Ilia Mashkov
18a311c6b1 chore: delete filters sidebar 2026-01-24 15:30:02 +03:00
Ilia Mashkov
732f77f504 feat(CheckboxFilter): use new transition function springySlideFade 2026-01-24 15:25:56 +03:00
Ilia Mashkov
b7992ca138 feat(app): add common animaition for ux elements that can interact with 2026-01-24 15:22:57 +03:00
Ilia Mashkov
32b1367877 feat(springySliderFade): add custom transition function for slide+fade 2026-01-24 15:16:04 +03:00
30 changed files with 753 additions and 338 deletions

View File

@@ -140,3 +140,25 @@
.peer:focus-visible ~ * { .peer:focus-visible ~ * {
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
} }
@keyframes nudge {
0%, 100% {
transform: translateY(0) scale(1) rotate(0deg);
}
2% {
transform: translateY(-2px) scale(1.1) rotate(-1deg);
}
4% {
transform: translateY(0) scale(1) rotate(1deg);
}
6% {
transform: translateY(-2px) scale(1.1) rotate(0deg);
}
8% {
transform: translateY(0) scale(1) rotate(0deg);
}
}
.animate-nudge {
animation: nudge 10s ease-in-out infinite;
}

View File

@@ -3,23 +3,25 @@
* Layout Component * Layout Component
* *
* Root layout wrapper that provides the application shell structure. Handles favicon, * Root layout wrapper that provides the application shell structure. Handles favicon,
* sidebar provider initialization, and renders child routes with consistent structure. * toolbar provider initialization, and renders child routes with consistent structure.
* *
* Layout structure: * Layout structure:
* - Header area (currently empty, reserved for future use) * - Header area (currently empty, reserved for future use)
* - Collapsible sidebar with main content area
* - Footer area (currently empty, reserved for future use)
* *
* Uses Sidebar.Provider to enable mobile-responsive collapsible sidebar behavior * - Footer area (currently empty, reserved for future use)
* throughout the application.
*/ */
import favicon from '$shared/assets/favicon.svg'; import favicon from '$shared/assets/favicon.svg';
import * as Sidebar from '$shared/shadcn/ui/sidebar/index'; import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
import { FiltersSidebar } from '$widgets/FiltersSidebar'; import { Provider as TooltipProvider } from '$shared/shadcn/ui/tooltip';
import TypographyMenu from '$widgets/TypographySettings/ui/TypographyMenu.svelte'; import { TypographyMenu } from '$widgets/TypographySettings';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
}
/** Slot content for route pages to render */ /** Slot content for route pages to render */
let { children } = $props(); let { children }: Props = $props();
</script> </script>
<svelte:head> <svelte:head>
@@ -35,22 +37,16 @@ let { children } = $props();
> >
</svelte:head> </svelte:head>
<div id="app-root"> <div id="app-root" class="min-h-screen flex flex-col bg-background">
<header></header> <header></header>
<Sidebar.Provider> <ScrollArea class="h-screen w-screen">
<FiltersSidebar /> <main class="flex-1 w-full max-w-5xl mx-auto px-4 py-6 md:px-8 lg:py-10 relative">
<main class="w-dvw"> <TooltipProvider>
<TypographyMenu /> <TypographyMenu />
{@render children?.()} {@render children?.()}
</TooltipProvider>
</main> </main>
</Sidebar.Provider> </ScrollArea>
<footer></footer> <footer></footer>
</div> </div>
<style>
#app-root {
width: 100%;
height: 100vh;
}
</style>

View File

@@ -1 +1,2 @@
export { displayedFontsStore } from './model';
export { FontDisplay } from './ui'; export { FontDisplay } from './ui';

View File

@@ -28,6 +28,8 @@ export class DisplayedFontsStore {
#selectedPair = $state<Partial<[UnifiedFont, UnifiedFont]>>([]); #selectedPair = $state<Partial<[UnifiedFont, UnifiedFont]>>([]);
#hasAnySelectedFonts = $derived(this.#displayedFonts.length > 0);
get fonts() { get fonts() {
return this.#displayedFonts; return this.#displayedFonts;
} }
@@ -41,7 +43,9 @@ export class DisplayedFontsStore {
} }
set selectedPair(pair: Partial<[UnifiedFont, UnifiedFont]>) { set selectedPair(pair: Partial<[UnifiedFont, UnifiedFont]>) {
this.#selectedPair = pair; const [first, second] = this.#selectedPair;
const [newFist, newSecond] = pair;
this.#selectedPair = [newFist ?? first, newSecond ?? second];
} }
get text() { get text() {
@@ -52,6 +56,10 @@ export class DisplayedFontsStore {
this.#sampleText = text; this.#sampleText = text;
} }
get hasAnyFonts() {
return this.#hasAnySelectedFonts;
}
isSelectedPairEmpty(): boolean { isSelectedPairEmpty(): boolean {
const [font1, font2] = this.#selectedPair; const [font1, font2] = this.#selectedPair;
return !font1 || !font2; return !font1 || !font2;

View File

@@ -15,5 +15,5 @@ export { filterManager } from './model/state/manager.svelte';
export { export {
FilterControls, FilterControls,
Filters, Filters,
FontSearch, SuggestedFonts,
} from './ui'; } from './ui';

View File

@@ -5,15 +5,42 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Button } from '$shared/shadcn/ui/button'; import { Button } from '$shared/shadcn/ui/button';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import Rotate from '@lucide/svelte/icons/rotate-ccw';
import { cubicOut } from 'svelte/easing';
import { Tween } from 'svelte/motion';
import { filterManager } from '../../model'; import { filterManager } from '../../model';
interface Props {
class?: string;
}
const { class: className }: Props = $props();
const transform = new Tween(
{ scale: 1, rotate: 0 },
{ duration: 150, easing: cubicOut },
);
function handleClick() {
filterManager.deselectAllGlobal();
transform.set({ scale: 0.98, rotate: 1 }).then(() => {
transform.set({ scale: 1, rotate: 0 });
});
}
</script> </script>
<div class="flex flex-row gap-2"> <div
<Button class={cn('flex flex-row gap-2', className)}
variant="outline" style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
class="flex-1 cursor-pointer"
onclick={filterManager.deselectAllGlobal}
> >
<Button
variant="ghost"
class="group flex flex-1 cursor-pointer gap-1"
onclick={handleClick}
>
<Rotate class="size-4 group-hover:-rotate-180 transition-transform duration-300" />
Reset Reset
</Button> </Button>
</div> </div>

View File

@@ -1,33 +0,0 @@
<!--
Component: FontSearch
Combines search input with font list display
-->
<script lang="ts">
import { fontshareStore } from '$entities/Font';
import { SearchBar } from '$shared/ui';
import { onMount } from 'svelte';
import { mapManagerToParams } from '../../lib';
import { filterManager } from '../../model';
import SuggestedFonts from '../SuggestedFonts/SuggestedFonts.svelte';
onMount(() => {
/**
* The Pairing:
* We "plug" this manager into the global store.
* addBinding returns a function that removes this binding when the component unmounts.
*/
const unbind = fontshareStore.addBinding(() => mapManagerToParams(filterManager));
return unbind;
});
</script>
<SearchBar
id="font-search"
class="w-full"
placeholder="Search fonts by name..."
bind:value={filterManager.queryValue}
>
<SuggestedFonts />
</SearchBar>

View File

@@ -1,9 +1,9 @@
import Filters from './Filters/Filters.svelte'; import Filters from './Filters/Filters.svelte';
import FilterControls from './FiltersControl/FilterControls.svelte'; import FilterControls from './FiltersControl/FilterControls.svelte';
import FontSearch from './FontSearch/FontSearch.svelte'; import SuggestedFonts from './SuggestedFonts/SuggestedFonts.svelte';
export { export {
FilterControls, FilterControls,
Filters, Filters,
FontSearch, SuggestedFonts,
}; };

View File

@@ -3,18 +3,19 @@
Contains controls for setting up font properties. Contains controls for setting up font properties.
--> -->
<script lang="ts"> <script lang="ts">
import { Separator } from '$shared/shadcn/ui/separator/index';
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
import { ComboControl } from '$shared/ui'; import { ComboControl } from '$shared/ui';
import { controlManager } from '../model'; import { controlManager } from '../model';
</script> </script>
<div class="p-2 flex flex-row items-center gap-2"> <div class="py-2 px-10 flex flex-row items-center gap-2">
<SidebarTrigger /> <div class="flex flex-row gap-3">
<Separator orientation="vertical" class="h-full" />
<div class="flex flex-row gap-2">
{#each controlManager.controls as control (control.id)} {#each controlManager.controls as control (control.id)}
<ComboControl control={control.instance} /> <ComboControl
control={control.instance}
increaseLabel={control.increaseLabel}
decreaseLabel={control.decreaseLabel}
controlLabel={control.controlLabel}
/>
{/each} {/each}
</div> </div>
</div> </div>

View File

@@ -1,12 +1,35 @@
<script lang="ts"> <script lang="ts">
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte'; import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
import { FontSearch } from '$widgets/FontSearch';
/** /**
* Page Component * Page Component
*/ */
let searchContainer: HTMLElement;
let isExpanded = $state(false);
</script> </script>
<!-- Font List --> <!-- Font List -->
<div class="p-2"> <div class="p-2 will-change-[height]">
<div bind:this={searchContainer}>
<FontSearch bind:showFilters={isExpanded} />
</div>
<div class="will-change-tranform transition-transform content">
<FontDisplay /> <FontDisplay />
</div> </div>
</div>
<style>
.content {
/* Tells the browser to skip rendering off-screen content */
content-visibility: auto;
/* Helps the browser reserve space without calculating everything */
contain-intrinsic-size: 1px 1000px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

View File

@@ -20,3 +20,5 @@ export {
} from './helpers'; } from './helpers';
export { splitArray } from './utils'; export { splitArray } from './utils';
export { springySlideFade } from './transitions';

View File

@@ -0,0 +1 @@
export { springySlideFade } from './springySlideFade/springySlideFade';

View File

@@ -0,0 +1,60 @@
import type {
SlideParams,
TransitionConfig,
} from 'svelte/transition';
function elasticOut(t: number) {
return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1;
}
function gentleSpring(t: number) {
return 1 - Math.pow(1 - t, 3) * Math.cos(t * Math.PI * 2);
}
/**
* Svelte slide transition function for custom slide+fade
* @param node - The element to apply the transition to
* @param params - Transition parameters
* @returns Transition configuration
*/
export function springySlideFade(
node: HTMLElement,
params: SlideParams = {},
): TransitionConfig {
const { duration = 400 } = params;
const height = node.scrollHeight;
// Check if the browser is Firefox to work around specific rendering issues
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
return {
duration,
// We use 'tick' for the most precise control over the
// coordination with the elements below.
css: t => {
// Use elastic easing
const eased = gentleSpring(t);
return `
height: ${eased * height}px;
opacity: ${t};
transform: translateY(${(1 - t) * -10}px);
transform-origin: top;
overflow: hidden;
contain: size layout style;
will-change: max-height, opacity, transform;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
${
isFirefox
? `
perspective: 1000px;
isolation: isolate;
`
: ''
}
`;
},
};
}

View File

@@ -0,0 +1,10 @@
import Scrollbar from './scroll-area-scrollbar.svelte';
import Root from './scroll-area.svelte';
export {
Root,
// ,
Root as ScrollArea,
Scrollbar,
Scrollbar as ScrollAreaScrollbar,
};

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import {
type WithoutChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
orientation = 'vertical',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
</script>
<ScrollAreaPrimitive.Scrollbar
bind:ref
data-slot="scroll-area-scrollbar"
{orientation}
class={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' && 'h-full w-2.5 border-s border-s-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...restProps}
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
class="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.Scrollbar>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import {
type WithoutChild,
cn,
} from '$shared/shadcn/utils/shadcn-utils.js';
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
import { Scrollbar } from './index.js';
let {
ref = $bindable(null),
viewportRef = $bindable(null),
class: className,
orientation = 'vertical',
scrollbarXClasses = '',
scrollbarYClasses = '',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: 'vertical' | 'horizontal' | 'both' | undefined;
scrollbarXClasses?: string | undefined;
scrollbarYClasses?: string | undefined;
viewportRef?: HTMLElement | null;
} = $props();
</script>
<ScrollAreaPrimitive.Root
bind:ref
data-slot="scroll-area"
class={cn('relative', className)}
{...restProps}
>
<ScrollAreaPrimitive.Viewport
bind:ref={viewportRef}
data-slot="scroll-area-viewport"
class="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
>
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if orientation === 'vertical' || orientation === 'both'}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === 'horizontal' || orientation === 'both'}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>

View File

@@ -8,7 +8,10 @@
- Local transition prevents animation when component first renders - Local transition prevents animation when component first renders
--> -->
<script lang="ts"> <script lang="ts">
import type { Filter } from '$shared/lib'; import {
type Filter,
springySlideFade,
} from '$shared/lib';
import { Badge } from '$shared/shadcn/ui/badge'; import { Badge } from '$shared/shadcn/ui/badge';
import { buttonVariants } from '$shared/shadcn/ui/button'; import { buttonVariants } from '$shared/shadcn/ui/button';
import { Checkbox } from '$shared/shadcn/ui/checkbox'; import { Checkbox } from '$shared/shadcn/ui/checkbox';
@@ -20,7 +23,6 @@ import { Label } from '$shared/shadcn/ui/label';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { prefersReducedMotion } from 'svelte/motion'; import { prefersReducedMotion } from 'svelte/motion';
import { slide } from 'svelte/transition';
interface PropertyFilterProps { interface PropertyFilterProps {
/** Label for this filter group (e.g., "Properties", "Tags") */ /** Label for this filter group (e.g., "Properties", "Tags") */
@@ -37,7 +39,7 @@ let isOpen = $state(true);
// Animation config respects user preferences - zero duration if reduced motion enabled // Animation config respects user preferences - zero duration if reduced motion enabled
// Local modifier prevents animation on initial render, only animates user interactions // Local modifier prevents animation on initial render, only animates user interactions
const slideConfig = $derived({ const slideConfig = $derived({
duration: prefersReducedMotion.current ? 0 : 250, duration: prefersReducedMotion.current ? 0 : 150,
easing: cubicOut, easing: cubicOut,
}); });
@@ -49,7 +51,7 @@ const hasSelection = $derived(selectedCount > 0);
<!-- Collapsible card wrapper with subtle hover state for affordance --> <!-- Collapsible card wrapper with subtle hover state for affordance -->
<CollapsibleRoot <CollapsibleRoot
bind:open={isOpen} bind:open={isOpen}
class="w-full rounded-lg border bg-card transition-colors hover:bg-accent/5" class="w-full bg-card transition-colors hover:bg-accent/5"
> >
<!-- Trigger row: title, expand indicator, and optional count badge --> <!-- Trigger row: title, expand indicator, and optional count badge -->
<div class="flex items-center justify-between px-4 py-2"> <div class="flex items-center justify-between px-4 py-2">
@@ -88,8 +90,8 @@ const hasSelection = $derived(selectedCount > 0);
<!-- Expandable content with slide animation --> <!-- Expandable content with slide animation -->
{#if isOpen} {#if isOpen}
<div <div
transition:slide|local={slideConfig} transition:springySlideFade|local={slideConfig}
class="border-t" class="will-change-[height,opacity]"
> >
<div class="px-4 py-3"> <div class="px-4 py-3">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">

View File

@@ -16,6 +16,11 @@ import {
Trigger as PopoverTrigger, Trigger as PopoverTrigger,
} from '$shared/shadcn/ui/popover'; } from '$shared/shadcn/ui/popover';
import { Slider } from '$shared/shadcn/ui/slider'; import { Slider } from '$shared/shadcn/ui/slider';
import {
Content as TooltipContent,
Root as TooltipRoot,
Trigger as TooltipTrigger,
} from '$shared/shadcn/ui/tooltip';
import MinusIcon from '@lucide/svelte/icons/minus'; import MinusIcon from '@lucide/svelte/icons/minus';
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import type { ChangeEventHandler } from 'svelte/elements'; import type { ChangeEventHandler } from 'svelte/elements';
@@ -71,16 +76,35 @@ const handleSliderChange = (newValue: number) => {
}; };
</script> </script>
<TooltipRoot>
<ButtonGroupRoot class="bg-transparent border-none shadow-none"> <ButtonGroupRoot class="bg-transparent border-none shadow-none">
<TooltipTrigger class="flex items-center">
<Button <Button
variant="ghost" variant="ghost"
class="hover:bg-white/50 bg-white/20 border-none" class="
group relative border-none size-9
bg-white/20 hover:bg-white/60
transition-all duration-200 ease-out
will-change-transform
hover:-translate-y-0.5
active:translate-y-0 active:scale-95 active:shadow-none
cursor-pointer
disabled:opacity-50 disabled:pointer-events-none
"
size="icon" size="icon"
aria-label={decreaseLabel}
onclick={control.decrease} onclick={control.decrease}
disabled={control.isAtMin} disabled={control.isAtMin}
aria-label={decreaseLabel}
> >
<MinusIcon class="size-4" /> <MinusIcon
class="
size-4 transition-all duration-200
stroke-slate-600/50
group-hover:stroke-indigo-500 group-hover:scale-110 group-hover:stroke-3
group-active:scale-90 group-active:-rotate-6
group-disabled:stroke-transparent
"
/>
</Button> </Button>
<PopoverRoot> <PopoverRoot>
<PopoverTrigger> <PopoverTrigger>
@@ -88,7 +112,7 @@ const handleSliderChange = (newValue: number) => {
<Button <Button
{...props} {...props}
variant="ghost" variant="ghost"
class="hover:bg-white/50 bg-white/20 border-none" class="hover:bg-white/50 hover:font-bold bg-white/20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
size="icon" size="icon"
aria-label={controlLabel} aria-label={controlLabel}
> >
@@ -118,14 +142,39 @@ const handleSliderChange = (newValue: number) => {
</div> </div>
</PopoverContent> </PopoverContent>
</PopoverRoot> </PopoverRoot>
<Button <Button
variant="ghost" variant="ghost"
class="hover:bg-white/50 bg-white/20 border-none" class="
group relative border-none size-9
bg-white/20 hover:bg-white/60
transition-all duration-200 ease-out
will-change-transform
hover:-translate-y-0.5
active:translate-y-0 active:scale-95 active:shadow-none
disabled:opacity-50 disabled:pointer-events-none
cursor-pointer
"
size="icon" size="icon"
aria-label={increaseLabel} aria-label={increaseLabel}
onclick={control.increase} onclick={control.increase}
disabled={control.isAtMax} disabled={control.isAtMax}
> >
<PlusIcon class="size-4" /> <PlusIcon
class="
size-4 transition-all duration-200
stroke-slate-600/50
group-hover:stroke-indigo-500 group-hover:scale-110 group-hover:stroke-3
group-active:scale-90 group-active:rotate-6
group-disabled:stroke-transparent
"
/>
</Button> </Button>
</TooltipTrigger>
</ButtonGroupRoot> </ButtonGroupRoot>
{#if controlLabel}
<TooltipContent>
{controlLabel}
</TooltipContent>
{/if}
</TooltipRoot>

View File

@@ -0,0 +1,188 @@
<!--
Component: ExpandableWrapper
Animated wrapper for content that can be expanded and collapsed.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { Spring } from 'svelte/motion';
import { slide } from 'svelte/transition';
interface Props extends HTMLAttributes<HTMLDivElement> {
/**
* Bindable property to control the expanded state of the wrapper.
* @default false
*/
expanded?: boolean;
/**
* Disabled flag
* @default false
*/
disabled?: boolean;
/**
* Bindable property to bind:this
* @default null
*/
element?: HTMLElement | null;
/**
* Content that's always visible
*/
visibleContent?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Content that's hidden when the wrapper is collapsed
*/
hiddenContent?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Optional badge to render
*/
badge?: Snippet<[{ expanded?: boolean; disabled?: boolean }]>;
/**
* Rotation animation direction
* @default 'clockwise'
*/
rotation?: 'clockwise' | 'counterclockwise';
}
let {
expanded = $bindable(false),
disabled = false,
element = $bindable(null),
visibleContent,
hiddenContent,
badge,
rotation = 'clockwise',
class: className = '',
...props
}: Props = $props();
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
export const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle
});
const ySpring = new Spring(0, {
stiffness: 0.32,
damping: 0.65,
});
const scaleSpring = new Spring(1, {
stiffness: 0.32,
damping: 0.65,
});
export const rotateSpring = new Spring(0, {
stiffness: 0.12,
damping: 0.55,
});
function handleClickOutside(e: MouseEvent) {
if (element && !element.contains(e.target as Node)) {
expanded = false;
}
}
function handleWrapperClick() {
if (!disabled) {
expanded = true;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleWrapperClick();
}
if (expanded && e.key === 'Escape') {
expanded = false;
}
}
// Elevation and scale on activation
$effect(() => {
if (expanded && !disabled) {
// Lift up
ySpring.target = 8;
// Slightly bigger
scaleSpring.target = 1.1;
rotateSpring.target = rotation === 'clockwise' ? -0.5 : 0.5;
timeoutId = setTimeout(() => {
rotateSpring.target = 0;
scaleSpring.target = 1.05;
}, 300);
} else {
ySpring.target = 0;
scaleSpring.target = 1;
rotateSpring.target = 0;
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
});
// Click outside handling
$effect(() => {
if (typeof window === 'undefined') {
return;
}
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
$effect(() => {
if (disabled) {
expanded = false;
}
});
</script>
<div
bind:this={element}
onclick={handleWrapperClick}
onkeydown={handleKeyDown}
role="button"
tabindex={0}
class={cn(
'will-change-transform duration-300',
disabled ? 'pointer-events-none' : 'pointer-events-auto',
className,
)}
style:transform="
translate({xSpring.current}px, {ySpring.current}px)
scale({scaleSpring.current})
rotateZ({rotateSpring.current}deg)
"
{...props}
>
{@render badge?.({ expanded, disabled })}
<div
class={cn(
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
expanded
? 'bg-white/95 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
disabled && 'opacity-80 grayscale-[0.2]',
)}
>
{@render visibleContent?.({ expanded, disabled })}
{#if expanded}
<div
in:slide={{ duration: 250, delay: 50 }}
out:slide={{ duration: 250 }}
>
{@render hiddenContent?.({ expanded, disabled })}
</div>
{/if}
</div>
</div>

View File

@@ -72,8 +72,9 @@ function handleInputClick() {
onclick={handleInputClick} onclick={handleInputClick}
class=" class="
h-20 w-full md:text-2xl backdrop-blur-sm bg-white/60 dark:bg-slate-900/40 h-20 w-full md:text-2xl backdrop-blur-sm bg-white/60 dark:bg-slate-900/40
border-2 border-slate-200/50 dark:border-slate-700/50 ring-2 ring-slate-200/50
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/50 active:ring-indigo-500/50
focus-visible:border-indigo-500/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/50
hover:bg-white/70 dark:hover:bg-slate-900/50 text-slate-900 dark:text-slate-100 hover:bg-white/70 dark:hover:bg-slate-900/50 text-slate-900 dark:text-slate-100
placeholder:text-slate-400 px-6 py-4 rounded-2xl transition-all duration-300 placeholder:text-slate-400 px-6 py-4 rounded-2xl transition-all duration-300
font-medium font-medium

View File

@@ -8,6 +8,7 @@ import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
import ComboControl from './ComboControl/ComboControl.svelte'; import ComboControl from './ComboControl/ComboControl.svelte';
import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte'; import ComboControlV2 from './ComboControlV2/ComboControlV2.svelte';
import ContentEditable from './ContentEditable/ContentEditable.svelte'; import ContentEditable from './ContentEditable/ContentEditable.svelte';
import ExpandableWrapper from './ExpandableWrapper/ExpandableWrapper.svelte';
import SearchBar from './SearchBar/SearchBar.svelte'; import SearchBar from './SearchBar/SearchBar.svelte';
import VirtualList from './VirtualList/VirtualList.svelte'; import VirtualList from './VirtualList/VirtualList.svelte';
@@ -16,6 +17,7 @@ export {
ComboControl, ComboControl,
ComboControlV2, ComboControlV2,
ContentEditable, ContentEditable,
ExpandableWrapper,
SearchBar, SearchBar,
VirtualList, VirtualList,
}; };

View File

@@ -0,0 +1 @@
export { ComparisonSlider } from './ui';

View File

@@ -3,9 +3,9 @@ import type { TypographyControl } from '$shared/lib';
import { Input } from '$shared/shadcn/ui/input'; import { Input } from '$shared/shadcn/ui/input';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { ComboControlV2 } from '$shared/ui'; import { ComboControlV2 } from '$shared/ui';
import { ExpandableWrapper } from '$shared/ui';
import AArrowUP from '@lucide/svelte/icons/a-arrow-up'; import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
import { slide } from 'svelte/transition';
interface Props { interface Props {
wrapper?: HTMLDivElement | null; wrapper?: HTMLDivElement | null;
@@ -29,36 +29,27 @@ let {
heightControl, heightControl,
}: Props = $props(); }: Props = $props();
let panelWidth = $state(0); let panelWidth = $derived(wrapper?.clientWidth ?? 0);
const margin = 24; const margin = 24;
let side = $state<'left' | 'right'>('left'); let side = $state<'left' | 'right'>('left');
// Unified active state for the entire wrapper // Unified active state for the entire wrapper
let isActive = $state(false); let isActive = $state(false);
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
function handleWrapperClick() { const xSpring = new Spring(0, {
if (!isDragging) { stiffness: 0.14, // Lower is slower
isActive = true; damping: 0.5, // Settle
} });
}
function handleClickOutside(e: MouseEvent) { const rotateSpring = new Spring(0, {
if (wrapper && !wrapper.contains(e.target as Node)) { stiffness: 0.12,
isActive = false; damping: 0.55,
} });
}
function handleInputFocus() { function handleInputFocus() {
isActive = true; isActive = true;
} }
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleWrapperClick();
}
}
// Movement Logic // Movement Logic
$effect(() => { $effect(() => {
if (containerWidth === 0 || panelWidth === 0) return; if (containerWidth === 0 || panelWidth === 0) return;
@@ -74,98 +65,45 @@ $effect(() => {
} }
}); });
// The "Dodge"
const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle
});
// The "Focus"
const ySpring = new Spring(0, {
stiffness: 0.32,
damping: 0.65,
});
// The "Rise"
const scaleSpring = new Spring(1, {
stiffness: 0.32,
damping: 0.65,
});
// The "Lean"
const rotateSpring = new Spring(0, {
stiffness: 0.12,
damping: 0.55,
});
$effect(() => { $effect(() => {
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0; const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
if (containerWidth > 0 && panelWidth > 0 && !isActive) { if (containerWidth > 0 && panelWidth > 0) {
// On side change set the position and the rotation // On side change set the position and the rotation
xSpring.target = targetX; xSpring.target = targetX;
rotateSpring.target = side === 'right' ? 3.5 : -3.5; rotateSpring.target = side === 'right' ? 3.5 : -3.5;
setTimeout(() => { timeoutId = setTimeout(() => {
rotateSpring.target = 0; rotateSpring.target = 0;
}, 600); }, 600);
} }
});
// Elevation and scale on focus and mouse over return () => {
$effect(() => { if (timeoutId) {
if (isActive && !isDragging) { clearTimeout(timeoutId);
// Lift up
ySpring.target = 8;
// Slightly bigger
scaleSpring.target = 1.1;
rotateSpring.target = side === 'right' ? -1.1 : 1.1;
setTimeout(() => {
rotateSpring.target = 0;
scaleSpring.target = 1.05;
}, 300);
} else {
ySpring.target = 0;
scaleSpring.target = 1;
rotateSpring.target = 0;
} }
}); };
$effect(() => {
if (isDragging) {
isActive = false;
}
});
// Click outside handler
$effect(() => {
if (typeof window === 'undefined') return;
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}); });
</script> </script>
<div <div
onclick={handleWrapperClick} class="absolute top-6 left-6 z-50 will-change-transform"
bind:this={wrapper}
bind:clientWidth={panelWidth}
class={cn(
'absolute top-6 left-6 z-50 will-change-transform transition-opacity duration-300 flex items-top gap-1.5',
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
)}
style:pointer-events={isDragging ? 'none' : 'auto'}
style:transform=" style:transform="
translate({xSpring.current}px, {ySpring.current}px) translateX({xSpring.current}px)
scale({scaleSpring.current})
rotateZ({rotateSpring.current}deg) rotateZ({rotateSpring.current}deg)
" "
role="button"
tabindex={0}
onkeydown={handleKeyDown}
aria-label="Font controls"
> >
<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 <div
class={cn( class={cn(
'animate-nudge relative transition-all', 'animate-nudge relative transition-all',
@@ -176,17 +114,9 @@ $effect(() => {
> >
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} /> <AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
</div> </div>
{/snippet}
<div {#snippet visibleContent()}
class={cn(
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5',
isActive
? 'bg-white/95 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)]',
isDragging && 'opacity-80 grayscale-[0.2]',
)}
style:backdrop-filter="blur(24px)"
>
<div class="relative px-2 py-1"> <div class="relative px-2 py-1">
<Input <Input
bind:value={text} bind:value={text}
@@ -198,44 +128,17 @@ $effect(() => {
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm font-medium focus-visible:ring-0 text-slate-900/50', : '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:text-slate-400 selection:bg-indigo-100 flex-1 transition-all duration-350 w-56',
)} )}
placeholder="Edit label..." placeholder="The quick brown fox..."
/> />
</div> </div>
{/snippet}
{#if isActive} {#snippet hiddenContent()}
<div <div class="flex justify-between items-center-safe">
in:slide={{ duration: 250, delay: 50 }}
out:slide={{ duration: 250 }}
class="flex justify-between items-center-safe"
>
<ComboControlV2 control={weightControl} /> <ComboControlV2 control={weightControl} />
<ComboControlV2 control={sizeControl} /> <ComboControlV2 control={sizeControl} />
<ComboControlV2 control={heightControl} /> <ComboControlV2 control={heightControl} />
</div> </div>
{/if} {/snippet}
</ExpandableWrapper>
</div> </div>
</div>
<style>
@keyframes nudge {
0%, 100% {
transform: translateY(0) scale(1) rotate(0deg);
}
2% {
transform: translateY(-2px) scale(1.1) rotate(-1deg);
}
4% {
transform: translateY(0) scale(1) rotate(1deg);
}
6% {
transform: translateY(-2px) scale(1.1) rotate(0deg);
}
8% {
transform: translateY(0) scale(1) rotate(0deg);
}
}
.animate-nudge {
animation: nudge 10s ease-in-out infinite;
}
</style>

View File

@@ -1,3 +0,0 @@
import FiltersSidebar from './ui/FiltersSidebar.svelte';
export { FiltersSidebar };

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import {
FilterControls,
Filters,
} from '$features/GetFonts';
import {
Content as SidebarContent,
Root as SidebarRoot,
} from '$shared/shadcn/ui/sidebar/index';
/**
* FiltersSidebar Component
*
* Main application sidebar widget. Contains filter controls and action buttons
* for font filtering operations. Organized into two sections:
*
* - Filters: Category-based filter groups (providers, subsets, categories)
* - Controls: Reset button for filter actions
*
* Features:
* - Loading indicator during font fetch operations
* - Empty state message when no fonts match filters
* - Error display for failed font operations
* - Responsive sidebar behavior via shadcn Sidebar component
*
* Uses Sidebar.Root from shadcn for responsive sidebar behavior including
* mobile drawer and desktop persistent sidebar modes.
*/
</script>
<SidebarRoot>
<SidebarContent class="p-2">
<!-- Filter groups -->
<Filters />
<!-- Action buttons -->
<FilterControls />
</SidebarContent>
</SidebarRoot>

View File

@@ -0,0 +1 @@
export { FontSearch } from './ui';

View File

@@ -0,0 +1,107 @@
<!--
Component: FontSearch
Combines search input with font list display
-->
<script lang="ts">
import { fontshareStore } from '$entities/Font';
import {
FilterControls,
Filters,
SuggestedFonts,
filterManager,
mapManagerToParams,
} from '$features/GetFonts';
import { springySlideFade } from '$shared/lib';
import { Button } from '$shared/shadcn/ui/button';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { SearchBar } from '$shared/ui';
import FunnelIcon from '@lucide/svelte/icons/funnel';
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import {
Tween,
prefersReducedMotion,
} from 'svelte/motion';
import { type SlideParams } from 'svelte/transition';
interface Props {
showFilters?: boolean;
}
let { showFilters = $bindable(false) }: Props = $props();
onMount(() => {
/**
* The Pairing:
* We "plug" this manager into the global store.
* addBinding returns a function that removes this binding when the component unmounts.
*/
const unbind = fontshareStore.addBinding(() => mapManagerToParams(filterManager));
return unbind;
});
const transform = new Tween(
{ scale: 1, rotate: 0 },
{ duration: 250, easing: cubicOut },
);
const slideConfig = $derived<SlideParams>({
duration: prefersReducedMotion.current ? 0 : 200,
easing: cubicOut,
axis: 'y',
});
function toggleFilters() {
showFilters = !showFilters;
transform.set({ scale: 0.9, rotate: 2 }).then(() => {
transform.set({ scale: 1, rotate: 0 });
});
}
</script>
<div class="flex flex-col gap-2 relative">
<SearchBar
id="font-search"
class="w-full"
placeholder="Search fonts by name..."
bind:value={filterManager.queryValue}
>
<SuggestedFonts />
</SearchBar>
<div class="absolute right-5 top-10 translate-y-[-50%] pl-5 border-l-2">
<div style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)">
<Button
class={cn(
'cursor-pointer will-change-transform hover:bg-inherit hover:*:stroke-indigo-500',
showFilters ? 'hover:*:stroke-indigo-500/80' : 'hover:*:stroke-indigo-500',
)}
variant="ghost"
size="icon"
onclick={toggleFilters}
>
<FunnelIcon
class={cn(
'size-8 stroke-indigo-600/50 transition-all duration-150',
showFilters ? 'stroke-indigo-600' : 'stroke-indigo-600/50',
)}
/>
</Button>
</div>
</div>
{#if showFilters}
<div
transition:springySlideFade|local={slideConfig}
class="will-change-[height,opacity] contain-layout overflow-hidden"
>
<div class="grid gap-1 grid-cols-[repeat(auto-fit,minmax(8em,14em))]">
<Filters />
<FilterControls class="ml-auto py-1" />
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,3 @@
import FontSearch from './FontSearch/FontSearch.svelte';
export { FontSearch };

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { FontSearch } from '$features/GetFonts';
import SetupFontMenu from '$features/SetupFont/ui/SetupFontMenu.svelte'; import SetupFontMenu from '$features/SetupFont/ui/SetupFontMenu.svelte';
import { import {
Content as ItemContent, Content as ItemContent,
@@ -7,11 +6,10 @@ import {
} from '$shared/shadcn/ui/item'; } from '$shared/shadcn/ui/item';
</script> </script>
<div class="w-full p-2"> <div class="w-full p-2 backdrop-blur-2xl">
<ItemRoot variant="outline" class="w-full p-2.5"> <ItemRoot variant="outline" class="w-full p-2.5">
<ItemContent class="flex flex-row justify-center items-center"> <ItemContent class="flex flex-row justify-center items-center">
<SetupFontMenu /> <SetupFontMenu />
<FontSearch />
</ItemContent> </ItemContent>
</ItemRoot> </ItemRoot>
</div> </div>

3
src/widgets/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { ComparisonSlider } from './ComparisonSlider';
export { FontSearch } from './FontSearch';
export { TypographyMenu } from './TypographySettings';