diff --git a/SYNTAX-SPEC-v0.1.md b/SYNTAX-SPEC-v0.1.md index 3ccd209b..4f0cc6cd 100644 --- a/SYNTAX-SPEC-v0.1.md +++ b/SYNTAX-SPEC-v0.1.md @@ -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 diff --git a/docs/guide/syntax.md b/docs/guide/syntax.md index 6b31bce7..7012552e 100644 --- a/docs/guide/syntax.md +++ b/docs/guide/syntax.md @@ -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. diff --git a/examples/tabs-demo.md b/examples/tabs-demo.md new file mode 100644 index 00000000..2ac68fe3 --- /dev/null +++ b/examples/tabs-demo.md @@ -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]* diff --git a/package-lock.json b/package-lock.json index 3393762b..731aa1a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wiremd", - "version": "0.1.1", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wiremd", - "version": "0.1.1", + "version": "0.1.5", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -20,7 +20,7 @@ "unist-util-visit": "^5.0.0" }, "bin": { - "wiremd": "dist/cli/index.js" + "wiremd": "bin/wiremd.js" }, "devDependencies": { "@types/node": "^20.19.24", diff --git a/src/parser/transformer.ts b/src/parser/transformer.ts index 51859580..6cff91bb 100644 --- a/src/parser/transformer.ts +++ b/src/parser/transformer.ts @@ -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); diff --git a/src/renderer/html-renderer.ts b/src/renderer/html-renderer.ts index eb64b90e..3e2a8d87 100644 --- a/src/renderer/html-renderer.ts +++ b/src/renderer/html-renderer.ts @@ -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 ``; } @@ -649,6 +655,43 @@ function renderSeparator(node: any, context: RenderContext): string { return `
`; } +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 ``; + }).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 `
+ ${panelChildren} +
`; + }).join('\n '); + + return `
+
${headers}
+
+ ${panels} +
+
${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 `
${childrenHTML}
`; +} + +function getTabsScript(prefix: string): string { + return ``; +} + /** * Build CSS classes string from prefix, base class, and props */ diff --git a/src/renderer/styles.ts b/src/renderer/styles.ts index 919c8806..c14eb36a 100644 --- a/src/renderer/styles.ts +++ b/src/renderer/styles.ts @@ -14,6 +14,16 @@ export function getStyleCSS(style: string, prefix: string): string { // Reset browser link defaults for buttons rendered as 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; @@ -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; } /** @@ -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; } `; } diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 42a05103..c7107a9d 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -727,4 +727,75 @@ Quick expect(types.every((t: string) => t === 'grid-item')).toBe(true); }); }); + + describe('Tabs Syntax', () => { + const input = ` +## Product {.tabs} + +### Overview +Overview panel text. + +### Details +[Buy Now]* + +### Reviews +Review content + `.trim(); + + it('should parse heading with .tabs class into a tabs node', () => { + const result = parse(input); + expect(result.children).toHaveLength(1); + expect(result.children[0].type).toBe('tabs'); + }); + + it('should produce one tab child per sub-heading', () => { + const result = parse(input); + const tabs = result.children[0] as any; + expect(tabs.children).toHaveLength(3); + expect(tabs.children.every((c: any) => c.type === 'tab')).toBe(true); + }); + + it('should use sub-heading text as the tab label', () => { + const result = parse(input); + const tabs = result.children[0] as any; + expect(tabs.children.map((t: any) => t.label)).toEqual(['Overview', 'Details', 'Reviews']); + }); + + it('should mark the first tab active by default', () => { + const result = parse(input); + const tabs = result.children[0] as any; + expect(tabs.children[0].active).toBe(true); + expect(tabs.children[1].active).toBe(false); + expect(tabs.children[2].active).toBe(false); + }); + + it('should let {.active} on a sub-heading override the default active tab', () => { + const result = parse(` +## Product {.tabs} + +### Overview +a + +### Details {.active} +b + `.trim()); + const tabs = result.children[0] as any; + expect(tabs.children[0].active).toBe(false); + expect(tabs.children[1].active).toBe(true); + }); + + it('should put panel content as tab children', () => { + const result = parse(input); + const tabs = result.children[0] as any; + const detailsTab = tabs.children[1]; + const types = detailsTab.children.map((c: any) => c.type); + expect(types).toContain('button'); + }); + + it('should not emit the parent heading as a separate node', () => { + const result = parse(input); + const topTypes = result.children.map((c: any) => c.type); + expect(topTypes).not.toContain('heading'); + }); + }); }); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index fce5bd32..bd29df8b 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -551,6 +551,66 @@ Content here }); }); + describe('Tabs', () => { + const input = ` +## Product {.tabs} + +### Overview +Overview panel + +### Details +Details panel + `.trim(); + + it('should render a tabs container with headers and panels', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toContain('wmd-tabs'); + expect(html).toContain('wmd-tab-header'); + expect(html).toContain('wmd-tab-panel'); + }); + + it('should render tab labels as header buttons', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/]*wmd-tab-header[^>]*>Overview<\/button>/); + expect(html).toMatch(/]*wmd-tab-header[^>]*>Details<\/button>/); + }); + + it('should mark the active header with wmd-active', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + const headerMatches = [...html.matchAll(/]*class="([^"]*)"[^>]*>(Overview|Details)<\/button>/g)]; + expect(headerMatches).toHaveLength(2); + const overviewClasses = headerMatches.find((m) => m[2] === 'Overview')![1]; + const detailsClasses = headerMatches.find((m) => m[2] === 'Details')![1]; + expect(overviewClasses).toContain('wmd-active'); + expect(detailsClasses).not.toContain('wmd-active'); + }); + + it('should hide inactive panels via the hidden attribute', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + const panels = [...html.matchAll(/]*data-wmd-tab-panel="\d+"[^>]*>/g)].map((m) => m[0]); + expect(panels).toHaveLength(2); + expect(panels[0]).not.toContain('hidden'); + expect(panels[1]).toContain('hidden'); + }); + + it('should render panel children', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toContain('Overview panel'); + expect(html).toContain('Details panel'); + }); + + it('should inject a tabs interactivity script', () => { + const ast = parse(input); + const html = renderToHTML(ast, { style: 'sketch' }); + expect(html).toMatch(/