Skip to content
Merged
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
21 changes: 15 additions & 6 deletions SYNTAX-SPEC-v0.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,16 +400,25 @@ Home > Products > Category > Current Page
### 7.3 Tabs

```markdown
[Overview]* | Details | Reviews | FAQ
## Settings {.tabs}

Content for Overview tab...
### Profile
Name
[_____________________________]{required}

### Notifications
[ ] Email alerts
[ ] SMS alerts

### Security {.active}
[Change Password]
```

**Parser Rules:**
- Pipe-separated button-like elements
- `*` suffix indicates active tab
- Following content belongs to active tab
- Each H2 can start a new tab content section
- `{.tabs}` class on a heading declares a tabs container
- Child headings one level deeper become tab panels (label = heading text)
- First tab is active by default; add `{.active}` to a child heading to override
- Any wiremd content is valid inside a tab panel

### 7.4 Badges/Pills

Expand Down
19 changes: 19 additions & 0 deletions docs/guide/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,25 @@ Place inline content directly on the opener line to inject it as the first child
:::
```

### Tabs

`{.tabs}` on a heading creates a tabbed panel. Child headings one level deeper become tab labels; their content becomes the panel body. The first tab is active by default — add `{.active}` to a child heading to override.

```markdown
## Settings {.tabs}

### Profile
Name
[_____________________________]{required}

### Notifications
[ ] Email alerts
[ ] SMS alerts

### Security {.active}
[Change Password]
```

### Grid Layouts

`{.grid-N}` on a heading creates an N-column layout. Child `###` headings become grid items. The heading label itself is **declaration-only** — it is never rendered in the output; it only names the grid for the author.
Expand Down
72 changes: 72 additions & 0 deletions examples/tabs-demo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Tabs Demo

A showcase of the `{.tabs}` heading-class syntax — interactive, JS-switched tab panels.

---

## Product Details {.tabs}

### Overview
This is the overview panel. Tap/click the other tab headers above to switch.

**Key points:**
- Available in three sizes
- Ships worldwide
- 30-day returns

[Buy Now]* [Add to Cart]

### Specifications
| Spec | Value |
|------|-------|
| Weight | 250g |
| Dimensions | 10 × 5 × 3 cm |
| Material | Aluminium |
| Warranty | 2 years |

### Reviews {.active}
> :star: :star: :star: :star: :star:
>
> "Fantastic build quality — exactly as described."
> — Satisfied Customer

> :star: :star: :star: :star:
>
> "Works as advertised, shipping was fast."
> — Another Buyer

### FAQ
**Q: Does it come with a charger?**
A: Yes, a USB-C cable is included.

**Q: Is there a mobile app?**
A: Yes — available on iOS and Android.

---

## Another Example {.tabs}

### Login
Username
[_______________________]

Password
[***********************]

[Sign In]*

### Register
Email
[Email___________________]

Password
[***********************]

[Create Account]*

### Forgot?
Enter your email and we'll send a reset link.

[Email___________________]

[Send Reset Link]*
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

66 changes: 66 additions & 0 deletions src/parser/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,72 @@ function processNodeList(nodeChildren: any[], options: ParseOptions): WiremdNode
if (node.type === 'heading') {
const content = extractTextContent(node);
const gridMatch = content.match(/\{[^}]*\.grid-(\d+)[^}]*\}/);
const tabsMatch = !gridMatch && /\{[^}]*\.tabs\b[^}]*\}/.test(content);

if (tabsMatch) {
const tabsHeadingLevel = node.depth;
const tabs: WiremdNode[] = [];
const headingTransformed = transformHeading(node, options);

i++;

while (i < nodeChildren.length) {
const childNode = nodeChildren[i];

if (childNode.type === 'heading' && childNode.depth === tabsHeadingLevel + 1) {
const tabPanelChildren: WiremdNode[] = [];
const tabHeadingContent = extractTextContent(childNode);
const tabAttrMatch = tabHeadingContent.match(/^(.+?)(\{[^}]+\})$/);
let tabLabel = tabHeadingContent;
let tabProps: any = { classes: [] };
if (tabAttrMatch) {
tabLabel = tabAttrMatch[1].trim();
tabProps = parseAttributes(tabAttrMatch[2]);
}
const isActive = (tabProps.classes || []).includes('active');

i++;

while (i < nodeChildren.length) {
const contentNode = nodeChildren[i];
if (contentNode.type === 'heading' && contentNode.depth <= tabsHeadingLevel + 1) break;
const contentNextNode = nodeChildren[i + 1];
const contentTransformed = transformNode(contentNode, options, contentNextNode);
if (contentTransformed) {
tabPanelChildren.push(contentTransformed);
if (contentTransformed.type === 'select' && contentNextNode?.type === 'list') i++;
}
i++;
}

tabs.push({
type: 'tab',
label: tabLabel,
active: isActive,
props: tabProps,
children: tabPanelChildren as any,
});
} else if (childNode.type === 'heading' && childNode.depth <= tabsHeadingLevel) {
break;
} else if (tabs.length === 0) {
i++;
continue;
} else {
break;
}
}

if (tabs.length > 0 && !tabs.some((t: any) => t.active)) {
(tabs[0] as any).active = true;
}

result.push({
type: 'tabs',
props: (headingTransformed as any).props || {},
children: tabs as any,
});
continue;
}

if (gridMatch) {
const columns = parseInt(gridMatch[1], 10);
Expand Down
43 changes: 43 additions & 0 deletions src/renderer/html-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ export function renderNode(node: WiremdNode, context: RenderContext): string {
case 'separator':
return renderSeparator(node, context);

case 'tabs':
return renderTabs(node, context);

case 'tab':
return renderTab(node, context);

default:
return `<!-- Unknown node type: ${(node as any).type} -->`;
}
Expand Down Expand Up @@ -649,6 +655,43 @@ function renderSeparator(node: any, context: RenderContext): string {
return `<hr class="${classes}" />`;
}

function renderTabs(node: any, context: RenderContext): string {
const { classPrefix: prefix } = context;
const classes = buildClasses(prefix, 'tabs', node.props);
const tabs: any[] = node.children || [];

const headers = tabs.map((tab: any, i: number) => {
const activeClass = tab.active ? ` ${prefix}active` : '';
return `<button type="button" role="tab" class="${prefix}tab-header${activeClass}" data-wmd-tab="${i}">${escapeHtml(tab.label || '')}</button>`;
}).join('');

const panels = tabs.map((tab: any, i: number) => {
const panelChildren = (tab.children || []).map((c: any) => renderNode(c, context)).join('\n ');
const hidden = tab.active ? '' : ' hidden';
return `<div class="${prefix}tab-panel" role="tabpanel" data-wmd-tab-panel="${i}"${hidden}>
${panelChildren}
</div>`;
}).join('\n ');

return `<div class="${classes}" data-wmd-tabs>
<div class="${prefix}tab-headers" role="tablist">${headers}</div>
<div class="${prefix}tab-panels">
${panels}
</div>
</div>${getTabsScript(prefix)}`;
}

function renderTab(node: any, context: RenderContext): string {
const { classPrefix: prefix } = context;
const hidden = node.active ? '' : ' hidden';
const childrenHTML = (node.children || []).map((c: any) => renderNode(c, context)).join('');
return `<div class="${prefix}tab-panel" role="tabpanel"${hidden}>${childrenHTML}</div>`;
}

function getTabsScript(prefix: string): string {
return `<script>(function(){if(window.__wmdTabsInit)return;window.__wmdTabsInit=true;document.addEventListener('click',function(e){var btn=e.target.closest('.${prefix}tab-header');if(!btn)return;var root=btn.closest('[data-wmd-tabs]');if(!root)return;var idx=btn.getAttribute('data-wmd-tab');root.querySelectorAll('.${prefix}tab-header').forEach(function(b){b.classList.toggle('${prefix}active',b.getAttribute('data-wmd-tab')===idx);});root.querySelectorAll('[data-wmd-tab-panel]').forEach(function(p){if(p.getAttribute('data-wmd-tab-panel')===idx){p.removeAttribute('hidden');}else{p.setAttribute('hidden','');}});});})();</script>`;
}

/**
* Build CSS classes string from prefix, base class, and props
*/
Expand Down
19 changes: 18 additions & 1 deletion src/renderer/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ export function getStyleCSS(style: string, prefix: string): string {
// Reset browser link defaults for buttons rendered as <a> tags via [[Text](url)] syntax
const linkButtonReset = `a.${prefix}button { text-decoration: none; color: inherit; }\n`;

// Structural rules shared across themes — required for tabs visibility toggling
const tabsStructural = `
.${prefix}tab-headers { display: flex; gap: 0; border-bottom: 2px solid #e0e0e0; margin-bottom: 12px; }
.${prefix}tab-header { display: inline-block; padding: 8px 16px; border: none; border-bottom: 2px solid transparent; margin-bottom: -2px; font-size: 14px; font-weight: 500; color: #888; background: transparent; cursor: pointer; font-family: inherit; transition: color 0.15s; }
.${prefix}tab-header:hover { color: #333; }
.${prefix}tab-header.${prefix}active { border-bottom-color: currentColor; color: #333; font-weight: 600; }
.${prefix}tab-panel[hidden] { display: none; }
.${prefix}tab-panels { padding: 12px 0; }
`;

let themeCSS: string;
switch (style) {
case 'sketch': themeCSS = getSketchStyle(prefix); break;
Expand All @@ -25,7 +35,7 @@ export function getStyleCSS(style: string, prefix: string): string {
case 'brutal': themeCSS = getBrutalStyle(prefix); break;
default: themeCSS = getSketchStyle(prefix);
}
return linkButtonReset + themeCSS;
return linkButtonReset + tabsStructural + themeCSS;
}

/**
Expand Down Expand Up @@ -545,6 +555,13 @@ body.${prefix}root {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

/* Tabs */
.${prefix}tabs { margin: 12px 0; }
.${prefix}tab-headers { border-bottom-color: #666; }
.${prefix}tab-header { color: #666; }
.${prefix}tab-header:hover { color: #000; }
.${prefix}tab-header.${prefix}active { border-bottom-color: #000; color: #000; }
`;
}

Expand Down
Loading