mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 09:54:43 +10:00
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:
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user