feat: update links for improved accessibility

This commit is contained in:
Amruth Pillai
2026-05-26 13:09:30 +02:00
parent dd1e37e579
commit 8da780c868
43 changed files with 952 additions and 189 deletions
+2
View File
@@ -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",
+2
View File
@@ -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", () => {
+2 -2
View File
@@ -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"
>
+4 -9
View File
@@ -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>
);
+3 -1
View File
@@ -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()}>
+6 -8
View File
@@ -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;
+17 -12
View File
@@ -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,
);
}
+3 -1
View File
@@ -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>
+1 -1
View File
@@ -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)}
+2 -2
View File
@@ -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>
+1
View File
@@ -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>