Skip to content

Commit

Permalink
Checklist support (facebook#2050)
Browse files Browse the repository at this point in the history
* Checklist, markdown shortcut, toolbar update

* [wip] a11y concerns
  • Loading branch information
fantactuka authored May 4, 2022
1 parent 16e8955 commit adea51c
Show file tree
Hide file tree
Showing 35 changed files with 915 additions and 72 deletions.
1 change: 1 addition & 0 deletions .flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ module.name_mapper='^@lexical/react/LexicalTablePlugin' -> '<PROJECT_ROOT>/packa
module.name_mapper='^@lexical/react/LexicalLinkPlugin' -> '<PROJECT_ROOT>/packages/lexical-react/flow/LexicalLinkPlugin.js.flow'
module.name_mapper='^@lexical/react/LexicalAutoLinkPlugin' -> '<PROJECT_ROOT>/packages/lexical-react/flow/LexicalAutoLinkPlugin.js.flow'
module.name_mapper='^@lexical/react/LexicalListPlugin' -> '<PROJECT_ROOT>/packages/lexical-react/flow/LexicalListPlugin.js.flow'
module.name_mapper='^@lexical/react/LexicalCheckListPlugin' -> '<PROJECT_ROOT>/packages/lexical-react/flow/LexicalCheckListPlugin.js.flow'
module.name_mapper='^@lexical/react/LexicalAutoScrollPlugin' -> '<PROJECT_ROOT>/packages/lexical-react/flow/LexicalAutoScrollPlugin.js.flow'

module.name_mapper='^@lexical/dragon/LexicalDragon' -> '<PROJECT_ROOT>/packages/lexical-dragon/flow/LexicalDragon.js.flow'
Expand Down
2 changes: 2 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ module.exports = {
'<rootDir>/packages/lexical-react/src/LexicalAutoLinkPlugin.js',
'^@lexical/react/LexicalAutoScrollPlugin$':
'<rootDir>/packages/lexical-react/src/LexicalAutoScrollPlugin.js',
'^@lexical/react/LexicalCheckListPlugin$':
'<rootDir>/packages/lexical-react/src/LexicalCheckListPlugin.js',
'^@lexical/react/LexicalCollaborationPlugin$':
'<rootDir>/packages/lexical-react/src/LexicalCollaborationPlugin.js',
'^@lexical/react/LexicalComposerContext$':
Expand Down
12 changes: 9 additions & 3 deletions packages/lexical-list/LexicalList.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ import {
LexicalCommand,
} from 'lexical';

export function $createListItemNode(): ListItemNode;
export function $createListNode(tag: ListNodeTagType, start?: number): ListNode;
export type ListType = 'number' | 'bullet' | 'check';
export function $createListItemNode(checked?: boolean | void): ListItemNode;
export function $createListNode(listType: ListType, start?: number): ListNode;
export function $getListDepth(listNode: ListNode): number;
export function $handleListInsertParagraph(): boolean;
export function $isListItemNode(node?: LexicalNode): node is ListItemNode;
export function $isListNode(node?: LexicalNode): node is ListNode;
export function indentList(): void;
export function insertList(editor: LexicalEditor, listType: 'ul' | 'ol'): void;
export function insertList(editor: LexicalEditor, listType: ListType): void;
export declare class ListItemNode extends ElementNode {
append(...nodes: LexicalNode[]): ListItemNode;
replace<N extends LexicalNode>(replaceWithNode: N): N;
Expand All @@ -36,15 +37,20 @@ export declare class ListItemNode extends ElementNode {
canInsertAfter(node: LexicalNode): boolean;
canReplaceWith(replacement: LexicalNode): boolean;
canMergeWith(node: LexicalNode): boolean;
getChecked(): boolean | void;
setChecked(boolean): this;
toggleChecked(): void;
}
export declare class ListNode extends ElementNode {
canBeEmpty(): false;
append(...nodesToAppend: LexicalNode[]): ListNode;
getTag(): ListNodeTagType;
getListType(): ListType;
}
export function outdentList(): void;
export function removeList(editor: LexicalEditor): boolean;

export var INSERT_UNORDERED_LIST_COMMAND: LexicalCommand<void>;
export var INSERT_ORDERED_LIST_COMMAND: LexicalCommand<void>;
export var INSERT_CHECK_LIST_COMMAND: LexicalCommand<void>;
export var REMOVE_LIST_COMMAND: LexicalCommand<void>;
14 changes: 11 additions & 3 deletions packages/lexical-list/flow/LexicalList.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ import type {
import {ElementNode} from 'lexical';

type ListNodeTagType = 'ul' | 'ol';
declare export function $createListItemNode(): ListItemNode;
export type ListType = 'number' | 'bullet' | 'check';
declare export function $createListItemNode(
checked?: boolean | void,
): ListItemNode;
declare export function $createListNode(
tag: ListNodeTagType,
listType: ListType,
start?: number,
): ListNode;
declare export function $getListDepth(listNode: ListNode): number;
Expand All @@ -33,7 +36,7 @@ declare export function $isListNode(
declare export function indentList(): void;
declare export function insertList(
editor: LexicalEditor,
listType: 'ul' | 'ol',
listType: ListType,
): void;
declare export class ListItemNode extends ElementNode {
append(...nodes: LexicalNode[]): ListItemNode;
Expand All @@ -47,6 +50,9 @@ declare export class ListItemNode extends ElementNode {
canInsertAfter(node: LexicalNode): boolean;
canReplaceWith(replacement: LexicalNode): boolean;
canMergeWith(node: LexicalNode): boolean;
getChecked(): boolean | void;
setChecked(boolean): this;
toggleChecked(): void;
}
declare export class ListNode extends ElementNode {
__tag: ListNodeTagType;
Expand All @@ -55,10 +61,12 @@ declare export class ListNode extends ElementNode {
append(...nodesToAppend: LexicalNode[]): ListNode;
getTag(): ListNodeTagType;
getStart(): number;
getListType(): ListType;
}
declare export function outdentList(): void;
declare export function removeList(editor: LexicalEditor): boolean;

declare export var INSERT_UNORDERED_LIST_COMMAND: LexicalCommand<void>;
declare export var INSERT_ORDERED_LIST_COMMAND: LexicalCommand<void>;
declare export var INSERT_CHECK_LIST_COMMAND: LexicalCommand<void>;
declare export var REMOVE_LIST_COMMAND: LexicalCommand<void>;
90 changes: 82 additions & 8 deletions packages/lexical-list/src/LexicalListItemNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* @flow strict
*/

import type {ListNode} from './';
import type {
DOMConversionMap,
DOMConversionOutput,
Expand Down Expand Up @@ -42,25 +43,28 @@ import {

export class ListItemNode extends ElementNode {
__value: number;
__checked: boolean | void;

static getType(): string {
return 'listitem';
}

static clone(node: ListItemNode): ListItemNode {
return new ListItemNode(node.__value, node.__key);
return new ListItemNode(node.__value, node.__checked, node.__key);
}

constructor(value?: number, key?: NodeKey): void {
constructor(value?: number, checked?: boolean, key?: NodeKey): void {
super(key);
this.__value = value === undefined ? 1 : value;
this.__checked = checked;
}

createDOM(config: EditorConfig): HTMLElement {
const element = document.createElement('li');
const parent = this.getParent();
if ($isListNode(parent)) {
updateChildrenListItemValue(parent);
updateListItemChecked(element, this, null, parent);
}
element.value = this.__value;
$setListItemThemeClassNames(element, config.theme, this);
Expand All @@ -75,6 +79,7 @@ export class ListItemNode extends ElementNode {
const parent = this.getParent();
if ($isListNode(parent)) {
updateChildrenListItemValue(parent);
updateListItemChecked(dom, this, prevNode, parent);
}
// $FlowFixMe - this is always HTMLListItemElement
dom.value = this.__value;
Expand Down Expand Up @@ -120,7 +125,7 @@ export class ListItemNode extends ElementNode {
list.insertAfter(replaceWithNode);
} else {
// Split the list
const newList = $createListNode(list.getTag());
const newList = $createListNode(list.getListType());
const children = list.getChildren();
for (let i = index + 1; i < childrenLength; i++) {
const child = children[i];
Expand Down Expand Up @@ -157,7 +162,7 @@ export class ListItemNode extends ElementNode {
}

// Attempt to merge if the list is of the same type.
if ($isListNode(node) && node.getTag() === listNode.getTag()) {
if ($isListNode(node) && node.getListType() === listNode.getListType()) {
let child = node;
const children = node.getChildren();
for (let i = children.length - 1; i >= 0; i--) {
Expand All @@ -171,7 +176,7 @@ export class ListItemNode extends ElementNode {
// Split the lists and insert the node in between them
listNode.insertAfter(node);
if (siblings.length !== 0) {
const newListNode = $createListNode(listNode.getTag());
const newListNode = $createListNode(listNode.getListType());
siblings.forEach((sibling) => newListNode.append(sibling));
node.insertAfter(newListNode);
}
Expand All @@ -190,7 +195,9 @@ export class ListItemNode extends ElementNode {
}

insertNewAfter(): ListItemNode | ParagraphNode {
const newElement = $createListItemNode();
const newElement = $createListItemNode(
this.__checked == null ? undefined : false,
);
this.insertAfter(newElement);

return newElement;
Expand Down Expand Up @@ -240,6 +247,20 @@ export class ListItemNode extends ElementNode {
self.__value = value;
}

getChecked(): boolean | void {
const self = this.getLatest();
return self.__checked;
}

setChecked(checked: boolean | void): void {
const self = this.getWritable();
self.__checked = checked;
}

toggleChecked(): void {
this.setChecked(!this.__checked);
}

getIndent(): number {
// ListItemNode should always have a ListNode for a parent.
let listNodeParent = this.getParentOrThrow().getParentOrThrow();
Expand Down Expand Up @@ -329,6 +350,24 @@ function $setListItemThemeClassNames(
classesToAdd.push(...listItemClasses);
}

if (listTheme) {
const parentNode = node.getParent();
const isCheckList =
$isListNode(parentNode) && parentNode.getListType() === 'check';
const checked = node.getChecked();
if (!isCheckList || checked) {
classesToRemove.push(listTheme.listitemUnchecked);
}
if (!isCheckList || !checked) {
classesToRemove.push(listTheme.listitemChecked);
}
if (isCheckList) {
classesToAdd.push(
checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
);
}
}

if (nestedListItemClassName !== undefined) {
const nestedListItemClasses = nestedListItemClassName.split(' ');
if (node.getChildren().some((child) => $isListNode(child))) {
Expand All @@ -347,12 +386,47 @@ function $setListItemThemeClassNames(
}
}

function updateListItemChecked(
dom: HTMLElement,
listItemNode: ListItemNode,
prevListItemNode: ListItemNode | null,
listNode: ListNode,
): void {
const isCheckList = listNode.getListType() === 'check';
if (isCheckList) {
// Only add attributes for leaf list items
if ($isListNode(listItemNode.getFirstChild())) {
dom.removeAttribute('role');
dom.removeAttribute('tabIndex');
dom.removeAttribute('aria-checked');
} else {
dom.setAttribute('role', 'checkbox');
dom.setAttribute('tabIndex', '-1');

if (
!prevListItemNode ||
listItemNode.__checked !== prevListItemNode.__checked
) {
dom.setAttribute(
'aria-checked',
listItemNode.getChecked() ? 'true' : 'false',
);
}
}
} else {
// Clean up checked state
if (listItemNode.getChecked() != null) {
listItemNode.setChecked(undefined);
}
}
}

function convertListItemElement(domNode: Node): DOMConversionOutput {
return {node: $createListItemNode()};
}

export function $createListItemNode(): ListItemNode {
return new ListItemNode();
export function $createListItemNode(checked?: boolean | void): ListItemNode {
return new ListItemNode(undefined, checked);
}

export function $isListItemNode(node: ?LexicalNode): boolean %checks {
Expand Down
33 changes: 26 additions & 7 deletions packages/lexical-list/src/LexicalListNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,40 @@ import {$createTextNode, ElementNode} from 'lexical';
import {$createListItemNode, $isListItemNode} from '.';
import {$getListDepth} from './utils';

export type ListType = 'number' | 'bullet' | 'check';

export type ListNodeTagType = 'ul' | 'ol';

export class ListNode extends ElementNode {
__tag: ListNodeTagType;
__start: number;
__listType: ListType;

static getType(): string {
return 'list';
}

static clone(node: ListNode): ListNode {
return new ListNode(node.__tag, node.__start, node.__key);
return new ListNode(node.__listType, node.__start, node.__key);
}

constructor(tag: ListNodeTagType, start: number, key?: NodeKey): void {
constructor(listType: ListType, start: number, key?: NodeKey): void {
super(key);
this.__tag = tag;
// $FlowFixMe added for backward compatibility to map tags to list type
const _listType = TAG_TO_LIST_TYPE[listType] || listType;
this.__listType = _listType;
this.__tag = _listType === 'number' ? 'ol' : 'ul';
this.__start = start;
}

getTag(): ListNodeTagType {
return this.__tag;
}

getListType(): ListType {
return this.__listType;
}

getStart(): number {
return this.__start;
}
Expand All @@ -61,6 +71,8 @@ export class ListNode extends ElementNode {
if (this.__start !== 1) {
dom.setAttribute('start', String(this.__start));
}
// $FlowFixMe internal field
dom.__lexicalListType = this.__listType;
setListThemeClassNames(dom, config.theme, this);
return dom;
}
Expand Down Expand Up @@ -176,17 +188,24 @@ function setListThemeClassNames(
function convertListNode(domNode: Node): DOMConversionOutput {
const nodeName = domNode.nodeName.toLowerCase();
let node = null;
if (nodeName === 'ol' || nodeName === 'ul') {
node = $createListNode(nodeName);
if (nodeName === 'ol') {
node = $createListNode('number');
} else if (nodeName === 'ul') {
node = $createListNode('bullet');
}
return {node};
}

const TAG_TO_LIST_TYPE: $ReadOnly<{[ListNodeTagType]: ListType}> = {
ol: 'number',
ul: 'bullet',
};

export function $createListNode(
tag: ListNodeTagType,
listType: ListType,
start?: number = 1,
): ListNode {
return new ListNode(tag, start);
return new ListNode(listType, start);
}

export function $isListNode(node: ?LexicalNode): boolean %checks {
Expand Down
Loading

0 comments on commit adea51c

Please sign in to comment.