mirror of
https://github.com/kbenestad/mdcms.git
synced 2026-06-18 15:24:32 +00:00
Add tabs and accordion content components to index.html
Implements four new mdcms fenced-block types: tab-underline / tab, tab-filled, accordion-underline / accordion, accordion-filled Each block reads items: from a YAML body. Tab state and accordion open/close are managed with aria-selected / aria-expanded and data-open attributes. Markdown content inside each item is rendered with the same pipeline as the surrounding page. Adds computeDerivedTokens() — called on every applyTheme() — which computes --mdcms-bar, --mdcms-filled-bg/border/fg, --mdcms-strip-border from the active palette. Uses HSL chroma (S × (1-|2L-1|)) instead of raw HSL S for the bold-nav heuristic, avoiding a false-positive on near-white nav colours like the default #F8FAFC. Adds app/pages/tabs-accordions.md as a visual test page and docs/unreleased.md to track this change ahead of the next release. https://claude.ai/code/session_01SFMh7PDxJjvvo5dYbCCFFs
This commit is contained in:
parent
099320cde7
commit
a09df3a63c
6 changed files with 617 additions and 1 deletions
375
app/index.html
375
app/index.html
|
|
@ -917,6 +917,146 @@ body {
|
||||||
}
|
}
|
||||||
.post-load-more:hover { background: var(--nav-hover-bg); }
|
.post-load-more:hover { background: var(--nav-hover-bg); }
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
TAG SYSTEM: TABS
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
.mdcms-tabs { margin: 1.25rem 0; }
|
||||||
|
|
||||||
|
/* Underline variant */
|
||||||
|
.mdcms-tabs-underline .mdcms-tabs-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 18px;
|
||||||
|
border-bottom: 1px solid var(--mdcms-strip-border, color-mix(in srgb, var(--font-colour) 12%, transparent));
|
||||||
|
}
|
||||||
|
.mdcms-tabs-underline .mdcms-tab-btn {
|
||||||
|
padding: 8px 2px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--font-colour-muted);
|
||||||
|
line-height: inherit;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.mdcms-tabs-underline .mdcms-tab-btn:hover { color: var(--font-colour); }
|
||||||
|
.mdcms-tabs-underline .mdcms-tab-btn[aria-selected="true"] {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--font-colour);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filled variant */
|
||||||
|
.mdcms-tabs-filled .mdcms-tabs-strip { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.mdcms-tabs-filled .mdcms-tab-btn {
|
||||||
|
padding: 6px 11px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--mdcms-filled-border, rgba(var(--accent-rgb), 0.30));
|
||||||
|
background: var(--mdcms-filled-bg, rgba(var(--accent-rgb), 0.10));
|
||||||
|
color: var(--mdcms-filled-fg-muted, var(--font-colour-muted));
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: inherit;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.mdcms-tabs-filled .mdcms-tab-btn:hover { color: var(--mdcms-filled-fg, var(--font-colour)); }
|
||||||
|
.mdcms-tabs-filled .mdcms-tab-btn[aria-selected="true"] {
|
||||||
|
background: var(--mdcms-bg, var(--bg-main));
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.55);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared panel */
|
||||||
|
.mdcms-tabs-panel { padding-top: 1rem; }
|
||||||
|
.mdcms-tabs-panel[hidden] { display: none; }
|
||||||
|
.mdcms-tabs-panel > *:first-child { margin-top: 0; }
|
||||||
|
.mdcms-tabs-panel > *:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
TAG SYSTEM: ACCORDIONS
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
.mdcms-accordion { margin: 1.25rem 0; }
|
||||||
|
.mdcms-accordion-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
.mdcms-accordion-chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.1em;
|
||||||
|
height: 1.1em;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
.mdcms-accordion-item[data-open="false"] .mdcms-accordion-chevron { transform: rotate(-90deg); }
|
||||||
|
.mdcms-accordion-body[hidden] { display: none; }
|
||||||
|
.mdcms-accordion-body > *:first-child { margin-top: 0; }
|
||||||
|
.mdcms-accordion-body > *:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Underline variant */
|
||||||
|
.mdcms-accordion-underline .mdcms-accordion-item { margin-bottom: 6px; }
|
||||||
|
.mdcms-accordion-underline .mdcms-accordion-btn {
|
||||||
|
padding: 8px 2px 9px;
|
||||||
|
border-bottom: 2px solid var(--mdcms-bar, var(--accent));
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--font-colour);
|
||||||
|
}
|
||||||
|
.mdcms-accordion-underline .mdcms-accordion-chevron { color: var(--mdcms-bar, var(--accent)); }
|
||||||
|
.mdcms-accordion-underline .mdcms-accordion-body {
|
||||||
|
border-left: 1px solid var(--mdcms-bar, var(--accent));
|
||||||
|
border-right: 1px solid var(--mdcms-bar, var(--accent));
|
||||||
|
border-bottom: 1px solid var(--mdcms-bar, var(--accent));
|
||||||
|
border-radius: 0 0 3px 3px;
|
||||||
|
padding: 8px 10px 9px;
|
||||||
|
color: var(--font-colour-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filled variant — closed */
|
||||||
|
.mdcms-accordion-filled .mdcms-accordion-item { margin-bottom: 6px; }
|
||||||
|
.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] { margin-bottom: 8px; }
|
||||||
|
.mdcms-accordion-filled .mdcms-accordion-btn {
|
||||||
|
padding: 8px 11px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--mdcms-filled-border, rgba(var(--accent-rgb), 0.30));
|
||||||
|
background: var(--mdcms-filled-bg, rgba(var(--accent-rgb), 0.10));
|
||||||
|
color: var(--mdcms-filled-fg, var(--font-colour));
|
||||||
|
}
|
||||||
|
.mdcms-accordion-filled .mdcms-accordion-chevron { color: var(--mdcms-filled-fg, var(--font-colour)); }
|
||||||
|
|
||||||
|
/* Filled variant — open: item becomes the outer frame */
|
||||||
|
.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] {
|
||||||
|
border: 1px solid var(--mdcms-bar, var(--accent));
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mdcms-accordion-filled .mdcms-accordion-item[data-open="true"] > .mdcms-accordion-btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.mdcms-accordion-filled .mdcms-accordion-body {
|
||||||
|
background: var(--mdcms-bg, var(--bg-main));
|
||||||
|
padding: 8px 11px 9px;
|
||||||
|
color: var(--font-colour-muted);
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
.sidebar, .topbar, .scroll-top, .hamburger,
|
.sidebar, .topbar, .scroll-top, .hamburger,
|
||||||
.mobile-header, .theme-toggle, .search-container { display: none !important; }
|
.mobile-header, .theme-toggle, .search-container { display: none !important; }
|
||||||
|
|
@ -1236,6 +1376,7 @@ body {
|
||||||
btn.appendChild(iconEl(isDark ? 'light_mode' : 'dark_mode'));
|
btn.appendChild(iconEl(isDark ? 'light_mode' : 'dark_mode'));
|
||||||
btn.appendChild(el('span', { textContent: isDark ? 'Light mode' : 'Dark mode' }));
|
btn.appendChild(el('span', { textContent: isDark ? 'Light mode' : 'Dark mode' }));
|
||||||
}
|
}
|
||||||
|
computeDerivedTokens();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitialTheme() {
|
function getInitialTheme() {
|
||||||
|
|
@ -1467,6 +1608,10 @@ body {
|
||||||
const fenceType = codeLang === 'mdcms' ? '' : codeLang.slice('mdcms '.length).trim();
|
const fenceType = codeLang === 'mdcms' ? '' : codeLang.slice('mdcms '.length).trim();
|
||||||
const fullText = fenceType ? (fenceType + '\n' + (codeText || '')) : (codeText || '');
|
const fullText = fenceType ? (fenceType + '\n' + (codeText || '')) : (codeText || '');
|
||||||
const tag = parseMdcmsTag(fullText);
|
const tag = parseMdcmsTag(fullText);
|
||||||
|
// For tab/accordion blocks, preserve the raw fence body to avoid trim() breaking YAML indentation.
|
||||||
|
if (/^tab(-underline|-filled)?$|^accordion(-underline|-filled)?$/.test(tag.tagName)) {
|
||||||
|
tag.rawBody = codeText || '';
|
||||||
|
}
|
||||||
const encoded = JSON.stringify(tag).replace(/&/g, '&').replace(/"/g, '"');
|
const encoded = JSON.stringify(tag).replace(/&/g, '&').replace(/"/g, '"');
|
||||||
return '<div class="mdcms-tag" data-config="' + encoded + '"></div>';
|
return '<div class="mdcms-tag" data-config="' + encoded + '"></div>';
|
||||||
}
|
}
|
||||||
|
|
@ -1956,6 +2101,95 @@ function fmtDatetime(dtStr) {
|
||||||
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
|
return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseColorToHex(val) {
|
||||||
|
if (!val) return null;
|
||||||
|
val = val.trim();
|
||||||
|
if (val.startsWith('#')) {
|
||||||
|
if (val.length === 4) return '#' + val[1]+val[1]+val[2]+val[2]+val[3]+val[3];
|
||||||
|
return val.toLowerCase();
|
||||||
|
}
|
||||||
|
var m = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
|
||||||
|
if (m) return '#' + [m[1],m[2],m[3]].map(function(n) { return parseInt(n).toString(16).padStart(2,'0'); }).join('');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeLuminance(hex) {
|
||||||
|
hex = hex.replace('#','');
|
||||||
|
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
||||||
|
var r = parseInt(hex.substr(0,2),16)/255;
|
||||||
|
var g = parseInt(hex.substr(2,2),16)/255;
|
||||||
|
var b = parseInt(hex.substr(4,2),16)/255;
|
||||||
|
function lin(c) { return c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); }
|
||||||
|
return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToHsl(hex) {
|
||||||
|
hex = hex.replace('#','');
|
||||||
|
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
||||||
|
var r = parseInt(hex.substr(0,2),16)/255;
|
||||||
|
var g = parseInt(hex.substr(2,2),16)/255;
|
||||||
|
var b = parseInt(hex.substr(4,2),16)/255;
|
||||||
|
var max = Math.max(r,g,b), min = Math.min(r,g,b);
|
||||||
|
var h = 0, s = 0, l = (max+min)/2;
|
||||||
|
if (max !== min) {
|
||||||
|
var d = max - min;
|
||||||
|
s = l > 0.5 ? d/(2-max-min) : d/(max+min);
|
||||||
|
switch(max) {
|
||||||
|
case r: h = ((g-b)/d + (g<b?6:0))/6; break;
|
||||||
|
case g: h = ((b-r)/d + 2)/6; break;
|
||||||
|
case b: h = ((r-g)/d + 4)/6; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [h, s, l];
|
||||||
|
}
|
||||||
|
|
||||||
|
// HSL chroma: S × (1-|2L-1|) — gives perceptually meaningful colorfulness
|
||||||
|
// unlike raw HSL S which is artificially high near white/black.
|
||||||
|
function hslChroma(hex) {
|
||||||
|
var hsl = hexToHsl(hex);
|
||||||
|
return hsl[1] * (1 - Math.abs(2 * hsl[2] - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDerivedTokens() {
|
||||||
|
var cs = getComputedStyle(document.documentElement);
|
||||||
|
var bgHex = parseColorToHex(cs.getPropertyValue('--bg-main').trim());
|
||||||
|
var navHex = parseColorToHex(cs.getPropertyValue('--bg-nav').trim());
|
||||||
|
var textHex = parseColorToHex(cs.getPropertyValue('--font-colour').trim());
|
||||||
|
var mutedHex = parseColorToHex(cs.getPropertyValue('--font-colour-muted').trim());
|
||||||
|
var accentHex = parseColorToHex(cs.getPropertyValue('--accent').trim());
|
||||||
|
|
||||||
|
if (!bgHex || !navHex || !textHex || !mutedHex || !accentHex) return;
|
||||||
|
|
||||||
|
var bgL = relativeLuminance(bgHex);
|
||||||
|
var navL = relativeLuminance(navHex);
|
||||||
|
var navC = hslChroma(navHex);
|
||||||
|
var bgC = hslChroma(bgHex);
|
||||||
|
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||||
|
|
||||||
|
var navIsAccent = Math.abs(bgL - navL) > 0.22 || (navC > 0.35 && Math.abs(navC - bgC) > 0.25);
|
||||||
|
var navIsInverted = Math.abs(bgL - navL) > 0.35;
|
||||||
|
|
||||||
|
var navText = navIsInverted ? (navL < 0.5 ? '#F2EFE8' : '#161412') : textHex;
|
||||||
|
var navTextMuted = navIsInverted ? hexToRgba(navText, 0.6) : mutedHex;
|
||||||
|
|
||||||
|
var filledBg = navIsAccent ? navHex : hexToRgba(accentHex, 0.10);
|
||||||
|
var filledBorder = navIsAccent ? hexToRgba(navText, 0.18) : hexToRgba(accentHex, 0.30);
|
||||||
|
var filledFg = navIsAccent ? navText : textHex;
|
||||||
|
var filledFgMuted = navIsAccent ? navTextMuted : mutedHex;
|
||||||
|
var barColor = navIsAccent ? navHex : accentHex;
|
||||||
|
var stripAlpha = isDark ? 0.14 : 0.10;
|
||||||
|
|
||||||
|
var root = document.documentElement;
|
||||||
|
root.style.setProperty('--mdcms-bg', bgHex);
|
||||||
|
root.style.setProperty('--mdcms-accent', accentHex);
|
||||||
|
root.style.setProperty('--mdcms-filled-bg', filledBg);
|
||||||
|
root.style.setProperty('--mdcms-filled-border', filledBorder);
|
||||||
|
root.style.setProperty('--mdcms-filled-fg', filledFg);
|
||||||
|
root.style.setProperty('--mdcms-filled-fg-muted',filledFgMuted);
|
||||||
|
root.style.setProperty('--mdcms-bar', barColor);
|
||||||
|
root.style.setProperty('--mdcms-strip-border', hexToRgba(textHex, stripAlpha));
|
||||||
|
}
|
||||||
|
|
||||||
function renderTocTag(container) {
|
function renderTocTag(container) {
|
||||||
const byCode = {};
|
const byCode = {};
|
||||||
navSections.forEach(s => { byCode[s.code] = s; });
|
navSections.forEach(s => { byCode[s.code] = s; });
|
||||||
|
|
@ -2012,6 +2246,143 @@ function fmtDatetime(dtStr) {
|
||||||
container.replaceWith(div);
|
container.replaceWith(div);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTabsTag(container, cfg) {
|
||||||
|
var variant = cfg.tagName === 'tab' ? 'tab-underline' : cfg.tagName;
|
||||||
|
var isFilled = variant === 'tab-filled';
|
||||||
|
var varClass = isFilled ? 'filled' : 'underline';
|
||||||
|
|
||||||
|
var items = [];
|
||||||
|
try {
|
||||||
|
// Use rawBody (pre-trim YAML) when available; fall back to reconstructed form.
|
||||||
|
var rawYaml = cfg.rawBody !== undefined ? cfg.rawBody : ('items:\n' + (cfg.body || ''));
|
||||||
|
var parsed = jsyaml.load(rawYaml);
|
||||||
|
items = (parsed && parsed.items) || [];
|
||||||
|
} catch (e) {
|
||||||
|
container.textContent = 'Error parsing tab items.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!items.length) { container.textContent = 'No tab items.'; return; }
|
||||||
|
|
||||||
|
var selectedIdx = items.findIndex(function(it) { return it && it.default === 'selected'; });
|
||||||
|
if (selectedIdx < 0) selectedIdx = 0;
|
||||||
|
|
||||||
|
var wrapper = el('div', { className: 'mdcms-tabs mdcms-tabs-' + varClass });
|
||||||
|
var strip = el('div', { className: 'mdcms-tabs-strip', role: 'tablist' });
|
||||||
|
var panels = [];
|
||||||
|
|
||||||
|
items.forEach(function(item, i) {
|
||||||
|
if (!item) return;
|
||||||
|
var isSelected = i === selectedIdx;
|
||||||
|
|
||||||
|
var btn = el('button', {
|
||||||
|
className: 'mdcms-tab-btn',
|
||||||
|
role: 'tab',
|
||||||
|
type: 'button',
|
||||||
|
'aria-selected': String(isSelected)
|
||||||
|
});
|
||||||
|
var titleStyle = item['title-style'] || '';
|
||||||
|
var lvlMatch = titleStyle.match(/^(#{1,6})$/);
|
||||||
|
var titleSpan;
|
||||||
|
if (lvlMatch) {
|
||||||
|
titleSpan = el('span', { role: 'heading', 'aria-level': String(lvlMatch[1].length) });
|
||||||
|
titleSpan.textContent = item.title || '';
|
||||||
|
} else {
|
||||||
|
titleSpan = el('span', { textContent: item.title || '' });
|
||||||
|
}
|
||||||
|
btn.appendChild(titleSpan);
|
||||||
|
strip.appendChild(btn);
|
||||||
|
|
||||||
|
var panel = el('div', { className: 'mdcms-tabs-panel', role: 'tabpanel' });
|
||||||
|
panel.innerHTML = renderMarkdown(String(item.content || ''));
|
||||||
|
if (!isSelected) panel.setAttribute('hidden', '');
|
||||||
|
panels.push(panel);
|
||||||
|
|
||||||
|
btn.addEventListener('click', (function(idx) {
|
||||||
|
return function() {
|
||||||
|
strip.querySelectorAll('.mdcms-tab-btn').forEach(function(b, j) {
|
||||||
|
b.setAttribute('aria-selected', String(j === idx));
|
||||||
|
if (j === idx) panels[j].removeAttribute('hidden');
|
||||||
|
else panels[j].setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})(i));
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.appendChild(strip);
|
||||||
|
panels.forEach(function(p) { wrapper.appendChild(p); });
|
||||||
|
container.replaceWith(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAccordionTag(container, cfg) {
|
||||||
|
var variant = cfg.tagName === 'accordion' ? 'accordion-underline' : cfg.tagName;
|
||||||
|
var isFilled = variant === 'accordion-filled';
|
||||||
|
var varClass = isFilled ? 'filled' : 'underline';
|
||||||
|
|
||||||
|
var items = [];
|
||||||
|
try {
|
||||||
|
var rawYaml = cfg.rawBody !== undefined ? cfg.rawBody : ('items:\n' + (cfg.body || ''));
|
||||||
|
var parsed = jsyaml.load(rawYaml);
|
||||||
|
items = (parsed && parsed.items) || [];
|
||||||
|
} catch (e) {
|
||||||
|
container.textContent = 'Error parsing accordion items.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!items.length) { container.textContent = 'No accordion items.'; return; }
|
||||||
|
|
||||||
|
var CHEVRON_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||||
|
|
||||||
|
var wrapper = el('div', { className: 'mdcms-accordion mdcms-accordion-' + varClass });
|
||||||
|
|
||||||
|
items.forEach(function(item) {
|
||||||
|
if (!item) return;
|
||||||
|
var isOpen = item.default === 'open';
|
||||||
|
|
||||||
|
var itemEl = el('div', { className: 'mdcms-accordion-item' });
|
||||||
|
itemEl.setAttribute('data-open', String(isOpen));
|
||||||
|
|
||||||
|
var btn = el('button', {
|
||||||
|
className: 'mdcms-accordion-btn',
|
||||||
|
type: 'button',
|
||||||
|
'aria-expanded': String(isOpen)
|
||||||
|
});
|
||||||
|
|
||||||
|
var titleStyle = item['title-style'] || '';
|
||||||
|
var lvlMatch = titleStyle.match(/^(#{1,6})$/);
|
||||||
|
var titleEl = el('span', { className: 'mdcms-accordion-title' });
|
||||||
|
if (lvlMatch) {
|
||||||
|
var heading = el('span', { role: 'heading', 'aria-level': String(lvlMatch[1].length) });
|
||||||
|
heading.textContent = item.title || '';
|
||||||
|
titleEl.appendChild(heading);
|
||||||
|
} else {
|
||||||
|
titleEl.textContent = item.title || '';
|
||||||
|
}
|
||||||
|
btn.appendChild(titleEl);
|
||||||
|
|
||||||
|
var chevron = el('span', { className: 'mdcms-accordion-chevron' });
|
||||||
|
chevron.innerHTML = CHEVRON_SVG;
|
||||||
|
btn.appendChild(chevron);
|
||||||
|
|
||||||
|
var body = el('div', { className: 'mdcms-accordion-body' });
|
||||||
|
body.innerHTML = renderMarkdown(String(item.content || ''));
|
||||||
|
if (!isOpen) body.setAttribute('hidden', '');
|
||||||
|
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var open = itemEl.getAttribute('data-open') === 'true';
|
||||||
|
var next = !open;
|
||||||
|
itemEl.setAttribute('data-open', String(next));
|
||||||
|
btn.setAttribute('aria-expanded', String(next));
|
||||||
|
if (next) body.removeAttribute('hidden');
|
||||||
|
else body.setAttribute('hidden', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
itemEl.appendChild(btn);
|
||||||
|
itemEl.appendChild(body);
|
||||||
|
wrapper.appendChild(itemEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.replaceWith(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
function hydrateMdcmsTags() {
|
function hydrateMdcmsTags() {
|
||||||
document.querySelectorAll('.mdcms-tag').forEach(function(tagEl) {
|
document.querySelectorAll('.mdcms-tag').forEach(function(tagEl) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -2020,6 +2391,10 @@ function fmtDatetime(dtStr) {
|
||||||
renderCalloutTag(tagEl, cfg);
|
renderCalloutTag(tagEl, cfg);
|
||||||
} else if (cfg.tagName === 'toc') {
|
} else if (cfg.tagName === 'toc') {
|
||||||
renderTocTag(tagEl);
|
renderTocTag(tagEl);
|
||||||
|
} else if (/^tab(-underline|-filled)?$/.test(cfg.tagName)) {
|
||||||
|
renderTabsTag(tagEl, cfg);
|
||||||
|
} else if (/^accordion(-underline|-filled)?$/.test(cfg.tagName)) {
|
||||||
|
renderAccordionTag(tagEl, cfg);
|
||||||
} else {
|
} else {
|
||||||
renderPostTag(tagEl, cfg);
|
renderPostTag(tagEl, cfg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,7 @@ pages:
|
||||||
- file: pages/docs.md
|
- file: pages/docs.md
|
||||||
title: Docs
|
title: Docs
|
||||||
sort: 300
|
sort: 300
|
||||||
|
|
||||||
|
- file: pages/tabs-accordions.md
|
||||||
|
title: Tabs & Accordions
|
||||||
|
sort: 400
|
||||||
|
|
|
||||||
78
app/pages/tabs-accordions.md
Normal file
78
app/pages/tabs-accordions.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
title: Tabs & Accordions
|
||||||
|
sort: 400
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tabs & Accordions
|
||||||
|
|
||||||
|
## Tab — Underline variant
|
||||||
|
|
||||||
|
```mdcms tab-underline
|
||||||
|
items:
|
||||||
|
- title: Install
|
||||||
|
default: selected
|
||||||
|
content: |
|
||||||
|
Install with `npm i mdcms` or `pnpm add mdcms`.
|
||||||
|
- title: Configure
|
||||||
|
content: |
|
||||||
|
Drop a `mdcms.config.yaml` next to your content folder.
|
||||||
|
- title: Deploy
|
||||||
|
content: |
|
||||||
|
Any static host. The build emits plain HTML.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tab — Filled variant
|
||||||
|
|
||||||
|
```mdcms tab-filled
|
||||||
|
items:
|
||||||
|
- title: Overview
|
||||||
|
default: selected
|
||||||
|
content: |
|
||||||
|
MD-CMS is a markdown-based static site system with no build step.
|
||||||
|
- title: Features
|
||||||
|
content: |
|
||||||
|
- Sidebar navigation with sections
|
||||||
|
- Full-text search via Fuse.js
|
||||||
|
- PWA support with offline caching
|
||||||
|
- Dark / light theme toggle
|
||||||
|
- title: Architecture
|
||||||
|
content: |
|
||||||
|
Two parts: `mdcms.py` (CLI) and `app/index.html` (browser renderer).
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accordion — Underline variant
|
||||||
|
|
||||||
|
```mdcms accordion-underline
|
||||||
|
items:
|
||||||
|
- title: What is MD-CMS?
|
||||||
|
default: open
|
||||||
|
content: |
|
||||||
|
MD-CMS is a single-file browser renderer that reads markdown, config,
|
||||||
|
and nav at runtime entirely client-side. No build pipeline, no compilation.
|
||||||
|
- title: How do I install it?
|
||||||
|
content: |
|
||||||
|
Run `pip install mdcms` or download a binary from the GitHub releases page.
|
||||||
|
- title: Does it work offline?
|
||||||
|
content: |
|
||||||
|
Yes — run `mdcms fetch-deps` to bundle all vendor assets locally, then
|
||||||
|
enable `pwa: yes` in `config.yml` for full offline support.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accordion — Filled variant
|
||||||
|
|
||||||
|
```mdcms accordion-filled
|
||||||
|
items:
|
||||||
|
- title: Can I use custom themes?
|
||||||
|
default: open
|
||||||
|
content: |
|
||||||
|
Yes. Create a `theme.yml` file and point to it with `theme: theme.yml` in
|
||||||
|
your `config.yml`. The theme controls colours, fonts, and layout.
|
||||||
|
- title: What markdown features are supported?
|
||||||
|
content: |
|
||||||
|
GFM (GitHub Flavored Markdown): tables, task lists, fenced code blocks,
|
||||||
|
strikethrough, and autolinks. Syntax highlighting via highlight.js.
|
||||||
|
- title: Can I nest categories?
|
||||||
|
content: |
|
||||||
|
Categories are flat (no nesting), but nav sections support a `parent:`
|
||||||
|
key for two-level sidebar grouping.
|
||||||
|
```
|
||||||
|
|
@ -34,5 +34,17 @@
|
||||||
"modified": "",
|
"modified": "",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"body": "# Phase 7 — PWA Test\n\nThis page verifies the service worker and manifest generated by `mdcms build` when `pwa: yes` is set in `config.yml`.\n\n## Test procedure\n\n1. Run `python3 mdcms.py build --path app/` — confirm `manifest.json` and `service-worker.js` appear in `app/`\n2. Load `http://localhost:8800` — service worker registers on first load\n3. Navigate to the **About** and **Docs** pages so they are fetched and cached\n4. Stop the HTTP server (`Ctrl+C` in its terminal)\n5. Reload — site should load fully from the service worker cache\n6. Navigate between pages — all should work offline\n7. Check that a page not yet visited shows the offline message\n\n## What to look for\n\n- `manifest.json` and `service-worker.js` exist after build\n- DevTools → Application → Service Workers: status **activated and running**\n- DevTools → Application → Cache Storage: cache named `mdcms-xxxxxxxx` with all files listed\n- Site loads fully with server stopped\n- Offline message (`config.yml: offline-message`) appears for uncached pages\n"
|
"body": "# Phase 7 — PWA Test\n\nThis page verifies the service worker and manifest generated by `mdcms build` when `pwa: yes` is set in `config.yml`.\n\n## Test procedure\n\n1. Run `python3 mdcms.py build --path app/` — confirm `manifest.json` and `service-worker.js` appear in `app/`\n2. Load `http://localhost:8800` — service worker registers on first load\n3. Navigate to the **About** and **Docs** pages so they are fetched and cached\n4. Stop the HTTP server (`Ctrl+C` in its terminal)\n5. Reload — site should load fully from the service worker cache\n6. Navigate between pages — all should work offline\n7. Check that a page not yet visited shows the offline message\n\n## What to look for\n\n- `manifest.json` and `service-worker.js` exist after build\n- DevTools → Application → Service Workers: status **activated and running**\n- DevTools → Application → Cache Storage: cache named `mdcms-xxxxxxxx` with all files listed\n- Site loads fully with server stopped\n- Offline message (`config.yml: offline-message`) appears for uncached pages\n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "pages/tabs-accordions.md",
|
||||||
|
"title": "Tabs & Accordions",
|
||||||
|
"section-id": null,
|
||||||
|
"keywords": "",
|
||||||
|
"description": "",
|
||||||
|
"author": null,
|
||||||
|
"created": "",
|
||||||
|
"modified": "",
|
||||||
|
"language": "en",
|
||||||
|
"body": "# Tabs & Accordions\n\n## Tab — Underline variant\n\n```mdcms tab-underline\nitems:\n - title: Install\n default: selected\n content: |\n Install with `npm i mdcms` or `pnpm add mdcms`.\n - title: Configure\n content: |\n Drop a `mdcms.config.yaml` next to your content folder.\n - title: Deploy\n content: |\n Any static host. The build emits plain HTML.\n```\n\n## Tab — Filled variant\n\n```mdcms tab-filled\nitems:\n - title: Overview\n default: selected\n content: |\n MD-CMS is a markdown-based static site system with no build step.\n - title: Features\n content: |\n - Sidebar navigation with sections\n - Full-text search via Fuse.js\n - PWA support with offline caching\n - Dark / light theme toggle\n - title: Architecture\n content: |\n Two parts: `mdcms.py` (CLI) and `app/index.html` (browser renderer).\n```\n\n## Accordion — Underline variant\n\n```mdcms accordion-underline\nitems:\n - title: What is MD-CMS?\n default: open\n content: |\n MD-CMS is a single-file browser renderer that reads markdown, config,\n and nav at runtime entirely client-side. No build pipeline, no compilation.\n - title: How do I install it?\n content: |\n Run `pip install mdcms` or download a binary from the GitHub releases page.\n - title: Does it work offline?\n content: |\n Yes — run `mdcms fetch-deps` to bundle all vendor assets locally, then\n enable `pwa: yes` in `config.yml` for full offline support.\n```\n\n## Accordion — Filled variant\n\n```mdcms accordion-filled\nitems:\n - title: Can I use custom themes?\n default: open\n content: |\n Yes. Create a `theme.yml` file and point to it with `theme: theme.yml` in\n your `config.yml`. The theme controls colours, fonts, and layout.\n - title: What markdown features are supported?\n content: |\n GFM (GitHub Flavored Markdown): tables, task lists, fenced code blocks,\n strikethrough, and autolinks. Syntax highlighting via highlight.js.\n - title: Can I nest categories?\n content: |\n Categories are flat (no nesting), but nav sections support a `parent:`\n key for two-level sidebar grouping.\n```\n"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// mdcms service worker — generated by mdcms build
|
// mdcms service worker — generated by mdcms build
|
||||||
const CACHE_NAME = 'mdcms-eb384247';
|
const CACHE_NAME = 'mdcms-a1862733';
|
||||||
const PRECACHE_URLS = [
|
const PRECACHE_URLS = [
|
||||||
"index.html",
|
"index.html",
|
||||||
"config.yml",
|
"config.yml",
|
||||||
|
|
@ -9,20 +9,29 @@ const PRECACHE_URLS = [
|
||||||
"pages/about.md",
|
"pages/about.md",
|
||||||
"pages/docs.md",
|
"pages/docs.md",
|
||||||
"pages/home.md",
|
"pages/home.md",
|
||||||
|
"pages/tabs-accordions.md",
|
||||||
"posts/.gitkeep",
|
"posts/.gitkeep",
|
||||||
"assets/fonts/.gitkeep",
|
"assets/fonts/.gitkeep",
|
||||||
"assets/icons/.gitkeep",
|
"assets/icons/.gitkeep",
|
||||||
|
"assets/icons/add.svg",
|
||||||
"assets/icons/arrow_drop_down.svg",
|
"assets/icons/arrow_drop_down.svg",
|
||||||
"assets/icons/arrow_right.svg",
|
"assets/icons/arrow_right.svg",
|
||||||
|
"assets/icons/collapse_content.svg",
|
||||||
"assets/icons/dangerous.svg",
|
"assets/icons/dangerous.svg",
|
||||||
"assets/icons/dark_mode.svg",
|
"assets/icons/dark_mode.svg",
|
||||||
"assets/icons/error.svg",
|
"assets/icons/error.svg",
|
||||||
"assets/icons/exclamation.svg",
|
"assets/icons/exclamation.svg",
|
||||||
|
"assets/icons/expand_content.svg",
|
||||||
"assets/icons/history.svg",
|
"assets/icons/history.svg",
|
||||||
"assets/icons/info.svg",
|
"assets/icons/info.svg",
|
||||||
|
"assets/icons/keyboard_arrow_down.svg",
|
||||||
|
"assets/icons/keyboard_arrow_right.svg",
|
||||||
|
"assets/icons/keyboard_double_arrow_down.svg",
|
||||||
|
"assets/icons/keyboard_double_arrow_right.svg",
|
||||||
"assets/icons/language.svg",
|
"assets/icons/language.svg",
|
||||||
"assets/icons/light_mode.svg",
|
"assets/icons/light_mode.svg",
|
||||||
"assets/icons/menu.svg",
|
"assets/icons/menu.svg",
|
||||||
|
"assets/icons/minimize.svg",
|
||||||
"assets/icons/mobile_arrow_down.svg",
|
"assets/icons/mobile_arrow_down.svg",
|
||||||
"assets/icons/report.svg",
|
"assets/icons/report.svg",
|
||||||
"assets/icons/search.svg",
|
"assets/icons/search.svg",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,144 @@ Changes merged into `development` that have not yet been released to `main`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Tabs & Accordions (`app/index.html`)
|
||||||
|
|
||||||
|
Four new `mdcms` fenced-block types for rich content layout. All four variants read from the active theme automatically — no new config keys, no per-theme overrides needed.
|
||||||
|
|
||||||
|
### Block types
|
||||||
|
|
||||||
|
| Language tag | Alias for | Renders as |
|
||||||
|
|---|---|---|
|
||||||
|
| `tab-underline` | — | Tab strip, active tab marked with underline |
|
||||||
|
| `tab` | `tab-underline` | (same) |
|
||||||
|
| `tab-filled` | — | Tab strip, tabs as filled chips |
|
||||||
|
| `accordion-underline` | — | Stacked accordion, header underline style |
|
||||||
|
| `accordion` | `accordion-underline` | (same) |
|
||||||
|
| `accordion-filled` | — | Stacked accordion, filled card style |
|
||||||
|
|
||||||
|
### Authoring syntax
|
||||||
|
|
||||||
|
Open a fenced block with the language tag `mdcms <type>`. The body is YAML with a single top-level key `items:`, whose value is a list of item objects.
|
||||||
|
|
||||||
|
~~~markdown
|
||||||
|
```mdcms tab-underline
|
||||||
|
items:
|
||||||
|
- title: Install
|
||||||
|
default: selected
|
||||||
|
content: |
|
||||||
|
Install with `npm i mdcms` or `pnpm add mdcms`.
|
||||||
|
- title: Configure
|
||||||
|
content: |
|
||||||
|
Drop a `mdcms.config.yaml` next to your content folder.
|
||||||
|
- title: Deploy
|
||||||
|
content: |
|
||||||
|
Any static host. The build emits plain HTML.
|
||||||
|
```
|
||||||
|
~~~
|
||||||
|
|
||||||
|
### Per-item keys
|
||||||
|
|
||||||
|
| Key | Required | Type | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `title` | yes | plain string | Label shown on the tab button or accordion header. Plain text only — no Markdown. |
|
||||||
|
| `content` | yes | Markdown block | Body content. Use the YAML literal block scalar (`\|`) for multi-line Markdown. Rendered with the same pipeline as the surrounding page (GFM, syntax highlighting, internal links). |
|
||||||
|
| `default` | no | string | **Tabs:** `selected` marks the tab that is open on load; if no item has `selected`, the first item is used. `notselected` (or omitting the key) leaves the tab inactive. Exactly one tab should be `selected`. **Accordions:** `open` makes the item expanded on load; `closed` (or omitting) leaves it collapsed. Any number of accordion items may be `open`. |
|
||||||
|
| `title-style` | no | string | Heading level for screen readers and external TOC tools. One of `"#"`, `"##"`, `"###"`, `"####"`, `"#####"`, `"######"`, or `""` (default). Visual size is always fixed by the component — this only changes the underlying ARIA role and level. Use a value when you want the item to be picked up as a heading by assistive technology. |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Tabs — underline (default)**
|
||||||
|
|
||||||
|
~~~markdown
|
||||||
|
```mdcms tab
|
||||||
|
items:
|
||||||
|
- title: npm
|
||||||
|
default: selected
|
||||||
|
content: |
|
||||||
|
```bash
|
||||||
|
npm install mdcms
|
||||||
|
```
|
||||||
|
- title: pnpm
|
||||||
|
content: |
|
||||||
|
```bash
|
||||||
|
pnpm add mdcms
|
||||||
|
```
|
||||||
|
- title: yarn
|
||||||
|
content: |
|
||||||
|
```bash
|
||||||
|
yarn add mdcms
|
||||||
|
```
|
||||||
|
```
|
||||||
|
~~~
|
||||||
|
|
||||||
|
**Tabs — filled chips**
|
||||||
|
|
||||||
|
~~~markdown
|
||||||
|
```mdcms tab-filled
|
||||||
|
items:
|
||||||
|
- title: Overview
|
||||||
|
default: selected
|
||||||
|
content: |
|
||||||
|
MD-CMS is a markdown-based static site system with no build step.
|
||||||
|
- title: Features
|
||||||
|
content: |
|
||||||
|
- Sidebar navigation
|
||||||
|
- Full-text search
|
||||||
|
- PWA + offline support
|
||||||
|
- Dark / light theme
|
||||||
|
```
|
||||||
|
~~~
|
||||||
|
|
||||||
|
**Accordion — underline (default)**
|
||||||
|
|
||||||
|
~~~markdown
|
||||||
|
```mdcms accordion
|
||||||
|
items:
|
||||||
|
- title: What is MD-CMS?
|
||||||
|
default: open
|
||||||
|
content: |
|
||||||
|
A single-file browser renderer. No build pipeline, no compilation,
|
||||||
|
no server required.
|
||||||
|
- title: How do I install it?
|
||||||
|
content: |
|
||||||
|
Run `pip install mdcms` or download a binary from the GitHub releases page.
|
||||||
|
- title: Does it work offline?
|
||||||
|
content: |
|
||||||
|
Yes — run `mdcms fetch-deps` to bundle vendor assets locally, then enable
|
||||||
|
`pwa: yes` in `config.yml` for full offline support.
|
||||||
|
```
|
||||||
|
~~~
|
||||||
|
|
||||||
|
**Accordion — filled cards**
|
||||||
|
|
||||||
|
~~~markdown
|
||||||
|
```mdcms accordion-filled
|
||||||
|
items:
|
||||||
|
- title: Can I use custom themes?
|
||||||
|
default: open
|
||||||
|
content: |
|
||||||
|
Yes. Create a `theme.yml` and reference it with `theme: theme.yml` in
|
||||||
|
`config.yml`. The theme controls colours, fonts, and layout.
|
||||||
|
- title: title-style example
|
||||||
|
title-style: "##"
|
||||||
|
content: |
|
||||||
|
This header is announced as an `<h2>` to screen readers, even though
|
||||||
|
its visual size is set by the accordion component.
|
||||||
|
```
|
||||||
|
~~~
|
||||||
|
|
||||||
|
### How the appearance adapts to themes
|
||||||
|
|
||||||
|
The components derive their fill colours and bar/border colours from the active theme at runtime. No new keys in `config.yml` or `theme.yml` are needed.
|
||||||
|
|
||||||
|
**Bold themes** (nav background is visually distinct from the page — e.g. a dark sidebar on a light page, or a coloured nav like red or navy): filled tabs and accordion headers use the nav background colour as their fill; the bar/border uses the nav colour. This makes the components look like an extension of the sidebar chrome.
|
||||||
|
|
||||||
|
**Subtle themes** (nav background is almost identical to the page — e.g. both near-white or both near-dark): filled tabs use a light tint of the accent colour; the bar and border use the accent colour directly. This keeps the components visible without a strong nav background to borrow from.
|
||||||
|
|
||||||
|
The switch between bold and subtle is automatic. The algorithm uses HSL chroma (`S × (1−|2L−1|)`) rather than raw HSL saturation, which would give false "bold" readings for near-white or near-black nav backgrounds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## `mdcms build` patches `<title>` with sitename
|
## `mdcms build` patches `<title>` with sitename
|
||||||
|
|
||||||
`mdcms build` now rewrites the `<title>` tag in `index.html` with the value of `sitename` from `config.yml`. Previously the tag was hardcoded (`MD-CMS`) in older templates, or blank in the starter template, so link previews in WhatsApp, Slack, and other crawlers that read static HTML showed the wrong name.
|
`mdcms build` now rewrites the `<title>` tag in `index.html` with the value of `sitename` from `config.yml`. Previously the tag was hardcoded (`MD-CMS`) in older templates, or blank in the starter template, so link previews in WhatsApp, Slack, and other crawlers that read static HTML showed the wrong name.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue