Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 102 additions & 12 deletions src/components/HomePage/Card/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import React from 'react';
import React, { useCallback, useRef } from 'react';

import classNames from 'classnames';
import { useRouter } from 'next/router';

import styles from './Card.module.scss';

import Link from '@/dls/Link/Link';

interface CardProps {
children: React.ReactNode;
link?: string;
Expand All @@ -23,15 +22,106 @@ const Card: React.FC<CardProps> = ({
linkClassName,
onClick,
}) => {
if (link) {
return (
<Link href={link} isNewTab={isNewTab} className={linkClassName} onClick={onClick}>
<div className={classNames(className, styles.card, styles.cardWithLink)}>{children}</div>
</Link>
);
}

return <div className={classNames(className, styles.card)}>{children}</div>;
const router = useRouter();
const cardRef = useRef<HTMLDivElement>(null);

/**
* Determine if an event target is a nested interactive element that should keep control.
*/
const shouldIgnoreEvent = useCallback(
(target: EventTarget | null) => {
if (!link) return false;
if (!(target instanceof HTMLElement)) return false;
if (!cardRef.current) return false;
const interactiveElement = target.closest(
'a, button, input, textarea, select, [role="button"], [role="link"]',
);
return Boolean(interactiveElement && interactiveElement !== cardRef.current);
},
[link],
);

/**
* Trigger navigation using Next.js for internal routes or the browser for external URLs.
*/
const navigate = useCallback(() => {
if (!link) return;

if (isNewTab) {
if (typeof window !== 'undefined') {
window.open(link, '_blank', 'noopener,noreferrer');
}
return;
}

const isInternal = link.startsWith('/') || link.startsWith('#');

if (isInternal) {
router.push(link);
return;
}

if (typeof window !== 'undefined') {
window.location.href = link;
}
}, [isNewTab, link, router]);

/**
* Handle mouse clicks on the card while respecting nested controls.
*/
const handleCardClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!link) {
onClick?.();
return;
}

onClick?.();

if (event.defaultPrevented || shouldIgnoreEvent(event.target)) return;

event.preventDefault();

navigate();
},
[link, navigate, onClick, shouldIgnoreEvent],
);

/**
* Provide keyboard accessibility for the card when it acts like a link.
*/
const handleCardKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (!link) return;

if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick?.();
navigate();
}
},
[link, navigate, onClick],
);

return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
ref={cardRef}
role={link ? 'link' : undefined}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={link ? 0 : undefined}
className={classNames(
styles.card,
className,
{ [styles.cardWithLink]: Boolean(link) },
link ? linkClassName : undefined,
)}
onClick={link ? handleCardClick : onClick}
onKeyDown={link ? handleCardKeyDown : undefined}
Comment on lines +106 to +120

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replacing anchor with div removes browser link affordances

The new card renders a div with role="link" and drives navigation via router.push/window.location instead of the previous <Link> wrapper. While this prevents the hydration warning, it removes native link semantics: users can no longer open the card in a new tab with middle‑click or Cmd/Ctrl‑click, there is no context‑menu “open link” option, and Next.js’ Link prefetching is lost. These regressions affect accessibility and performance for every card rendered with a link prop.

Useful? React with 👍 / 👎.

>
{children}
</div>
);
};

export default Card;