Custom Form components
Splade comes with a great set of built-in Form Components, but your application might require a custom component as part of a form. There are several ways to implement this, so we’ll look at implementing the same custom component differently.
Card Select component
The component we'll be implementing is a card selector. In this example, two cards represent a subscription plan (basic or pro). Clicking on one of the cards will update the form data, as the cards are built around a radio
input element. Here's what it looks like:
Blade implementation
First, let's take a look at a Blade implementation. This implementation works without any custom Vue components. For the sake of simplicity, we'll hard-code both plans, and we don't extract each card into a separate component.
<x-splade-form default="{ plan: 'basic' }"> <div class="flex flex-row justify-center space-x-4"> {{-- Plan Basic --}} <label class="w-64 cursor-pointer rounded-md shadow-sm bg-white border-2 py-8 px-4" :class="{ 'border-white': form.plan !== 'basic', 'border-green-500': form.plan === 'basic' }"> <input type="radio" value="basic" v-model="form.plan" class="sr-only" /> <div class="flex flex-row justify-between items-center"> <span class="font-bold">Plan Basic</span> <svg v-show="form.plan === 'basic'" class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> </div> <span class="text-sm">$ 9.99 / month</span> </label> {{-- Plan Pro --}} <label class="w-64 cursor-pointer rounded-md shadow-sm bg-white border-2 py-8 px-4" :class="{ 'border-white': form.plan !== 'pro', 'border-green-500': form.plan === 'pro' }"> <input type="radio" value="pro" v-model="form.plan" class="sr-only" /> <div class="flex flex-row justify-between items-center"> <span class="font-bold">Plan Pro</span> <svg v-show="form.plan === 'pro'" class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> </div> <span class="text-sm">$ 19.99 / month</span> </label> </div></x-splade-form>
Let's break down what's happening here. First, there's a Form component with a default basic plan.
<x-splade-form default="{ plan: 'basic' }">
Then there are two Cards built around a label
element. The border color is based on whether the plan is selected or not.
<label :class="{ 'border-white': form.plan !== 'basic', 'border-green-500': form.plan === 'basic' }">
Within the label
element, there's an input
element. It has the value
set to the plan and two-way binding by setting the v-model
to form.plan
.
<input type="radio" value="basic" v-model="form.plan" class="sr-only" />
Lastly, an SVG
element (checkmark icon) that's only visible when the plan is selected.
<svg v-show="form.plan === 'basic'"> <path d="..." /></svg>
Extract Card into Blade Component
We begin by creating a new Blade Component. As this is a fairly simple component, we'll create an anonymous component with only a view:
php artisan make:component CardLabel --view
Then, we can move the Card component out of the form and paste it into the new Blade component view. We'll replace all hardcoded values with variables. To pass PHP values to Vue, always use Laravel's @js
directive. Note how the radio
input element uses a :value
attribute (with a colon).
<label class="w-64 cursor-pointer rounded-md shadow-sm bg-white border-2 py-8 px-4" :class="{ 'border-white': form.plan !== @js($plan), 'border-green-500': form.plan === @js($plan) }"> <input type="radio" class="sr-only" :value="@js($plan)" v-model="form.plan" /> <div class="flex flex-row justify-between items-center"> <span class="font-bold">{{ $title }}</span> <svg v-show="form.plan === @js($plan)" class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> </div> <span class="text-sm">$ {{ $price }} / month</span></label>
Lastly, we'll update the form to use the new component:
<x-splade-form default="{ plan: 'basic' }"> <div class="flex flex-row justify-center space-x-4"> <x-card-label plan="basic" title="Plan Basic" price="9.99" /> <x-card-label plan="pro" title="Plan Pro" price="19.99" /> </div></x-splade-form>
Vue implementation
Instead of building the Card component with a Blade template, you could also create a custom Vue component. Again, there are many ways of doing this, but let's show two examples corresponding to the Blade examples.
Matching the first Blade example, you could create a Vue component that contains both plans. We'll create a PlanSelector.vue
component and pass the form
object as a property. By passing the entire form
object to the component, you can interact with it within the Vue component by using the Form API. Remember to register the custom Vue component in the main app.js
file.
<template> <div class="flex flex-row justify-center space-x-4"> <label class="w-64 cursor-pointer rounded-md shadow-sm bg-white border-2 py-8 px-4" :class="{ 'border-white': form.plan !== 'basic', 'border-green-500': form.plan === 'basic', }"> <input type="radio" value="basic" v-model="form.plan" class="sr-only" /> <div class="flex flex-row justify-between items-center"> <span class="font-bold">Plan Basic</span> <svg v-show="form.plan === 'basic'" class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> </div> <span class="text-sm">$ 9.99 / month</span> </label> <label class="w-64 cursor-pointer rounded-md shadow-sm bg-white border-2 py-8 px-4" :class="{ 'border-white': form.plan !== 'pro', 'border-green-500': form.plan === 'pro', }"> <input type="radio" value="pro" v-model="form.plan" class="sr-only" /> <div class="flex flex-row justify-between items-center"> <span class="font-bold">Plan Pro</span> <svg v-show="form.plan === 'pro'" class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> </div> <span class="text-sm">$ 19.99 / month</span> </label> </div></template> <script setup>defineProps({ form: { type: Object, required: true, },});</script>
Then in the Blade template, you'd use this component and pass the form
object:
<x-splade-form default="{ plan: 'basic' }"> <PlanSelector :form="form" /></x-splade-form>
Replace Form object with v-model
Instead of passing the entire form
object to the Vue component, you could also use a v-model
directive:
<x-splade-form default="{ plan: 'basic' }"> <PlanSelector v-model="form.plan" /></x-splade-form>
Then we'd need to update the Vue component to work as a custom input. We'll replace form.plan
with modelValue
, and replace the v-model
directive on the input element with :checked
and @change
attributes.
<template> <div class="flex flex-row justify-center space-x-4"> <label class="w-64 cursor-pointer rounded-md shadow-sm bg-white border-2 py-8 px-4" :class="{ 'border-white': modelValue !== 'basic', 'border-green-500': modelValue === 'basic', }"> <input type="radio" value="basic" class="sr-only" :checked="modelValue === 'basic'" @change="$emit('update:modelValue', 'basic')" /> <div class="flex flex-row justify-between items-center"> <span class="font-bold">Plan Basic</span> <svg v-show="modelValue === 'basic'" class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> </div> <span class="text-sm">$ 9.99 / month</span> </label> <!-- Other plans... --> </div></template> <script>export default { props: { modelValue: String, }, emits: ["update:modelValue"],};</script>
Again, this works fine, but you don't want to hardcode the plans into the Vue component.
Extract Card into Vue Component
Let's take the Vue implementation one step further, and extract the Cards into a dedicated Vue component. We'll name this component CardLabel.vue
. As the plans are now dynamic, we'll add a plan
, price
, and title
property:
<template> <label class="w-64 cursor-pointer rounded-md shadow-sm bg-white border-2 py-8 px-4" :class="{ 'border-white': modelValue !== plan, 'border-green-500': modelValue === plan, }"> <input type="radio" :value="plan" class="sr-only" :checked="modelValue === plan" @change="$emit('update:modelValue', plan)" /> <div class="flex flex-row justify-between items-center"> <span class="font-bold" v-text="title" /> <svg v-show="modelValue === plan" class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> </svg> </div> <span class="text-sm">$ {{ price }} / month</span> </label></template> <script>export default { props: { modelValue: String, plan: String, price: String, title: String, }, emits: ["update:modelValue"],};</script>
You can use the new CardLabel
component in the Blade template:
<x-splade-form default="{ plan: 'basic' }"> <div class="flex flex-row justify-center space-x-4"> <CardLabel v-model="form.plan" plan="basic" title="Plan Basic" price="9.99" /> <CardLabel v-model="form.plan" plan="pro" title="Plan Pro" price="19.99" /> </div></x-splade-form>
Renderless Vue component
Vue also supports renderless components, which don't include a template section. So in the example below, the total
value will be updated whenever the price
or amount
value change.
<x-splade-form> <Calculator :form="form"> <x-splade-input label="Price" name="price" /> <x-splade-input label="Amount" name="amount" /> <x-splade-input label="Total" name="total" disabled /> </Calculator></x-splade-form>
We'll pass the form
object to the Vue component again and then set up watchers for the price
and amount
values, which will call the updateTotal
method:
<script>export default { props: { form: Object, }, watch: { "form.amount"() { this.updateTotal(); }, "form.price"() { this.updateTotal(); }, }, methods: { updateTotal() { this.form.total = this.form.price && this.form.amount ? this.form.price * this.form.amount : 0; }, }, render() { return this.$slots.default({}); },};</script>