added application form UI

This commit is contained in:
2025-08-14 13:25:08 -04:00
parent 7277b1a355
commit 59109ef298
26 changed files with 1010 additions and 107 deletions

View File

@@ -0,0 +1,24 @@
<script setup>
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from ".";
const props = defineProps({
variant: { type: null, required: false },
size: { type: null, required: false },
class: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false, default: "button" },
});
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,34 @@
import { cva } from "class-variance-authority";
export { default as Button } from "./Button.vue";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);

View File

@@ -0,0 +1,46 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: [Boolean, String], required: false },
modelValue: { type: [Boolean, String, null], required: false },
disabled: { type: Boolean, required: false },
value: { type: null, required: false },
id: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<CheckboxRoot
data-slot="checkbox"
v-bind="forwarded"
:class="
cn(
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="flex items-center justify-center text-current transition-none"
>
<slot>
<Check class="size-3.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Checkbox } from "./Checkbox.vue";

View File

@@ -0,0 +1,19 @@
<script setup>
import { Slot } from "reka-ui";
import { useFormField } from "./useFormField";
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
</script>
<template>
<Slot
:id="formItemId"
data-slot="form-control"
:aria-describedby="
!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`
"
:aria-invalid="!!error"
>
<slot />
</Slot>
</template>

View File

@@ -0,0 +1,20 @@
<script setup>
import { cn } from "@/lib/utils";
import { useFormField } from "./useFormField";
const props = defineProps({
class: { type: null, required: false },
});
const { formDescriptionId } = useFormField();
</script>
<template>
<p
:id="formDescriptionId"
data-slot="form-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { useId } from "reka-ui";
import { provide } from "vue";
import { cn } from "@/lib/utils";
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
const props = defineProps({
class: { type: null, required: false },
});
const id = useId();
provide(FORM_ITEM_INJECTION_KEY, id);
</script>
<template>
<div data-slot="form-item" :class="cn('grid gap-2', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup>
import { cn } from "@/lib/utils";
import { Label } from '@/components/ui/label';
import { useFormField } from "./useFormField";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const { error, formItemId } = useFormField();
</script>
<template>
<Label
data-slot="form-label"
:data-error="!!error"
:class="cn('data-[error=true]:text-destructive-foreground', props.class)"
:for="formItemId"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import { ErrorMessage } from "vee-validate";
import { toValue } from "vue";
import { cn } from "@/lib/utils";
import { useFormField } from "./useFormField";
const props = defineProps({
class: { type: null, required: false },
});
const { name, formMessageId } = useFormField();
</script>
<template>
<ErrorMessage
:id="formMessageId"
data-slot="form-message"
as="p"
:name="toValue(name)"
:class="cn('text-destructive-foreground text-sm', props.class)"
/>
</template>

View File

@@ -0,0 +1,11 @@
export { default as FormControl } from "./FormControl.vue";
export { default as FormDescription } from "./FormDescription.vue";
export { default as FormItem } from "./FormItem.vue";
export { default as FormLabel } from "./FormLabel.vue";
export { default as FormMessage } from "./FormMessage.vue";
export { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
export {
Form,
Field as FormField,
FieldArray as FormFieldArray,
} from "vee-validate";

View File

@@ -0,0 +1 @@
export const FORM_ITEM_INJECTION_KEY = Symbol();

View File

@@ -0,0 +1,36 @@
import {
FieldContextKey,
useFieldError,
useIsFieldDirty,
useIsFieldTouched,
useIsFieldValid,
} from "vee-validate";
import { inject } from "vue";
import { FORM_ITEM_INJECTION_KEY } from "./injectionKeys";
export function useFormField() {
const fieldContext = inject(FieldContextKey);
const fieldItemContext = inject(FORM_ITEM_INJECTION_KEY);
if (!fieldContext)
throw new Error("useFormField should be used within <FormField>");
const { name } = fieldContext;
const id = fieldItemContext;
const fieldState = {
valid: useIsFieldValid(name),
isDirty: useIsFieldDirty(name),
isTouched: useIsFieldTouched(name),
error: useFieldError(name),
};
return {
id,
name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
}

View File

@@ -0,0 +1,32 @@
<script setup>
import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
});
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Input } from "./Input.vue";

View File

@@ -0,0 +1,29 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Label } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@@ -0,0 +1 @@
export { default as Label } from "./Label.vue";

View File

@@ -0,0 +1,28 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Separator } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
orientation: { type: String, required: false, default: "horizontal" },
decorative: { type: Boolean, required: false, default: true },
asChild: { type: Boolean, required: false },
as: { type: [String, Object, Function], required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Separator
data-slot="separator-root"
v-bind="delegatedProps"
:class="
cn(
`bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px`,
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Separator } from "./Separator.vue";

View File

@@ -0,0 +1,30 @@
<script setup>
import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false },
});
const emits = defineEmits(["update:modelValue"]);
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
});
</script>
<template>
<textarea
v-model="modelValue"
data-slot="textarea"
:class="
cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
props.class,
)
"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Textarea } from "./Textarea.vue";