This commit is contained in:
Tobias Klemp
2025-11-23 18:55:37 +01:00
parent c829f9f57b
commit 90280fd436
53 changed files with 1620 additions and 82 deletions

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
outline:
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,20 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from './button.svelte'
import ToggleButton from './toggleButton.svelte'
export {
Root,
type ButtonProps as Props,
//
Root as Button,
ToggleButton,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from 'svelte'
const {
onclick,
children: content,
selected = $bindable(),
}: {
onclick?: () => void
children?: Snippet
selected?: boolean
} = $props()
</script>
<button
onclick={() => onclick?.()}
class:selected
class="cursor-pointer px-6 py-3 border-2 rounded-lg transition-all border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 dark:text-gray-300"
>
{@render content?.()}
</button>
<style>
@reference "../../../../app.css";
.selected {
@apply border-blue-600 bg-blue-50 dark:border-blue-500 dark:text-white dark:bg-blue-950;
}
</style>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/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="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/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="card-footer"
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/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="card-header"
class={cn(
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn("font-semibold leading-none", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import emblaCarouselSvelte from "embla-carousel-svelte";
import type { HTMLAttributes } from "svelte/elements";
import { getEmblaContext } from "./context.js";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
const emblaCtx = getEmblaContext("<Carousel.Content/>");
</script>
<div
data-slot="carousel-content"
class="overflow-hidden"
use:emblaCarouselSvelte={{
options: {
container: "[data-embla-container]",
slides: "[data-embla-slide]",
...emblaCtx.options,
axis: emblaCtx.orientation === "horizontal" ? "x" : "y",
},
plugins: emblaCtx.plugins,
}}
onemblaInit={emblaCtx.onInit}
>
<div
bind:this={ref}
class={cn(
"flex",
emblaCtx.orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
data-embla-container=""
{...restProps}
>
{@render children?.()}
</div>
</div>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { getEmblaContext } from "./context.js";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
const emblaCtx = getEmblaContext("<Carousel.Item/>");
</script>
<div
bind:this={ref}
data-slot="carousel-item"
role="group"
aria-roledescription="slide"
class={cn(
"min-w-0 shrink-0 grow-0 basis-full",
emblaCtx.orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
data-embla-slide=""
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right'
import type { WithoutChildren } from 'bits-ui'
import { getEmblaContext } from './context.js'
import { cn } from '$lib/utils.js'
import { Button, type Props } from '$lib/components/ui/button/index.js'
let {
ref = $bindable(null),
class: className,
variant = 'outline',
size = 'icon',
...restProps
}: WithoutChildren<Props> = $props()
const emblaCtx = getEmblaContext('<Carousel.Next/>')
</script>
<Button
data-slot="carousel-next"
{variant}
{size}
aria-disabled={!emblaCtx.canScrollNext}
class={cn(
'absolute size-8 rounded-full',
emblaCtx.orientation === 'horizontal'
? '-right-12 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
onclick={emblaCtx.scrollNext}
onkeydown={emblaCtx.handleKeyDown}
bind:ref
{...restProps}
>
<ArrowRightIcon class="size-4" />
<span class="sr-only">Next slide</span>
</Button>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
import type { WithoutChildren } from "bits-ui";
import { getEmblaContext } from "./context.js";
import { cn } from "$lib/utils.js";
import { Button, type Props } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
variant = "outline",
size = "icon",
...restProps
}: WithoutChildren<Props> = $props();
const emblaCtx = getEmblaContext("<Carousel.Previous/>");
</script>
<Button
data-slot="carousel-previous"
{variant}
{size}
aria-disabled={!emblaCtx.canScrollPrev}
class={cn(
"absolute size-8 rounded-full",
emblaCtx.orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
onclick={emblaCtx.scrollPrev}
onkeydown={emblaCtx.handleKeyDown}
{...restProps}
bind:ref
>
<ArrowLeftIcon class="size-4" />
<span class="sr-only">Previous slide</span>
</Button>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import {
type CarouselAPI,
type CarouselProps,
type EmblaContext,
setEmblaContext,
} from "./context.js";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
opts = {},
plugins = [],
setApi = () => {},
orientation = "horizontal",
class: className,
children,
...restProps
}: WithElementRef<CarouselProps> = $props();
let carouselState = $state<EmblaContext>({
api: undefined,
scrollPrev,
scrollNext,
orientation,
canScrollNext: false,
canScrollPrev: false,
handleKeyDown,
options: opts,
plugins,
onInit,
scrollSnaps: [],
selectedIndex: 0,
scrollTo,
});
setEmblaContext(carouselState);
function scrollPrev() {
carouselState.api?.scrollPrev();
}
function scrollNext() {
carouselState.api?.scrollNext();
}
function scrollTo(index: number, jump?: boolean) {
carouselState.api?.scrollTo(index, jump);
}
function onSelect() {
if (!carouselState.api) return;
carouselState.selectedIndex = carouselState.api.selectedScrollSnap();
carouselState.canScrollNext = carouselState.api.canScrollNext();
carouselState.canScrollPrev = carouselState.api.canScrollPrev();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowLeft") {
e.preventDefault();
scrollPrev();
} else if (e.key === "ArrowRight") {
e.preventDefault();
scrollNext();
}
}
function onInit(event: CustomEvent<CarouselAPI>) {
carouselState.api = event.detail;
setApi(carouselState.api);
carouselState.scrollSnaps = carouselState.api.scrollSnapList();
carouselState.api.on("select", onSelect);
onSelect();
}
$effect(() => {
return () => {
carouselState.api?.off("select", onSelect);
};
});
</script>
<div
bind:this={ref}
data-slot="carousel"
class={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,58 @@
import type { WithElementRef } from "$lib/utils.js";
import type {
EmblaCarouselSvelteType,
default as emblaCarouselSvelte,
} from "embla-carousel-svelte";
import { getContext, hasContext, setContext } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
export type CarouselAPI =
NonNullable<NonNullable<EmblaCarouselSvelteType["$$_attributes"]>["on:emblaInit"]> extends (
evt: CustomEvent<infer CarouselAPI>
) => void
? CarouselAPI
: never;
type EmblaCarouselConfig = NonNullable<Parameters<typeof emblaCarouselSvelte>[1]>;
export type CarouselOptions = EmblaCarouselConfig["options"];
export type CarouselPlugins = EmblaCarouselConfig["plugins"];
////
export type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugins;
setApi?: (api: CarouselAPI | undefined) => void;
orientation?: "horizontal" | "vertical";
} & WithElementRef<HTMLAttributes<HTMLDivElement>>;
const EMBLA_CAROUSEL_CONTEXT = Symbol("EMBLA_CAROUSEL_CONTEXT");
export type EmblaContext = {
api: CarouselAPI | undefined;
orientation: "horizontal" | "vertical";
scrollNext: () => void;
scrollPrev: () => void;
canScrollNext: boolean;
canScrollPrev: boolean;
handleKeyDown: (e: KeyboardEvent) => void;
options: CarouselOptions;
plugins: CarouselPlugins;
onInit: (e: CustomEvent<CarouselAPI>) => void;
scrollTo: (index: number, jump?: boolean) => void;
scrollSnaps: number[];
selectedIndex: number;
};
export function setEmblaContext(config: EmblaContext): EmblaContext {
setContext(EMBLA_CAROUSEL_CONTEXT, config);
return config;
}
export function getEmblaContext(name = "This component") {
if (!hasContext(EMBLA_CAROUSEL_CONTEXT)) {
throw new Error(`${name} must be used within a <Carousel.Root> component`);
}
return getContext<ReturnType<typeof setEmblaContext>>(EMBLA_CAROUSEL_CONTEXT);
}

View File

@@ -0,0 +1,19 @@
import Root from "./carousel.svelte";
import Content from "./carousel-content.svelte";
import Item from "./carousel-item.svelte";
import Previous from "./carousel-previous.svelte";
import Next from "./carousel-next.svelte";
export {
Root,
Content,
Item,
Previous,
Next,
//
Root as Carousel,
Content as CarouselContent,
Item as CarouselItem,
Previous as CarouselPrevious,
Next as CarouselNext,
};

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,10 @@
import Root from "./radio-group.svelte";
import Item from "./radio-group-item.svelte";
export {
Root,
Item,
//
Root as RadioGroup,
Item as RadioGroupItem,
};

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
</script>
<RadioGroupPrimitive.Item
bind:ref
data-slot="radio-group-item"
class={cn(
"border-input text-primary 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 shadow-xs aspect-square size-4 shrink-0 rounded-full border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<div data-slot="radio-group-indicator" class="relative flex items-center justify-center">
{#if checked}
<CircleIcon
class="fill-primary absolute start-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2"
/>
{/if}
</div>
{/snippet}
</RadioGroupPrimitive.Item>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: RadioGroupPrimitive.RootProps = $props();
</script>
<RadioGroupPrimitive.Root
bind:ref
bind:value
data-slot="radio-group"
class={cn("grid gap-3", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { Specification } from '$lib'
import * as Card from '../card'
import { m } from '$lib/paraglide/messages'
import * as Tabs from '../tabs'
import SpecTable from './SpecTable.svelte'
const { specifications }: { specifications: Specification[] } = $props()
let selectedSpec = $state(specifications.at(0)?.title ?? '')
</script>
<Card.Root>
<Card.Header>
{m.specification_title()}
</Card.Header>
<Card.Content>
<Tabs.Root bind:value={selectedSpec}>
<Tabs.List class="w-full">
{#each specifications as spec (spec.title)}
<Tabs.Trigger value={spec.title}>{spec.title}</Tabs.Trigger>
{/each}
</Tabs.List>
{#each specifications as spec (spec.title)}
<Tabs.TabsContent value={spec.title}>
<SpecTable attributes={spec.attributes} />
</Tabs.TabsContent>
{/each}
</Tabs.Root>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,12 @@
<script lang="ts">
import type { SpecificationAttribute } from '$lib'
const { attribute }: { attribute: SpecificationAttribute } = $props()
</script>
<div
class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700"
>
<dt class="text-gray-600 dark:text-gray-400">{attribute.key}</dt>
<dd class="dark:text-gray-200">{attribute.value}</dd>
</div>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import type { SpecificationAttribute } from '$lib'
import type { Snippet } from 'svelte'
import SpecRow from './SpecRow.svelte'
const { attributes }: { attributes: SpecificationAttribute[] } = $props()
</script>
<dl class="space-y-3">
{#each attributes as attribute (attribute.key)}
<SpecRow {attribute} />
{/each}
</dl>

View File

@@ -0,0 +1,3 @@
import SpecCard from './SpecCard.svelte'
export { SpecCard }

View File

@@ -0,0 +1,16 @@
import Root from "./tabs.svelte";
import Content from "./tabs-content.svelte";
import List from "./tabs-list.svelte";
import Trigger from "./tabs-trigger.svelte";
export {
Root,
Content,
List,
Trigger,
//
Root as Tabs,
Content as TabsContent,
List as TabsList,
Trigger as TabsTrigger,
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ContentProps = $props();
</script>
<TabsPrimitive.Content
bind:ref
data-slot="tabs-content"
class={cn("flex-1 outline-none", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ListProps = $props();
</script>
<TabsPrimitive.List
bind:ref
data-slot="tabs-list"
class={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.TriggerProps = $props();
</script>
<TabsPrimitive.Trigger
bind:ref
data-slot="tabs-trigger"
class={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
</script>
<TabsPrimitive.Root
bind:ref
bind:value
data-slot="tabs"
class={cn("flex flex-col gap-2", className)}
{...restProps}
/>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { CapacityProductVariation } from '$lib'
import * as m from '$lib/paraglide/messages'
import { ToggleButton } from '../button'
import { Label } from '../label'
const { variations }: { variations: CapacityProductVariation[] } = $props()
let selectedVariation = $state('')
</script>
<div class="mb-6">
<Label class="mb-3">{m['capacity_variation.title']()}</Label>
<div class="flex flex-row gap-2">
{#each variations as variation (variation.numericValue)}
<ToggleButton
onclick={() => (selectedVariation = variation.name)}
selected={selectedVariation === variation.name}
>
{variation.name}
</ToggleButton>
{/each}
</div>
</div>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { ColorProductVariation } from '$lib'
import * as m from '$lib/paraglide/messages'
import { Label } from '../label'
import ColorButton from './color/ColorButton.svelte'
const { variations }: { variations: ColorProductVariation[] } = $props()
let selectedColor = $state('')
</script>
<div class="mb-6">
<Label class="mb-3">{m['color_variation.title']()}</Label>
<div class="flex flex-row gap-2">
{#each variations as variation (variation.hex)}
<ColorButton
onclick={(input) => (selectedColor = input.name)}
selected={selectedColor === variation.name}
hex={variation.hex}
name={variation.name}
/>
{/each}
</div>
</div>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import type { ConditionEnum, ConditionProductVariation } from '$lib'
import * as RadioGroup from '$lib/components/ui/radio-group'
import { Label } from '$lib/components/ui/label'
import * as Card from '../card'
import { cn } from '$lib/utils'
import Badge from '../badge/badge.svelte'
import { m } from '$lib/paraglide/messages'
const { variations }: { variations: ConditionProductVariation[] } = $props()
const titleMap: Record<ConditionEnum, () => string> = {
excellent: m.condition_variation_excellent,
very_good: m.condition_variation_very_good,
good: m.condition_variation_good,
fair: m.condition_variation_fair,
}
const descriptionMap: Record<ConditionEnum, () => string> = {
excellent: m.condition_variation_description_excellent,
very_good: m.condition_variation_description_very_good,
good: m.condition_variation_description_good,
fair: m.condition_variation_description_fair,
}
let selectedCondition = $state('')
</script>
<div class="mb-6">
<Label class="mb-3">{m.condition_title()}</Label>
<RadioGroup.Root bind:value={selectedCondition}>
{#each variations as variation, i (variation.name)}
<Card.Root
class={cn(
'flex flex-row p-4 items-center cursor-pointer transition-all border-2 shadow-none',
selectedCondition === variation.name
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-950'
: '',
)}
onclick={() => (selectedCondition = variation.name)}
>
<RadioGroup.Item value={variation.name} id={`r${i}`} />
<div>
<div class="flex flex-row gap-4">
<Label class="mb-1" for={`r${i}`}
>{titleMap[variation.name]()}</Label
>
<Badge variant="secondary">{variation.numericValue}%</Badge>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
{descriptionMap[variation.name]()}
</div>
</div>
</Card.Root>
{/each}
</RadioGroup.Root>
</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
export type ColorButtonInput = {
hex: string
name: string
}
const {
onclick,
hex,
name,
selected = $bindable(),
}: {
onclick: (input: ColorButtonInput) => void
hex: string
name: string
selected: boolean
} = $props()
</script>
<button
onclick={() => onclick({ hex, name })}
class:border-blue-600={selected}
class:dark:border-blue-500={selected}
class:ring-2={selected}
class:ring-blue-200={selected}
class:dark:ring-blue-900={selected}
class="cursor-pointer relative w-12 h-12 rounded-full border-2 transition-all border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500"
title={name}
>
<div class="w-full h-full rounded-full" style:background-color={hex}></div>
</button>