diff --git a/.changeset/column-span-grid.md b/.changeset/column-span-grid.md new file mode 100644 index 0000000..30c189e --- /dev/null +++ b/.changeset/column-span-grid.md @@ -0,0 +1,9 @@ +--- +"@formrelay/core": patch +"@formrelay/vue": patch +"@formrelay/nuxt": patch +--- + +Add column span support for form field layout. The form schema now exposes `columns` (number of grid columns) and each field has a `columnSpan` property controlling its width. + +New `` component in `@formrelay/vue` and `@formrelay/nuxt` handles CSS grid layout automatically — consumers just define their field template via the `#field` scoped slot. diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index fe90ffc..5b5a022 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -20,6 +20,7 @@ const RAW_SCHEMA_RESPONSE = { id: "01abc", name: "Contact Form", is_active: true, + columns: 2, fields: [ { name: "email", @@ -30,6 +31,7 @@ const RAW_SCHEMA_RESPONSE = { options: null, help_text: "Your email", order: 0, + column_span: 2, }, { name: "subject", @@ -43,6 +45,7 @@ const RAW_SCHEMA_RESPONSE = { ], help_text: null, order: 1, + column_span: 1, }, ], validation_schema: { @@ -98,6 +101,7 @@ describe("createSchemaFetcher", () => { expect(schema.id).toBe("01abc"); expect(schema.name).toBe("Contact Form"); expect(schema.isActive).toBe(true); + expect(schema.columns).toBe(2); expect(schema.honeypotField).toBe("_hp_phone"); expect(schema.submitUrl).toBe("https://formrelay.app/api/v1/form/01abc"); expect(schema.botProtection).toEqual({ @@ -113,6 +117,7 @@ describe("createSchemaFetcher", () => { options: null, helpText: "Your email", order: 0, + columnSpan: 2, }); expect(schema.fields[1]!.options).toEqual([ { label: "General", value: "general" }, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 5012eba..6f3802c 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -14,6 +14,7 @@ interface RawSchemaResponse { id: string; name: string; is_active: boolean; + columns: number; fields: RawFormField[]; validation_schema: Record; honeypot_field: string | null; @@ -31,6 +32,7 @@ interface RawFormField { options: { label: string; value: string }[] | null; help_text: string | null; order: number; + column_span: number; } export function createSchemaFetcher( @@ -91,6 +93,7 @@ function transformSchema(raw: RawSchemaResponse["data"]): FormSchema { id: raw.id, name: raw.name, isActive: raw.is_active, + columns: raw.columns, fields: raw.fields.map(transformField), validationSchema: raw.validation_schema, honeypotField: raw.honeypot_field, @@ -114,5 +117,6 @@ function transformField(raw: RawFormField): FormField { options: raw.options, helpText: raw.help_text, order: raw.order, + columnSpan: raw.column_span, }; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index f101042..a91a709 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -4,6 +4,7 @@ export interface FormSchema { id: string; name: string; isActive: boolean; + columns: number; fields: FormField[]; validationSchema: JsonSchema; honeypotField: string | null; @@ -20,6 +21,7 @@ export interface FormField { options: FieldOption[] | null; helpText: string | null; order: number; + columnSpan: number; } export interface FieldOption { diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 4204ba3..9d20df0 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -31,5 +31,10 @@ export default defineNuxtModule({ name: "FormRelay", filePath: resolver.resolve("./runtime/components/FormRelay"), }); + + addComponent({ + name: "FormRelayGrid", + filePath: resolver.resolve("./runtime/components/FormRelayGrid"), + }); }, }); diff --git a/packages/nuxt/src/runtime/components/FormRelayGrid.ts b/packages/nuxt/src/runtime/components/FormRelayGrid.ts new file mode 100644 index 0000000..62869a2 --- /dev/null +++ b/packages/nuxt/src/runtime/components/FormRelayGrid.ts @@ -0,0 +1 @@ +export { FormRelayGrid as default } from "@formrelay/vue"; diff --git a/packages/vue/src/components/FormRelay.test.ts b/packages/vue/src/components/FormRelay.test.ts index d1e7290..a6849db 100644 --- a/packages/vue/src/components/FormRelay.test.ts +++ b/packages/vue/src/components/FormRelay.test.ts @@ -8,6 +8,7 @@ const mockSchema = { id: "01abc", name: "Test Form", isActive: true, + columns: 2, fields: [ { name: "email", @@ -18,6 +19,7 @@ const mockSchema = { options: null, helpText: null, order: 0, + columnSpan: 2, }, ], validationSchema: { type: "object" }, @@ -55,6 +57,7 @@ describe("FormRelay", () => { await nextTick(); expect(slotProps).toBeDefined(); + expect(slotProps.columns).toBe(2); expect(slotProps.fields).toBeDefined(); expect(slotProps.values).toBeDefined(); expect(slotProps.errors).toBeDefined(); diff --git a/packages/vue/src/components/FormRelay.ts b/packages/vue/src/components/FormRelay.ts index 6b36ea2..fb7405c 100644 --- a/packages/vue/src/components/FormRelay.ts +++ b/packages/vue/src/components/FormRelay.ts @@ -19,6 +19,7 @@ export default defineComponent({ return slots.default({ schema: state.schema.value, + columns: state.columns.value, fields: state.fields.value, schemaLoading: state.schemaLoading.value, schemaError: state.schemaError.value, diff --git a/packages/vue/src/components/FormRelayGrid.test.ts b/packages/vue/src/components/FormRelayGrid.test.ts new file mode 100644 index 0000000..704f024 --- /dev/null +++ b/packages/vue/src/components/FormRelayGrid.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment jsdom +import { describe, expect, test } from "vitest"; +import { mount } from "@vue/test-utils"; +import { h } from "vue"; +import FormRelayGrid from "./FormRelayGrid"; +import type { FormField } from "@formrelay/core"; + +const mockFields: FormField[] = [ + { + name: "first_name", + label: "First Name", + type: "text", + isRequired: true, + htmlInputType: "text", + options: null, + helpText: null, + order: 0, + columnSpan: 1, + }, + { + name: "last_name", + label: "Last Name", + type: "text", + isRequired: true, + htmlInputType: "text", + options: null, + helpText: null, + order: 1, + columnSpan: 1, + }, + { + name: "email", + label: "Email", + type: "email", + isRequired: true, + htmlInputType: "email", + options: null, + helpText: null, + order: 2, + columnSpan: 2, + }, +]; + +describe("FormRelayGrid", () => { + test("renders a grid container with correct columns", () => { + const wrapper = mount(FormRelayGrid, { + props: { fields: mockFields, columns: 2 }, + slots: { + field: ({ field }: { field: FormField }) => h("input", { name: field.name }), + }, + }); + + const container = wrapper.element as HTMLElement; + expect(container.style.display).toBe("grid"); + expect(container.style.gridTemplateColumns).toBe("repeat(2, 1fr)"); + }); + + test("applies columnSpan to each field wrapper", () => { + const wrapper = mount(FormRelayGrid, { + props: { fields: mockFields, columns: 2 }, + slots: { + field: ({ field }: { field: FormField }) => h("input", { name: field.name }), + }, + }); + + const children = wrapper.element.children; + expect(children).toHaveLength(3); + expect((children[0] as HTMLElement).style.gridColumn).toBe("span 1"); + expect((children[1] as HTMLElement).style.gridColumn).toBe("span 1"); + expect((children[2] as HTMLElement).style.gridColumn).toBe("span 2"); + }); + + test("passes field to the slot", () => { + const wrapper = mount(FormRelayGrid, { + props: { fields: mockFields, columns: 2 }, + slots: { + field: ({ field }: { field: FormField }) => + h("label", { "data-field": field.name }, field.label), + }, + }); + + const labels = wrapper.findAll("label"); + expect(labels).toHaveLength(3); + expect(labels[0]!.attributes("data-field")).toBe("first_name"); + expect(labels[1]!.attributes("data-field")).toBe("last_name"); + expect(labels[2]!.attributes("data-field")).toBe("email"); + }); + + test("renders nothing when no field slot is provided", () => { + const wrapper = mount(FormRelayGrid, { + props: { fields: mockFields, columns: 2 }, + }); + + expect(wrapper.element.nodeType).toBe(Node.COMMENT_NODE); + }); + + test("uses custom tag when provided", () => { + const wrapper = mount(FormRelayGrid, { + props: { fields: mockFields, columns: 2, tag: "fieldset" }, + slots: { + field: ({ field }: { field: FormField }) => h("input", { name: field.name }), + }, + }); + + expect(wrapper.element.tagName).toBe("FIELDSET"); + }); + + test("adapts to different column counts", () => { + const wrapper = mount(FormRelayGrid, { + props: { fields: mockFields, columns: 3 }, + slots: { + field: ({ field }: { field: FormField }) => h("input", { name: field.name }), + }, + }); + + expect((wrapper.element as HTMLElement).style.gridTemplateColumns).toBe("repeat(3, 1fr)"); + }); + + test("clamps columnSpan to columns when it exceeds the grid", () => { + const fields: FormField[] = [{ ...mockFields[0]!, columnSpan: 5 }]; + const wrapper = mount(FormRelayGrid, { + props: { fields, columns: 2 }, + slots: { + field: ({ field }: { field: FormField }) => h("input", { name: field.name }), + }, + }); + + expect((wrapper.element.children[0] as HTMLElement).style.gridColumn).toBe("span 2"); + }); + + test("clamps columnSpan to minimum of 1", () => { + const fields: FormField[] = [{ ...mockFields[0]!, columnSpan: 0 }]; + const wrapper = mount(FormRelayGrid, { + props: { fields, columns: 2 }, + slots: { + field: ({ field }: { field: FormField }) => h("input", { name: field.name }), + }, + }); + + expect((wrapper.element.children[0] as HTMLElement).style.gridColumn).toBe("span 1"); + }); + + test("renders empty grid container when fields array is empty", () => { + const wrapper = mount(FormRelayGrid, { + props: { fields: [], columns: 2 }, + slots: { + field: ({ field }: { field: FormField }) => h("input", { name: field.name }), + }, + }); + + const el = wrapper.element as HTMLElement; + expect(el.style.display).toBe("grid"); + expect(el.children).toHaveLength(0); + }); +}); diff --git a/packages/vue/src/components/FormRelayGrid.ts b/packages/vue/src/components/FormRelayGrid.ts new file mode 100644 index 0000000..2a934b3 --- /dev/null +++ b/packages/vue/src/components/FormRelayGrid.ts @@ -0,0 +1,44 @@ +import { defineComponent, h, type PropType } from "vue"; +import type { FormField } from "@formrelay/core"; + +export default defineComponent({ + name: "FormRelayGrid", + props: { + columns: { + type: Number, + default: 2, + validator: (v: number) => Number.isInteger(v) && v >= 1, + }, + fields: { type: Array as PropType, required: true }, + tag: { type: String, default: "div" }, + }, + setup(props, { slots }) { + return () => { + if (!slots.field) return null; + + const children = props.fields.map((field) => { + const span = Math.min(Math.max(1, field.columnSpan), props.columns); + + return h( + "div", + { + key: field.name, + style: { gridColumn: `span ${span}` }, + }, + slots.field!({ field }), + ); + }); + + return h( + props.tag, + { + style: { + display: "grid", + gridTemplateColumns: `repeat(${props.columns}, 1fr)`, + }, + }, + children, + ); + }; + }, +}); diff --git a/packages/vue/src/composables/useFormRelay.test.ts b/packages/vue/src/composables/useFormRelay.test.ts index 850b05c..f4982c0 100644 --- a/packages/vue/src/composables/useFormRelay.test.ts +++ b/packages/vue/src/composables/useFormRelay.test.ts @@ -14,6 +14,7 @@ const mockFields = [ options: null, helpText: null, order: 0, + columnSpan: 2, }, { name: "name", @@ -24,6 +25,7 @@ const mockFields = [ options: null, helpText: null, order: 1, + columnSpan: 1, }, ]; @@ -31,6 +33,7 @@ const mockSchema = { id: "01abc", name: "Test Form", isActive: true, + columns: 2, fields: mockFields, validationSchema: { type: "object" }, honeypotField: "_hp_phone", @@ -120,10 +123,11 @@ describe("useFormRelay", () => { test("fetches schema on init and populates state", async () => { mockGetSchema.mockResolvedValueOnce(mockSchemaWithBot); - const { schema, fields, schemaLoading, botProtection, validationSchema } = useFormRelay({ - formId: "01abc", - publicKey: "pk_fr_test", - }); + const { schema, columns, fields, schemaLoading, botProtection, validationSchema } = + useFormRelay({ + formId: "01abc", + publicKey: "pk_fr_test", + }); expect(schemaLoading.value).toBe(true); @@ -132,6 +136,7 @@ describe("useFormRelay", () => { expect(schemaLoading.value).toBe(false); expect(schema.value).toEqual(mockSchemaWithBot); + expect(columns.value).toBe(2); expect(fields.value).toHaveLength(2); expect(botProtection.value).toEqual({ type: "turnstile", diff --git a/packages/vue/src/composables/useFormRelay.ts b/packages/vue/src/composables/useFormRelay.ts index 84e3f04..68f4e7b 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -21,6 +21,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { const botToken = ref(null); + const columns = computed(() => schema.value?.columns || 2); const fields = computed(() => schema.value?.fields ?? []); const validationSchema = computed( () => schema.value?.validationSchema ?? null, @@ -163,6 +164,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { return { schema, + columns, fields, schemaLoading, schemaError, diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 355c988..5f54cb6 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,3 +1,4 @@ export { useFormRelay } from "./composables/useFormRelay"; export { default as FormRelay } from "./components/FormRelay"; +export { default as FormRelayGrid } from "./components/FormRelayGrid"; export type { UseFormRelayOptions, UseFormRelayReturn } from "./types"; diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 95e922c..4d567f8 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -19,6 +19,7 @@ export interface UseFormRelayOptions { export interface UseFormRelayReturn { schema: Ref; + columns: ComputedRef; fields: ComputedRef; schemaLoading: Ref; schemaError: Ref;