List Item
The ListItem node represents an individual <li> element inside a BulletList or OrderedList. It handles Enter key splitting (creating new items or exiting the list), and automatically includes the ListKeymap extension for Tab/Shift-Tab indentation.
You don’t need to add ListItem manually. It is automatically included when you add BulletList or OrderedList via their addExtensions(). If you are using StarterKit, ListItem is already included.
import { Document, Text, Paragraph, BulletList, OrderedList } from '@domternal/core';import { DomternalEditor } from '@domternal/vanilla';
const dm = new DomternalEditor(document.getElementById('editor')!, { extensions: [Document, Text, Paragraph, BulletList, OrderedList], content: '<ul><li><p>First item</p></li><li><p>Second item</p></li></ul>',});import { Component, signal } from '@angular/core';import { DomternalEditorComponent } from '@domternal/angular';import { Editor, Document, Text, Paragraph, BulletList, OrderedList } from '@domternal/core';
@Component({ selector: 'app-editor', imports: [DomternalEditorComponent], template: ` <domternal-editor [extensions]="extensions" [content]="content" (editorCreated)="editor.set($event)" /> `,})export class EditorComponent { editor = signal<Editor | null>(null); extensions = [Document, Text, Paragraph, BulletList, OrderedList]; content = '<ul><li><p>First item</p></li><li><p>Second item</p></li></ul>';}import { Domternal } from '@domternal/react';import { Document, Text, Paragraph, BulletList, OrderedList } from '@domternal/core';
export default function Editor() { return ( <Domternal extensions={[Document, Text, Paragraph, BulletList, OrderedList]} content="<ul><li><p>First item</p></li><li><p>Second item</p></li></ul>" > <Domternal.Content /> </Domternal> );}<script setup lang="ts">import { Domternal } from '@domternal/vue';import { Document, Text, Paragraph, BulletList, OrderedList } from '@domternal/core';
const extensions = [Document, Text, Paragraph, BulletList, OrderedList];</script>
<template> <Domternal :extensions="extensions" content="<ul><li><p>First item</p></li><li><p>Second item</p></li></ul>"> <Domternal.Content /> </Domternal></template>ListItem is auto-included by BulletList and OrderedList. It has no commands of its own. List manipulation is done through the parent list commands:
// Create a bullet listeditor.commands.toggleBulletList();
// Create an ordered listeditor.commands.toggleOrderedList();
// ListItem handles Enter splitting and Tab/Shift-Tab// indentation automatically via ListKeymapSchema
Section titled “Schema”| Property | Value |
|---|---|
| ProseMirror name | listItem |
| Type | Node |
| Group | None (used as content of list nodes) |
| Content | paragraph block* (strict: paragraph label + optional children-zone) |
| Defining | Yes |
| HTML tag | <li> |
The first child paragraph is the label - aligned with the bullet marker and the primary entry point for text. The remaining block* children make up the children-zone: nested content that renders indented below the label.
The defining property means that when you select a list item and paste content over it, the replacement keeps the list item wrapper.
Migration from v0.6 to v0.7
Section titled “Migration from v0.6 to v0.7”Existing content where a list item’s first child is NOT a paragraph fails to parse with the strict schema. Three migration patterns:
Pattern 1: heading-first listItem (rare, intentional)
<!-- Before (v0.6) --><li><h2>Title</h2></li><!-- After (v0.7) - convert the heading to a paragraph --><li><p>Title</p></li>Or, if you really want a heading inside, demote it into the children-zone with an empty label:
<li><p></p><h2>Title</h2></li>Pattern 2: pure-paragraph listItem (common, no migration needed)
<!-- Before and After identical --><li><p>Item</p></li>Pattern 3: nested-list-first listItem (uncommon but possible)
<!-- Before (v0.6) --><li><ul><li><p>nested</p></li></ul></li><!-- After (v0.7) - insert an empty paragraph as the label --><li><p></p><ul><li><p>nested</p></li></ul></li>The schema enforcement makes children-zone semantics predictable: indentation is always rooted on a paragraph label aligned with the bullet/checkbox marker.
Options
Section titled “Options”| Option | Type | Default | Description |
|---|---|---|---|
HTMLAttributes | Record<string, unknown> | {} | HTML attributes added to the <li> element |
Custom HTML attributes
Section titled “Custom HTML attributes”import { ListItem } from '@domternal/core';
const CustomListItem = ListItem.configure({ HTMLAttributes: { class: 'my-list-item' },});Keyboard shortcuts
Section titled “Keyboard shortcuts”| Shortcut | Cursor position | Behavior |
|---|---|---|
Enter | In label (first child), non-empty | Split the list item at the cursor; creates a new item below |
Enter | In label, empty | Lift content out of the list (exit) |
Enter | In children-zone, non-empty | Splits in place; both halves stay in the same item |
Enter | In children-zone, empty paragraph | Inserts an empty paragraph as a SIBLING inside the same list item (accumulate-on-Enter) |
Backspace | In label at offset 0 | Falls through to ListKeymap (lift) |
Backspace | In children-zone, empty at offset 0 | Lifts the empty paragraph as a top-level paragraph below the list |
Tab | In any list slot | Sink into nested list (sinkListItem via ListKeymap) |
Shift-Tab | In any list slot | Lift out one level (liftListItem via ListKeymap) |
Tab | Top-level block right after a list | Indent INTO the previous list as nested child (ListIndent) |
Shift-Tab | Last nested child of last item | Lift OUT of the list as top-level (ListIndent) |
Enter behavior (v0.7.0)
Section titled “Enter behavior (v0.7.0)”The Enter behavior depends on whether the cursor is in the label paragraph (first child) or the children-zone (subsequent blocks):
In the label (text aligned with the bullet):
- Non-empty, cursor in middle/end: Splits the list item, creating a new item with the text after the cursor (
splitListItem). - Empty: Lifts the content out of the list (
liftListItem). If the item is the last one, Enter on the empty label exits the list and creates a paragraph below. - Empty inside a nested list within a task item: Escapes to the parent task level by creating a new task item, rather than leaving a bare paragraph.
In the children-zone (blocks below the label):
- Empty paragraph: Inserts a new empty paragraph as a sibling INSIDE the same list item (the accumulate-on-Enter behavior). Use this to keep building children-zone content.
- Non-empty paragraph: Splits in place via
splitBlock. Both halves stay in the same list item.
Backspace at offset 0
Section titled “Backspace at offset 0”- In the label: Falls through to
ListKeymap’s default behavior (lift item). - In an empty children-zone paragraph: Lifts the empty paragraph as a top-level paragraph below the list, leaving the rest of the item intact. Use this to “escape” the children-zone with a single keystroke.
Tab/Shift-Tab
Section titled “Tab/Shift-Tab”Inside a list, Tab/Shift-Tab are provided by the ListKeymap extension (sinkListItem / liftListItem). At the boundary between top-level blocks and lists, ListIndent (new in v0.7.0) handles the Tab/Shift-Tab cases that ListKeymap doesn’t cover.
Included extensions
Section titled “Included extensions”ListItem automatically includes the ListKeymap extension via addExtensions():
| Extension | Description |
|---|---|
| ListKeymap | Tab/Shift-Tab indentation and Backspace handling |
JSON representation
Section titled “JSON representation”A list item always wraps its content in block nodes:
{ "type": "listItem", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "List item text" } ] } ]}A list item with a nested list:
{ "type": "listItem", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Parent item" } ] }, { "type": "bulletList", "content": [ { "type": "listItem", "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Nested item" } ] } ] } ] } ]}Source
Section titled “Source”@domternal/core - ListItem.ts