feat: added product page
This commit is contained in:
20
src/lib/components/ui/icon/Icon.svelte
Normal file
20
src/lib/components/ui/icon/Icon.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { icons } from '@lucide/svelte'
|
||||
import type { IconKeys } from '.'
|
||||
let {
|
||||
name,
|
||||
class: className,
|
||||
...props
|
||||
}: { name: IconKeys; class?: string } = $props()
|
||||
|
||||
const Icon = $derived.by(() => {
|
||||
if (name in icons) {
|
||||
// @ts-ignore
|
||||
return icons[name]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if Icon}
|
||||
<Icon class={className} {...props} />
|
||||
{/if}
|
||||
5
src/lib/components/ui/icon/index.ts
Normal file
5
src/lib/components/ui/icon/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import Icon from './Icon.svelte'
|
||||
import { icons } from '@lucide/svelte'
|
||||
type IconKeys = keyof typeof icons
|
||||
|
||||
export { Icon, type IconKeys }
|
||||
98
src/lib/components/ui/image-carousel/ImageCarousel.svelte
Normal file
98
src/lib/components/ui/image-carousel/ImageCarousel.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import type { ImageCarouselItem } from '.'
|
||||
import * as Carousel from '../carousel'
|
||||
import ImgCarouselItem from './ImageCarouselItem.svelte'
|
||||
import type { CarouselAPI } from '$lib/components/ui/carousel/context.js'
|
||||
import { Button } from '../button'
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@lucide/svelte'
|
||||
|
||||
const {
|
||||
items,
|
||||
preview = false,
|
||||
buttonPosition = 'default',
|
||||
}: {
|
||||
items: ImageCarouselItem[]
|
||||
preview?: boolean
|
||||
buttonPosition?: 'contained' | 'default' | 'hidden'
|
||||
} = $props()
|
||||
|
||||
let api = $state<CarouselAPI>()
|
||||
let currentImageIndex = $state(0)
|
||||
|
||||
$effect(() => {
|
||||
if (!api) return
|
||||
|
||||
api.on('select', () => {
|
||||
if (!api) return
|
||||
currentImageIndex = api.selectedScrollSnap()
|
||||
})
|
||||
})
|
||||
|
||||
function onPreviewImageClick(toIndex: number) {
|
||||
if (!api) return
|
||||
|
||||
api.scrollTo(toIndex, false)
|
||||
}
|
||||
|
||||
function prevImage() {
|
||||
if (!api) return
|
||||
|
||||
api.scrollPrev()
|
||||
}
|
||||
function nextImage() {
|
||||
if (!api) return
|
||||
|
||||
api.scrollNext()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Carousel.Root setApi={(emblaApi) => (api = emblaApi)}>
|
||||
<Carousel.Content>
|
||||
{#each items as item}
|
||||
<ImgCarouselItem {item} />
|
||||
{/each}
|
||||
</Carousel.Content>
|
||||
{#if buttonPosition === 'default'}
|
||||
<Carousel.Next />
|
||||
{:else if buttonPosition === 'contained'}
|
||||
<Button
|
||||
onclick={prevImage}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="absolute top-1/2 -translate-y-1/2 left-4 rounded-full"
|
||||
><ChevronLeftIcon /></Button
|
||||
>
|
||||
{/if}
|
||||
{#if buttonPosition === 'default'}
|
||||
<Carousel.Previous />
|
||||
{:else if buttonPosition === 'contained'}
|
||||
<Button
|
||||
onclick={nextImage}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="absolute top-1/2 -translate-y-1/2 right-4 rounded-full"
|
||||
><ChevronRightIcon /></Button
|
||||
>
|
||||
{/if}
|
||||
</Carousel.Root>
|
||||
{#if preview}
|
||||
<Carousel.Root
|
||||
opts={{
|
||||
align: 'start',
|
||||
}}
|
||||
class="hidden md:block"
|
||||
>
|
||||
<Carousel.Content>
|
||||
{#each items as item, i}
|
||||
<ImgCarouselItem
|
||||
onclick={() => onPreviewImageClick(i)}
|
||||
{item}
|
||||
class="md:basis-1/3 lg:basis-1/4 mt-4"
|
||||
isSelected={i === currentImageIndex}
|
||||
/>
|
||||
{/each}
|
||||
</Carousel.Content>
|
||||
</Carousel.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils'
|
||||
import type { ImageCarouselItem } from '.'
|
||||
import * as Carousel from '../carousel'
|
||||
|
||||
const {
|
||||
item,
|
||||
class: className,
|
||||
isSelected,
|
||||
onclick,
|
||||
}: {
|
||||
item: ImageCarouselItem
|
||||
class?: string
|
||||
isSelected?: boolean
|
||||
onclick?: () => void
|
||||
} = $props()
|
||||
|
||||
const itemClass = $derived(
|
||||
cn(
|
||||
'object-cover rounded-lg',
|
||||
isSelected
|
||||
? 'border-2 transition-all border-blue-600 dark:border-blue-500'
|
||||
: 'border-2 transition-all border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600',
|
||||
!item.aspect || item.aspect === '1:1' ? 'aspect-square' : '',
|
||||
item.aspect === '16:9' ? 'aspect-video' : '',
|
||||
item.aspect === '9:16' ? 'aspect-9/16' : '',
|
||||
item.aspect === '4:3' ? 'aspect-4/3' : '',
|
||||
),
|
||||
)
|
||||
</script>
|
||||
|
||||
<Carousel.Item {onclick} class={className}>
|
||||
<img src={item.image} alt={item.alt} class={itemClass} />
|
||||
</Carousel.Item>
|
||||
9
src/lib/components/ui/image-carousel/index.ts
Normal file
9
src/lib/components/ui/image-carousel/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import ImageCarousel from './ImageCarousel.svelte'
|
||||
|
||||
type ImageCarouselItem = {
|
||||
image: string
|
||||
alt: string
|
||||
aspect?: '1:1' | '16:9' | '9:16' | '4:3'
|
||||
}
|
||||
|
||||
export { ImageCarousel, type ImageCarouselItem }
|
||||
7
src/lib/components/ui/input/index.ts
Normal file
7
src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
52
src/lib/components/ui/input/input.svelte
Normal file
52
src/lib/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||
|
||||
type Props = WithElementRef<
|
||||
Omit<HTMLInputAttributes, "type"> &
|
||||
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
||||
>;
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
type,
|
||||
files = $bindable(),
|
||||
class: className,
|
||||
"data-slot": dataSlot = "input",
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if type === "file"}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
type="file"
|
||||
bind:files
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{type}
|
||||
bind:value
|
||||
{...restProps}
|
||||
/>
|
||||
{/if}
|
||||
36
src/lib/components/ui/sheet/index.ts
Normal file
36
src/lib/components/ui/sheet/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
import Trigger from "./sheet-trigger.svelte";
|
||||
import Close from "./sheet-close.svelte";
|
||||
import Overlay from "./sheet-overlay.svelte";
|
||||
import Content from "./sheet-content.svelte";
|
||||
import Header from "./sheet-header.svelte";
|
||||
import Footer from "./sheet-footer.svelte";
|
||||
import Title from "./sheet-title.svelte";
|
||||
import Description from "./sheet-description.svelte";
|
||||
|
||||
const Root = SheetPrimitive.Root;
|
||||
const Portal = SheetPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Close,
|
||||
Trigger,
|
||||
Portal,
|
||||
Overlay,
|
||||
Content,
|
||||
Header,
|
||||
Footer,
|
||||
Title,
|
||||
Description,
|
||||
//
|
||||
Root as Sheet,
|
||||
Close as SheetClose,
|
||||
Trigger as SheetTrigger,
|
||||
Portal as SheetPortal,
|
||||
Overlay as SheetOverlay,
|
||||
Content as SheetContent,
|
||||
Header as SheetHeader,
|
||||
Footer as SheetFooter,
|
||||
Title as SheetTitle,
|
||||
Description as SheetDescription,
|
||||
};
|
||||
7
src/lib/components/ui/sheet/sheet-close.svelte
Normal file
7
src/lib/components/ui/sheet/sheet-close.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />
|
||||
58
src/lib/components/ui/sheet/sheet-content.svelte
Normal file
58
src/lib/components/ui/sheet/sheet-content.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
export const sheetVariants = tv({
|
||||
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
variants: {
|
||||
side: {
|
||||
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 start-0 h-full w-3/4 border-e sm:max-w-sm",
|
||||
right: "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 end-0 h-full w-3/4 border-s sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
});
|
||||
|
||||
export type Side = VariantProps<typeof sheetVariants>["side"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
import XIcon from "@lucide/svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import SheetOverlay from "./sheet-overlay.svelte";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
side = "right",
|
||||
portalProps,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
|
||||
portalProps?: SheetPrimitive.PortalProps;
|
||||
side?: Side;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Portal {...portalProps}>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="sheet-content"
|
||||
class={cn(sheetVariants({ side }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<SheetPrimitive.Close
|
||||
class="ring-offset-background focus-visible:ring-ring rounded-xs focus-visible:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none"
|
||||
>
|
||||
<XIcon class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPrimitive.Portal>
|
||||
17
src/lib/components/ui/sheet/sheet-description.svelte
Normal file
17
src/lib/components/ui/sheet/sheet-description.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SheetPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="sheet-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
src/lib/components/ui/sheet/sheet-footer.svelte
Normal file
20
src/lib/components/ui/sheet/sheet-footer.svelte
Normal 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="sheet-footer"
|
||||
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
src/lib/components/ui/sheet/sheet-header.svelte
Normal file
20
src/lib/components/ui/sheet/sheet-header.svelte
Normal 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="sheet-header"
|
||||
class={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
src/lib/components/ui/sheet/sheet-overlay.svelte
Normal file
20
src/lib/components/ui/sheet/sheet-overlay.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SheetPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="sheet-overlay"
|
||||
class={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
17
src/lib/components/ui/sheet/sheet-title.svelte
Normal file
17
src/lib/components/ui/sheet/sheet-title.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: SheetPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="sheet-title"
|
||||
class={cn("text-foreground font-semibold", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
src/lib/components/ui/sheet/sheet-trigger.svelte
Normal file
7
src/lib/components/ui/sheet/sheet-trigger.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />
|
||||
4
src/lib/components/ui/shopList/ShopItemRating.svelte
Normal file
4
src/lib/components/ui/shopList/ShopItemRating.svelte
Normal file
@@ -0,0 +1,4 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
rating
|
||||
@@ -0,0 +1,4 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
shipping cost
|
||||
@@ -0,0 +1,4 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
shipping time
|
||||
4
src/lib/components/ui/shopList/ShopItemWarranty.svelte
Normal file
4
src/lib/components/ui/shopList/ShopItemWarranty.svelte
Normal file
@@ -0,0 +1,4 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
warranty
|
||||
17
src/lib/components/ui/shopList/ShopList.svelte
Normal file
17
src/lib/components/ui/shopList/ShopList.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { T } from '@tolgee/svelte'
|
||||
import * as Card from '../card'
|
||||
import type { TShopListItem } from '.'
|
||||
import ShopListItem from './ShopListItem.svelte'
|
||||
|
||||
const { items }: { items: TShopListItem[] } = $props()
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header><T keyName="shopList.title" /></Card.Header>
|
||||
<Card.Content class="flex flex-col gap-4">
|
||||
{#each items as item (item)}
|
||||
<ShopListItem {item} />
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
57
src/lib/components/ui/shopList/ShopListItem.svelte
Normal file
57
src/lib/components/ui/shopList/ShopListItem.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { T } from '@tolgee/svelte'
|
||||
import * as Card from '../card'
|
||||
import type { TShopListItem } from '.'
|
||||
import { Badge } from '../badge'
|
||||
import { Icon } from '../icon'
|
||||
import ShopItemRating from './ShopItemRating.svelte'
|
||||
import ShopItemWarranty from './ShopItemWarranty.svelte'
|
||||
import ShopItemShippingTime from './ShopItemShippingTime.svelte'
|
||||
import ShopItemShippingCost from './ShopItemShippingCost.svelte'
|
||||
import { Button } from '../button'
|
||||
import { ExternalLinkIcon } from '@lucide/svelte'
|
||||
|
||||
const { item }: { item: TShopListItem } = $props()
|
||||
</script>
|
||||
|
||||
<Card.Root class="py-4">
|
||||
<Card.Content>
|
||||
<div class="flex md:flex-row flex-col justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row">
|
||||
<div class="dark:text-white">
|
||||
{item.name}
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 ml-4">
|
||||
{#each item.badges as badge (badge.name)}
|
||||
<Badge variant={badge.variant}>
|
||||
{#if badge.icon}
|
||||
<Icon class="mr-1" name={badge.icon} />
|
||||
{/if}
|
||||
<T keyName={badge.name} />
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<ShopItemRating />
|
||||
<ShopItemShippingTime />
|
||||
<ShopItemWarranty />
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<ShopItemShippingCost />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-between">
|
||||
<div class="text-2xl dark:text-white text-right">
|
||||
{item.price}
|
||||
{item.currency}
|
||||
</div>
|
||||
<Button
|
||||
><T keyName="ShopList.item.toShop" /> <ExternalLinkIcon /></Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
28
src/lib/components/ui/shopList/index.ts
Normal file
28
src/lib/components/ui/shopList/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { BadgeVariant } from '../badge'
|
||||
import type { IconKeys } from '../icon'
|
||||
import ShopList from './ShopList.svelte'
|
||||
|
||||
type ShopListItemBadge = {
|
||||
name: string
|
||||
icon?: IconKeys
|
||||
variant: BadgeVariant
|
||||
}
|
||||
|
||||
type TShopListItem = {
|
||||
name: string
|
||||
badges: ShopListItemBadge[]
|
||||
price: number
|
||||
currency: string
|
||||
rating: {
|
||||
score: number
|
||||
amount: number
|
||||
}
|
||||
shipping: {
|
||||
shippingTime: string
|
||||
shippingCost: number
|
||||
}
|
||||
warrantyInMonths: number
|
||||
linkUrl: string
|
||||
}
|
||||
|
||||
export { ShopList, type TShopListItem }
|
||||
6
src/lib/components/ui/sidebar/constants.ts
Normal file
6
src/lib/components/ui/sidebar/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const SIDEBAR_COOKIE_NAME = "sidebar:state";
|
||||
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
export const SIDEBAR_WIDTH = "16rem";
|
||||
export const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
export const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
81
src/lib/components/ui/sidebar/context.svelte.ts
Normal file
81
src/lib/components/ui/sidebar/context.svelte.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { IsMobile } from "$lib/hooks/is-mobile.svelte.js";
|
||||
import { getContext, setContext } from "svelte";
|
||||
import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
|
||||
|
||||
type Getter<T> = () => T;
|
||||
|
||||
export type SidebarStateProps = {
|
||||
/**
|
||||
* A getter function that returns the current open state of the sidebar.
|
||||
* We use a getter function here to support `bind:open` on the `Sidebar.Provider`
|
||||
* component.
|
||||
*/
|
||||
open: Getter<boolean>;
|
||||
|
||||
/**
|
||||
* A function that sets the open state of the sidebar. To support `bind:open`, we need
|
||||
* a source of truth for changing the open state to ensure it will be synced throughout
|
||||
* the sub-components and any `bind:` references.
|
||||
*/
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
class SidebarState {
|
||||
readonly props: SidebarStateProps;
|
||||
open = $derived.by(() => this.props.open());
|
||||
openMobile = $state(false);
|
||||
setOpen: SidebarStateProps["setOpen"];
|
||||
#isMobile: IsMobile;
|
||||
state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
|
||||
|
||||
constructor(props: SidebarStateProps) {
|
||||
this.setOpen = props.setOpen;
|
||||
this.#isMobile = new IsMobile();
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
// Convenience getter for checking if the sidebar is mobile
|
||||
// without this, we would need to use `sidebar.isMobile.current` everywhere
|
||||
get isMobile() {
|
||||
return this.#isMobile.current;
|
||||
}
|
||||
|
||||
// Event handler to apply to the `<svelte:window>`
|
||||
handleShortcutKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
this.toggle();
|
||||
}
|
||||
};
|
||||
|
||||
setOpenMobile = (value: boolean) => {
|
||||
this.openMobile = value;
|
||||
};
|
||||
|
||||
toggle = () => {
|
||||
return this.#isMobile.current
|
||||
? (this.openMobile = !this.openMobile)
|
||||
: this.setOpen(!this.open);
|
||||
};
|
||||
}
|
||||
|
||||
const SYMBOL_KEY = "scn-sidebar";
|
||||
|
||||
/**
|
||||
* Instantiates a new `SidebarState` instance and sets it in the context.
|
||||
*
|
||||
* @param props The constructor props for the `SidebarState` class.
|
||||
* @returns The `SidebarState` instance.
|
||||
*/
|
||||
export function setSidebar(props: SidebarStateProps): SidebarState {
|
||||
return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the `SidebarState` instance from the context. This is a class instance,
|
||||
* so you cannot destructure it.
|
||||
* @returns The `SidebarState` instance.
|
||||
*/
|
||||
export function useSidebar(): SidebarState {
|
||||
return getContext(Symbol.for(SYMBOL_KEY));
|
||||
}
|
||||
75
src/lib/components/ui/sidebar/index.ts
Normal file
75
src/lib/components/ui/sidebar/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useSidebar } from "./context.svelte.js";
|
||||
import Content from "./sidebar-content.svelte";
|
||||
import Footer from "./sidebar-footer.svelte";
|
||||
import GroupAction from "./sidebar-group-action.svelte";
|
||||
import GroupContent from "./sidebar-group-content.svelte";
|
||||
import GroupLabel from "./sidebar-group-label.svelte";
|
||||
import Group from "./sidebar-group.svelte";
|
||||
import Header from "./sidebar-header.svelte";
|
||||
import Input from "./sidebar-input.svelte";
|
||||
import Inset from "./sidebar-inset.svelte";
|
||||
import MenuAction from "./sidebar-menu-action.svelte";
|
||||
import MenuBadge from "./sidebar-menu-badge.svelte";
|
||||
import MenuButton from "./sidebar-menu-button.svelte";
|
||||
import MenuItem from "./sidebar-menu-item.svelte";
|
||||
import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
|
||||
import MenuSubButton from "./sidebar-menu-sub-button.svelte";
|
||||
import MenuSubItem from "./sidebar-menu-sub-item.svelte";
|
||||
import MenuSub from "./sidebar-menu-sub.svelte";
|
||||
import Menu from "./sidebar-menu.svelte";
|
||||
import Provider from "./sidebar-provider.svelte";
|
||||
import Rail from "./sidebar-rail.svelte";
|
||||
import Separator from "./sidebar-separator.svelte";
|
||||
import Trigger from "./sidebar-trigger.svelte";
|
||||
import Root from "./sidebar.svelte";
|
||||
|
||||
export {
|
||||
Content,
|
||||
Footer,
|
||||
Group,
|
||||
GroupAction,
|
||||
GroupContent,
|
||||
GroupLabel,
|
||||
Header,
|
||||
Input,
|
||||
Inset,
|
||||
Menu,
|
||||
MenuAction,
|
||||
MenuBadge,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuSkeleton,
|
||||
MenuSub,
|
||||
MenuSubButton,
|
||||
MenuSubItem,
|
||||
Provider,
|
||||
Rail,
|
||||
Root,
|
||||
Separator,
|
||||
//
|
||||
Root as Sidebar,
|
||||
Content as SidebarContent,
|
||||
Footer as SidebarFooter,
|
||||
Group as SidebarGroup,
|
||||
GroupAction as SidebarGroupAction,
|
||||
GroupContent as SidebarGroupContent,
|
||||
GroupLabel as SidebarGroupLabel,
|
||||
Header as SidebarHeader,
|
||||
Input as SidebarInput,
|
||||
Inset as SidebarInset,
|
||||
Menu as SidebarMenu,
|
||||
MenuAction as SidebarMenuAction,
|
||||
MenuBadge as SidebarMenuBadge,
|
||||
MenuButton as SidebarMenuButton,
|
||||
MenuItem as SidebarMenuItem,
|
||||
MenuSkeleton as SidebarMenuSkeleton,
|
||||
MenuSub as SidebarMenuSub,
|
||||
MenuSubButton as SidebarMenuSubButton,
|
||||
MenuSubItem as SidebarMenuSubItem,
|
||||
Provider as SidebarProvider,
|
||||
Rail as SidebarRail,
|
||||
Separator as SidebarSeparator,
|
||||
Trigger as SidebarTrigger,
|
||||
Trigger,
|
||||
useSidebar,
|
||||
};
|
||||
24
src/lib/components/ui/sidebar/sidebar-content.svelte
Normal file
24
src/lib/components/ui/sidebar/sidebar-content.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<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<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
class={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
21
src/lib/components/ui/sidebar/sidebar-footer.svelte
Normal file
21
src/lib/components/ui/sidebar/sidebar-footer.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<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<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
class={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
36
src/lib/components/ui/sidebar/sidebar-group-action.svelte
Normal file
36
src/lib/components/ui/sidebar/sidebar-group-action.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLButtonAttributes> & {
|
||||
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||
} = $props();
|
||||
|
||||
const mergedProps = $derived({
|
||||
class: cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground outline-hidden absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
),
|
||||
"data-slot": "sidebar-group-action",
|
||||
"data-sidebar": "group-action",
|
||||
...restProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button bind:this={ref} {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
21
src/lib/components/ui/sidebar/sidebar-group-content.svelte
Normal file
21
src/lib/components/ui/sidebar/sidebar-group-content.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<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="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
class={cn("w-full text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
34
src/lib/components/ui/sidebar/sidebar-group-label.svelte
Normal file
34
src/lib/components/ui/sidebar/sidebar-group-label.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
children,
|
||||
child,
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
|
||||
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||
} = $props();
|
||||
|
||||
const mergedProps = $derived({
|
||||
class: cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
),
|
||||
"data-slot": "sidebar-group-label",
|
||||
"data-sidebar": "group-label",
|
||||
...restProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div bind:this={ref} {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
21
src/lib/components/ui/sidebar/sidebar-group.svelte
Normal file
21
src/lib/components/ui/sidebar/sidebar-group.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<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<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
class={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
21
src/lib/components/ui/sidebar/sidebar-header.svelte
Normal file
21
src/lib/components/ui/sidebar/sidebar-header.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<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<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
class={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
21
src/lib/components/ui/sidebar/sidebar-input.svelte
Normal file
21
src/lib/components/ui/sidebar/sidebar-input.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { ComponentProps } from "svelte";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
class: className,
|
||||
...restProps
|
||||
}: ComponentProps<typeof Input> = $props();
|
||||
</script>
|
||||
|
||||
<Input
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
class={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
24
src/lib/components/ui/sidebar/sidebar-inset.svelte
Normal file
24
src/lib/components/ui/sidebar/sidebar-inset.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<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<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<main
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-inset"
|
||||
class={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</main>
|
||||
43
src/lib/components/ui/sidebar/sidebar-menu-action.svelte
Normal file
43
src/lib/components/ui/sidebar/sidebar-menu-action.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
showOnHover = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLButtonAttributes> & {
|
||||
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||
showOnHover?: boolean;
|
||||
} = $props();
|
||||
|
||||
const mergedProps = $derived({
|
||||
class: cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground outline-hidden absolute end-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
),
|
||||
"data-slot": "sidebar-menu-action",
|
||||
"data-sidebar": "menu-action",
|
||||
...restProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button bind:this={ref} {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
29
src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
Normal file
29
src/lib/components/ui/sidebar/sidebar-menu-badge.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<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<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
class={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute end-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
103
src/lib/components/ui/sidebar/sidebar-menu-button.svelte
Normal file
103
src/lib/components/ui/sidebar/sidebar-menu-button.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
|
||||
export const sidebarMenuButtonVariants = tv({
|
||||
base: "peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pe-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "group-data-[collapsible=icon]:p-0! h-12 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type SidebarMenuButtonVariant = VariantProps<
|
||||
typeof sidebarMenuButtonVariants
|
||||
>["variant"];
|
||||
export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>["size"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||
import { cn, type WithElementRef, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import { mergeProps } from "bits-ui";
|
||||
import type { ComponentProps, Snippet } from "svelte";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { useSidebar } from "./context.svelte.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
child,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
isActive = false,
|
||||
tooltipContent,
|
||||
tooltipContentProps,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
|
||||
isActive?: boolean;
|
||||
variant?: SidebarMenuButtonVariant;
|
||||
size?: SidebarMenuButtonSize;
|
||||
tooltipContent?: Snippet | string;
|
||||
tooltipContentProps?: WithoutChildrenOrChild<ComponentProps<typeof Tooltip.Content>>;
|
||||
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||
} = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
const buttonProps = $derived({
|
||||
class: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||
"data-slot": "sidebar-menu-button",
|
||||
"data-sidebar": "menu-button",
|
||||
"data-size": size,
|
||||
"data-active": isActive,
|
||||
...restProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet Button({ props }: { props?: Record<string, unknown> })}
|
||||
{@const mergedProps = mergeProps(buttonProps, props)}
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button bind:this={ref} {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if !tooltipContent}
|
||||
{@render Button({})}
|
||||
{:else}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
{@render Button({ props })}
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={sidebar.state !== "collapsed" || sidebar.isMobile}
|
||||
{...tooltipContentProps}
|
||||
>
|
||||
{#if typeof tooltipContent === "string"}
|
||||
{tooltipContent}
|
||||
{:else if tooltipContent}
|
||||
{@render tooltipContent()}
|
||||
{/if}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
21
src/lib/components/ui/sidebar/sidebar-menu-item.svelte
Normal file
21
src/lib/components/ui/sidebar/sidebar-menu-item.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<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<HTMLLIElement>, HTMLLIElement> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
class={cn("group/menu-item relative", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</li>
|
||||
36
src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
Normal file
36
src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
showIcon = false,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
|
||||
showIcon?: boolean;
|
||||
} = $props();
|
||||
|
||||
// Random width between 50% and 90%
|
||||
const width = `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
class={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if showIcon}
|
||||
<Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
|
||||
{/if}
|
||||
<Skeleton
|
||||
class="max-w-(--skeleton-width) h-4 flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style="--skeleton-width: {width};"
|
||||
/>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
43
src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
Normal file
43
src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
children,
|
||||
child,
|
||||
class: className,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
child?: Snippet<[{ props: Record<string, unknown> }]>;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
} = $props();
|
||||
|
||||
const mergedProps = $derived({
|
||||
class: cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground outline-hidden flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
),
|
||||
"data-slot": "sidebar-menu-sub-button",
|
||||
"data-sidebar": "menu-sub-button",
|
||||
"data-size": size,
|
||||
"data-active": isActive,
|
||||
...restProps,
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<a bind:this={ref} {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
||||
21
src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
Normal file
21
src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
children,
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
class={cn("group/menu-sub-item relative", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</li>
|
||||
25
src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
Normal file
25
src/lib/components/ui/sidebar/sidebar-menu-sub.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<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<HTMLUListElement>> = $props();
|
||||
</script>
|
||||
|
||||
<ul
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
class={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-s px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</ul>
|
||||
21
src/lib/components/ui/sidebar/sidebar-menu.svelte
Normal file
21
src/lib/components/ui/sidebar/sidebar-menu.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<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<HTMLUListElement>, HTMLUListElement> = $props();
|
||||
</script>
|
||||
|
||||
<ul
|
||||
bind:this={ref}
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
class={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</ul>
|
||||
53
src/lib/components/ui/sidebar/sidebar-provider.svelte
Normal file
53
src/lib/components/ui/sidebar/sidebar-provider.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import {
|
||||
SIDEBAR_COOKIE_MAX_AGE,
|
||||
SIDEBAR_COOKIE_NAME,
|
||||
SIDEBAR_WIDTH,
|
||||
SIDEBAR_WIDTH_ICON,
|
||||
} from "./constants.js";
|
||||
import { setSidebar } from "./context.svelte.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
open = $bindable(true),
|
||||
onOpenChange = () => {},
|
||||
class: className,
|
||||
style,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
} = $props();
|
||||
|
||||
const sidebar = setSidebar({
|
||||
open: () => open,
|
||||
setOpen: (value: boolean) => {
|
||||
open = value;
|
||||
onOpenChange(value);
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
|
||||
|
||||
<Tooltip.Provider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
|
||||
class={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
bind:this={ref}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
36
src/lib/components/ui/sidebar/sidebar-rail.svelte
Normal file
36
src/lib/components/ui/sidebar/sidebar-rail.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { useSidebar } from "./context.svelte.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
</script>
|
||||
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onclick={sidebar.toggle}
|
||||
title="Toggle Sidebar"
|
||||
class={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:start-[calc(1/2*100%-1px)] after:w-[2px] group-data-[side=left]:-end-4 group-data-[side=right]:start-0 sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:start-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-end-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-start-2",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
19
src/lib/components/ui/sidebar/sidebar-separator.svelte
Normal file
19
src/lib/components/ui/sidebar/sidebar-separator.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from "$lib/components/ui/separator/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: ComponentProps<typeof Separator> = $props();
|
||||
</script>
|
||||
|
||||
<Separator
|
||||
bind:ref
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
class={cn("bg-sidebar-border", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
35
src/lib/components/ui/sidebar/sidebar-trigger.svelte
Normal file
35
src/lib/components/ui/sidebar/sidebar-trigger.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import PanelLeftIcon from "@lucide/svelte/icons/panel-left";
|
||||
import type { ComponentProps } from "svelte";
|
||||
import { useSidebar } from "./context.svelte.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
onclick,
|
||||
...restProps
|
||||
}: ComponentProps<typeof Button> & {
|
||||
onclick?: (e: MouseEvent) => void;
|
||||
} = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
</script>
|
||||
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={cn("size-7", className)}
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
onclick?.(e);
|
||||
sidebar.toggle();
|
||||
}}
|
||||
{...restProps}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span class="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
104
src/lib/components/ui/sidebar/sidebar.svelte
Normal file
104
src/lib/components/ui/sidebar/sidebar.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import * as Sheet from "$lib/components/ui/sheet/index.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { SIDEBAR_WIDTH_MOBILE } from "./constants.js";
|
||||
import { useSidebar } from "./context.svelte.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
} = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
</script>
|
||||
|
||||
{#if collapsible === "none"}
|
||||
<div
|
||||
class={cn(
|
||||
"bg-sidebar text-sidebar-foreground w-(--sidebar-width) flex h-full flex-col",
|
||||
className
|
||||
)}
|
||||
bind:this={ref}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{:else if sidebar.isMobile}
|
||||
<Sheet.Root
|
||||
bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)}
|
||||
{...restProps}
|
||||
>
|
||||
<Sheet.Content
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
|
||||
{side}
|
||||
>
|
||||
<Sheet.Header class="sr-only">
|
||||
<Sheet.Title>Sidebar</Sheet.Title>
|
||||
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
|
||||
</Sheet.Header>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
{:else}
|
||||
<div
|
||||
bind:this={ref}
|
||||
class="text-sidebar-foreground group peer hidden md:block"
|
||||
data-state={sidebar.state}
|
||||
data-collapsible={sidebar.state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
<!-- This is what handles the sidebar gap on desktop -->
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
class={cn(
|
||||
"w-(--sidebar-width) relative bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
class={cn(
|
||||
"w-(--sidebar-width) fixed inset-y-0 z-10 hidden h-svh transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "start-0 group-data-[collapsible=offcanvas]:start-[calc(var(--sidebar-width)*-1)]"
|
||||
: "end-0 group-data-[collapsible=offcanvas]:end-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-e group-data-[side=right]:border-s",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
7
src/lib/components/ui/skeleton/index.ts
Normal file
7
src/lib/components/ui/skeleton/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./skeleton.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Skeleton,
|
||||
};
|
||||
17
src/lib/components/ui/skeleton/skeleton.svelte
Normal file
17
src/lib/components/ui/skeleton/skeleton.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="skeleton"
|
||||
class={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...restProps}
|
||||
></div>
|
||||
@@ -1,9 +1,9 @@
|
||||
<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'
|
||||
import { T } from '@tolgee/svelte'
|
||||
|
||||
const { specifications }: { specifications: Specification[] } = $props()
|
||||
|
||||
@@ -12,13 +12,15 @@
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
{m.specification_title()}
|
||||
<T keyName="specs.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>
|
||||
<Tabs.Trigger value={spec.title}>
|
||||
<T keyName={`specs.tabs.${spec.title}`} />
|
||||
</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
{#each specifications as spec (spec.title)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { SpecificationAttribute } from '$lib'
|
||||
import { T } from '@tolgee/svelte'
|
||||
|
||||
const { attribute }: { attribute: SpecificationAttribute } = $props()
|
||||
</script>
|
||||
@@ -7,6 +8,8 @@
|
||||
<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>
|
||||
<dt class="text-gray-600 dark:text-gray-400">
|
||||
<T keyName={`specs.${attribute.key}`} />
|
||||
</dt>
|
||||
<dd class="dark:text-gray-200">{attribute.value}</dd>
|
||||
</div>
|
||||
|
||||
21
src/lib/components/ui/tooltip/index.ts
Normal file
21
src/lib/components/ui/tooltip/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||
import Trigger from "./tooltip-trigger.svelte";
|
||||
import Content from "./tooltip-content.svelte";
|
||||
|
||||
const Root = TooltipPrimitive.Root;
|
||||
const Provider = TooltipPrimitive.Provider;
|
||||
const Portal = TooltipPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Trigger,
|
||||
Content,
|
||||
Provider,
|
||||
Portal,
|
||||
//
|
||||
Root as Tooltip,
|
||||
Content as TooltipContent,
|
||||
Trigger as TooltipTrigger,
|
||||
Provider as TooltipProvider,
|
||||
Portal as TooltipPortal,
|
||||
};
|
||||
47
src/lib/components/ui/tooltip/tooltip-content.svelte
Normal file
47
src/lib/components/ui/tooltip/tooltip-content.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
sideOffset = 0,
|
||||
side = "top",
|
||||
children,
|
||||
arrowClasses,
|
||||
...restProps
|
||||
}: TooltipPrimitive.ContentProps & {
|
||||
arrowClasses?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="tooltip-content"
|
||||
{sideOffset}
|
||||
{side}
|
||||
class={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<TooltipPrimitive.Arrow>
|
||||
{#snippet child({ props })}
|
||||
<div
|
||||
class={cn(
|
||||
"bg-primary z-50 size-2.5 rotate-45 rounded-[2px]",
|
||||
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]",
|
||||
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]",
|
||||
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2",
|
||||
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
|
||||
arrowClasses
|
||||
)}
|
||||
{...props}
|
||||
></div>
|
||||
{/snippet}
|
||||
</TooltipPrimitive.Arrow>
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
7
src/lib/components/ui/tooltip/tooltip-trigger.svelte
Normal file
7
src/lib/components/ui/tooltip/tooltip-trigger.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
|
||||
10
src/lib/components/ui/topBar/TopBar.svelte
Normal file
10
src/lib/components/ui/topBar/TopBar.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { BarcodeIcon } from '@lucide/svelte'
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 w-full h-20 bg-blue-950 flex justify-center z-50">
|
||||
<div class="max-w-6xl w-full flex flex-row items-center gap-4">
|
||||
<BarcodeIcon size="48" class="text-white" />
|
||||
<span class="text-white text-3xl">ReBuyWise</span>
|
||||
</div>
|
||||
</div>
|
||||
3
src/lib/components/ui/topBar/index.ts
Normal file
3
src/lib/components/ui/topBar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import TopBar from './TopBar.svelte'
|
||||
|
||||
export { TopBar }
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { CapacityProductVariation } from '$lib'
|
||||
import * as m from '$lib/paraglide/messages'
|
||||
import { T } from '@tolgee/svelte'
|
||||
import { ToggleButton } from '../button'
|
||||
import { Label } from '../label'
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
</script>
|
||||
|
||||
<div class="mb-6">
|
||||
<Label class="mb-3">{m['capacity_variation.title']()}</Label>
|
||||
<Label class="mb-3">
|
||||
<T keyName="capacity.title" />
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
{#each variations as variation (variation.numericValue)}
|
||||
<ToggleButton
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ColorProductVariation } from '$lib'
|
||||
import * as m from '$lib/paraglide/messages'
|
||||
import { T } from '@tolgee/svelte'
|
||||
import { Label } from '../label'
|
||||
import ColorButton from './color/ColorButton.svelte'
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
</script>
|
||||
|
||||
<div class="mb-6">
|
||||
<Label class="mb-3">{m['color_variation.title']()}</Label>
|
||||
<Label class="mb-3">
|
||||
<T keyName="color.title" />
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
{#each variations as variation (variation.hex)}
|
||||
<ColorButton
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { ConditionEnum, ConditionProductVariation } from '$lib'
|
||||
import type { 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'
|
||||
import { T } from '@tolgee/svelte'
|
||||
|
||||
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>
|
||||
<Label class="mb-3">
|
||||
<T keyName="condition.title" />
|
||||
</Label>
|
||||
|
||||
<RadioGroup.Root bind:value={selectedCondition}>
|
||||
{#each variations as variation, i (variation.name)}
|
||||
@@ -44,12 +32,12 @@
|
||||
<div>
|
||||
<div class="flex flex-row gap-4">
|
||||
<Label class="mb-1" for={`r${i}`}
|
||||
>{titleMap[variation.name]()}</Label
|
||||
><T keyName={`condition.option.${variation.name}.title`} /></Label
|
||||
>
|
||||
<Badge variant="secondary">{variation.numericValue}%</Badge>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{descriptionMap[variation.name]()}
|
||||
<T keyName={`condition.option.${variation.name}.description`} />
|
||||
</div>
|
||||
</div>
|
||||
</Card.Root>
|
||||
|
||||
9
src/lib/hooks/is-mobile.svelte.ts
Normal file
9
src/lib/hooks/is-mobile.svelte.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { MediaQuery } from "svelte/reactivity";
|
||||
|
||||
const DEFAULT_MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export class IsMobile extends MediaQuery {
|
||||
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
|
||||
super(`max-width: ${breakpoint - 1}px`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user