mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
feat: choose which side the sidebar sits on
Two-column templates take a sidebarSide from the layout (left or right). Without one, each template keeps its own side and right-to-left pages mirror it. The PDF templates reverse only their columns, header band and sidebar background, and the DOCX export follows the same choice. Design's Sidebar panel offers Left and Right.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
@@ -33,13 +33,15 @@ afterEach(() => {
|
||||
toastState.add.mockClear();
|
||||
});
|
||||
|
||||
function renderGroup() {
|
||||
function renderGroup(edit?: (data: Resume["data"]) => void) {
|
||||
const data = parseResumeData(structuredClone(sampleResumeData));
|
||||
edit?.(data);
|
||||
const resume: Resume = {
|
||||
id: "template-group",
|
||||
name: "Template group",
|
||||
slug: "template-group",
|
||||
tags: [],
|
||||
data: parseResumeData(structuredClone(sampleResumeData)),
|
||||
data,
|
||||
isLocked: false,
|
||||
updatedAt: new Date("2026-09-28T00:00:00.000Z"),
|
||||
};
|
||||
@@ -130,4 +132,28 @@ describe("TemplateGroup", () => {
|
||||
expect(screen.getByText(`${twoColumn} of ${Object.keys(templateLayouts).length} shown`)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^Bronzor\b/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("puts a two-column template's sidebar on the side chosen, starting from the side the page shows", () => {
|
||||
renderGroup((data) => {
|
||||
data.metadata.template = "azurill";
|
||||
});
|
||||
const side = screen.getByRole("radiogroup", { name: "Sidebar side" });
|
||||
const option = (name: string) => within(side).getByRole("radio", { name });
|
||||
|
||||
expect(option("Left")).toBeChecked();
|
||||
fireEvent.click(option("Right"));
|
||||
|
||||
expect(useResumeStore.getState().resume?.data.metadata.layout.sidebarSide).toBe("right");
|
||||
expect(option("Right")).toBeChecked();
|
||||
});
|
||||
|
||||
it("shows a right-to-left page's mirrored sidebar as the side it's on", () => {
|
||||
renderGroup((data) => {
|
||||
data.metadata.template = "azurill";
|
||||
data.metadata.page.locale = "ar-SA";
|
||||
});
|
||||
const side = screen.getByRole("radiogroup", { name: "Sidebar side" });
|
||||
|
||||
expect(within(side).getByRole("radio", { name: "Right" })).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,10 @@ import { useEffect, useId, useRef, useState } from "react";
|
||||
import { templateLayouts } from "@reactive-resume/schema/templates";
|
||||
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { isRTL } from "@reactive-resume/utils/locale";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { useResumeData, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
@@ -140,7 +142,7 @@ export function TemplateGroup({ layout = "grid" }: TemplateGroupProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{templateLayouts[current].columns === 2 && <SidebarPanel data={data} />}
|
||||
{templateLayouts[current].columns === 2 && <SidebarPanel data={data} template={current} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -205,9 +207,15 @@ function TemplateCard({ id, data, selected, onPreview }: TemplateCardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Two-column templates: the sidebar's width and which sections it holds, with the ATS caveat. */
|
||||
function SidebarPanel({ data }: { data: ResumeData }) {
|
||||
type SidebarPanelProps = { data: ResumeData; template: Template };
|
||||
|
||||
/** Two-column templates: the sidebar's side and width, which sections it holds, and the ATS caveat. */
|
||||
function SidebarPanel({ data, template }: SidebarPanelProps) {
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
// The side the page shows now: the chosen one, else the template's own (mirrored on right-to-left pages).
|
||||
const ownSide = templateLayouts[template].sidebarSide ?? "left";
|
||||
const shownOwnSide = isRTL(data.metadata.page.locale) ? (ownSide === "left" ? "right" : "left") : ownSide;
|
||||
const side = data.metadata.layout.sidebarSide ?? shownOwnSide;
|
||||
const rows = getOutlineRows(data);
|
||||
const width = Math.min(42, Math.max(26, data.metadata.layout.sidebarWidth));
|
||||
const titleId = useId();
|
||||
@@ -244,6 +252,26 @@ function SidebarPanel({ data }: { data: ResumeData }) {
|
||||
<p id={titleId} className="font-medium text-sm">
|
||||
<Trans>Sidebar</Trans>
|
||||
</p>
|
||||
<SegmentedControl
|
||||
aria-label={t`Sidebar side`}
|
||||
value={side}
|
||||
onValueChange={(value) =>
|
||||
updateResumeData(
|
||||
(draft) => {
|
||||
draft.metadata.layout.sidebarSide = value === "right" ? "right" : "left";
|
||||
},
|
||||
{ newStep: true },
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<SegmentedControlItem value="left">
|
||||
<Trans>Left</Trans>
|
||||
</SegmentedControlItem>
|
||||
<SegmentedControlItem value="right">
|
||||
<Trans>Right</Trans>
|
||||
</SegmentedControlItem>
|
||||
</SegmentedControl>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between text-[13px]">
|
||||
<span id={widthId}>
|
||||
|
||||
@@ -4415,6 +4415,14 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
|
||||
"minimum": 10,
|
||||
"maximum": 50
|
||||
},
|
||||
"sidebarSide": {
|
||||
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it).",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"left",
|
||||
"right"
|
||||
]
|
||||
},
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -23527,6 +23527,14 @@
|
||||
"maximum": 50,
|
||||
"description": "The width of the sidebar column, defined as a percentage of the page width."
|
||||
},
|
||||
"sidebarSide": {
|
||||
"enum": [
|
||||
"left",
|
||||
"right"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
|
||||
},
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -27886,6 +27894,14 @@
|
||||
"maximum": 50,
|
||||
"description": "The width of the sidebar column, defined as a percentage of the page width."
|
||||
},
|
||||
"sidebarSide": {
|
||||
"enum": [
|
||||
"left",
|
||||
"right"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
|
||||
},
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -32380,6 +32396,14 @@
|
||||
"maximum": 50,
|
||||
"description": "The width of the sidebar column, defined as a percentage of the page width."
|
||||
},
|
||||
"sidebarSide": {
|
||||
"enum": [
|
||||
"left",
|
||||
"right"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
|
||||
},
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -36845,6 +36869,14 @@
|
||||
"maximum": 50,
|
||||
"description": "The width of the sidebar column, defined as a percentage of the page width."
|
||||
},
|
||||
"sidebarSide": {
|
||||
"enum": [
|
||||
"left",
|
||||
"right"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
|
||||
},
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -41798,6 +41830,14 @@
|
||||
"maximum": 50,
|
||||
"description": "The width of the sidebar column, defined as a percentage of the page width."
|
||||
},
|
||||
"sidebarSide": {
|
||||
"enum": [
|
||||
"left",
|
||||
"right"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
|
||||
},
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -46255,6 +46295,14 @@
|
||||
"maximum": 50,
|
||||
"description": "The width of the sidebar column, defined as a percentage of the page width."
|
||||
},
|
||||
"sidebarSide": {
|
||||
"enum": [
|
||||
"left",
|
||||
"right"
|
||||
],
|
||||
"type": "string",
|
||||
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it)."
|
||||
},
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -51054,6 +51102,14 @@
|
||||
"minimum": 10,
|
||||
"maximum": 50
|
||||
},
|
||||
"sidebarSide": {
|
||||
"description": "Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it).",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"left",
|
||||
"right"
|
||||
]
|
||||
},
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -453,7 +453,8 @@ export function buildDocument(data: ResumeData, resolveTitle?: SectionTitleResol
|
||||
sidebarParagraphs,
|
||||
sidebarWidth,
|
||||
gapXTwips,
|
||||
templateConfig.sidebarSide,
|
||||
// The side chosen in Design, else the template's own.
|
||||
data.metadata.layout.sidebarSide ?? templateConfig.sidebarSide,
|
||||
sidebarShadingHex,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { ReactNode } from "react";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { templateLayouts } from "@reactive-resume/schema/templates";
|
||||
import { isRTL } from "@reactive-resume/utils/locale";
|
||||
|
||||
export type ResumeRenderOptions = {
|
||||
@@ -12,6 +14,11 @@ type RenderContextValue = ResumeData & {
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
renderOptions: ResumeRenderOptions;
|
||||
rtl: boolean;
|
||||
/**
|
||||
* Whether a two-column template lays its columns out from the right: its sidebar goes to the other side from
|
||||
* where the template draws it. That's the side chosen in Design, or, without a choice, a right-to-left page.
|
||||
*/
|
||||
columnsReversed: boolean;
|
||||
};
|
||||
|
||||
const RenderContext = createContext<RenderContextValue | null>(null);
|
||||
@@ -21,6 +28,7 @@ type RenderProviderProps = {
|
||||
data: ResumeData;
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
renderOptions?: ResumeRenderOptions | undefined;
|
||||
template?: Template | undefined;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@@ -28,12 +36,16 @@ export const RenderProvider = ({
|
||||
data,
|
||||
resolveSectionTitle,
|
||||
renderOptions = defaultRenderOptions,
|
||||
template,
|
||||
children,
|
||||
}: RenderProviderProps) => {
|
||||
const rtl = isRTL(data.metadata.page.locale);
|
||||
const chosenSide = data.metadata.layout.sidebarSide;
|
||||
const ownSide = template ? templateLayouts[template].sidebarSide : null;
|
||||
const columnsReversed = chosenSide && ownSide ? chosenSide !== ownSide : rtl;
|
||||
const contextValue = useMemo<RenderContextValue>(
|
||||
() => ({ ...data, resolveSectionTitle, renderOptions, rtl }),
|
||||
[data, resolveSectionTitle, renderOptions, rtl],
|
||||
() => ({ ...data, resolveSectionTitle, renderOptions, rtl, columnsReversed }),
|
||||
[data, resolveSectionTitle, renderOptions, rtl, columnsReversed],
|
||||
);
|
||||
|
||||
return <RenderContext.Provider value={contextValue}>{children}</RenderContext.Provider>;
|
||||
|
||||
@@ -78,7 +78,12 @@ export const ResumeDocument = ({
|
||||
sourceTree={runtime.sourceTree}
|
||||
renderTree={runtime.renderTree}
|
||||
>
|
||||
<RenderProvider data={resumeData} resolveSectionTitle={resolveSectionTitle} renderOptions={renderOptions}>
|
||||
<RenderProvider
|
||||
data={resumeData}
|
||||
template={template}
|
||||
resolveSectionTitle={resolveSectionTitle}
|
||||
renderOptions={renderOptions}
|
||||
>
|
||||
<Document
|
||||
pageMode="useNone"
|
||||
creationDate={creationDate}
|
||||
|
||||
@@ -135,7 +135,7 @@ const useAzurillTemplate = (): AzurillTemplate => {
|
||||
color: primary,
|
||||
},
|
||||
contentRow: {
|
||||
flexDirection: r.row,
|
||||
flexDirection: r.columns,
|
||||
},
|
||||
sidebarColumn: {},
|
||||
mainColumn: {
|
||||
@@ -264,5 +264,6 @@ const useAzurillTemplate = (): AzurillTemplate => {
|
||||
metrics.gapY,
|
||||
foreground,
|
||||
background,
|
||||
r.columns,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -61,6 +61,7 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader,
|
||||
const { metadata, picture } = data;
|
||||
const { colors, styles } = useChikoritaTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const { r } = useTemplateBase();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
|
||||
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
|
||||
@@ -90,8 +91,10 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader,
|
||||
style={composeStyles(styles.mainColumn, {
|
||||
marginTop: -metrics.page.paddingVertical,
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
paddingRight: page.fullWidth ? metrics.page.paddingHorizontal : metrics.columnGap,
|
||||
paddingLeft: metrics.page.paddingHorizontal,
|
||||
...r.columnInset(
|
||||
metrics.page.paddingHorizontal,
|
||||
page.fullWidth ? metrics.page.paddingHorizontal : metrics.columnGap,
|
||||
),
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
>
|
||||
@@ -112,8 +115,7 @@ export const ChikoritaPage = ({ page, pageSize, pageMinHeightStyle, showHeader,
|
||||
showHeader && hasPicture
|
||||
? metrics.page.paddingVertical + picture.size + metrics.itemGapY * 3
|
||||
: metrics.page.paddingVertical,
|
||||
paddingRight: metrics.page.paddingHorizontal,
|
||||
paddingLeft: metrics.columnGap,
|
||||
...r.columnInset(metrics.columnGap, metrics.page.paddingHorizontal),
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
>
|
||||
@@ -178,7 +180,7 @@ const useChikoritaTemplate = (): ChikoritaTemplate => {
|
||||
inline: { ...base.inline, columnGap: metrics.gapX(0.25) },
|
||||
page: {
|
||||
...base.page,
|
||||
flexDirection: r.row,
|
||||
flexDirection: r.columns,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
@@ -282,5 +284,5 @@ const useChikoritaTemplate = (): ChikoritaTemplate => {
|
||||
icon: createIconSlot({ metadata, accentFor }),
|
||||
} satisfies ChikoritaStyles,
|
||||
};
|
||||
}, [metadata, r.row, r.headerIdentity, metrics.gapX, primary, foreground, base, metrics.gapY, background]);
|
||||
}, [metadata, r.row, r.headerIdentity, metrics.gapX, primary, foreground, base, metrics.gapY, background, r.columns]);
|
||||
};
|
||||
|
||||
@@ -218,7 +218,7 @@ const useDitgarTemplate = (): DitgarTemplate => {
|
||||
...base,
|
||||
page: {
|
||||
...base.page,
|
||||
flexDirection: r.row,
|
||||
flexDirection: r.columns,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
@@ -356,5 +356,6 @@ const useDitgarTemplate = (): DitgarTemplate => {
|
||||
metrics.page.paddingHorizontal,
|
||||
foreground,
|
||||
background,
|
||||
r.columns,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -65,6 +65,7 @@ export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader, page
|
||||
const { metadata, picture } = data;
|
||||
const { colors, styles } = useDittoTemplate();
|
||||
const metrics = getTemplateMetrics(metadata.page);
|
||||
const { r } = useTemplateBase();
|
||||
const hasPicture = hasTemplatePicture(picture);
|
||||
const sidebarSections = useRenderedSectionIds(pageNodeKey, filterSections(page.sidebar, data));
|
||||
const mainSections = useRenderedSectionIds(pageNodeKey, filterSections(page.main, data));
|
||||
@@ -89,7 +90,7 @@ export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader, page
|
||||
style={composeStyles(styles.sidebarColumn, {
|
||||
display: page.fullWidth ? "none" : "flex",
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
paddingLeft: metrics.page.paddingHorizontal,
|
||||
...r.columnInset(metrics.page.paddingHorizontal, 0),
|
||||
paddingTop: showHeader && hasPicture ? metrics.page.paddingVertical : 0,
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
@@ -104,8 +105,7 @@ export const DittoPage = ({ page, pageSize, pageMinHeightStyle, showHeader, page
|
||||
<SemanticRegionView
|
||||
region="main"
|
||||
style={composeStyles(styles.mainColumn, {
|
||||
paddingLeft: metrics.columnGap,
|
||||
paddingRight: metrics.page.paddingHorizontal,
|
||||
...r.columnInset(metrics.columnGap, metrics.page.paddingHorizontal),
|
||||
rowGap: metrics.sectionGap,
|
||||
})}
|
||||
>
|
||||
@@ -201,7 +201,7 @@ const useDittoTemplate = (): DittoTemplate => {
|
||||
headerBand: {
|
||||
backgroundColor: primary,
|
||||
color: background,
|
||||
flexDirection: r.row,
|
||||
flexDirection: r.columns,
|
||||
...(hasPicture ? { minHeight: picture.size * 0.6 } : {}),
|
||||
},
|
||||
pictureAnchor: {
|
||||
@@ -232,7 +232,7 @@ const useDittoTemplate = (): DittoTemplate => {
|
||||
color: background,
|
||||
},
|
||||
contactRow: {
|
||||
flexDirection: r.row,
|
||||
flexDirection: r.columns,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
contactOffset: {
|
||||
@@ -256,7 +256,7 @@ const useDittoTemplate = (): DittoTemplate => {
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
contentRow: {
|
||||
flexDirection: r.row,
|
||||
flexDirection: r.columns,
|
||||
},
|
||||
sidebarColumn: {
|
||||
flexShrink: 0,
|
||||
@@ -308,5 +308,6 @@ const useDittoTemplate = (): DittoTemplate => {
|
||||
base,
|
||||
metrics.gapY,
|
||||
background,
|
||||
r.columns,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -199,7 +199,7 @@ const useGengarTemplate = (): GengarTemplate => {
|
||||
...base,
|
||||
page: {
|
||||
...base.page,
|
||||
flexDirection: r.row,
|
||||
flexDirection: r.columns,
|
||||
},
|
||||
section: {
|
||||
flexDirection: "column",
|
||||
@@ -333,5 +333,6 @@ const useGengarTemplate = (): GengarTemplate => {
|
||||
metrics.page.paddingHorizontal,
|
||||
foreground,
|
||||
background,
|
||||
r.columns,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -168,12 +168,12 @@ const useGlalieTemplate = (): GlalieTemplate => {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
...r.anchorToStart(0),
|
||||
...r.anchorToColumnStart(0),
|
||||
width: `${metadata.layout.sidebarWidth}%`,
|
||||
backgroundColor: primaryTint,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: r.row,
|
||||
flexDirection: r.columns,
|
||||
minHeight: "100%",
|
||||
},
|
||||
sidebarColumn: {
|
||||
@@ -247,7 +247,6 @@ const useGlalieTemplate = (): GlalieTemplate => {
|
||||
}, [
|
||||
metadata,
|
||||
r.row,
|
||||
r.anchorToStart,
|
||||
primary,
|
||||
metrics.sectionGap,
|
||||
metrics.gapY,
|
||||
@@ -257,5 +256,7 @@ const useGlalieTemplate = (): GlalieTemplate => {
|
||||
metrics.page.paddingHorizontal,
|
||||
foreground,
|
||||
background,
|
||||
r.columns,
|
||||
r.anchorToColumnStart,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -219,7 +219,7 @@ const useLeafishTemplate = (): LeafishTemplate => {
|
||||
columnGap: metrics.gapX(1 / 6),
|
||||
},
|
||||
body: {
|
||||
flexDirection: r.row,
|
||||
flexDirection: r.columns,
|
||||
columnGap: metrics.columnGap,
|
||||
paddingHorizontal: metrics.page.paddingHorizontal,
|
||||
paddingTop: metrics.page.paddingVertical,
|
||||
@@ -257,5 +257,6 @@ const useLeafishTemplate = (): LeafishTemplate => {
|
||||
metrics.columnGap,
|
||||
foreground,
|
||||
background,
|
||||
r.columns,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -210,7 +210,7 @@ const usePikachuTemplate = (): PikachuTemplate => {
|
||||
backgroundColor: primary,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: r.row,
|
||||
flexDirection: r.columns,
|
||||
columnGap: metrics.columnGap,
|
||||
},
|
||||
sidebarColumn: {
|
||||
@@ -302,5 +302,6 @@ const usePikachuTemplate = (): PikachuTemplate => {
|
||||
base,
|
||||
metrics.gapX,
|
||||
background,
|
||||
r.columns,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -13,10 +13,20 @@ type RtlStyleHelpers = {
|
||||
contactSeparator: (color: string, gap: number) => Style;
|
||||
contactSeparatorClear: Style;
|
||||
anchorToStart: (offset?: number | string) => Style;
|
||||
/** The flex direction of the row that holds a two-column template's columns. */
|
||||
columns: "row" | "row-reverse";
|
||||
/** Like `anchorToStart`, for the side the template's own sidebar starts from. */
|
||||
anchorToColumnStart: (offset?: number | string) => Style;
|
||||
/** Left and right padding for a column, given as the template draws it: `start` on its own left. */
|
||||
columnInset: (start: number, end: number) => Pick<Style, "paddingLeft" | "paddingRight">;
|
||||
};
|
||||
|
||||
export function createRtlStyleHelpers(rtl: boolean): RtlStyleHelpers {
|
||||
export function createRtlStyleHelpers(rtl: boolean, columnsReversed = rtl): RtlStyleHelpers {
|
||||
return {
|
||||
columns: columnsReversed ? "row-reverse" : "row",
|
||||
anchorToColumnStart: (offset = 0) => (columnsReversed ? { right: offset } : { left: offset }),
|
||||
columnInset: (start, end) =>
|
||||
columnsReversed ? { paddingLeft: end, paddingRight: start } : { paddingLeft: start, paddingRight: end },
|
||||
rtl,
|
||||
pageDirection: rtl ? "rtl" : "ltr",
|
||||
row: rtl ? "row-reverse" : "row",
|
||||
|
||||
@@ -14,7 +14,7 @@ type SidebarBackgroundProps = {
|
||||
* continues on a later page still reads as one band. Render it as a direct child of the page.
|
||||
*/
|
||||
export const SidebarBackground = ({ color, width, end = false }: SidebarBackgroundProps) => {
|
||||
const { rtl } = useRender();
|
||||
const side = end !== rtl ? "right" : "left";
|
||||
const { columnsReversed } = useRender();
|
||||
const side = end !== columnsReversed ? "right" : "left";
|
||||
return <View fixed style={{ position: "absolute", top: 0, bottom: 0, [side]: 0, width, backgroundColor: color }} />;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as forme from "@formepdf/core";
|
||||
import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { templateLayouts, templateSchema } from "@reactive-resume/schema/templates";
|
||||
import { renderResume } from "../../forme/render";
|
||||
|
||||
const twoColumn = templateSchema.options.filter((template) => templateLayouts[template].columns === 2);
|
||||
|
||||
/** Where the first page's sidebar and main sections sit, by their left edges. */
|
||||
const columnEdges = async (template: (typeof twoColumn)[number], sidebarSide?: "left" | "right", locale = "en-US") => {
|
||||
const data = parseResumeData({
|
||||
...sampleResumeData,
|
||||
picture: { ...sampleResumeData.picture, hidden: true },
|
||||
metadata: {
|
||||
...sampleResumeData.metadata,
|
||||
template,
|
||||
page: { ...sampleResumeData.metadata.page, locale },
|
||||
layout: { ...sampleResumeData.metadata.layout, sidebarSide },
|
||||
},
|
||||
});
|
||||
const { pageMap } = await renderResume(forme, { data, template });
|
||||
const firstPage = data.metadata.layout.pages[0];
|
||||
const x = (column: "main" | "sidebar") =>
|
||||
Math.min(
|
||||
...pageMap.nodes
|
||||
// Some templates print the summary across the page, in the header.
|
||||
.filter(
|
||||
(node) =>
|
||||
node.page === 0 &&
|
||||
node.kind === "section" &&
|
||||
node.sectionId !== "summary" &&
|
||||
firstPage?.[column].includes(node.sectionId),
|
||||
)
|
||||
.map((node) => node.x),
|
||||
);
|
||||
return { sidebar: x("sidebar"), main: x("main") };
|
||||
};
|
||||
|
||||
describe("sidebar side", () => {
|
||||
it.each(twoColumn)("puts %s's sidebar on the side chosen", { timeout: 60_000 }, async (template) => {
|
||||
const left = await columnEdges(template, "left");
|
||||
const right = await columnEdges(template, "right");
|
||||
|
||||
expect(left.sidebar).toBeLessThan(left.main);
|
||||
expect(right.sidebar).toBeGreaterThan(right.main);
|
||||
});
|
||||
|
||||
it("keeps a chosen side on a right-to-left page, and mirrors the template's own side without a choice", async () => {
|
||||
const chosen = await columnEdges("azurill", "left", "ar-SA");
|
||||
const own = await columnEdges("azurill", undefined, "ar-SA");
|
||||
|
||||
expect(chosen.sidebar).toBeLessThan(chosen.main);
|
||||
expect(own.sidebar).toBeGreaterThan(own.main);
|
||||
});
|
||||
});
|
||||
@@ -19,10 +19,10 @@ import { Heading, SemanticContactListView, SemanticHeaderPicture, SemanticHeader
|
||||
import { createRtlStyleHelpers } from "./rtl";
|
||||
|
||||
export const useTemplateBase = () => {
|
||||
const { picture, metadata, rtl } = useRender();
|
||||
const { picture, metadata, rtl, columnsReversed } = useRender();
|
||||
|
||||
return useMemo(() => {
|
||||
const r = createRtlStyleHelpers(rtl);
|
||||
const r = createRtlStyleHelpers(rtl, columnsReversed);
|
||||
const foreground = rgbaStringToHex(metadata.design.colors.text);
|
||||
const background = rgbaStringToHex(metadata.design.colors.background);
|
||||
const primary = rgbaStringToHex(metadata.design.colors.primary);
|
||||
@@ -30,7 +30,7 @@ export const useTemplateBase = () => {
|
||||
const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture });
|
||||
|
||||
return { picture, metadata, rtl, r, foreground, background, primary, metrics, base };
|
||||
}, [picture, metadata, rtl]);
|
||||
}, [picture, metadata, rtl, columnsReversed]);
|
||||
};
|
||||
|
||||
export const createIconSlot = ({
|
||||
|
||||
@@ -525,6 +525,13 @@ export const layoutSchema = z.object({
|
||||
.max(50)
|
||||
.catch(35)
|
||||
.describe("The width of the sidebar column, defined as a percentage of the page width."),
|
||||
sidebarSide: z
|
||||
.enum(["left", "right"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.describe(
|
||||
"Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it).",
|
||||
),
|
||||
pages: z.array(pageLayoutSchema).describe("The pages to display in the layout."),
|
||||
});
|
||||
|
||||
|
||||
@@ -835,6 +835,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
|
||||
| `metadata.template` | `string` | yes | enum: ["azurill","bronzor","chikorita","ditgar","ditto","gengar","glalie","kakuna","lapras","leafish","meowth","onyx","pikachu","rhyhorn","scizor"]; default: "onyx" | The template to use for the resume. Determines the overall design and appearance of the resume. |
|
||||
| `metadata.layout` | `object` | yes | — | The layout of the resume. Determines the structure and arrangement of the sections on the resume. |
|
||||
| `metadata.layout.sidebarWidth` | `number` | yes | minimum: 10; maximum: 50; default: 35 | The width of the sidebar column, defined as a percentage of the page width. |
|
||||
| `metadata.layout.sidebarSide` | `string` | no | enum: ["left","right"] | Which side of the page the sidebar column sits on in two-column templates. When unset, each template uses its own side (and right-to-left resumes mirror it). |
|
||||
| `metadata.layout.pages` | `array` | yes | — | The pages to display in the layout. |
|
||||
| `metadata.layout.pages[]` | `object` | — | — | — |
|
||||
| `metadata.layout.pages[].fullWidth` | `boolean` | yes | — | Whether the layout of the page should be full width. If true, the main column will span the entire width of the page. This means that there should be no items in the sidebar column. |
|
||||
|
||||
Reference in New Issue
Block a user