fix(markdown): keep task item content in one grid column
This commit is contained in:
parent
f22c4bd5c2
commit
7c3bff4e98
3 changed files with 83 additions and 12 deletions
|
|
@ -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] [&>*: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("[&>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] [&>*:last-child]:mb-0"> asdas');
|
||||
expect(html).not.toContain("[&_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("[&>p:first-child]:contents");
|
||||
expect(html).toContain("[&>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] [&>*: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] [&>*:last-child]:mb-0"> Northern Lights in Iceland — booking this for winter, <em>finally</em></div>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue