Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/add-list-style-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"streamdown": minor
---

Add `listStyle` prop for hierarchical bullet styling in nested lists.

- New `listStyle` prop accepts `"flat"` or `"hierarchical"` (default: `"hierarchical"`)
- Hierarchical mode cycles bullet styles through disc → circle → square based on nesting depth
- Flat mode preserves the previous uniform `list-disc` behavior
- All list elements (`<ul>`, `<ol>`, `<li>`) now receive a `data-depth` attribute for custom CSS targeting
5 changes: 5 additions & 0 deletions apps/website/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ Streamdown can be configured to suit your needs. This guide will walk you throug
type: "[ThemeInput, ThemeInput]",
default: "['github-light', 'github-dark']",
},
listStyle: {
description: "Bullet style preset for nested unordered lists. 'hierarchical' cycles through disc → circle → square based on nesting depth. 'flat' uses disc at all levels.",
type: '"hierarchical" | "flat"',
default: '"hierarchical"',
},
components: {
description: "Custom component overrides for Markdown elements",
type: "object",
Expand Down
3 changes: 2 additions & 1 deletion packages/streamdown/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const createContextValue = (
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
lineNumbers: true,
listStyle: "hierarchical",
mode: "streaming",
mermaid: undefined,
linkSafety,
Expand Down Expand Up @@ -54,7 +56,6 @@ describe("Markdown Components", () => {
const ul = container.querySelector("ul");
expect(ul).toBeTruthy();
expect(ul?.className).toContain("list-inside");
expect(ul?.className).toContain("list-disc");
expect(ul?.className).toContain("whitespace-normal");
});

Expand Down
2 changes: 2 additions & 0 deletions packages/streamdown/__tests__/link-safety.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ describe("Link Safety Modal", () => {
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
lineNumbers: true,
listStyle: "hierarchical",
mode: "streaming",
mermaid: undefined,
linkSafety,
Expand Down
169 changes: 169 additions & 0 deletions packages/streamdown/__tests__/list-style.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { StreamdownContext, type StreamdownContextType } from "../index";
import { components as customComponents } from "../lib/components";
import { Markdown } from "../lib/markdown";

const createContextValue = (
overrides?: Partial<StreamdownContextType>
): StreamdownContextType => ({
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
lineNumbers: true,
listStyle: "hierarchical",
mode: "streaming",
mermaid: undefined,
linkSafety: undefined,
...overrides,
});

const renderWithComponents = (
content: string,
contextOverrides?: Partial<StreamdownContextType>
) =>
render(
<StreamdownContext.Provider value={createContextValue(contextOverrides)}>
<Markdown components={customComponents}>{content}</Markdown>
</StreamdownContext.Provider>
);

describe("List Style and Depth", () => {
it("should add data-depth attributes to nested unordered lists", () => {
const content = `- Item 1
- Nested 1
- Deep 1
- Nested 2
- Item 2`;
const { container } = renderWithComponents(content);
const uls = container.querySelectorAll(
'[data-streamdown="unordered-list"]'
);
expect(uls.length).toBeGreaterThanOrEqual(2);
expect(uls[0]?.getAttribute("data-depth")).toBe("0");
if (uls[1]) {
expect(uls[1].getAttribute("data-depth")).toBe("1");
}
});

it("should add data-depth attributes to nested ordered lists", () => {
const content = `1. First
1. Sub first
2. Sub second
2. Second`;
const { container } = renderWithComponents(content);
const ols = container.querySelectorAll(
'[data-streamdown="ordered-list"]'
);
expect(ols.length).toBeGreaterThanOrEqual(1);
expect(ols[0]?.getAttribute("data-depth")).toBe("0");
});

it("should add data-depth attributes to list items", () => {
const content = `- A
- B
- C`;
const { container } = renderWithComponents(content);
const lis = container.querySelectorAll(
'[data-streamdown="list-item"]'
);
expect(lis.length).toBeGreaterThanOrEqual(3);
expect(lis[0]?.getAttribute("data-depth")).toBe("0");
expect(lis[1]?.getAttribute("data-depth")).toBe("1");
expect(lis[2]?.getAttribute("data-depth")).toBe("2");
});

it("should apply list-disc to all li in flat mode", () => {
const content = `- Item 1
- Nested 1`;
const { container } = renderWithComponents(content, {
listStyle: "flat",
});
const lis = container.querySelectorAll(
'[data-streamdown="list-item"]'
);
for (const li of lis) {
expect(li.className).toContain("list-disc");
}
});

it("should cycle bullet styles in hierarchical mode (default)", () => {
const content = `- Level 0
- Level 1
- Level 2`;
const { container } = renderWithComponents(content);
const lis = container.querySelectorAll(
'[data-streamdown="list-item"]'
);
expect(lis.length).toBeGreaterThanOrEqual(3);
expect(lis[0]?.className).toContain("list-disc");
expect(lis[1]?.className).toContain("list-[circle]");
expect(lis[2]?.className).toContain("list-[square]");
});

it("should not apply bullet styles to li inside ordered lists", () => {
const content = `1. First
2. Second`;
const { container } = renderWithComponents(content);
const lis = container.querySelectorAll(
'[data-streamdown="list-item"]'
);
for (const li of lis) {
expect(li.className).not.toContain("list-disc");
expect(li.className).not.toContain("list-[circle]");
expect(li.className).not.toContain("list-[square]");
}
});

it("should not have bullet class on ul (bullet style is on li)", () => {
const content = `- Item 1
- Item 2`;
const { container } = renderWithComponents(content);
const ul = container.querySelector(
'[data-streamdown="unordered-list"]'
);
expect(ul).toBeTruthy();
expect(ul?.className).toContain("list-inside");
expect(ul?.className).not.toContain("list-disc");
});

it("sibling lists should both start at depth 0", () => {
const content = `- List A1
- List A2

Some text

- List B1
- List B2`;
const { container } = renderWithComponents(content);
const uls = container.querySelectorAll(
'[data-streamdown="unordered-list"]'
);
for (const ul of uls) {
expect(ul.getAttribute("data-depth")).toBe("0");
}
});

it("should track ul depth separately from ol depth for bullet styles", () => {
const content = `- Unordered
1. Ordered inside
- Deep unordered`;
const { container } = renderWithComponents(content, {
listStyle: "hierarchical",
});
const lis = container.querySelectorAll(
'[data-streamdown="list-item"]'
);
// The top ul li should be disc
expect(lis[0]?.className).toContain("list-disc");
// The ol > li should NOT have bullet styles
if (lis[1]) {
expect(lis[1].className).not.toContain("list-disc");
expect(lis[1].className).not.toContain("list-[circle]");
}
// The nested ul > li inside ol should be circle (ulDepth=1)
if (lis[2]) {
expect(lis[2].className).toContain("list-[circle]");
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const createContextValue = (
shikiTheme: ["github-light", "github-dark"],
controls: true,
isAnimating: false,
lineNumbers: true,
listStyle: "hierarchical",
mode: "streaming",
mermaid: undefined,
linkSafety,
Expand Down Expand Up @@ -83,7 +85,7 @@ describe("Node Attribute Fix", () => {

// ✅ Verify correct attributes ARE present
expect(ul?.getAttribute("data-streamdown")).toBe("unordered-list");
expect(ul?.className).toContain("list-disc");
expect(ul?.className).toContain("list-inside");
});

it("should NOT render node attribute in LI element", () => {
Expand Down
15 changes: 15 additions & 0 deletions packages/streamdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ export type StreamdownProps = Options & {
isAnimating?: boolean;
animated?: boolean | AnimateOptions;
caret?: keyof typeof carets;
/** Bullet style cycling for nested unordered lists. @default "hierarchical" */
listStyle?: ListStylePreset;
plugins?: PluginConfig;
remend?: RemendOptions;
linkSafety?: LinkSafetyConfig;
Expand Down Expand Up @@ -276,13 +278,22 @@ const carets = {
circle: " ●",
};

/**
* Built-in list bullet style presets for nested unordered lists.
* - `"flat"` — all levels use disc (default, current behavior)
* - `"hierarchical"` — disc → circle → square, cycling
*/
export type ListStylePreset = "flat" | "hierarchical";

// Combined context for better performance - reduces React tree depth from 5 nested providers to 1
export interface StreamdownContextType {
controls: ControlsConfig;
isAnimating: boolean;
/** Show line numbers in code blocks. @default true */
lineNumbers: boolean;
linkSafety?: LinkSafetyConfig;
/** Bullet style cycling for nested unordered lists. @default "hierarchical" */
listStyle: ListStylePreset;
mermaid?: MermaidOptions;
mode: "static" | "streaming";
shikiTheme: [ThemeInput, ThemeInput];
Expand All @@ -302,6 +313,7 @@ const defaultStreamdownContext: StreamdownContextType = {
controls: true,
isAnimating: false,
lineNumbers: true,
listStyle: "hierarchical",
mode: "streaming",
mermaid: undefined,
linkSafety: defaultLinkSafetyConfig,
Expand Down Expand Up @@ -446,6 +458,7 @@ export const Streamdown = memo(
BlockComponent = Block,
parseMarkdownIntoBlocksFn = parseMarkdownIntoBlocks,
caret,
listStyle = "hierarchical",
plugins,
remend: remendOptions,
linkSafety = defaultLinkSafetyConfig,
Expand Down Expand Up @@ -613,6 +626,7 @@ export const Streamdown = memo(
controls,
isAnimating,
lineNumbers,
listStyle,
mode,
mermaid,
linkSafety,
Expand All @@ -622,6 +636,7 @@ export const Streamdown = memo(
controls,
isAnimating,
lineNumbers,
listStyle,
mode,
mermaid,
linkSafety,
Expand Down
Loading