Skip to content

Forms Guide

Waldur HomePort exclusively uses React Final Form for standard forms and dialogs, and a custom VStepperForm component for complex multi-step wizards.

Form Patterns Comparison

Aspect React Final Form (Standard) VStepperForm (Multi-step)
State Storage Local component state Shared across steps
Performance Optimized subscription model Step-based validation
Complexity Minimal boilerplate Step progression
Persistence Local to component lifecycle Visual progress indicators
Integration Isolated, no external dependencies Complex deployments

Implementation Examples

React Final Form

The standard form implementation uses a <Form> component that provides the handleSubmit method to its render prop.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { Form } from 'react-final-form';
import { SubmitButton, StringGroup } from '@/form';

<Form
  onSubmit={onSubmit}
  render={({ handleSubmit, submitting, invalid }) => (
    <form onSubmit={handleSubmit}>
      <StringGroup
        name="organization"
        label={translate('Organization')}
        required
      />
      <StringGroup name="name" label={translate('Name')} required />
      <SubmitButton submitting={submitting} disabled={invalid} />
    </form>
  )}
/>;

Labels, Fields and Help Text

A field is waldur-ui's FormField around a FormLabel, a control and a FormText. The field links its label to its control by itself: Input, Textarea, NativeSelect (so also NumberInput and SecretInput), the date pickers and Select give themselves an id when they sit in a field, and the label points at the first of them. Write no htmlFor or id, and no controlId.

One label per control. A <label> names one control. A field that holds several controls under one question (a start and an end, a select and its custom value, a list of rows) is a group, not a field with several controls: draw it as a FormFieldset named by a FormLegend (<FormGroup group label=...> in a react-final-form form), and give every control inside a name of its own, a FormField + FormLabel or an aria-label that says which one it is (aria-label={${translate('Limit')} ${i + 1}} for a row). The group's name is announced with each control, and FormText written directly inside the fieldset describes the fieldset, not each control. A select with an "other" value input gets the label on the select and an aria-label on the input. A table of controls is a group too, named by the legend, each cell by its aria-label. A FormLabel outside any field links to nothing, and the waldur-custom/no-orphan-form-label lint rule rejects it: use the legend for a group, FormHeading for text that captions something that is not a control (a read-only value, a preview), and a FormField for a label and its control. Where a field does hold several controls and no group fits, each keeps its own unique id and the label names the first.

A label and a control that cannot share a wrapper, such as the two cells of a table row, share a FieldScope instead (the same linking with no element of its own): PropertyItem is one, so its label names the control in its value with no htmlFor. A hand-written <label> reads the target with useFieldLabelFor().

The label always names the control that is really there: if a control keeps an id of its own, even inside a field with a controlId, that is the one the label points at.

Pass controlId only when the id has to be known: it is then the id of every control inside, so use it for one control. FormGroup from @/form passes one only when you give it controlId or id; it no longer derives one from the field's name, because react-select and the pickers take the id too and a group with several of them would repeat it. Tests look a control up by role and name or data-testid, not by an id. A control with an id of its own keeps it, and the label points at that. FormLabel required draws the asterisk; <FormText variant="danger"> is an error line. FormField draws no spacing, so give it the margin the layout needs, and as="td" makes it a table cell. Inside a react-final-form form use FormGroup (below), which builds all of this from a field's props.

Errors are announced. FieldError renders role="alert", so a screen reader reads the message when it appears, not only when the control is next focused; aria-describedby (linked by the field) covers that second case. FieldWarning is a polite role="status". Mount the message only while there is something to say (both already return nothing without an error) and do not wrap it in another alert, or it is read twice. A one-off error line written with <FormText variant="danger"> needs role="alert" itself.

Never react-bootstrap Form.Group, Form.Label or Form.Text, nor the form-label, form-text and required classes; the waldur-custom/no-bootstrap-form-control lint rule rejects them.

Autonomous Field Group Architecture (*Group Pattern)

To reduce boilerplate, improve type safety, and enforce UI consistency, Waldur uses the Autonomous Field Group pattern. A single base input (e.g. StringField) is wrapped by three sibling HOCs depending on context — see the Edit Field Architecture section below for the full cross-reference table.

We provide a set of components suffixed with *Group (e.g., StringGroup, SelectGroup, SecretGroup) created using the withFormGroup Higher-Order Component (HOC). These components autonomously bundle:

  1. The underlying input component (e.g., StringField)
  2. React Final Form's <Field> wrapper
  3. The layout, label, and validation structure via <FormGroup>

❌ Bad Example: Manual Wrapping (Legacy)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { Field } from 'react-final-form';
import { StringField, FormGroup } from '@/form';
import { translate } from '@/i18n';

// Too much boilerplate, props split between two layers.
<FormGroup
  label={translate('Project name')}
  description={translate('Provide a unique name.')}
  required
>
  <Field
    component={StringField}
    name="name"
    validate={validateProjectName}
    maxLength={150}
  />
</FormGroup>;

✅ Good Example: Autonomous Group (Modern)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { StringGroup } from '@/form';
import { translate } from '@/i18n';

// Clean, single component with unified props!
<StringGroup
  name="name"
  label={translate('Project name')}
  description={translate('Provide a unique name.')}
  required
  validate={validateProjectName}
  maxLength={150}
/>;

Rationale & Benefits

  1. Reduced Boilerplate: You don't need to import Field, the base component (StringField), and FormGroup separately.
  2. Unified Props: Form layout properties (label, description, help, required) and field logic properties (name, validate, maxLength) are cleanly passed to a single component.
  3. Strict Type Checking: The withFormGroup HOC intelligently strips out internal React Final Form props (component, render) and provides strong IntelliSense for the specific properties required by the underlying input component.
  4. Consistency: It enforces a single, standardized way to render labeled fields and their associated error messages or tooltips.

Available Autonomous Groups

Most standard fields have a corresponding *Group component exported from @/form. A selection includes:

  • Text & Input: StringGroup, TextGroup, SecretGroup, NumberGroup, EmailGroup
  • Selection: SelectGroup, AsyncSelectGroup, CreatableSelectGroup, BooleanGroup, RadioGroup
  • Advanced: DateGroup, DateTimeGroup, TimeGroup, MarkdownGroup, MonacoGroup (Code Editor)
  • Specialized: ImageGroup, FileUploadGroup, CountrySelectGroup, CommaSeparatedListGroup

When to Use Manual FormGroup + Field

While autonomous groups are the standard, there are specific architectural exceptions where manually separating <FormField> (or FormGroup) and <Field> (or <FieldArray>) remains the correct approach:

  1. Complex Arrays (FieldArray): When mapping over an array of data (e.g., configuring multiple network allocation pools or IP rules), you typically want a single label for the entire collection rather than repeating the label for every row.
  2. Multi-field Inline Layouts: When several logical fields compose a single input concept (e.g., Start IP and End IP, Min/Max range, X/Y coordinates) that need to be grouped horizontally in a flex row or a table layout.

Example: Array of Inline Inputs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { Field } from 'react-final-form';
import { FieldArray } from 'react-final-form-arrays';
import { FormField, FormLabel, FormText } from 'waldur-ui';

import { BaseInputField } from '@/form/InputField';
import { translate } from '@/i18n';

// The Array Renderer
const AllocationPoolsList = ({ fields }) => (
  <>
    {fields.map((name, index) => (
      <div key={index} className="d-flex align-items-center gap-2 mb-3">
        {/* Manual Field used here to strip out FormGroup wrappers inside the row */}
        <Field name={`${name}.start`}>
          {({ input }) => <BaseInputField {...input} containerClassName="flex-1" />}
        </Field>
        <span className="text-quaternary">-</span>
        <Field name={`${name}.end`}>
          {({ input }) => <BaseInputField {...input} containerClassName="flex-1" />}
        </Field>
      </div>
    ))}
  </>
);

// The Parent Container
export const NetworkPoolField = () => (
  {/* Manual FormGroup used here as the singular label wrapper for the whole list */}
  <FormField>
    <FormLabel>{translate('Allocation pools')}</FormLabel>
    <FieldArray name="allocation_pools" component={AllocationPoolsList} />
    <FormText>{translate('Define IP ranges for automatic assignment.')}</FormText>
  </FormField>
);

Edit Field Architecture (*EditField Pattern)

For details / settings panels that show a value with an inline edit affordance, Waldur uses the Edit Field Architecture. It mirrors the *Group pattern but renders a read-only PropertyItem plus a button that opens a generic edit modal.

Each base field control has three siblings:

Base Control Form Group (withFormGroup) Edit Field (withEditField) Table Filter (withTableFilter)
StringField StringGroup StringEditField StringFilter
SelectField SelectGroup SelectEditField SelectFilter
NumberField NumberGroup NumberEditField NumberFilter
SecretField SecretGroup SecretEditField —
EmailField EmailGroup EmailEditField —
SwitchField BooleanGroup BooleanEditField BooleanFilter
RadioGroupField RadioGroup — —
DateField DateGroup DateEditField —
MarkdownEditor MarkdownGroup MarkdownEditField —

Exports live in @/form/editFields. To make a custom field editable, run the base component through the HOC:

1
2
3
import { withEditField } from '@/form/editFields';

const ExternalIpsEditField = withEditField(OpenStackExternalIpsField);

Provider + field usage

EditFieldProvider supplies the scope (the object being edited) and the callback (the persist function) so individual edit fields don't need to be threaded with props.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import {
  EditFieldProvider,
  StringEditField,
  BooleanEditField,
  SelectEditField,
} from '@/form/editFields';
import { PropertyList } from 'waldur-ui';

<EditFieldProvider scope={offering} callback={update}>
  <PropertyList>
    <StringEditField
      name="service_attributes.backend_url"
      label={translate('API URL')}
      required
      validate={required}
    />
    <SelectEditField
      name="service_attributes.auth_type"
      label={translate('Authentication type')}
      options={AUTH_TYPE_OPTIONS}
      simpleValue
      isClearable={false}
    />
    <BooleanEditField
      name="service_attributes.verify_ssl"
      label={translate('Verify SSL')}
      hideLabel
    />
  </PropertyList>
</EditFieldProvider>;

Callback contract

When the user submits the modal, the dialog builds a body via set({}, field.name, value) (es-toolkit/compat) and passes it to the provider's callback. The callback receives a partial PATCH-shaped object:

1
2
3
4
5
// User edited service_attributes.backend_url
callback({ service_attributes: { backend_url: 'https://keystone…' } });

// User edited backend_id
callback({ backend_id: 'abc-123' });

The backend endpoint behind callback must therefore accept partial updates (PATCH semantics). The marketplace marketplaceProviderOfferingsUpdateIntegration endpoint and projectsPartialUpdate / customersPartialUpdate already do. Wrap the persist call in useManagedMutation so success/error notifications and closeDialog are handled uniformly.

Common props

  • name — dotted path into scope (e.g. service_attributes.backend_url). Reads via get, writes via set, both from es-toolkit/compat, which treat the path exactly as lodash did.
  • label, description, required, validate, format, parse — passed through to the modal's <Field>. There is no normalize: React Final Form ignores it, so convert the stored value with format and the typed value with parse.
  • isStaffOnly — non-staff users see a <StaffOnlyIndicator/> instead of the edit button; the read-only value is still visible.
  • renderValue(value) — override the read-only display. May return null/undefined; the HOC substitutes DASH_ESCAPE_CODE, so no manual dash fallback is needed.
  • tooltip, iconNode — propagate to the compact edit button (e.g. to flag IDP-managed fields with a lock icon).
  • warnTooltip — displays a warning marker next to the row's value, e.g. for fields with non-obvious side effects.
  • Any other prop is forwarded to the inner field when the modal opens — e.g. placeholder on CommaSeparatedListEditField reaches CommaSeparatedListField.

For panel-wide access control, pass hideActions={!canUpdate} to the enclosing <PropertyList> (or TabbedSection) — it hides the entire action column without conditional JSX around every field.

Marketplace plugin credentials

For plugin credentials, compose BaseCredentialsSection (@/marketplace/offerings/update/integration/BaseCredentialsSection) instead of redoing the layout — it provides the scope state badge, the sync button, and the surrounding EditFieldProvider wired to the offering's update mutation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { BaseCredentialsSection } from '@/marketplace/offerings/update/integration/BaseCredentialsSection';
import { StringEditField, SecretEditField } from '@/form/editFields';

export const MyPluginCredentialsSection = (props) => (
  <BaseCredentialsSection {...props}>
    <StringEditField
      name="service_attributes.api_url"
      label={translate('API URL')}
      required
    />
    <SecretEditField
      name="secret_options.api_token"
      label={translate('API token')}
      required
    />
  </BaseCredentialsSection>
);

Tabbed panels

For multi-section panels with URL-synced tabs, use TabbedSection from @/form/TabbedSection:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { TabbedSection } from '@/form/TabbedSection';

<EditFieldProvider scope={offering} callback={update}>
  <TabbedSection enableSearch>
    <TabbedSection.Tab id="filtering" title={translate('Filtering')}>
      <StringEditField name="service_attributes.flavor_exclude_regex" label={…} />
    </TabbedSection.Tab>
    <TabbedSection.Tab id="network" title={translate('Network')}>
      <BooleanEditField name="plugin_options.lbaas_enabled" label={…} />
    </TabbedSection.Tab>
  </TabbedSection>
</EditFieldProvider>

Search caveat: enableSearch filters by walking direct children only. If a tab's content is a composite subcomponent (e.g. <BasicInfoTab/>), search treats it as a single opaque child. Place *EditFields directly under <TabbedSection.Tab> when search is needed.

Don't do this

  • ❌ Don't add fields to a monolithic switch-based EditFieldDialog. The legacy src/customer/details/EditFieldDialog.tsx, src/user/support/EditFieldDialog.tsx, etc. have all been removed.
  • ❌ Don't write your own edit button + openDialog(SomeEditDialog, …) lazy loader when an *EditField would do — the standardized dialog handles validation, dirty-state, and submit wiring for you.
  • ❌ Don't render *EditField outside an EditFieldProvider. The HOC throws — you'll see <withEditField(X)>: "scope" and "callback" must be provided….
  • ❌ Don't pass a component that is already wrapped with withFormGroup to withEditField. The edit dialog injects its own label, so a *Group component produces double labels — wrap the base field (e.g. StringField) instead.
  • ❌ Don't manually check for empty values with ternary operators — withEditField renders a dash (—) for missing values automatically.

Canonical examples:

  • src/customer/details/CustomerDetailsPanel.tsx — TabbedSection + multiple tabs of *EditFields.
  • src/openstack/OpenStackCredentialsSection.tsx — BaseCredentialsSection with Select/Boolean/Secret edit fields.
  • src/marketplace/offerings/update/integration/LifecyclePolicySection.tsx — search-enabled tabs with flat field lists.

Date & Time Pickers

All date controls are built on Calendar, DatePicker and DateRangePicker from waldur-ui (shadcn's recipe over react-day-picker, opened in a Radix popover); waldur-ui also exports the pieces for custom pickers (DatePickerPopover, TimeInput, the dateBounds helpers, useCalendarLocale).

Component Stores in form state Notes
DateField ISO date yyyy-MM-dd DateGroup, DateEditField, DateFilter
DateTimeField ISO datetime with offset DateTimeGroup; shown as yyyy-MM-dd HH:mm
DateTimeRangeField [Date, Date] Floors at now unless minDate={null}; ref exposes open()
RangeDateField { min, max } ISO dates DateRangeFilter; date-only, no floor
DatePicker — (plain value/onChange Date) For non-form UI; from waldur-ui
DateRangePicker — (plain [Date, Date]) For non-form UI; from waldur-ui
MonthPicker — (plain Date, first of month) Billing/usage periods; from waldur-ui
  • minDate/maxDate accept a Date, an ISO date or datetime string, a luxon DateTime or 'today'. They are inclusive and day-granular in the calendar; a datetime bound also clamps the picked time.
  • enable takes predicates a day must match (e.g. first of the month only); inline renders the calendar without a popup.
  • captionLayout="dropdown" (on DatePicker/DateField/DateEditField) replaces the caption with month and year dropdowns — use it for dates far from today, like a birth date. Years span minDate–maxDate, else the last 100 years.
  • Closing the popup calls input.onBlur, so validation shows without a submit.
  • DateFilter, DateTimeFilter and DateRangeFilter open their calendar as soon as the filter is picked (autoOpen), as select filters show their menu.
  • Popups use --z-index-picker-popover, above modals and filter dropdowns.
  • Never use native <input type="date|datetime-local|time|month|week"> — the waldur-custom/no-native-date-input lint rule rejects it. For state kept as strings, read with parseDateValue and write with toIsoDate / toIsoMonth (all from waldur-ui).

Behaviour is specified by stories, each rule in one place, under Forms/Date & time in Storybook: DatePicker and DateRangePicker (packages/ui/src/DatePicker/) specify the pickers; Form fields (src/form/dateFields.stories.tsx) covers only what the adapters add — stored shape, touched-on-close, the maintenance floor; Calendar shows the primitive. Screens built on the pickers keep their stories under their own domain (Maintenance/…, Booking/…, Reporting/…). All of them drive the pickers through waldur-ui/src/DatePicker/testing (imported by path, never from the package index) and assert only on what the user or form sees, so a markup change is fixed in the driver, not the stories. Use the toolbar's theme switch for dark mode rather than adding dark copies. Unit tests only cover what a story can't: TimeInput's editing rules, anything that needs a frozen clock (the maintenance floor), and crash safety on malformed data — don't repeat a story's assertion in a test. Run them with yarn test:storybook packages/ui/src/DatePicker src/form.

Form Stories in Storybook

Form stories sit under Forms/, one folder per kind of control, in the order set by storySort in .storybook/preview.tsx:

Folder What lives there
Layout FormGroup, PropertyList: what wraps a control
Text inputs Input, Textarea, Input group, Number input, Secret input, Range input
Selection Native select, Select, and fields built on them
Check controls Checkbox, Switch, Radio, BoxRadioField
Date & time Calendar and the pickers
Edit fields The read-only-with-edit rows
  • One story file per primitive, next to it in packages/ui, titled Forms/<Folder>/<Component>. A text control has Playground (with controls), States (the shared StateGrid: default, filled, invalid, disabled, read only, outlined beside solid) and Sizes, then whatever is specific to it. Hover and focus are live; use the pseudo-state toolbar to pin them.
  • Form fields per folder covers only what the react-final-form adapters in src/form add: the stored value, touched and invalid reaching the control, props such as solid and icon. It does not repeat the look, which the primitive's page specifies.
  • A story asserts what is on screen, not markup, so it survives a restyle.
  • Add a new control's folder and its pages to storySort; an unlisted page sorts alphabetically after the listed ones.

Autonomous Table Filters (*Filter Pattern)

Similar to the *Group pattern, Waldur provides autonomous components for table filters. These components combine TableFilterItem, React Final Form's Field, and an input component.

They are created using the withTableFilter HOC and handle the toggle button, menu/sidebar layout, and form state binding autonomously.

Example: Table Filter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import { SelectFilter, BooleanFilter } from '@/table';

// Standard select filter
<SelectFilter
  name="state"
  title={translate('State')}
  options={stateOptions}
/>

// Boolean (checkbox) filter
<BooleanFilter
  name="is_active"
  title={translate('Active only')}
  parse={(v) => v || undefined} // Common pattern to remove filter when unchecked
/>

Available Autonomous Filters

  • SelectFilter: Standard selection
  • AsyncSelectFilter: Dynamic selection from API
  • BooleanFilter: Checkbox toggle
  • StringFilter: Custom text search
  • DateFilter / DateTimeFilter: Date/Time selection
  • NumberFilter / NumberRangeFilter: Numeric selection
  • OfferingFilter: Specialized offering selector
  • ProjectFilter: Specialized project selector
  • ProviderFilter: Specialized service provider selector

Tooltips & Help Text

Avoid creating manual tooltips or extra label wrappers. Use the built-in help or tooltip properties provided by the autonomous groups.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// ❌ Manual tooltip implementation
<label>
  {translate('Plan')}
  <Tooltip label={translate('Help text')}>
    <QuestionIcon />
  </Tooltip>
</label>
<Field component={SelectField} name="period" />

// ✅ Built-in help prop with *Group component
<SelectGroup
  name="period"
  label={translate('Plan')}
  help={translate('Help text')}
/>

Key Patterns & Best Practices

Validation

Validation functions are pure functions that return an error string if invalid, or undefined if valid.

1
2
export const validateProjectName = (value, _, props) =>
  checkDuplicate(value, props) || checkPattern(value);

Async Data Integration

Use React Query inside forms to load asynchronous data:

1
2
3
4
const { data, isLoading, error, refetch } = useQuery({
  queryKey: ['CustomerProjects', selectedCustomer?.uuid],
  queryFn: () => fetchCustomerProjects(selectedCustomer.uuid),
});

Modern Form Submission

When writing the submission handler for modal dialogs, use useManagedMutation or handle success/error states natively.

1
2
3
4
5
6
7
8
9
const onSubmit = async (formData) => {
  try {
    await projectsCreate({ body: formData });
    showSuccess(translate('Project created'));
    closeDialog();
  } catch (e) {
    showErrorResponse(e, translate('Unable to create project'));
  }
};

Error Handling

Issue: Avoid unhandled promise rejections when reporting API errors.

Solution: Don't re-throw errors after showErrorResponse():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ❌ Before (problematic)
catch (e) {
  showErrorResponse(e, translate('Unable to create key.'));
  throw e; // Causes unhandled promise rejection
}

// ✅ After (correct)
catch (e) {
  showErrorResponse(e, translate('Unable to create key.'));
  // Error handled, no re-throw needed
}

Key Issue: React Final Form context boundaries - submit buttons must be inside the <Form> component.

Solution: Move submit buttons inside the form context and provide a custom footer structure.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// ✅ Correct structure
import { ModalDialog } from '@/modal/ModalDialog';

<Form
  onSubmit={onSubmit}
  render={({ handleSubmit, submitting, invalid }) => (
    <form onSubmit={handleSubmit}>
      <ModalDialog
        title={translate('New Project')}
        footer={<SubmitButton submitting={submitting} disabled={invalid} />}
      >
        <ResourceForm />
      </ModalDialog>
    </form>
  )}
/>;

Advanced Tooling

Dirty Form Protection

If you want to prevent users from accidentally closing a modal dialog when a form has unsaved changes, use the DirtyStateReporter drop-in component anywhere inside your <Form> element:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { DirtyStateReporter } from '@/core/DirtyFormContext';

<Form
  onSubmit={onSubmit}
  render={({ handleSubmit }) => (
    <form onSubmit={handleSubmit}>
      <DirtyStateReporter />
      <StringGroup name="name" label={translate('Name')} />
    </form>
  )}
/>;

Tabbed Sections (TabbedSection)

When building complex forms with many fields, use the TabbedSection component to organize fields into logical, URL-synced tabs. It includes built-in support for searching across tabs and automatically jumping to tabs with matching results.

Example Usage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import {
  EditFieldProvider,
  StringEditField,
  BooleanEditField,
} from '@/form/editFields';
import { TabbedSection } from '@/form/TabbedSection';
import { translate } from '@/i18n';

export const MySettingsPanel = ({ settings, update, canUpdate }) => (
  <EditFieldProvider scope={settings} callback={update}>
    <TabbedSection
      title={translate('Settings')}
      enableSearch
      hideActions={!canUpdate}
    >
      <TabbedSection.Tab id="general" title={translate('General')}>
        <StringEditField name="name" label={translate('Name')} />
        <BooleanEditField name="is_active" label={translate('Active')} />
      </TabbedSection.Tab>

      <TabbedSection.Tab id="advanced" title={translate('Advanced')}>
        <StringEditField name="api_key" label={translate('API Key')} />
      </TabbedSection.Tab>
    </TabbedSection>
  </EditFieldProvider>
);

Key Features

  1. URL Synchronization: Automatically syncs the active tab to the URL query string using useUrlTab (@/navigation/useUrlTab), allowing users to share links to specific tabs.
  2. Built-in Search (enableSearch): When enabled, provides a search input that filters fields across all tabs by matching against their label and description props.
  3. Auto-jump: When searching, if the current tab yields no results, it automatically jumps to the first tab containing matching fields.
  4. Field Counters: Displays a badge on each tab showing the number of matching fields during a search.
  5. PropertyList Integration: Automatically wraps the tab's fields in a PropertyList for consistent alignment and styling.