diff --git a/src/Home.tsx b/src/Home.tsx index 592d0ec..de2bacc 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -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 ( - - ) -} - function LaneCard({ lane }: { lane: Lane }) { const prototypes = prototypesInLane(lane.id) return (
-

+

{lane.title} {prototypes.length > 0 && ( @@ -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>({}) + + const onKeyDown = (event: React.KeyboardEvent) => { + 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 ( -
-

- {theme.title} -

+
+ {THEMES.map((theme) => { + const selected = theme.id === activeId + const count = countInTheme(theme) + return ( + + ) + })} +
+ ) +} + +/* 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 ( +
{theme.lanes.map((lane) => ( @@ -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 (
@@ -119,12 +168,9 @@ export function Home() { - -
- {THEMES.map((theme) => ( - - ))} + +
) diff --git a/src/styles/global.css b/src/styles/global.css index 6c08dde..075d918 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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; @@ -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 @@ -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);