field.tsx 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. "use client"
  2. import { useMemo } from "react"
  3. import { cva, type VariantProps } from "class-variance-authority"
  4. import { cn } from "@/lib/utils"
  5. import { Label } from "@/components/ui/label"
  6. import { Separator } from "@/components/ui/separator"
  7. function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
  8. return (
  9. <fieldset
  10. data-slot="field-set"
  11. className={cn(
  12. "flex flex-col gap-6",
  13. "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
  14. className
  15. )}
  16. {...props}
  17. />
  18. )
  19. }
  20. function FieldLegend({
  21. className,
  22. variant = "legend",
  23. ...props
  24. }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
  25. return (
  26. <legend
  27. data-slot="field-legend"
  28. data-variant={variant}
  29. className={cn(
  30. "mb-3 font-medium",
  31. "data-[variant=legend]:text-base",
  32. "data-[variant=label]:text-sm",
  33. className
  34. )}
  35. {...props}
  36. />
  37. )
  38. }
  39. function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
  40. return (
  41. <div
  42. data-slot="field-group"
  43. className={cn(
  44. "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
  45. className
  46. )}
  47. {...props}
  48. />
  49. )
  50. }
  51. const fieldVariants = cva(
  52. "group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
  53. {
  54. variants: {
  55. orientation: {
  56. vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
  57. horizontal: [
  58. "flex-row items-center",
  59. "[&>[data-slot=field-label]]:flex-auto",
  60. "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
  61. ],
  62. responsive: [
  63. "flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
  64. "@md/field-group:[&>[data-slot=field-label]]:flex-auto",
  65. "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
  66. ],
  67. },
  68. },
  69. defaultVariants: {
  70. orientation: "vertical",
  71. },
  72. }
  73. )
  74. function Field({
  75. className,
  76. orientation = "vertical",
  77. ...props
  78. }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
  79. return (
  80. <div
  81. role="group"
  82. data-slot="field"
  83. data-orientation={orientation}
  84. className={cn(fieldVariants({ orientation }), className)}
  85. {...props}
  86. />
  87. )
  88. }
  89. function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
  90. return (
  91. <div
  92. data-slot="field-content"
  93. className={cn(
  94. "group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
  95. className
  96. )}
  97. {...props}
  98. />
  99. )
  100. }
  101. function FieldLabel({
  102. className,
  103. ...props
  104. }: React.ComponentProps<typeof Label>) {
  105. return (
  106. <Label
  107. data-slot="field-label"
  108. className={cn(
  109. "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
  110. "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
  111. "has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
  112. className
  113. )}
  114. {...props}
  115. />
  116. )
  117. }
  118. function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
  119. return (
  120. <div
  121. data-slot="field-label"
  122. className={cn(
  123. "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
  124. className
  125. )}
  126. {...props}
  127. />
  128. )
  129. }
  130. function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
  131. return (
  132. <p
  133. data-slot="field-description"
  134. className={cn(
  135. "text-sm leading-normal font-normal text-muted-foreground group-has-[[data-orientation=horizontal]]/field:text-balance",
  136. "last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
  137. "[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
  138. className
  139. )}
  140. {...props}
  141. />
  142. )
  143. }
  144. function FieldSeparator({
  145. children,
  146. className,
  147. ...props
  148. }: React.ComponentProps<"div"> & {
  149. children?: React.ReactNode
  150. }) {
  151. return (
  152. <div
  153. data-slot="field-separator"
  154. data-content={!!children}
  155. className={cn(
  156. "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
  157. className
  158. )}
  159. {...props}
  160. >
  161. <Separator className="absolute inset-0 top-1/2" />
  162. {children && (
  163. <span
  164. className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
  165. data-slot="field-separator-content"
  166. >
  167. {children}
  168. </span>
  169. )}
  170. </div>
  171. )
  172. }
  173. function FieldError({
  174. className,
  175. children,
  176. errors,
  177. ...props
  178. }: React.ComponentProps<"div"> & {
  179. errors?: Array<{ message?: string } | undefined>
  180. }) {
  181. const content = useMemo(() => {
  182. if (children) {
  183. return children
  184. }
  185. if (!errors?.length) {
  186. return null
  187. }
  188. const uniqueErrors = [
  189. ...new Map(errors.map((error) => [error?.message, error])).values(),
  190. ]
  191. if (uniqueErrors?.length == 1) {
  192. return uniqueErrors[0]?.message
  193. }
  194. return (
  195. <ul className="ml-4 flex list-disc flex-col gap-1">
  196. {uniqueErrors.map(
  197. (error, index) =>
  198. error?.message && <li key={index}>{error.message}</li>
  199. )}
  200. </ul>
  201. )
  202. }, [children, errors])
  203. if (!content) {
  204. return null
  205. }
  206. return (
  207. <div
  208. role="alert"
  209. data-slot="field-error"
  210. className={cn("text-sm font-normal text-destructive", className)}
  211. {...props}
  212. >
  213. {content}
  214. </div>
  215. )
  216. }
  217. export {
  218. Field,
  219. FieldLabel,
  220. FieldDescription,
  221. FieldError,
  222. FieldGroup,
  223. FieldLegend,
  224. FieldSeparator,
  225. FieldSet,
  226. FieldContent,
  227. FieldTitle,
  228. }