331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
|
|
import React, { CSSProperties, useState } from 'react';
|
||
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
||
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||
|
|
|
||
|
|
interface AccordionItem {
|
||
|
|
title: string;
|
||
|
|
content: string;
|
||
|
|
isOpen?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface AccordionProps {
|
||
|
|
items?: AccordionItem[];
|
||
|
|
style?: CSSProperties;
|
||
|
|
headerBg?: string;
|
||
|
|
headerColor?: string;
|
||
|
|
contentBg?: string;
|
||
|
|
borderColor?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
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',
|
||
|
|
}) => {
|
||
|
|
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)); }}
|
||
|
|
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)' }}>▼</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' }}>
|
||
|
|
<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',
|
||
|
|
},
|
||
|
|
rules: {
|
||
|
|
canDrag: () => true,
|
||
|
|
canMoveIn: () => false,
|
||
|
|
canMoveOut: () => true,
|
||
|
|
},
|
||
|
|
related: {
|
||
|
|
settings: AccordionSettings,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ---------- HTML export ---------- */
|
||
|
|
|
||
|
|
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
|
||
|
|
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||
|
|
const sectionStyle = cssPropsToString({
|
||
|
|
padding: '60px 20px',
|
||
|
|
...props.style,
|
||
|
|
});
|
||
|
|
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 {
|
||
|
|
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||
|
|
<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>`,
|
||
|
|
};
|
||
|
|
};
|