` inside
- * the loose item's ``, which a wrapper around `` cannot
- * reproduce without invalid HTML (`` is not phrasing content, so it cannot
- * go inside ``). Loose task items keep the default text rendering
- * until Para grows a slot for it.
+ * ## Where the `` is built (bd-qif9l4cx)
+ *
+ * The `` is rendered by the head block itself (`Plain`/`Para`), not
+ * by the `` around ``. A block dispatched through
+ * `` picks up block-level decorations on the way — CommentBlock's
+ * positioned wrapper, the attribution wrapper, the measured edit surface —
+ * and any of those landing *inside* an inline `` after the ` `
+ * pushes the item text onto its own line (the reported bug). Building the
+ * label inside the block keeps every wrapper an ancestor of the label, so
+ * checkbox and text always share one inline formatting context. The ``
+ * hands the checked state and toggle handler to its head block through
+ * `TaskItemContext`; the head block strips the marker and wraps the rest.
*/
-/** `true`/`false` = task item with that checked state; `null` = not a task item. */
+/** What the ` ` tells its head block: checked state, and the toggle
+ * handler (absent = render a disabled checkbox). */
+export interface TaskItemState {
+ checked: boolean;
+ onToggle?: () => void;
+}
+
+/** Provided by `BulletList`/`OrderedList` around a task item's head block
+ * only. `null` everywhere else, including inside the label's own content. */
+export const TaskItemContext = createContext(null);
+
+function isTaskMarker(inline: InlineNode | undefined): inline is InlineNode & { t: 'Str'; c: '☐' | '☒' } {
+ return !!inline && inline.t === 'Str' && (inline.c === '☐' || inline.c === '☒');
+}
+
+/** `true`/`false` = task item with that checked state; `null` = not a task
+ * item. Mirrors the writer's `task_item_checked`: the head block is a
+ * `Plain` (tight) or `Para` (loose) whose inlines start with the marker
+ * `Str` followed by `Space`. */
export function taskItemChecked(item: BlockNode[]): boolean | null {
const head = item[0] as any;
- if (!head || head.t !== 'Plain' || !Array.isArray(head.c)) return null;
+ if (!head || (head.t !== 'Plain' && head.t !== 'Para') || !Array.isArray(head.c)) return null;
const first = head.c[0];
- if (!first || first.t !== 'Str' || (first.c !== '☐' && first.c !== '☒')) return null;
+ if (!isTaskMarker(first)) return null;
if (head.c[1]?.t !== 'Space') return null;
return first.c === '☒';
}
@@ -39,12 +67,12 @@ export function allTaskItems(items: BlockNode[][]): boolean {
return items.length > 0 && items.every((item) => taskItemChecked(item) !== null);
}
-/** The item's head block with the marker `Str` + `Space` stripped, so the
- * remaining inlines render inside the ``. Keeps the pool-id (`s`) so
- * the `` borrow behaves exactly as for a non-task tight item. */
-export function strippedTaskHead(head: BlockNode): BlockNode {
- const h = head as any;
- return { ...h, c: h.c.slice(2) };
+/** The inlines after the marker `Str` + `Space`, or `null` when `inlines`
+ * does not start with a task marker. Used by the head block to decide
+ * whether it is the task head and what goes inside the ``. */
+export function stripTaskMarker(inlines: InlineNode[]): InlineNode[] | null {
+ if (!isTaskMarker(inlines[0]) || inlines[1]?.t !== 'Space') return null;
+ return inlines.slice(2);
}
/**
@@ -68,7 +96,7 @@ export function makeTaskToggle(
const clone = JSON.parse(JSON.stringify(resolved.sourceNode)) as any;
const items = clone.t === 'OrderedList' ? clone.c?.[1] : clone.c;
const marker = items?.[itemIndex]?.[0]?.c?.[0];
- if (!marker || marker.t !== 'Str' || (marker.c !== '☐' && marker.c !== '☒')) {
+ if (!isTaskMarker(marker)) {
// The transformed and source lists disagree (transform reshaped
// the list) — refuse a blind edit rather than corrupt the doc.
return;
@@ -78,9 +106,12 @@ export function makeTaskToggle(
};
}
-/** The ` {stripped head inlines} ` body
- * of a task ``. Rest-of-item blocks render after the label (writer parity). */
-export function TaskItemBody(props: {
+/**
+ * The blocks of a task ` `: the head block under a `TaskItemContext`
+ * provider (so `Plain`/`Para` render the ``), then the rest of the
+ * item's blocks outside it (a nested list must not inherit the state).
+ */
+export function TaskItemBlocks(props: {
item: BlockNode[];
checked: boolean;
onToggle?: () => void;
@@ -88,6 +119,27 @@ export function TaskItemBody(props: {
}) {
const { item, checked, onToggle, onNavigateToDocument } = props;
const noop = () => {};
+ return (
+ <>
+
+
+
+ {item.slice(1).map((block, j) => (
+
+ ))}
+ >
+ );
+}
+
+/**
+ * ` {children} ` — the writer's task
+ * markup, rendered by the head block with the marker already stripped.
+ * Re-provides `TaskItemContext` as `null` so nothing inside the label
+ * (a footnote's blocks, a custom inline) can mistake itself for the head.
+ */
+export function TaskLabel(props: { state: TaskItemState; children: ReactNode }) {
+ const { checked, onToggle } = props.state;
+ const noop = () => {};
// Pointer events must not escape the checkbox: the block-edit surface
// activates on the host's React onPointerUp (useBlockEditHover), so a
// toggle click would otherwise ALSO open the item's editor. Text clicks
@@ -96,35 +148,26 @@ export function TaskItemBody(props: {
// any click that isn't on the input itself.
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
return (
- <>
- {
- if ((e.target as HTMLElement).tagName !== 'INPUT') e.preventDefault();
- }}
- >
-
-
-
- {item.slice(1).map((block, j) => (
-
- ))}
- >
+ {
+ if ((e.target as HTMLElement).tagName !== 'INPUT') e.preventDefault();
+ }}
+ >
+
+ {props.children}
+
);
}
+
+/** The current task-item state, or `null` outside a task head block. */
+export function useTaskItem(): TaskItemState | null {
+ return useContext(TaskItemContext);
+}
diff --git a/ts-packages/preview-renderer/src/q2-preview/task-list.integration.test.tsx b/ts-packages/preview-renderer/src/q2-preview/task-list.integration.test.tsx
index 28b90f4ad..f18e84f2f 100644
--- a/ts-packages/preview-renderer/src/q2-preview/task-list.integration.test.tsx
+++ b/ts-packages/preview-renderer/src/q2-preview/task-list.integration.test.tsx
@@ -16,9 +16,14 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { render, cleanup, fireEvent } from '@testing-library/react';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
import { PreviewRoot } from './PreviewRoot';
import type { PreviewRootProps } from './PreviewRoot';
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
afterEach(() => {
cleanup();
vi.restoreAllMocks();
@@ -93,15 +98,54 @@ function mountPreviewRoot(overrides: Partial = {}) {
return { setAst, ...render( ) };
}
+/**
+ * Larger fixtures live in `__fixtures__/task-list-.{qmd,ast.json}`;
+ * the JSON is verbatim `pampa .qmd -t json` with the file name in
+ * `astContext.files[0].name` rewritten to `/task-list-.qmd`.
+ */
+function mountFixture(name: string, overrides: Partial = {}) {
+ const dir = join(__dirname, '__fixtures__');
+ const astJson = readFileSync(join(dir, `task-list-${name}.ast.json`), 'utf8');
+ const content = readFileSync(join(dir, `task-list-${name}.qmd`), 'utf8');
+ return mountPreviewRoot({
+ astJson,
+ untransformedAstJson: astJson,
+ renderedContent: content,
+ currentFilePath: `/task-list-${name}.qmd`,
+ ...overrides,
+ });
+}
+
+/**
+ * The regression this file guards (bd-qif9l4cx): the checkbox and the
+ * item text must share one inline formatting context. Block-level
+ * decorations the dispatcher stack adds (CommentBlock's positioned
+ * wrapper, the attribution wrapper, the edit surface) must be ANCESTORS
+ * of the ``, never descendants — a `` after the ` `
+ * inside the label pushes the text onto its own line.
+ */
+function expectInlineLabel(input: Element, text: string) {
+ const label = input.parentElement!;
+ expect(label.tagName).toBe('LABEL');
+ expect(label.firstElementChild).toBe(input);
+ expect(label.querySelector('div, p, ul, ol, pre, table, section')).toBeNull();
+ expect(label.textContent).toBe(text);
+ // Wrappers may sit between the
and the label, never inside it.
+ expect(label.closest('li')).not.toBeNull();
+}
+
describe('task-list rendering', () => {
it('renders ul.task-list with label-wrapped checkboxes (writer parity)', () => {
const { container } = mountPreviewRoot();
const ul = container.querySelector('ul.task-list');
expect(ul).not.toBeNull();
- const inputs = ul!.querySelectorAll('li > label > input[type="checkbox"]');
+ const inputs = ul!.querySelectorAll('li label > input[type="checkbox"]');
expect(inputs.length).toBe(2);
expect((inputs[0] as HTMLInputElement).checked).toBe(false);
expect((inputs[1] as HTMLInputElement).checked).toBe(true);
+ // Checkbox and text on one line: no block box inside the label.
+ expectInlineLabel(inputs[0], 'todo');
+ expectInlineLabel(inputs[1], 'done');
// The ballot-box characters must not leak into the visible text.
expect(container.textContent).not.toContain('☐');
expect(container.textContent).not.toContain('☒');
@@ -174,3 +218,71 @@ describe('task-list rendering', () => {
expect(setAst).not.toHaveBeenCalled();
});
});
+
+describe('task-list DOM shape across list kinds (bd-qif9l4cx)', () => {
+ it('nested mixed list: the one task item keeps its checkbox inline', () => {
+ // The reporter's document: an outer bullet whose nested list mixes
+ // plain items with a single `[x]` item.
+ const { container } = mountFixture('nested');
+ const inputs = container.querySelectorAll('input[type="checkbox"]');
+ expect(inputs.length).toBe(1);
+ expect((inputs[0] as HTMLInputElement).checked).toBe(true);
+ expectInlineLabel(
+ inputs[0],
+ 'working with Julia on getting her work on replacing vdocs in Positron merged',
+ );
+ // Writer parity: `class="task-list"` only when EVERY item is a task.
+ const innerUl = inputs[0].closest('ul')!;
+ expect(innerUl.classList.contains('task-list')).toBe(false);
+ expect(innerUl.querySelectorAll(':scope > li').length).toBe(2);
+ expect(container.querySelector('ul.task-list')).toBeNull();
+ expect(container.textContent).not.toContain('☒');
+ });
+
+ it('loose (Para-leading) items render li > p > label > input (writer parity)', () => {
+ const { container } = mountFixture('loose');
+ const inputs = container.querySelectorAll('li p > label > input[type="checkbox"]');
+ expect(inputs.length).toBe(2);
+ expect((inputs[0] as HTMLInputElement).checked).toBe(false);
+ expect((inputs[1] as HTMLInputElement).checked).toBe(true);
+ expectInlineLabel(inputs[0], 'todo');
+ expectInlineLabel(inputs[1], 'done');
+ expect(container.querySelector('ul.task-list')).not.toBeNull();
+ expect(container.textContent).not.toContain('☐');
+ expect(container.textContent).not.toContain('☒');
+ });
+
+ it('toggling a loose item flips its Para marker through the subtree channel', () => {
+ const { container, setAst } = mountFixture('loose');
+ const inputs = container.querySelectorAll('input[type="checkbox"]');
+ fireEvent.click(inputs[0]);
+
+ expect(setAst).toHaveBeenCalledTimes(1);
+ const payload = setAst.mock.calls[0][0];
+ expect(payload.channel).toBe('subtree');
+ const list = JSON.parse(payload.modifiedSubtreeJson).blocks[0];
+ expect(list.t).toBe('BulletList');
+ expect(list.c[0][0].t).toBe('Para');
+ expect(list.c[0][0].c[0]).toMatchObject({ t: 'Str', c: '☒' });
+ expect(list.c[1][0].c[0]).toMatchObject({ t: 'Str', c: '☒' });
+ });
+
+ it('ordered lists render inline checkboxes and never carry the task-list class', () => {
+ const { container, setAst } = mountFixture('ordered');
+ const ol = container.querySelector('ol')!;
+ expect(ol.classList.contains('task-list')).toBe(false);
+ const inputs = ol.querySelectorAll('li label > input[type="checkbox"]');
+ expect(inputs.length).toBe(2);
+ expect((inputs[0] as HTMLInputElement).checked).toBe(false);
+ expect((inputs[1] as HTMLInputElement).checked).toBe(true);
+ expectInlineLabel(inputs[0], 'todo');
+ expectInlineLabel(inputs[1], 'done');
+
+ fireEvent.click(inputs[1]);
+ expect(setAst).toHaveBeenCalledTimes(1);
+ const list = JSON.parse(setAst.mock.calls[0][0].modifiedSubtreeJson).blocks[0];
+ expect(list.t).toBe('OrderedList');
+ expect(list.c[1][0][0].c[0]).toMatchObject({ t: 'Str', c: '☐' });
+ expect(list.c[1][1][0].c[0]).toMatchObject({ t: 'Str', c: '☐' });
+ });
+});