a11y: exported widget ARIA (slider/tabs/gallery)
ContentSlider, Tabs, and Gallery toHtml exports and their inline scripts now carry ARIA semantics and keyboard support: - ContentSlider: prev/next arrows and dot buttons get aria-labels, the slide stack is wrapped in an aria-live="polite" region, and the decorative chevron icons are aria-hidden. - Tabs: tablist/tab/tabpanel roles, aria-selected/aria-controls/ aria-labelledby, and Left/Right/Home/End arrow-key navigation in the inline script. The tab<->panel id scope is now derived from a deterministic hash of anchorId/labels instead of Math.random, since the ARIA linking ids must be stable across export runs. - Gallery: the lightbox overlay gets role="dialog"/aria-modal/aria-label, Escape closes it, and thumbnails are keyboard-operable (role="button" tabindex="0" plus Enter/Space handling in the existing delegated listener). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -124,6 +124,19 @@ Tabs.craft = {
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
/**
|
||||
* Deterministic (non-cryptographic) string hash -- djb2 -- used to derive a
|
||||
* stable id scope from existing prop data instead of Math.random/Date.now.
|
||||
* Same input always produces the same output across export runs.
|
||||
*/
|
||||
function stableHash(seed: string): string {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h = ((h << 5) + h + seed.charCodeAt(i)) | 0;
|
||||
}
|
||||
return (h >>> 0).toString(36);
|
||||
}
|
||||
|
||||
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
@@ -137,15 +150,22 @@ Tabs.craft = {
|
||||
const inactiveTabColor = props.inactiveTabColor || '#64748b';
|
||||
const contentBg = props.contentBg || '#ffffff';
|
||||
|
||||
const tabId = 'tabs_' + Math.random().toString(36).slice(2, 8);
|
||||
// tabId scopes the functional wiring (onclick/getElementById) as well as
|
||||
// the ARIA tab<->panel linking ids. It must be deterministic (no
|
||||
// Math.random) because aria-controls/aria-labelledby need to reference
|
||||
// the SAME id across repeated exports of the same content -- derived from
|
||||
// anchorId when present, otherwise a stable hash of the tab labels (pure
|
||||
// function of the props, not time/randomness).
|
||||
const scopeSeed = props.anchorId || tabs.map((t) => t.label).join('|') + '::' + tabs.length;
|
||||
const tabId = 'tabs_' + stableHash(scopeSeed);
|
||||
|
||||
const tabButtons = tabs.map((tab, i) => {
|
||||
const isActive = i === 0;
|
||||
return `<button onclick="${tabId}_switch(${i})" id="${tabId}_btn_${i}" style="padding:12px 24px;font-size:14px;font-weight:600;border:none;border-top-left-radius:8px;border-top-right-radius:8px;cursor:pointer;background-color:${isActive ? activeTabBg : inactiveTabBg};color:${isActive ? activeTabColor : inactiveTabColor}">${escapeHtml(tab.label)}</button>`;
|
||||
return `<button onclick="${tabId}_switch(${i})" id="${tabId}_btn_${i}" role="tab" aria-selected="${isActive ? 'true' : 'false'}" aria-controls="${tabId}_panel_${i}" tabindex="${isActive ? '0' : '-1'}" style="padding:12px 24px;font-size:14px;font-weight:600;border:none;border-top-left-radius:8px;border-top-right-radius:8px;cursor:pointer;background-color:${isActive ? activeTabBg : inactiveTabBg};color:${isActive ? activeTabColor : inactiveTabColor}">${escapeHtml(tab.label)}</button>`;
|
||||
}).join('\n ');
|
||||
|
||||
const tabPanels = tabs.map((tab, i) => {
|
||||
return `<div id="${tabId}_panel_${i}" style="padding:24px;background-color:${contentBg};border:1px solid #e2e8f0;border-top:none;border-bottom-left-radius:8px;border-bottom-right-radius:8px;font-size:14px;line-height:1.7;color:#4b5563;min-height:100px;${i !== 0 ? 'display:none' : ''}">${escapeHtml(tab.content)}</div>`;
|
||||
return `<div id="${tabId}_panel_${i}" role="tabpanel" aria-labelledby="${tabId}_btn_${i}" tabindex="0" style="padding:24px;background-color:${contentBg};border:1px solid #e2e8f0;border-top:none;border-bottom-left-radius:8px;border-bottom-right-radius:8px;font-size:14px;line-height:1.7;color:#4b5563;min-height:100px;${i !== 0 ? 'display:none' : ''}">${escapeHtml(tab.content)}</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const switchScript = `<script>
|
||||
@@ -156,14 +176,36 @@ function ${tabId}_switch(idx){
|
||||
var btn=document.getElementById('${tabId}_btn_'+i);
|
||||
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
|
||||
btn.style.color=i===idx?'${activeTabColor}':'${inactiveTabColor}';
|
||||
btn.setAttribute('aria-selected', i===idx ? 'true' : 'false');
|
||||
btn.setAttribute('tabindex', i===idx ? '0' : '-1');
|
||||
}
|
||||
}
|
||||
(function(){
|
||||
var total=${tabs.length};
|
||||
for(var i=0;i<total;i++){
|
||||
(function(idx){
|
||||
var btn=document.getElementById('${tabId}_btn_'+idx);
|
||||
btn.addEventListener('keydown', function(e){
|
||||
var next=null;
|
||||
if(e.key==='ArrowRight'){ next=(idx+1)%total; }
|
||||
else if(e.key==='ArrowLeft'){ next=(idx-1+total)%total; }
|
||||
else if(e.key==='Home'){ next=0; }
|
||||
else if(e.key==='End'){ next=total-1; }
|
||||
if(next!==null){
|
||||
e.preventDefault();
|
||||
${tabId}_switch(next);
|
||||
document.getElementById('${tabId}_btn_'+next).focus();
|
||||
}
|
||||
});
|
||||
})(i);
|
||||
}
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
return {
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:800px;margin:0 auto">
|
||||
<div style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
|
||||
<div role="tablist" style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
|
||||
${tabButtons}
|
||||
</div>
|
||||
${tabPanels}
|
||||
|
||||
Reference in New Issue
Block a user