Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 92 additions & 46 deletions src/Home.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,15 @@
import { useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Alert } from './kit'
import { THEMES, type Lane, type Theme } from './lanes'
import { prototypesInLane } from './prototypes/registry'

/**
* Jump straight to a lane, with how many prototypes are in each.
*
* Buttons rather than anchors: this app uses hash routing, so an in-page
* `#lane-id` fragment would be read as a route. Focus moves to the heading as
* well as scrolling, so it works for keyboard and screen reader users too.
*/
function JumpTo() {
const go = (laneId: string) => {
const heading = document.getElementById(`${laneId}-title`)
heading?.scrollIntoView({ block: 'start' })
heading?.focus()
}

return (
<nav className="jump-to" aria-label="Jump to a lane">
{THEMES.map((theme) => (
<div className="jump-to-row" key={theme.id}>
<span className="jump-to-theme">{theme.title}</span>
<span className="jump-to-lanes">
{theme.lanes.map((lane) => {
const count = prototypesInLane(lane.id).length
return (
<button className="link-button" key={lane.id} onClick={() => go(lane.id)}>
{lane.title} ({count})
</button>
)
})}
</span>
</div>
))}
</nav>
)
}

function LaneCard({ lane }: { lane: Lane }) {
const prototypes = prototypesInLane(lane.id)

return (
<section className="lane" aria-labelledby={`${lane.id}-title`}>
<h3 className="title-sm lane-heading" id={`${lane.id}-title`} tabIndex={-1}>
<h3 className="title-sm lane-heading" id={`${lane.id}-title`}>
{lane.title}
{prototypes.length > 0 && (
<span className="lane-count">
Expand Down Expand Up @@ -81,12 +48,87 @@ function LaneCard({ lane }: { lane: Lane }) {
)
}

function ThemeSection({ theme }: { theme: Theme }) {
/** Total across the theme's lanes — the tab has to speak for a list that is
* not on screen. */
function countInTheme(theme: Theme) {
return theme.lanes.reduce((total, lane) => total + prototypesInLane(lane.id).length, 0)
}

/**
* One tab per theme. Follows the ARIA tabs pattern: the strip is a single tab
* stop and the arrow keys move between tabs, so a keyboard user does not have
* to tab past every theme to reach the list.
*/
function ThemeTabs({
activeId,
onSelect,
}: {
activeId: string
onSelect: (themeId: string) => void
}) {
const tabs = useRef<Record<string, HTMLButtonElement | null>>({})

const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const current = THEMES.findIndex((theme) => theme.id === activeId)
let next = current

if (event.key === 'ArrowRight') next = (current + 1) % THEMES.length
else if (event.key === 'ArrowLeft') next = (current - 1 + THEMES.length) % THEMES.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = THEMES.length - 1
else return

event.preventDefault()
const { id } = THEMES[next]
onSelect(id)
tabs.current[id]?.focus()
}

return (
<section aria-labelledby={`${theme.id}-title`} className="theme">
<h2 className="title-md theme-head" id={`${theme.id}-title`}>
{theme.title}
</h2>
<div className="theme-tabs" role="tablist" aria-label="Theme" onKeyDown={onKeyDown}>
{THEMES.map((theme) => {
const selected = theme.id === activeId
const count = countInTheme(theme)
return (
<button
className="theme-tab"
key={theme.id}
id={`${theme.id}-tab`}
ref={(node) => {
tabs.current[theme.id] = node
}}
role="tab"
type="button"
aria-selected={selected}
aria-controls={`${theme.id}-panel`}
tabIndex={selected ? 0 : -1}
onClick={() => onSelect(theme.id)}
/* A bare number next to the title reads as a step number, not a
count, so spell it out for screen readers. */
aria-label={`${theme.title}, ${count} ${count === 1 ? 'prototype' : 'prototypes'}`}
>
{theme.title}
<span className="theme-tab-count" aria-hidden="true">
{count}
</span>
</button>
)
})}
</div>
)
}

/* Only the selected panel is rendered. The tab is the panel's accessible name,
so there is no visible theme heading any more — it would just repeat it. */
function ThemePanel({ theme }: { theme: Theme }) {
return (
<section
className="theme"
id={`${theme.id}-panel`}
role="tabpanel"
aria-labelledby={`${theme.id}-tab`}
tabIndex={-1}
>
<div className="lane-list">
{theme.lanes.map((lane) => (
<LaneCard key={lane.id} lane={lane} />
Expand All @@ -97,6 +139,13 @@ function ThemeSection({ theme }: { theme: Theme }) {
}

export function Home() {
/* The selected theme lives in the query string, so a link to the page can
open on a particular tab — the same reason prototypes are deep-linkable.
`replace` because tabbing through themes should not fill the back button. */
const [params, setParams] = useSearchParams()
const activeTheme = THEMES.find((theme) => theme.id === params.get('theme')) ?? THEMES[0]
const selectTheme = (themeId: string) => setParams({ theme: themeId }, { replace: true })

return (
<div className="card">
<div className="intro">
Expand All @@ -119,12 +168,9 @@ export function Home() {
</ul>
</Alert>

<JumpTo />

<div className="theme-list">
{THEMES.map((theme) => (
<ThemeSection key={theme.id} theme={theme} />
))}
<ThemeTabs activeId={activeTheme.id} onSelect={selectTheme} />
<ThemePanel key={activeTheme.id} theme={activeTheme} />
</div>
</div>
)
Expand Down
93 changes: 48 additions & 45 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ hr.divider {

/* ---------------- Themes and lanes ---------------- */
/* A "theme" is one of the areas in the GitHub story (onboarding, importing
projects). A "lane" is one question within a theme, for one audience.
Prototypes will be listed inside their lane. */
projects), shown as a tab. A "lane" is one question within a theme, for one
audience. Prototypes are listed inside their lane. */
.theme-list {
display: flex;
flex-direction: column;
Expand All @@ -205,10 +205,54 @@ hr.divider {
gap: var(--space-2);
}

.theme-head {
padding-bottom: var(--space-1);
/* Themes are tabs: only one theme's lanes are on the page at a time. The strip
sits on a full-width rule, with the selected tab's underline drawn over it. */
.theme-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
border-bottom: 2px solid var(--rpf-grey-150);
}
.theme-tab {
appearance: none;
background: none;
border: none;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
padding: var(--space-1) var(--space-2);
font-family: inherit;
font-size: var(--fs-15);
line-height: var(--lh-15);
font-weight: var(--fw-bold);
color: var(--rpf-text-secondary);
cursor: pointer;
}
.theme-tab:hover {
color: var(--rpf-text);
}
.theme-tab[aria-selected='true'] {
color: var(--rpf-black);
border-bottom-color: var(--rpf-link-text);
}
.theme-tab:focus-visible {
outline: 3px solid var(--rpf-link-text);
outline-offset: 2px;
}
/* How many prototypes are behind the tab, so the unselected theme is not a
closed door — you can see whether it is worth opening. */
.theme-tab-count {
margin-left: var(--space-1);
border-radius: 999px;
padding: 0 var(--space-1);
background: var(--rpf-grey-150);
font-weight: var(--fw-regular);
font-size: var(--fs-075);
color: var(--rpf-text-secondary);
}

.theme:focus {
outline: none;
}

/* Stacked, always. Side by side was fine when a lane was just a heading and
"No prototypes yet", but once lanes list several prototypes each, two narrow
Expand Down Expand Up @@ -1275,53 +1319,12 @@ body.full-screen-prototype .surface-footer {
color: var(--rpf-text);
}

/* ---------------- Landing page at scale ---------------- */
/* With fifteen to twenty prototypes the page runs several screens, so it needs
somewhere to start from and cards that stay scannable. */
.jump-to {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-2);
border: 1px solid var(--rpf-grey-150);
border-radius: var(--radius-sm);
background: var(--rpf-off-white);
font-size: var(--fs-075);
}
.jump-to-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-1) var(--space-2);
}
.jump-to-theme {
font-weight: var(--fw-bold);
min-width: 9rem;
}
.jump-to-lanes {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.jump-to .link-button {
font-size: var(--fs-075);
}

.lane-heading {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-1) var(--space-2);
}
/* Focus lands here from the jump list; it should be visible but not look
permanently selected. */
.lane-heading:focus {
outline: none;
}
.lane-heading:focus-visible {
outline: 3px solid var(--rpf-link-text);
outline-offset: 4px;
}
.lane-count {
font-weight: var(--fw-regular);
font-size: var(--fs-075);
Expand Down
Loading