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

@@ -1,11 +1,21 @@
<script setup></script>
<script setup>
import { RouterLink, RouterView } from 'vue-router';
import Separator from './components/ui/separator/Separator.vue';
import Application from './pages/ApplicationForm.vue';
import Button from './components/ui/button/Button.vue';
</script>
<template>
<h1>You did it!</h1>
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
<div>
<div class="h-15 flex items-center justify-center gap-20">
<Button variant="link">Link</Button>
<Button variant="link">Link</Button>
<Button variant="link">Link</Button>
<Button variant="link">Link</Button>
</div>
<Separator></Separator>
<Application></Application>
</div>
</template>
<style scoped></style>

View File

@@ -1,102 +1,103 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.9911 0 0);
--foreground: oklch(0.2046 0 0);
--card: oklch(0.9911 0 0);
--card-foreground: oklch(0.2046 0 0);
--popover: oklch(0.9911 0 0);
--popover-foreground: oklch(0.4386 0 0);
--primary: oklch(1.0000 0 0);
--primary-foreground: oklch(0.3709 0.0313 95.1202);
--secondary: oklch(0.9940 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.9461 0 0);
--muted-foreground: oklch(0.2435 0 0);
--accent: oklch(0.9461 0 0);
--accent-foreground: oklch(0.2435 0 0);
--destructive: oklch(0.6704 0.2070 300.0793);
--background: oklch(0.2046 0 0);
--foreground: oklch(0.9219 0 0);
--card: oklch(0.2686 0 0);
--card-foreground: oklch(0.9219 0 0);
--popover: oklch(0.2686 0 0);
--popover-foreground: oklch(0.9219 0 0);
--primary: oklch(0.7686 0.1647 70.0804);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2686 0 0);
--secondary-foreground: oklch(0.9219 0 0);
--muted: oklch(0.2686 0 0);
--muted-foreground: oklch(0.7155 0 0);
--accent: oklch(0.4732 0.1247 46.2007);
--accent-foreground: oklch(0.9243 0.1151 95.7459);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.9037 0 0);
--input: oklch(0.9731 0 0);
--ring: oklch(1.0000 0 0);
--chart-1: oklch(1.0000 0 0);
--chart-2: oklch(0.9936 0.0111 141.2643);
--chart-3: oklch(1.0000 0 0);
--chart-4: oklch(0.8317 0.1444 322.3270);
--chart-5: oklch(0.9397 0.1746 103.9958);
--sidebar: oklch(0.9911 0 0);
--sidebar-foreground: oklch(0.5452 0 0);
--sidebar-primary: oklch(1.0000 0 0);
--sidebar-primary-foreground: oklch(0.3709 0.0313 95.1202);
--sidebar-accent: oklch(0.9461 0 0);
--sidebar-accent-foreground: oklch(0.2435 0 0);
--sidebar-border: oklch(0.9037 0 0);
--sidebar-ring: oklch(1.0000 0 0);
--font-sans: Outfit, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: monospace;
--radius: 0.5rem;
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
--tracking-normal: 0.025em;
--spacing: 0.25rem;
--border: oklch(0.3715 0 0);
--input: oklch(0.3715 0 0);
--ring: oklch(0.7686 0.1647 70.0804);
--chart-1: oklch(0.8369 0.1644 84.4286);
--chart-2: oklch(0.6658 0.1574 58.3183);
--chart-3: oklch(0.4732 0.1247 46.2007);
--chart-4: oklch(0.5553 0.1455 48.9975);
--chart-5: oklch(0.4732 0.1247 46.2007);
--sidebar: oklch(0.1684 0 0);
--sidebar-foreground: oklch(0.9219 0 0);
--sidebar-primary: oklch(0.7686 0.1647 70.0804);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.4732 0.1247 46.2007);
--sidebar-accent-foreground: oklch(0.9243 0.1151 95.7459);
--sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0.7686 0.1647 70.0804);
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 2px 4px -2px hsl(0 0% 0% / 0.14);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 4px 6px -2px hsl(0 0% 0% / 0.14);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 8px 10px -2px hsl(0 0% 0% / 0.14);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35);
}
.dark {
--background: oklch(0.1822 0 0);
--foreground: oklch(1.0000 0 0);
--card: oklch(0.2046 0 0);
--card-foreground: oklch(1.0000 0 0);
--popover: oklch(0.2603 0 0);
--popover-foreground: oklch(0.7348 0 0);
--primary: oklch(0.6277 0.1287 94.1115);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.2603 0 0);
--secondary-foreground: oklch(0.9851 0 0);
--muted: oklch(0.2393 0 0);
--muted-foreground: oklch(0.7122 0 0);
--accent: oklch(0.3132 0 0);
--accent-foreground: oklch(0.9851 0 0);
--destructive: oklch(0.3712 0.2143 284.7713);
--background: oklch(0.2046 0 0);
--foreground: oklch(0.9219 0 0);
--card: oklch(0.2686 0 0);
--card-foreground: oklch(0.9219 0 0);
--popover: oklch(0.2686 0 0);
--popover-foreground: oklch(0.9219 0 0);
--primary: oklch(0.7686 0.1647 70.0804);
--primary-foreground: oklch(0 0 0);
--secondary: oklch(0.2686 0 0);
--secondary-foreground: oklch(0.9219 0 0);
--muted: oklch(0.2686 0 0);
--muted-foreground: oklch(0.7155 0 0);
--accent: oklch(0.4732 0.1247 46.2007);
--accent-foreground: oklch(0.9243 0.1151 95.7459);
--destructive: oklch(0.6368 0.2078 25.3313);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.2809 0 0);
--input: oklch(0.2603 0 0);
--ring: oklch(0.9786 0.0203 81.7829);
--chart-1: oklch(0.9786 0.0203 81.7829);
--chart-2: oklch(1.0000 0 0);
--chart-3: oklch(1.0000 0 0);
--chart-4: oklch(0.9312 0.0608 325.1964);
--chart-5: oklch(0.9724 0.1085 114.3821);
--sidebar: oklch(0.1822 0 0);
--sidebar-foreground: oklch(0.6301 0 0);
--sidebar-primary: oklch(0.6277 0.1287 94.1115);
--border: oklch(0.3715 0 0);
--input: oklch(0.3715 0 0);
--ring: oklch(0.7686 0.1647 70.0804);
--chart-1: oklch(0.8369 0.1644 84.4286);
--chart-2: oklch(0.6658 0.1574 58.3183);
--chart-3: oklch(0.4732 0.1247 46.2007);
--chart-4: oklch(0.5553 0.1455 48.9975);
--chart-5: oklch(0.4732 0.1247 46.2007);
--sidebar: oklch(0.1684 0 0);
--sidebar-foreground: oklch(0.9219 0 0);
--sidebar-primary: oklch(0.7686 0.1647 70.0804);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.3132 0 0);
--sidebar-accent-foreground: oklch(0.9851 0 0);
--sidebar-border: oklch(0.2809 0 0);
--sidebar-ring: oklch(0.9786 0.0203 81.7829);
--font-sans: Outfit, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: monospace;
--radius: 0.5rem;
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09);
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17);
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17);
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17);
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17);
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43);
--sidebar-accent: oklch(0.4732 0.1247 46.2007);
--sidebar-accent-foreground: oklch(0.9243 0.1151 95.7459);
--sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0.7686 0.1647 70.0804);
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--radius: 0.375rem;
--shadow-2xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-xs: 0px 4px 8px -1px hsl(0 0% 0% / 0.07);
--shadow-sm: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 1px 2px -2px hsl(0 0% 0% / 0.14);
--shadow-md: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 2px 4px -2px hsl(0 0% 0% / 0.14);
--shadow-lg: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 4px 6px -2px hsl(0 0% 0% / 0.14);
--shadow-xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.14), 0px 8px 10px -2px hsl(0 0% 0% / 0.14);
--shadow-2xl: 0px 4px 8px -1px hsl(0 0% 0% / 0.35);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@@ -148,15 +149,13 @@
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-normal: var(--tracking-normal);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
}
body {
letter-spacing: var(--tracking-normal);
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

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";

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue';
import Checkbox from '@/components/ui/checkbox/Checkbox.vue';
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import Input from '@/components/ui/input/Input.vue';
import Label from '@/components/ui/label/Label.vue';
import Textarea from '@/components/ui/textarea/Textarea.vue';
import { toTypedSchema } from '@vee-validate/zod';
import { Form } from 'vee-validate';
import * as z from 'zod';
const formSchema = toTypedSchema(z.object({
age: z.coerce.number().min(0),
name: z.string(),
playtime: z.coerce.number().min(0),
hobbies: z.string(),
military: z.boolean(),
communities: z.string(),
joinReason: z.string(),
milsimAttraction: z.string(),
referral: z.string(),
steamProfile: z.string(),
timezone: z.string(),
canAttendSaturday: z.boolean(),
interests: z.string(),
aknowledgeRules: z.boolean(),
}))
function onSubmit(val) {
console.log(val)
}
</script>
<template>
<Form :validation-schema="formSchema" @submit="onSubmit" class="space-y-6 max-w-3xl mx-auto my-20">
<!-- Age -->
<FormField name="age" v-slot="{ componentField }">
<FormItem>
<FormLabel>What is your age?</FormLabel>
<FormControl>
<Input type="number" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Name -->
<FormField name="name" v-slot="{ componentField }">
<FormItem>
<FormLabel>What name will you be going by within the community?</FormLabel>
<FormDescription>This name must be consistent across platforms.</FormDescription>
<FormControl>
<Input v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Playtime -->
<FormField name="playtime" v-slot="{ componentField }">
<FormItem>
<FormLabel>How long have you played Arma 3 for (in hours)?</FormLabel>
<FormControl>
<Input type="number" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Hobbies -->
<FormField name="hobbies" v-slot="{ componentField }">
<FormItem>
<FormLabel>What hobbies do you like to participate in outside of gaming?</FormLabel>
<FormControl>
<Textarea rows="4" class="resize-none" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Military (boolean) -->
<FormField name="military" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Have you ever served in the military?</FormLabel>
<FormControl>
<div class="flex items-center gap-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<span>Yes (checked) / No (unchecked)</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Other communities (freeform) -->
<FormField name="communities" v-slot="{ componentField }">
<FormItem>
<FormLabel>Are you a part of any other communities? If so, which ones? If none, type "No"</FormLabel>
<FormControl>
<Input v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Why join -->
<FormField name="joinReason" v-slot="{ componentField }">
<FormItem>
<FormLabel>Why do you want to join our community?</FormLabel>
<FormControl>
<Textarea rows="4" class="resize-none" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Attraction to milsim -->
<FormField name="milsimAttraction" v-slot="{ componentField }">
<FormItem>
<FormLabel>What attracts you to the Arma 3 milsim playstyle?</FormLabel>
<FormControl>
<Textarea rows="4" class="resize-none" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Referral (freeform) -->
<FormField name="referral" v-slot="{ componentField }">
<FormItem>
<FormLabel>Where did you hear about us? (If another member, who?)</FormLabel>
<FormControl>
<Input placeholder="e.g., Reddit / Member: Alice" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Steam profile -->
<FormField name="steamProfile" v-slot="{ componentField }">
<FormItem>
<FormLabel>Steam profile link</FormLabel>
<FormDescription>
Format: <code>https://steamcommunity.com/id/USER/</code> or
<code>https://steamcommunity.com/profiles/STEAMID64/</code>
</FormDescription>
<FormControl>
<Input type="url" placeholder="https://steamcommunity.com/profiles/7656119..." v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Timezone -->
<FormField name="timezone" v-slot="{ componentField }">
<FormItem>
<FormLabel>What time zone are you in?</FormLabel>
<FormControl>
<Input placeholder="e.g., AEST, EST, UTC+10" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Attendance (boolean) -->
<FormField name="canAttendSaturday" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Are you able to attend weekly operations Saturdays @ 7pm CST?</FormLabel>
<FormControl>
<div class="flex items-center gap-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<span>Yes (checked) / No (unchecked)</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Interests / Playstyle (freeform) -->
<FormField name="interests" v-slot="{ componentField }">
<FormItem>
<FormLabel>Which playstyles interest you?</FormLabel>
<FormControl>
<Input placeholder="e.g., Rifleman; Medic; Pilot" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Code of Conduct (boolean, field name kept as-is) -->
<FormField name="aknowledgeRules" v-slot="{ value, handleChange }">
<FormItem>
<FormLabel>Community Code of Conduct</FormLabel>
<FormControl>
<div class="flex items-center gap-2">
<Checkbox :checked="value" @update:checked="handleChange" />
<span>By checking this box, you accept the <Button variant="link" class="p-0">Code of Conduct</Button>.</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<div class="pt-2">
<Button type="submit" class="w-full">Submit Application</Button>
</div>
</Form>
</template>

View File

@@ -2,7 +2,8 @@ import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [],
routes: [
],
})
export default router