feat(web): Agents ops console по ReUI Solutions Agents
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m15s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

KPI/Chart/Attention на списке, Stepper wizard, Timeline и Install на detail; breadcrumb loader.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-07-21 03:35:10 +07:00
co-authored by Cursor
parent 41e4ca1410
commit 0653313a57
10 changed files with 1683 additions and 102 deletions
@@ -23,14 +23,24 @@ import {
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import {
Stepper,
StepperContent,
StepperDescription,
StepperIndicator,
StepperItem,
StepperNav,
StepperPanel,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@/components/reui/stepper'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
/**
* Create agent install invite — Sheet.
* Preview: https://reui.io/preview/base/sheet-1 · https://reui.io/preview/base/sheet-8
* Hub: https://reui.io/components/sheet · https://reui.io/preview/base/components/c-sheet-1
* Copy pattern: https://reui.io/preview/base/settings-14
* Primitive API: https://ui.shadcn.com/docs/components/base/sheet
* Add agent wizard — Stepper in Sheet.
* Preview: https://reui.io/preview/base/solution-agents-6 · sheet-8
* Docs: https://reui.io/docs/components/base/stepper
*/
type Platform = 'linux' | 'mikrotik'
@@ -48,6 +58,7 @@ interface AddAgentSheetProps {
export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const [step, setStep] = useState(1)
const [name, setName] = useState('web-01')
const [platform, setPlatform] = useState<Platform>('linux')
const [created, setCreated] = useState<InstallLink | null>(null)
@@ -57,6 +68,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
setCreated(null)
setName('web-01')
setPlatform('linux')
setStep(1)
}
}, [open])
@@ -68,6 +80,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
}),
onSuccess: (link) => {
setCreated(link)
setStep(4)
toast.success('Агент создан')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
@@ -82,107 +95,177 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
toast.success('Скопировано')
}
function resetWizard() {
setCreated(null)
setName('web-01')
setPlatform('linux')
setStep(1)
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-lg">
<SheetHeader className="shrink-0">
<SheetTitle>
{created ? 'Команда установки' : 'Добавить агента'}
</SheetTitle>
<SheetDescription>
{created
? 'Агент уже в списке (Invited). Скопируйте one-liner и выполните на хосте.'
: 'Создайте агента и короткую install-ссылку.'}
? 'Агент в списке (Invited). Скопируйте one-liner на хост.'
: 'Платформа → имя → подтверждение → install.'}
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="grid auto-rows-min gap-4 py-2 pb-4">
{!created ? (
<>
<Field>
<FieldLabel htmlFor="agent-name">Имя клиента</FieldLabel>
<Input
id="agent-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="web-01"
/>
</Field>
<Field>
<FieldLabel>Платформа</FieldLabel>
<Select
items={[...PLATFORM_ITEMS]}
value={platform}
onValueChange={(v) => {
if (v === 'linux' || v === 'mikrotik') setPlatform(v)
}}
<div className="flex flex-col gap-4 py-2 pb-4">
<Stepper
value={created ? 4 : step}
onValueChange={setStep}
className="gap-4"
>
<StepperNav className="gap-1">
{(
[
[1, 'Платформа'],
[2, 'Имя'],
[3, 'Обзор'],
[4, 'Install'],
] as const
).map(([n, label], idx, arr) => (
<StepperItem
key={n}
step={n}
className="flex-1"
completed={Boolean(created) || step > n}
disabled={n === 4 && !created}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLATFORM_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</>
) : (
<>
<Field>
<FieldLabel>По id</FieldLabel>
<div className="flex flex-col gap-2">
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs whitespace-pre-wrap break-all">
{created.curl?.by_id}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => handleCopy(created.curl?.by_id ?? '')}
<StepperTrigger className="w-full flex-col gap-1 rounded-md p-2">
<StepperIndicator />
<StepperTitle className="text-xs">{label}</StepperTitle>
</StepperTrigger>
{idx < arr.length - 1 ? <StepperSeparator /> : null}
</StepperItem>
))}
</StepperNav>
<StepperPanel>
<StepperContent value={1} className="grid gap-4">
<StepperDescription>
Выберите ОС агента от неё зависит install one-liner.
</StepperDescription>
<Field>
<FieldLabel>Платформа</FieldLabel>
<Select
items={[...PLATFORM_ITEMS]}
value={platform}
onValueChange={(v) => {
if (v === 'linux' || v === 'mikrotik') setPlatform(v)
}}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLATFORM_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</StepperContent>
<StepperContent value={2} className="grid gap-4">
<StepperDescription>
Имя клиента в UI и в install-ссылке.
</StepperDescription>
<Field>
<FieldLabel htmlFor="agent-name">Имя клиента</FieldLabel>
<Input
id="agent-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="web-01"
/>
</Field>
</StepperContent>
<StepperContent value={3} className="grid gap-3">
<StepperDescription>
Проверьте параметры перед созданием.
</StepperDescription>
<div className="bg-muted flex flex-col gap-1 rounded-lg p-3 text-sm">
<div>
<span className="text-muted-foreground">Платформа: </span>
{platform === 'mikrotik' ? 'MikroTik' : 'Linux'}
</div>
<div>
<span className="text-muted-foreground">Имя: </span>
{name.trim() || '—'}
</div>
</div>
</Field>
<Field>
<FieldLabel>Короткий slug</FieldLabel>
<div className="flex flex-col gap-2">
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs whitespace-pre-wrap break-all">
{created.curl?.by_slug}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => handleCopy(created.curl?.by_slug ?? '')}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
</div>
</Field>
</>
)}
</StepperContent>
<StepperContent value={4} className="grid gap-4">
{created ? (
<>
<Field>
<FieldLabel>По id</FieldLabel>
<div className="flex flex-col gap-2">
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
{created.curl?.by_id}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() =>
handleCopy(created.curl?.by_id ?? '')
}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
</div>
</Field>
<Field>
<FieldLabel>Короткий slug</FieldLabel>
<div className="flex flex-col gap-2">
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
{created.curl?.by_slug}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() =>
handleCopy(created.curl?.by_slug ?? '')
}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
</div>
</Field>
</>
) : (
<p className="text-muted-foreground text-sm">
Сначала создайте агента на шаге «Обзор».
</p>
)}
</StepperContent>
</StepperPanel>
</Stepper>
</div>
</ScrollArea>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
{created ? (
<>
<Button
variant="outline"
onClick={() => {
setCreated(null)
}}
>
<Button variant="outline" onClick={resetWizard}>
Ещё агент
</Button>
<Button onClick={() => onOpenChange(false)}>Готово</Button>
@@ -192,9 +275,29 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button disabled={!canCreate} onClick={() => create.mutate()}>
Создать
</Button>
{step > 1 ? (
<Button
variant="outline"
onClick={() => setStep((s) => Math.max(1, s - 1))}
>
Назад
</Button>
) : null}
{step < 3 ? (
<Button
onClick={() => setStep((s) => s + 1)}
disabled={step === 2 && !name.trim()}
>
Далее
</Button>
) : (
<Button
disabled={!canCreate}
onClick={() => create.mutate()}
>
Создать
</Button>
)}
</>
)}
</SheetFooter>
@@ -0,0 +1,123 @@
import type { Agent } from '@evofw/shared'
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from '@/components/reui/timeline'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
/**
* Agent lifecycle timeline.
* Preview: https://reui.io/preview/base/solution-agents-3
* Docs: https://reui.io/docs/components/base/timeline
*/
type Step = {
title: string
date?: string | null
detail?: string
done: boolean
}
function formatWhen(iso?: string | null): string | undefined {
if (!iso) return undefined
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('ru-RU')
}
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
const steps: Step[] = [
{
title: 'Создан (Invited)',
date: agent.created_at,
detail: 'Install-ссылка выдана',
done: true,
},
{
title: 'Первый контакт',
date: agent.last_seen_at,
detail: agent.last_seen_ip
? `IP ${agent.last_seen_ip}`
: agent.hostname
? agent.hostname
: 'Ещё не подключался',
done: Boolean(agent.last_seen_at),
},
{
title: 'Approved',
date: agent.approved_at,
detail: agent.status === 'pending' ? 'Ожидает approve' : undefined,
done: Boolean(agent.approved_at) || agent.status === 'approved',
},
{
title: 'Last apply',
date: agent.last_apply_at,
detail: agent.last_apply_error
? agent.last_apply_error
: (agent.last_apply_status ??
(agent.last_apply_prefix_count != null
? `${agent.last_apply_prefix_count} prefixes`
: undefined)),
done: Boolean(agent.last_apply_at),
},
]
if (agent.revoked_at || agent.status === 'revoked') {
steps.push({
title: 'Revoked',
date: agent.revoked_at,
done: true,
})
}
const activeStep = Math.max(
1,
steps.reduce((acc, s, i) => (s.done ? i + 1 : acc), 1),
)
return (
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Жизненный цикл</FrameTitle>
<FrameDescription>
Invite enroll approve apply
</FrameDescription>
</FrameHeader>
<FramePanel>
<Timeline value={activeStep} className="gap-4 ps-6">
{steps.map((s, i) => (
<TimelineItem key={s.title} step={i + 1}>
<TimelineSeparator />
<TimelineIndicator />
<TimelineHeader>
<TimelineTitle>{s.title}</TimelineTitle>
{s.date ? (
<TimelineDate dateTime={s.date}>
{formatWhen(s.date)}
</TimelineDate>
) : (
<TimelineDate></TimelineDate>
)}
</TimelineHeader>
{s.detail ? (
<TimelineContent>{s.detail}</TimelineContent>
) : null}
</TimelineItem>
))}
</Timeline>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,96 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { recentStatsQueryOptions } from '@/queries'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@evofw/ui/components/chart'
import { Skeleton } from '@evofw/ui/components/skeleton'
/**
* Fleet apply throughput — live time-series from /api/v1/stats/recent.
* DNA: https://reui.io/preview/base/solution-agents-1
*/
const chartConfig = {
dropped: { label: 'Dropped', color: 'var(--chart-1)' },
accepted: { label: 'Accepted', color: 'var(--chart-2)' },
} satisfies ChartConfig
export function AgentsFleetChart() {
const stats = useQuery(recentStatsQueryOptions())
const series = useMemo(() => {
const items = [...(stats.data?.items ?? [])].reverse().slice(-40)
return items.map((s) => ({
t: s.recorded_at.slice(11, 19),
dropped: s.packets_dropped,
accepted: s.packets_accepted,
}))
}, [stats.data?.items])
return (
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Throughput</FrameTitle>
<FrameDescription>
Dropped / accepted по последним apply-снимкам флота
</FrameDescription>
</FrameHeader>
<FramePanel>
{stats.isLoading ? (
<Skeleton className="aspect-[3/1] w-full rounded-lg" />
) : series.length === 0 ? (
<p className="text-muted-foreground text-sm">
Пока нет статистики apply появится после sync агентов.
</p>
) : (
<ChartContainer config={chartConfig} className="aspect-[3/1] w-full">
<AreaChart data={series} margin={{ left: 0, right: 8, top: 8 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="t"
tickLine={false}
axisLine={false}
fontSize={11}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={11}
width={36}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey="dropped"
stroke="var(--color-dropped)"
fill="var(--color-dropped)"
fillOpacity={0.2}
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="accepted"
stroke="var(--color-accepted)"
fill="var(--color-accepted)"
fillOpacity={0.15}
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
)}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,88 @@
import type { ReactNode } from 'react'
import type { Agent } from '@evofw/shared'
import type { KpiStatCard } from '@/components/reui-kit'
const STALE_MS = 24 * 60 * 60 * 1000
export function isAgentStale(agent: Agent, now = Date.now()): boolean {
if (agent.status !== 'approved') return false
if (!agent.last_seen_at) return true
const t = Date.parse(agent.last_seen_at)
if (Number.isNaN(t)) return true
return now - t > STALE_MS
}
export type FleetCounts = {
pending: number
invited: number
approved: number
revoked: number
stale: number
applyErrors: number
}
export function computeFleetCounts(agents: Agent[]): FleetCounts {
const now = Date.now()
let pending = 0
let invited = 0
let approved = 0
let revoked = 0
let stale = 0
let applyErrors = 0
for (const a of agents) {
if (a.status === 'pending') pending += 1
else if (a.status === 'invited') invited += 1
else if (a.status === 'approved') approved += 1
else if (a.status === 'revoked') revoked += 1
if (isAgentStale(a, now)) stale += 1
if (a.last_apply_error) applyErrors += 1
}
return { pending, invited, approved, revoked, stale, applyErrors }
}
export function fleetKpiCards(
counts: FleetCounts,
icons: {
pending: ReactNode
invited: ReactNode
approved: ReactNode
stale: ReactNode
},
): KpiStatCard[] {
return [
{
id: 'pending',
label: 'Pending',
value: counts.pending,
hint: 'approve backlog',
icon: icons.pending,
iconClassName: 'text-warning',
variant: counts.pending > 0 ? 'warning' : 'default',
},
{
id: 'invited',
label: 'Invited',
value: counts.invited,
hint: 'ожидают install',
icon: icons.invited,
iconClassName: 'text-info',
},
{
id: 'approved',
label: 'Approved',
value: counts.approved,
hint: 'в парке',
icon: icons.approved,
iconClassName: 'text-success',
},
{
id: 'stale',
label: 'Offline / stale',
value: counts.stale,
hint: '>24ч без seen',
icon: icons.stale,
iconClassName: 'text-muted-foreground',
variant: counts.stale > 0 ? 'warning' : 'default',
},
]
}
+477
View File
@@ -0,0 +1,477 @@
import {
Children,
createContext,
HTMLAttributes,
isValidElement,
ReactElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@evofw/ui/lib/utils"
// Types
type StepperOrientation = "horizontal" | "vertical"
type StepState = "active" | "completed" | "inactive" | "loading"
type StepIndicators = {
active?: React.ReactNode
completed?: React.ReactNode
inactive?: React.ReactNode
loading?: React.ReactNode
}
interface StepperContextValue {
activeStep: number
setActiveStep: (step: number) => void
stepsCount: number
orientation: StepperOrientation
registerTrigger: (node: HTMLButtonElement | null) => void
triggerNodes: HTMLButtonElement[]
focusNext: (currentIdx: number) => void
focusPrev: (currentIdx: number) => void
focusFirst: () => void
focusLast: () => void
indicators: StepIndicators
}
interface StepItemContextValue {
step: number
state: StepState
isDisabled: boolean
isLoading: boolean
}
const StepperContext = createContext<StepperContextValue | undefined>(undefined)
const StepItemContext = createContext<StepItemContextValue | undefined>(
undefined
)
function useStepper() {
const ctx = useContext(StepperContext)
if (!ctx) throw new Error("useStepper must be used within a Stepper")
return ctx
}
function useStepItem() {
const ctx = useContext(StepItemContext)
if (!ctx) throw new Error("useStepItem must be used within a StepperItem")
return ctx
}
interface StepperProps extends HTMLAttributes<HTMLDivElement> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: StepperOrientation
indicators?: StepIndicators
}
function Stepper({
defaultValue = 1,
value,
onValueChange,
orientation = "horizontal",
className,
children,
indicators = {},
...props
}: StepperProps) {
const [activeStep, setActiveStep] = useState(defaultValue)
const [triggerNodes, setTriggerNodes] = useState<HTMLButtonElement[]>([])
// Register/unregister triggers
const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
setTriggerNodes((prev) => {
if (node && !prev.includes(node)) {
return [...prev, node]
} else if (!node && prev.includes(node!)) {
return prev.filter((n) => n !== node)
} else {
return prev
}
})
}, [])
const handleSetActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setActiveStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
// Keyboard navigation logic
const focusTrigger = (idx: number) => {
if (triggerNodes[idx]) triggerNodes[idx].focus()
}
const focusNext = (currentIdx: number) =>
focusTrigger((currentIdx + 1) % triggerNodes.length)
const focusPrev = (currentIdx: number) =>
focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length)
const focusFirst = () => focusTrigger(0)
const focusLast = () => focusTrigger(triggerNodes.length - 1)
// Context value
const contextValue = useMemo<StepperContextValue>(
() => ({
activeStep: currentStep,
setActiveStep: handleSetActiveStep,
stepsCount: Children.toArray(children).filter(
(child): child is ReactElement =>
isValidElement(child) &&
(child.type as { displayName?: string }).displayName === "StepperItem"
).length,
orientation,
registerTrigger,
focusNext,
focusPrev,
focusFirst,
focusLast,
triggerNodes,
indicators,
}),
[
currentStep,
handleSetActiveStep,
children,
orientation,
registerTrigger,
triggerNodes,
]
)
return (
<StepperContext.Provider value={contextValue}>
<div
role="tablist"
aria-orientation={orientation}
data-slot="stepper"
className={cn("w-full", className)}
data-orientation={orientation}
{...props}
>
{children}
</div>
</StepperContext.Provider>
)
}
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
step: number
completed?: boolean
disabled?: boolean
loading?: boolean
}
function StepperItem({
step,
completed = false,
disabled = false,
loading = false,
className,
children,
...props
}: StepperItemProps) {
const { activeStep } = useStepper()
const state: StepState =
completed || step < activeStep
? "completed"
: activeStep === step
? "active"
: "inactive"
const isLoading = loading && step === activeStep
return (
<StepItemContext.Provider
value={{ step, state, isDisabled: disabled, isLoading }}
>
<div
data-slot="stepper-item"
className={cn(
"group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col",
className
)}
data-state={state}
{...(isLoading ? { "data-loading": true } : {})}
{...props}
>
{children}
</div>
</StepItemContext.Provider>
)
}
type StepperTriggerProps = useRender.ComponentProps<"button">
function StepperTrigger({
className,
children,
tabIndex,
render,
...props
}: StepperTriggerProps) {
const { state, isLoading } = useStepItem()
const stepperCtx = useStepper()
const {
setActiveStep,
activeStep,
registerTrigger,
triggerNodes,
focusNext,
focusPrev,
focusFirst,
focusLast,
} = stepperCtx
const { step, isDisabled } = useStepItem()
const isSelected = activeStep === step
const id = `stepper-tab-${step}`
const panelId = `stepper-panel-${step}`
// Register this trigger for keyboard navigation
const btnRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (btnRef.current) {
registerTrigger(btnRef.current)
}
}, [btnRef.current])
// Find our index among triggers for navigation
const myIdx = useMemo(
() =>
triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
[triggerNodes, btnRef.current]
)
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault()
if (myIdx !== -1 && focusNext) focusNext(myIdx)
break
case "ArrowLeft":
case "ArrowUp":
e.preventDefault()
if (myIdx !== -1 && focusPrev) focusPrev(myIdx)
break
case "Home":
e.preventDefault()
if (focusFirst) focusFirst()
break
case "End":
e.preventDefault()
if (focusLast) focusLast()
break
case "Enter":
case " ":
e.preventDefault()
setActiveStep(step)
break
}
}
const defaultProps = {
role: "tab",
id,
"aria-selected": isSelected,
"aria-controls": panelId,
tabIndex: typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1,
"data-slot": "stepper-trigger",
"data-state": state,
"data-loading": isLoading,
className: cn(
"focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60",
"gap-2.5 rounded-full",
className
),
onClick: () => setActiveStep(step),
onKeyDown: handleKeyDown,
disabled: isDisabled,
children,
}
return useRender({
defaultTagName: "button",
render,
ref: btnRef,
props: mergeProps<"button">(defaultProps, props),
})
}
function StepperIndicator({
children,
className,
}: React.ComponentProps<"div">) {
const { state, isLoading } = useStepItem()
const { indicators } = useStepper()
return (
<div
data-slot="stepper-indicator"
data-state={state}
className={cn(
"border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden",
"rounded-full text-xs",
className
)}
>
<div className="absolute">
{indicators &&
((isLoading && indicators.loading) ||
(state === "completed" && indicators.completed) ||
(state === "active" && indicators.active) ||
(state === "inactive" && indicators.inactive))
? (isLoading && indicators.loading) ||
(state === "completed" && indicators.completed) ||
(state === "active" && indicators.active) ||
(state === "inactive" && indicators.inactive)
: children}
</div>
</div>
)
}
function StepperSeparator({ className }: React.ComponentProps<"div">) {
const { state } = useStepItem()
return (
<div
data-slot="stepper-separator"
data-state={state}
className={cn(
"bg-muted rounded-sm group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5 m-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1",
className
)}
/>
)
}
function StepperTitle({ children, className }: React.ComponentProps<"h3">) {
const { state } = useStepItem()
return (
<h3
data-slot="stepper-title"
data-state={state}
className={cn(
"text-sm leading-none font-medium",
className
)}
>
{children}
</h3>
)
}
function StepperDescription({
children,
className,
}: React.ComponentProps<"div">) {
const { state } = useStepItem()
return (
<div
data-slot="stepper-description"
data-state={state}
className={cn(
"text-muted-foreground text-sm",
className
)}
>
{children}
</div>
)
}
function StepperNav({ children, className }: React.ComponentProps<"nav">) {
const { activeStep, orientation } = useStepper()
return (
<nav
data-slot="stepper-nav"
data-state={activeStep}
data-orientation={orientation}
className={cn(
"group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
)}
>
{children}
</nav>
)
}
function StepperPanel({ children, className }: React.ComponentProps<"div">) {
const { activeStep } = useStepper()
return (
<div
data-slot="stepper-panel"
data-state={activeStep}
className={cn("w-full", className)}
>
{children}
</div>
)
}
interface StepperContentProps extends React.ComponentProps<"div"> {
value: number
forceMount?: boolean
}
function StepperContent({
value,
forceMount,
children,
className,
}: StepperContentProps) {
const { activeStep } = useStepper()
const isActive = value === activeStep
if (!forceMount && !isActive) {
return null
}
return (
<div
data-slot="stepper-content"
data-state={activeStep}
className={cn("w-full", className, !isActive && forceMount && "hidden")}
hidden={!isActive && forceMount}
>
{children}
</div>
)
}
export {
useStepper,
useStepItem,
Stepper,
StepperItem,
StepperTrigger,
StepperIndicator,
StepperSeparator,
StepperTitle,
StepperDescription,
StepperPanel,
StepperContent,
StepperNav,
type StepperProps,
type StepperItemProps,
type StepperTriggerProps,
type StepperContentProps,
}
+258
View File
@@ -0,0 +1,258 @@
"use client"
import { createContext, useCallback, useContext, useState } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@evofw/ui/lib/utils"
// Types
type TimelineContextValue = {
activeStep: number
setActiveStep: (step: number) => void
}
// Context
const TimelineContext = createContext<TimelineContextValue | undefined>(
undefined
)
const useTimeline = () => {
const context = useContext(TimelineContext)
if (!context) {
throw new Error("useTimeline must be used within a Timeline")
}
return context
}
// Components
interface TimelineProps extends useRender.ComponentProps<"div"> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: "horizontal" | "vertical"
}
function Timeline({
defaultValue = 1,
value,
onValueChange,
orientation = "vertical",
className,
render,
children,
...props
}: TimelineProps) {
const [activeStep, setInternalStep] = useState(defaultValue)
const setActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
const defaultProps = {
className: cn(
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
),
"data-orientation": orientation,
"data-slot": "timeline",
children,
}
return (
<TimelineContext.Provider
value={{ activeStep: currentStep, setActiveStep }}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</TimelineContext.Provider>
)
}
// TimelineContent
function TimelineContent({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn("text-muted-foreground text-sm", className),
"data-slot": "timeline-content",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineDate
type TimelineDateProps = useRender.ComponentProps<"time">
function TimelineDate({
className,
render,
children,
...props
}: TimelineDateProps) {
const defaultProps = {
className: cn(
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
className
),
"data-slot": "timeline-date",
children,
}
return useRender({
defaultTagName: "time",
render,
props: mergeProps<"time">(defaultProps, props),
})
}
// TimelineHeader
function TimelineHeader({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn(className),
"data-slot": "timeline-header",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineIndicator
type TimelineIndicatorProps = useRender.ComponentProps<"div">
function TimelineIndicator({
className,
children,
render,
...props
}: TimelineIndicatorProps) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
className
),
"data-slot": "timeline-indicator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineItem
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
step: number
}
function TimelineItem({
step,
className,
render,
children,
...props
}: TimelineItemProps) {
const { activeStep } = useTimeline()
const defaultProps = {
className: cn(
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
className
),
"data-completed": step <= activeStep || undefined,
"data-slot": "timeline-item",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineSeparator
function TimelineSeparator({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
className
),
"data-slot": "timeline-separator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineTitle
function TimelineTitle({
className,
render,
children,
...props
}: useRender.ComponentProps<"h3">) {
const defaultProps = {
className: cn("font-medium text-sm", className),
"data-slot": "timeline-title",
children,
}
return useRender({
defaultTagName: "h3",
render,
props: mergeProps<"h3">(defaultProps, props),
})
}
export {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
}