g1t/apps/web/app/components/ui/radio-group.tsx
| 1 | import { RadioGroup as Primitive } from "radix-ui"; |
| 2 | import { type ComponentProps, type ReactNode, useId } from "react"; |
| 3 | |
| 4 | import { 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 | |
| 15 | export 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. */ |
| 20 | export 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. */ |
| 37 | export 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 | */ |
| 75 | export 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 | } |