mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 15:22:20 +10:00
fix(stylesheet): apply item-header to every header row on every template (#3357)
`SectionItemHeader` only rendered its own box when a template opted into `mainItemHeaderBorder` (only Ditgar did). Everywhere else it walked the header children and attached the resolved `item-header` style to the first descendant that happened to be a literal `View` or `InlineItemHeader`. Sections whose header starts with anything else — certifications, awards, projects, publications, references — matched nothing, so the style was silently dropped; stacked headers such as experience matched only their first row, so a second row went unstyled. The header now always renders its own `Div`, so `item-header` covers the whole header row of every section on every template. `Div` rather than `View` keeps the base row gap the rows used to inherit from the item box, and Ditgar keeps its tight header via `rowGap: 0` on its own `sectionItemHeader` slot, so rendered output is unchanged apart from the newly styled rows. `mainItemHeaderBorder` is now dead and removed. Fixes #3349
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||||
|
import type { Template } from "@reactive-resume/schema/templates";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { pdf } from "@react-pdf/renderer";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||||
|
import { templateSchema } from "@reactive-resume/schema/templates";
|
||||||
|
import { ResumeDocument } from "../document";
|
||||||
|
import { resolveResumeRuntime } from "./resolve";
|
||||||
|
|
||||||
|
type HostNode = { type: string; style?: unknown; value?: string; children?: HostNode[] };
|
||||||
|
|
||||||
|
const HEADER_BACKGROUND = "#ff0000";
|
||||||
|
const STYLESHEET = `@version 1;\nitem-header { background-color: ${HEADER_BACKGROUND}; }`;
|
||||||
|
|
||||||
|
const mergedStyle = (node: HostNode): Record<string, unknown> =>
|
||||||
|
Object.assign({}, ...(Array.isArray(node.style) ? node.style : node.style ? [node.style] : []));
|
||||||
|
const nodeText = (node: HostNode): string => node.value ?? (node.children ?? []).map(nodeText).join("");
|
||||||
|
const flatten = (node: HostNode): HostNode[] => [node, ...(node.children ?? []).flatMap(flatten)];
|
||||||
|
|
||||||
|
const buildFixture = (): ResumeData => {
|
||||||
|
const data = structuredClone(defaultResumeData);
|
||||||
|
data.picture.hidden = true;
|
||||||
|
data.sections.certifications.items = [
|
||||||
|
{
|
||||||
|
id: "certification/1",
|
||||||
|
hidden: false,
|
||||||
|
title: "Certified Kubernetes Administrator",
|
||||||
|
issuer: "The Linux Foundation",
|
||||||
|
date: "Sep 2023",
|
||||||
|
website: { url: "", label: "", inlineLink: false },
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
data.sections.experience.items = [
|
||||||
|
{
|
||||||
|
id: "experience/1",
|
||||||
|
hidden: false,
|
||||||
|
company: "Braincore",
|
||||||
|
position: "Automation Engineer",
|
||||||
|
location: "Jakarta",
|
||||||
|
period: "Jan 2024 - Mar 2025",
|
||||||
|
website: { url: "", label: "", inlineLink: false },
|
||||||
|
description: "",
|
||||||
|
roles: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const page = data.metadata.layout.pages[0];
|
||||||
|
if (!page) throw new Error("Missing authored page");
|
||||||
|
page.main = ["certifications", "experience"];
|
||||||
|
page.sidebar = [];
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderHeaderBoxes = async (template: Template): Promise<string[]> => {
|
||||||
|
const data = buildFixture();
|
||||||
|
const semanticRuntime = resolveResumeRuntime({
|
||||||
|
data,
|
||||||
|
template,
|
||||||
|
mode: "semantic",
|
||||||
|
source: { languageVersion: 1, text: STYLESHEET },
|
||||||
|
});
|
||||||
|
const element = createElement(ResumeDocument, { data, template, semanticRuntime }) as unknown as Parameters<
|
||||||
|
typeof pdf
|
||||||
|
>[0];
|
||||||
|
const instance = pdf(element);
|
||||||
|
await expect.poll(() => instance.container.document).not.toBeNull();
|
||||||
|
const document = instance.container.document as unknown as HostNode;
|
||||||
|
|
||||||
|
return flatten(document)
|
||||||
|
.filter((node) => mergedStyle(node).backgroundColor === HEADER_BACKGROUND)
|
||||||
|
.map((node) => nodeText(node));
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://github.com/amruthpillai/reactive-resume/issues/3349: the resolved `item-header` style used
|
||||||
|
// to be bound onto the first child that happened to be a literal `View`, so sections whose header
|
||||||
|
// starts with anything else lost it entirely and stacked headers only styled their first row.
|
||||||
|
describe("item-header binding", () => {
|
||||||
|
it.each(templateSchema.options)("covers every header row of every section on %s", async (template) => {
|
||||||
|
const headers = await renderHeaderBoxes(template);
|
||||||
|
|
||||||
|
expect(headers).toHaveLength(2);
|
||||||
|
const [certification, experience] = headers;
|
||||||
|
expect(certification).toContain("Certified Kubernetes Administrator");
|
||||||
|
expect(certification).toContain("The Linux Foundation");
|
||||||
|
expect(certification).toContain("Sep 2023");
|
||||||
|
expect(experience).toContain("Braincore");
|
||||||
|
expect(experience).toContain("Automation Engineer");
|
||||||
|
expect(experience).toContain("Jan 2024 - Mar 2025");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,7 +63,6 @@ type DitgarHeaderProps = {
|
|||||||
|
|
||||||
const ditgarFeatures = {
|
const ditgarFeatures = {
|
||||||
stackSidebarItemHeader: true,
|
stackSidebarItemHeader: true,
|
||||||
mainItemHeaderBorder: true,
|
|
||||||
} satisfies TemplateFeatures;
|
} satisfies TemplateFeatures;
|
||||||
|
|
||||||
export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
|
export const DitgarPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageNumber }: TemplatePageProps) => {
|
||||||
@@ -319,6 +318,8 @@ const useDitgarTemplate = (): DitgarTemplate => {
|
|||||||
borderBottomColor: accentFor(context),
|
borderBottomColor: accentFor(context),
|
||||||
}),
|
}),
|
||||||
sectionItemHeader: (context) => ({
|
sectionItemHeader: (context) => ({
|
||||||
|
/** Ditgar packs the header rows tight inside the accent border, without the usual row gap. */
|
||||||
|
rowGap: 0,
|
||||||
...(context.placement === "main"
|
...(context.placement === "main"
|
||||||
? {
|
? {
|
||||||
borderLeftWidth: 2,
|
borderLeftWidth: 2,
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ import type {
|
|||||||
VolunteerItem,
|
VolunteerItem,
|
||||||
} from "@reactive-resume/schema/resume/data";
|
} from "@reactive-resume/schema/resume/data";
|
||||||
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { CombinedTextName } from "../../semantic/node-keys";
|
import type { CombinedTextName } from "../../semantic/node-keys";
|
||||||
import type { StyleInput, TemplatePlacement } from "./styles";
|
import type { StyleInput, TemplatePlacement } from "./styles";
|
||||||
import type { CustomItemSection, ItemSection } from "./types";
|
import type { CustomItemSection, ItemSection } from "./types";
|
||||||
import { Children, cloneElement, createContext, Fragment, isValidElement, use } from "react";
|
import { Children, createContext, Fragment, isValidElement, use } from "react";
|
||||||
import { View } from "#react-pdf-renderer";
|
import { View } from "#react-pdf-renderer";
|
||||||
import { useRender } from "../../context";
|
import { useRender } from "../../context";
|
||||||
import { getResumeSectionIcon } from "../../section-icon";
|
import { getResumeSectionIcon } from "../../section-icon";
|
||||||
@@ -527,21 +527,19 @@ const SectionItem = ({ itemId, children, style }: SectionItemProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const InlineItemHeader = ({
|
/**
|
||||||
leading,
|
* The single-line variant of an item header. `SectionItemHeader` owns the `item-header` node and
|
||||||
middle,
|
* its resolved style, so this only lays the three slots out and addresses their template parts
|
||||||
trailing,
|
* against the surrounding item-header key.
|
||||||
nodeKey,
|
*/
|
||||||
}: InlineItemHeaderProps & { nodeKey?: string | undefined }) => {
|
const InlineItemHeader = ({ leading, middle, trailing }: InlineItemHeaderProps) => {
|
||||||
const inlineItemHeaderStyle = useTemplateStyle("inlineItemHeader");
|
const inlineItemHeaderStyle = useTemplateStyle("inlineItemHeader");
|
||||||
const leadingStyle = useTemplateStyle("inlineItemHeaderLeading");
|
const leadingStyle = useTemplateStyle("inlineItemHeaderLeading");
|
||||||
const middleStyle = useTemplateStyle("inlineItemHeaderMiddle");
|
const middleStyle = useTemplateStyle("inlineItemHeaderMiddle");
|
||||||
const trailingStyle = useTemplateStyle("inlineItemHeaderTrailing");
|
const trailingStyle = useTemplateStyle("inlineItemHeaderTrailing");
|
||||||
|
|
||||||
const resolved = useResolvedNode(nodeKey);
|
const nodeKey = useSemanticNodeKey();
|
||||||
const renderedChildKeys = useRenderedChildKeys(nodeKey);
|
const renderedChildKeys = useRenderedChildKeys(nodeKey);
|
||||||
const visible = useSemanticNodeVisible(nodeKey);
|
|
||||||
if (!visible) return null;
|
|
||||||
const parts = [
|
const parts = [
|
||||||
{
|
{
|
||||||
nodeKey: semanticTemplatePartNodeKey(nodeKey, "inline-item-header-leading") ?? "inline-item-header-leading",
|
nodeKey: semanticTemplatePartNodeKey(nodeKey, "inline-item-header-leading") ?? "inline-item-header-leading",
|
||||||
@@ -584,11 +582,7 @@ const InlineItemHeader = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return <View style={composeStyles(inlineItemHeaderStyle)}>{projectRenderedChildren(renderedChildKeys, parts)}</View>;
|
||||||
<View {...resolvedPdfFlowProps(resolved)} style={composeStyles(inlineItemHeaderStyle, resolved.style)}>
|
|
||||||
{projectRenderedChildren(renderedChildKeys, parts)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const stackedSidebarSplitRowStyle = {
|
const stackedSidebarSplitRowStyle = {
|
||||||
@@ -653,55 +647,25 @@ const ItemHeaderRow = ({ children, style }: ItemHeaderRowProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The box around an item's header rows, exposed to Semantic CSS as `item-header`.
|
||||||
|
*
|
||||||
|
* It always renders its own `Div`, whatever shape the section's header markup has, so a resolved
|
||||||
|
* `item-header` style covers every header row of every section on every template. `Div` rather than
|
||||||
|
* `View` because the header rows keep the row gap they used to get from the item box around them.
|
||||||
|
*/
|
||||||
const SectionItemHeader = ({ children }: SectionItemHeaderProps) => {
|
const SectionItemHeader = ({ children }: SectionItemHeaderProps) => {
|
||||||
const itemNodeKey = useSemanticNodeKey();
|
const itemNodeKey = useSemanticNodeKey();
|
||||||
const itemHeaderNodeKey = itemNodeKey ? semanticNodeKeys.itemHeader(itemNodeKey) : undefined;
|
const itemHeaderNodeKey = itemNodeKey ? semanticNodeKeys.itemHeader(itemNodeKey) : undefined;
|
||||||
const resolved = useResolvedNode(itemHeaderNodeKey);
|
|
||||||
const mainItemHeaderBorder = useTemplateFeature("mainItemHeaderBorder");
|
|
||||||
const sectionItemHeaderStyle = useTemplateStyle("sectionItemHeader");
|
const sectionItemHeaderStyle = useTemplateStyle("sectionItemHeader");
|
||||||
const exists = useSemanticNodeExists(itemHeaderNodeKey);
|
const exists = useSemanticNodeExists(itemHeaderNodeKey);
|
||||||
const visible = useSemanticNodeVisible(itemHeaderNodeKey);
|
|
||||||
if (!exists) return <>{children}</>;
|
if (!exists) return <>{children}</>;
|
||||||
if (!visible) return null;
|
|
||||||
|
|
||||||
if (!mainItemHeaderBorder) {
|
|
||||||
const bindFirstExistingView = (node: ReactNode): [ReactNode, boolean] => {
|
|
||||||
if (!isValidElement(node)) return [node, false];
|
|
||||||
if (node.type === InlineItemHeader) {
|
|
||||||
const inline = node as ReactElement<InlineItemHeaderProps & { nodeKey?: string | undefined }>;
|
|
||||||
return [cloneElement(inline, { nodeKey: itemHeaderNodeKey }), true];
|
|
||||||
}
|
|
||||||
if (node.type === View) {
|
|
||||||
const view = node as ReactElement<{ style?: StyleInput }>;
|
|
||||||
return [
|
|
||||||
cloneElement(view, {
|
|
||||||
...resolvedPdfFlowProps(resolved),
|
|
||||||
style: composeStyles(view.props.style, resolved.style),
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (node.type !== Fragment) return [node, false];
|
|
||||||
|
|
||||||
let bound = false;
|
|
||||||
const fragment = node as ReactElement<{ children?: ReactNode }>;
|
|
||||||
const nextChildren = Children.map(fragment.props.children, (child) => {
|
|
||||||
if (bound) return child;
|
|
||||||
const [next, didBind] = bindFirstExistingView(child);
|
|
||||||
bound = didBind;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
return [cloneElement(fragment, {}, nextChildren), bound];
|
|
||||||
};
|
|
||||||
const [boundChildren] = bindFirstExistingView(children);
|
|
||||||
return <SemanticNodeKeyProvider nodeKey={itemHeaderNodeKey}>{boundChildren}</SemanticNodeKeyProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SemanticNodeKeyProvider nodeKey={itemHeaderNodeKey}>
|
<SemanticNodeKeyProvider nodeKey={itemHeaderNodeKey}>
|
||||||
<View {...resolvedPdfFlowProps(resolved)} style={composeStyles(sectionItemHeaderStyle, resolved.style)}>
|
<Div nodeKey={itemHeaderNodeKey} style={composeStyles(sectionItemHeaderStyle)}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</Div>
|
||||||
</SemanticNodeKeyProvider>
|
</SemanticNodeKeyProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export type TemplateFeatures = {
|
|||||||
sectionTimeline?: boolean;
|
sectionTimeline?: boolean;
|
||||||
inlineItemHeader?: boolean;
|
inlineItemHeader?: boolean;
|
||||||
stackSidebarItemHeader?: boolean;
|
stackSidebarItemHeader?: boolean;
|
||||||
mainItemHeaderBorder?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SectionTimelineStyleSlots = {
|
export type SectionTimelineStyleSlots = {
|
||||||
|
|||||||
Reference in New Issue
Block a user