Field
Composable form field components for building accessible forms with labels, controls, and help text.
Basic Field
A simple field with label, input, and description.
Enter your email address
Horizontal Orientation
Fields can be laid out horizontally with label and input side-by-side.
This will be your public display name.
FieldSet with Legend
Group related fields with FieldSet and FieldLegend.
Error State
Fields can display validation errors.
Password must be at least 8 characters
Field Separator
Separate sections with FieldSeparator.
or continue with
With FieldTitle
Use FieldTitle for checkbox/radio groups.
Get weekly updates on new features and promotions.
Accessibility
- FieldSet renders semantic <fieldset> for grouping
- FieldLegend renders <legend> for group labels
- Field uses role="group" for accessibility
- FieldError uses role="alert" for screen readers
- Labels are properly associated with inputs via for/id
FreeField
Form layout, labels, descriptions, and errors.
Install with the Proa CLI
$ proa ui add fieldInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Field recipe — typed root orientation and named compound slots.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Field layout orientation.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, proa_macros::ProaVariant)]
#[variant(name = "orientation")]
pub enum FieldOrientation {
#[default]
Vertical,
Horizontal,
Responsive,
}
impl FieldOrientation {
pub const fn as_str(self) -> &'static str {
match self {
Field source
Browse and copy the reviewed source included with this Free component.
field.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Field component implementation
//!
//! Composable form field components for building accessible forms.
//! Based on shadcn/ui Field component.
use super::recipe::{FieldLegendVariant, FieldOrientation, FieldRecipeProps, FIELD_RECIPE};
use super::FIELD_ANATOMY;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// FieldSet component - semantic container for form field groups
///
/// # Example
///
/// ```ignore
/// use proa_ui::FieldSet;
/// use proa_macros::html_sync;
///
/// html_sync! {
/// {FieldSet {
/// children: Some(html_sync! {
/// // FieldLegend and Field components here
/// }),
/// ..Default::default()
/// }}
/// }
/// ```
pub struct FieldSet<C = ()> {
/// Additional classes
pub class: Option<&'static str>,
/// Children content
pub children: Option<C>,
}
impl<C> Default for FieldSet<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for FieldSet<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<fieldset
data-scope={FIELD_ANATOMY.scope()}
data-part="fieldset"
data-slot="fieldset"
class={proa_macros::text!("{} {}", FIELD_RECIPE.fieldset, self.class)}
>
{self.children}
</fieldset>
}
.render(cx)
}
}
/// FieldLegend component - legend for FieldSet
///
/// # Example
///
/// ```ignore
/// use proa_ui::{FieldLegend, FieldLegendVariant};
///
/// html_sync! {
/// {FieldLegend {
/// variant: FieldLegendVariant::Legend,
/// children: Some("Personal Information"),
/// ..Default::default()
/// }}
/// }
/// ```
pub struct FieldLegend<C = ()> {
/// Style variant
pub variant: FieldLegendVariant,
/// Additional classes
pub class: Option<&'static str>,
/// Children content
pub children: Option<C>,
}
impl<C> Default for FieldLegend<C> {
fn default() -> Self {
Self {
variant: FieldLegendVariant::Legend,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for FieldLegend<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<legend
data-scope={FIELD_ANATOMY.scope()}
data-part="legend"
data-slot="field-legend"
data-variant={self.variant.as_str()}
class={proa_macros::text!("{} {} {}", FIELD_RECIPE.legend, self.variant.classes(), self.class)}
>
{self.children}
</legend>
}
.render(cx)
}
}
/// FieldGroup component - container with CSS container queries
///
/// # Example
///
/// ```ignore
/// use proa_ui::FieldGroup;
///
/// html_sync! {
/// {FieldGroup {
/// children: Some(html_sync! {
/// // Multiple Field components
/// }),
/// ..Default::default()
/// }}
/// }
/// ```
pub struct FieldGroup<C = ()> {
/// Additional classes
pub class: Option<&'static str>,
/// Children content
pub children: Option<C>,
}
impl<C> Default for FieldGroup<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for FieldGroup<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-scope={FIELD_ANATOMY.scope()}
data-part="group"
data-slot="field-group"
role="group"
class={proa_macros::text!("{} {}", FIELD_RECIPE.group, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// Field component - wrapper for form field with label, input, and helpers
///
/// # Example
///
/// ```ignore
/// use proa_ui::{Field, FieldOrientation};
///
/// html_sync! {
/// {Field {
/// orientation: FieldOrientation::Vertical,
/// invalid: false,
/// children: Some(html_sync! {
/// // FieldLabel, Input, FieldDescription, FieldError
/// }),
/// ..Default::default()
/// }}
/// }
/// ```
pub struct Field<C = ()> {
/// Layout orientation
pub orientation: FieldOrientation,
/// Whether the field is in invalid state
pub invalid: bool,
/// Additional classes
pub class: Option<&'static str>,
/// Children content
pub children: Option<C>,
}
impl<C> Default for Field<C> {
fn default() -> Self {
Self {
orientation: FieldOrientation::Vertical,
invalid: false,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for Field<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = FIELD_RECIPE.resolve(FieldRecipeProps {
orientation: Some(self.orientation),
});
let state = crate::FormControlState {
invalid: self.invalid,
..crate::FormControlState::enabled()
};
html_sync! {
<div
data-field
data-scope={FIELD_ANATOMY.scope()}
data-part="root"
data-slot="field"
role="group"
data-orientation={style.orientation().as_str()}
data-invalid={state.data_invalid()}
aria-invalid={state.aria_invalid()}
class={proa_macros::text!("{} {}", style, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// FieldLabel component - accessible label for form controls
///
/// # Example
///
/// ```ignore
/// use proa_ui::FieldLabel;
///
/// html_sync! {
/// {FieldLabel {
/// for_id: Some("email"),
/// disabled: false,
/// checked: false,
/// children: Some("Email"),
/// ..Default::default()
/// }}
/// }
/// ```
pub struct FieldLabel<C = ()> {
/// ID of the form element this label is for
pub for_id: Option<&'static str>,
/// Whether the associated control is disabled
pub disabled: bool,
/// Whether the associated control is checked (for checkbox/radio)
pub checked: bool,
/// Additional classes
pub class: Option<&'static str>,
/// Children content
pub children: Option<C>,
}
impl<C> Default for FieldLabel<C> {
fn default() -> Self {
Self {
for_id: None,
disabled: false,
checked: false,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for FieldLabel<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let disabled_attr = if self.disabled { Some("true") } else { None };
let checked_attr = if self.checked { Some("true") } else { None };
html_sync! {
<label
data-scope={FIELD_ANATOMY.scope()}
data-part="label"
data-slot="label"
for={self.for_id}
data-disabled={disabled_attr}
data-checked={checked_attr}
class={proa_macros::text!("{} {}", FIELD_RECIPE.label, self.class)}
>
{self.children}
</label>
}
.render(cx)
}
}
/// FieldContent component - flex container for control and description
///
/// # Example
///
/// ```ignore
/// use proa_ui::FieldContent;
///
/// html_sync! {
/// {FieldContent {
/// children: Some(html_sync! {
/// // Input and FieldDescription
/// }),
/// ..Default::default()
/// }}
/// }
/// ```
pub struct FieldContent<C = ()> {
/// Additional classes
pub class: Option<&'static str>,
/// Children content
pub children: Option<C>,
}
impl<C> Default for FieldContent<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for FieldContent<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-scope={FIELD_ANATOMY.scope()}
data-part="content"
data-slot="field-content"
class={proa_macros::text!("{} {}", FIELD_RECIPE.content, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// FieldTitle component - semantic title for field content
///
/// # Example
///
/// ```ignore
/// use proa_ui::FieldTitle;
///
/// html_sync! {
/// {FieldTitle {
/// children: Some("Newsletter Subscription"),
/// ..Default::default()
/// }}
/// }
/// ```
pub struct FieldTitle<C = ()> {
/// Additional classes
pub class: Option<&'static str>,
/// Children content
pub children: Option<C>,
}
impl<C> Default for FieldTitle<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for FieldTitle<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<span
data-scope={FIELD_ANATOMY.scope()}
data-part="title"
data-slot="field-title"
class={proa_macros::text!("{} {}", FIELD_RECIPE.title, self.class)}
>
{self.children}
</span>
}
.render(cx)
}
}
/// FieldDescription component - helper/description text for form fields
///
/// # Example
///
/// ```ignore
/// use proa_ui::FieldDescription;
///
/// html_sync! {
/// {FieldDescription {
/// children: Some("Enter your email address"),
/// ..Default::default()
/// }}
/// }
/// ```
pub struct FieldDescription<C = ()> {
/// Additional classes
pub class: Option<&'static str>,
/// Children content
pub children: Option<C>,
}
impl<C> Default for FieldDescription<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for FieldDescription<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<p
data-scope={FIELD_ANATOMY.scope()}
data-part="description"
data-slot="field-description"
class={proa_macros::text!("{} {}", FIELD_RECIPE.description, self.class)}
>
{self.children}
</p>
}
.render(cx)
}
}
/// FieldError component - accessible error message container
///
/// Hidden by default, shown when parent Field has `data-invalid="true"`.
///
/// # Example
///
/// ```ignore
/// use proa_ui::FieldError;
///
/// html_sync! {
/// {FieldError {
/// children: Some("This field is required"),
/// ..Default::default()
/// }}
/// }
/// ```
pub struct FieldError<C = ()> {
/// Additional classes
pub class: Option<&'static str>,
/// Children content (single error message or list)
pub children: Option<C>,
}
impl<C> Default for FieldError<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for FieldError<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-scope={FIELD_ANATOMY.scope()}
data-part="error"
data-slot="field-error"
role="alert"
class={proa_macros::text!("{} {}", FIELD_RECIPE.error, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// FieldSeparator component - divider with optional centered content
///
/// # Example
///
/// ```ignore
/// use proa_ui::FieldSeparator;
///
/// // Simple separator
/// html_sync! {
/// {FieldSeparator::<()> {
/// children: None,
/// ..Default::default()
/// }}
/// }
///
/// // Separator with text
/// html_sync! {
/// {FieldSeparator {
/// children: Some("or"),
/// ..Default::default()
/// }}
/// }
/// ```
pub struct FieldSeparator<C = ()> {
/// Additional classes
pub class: Option<&'static str>,
/// Optional content to display centered over separator
pub children: Option<C>,
}
impl<C> Default for FieldSeparator<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for FieldSeparator<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-scope={FIELD_ANATOMY.scope()}
data-part="separator"
data-slot="field-separator"
class={proa_macros::text!("{} {}", FIELD_RECIPE.separator, self.class)}
>
<hr
data-scope={FIELD_ANATOMY.scope()}
data-part="separator-rule"
data-slot="field-separator-rule"
class={FIELD_RECIPE.separator_rule}
/>
<div
data-scope={FIELD_ANATOMY.scope()}
data-part="separator-content"
data-slot="field-separator-content"
class={FIELD_RECIPE.separator_content}
>
<span
data-scope={FIELD_ANATOMY.scope()}
data-part="separator-text"
data-slot="field-separator-text"
class={FIELD_RECIPE.separator_text}
>
{self.children}
</span>
</div>
</div>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
#[test]
fn fieldset_renders_with_data_slot() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
FieldSet {
children: Some(text("content")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<fieldset"));
assert!(html.contains("data-slot=\"fieldset\""));
assert!(html.contains("content"));
}
#[test]
fn field_legend_renders_variants() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
FieldLegend {
variant: FieldLegendVariant::Legend,
children: Some(text("Legend")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<legend"));
assert!(html.contains("data-slot=\"field-legend\""));
assert!(html.contains("data-variant=\"legend\""));
assert!(html.contains("Legend"));
}
#[test]
fn field_group_has_container_query() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
FieldGroup {
children: Some(text("group")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-slot=\"field-group\""));
assert!(html.contains("role=\"group\""));
}
#[test]
fn field_renders_with_orientation() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Field {
orientation: FieldOrientation::Vertical,
children: Some(text("field")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-slot=\"field\""));
assert!(html.contains("data-orientation=\"vertical\""));
assert!(html.contains("flex-col"));
}
#[test]
fn field_with_invalid_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Field {
invalid: true,
children: Some(text("invalid field")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-invalid=\"true\""));
assert!(html.contains("aria-invalid=\"true\""));
}
#[test]
fn field_label_renders_with_for() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
FieldLabel {
for_id: Some("email"),
children: Some(text("Email")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<label"));
assert!(html.contains("for=\"email\""));
assert!(html.contains("data-slot=\"label\""));
}
#[test]
fn field_description_renders() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
FieldDescription {
children: Some(text("Helper text")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<p"));
assert!(html.contains("data-slot=\"field-description\""));
assert!(html.contains("Helper text"));
}
#[test]
fn field_error_has_alert_role() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
FieldError {
children: Some(text("Error message")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("role=\"alert\""));
assert!(html.contains("data-slot=\"field-error\""));
assert!(html.contains("hidden"));
}
#[test]
fn field_content_renders() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
FieldContent {
children: Some(text("content")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-slot=\"field-content\""));
}
#[test]
fn field_title_renders() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
FieldTitle {
children: Some(text("Title")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-slot=\"field-title\""));
assert!(html.contains("Title"));
}
#[test]
fn field_separator_renders() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
FieldSeparator {
children: Some(text("or")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-slot=\"field-separator\""));
assert!(html.contains("data-slot=\"field-separator-rule\""));
assert!(html.contains("data-slot=\"field-separator-content\""));
assert!(html.contains("data-slot=\"field-separator-text\""));
assert!(html.contains("<hr"));
assert!(html.contains("or"));
}
#[test]
fn field_root_fuses_custom_class_with_resolved_style() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Field {
orientation: FieldOrientation::Horizontal,
class: Some("custom-field"),
children: Some(text("field")),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-orientation=\"horizontal\""));
assert!(html.contains("flex-row"));
assert!(html.contains("custom-field"));
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Markdown rendering for the field components.
use super::{
Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend,
FieldSeparator, FieldSet, FieldTitle,
};
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// FieldSet is a passthrough container followed by a blank line.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for FieldSet<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// FieldLegend renders as bold text on its own line.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for FieldLegend<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
if let Some(children) = self.children {
block_boundary(cx)?;
let prev = cx.md_set_inline(true);
md_sync! { "**" { children } "**" }.render_md(cx)?;
cx.md_set_inline(prev);
block_boundary(cx)?;
}
Ok(())
}
}
/// FieldGroup is a passthrough container.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for FieldGroup<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
Ok(())
}
}
/// Field renders its children (label, control, help text) followed by a
/// blank line separating it from the next field.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for Field<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
self.children.render_md(cx)?;
block_boundary(cx)?;
Ok(())
}
}
/// FieldLabel renders as bold text on its own line, above the control.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for FieldLabel<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
if let Some(children) = self.children {
block_boundary(cx)?;
let prev = cx.md_set_inline(true);
md_sync! { "**" { children } "**" }.render_md(cx)?;
cx.md_set_inline(prev);
block_boundary(cx)?;
}
Ok(())
}
}
/// FieldContent is a passthrough container.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for FieldContent<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
Ok(())
}
}
/// FieldTitle renders as inline bold text.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for FieldTitle<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
if let Some(children) = self.children {
let prev = cx.md_set_inline(true);
md_sync! { "**" { children } "**" }.render_md(cx)?;
cx.md_set_inline(prev);
}
Ok(())
}
}
/// FieldDescription renders as italic help text.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for FieldDescription<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
if let Some(children) = self.children {
block_boundary(cx)?;
let prev = cx.md_set_inline(true);
md_sync! { "*" { children } "*" }.render_md(cx)?;
cx.md_set_inline(prev);
block_boundary(cx)?;
}
Ok(())
}
}
/// FieldError renders as a bold "Error:" prefix with italic message text.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for FieldError<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
if let Some(children) = self.children {
block_boundary(cx)?;
let prev = cx.md_set_inline(true);
md_sync! { "**Error:** *" { children } "*" }.render_md(cx)?;
cx.md_set_inline(prev);
block_boundary(cx)?;
}
Ok(())
}
}
/// FieldSeparator renders its centered content as an italic line, or a
/// horizontal rule when it has none.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for FieldSeparator<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
if let Some(children) = self.children {
let prev = cx.md_set_inline(true);
md_sync! { "*" { children } "*" }.render_md(cx)?;
cx.md_set_inline(prev);
block_boundary(cx)?;
} else {
cx.md_hr()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_label_renders_bold() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
FieldLabel {
children: Some("Email"),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "**Email**\n\n");
}
#[test]
fn field_description_renders_italic() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
FieldDescription {
children: Some("Enter your email address"),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
"*Enter your email address*\n\n"
);
}
#[test]
fn field_error_renders_prefix() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
FieldError {
children: Some("This field is required"),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(
String::from_utf8(buf).unwrap(),
"**Error:** *This field is required*\n\n"
);
}
#[test]
fn field_composes_label_control_and_help() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Field {
children: Some((
FieldLabel {
children: Some("Email"),
..Default::default()
},
"[Enter your email]",
FieldDescription {
children: Some("We never share it."),
..Default::default()
},
)),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("**Email**"), "output: {output}");
assert!(output.contains("Enter your email"), "output: {output}");
assert!(
output.contains("*We never share it\\.*"),
"output: {output}"
);
}
/// A Field composed after mid-line prose must be blank-line separated,
/// and its label, control, and description must not fuse onto one line.
#[test]
fn field_after_prose_separates_blocks() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
Field {
children: Some((
FieldLabel {
children: Some("Email"),
..Default::default()
},
"controlvalue",
FieldDescription {
children: Some("We never share it."),
..Default::default()
},
)),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("intro prose\n\n**Email**\n\n"),
"label must be blank-line separated from prose, output: {output}"
);
assert!(
output.contains("controlvalue\n\n*We never share it\\.*"),
"description must not fuse onto the control line, output: {output}"
);
}
#[test]
fn fieldset_with_legend() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
FieldSet {
children: Some(FieldLegend {
children: Some("Personal Information"),
..Default::default()
}),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("**Personal Information**"),
"output: {output}"
);
}
#[test]
fn field_title_is_inline_bold() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
FieldTitle {
children: Some("Newsletter"),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "**Newsletter**");
}
#[test]
fn field_separator_without_children_is_hr() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"above").unwrap();
FieldSeparator::<&str> {
children: None,
..Default::default()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("---"), "output: {output}");
}
#[test]
fn field_separator_with_text() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
FieldSeparator {
children: Some("or"),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("*or*"), "output: {output}");
}
#[test]
fn field_group_and_content_are_passthrough() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
FieldGroup {
children: Some(FieldContent {
children: Some("inner"),
..Default::default()
}),
..Default::default()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "inner");
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Field component module
//!
//! Composable form field components for building accessible forms.
//!
//! Based on shadcn/ui styling.
use crate::Anatomy;
#[allow(clippy::module_inception)]
mod field;
mod md;
mod recipe;
/// Stable DOM parts shared by the Field and Fieldset family.
pub const FIELD_ANATOMY: Anatomy = Anatomy::new(
"field",
&[
"root",
"fieldset",
"legend",
"group",
"label",
"content",
"title",
"description",
"error",
"separator",
"separator-rule",
"separator-content",
"separator-text",
],
);
pub use field::{
Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend,
FieldSeparator, FieldSet, FieldTitle,
};
pub use recipe::{
field_recipe, FieldLegendVariant, FieldOrientation, FieldRecipe, FieldRecipeProps, FieldStyle,
FIELD_RECIPE,
};
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_public_web_part() {
assert_eq!(FIELD_ANATOMY.scope(), "field");
for part in [
"root",
"fieldset",
"legend",
"group",
"label",
"content",
"title",
"description",
"error",
"separator",
"separator-rule",
"separator-content",
"separator-text",
] {
assert!(FIELD_ANATOMY.contains(part), "missing part: {part}");
}
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Field recipe — typed root orientation and named compound slots.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Field layout orientation.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, proa_macros::ProaVariant)]
#[variant(name = "orientation")]
pub enum FieldOrientation {
#[default]
Vertical,
Horizontal,
Responsive,
}
impl FieldOrientation {
pub const fn as_str(self) -> &'static str {
match self {
Self::Vertical => "vertical",
Self::Horizontal => "horizontal",
Self::Responsive => "responsive",
}
}
pub const fn classes(self) -> &'static str {
match self {
Self::Vertical => FIELD_VERTICAL,
Self::Horizontal => FIELD_HORIZONTAL,
Self::Responsive => FIELD_RESPONSIVE,
}
}
}
/// FieldLegend text treatment.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
pub enum FieldLegendVariant {
#[default]
Legend,
Label,
}
impl FieldLegendVariant {
pub const fn as_str(self) -> &'static str {
match self {
Self::Legend => "legend",
Self::Label => "label",
}
}
pub const fn classes(self) -> &'static str {
match self {
Self::Legend => FIELD_LEGEND_LEGEND,
Self::Label => FIELD_LEGEND_LABEL,
}
}
}
/// Optional author inputs accepted by [`FieldRecipe::resolve`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FieldRecipeProps {
pub orientation: Option<FieldOrientation>,
}
/// Named classes for every public and internal styled Field part.
#[derive(Debug, Clone, Copy)]
pub struct FieldRecipe {
pub root: &'static str,
pub fieldset: &'static str,
pub legend: &'static str,
pub group: &'static str,
pub label: &'static str,
pub content: &'static str,
pub title: &'static str,
pub description: &'static str,
pub error: &'static str,
pub separator: &'static str,
pub separator_rule: &'static str,
pub separator_content: &'static str,
pub separator_text: &'static str,
orientation_default: FieldOrientation,
}
impl FieldRecipe {
pub const fn resolve(&self, props: FieldRecipeProps) -> FieldStyle {
let orientation = match props.orientation {
Some(orientation) => orientation,
None => self.orientation_default,
};
FieldStyle {
root: self.root,
orientation,
}
}
}
/// Fully resolved, zero-allocation Field root style.
#[must_use]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FieldStyle {
root: &'static str,
orientation: FieldOrientation,
}
impl FieldStyle {
pub const fn orientation(self) -> FieldOrientation {
self.orientation
}
}
impl AttrValue for FieldStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.root.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.orientation.classes().as_bytes())
}
}
pub const FIELD_ROOT: &str = tw_join!(
"group/field",
"flex",
"gap-2",
"data-[invalid=true]:[&_[data-slot=field-error]]:block"
);
pub const FIELD_VERTICAL: &str = tw_join!("flex-col", "w-full");
pub const FIELD_HORIZONTAL: &str = tw_join!(
"flex-row",
"items-start",
"[&>[data-slot=label]]:flex-1",
"[&>[data-slot=label]]:pt-2"
);
pub const FIELD_RESPONSIVE: &str = tw_join!(
"flex-col",
"w-full",
"@md/field-group:flex-row",
"@md/field-group:items-start",
"@md/field-group:[&>[data-slot=label]]:flex-1",
"@md/field-group:[&>[data-slot=label]]:pt-2"
);
pub const FIELDSET: &str = tw_join!(
"flex",
"flex-col",
"gap-6",
"has-[[data-slot=field-group]]:gap-4"
);
pub const FIELD_LEGEND: &str = "text-foreground";
pub const FIELD_LEGEND_LEGEND: &str = tw_join!("text-base", "font-medium");
pub const FIELD_LEGEND_LABEL: &str = tw_join!("text-sm", "font-medium", "leading-none");
pub const FIELD_GROUP: &str = tw_join!(
"@container/field-group",
"flex",
"flex-col",
"gap-3",
"has-[[data-slot=checkbox]]:gap-3",
"has-[[data-slot=radio]]:gap-3"
);
pub const FIELD_LABEL: &str = tw_join!(
"flex",
"items-center",
"gap-2",
"text-sm",
"leading-none",
"font-medium",
"text-foreground",
"select-none",
"data-[disabled=true]:pointer-events-none",
"data-[disabled=true]:opacity-50",
"data-[checked=true]:rounded-md",
"data-[checked=true]:bg-accent",
"data-[checked=true]:border",
"data-[checked=true]:border-accent"
);
pub const FIELD_CONTENT: &str = tw_join!(
"flex",
"flex-col",
"gap-2",
"leading-5",
"@md/field-group:leading-normal"
);
pub const FIELD_TITLE: &str = tw_join!(
"text-sm",
"font-medium",
"leading-none",
"text-foreground",
"group-data-[disabled=true]/field:opacity-50"
);
pub const FIELD_DESCRIPTION: &str = tw_join!(
"text-sm",
"text-muted-foreground",
"[&_a]:underline",
"[&_a]:underline-offset-4",
"[&_a]:hover:text-foreground",
"@md/field-group:text-balance"
);
pub const FIELD_ERROR: &str = tw_join!(
"hidden",
"text-sm",
"font-medium",
"text-destructive",
"[&_ul]:list-disc",
"[&_ul]:list-inside"
);
pub const FIELD_SEPARATOR: &str = tw_join!("relative", "text-center", "text-sm");
pub const FIELD_SEPARATOR_RULE: &str = tw_join!("border-t", "border-border");
pub const FIELD_SEPARATOR_CONTENT: &str = tw_join!(
"absolute",
"inset-0",
"flex",
"items-center",
"justify-center"
);
pub const FIELD_SEPARATOR_TEXT: &str = tw_join!("bg-background", "px-2", "text-muted-foreground");
/// Global named-slot Field recipe.
pub const FIELD_RECIPE: FieldRecipe = FieldRecipe {
root: FIELD_ROOT,
fieldset: FIELDSET,
legend: FIELD_LEGEND,
group: FIELD_GROUP,
label: FIELD_LABEL,
content: FIELD_CONTENT,
title: FIELD_TITLE,
description: FIELD_DESCRIPTION,
error: FIELD_ERROR,
separator: FIELD_SEPARATOR,
separator_rule: FIELD_SEPARATOR_RULE,
separator_content: FIELD_SEPARATOR_CONTENT,
separator_text: FIELD_SEPARATOR_TEXT,
orientation_default: FieldOrientation::Vertical,
};
pub const fn field_recipe() -> &'static FieldRecipe {
&FIELD_RECIPE
}
impl crate::variant_spec::ComponentSpec for FieldRecipe {
const NAME: &'static str = "Field";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<FieldOrientation>()]
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::FIELD_ANATOMY)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_every_styled_slot() {
let recipe = field_recipe();
assert!(recipe.root.contains("group/field"));
assert!(recipe.fieldset.contains("gap-6"));
assert!(recipe.legend.contains("text-foreground"));
assert!(recipe.group.contains("@container/field-group"));
assert!(recipe.label.contains("data-[checked=true]"));
assert!(recipe.content.contains("leading-5"));
assert!(recipe.title.contains("group-data-[disabled=true]"));
assert!(recipe.description.contains("text-muted-foreground"));
assert!(recipe.error.contains("text-destructive"));
assert!(recipe.separator.contains("relative"));
assert!(recipe.separator_rule.contains("border-border"));
assert!(recipe.separator_content.contains("absolute"));
assert!(recipe.separator_text.contains("bg-background"));
}
#[test]
fn recipe_resolves_orientation_defaults_and_overrides() {
let default = field_recipe().resolve(FieldRecipeProps::default());
assert_eq!(default.orientation(), FieldOrientation::Vertical);
let horizontal = field_recipe().resolve(FieldRecipeProps {
orientation: Some(FieldOrientation::Horizontal),
});
assert_eq!(horizontal.orientation(), FieldOrientation::Horizontal);
assert!(horizontal.orientation().classes().contains("flex-row"));
}
#[test]
fn enum_values_are_stable_for_data_attributes() {
assert_eq!(FieldOrientation::Responsive.as_str(), "responsive");
assert_eq!(FieldLegendVariant::Legend.as_str(), "legend");
assert_eq!(FieldLegendVariant::Label.as_str(), "label");
}
#[test]
fn component_metadata_uses_root_axis_and_canonical_anatomy() {
use crate::variant_spec::ComponentSpec;
let variants = FIELD_RECIPE.variant_metadata();
assert_eq!(variants.len(), 1);
assert_eq!(variants[0].name, "orientation");
assert_eq!(FIELD_RECIPE.anatomy(), Some(super::super::FIELD_ANATOMY));
}
}