fix(markdown): keep task item content in one grid column

pull/5987/head^2
boojack 1 month ago
parent f22c4bd5c2
commit 7c3bff4e98

@ -1,8 +1,65 @@
import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "../constants";
import { NestedMarkdownRenderContext } from "../MarkdownRenderContext";
import type { ReactMarkdownProps } from "./types";
interface TaskListChildProps {
children?: ReactNode;
node?: {
tagName?: string;
properties?: {
type?: unknown;
};
};
type?: string;
}
const isCheckboxInput = (child: ReactNode): child is ReactElement<TaskListChildProps> => {
return (
isValidElement<TaskListChildProps>(child) && (child.props.type === "checkbox" || child.props.node?.properties?.type === "checkbox")
);
};
const isParagraphElement = (child: ReactNode): child is ReactElement<TaskListChildProps> => {
return isValidElement<TaskListChildProps>(child) && (child.type === "p" || child.props.node?.tagName === "p");
};
const splitTaskListItemChildren = (children: ReactNode) => {
let checkbox: ReactNode;
const content: ReactNode[] = [];
Children.toArray(children).forEach((child) => {
if (!checkbox && isCheckboxInput(child)) {
checkbox = child;
return;
}
if (!checkbox && isParagraphElement(child)) {
const paragraphChildren: ReactNode[] = [];
Children.toArray(child.props.children).forEach((paragraphChild) => {
if (!checkbox && isCheckboxInput(paragraphChild)) {
checkbox = paragraphChild;
return;
}
paragraphChildren.push(paragraphChild);
});
if (checkbox) {
if (paragraphChildren.length > 0) {
content.push(cloneElement(child, undefined, ...paragraphChildren));
}
return;
}
}
content.push(child);
});
return { checkbox, content };
};
interface ListProps extends React.HTMLAttributes<HTMLUListElement | HTMLOListElement>, ReactMarkdownProps {
ordered?: boolean;
children: React.ReactNode;
@ -46,17 +103,20 @@ export const ListItem = ({ children, className, node: _node, ...domProps }: List
const isTaskListItem = className?.includes(TASK_LIST_ITEM_CLASS);
if (isTaskListItem) {
const { checkbox, content } = splitTaskListItemChildren(children);
return (
<li
className={cn(
"mt-0.5 leading-6 list-none grid grid-cols-[auto_1fr] items-start gap-x-2 [&>[data-slot=checkbox]]:mt-1",
"[&>ul]:col-start-2 [&>ul]:col-span-1 [&>ol]:col-start-2 [&>ol]:col-span-1",
"[&>p:first-child]:contents [&>p:not(:first-child)]:col-start-2 [&>p:not(:first-child)]:col-span-1",
"mt-0.5 min-w-0 leading-6 list-none grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2 [&>[data-slot=checkbox]]:mt-1",
className,
)}
{...domProps}
>
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
<NestedMarkdownRenderContext>
{checkbox}
<div className="min-w-0 [overflow-wrap:anywhere] [&>*:last-child]:mb-0">{content}</div>
</NestedMarkdownRenderContext>
</li>
);
}

@ -14,5 +14,6 @@ GFM task lists are normalized before rendering by `remarkSplitMixedTaskLists`.
- Mixed task/bullet lists are split into separate lists so regular bullets keep bullets.
- Single-block split items are rendered as tight list items, preventing accidental `<p>` wrappers.
- `ListItem` uses a two-column grid: checkbox/control in the first column, task text and nested content in the second.
- Loose task items keep paragraph structure; the first paragraph contributes its checkbox/text to the grid, while later paragraphs align with the text column.
- `ListItem` uses a two-column grid: checkbox/control in the first column and a single task-body wrapper in the second.
- Task text, emphasis, links, tags, and nested content stay inside the body wrapper so inline markdown does not become separate grid items.
- Loose task items keep paragraph structure inside the task-body wrapper.

@ -34,7 +34,8 @@ describe("memo content lists", () => {
expect(html).toContain('<li class="mt-0.5 leading-6">milk</li>');
expect(html).not.toContain('<li class="mt-0.5 leading-6">\n<p>milk</p>');
expect(html).toContain(TASK_LIST_ITEM_CLASS);
expect(html).toContain("grid grid-cols-[auto_1fr] items-start gap-x-2");
expect(html).toContain("grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2");
expect(html).toContain('<div class="min-w-0 [overflow-wrap:anywhere] [&amp;&gt;*:last-child]:mb-0"> pickup package</div>');
expect(html).not.toMatch(/<li class="[^"]*task-list-item[^"]*"><p\b/);
});
@ -49,8 +50,8 @@ describe("memo content lists", () => {
it("keeps nested task lists on their own row", () => {
const html = renderListContent("- [ ] asdas\n - [ ] zzzz");
expect(html).toContain("grid grid-cols-[auto_1fr] items-start gap-x-2");
expect(html).toContain("[&amp;&gt;ul]:col-start-2");
expect(html).toContain("grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-2");
expect(html).toContain('<div class="min-w-0 [overflow-wrap:anywhere] [&amp;&gt;*:last-child]:mb-0"> asdas');
expect(html).not.toContain("[&amp;_ul.contains-task-list]:ml-6");
expect(html).toContain("zzzz");
});
@ -58,10 +59,19 @@ describe("memo content lists", () => {
it("keeps loose task list paragraphs while aligning the first line", () => {
const html = renderListContent("- [ ] plan\n\n keep details\n\n- [ ] zzzz");
expect(html).toMatch(/<li class="[^"]*task-list-item[^"]*">\s*<p>/);
expect(html).toContain("[&amp;&gt;p:first-child]:contents");
expect(html).toContain("[&amp;&gt;p:not(:first-child)]:col-start-2");
expect(html).toMatch(/<li class="[^"]*task-list-item[^"]*">\s*<input type="checkbox" disabled=""\/>/);
expect(html).toContain('<div class="min-w-0 [overflow-wrap:anywhere] [&amp;&gt;*:last-child]:mb-0">');
expect(html).toContain("<p> plan</p>");
expect(html).toContain("<p>keep details</p>");
expect(html).toContain("zzzz");
});
it("keeps inline task markdown in the task body", () => {
const html = renderListContent("- [ ] Northern Lights in Iceland — booking this for winter, *finally*");
expect(html).toContain('<input type="checkbox" disabled=""/>');
expect(html).toContain(
'<div class="min-w-0 [overflow-wrap:anywhere] [&amp;&gt;*:last-child]:mb-0"> Northern Lights in Iceland — booking this for winter, <em>finally</em></div>',
);
});
});

Loading…
Cancel
Save