flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/components/ui/radio-group.tsx

138 lines5,446 bytesCodeBlame
1import { RadioGroup as Primitive } from "radix-ui";
2import { type ComponentProps, type ReactNode, useId } from "react";
3
4import { cn } from "../../lib/cn";
5
6// shadcn/ui's radio group, styled with g1t's tokens, in two shapes:
7//
8// - compact: <RadioOption> rows, a dot and a line of text each;
9// - cards: <RadioCard> tiles, an icon, a title and a description each.
10//
11// Inside a <Form>, the group's `name` makes Radix render a hidden radio per
12// item, so the chosen `value` posts exactly as native radios do. The arrow
13// keys move between items and choose as they go; disabled items are skipped.
14
15export function RadioGroup({ className, ...props }: ComponentProps<typeof Primitive.Root>) {
16 return <Primitive.Root className={cn("grid gap-2", className)} {...props} />;
17}
18
19/** The bare dot. */
20export function RadioGroupItem({ className, ...props }: ComponentProps<typeof Primitive.Item>) {
21 return (
22 <Primitive.Item
23 className={cn(
24 "peer relative inline-flex size-4 shrink-0 items-center justify-center rounded-full border border-line-strong bg-bg outline-none transition-colors",
25 "hover:border-faint focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-bg focus-visible:outline-none",
26 "data-[state=checked]:border-accent disabled:cursor-not-allowed disabled:opacity-50",
27 className,
28 )}
29 {...props}
30 >
31 <Primitive.Indicator className="size-2 rounded-full bg-accent data-[state=checked]:animate-pop-in motion-reduce:animate-none" />
32 </Primitive.Item>
33 );
34}
35
36/** A compact choice: the dot, a label, and optionally a line under it. */
37export function RadioOption({
38 value,
39 label,
40 description,
41 icon,
42 disabled,
43 className,
44}: {
45 value: string;
46 label: ReactNode;
47 description?: ReactNode;
48 icon?: ReactNode;
49 disabled?: boolean;
50 className?: string;
51}) {
52 const id = useId();
53 return (
54 <div className={cn("flex items-start gap-2.5", disabled && "opacity-60", className)}>
55 <RadioGroupItem value={value} id={id} disabled={disabled} className="mt-0.5" aria-describedby={description ? `${id}-d` : undefined} />
56 <label htmlFor={id} className={cn("min-w-0 text-sm", disabled ? "cursor-not-allowed" : "cursor-pointer")}>
57 <span className="flex items-center gap-1.5 text-fg [&_svg]:size-3.5 [&_svg]:text-muted">
58 {icon}
59 {label}
60 </span>
61 {description && (
62 <span id={`${id}-d`} className="mt-0.5 block text-xs text-muted">
63 {description}
64 </span>
65 )}
66 </label>
67 </div>
68 );
69}
70
71/**
72 * A big selectable card. The whole card is the radio: it takes focus, and
73 * Space or a click chooses it. `badge` replaces the dot (for "Soon").
74 */
75export function RadioCard({
76 value,
77 title,
78 description,
79 icon,
80 badge,
81 disabled,
82 className,
83 children,
84}: {
85 value: string;
86 title: ReactNode;
87 description?: ReactNode;
88 icon?: ReactNode;
89 badge?: ReactNode;
90 disabled?: boolean;
91 className?: string;
92 children?: ReactNode;
93}) {
94 const id = useId();
95 return (
96 <Primitive.Item
97 value={value}
98 disabled={disabled}
99 aria-labelledby={`${id}-t`}
100 aria-describedby={description ? `${id}-d` : undefined}
101 className={cn(
102 "group/card relative flex h-full w-full flex-col items-start rounded-xl border border-line bg-surface/40 p-4 text-left outline-none transition-[border-color,background-color,box-shadow] duration-150",
103 "hover:border-line-strong hover:bg-surface",
104 "focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none",
105 "data-[state=checked]:border-accent/70 data-[state=checked]:bg-accent/[0.06] data-[state=checked]:shadow-[0_0_0_1px_color-mix(in_srgb,var(--color-accent)_35%,transparent),0_8px_24px_-12px_color-mix(in_srgb,var(--color-accent)_40%,transparent)]",
106 "disabled:cursor-not-allowed disabled:border-dashed disabled:bg-transparent disabled:opacity-60 disabled:hover:border-line",
107 className,
108 )}
109 >
110 <span className="flex w-full items-start justify-between gap-3">
111 {icon && (
112 <span className="flex size-8 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line transition-colors group-data-[state=checked]/card:bg-accent/10 group-data-[state=checked]/card:text-accent group-data-[state=checked]/card:ring-accent/30 [&_svg]:size-4">
113 {icon}
114 </span>
115 )}
116 {badge ? (
117 <span className="ml-auto rounded-full bg-raised px-2 py-0.5 text-[0.6875rem] text-muted ring-1 ring-line">{badge}</span>
118 ) : (
119 <span
120 aria-hidden="true"
121 className="ml-auto flex size-4 items-center justify-center rounded-full border border-line-strong transition-colors group-data-[state=checked]/card:border-accent"
122 >
123 <span className="size-2 scale-0 rounded-full bg-accent transition-transform duration-150 group-data-[state=checked]/card:scale-100" />
124 </span>
125 )}
126 </span>
127 <span id={`${id}-t`} className={cn("block text-sm font-medium text-fg", icon && "mt-3")}>
128 {title}
129 </span>
130 {description && (
131 <span id={`${id}-d`} className="mt-1 block text-xs leading-relaxed text-muted">
132 {description}
133 </span>
134 )}
135 {children}
136 </Primitive.Item>
137 );
138}