Pressing Tab in a list should indent the item. Pressing Shift+Tab should outdent it. That is the whole feature as a writer describes it, and it is also the source of most list bugs. An indented item becomes a child of the item above it. An outdented item may need to take its own children with it. The Tab key also has a second job in some editors, moving focus out of the document, and the two jobs collide in the wrong places.
Lists are nested nodes, not indent levels
A common shortcut stores each list item with a number for its depth, such as level: 2, and renders the indentation from that number. It is simple to write and it breaks as soon as a writer expects an item to carry its sub-items with it. A nested list is a tree, so store it as one: a list item contains a paragraph and optionally a nested list. Indenting then means moving the item into the list of the sibling above it, and outdenting means moving it out, after its parent.
Indent takes the item only if there is a sibling above
The first item in a list has nothing above it to become a child of. Indenting it should do nothing, not move it somewhere unexpected. The rule is simple: an item can be indented only when the item before it in the same list exists. The item becomes the last child of that previous sibling, so its position in the list is kept.
function canIndent(listItemPos: number, doc: ProseNode): boolean {
const $pos = doc.resolve(listItemPos);
const index = $pos.index($pos.depth - 1);
return index > 0; // there is a previous sibling to nest under
}The check reads the index in the parent list, so a first item cannot be indented and the editor does nothing visible. Give the writer no error and no beep, because nothing is wrong. They simply reached the top of the list.
Outdent moves the item and its children
Outdenting an item that has its own sub-items is where the surprise happens. A writer who outdents a heading-level item expects the sub-items to follow it. Make that the default: when an item moves out of its parent, its nested items move with it, as its own children. The alternative, where the sub-items stay behind and become orphans under the old parent, is almost never what anyone wants.
Outdenting the top-level item removes the list level altogether and turns the item into a paragraph. Make sure that case is a clean paragraph, with its marks intact and no empty list left behind.
Tab is for lists only while the caret is in one
Trapping Tab inside the document is only acceptable when the caret is in a list, where Tab has a clear meaning. Everywhere else, Tab should move focus to the next control, as the writer expects in a form. Check the list state before calling preventDefault(). A writer who presses Tab in a paragraph and finds that focus is stuck in the editor has hit a trap, and the trap is the most common accessibility complaint about rich text editors.
Ordered numbers follow the tree
An ordered list has a number on each item, and that number should be computed from the tree, not stored. When an item is indented, the numbering of the list it leaves and the list it joins both change. Store the list type on the parent, and let the renderer count siblings. If you do store a start value, store it on the list, and only when the writer sets it explicitly.
A short test list
- Press Tab on the first item of a list. Nothing should change and focus should stay in the editor.
- Indent an item that has sub-items. The sub-items should move with it.
- Outdent a sub-item that is the only child of its parent. The parent should not be left with an empty list.
- Outdent the top-level item into a paragraph, then press undo. The list should come back with its sub-items intact.
- Press Tab in a paragraph outside any list. Focus should move to the next control, not stay in the editor.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.