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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ Config: `registry.config.ts` and `components.json` `registries` field.

## Content

- `content.config.ts` defines the docs collection with `category` field: `element | content | marketing | chat | overview`
- `content.config.ts` defines the docs collection with `category` field: `form | content | marketing | chat | overview`
- `@nuxt/content` v3 with D1 database (`content.database.type: "d1"`)
- Markdown highlight disabled; `nuxt-shiki` module handles syntax highlighting instead

Expand Down
118 changes: 118 additions & 0 deletions app/components/demo/FormDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Textarea } from "~/components/ui/textarea";
import { Form, type FormError } from "~/registry/blocks/form";
import { FormField } from "~/registry/blocks/form-field";

const state = reactive({
email: "",
message: "",
role: "",
});
const submitted = ref(false);

function validate(values: typeof state): FormError[] {
const errors: FormError[] = [];

if (!values.email.includes("@")) {
errors.push({ name: "email", message: "Enter a valid email address." });
}
if (values.message.trim().length < 10) {
errors.push({ name: "message", message: "Message must be at least 10 characters." });
}
if (!values.role) {
errors.push({ name: "role", message: "Select a role." });
}

return errors;
}

function onSubmit() {
submitted.value = true;
}
</script>

<template>
<Form
:state="state"
:validate="validate"
class="w-full max-w-md space-y-4"
@submit="onSubmit"
>
<FormField
v-slot="field"
label="Email"
name="email"
description="Use your work email."
required
>
<Input
v-model="state.email"
v-bind="field"
type="email"
placeholder="you@example.com"
/>
</FormField>

<FormField
v-slot="field"
label="Message"
name="message"
help="Tell us what you are building."
>
<Textarea
v-model="state.message"
v-bind="field"
placeholder="I am building..."
rows="3"
/>
</FormField>

<FormField
v-slot="field"
label="Role"
name="role"
>
<Select v-model="state.role">
<SelectTrigger
class="w-full"
v-bind="field"
>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">
Admin
</SelectItem>
<SelectItem value="editor">
Editor
</SelectItem>
<SelectItem value="viewer">
Viewer
</SelectItem>
</SelectContent>
</Select>
</FormField>

<div class="flex items-center gap-3">
<Button type="submit">
Submit
</Button>
<p
v-if="submitted"
class="text-sm text-muted-foreground"
>
Submitted locally. Your app owns the API call.
</p>
</div>
</Form>
</template>
32 changes: 32 additions & 0 deletions app/components/demo/FormFieldDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { Input } from "~/components/ui/input";
import { FormField } from "~/registry/blocks/form-field";
</script>

<template>
<div class="w-full max-w-md space-y-4">
<FormField
v-slot="field"
label="Project name"
description="This appears in your workspace switcher."
hint="Required"
required
>
<Input
v-bind="field"
placeholder="Acme dashboard"
/>
</FormField>

<FormField
v-slot="field"
label="Slug"
error="Use lowercase letters, numbers, and dashes only."
>
<Input
v-bind="field"
model-value="Acme Dashboard"
/>
</FormField>
</div>
</template>
25 changes: 25 additions & 0 deletions app/components/ui/field/Field.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

const props = withDefaults(defineProps<{
orientation?: "vertical" | "horizontal" | "responsive";
class?: HTMLAttributes["class"];
}>(), {
orientation: "vertical",
});
</script>

<template>
<div
data-slot="field"
:data-orientation="orientation"
:class="cn(
'grid gap-2 data-[orientation=horizontal]:grid-cols-[auto_1fr] data-[orientation=horizontal]:items-center data-[orientation=responsive]:gap-3 @md/field-group:data-[orientation=responsive]:grid-cols-[auto_1fr] @md/field-group:data-[orientation=responsive]:items-center',
props.class,
)"
role="group"
>
<slot />
</div>
</template>
15 changes: 15 additions & 0 deletions app/components/ui/field/FieldContent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>

<template>
<div
data-slot="field-content"
:class="cn('grid gap-1.5 leading-none', props.class)"
>
<slot />
</div>
</template>
15 changes: 15 additions & 0 deletions app/components/ui/field/FieldDescription.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>

<template>
<p
data-slot="field-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
47 changes: 47 additions & 0 deletions app/components/ui/field/FieldError.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { computed } from "vue";
import { cn } from "@/lib/utils";

export interface FieldErrorItem {
message?: string;
}

const props = defineProps<{
error?: string | boolean | null;
errors?: Array<string | FieldErrorItem | undefined> | null;
class?: HTMLAttributes["class"];
}>();

const messages = computed(() => {
if (typeof props.error === "string") return [props.error];
return (props.errors ?? [])
.map(error => typeof error === "string" ? error : error?.message)
.filter((message): message is string => Boolean(message));
});
</script>

<template>
<div
v-if="messages.length || $slots.default"
data-slot="field-error"
:class="cn('text-destructive text-sm font-medium', props.class)"
>
<slot>
<p v-if="messages.length === 1">
{{ messages[0] }}
</p>
<ul
v-else
class="list-disc ps-5"
>
<li
v-for="message in messages"
:key="message"
>
{{ message }}
</li>
</ul>
</slot>
</div>
</template>
15 changes: 15 additions & 0 deletions app/components/ui/field/FieldGroup.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>

<template>
<div
data-slot="field-group"
:class="cn('@container/field-group grid gap-6', props.class)"
>
<slot />
</div>
</template>
20 changes: 20 additions & 0 deletions app/components/ui/field/FieldLabel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { LabelProps } from "reka-ui";
import type { HTMLAttributes } from "vue";
import { reactiveOmit } from "@vueuse/core";
import { Label } from "reka-ui";
import { cn } from "@/lib/utils";

const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>();
const delegatedProps = reactiveOmit(props, "class");
</script>

<template>
<Label
data-slot="field-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>
21 changes: 21 additions & 0 deletions app/components/ui/field/FieldLegend.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

const props = withDefaults(defineProps<{
variant?: "legend" | "label";
class?: HTMLAttributes["class"];
}>(), {
variant: "legend",
});
</script>

<template>
<legend
data-slot="field-legend"
:data-variant="variant"
:class="cn('mb-3 font-medium data-[variant=legend]:text-base data-[variant=label]:text-sm', props.class)"
>
<slot />
</legend>
</template>
15 changes: 15 additions & 0 deletions app/components/ui/field/FieldSeparator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>

<template>
<div
data-slot="field-separator"
:class="cn('bg-border h-px w-full', props.class)"
>
<slot />
</div>
</template>
15 changes: 15 additions & 0 deletions app/components/ui/field/FieldSet.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>

<template>
<fieldset
data-slot="field-set"
:class="cn('grid gap-6', props.class)"
>
<slot />
</fieldset>
</template>
15 changes: 15 additions & 0 deletions app/components/ui/field/FieldTitle.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { cn } from "@/lib/utils";

const props = defineProps<{ class?: HTMLAttributes["class"] }>();
</script>

<template>
<div
data-slot="field-title"
:class="cn('text-sm leading-none font-medium', props.class)"
>
<slot />
</div>
</template>
10 changes: 10 additions & 0 deletions app/components/ui/field/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export { default as Field } from "./Field.vue";
export { default as FieldContent } from "./FieldContent.vue";
export { default as FieldDescription } from "./FieldDescription.vue";
export { default as FieldError, type FieldErrorItem } from "./FieldError.vue";
export { default as FieldGroup } from "./FieldGroup.vue";
export { default as FieldLabel } from "./FieldLabel.vue";
export { default as FieldLegend } from "./FieldLegend.vue";
export { default as FieldSeparator } from "./FieldSeparator.vue";
export { default as FieldSet } from "./FieldSet.vue";
export { default as FieldTitle } from "./FieldTitle.vue";
Loading
Loading