Rent a car: A custom item renderer
The default dropdown item template is just a label. Real applications usually want richer items — a thumbnail, the title, secondary metadata. GolemUI lets you supply a custom item renderer for that.
Step 1 — Enrich the data
Section titled “Step 1 — Enrich the data”The car items now carry an emoji thumbnail and a price:
items: [ { id: 'compact', label: 'Compact', img: '🚗', price: 35 }, { id: 'suv', label: 'SUV', img: '🚙', price: 75 }, { id: 'convertible', label: 'Convertible', img: '🏎️', price: 110 }, { id: 'luxury', label: 'Luxury', img: '🚘', price: 180 },],"props": { "items": [ { "id": "compact", "label": "Compact", "img": "🚗", "price": 35 }, { "id": "suv", "label": "SUV", "img": "🚙", "price": 75 }, { "id": "convertible", "label": "Convertible", "img": "🏎️", "price": 110 }, { "id": "luxury", "label": "Luxury", "img": "🚘", "price": 180 } ]}Step 2 — Implement the renderer
Section titled “Step 2 — Implement the renderer”Item renderers are framework-specific.
import type { ReactItemRenderer } from '@golemui/react';
type Car = { id: string; label: string; img: string; price: number };
export const CarItemRenderer: ReactItemRenderer<Car> = ({ template, selected }) => ( <div className={`car-renderer${selected ? ' selected' : ''}`}> <span className="car-renderer__img">{template?.img}</span> <span className="car-renderer__label">{template?.label}</span> <span className="car-renderer__price">${template?.price}/day</span> </div>);template is the per-item data — typed as Car | undefined because the list can render placeholder rows while items load, so use optional chaining (template?.foo).
import { Component, ViewEncapsulation, input } from '@angular/core';import { type AngularItemRenderContext } from '@golemui/angular';
type Car = { id: string; label: string; img: string; price: number };
@Component({ standalone: true, selector: 'car-item-renderer', encapsulation: ViewEncapsulation.None, template: ` <div class="car-renderer" [class.selected]="selected()"> <span class="car-renderer__img">{{ template().img }}</span> <span class="car-renderer__label">{{ template().label }}</span> <span class="car-renderer__price">\${{ template().price }}/day</span> </div> `,})export class CarItemRenderer implements AngularItemRenderContext<Car> { template = input.required<Car>(); value = input.required<string | number>(); index = input.required<number>(); selected = input<boolean | undefined>(undefined); disabled = input<boolean | undefined>(undefined); focused = input<boolean | undefined>(undefined);}implements AngularItemRenderContext<Car> is the contract for signal-based renderers — TypeScript enforces every input. If you prefer the classic @Input() decorator style, implements ItemRenderContext<Car> from @golemui/core works instead; see Extending / Item Renderers for both forms.
encapsulation: ViewEncapsulation.None lets the global styles.scss rules (Step 3) reach the rendered items — the dropdown projects each item into its own overlay, and Angular’s default emulated scoping would stop the selectors from matching.
import { html, type TemplateResult } from 'lit';import { classMap } from 'lit/directives/class-map.js';import { type ItemRenderContext } from '@golemui/core';
type Car = { id: string; label: string; img: string; price: number };
export const carItemRenderer = (ctx: ItemRenderContext<Car>): TemplateResult => { const classes = { 'car-renderer': true, disabled: !!ctx.disabled, selected: !!ctx.selected, focused: !!ctx.focused, odd: ctx.index % 2 !== 0, };
return html` <div class=${classMap(classes)}> <span class="car-renderer__img">${ctx.template.img}</span> <span class="car-renderer__label">${ctx.template.label}</span> <span class="car-renderer__price">$${ctx.template.price}/day</span> </div> `;};Lit item renderers are plain functions that take an ItemRenderContext<T> and return a TemplateResult — not LitElement subclasses. classMap toggles modifier classes (selected, disabled, focused, odd) from the context flags.
<script setup lang="ts">import type { ListItemRendererProps } from '@golemui/gui-vue';
type Car = { id: string; label: string; img: string; price: number };
defineProps<ListItemRendererProps<Car>>();</script>
<template> <div :class="['car-renderer', { selected }]"> <span class="car-renderer__img">{{ template?.img }}</span> <span class="car-renderer__label">{{ template?.label }}</span> <span class="car-renderer__price">${{ template?.price }}/day</span> </div></template>template is the per-item data — typed as Car | undefined because the list can render placeholder rows while items load, so use optional chaining (template?.foo).
export const carItemRenderer = (ctx) => { const root = document.createElement('div'); root.className = 'car-renderer'; if (ctx.disabled) root.classList.add('disabled'); if (ctx.selected) root.classList.add('selected'); if (ctx.focused) root.classList.add('focused'); if (ctx.index % 2 !== 0) root.classList.add('odd');
const img = document.createElement('span'); img.className = 'car-renderer__img'; img.textContent = ctx.template.img;
const label = document.createElement('span'); label.className = 'car-renderer__label'; label.textContent = ctx.template.label;
const price = document.createElement('span'); price.className = 'car-renderer__price'; price.textContent = `$${ctx.template.price}/day`;
root.append(img, label, price); return root;};A vanilla item renderer is a plain function that takes an ItemRenderContext and returns a DOM node. ctx.template is the per-item data; the flags ctx.selected, ctx.focused, ctx.disabled, and ctx.index let you toggle modifier classes from the context — the dropdown can render placeholder rows while items load, so guard against ctx.template being undefined if you start with an empty items list.
Step 3 — Add styles
Section titled “Step 3 — Add styles”Drop the following into a global stylesheet:
.car-renderer { display: flex; align-items: center; padding: var(--gui-space-2); gap: var(--gui-space-3); cursor: pointer;
&:hover { background-color: var(--gui-intent-primary-hover); color: var(--gui-text-inverse);
.car-renderer__price { color: var(--gui-text-inverse); } }}.car-renderer.selected { background-color: var(--gui-intent-primary-active); color: var(--gui-text-inverse);
.car-renderer__price { color: var(--gui-text-inverse); }}.car-renderer__img { font-size: var(--gui-font-xl);}.car-renderer__label { flex: 1;}.car-renderer__price { color: var(--gui-text-secondary);}Step 4 — Register the renderer
Section titled “Step 4 — Register the renderer”itemRenderers is a top-level key on the same config object you’ve been wiring since the intro chapter — it maps a renderer name to the framework component. Unlike states, it always lives on the component (the form file can only carry a string name, never a real component reference):
In the programmatic API, itemRenderers sits next to formDef and the formConfig we set up for states:
const config = { formDef: rentACarForm, formConfig: { states: { differentReturn: '$form.differentReturn === true', hasDiscount: '$form.hasDiscountCode === true', }, }, itemRenderers: { carItemRenderer: CarItemRenderer, },};export class RentACarPage { protected config = { formDef: rentACarForm, formConfig: { states: { differentReturn: '$form.differentReturn === true', hasDiscount: '$form.hasDiscountCode === true', }, }, itemRenderers: { carItemRenderer: CarItemRenderer, }, };}export class RentACarPage extends LitElement { config = { formDef: rentACarForm, formConfig: { states: { differentReturn: '$form.differentReturn === true', hasDiscount: '$form.hasDiscountCode === true', }, }, itemRenderers: { carItemRenderer: CarItemRenderer, }, }; // …rest of the class unchanged…}<script setup lang="ts">const config = { formDef: rentACarForm, formConfig: { states: { differentReturn: '$form.differentReturn === true', hasDiscount: '$form.hasDiscountCode === true', }, }, itemRenderers: { carItemRenderer: CarItemRenderer, },};</script>import { carItemRenderer } from './car-item-renderer';
const config = { formDef: rentACarForm, formConfig: { states: { differentReturn: '$form.differentReturn === true', hasDiscount: '$form.hasDiscountCode === true', }, }, itemRenderers: { carItemRenderer, },};In the JSON API, the states are already declared in the form file’s "states" block, so the component just adds itemRenderers next to formDef:
const config = { formDef: rentACarForm, itemRenderers: { carItemRenderer: CarItemRenderer, },};export class RentACarPage { protected config = { formDef: rentACarForm, itemRenderers: { carItemRenderer: CarItemRenderer, }, };}export class RentACarPage extends LitElement { config = { formDef: rentACarForm, itemRenderers: { carItemRenderer: CarItemRenderer, }, }; // …rest of the class unchanged…}<script setup lang="ts">const config = { formDef: rentACarForm, itemRenderers: { carItemRenderer: CarItemRenderer, },};</script>import { carItemRenderer } from './car-item-renderer';
const config = { formDef: rentACarForm, itemRenderers: { carItemRenderer, },};Step 5 — Reference it from the dropdown
Section titled “Step 5 — Reference it from the dropdown”The dropdown’s itemRenderer prop carries the name you registered:
import { gui } from '@golemui/gui-shared';
export default [ gui.inputs.dropdown('car', { labelField: 'label', valueField: 'id', itemRenderer: 'carItemRenderer', items: [ { id: 'compact', label: 'Compact', img: '🚗', price: 35, }, { id: 'suv', label: 'SUV', img: '🚙', price: 75, }, { id: 'convertible', label: 'Convertible', img: '🏎️', price: 110, }, { id: 'luxury', label: 'Luxury', img: '🚘', price: 180, }, ], label: 'Select car', validator: { type: 'string', required: true, messages: { required: 'Please pick a car model', invalid: 'Please pick a car model', }, }, }), gui.layouts.grid([ gui.inputs.dropdown('collectOffice', { labelField: 'label', valueField: 'id', items: [ { id: 'lhr', label: 'London Heathrow', }, { id: 'cdg', label: 'Paris CDG', }, { id: 'fra', label: 'Frankfurt Main', }, ], label: 'Collect from office', validator: { type: 'string', required: true, messages: { required: 'Choose where you\'ll pick up the car', }, }, }), gui.inputs.dropdown('returnOffice', { labelField: 'label', valueField: 'id', items: [ { id: 'lhr', label: 'London Heathrow', }, { id: 'cdg', label: 'Paris CDG', }, { id: 'fra', label: 'Frankfurt Main', }, ], label: 'Return to office', validator: { type: 'string', required: true, messages: { required: 'Choose where you\'ll drop the car off', }, }, include: { in: ['differentReturn'], }, }), ], { direction: 'row', autoFit: true, }), gui.inputs.booleanInput('differentReturn', { label: 'Choose a different return location', }), gui.inputs.rangeCalendar('rentalDates', { numberOfMonths: 2, label: 'Rental dates', validator: { required: true, minItems: 1, maxItems: 1, messages: { required: 'Please select your rental dates', minItems: 'Pick a rental date range', maxItems: 'Only one date range, please', }, }, }), gui.inputs.radiogroup('rentalType', { options: [ { label: 'Daily', value: 'daily', }, { label: 'Weekly', value: 'weekly', }, { label: 'Monthly', value: 'monthly', }, ], label: 'Rental type', validator: { type: 'string', required: true, messages: { required: 'Choose Daily, Weekly, or Monthly', }, }, }), gui.inputs.booleanInput('driverOver25', { label: 'Driver aged over 25', validator: { const: true, required: true, messages: { const: 'Drivers must be at least 25 years old to rent', required: 'Confirm the driver is over 25', }, }, }), gui.inputs.booleanInput('hasDiscountCode', { label: 'I have a discount code', }), gui.inputs.textInput('discountCode', { label: 'Discount code', validator: { required: true, minLength: 4, messages: { required: 'Enter your discount code', minLength: 'Discount codes are at least 4 characters', }, }, include: { in: ['hasDiscount'], }, }), gui.actions.button({ label: 'Reserve', actionType: 'submit', }),];{ "states": { "differentReturn": "$form.differentReturn === true", "hasDiscount": "$form.hasDiscountCode === true" }, "form": [ { "kind": "input", "type": "dropdown", "path": "car", "label": "Select car", "validator": { "type": "string", "required": true, "messages": { "required": "Please pick a car model", "invalid": "Please pick a car model" } }, "props": { "labelField": "label", "valueField": "id", "itemRenderer": "carItemRenderer", "items": [ { "id": "compact", "label": "Compact", "img": "🚗", "price": 35 }, { "id": "suv", "label": "SUV", "img": "🚙", "price": 75 }, { "id": "convertible", "label": "Convertible", "img": "🏎️", "price": 110 }, { "id": "luxury", "label": "Luxury", "img": "🚘", "price": 180 } ] } }, { "kind": "layout", "type": "grid", "props": { "direction": "row", "autoFit": true }, "children": [ { "kind": "input", "type": "dropdown", "path": "collectOffice", "label": "Collect from office", "validator": { "type": "string", "required": true, "messages": { "required": "Choose where you'll pick up the car" } }, "props": { "labelField": "label", "valueField": "id", "items": [ { "id": "lhr", "label": "London Heathrow" }, { "id": "cdg", "label": "Paris CDG" }, { "id": "fra", "label": "Frankfurt Main" } ] } }, { "kind": "input", "type": "dropdown", "path": "returnOffice", "label": "Return to office", "include": { "in": ["differentReturn"] }, "validator": { "type": "string", "required": true, "messages": { "required": "Choose where you'll drop the car off" } }, "props": { "labelField": "label", "valueField": "id", "items": [ { "id": "lhr", "label": "London Heathrow" }, { "id": "cdg", "label": "Paris CDG" }, { "id": "fra", "label": "Frankfurt Main" } ] } } ] }, { "kind": "input", "type": "toggle", "path": "differentReturn", "label": "Choose a different return location" }, { "kind": "input", "type": "rangeCalendar", "path": "rentalDates", "label": "Rental dates", "props": { "numberOfMonths": 2 }, "validator": { "type": "array", "required": true, "minItems": 1, "maxItems": 1, "messages": { "required": "Please select your rental dates", "minItems": "Pick a rental date range", "maxItems": "Only one date range, please" } } }, { "kind": "input", "type": "radiogroup", "path": "rentalType", "label": "Rental type", "validator": { "type": "string", "required": true, "messages": { "required": "Choose Daily, Weekly, or Monthly" } }, "props": { "options": [ { "label": "Daily", "value": "daily" }, { "label": "Weekly", "value": "weekly" }, { "label": "Monthly", "value": "monthly" } ] } }, { "kind": "input", "type": "toggle", "path": "driverOver25", "label": "Driver aged over 25", "validator": { "type": "boolean", "const": true, "required": true, "messages": { "const": "Drivers must be at least 25 years old to rent", "required": "Confirm the driver is over 25" } } }, { "kind": "input", "type": "toggle", "path": "hasDiscountCode", "label": "I have a discount code" }, { "kind": "input", "type": "textinput", "path": "discountCode", "label": "Discount code", "include": { "in": ["hasDiscount"] }, "validator": { "type": "string", "required": true, "minLength": 4, "messages": { "required": "Enter your discount code", "minLength": "Discount codes are at least 4 characters" } } }, { "kind": "action", "type": "button", "label": "Reserve", "actionType": "submit" } ]}See also
Section titled “See also”- Features / Item Renderers — wiring renderers in
formConfig. - Extending GolemUI / Item Renderers — the renderer contract per framework.