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-05-25 12:43:28 -07:00
import { AnchorIdField } from '../../ui/AnchorIdField' ;
2026-04-05 18:31:16 -07:00
interface AccordionItem {
title : string ;
content : string ;
isOpen? : boolean ;
}
interface AccordionProps {
items? : AccordionItem [];
style? : CSSProperties ;
headerBg? : string ;
headerColor? : string ;
contentBg? : string ;
borderColor? : string ;
2026-05-25 12:43:28 -07:00
anchorId? : string ;
2026-04-05 18:31:16 -07:00
}
const defaultItems : AccordionItem [] = [
{ title : 'What is this product?' , content : 'Our product is a powerful yet easy-to-use tool designed to help you build beautiful websites without writing a single line of code.' , isOpen : true },
{ title : 'How do I get started?' , content : 'Simply sign up for a free account, choose a template, and start customizing. Our drag-and-drop editor makes it easy to create professional pages in minutes.' , isOpen : false },
{ title : 'Is there a free plan?' , content : 'Yes! We offer a generous free tier that includes all core features. Upgrade anytime to unlock advanced capabilities like custom domains and analytics.' , isOpen : false },
];
export const Accordion : UserComponent < AccordionProps > = ({
items = defaultItems ,
style = {},
headerBg = '#f8fafc' ,
headerColor = '#18181b' ,
contentBg = '#ffffff' ,
borderColor = '#e2e8f0' ,
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 [ openIndexes , setOpenIndexes ] = useState < Set < number >>(() => {
const initial = new Set < number >();
items . forEach (( item , i ) => { if ( item . isOpen ) initial . add ( i ); });
return initial ;
});
const toggle = ( index : number ) => {
setOpenIndexes (( prev ) => {
const next = new Set ( prev );
if ( next . has ( index )) next . delete ( index );
else next . add ( index );
return next ;
});
};
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' , display : 'flex' , flexDirection : 'column' , gap : '0px' }}>
{ items . map (( item , i ) => {
const isOpen = openIndexes . has ( i );
return (
< div
key = { i }
style = {{
border : `1px solid ${ borderColor } ` ,
borderBottom : i === items . length - 1 ? `1px solid ${ borderColor } ` : 'none' ,
...( i === 0 ? { borderTopLeftRadius : '8px' , borderTopRightRadius : '8px' } : {}),
...( i === items . length - 1 ? { borderBottomLeftRadius : '8px' , borderBottomRightRadius : '8px' , borderBottom : `1px solid ${ borderColor } ` } : {}),
}}
>
< div
onClick = {() => toggle ( i )}
style = {{
padding : '16px 20px' ,
backgroundColor : headerBg ,
color : headerColor ,
cursor : 'pointer' ,
display : 'flex' ,
justifyContent : 'space-between' ,
alignItems : 'center' ,
fontWeight : '600' ,
fontSize : '16px' ,
userSelect : 'none' ,
...( i === 0 ? { borderTopLeftRadius : '7px' , borderTopRightRadius : '7px' } : {}),
...( i === items . length - 1 && ! isOpen ? { borderBottomLeftRadius : '7px' , borderBottomRightRadius : '7px' } : {}),
}}
>
< span >{ item . title }</ span >
< span style = {{ fontSize : '12px' , transition : 'transform 0.2s' , transform : isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}> & # 9660 ;</ span >
</ div >
{ isOpen && (
< div
style = {{
padding : '16px 20px' ,
backgroundColor : contentBg ,
color : '#4b5563' ,
fontSize : '14px' ,
lineHeight : '1.6' ,
borderTop : `1px solid ${ borderColor } ` ,
...( i === items . length - 1 ? { borderBottomLeftRadius : '7px' , borderBottomRightRadius : '7px' } : {}),
}}
>
{ item . content }
</ div >
)}
</ div >
);
})}
</ div >
</ section >
);
};
/* ---------- Settings panel ---------- */
const AccordionSettings : React.FC = () => {
const { actions : { setProp }, props } = useNode (( node ) => ({
props : node.data.props as AccordionProps ,
}));
const items = props . items || defaultItems ;
const inputStyle : CSSProperties = {
width : '100%' , padding : '3px 6px' , background : '#27272a' , color : '#e4e4e7' ,
border : '1px solid #3f3f46' , borderRadius : 4 , fontSize : 11 ,
};
const labelStyle : CSSProperties = { fontSize : 11 , color : '#a1a1aa' , display : 'block' , marginBottom : 6 };
const updateItem = ( index : number , field : keyof AccordionItem , value : string | boolean ) => {
setProp (( p : AccordionProps ) => {
const updated = [...( p . items || defaultItems )];
updated [ index ] = { ... updated [ index ], [ field ] : value };
p . items = updated ;
});
};
const addItem = () => {
setProp (( p : AccordionProps ) => {
p . items = [...( p . items || defaultItems ), { title : 'New Question' , content : 'Answer goes here.' , isOpen : false }];
});
};
const removeItem = ( index : number ) => {
setProp (( p : AccordionProps ) => {
const updated = [...( p . items || defaultItems )];
updated . splice ( index , 1 );
p . items = updated ;
});
};
const colorSwatches = [ '#f8fafc' , '#f1f5f9' , '#e2e8f0' , '#ffffff' , '#18181b' , '#1e293b' , '#3b82f6' , '#8b5cf6' ];
return (
< div style = {{ padding : '12px' , display : 'flex' , flexDirection : 'column' , gap : '14px' }}>
2026-05-25 12:43:28 -07:00
< AnchorIdField />
2026-04-05 18:31:16 -07:00
< div >
< label style = { labelStyle }> Header Background </ label >
< div style = {{ display : 'flex' , gap : 4 , flexWrap : 'wrap' }}>
{ colorSwatches . map (( c ) => (
< button
key = { c }
onClick = {() => setProp (( p : AccordionProps ) => { p . headerBg = c ; })}
style = {{
width : 24 , height : 24 , borderRadius : 4 , border : '1px solid #3f3f46' ,
backgroundColor : c , cursor : 'pointer' ,
outline : props.headerBg === c ? '2px solid #3b82f6' : 'none' ,
outlineOffset : 1 ,
}}
/>
))}
</ div >
</ div >
< div >
< label style = { labelStyle }> Header Text Color </ label >
< div style = {{ display : 'flex' , gap : 4 , flexWrap : 'wrap' }}>
{[ '#18181b' , '#1f2937' , '#374151' , '#ffffff' , '#e2e8f0' , '#3b82f6' ]. map (( c ) => (
< button
key = { c }
onClick = {() => setProp (( p : AccordionProps ) => { p . headerColor = c ; })}
style = {{
width : 24 , height : 24 , borderRadius : 4 , border : '1px solid #3f3f46' ,
backgroundColor : c , cursor : 'pointer' ,
outline : props.headerColor === c ? '2px solid #3b82f6' : 'none' ,
outlineOffset : 1 ,
}}
/>
))}
</ div >
</ div >
< div >
< label style = { labelStyle }> Content Background </ label >
< div style = {{ display : 'flex' , gap : 4 , flexWrap : 'wrap' }}>
{[ '#ffffff' , '#f8fafc' , '#f1f5f9' , '#18181b' , '#1e293b' ]. map (( c ) => (
< button
key = { c }
onClick = {() => setProp (( p : AccordionProps ) => { p . contentBg = c ; })}
style = {{
width : 24 , height : 24 , borderRadius : 4 , border : '1px solid #3f3f46' ,
backgroundColor : c , cursor : 'pointer' ,
outline : props.contentBg === c ? '2px solid #3b82f6' : 'none' ,
outlineOffset : 1 ,
}}
/>
))}
</ div >
</ div >
< div >
< label style = { labelStyle }> Border Color </ label >
< div style = {{ display : 'flex' , gap : 4 , flexWrap : 'wrap' }}>
{[ '#e2e8f0' , '#cbd5e1' , '#d1d5db' , '#3f3f46' , '#52525b' ]. map (( c ) => (
< button
key = { c }
onClick = {() => setProp (( p : AccordionProps ) => { p . borderColor = c ; })}
style = {{
width : 24 , height : 24 , borderRadius : 4 , border : '1px solid #3f3f46' ,
backgroundColor : c , cursor : 'pointer' ,
outline : props.borderColor === c ? '2px solid #3b82f6' : 'none' ,
outlineOffset : 1 ,
}}
/>
))}
</ div >
</ div >
< div >
< label style = { labelStyle }> Items </ label >
< div style = {{ display : 'flex' , flexDirection : 'column' , gap : 8 }}>
{ items . map (( item , i ) => (
< div key = { i } style = {{ background : '#1e1e22' , borderRadius : 6 , padding : 8 , display : 'flex' , flexDirection : 'column' , gap : 4 }}>
< div style = {{ display : 'flex' , gap : 4 }}>
< input type = "text" value = { item . title } onChange = {( e ) => updateItem ( i , 'title' , e . target . value )} placeholder = "Title" style = {{ ... inputStyle , flex : 1 }} />
< button
onClick = {() => removeItem ( i )}
style = {{ padding : '2px 6px' , fontSize : 11 , background : '#ef4444' , color : '#fff' , border : 'none' , borderRadius : 4 , cursor : 'pointer' }}
>
X
</ button >
</ div >
< textarea
value = { item . content }
onChange = {( e ) => updateItem ( i , 'content' , e . target . value )}
placeholder = "Content"
rows = { 2 }
style = {{ ... inputStyle , resize : 'vertical' }}
/>
</ div >
))}
</ div >
< button
onClick = { addItem }
style = {{ marginTop : 6 , width : '100%' , padding : '6px' , fontSize : 11 , background : '#27272a' , color : '#e4e4e7' , border : '1px solid #3f3f46' , borderRadius : 4 , cursor : 'pointer' }}
>
+ Add Item
</ button >
</ div >
</ div >
);
};
/* ---------- Craft config ---------- */
Accordion . craft = {
displayName : 'Accordion' ,
props : {
items : defaultItems ,
style : { backgroundColor : '#ffffff' },
headerBg : '#f8fafc' ,
headerColor : '#18181b' ,
contentBg : '#ffffff' ,
borderColor : '#e2e8f0' ,
2026-05-25 12:43:28 -07:00
anchorId : '' ,
2026-04-05 18:31:16 -07:00
},
rules : {
canDrag : () => true ,
canMoveIn : () => false ,
canMoveOut : () => true ,
},
related : {
settings : AccordionSettings ,
},
};
/* ---------- HTML export ---------- */
( Accordion as any ). toHtml = ( props : AccordionProps , _childrenHtml : string ) => {
2026-05-25 12:43:28 -07:00
const esc = ( s : any ) => String ( s ?? "" ). replace ( /</g , '<' ). replace ( />/g , '>' ). replace ( /"/g , '"' );
2026-04-05 18:31:16 -07:00
const sectionStyle = cssPropsToString ({
padding : '60px 20px' ,
... props . style ,
});
2026-05-25 12:43:28 -07:00
const idAttr = props . anchorId ? ` id=" ${ esc ( props . anchorId ) } "` : '' ;
2026-04-05 18:31:16 -07:00
const headerBg = props . headerBg || '#f8fafc' ;
const headerColor = props . headerColor || '#18181b' ;
const contentBg = props . contentBg || '#ffffff' ;
const borderColor = props . borderColor || '#e2e8f0' ;
const items = props . items || defaultItems ;
const panels = items . map (( item , i ) => {
const openAttr = item . isOpen ? ' open' : '' ;
const topRadius = i === 0 ? 'border-top-left-radius:8px;border-top-right-radius:8px;' : '' ;
const bottomRadius = i === items . length - 1 ? 'border-bottom-left-radius:8px;border-bottom-right-radius:8px;' : '' ;
const borderBottom = i === items . length - 1 ? `border:1px solid ${ borderColor } ;` : `border:1px solid ${ borderColor } ;border-bottom:none;` ;
return `<details ${ openAttr } style=" ${ borderBottom }${ topRadius }${ bottomRadius } ">
<summary style="padding:16px 20px;background-color: ${ headerBg } ;color: ${ headerColor } ;cursor:pointer;font-weight:600;font-size:16px;list-style:none;display:flex;justify-content:space-between;align-items:center">
${ esc ( item . title ) }
</summary>
<div style="padding:16px 20px;background-color: ${ contentBg } ;color:#4b5563;font-size:14px;line-height:1.6;border-top:1px solid ${ borderColor } ">
${ esc ( item . content ) }
</div>
</details>` ;
}). join ( '\n ' );
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;display:flex;flex-direction:column">
${ panels }
</div>
<style>details summary::-webkit-details-marker{display:none}details summary::marker{display:none}</style>
</section>` ,
};
};