194 lines
7.5 KiB
Svelte
194 lines
7.5 KiB
Svelte
<!--
|
|
Component: TypographyMenu
|
|
Floating controls bar for typography settings.
|
|
Warm surface, sharp corners, Settings icon header, dividers between units.
|
|
Mobile: popover with slider controls anchored to settings button.
|
|
Desktop: inline bar with combo controls.
|
|
-->
|
|
<script lang="ts">
|
|
import type { ResponsiveManager } from '$shared/lib';
|
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
import {
|
|
Button,
|
|
ComboControl,
|
|
ControlGroup,
|
|
Slider,
|
|
} from '$shared/ui';
|
|
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
|
import XIcon from '@lucide/svelte/icons/x';
|
|
import { Popover } from 'bits-ui';
|
|
import { getContext } from 'svelte';
|
|
import { cubicOut } from 'svelte/easing';
|
|
import { fly } from 'svelte/transition';
|
|
import {
|
|
MULTIPLIER_L,
|
|
MULTIPLIER_M,
|
|
MULTIPLIER_S,
|
|
controlManager,
|
|
} from '../../model';
|
|
|
|
interface Props {
|
|
/**
|
|
* CSS classes
|
|
*/
|
|
class?: string;
|
|
/**
|
|
* Hidden state
|
|
* @default false
|
|
*/
|
|
hidden?: boolean;
|
|
}
|
|
|
|
const { class: className, hidden = false }: Props = $props();
|
|
|
|
const responsive = getContext<ResponsiveManager>('responsive');
|
|
|
|
let isOpen = $state(false);
|
|
|
|
/**
|
|
* Sets the common font size multiplier based on the current responsive state.
|
|
*/
|
|
$effect(() => {
|
|
if (!responsive) return;
|
|
switch (true) {
|
|
case responsive.isMobile:
|
|
controlManager.multiplier = MULTIPLIER_S;
|
|
break;
|
|
case responsive.isTablet:
|
|
controlManager.multiplier = MULTIPLIER_M;
|
|
break;
|
|
case responsive.isDesktop:
|
|
controlManager.multiplier = MULTIPLIER_L;
|
|
break;
|
|
default:
|
|
controlManager.multiplier = MULTIPLIER_L;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
{#if !hidden}
|
|
{#if responsive.isMobile}
|
|
<Popover.Root bind:open={isOpen}>
|
|
<Popover.Trigger>
|
|
{#snippet child({ props })}
|
|
<button
|
|
{...props}
|
|
class={cn(
|
|
'inline-flex items-center justify-center',
|
|
'size-8 p-0',
|
|
'border border-transparent rounded-none',
|
|
'transition-colors duration-150',
|
|
'hover:bg-white/50 dark:hover:bg-white/5',
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/30',
|
|
isOpen && 'bg-paper dark:bg-dark-card border-black/5 dark:border-white/10 shadow-sm',
|
|
className,
|
|
)}
|
|
>
|
|
<Settings2Icon class="size-4" />
|
|
</button>
|
|
{/snippet}
|
|
</Popover.Trigger>
|
|
|
|
<Popover.Portal>
|
|
<Popover.Content
|
|
side="top"
|
|
align="start"
|
|
sideOffset={8}
|
|
class={cn(
|
|
'z-50 w-72',
|
|
'bg-surface dark:bg-dark-card',
|
|
'border border-black/5 dark:border-white/10',
|
|
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.15)]',
|
|
'rounded-none p-4',
|
|
'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=top]:slide-in-from-bottom-2',
|
|
'data-[side=bottom]:slide-in-from-top-2',
|
|
)}
|
|
interactOutsideBehavior="close"
|
|
escapeKeydownBehavior="close"
|
|
>
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-3 pb-3 border-b border-black/5 dark:border-white/10">
|
|
<div class="flex items-center gap-1.5">
|
|
<Settings2Icon size={12} class="text-swiss-red" />
|
|
<span
|
|
class="text-[0.5625rem] font-mono uppercase tracking-widest font-bold text-swiss-black dark:text-neutral-200"
|
|
>
|
|
CONTROLS
|
|
</span>
|
|
</div>
|
|
<Popover.Close>
|
|
{#snippet child({ props })}
|
|
<button
|
|
{...props}
|
|
class="inline-flex items-center justify-center size-6 rounded-none hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
|
aria-label="Close controls"
|
|
>
|
|
<XIcon class="size-3.5 text-neutral-500" />
|
|
</button>
|
|
{/snippet}
|
|
</Popover.Close>
|
|
</div>
|
|
|
|
<!-- Controls -->
|
|
{#each controlManager.controls as control (control.id)}
|
|
<ControlGroup label={control.controlLabel ?? ''}>
|
|
<Slider
|
|
bind:value={control.instance.value}
|
|
min={control.instance.min}
|
|
max={control.instance.max}
|
|
step={control.instance.step}
|
|
/>
|
|
</ControlGroup>
|
|
{/each}
|
|
</Popover.Content>
|
|
</Popover.Portal>
|
|
</Popover.Root>
|
|
{:else}
|
|
<div
|
|
class={cn('w-full md:w-auto', className)}
|
|
transition:fly={{ y: 100, duration: 200, easing: cubicOut }}
|
|
>
|
|
<div
|
|
class={cn(
|
|
'flex items-center gap-1 md:gap-2 p-1.5 md:p-2',
|
|
'bg-surface/95 dark:bg-dark-bg/95 backdrop-blur-xl',
|
|
'border border-black/5 dark:border-white/10',
|
|
'shadow-[0_20px_40px_-10px_rgba(0,0,0,0.1)]',
|
|
'rounded-none ring-1 ring-black/5 dark:ring-white/5',
|
|
)}
|
|
>
|
|
<!-- Header: icon + label -->
|
|
<div class="px-2 md:px-3 flex items-center gap-1.5 md:gap-2 border-r border-black/5 dark:border-white/10 mr-1 text-swiss-black dark:text-neutral-200 shrink-0">
|
|
<Settings2Icon
|
|
size={14}
|
|
class="text-swiss-red"
|
|
/>
|
|
<span
|
|
class="text-[0.5625rem] md:text-[0.625rem] font-mono uppercase tracking-widest font-bold hidden sm:inline whitespace-nowrap"
|
|
>
|
|
GLOBAL_CONTROLS
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Controls with dividers between each -->
|
|
{#each controlManager.controls as control, i (control.id)}
|
|
{#if i > 0}
|
|
<div class="w-px h-6 md:h-8 bg-black/5 dark:bg-white/10 mx-0.5 md:mx-1 shrink-0"></div>
|
|
{/if}
|
|
|
|
<ComboControl
|
|
control={control.instance}
|
|
label={control.controlLabel}
|
|
increaseLabel={control.increaseLabel}
|
|
decreaseLabel={control.decreaseLabel}
|
|
controlLabel={control.controlLabel}
|
|
/>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|