Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { ContentSlider } from './ContentSlider';
|
||||||
|
|
||||||
|
const toHtml = (ContentSlider as any).toHtml;
|
||||||
|
|
||||||
|
const slides = [
|
||||||
|
{ type: 'image' as const, heading: 'One' },
|
||||||
|
{ type: 'image' as const, heading: 'Two' },
|
||||||
|
{ type: 'image' as const, heading: 'Three' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('ContentSlider.toHtml accessibility (F1.1)', () => {
|
||||||
|
test('prev/next arrows get aria-labels and are real buttons', () => {
|
||||||
|
const { html } = toHtml({ slides, showArrows: true }, '');
|
||||||
|
expect(html).toMatch(/<button[^>]*aria-label="Previous slide"/);
|
||||||
|
expect(html).toMatch(/<button[^>]*aria-label="Next slide"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dot buttons get "Go to slide N" aria-labels', () => {
|
||||||
|
const { html } = toHtml({ slides, showDots: true }, '');
|
||||||
|
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 1"/);
|
||||||
|
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 2"/);
|
||||||
|
expect(html).toMatch(/<button[^>]*aria-label="Go to slide 3"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slides are wrapped in an aria-live="polite" region', () => {
|
||||||
|
const { html } = toHtml({ slides }, '');
|
||||||
|
expect(html).toContain('aria-live="polite"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decorative chevron icons in arrows are aria-hidden', () => {
|
||||||
|
const { html } = toHtml({ slides, showArrows: true }, '');
|
||||||
|
expect(html).toMatch(/<i class="fa fa-chevron-left" aria-hidden="true"><\/i>/);
|
||||||
|
expect(html).toMatch(/<i class="fa fa-chevron-right" aria-hidden="true"><\/i>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -289,19 +289,21 @@ ContentSlider.craft = {
|
|||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
const arrowsHtml = showArrows && items.length > 1
|
const arrowsHtml = showArrows && items.length > 1
|
||||||
? `<button onclick="${uid}_prev()" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left"></i></button>
|
? `<button onclick="${uid}_prev()" aria-label="Previous slide" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left" aria-hidden="true"></i></button>
|
||||||
<button onclick="${uid}_next()" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right"></i></button>`
|
<button onclick="${uid}_next()" aria-label="Next slide" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right" aria-hidden="true"></i></button>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const dotsHtml = showDots && items.length > 1
|
const dotsHtml = showDots && items.length > 1
|
||||||
? `<div style="position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:2">
|
? `<div style="position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:2">
|
||||||
${items.map((_, i) => `<button onclick="${uid}_go(${i})" id="${uid}_d${i}" style="width:10px;height:10px;border-radius:50%;border:none;cursor:pointer;background-color:${i === 0 ? '#ffffff' : 'rgba(255,255,255,0.5)'};transition:background-color 0.3s"></button>`).join('\n ')}
|
${items.map((_, i) => `<button onclick="${uid}_go(${i})" id="${uid}_d${i}" aria-label="Go to slide ${i + 1}" style="width:10px;height:10px;border-radius:50%;border:none;cursor:pointer;background-color:${i === 0 ? '#ffffff' : 'rgba(255,255,255,0.5)'};transition:background-color 0.3s"></button>`).join('\n ')}
|
||||||
</div>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
|
<div aria-live="polite">
|
||||||
${slidesHtml}
|
${slidesHtml}
|
||||||
|
</div>
|
||||||
${arrowsHtml}
|
${arrowsHtml}
|
||||||
${dotsHtml}
|
${dotsHtml}
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -31,3 +31,33 @@ describe('Gallery.toHtml lightbox uses a delegated listener, not per-item onclic
|
|||||||
expect(html).not.toContain('<script>');
|
expect(html).not.toContain('<script>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Gallery.toHtml lightbox accessibility (F1.3)', () => {
|
||||||
|
test('lightbox overlay has role="dialog", aria-modal, and aria-label', () => {
|
||||||
|
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||||
|
expect(html).toMatch(/role="dialog"/);
|
||||||
|
expect(html).toMatch(/aria-modal="true"/);
|
||||||
|
expect(html).toMatch(/aria-label="[^"]+"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Escape closes the lightbox via the inline script', () => {
|
||||||
|
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||||
|
expect(html).toMatch(/Escape/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thumbnails are keyboard-operable when lightbox is enabled (role=button + tabindex=0)', () => {
|
||||||
|
const { html } = toHtml({ images: [{ src: '/a.jpg', alt: 'a' }], lightbox: true }, '');
|
||||||
|
expect(html).toMatch(/data-lb-src="[^"]*"[^>]*role="button"[^>]*tabindex="0"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delegated listener handles Enter/Space for keyboard activation', () => {
|
||||||
|
const { html } = toHtml({ images: [{ src: '/a.jpg' }, { src: '/b.jpg' }], lightbox: true }, '');
|
||||||
|
expect(html).toMatch(/addEventListener\(['"]keydown['"]/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lightbox=false: no role="dialog", no role="button" thumbnails', () => {
|
||||||
|
const { html } = toHtml({ images: [{ src: '/a.jpg' }], lightbox: false }, '');
|
||||||
|
expect(html).not.toContain('role="dialog"');
|
||||||
|
expect(html).not.toContain('role="button"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ Gallery.craft = {
|
|||||||
// inline onclick with an interpolated src -- a single delegated click
|
// inline onclick with an interpolated src -- a single delegated click
|
||||||
// listener below reads it, so a src containing a quote can't break out
|
// listener below reads it, so a src containing a quote can't break out
|
||||||
// of a per-item event-handler string.
|
// of a per-item event-handler string.
|
||||||
const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeUrl(img.src || ''))}"` : '';
|
const lbAttr = lightbox ? ` data-lb-src="${escapeAttr(safeUrl(img.src || ''))}" role="button" tabindex="0"` : '';
|
||||||
const itemStyle = lightbox ? 'cursor:pointer;position:relative;overflow:hidden;border-radius:8px' : 'position:relative;overflow:hidden;border-radius:8px';
|
const itemStyle = lightbox ? 'cursor:pointer;position:relative;overflow:hidden;border-radius:8px' : 'position:relative;overflow:hidden;border-radius:8px';
|
||||||
return `<div${lbAttr} style="${itemStyle}">
|
return `<div${lbAttr} style="${itemStyle}">
|
||||||
<img src="${escapeAttr(safeUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
|
<img src="${escapeAttr(safeUrl(img.src || ''))}" alt="${escapeAttr(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
|
||||||
@@ -157,18 +157,30 @@ Gallery.craft = {
|
|||||||
if (lightbox) {
|
if (lightbox) {
|
||||||
gridIdAttr = ` id="${galleryId}_grid"`;
|
gridIdAttr = ` id="${galleryId}_grid"`;
|
||||||
lightboxHtml = `
|
lightboxHtml = `
|
||||||
<div id="${galleryId}_overlay" onclick="${galleryId}_close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
|
<div id="${galleryId}_overlay" role="dialog" aria-modal="true" aria-label="Image preview" onclick="${galleryId}_close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
|
||||||
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
|
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function ${galleryId}_close(){document.getElementById('${galleryId}_overlay').style.display='none';}
|
function ${galleryId}_close(){document.getElementById('${galleryId}_overlay').style.display='none';}
|
||||||
document.getElementById('${galleryId}_grid').addEventListener('click', function(e){
|
function ${galleryId}_open(src){
|
||||||
var t = e.target.closest('[data-lb-src]');
|
|
||||||
if(!t) return;
|
|
||||||
var src = t.getAttribute('data-lb-src');
|
|
||||||
var o = document.getElementById('${galleryId}_overlay');
|
var o = document.getElementById('${galleryId}_overlay');
|
||||||
document.getElementById('${galleryId}_img').src = src;
|
document.getElementById('${galleryId}_img').src = src;
|
||||||
o.style.display = 'flex';
|
o.style.display = 'flex';
|
||||||
|
}
|
||||||
|
document.getElementById('${galleryId}_grid').addEventListener('click', function(e){
|
||||||
|
var t = e.target.closest('[data-lb-src]');
|
||||||
|
if(!t) return;
|
||||||
|
${galleryId}_open(t.getAttribute('data-lb-src'));
|
||||||
|
});
|
||||||
|
document.getElementById('${galleryId}_grid').addEventListener('keydown', function(e){
|
||||||
|
if(e.key!=='Enter' && e.key!==' ') return;
|
||||||
|
var t = e.target.closest('[data-lb-src]');
|
||||||
|
if(!t) return;
|
||||||
|
e.preventDefault();
|
||||||
|
${galleryId}_open(t.getAttribute('data-lb-src'));
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function(e){
|
||||||
|
if(e.key==='Escape'){ ${galleryId}_close(); }
|
||||||
});
|
});
|
||||||
</script>`;
|
</script>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { Tabs } from './Tabs';
|
||||||
|
|
||||||
|
const toHtml = (Tabs as any).toHtml;
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ label: 'Overview', content: 'Overview content' },
|
||||||
|
{ label: 'Features', content: 'Features content' },
|
||||||
|
{ label: 'Support', content: 'Support content' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Tabs.toHtml accessibility (F1.2)', () => {
|
||||||
|
test('tab button container has role="tablist"', () => {
|
||||||
|
const { html } = toHtml({ tabs }, '');
|
||||||
|
expect(html).toMatch(/role="tablist"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each tab button has role="tab", aria-selected, aria-controls', () => {
|
||||||
|
const { html } = toHtml({ tabs }, '');
|
||||||
|
const buttonMatches = html.match(/<button[^>]*role="tab"[^>]*>/g) || [];
|
||||||
|
expect(buttonMatches.length).toBe(3);
|
||||||
|
expect(html).toMatch(/aria-selected="true"/);
|
||||||
|
expect(html).toMatch(/aria-selected="false"/);
|
||||||
|
expect(html).toMatch(/role="tab"[^>]*aria-controls="[^"]+"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each panel has role="tabpanel" and aria-labelledby matching a tab id', () => {
|
||||||
|
const { html } = toHtml({ tabs }, '');
|
||||||
|
const panelMatches = html.match(/role="tabpanel"/g) || [];
|
||||||
|
expect(panelMatches.length).toBe(3);
|
||||||
|
|
||||||
|
// aria-controls on the first tab button should point at an id that
|
||||||
|
// actually exists as a panel's id.
|
||||||
|
const controlsMatch = html.match(/role="tab"[^>]*aria-controls="([^"]+)"/);
|
||||||
|
expect(controlsMatch).toBeTruthy();
|
||||||
|
const controlledId = controlsMatch![1];
|
||||||
|
expect(html).toContain(`id="${controlledId}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ids linking tab<->panel are deterministic (stable across repeated calls, no randomness)', () => {
|
||||||
|
const { html: html1 } = toHtml({ tabs }, '');
|
||||||
|
const { html: html2 } = toHtml({ tabs }, '');
|
||||||
|
const id1 = html1.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
|
||||||
|
const id2 = html2.match(/role="tab"[^>]*aria-controls="([^"]+)"/)![1];
|
||||||
|
expect(id1).toBe(id2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('arrow-key navigation is wired in the inline script', () => {
|
||||||
|
const { html } = toHtml({ tabs }, '');
|
||||||
|
expect(html).toMatch(/ArrowRight/);
|
||||||
|
expect(html).toMatch(/ArrowLeft/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -124,6 +124,19 @@ Tabs.craft = {
|
|||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- 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) => {
|
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
@@ -137,15 +150,22 @@ Tabs.craft = {
|
|||||||
const inactiveTabColor = props.inactiveTabColor || '#64748b';
|
const inactiveTabColor = props.inactiveTabColor || '#64748b';
|
||||||
const contentBg = props.contentBg || '#ffffff';
|
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 tabButtons = tabs.map((tab, i) => {
|
||||||
const isActive = i === 0;
|
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 ');
|
}).join('\n ');
|
||||||
|
|
||||||
const tabPanels = tabs.map((tab, i) => {
|
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 ');
|
}).join('\n ');
|
||||||
|
|
||||||
const switchScript = `<script>
|
const switchScript = `<script>
|
||||||
@@ -156,14 +176,36 @@ function ${tabId}_switch(idx){
|
|||||||
var btn=document.getElementById('${tabId}_btn_'+i);
|
var btn=document.getElementById('${tabId}_btn_'+i);
|
||||||
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
|
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
|
||||||
btn.style.color=i===idx?'${activeTabColor}':'${inactiveTabColor}';
|
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>`;
|
</script>`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
<div style="max-width:800px;margin:0 auto">
|
<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}
|
${tabButtons}
|
||||||
</div>
|
</div>
|
||||||
${tabPanels}
|
${tabPanels}
|
||||||
|
|||||||
Reference in New Issue
Block a user