Compare commits

...

6 Commits

11 changed files with 297 additions and 44 deletions

View File

@@ -85,8 +85,8 @@ $effect(() => {
{/snippet} {/snippet}
</IconButton> </IconButton>
{/snippet} {/snippet}
{#snippet content()} {#snippet content({ className })}
<div class="flex flex-col gap-6 px-2 py-4"> <div class={cn(className, 'flex flex-col gap-6')}>
{#each controlManager.controls as control (control.id)} {#each controlManager.controls as control (control.id)}
<ComboControlV2 <ComboControlV2
control={control.instance} control={control.instance}

View File

@@ -0,0 +1,43 @@
<script module>
import { createTypographyControl } from '$shared/lib';
import { defineMeta } from '@storybook/addon-svelte-csf';
import ComboControlV2 from './ComboControlV2.svelte';
const { Story } = defineMeta({
title: 'Shared/ComboControlV2',
component: ComboControlV2,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'ComboControl with input field and slider',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
argTypes: {
orientation: {
control: 'select',
options: ['horizontal', 'vertical'],
description: 'Orientation of the ComboControl',
defaultValue: 'vertical',
},
label: {
control: 'text',
description: 'Label for the ComboControl',
},
},
});
</script>
<script lang="ts">
const control = createTypographyControl({ min: 0, max: 100, step: 1, value: 50 });
</script>
<Story name="Horizontal" args={{ orientation: 'horizontal', control }}>
<ComboControlV2 control={control} orientation="horizontal" />
</Story>
<Story name="Vertical" args={{ orientation: 'vertical', control, class: 'h-48' }}>
<ComboControlV2 control={control} orientation="vertical" />
</Story>

View File

@@ -4,62 +4,113 @@
--> -->
<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 { Slider } from '$shared/shadcn/ui/slider';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Input } from '$shared/ui';
import { Slider } from '$shared/ui';
import type { Orientation } from 'bits-ui'; import type { Orientation } from 'bits-ui';
import type { ChangeEventHandler } from 'svelte/elements'; import type { ChangeEventHandler } from 'svelte/elements';
interface Props { interface Props {
/** /**
* Typography control instance * Control instance
*/ */
control: TypographyControl; control: TypographyControl;
/** /**
* Orientation of the component * Orientation
*/ */
orientation?: Orientation; orientation?: Orientation;
/**
* Label text
*/
label?: string;
/**
* CSS class
*/
class?: string;
} }
let { let {
control, control,
orientation = 'vertical', orientation = 'vertical',
label,
class: className,
}: Props = $props(); }: Props = $props();
let sliderValue = $state(Number(control.value)); let inputValue = $state(String(control.value));
$effect(() => { $effect(() => {
sliderValue = Number(control?.value); inputValue = String(control.value);
}); });
const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => { const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
const parsedValue = parseFloat(event.currentTarget.value); const parsedValue = parseFloat(event.currentTarget.value);
if (!isNaN(parsedValue)) { if (!isNaN(parsedValue)) {
control.value = parsedValue; control.value = parsedValue;
inputValue = String(parsedValue);
} }
}; };
const handleSliderChange = (newValue: number) => {
control.value = newValue;
};
</script> </script>
<div class={cn('flex flex-col items-center gap-4 w-full', orientation === 'vertical' ? 'flex-col' : 'flex-row')}> <div
class={cn(
'flex gap-4 sm:p-4 rounded-xl transition-all duration-300',
'backdrop-blur-md',
orientation === 'horizontal' ? 'flex-row items-end w-full' : 'flex-col items-center h-full',
className,
)}
>
<Input <Input
value={control.value} class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center"
value={inputValue}
onchange={handleInputChange} onchange={handleInputChange}
min={control.min} min={control.min}
max={control.max} max={control.max}
class="w-14 h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50" step={control.step}
/> />
<div class={cn('relative', orientation === 'horizontal' ? 'w-full' : 'h-full')}>
<div
class={cn(
'absolute flex justify-between',
orientation === 'horizontal' ? 'flex-row w-full -top-5 px-0.5' : 'flex-col h-full -left-5 py-0.5',
)}
>
{#each Array(5) as _, i}
<div
class={cn(
'flex items-center gap-1.5',
orientation === 'horizontal' ? 'flex-col' : 'flex-row',
)}
>
<span class="font-mono text-[0.375rem] text-gray-400 tabular-nums">
{
Number.isInteger(control.step)
? Math.round(control.min + (i * (control.max - control.min) / 4))
: (control.min + (i * (control.max - control.min) / 4)).toFixed(2)
}
</span>
<div class={cn('bg-gray-300', orientation === 'horizontal' ? 'w-px h-1' : 'h-px w-1')}></div>
</div>
{/each}
</div>
<Slider <Slider
class={cn(orientation === 'horizontal' ? 'w-full' : 'h-full')}
bind:value={control.value}
min={control.min} min={control.min}
max={control.max} max={control.max}
step={control.step} step={control.step}
value={sliderValue} {orientation}
onValueChange={handleSliderChange}
type="single"
orientation={orientation}
class={cn(orientation === 'vertical' ? 'h-30' : 'w-full')}
/> />
</div>
{#if label}
<div class="flex items-center gap-2 opacity-70">
<div class="w-1 h-1 rounded-full bg-gray-900"></div>
<div class="w-px h-2 bg-gray-400/50"></div>
<span class="font-mono text-[8px] uppercase tracking-[0.2em] text-gray-500 font-medium">
{label}
</span>
</div>
{/if}
</div> </div>

View File

@@ -14,7 +14,7 @@ import type { Snippet } from 'svelte';
interface Props { interface Props {
isOpen?: boolean; isOpen?: boolean;
trigger?: Snippet<[{ isOpen: boolean; onClick: () => void }]>; trigger?: Snippet<[{ isOpen: boolean; onClick: () => void }]>;
content?: Snippet<[{ isOpen: boolean }]>; content?: Snippet<[{ isOpen: boolean; className?: string }]>;
contentClassName?: string; contentClassName?: string;
} }
@@ -35,7 +35,7 @@ function handleClick() {
</Button> </Button>
{/if} {/if}
</DrawerTrigger> </DrawerTrigger>
<DrawerContent class={cn('min-h-60', contentClassName)}> <DrawerContent>
{@render content?.({ isOpen })} {@render content?.({ isOpen, className: cn('min-h-60 px-2 pt-4 pb-8', contentClassName) })}
</DrawerContent> </DrawerContent>
</DrawerRoot> </DrawerRoot>

View File

@@ -44,7 +44,7 @@ let {
'placeholder:font-mono', 'placeholder:font-mono',
'placeholder:text-xs sm:placeholder:text-sm', 'placeholder:text-xs sm:placeholder:text-sm',
'placeholder:tracking-wide', 'placeholder:tracking-wide',
'pl-11 sm:pl-14 pr-4 sm:pr-6', 'pl-4 sm:pl-6 pr-4 sm:pr-6',
'rounded-xl', 'rounded-xl',
'transition-all duration-200', 'transition-all duration-200',
'font-medium', 'font-medium',

View File

@@ -1,5 +1,6 @@
<!-- Component: SearchBar --> <!-- Component: SearchBar -->
<script lang="ts"> <script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Input } from '$shared/ui'; import { Input } from '$shared/ui';
import AsteriskIcon from '@lucide/svelte/icons/asterisk'; import AsteriskIcon from '@lucide/svelte/icons/asterisk';
@@ -34,5 +35,5 @@ let {
<div class="absolute left-4 sm:left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10"> <div class="absolute left-4 sm:left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
<AsteriskIcon class="size-3 sm:size-4 stroke-gray-400 stroke-[1.5]" /> <AsteriskIcon class="size-3 sm:size-4 stroke-gray-400 stroke-[1.5]" />
</div> </div>
<Input {id} class={className} bind:value={value} placeholder={placeholder} /> <Input {id} class={cn('pl-11 sm:pl-14', className)} bind:value={value} placeholder={placeholder} />
</div> </div>

View File

@@ -0,0 +1,51 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Slider from './Slider.svelte';
const { Story } = defineMeta({
title: 'Shared/Slider',
component: Slider,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component: 'Styled bits-ui slider component for selecting a value within a range.',
},
story: { inline: false }, // Render stories in iframe for state isolation
},
},
argTypes: {
value: {
control: 'number',
description: 'Current value (two-way bindable)',
},
min: {
control: 'number',
description: 'Minimum value',
},
max: {
control: 'number',
description: 'Maximum value',
},
step: {
control: 'number',
description: 'Step size for value increments',
},
},
});
</script>
<script lang="ts">
let minValue = 0;
let maxValue = 100;
let stepValue = 1;
let value = $state(50);
</script>
<Story name="Horizontal" args={{ orientation: 'horizontal', min: minValue, max: maxValue, step: stepValue, value }}>
<Slider bind:value min={minValue} max={maxValue} step={stepValue} />
</Story>
<Story name="Vertical" args={{ orientation: 'vertical', min: minValue, max: maxValue, step: stepValue, value }}>
<Slider bind:value min={minValue} max={maxValue} step={stepValue} orientation="vertical" />
</Story>

View File

@@ -0,0 +1,105 @@
<!--
Component: Slider
Styled bits-ui slider component with single value.
-->
<script lang="ts">
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import {
Slider,
type SliderRootProps,
} from 'bits-ui';
type Props = Omit<SliderRootProps, 'type' | 'onValueChange' | 'onValueCommit'> & {
/**
* Slider value, numeric.
*/
value: number;
/**
* A callback function called when the value changes.
* @param newValue - number
*/
onValueChange?: (newValue: number) => void;
/**
* A callback function called when the user stops dragging the thumb and the value is committed.
* @param newValue - number
*/
onValueCommit?: (newValue: number) => void;
};
let { value = $bindable(), orientation = 'horizontal', class: className, ...rest }: Props = $props();
</script>
<Slider.Root
bind:value={value}
class={cn(
'relative flex h-full w-6 touch-none select-none items-center justify-center',
orientation === 'horizontal' ? 'w-48 h-6' : 'w-6 h-48',
className,
)}
type="single"
{orientation}
{...rest}
>
{#snippet children(props)}
<span
{...props}
class={cn('relative bg-gray-200 rounded-full', orientation === 'horizontal' ? 'w-full h-px' : 'h-full w-px')}
>
<!-- Filled range with NO transition -->
<Slider.Range
class={cn('absolute bg-gray-900 rounded-full', orientation === 'horizontal' ? 'h-full' : 'w-full')}
/>
<Slider.Thumb
index={0}
class={cn(
'group/thumb relative block',
orientation === 'horizontal' ? '-top-1 w-2 h-2.25' : '-left-1 h-2 w-2.25',
'rounded-sm',
'bg-gray-900',
// Glow shadow
'shadow-[0_0_6px_rgba(0,0,0,0.4)]',
// Smooth transitions only for size/position
'duration-200 ease-out',
orientation === 'horizontal' ? 'transition-[height,top,left,box-shadow]' : 'transition-[width,top,left,box-shadow]',
// Hover: bigger glow
'hover:shadow-[0_0_10px_rgba(0,0,0,0.5)]',
orientation === 'horizontal' ? 'hover:h-3 hover:-top-[5.5px]' : 'hover:w-3 hover:-left-[5.5px]',
// Active: smaller glow
'active:shadow-[0_0_4px_rgba(0,0,0,0.3)]',
orientation === 'horizontal' ? 'active:h-2.5 active:-top-[4.5px]' : 'active:w-2.5 active:-left-[4.5px]',
'focus:outline-none',
'cursor-grab active:cursor-grabbing',
)}
>
<!-- Soft glow on hover -->
<div
class="
absolute inset-0 rounded-sm
bg-white/20
opacity-0 group-hover/thumb:opacity-100
transition-opacity duration-200
"
>
</div>
<!-- Value label -->
<span
class={cn(
'absolute',
orientation === 'horizontal' ? '-top-8 left-1/2 -translate-x-1/2' : 'left-5 top-1/2 -translate-y-1/2',
'px-1.5 py-0.5 rounded-md',
'bg-gray-900/90 backdrop-blur-sm',
'font-mono text-[0.625rem] font-medium text-white ',
'opacity-0 group-hover/thumb:opacity-100',
'transition-all duration-300',
'pointer-events-none',
'shadow-sm',
)}
>
{value}
</span>
</Slider.Thumb>
</span>
{/snippet}
</Slider.Root>

View File

@@ -12,4 +12,5 @@ export { default as Logo } from './Logo/Logo.svelte';
export { default as SearchBar } from './SearchBar/SearchBar.svelte'; export { default as SearchBar } from './SearchBar/SearchBar.svelte';
export { default as Section } from './Section/Section.svelte'; export { default as Section } from './Section/Section.svelte';
export { default as Skeleton } from './Skeleton/Skeleton.svelte'; export { default as Skeleton } from './Skeleton/Skeleton.svelte';
export { default as Slider } from './Slider/Slider.svelte';
export { default as VirtualList } from './VirtualList/VirtualList.svelte'; export { default as VirtualList } from './VirtualList/VirtualList.svelte';

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { ResponsiveManager } from '$shared/lib'; import type { ResponsiveManager } from '$shared/lib';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { import {
Drawer, Drawer,
IconButton, IconButton,
@@ -36,10 +37,9 @@ const responsive = getContext<ResponsiveManager>('responsive');
</IconButton> </IconButton>
{/snippet} {/snippet}
{#snippet content({ isOpen })} {#snippet content({ isOpen, className })}
<div class="px-2 py-4"> <div class={cn(className, 'flex flex-col gap-6')}>
<SelectComparedFonts {sliderPos} /> <SelectComparedFonts {sliderPos} />
</div>
<TypographyControls <TypographyControls
{sliderPos} {sliderPos}
{isDragging} {isDragging}
@@ -48,11 +48,12 @@ const responsive = getContext<ResponsiveManager>('responsive');
containerWidth={container?.clientWidth} containerWidth={container?.clientWidth}
staticPosition staticPosition
/> />
</div>
{/snippet} {/snippet}
</Drawer> </Drawer>
{:else} {:else}
{#if !isLoading} {#if !isLoading}
<div class="absolute top-3 sm:top-6 left-3 sm:left-6"> <div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50">
<TypographyControls <TypographyControls
{sliderPos} {sliderPos}
{isDragging} {isDragging}

View File

@@ -116,7 +116,7 @@ $effect(() => {
out:fade={{ duration: 300, delay: 300 }} out:fade={{ duration: 300, delay: 300 }}
> >
{#if staticPosition} {#if staticPosition}
<div class="flex flex-col gap-6 px-2 py-4"> <div class="flex flex-col gap-6">
<Input <Input
class="p-6" class="p-6"
bind:value={comparisonStore.text} bind:value={comparisonStore.text}
@@ -194,9 +194,9 @@ $effect(() => {
{#snippet hiddenContent()} {#snippet hiddenContent()}
{#if typography.weightControl && typography.sizeControl && typography.heightControl} {#if typography.weightControl && typography.sizeControl && typography.heightControl}
<div class="flex flex-row justify-between items-center-safe gap-2 sm:gap-0"> <div class="flex flex-row justify-between items-center-safe gap-2 sm:gap-0">
<ComboControlV2 control={typography.weightControl} /> <ComboControlV2 control={typography.weightControl} orientation="vertical" />
<ComboControlV2 control={typography.sizeControl} /> <ComboControlV2 control={typography.sizeControl} orientation="vertical" />
<ComboControlV2 control={typography.heightControl} /> <ComboControlV2 control={typography.heightControl} orientation="vertical" />
</div> </div>
{/if} {/if}
{/snippet} {/snippet}