feat: adicionar browser e fallback do notebook

This commit is contained in:
Evandro Fonseca Junior committed 2026-08-30 23:42:31 -03:00
1 parent c66ff7fde6
commit b4a2b81d55
5 files changed
+757

No files matched your search

@@ -0,0 +1,112 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { portfolio } from "@/lib/portfolio-data";
import NotebookBrowser, { type NotebookBrowserProps } from "./NotebookBrowser";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(portfolio.projects);
const frame: NotebookBrowserProps["frame"] = {
cursorVisible: true,
cursorX: 0.4,
cursorY: 0.6,
cursorClicking: false,
};
function renderBrowser(overrides: Partial<NotebookBrowserProps> = {}) {
return render(
<NotebookBrowser
projects={projects}
activeIndex={0}
frame={frame}
onSelectTab={jest.fn()}
{...overrides}
/>,
);
}
describe("NotebookBrowser", () => {
it("renders the active project as an accessible tabpanel", () => {
renderBrowser({ activeIndex: 1 });
expect(
screen.getByRole("tablist", { name: /projetos em produção/i }),
).toBeInTheDocument();
expect(screen.getAllByRole("tab")[1]).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
/SC Plus Aquicultura/i,
);
});
it("moves between tabs with arrow keys and Home/End", async () => {
const user = userEvent.setup();
const onSelectTab = jest.fn();
renderBrowser({ onSelectTab });
const tabs = screen.getAllByRole("tab");
await user.click(tabs[1]);
await user.keyboard("{ArrowRight}");
await user.keyboard("{Home}");
await user.keyboard("{End}");
expect(onSelectTab).toHaveBeenNthCalledWith(1, 1);
expect(onSelectTab).toHaveBeenNthCalledWith(2, 2);
expect(onSelectTab).toHaveBeenNthCalledWith(3, 0);
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
});
it("bounds arrow navigation at the first and last tab", async () => {
const user = userEvent.setup();
const onSelectTab = jest.fn();
renderBrowser({ onSelectTab });
const tabs = screen.getAllByRole("tab");
await user.click(tabs[0]);
await user.keyboard("{ArrowLeft}");
await user.click(tabs.at(-1) as HTMLElement);
await user.keyboard("{ArrowRight}");
expect(onSelectTab).toHaveBeenNthCalledWith(2, 0);
expect(onSelectTab).toHaveBeenNthCalledWith(4, projects.length - 1);
});
it("exposes a real deployment link with safe new-tab attributes", () => {
renderBrowser();
const link = screen.getByRole("link", { name: /abrir projeto/i });
expect(link).toHaveAttribute("href", projects[0].demoUrl);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("keeps the animated cursor decorative and never embeds an iframe", () => {
const { container } = renderBrowser({
frame: { ...frame, cursorClicking: true },
});
expect(container.querySelector(".notebook-cursor")).toHaveAttribute(
"aria-hidden",
"true",
);
expect(container.querySelector(".notebook-cursor")).toHaveStyle({
left: "40%",
top: "60%",
});
expect(container.querySelector("iframe")).not.toBeInTheDocument();
});
it("uses the selected tab to label the panel and screenshot", () => {
renderBrowser({ activeIndex: 2 });
const tab = screen.getAllByRole("tab")[2];
const panel = screen.getByRole("tabpanel");
const image = screen.getByRole("img");
expect(tab).toHaveAttribute("aria-controls", panel.id);
expect(panel).toHaveAttribute("aria-labelledby", tab.id);
expect(image).toHaveAccessibleName(/SEV Moda Íntima/i);
expect(image).toHaveAttribute("src", "/assets/prints/print-sev.png");
});
});
+172
View File
@@ -0,0 +1,172 @@
"use client";
import Image from "next/image";
import type { NotebookProject } from "./notebookProjects";
import type { NotebookFrame } from "./notebookTimeline";
export type NotebookBrowserProps = {
projects: NotebookProject[];
activeIndex: number;
frame: Pick<
NotebookFrame,
"cursorVisible" | "cursorX" | "cursorY" | "cursorClicking"
>;
onSelectTab: (index: number) => void;
};
function clampIndex(index: number, length: number): number {
if (length < 1) return -1;
return Math.min(Math.max(index, 0), length - 1);
}
function toDomId(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
}
export default function NotebookBrowser({
projects,
activeIndex,
frame,
onSelectTab,
}: NotebookBrowserProps) {
const selectedIndex = clampIndex(activeIndex, projects.length);
const selectedProject =
selectedIndex >= 0 ? projects[selectedIndex] : undefined;
function handleTabKeyDown(
event: React.KeyboardEvent<HTMLButtonElement>,
index: number,
) {
if (projects.length < 1) return;
let nextIndex: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
nextIndex = Math.min(index + 1, projects.length - 1);
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
nextIndex = Math.max(index - 1, 0);
} else if (event.key === "Home") {
nextIndex = 0;
} else if (event.key === "End") {
nextIndex = projects.length - 1;
}
if (nextIndex === null) return;
event.preventDefault();
onSelectTab(nextIndex);
}
if (!selectedProject) {
return (
<div
className="notebook-browser"
data-testid="notebook-browser"
data-empty
>
<p aria-live="polite">Nenhum projeto disponível para esta cena.</p>
</div>
);
}
const selectedId = toDomId(selectedProject.id);
const panelId = `notebook-panel-${selectedId}`;
return (
<div className="notebook-browser" data-testid="notebook-browser">
<div className="notebook-browser-bar" aria-hidden="true">
<span className="notebook-browser-dot" />
<span className="notebook-browser-dot" />
<span className="notebook-browser-dot" />
<span className="notebook-browser-address">
evandro.dev.br / projetos
</span>
</div>
<div className="notebook-browser-body">
<div className="notebook-browser-heading">
<span className="notebook-browser-eyebrow">Projetos em produção</span>
<span className="notebook-browser-counter">
{String(selectedIndex + 1).padStart(2, "0")} /{" "}
{String(projects.length).padStart(2, "0")}
</span>
</div>
<div
className="notebook-tablist"
role="tablist"
aria-label="Projetos em produção"
>
{projects.map((project, index) => {
const tabId = `notebook-tab-${toDomId(project.id)}`;
const isSelected = index === selectedIndex;
return (
<button
aria-controls={panelId}
aria-selected={isSelected}
className="notebook-tab"
id={tabId}
key={project.id}
onClick={() => onSelectTab(index)}
onKeyDown={(event) => handleTabKeyDown(event, index)}
role="tab"
tabIndex={isSelected ? 0 : -1}
type="button"
>
<span className="notebook-tab-number">
{String(index + 1).padStart(2, "0")}
</span>
<span className="notebook-tab-title">{project.title}</span>
</button>
);
})}
</div>
<div
aria-labelledby={`notebook-tab-${selectedId}`}
className="notebook-panel"
id={panelId}
role="tabpanel"
>
<div className="notebook-panel-copy">
<h3>{selectedProject.title}</h3>
<p>{selectedProject.subtitle}</p>
<ul aria-label={`Tecnologias de ${selectedProject.title}`}>
{selectedProject.technologies.slice(0, 4).map((technology) => (
<li key={technology}>{technology}</li>
))}
</ul>
<a
href={selectedProject.demoUrl}
rel="noopener noreferrer"
target="_blank"
>
Abrir projeto <span aria-hidden="true">↗</span>
</a>
</div>
<div className="notebook-screenshot-wrap">
<Image
alt={`Captura de tela de ${selectedProject.title}`}
className="notebook-screenshot"
fill
sizes="(min-width: 681px) 60vw, 100vw"
src={selectedProject.screenshotSrc}
unoptimized
/>
{frame.cursorVisible ? (
<span
aria-hidden="true"
className={`notebook-cursor${frame.cursorClicking ? " is-clicking" : ""}`}
data-testid="notebook-cursor"
style={{
left: `${frame.cursorX * 100}%`,
top: `${frame.cursorY * 100}%`,
}}
/>
) : null}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { portfolio } from "@/lib/portfolio-data";
import NotebookFallback from "./NotebookFallback";
import { selectNotebookProjects } from "./notebookProjects";
const projects = selectNotebookProjects(portfolio.projects);
describe("NotebookFallback", () => {
it("keeps project content and controls in normal document flow", () => {
const { container } = render(<NotebookFallback projects={projects} />);
expect(screen.getByTestId("notebook-fallback")).toBeInTheDocument();
expect(screen.getByRole("tablist")).toBeInTheDocument();
expect(screen.getByRole("tabpanel")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /abrir projeto/i }),
).toBeInTheDocument();
expect(container.querySelector("canvas")).not.toBeInTheDocument();
});
it("allows manual tab changes without an animated stage", async () => {
const user = userEvent.setup();
const { container } = render(<NotebookFallback projects={projects} />);
await user.click(screen.getAllByRole("tab")[2]);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(
/SEV Moda Íntima/i,
);
expect(container.querySelector("canvas")).not.toBeInTheDocument();
});
it("respects the requested initial project", () => {
render(<NotebookFallback initialIndex={3} projects={projects} />);
expect(screen.getByRole("tabpanel")).toHaveAccessibleName(/AS Serviços/i);
expect(screen.getAllByRole("tab")[3]).toHaveAttribute(
"aria-selected",
"true",
);
});
});
+47
View File
@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import NotebookBrowser from "./NotebookBrowser";
import type { NotebookProject } from "./notebookProjects";
export type NotebookFallbackProps = {
projects: NotebookProject[];
initialIndex?: number;
};
function clampIndex(index: number, length: number): number {
if (length < 1) return -1;
return Math.min(Math.max(index, 0), length - 1);
}
export default function NotebookFallback({
projects,
initialIndex = 0,
}: NotebookFallbackProps) {
const [activeIndex, setActiveIndex] = useState(() =>
clampIndex(initialIndex, projects.length),
);
return (
<div className="notebook-fallback" data-testid="notebook-fallback">
<div className="notebook-fallback-intro">
<span className="notebook-fallback-kicker">Interface em produção</span>
<p>
Navegue pelos projetos com as abas abaixo. A experiência permanece
completa sem movimento ou WebGL.
</p>
</div>
<NotebookBrowser
activeIndex={activeIndex}
frame={{
cursorClicking: false,
cursorVisible: false,
cursorX: 0,
cursorY: 0,
}}
onSelectTab={setActiveIndex}
projects={projects}
/>
</div>
);
}