diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:30 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:26:30 +0800 |
| commit | 06a7b1668b44f111eca5d08bc8c2c9d41d8e617e (patch) | |
| tree | 0c7220a22b866d3e298f756b42f565f4f674152c /frontend/src/lib/components/ui | |
| parent | 95215e84e045602db2370586f6089a7f9f91b33b (diff) | |
| download | strategy-lab-06a7b1668b44f111eca5d08bc8c2c9d41d8e617e.tar.gz | |
chore(frontend): shadcn-svelte / Tailwind / layerchart 工具链初始化
[变更性质]
本轮为维护性依赖与配置初始化,不改变业务页面(页面迁移见后续提交)。
[维护内容]
按 Leader 返工指令,前端 UI 组件库全量迁移 shadcn-svelte,图表渲染
从 echarts 换为 layerchart,需要一次性引入配套依赖与构建配置。
[实现方案]
package.json/pnpm-lock.yaml:tailwindcss@4、@tailwindcss/vite、
tw-animate-css、shadcn-svelte、bits-ui、tailwind-variants、clsx、
tailwind-merge、@lucide/svelte、layerchart、@fontsource-variable/inter
与 @types/node;echarts 保留至页面迁移完成后移除。pnpm-workspace.yaml
声明 allowBuilds(esbuild、@tailwindcss/oxide)。components.json 采用
mira preset,别名 $lib。tsconfig 新增 $lib paths;vite.config.ts 加入
tailwindcss 插件、$lib alias(vitest 需要)与 test 环境配置。
src/lib/components/ 为 shadcn 生成的 UI 组件源码(button/input/label/
card/badge/alert/select/table/tabs/separator/chart),src/lib/utils.ts
为 cn 工具。app.css 引入 tailwind 与 shadcn CSS 变量并调整为 teal
主题(primary #0f766e、radius 0.375rem、双主题 chart-1..5),保留
legacy 选择器(banner/beat/table.data/metric 等),mobileCss.test.ts
依赖其语义断言。
[影响范围]
构建链与主题变量;后续页面提交逐步消费这些组件,npm run check/test/
build 已全绿验证。
Diffstat (limited to 'frontend/src/lib/components/ui')
55 files changed, 1514 insertions, 0 deletions
diff --git a/frontend/src/lib/components/ui/alert/alert-action.svelte b/frontend/src/lib/components/ui/alert/alert-action.svelte new file mode 100644 index 0000000..c60d787 --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert-action.svelte @@ -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="alert-action" + class={cn("absolute top-1.5 right-2", className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/alert/alert-description.svelte b/frontend/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000..c5c6223 --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert-description.svelte @@ -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="alert-description" + class={cn( + "text-muted-foreground text-xs/relaxed text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...restProps} +> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/alert/alert-title.svelte b/frontend/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000..371164e --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert-title.svelte @@ -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="alert-title" + class={cn( + "font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...restProps} +> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/alert/alert.svelte b/frontend/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000..e8f3489 --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,43 @@ +<script lang="ts" module> + import { type VariantProps, tv } from "tailwind-variants"; + + export const alertVariants = tv({ + base: "grid gap-0.5 rounded-lg border px-2 py-1.5 text-left text-xs/relaxed has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-1.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-3.5 group/alert relative w-full", + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + }); + + export type AlertVariant = VariantProps<typeof alertVariants>["variant"]; +</script> + +<script lang="ts"> + import { cn, type WithElementRef } from "$lib/utils.js"; + import type { HTMLAttributes } from "svelte/elements"; + + let { + ref = $bindable(null), + class: className, + variant = "default", + children, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { + variant?: AlertVariant; + } = $props(); +</script> + +<div + bind:this={ref} + data-slot="alert" + role="alert" + class={cn(alertVariants({ variant }), className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/alert/index.ts b/frontend/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000..501249b --- /dev/null +++ b/frontend/src/lib/components/ui/alert/index.ts @@ -0,0 +1,17 @@ +import Action from "./alert-action.svelte"; +import Description from "./alert-description.svelte"; +import Title from "./alert-title.svelte"; +import Root from "./alert.svelte"; +export { alertVariants, type AlertVariant } from "./alert.svelte"; + +export { + Root, + Description, + Title, + Action, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle, + Action as AlertAction, +}; diff --git a/frontend/src/lib/components/ui/badge/badge.svelte b/frontend/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 0000000..0b34af3 --- /dev/null +++ b/frontend/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,49 @@ +<script lang="ts" module> + import { type VariantProps, tv } from "tailwind-variants"; + + export const badgeVariants = tv({ + base: "h-5 gap-1 rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-2.5! group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none", + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20", + outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/20 dark:bg-input/30", + ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + }); + + export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"]; +</script> + +<script lang="ts"> + import { cn, type WithElementRef } from "$lib/utils.js"; + import type { HTMLAnchorAttributes } from "svelte/elements"; + + 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> diff --git a/frontend/src/lib/components/ui/badge/index.ts b/frontend/src/lib/components/ui/badge/index.ts new file mode 100644 index 0000000..64e0aa9 --- /dev/null +++ b/frontend/src/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from "./badge.svelte"; +export { badgeVariants, type BadgeVariant } from "./badge.svelte"; diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte new file mode 100644 index 0000000..7669a5e --- /dev/null +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ +<script lang="ts" module> + import { type VariantProps, tv } from "tailwind-variants"; + import { cn, type WithElementRef } from "$lib/utils.js"; + import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements"; + + export const buttonVariants = tv({ + base: "focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium focus-visible:ring-2 aria-invalid:ring-2 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: "border-border dark:bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground", + destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5", + sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4", + icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5", + "icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5", + "icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3", + "icon-lg": "size-8 [&_svg:not([class*='size-'])]:size-4", + }, + }, + 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} diff --git a/frontend/src/lib/components/ui/button/index.ts b/frontend/src/lib/components/ui/button/index.ts new file mode 100644 index 0000000..fb585d7 --- /dev/null +++ b/frontend/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/frontend/src/lib/components/ui/card/card-action.svelte b/frontend/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 0000000..53b3861 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-action.svelte @@ -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-action" + class={cn( + "col-start-2 row-span-2 row-start-1 self-start justify-self-end", + className + )} + {...restProps} +> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/card/card-content.svelte b/frontend/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..bf5dac7 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-content.svelte @@ -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-content" + class={cn("px-(--card-spacing)", className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/card/card-description.svelte b/frontend/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..5d5c09a --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-description.svelte @@ -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<HTMLParagraphElement>> = $props(); +</script> + +<p + bind:this={ref} + data-slot="card-description" + class={cn("text-muted-foreground text-xs/relaxed", className)} + {...restProps} +> + {@render children?.()} +</p> diff --git a/frontend/src/lib/components/ui/card/card-footer.svelte b/frontend/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..ce4a2c0 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-footer.svelte @@ -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("rounded-b-lg px-(--card-spacing) [.border-t]:pt-(--card-spacing) flex items-center", className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..019a3f9 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-header.svelte @@ -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( + "gap-1 rounded-t-lg px-(--card-spacing) [.border-b]:pb-(--card-spacing) group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]", + className + )} + {...restProps} +> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/card/card-title.svelte b/frontend/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..d95ec39 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card-title.svelte @@ -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-title" + class={cn("text-sm font-medium", className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..345eaf2 --- /dev/null +++ b/frontend/src/lib/components/ui/card/card.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import { cn, type WithElementRef } from "$lib/utils.js"; + import type { HTMLAttributes } from "svelte/elements"; + + let { + ref = $bindable(null), + class: className, + children, + size = "default", + ...restProps + }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { size?: "default" | "sm" } = $props(); +</script> + +<div + bind:this={ref} + data-slot="card" + data-size={size} + class={cn("ring-foreground/10 bg-card text-card-foreground gap-(--card-spacing) overflow-hidden rounded-lg py-(--card-spacing) text-xs/relaxed ring-1 [--card-spacing:--spacing(4)] has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg group/card flex flex-col", className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/card/index.ts b/frontend/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..5705f13 --- /dev/null +++ b/frontend/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Action from "./card-action.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 Root from "./card.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, +}; diff --git a/frontend/src/lib/components/ui/chart/chart-container.svelte b/frontend/src/lib/components/ui/chart/chart-container.svelte new file mode 100644 index 0000000..3687ea0 --- /dev/null +++ b/frontend/src/lib/components/ui/chart/chart-container.svelte @@ -0,0 +1,80 @@ +<script lang="ts"> + import { cn, type WithElementRef } from "$lib/utils.js"; + import ChartStyle from "./chart-style.svelte"; + import { setChartContext, type ChartConfig } from "./chart-utils.js"; + import type { HTMLAttributes } from "svelte/elements"; + + const uid = $props.id(); + + let { + ref = $bindable(null), + id = uid, + class: className, + children, + config, + ...restProps + }: WithElementRef<HTMLAttributes<HTMLElement>> & { + config: ChartConfig; + } = $props(); + + const chartId = $derived(`chart-${id || uid.replace(/:/g, "")}`); + + setChartContext({ + get config() { + return config; + }, + }); +</script> + +<div + bind:this={ref} + data-chart={chartId} + data-slot="chart" + class={cn( + "flex aspect-video justify-center overflow-visible text-xs", + // Overrides + // + // Stroke around dots/marks when hovering + "[&_.lc-highlight-point]:stroke-transparent", + // override the default stroke color of lines + "[&_.lc-line]:stroke-border/50", + + // by default, layerchart shows a line intersecting the point when hovering, this hides that + "[&_.lc-highlight-line]:stroke-0", + + // by default, when you hover a point on a stacked series chart, it will drop the opacity + // of the other series, this overrides that + "[&_.lc-area-path]:opacity-100 [&_.lc-highlight-line]:opacity-100 [&_.lc-highlight-point]:opacity-100 [&_.lc-spline-path]:opacity-100 [&_.lc-text]:text-xs [&_.lc-text-svg]:overflow-visible", + + // We don't want the little tick lines between the axis labels and the chart, so we remove + // the stroke. The alternative is to manually disable `tickMarks` on the x/y axis of every + // chart. + "[&_.lc-axis-tick]:stroke-0", + + // We don't want to display the rule on the x/y axis, as there is already going to be + // a grid line there and rule ends up overlapping the marks because it is rendered after + // the marks + "[&_.lc-rule-x-line:not(.lc-grid-x-rule)]:stroke-0 [&_.lc-rule-y-line:not(.lc-grid-y-rule)]:stroke-0", + "[&_.lc-grid-x-radial-circle]:stroke-border [&_.lc-grid-x-radial-line]:stroke-border", + "[&_.lc-grid-y-radial-circle]:stroke-border [&_.lc-grid-y-radial-line]:stroke-border", + + // Legend adjustments + "[&_.lc-legend-swatch-button]:items-center [&_.lc-legend-swatch-button]:gap-1.5", + "[&_.lc-legend-swatch-group]:items-center [&_.lc-legend-swatch-group]:gap-4", + "[&_.lc-legend-swatch]:size-2.5 [&_.lc-legend-swatch]:rounded-[2px]", + + // Labels + "[&_.lc-labels-text:not([fill])]:fill-foreground [&_text]:stroke-transparent", + + // Tick labels on th x/y axes + "[&_.lc-axis-tick-label]:fill-muted-foreground [&_.lc-axis-tick-label]:font-normal", + "[&_.lc-tooltip-rects-g]:fill-transparent", + "[&_.lc-layout-svg-g]:fill-transparent", + "[&_.lc-root-container]:w-full", + className + )} + {...restProps} +> + <ChartStyle id={chartId} {config} /> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/chart/chart-style.svelte b/frontend/src/lib/components/ui/chart/chart-style.svelte new file mode 100644 index 0000000..74c2b8a --- /dev/null +++ b/frontend/src/lib/components/ui/chart/chart-style.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { THEMES, type ChartConfig } from "./chart-utils.js"; + + let { id, config }: { id: string; config: ChartConfig } = $props(); + + const colorConfig = $derived( + config ? Object.entries(config).filter(([, config]) => config.theme || config.color) : null + ); + + const themeContents = $derived.by(() => { + if (!colorConfig || !colorConfig.length) return; + + const themeContents = []; + for (const [_theme, prefix] of Object.entries(THEMES)) { + let content = `${prefix} [data-chart=${id}] {\n`; + const color = colorConfig.map(([key, itemConfig]) => { + const theme = _theme as keyof typeof itemConfig.theme; + const color = itemConfig.theme?.[theme] || itemConfig.color; + return color ? `\t--color-${key}: ${color};` : null; + }); + + content += color.join("\n") +"\n}"; + + themeContents.push(content); + } + + return themeContents.join("\n"); + }); +</script> + +{#if themeContents} + {#key id} + <svelte:element this={"style"}> + {themeContents} + </svelte:element> + {/key} +{/if} diff --git a/frontend/src/lib/components/ui/chart/chart-tooltip.svelte b/frontend/src/lib/components/ui/chart/chart-tooltip.svelte new file mode 100644 index 0000000..8811436 --- /dev/null +++ b/frontend/src/lib/components/ui/chart/chart-tooltip.svelte @@ -0,0 +1,180 @@ +<script lang="ts"> + import { getChartContext, Tooltip as TooltipPrimitive } from "layerchart"; + import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js"; + import { getPayloadConfigFromPayload, useChart, type TooltipPayload } from "./chart-utils.js"; + import type { Snippet } from "svelte"; + import type { HTMLAttributes } from "svelte/elements"; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function defaultFormatter(value: any, _payload: TooltipPayload[]) { + return `${value}`; + } + + let { + ref = $bindable(null), + class: className, + hideLabel = false, + indicator = "dot", + hideIndicator = false, + labelKey, + label, + labelFormatter = defaultFormatter, + labelClassName, + formatter, + nameKey, + color, + ...restProps + }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> & { + hideLabel?: boolean; + label?: string; + indicator?: "line" | "dot" | "dashed"; + nameKey?: string; + labelKey?: string; + hideIndicator?: boolean; + labelClassName?: string; + labelFormatter?: // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((value: any, payload: TooltipPayload[]) => string | number | Snippet) | null; + formatter?: Snippet< + [ + { + value: unknown; + name: string; + item: TooltipPayload; + index: number; + payload: TooltipPayload[]; + }, + ] + >; + } = $props(); + + const chart = useChart(); + const chartCtx = getChartContext(); + + // Filter to series with defined values (important for item-based charts like Pie/Arc + // where only the hovered item has a value) + const visibleSeries = $derived( + chartCtx.tooltip.series.filter((s: TooltipPayload) => s.value !== undefined) + ); + + const formattedLabel = $derived.by(() => { + if (hideLabel || !visibleSeries?.length) return null; + + const [item] = visibleSeries; + const tooltipData = chartCtx.tooltip.data; + + // Get the x-axis label value from the raw tooltip data (e.g. a Date or month string) + const dataLabel = tooltipData != null ? chartCtx.x(tooltipData) : undefined; + + const key = labelKey ?? item?.label ?? item?.key ?? "value"; + const itemConfig = getPayloadConfigFromPayload( + chart.config, + item, + key, + tooltipData as Record<string, unknown> | null + ); + + let value: unknown; + if (!labelKey && typeof label === "string") { + value = chart.config[label as keyof typeof chart.config]?.label ?? label; + } else if (labelKey) { + value = itemConfig?.label ?? dataLabel; + } else { + value = dataLabel; + } + + if (value === undefined) return null; + if (!labelFormatter) return value; + return labelFormatter(value, visibleSeries); + }); + + const nestLabel = $derived(visibleSeries.length === 1 && indicator !== "dot"); +</script> + +{#snippet TooltipLabel()} + {#if formattedLabel} + <div class={cn("font-medium", labelClassName)}> + {#if typeof formattedLabel === "function"} + {@render formattedLabel()} + {:else} + {formattedLabel} + {/if} + </div> + {/if} +{/snippet} + +<TooltipPrimitive.Root variant="none"> + <div + bind:this={ref} + class={cn( + "grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", + className + )} + {...restProps} + > + {#if !nestLabel} + {@render TooltipLabel()} + {/if} + <div class="grid gap-1.5"> + {#each visibleSeries as item, i (item.key + i)} + {@const key = `${nameKey || item.key || item.label || "value"}`} + {@const itemConfig = getPayloadConfigFromPayload( + chart.config, + item, + key, + chartCtx.tooltip.data + )} + {@const indicatorColor = color || item.config?.color || item.color} + <div + class={cn( + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + indicator === "dot" && "items-center" + )} + > + {#if formatter && item.value !== undefined && item.label} + {@render formatter({ + value: item.value, + name: item.label, + item, + index: i, + payload: visibleSeries, + })} + {:else} + {#if itemConfig?.icon} + <itemConfig.icon /> + {:else if !hideIndicator} + <div + style="--color-bg: {indicatorColor}; --color-border: {indicatorColor};" + class={cn("shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)", { + "h-2.5 w-2.5": indicator === "dot", + "h-full w-1": indicator === "line", + "w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed", + "my-0.5": nestLabel && indicator === "dashed", + })} + ></div> + {/if} + <div + class={cn( + "flex flex-1 shrink-0 justify-between leading-none", + nestLabel ? "items-end" : "items-center" + )} + > + <div class="grid gap-1.5"> + {#if nestLabel} + {@render TooltipLabel()} + {/if} + <span class="text-muted-foreground"> + {itemConfig?.label || item.label} + </span> + </div> + {#if item.value !== undefined} + <span class="font-mono font-medium text-foreground tabular-nums"> + {item.value.toLocaleString()} + </span> + {/if} + </div> + {/if} + </div> + {/each} + </div> + </div> +</TooltipPrimitive.Root> diff --git a/frontend/src/lib/components/ui/chart/chart-utils.ts b/frontend/src/lib/components/ui/chart/chart-utils.ts new file mode 100644 index 0000000..6e1f000 --- /dev/null +++ b/frontend/src/lib/components/ui/chart/chart-utils.ts @@ -0,0 +1,68 @@ +import { getContext, setContext, type Component, type Snippet } from "svelte"; +import type { Tooltip } from "layerchart"; + +export const THEMES = { light: "", dark: ".dark" } as const; + +export type ChartConfig = { + [k in string]: { + label?: string; + icon?: Component; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<keyof typeof THEMES, string> } + ); +}; + +export type ExtractSnippetParams<T> = T extends Snippet<[infer P]> ? P : never; + +export type TooltipPayload = Tooltip.TooltipSeries; + +// Helper to extract item config from a payload. +export function getPayloadConfigFromPayload( + config: ChartConfig, + payload: TooltipPayload, + key: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data?: Record<string, any> | null +) { + if (typeof payload !== "object" || payload === null) return undefined; + + const payloadConfig = + "config" in payload && typeof payload.config === "object" && payload.config !== null + ? payload.config + : undefined; + + let configLabelKey: string = key; + + if (payload.key === key) { + configLabelKey = payload.key; + } else if (payload.label === key) { + configLabelKey = payload.label; + } else if (key in payload && typeof payload[key as keyof typeof payload] === "string") { + configLabelKey = payload[key as keyof typeof payload] as string; + } else if ( + payloadConfig !== undefined && + key in payloadConfig && + typeof payloadConfig[key as keyof typeof payloadConfig] === "string" + ) { + configLabelKey = payloadConfig[key as keyof typeof payloadConfig] as string; + } else if (data != null && key in data && typeof data[key] === "string") { + configLabelKey = data[key] as string; + } + + return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]; +} + +type ChartContextValue = { + config: ChartConfig; +}; + +const chartContextKey = Symbol("chart-context"); + +export function setChartContext(value: ChartContextValue) { + return setContext(chartContextKey, value); +} + +export function useChart() { + return getContext<ChartContextValue>(chartContextKey); +} diff --git a/frontend/src/lib/components/ui/chart/index.ts b/frontend/src/lib/components/ui/chart/index.ts new file mode 100644 index 0000000..f22375e --- /dev/null +++ b/frontend/src/lib/components/ui/chart/index.ts @@ -0,0 +1,6 @@ +import ChartContainer from "./chart-container.svelte"; +import ChartTooltip from "./chart-tooltip.svelte"; + +export { getPayloadConfigFromPayload, type ChartConfig } from "./chart-utils.js"; + +export { ChartContainer, ChartTooltip, ChartContainer as Container, ChartTooltip as Tooltip }; diff --git a/frontend/src/lib/components/ui/input/index.ts b/frontend/src/lib/components/ui/input/index.ts new file mode 100644 index 0000000..f47b6d3 --- /dev/null +++ b/frontend/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from "./input.svelte"; + +export { + Root, + // + Root as Input, +}; diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte new file mode 100644 index 0000000..549c6b8 --- /dev/null +++ b/frontend/src/lib/components/ui/input/input.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + import { cn, type WithElementRef } from "$lib/utils.js"; + import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements"; + + 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( + "bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-2 aria-invalid:ring-2 md:text-xs/relaxed w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", + className + )} + type="file" + bind:files + bind:value + {...restProps} + /> +{:else} + <input + bind:this={ref} + data-slot={dataSlot} + class={cn( + "bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm transition-colors file:h-6 file:text-xs/relaxed file:font-medium focus-visible:ring-2 aria-invalid:ring-2 md:text-xs/relaxed w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50", + className + )} + {type} + bind:value + {...restProps} + /> +{/if} diff --git a/frontend/src/lib/components/ui/label/index.ts b/frontend/src/lib/components/ui/label/index.ts new file mode 100644 index 0000000..8bfca0b --- /dev/null +++ b/frontend/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from "./label.svelte"; + +export { + Root, + // + Root as Label, +}; diff --git a/frontend/src/lib/components/ui/label/label.svelte b/frontend/src/lib/components/ui/label/label.svelte new file mode 100644 index 0000000..f1e8c35 --- /dev/null +++ b/frontend/src/lib/components/ui/label/label.svelte @@ -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( + "gap-2 text-xs/relaxed leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed", + className + )} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/select/index.ts b/frontend/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..4960d45 --- /dev/null +++ b/frontend/src/lib/components/ui/select/index.ts @@ -0,0 +1,40 @@ +import Content from "./select-content.svelte"; +import GroupHeading from "./select-group-heading.svelte"; +import Group from "./select-group.svelte"; +import Item from "./select-item.svelte"; +import Label from "./select-label.svelte"; +import Portal from "./select-portal.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import Separator from "./select-separator.svelte"; +import Trigger from "./select-trigger.svelte"; +import Value from "./select-value.svelte"; +import Root from "./select.svelte"; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + Portal, + Value, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, + Portal as SelectPortal, + Value as SelectValue, +}; diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..3ec8408 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,45 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from "bits-ui"; + import { cn, type WithoutChild } from "$lib/utils.js"; + import type { WithoutChildrenOrChild } from "$lib/utils.js"; + import SelectPortal from "./select-portal.svelte"; + import SelectScrollDownButton from "./select-scroll-down-button.svelte"; + import SelectScrollUpButton from "./select-scroll-up-button.svelte"; + import type { ComponentProps } from "svelte"; + + let { + ref = $bindable(null), + class: className, + sideOffset = 4, + portalProps, + children, + preventScroll = true, + ...restProps + }: WithoutChild<SelectPrimitive.ContentProps> & { + portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>; + } = $props(); +</script> + +<SelectPortal {...portalProps}> + <SelectPrimitive.Content + bind:ref + {sideOffset} + {preventScroll} + data-slot="select-content" + class={cn( + "bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-32 rounded-lg shadow-md ring-1 duration-100 relative z-50 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto", + className + )} + {...restProps} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + class={cn( + "h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1" + )} + > + {@render children?.()} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> +</SelectPortal> diff --git a/frontend/src/lib/components/ui/select/select-group-heading.svelte b/frontend/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..1fab5f0 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from "bits-ui"; + import { cn } from "$lib/utils.js"; + import type { ComponentProps } from "svelte"; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props(); +</script> + +<SelectPrimitive.GroupHeading + bind:ref + data-slot="select-group-heading" + class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} + {...restProps} +> + {@render children?.()} +</SelectPrimitive.GroupHeading> diff --git a/frontend/src/lib/components/ui/select/select-group.svelte b/frontend/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..f666cb2 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from "bits-ui"; + import { cn } from "$lib/utils.js"; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: SelectPrimitive.GroupProps = $props(); +</script> + +<SelectPrimitive.Group + bind:ref + data-slot="select-group" + class={cn("scroll-my-1 p-1", className)} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/select/select-item.svelte b/frontend/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..446dfdc --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from "bits-ui"; + import CheckIcon from '@lucide/svelte/icons/check'; + import { cn, type WithoutChild } from "$lib/utils.js"; + + let { + ref = $bindable(null), + class: className, + value, + label, + children: childrenProp, + ...restProps + }: WithoutChild<SelectPrimitive.ItemProps> = $props(); +</script> + +<SelectPrimitive.Item + bind:ref + {value} + {label} + data-slot="select-item" + class={cn( + "focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground min-h-7 gap-2 rounded-md px-2 py-1 text-xs/relaxed [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "data-highlighted:bg-accent data-highlighted:text-accent-foreground", + className + )} + {...restProps} +> + {#snippet children({ selected, highlighted })} + <span class="pointer-events-none absolute right-2 flex items-center justify-center"> + {#if selected} + <CheckIcon class="pointer-events-none" /> + {/if} + </span> + <span class="flex flex-1 gap-2 shrink-0 whitespace-nowrap"> + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + </span> + {/snippet} +</SelectPrimitive.Item> diff --git a/frontend/src/lib/components/ui/select/select-label.svelte b/frontend/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..4696025 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-label.svelte @@ -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="select-label" + class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} + {...restProps} +> + {@render children?.()} +</div> diff --git a/frontend/src/lib/components/ui/select/select-portal.svelte b/frontend/src/lib/components/ui/select/select-portal.svelte new file mode 100644 index 0000000..424bcdd --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-portal.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from "bits-ui"; + + let { ...restProps }: SelectPrimitive.PortalProps = $props(); +</script> + +<SelectPrimitive.Portal {...restProps} /> diff --git a/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..7bc111e --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from "bits-ui"; + import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; + import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props(); +</script> + +<SelectPrimitive.ScrollDownButton + bind:ref + data-slot="select-scroll-down-button" + class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-3.5", className)} + {...restProps} +> + <ChevronDownIcon /> +</SelectPrimitive.ScrollDownButton> diff --git a/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..6c066ff --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from "bits-ui"; + import ChevronUpIcon from '@lucide/svelte/icons/chevron-up'; + import { cn, type WithoutChildrenOrChild } from "$lib/utils.js"; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props(); +</script> + +<SelectPrimitive.ScrollUpButton + bind:ref + data-slot="select-scroll-up-button" + class={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-3.5", className)} + {...restProps} +> + <ChevronUpIcon /> +</SelectPrimitive.ScrollUpButton> diff --git a/frontend/src/lib/components/ui/select/select-separator.svelte b/frontend/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..aac5ca3 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + import { Separator } from "$lib/components/ui/separator/index.js"; + import { cn } from "$lib/utils.js"; + import type { Separator as SeparatorPrimitive } from "bits-ui"; + + let { + ref = $bindable(null), + class: className, + ...restProps + }: SeparatorPrimitive.RootProps = $props(); +</script> + +<Separator + bind:ref + data-slot="select-separator" + class={cn("bg-border/50 -mx-1 my-1 h-px pointer-events-none", className)} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..3acff3b --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from "bits-ui"; + import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; + import { cn, type WithoutChild } from "$lib/utils.js"; + + let { + ref = $bindable(null), + class: className, + children, + size = "default", + ...restProps + }: WithoutChild<SelectPrimitive.TriggerProps> & { + size?: "sm" | "default"; + } = $props(); +</script> + +<SelectPrimitive.Trigger + bind:ref + data-slot="select-trigger" + data-size={size} + class={cn( + "border-input data-placeholder:text-muted-foreground bg-input/20 dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-md border px-2 py-1.5 text-xs/relaxed transition-colors focus-visible:ring-2 aria-invalid:ring-2 data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:flex *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-3.5 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + className + )} + {...restProps} +> + {@render children?.()} + <ChevronDownIcon class="text-muted-foreground size-3.5 pointer-events-none" /> +</SelectPrimitive.Trigger> diff --git a/frontend/src/lib/components/ui/select/select-value.svelte b/frontend/src/lib/components/ui/select/select-value.svelte new file mode 100644 index 0000000..ff34b0e --- /dev/null +++ b/frontend/src/lib/components/ui/select/select-value.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from "bits-ui"; + + let { ref = $bindable(null), ...restProps }: SelectPrimitive.ValueProps = $props(); +</script> + +<SelectPrimitive.Value bind:ref data-slot="select-value" {...restProps} /> diff --git a/frontend/src/lib/components/ui/select/select.svelte b/frontend/src/lib/components/ui/select/select.svelte new file mode 100644 index 0000000..05eb663 --- /dev/null +++ b/frontend/src/lib/components/ui/select/select.svelte @@ -0,0 +1,11 @@ +<script lang="ts"> + import { Select as SelectPrimitive } from "bits-ui"; + + let { + open = $bindable(false), + value = $bindable(), + ...restProps + }: SelectPrimitive.RootProps = $props(); +</script> + +<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} /> diff --git a/frontend/src/lib/components/ui/separator/index.ts b/frontend/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/frontend/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/frontend/src/lib/components/ui/separator/separator.svelte b/frontend/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..e91b176 --- /dev/null +++ b/frontend/src/lib/components/ui/separator/separator.svelte @@ -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( + "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", + className + )} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/table/index.ts b/frontend/src/lib/components/ui/table/index.ts new file mode 100644 index 0000000..841ee58 --- /dev/null +++ b/frontend/src/lib/components/ui/table/index.ts @@ -0,0 +1,28 @@ +import Body from "./table-body.svelte"; +import Caption from "./table-caption.svelte"; +import Cell from "./table-cell.svelte"; +import Footer from "./table-footer.svelte"; +import Head from "./table-head.svelte"; +import Header from "./table-header.svelte"; +import Row from "./table-row.svelte"; +import Root from "./table.svelte"; + +export { + Root, + Body, + Caption, + Cell, + Footer, + Head, + Header, + Row, + // + Root as Table, + Body as TableBody, + Caption as TableCaption, + Cell as TableCell, + Footer as TableFooter, + Head as TableHead, + Header as TableHeader, + Row as TableRow, +}; diff --git a/frontend/src/lib/components/ui/table/table-body.svelte b/frontend/src/lib/components/ui/table/table-body.svelte new file mode 100644 index 0000000..935feae --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-body.svelte @@ -0,0 +1,15 @@ +<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<HTMLTableSectionElement>> = $props(); +</script> + +<tbody bind:this={ref} data-slot="table-body" class={cn("[&_tr:last-child]:border-0", className)} {...restProps}> + {@render children?.()} +</tbody> diff --git a/frontend/src/lib/components/ui/table/table-caption.svelte b/frontend/src/lib/components/ui/table/table-caption.svelte new file mode 100644 index 0000000..b7a3e98 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-caption.svelte @@ -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<HTMLElement>> = $props(); +</script> + +<caption + bind:this={ref} + data-slot="table-caption" + class={cn("text-muted-foreground mt-4 text-xs", className)} + {...restProps} +> + {@render children?.()} +</caption> diff --git a/frontend/src/lib/components/ui/table/table-cell.svelte b/frontend/src/lib/components/ui/table/table-cell.svelte new file mode 100644 index 0000000..a998bf6 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-cell.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + import { cn, type WithElementRef } from "$lib/utils.js"; + import type { HTMLTdAttributes } from "svelte/elements"; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLTdAttributes> = $props(); +</script> + +<td bind:this={ref} data-slot="table-cell" class={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}> + {@render children?.()} +</td> diff --git a/frontend/src/lib/components/ui/table/table-footer.svelte b/frontend/src/lib/components/ui/table/table-footer.svelte new file mode 100644 index 0000000..b9b14eb --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-footer.svelte @@ -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<HTMLTableSectionElement>> = $props(); +</script> + +<tfoot + bind:this={ref} + data-slot="table-footer" + class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} +</tfoot> diff --git a/frontend/src/lib/components/ui/table/table-head.svelte b/frontend/src/lib/components/ui/table/table-head.svelte new file mode 100644 index 0000000..267c4e0 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-head.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + import { cn, type WithElementRef } from "$lib/utils.js"; + import type { HTMLThAttributes } from "svelte/elements"; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLThAttributes> = $props(); +</script> + +<th bind:this={ref} data-slot="table-head" class={cn("text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)} {...restProps}> + {@render children?.()} +</th> diff --git a/frontend/src/lib/components/ui/table/table-header.svelte b/frontend/src/lib/components/ui/table/table-header.svelte new file mode 100644 index 0000000..f47d259 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-header.svelte @@ -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<HTMLTableSectionElement>> = $props(); +</script> + +<thead + bind:this={ref} + data-slot="table-header" + class={cn("[&_tr]:border-b", className)} + {...restProps} +> + {@render children?.()} +</thead> diff --git a/frontend/src/lib/components/ui/table/table-row.svelte b/frontend/src/lib/components/ui/table/table-row.svelte new file mode 100644 index 0000000..c7b05c8 --- /dev/null +++ b/frontend/src/lib/components/ui/table/table-row.svelte @@ -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<HTMLTableRowElement>> = $props(); +</script> + +<tr + bind:this={ref} + data-slot="table-row" + class={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors has-aria-expanded:bg-muted/50", className)} + {...restProps} +> + {@render children?.()} +</tr> diff --git a/frontend/src/lib/components/ui/table/table.svelte b/frontend/src/lib/components/ui/table/table.svelte new file mode 100644 index 0000000..b2b477f --- /dev/null +++ b/frontend/src/lib/components/ui/table/table.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { cn, type WithElementRef } from "$lib/utils.js"; + import type { HTMLTableAttributes } from "svelte/elements"; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef<HTMLTableAttributes> = $props(); +</script> + +<div data-slot="table-container" class="relative w-full overflow-x-auto"> + <table bind:this={ref} data-slot="table" class={cn("w-full caption-bottom text-xs", className)} {...restProps}> + {@render children?.()} + </table> +</div> diff --git a/frontend/src/lib/components/ui/tabs/index.ts b/frontend/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..581b188 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,18 @@ +import Content from "./tabs-content.svelte"; +import Trigger from "./tabs-trigger.svelte"; +import Root from "./tabs.svelte"; +import List, { tabsListVariants, type TabsListVariant } from "./tabs-list.svelte"; + +export { + Root, + Content, + List, + Trigger, + tabsListVariants, + type TabsListVariant, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/frontend/src/lib/components/ui/tabs/tabs-content.svelte b/frontend/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..b9e4530 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-content.svelte @@ -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("text-xs/relaxed flex-1 outline-none", className)} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/tabs/tabs-list.svelte b/frontend/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..4445c0f --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,40 @@ +<script lang="ts" module> + import { tv, type VariantProps } from "tailwind-variants"; + + export const tabsListVariants = tv({ + base: "rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col", + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + }); + + export type TabsListVariant = VariantProps<typeof tabsListVariants>["variant"]; +</script> + +<script lang="ts"> + import { Tabs as TabsPrimitive } from "bits-ui"; + import { cn } from "$lib/utils.js"; + + let { + ref = $bindable(null), + variant = "default", + class: className, + ...restProps + }: TabsPrimitive.ListProps & { + variant?: TabsListVariant; + } = $props(); +</script> + +<TabsPrimitive.List + bind:ref + data-slot="tabs-list" + data-variant={variant} + class={cn(tabsListVariants({ variant }), className)} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..c243f83 --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,23 @@ +<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( + "gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-xs font-medium group-data-vertical/tabs:py-[calc(--spacing(1.25))] [&_svg:not([class*='size-'])]:size-3.5 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 relative inline-flex h-[calc(100%_-_1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0", + "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", + "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground", + "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", + className + )} + {...restProps} +/> diff --git a/frontend/src/lib/components/ui/tabs/tabs.svelte b/frontend/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000..a7b461a --- /dev/null +++ b/frontend/src/lib/components/ui/tabs/tabs.svelte @@ -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("gap-2 group/tabs flex data-horizontal:flex-col", className)} + {...restProps} +/> |
