Takazudo Modular Styleguide

Shared/Link

Button Style Link
Default
External Link
Invert Color Link
Navigation Links
With Active Class

Component Source

link.tsx

import type { FunctionComponent, ComponentChildren } from 'preact';
import { usePathname } from '@/src/hooks/use-pathname';
import { usePageLoadingState } from './page-loading-state-provider';

interface LinkProps {
  children: ComponentChildren;
  to: string;
  activeClassName?: string;
  partiallyActive?: boolean;
  className?: string;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  onClick?: (event: any) => void;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  [key: string]: any;
}

// Link component with active-state detection and loading behavior
const Link: FunctionComponent<LinkProps> = ({
  children,
  to,
  activeClassName,
  partiallyActive,
  className = '',
  ...other
}) => {
  const pathname = usePathname();
  const { isPageLoading, setIsPageLoading } = usePageLoadingState();

  // Extract onClick handler from props to preserve it
  const { onClick, ...rest } = other;

  // Check if link is internal (starts with / but not //)
  const internal = /^\/(?!\/)/.test(to);

  // Check if link is active
  const isActive = partiallyActive ? pathname.startsWith(to) : pathname === to;
  const finalClassName =
    isActive && activeClassName ? `${className} ${activeClassName}` : className;

  if (internal) {
    return (
      <a
        href={to}
        className={finalClassName}
        onClick={(event: MouseEvent) => {
          // Call the original onClick handler first (if provided)
          if (onClick && typeof onClick === 'function') {
            onClick(event);
          }
          // If event was cancelled by the onClick handler, stop here
          if (event.defaultPrevented) {
            return;
          }
          // Allow default browser behavior for meta/ctrl + click (open in new tab)
          if (event.metaKey || event.ctrlKey) {
            return;
          }
          // Prevent click if page is already loading
          if (isPageLoading) {
            event.preventDefault();
            return;
          }
          // Skip loading state for same-path navigation (already on this page)
          if (pathname === to) {
            return;
          }

          // Scroll to top BEFORE navigation (while overlay is shown)
          // Use auto behavior to override CSS smooth scroll
          // This ensures scroll happens instantly before new content is visible
          window.scrollTo({ top: 0, left: 0, behavior: 'auto' });

          setIsPageLoading(true);
        }}
        {...rest}
      >
        {children}
      </a>
    );
  }

  // External link
  return (
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    <a href={to} className={finalClassName} rel="nofollow noopener noreferrer" {...(rest as any)}>
      {children}
    </a>
  );
};

export { Link };

Story Source

link.stories.tsx

import { Link } from './link';
import type { ControlsMap } from '../../sub-packages/styleguide-v2/src/data/control-types';

/**
 * Link Component
 *
 * A link component that handles both internal and external links.
 * Features:
 * - Automatic internal/external detection
 * - Active state styling with activeClassName
 * - Partial matching support for navigation
 * - Page loading state integration
 * - External links with nofollow/noopener
 */
export const meta = {
  title: 'Shared/Link',
};

export const controls: ControlsMap = {
  label: { type: 'text', default: '製品一覧を見る' },
  href: { type: 'text', default: '/products/' },
};

/**
 * Default - Internal link
 */
export const Default = {
  args: { label: '製品一覧を見る', href: '/products/' },
  render: ({ label, href }: { label: string; href: string }) => (
    <Link to={href} className="text-zd-link hover:underline">
      {label}
    </Link>
  ),
};

/**
 * External link
 */
export const ExternalLink = () => (
  <Link to="https://example.com" className="text-zd-link hover:underline">
    外部サイトへ
  </Link>
);

/**
 * With active class styling
 */
export const WithActiveClass = () => (
  <Link to="/notes/" className="text-zd-white" activeClassName="text-zd-link font-bold">
    記事一覧
  </Link>
);

/**
 * Navigation style links
 */
export const NavigationLinks = () => (
  <nav className="flex gap-hgap-md">
    <Link to="/" className="text-zd-white hover:text-zd-link transition-colors">
      ホーム
    </Link>
    <Link to="/products/" className="text-zd-white hover:text-zd-link transition-colors">
      製品
    </Link>
    <Link to="/brands/" className="text-zd-white hover:text-zd-link transition-colors">
      ブランド
    </Link>
    <Link to="/notes/" className="text-zd-white hover:text-zd-link transition-colors">
      記事
    </Link>
    <Link to="/contact/" className="text-zd-white hover:text-zd-link transition-colors">
      お問い合わせ
    </Link>
  </nav>
);

/**
 * Invert color link style (used in breadcrumbs)
 */
export const InvertColorLink = () => (
  <Link to="/brands/" className="zd-invert-color-link">
    ブランド一覧
  </Link>
);

/**
 * Button-style link
 */
export const ButtonStyleLink = () => (
  <Link
    to="/contact/"
    className="inline-block px-hgap-md py-vgap-sm bg-zd-link text-zd-white rounded hover:brightness-110 transition-all"
  >
    お問い合わせ
  </Link>
);