BizzPocket

Fields: f.*

On this page

fields is the list of settings a seller can change on a block. You write it once, and from it BizzPocket gets everything else:

  • the props of render (typed for you, no extra type to write),
  • the default of each prop,
  • the controls in the page editor (text box, image picker, collection picker...),
  • the checks that make a saved value safe before render sees it.
tsx
import { block, f, Image, Link } from '@bizzpocket/theme';

export default block({
  name: 'Promo',
  category: 'promo',
  fields: {
    headline: f.text({ default: 'Summer sale' }),
    image: f.image(),
    ctaLabel: f.text({ default: 'Shop now' }),
    ctaHref: f.link({ default: '/shop' }),
  },
  render: ({ headline, image, ctaLabel, ctaHref }) => (
    <section>
      <h2>{headline}</h2>
      {image ? <Image src={image.src} alt={image.alt ?? ''} width={1200} height={600} sizes="100vw" /> : null}
      {ctaHref ? <Link href={ctaHref}>{ctaLabel}</Link> : null}
    </section>
  ),
});

A link is always f.link() (it only ever holds a safe URL) rendered with <Link>, and a picture f.image() with <Image>. A plain f.text in an href is whatever was saved: another site (//elsewhere.example), a data: URL, anything.

Contents: All field kinds · Options every field has · Defaults · Labels, tiers and hidden fields · Lists and groups · Blocks inside a block · Traits · What happens to a bad saved value · Types


All the field kinds

There is one kind for each job. There are no synonyms.

Field render gets Use it for Editor control
f.text(opts?) string a short piece of text text box (a multi-line box with multiline: true; a link picker when the key ends in href)
f.richText(opts?) string (safe HTML) formatted text: bold, links, lists rich-text editor
f.number(opts?) number a count, a size, a delay number box
f.boolean(opts?) boolean an on/off switch toggle
f.select(options, opts?) one of your option values choose one from a fixed list dropdown
f.color(opts?) string (a colour) a colour colour picker
f.image(opts?) { src, alt? } | undefined a picture image picker
f.video(opts?) { src, poster? } | undefined a video video picker
f.video({ format: 'url' }) string | undefined (the video URL) a video saved as a plain URL (2.x videoUrl-style content; migrate writes this) video picker
f.link(opts?) string | undefined where a button or link goes link picker
f.product(opts?) string | undefined (a product handle) choose a product product picker
f.collection(opts?) string | undefined (a collection slug) choose a collection collection picker
f.category(opts?) string | undefined choose a category category picker
f.menu(opts?) string | undefined (a menu handle) choose a navigation menu menu picker
f.list(itemFields, opts?) Array<item> a repeating group, such as slides or FAQs repeater
f.group(fields, opts?) an object a few fields kept together grouped controls
f.json<T>(opts?) T anything else (an escape hatch; use sparingly) JSON box
f.blocks(opts?) ReactNode[] (the child blocks, already rendered) a container: blocks the seller puts inside this one "add block inside" (group.md)
f.spacing(), f.colorScheme(), f.typography() not a prop see Traits spacing / colour / type controls

A shape the builder cannot describe is a build error, never a silent unknown.

Field names. Do not give a field the name of a data helper (product, products, menu, store, categories, home, search, related, gql). Destructured in data(), it hides the helper: ({ product }) => ({ item: product({ handle: product }) }) calls the seller's string. Use productHandle, menuHandle and so on (data.md, rule 3).

Examples of each

blocks/field-tour/field-tour.tsx
import { block, f } from '@bizzpocket/theme';

export default block({
  name: 'Field tour',
  category: 'content',
  fields: {
    // Text
    title: f.text({ default: 'Hello', maxLength: 60, placeholder: 'A short title' }),
    intro: f.text({ multiline: true, help: 'One or two sentences.' }),
    note: f.text({ optional: true }), // string | undefined, no default
    body: f.richText(),

    // Numbers and switches
    columns: f.number({ default: 3, min: 1, max: 6, step: 1 }),
    showPrices: f.boolean({ default: true }),

    // One of a list. A plain string is both the value and the label.
    layout: f.select(['grid', { value: 'list', label: 'List view' }], { default: 'grid' }),

    // A colour: #rgb, #rrggbb, rgb(), hsl() or var(--token)
    accent: f.color({ default: '#111111' }),

    // Media and links
    banner: f.image(),
    clip: f.video(),
    buttonLink: f.link(),

    // Store resources: each holds a handle or slug the data helpers understand
    featured: f.product(),
    collection: f.collection(),
    category: f.category(),
    menu: f.menu(),

    // Repeating and grouped fields
    slides: f.list({ heading: f.text(), picture: f.image() }, { max: 6, label: 'Slides' }),
    cta: f.group({ label: f.text({ default: 'Shop now' }), href: f.link() }, { label: 'Button' }),

    // Anything else
    config: f.json<{ speed: number }>({ default: { speed: 3 } }),
  },
  render: (p) => (
    <section style={{ color: p.accent }} data-columns={p.columns}>
      <h2>{p.title}</h2>
      <p>{p.intro}</p>
      {p.note ? <small>{p.note}</small> : null}
      <div dangerouslySetInnerHTML={{ __html: p.body }} />
      {p.banner ? <img src={p.banner.src} alt={p.banner.alt ?? ''} /> : null}
      <ul data-layout={p.layout}>
        {p.slides.map((s, i) => (
          <li key={i}>{s.heading}</li>
        ))}
      </ul>
      <a href={p.cta.href ?? '#'}>{p.cta.label}</a>
      <span hidden>{String(p.showPrices)}{p.config.speed}</span>
    </section>
  ),
});

Options every field has

These work on every kind except list, spacing, colorScheme and typography (see below).

Option What it does
label The name shown in the editor. If you leave it out it is made from the key: subLabel becomes "Sub label".
help A line of help text under the control.
group A heading to group fields under in the editor, such as 'Button'.
tier 'basic' or 'advanced'. See Labels, tiers and hidden fields.
hidden true keeps the field out of the editor. It still has a value and still reaches render.
default The value used until the seller changes it.
optional true means "may be empty". The prop's type gains `

Kind-specific options:

Field Extra options
f.text multiline (a larger box), maxLength (longer text is cut), placeholder
f.number min, max (values outside are pulled in), step, integer (whole numbers: a fraction is truncated, the editor asks for a whole number)
f.select(options, opts) options is an array of strings or { value, label } objects
f.list(item, opts) label, help, group, tier, min, max (no default; a list starts empty)

Defaults

If a field has no default option, its prop starts as:

Kind Starts as
text, richText, color '' (empty text)
number min if you set one, else 0
boolean false
select the first option's value
list []
group the defaults of its fields
image, video, link, product, collection, category, menu, json undefined
any field with optional: true undefined
blocks no default: its children come from the layout ([] when there are none)

The default is used when a seller never set the value, when the saved value is empty, or when it was invalid. It is also what data() sees (see data.md). The defaults are saved in dist/blocks.json as the block's defaultProps.

Because f.link(), f.collection(), f.image() and the other resource kinds can be undefined, check before use ({image ? <Image ... /> : null}), or give them a default.


Labels, tiers and hidden fields

You rarely need to set these. The defaults are chosen so most blocks need no extra options.

Labels come from the key. headline is "Headline", ctaHref is "Cta href" (set label: 'Button link' if you want better).

Tiers decide what the editor shows first.

  • basic: the everyday content: text, richText, image, video and link fields (and text fields that become link pickers).
  • advanced: everything else (numbers, switches, selects, colours, resource pickers, lists, groups, json, traits).

Override with tier: 'basic' or tier: 'advanced' on any field.

Hidden fields are not shown in the editor. A text field is hidden by itself when its key looks like plumbing: id, slug, key, or a key ending in Id, Slug, Key, _id, -slug and so on (for example productId). Set hidden: true to hide any other field.

Link controls. A text field whose key ends in href or Href uses the link picker, just like f.link().


Lists and groups

f.list is a repeating row. Give it the fields of one item:

tsx
slides: f.list(
  { heading: f.text({ default: 'Slide' }), picture: f.image(), link: f.link() },
  { max: 6, label: 'Slides' },
),

In render, slides is an array of { heading, picture, link } (each item is read with the same rules as any fields). Items beyond max are dropped. The array is never undefined. If the seller added none, it is [], so render can write slides.map(...) directly.

f.group keeps related fields together under one key:

tsx
button: f.group({ label: f.text({ default: 'Shop now' }), href: f.link() }),
// render: button.label, button.href

Both can be nested.

Blocks inside a block: f.blocks()

f.blocks() makes a block a container: the seller adds other blocks inside it, and render gets them, already rendered, as an array to place:

tsx
fields: {
  title: f.text(),
  items: f.blocks({ allow: ['hero', 'category:media'], max: 4 }),
},
render: ({ title, items }) => (
  <div className="panel">
    <h2>{title}</h2>
    <div className="panel__items">{items}</div>
  </div>
),

It is not like the other fields:

  • the children are saved in the layout as the node's children, not in props; the field has no default and is not in defaultProps;
  • allow (block types, 'category:<name>', or '*') and max (default 24) are the editor's rules, written to blocks.json as x-bp-children, and the renderer enforces them;
  • one per block, at the top level of fields (not inside f.list or f.group).

The whole story, with a layout JSON and the registry's ready-made group block, is in group.md.


Traits: spacing, colours and type

Some settings are the same on many blocks: the space around, the background colour, the font. A trait is a ready-made group of such settings. It does not become a prop. Instead the page puts the chosen values on the block's wrapper as CSS variables, and you use them in your CSS.

tsx
fields: {
  title: f.text(),
  spacing: f.spacing(),          // --bp-spacing-top / -right / -bottom / -left
  scheme: f.colorScheme(),       // --bp-color-background / --bp-color-text
  type: f.typography(),          // --bp-type-font / -size / -weight
},
css
.promo {
  padding-top: var(--bp-spacing-top);
  padding-bottom: var(--bp-spacing-bottom);
  background: var(--bp-color-background);
  color: var(--bp-color-text);
  font-family: var(--bp-type-font);
}

What the seller picks and what the variable becomes:

Trait Properties Variable value
f.spacing() top, right, bottom, left a spacing token name, such as l, becomes var(--spacing-l)
f.colorScheme() background, text a colour token name, such as primary, becomes var(--colors-primary)
f.typography() font, size, weight font is a font token (var(--fonts-sans)); size and weight are used as written, such as 1.25rem and 600

Rules:

  • A block can have one trait of each kind. Two f.spacing() fields in one block is an error.
  • Traits take only label: f.spacing({ label: 'Padding' }).
  • A trait that the seller left empty writes no variable, so give your CSS a fallback: padding-top: var(--bp-spacing-top, 2rem).
  • The values come from your theme's tokens (theme-config.md).

What happens to a bad saved value

Saved values come from sellers, importers and old versions of your block, so they can be wrong. Before render runs, every value is cleaned using its field. You never need to re-check them in render.

Kind Cleaning
text trimmed; cut to maxLength; a non-string falls back to the default
richText cleaned to a safe set of tags (scripts and event handlers are removed)
number '9' becomes 9; pulled into min..max; not a number gives the default
boolean only real true/false; anything else gives the default
select must be one of the options, else the default
color must be a valid colour (#abc, #aabbcc, rgb(), hsl(), var(--x)), else the default
image, video needs a src that is https: or a path starting with a single /, else undefined
link a path, #anchor, https:, mailto: or tel: link; javascript: and //host become undefined
product, collection, category, menu a non-empty string, else the default
list, group each item or member is cleaned with its own fields; unknown keys are dropped
json passed through as is (clean it yourself)
blocks a saved value under its key is ignored; render gets the rendered children (or [])

Unknown keys in the saved data are dropped.


Types

You do not write a props type. render's props are worked out from fields (and from data()), so an editor can complete and check them:

tsx
fields: { count: f.number({ default: 4 }), note: f.text({ optional: true }) },
render: ({ count, note }) => {
  count;   // number
  note;    // string | undefined
}

To name the type elsewhere (for example in a component) use PropsOf:

ts
import { f, type PropsOf } from '@bizzpocket/theme';
const fields = { title: f.text(), count: f.number() };
type Props = PropsOf<typeof fields>; // { title: string; count: number }

The editor reads dist/blocks.json. For each field it holds a standard JSON Schema with these extra keys: x-bp-control (which control), x-bp-label, x-bp-help, x-bp-group, x-bp-tier and x-bp-hidden, and default. A container's schema also has x-bp-children ({ childrenField, allow, max }), and the block is "container": true.

Next: data.md.