refactor: update forms and other areas to use new shadcn components (#1115)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
Co-authored-by: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Kyle Mendell
2026-01-02 17:45:08 +01:00
committed by GitHub
co-authored by Elias Schneider ItalyPaleAle Copilot
parent 894eaf3cff
commit 386add08c4
60 changed files with 1427 additions and 783 deletions
@@ -18,7 +18,7 @@
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
sm: 'h-8 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'
}
@@ -42,7 +42,7 @@
</script>
<script lang="ts">
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import { Spinner } from '$lib/components/ui/spinner';
import { onMount } from 'svelte';
let {
@@ -97,7 +97,7 @@
{...restProps}
>
{#if isLoading}
<LoaderCircle class="size-4 animate-spin" />
<Spinner />
{/if}
{@render children?.()}
</a>
@@ -112,7 +112,7 @@
{...restProps}
>
{#if isLoading}
<LoaderCircle class="size-4 animate-spin" />
<Spinner />
{/if}
{@render children?.()}
</button>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils/style.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="field-content"
class={cn("group/field-content flex flex-1 flex-col gap-1.5 leading-snug", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/style.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="field-description"
class={cn(
'text-muted-foreground -mt-1 mb-0 text-xs leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className
)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,58 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils/style.js";
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
errors,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
children?: Snippet;
errors?: { message?: string }[];
} = $props();
const hasContent = $derived.by(() => {
// has slotted error
if (children) return true;
// no errors
if (!errors) return false;
// has an error but no message
if (errors.length === 1 && !errors[0]?.message) {
return false;
}
return true;
});
const isMultipleErrors = $derived(errors && errors.length > 1);
const singleErrorMessage = $derived(errors && errors.length === 1 && errors[0]?.message);
</script>
{#if hasContent}
<div
bind:this={ref}
role="alert"
data-slot="field-error"
class={cn("text-destructive text-sm font-normal", className)}
{...restProps}
>
{#if children}
{@render children()}
{:else if singleErrorMessage}
{singleErrorMessage}
{:else if isMultipleErrors}
<ul class="ms-4 flex list-disc flex-col gap-1">
{#each errors ?? [] as error, index (index)}
{#if error?.message}
<li>{error.message}</li>
{/if}
{/each}
</ul>
{/if}
</div>
{/if}
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/style.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="field-group"
class={cn(
'group/field-group @container/field-group flex w-full flex-col gap-4 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,30 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label/index.js';
import { cn } from '$lib/utils/style.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
required = false,
children,
...restProps
}: ComponentProps<typeof Label> & {
required?: boolean;
} = $props();
</script>
<Label
bind:ref
data-slot="field-label"
{required}
class={cn(
'group/field-label peer/field-label mb-0 flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
className
)}
{...restProps}
>
{@render children?.()}
</Label>
@@ -0,0 +1,29 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils/style.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
variant = "legend",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLegendElement>> & {
variant?: "legend" | "label";
} = $props();
</script>
<legend
bind:this={ref}
data-slot="field-legend"
data-variant={variant}
class={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...restProps}
>
{@render children?.()}
</legend>
@@ -0,0 +1,38 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn, type WithElementRef } from "$lib/utils/style.js";
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
children?: Snippet;
} = $props();
const hasContent = $derived(!!children);
</script>
<div
bind:this={ref}
data-slot="field-separator"
data-content={hasContent}
class={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...restProps}
>
<Separator class="absolute inset-0 top-1/2" />
{#if children}
<span
class="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{@render children()}
</span>
{/if}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/style.js';
import type { HTMLFieldsetAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLFieldsetAttributes> = $props();
</script>
<fieldset
bind:this={ref}
data-slot="field-set"
class={cn(
'flex flex-col gap-4',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className
)}
{...restProps}
>
{@render children?.()}
</fieldset>
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils/style.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="field-title"
class={cn(
"flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50",
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,53 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const fieldVariants = tv({
base: 'group/field data-[invalid=true]:text-destructive flex w-full gap-1.5',
variants: {
orientation: {
vertical: 'flex-col [&>*]:w-full [&>.sr-only]:w-auto',
horizontal: [
'flex-row items-center',
'[&>[data-slot=field-label]]:flex-auto',
'has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start'
],
responsive: [
'@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto',
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px'
]
}
},
defaultVariants: {
orientation: 'vertical'
}
});
export type FieldOrientation = VariantProps<typeof fieldVariants>['orientation'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
orientation = 'vertical',
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: FieldOrientation;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="field"
data-orientation={orientation}
class={cn(fieldVariants({ orientation }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,33 @@
import Field from "./field.svelte";
import Set from "./field-set.svelte";
import Legend from "./field-legend.svelte";
import Group from "./field-group.svelte";
import Content from "./field-content.svelte";
import Label from "./field-label.svelte";
import Title from "./field-title.svelte";
import Description from "./field-description.svelte";
import Separator from "./field-separator.svelte";
import Error from "./field-error.svelte";
export {
Field,
Set,
Legend,
Group,
Content,
Label,
Title,
Description,
Separator,
Error,
//
Set as FieldSet,
Legend as FieldLegend,
Group as FieldGroup,
Content as FieldContent,
Label as FieldLabel,
Title as FieldTitle,
Description as FieldDescription,
Separator as FieldSeparator,
Error as FieldError,
};
@@ -24,7 +24,7 @@
bind:this={ref}
data-slot="input"
class={cn(
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-8 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none 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
@@ -39,7 +39,7 @@
bind:this={ref}
data-slot="input"
class={cn(
'border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-8 w-full min-w-0 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none 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
@@ -0,0 +1,34 @@
import Root from "./item.svelte";
import Group from "./item-group.svelte";
import Separator from "./item-separator.svelte";
import Header from "./item-header.svelte";
import Footer from "./item-footer.svelte";
import Content from "./item-content.svelte";
import Title from "./item-title.svelte";
import Description from "./item-description.svelte";
import Actions from "./item-actions.svelte";
import Media from "./item-media.svelte";
export {
Root,
Group,
Separator,
Header,
Footer,
Content,
Title,
Description,
Actions,
Media,
//
Root as Item,
Group as ItemGroup,
Separator as ItemSeparator,
Header as ItemHeader,
Footer as ItemFooter,
Content as ItemContent,
Title as ItemTitle,
Description as ItemDescription,
Actions as ItemActions,
Media as ItemMedia,
};
@@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
wrapOnMobile,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
wrapOnMobile?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="item-actions"
class={cn('flex items-center gap-2', wrapOnMobile && 'w-full pl-8 sm:w-auto sm:pl-0 pt-1', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils/style.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="item-content"
class={cn("flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils/style.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="item-description"
class={cn(
"text-muted-foreground text-balance text-sm font-normal leading-normal",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...restProps}
>
{@render children?.()}
</p>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils/style.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="item-footer"
class={cn("flex basis-full items-center justify-between gap-2", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils/style.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
role="list"
data-slot="item-group"
class={cn("group/item-group flex flex-col", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils/style.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="item-header"
class={cn("flex basis-full items-center justify-between gap-2", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,43 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const itemMediaVariants = tv({
base: 'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none',
variants: {
variant: {
default: 'bg-transparent',
transparent: 'bg-transparent',
icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4",
image: 'size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover'
}
},
defaultVariants: {
variant: 'default'
}
});
export type ItemMediaVariant = VariantProps<typeof itemMediaVariants>['variant'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
variant = 'default',
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: ItemMediaVariant } = $props();
</script>
<div
bind:this={ref}
data-slot="item-media"
data-variant={variant}
class={cn(itemMediaVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils/style.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="item-separator"
orientation="horizontal"
class={cn("my-0", className)}
{...restProps}
/>
@@ -0,0 +1,22 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils/style.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
role="heading"
aria-level="3"
data-slot="item-title"
class={cn("flex w-fit items-center gap-2 font-semibold leading-snug", className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,62 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const itemVariants = tv({
base: 'group/item focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-xl border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:ring-[3px]',
variants: {
variant: {
default: 'bg-transparent [a&]:hover:bg-accent/50 [a&]:transition-colors',
outline: 'border-border [a&]:hover:bg-accent/50 [a&]:transition-colors',
muted: 'bg-muted/50 [a&]:hover:bg-accent/50 [a&]:transition-colors',
card: 'bg-card shadow-sm [a&]:hover:bg-accent/50 [a&]:transition-colors',
transparent: 'bg-transparent'
},
size: {
default: 'gap-4 p-4',
sm: 'gap-2.5 px-4 py-3'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ItemSize = VariantProps<typeof itemVariants>['size'];
export type ItemVariant = VariantProps<typeof itemVariants>['variant'];
</script>
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
class: className,
child,
variant,
size,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
variant?: ItemVariant;
size?: ItemSize;
} = $props();
const mergedProps = $derived({
class: cn(itemVariants({ variant, size }), className),
'data-slot': 'item',
'data-variant': variant,
'data-size': size,
...restProps
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render mergedProps.children?.()}
</div>
{/if}
@@ -0,0 +1 @@
export { default as Spinner } from "./spinner.svelte";
@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from "$lib/utils/style.js";
import Loader2Icon from "@lucide/svelte/icons/loader-2";
import type { ComponentProps } from "svelte";
let { class: className, ...restProps }: ComponentProps<typeof Loader2Icon> = $props();
</script>
<Loader2Icon
role="status"
aria-label="Loading"
class={cn("size-4 animate-spin", className)}
{...restProps}
/>