import React, { CSSProperties, useState } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeHtml, escapeAttr } from '../../utils/escape'; interface TabItem { label: string; content: string; } interface TabsProps { tabs?: TabItem[]; style?: CSSProperties; activeTabBg?: string; activeTabColor?: string; inactiveTabBg?: string; inactiveTabColor?: string; contentBg?: string; anchorId?: string; } 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 = ({ tabs = defaultTabs, style = {}, activeTabBg = '#3b82f6', activeTabColor = '#ffffff', inactiveTabBg = '#f1f5f9', inactiveTabColor = '#64748b', contentBg = '#ffffff', anchorId, }) => { const { connectors: { connect, drag }, selected, } = useNode((node) => ({ selected: node.events.selected, })); const [activeIndex, setActiveIndex] = useState(0); return (
{ if (ref) connect(drag(ref)); }} id={anchorId || undefined} style={{ padding: '60px 20px', backgroundColor: '#ffffff', outline: selected ? '2px solid #3b82f6' : 'none', ...style, }} >
{/* Tab buttons */}
{tabs.map((tab, i) => ( ))}
{/* Content panel */}
{tabs[activeIndex]?.content || ''}
); }; /* ---------- Craft config ---------- */ Tabs.craft = { displayName: 'Tabs', props: { tabs: defaultTabs, style: { backgroundColor: '#ffffff' }, activeTabBg: '#3b82f6', activeTabColor: '#ffffff', inactiveTabBg: '#f1f5f9', inactiveTabColor: '#64748b', contentBg: '#ffffff', anchorId: '', }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true, }, }; /* ---------- 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', ...props.style, }); const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : ''; 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'; // 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 ``; }).join('\n '); const tabPanels = tabs.map((tab, i) => { return `
${escapeHtml(tab.content)}
`; }).join('\n '); const switchScript = ``; return { html: `
${tabButtons}
${tabPanels}
${switchScript} `, }; };