mirror of
https://github.com/temetro/temetro.git
synced 2026-08-25 09:56:56 +00:00
frontend: center wallet-sync stepper and record-history timeline
Replace the bespoke left-aligned DialogStepper with the Origin UI Stepper primitive (components/ui/stepper.tsx) so the numbered indicators sit centered inline with their labels. Rebuild the patient sheet's Record History section as a vertical Timeline (components/ui/timeline.tsx) showing the actor name, an entity-type icon, the action, and the date, replacing the flat avatar list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { Slot } from "radix-ui";
|
||||
import * as React from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Types
|
||||
type StepperContextValue = {
|
||||
activeStep: number;
|
||||
setActiveStep: (step: number) => void;
|
||||
orientation: "horizontal" | "vertical";
|
||||
};
|
||||
|
||||
type StepItemContextValue = {
|
||||
step: number;
|
||||
state: StepState;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
type StepState = "active" | "completed" | "inactive" | "loading";
|
||||
|
||||
// Contexts
|
||||
const StepperContext = createContext<StepperContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const StepItemContext = createContext<StepItemContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const useStepper = () => {
|
||||
const context = useContext(StepperContext);
|
||||
if (!context) {
|
||||
throw new Error("useStepper must be used within a Stepper");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const useStepItem = () => {
|
||||
const context = useContext(StepItemContext);
|
||||
if (!context) {
|
||||
throw new Error("useStepItem must be used within a StepperItem");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Components
|
||||
interface StepperProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
defaultValue?: number;
|
||||
value?: number;
|
||||
onValueChange?: (value: number) => void;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
defaultValue = 0,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "horizontal",
|
||||
className,
|
||||
...props
|
||||
}: StepperProps) {
|
||||
const [activeStep, setInternalStep] = React.useState(defaultValue);
|
||||
|
||||
const setActiveStep = React.useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setInternalStep(step);
|
||||
}
|
||||
onValueChange?.(step);
|
||||
},
|
||||
[value, onValueChange],
|
||||
);
|
||||
|
||||
const currentStep = value ?? activeStep;
|
||||
|
||||
return (
|
||||
<StepperContext.Provider
|
||||
value={{
|
||||
activeStep: currentStep,
|
||||
orientation,
|
||||
setActiveStep,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group/stepper inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
data-orientation={orientation}
|
||||
data-slot="stepper"
|
||||
{...props}
|
||||
/>
|
||||
</StepperContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperItem
|
||||
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={{ isDisabled: disabled, isLoading, state, step }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group/step flex items-center group-data-[orientation=horizontal]/stepper:flex-row group-data-[orientation=vertical]/stepper:flex-col",
|
||||
className,
|
||||
)}
|
||||
data-slot="stepper-item"
|
||||
data-state={state}
|
||||
{...(isLoading ? { "data-loading": true } : {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StepItemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperTrigger
|
||||
interface StepperTriggerProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function StepperTrigger({
|
||||
asChild = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StepperTriggerProps) {
|
||||
const { setActiveStep } = useStepper();
|
||||
const { step, isDisabled } = useStepItem();
|
||||
|
||||
if (asChild) {
|
||||
const Comp = asChild ? Slot.Root : "span";
|
||||
return (
|
||||
<Comp className={className} data-slot="stepper-trigger">
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center gap-3 rounded-full outline-none focus-visible:z-10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
data-slot="stepper-trigger"
|
||||
disabled={isDisabled}
|
||||
onClick={() => setActiveStep(step)}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperIndicator
|
||||
interface StepperIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function StepperIndicator({
|
||||
asChild = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StepperIndicatorProps) {
|
||||
const { state, step, isLoading } = useStepItem();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex size-6 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground text-xs data-[state=active]:bg-primary data-[state=completed]:bg-primary data-[state=active]:text-primary-foreground data-[state=completed]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
data-slot="stepper-indicator"
|
||||
data-state={state}
|
||||
{...props}
|
||||
>
|
||||
{asChild ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
<span className="transition-all group-data-[state=completed]/step:scale-0 group-data-loading/step:scale-0 group-data-[state=completed]/step:opacity-0 group-data-loading/step:opacity-0 group-data-loading/step:transition-none">
|
||||
{step}
|
||||
</span>
|
||||
<CheckIcon
|
||||
aria-hidden="true"
|
||||
className="absolute scale-0 opacity-0 transition-all group-data-[state=completed]/step:scale-100 group-data-[state=completed]/step:opacity-100"
|
||||
size={16}
|
||||
/>
|
||||
{isLoading && (
|
||||
<span className="absolute transition-all">
|
||||
<LoaderCircleIcon
|
||||
aria-hidden="true"
|
||||
className="animate-spin"
|
||||
size={14}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperTitle
|
||||
function StepperTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("font-medium text-sm", className)}
|
||||
data-slot="stepper-title"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperDescription
|
||||
function StepperDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
data-slot="stepper-description"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// StepperSeparator
|
||||
function StepperSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"m-0.5 bg-muted group-data-[orientation=horizontal]/stepper:h-0.5 group-data-[orientation=vertical]/stepper:h-12 group-data-[orientation=horizontal]/stepper:w-full group-data-[orientation=vertical]/stepper:w-0.5 group-data-[orientation=horizontal]/stepper:flex-1 group-data-[state=completed]/step:bg-primary",
|
||||
className,
|
||||
)}
|
||||
data-slot="stepper-separator"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Stepper,
|
||||
StepperDescription,
|
||||
StepperIndicator,
|
||||
StepperItem,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperTrigger,
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { Slot } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Types
|
||||
type TimelineContextValue = {
|
||||
activeStep: number;
|
||||
setActiveStep: (step: number) => void;
|
||||
};
|
||||
|
||||
// Context
|
||||
const TimelineContext = React.createContext<TimelineContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const useTimeline = () => {
|
||||
const context = React.useContext(TimelineContext);
|
||||
if (!context) {
|
||||
throw new Error("useTimeline must be used within a Timeline");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Components
|
||||
interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
defaultValue?: number;
|
||||
value?: number;
|
||||
onValueChange?: (value: number) => void;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
function Timeline({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "vertical",
|
||||
className,
|
||||
...props
|
||||
}: TimelineProps) {
|
||||
const [activeStep, setInternalStep] = React.useState(defaultValue);
|
||||
|
||||
const setActiveStep = React.useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setInternalStep(step);
|
||||
}
|
||||
onValueChange?.(step);
|
||||
},
|
||||
[value, onValueChange],
|
||||
);
|
||||
|
||||
const currentStep = value ?? activeStep;
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider
|
||||
value={{ activeStep: currentStep, setActiveStep }}
|
||||
>
|
||||
<div
|
||||
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"
|
||||
{...props}
|
||||
/>
|
||||
</TimelineContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineContent
|
||||
function TimelineContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
data-slot="timeline-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineDate
|
||||
interface TimelineDateProps extends React.HTMLAttributes<HTMLTimeElement> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function TimelineDate({
|
||||
asChild = false,
|
||||
className,
|
||||
...props
|
||||
}: TimelineDateProps) {
|
||||
const Comp = asChild ? Slot.Root : "time";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
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"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineHeader
|
||||
function TimelineHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn(className)} data-slot="timeline-header" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineIndicator
|
||||
interface TimelineIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function TimelineIndicator({
|
||||
asChild = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TimelineIndicatorProps) {
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineItem
|
||||
interface TimelineItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
step: number;
|
||||
}
|
||||
|
||||
function TimelineItem({ step, className, ...props }: TimelineItemProps) {
|
||||
const { activeStep } = useTimeline();
|
||||
|
||||
return (
|
||||
<div
|
||||
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-12 has-[+[data-completed]]:[&_[data-slot=timeline-separator]]:bg-primary",
|
||||
className,
|
||||
)}
|
||||
data-completed={step <= activeStep || undefined}
|
||||
data-slot="timeline-item"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineSeparator
|
||||
function TimelineSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TimelineTitle
|
||||
function TimelineTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn("font-medium text-sm", className)}
|
||||
data-slot="timeline-title"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
};
|
||||
Reference in New Issue
Block a user