diff --git a/craft/src/components/layout/Container.toHtml.test.ts b/craft/src/components/layout/Container.toHtml.test.ts new file mode 100644 index 0000000..d26be50 --- /dev/null +++ b/craft/src/components/layout/Container.toHtml.test.ts @@ -0,0 +1,35 @@ +import { describe, test, expect } from 'vitest'; +import { Container } from './Container'; + +const toHtml = (Container as any).toHtml; + +describe('Container.toHtml cssId/cssClass', () => { + test('emits id and class when both set', () => { + const { html } = toHtml({ cssId: 'my-id', cssClass: 'my-class' }, 'child'); + expect(html).toContain('id="my-id"'); + expect(html).toContain('class="my-class"'); + }); + + test('emits neither id nor class when empty/unset', () => { + const { html } = toHtml({}, 'child'); + expect(html).not.toContain(' id="'); + expect(html).not.toContain(' class="'); + }); + + test('escapes cssId/cssClass values', () => { + const { html } = toHtml({ cssId: 'x" onerror="alert(1)', cssClass: 'y" onerror="alert(1)' }, 'child'); + expect(html).not.toContain('onerror="alert(1)"'); + }); + + test('cssId takes precedence over anchorId when both set (no duplicate id attrs)', () => { + const { html } = toHtml({ cssId: 'explicit-id', anchorId: 'anchor-id' }, 'child'); + const idMatches = html.match(/ id="/g) || []; + expect(idMatches.length).toBe(1); + expect(html).toContain('id="explicit-id"'); + }); + + test('falls back to anchorId when cssId is not set', () => { + const { html } = toHtml({ anchorId: 'anchor-id' }, 'child'); + expect(html).toContain('id="anchor-id"'); + }); +}); diff --git a/craft/src/components/layout/Container.tsx b/craft/src/components/layout/Container.tsx index 9f8360f..904c8e7 100644 --- a/craft/src/components/layout/Container.tsx +++ b/craft/src/components/layout/Container.tsx @@ -37,6 +37,8 @@ export const Container: UserComponent = ({ fullWidth = false, contentWidth = 'full', anchorId, + cssId, + cssClass, }) => { const { connectors: { connect, drag } } = useNode(); @@ -50,13 +52,19 @@ export const Container: UserComponent = ({ ...(needsBoxedWrapper ? {} : flexStyles), }; + // cssId is the user-facing "CSS ID" advanced field; it takes precedence + // over anchorId (the scroll-jump anchor) when both happen to be set, since + // only one `id` attribute can be emitted on the element. + const idValue = cssId || anchorId || undefined; + const el = React.createElement( tag, { ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }, style: outerStyle, 'data-craft-container': 'true', - id: anchorId || undefined, + id: idValue, + className: cssClass || undefined, }, needsBoxedWrapper ? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children) @@ -76,6 +84,8 @@ Container.craft = { fullWidth: false, contentWidth: 'full', anchorId: '', + cssId: '', + cssClass: '', }, rules: { canDrag: () => true, @@ -102,12 +112,15 @@ Container.craft = { } const styleStr = cssPropsToString(outerCss); - const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : ''; + // cssId wins over anchorId when both are set (see the render fn above for why). + const idValue = props.cssId || props.anchorId; + const idAttr = idValue ? ` id="${escapeAttr(idValue)}"` : ''; + const classAttr = props.cssClass ? ` class="${escapeAttr(props.cssClass)}"` : ''; if (isBoxed) { const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles }); - return { html: `<${tag}${idAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; + return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; } - return { html: `<${tag}${idAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; + return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; }; diff --git a/craft/src/panels/right/styles/ContainerStylePanel.tsx b/craft/src/panels/right/styles/ContainerStylePanel.tsx index e7a0f5d..c3420ef 100644 --- a/craft/src/panels/right/styles/ContainerStylePanel.tsx +++ b/craft/src/panels/right/styles/ContainerStylePanel.tsx @@ -11,6 +11,9 @@ import { ColorSwatchGrid, GradientSwatchGrid, PresetButtonGrid, + labelStyle, + inputStyle, + sectionGap, } from './shared'; /* ---------- CONTAINER / SECTION ---------- */ @@ -27,8 +30,39 @@ export const ContainerStylePanel: React.FC = ({ selectedId, nod [actions, selectedId], ); + const setProp = useCallback( + (key: string, value: string) => { + actions.setProp(selectedId, (props: any) => { props[key] = value; }); + }, + [actions, selectedId], + ); + return ( <> + {nodeProps.cssId !== undefined && ( +
+ + setProp('cssId', e.target.value)} + placeholder="my-element-id" + style={inputStyle} + /> +
+ )} + {nodeProps.cssClass !== undefined && ( +
+ + setProp('cssClass', e.target.value)} + placeholder="my-custom-class" + style={inputStyle} + /> +
+ )}
Background Color