feat: add section heading icons to PDF templates (#3127)

* feat: add section heading icons to PDF templates

Add customizable Phosphor icons before section titles in PDF output.
Users can toggle visibility globally via a new "Hide section heading icons"
switch (independent of item-level icons) and customize individual section
icons through the builder sidebar icon picker.

- Add `icon` field to `baseSectionSchema` and `summarySchema`
- Add `hideSectionIcons` to `pageSchema` (defaults to true for backward compat)
- Implement `SectionHeadingIcon` component with heading font-size scaling
- Support "none" sentinel for per-section icon hiding
- Fallback to sensible defaults (briefcase, graduation-cap, etc.) for legacy data
- Add icon picker to builder sidebar sections and custom section dialogs

Closes #2632

* test: add unit tests for section heading icons

- Add tests for getResumeSectionIcon() covering built-in sections,
  summary, custom sections, "none" sentinel, and default fallbacks
- Add schema tests for baseSectionSchema icon field, summarySchema icon,
  and pageSchema hideSectionIcons default behavior

* refactor: minor updates to icon display

* Update apps/web/locales/es-ES.po

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
JamesGoslings
2026-06-01 20:02:42 +08:00
committed by GitHub
parent 1522794733
commit b932711f08
81 changed files with 1153 additions and 306 deletions
@@ -141,9 +141,9 @@ export const useTemplateFeatureStyle = (
return resolveStyleSlot(slots?.[slot] as TemplateStyleSlot | undefined, context);
};
export const useTemplateIconSlot = (slot: "icon") => {
export const useTemplateIconSlot = (slot: "icon" | "sectionHeadingIcon") => {
const { styles } = useTemplateContext();
const context = useTemplateStyleContext();
return resolveIconSlot(styles[slot], context);
return resolveIconSlot(styles[slot] as TemplateIconSlot | undefined, context);
};
@@ -0,0 +1,13 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const source = readFileSync(fileURLToPath(new URL("./primitives.tsx", import.meta.url)), "utf8");
describe("SectionHeadingIcon", () => {
it("passes the resolved heading icon size through the icon size prop", () => {
expect(source).toContain("size: sizeProp");
expect(source).toContain("{...(resolvedSize === undefined ? {} : { size: resolvedSize })}");
expect(source).not.toContain("{ size: headingFontSize } as Style");
});
});
@@ -2,6 +2,7 @@ import type { Style } from "@react-pdf/types";
import type { ComponentProps } from "react";
import type { StyleInput } from "./styles";
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
import { useRender } from "../../context";
import { Link as PdfLink, Text as PdfText, View } from "../../renderer";
import { useSectionStyleRule, useTemplateIconSlot, useTemplateStyle } from "./context";
import { resolveIconSize } from "./icon-size";
@@ -88,3 +89,39 @@ export const Icon = ({ style, size: sizeProp, ...props }: ComponentProps<typeof
/>
);
};
export const SectionHeadingIcon = ({ style, size: sizeProp, ...props }: ComponentProps<typeof PhosphorIcon>) => {
const data = useRender();
const { style: sectionIconStyle, ...sectionIconProps } = useTemplateIconSlot("sectionHeadingIcon");
const { style: fallbackIconStyle, ...fallbackIconProps } = useTemplateIconSlot("icon");
// Fall back to the item icon slot if no section heading icon slot is defined
const hasSlot = sectionIconStyle !== undefined || Object.keys(sectionIconProps).length > 0;
const iconStyle = hasSlot ? sectionIconStyle : fallbackIconStyle;
const iconProps = hasSlot ? sectionIconProps : fallbackIconProps;
// Section heading icon visibility is controlled by hideSectionIcons (in SectionShell),
// NOT by the item-level hideIcons toggle. Ignore the "display: none" from item icon slot.
const { display: _, size: templateSize, ...iconPropsWithoutDisplay } = iconProps;
const templateIconSize =
hasSlot && (typeof templateSize === "number" || typeof templateSize === "string") ? templateSize : undefined;
// Icon size follows heading fontSize so they scale together
const headingFontSize = data.metadata.typography.heading.fontSize;
const resolvedSize =
resolveIconSize({
size: sizeProp,
styles: [asStyleInput(iconStyle), asStyleInput(style)],
}) ??
templateIconSize ??
headingFontSize;
return (
<PhosphorIcon
{...iconPropsWithoutDisplay}
{...props}
{...(resolvedSize === undefined ? {} : { size: resolvedSize })}
style={composeStyles(asStyleInput(iconStyle), asStyleInput(style))}
/>
);
};
@@ -13,3 +13,26 @@ describe("ExperienceSection", () => {
expect(source).not.toContain("item.roles.length > 0 && <Text>{item.period}</Text>");
});
});
describe("SectionShell", () => {
it("keeps section and heading style rules when section heading icons are hidden", () => {
expect(source).toContain("<View style={composeStyles(sectionStyle, sectionRuleStyle)}>");
expect(source).toContain("<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>");
});
it("wires the section heading container style slot into the icon row", () => {
expect(source).toContain('useTemplateStyle("sectionHeadingContainer")');
expect(source).toContain("sectionHeadingContainerStyle");
});
it("top-aligns heading icon rows and does not use unsupported auto width resets", () => {
const headingContainerBlock = source.match(
/const defaultSectionHeadingContainerStyle = {(?<body>[\s\S]*?)} satisfies Style;/,
);
expect(headingContainerBlock?.groups?.body).toContain('alignItems: "flex-start"');
expect(source).toContain("getSectionHeadingTextStyle(sectionHeadingStyle, sectionHeadingRuleStyle)");
expect(source).toContain("width: _width");
expect(source).not.toContain('width: "auto"');
});
});
+60 -2
View File
@@ -23,6 +23,7 @@ import { Children, createContext, isValidElement, use } from "react";
import { match } from "ts-pattern";
import { useRender } from "../../context";
import { View } from "../../renderer";
import { getResumeSectionIcon } from "../../section-icon";
import { getResumeSectionTitle } from "../../section-title";
import { getSectionItemRows, getSectionItemsLayout, shouldUseSectionTimeline } from "./columns";
import { getWebsiteDisplayText } from "./contact";
@@ -38,7 +39,7 @@ import {
import { filterItems, hasVisibleItems, isSectionVisible, isVisibleSummary } from "./filtering";
import { LevelDisplay } from "./level-display";
import { getTemplateMetrics } from "./metrics";
import { Bold, Div, Heading, Icon, Link, Small, Text } from "./primitives";
import { Bold, Div, Heading, Icon, Link, SectionHeadingIcon, Small, Text } from "./primitives";
import { RichText } from "./rich-text";
import { createRtlStyleHelpers } from "./rtl";
import { getInlineItemWebsiteUrl, shouldRenderSeparateItemWebsite } from "./section-links";
@@ -132,6 +133,35 @@ const SECTION_ITEM_PLACEHOLDER_KEYS = [
"placeholder-6",
] as const;
const defaultSectionHeadingContainerStyle = {
flexDirection: "row",
alignItems: "flex-start",
columnGap: 4,
} satisfies Style;
const getSectionHeadingTextStyle = (...styles: StyleInput[]): Style[] =>
composeStyles(...styles).map(
({
borderBottomWidth: _borderBottomWidth,
borderLeftWidth: _borderLeftWidth,
borderRightWidth: _borderRightWidth,
borderTopWidth: _borderTopWidth,
borderWidth: _borderWidth,
flexGrow: _flexGrow,
flexShrink: _flexShrink,
marginBottom: _marginBottom,
marginLeft: _marginLeft,
marginRight: _marginRight,
marginTop: _marginTop,
paddingBottom: _paddingBottom,
paddingLeft: _paddingLeft,
paddingRight: _paddingRight,
paddingTop: _paddingTop,
width: _width,
...textStyle
}) => textStyle,
);
const useSectionItemsContext = () => use(SectionItemsContext);
const getChildKey = (child: ReactNode, fallbackIndex: number) => {
@@ -154,13 +184,41 @@ const SectionShell = ({ sectionId, title, showHeading = true, children }: Sectio
const sectionStyle = useTemplateStyle("section");
const sectionRuleStyle = useSectionStyleRule("section");
const sectionHeadingStyle = useTemplateStyle("sectionHeading");
const sectionHeadingContainerStyle = useTemplateStyle("sectionHeadingContainer");
const sectionHeadingRuleStyle = useSectionStyleRule("heading");
const sectionTitle = getResumeSectionTitle(data, sectionId, title);
const sectionIcon = getResumeSectionIcon(data, sectionId);
const showIcon = Boolean(sectionIcon) && !data.metadata.page.hideSectionIcons;
if (!showIcon) {
// No icon: render heading exactly as before (no structural change)
return (
<View style={composeStyles(sectionStyle, sectionRuleStyle)}>
{showHeading && (
<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>{sectionTitle}</Heading>
)}
{children}
</View>
);
}
// With icon: wrap in a flex row container that inherits the heading's border/decoration
return (
<View style={composeStyles(sectionStyle, sectionRuleStyle)}>
{showHeading && (
<Heading style={composeStyles(sectionHeadingStyle, sectionHeadingRuleStyle)}>{sectionTitle}</Heading>
<View
style={composeStyles(
sectionHeadingStyle,
defaultSectionHeadingContainerStyle,
sectionHeadingContainerStyle,
sectionHeadingRuleStyle,
)}
>
<SectionHeadingIcon name={sectionIcon as IconName} />
<Heading style={getSectionHeadingTextStyle(sectionHeadingStyle, sectionHeadingRuleStyle)}>
{sectionTitle}
</Heading>
</View>
)}
{children}
</View>
@@ -66,6 +66,8 @@ export type TemplateStyleSlots = {
sectionItemHeader?: TemplateStyleSlot;
section?: TemplateStyleSlot;
sectionHeading?: TemplateStyleSlot;
sectionHeadingContainer?: TemplateStyleSlot;
sectionHeadingIcon?: TemplateIconSlot;
sectionItems?: TemplateStyleSlot;
item?: TemplateStyleSlot;
levelContainer?: TemplateStyleSlot;