T45d Site · Components: one search over chapters, stories and components (Slash, Enter, Escape), the index in hairline rows, the story beside a sticky chapter rail, arrows between stories
This commit is contained in:
5 files changed
+173
-48
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { componentCount } from "./data";
|
||||
|
||||
/** The search field; Slash focuses it, Enter takes the first match. */
|
||||
export function Find({ query, onQuery, onEnter, className }: { query: string; onQuery: (q: string) => void; onEnter: () => void; className: string }) {
|
||||
const field = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
const slash = (e: KeyboardEvent) => {
|
||||
if (e.key !== "/" || e.metaKey || e.ctrlKey || typing(e)) return;
|
||||
e.preventDefault();
|
||||
field.current?.focus();
|
||||
};
|
||||
addEventListener("keydown", slash);
|
||||
return () => removeEventListener("keydown", slash);
|
||||
}, []);
|
||||
return (
|
||||
<input
|
||||
ref={field}
|
||||
className={className}
|
||||
type="search"
|
||||
placeholder={`Search ${componentCount} components`}
|
||||
aria-label="Search components"
|
||||
value={query}
|
||||
onChange={(e) => onQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onEnter();
|
||||
if (e.key === "Escape") onQuery("");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a key went to a field, not the page. */
|
||||
export function typing(e: KeyboardEvent): boolean {
|
||||
const t = e.target as HTMLElement;
|
||||
return t.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(t.tagName);
|
||||
}
|
||||
@@ -21,6 +21,17 @@ export const componentCount = new Set(chapters.flatMap((c) => c.stories.flatMap(
|
||||
/** Every story in reading order, for paging and search. */
|
||||
export const all = chapters.flatMap((chapter) => chapter.stories.map((story) => ({ chapter, story })));
|
||||
|
||||
/** Chapters by title, else their stories by title or component. */
|
||||
export function search(query: string): Chapter[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return chapters;
|
||||
return chapters.flatMap((c) => {
|
||||
if (c.title.toLowerCase().includes(q)) return [c];
|
||||
const stories = c.stories.filter((s) => s.title.toLowerCase().includes(q) || s.components.some((n) => n.toLowerCase().includes(q)));
|
||||
return stories.length ? [{ ...c, stories }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export const REPO = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components";
|
||||
|
||||
export const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -1,22 +1,35 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { chapters, pad } from "../data";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { pad, search } from "../data";
|
||||
import { Find } from "../Find";
|
||||
|
||||
export function Chapters() {
|
||||
const [query, setQuery] = useState("");
|
||||
const navigate = useNavigate();
|
||||
const found = search(query);
|
||||
useEffect(() => {
|
||||
document.title = "Components · Ely";
|
||||
}, []);
|
||||
const first = () => found[0] && navigate({ to: "/components/$page/$story/", params: { page: found[0].slug, story: found[0].stories[0].slug } });
|
||||
return (
|
||||
<section className="chapters">
|
||||
<h1>Components</h1>
|
||||
<h1>Components.</h1>
|
||||
<Find className="find" query={query} onQuery={setQuery} onEnter={first} />
|
||||
{found.length === 0 && <p className="none">Nothing matches “{query}”.</p>}
|
||||
<ol>
|
||||
{chapters.map((c) => (
|
||||
{found.map((c) => (
|
||||
<li key={c.slug}>
|
||||
<Link to="/components/$page/$story/" params={{ page: c.slug, story: c.stories[0].slug }}>
|
||||
<span className="n">{pad(c.number)}</span>
|
||||
<span className="name">{c.title}</span>
|
||||
<span className="count">{c.stories.length}</span>
|
||||
<span className="n">{pad(c.number)}</span>
|
||||
<Link className="name" to="/components/$page/$story/" params={{ page: c.slug, story: c.stories[0].slug }}>
|
||||
{c.title}
|
||||
</Link>
|
||||
<span className="titles">
|
||||
{c.stories.map((s) => (
|
||||
<Link key={s.slug} to="/components/$page/$story/" params={{ page: c.slug, story: s.slug }}>
|
||||
{s.title}
|
||||
</Link>
|
||||
))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
@@ -1,46 +1,92 @@
|
||||
import { Link, useParams } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Live } from "../Live";
|
||||
import { Capture, Windows, isCaptured } from "../Native";
|
||||
import { all, chapters } from "../data";
|
||||
import { all, chapters, search } from "../data";
|
||||
import { Find, typing } from "../Find";
|
||||
import { NotFound } from "./NotFound";
|
||||
|
||||
export function Story() {
|
||||
const { page, story } = useParams({ strict: false });
|
||||
const navigate = useNavigate();
|
||||
const [query, setQuery] = useState("");
|
||||
const chapter = chapters.find((c) => c.slug === page);
|
||||
const found = chapter?.stories.find((s) => s.slug === story);
|
||||
const at = all.findIndex((e) => e.story === found);
|
||||
const [prev, next] = at < 0 ? [] : [all[at - 1], all[at + 1]];
|
||||
|
||||
useEffect(() => {
|
||||
if (chapter && found) document.title = `${found.title} · ${chapter.title} · Ely`;
|
||||
}, [chapter, found]);
|
||||
|
||||
useEffect(() => {
|
||||
document.querySelector('.side [aria-current="page"]')?.scrollIntoView({ block: "nearest" });
|
||||
}, [page, story]);
|
||||
|
||||
useEffect(() => {
|
||||
const step = (e: KeyboardEvent) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || typing(e)) return;
|
||||
const to = e.key === "ArrowLeft" ? prev : e.key === "ArrowRight" ? next : undefined;
|
||||
if (to) navigate({ to: "/components/$page/$story/", params: { page: to.chapter.slug, story: to.story.slug } });
|
||||
};
|
||||
addEventListener("keydown", step);
|
||||
return () => removeEventListener("keydown", step);
|
||||
}, [prev, next, navigate]);
|
||||
|
||||
if (!chapter || !found) return <NotFound what={`No story at ${page}/${story}.`} />;
|
||||
const at = all.findIndex((e) => e.story === found);
|
||||
const [prev, next] = [all[at - 1], all[at + 1]];
|
||||
const shown = search(query);
|
||||
const first = () => shown[0] && navigate({ to: "/components/$page/$story/", params: { page: shown[0].slug, story: shown[0].stories[0].slug } });
|
||||
return (
|
||||
<article className="story">
|
||||
<Link to="/components/" className="crumb">
|
||||
{chapter.title}
|
||||
</Link>
|
||||
<h1>{found.title}</h1>
|
||||
{isCaptured(chapter.slug, found.slug) ? (
|
||||
<Capture page={chapter.slug} story={found.slug} title={found.title} />
|
||||
) : (
|
||||
<>
|
||||
<Live key={`${chapter.slug}/${found.slug}`} page={chapter.slug} story={found.slug} title={found.title} />
|
||||
<Windows page={chapter.slug} story={found.slug} title={found.title} />
|
||||
</>
|
||||
)}
|
||||
<nav className="pager" aria-label="More stories">
|
||||
{prev && (
|
||||
<Link to="/components/$page/$story/" params={{ page: prev.chapter.slug, story: prev.story.slug }}>
|
||||
{prev.story.title}
|
||||
</Link>
|
||||
<div className="reader">
|
||||
<aside className="side" data-lenis-prevent>
|
||||
<Find className="find small" query={query} onQuery={setQuery} onEnter={first} />
|
||||
<nav aria-label="Chapters">
|
||||
{shown.map((c) => (
|
||||
<div key={c.slug}>
|
||||
<Link className="side-chapter" to="/components/$page/$story/" params={{ page: c.slug, story: c.stories[0].slug }} data-on={c.slug === chapter.slug || undefined}>
|
||||
{c.title}
|
||||
</Link>
|
||||
{(query || c.slug === chapter.slug) && (
|
||||
<ol>
|
||||
{c.stories.map((s) => (
|
||||
<li key={s.slug}>
|
||||
<Link to="/components/$page/$story/" params={{ page: c.slug, story: s.slug }} activeProps={{ "aria-current": "page" }}>
|
||||
{s.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<article className="story">
|
||||
<Link to="/components/" className="crumb">
|
||||
{chapter.title}
|
||||
</Link>
|
||||
<h1>{found.title}</h1>
|
||||
{isCaptured(chapter.slug, found.slug) ? (
|
||||
<Capture page={chapter.slug} story={found.slug} title={found.title} />
|
||||
) : (
|
||||
<>
|
||||
<Live key={`${chapter.slug}/${found.slug}`} page={chapter.slug} story={found.slug} title={found.title} />
|
||||
<Windows page={chapter.slug} story={found.slug} title={found.title} />
|
||||
</>
|
||||
)}
|
||||
{next && (
|
||||
<Link className="next" to="/components/$page/$story/" params={{ page: next.chapter.slug, story: next.story.slug }}>
|
||||
{next.story.title}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</article>
|
||||
<nav className="pager" aria-label="More stories">
|
||||
{prev && (
|
||||
<Link to="/components/$page/$story/" params={{ page: prev.chapter.slug, story: prev.story.slug }}>
|
||||
← {prev.story.title}
|
||||
</Link>
|
||||
)}
|
||||
{next && (
|
||||
<Link className="next" to="/components/$page/$story/" params={{ page: next.chapter.slug, story: next.story.slug }}>
|
||||
{next.story.title} →
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+27
-9
@@ -115,15 +115,29 @@ main > :first-child { animation: enter 480ms ease-out both; }
|
||||
@keyframes enter { from { opacity: 0; } }
|
||||
|
||||
.chapters { padding: 160px 64px 128px; }
|
||||
.chapters h1 { font-size: clamp(40px, 6vw, 96px); letter-spacing: -0.04em; margin-bottom: 96px; }
|
||||
.chapters h1 { font-size: clamp(48px, 7vw, 112px); line-height: 0.95; font-weight: 800; letter-spacing: -0.045em; }
|
||||
.find { display: block; width: 100%; margin: 56px 0 72px; padding: 12px 0; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: none; color: var(--fg); font: 300 24px/32px var(--sans); outline: none; transition: border-color 160ms ease-out; }
|
||||
.find::placeholder { color: var(--faint); }
|
||||
.find:focus-visible { border-color: var(--accent); }
|
||||
.find.small { margin: 0 0 24px; font-size: 15px; line-height: 24px; padding: 8px 0; }
|
||||
.none { font-size: 18px; }
|
||||
.chapters ol { list-style: none; margin: 0; padding: 0; }
|
||||
.chapters a { display: grid; grid-template-columns: 64px 1fr auto; align-items: baseline; padding: 20px 0; border-top: 1px solid var(--line); }
|
||||
.chapters .n, .chapters .count { font: 400 13px/20px var(--mono); color: var(--faint); }
|
||||
.chapters li { display: grid; grid-template-columns: 64px minmax(160px, 1fr) 2fr; gap: 0 24px; align-items: baseline; padding: 22px 0; border-top: 1px solid var(--line); }
|
||||
.chapters .n { font: 400 13px/20px var(--mono); color: var(--faint); }
|
||||
.chapters .name { font-size: 28px; line-height: 36px; font-weight: 700; letter-spacing: -0.02em; }
|
||||
.chapters .titles { display: flex; flex-wrap: wrap; gap: 4px 20px; font-size: 14px; line-height: 22px; min-width: 0; }
|
||||
.titles a, .side a { color: var(--muted); transition: color 160ms ease-out; overflow-wrap: anywhere; }
|
||||
.titles a:hover, .side a:hover, .side a[aria-current="page"], .side-chapter[data-on] { color: var(--fg); }
|
||||
|
||||
.story { padding: 128px 64px 96px; }
|
||||
.reader { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 64px; padding: 0 64px; }
|
||||
.side { position: sticky; top: 64px; height: calc(100vh - 64px); overflow-y: auto; padding: 64px 8px 64px 0; overscroll-behavior: contain; }
|
||||
.side nav > div { margin-bottom: 4px; }
|
||||
.side-chapter { display: block; padding: 4px 0; font-size: 15px; font-weight: 500; }
|
||||
.side ol { list-style: none; margin: 4px 0 12px; padding: 0 0 0 14px; border-left: 1px solid var(--line); font-size: 13px; line-height: 20px; }
|
||||
.side li { padding: 4px 0; }
|
||||
.story { padding: 128px 0 96px; min-width: 0; }
|
||||
.crumb { font-size: 14px; color: var(--muted); }
|
||||
.story h1 { font-size: clamp(32px, 4vw, 56px); line-height: 1.05; letter-spacing: -0.035em; margin: 12px 0 64px; max-width: 18ch; }
|
||||
.story h1 { font-size: clamp(28px, 3vw, 44px); line-height: 1.05; letter-spacing: -0.035em; font-weight: 800; margin: 12px 0 64px; overflow-wrap: anywhere; }
|
||||
|
||||
.live { position: relative; height: 72vh; min-height: 520px; background: var(--bg); }
|
||||
.live iframe { display: block; width: 100%; height: 100%; border: 0; background: var(--bg); }
|
||||
@@ -142,8 +156,8 @@ img.shot { display: block; max-width: 100%; height: auto; }
|
||||
.windows { margin-top: 96px; }
|
||||
.shots { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 32px; }
|
||||
|
||||
.pager { display: flex; justify-content: space-between; gap: 24px; margin-top: 96px; font-size: 18px; font-weight: 600; }
|
||||
.pager a { color: var(--muted); transition: color 160ms ease-out; }
|
||||
.pager { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 24px; margin-top: 96px; font-size: 18px; font-weight: 600; }
|
||||
.pager a { max-width: 100%; color: var(--muted); overflow-wrap: anywhere; transition: color 160ms ease-out; }
|
||||
.pager a:hover { color: var(--fg); }
|
||||
.pager .next { margin-left: auto; text-align: right; }
|
||||
|
||||
@@ -156,13 +170,17 @@ img.shot { display: block; max-width: 100%; height: auto; }
|
||||
@media (max-width: 767px) {
|
||||
.bar { padding: 0 16px; gap: 12px; }
|
||||
.bar .end { gap: 0; }
|
||||
.hero, .chapters, .story, .missing { padding-left: 20px; padding-right: 20px; }
|
||||
.hero, .chapters, .missing { padding-left: 20px; padding-right: 20px; }
|
||||
.reader { grid-template-columns: minmax(0, 1fr); padding: 0 20px; }
|
||||
.side { display: none; }
|
||||
.chapters li { grid-template-columns: 40px minmax(0, 1fr); }
|
||||
.chapters .titles { grid-column: 2; margin-top: 8px; }
|
||||
.foot { padding: 32px 20px; }
|
||||
.chapters .name { font-size: 22px; }
|
||||
.hero-iso { width: 100%; height: 55%; }
|
||||
.hero > :not(.iso) { max-width: none; }
|
||||
.city { height: auto; }
|
||||
.hero h1 { font-size: 13vw; }
|
||||
.hero h1, .chapters h1 { font-size: 13vw; }
|
||||
.feature h2, .city h2, .close h2 { font-size: 10vw; }
|
||||
.feature, .feature:nth-of-type(odd), .city { grid-template-columns: 1fr; grid-template-rows: none; gap: 32px; padding: 112px 20px 48px; }
|
||||
.feature-words, .feature-iso, .feature-live, .feature:nth-of-type(odd) .feature-words, .feature:nth-of-type(odd) .feature-iso, .feature:nth-of-type(odd) .feature-live, .city h2 { grid-area: auto; }
|
||||
|
||||
Reference in new issue
Block a user