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>
|
<header></header>
|
||||||
|
|
||||||
<ScrollArea class="h-screen w-screen">
|
<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>
|
<TooltipProvider>
|
||||||
<TypographyMenu />
|
<TypographyMenu />
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|||||||
@@ -15,18 +15,9 @@ export class DisplayedFontsStore {
|
|||||||
return selectedFontsStore.all;
|
return selectedFontsStore.all;
|
||||||
});
|
});
|
||||||
|
|
||||||
#fontPairs = $derived.by(() => {
|
#fontA = $state<UnifiedFont | undefined>(undefined);
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
#selectedPair = $state<Partial<[UnifiedFont, UnifiedFont]>>([]);
|
#fontB = $state<UnifiedFont | undefined>(undefined);
|
||||||
|
|
||||||
#hasAnySelectedFonts = $derived(this.#displayedFonts.length > 0);
|
#hasAnySelectedFonts = $derived(this.#displayedFonts.length > 0);
|
||||||
|
|
||||||
@@ -34,18 +25,20 @@ export class DisplayedFontsStore {
|
|||||||
return this.#displayedFonts;
|
return this.#displayedFonts;
|
||||||
}
|
}
|
||||||
|
|
||||||
get pairs() {
|
get fontA() {
|
||||||
return this.#fontPairs;
|
return this.#fontA ?? this.#displayedFonts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
get selectedPair() {
|
set fontA(font: UnifiedFont | undefined) {
|
||||||
return this.#selectedPair;
|
this.#fontA = font;
|
||||||
}
|
}
|
||||||
|
|
||||||
set selectedPair(pair: Partial<[UnifiedFont, UnifiedFont]>) {
|
get fontB() {
|
||||||
const [first, second] = this.#selectedPair;
|
return this.#fontB ?? this.#displayedFonts[1];
|
||||||
const [newFist, newSecond] = pair;
|
}
|
||||||
this.#selectedPair = [newFist ?? first, newSecond ?? second];
|
|
||||||
|
set fontB(font: UnifiedFont | undefined) {
|
||||||
|
this.#fontB = font;
|
||||||
}
|
}
|
||||||
|
|
||||||
get text() {
|
get text() {
|
||||||
@@ -60,9 +53,8 @@ export class DisplayedFontsStore {
|
|||||||
return this.#hasAnySelectedFonts;
|
return this.#hasAnySelectedFonts;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelectedPairEmpty(): boolean {
|
getById(id: string): UnifiedFont | undefined {
|
||||||
const [font1, font2] = this.#selectedPair;
|
return selectedFontsStore.getById(id);
|
||||||
return !font1 || !font2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import { displayedFontsStore } from '../../model';
|
import { displayedFontsStore } from '../../model';
|
||||||
import FontComparer from '../FontComparer/FontComparer.svelte';
|
|
||||||
import FontSampler from '../FontSampler/FontSampler.svelte';
|
import FontSampler from '../FontSampler/FontSampler.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FontComparer />
|
|
||||||
|
|
||||||
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
|
<div class="grid gap-2 grid-cols-[repeat(auto-fit,minmax(500px,1fr))]">
|
||||||
{#each displayedFontsStore.fonts as font (font.id)}
|
{#each displayedFontsStore.fonts as font (font.id)}
|
||||||
<FontSampler font={font} bind:text={displayedFontsStore.text} />
|
<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">
|
<script lang="ts">
|
||||||
|
import { appliedFontsManager } from '$entities/Font';
|
||||||
|
import { displayedFontsStore } from '$features/DisplayFont';
|
||||||
import FontDisplay from '$features/DisplayFont/ui/FontDisplay/FontDisplay.svelte';
|
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';
|
import { FontSearch } from '$widgets/FontSearch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,6 +13,12 @@ import { FontSearch } from '$widgets/FontSearch';
|
|||||||
let searchContainer: HTMLElement;
|
let searchContainer: HTMLElement;
|
||||||
|
|
||||||
let isExpanded = $state(false);
|
let isExpanded = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
appliedFontsManager.touch(
|
||||||
|
displayedFontsStore.fonts.map(font => ({ slug: font.id, weight: controlManager.weight })),
|
||||||
|
);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Font List -->
|
<!-- Font List -->
|
||||||
@@ -17,6 +27,8 @@ let isExpanded = $state(false);
|
|||||||
<FontSearch bind:showFilters={isExpanded} />
|
<FontSearch bind:showFilters={isExpanded} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ComparisonSlider />
|
||||||
|
|
||||||
<div class="will-change-tranform transition-transform content">
|
<div class="will-change-tranform transition-transform content">
|
||||||
<FontDisplay />
|
<FontDisplay />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,16 +15,22 @@ export interface LineData {
|
|||||||
* @param fontB - The second font definition
|
* @param fontB - The second font definition
|
||||||
* @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState)
|
* @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,
|
text: () => string,
|
||||||
fontA: () => { name: string; id: string },
|
fontA: () => T,
|
||||||
fontB: () => { name: string; id: string },
|
fontB: () => T,
|
||||||
weight: () => number,
|
weight: () => number,
|
||||||
size: () => number,
|
size: () => number,
|
||||||
) {
|
) {
|
||||||
let lines = $state<LineData[]>([]);
|
let lines = $state<LineData[]>([]);
|
||||||
let containerWidth = $state(0);
|
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.
|
* Measures text width using a canvas context.
|
||||||
* @param ctx - Canvas rendering context
|
* @param ctx - Canvas rendering context
|
||||||
@@ -36,10 +42,11 @@ export function createCharacterComparison(
|
|||||||
function measureText(
|
function measureText(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
text: string,
|
text: string,
|
||||||
fontFamily: string,
|
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
fontWeight: number,
|
fontWeight: number,
|
||||||
|
fontFamily?: string,
|
||||||
): number {
|
): number {
|
||||||
|
if (!fontFamily) return 0;
|
||||||
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
||||||
return ctx.measureText(text).width;
|
return ctx.measureText(text).width;
|
||||||
}
|
}
|
||||||
@@ -73,10 +80,11 @@ export function createCharacterComparison(
|
|||||||
container: HTMLElement | undefined,
|
container: HTMLElement | undefined,
|
||||||
measureCanvas: HTMLCanvasElement | undefined,
|
measureCanvas: HTMLCanvasElement | undefined,
|
||||||
) {
|
) {
|
||||||
if (!container || !measureCanvas) return;
|
if (!container || !measureCanvas || !fontA() || !fontB()) return;
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
containerWidth = rect.width;
|
containerWidth = rect.width;
|
||||||
|
|
||||||
// Padding considerations - matches the container padding
|
// Padding considerations - matches the container padding
|
||||||
const padding = window.innerWidth < 640 ? 48 : 96;
|
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||||
const availableWidth = rect.width - padding;
|
const availableWidth = rect.width - padding;
|
||||||
@@ -91,22 +99,24 @@ export function createCharacterComparison(
|
|||||||
let currentLineWords: string[] = [];
|
let currentLineWords: string[] = [];
|
||||||
|
|
||||||
function pushLine(words: string[]) {
|
function pushLine(words: string[]) {
|
||||||
if (words.length === 0) return;
|
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const lineText = words.join(' ');
|
const lineText = words.join(' ');
|
||||||
// Measure both fonts at the CURRENT weight
|
// Measure both fonts at the CURRENT weight
|
||||||
const widthA = measureText(
|
const widthA = measureText(
|
||||||
ctx!,
|
ctx!,
|
||||||
lineText,
|
lineText,
|
||||||
fontA().name,
|
|
||||||
Math.min(fontSize, controlledFontSize),
|
Math.min(fontSize, controlledFontSize),
|
||||||
currentWeight,
|
currentWeight,
|
||||||
|
fontA()?.name,
|
||||||
);
|
);
|
||||||
const widthB = measureText(
|
const widthB = measureText(
|
||||||
ctx!,
|
ctx!,
|
||||||
lineText,
|
lineText,
|
||||||
fontB().name,
|
|
||||||
Math.min(fontSize, controlledFontSize),
|
Math.min(fontSize, controlledFontSize),
|
||||||
currentWeight,
|
currentWeight,
|
||||||
|
fontB()?.name,
|
||||||
);
|
);
|
||||||
const maxWidth = Math.max(widthA, widthB);
|
const maxWidth = Math.max(widthA, widthB);
|
||||||
newLines.push({ text: lineText, width: maxWidth });
|
newLines.push({ text: lineText, width: maxWidth });
|
||||||
@@ -120,20 +130,64 @@ export function createCharacterComparison(
|
|||||||
const widthA = measureText(
|
const widthA = measureText(
|
||||||
ctx,
|
ctx,
|
||||||
testLine,
|
testLine,
|
||||||
fontA().name,
|
|
||||||
Math.min(fontSize, controlledFontSize),
|
Math.min(fontSize, controlledFontSize),
|
||||||
currentWeight,
|
currentWeight,
|
||||||
|
fontA()?.name,
|
||||||
);
|
);
|
||||||
const widthB = measureText(
|
const widthB = measureText(
|
||||||
ctx,
|
ctx,
|
||||||
testLine,
|
testLine,
|
||||||
fontB().name,
|
|
||||||
Math.min(fontSize, controlledFontSize),
|
Math.min(fontSize, controlledFontSize),
|
||||||
currentWeight,
|
currentWeight,
|
||||||
|
fontB()?.name,
|
||||||
);
|
);
|
||||||
const maxWidth = Math.max(widthA, widthB);
|
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);
|
pushLine(currentLineWords);
|
||||||
currentLineWords = [word];
|
currentLineWords = [word];
|
||||||
} else {
|
} else {
|
||||||
@@ -141,7 +195,9 @@ export function createCharacterComparison(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentLineWords.length > 0) pushLine(currentLineWords);
|
if (currentLineWords.length > 0) {
|
||||||
|
pushLine(currentLineWords);
|
||||||
|
}
|
||||||
lines = newLines;
|
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="
|
class="
|
||||||
group relative border-none size-9
|
group relative border-none size-9
|
||||||
bg-white/20 hover:bg-white/60
|
bg-white/20 hover:bg-white/60
|
||||||
|
backdrop-blur-3xl
|
||||||
transition-all duration-200 ease-out
|
transition-all duration-200 ease-out
|
||||||
will-change-transform
|
will-change-transform
|
||||||
hover:-translate-y-0.5
|
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">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { Spring } from 'svelte/motion';
|
import { Spring } from 'svelte/motion';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
@@ -42,6 +43,10 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|||||||
* @default 'clockwise'
|
* @default 'clockwise'
|
||||||
*/
|
*/
|
||||||
rotation?: 'clockwise' | 'counterclockwise';
|
rotation?: 'clockwise' | 'counterclockwise';
|
||||||
|
/**
|
||||||
|
* Classes for intermnal container
|
||||||
|
*/
|
||||||
|
containerClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -53,6 +58,7 @@ let {
|
|||||||
badge,
|
badge,
|
||||||
rotation = 'clockwise',
|
rotation = 'clockwise',
|
||||||
class: className = '',
|
class: className = '',
|
||||||
|
containerClassName = '',
|
||||||
...props
|
...props
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -91,7 +97,7 @@ function handleWrapperClick() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || (e.key === ' ' && !expanded)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleWrapperClick();
|
handleWrapperClick();
|
||||||
}
|
}
|
||||||
@@ -169,17 +175,18 @@ $effect(() => {
|
|||||||
class={cn(
|
class={cn(
|
||||||
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
|
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
|
||||||
expanded
|
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)]',
|
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
|
||||||
disabled && 'opacity-80 grayscale-[0.2]',
|
disabled && 'opacity-80 grayscale-[0.2]',
|
||||||
|
containerClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{@render visibleContent?.({ expanded, disabled })}
|
{@render visibleContent?.({ expanded, disabled })}
|
||||||
|
|
||||||
{#if expanded}
|
{#if expanded}
|
||||||
<div
|
<div
|
||||||
in:slide={{ duration: 250, delay: 50 }}
|
in:slide={{ duration: 250, easing: cubicOut }}
|
||||||
out:slide={{ duration: 250 }}
|
out:slide={{ duration: 250, easing: cubicOut }}
|
||||||
>
|
>
|
||||||
{@render hiddenContent?.({ expanded, disabled })}
|
{@render hiddenContent?.({ expanded, disabled })}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,38 +9,26 @@
|
|||||||
- Responsive layout with Tailwind breakpoints for font sizing.
|
- Responsive layout with Tailwind breakpoints for font sizing.
|
||||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
- 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 {
|
import {
|
||||||
createCharacterComparison,
|
createCharacterComparison,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
import type { LineData } from '$shared/lib';
|
import type { LineData } from '$shared/lib';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { Spring } from 'svelte/motion';
|
import { Spring } from 'svelte/motion';
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
import CharacterSlot from './components/CharacterSlot.svelte';
|
import CharacterSlot from './components/CharacterSlot.svelte';
|
||||||
import ControlsWrapper from './components/ControlsWrapper.svelte';
|
import ControlsWrapper from './components/ControlsWrapper.svelte';
|
||||||
import Labels from './components/Labels.svelte';
|
import Labels from './components/Labels.svelte';
|
||||||
import SliderLine from './components/SliderLine.svelte';
|
import SliderLine from './components/SliderLine.svelte';
|
||||||
|
|
||||||
interface Props<T extends { name: string; id: string }> {
|
// Displayed text
|
||||||
/**
|
let text = $state('The quick brown fox jumps over the lazy dog...');
|
||||||
* First font definition ({name, id})
|
// Pair of fonts to compare
|
||||||
*/
|
const fontA = $derived(displayedFontsStore.fontA);
|
||||||
fontA: T;
|
const fontB = $derived(displayedFontsStore.fontB);
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
|
|
||||||
let container: HTMLElement | undefined = $state();
|
let container: HTMLElement | undefined = $state();
|
||||||
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
|
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
|
||||||
@@ -98,7 +86,6 @@ function handleMove(e: PointerEvent) {
|
|||||||
|
|
||||||
function startDragging(e: PointerEvent) {
|
function startDragging(e: PointerEvent) {
|
||||||
if (e.target === controlsWrapperElement || controlsWrapperElement?.contains(e.target as Node)) {
|
if (e.target === controlsWrapperElement || controlsWrapperElement?.contains(e.target as Node)) {
|
||||||
console.log('Pointer down on controls wrapper');
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -162,6 +149,7 @@ $effect(() => {
|
|||||||
- Font Family switches based on `isPast`
|
- Font Family switches based on `isPast`
|
||||||
- Transitions/Transforms provide the "morph" feel
|
- Transitions/Transforms provide the "morph" feel
|
||||||
-->
|
-->
|
||||||
|
{#if fontA && fontB}
|
||||||
<CharacterSlot
|
<CharacterSlot
|
||||||
{char}
|
{char}
|
||||||
{proximity}
|
{proximity}
|
||||||
@@ -171,10 +159,12 @@ $effect(() => {
|
|||||||
fontAName={fontA.name}
|
fontAName={fontA.name}
|
||||||
fontBName={fontB.name}
|
fontBName={fontB.name}
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#if fontA && fontB}
|
||||||
<!-- Hidden canvas used for text measurement by the helper -->
|
<!-- Hidden canvas used for text measurement by the helper -->
|
||||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
||||||
|
|
||||||
@@ -192,6 +182,7 @@ $effect(() => {
|
|||||||
select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center
|
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;'}
|
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 -->
|
<!-- Background Gradient Accent -->
|
||||||
<div
|
<div
|
||||||
@@ -225,10 +216,10 @@ $effect(() => {
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Visual Components -->
|
|
||||||
<SliderLine {sliderPos} {isDragging} />
|
<SliderLine {sliderPos} {isDragging} />
|
||||||
<Labels {fontA} {fontB} {sliderPos} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Labels fontA={fontA} fontB={fontB} {sliderPos} />
|
||||||
<!-- Since there're slider controls inside we put them outside the main one -->
|
<!-- Since there're slider controls inside we put them outside the main one -->
|
||||||
<ControlsWrapper
|
<ControlsWrapper
|
||||||
bind:wrapper={controlsWrapperElement}
|
bind:wrapper={controlsWrapperElement}
|
||||||
@@ -241,3 +232,4 @@ $effect(() => {
|
|||||||
heightControl={heightControl}
|
heightControl={heightControl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<script lang="ts">
|
||||||
import type { TypographyControl } from '$shared/lib';
|
import type { TypographyControl } from '$shared/lib';
|
||||||
import { Input } from '$shared/shadcn/ui/input';
|
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';
|
import { Spring } from 'svelte/motion';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Ref
|
||||||
|
*/
|
||||||
wrapper?: HTMLDivElement | null;
|
wrapper?: HTMLDivElement | null;
|
||||||
|
/**
|
||||||
|
* Slider position
|
||||||
|
*/
|
||||||
sliderPos: number;
|
sliderPos: number;
|
||||||
|
/**
|
||||||
|
* Whether slider is being dragged
|
||||||
|
*/
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
|
/**
|
||||||
|
* Text to display
|
||||||
|
*/
|
||||||
text: string;
|
text: string;
|
||||||
|
/**
|
||||||
|
* Container width
|
||||||
|
*/
|
||||||
containerWidth: number;
|
containerWidth: number;
|
||||||
|
/**
|
||||||
|
* Weight control
|
||||||
|
*/
|
||||||
weightControl: TypographyControl;
|
weightControl: TypographyControl;
|
||||||
|
/**
|
||||||
|
* Size control
|
||||||
|
*/
|
||||||
sizeControl: TypographyControl;
|
sizeControl: TypographyControl;
|
||||||
|
/**
|
||||||
|
* Height control
|
||||||
|
*/
|
||||||
heightControl: TypographyControl;
|
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 {
|
interface Props<T> {
|
||||||
fontA: { name: string; id: string };
|
/**
|
||||||
fontB: { name: string; id: string };
|
* First font to compare
|
||||||
|
*/
|
||||||
|
fontA: T;
|
||||||
|
/**
|
||||||
|
* Second font to compare
|
||||||
|
*/
|
||||||
|
fontB: T;
|
||||||
|
/**
|
||||||
|
* Position of the slider
|
||||||
|
*/
|
||||||
sliderPos: number;
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- Bottom Labels -->
|
{#snippet fontSelector(
|
||||||
<div class="absolute bottom-6 inset-x-8 sm:inset-x-12 flex justify-between items-center pointer-events-none z-20">
|
name: string,
|
||||||
<!-- Left Label (Font A) -->
|
id: string,
|
||||||
|
fonts: UnifiedFont[],
|
||||||
|
handleChange: (value: string) => void,
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-1 transition-opacity duration-300"
|
class="z-50 pointer-events-auto **:bg-transparent"
|
||||||
style:opacity={sliderPos < 10 ? 0 : 1}
|
onpointerdown={(e => e.stopPropagation())}
|
||||||
>
|
>
|
||||||
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-indigo-400"
|
<SelectRoot type="single" onValueChange={handleChange}>
|
||||||
>Baseline</span>
|
<SelectTrigger
|
||||||
<span class="text-xs sm:text-sm font-bold text-indigo-600">
|
class={cn(buttonVariants({ variant: 'ghost' }), 'border-none, hover:bg-indigo-100')}
|
||||||
{fontB.name}
|
disabled={!fontList.length}
|
||||||
</span>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Right Label (Font B) -->
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-col items-end text-right gap-1 transition-opacity duration-300"
|
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"
|
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-slate-400">
|
||||||
>Comparison</span>
|
Comparison</span>
|
||||||
<span class="text-xs sm:text-sm font-bold text-slate-900">
|
{@render fontSelector(fontA.name, fontA.id, fontList, selectFontA)}
|
||||||
{fontA.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
<!--
|
||||||
|
Component: SliderLine
|
||||||
|
Visual representation of the slider line.
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Position of the slider
|
||||||
|
*/
|
||||||
sliderPos: number;
|
sliderPos: number;
|
||||||
|
/**
|
||||||
|
* Whether the slider is being dragged
|
||||||
|
*/
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
}
|
}
|
||||||
let { sliderPos, isDragging }: Props = $props();
|
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 class="w-3 h-3 bg-indigo-500 rounded-full shadow-lg ring-2 ring-white"></div>
|
||||||
</div>
|
</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">
|
<script lang="ts">
|
||||||
import SetupFontMenu from '$features/SetupFont/ui/SetupFontMenu.svelte';
|
import { SetupFontMenu } from '$features/SetupFont';
|
||||||
import {
|
import {
|
||||||
Content as ItemContent,
|
Content as ItemContent,
|
||||||
Root as ItemRoot,
|
Root as ItemRoot,
|
||||||
} from '$shared/shadcn/ui/item';
|
} 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>
|
</script>
|
||||||
|
|
||||||
<div class="w-full p-2 backdrop-blur-2xl">
|
{#if displayedFontsStore.hasAnyFonts}
|
||||||
<ItemRoot variant="outline" class="w-full p-2.5">
|
<div
|
||||||
<ItemContent class="flex flex-row justify-center items-center">
|
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 />
|
<SetupFontMenu />
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</ItemRoot>
|
</ItemRoot>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user