2026-04-05 18:31:16 -07:00
import React , { CSSProperties , useState } from 'react' ;
import { useNode , UserComponent } from '@craftjs/core' ;
import { cssPropsToString } from '../../utils/style-helpers' ;
2026-07-12 11:49:10 -07:00
import { escapeHtml , escapeAttr } from '../../utils/escape' ;
2026-04-05 18:31:16 -07:00
interface TabItem {
label : string ;
content : string ;
}
interface TabsProps {
tabs? : TabItem [];
style? : CSSProperties ;
activeTabBg? : string ;
activeTabColor? : string ;
inactiveTabBg? : string ;
inactiveTabColor? : string ;
contentBg? : string ;
2026-05-25 12:43:28 -07:00
anchorId? : string ;
2026-04-05 18:31:16 -07:00
}
const defaultTabs : TabItem [] = [
{ label : 'Overview' , content : 'Welcome to our platform. We provide the tools you need to build, launch, and grow your online presence. Our intuitive interface makes it simple to get started in minutes.' },
{ label : 'Features' , content : 'Drag-and-drop editor, responsive templates, custom domains, analytics dashboard, SEO tools, and integrations with your favorite services. Everything you need in one place.' },
{ label : 'Support' , content : 'Our dedicated support team is available 24/7 to help you with any questions. Access our knowledge base, community forums, or reach out directly via live chat or email.' },
];
export const Tabs : UserComponent < TabsProps > = ({
tabs = defaultTabs ,
style = {},
activeTabBg = '#3b82f6' ,
activeTabColor = '#ffffff' ,
inactiveTabBg = '#f1f5f9' ,
inactiveTabColor = '#64748b' ,
contentBg = '#ffffff' ,
2026-05-25 12:43:28 -07:00
anchorId ,
2026-04-05 18:31:16 -07:00
}) => {
const {
connectors : { connect , drag },
selected ,
} = useNode (( node ) => ({
selected : node.events.selected ,
}));
const [ activeIndex , setActiveIndex ] = useState ( 0 );
return (
< section
ref = {( ref : HTMLElement | null ) : void => { if ( ref ) connect ( drag ( ref )); }}
2026-05-25 12:43:28 -07:00
id = { anchorId || undefined }
2026-04-05 18:31:16 -07:00
style = {{
padding : '60px 20px' ,
backgroundColor : '#ffffff' ,
outline : selected ? '2px solid #3b82f6' : 'none' ,
... style ,
}}
>
< div style = {{ maxWidth : '800px' , margin : '0 auto' }}>
{ /* Tab buttons */ }
< div style = {{ display : 'flex' , gap : '2px' , borderBottom : '2px solid #e2e8f0' }}>
{ tabs . map (( tab , i ) => (
< button
key = { i }
onClick = {() => setActiveIndex ( i )}
style = {{
padding : '12px 24px' ,
fontSize : '14px' ,
fontWeight : '600' ,
border : 'none' ,
borderTopLeftRadius : '8px' ,
borderTopRightRadius : '8px' ,
cursor : 'pointer' ,
backgroundColor : i === activeIndex ? activeTabBg : inactiveTabBg ,
color : i === activeIndex ? activeTabColor : inactiveTabColor ,
transition : 'background-color 0.2s, color 0.2s' ,
}}
>
{ tab . label }
</ button >
))}
</ div >
{ /* Content panel */ }
< div
style = {{
padding : '24px' ,
backgroundColor : contentBg ,
border : '1px solid #e2e8f0' ,
borderTop : 'none' ,
borderBottomLeftRadius : '8px' ,
borderBottomRightRadius : '8px' ,
fontSize : '14px' ,
lineHeight : '1.7' ,
color : '#4b5563' ,
minHeight : '100px' ,
}}
>
{ tabs [ activeIndex ] ? . content || '' }
</ div >
</ div >
</ section >
);
};
/* ---------- Craft config ---------- */
Tabs . craft = {
displayName : 'Tabs' ,
props : {
tabs : defaultTabs ,
style : { backgroundColor : '#ffffff' },
activeTabBg : '#3b82f6' ,
activeTabColor : '#ffffff' ,
inactiveTabBg : '#f1f5f9' ,
inactiveTabColor : '#64748b' ,
contentBg : '#ffffff' ,
2026-05-25 12:43:28 -07:00
anchorId : '' ,
2026-04-05 18:31:16 -07:00
},
rules : {
canDrag : () => true ,
canMoveIn : () => false ,
canMoveOut : () => true ,
},
};
/* ---------- HTML export ---------- */
2026-07-12 14:18:49 -07:00
/**
* 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 );
}
2026-04-05 18:31:16 -07:00
( Tabs as any ). toHtml = ( props : TabsProps , _childrenHtml : string ) => {
const sectionStyle = cssPropsToString ({
padding : '60px 20px' ,
... props . style ,
});
2026-07-12 11:49:10 -07:00
const idAttr = props . anchorId ? ` id=" ${ escapeAttr ( props . anchorId ) } "` : '' ;
2026-04-05 18:31:16 -07:00
const tabs = props . tabs || defaultTabs ;
const activeTabBg = props . activeTabBg || '#3b82f6' ;
const activeTabColor = props . activeTabColor || '#ffffff' ;
const inactiveTabBg = props . inactiveTabBg || '#f1f5f9' ;
const inactiveTabColor = props . inactiveTabColor || '#64748b' ;
const contentBg = props . contentBg || '#ffffff' ;
2026-07-12 14:18:49 -07:00
// 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 );
2026-04-05 18:31:16 -07:00
const tabButtons = tabs . map (( tab , i ) => {
const isActive = i === 0 ;
2026-07-12 14:18:49 -07:00
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>` ;
2026-04-05 18:31:16 -07:00
}). join ( '\n ' );
const tabPanels = tabs . map (( tab , i ) => {
2026-07-12 14:18:49 -07:00
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>` ;
2026-04-05 18:31:16 -07:00
}). join ( '\n ' );
const switchScript = `<script>
function ${ tabId } _switch(idx){
var total= ${ tabs . length } ;
for(var i=0;i<total;i++){
document.getElementById(' ${ tabId } _panel_'+i).style.display=i===idx?'':'none';
var btn=document.getElementById(' ${ tabId } _btn_'+i);
btn.style.backgroundColor=i===idx?' ${ activeTabBg } ':' ${ inactiveTabBg } ';
btn.style.color=i===idx?' ${ activeTabColor } ':' ${ inactiveTabColor } ';
2026-07-12 14:18:49 -07:00
btn.setAttribute('aria-selected', i===idx ? 'true' : 'false');
btn.setAttribute('tabindex', i===idx ? '0' : '-1');
2026-04-05 18:31:16 -07:00
}
}
2026-07-12 14:18:49 -07:00
(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);
}
})();
2026-04-05 18:31:16 -07:00
</script>` ;
return {
2026-05-25 12:43:28 -07:00
html : `<section ${ idAttr }${ sectionStyle ? ` style=" ${ sectionStyle } "` : '' } >
2026-04-05 18:31:16 -07:00
<div style="max-width:800px;margin:0 auto">
2026-07-12 14:18:49 -07:00
<div role="tablist" style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
2026-04-05 18:31:16 -07:00
${ tabButtons }
</div>
${ tabPanels }
</div>
${ switchScript }
</section>` ,
};
};