Skip to content

feat: repeating table rows (#349 phase 3) - #531

Merged
kevinchappell merged 25 commits into
mainfrom
feat/349-table-repeat
Oct 6, 2026
Merged

kevinchappell merged 25 commits into
mainfrom
feat/349-table-repeat

Conversation

@kevinchappell

Copy link
Copy Markdown
Collaborator

Summary

  • Repeating rows (Need Table Element with Row and Columns #349, phase 3). A Table with an input column can repeat. With table.repeat = { min, max }, the person filling in the form adds and removes rows. rows[0] is the template: its static text and Required apply to every row.
  • Names. Rows post contiguous positional keys (order[0][qty], radios order[0]) and are renumbered after a remove. With row headers on, rows are numbered from the template label ("Item 2").
  • Keyboard and screen readers. + Row and each row's remove button (named "Remove row 2") work from the keyboard. Focus moves to the new row's first input after an add. After a remove it moves to the next remove button, else the previous one, else + Row. A status region announces "Item 3 added" / "Item 2 removed". Remove buttons are disabled at min and + Row at max.
  • Data.
    • The userData setter recreates the rows a saved answer needs, up to max (or 500 with no max).
    • userFormData labels positional rows ("Order: Item 2, Qty").
    • Rows added while the table is hidden by a condition or on a skipped page stay out of validation until shown.
  • Conditions apply to the whole table only. Row and cell addresses on a repeating table resolve to nothing, and the picker lists no rows for it.
  • Input groups get the same treatment:
    • a translated Add label
    • a remove button with an accessible name ("Remove group 2") that also works when its icon is clicked
    • focus management and announcements
    • copies that no longer throw on plain DOM children
    • deterministic names (<name>-<n>, renumbered after a remove)
    • copies cached, so userFormData labels their answers
    • a setter that recreates copies (capped at 500)
  • Editor.
    • The Table panel gains Repeating rows, Minimum rows and Maximum rows, and shows only the template row while repeating.
    • The condition picker keeps an item when its own, an ancestor's or a descendant's label matches, so typing a row name no longer hides its cells.
    • Right and Left move into and out of nested rows, cells and options; they move the caret as before otherwise.
  • Event. formeo:rowschange, a bubbling CustomEvent with { action: 'add' | 'remove', index }, is dispatched by tables and input groups, and the renderer calls onChange for it.
  • Schema, types and docs. Schema repeat, TableRepeat / RowsChangeDetail types, CSS (24px remove targets with a focus outline, stacked cards end with the remove button), and docs in docs/controls/table.md and docs/renderer/renderer.md.

Compatibility

  • Saved phase 1 and phase 2 tables render byte-identically. Characterization snapshots were recorded before the refactor and never re-recorded.
  • Input-group copies:
    • Radio and checkbox groups, multiple selects and matrices are now named <name>-<n> instead of a random id, so their answers can be restored.
    • Unnamed textareas and single selects now share the original's name and post an array, like plain inputs.
    • The English "Add +" text and the add-input-group / remove-input-group classes are unchanged.
  • onChange also fires when a row or group is added or removed. For those events target is the table or group wrapper, which has no name, so check event.type === 'formeo:rowschange' before reading target.name.
  • userData setter:
    • It matches keys by name first, then by id for a single text-like input, select or textarea, as before.
    • Setting a checkbox or radio option by its internal option id (f-x-0) no longer works. Unnamed checkables are named f-<id>, so that key still does.
  • Known limits (documented):
    • Conditions don't apply to input-group copies (as before).
    • A repeating table inside an input-group copy doesn't repeat; its buttons are hidden.
    • A group with a grouped control named x shouldn't also contain a control named x-<n>.
  • New strings fall back to English until @draggable/formeo-languages ships them (branch feat/table-repeat-keys). They're listed in PENDING in i18n-fallbacks.test.mjs.

Follow-up

  • Once a languages release includes the 13 keys, bump @draggable/formeo-languages and empty PENDING.

Test plan

  • npm test (1175), npm run test:types, npm run lint, npx biome check src tests, npm run build, npm run test:dist

  • Playwright: tests/table-repeat.spec.js covers:

    • building a repeating table from the keyboard
    • filling it in from the keyboard (add, remove row 1 with renumbering and focus, + Row disabled at max)
    • data round trip, a required row, 360px stacking
    • input-group names and restore
    • the picker keyboard and filter

    The full suite passed: 227 passed, 3 skipped.

  • The dist/formData_schema.json diff is only repeat and the table description.

Phase 3 (repeating rows) is ready for review on feat/349-table-repeat: table.repeat {min, max} with a template row, positional keys, setter-created rows, input-group names, labels, restore and accessibility, and picker filter and keyboard fixes. Languages: feat/table-repeat-keys.

Refs #349

Please squash-merge.

repeatOf, isRepeating, setRepeat, templateRow and repeatRowName for table.repeat, and
withKeys leaves a repeating table's rows unkeyed. PENDING in i18n-fallbacks lists keys the
languages package hasn't shipped yet.

Refs #349
A characterization snapshot before the matrix row builder is factored out for repeating
rows.

Refs #349
A repeating table renders min rows from its template with positional names, numbered row
headers, a remove button per row, an Add button and a status region. matrixRowConfig builds one
row, so added rows match the first render; dom.tableOptions shares the hook's options.

Refs #349
One delegated listener per form adds rows from the template and removes them, renumbering
later rows so keys stay contiguous. Focus moves to the new row or the next remove button, a
status region announces the change, and formeo:rowschange reaches onChange.

Refs #349
The userData setter grows a repeating table to the rows its keys name (never past max, or 500
rows when there is no max), and new rows adopt condition-hidden and skipped-page state.
userFormData labels positional rows, and row and cell addresses on a repeating table resolve to
nothing.

Refs #349
The userData setter synced the button limits once per added row, a whole-table query each time.
It now syncs once after growing. Adds a test that setter growth fires no rowschange, no
onChange and no announcement.

Refs #349
Input group code moves to renderer/input-groups.js. The Add label is translated, the remove
button is named and works when its icon is clicked, focus and a status region follow add and
remove, and formeo:rowschange fires. Copies of radio, checkbox and multiple select groups and
matrices are named <name>-<n> and renumbered; copies are cached so userFormData labels them; a
field with plain DOM children no longer throws. A repeating table inside a copy renders but
neither clicks nor the userData setter add or remove its rows.

Refs #349
The userData setter fills same-named text inputs, textareas and selects in order, and creates
the input group copies that array answers and <name>-<n> keys need (up to 500), so saved
answers round-trip into a fresh render. Setter lookups match names only, since a copy's
<id>-<n> name collided with its option input ids.

Refs #349
Copies the userData setter adds inside a skipped page or a condition-hidden group are
disabled or not required like their neighbours. adoptRow moves to row-actions.js as adoptInto,
shared by repeating table rows and input group copies.

Refs #349
table.repeat { min, max } in the formData schema and TableData, plus RowsChangeDetail for
formeo:rowschange, which onChange now also receives.

Refs #349
A Repeating rows checkbox (locked, with a hint, until the table has an input column), and
Minimum and Maximum rows inputs normalised on commit. While repeating, the grid shows only the
template row, whose Required checkbox applies to every row.

Refs #349
Typing keeps the items a match sits under and the items under it, so a row name no longer hides
its cells. Right and Left move into and out of nested rows, cells and options, which keyboard
users could not reach. Repeating tables list no rows or cells.

Refs #349
Visually hidden helpers, 24px remove targets with a focus outline, the stacked actions cell,
the panel's row limits, and nested picker lists that stay open around the keyboard's active item.

Refs #349
How to turn on repeating rows, the template row, limits, positional names and userData, the
setter, keyboard use and formeo:rowschange; and input group names, restore and accessibility.

Refs #349
Panel keyboard setup, keyboard add and remove with focus and announcements, a userData round
trip into a fresh render, required rows, stacking at 360px, input group names and restore, and
reaching matrix cells in the condition picker from the keyboard. The shared table e2e helpers
move to tests/helpers/table.js.

Refs #349
Assert the input group restore recreates clones, build the table from the keyboard including
column types and limits, check each stacked card ends with its remove button, and wait for focus
before typing into a new row.

Refs #349
The setter stopped accepting element ids when it began matching names only, so
`{ 'f-t1': 'x' }` no longer filled an unnamed text field whose name came from
its label. Names are still tried first; an id now fills a single text-like
control or select as a last resort, never a checkbox or radio, whose option ids
look like an input group copy's `<base>-<n>` name.

A name lookup that mixes id and name matches now returns the one named control
itself, so a multiple select found that way takes every value of an array.

Refs #349
An input group answer array longer than the 501 inputs the setter may create
lost its extra values silently, though the docs say they're reported. They now
join the setter's warning for keys with no field, e.g. `email (values 502-600)`.

Refs #349
A repeating table inside an input group copy doesn't repeat, yet it showed Add
and remove buttons that did nothing, and bringing a skipped page back re-enabled
them. A copy now renders those buttons hidden and disabled, and repeatingTables
leaves copy tables out, so a skipped page coming back doesn't re-sync them. The
remove button's display rule beat [hidden], so it gets its own [hidden] rule.

Refs #349
A repeating table imported with `rows: []` showed a blank template row in the
Table panel that wasn't in its data, so cell edits and the Every row required
toggle changed nothing. The panel now edits from a table holding that template
row, and turning repeating on for a table with no rows stores it too.

Refs #349
onChange's formeo:rowschange event targets the table or group wrapper, which has
no name, so the target.name recipe needs an event.type check first; the
renderer docs and the onChange type comment now say so. Re-wraps the input
group Names and Accessibility paragraphs and a table docs line to 120 columns.

Refs #349
Copilot AI balanced review requested due to automatic review settings October 6, 2026 21:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Clone cache lifecycle, unbounded row allocation, and incomplete event typings remain unresolved.

Review effort: Balanced
Findings: 2 High severity

Open (2)
What changed in this PR

Adds repeatable table rows to the renderer/editor, while improving input-group restoration, accessibility, condition selection, schema, and public types.

Changes:

  • Implements repeatable table rows with limits, positional names, restoration, events, and accessibility.
  • Refactors input-group cloning and condition-picker navigation.
  • Adds extensive unit, type, CSS, and Playwright coverage plus documentation.
File Description
tools/​formdata-schema.mjs Adds repeating-table schema.
tests/​table.spec.js Uses shared table helpers.
tests/​table-repeat.spec.js Adds repeat-row E2E coverage.
tests/​table-matrix.spec.js Uses shared table helpers.
tests/​helpers/​table.js Provides shared Playwright utilities.
src/​types/​formeo.test-d.ts Tests new public types.
src/​types/​formeo.d.ts Adds repeat and event-detail types.
src/​lib/​sass/​css-properties.test.mjs Tests new CSS rules.
src/​lib/​sass/​components/​_field-edit.scss Styles repeat-limit controls.
src/​lib/​sass/​components/​_autocomplete.scss Keeps keyboard-selected branches visible.
src/​lib/​sass/​base/​_bs.scss Styles repeating-table controls.
src/​lib/​sass/​_render.scss Improves input-group remove buttons.
src/​lib/​sass/​__fixtures__/​formeo-baseline.css Updates compiled CSS fixture.
src/​lib/​js/​renderer/​table-repeat.test.js Tests repeating-row behavior.
src/​lib/​js/​renderer/​table-matrix.test.js Updates clone naming expectations.
src/​lib/​js/​renderer/​row-actions.js Shares row/group actions.
src/​lib/​js/​renderer/​repeat-rows.js Implements runtime row repetition.
src/​lib/​js/​renderer/​renderer.test.js Tests identifier-based restoration.
src/​lib/​js/​renderer/​input-groups.test.js Tests input-group cloning and restore.
src/​lib/​js/​renderer/​input-groups.js Implements deterministic group copies.
src/​lib/​js/​renderer/​index.js Integrates repetition and restoration.
src/​lib/​js/​renderer/​helpers.js Exports shared skip helpers.
src/​lib/​js/​i18n-fallbacks.test.mjs Tracks pending translations.
src/​lib/​js/​formdata-schema.test.mjs Tests repeat schema generation.
src/​lib/​js/​components/​fields/​table-panel.test.js Tests repeat editor controls.
src/​lib/​js/​components/​fields/​table-panel.js Adds repeat configuration UI.
src/​lib/​js/​components/​autocomplete/​table-targets.test.mjs Tests filtering and keyboard navigation.
src/​lib/​js/​components/​autocomplete/​helpers.mjs Adds hierarchical filtering.
src/​lib/​js/​components/​autocomplete/​autocomplete.mjs Adds nested keyboard navigation.
src/​lib/​js/​common/​table.test.mjs Tests repeat utilities.
src/​lib/​js/​common/​table.mjs Defines repeat data and markup.
src/​lib/​js/​common/​table-text.test.mjs Tests new table strings.
src/​lib/​js/​common/​table-text.mjs Adds table translations.
src/​lib/​js/​common/​table-repeat-dom.test.js Tests rendered repeat markup.
src/​lib/​js/​common/​input-group-text.test.mjs Tests group translations.
src/​lib/​js/​common/​input-group-text.mjs Adds group translation fallbacks.
src/​lib/​js/​common/​dom.js Centralizes table rendering options.
src/​lib/​js/​common/​__snapshots__/​table-repeat-dom.test.js.snapshot Records compatibility markup.
docs/​typescript.md Documents new exported types.
docs/​renderer/​renderer.md Documents events and input groups.
docs/​controls/​table.md Documents repeating rows.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/lib/js/common/table.mjs
Comment thread src/lib/js/renderer/input-groups.js
repeatOf capped nothing, so a huge repeat.min built that many rows, or threw a RangeError past
the maximum array length, before the form rendered. min is now capped at REPEAT_MIN_LIMIT
(500) in repeatOf, the schema, the panel's Min input and the docs.

Refs #349
Copies are cached in renderer.components, which only destroy() reset. Rendering again kept the
last render's copies, so labels and conditions could read stale entries and the cache grew
with every render. getRenderedForm now starts from an empty cache.

Refs #349
@kevinchappell
kevinchappell merged commit 0c2e3b8 into main Oct 6, 2026
2 checks passed
github-actions Bot pushed a commit that referenced this pull request Oct 6, 2026
# [5.19.0](v5.18.0...v5.19.0) (2026-10-06)

### Features

* repeating table rows ([#349](#349) phase 3) ([#531](#531)) ([0c2e3b8](0c2e3b8))
@kevinchappell

Copy link
Copy Markdown
Collaborator Author

🎉 This PR is included in version 5.19.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@kevinchappell
kevinchappell deleted the feat/349-table-repeat branch October 8, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants