Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions app/frontend/components/delete-user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Form } from "@inertiajs/react"
import { useRef } from "react"

import HeadingSmall from "@/components/heading-small"
import InputError from "@/components/input-error"
import { Button } from "@/components/ui/button"
import {
Dialog,
Expand All @@ -13,8 +12,8 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { usersPath } from "@/routes"

export default function DeleteUser() {
Expand Down Expand Up @@ -59,10 +58,13 @@ export default function DeleteUser() {
>
{({ resetAndClearErrors, processing, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="password_challenge" className="sr-only">
<Field>
<FieldLabel
htmlFor="password_challenge"
className="sr-only"
>
Password
</Label>
</FieldLabel>

<Input
id="password_challenge"
Expand All @@ -73,8 +75,12 @@ export default function DeleteUser() {
autoComplete="current-password"
/>

<InputError messages={errors.password_challenge} />
</div>
<FieldError
errors={errors.password_challenge?.map((message) => ({
message,
}))}
/>
</Field>

<DialogFooter>
<DialogClose asChild>
Expand Down
246 changes: 246 additions & 0 deletions app/frontend/components/ui/field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { type VariantProps, cva } from "class-variance-authority"
import { useMemo } from "react"

import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"

function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
)
}

function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className,
)}
{...props}
/>
)
}

function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"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",
className,
)}
{...props}
/>
)
}

const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
},
)

function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}

function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className,
)}
{...props}
/>
)
}

function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"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",
"has-data-[state=checked]:border-primary has-data-[state=checked]:bg-primary/5 dark:has-data-[state=checked]:bg-primary/10",
className,
)}
{...props}
/>
)
}

function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
)
}

function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className,
)}
{...props}
/>
)
}

function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}

function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: ({ message?: string } | undefined)[]
}) {
const content = useMemo(() => {
if (children) {
return children
}

if (!errors?.length) {
return null
}

const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]

if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}

return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
)
}, [children, errors])

if (!content) {
return null
}

return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}

export {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
}
50 changes: 29 additions & 21 deletions app/frontend/pages/identity/password_resets/edit.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { Form, Head } from "@inertiajs/react"

import InputError from "@/components/input-error"
import { Button } from "@/components/ui/button"
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
import AuthLayout from "@/layouts/auth-layout"
import { identityPasswordResetPath } from "@/routes"
Expand All @@ -27,56 +31,60 @@ export default function ResetPassword({ sid, email }: ResetPasswordProps) {
resetOnSuccess={["password", "password_confirmation"]}
>
{({ processing, errors }) => (
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
name="email"
autoComplete="email"
value={email}
className="mt-1 block w-full"
readOnly
/>
<InputError messages={errors.email} className="mt-2" />
</div>
<FieldError
errors={errors.email?.map((message) => ({ message }))}
/>
</Field>

<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Field>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input
id="password"
type="password"
name="password"
autoComplete="new-password"
className="mt-1 block w-full"
autoFocus
placeholder="Password"
/>
<InputError messages={errors.password} />
</div>
<FieldError
errors={errors.password?.map((message) => ({ message }))}
/>
</Field>

<div className="grid gap-2">
<Label htmlFor="password_confirmation">Confirm password</Label>
<Field>
<FieldLabel htmlFor="password_confirmation">
Confirm password
</FieldLabel>
<Input
id="password_confirmation"
type="password"
name="password_confirmation"
autoComplete="new-password"
className="mt-1 block w-full"
placeholder="Confirm password"
/>
<InputError
messages={errors.password_confirmation}
className="mt-2"
<FieldError
errors={errors.password_confirmation?.map((message) => ({
message,
}))}
/>
</div>
</Field>

<Button type="submit" className="mt-4 w-full" disabled={processing}>
{processing && <Spinner />}
Reset password
</Button>
</div>
</FieldGroup>
)}
</Form>
</AuthLayout>
Expand Down
Loading