diff --git a/.changeset/smart-points-exercise.md b/.changeset/smart-points-exercise.md new file mode 100644 index 00000000000..944dee128b2 --- /dev/null +++ b/.changeset/smart-points-exercise.md @@ -0,0 +1,7 @@ +--- +'@primer/react': patch +--- + +MarkdownViewer: Address scenario in useListInteraction where the position calculation can be incorrect when tasklists appear above legacy task lists + + diff --git a/src/drafts/MarkdownViewer/MarkdownViewer.test.tsx b/src/drafts/MarkdownViewer/MarkdownViewer.test.tsx index 14d4e393a79..c6db485a296 100644 --- a/src/drafts/MarkdownViewer/MarkdownViewer.test.tsx +++ b/src/drafts/MarkdownViewer/MarkdownViewer.test.tsx @@ -27,6 +27,56 @@ text before list - [x] item 1 - [ ] item 2 +text after list` + const hierarchyBeforeTaskListNoItemsChecked = ` +text before list + +\`\`\`[tasklist] +- [ ] item A +- [ ] item B +\`\`\` + +- [ ] item 1 +- [ ] item 2 + +text after list` + const hierarchyBeforeTaskListOneItemChecked = ` +text before list + +\`\`\`[tasklist] +- [ ] item A +- [ ] item B +\`\`\` + +- [x] item 1 +- [ ] item 2 + +text after list` + const hierarchyBeforeTaskListNoItemsCheckedTildes = ` +text before list + +~~~[tasklist] +- [ ] item A +- [ ] item B +\`\`\` +~~~~~~ + +- [ ] item 1 +- [ ] item 2 + +text after list` + const hierarchyBeforeTaskListOneItemCheckedTildes = ` +text before list + +~~~[tasklist] +- [ ] item A +- [ ] item B +\`\`\` +~~~~~~ + +- [x] item 1 +- [ ] item 2 + text after list` it('enables checklists by default', () => { @@ -75,6 +125,36 @@ text after list` await waitFor(() => expect(onChangeMock).toHaveBeenCalledWith(firstItemCheckedMarkdown)) }) + it('calls `onChange` with the updated Markdown when a task is checked and hierarchy is present', async () => { + const onChangeMock = jest.fn() + const {getAllByRole} = render( + , + ) + const items = getAllByRole('checkbox') + fireEvent.change(items[0]) + await waitFor(() => expect(onChangeMock).toHaveBeenCalledWith(hierarchyBeforeTaskListOneItemChecked)) + }) + + it('calls `onChange` with the updated Markdown when a task is checked and hierarchy is present with tildes', async () => { + const onChangeMock = jest.fn() + const {getAllByRole} = render( + , + ) + const items = getAllByRole('checkbox') + fireEvent.change(items[0]) + await waitFor(() => expect(onChangeMock).toHaveBeenCalledWith(hierarchyBeforeTaskListOneItemCheckedTildes)) + }) + it('calls `onChange` with the updated Markdown when a task is unchecked', async () => { const onChangeMock = jest.fn() const {getAllByRole} = render( diff --git a/src/drafts/MarkdownViewer/_useListInteraction.ts b/src/drafts/MarkdownViewer/_useListInteraction.ts index 1ce972970a1..ce1dad4ace0 100644 --- a/src/drafts/MarkdownViewer/_useListInteraction.ts +++ b/src/drafts/MarkdownViewer/_useListInteraction.ts @@ -4,6 +4,17 @@ import {ListItem, listItemToString, parseListItem} from '../MarkdownEditor/_useL type TaskListItem = ListItem & {taskBox: '[ ]' | '[x]'} +// Make check for code fences more robust per spec: https://github.github.com/gfm/#fenced-code-blocks +const parseCodeFenceBegin = (line: string) => { + const match = line.match(/^ {0,3}(`{3,}|~{3,})[^`]*$/) + return match ? match[1] : null +} + +const isCodeFenceEnd = (line: string, fence: string) => { + const regex = new RegExp(`^ {0,3}${fence}${fence[0]}* *$`) + return regex.test(line) +} + const isTaskListItem = (item: ListItem | null): item is TaskListItem => typeof item?.taskBox === 'string' const toggleTaskListItem = (item: TaskListItem): TaskListItem => ({ @@ -43,9 +54,21 @@ export const useListInteraction = ({ const onToggleItem = useCallback( (toggledItemIndex: number) => () => { const lines = markdownRef.current.split('\n') + let currentCodeFence: string | null = null for (let lineIndex = 0, taskIndex = 0; lineIndex < lines.length; lineIndex++) { - const parsedLine = parseListItem(lines[lineIndex]) + const line = lines[lineIndex] + + if (!currentCodeFence) { + currentCodeFence = parseCodeFenceBegin(line) + } else if (isCodeFenceEnd(line, currentCodeFence)) { + currentCodeFence = null + continue + } + + if (currentCodeFence) continue + + const parsedLine = parseListItem(line) if (!isTaskListItem(parsedLine)) continue