Compare commits
18 Commits
59b0d9c620
...
c07800cc96
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c07800cc96 | ||
|
|
b49bf0d397 | ||
|
|
ed4ee8bb44 | ||
|
|
8a2059ac4a | ||
|
|
7ffc5d6a34 | ||
|
|
08cccc5ede | ||
|
|
71266f8b22 | ||
|
|
d5221ad449 | ||
|
|
873b697e8c | ||
|
|
3dce409034 | ||
|
|
cf08f7adfa | ||
|
|
4b01b1592d | ||
|
|
ecb4bea642 | ||
|
|
e89c6369cb | ||
|
|
18a311c6b1 | ||
|
|
732f77f504 | ||
|
|
b7992ca138 | ||
|
|
32b1367877 |
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export { displayedFontsStore } from './model';
|
||||||
export { FontDisplay } from './ui';
|
export { FontDisplay } from './ui';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ export { filterManager } from './model/state/manager.svelte';
|
|||||||
export {
|
export {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
FontSearch,
|
SuggestedFonts,
|
||||||
} from './ui';
|
} from './ui';
|
||||||
|
|||||||
@@ -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
|
||||||
|
class={cn('flex flex-row gap-2', className)}
|
||||||
|
style:transform="scale({transform.current.scale}) rotate({transform.current.rotate}deg)"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
class="flex-1 cursor-pointer"
|
class="group flex flex-1 cursor-pointer gap-1"
|
||||||
onclick={filterManager.deselectAllGlobal}
|
onclick={handleClick}
|
||||||
>
|
>
|
||||||
|
<Rotate class="size-4 group-hover:-rotate-180 transition-transform duration-300" />
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: FontSearch
|
|
||||||
|
|
||||||
Combines search input with font list display
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { fontshareStore } from '$entities/Font';
|
|
||||||
import { SearchBar } from '$shared/ui';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { mapManagerToParams } from '../../lib';
|
|
||||||
import { filterManager } from '../../model';
|
|
||||||
import SuggestedFonts from '../SuggestedFonts/SuggestedFonts.svelte';
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
/**
|
|
||||||
* The Pairing:
|
|
||||||
* We "plug" this manager into the global store.
|
|
||||||
* addBinding returns a function that removes this binding when the component unmounts.
|
|
||||||
*/
|
|
||||||
const unbind = fontshareStore.addBinding(() => mapManagerToParams(filterManager));
|
|
||||||
|
|
||||||
return unbind;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<SearchBar
|
|
||||||
id="font-search"
|
|
||||||
class="w-full"
|
|
||||||
placeholder="Search fonts by name..."
|
|
||||||
bind:value={filterManager.queryValue}
|
|
||||||
>
|
|
||||||
<SuggestedFonts />
|
|
||||||
</SearchBar>
|
|
||||||
@@ -1,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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,18 +3,19 @@
|
|||||||
Contains controls for setting up font properties.
|
Contains controls for setting up font properties.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Separator } from '$shared/shadcn/ui/separator/index';
|
|
||||||
import { Trigger as SidebarTrigger } from '$shared/shadcn/ui/sidebar';
|
|
||||||
import { ComboControl } from '$shared/ui';
|
import { ComboControl } from '$shared/ui';
|
||||||
import { controlManager } from '../model';
|
import { controlManager } from '../model';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-2 flex flex-row items-center gap-2">
|
<div class="py-2 px-10 flex flex-row items-center gap-2">
|
||||||
<SidebarTrigger />
|
<div class="flex flex-row gap-3">
|
||||||
<Separator orientation="vertical" class="h-full" />
|
|
||||||
<div class="flex flex-row gap-2">
|
|
||||||
{#each controlManager.controls as control (control.id)}
|
{#each controlManager.controls as control (control.id)}
|
||||||
<ComboControl control={control.instance} />
|
<ComboControl
|
||||||
|
control={control.instance}
|
||||||
|
increaseLabel={control.increaseLabel}
|
||||||
|
decreaseLabel={control.decreaseLabel}
|
||||||
|
controlLabel={control.controlLabel}
|
||||||
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,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>
|
||||||
|
|||||||
@@ -20,3 +20,5 @@ export {
|
|||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
|
||||||
export { splitArray } from './utils';
|
export { splitArray } from './utils';
|
||||||
|
|
||||||
|
export { springySlideFade } from './transitions';
|
||||||
|
|||||||
1
src/shared/lib/transitions/index.ts
Normal file
1
src/shared/lib/transitions/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { springySlideFade } from './springySlideFade/springySlideFade';
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type {
|
||||||
|
SlideParams,
|
||||||
|
TransitionConfig,
|
||||||
|
} from 'svelte/transition';
|
||||||
|
|
||||||
|
function elasticOut(t: number) {
|
||||||
|
return Math.pow(2, -10 * t) * Math.sin((t - 0.075) * (2 * Math.PI) / 0.3) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function gentleSpring(t: number) {
|
||||||
|
return 1 - Math.pow(1 - t, 3) * Math.cos(t * Math.PI * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Svelte slide transition function for custom slide+fade
|
||||||
|
* @param node - The element to apply the transition to
|
||||||
|
* @param params - Transition parameters
|
||||||
|
* @returns Transition configuration
|
||||||
|
*/
|
||||||
|
export function springySlideFade(
|
||||||
|
node: HTMLElement,
|
||||||
|
params: SlideParams = {},
|
||||||
|
): TransitionConfig {
|
||||||
|
const { duration = 400 } = params;
|
||||||
|
const height = node.scrollHeight;
|
||||||
|
|
||||||
|
// Check if the browser is Firefox to work around specific rendering issues
|
||||||
|
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
|
||||||
|
|
||||||
|
return {
|
||||||
|
duration,
|
||||||
|
// We use 'tick' for the most precise control over the
|
||||||
|
// coordination with the elements below.
|
||||||
|
css: t => {
|
||||||
|
// Use elastic easing
|
||||||
|
const eased = gentleSpring(t);
|
||||||
|
|
||||||
|
return `
|
||||||
|
height: ${eased * height}px;
|
||||||
|
opacity: ${t};
|
||||||
|
transform: translateY(${(1 - t) * -10}px);
|
||||||
|
transform-origin: top;
|
||||||
|
overflow: hidden;
|
||||||
|
contain: size layout style;
|
||||||
|
will-change: max-height, opacity, transform;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
${
|
||||||
|
isFirefox
|
||||||
|
? `
|
||||||
|
perspective: 1000px;
|
||||||
|
isolation: isolate;
|
||||||
|
`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/shared/shadcn/ui/scroll-area/index.ts
Normal file
10
src/shared/shadcn/ui/scroll-area/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Scrollbar from './scroll-area-scrollbar.svelte';
|
||||||
|
import Root from './scroll-area.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
// ,
|
||||||
|
Root as ScrollArea,
|
||||||
|
Scrollbar,
|
||||||
|
Scrollbar as ScrollAreaScrollbar,
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
type WithoutChild,
|
||||||
|
cn,
|
||||||
|
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = 'vertical',
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
bind:ref
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
{orientation}
|
||||||
|
class={cn(
|
||||||
|
'flex touch-none p-px transition-colors select-none',
|
||||||
|
orientation === 'vertical' && 'h-full w-2.5 border-s border-s-transparent',
|
||||||
|
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
<ScrollAreaPrimitive.Thumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
class="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
46
src/shared/shadcn/ui/scroll-area/scroll-area.svelte
Normal file
46
src/shared/shadcn/ui/scroll-area/scroll-area.svelte
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
type WithoutChild,
|
||||||
|
cn,
|
||||||
|
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from 'bits-ui';
|
||||||
|
import { Scrollbar } from './index.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
viewportRef = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
orientation = 'vertical',
|
||||||
|
scrollbarXClasses = '',
|
||||||
|
scrollbarYClasses = '',
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
|
||||||
|
orientation?: 'vertical' | 'horizontal' | 'both' | undefined;
|
||||||
|
scrollbarXClasses?: string | undefined;
|
||||||
|
scrollbarYClasses?: string | undefined;
|
||||||
|
viewportRef?: HTMLElement | null;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="scroll-area"
|
||||||
|
class={cn('relative', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
bind:ref={viewportRef}
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
class="ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
{#if orientation === 'vertical' || orientation === 'both'}
|
||||||
|
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
|
||||||
|
{/if}
|
||||||
|
{#if orientation === 'horizontal' || orientation === 'both'}
|
||||||
|
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
|
||||||
|
{/if}
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<ButtonGroupRoot class="bg-transparent border-none shadow-none">
|
<TooltipRoot>
|
||||||
|
<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>
|
||||||
</ButtonGroupRoot>
|
</TooltipTrigger>
|
||||||
|
</ButtonGroupRoot>
|
||||||
|
{#if controlLabel}
|
||||||
|
<TooltipContent>
|
||||||
|
{controlLabel}
|
||||||
|
</TooltipContent>
|
||||||
|
{/if}
|
||||||
|
</TooltipRoot>
|
||||||
|
|||||||
188
src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte
Normal file
188
src/shared/ui/ExpandableWrapper/ExpandableWrapper.svelte
Normal 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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
1
src/widgets/ComparisonSlider/index.ts
Normal file
1
src/widgets/ComparisonSlider/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ComparisonSlider } from './ui';
|
||||||
@@ -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}
|
||||||
</div>
|
</ExpandableWrapper>
|
||||||
</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>
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import FiltersSidebar from './ui/FiltersSidebar.svelte';
|
|
||||||
|
|
||||||
export { FiltersSidebar };
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import {
|
|
||||||
FilterControls,
|
|
||||||
Filters,
|
|
||||||
} from '$features/GetFonts';
|
|
||||||
import {
|
|
||||||
Content as SidebarContent,
|
|
||||||
Root as SidebarRoot,
|
|
||||||
} from '$shared/shadcn/ui/sidebar/index';
|
|
||||||
/**
|
|
||||||
* FiltersSidebar Component
|
|
||||||
*
|
|
||||||
* Main application sidebar widget. Contains filter controls and action buttons
|
|
||||||
* for font filtering operations. Organized into two sections:
|
|
||||||
*
|
|
||||||
* - Filters: Category-based filter groups (providers, subsets, categories)
|
|
||||||
* - Controls: Reset button for filter actions
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Loading indicator during font fetch operations
|
|
||||||
* - Empty state message when no fonts match filters
|
|
||||||
* - Error display for failed font operations
|
|
||||||
* - Responsive sidebar behavior via shadcn Sidebar component
|
|
||||||
*
|
|
||||||
* Uses Sidebar.Root from shadcn for responsive sidebar behavior including
|
|
||||||
* mobile drawer and desktop persistent sidebar modes.
|
|
||||||
*/
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<SidebarRoot>
|
|
||||||
<SidebarContent class="p-2">
|
|
||||||
<!-- Filter groups -->
|
|
||||||
<Filters />
|
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<FilterControls />
|
|
||||||
</SidebarContent>
|
|
||||||
</SidebarRoot>
|
|
||||||
1
src/widgets/FontSearch/index.ts
Normal file
1
src/widgets/FontSearch/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FontSearch } from './ui';
|
||||||
107
src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte
Normal file
107
src/widgets/FontSearch/ui/FontSearch/FontSearch.svelte
Normal 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>
|
||||||
3
src/widgets/FontSearch/ui/index.ts
Normal file
3
src/widgets/FontSearch/ui/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import FontSearch from './FontSearch/FontSearch.svelte';
|
||||||
|
|
||||||
|
export { FontSearch };
|
||||||
@@ -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
3
src/widgets/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { ComparisonSlider } from './ComparisonSlider';
|
||||||
|
export { FontSearch } from './FontSearch';
|
||||||
|
export { TypographyMenu } from './TypographySettings';
|
||||||
Reference in New Issue
Block a user