Merge pull request 'Feature/image dialog' (#8) from feature/image-dialog into main
Build and push / build (push) Successful in 1m7s
Build and push / build (push) Successful in 1m7s
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -19,9 +19,9 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
params: Promise<{ slug?: string[] }>;
|
params: Promise<{ slug?: string[] }>;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Portfolio page — one route per section, sections list always visible.
|
* Portfolio page — one route per section, sections list always visible.
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
|||||||
*/
|
*/
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" data-scroll-behavior="smooth">
|
||||||
<body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}>
|
<body className={`${fraunces.variable} ${publicSans.variable} pb-footer md:pb-footer-wide`}>
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -55,10 +55,5 @@ describe('SectionAccordion', () => {
|
|||||||
render(<SectionAccordion {...activeProps} />);
|
render(<SectionAccordion {...activeProps} />);
|
||||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('content wrapper has section-content class', () => {
|
|
||||||
const { container } = render(<SectionAccordion {...activeProps} />);
|
|
||||||
expect(container.querySelector('.section-content')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ export function SectionAccordion({ number, title, id, isActive, href, children }
|
|||||||
<section id={id} className="scroll-mt-8">
|
<section id={id} className="scroll-mt-8">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<div className="mb-6 sm:mb-12">
|
<div className="mb-6 sm:mb-12">
|
||||||
<ViewTransitionWrapper name="section-content">
|
<ViewTransitionWrapper name="section-title">
|
||||||
<div className="mb-6 sm:mb-16">
|
<div className="mb-6 sm:mb-12">
|
||||||
<h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1>
|
<h1 className="font-heading font-black text-xl sm:text-section-title leading-[1.2] mb-0">{heading}</h1>
|
||||||
</div>
|
</div>
|
||||||
</ViewTransitionWrapper>
|
</ViewTransitionWrapper>
|
||||||
<ViewTransitionWrapper name="section-body">
|
<ViewTransitionWrapper name="section-body">
|
||||||
<div className="section-content">{children}</div>
|
<div>{children}</div>
|
||||||
</ViewTransitionWrapper>
|
</ViewTransitionWrapper>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
import { Badge, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Job title
|
* Job title
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +25,7 @@ type Props = {
|
|||||||
* Additional CSS classes forwarded to the card
|
* Additional CSS classes forwarded to the card
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Work experience card with sidebar layout.
|
* Work experience card with sidebar layout.
|
||||||
@@ -37,9 +37,9 @@ export function ExperienceCard({ title, company, period, description, stack, cla
|
|||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardSidebar
|
<CardSidebar
|
||||||
sidebar={
|
sidebar={
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-3 sm:gap-4">
|
||||||
<p className="text-sm font-medium brutal-border-left pl-3">{period}</p>
|
<p className="text-sm font-medium brutal-border-left pl-3">{period}</p>
|
||||||
<p className="text-base font-medium">{company}</p>
|
<p className="text-lg font-black">{company}</p>
|
||||||
{stack.length > 0 && (
|
{stack.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{stack.map((tech) => (
|
{stack.map((tech) => (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Image from 'next/image';
|
|||||||
import { Card, RichText } from '$shared/ui';
|
import { Card, RichText } from '$shared/ui';
|
||||||
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
|
import { ProjectMetadata } from '../ProjectMetadata/ProjectMetadata';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Project name
|
* Project name
|
||||||
*/
|
*/
|
||||||
@@ -36,7 +36,7 @@ type Props = {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
reverse?: boolean;
|
reverse?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-width detailed project card with metadata sidebar.
|
* Full-width detailed project card with metadata sidebar.
|
||||||
|
|||||||
@@ -124,5 +124,11 @@ describe('ProjectCard', () => {
|
|||||||
const imgWrapper = container.querySelector('img')?.parentElement;
|
const imgWrapper = container.querySelector('img')?.parentElement;
|
||||||
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
|
expect(imgWrapper).toHaveClass('aspect-video', 'overflow-hidden', 'brutal-border');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('image is wrapped in a lightbox button with cursor-zoom-in', () => {
|
||||||
|
render(<ProjectCard {...DEFAULT_PROPS} imageUrl="/project.jpg" />);
|
||||||
|
const btn = screen.getByRole('button', { name: DEFAULT_PROPS.title });
|
||||||
|
expect(btn).toHaveClass('cursor-zoom-in');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import Image from 'next/image';
|
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import { Badge, Button, Card, CardSidebar, CardTitle, RichText } from '$shared/ui';
|
import { Badge, Button, Card, CardSidebar, CardTitle, ImageLightbox, RichText } from '$shared/ui';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Project name
|
* Project name
|
||||||
*/
|
*/
|
||||||
@@ -27,14 +26,20 @@ type Props = {
|
|||||||
* Optional preview image URL
|
* Optional preview image URL
|
||||||
*/
|
*/
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
};
|
/**
|
||||||
|
* Skip lazy-loading the preview image. Set true for above-the-fold cards
|
||||||
|
* (typically the first card in the list) to improve LCP.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
priority?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Project card with sidebar layout.
|
* Project card with sidebar layout.
|
||||||
* Sidebar: year badge, stack tags, View Project button.
|
* Sidebar: year badge, stack tags, View Project button.
|
||||||
* Main: title, optional image, description.
|
* Main: title, optional image, description.
|
||||||
*/
|
*/
|
||||||
export function ProjectCard({ title, year, description, tags, url, imageUrl }: Props) {
|
export function ProjectCard({ title, year, description, tags, url, imageUrl, priority = false }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
|
<Card className={cn('group hover:shadow-brutal-xl transition-shadow duration-300')}>
|
||||||
<CardSidebar
|
<CardSidebar
|
||||||
@@ -50,7 +55,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button href={url} variant="primary" size="sm" className="w-full">
|
<Button href={url} variant="primary" size="sm" className="self-start lg:w-full lg:self-auto text-center">
|
||||||
View Project
|
View Project
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,9 +64,7 @@ export function ProjectCard({ title, year, description, tags, url, imageUrl }: P
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<CardTitle className="font-heading">{title}</CardTitle>
|
<CardTitle className="font-heading">{title}</CardTitle>
|
||||||
{imageUrl && (
|
{imageUrl && (
|
||||||
<div className="brutal-border aspect-video bg-blue overflow-hidden relative">
|
<ImageLightbox src={imageUrl} alt={title} priority={priority} className="brutal-border bg-blue" />
|
||||||
<Image src={imageUrl} alt={title} fill className="object-cover" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<RichText html={description} />
|
<RichText html={description} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Project year
|
* Project year
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +17,7 @@ type Props = {
|
|||||||
* Additional CSS classes
|
* Additional CSS classes
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar metadata display for a project: year, role, and stack.
|
* Sidebar metadata display for a project: year, role, and stack.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* CSS classes on the svg element
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close / X icon (Lucide).
|
||||||
|
*/
|
||||||
|
export function CloseIcon({ className }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden={true}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M18 6 6 18" />
|
||||||
|
<path d="m6 6 12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* CSS classes on the svg element
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magnify / search icon (Lucide).
|
||||||
|
*/
|
||||||
|
export function MagnifyIcon({ className }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden={true}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="m21 21-4.34-4.34" />
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { CloseIcon } from './CloseIcon';
|
||||||
|
export { MagnifyIcon } from './MagnifyIcon';
|
||||||
+104
-42
@@ -96,7 +96,6 @@
|
|||||||
--duration-spring: 220ms;
|
--duration-spring: 220ms;
|
||||||
--delay-normal: 200ms;
|
--delay-normal: 200ms;
|
||||||
--slide-section-body-in: clamp(1.25rem, 5vw, 3rem);
|
--slide-section-body-in: clamp(1.25rem, 5vw, 3rem);
|
||||||
--slide-section-body-out: clamp(0.5rem, 1.5vw, 0.75rem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -154,6 +153,10 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: var(--font-size);
|
font-size: var(--font-size);
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
/* Reserve scrollbar gutter so locking body scroll (e.g. when a modal
|
||||||
|
* opens) doesn't widen the viewport and shift fixed elements. */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -164,30 +167,16 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subtle blue-tinted grain on parchment */
|
/* Page-wide blue dot grain overlay. z-index 100 puts it above the footer
|
||||||
|
* (z-50) so the grain reads as continuous across the entire viewport;
|
||||||
|
* pointer-events: none keeps everything clickable through it. */
|
||||||
body::before {
|
body::before {
|
||||||
|
@apply grain-pattern;
|
||||||
content: "";
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
|
||||||
repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
transparent,
|
|
||||||
transparent 2px,
|
|
||||||
rgba(4, 28, 243, 0.015) 2px,
|
|
||||||
rgba(4, 28, 243, 0.015) 4px
|
|
||||||
),
|
|
||||||
repeating-linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent,
|
|
||||||
transparent 2px,
|
|
||||||
rgba(4, 28, 243, 0.015) 2px,
|
|
||||||
rgba(4, 28, 243, 0.015) 4px
|
|
||||||
);
|
|
||||||
opacity: 0.6;
|
|
||||||
display: block;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 1;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -272,6 +261,18 @@
|
|||||||
@utility brutal-border-right {
|
@utility brutal-border-right {
|
||||||
border-right: var(--border-width) solid var(--blue);
|
border-right: var(--border-width) solid var(--blue);
|
||||||
}
|
}
|
||||||
|
/* Border drawn as an outline — painted after children, so an image's
|
||||||
|
* subpixel paint bleed can't cover it. Doesn't take layout space; the
|
||||||
|
* ancestor must not have overflow:hidden or the outline gets clipped. */
|
||||||
|
@utility brutal-outline {
|
||||||
|
outline: var(--border-width) solid var(--blue);
|
||||||
|
}
|
||||||
|
/* Tiled blue dot pattern — applied to body::before (page-wide) and reusable
|
||||||
|
* on any surface that should share the same paper-grain texture. The SVG
|
||||||
|
* tile is rasterized once and composited cheaply via repeating background. */
|
||||||
|
@utility grain-pattern {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4'%3E%3Ccircle cx='1' cy='1' r='1' fill='%23041cf3' opacity='0.10'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
/* Apply Fraunces variable axes to non-heading elements using the heading font */
|
/* Apply Fraunces variable axes to non-heading elements using the heading font */
|
||||||
.font-wonk {
|
.font-wonk {
|
||||||
font-variation-settings:
|
font-variation-settings:
|
||||||
@@ -340,14 +341,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Cross-section view transition (navigation between sections) */
|
/* Cross-section view transition (navigation between sections) */
|
||||||
::view-transition-old(section-content) {
|
::view-transition-old(section-title) {
|
||||||
animation-name: section-fade-out;
|
animation-name: section-fade-out;
|
||||||
animation-duration: var(--duration-normal);
|
animation-duration: var(--duration-normal);
|
||||||
animation-timing-function: var(--ease-default);
|
animation-timing-function: var(--ease-default);
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-new(section-content) {
|
::view-transition-new(section-title) {
|
||||||
animation-name: section-fade-in;
|
animation-name: section-fade-in;
|
||||||
animation-duration: var(--duration-spring);
|
animation-duration: var(--duration-spring);
|
||||||
animation-timing-function: var(--ease-spring);
|
animation-timing-function: var(--ease-spring);
|
||||||
@@ -376,12 +377,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section body slide-in from right */
|
/* Disable group geometry interpolation — OLD and NEW live at different scroll
|
||||||
|
* positions, so morphing the container drags the slide-in across the viewport.
|
||||||
|
* Let old/new each animate at their own positions instead. */
|
||||||
|
::view-transition-group(section-body) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section body — snap OLD out and slide NEW in. Running an explicit OLD
|
||||||
|
* animation left a visible ghost of the previous section's content sitting
|
||||||
|
* behind the incoming slide; hiding it immediately keeps the transition
|
||||||
|
* clean and prevents the two-content overlap. */
|
||||||
::view-transition-old(section-body) {
|
::view-transition-old(section-body) {
|
||||||
animation-name: section-body-out;
|
animation: none;
|
||||||
animation-duration: var(--duration-normal);
|
opacity: 0;
|
||||||
animation-timing-function: var(--ease-default);
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-new(section-body) {
|
::view-transition-new(section-body) {
|
||||||
@@ -389,20 +398,11 @@
|
|||||||
animation-duration: var(--duration-spring);
|
animation-duration: var(--duration-spring);
|
||||||
animation-timing-function: var(--ease-spring);
|
animation-timing-function: var(--ease-spring);
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
|
/* Hold the start-state for this delay before the slide-in begins — gives
|
||||||
|
* the snap-out a beat to register visually before new content arrives. */
|
||||||
animation-delay: var(--delay-normal);
|
animation-delay: var(--delay-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes section-body-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0) scale(1);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(calc(-1 * var(--slide-section-body-out))) scale(0.98);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes section-body-in {
|
@keyframes section-body-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -414,11 +414,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep footer above sliding section-body during view transitions */
|
/* Page-load entry animation for the footer. Previously also carried
|
||||||
|
* `view-transition-name: site-footer` to layer above section-body's slide
|
||||||
|
* group, but the section-body group now has `animation: none` (purely
|
||||||
|
* horizontal slide of OLD/NEW snapshots), so the footer can live in the root
|
||||||
|
* snapshot during transitions — which also lets the lightbox dialog group
|
||||||
|
* stack cleanly above it. */
|
||||||
.footer-vt {
|
.footer-vt {
|
||||||
view-transition-name: site-footer;
|
animation: footer-enter var(--duration-slow) var(--ease-spring) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-group(site-footer) {
|
@keyframes footer-enter {
|
||||||
z-index: 10;
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox dialog backdrop — flat cream wash. No filter, no gradient.
|
||||||
|
* Cheapest possible; Firefox-friendly during view transitions. */
|
||||||
|
dialog.lightbox::backdrop {
|
||||||
|
background-color: color-mix(in srgb, var(--cream) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Give the lightbox dialog its own view-transition group so it can stack
|
||||||
|
* above the footer's named group (z=10) during the open/close transition.
|
||||||
|
* Closed dialogs have `display: none` (UA) and don't get snapshotted, so
|
||||||
|
* sharing the name across all `.lightbox` dialogs at rest is harmless —
|
||||||
|
* only the open one participates in any given transition. */
|
||||||
|
dialog.lightbox {
|
||||||
|
view-transition-name: lightbox-dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(lightbox-dialog) {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The image wrapper (and the thumb during the OLD snapshot of an open
|
||||||
|
* transition) shares this static name — imperatively assigned only during
|
||||||
|
* the morph, so there's never more than one element holding it at a time.
|
||||||
|
* z=30 keeps it above the dialog (z=20) and the footer (z=10) during VT. */
|
||||||
|
::view-transition-group(lightbox-frame) {
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox image sizing — leaves vertical headroom for the fixed close button
|
||||||
|
* (sits at top-3, ~2.5rem tall). 8rem total = ~4rem top/bottom after centering,
|
||||||
|
* so the button never overlaps the image.
|
||||||
|
* box-sizing: content-box so max-w/max-h apply to the image pixels and the
|
||||||
|
* 3px brutal-border sits outside them — avoids subpixel clipping of the
|
||||||
|
* border by the dialog's overflow:hidden when both have border-box. */
|
||||||
|
@utility lightbox-image {
|
||||||
|
box-sizing: content-box;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
max-height: calc(100vh - 10rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disable transition animation for Firefox
|
||||||
|
* since it isn't supported yet */
|
||||||
|
@supports (-moz-appearance: none) {
|
||||||
|
::view-transition-group(lightbox-frame),
|
||||||
|
::view-transition-old(lightbox-frame),
|
||||||
|
::view-transition-new(lightbox-frame) {
|
||||||
|
animation-duration: 0s !important;
|
||||||
|
animation-delay: 0s !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ describe('Button', () => {
|
|||||||
});
|
});
|
||||||
it('applies outline variant', () => {
|
it('applies outline variant', () => {
|
||||||
render(<Button variant="outline">Go</Button>);
|
render(<Button variant="outline">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
expect(screen.getByRole('button')).toHaveClass('bg-cream');
|
||||||
});
|
});
|
||||||
it('applies ghost variant', () => {
|
it('applies ghost variant', () => {
|
||||||
render(<Button variant="ghost">Go</Button>);
|
render(<Button variant="ghost">Go</Button>);
|
||||||
expect(screen.getByRole('button')).toHaveClass('bg-cream');
|
expect(screen.getByRole('button')).toHaveClass('bg-transparent');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe('sizes', () => {
|
describe('sizes', () => {
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ const VARIANTS = {
|
|||||||
secondary:
|
secondary:
|
||||||
'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
|
'brutal-border bg-blue text-cream shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
|
||||||
outline:
|
outline:
|
||||||
'brutal-border bg-transparent text-blue shadow-brutal hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-md active:translate-x-0.5 active:translate-y-0.5 active:shadow-brutal-xs',
|
|
||||||
ghost:
|
|
||||||
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
|
'brutal-border border-blue/35 bg-cream text-blue hover:border-blue hover:bg-blue/10 active:bg-blue active:text-cream',
|
||||||
|
ghost:
|
||||||
|
'brutal-border bg-transparent text-blue hover:-translate-x-0.5 hover:-translate-y-0.5 active:translate-x-0.5 active:translate-y-0.5',
|
||||||
} as const satisfies Record<ButtonVariant, string>;
|
} as const satisfies Record<ButtonVariant, string>;
|
||||||
|
|
||||||
const SIZES = {
|
const SIZES = {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ImageLightbox } from './ui/ImageLightbox';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
|
const meta: Meta<typeof ImageLightbox> = {
|
||||||
|
title: 'Shared/ImageLightbox',
|
||||||
|
component: ImageLightbox,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof ImageLightbox>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
src: 'https://picsum.photos/800/450',
|
||||||
|
alt: 'Sample project image',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div className="p-8 max-w-2xl">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
|
// jsdom does not implement dialog methods — mock them
|
||||||
|
beforeAll(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal = vi.fn();
|
||||||
|
HTMLDialogElement.prototype.close = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_PROPS = { src: '/project.jpg', alt: 'My Project' };
|
||||||
|
|
||||||
|
describe('ImageLightbox', () => {
|
||||||
|
describe('thumbnail', () => {
|
||||||
|
it('renders a thumbnail image', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
expect(screen.getByRole('img', { name: 'My Project' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('thumbnail button has cursor-zoom-in', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const btn = screen.getByRole('button', { name: 'My Project' });
|
||||||
|
expect(btn).toHaveClass('cursor-zoom-in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards className to the thumbnail button', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} className="extra-class" />);
|
||||||
|
expect(screen.getByRole('button', { name: 'My Project' })).toHaveClass('extra-class');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dialog', () => {
|
||||||
|
it('clicking the thumbnail opens the dialog', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
|
||||||
|
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the close button closes the dialog', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' })); // open first
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /close/i, hidden: true }));
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking the backdrop (dialog element itself) closes the dialog', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent.click(dialog, { target: dialog });
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking inside the dialog (not backdrop) does not close', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
const inner = dialog.querySelector('img') as HTMLImageElement;
|
||||||
|
fireEvent.click(inner);
|
||||||
|
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dialog has accessible label matching alt text', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My Project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening the lightbox blocks body scroll', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
|
||||||
|
expect(document.body.style.overflow).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closing the lightbox restores body scroll', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'My Project' }));
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('close'));
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close button is positioned fixed', () => {
|
||||||
|
render(<ImageLightbox {...DEFAULT_PROPS} />);
|
||||||
|
const closeBtn = screen.getByRole('button', { name: /close/i, hidden: true });
|
||||||
|
expect(closeBtn).toHaveClass('fixed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { type SyntheticEvent, useRef } from 'react';
|
||||||
|
import { CloseIcon } from '$shared/assets/icons';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
import { Button } from '$shared/ui/Button';
|
||||||
|
import { Modal, type ModalHandle } from '$shared/ui/Modal';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
/**
|
||||||
|
* Image source URL
|
||||||
|
*/
|
||||||
|
src: string;
|
||||||
|
/**
|
||||||
|
* Image alt text, also used as the dialog accessible label
|
||||||
|
*/
|
||||||
|
alt: string;
|
||||||
|
/**
|
||||||
|
* CSS classes forwarded to the thumbnail button wrapper
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Skip lazy-loading and preload the thumbnail. Set true for above-the-fold
|
||||||
|
* images to improve LCP.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
priority?: boolean;
|
||||||
|
/**
|
||||||
|
* Responsive `sizes` attribute for the thumbnail. Without this, next/image
|
||||||
|
* `fill` defaults to `100vw` and the browser fetches the largest srcset
|
||||||
|
* variant. Tune to match the actual rendered width at each breakpoint.
|
||||||
|
* @default '(min-width: 1024px) 56rem, 100vw'
|
||||||
|
*/
|
||||||
|
sizes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clickable image thumbnail that opens a fullscreen brutalist dialog on click.
|
||||||
|
*
|
||||||
|
* Uses the View Transitions API to morph the thumbnail's rect into the dialog
|
||||||
|
* frame's rect (and back on close). The view-transition-name is seated
|
||||||
|
* just-in-time around each transition rather than living on the thumb at rest
|
||||||
|
* — a persistent name would isolate the thumb from any parent transition
|
||||||
|
* (e.g. the section-body slide-in), causing the image to snap into place
|
||||||
|
* while the rest of the section animates.
|
||||||
|
*/
|
||||||
|
export function ImageLightbox({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
priority = false,
|
||||||
|
sizes = '(min-width: 1024px) 56rem, 100vw',
|
||||||
|
}: Props) {
|
||||||
|
const modalRef = useRef<ModalHandle>(null);
|
||||||
|
const thumbRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dialogFrameRef = useRef<HTMLDivElement>(null);
|
||||||
|
/* Shared static name across all instances. Only one dialog can be open at a
|
||||||
|
* time (showModal is browser-exclusive), and we set the name imperatively
|
||||||
|
* only during a transition — so at any snapshot, exactly one element has it. */
|
||||||
|
const vtName = 'lightbox-frame';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the view-transition-name from both thumb and dialog frame. Called
|
||||||
|
* after the lightbox close transition settles (and as a safety net on any
|
||||||
|
* unexpected close path) so the thumb rejoins parent transitions.
|
||||||
|
*/
|
||||||
|
function clearVtNames() {
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `mutate` inside a view transition when supported; falls back to a
|
||||||
|
* plain synchronous call otherwise (Firefox without VT support, jsdom).
|
||||||
|
* Returns the transition handle so callers can await `finished` for cleanup.
|
||||||
|
*/
|
||||||
|
function withTransition(mutate: () => void): { finished: Promise<void> } | null {
|
||||||
|
const doc = document as Document & {
|
||||||
|
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
||||||
|
};
|
||||||
|
if (typeof doc.startViewTransition === 'function') {
|
||||||
|
return doc.startViewTransition(mutate);
|
||||||
|
}
|
||||||
|
mutate();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
/* Seat the name on the thumb *before* startViewTransition so it's
|
||||||
|
* captured in the OLD snapshot. The thumb otherwise carries no vt-name. */
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = vtName;
|
||||||
|
}
|
||||||
|
withTransition(() => {
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = vtName;
|
||||||
|
}
|
||||||
|
modalRef.current?.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
const transition = withTransition(() => {
|
||||||
|
if (dialogFrameRef.current) {
|
||||||
|
dialogFrameRef.current.style.viewTransitionName = '';
|
||||||
|
}
|
||||||
|
if (thumbRef.current) {
|
||||||
|
thumbRef.current.style.viewTransitionName = vtName;
|
||||||
|
}
|
||||||
|
modalRef.current?.close();
|
||||||
|
});
|
||||||
|
/* Drop the name from the thumb once the transition settles. Otherwise the
|
||||||
|
* thumb stays its own snapshot until the next open, isolated from any
|
||||||
|
* parent transition that runs in the meantime. */
|
||||||
|
if (transition) {
|
||||||
|
transition.finished.finally(clearVtNames);
|
||||||
|
} else {
|
||||||
|
clearVtNames();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercept ESC so it also runs through our view-transition-wrapped close.
|
||||||
|
* Without this, ESC would snap the dialog away without the morph.
|
||||||
|
*/
|
||||||
|
function handleCancel(e: SyntheticEvent<HTMLDialogElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={thumbRef}
|
||||||
|
type="button"
|
||||||
|
onClick={open}
|
||||||
|
aria-label={alt}
|
||||||
|
className={cn('relative block w-full aspect-video overflow-hidden cursor-zoom-in', className)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
fill
|
||||||
|
loading={priority ? 'eager' : undefined}
|
||||||
|
priority={priority}
|
||||||
|
sizes={sizes}
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
ref={modalRef}
|
||||||
|
aria-label={alt}
|
||||||
|
className="lightbox bg-cream overflow-visible"
|
||||||
|
onClose={clearVtNames}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
onBackdropClose={close}
|
||||||
|
>
|
||||||
|
{/* Wrapper carries the border as an `outline` (not `border`) — paint
|
||||||
|
* order is border→children→outline, so an `outline` is drawn ON TOP
|
||||||
|
* of any subpixel image bleed and stays fully visible. The dialog
|
||||||
|
* uses `overflow-visible` because outline can be clipped by an
|
||||||
|
* ancestor's overflow:hidden.
|
||||||
|
* The wrapper is the named VT element so the brutalist frame
|
||||||
|
* participates in the morph.
|
||||||
|
* aria-hidden: the dialog element itself carries the accessible label. */}
|
||||||
|
<div ref={dialogFrameRef} className="brutal-outline block w-fit">
|
||||||
|
{/* Explicit width/height are placeholders next/image requires when not using `fill`; CSS
|
||||||
|
* (`lightbox-image` max-w/max-h + `w-auto h-auto`) drives the actual rendered size, so
|
||||||
|
* the dialog still hugs the image's intrinsic dimensions (capped at viewport bounds).
|
||||||
|
* `sizes="100vw"` hints the browser to fetch the srcset variant matching viewport width. */}
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={2000}
|
||||||
|
height={2000}
|
||||||
|
loading="lazy"
|
||||||
|
sizes="100vw"
|
||||||
|
aria-hidden={true}
|
||||||
|
className="lightbox-image block w-auto h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={close} aria-label="Close image" className="fixed top-3 right-3">
|
||||||
|
<CloseIcon />
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import parse from 'html-react-parser';
|
import parse from 'html-react-parser';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* SVG markup string to inline as React elements
|
* SVG markup string to inline as React elements
|
||||||
*/
|
*/
|
||||||
@@ -10,7 +10,7 @@ type Props = {
|
|||||||
* Additional CSS classes on the wrapper span
|
* Additional CSS classes on the wrapper span
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses an SVG markup string into React elements.
|
* Parses an SVG markup string into React elements.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Modal, type ModalHandle } from './ui/Modal';
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { Button } from '../../Button';
|
||||||
|
import { Modal, type ModalHandle } from './Modal';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Modal> = {
|
||||||
|
title: 'Shared/Modal',
|
||||||
|
component: Modal,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof Modal>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperative trigger — Storybook can't drive a ref-based API directly, so each
|
||||||
|
* story renders its own trigger button that calls `modalRef.current?.open()`.
|
||||||
|
*/
|
||||||
|
function TriggerWrapper({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||||
|
const modalRef = useRef<ModalHandle>(null);
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<Button onClick={() => modalRef.current?.open()}>Open modal</Button>
|
||||||
|
<Modal ref={modalRef} aria-label="Story modal" className={className}>
|
||||||
|
<div className="p-8 max-w-md">
|
||||||
|
{children}
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => modalRef.current?.close()}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => (
|
||||||
|
<TriggerWrapper className="bg-cream brutal-border">
|
||||||
|
<h3 className="mb-2">Default modal</h3>
|
||||||
|
<p>Native dialog with scroll lock, backdrop-click close, and ESC close.</p>
|
||||||
|
</TriggerWrapper>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Brutalist: Story = {
|
||||||
|
render: () => (
|
||||||
|
<TriggerWrapper className="bg-blue text-cream brutal-border">
|
||||||
|
<h3 className="mb-2 text-cream">Brutalist modal</h3>
|
||||||
|
<p className="text-cream">
|
||||||
|
Blue background, hard border. Style is fully driven by the className you pass — Modal stays neutral.
|
||||||
|
</p>
|
||||||
|
</TriggerWrapper>
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { createRef } from 'react';
|
||||||
|
import { Modal, type ModalHandle } from './Modal';
|
||||||
|
|
||||||
|
// jsdom does not implement dialog methods — mock them
|
||||||
|
beforeAll(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal = vi.fn();
|
||||||
|
HTMLDialogElement.prototype.close = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Modal', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(<Modal aria-label="Test">child content</Modal>);
|
||||||
|
expect(screen.getByText('child content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards className to dialog element', () => {
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" className="extra-class">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
expect(document.querySelector('dialog')).toHaveClass('extra-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets aria-label on dialog', () => {
|
||||||
|
render(<Modal aria-label="My modal">x</Modal>);
|
||||||
|
expect(document.querySelector('dialog')).toHaveAttribute('aria-label', 'My modal');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('imperative API', () => {
|
||||||
|
it('open() calls showModal', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.open();
|
||||||
|
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close() calls dialog.close', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.close();
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('scroll lock', () => {
|
||||||
|
it('locks body scroll on open()', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.open();
|
||||||
|
expect(document.body.style.overflow).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores body scroll when the dialog close event fires', () => {
|
||||||
|
const ref = createRef<ModalHandle>();
|
||||||
|
render(
|
||||||
|
<Modal ref={ref} aria-label="Test">
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
ref.current?.open();
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('close'));
|
||||||
|
expect(document.body.style.overflow).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('backdrop interaction', () => {
|
||||||
|
it('closes on backdrop click (dialog element itself)', () => {
|
||||||
|
render(<Modal aria-label="Test">x</Modal>);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent.click(dialog, { target: dialog });
|
||||||
|
expect(HTMLDialogElement.prototype.close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not close on click inside content', () => {
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test">
|
||||||
|
<span>inner</span>
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('inner'));
|
||||||
|
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes backdrop click through onBackdropClose when provided (skips default close)', () => {
|
||||||
|
const onBackdropClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" onBackdropClose={onBackdropClose}>
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent.click(dialog, { target: dialog });
|
||||||
|
expect(onBackdropClose).toHaveBeenCalledOnce();
|
||||||
|
expect(HTMLDialogElement.prototype.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('callbacks', () => {
|
||||||
|
it('invokes onClose when close event fires', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" onClose={onClose}>
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('close'));
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onCancel when cancel event fires', () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<Modal aria-label="Test" onCancel={onCancel}>
|
||||||
|
x
|
||||||
|
</Modal>,
|
||||||
|
);
|
||||||
|
const dialog = document.querySelector('dialog') as HTMLDialogElement;
|
||||||
|
fireEvent(dialog, new Event('cancel'));
|
||||||
|
expect(onCancel).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type DialogHTMLAttributes,
|
||||||
|
forwardRef,
|
||||||
|
type KeyboardEvent,
|
||||||
|
type MouseEvent,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
|
export type ModalHandle = {
|
||||||
|
/**
|
||||||
|
* Opens the dialog as a modal and locks body scroll.
|
||||||
|
*/
|
||||||
|
open: () => void;
|
||||||
|
/**
|
||||||
|
* Closes the dialog. Body scroll is restored on the native `close` event,
|
||||||
|
* so any close path (ESC, backdrop, this method) restores it.
|
||||||
|
*/
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Props extends DialogHTMLAttributes<HTMLDialogElement> {
|
||||||
|
/**
|
||||||
|
* Called when the user activates the backdrop (click or Enter/Space).
|
||||||
|
* Replaces the default close — useful when the close path must be wrapped
|
||||||
|
* (e.g. in a view transition). When omitted, the dialog closes itself.
|
||||||
|
*/
|
||||||
|
onBackdropClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper over native `<dialog>` with `showModal()`. Locks body scroll
|
||||||
|
* while open, restores it on any close path, and treats backdrop clicks /
|
||||||
|
* Enter|Space on the backdrop as a close intent. Style the backdrop externally
|
||||||
|
* via a className on this component + a `::backdrop` selector.
|
||||||
|
*
|
||||||
|
* All standard dialog attributes pass through — `aria-label`, `onClose`,
|
||||||
|
* `onCancel`, etc. Use `onCancel` with `e.preventDefault()` to intercept ESC
|
||||||
|
* (e.g. to route close through a view transition wrapper).
|
||||||
|
*/
|
||||||
|
export const Modal = forwardRef<ModalHandle, Props>(function Modal(
|
||||||
|
{ className, onClick, onKeyUp, onBackdropClose, children, ...rest },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
|
/* Either run consumer-supplied close path (e.g. view-transition-wrapped) or
|
||||||
|
* fall back to closing the dialog directly. Shared between click and keyboard
|
||||||
|
* backdrop activation. */
|
||||||
|
function triggerBackdropClose() {
|
||||||
|
if (onBackdropClose) {
|
||||||
|
onBackdropClose();
|
||||||
|
} else {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
open: () => {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
dialogRef.current?.close();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = dialogRef.current;
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Scroll restore lives on the native event listener (not a React onClose
|
||||||
|
* prop) so it can't be unintentionally overridden by a caller that passes
|
||||||
|
* their own onClose. Both fire for the same close event. */
|
||||||
|
const handleClose = () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
el.addEventListener('close', handleClose);
|
||||||
|
return () => el.removeEventListener('close', handleClose);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the dialog when the user clicks the backdrop area directly.
|
||||||
|
* Target===currentTarget distinguishes the <dialog> element itself (the
|
||||||
|
* backdrop hit-area) from its content children. Caller's onClick still runs.
|
||||||
|
*/
|
||||||
|
function handleClick(e: MouseEvent<HTMLDialogElement>) {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
triggerBackdropClose();
|
||||||
|
}
|
||||||
|
onClick?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard equivalent of backdrop click — Enter/Space on the backdrop area
|
||||||
|
* closes the dialog. ESC is handled natively by `showModal()`.
|
||||||
|
*/
|
||||||
|
function handleKeyUp(e: KeyboardEvent<HTMLDialogElement>) {
|
||||||
|
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
triggerBackdropClose();
|
||||||
|
}
|
||||||
|
onKeyUp?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
{...rest}
|
||||||
|
className={cn('fixed inset-0 m-auto p-0 overflow-hidden', className)}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyUp={handleKeyUp}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import parse from 'html-react-parser';
|
import parse from 'html-react-parser';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* HTML string from PocketBase rich-text editor
|
* HTML string from PocketBase rich-text editor
|
||||||
*/
|
*/
|
||||||
@@ -10,7 +10,7 @@ type Props = {
|
|||||||
* Additional CSS classes merged onto the wrapper div
|
* Additional CSS classes merged onto the wrapper div
|
||||||
*/
|
*/
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a PocketBase rich-text HTML string as React elements.
|
* Renders a PocketBase rich-text HTML string as React elements.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Fragment, type ReactNode, ViewTransition as VT } from 'react';
|
|||||||
*/
|
*/
|
||||||
const Transition = (VT ?? Fragment) as typeof VT;
|
const Transition = (VT ?? Fragment) as typeof VT;
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Maps to the view-transition-name CSS property
|
* Maps to the view-transition-name CSS property
|
||||||
*/
|
*/
|
||||||
@@ -15,7 +15,7 @@ type Props = {
|
|||||||
* Content to animate
|
* Content to animate
|
||||||
*/
|
*/
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps children in React's ViewTransition when available,
|
* Wraps children in React's ViewTransition when available,
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ export type { ButtonSize, ButtonVariant } from './Button';
|
|||||||
export { Button } from './Button';
|
export { Button } from './Button';
|
||||||
export type { CardBackground } from './Card';
|
export type { CardBackground } from './Card';
|
||||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardSidebar, CardTitle } from './Card';
|
||||||
|
export { ImageLightbox } from './ImageLightbox';
|
||||||
export { InlineSvg } from './InlineSvg';
|
export { InlineSvg } from './InlineSvg';
|
||||||
export { Input, Textarea } from './Input';
|
export { Input, Textarea } from './Input';
|
||||||
export type { LinkVariant } from './Link';
|
export type { LinkVariant } from './Link';
|
||||||
export { Link } from './Link';
|
export { Link } from './Link';
|
||||||
|
export { Modal, type ModalHandle } from './Modal';
|
||||||
export { RichText } from './RichText';
|
export { RichText } from './RichText';
|
||||||
export type { ContainerSize, SectionBackground } from './Section';
|
export type { ContainerSize, SectionBackground } from './Section';
|
||||||
export { Container, Section } from './Section';
|
export { Container, Section } from './Section';
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export type { NavItem } from './model/types';
|
|
||||||
export { MobileNav } from './ui/MobileNav';
|
|
||||||
export { SidebarNav } from './ui/SidebarNav';
|
|
||||||
export { UtilityBar } from './ui/UtilityBar';
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export type NavItem = {
|
|
||||||
/**
|
|
||||||
* Section HTML id for anchor scrolling
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
/**
|
|
||||||
* Display label
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
/**
|
|
||||||
* Display number prefix (e.g. "01")
|
|
||||||
*/
|
|
||||||
number: string;
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
||||||
import { MobileNav } from './MobileNav';
|
|
||||||
|
|
||||||
// MobileNav is lg:hidden — it renders only on mobile viewports.
|
|
||||||
// Use the viewport toolbar in Storybook to switch to a mobile size to see it.
|
|
||||||
const meta: Meta<typeof MobileNav> = {
|
|
||||||
title: 'Widgets/MobileNav',
|
|
||||||
component: MobileNav,
|
|
||||||
parameters: {
|
|
||||||
viewport: {
|
|
||||||
defaultViewport: 'mobile1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof MobileNav>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
items: [
|
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
|
||||||
{ id: 'work', label: 'Work', number: '02' },
|
|
||||||
{ id: 'contact', label: 'Contact', number: '03' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import type { NavItem } from '../model/types';
|
|
||||||
import { MobileNav } from './MobileNav';
|
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({ usePathname: vi.fn(() => '/') }));
|
|
||||||
|
|
||||||
const ITEMS: NavItem[] = [
|
|
||||||
{ id: 'intro', label: 'Intro', number: '01' },
|
|
||||||
{ id: 'bio', label: 'Bio', number: '02' },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('MobileNav', () => {
|
|
||||||
describe('rendering', () => {
|
|
||||||
it('renders title "allmy.work"', () => {
|
|
||||||
render(<MobileNav items={ITEMS} />);
|
|
||||||
expect(screen.getByText('allmy.work')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders toggle button with text "Menu" initially', () => {
|
|
||||||
render(<MobileNav items={ITEMS} />);
|
|
||||||
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('menu items are hidden initially', () => {
|
|
||||||
render(<MobileNav items={ITEMS} />);
|
|
||||||
expect(screen.queryByRole('link', { name: /intro/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('navigation items', () => {
|
|
||||||
it('shows items as links with correct hrefs when open', async () => {
|
|
||||||
render(<MobileNav items={ITEMS} />);
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
|
||||||
expect(screen.getByRole('link', { name: /01.*Intro/i })).toHaveAttribute('href', '/intro');
|
|
||||||
expect(screen.getByRole('link', { name: /02.*Bio/i })).toHaveAttribute('href', '/bio');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('interactions', () => {
|
|
||||||
it('click toggle shows links and changes label to "Close"', async () => {
|
|
||||||
render(<MobileNav items={ITEMS} />);
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
|
||||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Intro')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes menu when pathname changes', async () => {
|
|
||||||
const { usePathname } = await import('next/navigation');
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/');
|
|
||||||
const { rerender } = render(<MobileNav items={ITEMS} />);
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Menu' }));
|
|
||||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
|
||||||
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
||||||
rerender(<MobileNav items={ITEMS} />);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Menu' })).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { cn } from '$shared/lib';
|
|
||||||
import type { NavItem } from '../model/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for MobileNav.
|
|
||||||
*/
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Navigation items to render
|
|
||||||
*/
|
|
||||||
items: NavItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mobile navigation overlay, hidden on lg+ screens.
|
|
||||||
* Closes automatically when the URL pathname changes after navigation.
|
|
||||||
*/
|
|
||||||
export function MobileNav({ items }: Props) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname is the trigger, not a value used inside the callback
|
|
||||||
useEffect(() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lg:hidden fixed top-0 left-0 right-0 bg-cream brutal-border-bottom z-50">
|
|
||||||
<div className="px-6 py-4 flex items-center justify-between">
|
|
||||||
<h4>allmy.work</h4>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsOpen((prev) => !prev)}
|
|
||||||
className="brutal-border px-4 py-2 bg-blue text-cream"
|
|
||||||
>
|
|
||||||
{isOpen ? 'Close' : 'Menu'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{isOpen && (
|
|
||||||
<div className="px-6 py-6 brutal-border-top space-y-2 max-h-[80vh] overflow-y-auto">
|
|
||||||
{items.map((item) => (
|
|
||||||
<Link key={item.id} href={`/${item.id}`} className="block w-full brutal-border bg-cream px-4 py-3">
|
|
||||||
<div className={cn('flex items-baseline gap-3')}>
|
|
||||||
<span className="text-sm opacity-60 font-body">{item.number}</span>
|
|
||||||
<span
|
|
||||||
className="font-heading text-lg font-black"
|
|
||||||
style={{ fontVariationSettings: '"WONK" 1, "SOFT" 0' }}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
||||||
import { SidebarNav } from './SidebarNav';
|
|
||||||
|
|
||||||
// SidebarNav is hidden lg:block — it renders only on desktop viewports.
|
|
||||||
// Use the viewport toolbar in Storybook to switch to a desktop size to see it.
|
|
||||||
const meta: Meta<typeof SidebarNav> = {
|
|
||||||
title: 'Widgets/SidebarNav',
|
|
||||||
component: SidebarNav,
|
|
||||||
parameters: {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
viewport: {
|
|
||||||
defaultViewport: 'desktop',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof SidebarNav>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
items: [
|
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
|
||||||
{ id: 'work', label: 'Work', number: '02' },
|
|
||||||
{ id: 'contact', label: 'Contact', number: '03' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import type { NavItem } from '../model/types';
|
|
||||||
import { SidebarNav } from './SidebarNav';
|
|
||||||
|
|
||||||
vi.mock('next/navigation', () => ({
|
|
||||||
usePathname: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
|
|
||||||
const ITEMS: NavItem[] = [
|
|
||||||
{ id: 'bio', label: 'Bio', number: '01' },
|
|
||||||
{ id: 'work', label: 'Work', number: '02' },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('SidebarNav', () => {
|
|
||||||
describe('rendering', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a nav element', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders "Index" heading', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByText('Index')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders "Digital Monograph" subtitle', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByText('Digital Monograph')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders each item label and number', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByText('Bio')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('01')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Work')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('02')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders "Quick Links" section', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByText('Quick Links')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders Email quick link', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByRole('link', { name: 'Email' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a link for each nav item', () => {
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
expect(screen.getByRole('link', { name: /Bio/i })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('link', { name: /Work/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('active state', () => {
|
|
||||||
it('marks matching pathname item as active (no opacity-40)', () => {
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
const activeLink = screen.getByRole('link', { name: /Bio/i });
|
|
||||||
expect(activeLink).not.toHaveClass('opacity-40');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks non-matching item as inactive (opacity-40)', () => {
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/bio');
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
const inactiveLink = screen.getByRole('link', { name: /Work/i });
|
|
||||||
expect(inactiveLink).toHaveClass('opacity-40');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks first item active at root path', () => {
|
|
||||||
vi.mocked(usePathname).mockReturnValue('/');
|
|
||||||
render(<SidebarNav items={ITEMS} />);
|
|
||||||
const firstLink = screen.getByRole('link', { name: /Bio/i });
|
|
||||||
expect(firstLink).not.toHaveClass('opacity-40');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { CONTACT_LINKS, cn } from '$shared/lib';
|
|
||||||
import type { NavItem } from '../model/types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for SidebarNav.
|
|
||||||
*/
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Navigation items to render
|
|
||||||
*/
|
|
||||||
items: NavItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed sidebar navigation, visible on lg+ screens.
|
|
||||||
* Active section determined by current URL pathname.
|
|
||||||
*/
|
|
||||||
export function SidebarNav({ items }: Props) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An item is active when its slug matches the current pathname,
|
|
||||||
* or when the pathname is root and it is the first item.
|
|
||||||
*/
|
|
||||||
function isActive(item: NavItem): boolean {
|
|
||||||
if (pathname === `/${item.id}`) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (pathname === '/' && items[0]?.id === item.id) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="fixed top-0 left-0 h-screen w-full lg:w-1/3 bg-cream brutal-border-right hidden lg:block overflow-y-auto z-50">
|
|
||||||
<div className="px-8 py-12 space-y-2">
|
|
||||||
<div className="mb-12">
|
|
||||||
<h2>Index</h2>
|
|
||||||
<div className="brutal-border-top pt-4">
|
|
||||||
<p className="text-sm opacity-60">Digital Monograph</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{items.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.id}
|
|
||||||
href={`/${item.id}`}
|
|
||||||
className={cn(
|
|
||||||
'block w-full text-left brutal-border bg-cream px-6 py-4 transition-all duration-300',
|
|
||||||
isActive(item)
|
|
||||||
? 'shadow-brutal-2xl opacity-100 translate-x-0'
|
|
||||||
: 'opacity-40 shadow-none hover:opacity-60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline gap-4">
|
|
||||||
<span className="text-sm opacity-60">{item.number}</span>
|
|
||||||
<span className="font-heading text-xl font-black">{item.label}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="mt-12 pt-12 brutal-border-top">
|
|
||||||
<p className="text-sm uppercase tracking-wider mb-4 opacity-60">Quick Links</p>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<a href={`mailto:${CONTACT_LINKS.email}`} className="block">
|
|
||||||
Email
|
|
||||||
</a>
|
|
||||||
<a href={CONTACT_LINKS.linkedin} className="block">
|
|
||||||
LinkedIn
|
|
||||||
</a>
|
|
||||||
<a href={CONTACT_LINKS.instagram} className="block">
|
|
||||||
Instagram
|
|
||||||
</a>
|
|
||||||
<a href={CONTACT_LINKS.arena} className="block">
|
|
||||||
Are.na
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
|
|
||||||
import { UtilityBar } from './UtilityBar';
|
|
||||||
|
|
||||||
const meta: Meta<typeof UtilityBar> = {
|
|
||||||
title: 'Widgets/UtilityBar',
|
|
||||||
component: UtilityBar,
|
|
||||||
decorators: [
|
|
||||||
(Story) => (
|
|
||||||
<div className="relative h-24 bg-ochre-clay">
|
|
||||||
<Story />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof UtilityBar>;
|
|
||||||
|
|
||||||
export const Default: Story = {};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { UtilityBar } from './UtilityBar';
|
|
||||||
|
|
||||||
describe('UtilityBar', () => {
|
|
||||||
describe('rendering', () => {
|
|
||||||
it('renders "Contact" label', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
expect(screen.getByText('Contact')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders email link with correct href', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
const link = screen.getByRole('link', { name: 'hello@allmy.work' });
|
|
||||||
expect(link).toBeInTheDocument();
|
|
||||||
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders "Download CV" button', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
expect(screen.getByRole('button', { name: /download cv/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Download CV button has primary variant class', () => {
|
|
||||||
render(<UtilityBar />);
|
|
||||||
const btn = screen.getByRole('button', { name: /download cv/i });
|
|
||||||
expect(btn).toHaveClass('bg-blue');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { CONTACT_LINKS } from '$shared/lib';
|
|
||||||
import { Button } from '$shared/ui';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed bottom utility bar with contact info and CV download.
|
|
||||||
*/
|
|
||||||
export function UtilityBar() {
|
|
||||||
/**
|
|
||||||
* Handles CV download action.
|
|
||||||
*/
|
|
||||||
function handleDownloadCV() {
|
|
||||||
console.log('Downloading CV...');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-cream brutal-border-top z-40">
|
|
||||||
<div className="max-w-[2560px] mx-auto px-6 md:px-12 lg:px-16 py-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm uppercase tracking-wider">Contact</span>
|
|
||||||
<a href={`mailto:${CONTACT_LINKS.email}`} className="text-base hover:opacity-60 transition-opacity">
|
|
||||||
{CONTACT_LINKS.email}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Button variant="primary" size="sm" onClick={handleDownloadCV}>
|
|
||||||
Download CV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,9 +13,14 @@ export default async function ProjectsSection() {
|
|||||||
tags: ['projects'],
|
tags: ['projects'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Mark the first project that actually has an image as LCP-priority.
|
||||||
|
* Using `index === 0` alone misses the case where the first card has no
|
||||||
|
* image and the LCP candidate ends up being the next card's image. */
|
||||||
|
const lcpIndex = items.findIndex((project) => project.image);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-section">
|
<div className="space-y-6 max-w-section">
|
||||||
{items.map((project) => (
|
{items.map((project, index) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
title={project.title}
|
title={project.title}
|
||||||
@@ -24,6 +29,7 @@ export default async function ProjectsSection() {
|
|||||||
tags={project.stack}
|
tags={project.stack}
|
||||||
url={project.url}
|
url={project.url}
|
||||||
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
|
imageUrl={project.image ? buildFileUrl(project.collectionId, project.id, project.image) : undefined}
|
||||||
|
priority={index === lcpIndex}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
import type { ErrorInfo, ReactNode } from 'react';
|
import type { ErrorInfo, ReactNode } from 'react';
|
||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Section content to render
|
* Section content to render
|
||||||
*/
|
*/
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Children } from 'react';
|
|||||||
import type { SectionRecord } from '$entities/Section';
|
import type { SectionRecord } from '$entities/Section';
|
||||||
import { SectionAccordion } from '$entities/Section';
|
import { SectionAccordion } from '$entities/Section';
|
||||||
|
|
||||||
type Props = {
|
export interface Props {
|
||||||
/**
|
/**
|
||||||
* Ordered section metadata — drives navigation labels and IDs
|
* Ordered section metadata — drives navigation labels and IDs
|
||||||
*/
|
*/
|
||||||
@@ -17,7 +17,7 @@ type Props = {
|
|||||||
* Pre-rendered RSC content slots, one per section, matched by index
|
* Pre-rendered RSC content slots, one per section, matched by index
|
||||||
*/
|
*/
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders all portfolio sections as an accordion list.
|
* Renders all portfolio sections as an accordion list.
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export * from './Footer';
|
export * from './Footer';
|
||||||
export * from './Navigation';
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
|
import path from 'node:path';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|||||||
Reference in New Issue
Block a user