Skip to content

Commit 2c27fd9

Browse files
authored
feat(template-builder): add fieldColors prop for field type styling (SD-883) (#2724)
* feat(template-builder): add fieldColors prop for field type styling Consumers can now pass a `fieldColors` prop to color-code fields by type in both the document and sidebar — no CSS import needed. - Generates scoped CSS from fieldColors and injects into <head> - Dynamic sidebar/menu badge colors via updated getFieldTypeStyle - Removes need for manual field-types.css import (still available) - Updates demo to use fieldColors instead of CSS variable overrides - Documents fieldColors in configuration guide and API reference Closes SD-883 * fix(template-builder): address review findings for fieldColors - Fix block label selector: use .superdoc-structured-content__label (not __block__label) to match actual DOM - Fix non-hex color support: use color-mix() instead of appending '1a' - Fix partial fieldColors: only generate default rule when owner key exists, preventing unrelated fields from being recolored - Simplify style injection: compute CSS in useMemo directly, remove JSON stringify/parse round-trip - Extract buildColorRules helper to deduplicate CSS template - Add 11 unit tests for getFieldTypeStyle and generateFieldColorCSS * fix(template-builder): pass fieldColors to right-side sidebar
1 parent eac7189 commit 2c27fd9

10 files changed

Lines changed: 222 additions & 32 deletions

File tree

apps/docs/solutions/template-builder/api-reference.mdx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ None - the component works with zero configuration.
7979
Lock mode for all inserted fields. Per-field `lockMode` overrides this. See [lock modes](/extensions/structured-content#lock-modes).
8080
</ParamField>
8181

82+
<ParamField path="fieldColors" type="Record<string, string>">
83+
Colors for field types in the document and sidebar. Keys are `fieldType` values, values are CSS colors (e.g. `{ owner: '#629be7', signer: '#d97706' }`). Generates scoped CSS automatically — no stylesheet import needed.
84+
</ParamField>
85+
8286
<ParamField path="cspNonce" type="string">
8387
Content Security Policy nonce for dynamically injected styles
8488
</ParamField>
@@ -393,14 +397,26 @@ Returns `SuperDoc | null`.
393397

394398
## Field type styling
395399

396-
Import the optional CSS to color-code fields by type in the editor:
400+
Use the `fieldColors` prop to color-code fields by type:
401+
402+
```tsx
403+
<SuperDocTemplateBuilder
404+
fieldColors={{
405+
owner: "#629be7",
406+
signer: "#d97706",
407+
date: "#059669",
408+
}}
409+
/>
410+
```
411+
412+
This generates scoped CSS automatically. Each color controls the field border and label in the document. Sidebar badges match.
413+
414+
For manual control, you can still import the standalone stylesheet and use CSS variables:
397415

398416
```jsx
399417
import "@superdoc-dev/template-builder/field-types.css";
400418
```
401419

402-
Override colors with CSS variables:
403-
404420
```css
405421
:root {
406422
--superdoc-field-owner-color: #629be7;

apps/docs/solutions/template-builder/configuration.mdx

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,24 +65,30 @@ Tag fields with a `fieldType` to distinguish roles:
6565
/>
6666
```
6767

68-
Import the optional CSS to color-code fields in the editor:
68+
Color-code fields in the document and sidebar with `fieldColors`:
6969

70-
```jsx
71-
import "@superdoc-dev/template-builder/field-types.css";
70+
```tsx
71+
<SuperDocTemplateBuilder
72+
fields={{
73+
available: [
74+
{ id: "1", label: "Company Name", fieldType: "owner" },
75+
{ id: "2", label: "Signer Name", fieldType: "signer" },
76+
{ id: "3", label: "Effective Date", fieldType: "date" },
77+
],
78+
}}
79+
fieldColors={{
80+
owner: "#629be7",
81+
signer: "#d97706",
82+
date: "#059669",
83+
}}
84+
/>
7285
```
7386

74-
Customize colors with CSS variables:
75-
76-
```css
77-
:root {
78-
--superdoc-field-owner-color: #629be7;
79-
--superdoc-field-signer-color: #d97706;
80-
}
81-
```
87+
Each color controls the field's border and label in the document. The sidebar badges match automatically. Custom field types beyond `owner` and `signer` work the same way — just add them to `fieldColors`.
8288

83-
<Important>
84-
Without `field-types.css`, structured content fields use the default Word-like appearance: borders are transparent and only visible on selection. Importing this stylesheet makes field borders always visible and color-coded by field type, which helps template authors quickly identify fields in the document.
85-
</Important>
89+
<Tip>
90+
You can also import `@superdoc-dev/template-builder/field-types.css` directly and use CSS variables instead. The `fieldColors` prop is the recommended approach — no extra imports needed.
91+
</Tip>
8692

8793
The `fieldType` value flows through all callbacks (`onFieldInsert`, `onFieldsChange`, `onExport`, etc.) and is stored in the SDT tag metadata.
8894

packages/template-builder/demo/src/App.css

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -255,12 +255,6 @@ header {
255255
background: #000000;
256256
}
257257

258-
/* Field type color overrides — just set the CSS variables */
259-
:root {
260-
--superdoc-field-owner-color: #1bb754;
261-
--superdoc-field-signer-color: #d81313;
262-
}
263-
264258
/* Responsive */
265259
@media (max-width: 768px) {
266260
.header-content {

packages/template-builder/demo/src/App.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type {
88
ExportEvent,
99
} from '@superdoc-dev/template-builder';
1010
import 'superdoc/style.css';
11-
import '@superdoc-dev/template-builder/field-types.css';
1211
import './App.css';
1312

1413
const availableFields: FieldDefinition[] = [
@@ -253,6 +252,10 @@ export function App() {
253252
fields={fieldsConfig}
254253
list={listConfig}
255254
toolbar={true}
255+
fieldColors={{
256+
owner: '#629be7',
257+
signer: '#d97706',
258+
}}
256259
telemetry={{
257260
enabled: true,
258261
metadata: {

packages/template-builder/src/defaults/FieldList.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const FieldItem: FC<{
1313
onDelete: (id: string | number) => void;
1414
isSelected: boolean;
1515
isGrouped?: boolean;
16-
}> = ({ field, onSelect, onDelete, isSelected, isGrouped = false }) => {
16+
fieldColors?: Record<string, string>;
17+
}> = ({ field, onSelect, onDelete, isSelected, isGrouped = false, fieldColors }) => {
1718
return (
1819
<div
1920
onClick={() => onSelect(field)}
@@ -117,7 +118,7 @@ const FieldItem: FC<{
117118
fontSize: '9px',
118119
padding: '2px 5px',
119120
borderRadius: '3px',
120-
...getFieldTypeStyle(field.fieldType),
121+
...getFieldTypeStyle(field.fieldType, fieldColors),
121122
fontWeight: '500',
122123
}}
123124
>
@@ -145,7 +146,7 @@ const FieldItem: FC<{
145146
);
146147
};
147148

148-
export const FieldList: FC<FieldListProps> = ({ fields, onSelect, onDelete, selectedFieldId }) => {
149+
export const FieldList: FC<FieldListProps> = ({ fields, onSelect, onDelete, selectedFieldId, fieldColors }) => {
149150
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
150151

151152
const { groupedFields, ungroupedFields } = useMemo(() => {
@@ -211,6 +212,7 @@ export const FieldList: FC<FieldListProps> = ({ fields, onSelect, onDelete, sele
211212
onSelect={onSelect}
212213
onDelete={onDelete}
213214
isSelected={selectedFieldId === field.id}
215+
fieldColors={fieldColors}
214216
/>
215217
))}
216218

@@ -273,6 +275,7 @@ export const FieldList: FC<FieldListProps> = ({ fields, onSelect, onDelete, sele
273275
onDelete={onDelete}
274276
isSelected={selectedFieldId === field.id}
275277
isGrouped
278+
fieldColors={fieldColors}
276279
/>
277280
))}
278281
</div>

packages/template-builder/src/defaults/FieldMenu.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
1515
onCreateField,
1616
existingFields = [],
1717
onSelectExisting,
18+
fieldColors,
1819
}) => {
1920
const [isCreating, setIsCreating] = useState(false);
2021
const [newFieldName, setNewFieldName] = useState('');
@@ -389,7 +390,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
389390
padding: '2px 6px',
390391
borderRadius: '3px',
391392
textTransform: 'capitalize',
392-
...getFieldTypeStyle(entry.fieldType),
393+
...getFieldTypeStyle(entry.fieldType, fieldColors),
393394
fontWeight: 500,
394395
}}
395396
>
@@ -501,7 +502,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
501502
padding: '2px 6px',
502503
borderRadius: '3px',
503504
textTransform: 'capitalize',
504-
...getFieldTypeStyle(field.fieldType),
505+
...getFieldTypeStyle(field.fieldType, fieldColors),
505506
fontWeight: 500,
506507
}}
507508
>

packages/template-builder/src/index.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { useRef, useState, useEffect, useCallback, useMemo, forwardRef, useImper
22
import type { SuperDoc } from 'superdoc'; // requires superdoc >=1.24.2 for correct types
33
import type * as Types from './types';
44
import { FieldMenu, FieldList } from './defaults';
5-
import { areTemplateFieldsEqual, resolveToolbar, clampToViewport, getPresentationEditor } from './utils';
5+
import {
6+
areTemplateFieldsEqual,
7+
resolveToolbar,
8+
clampToViewport,
9+
getPresentationEditor,
10+
generateFieldColorCSS,
11+
} from './utils';
612

713
export * from './types';
814
export { FieldMenu, FieldList };
@@ -53,6 +59,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
5359
list = {},
5460
toolbar,
5561
defaultLockMode,
62+
fieldColors,
5663
cspNonce,
5764
telemetry,
5865
licenseKey,
@@ -101,6 +108,26 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
101108
[telemetry?.enabled, JSON.stringify(telemetry?.metadata)],
102109
);
103110

111+
const fieldColorCSS = useMemo(() => {
112+
if (!fieldColors) return '';
113+
return generateFieldColorCSS(fieldColors, '.superdoc-template-builder');
114+
}, [fieldColors]);
115+
116+
// Inject scoped field-color CSS when fieldColors is provided
117+
useEffect(() => {
118+
if (!fieldColorCSS) return;
119+
120+
const style = window.document.createElement('style');
121+
style.setAttribute('data-superdoc-field-colors', '');
122+
if (cspNonce) style.nonce = cspNonce;
123+
style.textContent = fieldColorCSS;
124+
window.document.head.appendChild(style);
125+
126+
return () => {
127+
style.remove();
128+
};
129+
}, [fieldColorCSS, cspNonce]);
130+
104131
const computeFilteredFields = useCallback(
105132
(query: string) => {
106133
const normalized = query.trim().toLowerCase();
@@ -629,6 +656,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
629656
onDelete={deleteField}
630657
onUpdate={(field) => updateField(field.id, field)}
631658
selectedFieldId={selectedFieldId || undefined}
659+
fieldColors={fieldColors}
632660
/>
633661
</div>
634662
)}
@@ -657,6 +685,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
657685
onDelete={deleteField}
658686
onUpdate={(field) => updateField(field.id, field)}
659687
selectedFieldId={selectedFieldId || undefined}
688+
fieldColors={fieldColors}
660689
/>
661690
</div>
662691
)}
@@ -674,6 +703,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
674703
onCreateField={onFieldCreate}
675704
existingFields={templateFields}
676705
onSelectExisting={handleSelectExisting}
706+
fieldColors={fieldColors}
677707
/>
678708
</div>
679709
);

packages/template-builder/src/tests/utils.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, it, expect } from 'vitest';
2-
import { areTemplateFieldsEqual, resolveToolbar, clampToViewport } from '../utils';
2+
import {
3+
areTemplateFieldsEqual,
4+
resolveToolbar,
5+
clampToViewport,
6+
getFieldTypeStyle,
7+
generateFieldColorCSS,
8+
} from '../utils';
39
import type { TemplateField } from '../types';
410

511
describe('areTemplateFieldsEqual', () => {
@@ -156,3 +162,76 @@ describe('clampToViewport', () => {
156162
expect(result.top).toBe(458);
157163
});
158164
});
165+
166+
describe('getFieldTypeStyle', () => {
167+
it('returns hardcoded signer style without fieldColors', () => {
168+
const style = getFieldTypeStyle('signer');
169+
expect(style).toEqual({ background: '#fef3c7', color: '#b45309' });
170+
});
171+
172+
it('returns default style for unknown type without fieldColors', () => {
173+
const style = getFieldTypeStyle('unknown');
174+
expect(style).toEqual({ background: '#f3f4f6', color: '#6b7280' });
175+
});
176+
177+
it('returns custom color with color-mix background when fieldColors provided', () => {
178+
const style = getFieldTypeStyle('date', { date: '#059669' });
179+
expect(style.color).toBe('#059669');
180+
expect(style.background).toContain('color-mix');
181+
expect(style.background).toContain('#059669');
182+
});
183+
184+
it('falls back to default for types not in fieldColors', () => {
185+
const style = getFieldTypeStyle('unknown', { owner: '#629be7' });
186+
expect(style).toEqual({ background: '#f3f4f6', color: '#6b7280' });
187+
});
188+
189+
it('works with non-hex colors', () => {
190+
const style = getFieldTypeStyle('custom', { custom: 'rgb(100, 200, 50)' });
191+
expect(style.color).toBe('rgb(100, 200, 50)');
192+
expect(style.background).toContain('color-mix');
193+
});
194+
});
195+
196+
describe('generateFieldColorCSS', () => {
197+
it('returns empty string for empty object', () => {
198+
expect(generateFieldColorCSS({}, '.scope')).toBe('');
199+
});
200+
201+
it('generates per-type rules with data-sdt-tag selectors', () => {
202+
const css = generateFieldColorCSS({ signer: '#d97706' }, '.scope');
203+
expect(css).toContain('[data-sdt-tag*=\'"fieldType":"signer"\']');
204+
expect(css).toContain('#d97706');
205+
});
206+
207+
it('generates default rule when owner is defined', () => {
208+
const css = generateFieldColorCSS({ owner: '#629be7', signer: '#d97706' }, '.scope');
209+
// Default rule (no tag selector) + per-type rules
210+
expect(css).toContain('.scope .superdoc-structured-content-inline,');
211+
expect(css).toContain('#629be7');
212+
expect(css).toContain('#d97706');
213+
});
214+
215+
it('does not generate default rule when no owner key', () => {
216+
const css = generateFieldColorCSS({ signer: '#d97706' }, '.scope');
217+
// Should only have tag-selector rules, not a blanket default
218+
const lines = css.split('\n').filter((l) => l.includes('border-color'));
219+
lines.forEach((line) => {
220+
// Every border-color rule should be within a tag selector context
221+
expect(css).toContain('data-sdt-tag');
222+
});
223+
});
224+
225+
it('uses correct label selectors for inline and block', () => {
226+
const css = generateFieldColorCSS({ owner: '#629be7' }, '.scope');
227+
expect(css).toContain('.superdoc-structured-content-inline__label');
228+
expect(css).toContain('.superdoc-structured-content__label');
229+
// Should NOT contain the wrong block label class
230+
expect(css).not.toContain('.superdoc-structured-content-block__label');
231+
});
232+
233+
it('uses color-mix for label backgrounds', () => {
234+
const css = generateFieldColorCSS({ owner: '#629be7' }, '.scope');
235+
expect(css).toContain('color-mix(in srgb, #629be7 87%, transparent)');
236+
});
237+
});

packages/template-builder/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export interface FieldMenuProps {
5050
onCreateField?: (field: FieldDefinition) => void | Promise<FieldDefinition | void>;
5151
existingFields?: TemplateField[];
5252
onSelectExisting?: (field: TemplateField) => void;
53+
fieldColors?: Record<string, string>;
5354
}
5455

5556
export interface FieldListProps {
@@ -58,6 +59,7 @@ export interface FieldListProps {
5859
onDelete: (fieldId: string | number) => void;
5960
onUpdate?: (field: TemplateField) => void;
6061
selectedFieldId?: string | number;
62+
fieldColors?: Record<string, string>;
6163
}
6264

6365
export interface DocumentConfig {
@@ -121,6 +123,9 @@ export interface SuperDocTemplateBuilderProps {
121123
/** Lock mode applied to all inserted fields unless overridden per-field */
122124
defaultLockMode?: LockMode;
123125

126+
/** Colors for field types in the document and sidebar. Keys are fieldType values, values are CSS colors. */
127+
fieldColors?: Record<string, string>;
128+
124129
/** Content Security Policy nonce for dynamically injected styles */
125130
cspNonce?: string;
126131

0 commit comments

Comments
 (0)