FEATURE: core support for tabbed interface inside markdown

This is useful for AI features and documentation.

Adds support for:

```
[tabs]
[tab="tab name"]
content **here**
[/tab]
[tab="tab2 name"]
other content **here**
[/tab]
[/tabs]
```

Optionally you can select the default tab using:


```
[tabs]
[tab="tab name"]
content **here**
[/tab]
[tab="tab2 name" selected]
other content **here**
[/tab]
[/tabs]
```
This commit is contained in:
Sam Saffron 2024-11-06 12:47:46 +11:00
parent 57f4176b57
commit 89749d267a
No known key found for this signature in database
GPG Key ID: B9606168D2FFD9F5
6 changed files with 312 additions and 0 deletions

View File

@ -16,6 +16,7 @@ import * as onebox from "./onebox";
import * as paragraph from "./paragraph";
import * as quotes from "./quotes";
import * as table from "./table";
import * as tabs from "./tabs";
import * as textPostProcess from "./text-post-process";
import * as uploadProtocol from "./upload-protocol";
import * as watchedWords from "./watched-words";
@ -42,6 +43,7 @@ export default [
feature("bbcode-inline", bbcodeInline),
feature("bbcode-block", bbcodeBlock),
feature("anchor", anchor),
feature("tabs", tabs),
];
function feature(id, { setup, priority = 0 }) {

View File

@ -0,0 +1,131 @@
const WRAP_CLASS = "markdown-tabs";
function setupTabs(helper) {
helper.allowList([
"div.markdown-tabs",
"div.markdown-tabs-wrapper",
"div.markdown-tab",
"div.markdown-tab-panels",
"div.markdown-tab-panel",
"a[data-tab-id]",
"div[data-tab-id]",
"div[data-selected]",
]);
helper.registerPlugin((md) => {
const ruler = md.block.bbcode.ruler;
ruler.push("tabs", {
tag: "tabs",
before(state) {
state.env.tabContent = [];
const token = state.push("tab_open", "div", 1);
token.attrs = [["class", WRAP_CLASS]];
const wrapperToken = state.push("tab_wrapper_open", "div", 1);
wrapperToken.attrs = [["class", "markdown-tabs-wrapper"]];
},
after(state) {
if (state.env.tabContent) {
// force selection of 1 tab only
let selected = false;
state.env.tabContent.forEach((tab) => {
if (tab.selected) {
if (selected) {
tab.selected = false;
} else {
selected = true;
}
}
});
if (!selected) {
state.env.tabContent[0].selected = true;
}
let index = 0;
state.env.tabContent.forEach((tab) => {
const tabId = `tab-${index}`;
index++;
tab.id = tabId;
const tabToken = state.push("tab_button_open", "div", 1);
tabToken.attrs = [
["class", "markdown-tab"],
["data-tab-id", tabId],
];
if (tab.selected) {
tabToken.attrs.push(["data-selected", ""]);
}
const linkToken = state.push("tab_link_open", "a", 1);
linkToken.attrs = [["data-tab-id", tabId]];
// Add tab name
const textToken = state.push("text", "", 0);
textToken.content = tab.name;
state.push("tab_link_close", "a", -1);
state.push("tab_button_close", "div", -1);
});
}
state.push("tab_wrapper_close", "div", -1);
state.push("panels_open", "div", 1).attrs = [
["class", "markdown-tab-panels"],
];
if (state.env.tabContent) {
state.env.tabContent.forEach((tab) => {
const panelToken = state.push("tab_panel_open", "div", 1);
panelToken.attrs = [
["class", "markdown-tab-panel"],
["data-tab-id", tab.id],
];
if (tab.selected) {
panelToken.attrs.push(["data-selected", ""]);
}
if (tab.content) {
tab.content.forEach((token) => state.tokens.push(token));
}
state.push("tab_panel_close", "div", -1);
});
}
state.env.tabContent = null;
state.push("panels_close", "div", -1);
state.push("tab_close", "div", -1);
},
});
ruler.push("tab", {
tag: "tab",
replace(state, tagInfo, content) {
const attrs = tagInfo.attrs;
const name = attrs.name || attrs._default || "";
const selected = attrs.selected === "" || attrs.selected === "true";
const tokens = [];
state.md.block.parse(content, state.md, state.env, tokens);
state.env.tabContent.push({
selected,
name,
content: tokens,
startPos: state.tokens.length,
});
return true;
},
});
});
}
export function setup(helper) {
setupTabs(helper);
}

View File

@ -0,0 +1,50 @@
import { withPluginApi } from "discourse/lib/plugin-api";
function initializeMarkdownTabs(api) {
api.decorateCooked(
($elem) => {
const tabs = $elem[0].querySelectorAll(".markdown-tabs");
if (!tabs.length) {
return;
}
tabs.forEach((tabContainer) => {
const tabButtons = tabContainer.querySelectorAll(".markdown-tab");
const panels = tabContainer.querySelectorAll(".markdown-tab-panel");
tabButtons.forEach((tab) => {
tab.addEventListener("click", (e) => {
e.preventDefault();
if (tab.hasAttribute("data-selected")) {
return;
}
const tabId = tab.getAttribute("data-tab-id");
// Remove selected state from all tabs and panels
tabButtons.forEach((t) => t.removeAttribute("data-selected"));
panels.forEach((p) => p.removeAttribute("data-selected"));
// Set selected state for clicked tab and its panel
tab.setAttribute("data-selected", "");
const panel = tabContainer.querySelector(
`.markdown-tab-panel[data-tab-id="${tabId}"]`
);
if (panel) {
panel.setAttribute("data-selected", "");
}
});
});
});
},
{ id: "discourse-tabs" }
);
}
export default {
name: "discourse-tabs",
initialize() {
withPluginApi("0.8.7", initializeMarkdownTabs);
},
};

View File

@ -33,6 +33,7 @@
@import "login";
@import "magnific-popup";
@import "menu-panel";
@import "markdown-tabs";
@import "modal";
@import "new-user";
@import "not-found";

View File

@ -0,0 +1,50 @@
.markdown-tabs {
margin: 1em 0;
.markdown-tabs-wrapper {
display: flex;
gap: 0.2em;
border-bottom: 2px solid var(--primary-low);
padding: 0 0.2em;
}
.markdown-tab {
margin-bottom: -2px;
&[data-selected] {
a {
color: var(--tertiary);
font-weight: 500;
border-bottom: 2px solid var(--tertiary);
}
}
&:hover:not([data-selected]) {
a {
color: var(--primary);
background: var(--primary-very-low);
}
}
a {
display: block;
padding: 0.5em 1em;
color: var(--primary-medium);
text-decoration: none;
cursor: pointer;
border-bottom: 2px solid transparent;
}
}
.markdown-tab-panels {
padding: 0 0;
.markdown-tab-panel {
display: none;
&[data-selected] {
display: block;
}
}
}
}

View File

@ -2832,4 +2832,82 @@ HTML
expect(doc.to_html).to eq(html_with_thumbnail)
end
end
describe "markdown tabs" do
it "supports overriding default tab in markdown" do
md = <<~MD
[tabs]
[tab="First Tab"]
Tab 1 **content**
[/tab]
[tab="Second Tab" selected]
Tab 2 content
[/tab]
[/tabs]
MD
html = PrettyText.cook(md)
expected = <<~HTML
<div class="markdown-tabs">
<div class="markdown-tabs-wrapper">
<div class="markdown-tab" data-tab-id="tab-0">
<a data-tab-id="tab-0">
First Tab</a>
</div>
<div class="markdown-tab" data-tab-id="tab-1" data-selected="">
<a data-tab-id="tab-1">
Second Tab</a>
</div>
</div>
<div class="markdown-tab-panels">
<div class="markdown-tab-panel" data-tab-id="tab-0">
<p>Tab 1 <strong>content</strong></p>
</div>
<div class="markdown-tab-panel" data-tab-id="tab-1" data-selected="">
<p>Tab 2 content</p>
</div>
</div>
</div>
HTML
expect(html).to match_html(expected)
end
it "supports tabs markup" do
md = <<~MD
[tabs]
[tab="First Tab"]
Tab 1 **content**
[/tab]
[tab="Second Tab"]
Tab 2 content
[/tab]
[/tabs]
MD
html = PrettyText.cook(md)
expected = <<~HTML
<div class="markdown-tabs">
<div class="markdown-tabs-wrapper">
<div class="markdown-tab" data-tab-id="tab-0" data-selected="">
<a data-tab-id="tab-0">
First Tab</a>
</div>
<div class="markdown-tab" data-tab-id="tab-1">
<a data-tab-id="tab-1">
Second Tab</a>
</div>
</div>
<div class="markdown-tab-panels">
<div class="markdown-tab-panel" data-tab-id="tab-0" data-selected="">
<p>Tab 1 <strong>content</strong></p>
</div>
<div class="markdown-tab-panel" data-tab-id="tab-1">
<p>Tab 2 content</p>
</div>
</div>
</div>
HTML
expect(html).to match_html(expected)
end
end
end