Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/2026-06-01-ordered-list-type-attribute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@tiptap/extension-list': minor
'@tiptap/core': patch
---

**Ordered lists now support the `type` attribute** (`a`, `A`, `i`, `I`).

The `<ol>` `type` attribute is now fully preserved through the HTML round-trip:

- `type="a"` → lowercase alphabetical markers
- `type="A"` → uppercase alphabetical markers
- `type="i"` → lowercase roman numeral markers
- `type="I"` → uppercase roman numeral markers

**Paste from external editors** (Google Docs, Word, LibreOffice) now correctly detects the list style — both from the HTML `type` attribute and from CSS `list-style-type` properties.

**Plain text paste** of typed ordered list markers (e.g. `a. Item`, `I) Item`, `i. Item\nii. Item`) is detected and converted to the correct list type.

**Markdown round-trip** preserves typed markers: parsing `a. Item` creates `type: "a"`, and serializing a typed list back to markdown uses the correct prefix (e.g. `I.`, `ii.`).

**Joining** of adjacent lists now respects `type` — two lists with different types (e.g. default numeric and `type="a"`) are not merged.
10 changes: 10 additions & 0 deletions demos/src/Nodes/OrderedList/React/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ export default () => {
<li>This item starts at 5</li>
<li>And another one</li>
</ol>

<ol type="a">
<li>Lowercase alphabetical list</li>
<li>Second item</li>
</ol>

<ol type="I">
<li>Uppercase roman numerals</li>
<li>Second item</li>
</ol>
`,
})

Expand Down
10 changes: 10 additions & 0 deletions demos/src/Nodes/OrderedList/Vue/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ export default {
<li>This item starts at 5</li>
<li>And another one</li>
</ol>

<ol type="a">
<li>Lowercase alphabetical list</li>
<li>Second item</li>
</ol>

<ol type="I">
<li>Uppercase roman numerals</li>
<li>Second item</li>
</ol>
`,
})
},
Expand Down
31 changes: 31 additions & 0 deletions demos/src/Nodes/OrderedList/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,37 @@ test.describe(`${demoPath}/${demoName}`, () => {
await editor.type('Example')
await expect(page.locator('.tiptap p')).toContainText('1. Example')
})

// ── Type attribute tests ──

test('preserves type="a" attribute in HTML output', async ({ page }) => {
const editor = await getEditor(page)
const html = await editor.evaluate((el: any) => {
el.editor.commands.setContent(
'<ol type="a"><li><p>Item A</p></li><li><p>Item B</p></li></ol>',
)
return el.editor.getHTML()
})
expect(html).toContain('type="a"')
})

test('preserves type="I" attribute in HTML output', async ({ page }) => {
const editor = await getEditor(page)
const html = await editor.evaluate((el: any) => {
el.editor.commands.setContent('<ol type="I"><li><p>Item 1</p></li></ol>')
return el.editor.getHTML()
})
expect(html).toContain('type="I"')
})

test('does not render type attribute for default type', async ({ page }) => {
const editor = await getEditor(page)
const html = await editor.evaluate((el: any) => {
el.editor.commands.setContent('<ol><li><p>Item</p></li></ol>')
return el.editor.getHTML()
})
expect(html).not.toContain('type')
})
})
})
})
30 changes: 30 additions & 0 deletions packages/core/src/commands/toggleList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ import { getNodeType } from '../helpers/getNodeType.js'
import { isList } from '../helpers/isList.js'
import type { RawCommands } from '../types.js'

/**
* Normalise a list type attribute for comparison.
* Treats null, undefined, and "1" as equivalent (the default numeric type).
*/
function normalizeListType(type: string | null | undefined): string | null {
return !type || type === '1' ? null : type
}

/**
* Check if two list type attributes are compatible for joining.
* Lists can only join when they have the same type (both default, or both the same non-default type).
*/
function areListTypesCompatible(
typeA: string | null | undefined,
typeB: string | null | undefined,
): boolean {
return normalizeListType(typeA) === normalizeListType(typeB)
}

const joinListBackwards = (tr: Transaction, listType: NodeType): boolean => {
const list = findParentNode(node => node.type === listType)(tr.selection)

Expand All @@ -28,6 +47,12 @@ const joinListBackwards = (tr: Transaction, listType: NodeType): boolean => {
return true
}

// Don't join if the type attributes are incompatible
// (e.g. a default-type list should not merge with a type="a" list)
if (!areListTypesCompatible(list.node.attrs.type, nodeBefore?.attrs.type)) {
return true
}

tr.join(list.pos)

return true
Expand All @@ -53,6 +78,11 @@ const joinListForwards = (tr: Transaction, listType: NodeType): boolean => {
return true
}

// Don't join if the type attributes are incompatible
if (!areListTypesCompatible(list.node.attrs.type, nodeAfter?.attrs.type)) {
return true
}

tr.join(after)

return true
Expand Down
Loading
Loading