# Field types

The supported field types and their configuration.

## Field types

| Type | Description |
|---|---|
| `text` | Single-line text |
| `textarea` | Multi-line plain text |
| `html` | Rich text (bubble editor and field bar, see Editor buttons) |
| `slug` | URL-safe slug field computed from configured source fields |
| `boolean` | True/false toggle |
| `date` | Date picker |
| `email` | Email address |
| `url` | URL |
| `password` | Encrypted password field |
| `image` | Single image upload |
| `file` | Single file upload |
| `upload` | Generic upload |
| `gallery` | Multiple image upload |
| `select` | Dropdown - fixed options or from resource |
| `number` | Numeric input - supports optional `min` and `max` |
| `color` | Color picker |
| `location-picker` | Interactive world map that stores latitude in the field and longitude in the configured `longitude_field` companion field |

Field type names must match exactly.

## Field configuration

Common options per field:

| Key | Type | Description |
|---|---|---|
| `name` | string | Display label in admin |
| `required` | boolean | Validation - must be set on all title/name fields |
| `admin_col` | boolean | Show in admin overview table (default: true) |
| `admin_filter` | boolean or object | Add an admin overview filter for a `select` or `boolean` field. Use `{ "default": <stored-value> }` to enable it with a default selection, and optional `"feature": "permission-name"` to show it only to users with that feature. Independent from `admin_col`. |
| `multi` | boolean | Allow multiple values |
| `i18n` | boolean | Translate per language |
| `accept` | string | File type restriction for image/file fields |

**HTML fields** must explicitly use one of two configurations:

Simple (short text blocks, e.g. intro):
```json
"buttons": "bold,italic",
"admin_col": false
```

Rich (full content, e.g. body):
```json
"buttons": "h2,h3,h4,bold,italic,orderedlist,unorderedlist,quote,anchor",
"media": true,
"media_sizes": "sm-90,md-70,lg-60,xl-50,xxl-40",
"admin_col": false
```

Never define an `html` field without choosing one of these. `media_sizes` is required when `media: true`.

**Editor buttons.** `buttons` lists the formatting buttons of an `html` field, in order. Without `buttons` a field gets `bold,italic,removeFormat`. Built in:

| Button | Does |
|---|---|
| `bold`, `italic` | Inline formatting (Ctrl/Cmd+B, Ctrl/Cmd+I) |
| `h2`, `h3`, `h4`, `quote`, `pre` | Block format; again turns it back into a paragraph |
| `orderedlist`, `unorderedlist` | Lists |
| `anchor` | Link: add, edit (prefilled) or remove; Ctrl/Cmd+K |
| `removeFormat` | Clear formatting |
| `strikethrough`, `subscript`, `superscript`, `underline` | Optional, in no default set |

The same buttons show in two places: the bubble toolbar on selected text, and the field bar. In admin forms the field bar is part of the field box; on inline editing it floats above the focused field and also holds Media (when `media: true`) and Save (Ctrl/Cmd+S). Alt+F10 moves keyboard focus to the field bar. Pasting keeps only plain text unless the field sets `"paste_html": true`, which keeps basic formatting and strips Word/Google Docs markup.

**Custom editor buttons.** A project declares its own buttons as JSON in `ext/tpl/bubble-editor-buttons/index.tpl` (the template may use shortcodes, e.g. `[#text ...#]` for labels), then lists them in a field's `buttons` like the built-in ones:

```json
{
    "highlight": { "kind": "wrap", "tag": "mark", "label": "[#text Highlight#]", "icon": "M" },
    "h5": { "kind": "block", "tag": "h5", "label": "Heading 5", "icon": "H5" },
    "poi": {
        "kind": "event", "event": "nb:insert-point", "insert": true, "bubble": true,
        "active": "[data-poi-link]", "label": "[#text Point of interest#]", "icon": "<svg ...></svg>"
    }
}
```

| Kind | Does |
|---|---|
| `command` | A formatting command (`"command": "bold"`) |
| `block` | Block format (`"tag": "h5"`) |
| `list` | List command |
| `wrap` | Wraps the selection in an element (`"tag"`, optional `"class"`); again unwraps it |
| `insert` | Inserts fixed HTML at the caret (`"html"`) |
| `event` | Fires a DOM event on the field (`"event"`, bubbling, `detail: { editor, range }`) for the project's own code, e.g. to open a picker |
| `link` | URL prompt, like `anchor` |

Options: `"insert": true` puts a button next to Media in the field bar instead of with the formatting buttons; add `"bubble": true` when it also acts on selected text; `"active"` is a selector that marks the button active when the caret is inside a match. Links are `a[href]`: a project may use `<a>` without `href` for its own inline elements. Code can also register buttons with `nb.bubble_editor.register(name, { label, icon, run(ctx), is_active(ctx) })`.

A project's editing tools (the modal and script behind an `event` button) go in `ext/tpl/edit-extras/index.tpl`, which renders for users who can edit inline, on site and admin pages.

**Slug fields:**

Slugs are explicit fields. Do not add `"slug": true` to another field. Define a separate field with `"type": "slug"` and a `source` that points
to the field or fields it is derived from.

The admin form computes the slug in the browser from its source fields before
submit. The API does not invent missing slug fields on save; it stores the slug
field value it receives. If code creates or imports records outside the admin
form, it must also set the slug field value.

Set `"preserve_existing": true` on the slug field to generate missing slugs while
keeping saved URLs unchanged when the form opens or its source title changes.
Editors can still change a saved slug explicitly.

```json
"url_slug": {
  "name": "URL slug",
  "type": "slug",
  "source": "title",
  "admin_col": false
}
```

Multiple sources are comma-separated - values are joined with a space before slugifying:

```json
"url_slug": {
  "name": "URL slug",
  "type": "slug",
  "source": "title,date",
  "admin_col": false
}
```

With `"source": "title,date"` and title `"Jazz Night"`, date `"2026-04-09"`, the slug becomes `jazz-night-2026-04-09`. Always add `url_slug` to the resource `index` array so it can be looked up in `route.inc`.

If the source field is i18n, the slug field is treated as i18n too. For example,
`title.nl`, `title.en`, and `title.fr` produce matching values under
`title_slug.nl`, `title_slug.en`, and `title_slug.fr`. Do not hand-translate
slugs; let the slug field derive them from the language-specific source values.

**Select fields:**

Fixed options:
```json
"type": "select",
"options": {
  "new": "New",
  "active": "Active",
  "closed": "Closed"
}
```

Options from another resource:
```json
"type": "select",
"resource": "categories"
```

Admin overview filters are opt-in for `select` and `boolean` fields:

```json
"status": {
  "type": "select",
  "options": { "active": "Active", "closed": "Closed" },
  "admin_filter": { "default": "active" }
},
"published": {
  "type": "boolean",
  "admin_col": false,
  "admin_filter": true
}
```

Filters compare raw stored values while displaying configured option or related
resource labels. Multi-value selects match when the record contains the selected
value. Multiple filters combine with AND logic and continue to work with search,
sorting, and pagination. Selections persist in the URL as
`filter[field]=value`. An absent parameter uses the configured default; an empty
parameter explicitly selects **All**. Invalid URL values normalize to **All**.

**Location picker fields:**

Use `location-picker` when editors should choose coordinates on a map instead
of typing them. The picker field stores latitude in its own field and updates a
separate numeric longitude field named by `longitude_field`. Hide the companion
field with `wrapper_class: "hidden"` so the editor sees one atomic control while
the resource retains simple scalar coordinates:

```json
"location_lat": {
  "name": "Location",
  "type": "location-picker",
  "longitude_field": "location_lng",
  "required": true,
  "admin_col": false
},
"location_lng": {
  "name": "Longitude",
  "type": "number",
  "required": true,
  "min": -180,
  "max": 180,
  "wrapper_class": "hidden",
  "admin_col": false
}
```

## Form field rendering pipeline

This section explains the full flow from a resource field definition to a rendered form input. Read this before building a new field type.

### Flow overview

```
.meta field definition
  ↓
render_field() - PHP (core/modules/forms/lib/render-field.php)
  ↓
_f.* template variables
  ↓
[#field-{type}#] template (core/modules/forms/tpl/field-{type}/index.tpl)
  ↓
Alpine.js form_data.{field}   ← x-model binding
  ↓
API POST/PUT on submit
```

### `render_field()` and the `_f.*` context

`render_field()` is the gateway. It:

1. Takes a field definition array, the field key, and optional value/store/source parameters
2. Spreads the entire field definition into `_f.*` via `set_variable_dot('_f', $def)` - every `.meta` attribute is automatically available in the template
3. Sets computed variables on top: `_f.key`, `_f.title`, `_f.value`, `_f.model`, `_f.required`, `_f.ai`, `_f.bg`
4. Dispatches to `[#field-{type}#]` - the template for that field type

`_f.model` is the Alpine.js `x-model` expression, computed as `{store}.{field}`. The default store is `form_data`. For i18n fields it becomes `form_data.{field}[{lang}]`.

Standard `_f.*` variables available in every field template:

| Variable | Contents |
|---|---|
| `_f.key` | Field name / HTML name attribute |
| `_f.title` | Display label (from `name` in .meta) |
| `_f.value` | Pre-populated value |
| `_f.model` | Alpine x-model expression, e.g. `form_data.email` |
| `_f.required` | Whether the field is required |
| `_f.bg` | Background color class for the floating label |
| `_f.*` (any) | All other field definition keys - `_f.accept`, `_f.options`, `_f.resource`, etc. |

### `form_data`: the Alpine.js store

`form_data` is the reactive Alpine.js object that holds all current field values. It is declared on the `<form>` element. Each field writes its value here via `x-model="[#_f.model#]"`.

On submit, `form_data` is spread into the API payload and POSTed to `/api/v1/{resource}`. Rich editor fields (HTML, gallery, image) write their values through a separate mechanism (`nb.edit.get_field_values()`), which is merged in automatically - field templates do not need to handle this.

### The simplest field template

`field-default/index.tpl` is the base for text-like fields:

```html
<div class="[#_f.wrapper_class#]">
    <input type="[#_f.type#]" value="[#_f.value#]" name="[#_f.key#]"
        x-init="[#_f.model#]=`[#_f.value#]`"
        x-model="[#_f.model#]"
        [#if _f.required=(not-empty) echo=required#]
        class="input input-bordered w-full" />
    <label class="pointer-events-none absolute left-3 -top-2.5 px-1
            font-bold text-sm leading-tight [#get _f.bg default=bg-neutral-50#]">
        [#_f.title#][#if _f.required=(not-empty) echo=" *"#]
    </label>
</div>
```

### Adding a new field type

1. Create the template:
   ```
   core/modules/forms/tpl/field-{type}/index.tpl   ← core types
   ext/tpl/field-{type}/index.tpl                   ← project-specific types
   ```

2. In the template:
   - Bind to Alpine via `x-model="[#_f.model#]"`
   - Seed the initial value with `x-init="[#_f.model#]='[#_f.value#]'"`
   - Use `[#_f.key#]` as the HTML `name` attribute
   - Access type-specific options via `_f.*` (e.g. `[#_f.accept#]` for file restrictions)

3. Register the type name in `.meta` - it is immediately usable once the template exists.

4. If the field manages complex state (e.g. a file picker, a media browser trigger), nest its own `x-data` inside the form's `x-data`. The outer `form_data` remains accessible from nested scopes.

1.1 custom fields must use the `field-{type}` template convention and the
`_f.*` context (`_f.model`, `_f.key`, `_f.title`, `_f.bg`, etc.). Older
templates that use `_fmodel`, `_fid`, `_ftitle`, `_fbg`,
`_bf_render_field()`, or Tailwind Elements `data-te-*` attributes are legacy
and should be rebuilt instead of carried forward.

Nested and repeater fields must explicitly manage nested Alpine model paths.
For example, a `group` field that stores `locations` should bind child fields
to paths such as `form_data.locations[ix].location` or pass an explicit model
override when reusing `render_field()`. Do not assume the core renderer can
infer nested array paths from a flat field key.
