mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
feat: update links for improved accessibility
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsdown",
|
||||
"start": "node dist/index.mjs",
|
||||
"doctor": "react-doctor",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
@@ -80,6 +81,7 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260526.1",
|
||||
"react-doctor": "^0.2.6",
|
||||
"tsdown": "^0.22.0",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^6.0.3",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "vite dev",
|
||||
"serve": "vite preview",
|
||||
"start": "vite preview",
|
||||
"doctor": "react-doctor",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
@@ -99,6 +100,7 @@
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"react-doctor": "^0.2.6",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14"
|
||||
}
|
||||
|
||||
@@ -35,12 +35,12 @@ const renderButton = () =>
|
||||
);
|
||||
|
||||
describe("GithubStarsButton", () => {
|
||||
it("renders an anchor pointing at the project repo with rel=noopener and target=_blank", () => {
|
||||
it("renders an anchor pointing at the project repo with rel=noopener noreferrer and target=_blank", () => {
|
||||
renderButton();
|
||||
const link = screen.getByRole("button") as HTMLAnchorElement;
|
||||
expect(link.href).toBe("https://github.com/amruthpillai/reactive-resume");
|
||||
expect(link.target).toBe("_blank");
|
||||
expect(link.rel).toBe("noopener");
|
||||
expect(link.rel).toBe("noopener noreferrer");
|
||||
});
|
||||
|
||||
it("uses the no-count aria-label when star count hasn't loaded yet", () => {
|
||||
|
||||
@@ -18,7 +18,12 @@ export function GithubStarsButton() {
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a target="_blank" href="https://github.com/amruthpillai/reactive-resume" aria-label={ariaLabel} rel="noopener">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://github.com/amruthpillai/reactive-resume"
|
||||
aria-label={ariaLabel}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<GithubLogoIcon aria-hidden="true" />
|
||||
{starCount != null ? (
|
||||
<CountUp to={starCount} duration={0.5} separator="," className="font-bold" aria-hidden="true" />
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("Copyright", () => {
|
||||
renderCopyright();
|
||||
const link = screen.getByRole("link", { name: "MIT" });
|
||||
expect(link.getAttribute("href")).toBe("https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE");
|
||||
expect(link.getAttribute("rel")).toBe("noopener");
|
||||
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
});
|
||||
|
||||
it("renders the Amruth Pillai attribution link", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ export function Copyright({ className, ...props }: Props) {
|
||||
<a
|
||||
href="https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
MIT
|
||||
@@ -30,7 +30,7 @@ export function Copyright({ className, ...props }: Props) {
|
||||
A passion project by{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
href="https://amruthpillai.com"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
import { JSONResumeImporter } from "@reactive-resume/import/json-resume";
|
||||
@@ -92,7 +92,6 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
const navigate = useNavigate();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
const prevTypeRef = useRef<string>("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isImporting, setIsImporting] = useState<boolean>(false);
|
||||
|
||||
@@ -207,12 +206,6 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
|
||||
const type = useStore(form.store, (s) => s.values.type);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevTypeRef.current === type) return;
|
||||
prevTypeRef.current = type;
|
||||
form.setFieldValue("file", undefined);
|
||||
}, [form, type]);
|
||||
|
||||
const onSelectFile = () => {
|
||||
if (!inputRef.current) return;
|
||||
inputRef.current.click();
|
||||
@@ -261,7 +254,9 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
showClear={false}
|
||||
value={field.state.value}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(value as ImportType);
|
||||
const nextType = value as ImportType;
|
||||
if (nextType !== field.state.value) form.setFieldValue("file", undefined);
|
||||
field.handleChange(nextType);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
|
||||
@@ -190,13 +190,8 @@ export function PdfCanvasPage({
|
||||
</figcaption>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
role="img"
|
||||
aria-label={`Resume page ${pageNumber} of ${totalPages}`}
|
||||
style={scaledPageSize}
|
||||
className={cn("aspect-page overflow-hidden rounded-md", className)}
|
||||
>
|
||||
<canvas ref={canvasRef} />
|
||||
<div style={scaledPageSize} className={cn("aspect-page overflow-hidden rounded-md", className)}>
|
||||
<canvas ref={canvasRef} aria-label={`Resume page ${pageNumber} of ${totalPages}`} />
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
|
||||
@@ -64,8 +64,10 @@ export function ConfirmDialogProvider({ children }: { children: React.ReactNode
|
||||
setState((prev) => ({ ...prev, open: false, resolve: null }));
|
||||
}, [state.resolve]);
|
||||
|
||||
const contextValue = React.useMemo<ConfirmContextType>(() => ({ confirm }), [confirm]);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={{ confirm }}>
|
||||
<ConfirmContext.Provider value={contextValue}>
|
||||
{children}
|
||||
|
||||
<AlertDialog open={state.open} onOpenChange={(open) => !open && handleCancel()}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface CommonControlledStateProps<T> {
|
||||
value?: T;
|
||||
@@ -13,19 +13,17 @@ export function useControlledState<T, Rest extends unknown[] = []>(
|
||||
props: UseControlledStateProps<T, Rest>,
|
||||
): readonly [T, (next: T, ...args: Rest) => void] {
|
||||
const { value, defaultValue, onChange } = props;
|
||||
const isControlled = value !== undefined;
|
||||
|
||||
const [state, setInternalState] = useState<T>(value !== undefined ? value : (defaultValue as T));
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined) setInternalState(value);
|
||||
}, [value]);
|
||||
const [internalState, setInternalState] = useState<T>(value !== undefined ? value : (defaultValue as T));
|
||||
const state = isControlled ? (value as T) : internalState;
|
||||
|
||||
const setState = useCallback(
|
||||
(next: T, ...args: Rest) => {
|
||||
setInternalState(next);
|
||||
if (!isControlled) setInternalState(next);
|
||||
onChange?.(next, ...args);
|
||||
},
|
||||
[onChange],
|
||||
[isControlled, onChange],
|
||||
);
|
||||
|
||||
return [state, setState] as const;
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
|
||||
const [mediaQueryList] = useState(() => (typeof window === "undefined" ? null : window.matchMedia(MOBILE_QUERY)));
|
||||
const latestMatchesRef = useRef(mediaQueryList?.matches ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(MOBILE_QUERY);
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
setIsMobile(e.matches);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(mql.matches);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => {
|
||||
if (!mediaQueryList) return () => {};
|
||||
|
||||
return !!isMobile;
|
||||
const handleChange = (event: MediaQueryListEvent | { matches: boolean }) => {
|
||||
latestMatchesRef.current = event.matches;
|
||||
onStoreChange();
|
||||
};
|
||||
|
||||
mediaQueryList.addEventListener("change", handleChange);
|
||||
return () => mediaQueryList.removeEventListener("change", handleChange);
|
||||
},
|
||||
() => latestMatchesRef.current,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,8 +102,10 @@ export function PromptDialogProvider({ children }: { children: React.ReactNode }
|
||||
[handleConfirm],
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo<PromptContextType>(() => ({ prompt }), [prompt]);
|
||||
|
||||
return (
|
||||
<PromptContext.Provider value={{ prompt }}>
|
||||
<PromptContext.Provider value={contextValue}>
|
||||
{children}
|
||||
|
||||
<AlertDialog open={state.open} onOpenChange={(open) => !open && handleCancel()}>
|
||||
|
||||
@@ -202,7 +202,7 @@ export const DonationBanner = () => (
|
||||
nativeButton={false}
|
||||
className="h-11 gap-2 px-6"
|
||||
render={
|
||||
<a href="https://opencollective.com/reactive-resume/donate" target="_blank" rel="noopener">
|
||||
<a href="https://opencollective.com/reactive-resume/donate" target="_blank" rel="noopener noreferrer">
|
||||
<HeartIcon aria-hidden="true" weight="fill" className="text-rose-400 dark:text-rose-600" />
|
||||
Open Collective
|
||||
<span className="sr-only"> ({t`opens in new tab`})</span>
|
||||
@@ -215,7 +215,7 @@ export const DonationBanner = () => (
|
||||
nativeButton={false}
|
||||
className="h-11 gap-2 px-6"
|
||||
render={
|
||||
<a href="https://github.com/sponsors/AmruthPillai" target="_blank" rel="noopener">
|
||||
<a href="https://github.com/sponsors/AmruthPillai" target="_blank" rel="noopener noreferrer">
|
||||
<GithubLogoIcon aria-hidden="true" weight="fill" className="text-zinc-400 dark:text-zinc-600" />
|
||||
GitHub Sponsors
|
||||
<span className="sr-only"> ({t`opens in new tab`})</span>
|
||||
|
||||
@@ -60,7 +60,7 @@ const getFaqItems = (): FAQItemData[] => [
|
||||
},
|
||||
];
|
||||
|
||||
export function FAQ() {
|
||||
export function Faq() {
|
||||
const faqItems = getFaqItems();
|
||||
|
||||
return (
|
||||
|
||||
@@ -129,7 +129,7 @@ function FooterLink({ url, label }: FooterLinkItem) {
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
rel="noopener noreferrer"
|
||||
className="relative inline-block text-sm transition-colors hover:text-foreground"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createRootStructuredDataScript, getCanonicalRootUrl } from "@/libs/seo";
|
||||
import { DonationBanner } from "./-sections/donate";
|
||||
import { FAQ } from "./-sections/faq";
|
||||
import { Faq } from "./-sections/faq";
|
||||
import { Features } from "./-sections/features";
|
||||
import { Footer } from "./-sections/footer";
|
||||
import { Hero } from "./-sections/hero";
|
||||
@@ -35,7 +35,7 @@ function RouteComponent() {
|
||||
<Templates />
|
||||
<Testimonials />
|
||||
<DonationBanner />
|
||||
<FAQ />
|
||||
<Faq />
|
||||
<Prefooter />
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -994,6 +994,7 @@ function AgentChatComposer({
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
aria-label={t`Upload attachments`}
|
||||
className="hidden"
|
||||
onChange={(event) => onUploadFiles(event.target.files)}
|
||||
/>
|
||||
|
||||
@@ -55,7 +55,14 @@ function PicturePreviewControls({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-x-4">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onUploadPicture} />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
aria-label={t`Upload picture`}
|
||||
className="hidden"
|
||||
onChange={onUploadPicture}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -47,6 +47,6 @@ describe("InformationSectionBuilder", () => {
|
||||
renderInfo();
|
||||
const docs = screen.getByText("Documentation").closest("a") as HTMLAnchorElement;
|
||||
expect(docs.getAttribute("target")).toBe("_blank");
|
||||
expect(docs.getAttribute("rel")).toBe("noopener");
|
||||
expect(docs.getAttribute("rel")).toBe("noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ export function InformationSectionBuilder() {
|
||||
nativeButton={false}
|
||||
className="mt-2 whitespace-normal px-4! text-xs"
|
||||
render={
|
||||
<a href="http://opencollective.com/reactive-resume" target="_blank" rel="noopener">
|
||||
<a href="http://opencollective.com/reactive-resume" target="_blank" rel="noopener noreferrer">
|
||||
<HandHeartIcon />
|
||||
<span className="truncate">
|
||||
<Trans>Donate to Reactive Resume</Trans>
|
||||
@@ -47,7 +47,7 @@ export function InformationSectionBuilder() {
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener">
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Documentation</Trans>
|
||||
</a>
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export function InformationSectionBuilder() {
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://github.com/amruthpillai/reactive-resume" target="_blank" rel="noopener">
|
||||
<a href="https://github.com/amruthpillai/reactive-resume" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Source Code</Trans>
|
||||
</a>
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export function InformationSectionBuilder() {
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://github.com/amruthpillai/reactive-resume/issues" target="_blank" rel="noopener">
|
||||
<a href="https://github.com/amruthpillai/reactive-resume/issues" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Report a Bug</Trans>
|
||||
</a>
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export function InformationSectionBuilder() {
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener">
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Translations</Trans>
|
||||
</a>
|
||||
}
|
||||
@@ -95,7 +95,7 @@ export function InformationSectionBuilder() {
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://opencollective.com/reactive-resume/donate" target="_blank" rel="noopener">
|
||||
<a href="https://opencollective.com/reactive-resume/donate" target="_blank" rel="noopener noreferrer">
|
||||
<Trans>Sponsors</Trans>
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -224,11 +224,11 @@ export function ResumeAnalysisSectionBuilder() {
|
||||
<div key={suggestion.title} className="space-y-3 rounded-md border bg-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
className={`size-2.5 shrink-0 rounded-full ring-1 ring-border ${impactCircleClass(suggestion.impact)}`}
|
||||
title={impactLabel(suggestion.impact)}
|
||||
aria-label={impactLabel(suggestion.impact)}
|
||||
/>
|
||||
<span className="sr-only">{impactLabel(suggestion.impact)}</span>
|
||||
<div className="font-semibold text-sm tracking-tight">{suggestion.title}</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"doctor": "turbo run doctor",
|
||||
"check": "biome check --write --unsafe .",
|
||||
"db:generate": "turbo run db:generate --filter=@reactive-resume/db",
|
||||
"db:migrate": "turbo run db:migrate --filter=@reactive-resume/db",
|
||||
|
||||
@@ -946,11 +946,13 @@ export const agentService = {
|
||||
|
||||
await getThread({ id: input.id, userId: input.userId });
|
||||
|
||||
await db.delete(schema.agentAttachment).where(eq(schema.agentAttachment.threadId, input.id));
|
||||
await db
|
||||
.update(schema.agentThread)
|
||||
.set({ status: "deleted", deletedAt: new Date() })
|
||||
.where(and(eq(schema.agentThread.id, input.id), eq(schema.agentThread.userId, input.userId)));
|
||||
await Promise.all([
|
||||
db.delete(schema.agentAttachment).where(eq(schema.agentAttachment.threadId, input.id)),
|
||||
db
|
||||
.update(schema.agentThread)
|
||||
.set({ status: "deleted", deletedAt: new Date() })
|
||||
.where(and(eq(schema.agentThread.id, input.id), eq(schema.agentThread.userId, input.userId))),
|
||||
]);
|
||||
|
||||
try {
|
||||
await getStorageService().delete(`uploads/${input.userId}/agent/${input.id}`);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "email dev --dir src/templates --port 3002",
|
||||
"doctor": "react-doctor",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
@@ -27,6 +28,7 @@
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260526.1",
|
||||
"react-doctor": "^0.2.6",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||
},
|
||||
"scripts": {
|
||||
"doctor": "react-doctor",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
@@ -38,6 +39,7 @@
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/react": "^19.2.15",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260526.1",
|
||||
"react-doctor": "^0.2.6",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { ReactNode } from "react";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import { createContext, use } from "react";
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { isRTL } from "@reactive-resume/utils/locale";
|
||||
|
||||
type RenderContextValue = ResumeData & {
|
||||
@@ -19,8 +19,12 @@ export type RenderProviderProps = {
|
||||
|
||||
export const RenderProvider = ({ data, resolveSectionTitle, children }: RenderProviderProps) => {
|
||||
const rtl = isRTL(data.metadata.page.locale);
|
||||
const contextValue = useMemo<RenderContextValue>(
|
||||
() => ({ ...data, resolveSectionTitle, rtl }),
|
||||
[data, resolveSectionTitle, rtl],
|
||||
);
|
||||
|
||||
return <RenderContext.Provider value={{ ...data, resolveSectionTitle, rtl }}>{children}</RenderContext.Provider>;
|
||||
return <RenderContext.Provider value={contextValue}>{children}</RenderContext.Provider>;
|
||||
};
|
||||
|
||||
export const useRender = (): RenderContextValue => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
TemplateStyleSlot,
|
||||
TemplateStyleSlots,
|
||||
} from "./types";
|
||||
import { createContext, use } from "react";
|
||||
import { createContext, use, useMemo } from "react";
|
||||
|
||||
type TemplateContextValue = {
|
||||
styles: TemplateStyleSlots;
|
||||
@@ -60,9 +60,12 @@ export const TemplateProvider = ({
|
||||
features = EMPTY_FEATURES,
|
||||
children,
|
||||
}: TemplateProviderProps) => {
|
||||
return (
|
||||
<TemplateContext.Provider value={{ styles, featureStyles, colors, features }}>{children}</TemplateContext.Provider>
|
||||
const contextValue = useMemo<TemplateContextValue>(
|
||||
() => ({ styles, featureStyles, colors, features }),
|
||||
[colors, featureStyles, features, styles],
|
||||
);
|
||||
|
||||
return <TemplateContext.Provider value={contextValue}>{children}</TemplateContext.Provider>;
|
||||
};
|
||||
|
||||
export const TemplatePlacementProvider = ({
|
||||
|
||||
@@ -4,17 +4,11 @@ import type { StyleInput } from "./styles";
|
||||
import { Icon as PhosphorIcon } from "phosphor-icons-react-pdf/dynamic";
|
||||
import { Link as PdfLink, Text as PdfText, View } from "../../renderer";
|
||||
import { useTemplateIconSlot, useTemplateStyle } from "./context";
|
||||
import { safeTextStyle } from "./safe-text-style";
|
||||
import { composeLinkStyles, composeStyles } from "./styles";
|
||||
|
||||
const asStyleInput = (style: unknown): StyleInput => style as StyleInput;
|
||||
|
||||
export const safeTextStyle = {
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
flexShrink: 1,
|
||||
overflow: "hidden",
|
||||
} satisfies Style;
|
||||
|
||||
export const Div = ({ style, ...props }: ComponentProps<typeof View>) => {
|
||||
const divStyle = useTemplateStyle("div");
|
||||
|
||||
|
||||
@@ -72,9 +72,11 @@ const tryConvertPseudoBulletParagraph = (paragraphInnerHtml: string): string | n
|
||||
const cleaned = stripEmptyInlineWrappers(paragraphInnerHtml);
|
||||
if (!/<br\b/i.test(cleaned)) return null;
|
||||
|
||||
const segments = splitByBreaks(cleaned)
|
||||
.map((segment) => segment.trim())
|
||||
.filter((segment) => segment.length > 0);
|
||||
const segments: string[] = [];
|
||||
for (const segment of splitByBreaks(cleaned)) {
|
||||
const trimmed = segment.trim();
|
||||
if (trimmed.length > 0) segments.push(trimmed);
|
||||
}
|
||||
|
||||
if (segments.length < 2) return null;
|
||||
if (!segments.every((segment) => PSEUDO_BULLET_LEAD.test(segment))) return null;
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Html } from "react-pdf-html";
|
||||
import { useRender } from "../../context";
|
||||
import { Text as PdfText, View } from "../../renderer";
|
||||
import { useTemplateStyle } from "./context";
|
||||
import { safeTextStyle } from "./primitives";
|
||||
import { convertPseudoBulletParagraphs, normalizeRichTextHtml, richTextMarkClassName } from "./rich-text-html";
|
||||
import { renderRichTextParagraph, toRichTextStyleArray } from "./rich-text-renderers";
|
||||
import {
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
resolveRichTextBodyLineHeight,
|
||||
stripRichTextVerticalMargins,
|
||||
} from "./rich-text-spacing";
|
||||
import { safeTextStyle } from "./safe-text-style";
|
||||
import { composeStyles, mergeLinkStyles, mergeStyles } from "./styles";
|
||||
|
||||
const richListItemContentStackStyle = {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Style } from "@react-pdf/types";
|
||||
|
||||
export const safeTextStyle = {
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
flexShrink: 1,
|
||||
overflow: "hidden",
|
||||
} satisfies Style;
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"shadcn": "shadcn",
|
||||
"doctor": "react-doctor",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
@@ -43,6 +44,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260526.1",
|
||||
"postcss": "^8.5.15",
|
||||
"react-doctor": "^0.2.6",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ describe("Badge", () => {
|
||||
});
|
||||
|
||||
it("supports a custom render function", () => {
|
||||
render(<Badge render={(props) => <a {...props} href="/x" />}>link</Badge>);
|
||||
const anchor = screen.getByRole("link", { name: "link" });
|
||||
render(<Badge render={(props) => <a {...props} href="/x" aria-label="View profile" />}>View profile</Badge>);
|
||||
const anchor = screen.getByRole("link", { name: "View profile" });
|
||||
expect(anchor).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,9 +18,10 @@ type FormItemProps = React.ComponentProps<"div"> & { hasError?: boolean };
|
||||
|
||||
function FormItem({ className, hasError = false, ...props }: FormItemProps) {
|
||||
const id = React.useId();
|
||||
const contextValue = React.useMemo<FormItemContextValue>(() => ({ id, hasError }), [hasError, id]);
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id, hasError }}>
|
||||
<FormItemContext.Provider value={contextValue}>
|
||||
<div data-slot="form-item" className={cn("grid gap-1.5", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
|
||||
@@ -49,7 +49,6 @@ function InputGroupAddon({
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onKeyDown={(e) => {
|
||||
if (!(e.target instanceof Element) || !e.currentTarget.contains(e.target)) return;
|
||||
// Only respond to Space or Enter
|
||||
if (e.key !== " " && e.key !== "Enter") return;
|
||||
if (!(e.target as HTMLElement).closest("button")) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -268,6 +268,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
|
||||
@@ -61,8 +61,10 @@ export function ConfirmDialogProvider({ children }: { children: React.ReactNode
|
||||
setState((prev) => ({ ...prev, open: false, resolve: null }));
|
||||
}, [state.resolve]);
|
||||
|
||||
const contextValue = React.useMemo<ConfirmContextType>(() => ({ confirm }), [confirm]);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={{ confirm }}>
|
||||
<ConfirmContext.Provider value={contextValue}>
|
||||
{children}
|
||||
|
||||
<AlertDialog open={state.open} onOpenChange={(open) => !open && handleCancel()}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface CommonControlledStateProps<T> {
|
||||
value?: T;
|
||||
@@ -13,19 +13,17 @@ export function useControlledState<T, Rest extends unknown[] = []>(
|
||||
props: UseControlledStateProps<T, Rest>,
|
||||
): readonly [T, (next: T, ...args: Rest) => void] {
|
||||
const { value, defaultValue, onChange } = props;
|
||||
const isControlled = value !== undefined;
|
||||
|
||||
const [state, setInternalState] = useState<T>(value !== undefined ? value : (defaultValue as T));
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined) setInternalState(value);
|
||||
}, [value]);
|
||||
const [internalState, setInternalState] = useState<T>(value !== undefined ? value : (defaultValue as T));
|
||||
const state = isControlled ? (value as T) : internalState;
|
||||
|
||||
const setState = useCallback(
|
||||
(next: T, ...args: Rest) => {
|
||||
setInternalState(next);
|
||||
if (!isControlled) setInternalState(next);
|
||||
onChange?.(next, ...args);
|
||||
},
|
||||
[onChange],
|
||||
[isControlled, onChange],
|
||||
);
|
||||
|
||||
return [state, setState] as const;
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRef, useState, useSyncExternalStore } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
const MOBILE_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
|
||||
const [mediaQueryList] = useState(() => (typeof window === "undefined" ? null : window.matchMedia(MOBILE_QUERY)));
|
||||
const latestMatchesRef = useRef(mediaQueryList?.matches ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(MOBILE_QUERY);
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
setIsMobile(e.matches);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(mql.matches);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => {
|
||||
if (!mediaQueryList) return () => {};
|
||||
|
||||
return !!isMobile;
|
||||
const handleChange = (event: MediaQueryListEvent | { matches: boolean }) => {
|
||||
latestMatchesRef.current = event.matches;
|
||||
onStoreChange();
|
||||
};
|
||||
|
||||
mediaQueryList.addEventListener("change", handleChange);
|
||||
return () => mediaQueryList.removeEventListener("change", handleChange);
|
||||
},
|
||||
() => latestMatchesRef.current,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,8 +96,10 @@ export function PromptDialogProvider({ children }: { children: React.ReactNode }
|
||||
[handleConfirm],
|
||||
);
|
||||
|
||||
const contextValue = React.useMemo<PromptContextType>(() => ({ prompt }), [prompt]);
|
||||
|
||||
return (
|
||||
<PromptContext.Provider value={{ prompt }}>
|
||||
<PromptContext.Provider value={contextValue}>
|
||||
{children}
|
||||
|
||||
<AlertDialog open={state.open} onOpenChange={(open) => !open && handleCancel()}>
|
||||
|
||||
Generated
+799
-79
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -9,7 +9,8 @@ allowBuilds:
|
||||
bcrypt: true
|
||||
esbuild: true
|
||||
lefthook: true
|
||||
msgpackr-extract: true
|
||||
msw: true
|
||||
sharp: true
|
||||
overrides:
|
||||
postcss@<8.5.10: ^8.5.14
|
||||
postcss@<8.5.10: ^8.5.14
|
||||
|
||||
+2
-2
@@ -98,8 +98,8 @@
|
||||
"inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.env*"],
|
||||
"outputs": ["dist/**", ".vercel/**"]
|
||||
},
|
||||
"lint": {
|
||||
"dependsOn": ["^lint"]
|
||||
"doctor": {
|
||||
"dependsOn": ["^doctor"]
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["source"]
|
||||
|
||||
Reference in New Issue
Block a user