Skip to content

[RFC] ListInput — compact editor for small repeated records #4531

Description

@ernestt

Problem Statement

Product forms often need to edit a short, ordered collection of consistent records: guest lists, traveler details, mailing-list entries, emergency contacts, tag options, and similar data. Each record usually has one or two simple fields, and users need to add, remove, edit, validate, and sometimes reorder rows without leaving the form.

Astryx currently provides the individual inputs and display-table primitives, but consumers must independently assemble the repeated-row layout, controlled collection updates, responsive behavior, focus policy, accessible names, validation placement, and reorder interactions. That creates substantial boilerplate and makes accessibility quality depend on every caller implementing the same behavior correctly.

Evidence of Demand

The pattern recurs across several product-neutral scenarios:

  • Guest lists: name + email
  • Traveler information: full name + passport country
  • Tag options: color + label
  • Mailing lists: display name + address
  • Emergency contacts: name + phone

The intended design guidance is:

  • Normally fewer than seven records
  • Normally fewer than three fields per record
  • A consistent field schema across rows
  • Simple controls such as a single-line input, selector, checkbox, or switch

Those thresholds are guidance, not runtime restrictions. Larger consistent datasets should generally use Table; heterogeneous or complex records should generally use cards, tabs, or another progressive form pattern.

Public form libraries expose state primitives for repeated fields (for example Formik FieldArray, React Hook Form useFieldArray, and Ant Design Form.List), but a design-system component still needs to own the visual, interaction, and accessibility contracts.

Why Existing Components Don't Cover This

  • Table supplies semantic rows, columns, sizing, and overflow, but not row creation/removal, controlled item updates, collection validation, accessible reordering, or focus restoration.
  • FormLayout arranges independent controls but does not model a repeated ordered collection.
  • List is a display/navigation collection, not a form input.
  • Tokenizer represents scalar selections as tokens; it does not edit multiple fields within each record.

A recipe can demonstrate one instance, but every caller would still need to rebuild the same state transitions and accessibility behavior.

Proposed API: ListInput

API arbitration tested three mental models across guest, traveler, tag-option, dialog, narrow-container, and migration scenarios:

Candidate Result
Data-driven ListInput 38/40
Compound EditableList 33/40
Render-prop FieldArray 16/40

ListInput was the most discoverable and concise. Typed column configuration stayed reusable, while the component retained ownership of semantics and interaction. EditableList added repeated compound-component ceremony. FieldArray encouraged callers to rebuild rows and native controls, weakening accessibility-by-default.

Provisional consumer API:

type TagOption = {
  id: string;
  color: string;
  label: string;
};

const columns: ListInputColumn<TagOption>[] = [
  {
    key: 'color',
    header: 'Color',
    renderInput: ({item, updateItem, label, status, ...state}) => (
      <Selector
        label={label}
        isLabelHidden
        value={item.color}
        onChange={color => updateItem({...item, color})}
        status={status}
        {...state}
      />
    ),
  },
  {
    key: 'label',
    header: 'Label',
    renderInput: ({item, updateItem, label, status, ...state}) => (
      <TextInput
        label={label}
        isLabelHidden
        value={item.label}
        onChange={labelValue => updateItem({...item, label: labelValue})}
        status={status}
        {...state}
      />
    ),
  },
];

<ListInput
  label="Tag options"
  description="Choose a color and label for each option."
  itemName="tag"
  value={tags}
  onChange={setTags}
  getItemKey={tag => tag.id}
  createItem={() => ({id: crypto.randomUUID(), color: '', label: ''})}
  columns={columns}
  isReorderable
  status={listStatus}
  getItemStatus={tag => itemStatuses[tag.id]}
  getFieldStatus={(tag, columnKey) => fieldStatuses[tag.id]?.[columnKey]}
/>

Provisional types:

interface ListInputColumn<T> {
  key: string;
  header: string;
  width?: ColumnWidth;
  renderInput: (context: ListInputRenderContext<T>) => ReactNode;
  renderValue?: (context: ListInputValueContext<T>) => ReactNode;
}

interface ListInputProps<T> {
  label: string;
  value: T[];
  onChange: (next: T[], change: ListInputChange<T>) => void;
  getItemKey: (item: T) => React.Key;
  createItem: () => T;
  columns: ListInputColumn<T>[];
  itemName?: string;
  description?: string;
  status?: InputStatus;
  getItemStatus?: (item: T, index: number) => InputStatus | undefined;
  getFieldStatus?: (
    item: T,
    columnKey: string,
    index: number,
  ) => InputStatus | undefined;
  isReorderable?: boolean;
  isReadOnly?: boolean;
  isDisabled?: boolean;
  isLoading?: boolean;
  maxItems?: number;
}

ListInputChange<T> distinguishes add, update, remove, and reorder, including stable item key and relevant indexes/column key. Minimum-count validation remains caller-controlled through status; removal is not blocked merely because the resulting collection is invalid. maxItems is a physical constraint and disables Add at the limit.

Validation Contract

Validation is supported at three independent scopes:

  1. Field: Passed to the rendered input as an InputStatus; the control owns its invalid border/icon/message and aria-invalid/aria-describedby wiring.
  2. Item: A full-row message associated with that stable row. It does not replace field errors.
  3. List: A full-width collection message rendered after the rows and Add action, associated with the collection.

All three may coexist. Errors stay attached to stable item keys after reorder. Validation timing remains application-owned: callers may compute statuses on change, blur, submit, or after an async response.

Accessibility Considerations

  • Render semantic table structure with column headers associated to cells.
  • Give the collection a programmatic name and description.
  • Give every cell input a label combining column and item position.
  • Use stable keys so values, errors, DOM identity, and focus survive reorder.
  • Use real buttons with specific names for Add, Remove, and the reorder handle.
  • Support pointer, touch, and keyboard reorder with the same commit path.
  • During keyboard reorder, support start, move, commit, and cancel; announce position changes in a polite live region.
  • Keep focus on the moved item after reorder.
  • After Add, focus the first editable control in the new row.
  • After Remove, focus the equivalent action in the next row, previous row, or Add button.
  • Ensure drag never starts from a field or remove action.
  • Use persistent field error text; validation cannot be conveyed only by color or a hover tooltip.
  • Distinguish read-only, disabled, and loading behavior and ARIA.

Responsive Behavior

The component owns narrow-container behavior. Rows remain semantically tabular and horizontally scroll when their minimum column widths no longer fit. Long labels and validation messages wrap without requiring consumer wrapper CSS. The component does not switch to a different record format at runtime.

Performance Considerations

This component targets small collections, so v1 does not virtualize. Rendering cost is linear in items × columns and is intentionally bounded by the documented use case. Reorder may read the small set of row rectangles during an active pointer interaction; it should not continuously measure during ordinary rendering. Stable item keys prevent unnecessary remounts.

Implementation Plan

  1. Land an experimental implementation in @astryxdesign/lab.
  2. Compose Field, semantic Table elements, Astryx inputs, Button/IconButton, FieldStatus, and VisuallyHidden.
  3. Keep the reorder engine internal until a second consumer establishes a reusable hook contract.
  4. Add typed docs, Storybook scenarios, and colocated behavior/a11y tests.
  5. Test one-, two-, and three-column configurations; field/item/list errors; controlled external changes; add/remove; pointer/touch/keyboard reorder and cancellation; focus restoration; read-only/disabled/loading; empty/max states; narrow width; long localized content; and RTL.
  6. Harden in lab across themes and human design review before a separate core-graduation change.

Pre-submission Checklist

  • I have read the Contributing guide
  • I have read the API Conventions
  • I have checked that existing Astryx components cannot compose to solve this without repeating substantial behavior and accessibility logic
  • This is a general-purpose UI pattern, not specific to one product

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions