Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/lib/components/ui/stepper/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Root from './stepper.svelte';
import Item from './stepper-item.svelte';
import Trigger from './stepper-trigger.svelte';
import Indicator from './stepper-indicator.svelte';
import Title from './stepper-title.svelte';
import Description from './stepper-description.svelte';
import Separator from './stepper-separator.svelte';

export {
Root,
Item,
Trigger,
Indicator,
Title,
Description,
Separator,
Root as Stepper,
Item as StepperItem,
Trigger as StepperTrigger,
Indicator as StepperIndicator,
Title as StepperTitle,
Description as StepperDescription,
Separator as StepperSeparator
};
Empty file.
21 changes: 21 additions & 0 deletions src/lib/components/ui/stepper/stepper-description.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { Snippet } from 'svelte';

let {
className = '',
children,
...restProps
}: {
className?: string;
children?: Snippet;
} = $props();
</script>

<p
data-slot="stepper-description"
class={cn('text-muted-foreground text-xs', className)}
{...restProps}
>
{@render children?.()}
</p>
46 changes: 46 additions & 0 deletions src/lib/components/ui/stepper/stepper-indicator.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script lang="ts">
import { getContext } from 'svelte';
import { cn } from '$lib/utils';
import { CheckIcon, LoaderCircleIcon } from '@lucide/svelte';

type StepState = 'active' | 'completed' | 'inactive' | 'loading';

interface StepItemContextValue {
step: number;
state: StepState;
isLoading: boolean;
}

let {
className = '',
...restProps
}: {
className?: string;
} = $props();

const stepItemCtx = getContext<StepItemContextValue>('StepItemContext');
if (!stepItemCtx) {
throw new Error('StepperIndicator must be used within a StepperItem');
}
</script>

<span
data-slot="stepper-indicator"
class={cn(
'flex size-6 items-center justify-center rounded-full border text-xs font-medium',
stepItemCtx.state === 'active' && 'border-primary bg-primary text-primary-foreground',
stepItemCtx.state === 'completed' && 'border-primary bg-primary text-primary-foreground',
stepItemCtx.state === 'inactive' && 'border-border bg-muted text-muted-foreground',
className
)}
data-state={stepItemCtx.state}
{...restProps}
>
{#if stepItemCtx.isLoading}
<LoaderCircleIcon class="size-4 animate-spin" />
{:else if stepItemCtx.state === 'completed'}
<CheckIcon class="size-4" />
{:else}
{stepItemCtx.step}
{/if}
</span>
99 changes: 99 additions & 0 deletions src/lib/components/ui/stepper/stepper-item.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<script lang="ts">
import { getContext, setContext, onMount, type Snippet } from 'svelte';
import { cn } from '$lib/utils';

type StepState = 'active' | 'completed' | 'inactive' | 'loading';

interface StepperContextValue {
activeStep: number;
orientation: 'horizontal' | 'vertical';
incrementTotalSteps: () => number;
}

interface StepItemContextValue {
step: number;
state: StepState;
isDisabled: boolean;
isLoading: boolean;
orientation: 'horizontal' | 'vertical';
}

let {
step: explicitStep,
completed = false,
disabled = false,
loading = false,
className = '',
children,
...restProps
}: {
step?: number;
completed?: boolean;
disabled?: boolean;
loading?: boolean;
className?: string;
children?: Snippet;
} = $props();

const stepperCtx = getContext<StepperContextValue>('StepperContext');
if (!stepperCtx) {
throw new Error('StepperItem must be used within a Stepper');
}

let itemStep = $state(explicitStep ?? 0);
let itemState = $state<StepState>('inactive');
let itemIsLoading = $state(false);

onMount(() => {
if (explicitStep === undefined) {
itemStep = stepperCtx.incrementTotalSteps();
}
});

$effect(() => {
const isActive = stepperCtx.activeStep === itemStep;
itemIsLoading = loading && isActive;
if (completed || itemStep < stepperCtx.activeStep) {
itemState = 'completed';
} else if (isActive) {
itemState = 'active';
} else {
itemState = 'inactive';
}
});

const stepItemCtx: StepItemContextValue = {
get step() {
return itemStep;
},
get state() {
return itemState;
},
get isDisabled() {
return disabled;
},
get isLoading() {
return itemIsLoading;
},
get orientation() {
return stepperCtx.orientation;
}
};

setContext('StepItemContext', stepItemCtx);
</script>

<div
data-slot="stepper-item"
class={cn(
'group/step flex items-center',
stepperCtx.orientation === 'horizontal' ? 'flex-row' : 'flex-col',
className
)}
data-state={itemState}
data-orientation={stepperCtx.orientation}
data-loading={itemIsLoading ? '' : undefined}
{...restProps}
>
{@render children?.()}
</div>
44 changes: 44 additions & 0 deletions src/lib/components/ui/stepper/stepper-separator.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script lang="ts">
import { getContext } from 'svelte';
import { cn } from '$lib/utils.js';

type StepState = 'active' | 'completed' | 'inactive' | 'loading';

interface StepperContextValue {
orientation: 'horizontal' | 'vertical';
}

interface StepItemContextValue {
state: StepState;
}

let {
className = '',
...restProps
}: {
className?: string;
} = $props();

const stepperCtx = getContext<StepperContextValue>('StepperContext');
const stepItemCtx = getContext<StepItemContextValue>('StepItemContext'); // Assumes separator is child of StepperItem

if (!stepperCtx || !stepItemCtx) {
throw new Error(
'StepperSeparator must be used within a StepperItem, which is within a Stepper'
);
}
</script>

<div
data-slot="stepper-separator"
class={cn(
'group-data-[state=completed]/step:bg-primary',
'group-data-[state=active]/step:bg-border',
'group-data-[state=inactive]/step:bg-border',
stepperCtx.orientation === 'horizontal' ? 'h-px flex-1' : 'min-h-4 w-px flex-auto',
className
)}
data-orientation={stepperCtx.orientation}
data-state={stepItemCtx.state}
{...restProps}
></div>
17 changes: 17 additions & 0 deletions src/lib/components/ui/stepper/stepper-title.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { Snippet } from 'svelte';

let {
className = '',
children,
...restProps
}: {
className?: string;
children?: Snippet;
} = $props();
</script>

<h3 data-slot="stepper-title" class={cn('text-sm font-medium', className)} {...restProps}>
{@render children?.()}
</h3>
53 changes: 53 additions & 0 deletions src/lib/components/ui/stepper/stepper-trigger.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script lang="ts">
import { getContext } from 'svelte';
import { cn } from '$lib/utils';
import type { Snippet } from 'svelte';

interface StepperContextValue {
activeStep: number;
setActiveStep: (step: number) => void;
}

interface StepItemContextValue {
step: number;
isDisabled: boolean;
orientation: 'horizontal' | 'vertical';
}

let {
className = '',
children,
...restProps
}: {
className?: string;
children?: Snippet;
} = $props();

const stepperCtx = getContext<StepperContextValue>('StepperContext');
const stepItemCtx = getContext<StepItemContextValue>('StepItemContext');

if (!stepperCtx || !stepItemCtx) {
throw new Error('StepperTrigger must be used within a StepperItem, which is within a Stepper');
}

function handleClick() {
if (!stepItemCtx.isDisabled) {
stepperCtx.setActiveStep(stepItemCtx.step);
}
}
</script>

<button
type="button"
data-slot="stepper-trigger"
class={cn(
'group/trigger ring-offset-background focus-visible:ring-ring inline-flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
stepItemCtx.orientation === 'vertical' ? 'w-full flex-col items-start gap-y-1.5' : '',
className
)}
disabled={stepItemCtx.isDisabled}
onclick={handleClick}
{...restProps}
>
{@render children?.()}
</button>
55 changes: 55 additions & 0 deletions src/lib/components/ui/stepper/stepper.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';

let {
step = $bindable(1),
orientation = 'horizontal',
className = '',
children,
...restProps
}: {
step: number;
orientation?: 'horizontal' | 'vertical';
className?: string;
children?: Snippet;
} = $props();

// State
let totalSteps = $state(0); // Will be updated by StepperItems

// Context API
const stepperContext = {
get activeStep() {
return step;
},
setActiveStep: (newStep: number) => {
step = newStep;
},
get orientation() {
return orientation;
},
get totalSteps() {
return totalSteps;
},
incrementTotalSteps: () => {
totalSteps++;
return totalSteps; // Return the new total for StepperItem to use as its step number
}
};
setContext('StepperContext', stepperContext);
</script>

<div
role="group"
aria-label="Stepper"
class={cn(
'flex w-full flex-wrap items-center justify-between gap-2',
orientation === 'horizontal' ? 'flex-row' : 'flex-col',
className
)}
{...restProps}
>
{@render children?.()}
</div>