Files
site-builder/craft/src/components/sections/Tabs.tsx
T
shadowdao 8bf525c600 feat(builder): surface hidden section/pricing/social props + box-model rollout
SectionTypePanel (Accordion/Tabs/Testimonials/Countdown/NumberCounter/
CTASection/CallToAction/FeaturesGrid), PricingStylePanel, and
SocialStylePanel all gain a Spacing & Border section (margin/padding
per-side, border, box-shadow, opacity), an Animation section, and a
Visibility section, wired to the shared SpacingControl/BorderControl/
AnimationControl/VisibilityControl.

PricingTable's per-card colors (cardBg/textColor/subColor/featColor/
checkColor/btnBg/btnColor) were previously hard-coded literals computed
from featuredBg inside toHtml -- promoted to real optional props (each
falling back to the exact prior literal when unset) and exposed via
ColorPickerField in PricingStylePanel.

SocialStylePanel now exposes SocialLinks' iconShape/gap (already-built
props with no control), plus Icon's bgColor/bgShape/bgSize/link and
StarRating's filledColor/emptyColor, which the panel's generic
iconBgColor/starColor checks never matched since those aren't Icon's or
StarRating's real prop names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:47:58 -07:00

222 lines
8.2 KiB
TypeScript

import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, scopeId, cssValue } 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;
animation?: string;
animationDelay?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
}
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',
anchorId,
}) => {
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)); }}
id={anchorId || undefined}
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',
anchorId: '',
animation: '',
animationDelay: '',
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
};
/* ---------- HTML export ---------- */
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string, nodeId?: string) => {
const sectionStyle = cssPropsToString({
padding: '60px 20px',
...props.style,
});
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
const tabs = props.tabs || defaultTabs;
// Sanitized -- raw string-interpolation sinks below, both into style="..."
// attributes AND into an inline <script> as single-quoted JS string
// literals (a stray `'` there breaks out of the JS string, not just CSS);
// cssValue strips quotes too so it neutralizes both contexts at once.
const activeTabBg = cssValue(props.activeTabBg) || '#3b82f6';
const activeTabColor = cssValue(props.activeTabColor) || '#ffffff';
const inactiveTabBg = cssValue(props.inactiveTabBg) || '#f1f5f9';
const inactiveTabColor = cssValue(props.inactiveTabColor) || '#64748b';
const contentBg = cssValue(props.contentBg) || '#ffffff';
// tabId scopes the functional wiring (onclick/getElementById) as well as
// the ARIA tab<->panel linking ids. It must be BOTH deterministic (so
// aria-controls/aria-labelledby reference the SAME id across repeated
// exports of the same page) AND unique (so two Tabs instances with
// identical/default content -- e.g. both left at the default tab set --
// don't collide and clobber each other's script globals / ARIA links).
// Scoping on the Craft node id gives both properties; when it's
// unavailable (legacy 2-arg call sites) we fall back to a stable hash of
// the tab content, matching the old (collision-prone but never random)
// behavior.
const scopeSeed = props.anchorId || tabs.map((t) => t.label).join('|') + '::' + tabs.length;
const tabId = scopeId(nodeId, scopeSeed, 'tabs');
const tabButtons = tabs.map((tab, i) => {
const isActive = i === 0;
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 ');
const tabPanels = tabs.map((tab, i) => {
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 ');
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}';
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>`;
return {
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:800px;margin:0 auto">
<div role="tablist" style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
${tabButtons}
</div>
${tabPanels}
</div>
${switchScript}
</section>`,
};
};