Compare commits
15 Commits
c07800cc96
...
2022213921
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2022213921 | ||
|
|
6725a3b391 | ||
|
|
2eddb656a9 | ||
|
|
5973d241aa | ||
|
|
75a9c16070 | ||
|
|
31e4c64193 | ||
|
|
48e25fffa7 | ||
|
|
407c741349 | ||
|
|
13e114fafe | ||
|
|
1484ea024e | ||
|
|
67db6e22a7 | ||
|
|
192ce2d34a | ||
|
|
2b820230bc | ||
|
|
9b8ebed1c3 | ||
|
|
3d11f7317d |
@@ -41,7 +41,7 @@ let { children }: Props = $props();
|
||||
<header></header>
|
||||
|
||||
<ScrollArea class="h-screen w-screen">
|
||||
<main class="flex-1 w-full max-w-5xl mx-auto px-4 py-6 md:px-8 lg:py-10 relative">
|
||||
<main class="flex-1 w-full max-w-6xl mx-auto px-4 py-6 md:px-8 lg:py-10 relative">
|
||||
<TooltipProvider>
|
||||
<TypographyMenu />
|
||||
{@render children?.()}
|
||||
|
||||
@@ -15,18 +15,9 @@ export class DisplayedFontsStore {
|
||||
return selectedFontsStore.all;
|
||||
});
|
||||
|
||||
#fontPairs = $derived.by(() => {
|
||||
const fonts = this.#displayedFonts;
|
||||
const pairs = fonts.flatMap((v1, i) =>
|
||||
fonts.slice(i + 1).map<[UnifiedFont, UnifiedFont]>(v2 => [v1, v2])
|
||||
);
|
||||
if (pairs.length && this.isSelectedPairEmpty()) {
|
||||
this.selectedPair = pairs[0];
|
||||
}
|
||||
return pairs;
|
||||
});
|
||||
#fontA = $state<UnifiedFont | undefined>(undefined);
|
||||
|
||||
#selectedPair = $state<Partial<[UnifiedFont, UnifiedFont]>>([]);
|
||||
#fontB = $state<UnifiedFont | undefined>(undefined);
|
||||
|
||||
#hasAnySelectedFonts = $derived(this.#displayedFonts.length > 0);
|
||||
|
||||
@@ -34,18 +25,20 @@ export class DisplayedFontsStore {
|
||||
return this.#displayedFonts;
|
||||
}
|
||||
|
||||
get pairs() {
|
||||
return this.#fontPairs;
|
||||
get fontA() {
|
||||
return this.#fontA ?? this.#displayedFonts[0];
|
||||
}
|
||||
|
||||
get selectedPair() {
|
||||
return this.#selectedPair;
|
||||
set fontA(font: UnifiedFont | undefined) {
|
||||
this.#fontA = font;
|
||||
}
|
||||
|
||||
set selectedPair(pair: Partial<[UnifiedFont, UnifiedFont]>) {
|
||||
const [first, second] = this.#selectedPair;
|
||||
const [newFist, newSecond] = pair;
|
||||
this.#selectedPair = [newFist ?? first, newSecond ?? second];
|
||||
get fontB() {
|
||||
return this.#fontB ?? this.#displayedFonts[1];
|
||||
}
|
||||
|
||||
set fontB(font: UnifiedFont | undefined) {
|
||||
this.#fontB = font;
|
||||
}
|
||||
|
||||
get text() {
|
||||
@@ -60,9 +53,8 @@ export class DisplayedFontsStore {
|
||||
return this.#hasAnySelectedFonts;
|
||||
}
|
||||
|
||||
isSelectedPairEmpty(): boolean {
|
||||
const [font1, font2] = this.#selectedPair;
|
||||
return !font1 || !font2;
|
||||
getById(id: string): UnifiedFont | undefined {
|
||||
return selectedFontsStore.getById(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { appliedFontsManager } from '$entities/Font';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import { ComparisonSlider } from '$widgets/ComparisonSlider/ui';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { displayedFontsStore } from '../../model';
|
||||
import PairSelector from '../PairSelector/PairSelector.svelte';
|
||||
|
||||
let displayedText = $state('The quick brown fox jumps over the lazy dog...');
|
||||
const [fontA, fontB] = $derived(displayedFontsStore.selectedPair);
|
||||
const hasAnyPairs = $derived(displayedFontsStore.fonts.length > 0);
|
||||
|
||||
$effect(() => {
|
||||
appliedFontsManager.touch(
|
||||
displayedFontsStore.fonts.map(font => ({ slug: font.id, weight: controlManager.weight })),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if hasAnyPairs}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row gap-4">
|
||||
<PairSelector />
|
||||
</div>
|
||||
|
||||
{#if fontA && fontB}
|
||||
<div in:fly={{ y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 }}>
|
||||
<ComparisonSlider
|
||||
fontA={fontA}
|
||||
fontB={fontB}
|
||||
bind:text={displayedText}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -4,12 +4,9 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { displayedFontsStore } from '../../model';
|
||||
import FontComparer from '../FontComparer/FontComparer.svelte';
|
||||
import FontSampler from '../FontSampler/FontSampler.svelte';
|
||||
</script>
|
||||
|
||||
<FontComparer />
|
||||
|
||||
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
|
||||
{#each displayedFontsStore.fonts as font (font.id)}
|
||||
<FontSampler font={font} bind:text={displayedFontsStore.text} />
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontApplicator,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
|
||||
interface Props {
|
||||
pair: [UnifiedFont, UnifiedFont];
|
||||
selectedPair: Partial<[UnifiedFont, UnifiedFont]>;
|
||||
}
|
||||
|
||||
let { pair, selectedPair = $bindable() }: Props = $props();
|
||||
|
||||
const [font1, font2] = $derived(pair);
|
||||
|
||||
function handleClick() {
|
||||
selectedPair = pair;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row justify-between w-full" onclick={handleClick}>
|
||||
<FontApplicator id={font1.id} name={font1.name}>
|
||||
{font1.name}
|
||||
</FontApplicator>
|
||||
vs
|
||||
<FontApplicator id={font2.id} name={font2.name}>
|
||||
{font2.name}
|
||||
</FontApplicator>
|
||||
</div>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { FontVirtualList } from '$entities/Font';
|
||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||
import {
|
||||
Content as PopoverContent,
|
||||
Root as PopoverRoot,
|
||||
Trigger as PopoverTrigger,
|
||||
} from '$shared/shadcn/ui/popover';
|
||||
import { displayedFontsStore } from '../../model';
|
||||
import FontPair from '../FontPair/FontPair.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
const triggerContent = $derived.by(() => {
|
||||
const [beforeFont, afterFont] = displayedFontsStore.selectedPair ?? [];
|
||||
if (!beforeFont || !afterFont) return 'Select a pair';
|
||||
return `${beforeFont.name} vs ${afterFont.name}`;
|
||||
});
|
||||
|
||||
const triggerDisabled = $derived(displayedFontsStore.pairs.length === 0);
|
||||
</script>
|
||||
|
||||
<PopoverRoot bind:open>
|
||||
<PopoverTrigger class={buttonVariants({ variant: 'outline' })} disabled={triggerDisabled}>
|
||||
{triggerContent}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<FontVirtualList items={displayedFontsStore.pairs}>
|
||||
{#snippet children({ item: pair })}
|
||||
<FontPair {pair} bind:selectedPair={displayedFontsStore.selectedPair} />
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
</PopoverContent>
|
||||
</PopoverRoot>
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { appliedFontsManager } from '$entities/Font';
|
||||
import { displayedFontsStore } from '$features/DisplayFont';
|
||||
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
|
||||
import { controlManager } from '$features/SetupFont';
|
||||
import ComparisonSlider from '$widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.svelte';
|
||||
import { FontSearch } from '$widgets/FontSearch';
|
||||
|
||||
/**
|
||||
@@ -9,6 +13,12 @@ import { FontSearch } from '$widgets/FontSearch';
|
||||
let searchContainer: HTMLElement;
|
||||
|
||||
let isExpanded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
appliedFontsManager.touch(
|
||||
displayedFontsStore.fonts.map(font => ({ slug: font.id, weight: controlManager.weight })),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Font List -->
|
||||
@@ -17,6 +27,8 @@ let isExpanded = $state(false);
|
||||
<FontSearch bind:showFilters={isExpanded} />
|
||||
</div>
|
||||
|
||||
<ComparisonSlider />
|
||||
|
||||
<div class="will-change-tranform transition-transform content">
|
||||
<FontDisplay />
|
||||
</div>
|
||||
|
||||
@@ -15,16 +15,22 @@ export interface LineData {
|
||||
* @param fontB - The second font definition
|
||||
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
|
||||
*/
|
||||
export function createCharacterComparison(
|
||||
export function createCharacterComparison<
|
||||
T extends { name: string; id: string } | undefined = undefined,
|
||||
>(
|
||||
text: () => string,
|
||||
fontA: () => { name: string; id: string },
|
||||
fontB: () => { name: string; id: string },
|
||||
fontA: () => T,
|
||||
fontB: () => T,
|
||||
weight: () => number,
|
||||
size: () => number,
|
||||
) {
|
||||
let lines = $state<LineData[]>([]);
|
||||
let containerWidth = $state(0);
|
||||
|
||||
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
|
||||
return font !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures text width using a canvas context.
|
||||
* @param ctx - Canvas rendering context
|
||||
@@ -36,10 +42,11 @@ export function createCharacterComparison(
|
||||
function measureText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
fontWeight: number,
|
||||
fontFamily?: string,
|
||||
): number {
|
||||
if (!fontFamily) return 0;
|
||||
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
@@ -73,10 +80,11 @@ export function createCharacterComparison(
|
||||
container: HTMLElement | undefined,
|
||||
measureCanvas: HTMLCanvasElement | undefined,
|
||||
) {
|
||||
if (!container || !measureCanvas) return;
|
||||
if (!container || !measureCanvas || !fontA() || !fontB()) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
containerWidth = rect.width;
|
||||
|
||||
// Padding considerations - matches the container padding
|
||||
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||
const availableWidth = rect.width - padding;
|
||||
@@ -91,22 +99,24 @@ export function createCharacterComparison(
|
||||
let currentLineWords: string[] = [];
|
||||
|
||||
function pushLine(words: string[]) {
|
||||
if (words.length === 0) return;
|
||||
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
|
||||
return;
|
||||
}
|
||||
const lineText = words.join(' ');
|
||||
// Measure both fonts at the CURRENT weight
|
||||
const widthA = measureText(
|
||||
ctx!,
|
||||
lineText,
|
||||
fontA().name,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
fontA()?.name,
|
||||
);
|
||||
const widthB = measureText(
|
||||
ctx!,
|
||||
lineText,
|
||||
fontB().name,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
fontB()?.name,
|
||||
);
|
||||
const maxWidth = Math.max(widthA, widthB);
|
||||
newLines.push({ text: lineText, width: maxWidth });
|
||||
@@ -120,20 +130,64 @@ export function createCharacterComparison(
|
||||
const widthA = measureText(
|
||||
ctx,
|
||||
testLine,
|
||||
fontA().name,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
fontA()?.name,
|
||||
);
|
||||
const widthB = measureText(
|
||||
ctx,
|
||||
testLine,
|
||||
fontB().name,
|
||||
Math.min(fontSize, controlledFontSize),
|
||||
currentWeight,
|
||||
fontB()?.name,
|
||||
);
|
||||
const maxWidth = Math.max(widthA, widthB);
|
||||
const isContainerOverflown = maxWidth > availableWidth;
|
||||
|
||||
if (maxWidth > availableWidth && currentLineWords.length > 0) {
|
||||
if (isContainerOverflown) {
|
||||
if (currentLineWords.length > 0) {
|
||||
pushLine(currentLineWords);
|
||||
currentLineWords = [];
|
||||
}
|
||||
|
||||
let remainingWord = word;
|
||||
while (remainingWord.length > 0) {
|
||||
let low = 1;
|
||||
let high = remainingWord.length;
|
||||
let bestBreak = 1;
|
||||
|
||||
// Binary Search to find the maximum characters that fit
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
const testFragment = remainingWord.slice(0, mid);
|
||||
|
||||
const wA = measureText(
|
||||
ctx,
|
||||
testFragment,
|
||||
fontSize,
|
||||
currentWeight,
|
||||
fontA()?.name,
|
||||
);
|
||||
const wB = measureText(
|
||||
ctx,
|
||||
testFragment,
|
||||
fontSize,
|
||||
currentWeight,
|
||||
fontB()?.name,
|
||||
);
|
||||
|
||||
if (Math.max(wA, wB) <= availableWidth) {
|
||||
bestBreak = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
pushLine([remainingWord.slice(0, bestBreak)]);
|
||||
remainingWord = remainingWord.slice(bestBreak);
|
||||
}
|
||||
} else if (maxWidth > availableWidth && currentLineWords.length > 0) {
|
||||
pushLine(currentLineWords);
|
||||
currentLineWords = [word];
|
||||
} else {
|
||||
@@ -141,7 +195,9 @@ export function createCharacterComparison(
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLineWords.length > 0) pushLine(currentLineWords);
|
||||
if (currentLineWords.length > 0) {
|
||||
pushLine(currentLineWords);
|
||||
}
|
||||
lines = newLines;
|
||||
}
|
||||
|
||||
|
||||
37
src/shared/shadcn/ui/select/index.ts
Normal file
37
src/shared/shadcn/ui/select/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import Content from './select-content.svelte';
|
||||
import GroupHeading from './select-group-heading.svelte';
|
||||
import Group from './select-group.svelte';
|
||||
import Item from './select-item.svelte';
|
||||
import Label from './select-label.svelte';
|
||||
import Portal from './select-portal.svelte';
|
||||
import ScrollDownButton from './select-scroll-down-button.svelte';
|
||||
import ScrollUpButton from './select-scroll-up-button.svelte';
|
||||
import Separator from './select-separator.svelte';
|
||||
import Trigger from './select-trigger.svelte';
|
||||
import Root from './select.svelte';
|
||||
|
||||
export {
|
||||
Content,
|
||||
Content as SelectContent,
|
||||
Group,
|
||||
Group as SelectGroup,
|
||||
GroupHeading,
|
||||
GroupHeading as SelectGroupHeading,
|
||||
Item,
|
||||
Item as SelectItem,
|
||||
Label,
|
||||
Label as SelectLabel,
|
||||
Portal,
|
||||
Portal as SelectPortal,
|
||||
Root,
|
||||
//
|
||||
Root as Select,
|
||||
ScrollDownButton,
|
||||
ScrollDownButton as SelectScrollDownButton,
|
||||
ScrollUpButton,
|
||||
ScrollUpButton as SelectScrollUpButton,
|
||||
Separator,
|
||||
Separator as SelectSeparator,
|
||||
Trigger,
|
||||
Trigger as SelectTrigger,
|
||||
};
|
||||
48
src/shared/shadcn/ui/select/select-content.svelte
Normal file
48
src/shared/shadcn/ui/select/select-content.svelte
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import type { WithoutChildrenOrChild } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import SelectPortal from './select-portal.svelte';
|
||||
import SelectScrollDownButton from './select-scroll-down-button.svelte';
|
||||
import SelectScrollUpButton from './select-scroll-up-button.svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 4,
|
||||
portalProps,
|
||||
children,
|
||||
preventScroll = true,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPortal {...portalProps}>
|
||||
<SelectPrimitive.Content
|
||||
bind:ref
|
||||
{sideOffset}
|
||||
{preventScroll}
|
||||
data-slot="select-content"
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
class={cn(
|
||||
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1',
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPortal>
|
||||
21
src/shared/shadcn/ui/select/select-group-heading.svelte
Normal file
21
src/shared/shadcn/ui/select/select-group-heading.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="select-group-heading"
|
||||
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.GroupHeading>
|
||||
7
src/shared/shadcn/ui/select/select-group.svelte
Normal file
7
src/shared/shadcn/ui/select/select-group.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />
|
||||
41
src/shared/shadcn/ui/select/select-item.svelte
Normal file
41
src/shared/shadcn/ui/select/select-item.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
label,
|
||||
children: childrenProp,
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Item
|
||||
bind:ref
|
||||
{value}
|
||||
data-slot="select-item"
|
||||
class={cn(
|
||||
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ selected, highlighted })}
|
||||
<span class="absolute end-2 flex size-3.5 items-center justify-center">
|
||||
{#if selected}
|
||||
<CheckIcon class="size-4" />
|
||||
{/if}
|
||||
</span>
|
||||
{#if childrenProp}
|
||||
{@render childrenProp({ selected, highlighted })}
|
||||
{:else}
|
||||
{label || value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</SelectPrimitive.Item>
|
||||
23
src/shared/shadcn/ui/select/select-label.svelte
Normal file
23
src/shared/shadcn/ui/select/select-label.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithElementRef,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="select-label"
|
||||
class={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
7
src/shared/shadcn/ui/select/select-portal.svelte
Normal file
7
src/shared/shadcn/ui/select/select-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let { ...restProps }: SelectPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Portal {...restProps} />
|
||||
23
src/shared/shadcn/ui/select/select-scroll-down-button.svelte
Normal file
23
src/shared/shadcn/ui/select/select-scroll-down-button.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChildrenOrChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-down-button"
|
||||
class={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
23
src/shared/shadcn/ui/select/select-scroll-up-button.svelte
Normal file
23
src/shared/shadcn/ui/select/select-scroll-up-button.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChildrenOrChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
bind:ref
|
||||
data-slot="select-scroll-up-button"
|
||||
class={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<ChevronUpIcon class="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
18
src/shared/shadcn/ui/select/select-separator.svelte
Normal file
18
src/shared/shadcn/ui/select/select-separator.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '$shared/shadcn/ui/separator/index.js';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import type { Separator as SeparatorPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<Separator
|
||||
bind:ref
|
||||
data-slot="select-separator"
|
||||
class={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
32
src/shared/shadcn/ui/select/select-trigger.svelte
Normal file
32
src/shared/shadcn/ui/select/select-trigger.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type WithoutChild,
|
||||
cn,
|
||||
} from '$shared/shadcn/utils/shadcn-utils.js';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
size = 'default',
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||
size?: 'sm' | 'default';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
class={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon class="size-4 opacity-50" />
|
||||
</SelectPrimitive.Trigger>
|
||||
11
src/shared/shadcn/ui/select/select.svelte
Normal file
11
src/shared/shadcn/ui/select/select.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: SelectPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />
|
||||
@@ -84,6 +84,7 @@ const handleSliderChange = (newValue: number) => {
|
||||
class="
|
||||
group relative border-none size-9
|
||||
bg-white/20 hover:bg-white/60
|
||||
backdrop-blur-3xl
|
||||
transition-all duration-200 ease-out
|
||||
will-change-transform
|
||||
hover:-translate-y-0.5
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { createRawSnippet } from 'svelte';
|
||||
import ExpandableWrapper from './ExpandableWrapper.svelte';
|
||||
|
||||
const visibleSnippet = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<div class="w-48 p-2 font-bold text-indigo-600">
|
||||
Always visible
|
||||
</div>`,
|
||||
}));
|
||||
|
||||
const hiddenSnippet = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<div class="p-4 space-y-2 border-t border-indigo-100 mt-2">
|
||||
<div class="h-4 w-full bg-indigo-100 rounded animate-pulse"></div>
|
||||
<div class="h-4 w-2/3 bg-indigo-50 rounded animate-pulse"></div>
|
||||
</div>`,
|
||||
}));
|
||||
|
||||
const badgeSnippet = createRawSnippet(() => ({
|
||||
render: () =>
|
||||
`<div class="">
|
||||
<span class="badge badge-primary">*</span>
|
||||
</div>`,
|
||||
}));
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Shared/ExpandableWrapper',
|
||||
component: ExpandableWrapper,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Animated styled wrapper for content that can be expanded and collapsed.',
|
||||
},
|
||||
story: { inline: false }, // Render stories in iframe for state isolation
|
||||
},
|
||||
},
|
||||
args: {
|
||||
expanded: false,
|
||||
disabled: false,
|
||||
rotation: 'clockwise',
|
||||
visibleContent: visibleSnippet,
|
||||
hiddenContent: hiddenSnippet,
|
||||
},
|
||||
argTypes: {
|
||||
expanded: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
rotation: {
|
||||
control: 'select',
|
||||
options: ['clockwise', 'counterclockwise'],
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<Story name="With hidden content">
|
||||
{#snippet children(args)}
|
||||
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
|
||||
<ExpandableWrapper
|
||||
{...args}
|
||||
bind:expanded={args.expanded}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="Disabled" args={{ disabled: true }}>
|
||||
{#snippet children(args)}
|
||||
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
|
||||
<ExpandableWrapper
|
||||
{...args}
|
||||
bind:expanded={args.expanded}
|
||||
disabled={args.disabled}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story name="With badge" args={{ badge: badgeSnippet }}>
|
||||
{#snippet children(args)}
|
||||
<div class="p-12 bg-slate-100 min-h-[300px] flex justify-center items-start">
|
||||
<ExpandableWrapper
|
||||
{...args}
|
||||
bind:expanded={args.expanded}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Story>
|
||||
@@ -5,6 +5,7 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { slide } from 'svelte/transition';
|
||||
@@ -42,6 +43,10 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
* @default 'clockwise'
|
||||
*/
|
||||
rotation?: 'clockwise' | 'counterclockwise';
|
||||
/**
|
||||
* Classes for intermnal container
|
||||
*/
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -53,6 +58,7 @@ let {
|
||||
badge,
|
||||
rotation = 'clockwise',
|
||||
class: className = '',
|
||||
containerClassName = '',
|
||||
...props
|
||||
}: Props = $props();
|
||||
|
||||
@@ -91,7 +97,7 @@ function handleWrapperClick() {
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (e.key === 'Enter' || (e.key === ' ' && !expanded)) {
|
||||
e.preventDefault();
|
||||
handleWrapperClick();
|
||||
}
|
||||
@@ -169,17 +175,18 @@ $effect(() => {
|
||||
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/5 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
|
||||
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
|
||||
disabled && 'opacity-80 grayscale-[0.2]',
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
{@render visibleContent?.({ expanded, disabled })}
|
||||
|
||||
{#if expanded}
|
||||
<div
|
||||
in:slide={{ duration: 250, delay: 50 }}
|
||||
out:slide={{ duration: 250 }}
|
||||
in:slide={{ duration: 250, easing: cubicOut }}
|
||||
out:slide={{ duration: 250, easing: cubicOut }}
|
||||
>
|
||||
{@render hiddenContent?.({ expanded, disabled })}
|
||||
</div>
|
||||
|
||||
@@ -9,38 +9,26 @@
|
||||
- Responsive layout with Tailwind breakpoints for font sizing.
|
||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
||||
-->
|
||||
<script lang="ts" generics="T extends { name: string; id: string }">
|
||||
<script lang="ts">
|
||||
import { displayedFontsStore } from '$features/DisplayFont';
|
||||
import {
|
||||
createCharacterComparison,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import type { LineData } from '$shared/lib';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { fly } from 'svelte/transition';
|
||||
import CharacterSlot from './components/CharacterSlot.svelte';
|
||||
import ControlsWrapper from './components/ControlsWrapper.svelte';
|
||||
import Labels from './components/Labels.svelte';
|
||||
import SliderLine from './components/SliderLine.svelte';
|
||||
|
||||
interface Props<T extends { name: string; id: string }> {
|
||||
/**
|
||||
* First font definition ({name, id})
|
||||
*/
|
||||
fontA: T;
|
||||
/**
|
||||
* Second font definition ({name, id})
|
||||
*/
|
||||
fontB: T;
|
||||
/**
|
||||
* Text to display and compare
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
fontA,
|
||||
fontB,
|
||||
text = $bindable('The quick brown fox jumps over the lazy dog'),
|
||||
}: Props<T> = $props();
|
||||
// Displayed text
|
||||
let text = $state('The quick brown fox jumps over the lazy dog...');
|
||||
// Pair of fonts to compare
|
||||
const fontA = $derived(displayedFontsStore.fontA);
|
||||
const fontB = $derived(displayedFontsStore.fontB);
|
||||
|
||||
let container: HTMLElement | undefined = $state();
|
||||
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
|
||||
@@ -98,7 +86,6 @@ function handleMove(e: PointerEvent) {
|
||||
|
||||
function startDragging(e: PointerEvent) {
|
||||
if (e.target === controlsWrapperElement || controlsWrapperElement?.contains(e.target as Node)) {
|
||||
console.log('Pointer down on controls wrapper');
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
@@ -162,82 +149,87 @@ $effect(() => {
|
||||
- Font Family switches based on `isPast`
|
||||
- Transitions/Transforms provide the "morph" feel
|
||||
-->
|
||||
<CharacterSlot
|
||||
{char}
|
||||
{proximity}
|
||||
{isPast}
|
||||
weight={weightControl.value}
|
||||
size={sizeControl.value}
|
||||
fontAName={fontA.name}
|
||||
fontBName={fontB.name}
|
||||
/>
|
||||
{#if fontA && fontB}
|
||||
<CharacterSlot
|
||||
{char}
|
||||
{proximity}
|
||||
{isPast}
|
||||
weight={weightControl.value}
|
||||
size={sizeControl.value}
|
||||
fontAName={fontA.name}
|
||||
fontBName={fontB.name}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- Hidden canvas used for text measurement by the helper -->
|
||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
||||
{#if fontA && fontB}
|
||||
<!-- Hidden canvas used for text measurement by the helper -->
|
||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
bind:this={container}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={Math.round(sliderPos)}
|
||||
aria-label="Font comparison slider"
|
||||
onpointerdown={startDragging}
|
||||
class="
|
||||
group relative w-full py-16 px-6 sm:py-24 sm:px-12 overflow-hidden
|
||||
bg-indigo-50 rounded-[2.5rem] border border-slate-100 shadow-2xl
|
||||
select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center
|
||||
"
|
||||
class:box-shadow={'-20px 20px 60px #bebebe, 20px -20px 60px #ffffff;'}
|
||||
>
|
||||
<!-- Background Gradient Accent -->
|
||||
<div class="relative">
|
||||
<div
|
||||
bind:this={container}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={Math.round(sliderPos)}
|
||||
aria-label="Font comparison slider"
|
||||
onpointerdown={startDragging}
|
||||
class="
|
||||
absolute inset-0 bg-linear-to-br
|
||||
from-slate-50/50 via-white to-slate-100/50
|
||||
opacity-50 pointer-events-none
|
||||
group relative w-full py-16 px-6 sm:py-24 sm:px-12 overflow-hidden
|
||||
bg-indigo-50 rounded-[2.5rem] border border-slate-100 shadow-2xl
|
||||
select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center
|
||||
"
|
||||
class:box-shadow={'-20px 20px 60px #bebebe, 20px -20px 60px #ffffff;'}
|
||||
in:fly={{ y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 }}
|
||||
>
|
||||
<!-- Background Gradient Accent -->
|
||||
<div
|
||||
class="
|
||||
absolute inset-0 bg-linear-to-br
|
||||
from-slate-50/50 via-white to-slate-100/50
|
||||
opacity-50 pointer-events-none
|
||||
"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Text Rendering Container -->
|
||||
<div
|
||||
class="
|
||||
relative flex flex-col items-center gap-4
|
||||
text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15]
|
||||
z-10 pointer-events-none text-center
|
||||
"
|
||||
style:perspective="1000px"
|
||||
>
|
||||
{#each charComparison.lines as line, lineIndex}
|
||||
<div
|
||||
class="relative w-full whitespace-nowrap"
|
||||
style:height={`${heightControl.value}em`}
|
||||
style:display="flex"
|
||||
style:align-items="center"
|
||||
style:justify-content="center"
|
||||
>
|
||||
{@render renderLine(line, lineIndex)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<SliderLine {sliderPos} {isDragging} />
|
||||
</div>
|
||||
|
||||
<!-- Text Rendering Container -->
|
||||
<div
|
||||
class="
|
||||
relative flex flex-col items-center gap-4
|
||||
text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15]
|
||||
z-10 pointer-events-none text-center
|
||||
"
|
||||
style:perspective="1000px"
|
||||
>
|
||||
{#each charComparison.lines as line, lineIndex}
|
||||
<div
|
||||
class="relative w-full whitespace-nowrap"
|
||||
style:height={`${heightControl.value}em`}
|
||||
style:display="flex"
|
||||
style:align-items="center"
|
||||
style:justify-content="center"
|
||||
>
|
||||
{@render renderLine(line, lineIndex)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Visual Components -->
|
||||
<SliderLine {sliderPos} {isDragging} />
|
||||
<Labels {fontA} {fontB} {sliderPos} />
|
||||
<Labels fontA={fontA} fontB={fontB} {sliderPos} />
|
||||
<!-- Since there're slider controls inside we put them outside the main one -->
|
||||
<ControlsWrapper
|
||||
bind:wrapper={controlsWrapperElement}
|
||||
{sliderPos}
|
||||
{isDragging}
|
||||
bind:text={text}
|
||||
containerWidth={container?.clientWidth}
|
||||
weightControl={weightControl}
|
||||
sizeControl={sizeControl}
|
||||
heightControl={heightControl}
|
||||
/>
|
||||
</div>
|
||||
<!-- Since there're slider controls inside we put them outside the main one -->
|
||||
<ControlsWrapper
|
||||
bind:wrapper={controlsWrapperElement}
|
||||
{sliderPos}
|
||||
{isDragging}
|
||||
bind:text={text}
|
||||
containerWidth={container?.clientWidth}
|
||||
weightControl={weightControl}
|
||||
sizeControl={sizeControl}
|
||||
heightControl={heightControl}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<!--
|
||||
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';
|
||||
@@ -8,13 +14,37 @@ import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
|
||||
import { Spring } from 'svelte/motion';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Ref
|
||||
*/
|
||||
wrapper?: HTMLDivElement | null;
|
||||
/**
|
||||
* Slider position
|
||||
*/
|
||||
sliderPos: number;
|
||||
/**
|
||||
* Whether slider is being dragged
|
||||
*/
|
||||
isDragging: boolean;
|
||||
/**
|
||||
* Text to display
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* Container width
|
||||
*/
|
||||
containerWidth: number;
|
||||
/**
|
||||
* Weight control
|
||||
*/
|
||||
weightControl: TypographyControl;
|
||||
/**
|
||||
* Size control
|
||||
*/
|
||||
sizeControl: TypographyControl;
|
||||
/**
|
||||
* Height control
|
||||
*/
|
||||
heightControl: TypographyControl;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,110 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
<!--
|
||||
Component: Labels
|
||||
Displays labels for font selection in the comparison slider.
|
||||
-->
|
||||
<script lang="ts" generics="T extends { name: string; id: string }">
|
||||
import {
|
||||
FontVirtualList,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
|
||||
import { displayedFontsStore } from '$features/DisplayFont';
|
||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
fontA: { name: string; id: string };
|
||||
fontB: { name: string; id: string };
|
||||
interface Props<T> {
|
||||
/**
|
||||
* First font to compare
|
||||
*/
|
||||
fontA: T;
|
||||
/**
|
||||
* Second font to compare
|
||||
*/
|
||||
fontB: T;
|
||||
/**
|
||||
* Position of the slider
|
||||
*/
|
||||
sliderPos: number;
|
||||
}
|
||||
let { fontA, fontB, sliderPos }: Props = $props();
|
||||
let { fontA, fontB, sliderPos }: Props<T> = $props();
|
||||
|
||||
const fontList = $derived(
|
||||
displayedFontsStore.fonts.filter(font => font.name !== fontA.name && font.name !== fontB.name),
|
||||
);
|
||||
|
||||
function selectFontA(fontId: string) {
|
||||
const newFontA = displayedFontsStore.getById(fontId);
|
||||
if (!newFontA) return;
|
||||
displayedFontsStore.fontA = newFontA;
|
||||
}
|
||||
|
||||
function selectFontB(fontId: string) {
|
||||
const newFontB = displayedFontsStore.getById(fontId);
|
||||
if (!newFontB) return;
|
||||
displayedFontsStore.fontB = newFontB;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Bottom Labels -->
|
||||
<div class="absolute bottom-6 inset-x-8 sm:inset-x-12 flex justify-between items-center pointer-events-none z-20">
|
||||
<!-- Left Label (Font A) -->
|
||||
{#snippet fontSelector(
|
||||
name: string,
|
||||
id: string,
|
||||
fonts: UnifiedFont[],
|
||||
handleChange: (value: string) => void,
|
||||
)}
|
||||
<div
|
||||
class="flex flex-col gap-1 transition-opacity duration-300"
|
||||
style:opacity={sliderPos < 10 ? 0 : 1}
|
||||
class="z-50 pointer-events-auto **:bg-transparent"
|
||||
onpointerdown={(e => e.stopPropagation())}
|
||||
>
|
||||
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-indigo-400"
|
||||
>Baseline</span>
|
||||
<span class="text-xs sm:text-sm font-bold text-indigo-600">
|
||||
{fontB.name}
|
||||
</span>
|
||||
<SelectRoot type="single" onValueChange={handleChange}>
|
||||
<SelectTrigger
|
||||
class={cn(buttonVariants({ variant: 'ghost' }), 'border-none, hover:bg-indigo-100')}
|
||||
disabled={!fontList.length}
|
||||
>
|
||||
<FontApplicator name={name} id={id}>
|
||||
{name}
|
||||
</FontApplicator>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
class="h-60 bg-transparent **:bg-transparent backdrop-blur-0 data-[state=open]:backdrop-blur-lg transition-[backdrop-filter] duration-200"
|
||||
scrollYThreshold={100}
|
||||
side="top"
|
||||
>
|
||||
<FontVirtualList items={fonts}>
|
||||
{#snippet children({ item: font })}
|
||||
<SelectItem value={font.id} class="data-[highlighted]:bg-indigo-100">
|
||||
<FontApplicator name={font.name} id={font.id}>
|
||||
{font.name}
|
||||
</FontApplicator>
|
||||
</SelectItem>
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="absolute bottom-6 inset-x-6 sm:inset-x-6 flex justify-between items-end pointer-events-none z-20">
|
||||
<div
|
||||
class="flex flex-col gap-0.5 transition-opacity duration-300 items-start"
|
||||
style:opacity={sliderPos < 15 ? 0 : 1}
|
||||
>
|
||||
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-indigo-400">
|
||||
Baseline</span>
|
||||
{@render fontSelector(fontB.name, fontB.id, fontList, selectFontB)}
|
||||
</div>
|
||||
|
||||
<!-- Right Label (Font B) -->
|
||||
<div
|
||||
class="flex flex-col items-end text-right gap-1 transition-opacity duration-300"
|
||||
style:opacity={sliderPos > 90 ? 0 : 1}
|
||||
style:opacity={sliderPos > 85 ? 0 : 1}
|
||||
>
|
||||
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-slate-400"
|
||||
>Comparison</span>
|
||||
<span class="text-xs sm:text-sm font-bold text-slate-900">
|
||||
{fontA.name}
|
||||
</span>
|
||||
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-slate-400">
|
||||
Comparison</span>
|
||||
{@render fontSelector(fontA.name, fontA.id, fontList, selectFontA)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
<!--
|
||||
Component: SliderLine
|
||||
Visual representation of the slider line.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Position of the slider
|
||||
*/
|
||||
sliderPos: number;
|
||||
/**
|
||||
* Whether the slider is being dragged
|
||||
*/
|
||||
isDragging: boolean;
|
||||
}
|
||||
let { sliderPos, isDragging }: Props = $props();
|
||||
@@ -44,12 +54,3 @@ let { sliderPos, isDragging }: Props = $props();
|
||||
<div class="w-3 h-3 bg-indigo-500 rounded-full shadow-lg ring-2 ring-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<style>
|
||||
|
||||
div {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
</style>
|
||||
-->
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
<script lang="ts">
|
||||
import SetupFontMenu from '$features/SetupFont/ui/SetupFontMenu.svelte';
|
||||
import { SetupFontMenu } from '$features/SetupFont';
|
||||
import {
|
||||
Content as ItemContent,
|
||||
Root as ItemRoot,
|
||||
} from '$shared/shadcn/ui/item';
|
||||
|
||||
import { displayedFontsStore } from '$features/DisplayFont';
|
||||
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-full p-2 backdrop-blur-2xl">
|
||||
<ItemRoot variant="outline" class="w-full p-2.5">
|
||||
<ItemContent class="flex flex-row justify-center items-center">
|
||||
<SetupFontMenu />
|
||||
</ItemContent>
|
||||
</ItemRoot>
|
||||
</div>
|
||||
{#if displayedFontsStore.hasAnyFonts}
|
||||
<div
|
||||
class="w-auto fixed bottom-5 left-1/2 translate-x-[-50%] max-w-max z-10"
|
||||
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>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user