Skip to content

Conversation

@salehattari
Copy link

🔗 Linked issue

Resolves #5308

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

This PR introduces a new Repeater component.
It allows users to create dynamic, sortable lists of forms or items. It is designed to handle complex objects, supports custom templates, and is fully integrated with the Nuxt UI theme system.

  • Dependencies: This component utilizes @vueuse/integrations/useSortable (wrapping sortablejs).

Features

  • 🔄 Drag & Drop: Fully sortable items powered by useSortable (with touch support).
  • 🎛 Manual Sorting: Optional Up/Down buttons for better accessibility.
  • 🎨 Themeable: Built with tv (Tailwind Variants) supporting outline, soft, etc.
  • 🛡 Limits: Built-in support for maxItems and minItems.
  • 🧩 Extensible Slots: Full control via slots.
  • ⌨️ Typed: Generic type support for complex models (T extends Record<string, any>).

Checklist

  • Component logic (src/runtime/components/Repeater.vue)
  • Theme configuration (src/theme/repeater.ts)
  • Playground example (playgrounds/nuxt/app/pages/components/repeater.vue)
  • Tests (test/components/Repeater.spec.ts)
  • Documentation (docs/content/docs/components/repeater.md)

Preview

URepeater.-.salehattari.mp4

Unifies emit overloads per @typescript-eslint/unified-signatures.
Removes unused toRef and stop; useSortable cleanup is already handled by vueuse via scope disposal.
@ta-inventions
Copy link

Hey man,
CI was failing due to unified-signatures lint rules and unused vars.
I pushed a fix PR against your branch that resolves this:
salehattari#1
Thx for this great work btw!

fix(ui): unify Repeater emits signatures and clean up unused vars - also fixed other CI errors
Fixes accessibility violations ("button-name") by adding aria-labels
to icon-only buttons. Labels are localized using Nuxt UI locale
infrastructure.
This resolves failing a11y snapshot tests in both nuxt and vue projects.
No visual changes.
@ta-inventions
Copy link

Hey @salehattari,
I pushed another PR against your branch that resolves the new CI failed test:
salehattari#2

fix(ui): add accessible labels to Repeater action buttons
@ta-inventions
Copy link

Hey @salehattari,
I pushed another PR against your branch that resolves the new CI failed typecheck:
salehattari#3

@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 5, 2026

npm i https://pkg.pr.new/@nuxt/ui@5797

commit: 71b5a42

@ta-inventions
Copy link

Hey, @atinux @TheAlexLichter - I'm not sure if I'm tagging the right people to address it but -
it seens that tiptap/core published a new version (3.15) like 2 hours ago:
{C5CFB12D-270F-47DE-9D6B-7A56A9EA27F2}
https://www.npmjs.com/package/@tiptap/core?activeTab=readme
And since the pnpm-lock.yaml on the root of the project is fixed to:
'@tiptap/core': specifier: ^3.14.0 version: 3.14.0(@tiptap/pm@3.14.0)
but the Install dependencies for the playground CI runs:
Run pnpm install --ignore-workspace
image
it ignores the root lock file and install the latest version of tiptap/core(3.15) which later on causes an error on the
Typecheck step of the playground because there is a mismatch between the 2 tiptap/core versions:
Types of property 'addNodeView' are incompatible. Type '((this: { name: string; options: any; storage: any; editor: import("/home/runner/work/ui/ui/node_modules/.pnpm/@tiptap+core@3.14.0_@tiptap+pm@3.14.0/node_modules/@tiptap/core/dist/index").Editor; type: import("/home/runner/work/ui/ui/playgrounds/nuxt/node_modules/.pnpm/prosemirror-model@1.25.4/node_modules/prosemirr...' is not assignable to type '((this: { name: string; options: any; storage: any; editor: import("/home/runner/work/ui/ui/playgrounds/nuxt/node_modules/.pnpm/@tiptap+core@3.15.0_@tiptap+pm@3.15.0/node_modules/@tiptap/core/dist/index").Editor; type: import("/home/runner/work/ui/ui/playgrounds/nuxt/node_modules/.pnpm/prosemirror-model@1.25.4/node_...'.

{2D3D0ED9-719C-4810-9CA4-37A0E086615A}

Any guidance? 🙏

@caiotarifa
Copy link

Thanks @salehattari for your work on this PR! A Repeater is really needed, especially for forms.

Small naming thought: if the main goal is form usage (state + validation), maybe a name like FormRepeater (or UFormRepeater) could make that intent clearer.

A few points to consider:

  1. The UI of each item/card feels a bit “big”. Maybe the title and the actions could stay on the same line, with a more compact spacing.
  2. It would be great to have a “single line” / “compact” option for simple cases (like repeating only one field), without the full card structure.
  3. Accessibility is very important here: keyboard support, focus order, clear labels for icon-only buttons, and a good experience for screen readers.
  4. Form integration: it should work well with UForm state and Standard Schema validation (errors per item / per field, and predictable paths/keys).

For UI reference, I really like Filament’s Repeater: https://filamentphp.com/docs/3.x/forms/fields/repeater

It keeps the header/actions compact and works great in real forms.

With Buttons

With Collapsing

With 'One Line' Mode

@aaronlippold
Copy link

nice, I like it

@coderabbitai
Copy link

coderabbitai bot commented Jan 27, 2026

📝 Walkthrough

Walkthrough

This pull request introduces a new Repeater component for dynamic list management. The implementation includes a Vue 3 component with drag-and-drop reordering powered by useSortable, item management controls (add/remove/move), configurable min/max item limits, and comprehensive slot-based customization. The component integrates with the existing theme system via variants and sizes, exports TypeScript type definitions for props/emits/slots, and includes localization support across 70+ locales. Supporting files include theme configuration, documentation, a playground demo page, and a test suite with snapshot and accessibility checks.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

The change spans 80+ files with heterogeneous purposes (component logic, theming, localization, tests, documentation). While locale additions are highly repetitive and require minimal verification, the core component logic demands careful review for correctness of the drag-and-drop integration, state management, slot rendering, and API surface. The well-structured organization and comprehensive test coverage help offset the breadth of the change.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(components): add Repeater component' directly and clearly describes the main change—adding a new Repeater component.
Description check ✅ Passed The PR description is comprehensive and directly related to the changeset, covering features, implementation details, and linked issues.
Linked Issues check ✅ Passed The PR fully addresses issue #5308 requirements: new Repeater component added with sortable items, drag-and-drop support, accessibility features, themeable design, and form integration capabilities.
Out of Scope Changes check ✅ Passed All changes are focused on implementing the Repeater component: component code, theme configuration, documentation, tests, locale translations, and playground examples—all in scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/runtime/components/Repeater.vue`:
- Around line 233-237: The v-for :key currently can be an object or fallback to
index which breaks Vue's key contract and breaks sortable lists; change the
component to require or compute a stable primitive key for each item: add a
helper (e.g., getItemKey(item)) and use it in the template key binding (replace
the current :key expression), and implement logic that (1) prefers a stable
item.id (or a configured trackBy prop), (2) if absent assigns a stable per-item
primitive id (e.g., attach a generated uid property like _repeaterId to the item
in a setup/created hook) or throws/warns to require an id; ensure the template
binds :key="getItemKey(item)" and update any relevant lifecycle code that
assigns/generated ids so keys remain stable across reorders.

In `@src/runtime/locale/en.ts`:
- Around line 115-121: The 'repeater.add' string uses "Add Item" with
inconsistent capitalization compared to other entries like 'repeater.remove';
change the value of the repeater.add key from "Add Item" to "Add item" so UI
text is consistent (locate the repeater object and update the add property).

In `@src/runtime/locale/uz.ts`:
- Around line 115-121: The new Uzbek strings under the repeater object
(repeater.dragToReorder, repeater.moveUp, repeater.moveDown, repeater.remove,
repeater.add) use the U+2019 right single quotation mark; replace those
apostrophes with the escaped straight apostrophe sequence (\' ) to match the
rest of this file's convention for Uzbek strings so all entries use a consistent
apostrophe representation.
🧹 Nitpick comments (2)
test/components/Repeater.spec.ts (1)

101-116: Good test coverage with snapshot and accessibility checks.

The parameterized test cases effectively cover variants, sizes, props, and slots. The accessibility test using axe is a valuable addition.

Consider adding interaction tests in a follow-up to verify emit behavior for add, remove, and move actions, and to confirm canAdd/canRemove guards work correctly at boundary conditions.

src/runtime/components/Repeater.vue (1)

8-16: Type safety could be improved for modelValue and newItemTemplate.

The component uses a generic T in the setup script, but RepeaterProps uses any[] and () => any. This limits type inference for consumers. If Vue's SFC generic limitations prevent stronger typing here, consider documenting the workaround for typed usage.

Comment on lines +233 to +237
<div
v-for="(item, index) in items"
:key="(item as any).id ? (item as any).id : item"
data-slot="item"
:class="ui.item({ class: props.ui?.item })"
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n "v-for.*:key=" src/runtime/components/ -A1 | head -80

Repository: nuxt/ui

Length of output: 8354


🌐 Web query:

Vue 3 v-for key best practices object as key vs index

💡 Result:

  • Always provide a :key on v-for, especially when rendering components, to keep component/DOM state predictable. [1]
  • Best key = stable, unique, primitive identifier from your data (e.g., id, slug, uuid). Vue’s key expects a string | number | symbol (not an object). [2][3]
    <li v-for="item in items" :key="item.id">{{ item.name }}</li>

Object property key vs index (when iterating an object)

When doing v-for="(value, key, index) in myObject":

  • Prefer using the object’s property name (key) as :key (it’s a stable string identifier as long as the property names are stable). [3]
    <li v-for="(value, prop) in myObject" :key="prop">
      {{ prop }}: {{ value }}
    </li>

When (not) to use the index

  • Avoid :key="index" for arrays when items can be inserted/removed/reordered/filtered, because index tracks position, not identity (can cause state to “move” to the wrong row). Vue also notes its default “in-place patch” behavior is only safe for very simple, stateless list output. [3]
  • Using index is only reasonably safe when the list is static (no reorder/insert/delete) and the rendered content is simple/stateless. [3]

Rule of thumb: if your data has a real identity, key by that identity; if you’re iterating an object, key by the object’s property name; use index only as a last resort for truly static/simple lists. [1][2][3]

Sources:
[1] Vue Style Guide (Essential): keyed v-for https://vuejs.org/style-guide/rules-essential.html
[2] Vue API: key special attribute expects number | string | symbol https://vuejs.org/api/built-in-special-attributes.html
[3] Vue Guide: List Rendering / Maintaining State with key + object iteration aliases https://vuejs.org/guide/essentials/list


🏁 Script executed:

sed -n '1,130p' src/runtime/components/Repeater.vue | cat -n

Repository: nuxt/ui

Length of output: 4177


🏁 Script executed:

# Check if items are expected to have an id property
rg -n "id.*as.*key|item.*id" src/runtime/components/Repeater.vue -B2 -A2

Repository: nuxt/ui

Length of output: 855


Using object as a Vue key violates the API contract and causes issues in sortable lists.

Vue requires keys to be string | number | symbol, not objects. Additionally, the suggested fallback to index is an anti-pattern for reorderable/sortable lists, where positional keys cause state to move to the wrong item when the array is mutated.

Since the component has drag-and-drop and sorting capabilities, keys must be stable and based on item identity, not position. Either require items to have a unique id property, or use a composite key that preserves identity:

Suggested fix
        <div
          v-for="(item, index) in items"
-         :key="(item as any).id ? (item as any).id : item"
+         :key="(item as any).id ?? `fallback-${index}`"
          data-slot="item"
          :class="ui.item({ class: props.ui?.item })"
        >

Alternatively, document that items must have a stable unique identifier (e.g., id property) for proper tracking during reordering.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
v-for="(item, index) in items"
:key="(item as any).id ? (item as any).id : item"
data-slot="item"
:class="ui.item({ class: props.ui?.item })"
<div
v-for="(item, index) in items"
:key="(item as any).id ?? `fallback-${index}`"
data-slot="item"
:class="ui.item({ class: props.ui?.item })"
🤖 Prompt for AI Agents
In `@src/runtime/components/Repeater.vue` around lines 233 - 237, The v-for :key
currently can be an object or fallback to index which breaks Vue's key contract
and breaks sortable lists; change the component to require or compute a stable
primitive key for each item: add a helper (e.g., getItemKey(item)) and use it in
the template key binding (replace the current :key expression), and implement
logic that (1) prefers a stable item.id (or a configured trackBy prop), (2) if
absent assigns a stable per-item primitive id (e.g., attach a generated uid
property like _repeaterId to the item in a setup/created hook) or throws/warns
to require an id; ensure the template binds :key="getItemKey(item)" and update
any relevant lifecycle code that assigns/generated ids so keys remain stable
across reorders.

Comment on lines +115 to +121
repeater: {
dragToReorder: 'Drag to reorder item',
moveUp: 'Move item up',
moveDown: 'Move item down',
remove: 'Remove item',
add: 'Add Item'
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent capitalization in add key.

The add value uses "Add Item" with uppercase "Item", while other keys like remove use lowercase "item". This creates inconsistent UI text.

Suggested fix
     repeater: {
       dragToReorder: 'Drag to reorder item',
       moveUp: 'Move item up',
       moveDown: 'Move item down',
       remove: 'Remove item',
-      add: 'Add Item'
+      add: 'Add item'
     },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
repeater: {
dragToReorder: 'Drag to reorder item',
moveUp: 'Move item up',
moveDown: 'Move item down',
remove: 'Remove item',
add: 'Add Item'
},
repeater: {
dragToReorder: 'Drag to reorder item',
moveUp: 'Move item up',
moveDown: 'Move item down',
remove: 'Remove item',
add: 'Add item'
},
🤖 Prompt for AI Agents
In `@src/runtime/locale/en.ts` around lines 115 - 121, The 'repeater.add' string
uses "Add Item" with inconsistent capitalization compared to other entries like
'repeater.remove'; change the value of the repeater.add key from "Add Item" to
"Add item" so UI text is consistent (locate the repeater object and update the
add property).

Comment on lines +115 to +121
repeater: {
dragToReorder: 'Tartibni o‘zgartirish uchun sudrab olib o‘ting',
moveUp: 'Yuqoriga ko‘chirish',
moveDown: 'Pastga ko‘chirish',
remove: 'O‘chirish',
add: 'Qo‘shish'
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent apostrophe character in Uzbek translations.

The new translations use ' (U+2019 RIGHT SINGLE QUOTATION MARK) for the Uzbek apostrophe sound (e.g., o'zgartirish), but existing translations in this file use different representations:

  • Escaped straight apostrophe: \' (e.g., Line 13: ko\'rsatish)
  • Modifier letter turned comma: ʻ U+02BB (e.g., Line 5: Oʻzbek)

Consider standardizing on one character for consistency. Using the escaped straight apostrophe \' would match the majority of existing strings in this file.

🔧 Suggested fix for consistency
     repeater: {
-      dragToReorder: 'Tartibni o'zgartirish uchun sudrab olib o'ting',
-      moveUp: 'Yuqoriga ko'chirish',
-      moveDown: 'Pastga ko'chirish',
-      remove: 'O'chirish',
-      add: 'Qo'shish'
+      dragToReorder: 'Tartibni o\'zgartirish uchun sudrab olib o\'ting',
+      moveUp: 'Yuqoriga ko\'chirish',
+      moveDown: 'Pastga ko\'chirish',
+      remove: 'O\'chirish',
+      add: 'Qo\'shish'
     },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
repeater: {
dragToReorder: 'Tartibni ozgartirish uchun sudrab olib oting',
moveUp: 'Yuqoriga kochirish',
moveDown: 'Pastga kochirish',
remove: 'Ochirish',
add: 'Qoshish'
},
repeater: {
dragToReorder: 'Tartibni o\'zgartirish uchun sudrab olib o\'ting',
moveUp: 'Yuqoriga ko\'chirish',
moveDown: 'Pastga ko\'chirish',
remove: 'O\'chirish',
add: 'Qo\'shish'
},
🤖 Prompt for AI Agents
In `@src/runtime/locale/uz.ts` around lines 115 - 121, The new Uzbek strings under
the repeater object (repeater.dragToReorder, repeater.moveUp, repeater.moveDown,
repeater.remove, repeater.add) use the U+2019 right single quotation mark;
replace those apostrophes with the escaped straight apostrophe sequence (\' ) to
match the rest of this file's convention for Uzbek strings so all entries use a
consistent apostrophe representation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a Repeater Component for Nuxt UI Forms

4 participants