diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index a7c6bd6..cb73a1c 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -11,7 +11,7 @@ export function AppShell() { {/* Skip to main content — must be the first focusable element */} Skip to main content diff --git a/apps/web/src/components/ConnectGitHubBanner.tsx b/apps/web/src/components/ConnectGitHubBanner.tsx index 1935971..e745295 100644 --- a/apps/web/src/components/ConnectGitHubBanner.tsx +++ b/apps/web/src/components/ConnectGitHubBanner.tsx @@ -53,7 +53,6 @@ export function ConnectGitHubBanner() { variant="ghost" size="sm" onClick={() => setDismissed(true)} - aria-label="Dismiss" > Dismiss diff --git a/apps/web/src/components/MarkdownEditor.tsx b/apps/web/src/components/MarkdownEditor.tsx index b1f8d2d..a186b0f 100644 --- a/apps/web/src/components/MarkdownEditor.tsx +++ b/apps/web/src/components/MarkdownEditor.tsx @@ -53,6 +53,7 @@ export function MarkdownEditor({ required, }: MarkdownEditorProps) { const id = useId(); + const errorId = `${id}-error`; const textareaRef = useRef(null); const [previewHtml, setPreviewHtml] = useState(''); const [previewLoading, setPreviewLoading] = useState(false); @@ -170,11 +171,14 @@ export function MarkdownEditor({ className="rounded-none border-0 focus-visible:ring-0 font-mono text-sm resize-y" style={{ minHeight }} aria-invalid={error ? 'true' : 'false'} + aria-describedby={error ? errorId : undefined} /> + {/* Deliberately not a live region: the preview is the whole document + re-rendered on every debounce, so announcing it would read the + entire text back on each pause in typing. */}
{previewError ? (

{previewError}

@@ -192,7 +196,9 @@ export function MarkdownEditor({
{error ? ( - {error} + + {error} + ) : ( Markdown · supports GFM )} diff --git a/apps/web/src/components/NetworkErrorBanner.tsx b/apps/web/src/components/NetworkErrorBanner.tsx index c63ff59..19bd209 100644 --- a/apps/web/src/components/NetworkErrorBanner.tsx +++ b/apps/web/src/components/NetworkErrorBanner.tsx @@ -7,17 +7,26 @@ import { } from 'react'; interface NetworkErrorContextValue { - showError: (message?: string) => void; + /** + * Show the banner. Pass `retry` when the failed work can be re-issued; the + * button then reads "Retry" and runs it. Without one it reads "Dismiss". + */ + showError: (message?: string, retry?: () => void) => void; clearError: () => void; } +interface NetworkErrorState { + message: string; + retry?: () => void; +} + const NetworkErrorContext = createContext(null); export function NetworkErrorProvider({ children }: { children: ReactNode }) { - const [error, setError] = useState(null); + const [error, setError] = useState(null); - const showError = useCallback((message?: string) => { - setError(message ?? 'Something went wrong. We are looking at it.'); + const showError = useCallback((message?: string, retry?: () => void) => { + setError({ message: message ?? 'Something went wrong. We are looking at it.', retry }); }, []); const clearError = useCallback(() => { @@ -32,13 +41,15 @@ export function NetworkErrorProvider({ children }: { children: ReactNode }) { className="bg-destructive text-destructive-foreground px-4 py-2 text-sm flex items-center justify-between" data-testid="network-error-banner" > - {error} + {error.message}
)} diff --git a/apps/web/src/components/Pagination.tsx b/apps/web/src/components/Pagination.tsx index df54d9c..c666e7a 100644 --- a/apps/web/src/components/Pagination.tsx +++ b/apps/web/src/components/Pagination.tsx @@ -62,6 +62,7 @@ export function Pagination({ page, totalPages, onPageChange, siblingCount = 1, c variant={p === page ? 'default' : 'outline'} size="sm" onClick={() => onPageChange(p)} + aria-label={`Page ${p}`} aria-current={p === page ? 'page' : undefined} > {p} diff --git a/apps/web/src/components/PersonAvatar.tsx b/apps/web/src/components/PersonAvatar.tsx index fa74652..115e0f2 100644 --- a/apps/web/src/components/PersonAvatar.tsx +++ b/apps/web/src/components/PersonAvatar.tsx @@ -24,6 +24,7 @@ export function PersonAvatar({ person, size = 32, asLink = true, className, titl /> ) : ( + {inner} ); diff --git a/apps/web/src/components/SearchBox.tsx b/apps/web/src/components/SearchBox.tsx index e1c67da..97f5b01 100644 --- a/apps/web/src/components/SearchBox.tsx +++ b/apps/web/src/components/SearchBox.tsx @@ -1,7 +1,8 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useId, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router'; import { Input } from '@/components/ui/input'; import { useSearch, type SearchResult } from '@/hooks/useSearch'; +import { cn } from '@/lib/utils'; interface SearchBoxProps { /** If true, renders compactly for embedding in the mobile sheet */ @@ -22,47 +23,128 @@ function groupResults(results: SearchResult[]): Array<{ type: SearchResult['type .map((t) => ({ type: t, items: groups[t] })); } +/** + * Site search — an ARIA APG combobox with a listbox popup. + * + * Focus never leaves the `role="combobox"` input; the active option is pointed + * at with `aria-activedescendant` instead of being focused. The popup swallows + * `mousedown`, so a pointer click on an option cannot blur the input — which is + * why there is no close-on-blur timeout here (the old 150ms one raced the click + * and made results unreachable). + * + * Options stay `` — `option` is an allowed role for `a[href]`, and the + * href keeps middle-click / "open in new tab" working. Plain clicks and Enter + * are intercepted and routed through `useNavigate()` so activation stays inside + * the SPA instead of triggering a full-page reload. + */ export function SearchBox({ inline = false }: SearchBoxProps) { const navigate = useNavigate(); const { query, results, loading, setQuery, clear } = useSearch(); const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); const inputRef = useRef(null); - const handleFocus = useCallback(() => { - setOpen(true); + const baseId = useId(); + const listboxId = `${baseId}-listbox`; + const optionId = (i: number) => `${baseId}-option-${i}`; + const groupHeaderId = (type: string) => `${baseId}-group-${type}`; + + const trimmed = query.trim(); + const showDropdown = open && trimmed.length > 0; + + const grouped = useMemo(() => groupResults(results), [results]); + const flat = useMemo(() => grouped.flatMap((g) => g.items), [grouped]); + const seeAllUrl = trimmed ? `/projects?q=${encodeURIComponent(trimmed)}` : null; + const optionUrls = useMemo( + () => [...flat.map((r) => r.url), ...(seeAllUrl ? [seeAllUrl] : [])], + [flat, seeAllUrl], + ); + + // Clamp instead of resetting from an effect: results land asynchronously and + // can shrink out from under the cursor mid-keystroke. + const activeIdx = activeIndex >= 0 && activeIndex < optionUrls.length ? activeIndex : -1; + const activeDescendant = showDropdown && activeIdx >= 0 ? optionId(activeIdx) : undefined; + + const close = useCallback(() => { + setOpen(false); + setActiveIndex(-1); }, []); - const handleBlur = useCallback(() => { - setTimeout(() => setOpen(false), 150); + const activate = useCallback( + (url: string) => { + void navigate(url); + clear(); + close(); + // Selection is done: hand focus back to the page rather than leaving it + // parked in a now-empty combobox. + inputRef.current?.blur(); + }, + [navigate, clear, close], + ); + + const handleFocus = useCallback(() => { + setOpen(true); }, []); const handleChange = useCallback( (e: React.ChangeEvent) => { setQuery(e.target.value); setOpen(true); + setActiveIndex(-1); }, [setQuery], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && query.trim()) { - void navigate(`/projects?q=${encodeURIComponent(query.trim())}`); - clear(); - setOpen(false); - inputRef.current?.blur(); + const len = optionUrls.length; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setOpen(true); + if (len > 0) setActiveIndex(activeIdx === -1 ? 0 : (activeIdx + 1) % len); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setOpen(true); + if (len > 0) setActiveIndex(activeIdx <= 0 ? len - 1 : activeIdx - 1); + return; + } + if (e.key === 'Enter') { + const target = showDropdown && activeIdx >= 0 ? optionUrls[activeIdx] : undefined; + if (target) { + e.preventDefault(); + activate(target); + } else if (seeAllUrl) { + activate(seeAllUrl); + } + return; } if (e.key === 'Escape') { clear(); - setOpen(false); + close(); inputRef.current?.blur(); } }, - [navigate, query, clear], + [optionUrls, activeIdx, showDropdown, seeAllUrl, activate, clear, close], + ); + + /** Let the browser handle modified clicks (new tab / new window) natively. */ + const handleOptionClick = useCallback( + (e: React.MouseEvent, url: string) => { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return; + e.preventDefault(); + activate(url); + }, + [activate], ); - const showDropdown = open && query.trim().length > 0; - const grouped = groupResults(results); + const optionClass = (i: number) => + cn( + 'block px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground', + i === activeIdx && 'bg-accent text-accent-foreground', + ); return (
{showDropdown && (
- {loading && results.length === 0 && ( -

Searching…

- )} - {!loading && results.length === 0 && ( -

- No results for “{query}” -

+ data-search-dropdown + // Swallowing mousedown keeps focus on the input, so onBlur can close + // the popup immediately without racing the option's click. + onMouseDown={(e) => e.preventDefault()} + // Inline (mobile sheet): stay in the document flow so the popup + // cannot hang below a short viewport; the sheet's flex column and + // the popup's own scroll keep it reachable. Otherwise float right. + className={cn( + 'bg-popover border border-border rounded-md shadow-lg py-1 overflow-y-auto', + inline + ? 'mt-1 max-h-64' + : 'absolute top-full right-0 min-w-72 mt-1 z-50 max-h-[28rem]', )} + > + {/* Status lives outside the listbox — a listbox may only own + options, groups and presentational content. */} +
+ {loading && results.length === 0 && ( +

Searching…

+ )} + {!loading && results.length === 0 && ( +

+ No results for “{query}” +

+ )} +
- {grouped.map((group) => ( -
)}
diff --git a/apps/web/src/components/StageBadge.tsx b/apps/web/src/components/StageBadge.tsx index bb103db..36c913c 100644 --- a/apps/web/src/components/StageBadge.tsx +++ b/apps/web/src/components/StageBadge.tsx @@ -1,3 +1,4 @@ +import { useId } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; @@ -92,6 +93,8 @@ export function StageBadge({ stage, className }: StageBadgeProps) { return ( + {/* The tooltip is pointer-only; the description travels with the + badge text for AT instead of adding a roleless tab stop per card. */} {meta.label} + : {meta.description} {meta.description} @@ -114,16 +118,26 @@ interface StageProgressProps { export function StageProgressBar({ stage, showLabel = true }: StageProgressProps) { const meta = STAGES[asStage(stage)]; + const descriptionId = useId(); return ( -
+
+ + {meta.description} + {showLabel && }
diff --git a/apps/web/src/components/TagChip.tsx b/apps/web/src/components/TagChip.tsx index fcfa602..bf90294 100644 --- a/apps/web/src/components/TagChip.tsx +++ b/apps/web/src/components/TagChip.tsx @@ -12,13 +12,14 @@ interface TagChipProps { tag: Pick; count?: number; showNamespace?: boolean; + /** Toggle state. Only pass it for real toggles — it emits `aria-pressed`. */ active?: boolean; asLink?: boolean; onClick?: () => void; className?: string; } -export function TagChip({ tag, count, showNamespace = false, active = false, asLink = true, onClick, className }: TagChipProps) { +export function TagChip({ tag, count, showNamespace = false, active, asLink = true, onClick, className }: TagChipProps) { const nsClass = NAMESPACE_CLASSES[tag.namespace] ?? NAMESPACE_CLASSES['topic']!; const display = showNamespace ? `${tag.namespace} · ${tag.title}` : tag.title; const inner = ( @@ -38,7 +39,7 @@ export function TagChip({ tag, count, showNamespace = false, active = false, asL if (onClick) { return ( - ); diff --git a/apps/web/src/components/TagPicker.tsx b/apps/web/src/components/TagPicker.tsx index 12edec8..67d40af 100644 --- a/apps/web/src/components/TagPicker.tsx +++ b/apps/web/src/components/TagPicker.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useId, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; @@ -18,7 +18,16 @@ interface TagPickerProps { description?: string; } -/** Tag picker — autocompletes against the existing tag space for `namespace`. */ +const CREATABLE_SLUG = /^[a-z0-9][a-z0-9-]{0,49}$/; + +/** + * Tag picker — autocompletes against the existing tag space for `namespace`. + * + * An ARIA APG combobox: focus stays on the `role="combobox"` input and the + * active `role="option"` is pointed at with `aria-activedescendant`, so the + * list is operable from the keyboard (arrows wrap, Enter selects, Escape + * closes) as `specs/behaviors/app-shell.md` requires of every dropdown. + */ export function TagPicker({ namespace, label, @@ -29,7 +38,14 @@ export function TagPicker({ }: TagPickerProps) { const [query, setQuery] = useState(''); const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); const containerRef = useRef(null); + const inputRef = useRef(null); + + const baseId = useId(); + const inputId = `${baseId}-input`; + const listboxId = `${baseId}-listbox`; + const optionId = (i: number) => `${baseId}-option-${i}`; const tagsQ = useQuery({ queryKey: ['tag-picker', namespace], @@ -50,24 +66,34 @@ export function TagPicker({ .slice(0, 12); }, [allTags, query, value]); - const exactMatch = filtered.find( - (t) => t.slug.toLowerCase() === query.trim().toLowerCase(), + const trimmedQuery = query.trim().toLowerCase(); + const exactMatch = filtered.find((t) => t.slug.toLowerCase() === trimmedQuery); + const canCreate = Boolean( + allowCreate && trimmedQuery && !exactMatch && CREATABLE_SLUG.test(trimmedQuery), ); - useEffect(() => { - const handler = (e: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false); - } - }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); - }, []); + const optionCount = filtered.length + (canCreate ? 1 : 0); + const showList = open && optionCount > 0; + // Clamp instead of resetting from an effect — the tag list loads async and + // filtering can shrink the option set out from under the cursor. + const activeIdx = activeIndex >= 0 && activeIndex < optionCount ? activeIndex : -1; + + const close = () => { + setOpen(false); + setActiveIndex(-1); + }; + + // Close when focus leaves the widget entirely — Tab out, or a click + // anywhere outside. Options carry tabIndex={-1} so a click on one lands + // focus inside the container (relatedTarget) and does not count as leaving. + const handleBlur = (e: React.FocusEvent) => { + if (!containerRef.current?.contains(e.relatedTarget as Node | null)) close(); + }; const addTag = (slug: string) => { if (!value.includes(slug)) onChange([...value, slug]); setQuery(''); - setOpen(false); + close(); }; const removeTag = (slug: string) => { @@ -79,26 +105,78 @@ export function TagPicker({ return found?.title ?? slug; }; + /** Activate the option at `i`: an existing tag, or the trailing create entry. */ + const selectOption = (i: number) => { + // A pointer selection lands focus on the option, which is about to + // unmount; bring it back to the input so the next tag can be typed. + // Done first so the input's onFocus (which opens) is superseded by the + // close() inside addTag. + inputRef.current?.focus(); + const tag = filtered[i]; + if (tag) { + addTag(tag.slug); + } else if (canCreate && i === filtered.length) { + addTag(trimmedQuery); + } + }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setOpen(true); + if (optionCount > 0) { + setActiveIndex(activeIdx === -1 ? 0 : (activeIdx + 1) % optionCount); + } + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setOpen(true); + if (optionCount > 0) { + setActiveIndex(activeIdx <= 0 ? optionCount - 1 : activeIdx - 1); + } + return; + } + if (e.key === 'Escape') { + close(); + return; + } if (e.key === 'Enter') { e.preventDefault(); - const q = query.trim().toLowerCase(); - if (!q) return; + if (showList && activeIdx >= 0) { + selectOption(activeIdx); + return; + } + // No active option — fall back to the historical + // exact-match → first-match → create chain. + if (!trimmedQuery) return; if (exactMatch) { addTag(exactMatch.slug); } else if (filtered[0]) { addTag(filtered[0].slug); - } else if (allowCreate && /^[a-z0-9][a-z0-9-]{0,49}$/.test(q)) { - addTag(q); + } else if (allowCreate && CREATABLE_SLUG.test(trimmedQuery)) { + addTag(trimmedQuery); } - } else if (e.key === 'Backspace' && !query && value.length > 0) { + return; + } + if (e.key === 'Backspace' && !query && value.length > 0) { onChange(value.slice(0, -1)); } }; + const optionClass = (i: number) => + cn( + 'cursor-pointer px-3 py-1.5 text-sm hover:bg-accent', + i === activeIdx && 'bg-accent', + ); + return ( -
- {label && } +
+ {label && ( + + )} {description && (

{description}

)} @@ -122,12 +200,26 @@ export function TagPicker({
= 0 ? optionId(activeIdx) : undefined + } onChange={(e) => { setQuery(e.target.value); setOpen(true); + setActiveIndex(-1); }} onFocus={() => setOpen(true)} + // Focus stays here after a mouse selection, so onFocus alone can + // never reopen the list; a click has to. + onClick={() => setOpen(true)} onKeyDown={handleKeyDown} placeholder={ allowCreate @@ -135,41 +227,52 @@ export function TagPicker({ : `Add ${namespace} tag — type to search…` } /> - {open && (filtered.length > 0 || (allowCreate && query.trim())) && ( + {showList && (
    - {filtered.map((t: TagResponse) => ( -
  • - + {filtered.map((t: TagResponse, i) => ( +
  • selectOption(i)} + // onMouseMove (guarded), not onMouseEnter: options arriving + // under a stationary pointer must not steal the highlight. + onMouseMove={() => { + if (i !== activeIdx) setActiveIndex(i); + }} + className={optionClass(i)} + > + {t.title}{' '} + + ({t.slug} · {t.projectCount} projects) +
  • ))} - {allowCreate && - query.trim() && - !exactMatch && - /^[a-z0-9][a-z0-9-]{0,49}$/.test(query.trim().toLowerCase()) && ( -
  • - -
  • - )} + {canCreate && ( +
  • selectOption(filtered.length)} + onMouseMove={() => { + if (filtered.length !== activeIdx) setActiveIndex(filtered.length); + }} + className={cn(optionClass(filtered.length), 'text-primary')} + > + Create new tag “{trimmedQuery}” +
  • + )}
)}
diff --git a/apps/web/src/components/TopProgressBar.tsx b/apps/web/src/components/TopProgressBar.tsx index cf3007e..f51f52f 100644 --- a/apps/web/src/components/TopProgressBar.tsx +++ b/apps/web/src/components/TopProgressBar.tsx @@ -15,6 +15,10 @@ export function TopProgressBar() { aria-valuenow={isNavigating ? 50 : 100} aria-valuemin={0} aria-valuemax={100} + // The bar is only faded out when idle, not unmounted (the fade needs the + // node to stay put). Hide it from AT meanwhile, or every page announces a + // finished "Page loading" bar. + aria-hidden={!isNavigating} className="fixed top-0 left-0 right-0 h-0.5 z-50 bg-primary transition-all duration-300" style={{ opacity: isNavigating ? 1 : 0, diff --git a/apps/web/src/components/modals/AddMemberModal.tsx b/apps/web/src/components/modals/AddMemberModal.tsx index adb99e7..ee9ff3c 100644 --- a/apps/web/src/components/modals/AddMemberModal.tsx +++ b/apps/web/src/components/modals/AddMemberModal.tsx @@ -84,9 +84,14 @@ export function AddMemberModal({ open, onOpenChange, projectSlug }: AddMemberMod placeholder="e.g. chris" required aria-invalid={fieldErrors['personSlug'] ? 'true' : 'false'} + aria-describedby={ + fieldErrors['personSlug'] ? 'member-slug-error' : undefined + } /> {fieldErrors['personSlug'] && ( -

{fieldErrors['personSlug']}

+

+ {fieldErrors['personSlug']} +

)}
diff --git a/apps/web/src/components/modals/ManageMembersModal.tsx b/apps/web/src/components/modals/ManageMembersModal.tsx index ca2f95b..8ee24ed 100644 --- a/apps/web/src/components/modals/ManageMembersModal.tsx +++ b/apps/web/src/components/modals/ManageMembersModal.tsx @@ -113,6 +113,7 @@ export function ManageMembersModal({ open, onOpenChange, project }: ManageMember setEditingRole((r) => ({ ...r, [rowKey]: e.target.value })) } placeholder="Role" + aria-label="Role" className="h-7 mt-1 text-xs" /> ) : ( diff --git a/apps/web/src/components/modals/PostHelpWantedModal.tsx b/apps/web/src/components/modals/PostHelpWantedModal.tsx index 4bd1cab..87ee4f4 100644 --- a/apps/web/src/components/modals/PostHelpWantedModal.tsx +++ b/apps/web/src/components/modals/PostHelpWantedModal.tsx @@ -102,9 +102,13 @@ export function PostHelpWantedModal({ maxLength={120} required placeholder="e.g. React developer for admin dashboard" + aria-invalid={fieldErrors['title'] ? 'true' : 'false'} + aria-describedby={fieldErrors['title'] ? 'hw-title-error' : undefined} /> {fieldErrors['title'] && ( -

{fieldErrors['title']}

+

+ {fieldErrors['title']} +

)}
setTitle(e.target.value)} maxLength={80} required + aria-invalid={fieldErrors['title'] ? 'true' : 'false'} + aria-describedby={fieldErrors['title'] ? 'title-error' : undefined} /> {fieldErrors['title'] && ( -

{fieldErrors['title']}

+

+ {fieldErrors['title']} +

)}
) : ( @@ -97,9 +101,15 @@ export function TagEditModal({ open, onOpenChange, tag, mode }: TagEditModalProp onChange={(e) => setMergeInto(e.target.value)} placeholder="e.g. tech.flutter" required + aria-invalid={fieldErrors['mergeInto'] ? 'true' : 'false'} + aria-describedby={ + fieldErrors['mergeInto'] ? 'mergeInto-error' : undefined + } /> {fieldErrors['mergeInto'] && ( -

{fieldErrors['mergeInto']}

+

+ {fieldErrors['mergeInto']} +

)}
)} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 088889e..d08befc 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -135,7 +135,7 @@ nav[aria-label="Breadcrumb"], [data-testid="offline-banner"], [data-testid="network-error-banner"], - [id="search-results-dropdown"] { + [data-search-dropdown] { display: none !important; } } diff --git a/apps/web/src/lib/queryClient.tsx b/apps/web/src/lib/queryClient.tsx index 47d45e3..97a0b7c 100644 --- a/apps/web/src/lib/queryClient.tsx +++ b/apps/web/src/lib/queryClient.tsx @@ -6,34 +6,36 @@ import { ApiError } from '@/lib/api'; export function ApiQueryClientProvider({ children }: { children: ReactNode }) { const { showError } = useNetworkError(); - const client = useMemo( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: 30_000, - retry: (failureCount, error) => { - if (error instanceof ApiError && error.status >= 400 && error.status < 500) { - return false; - } - return failureCount < 2; - }, - refetchOnWindowFocus: false, - }, - }, - queryCache: new QueryCache({ - onError: (error) => { - if (error instanceof ApiError && error.isServerError) { - showError('Something went wrong. We are looking at it.'); - } else if (!(error instanceof ApiError)) { - // Network-level error (fetch threw): treat as server error - showError('Network error. Please check your connection and try again.'); + const client = useMemo(() => { + // The banner's Retry re-issues whatever is currently on screen. The + // closure runs on a later error, after `queryClient` is assigned. + const retry = () => void queryClient.refetchQueries({ type: 'active' }); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status >= 400 && error.status < 500) { + return false; } + return failureCount < 2; }, - }), + refetchOnWindowFocus: false, + }, + }, + queryCache: new QueryCache({ + onError: (error) => { + if (error instanceof ApiError && error.isServerError) { + showError('Something went wrong. We are looking at it.', retry); + } else if (!(error instanceof ApiError)) { + // Network-level error (fetch threw): treat as server error + showError('Network error. Please check your connection and try again.', retry); + } + }, }), - [showError], - ); + }); + return queryClient; + }, [showError]); // Clean up on unmount useEffect(() => { diff --git a/apps/web/src/pages/AccountClaim.tsx b/apps/web/src/pages/AccountClaim.tsx index be71480..10eaf76 100644 --- a/apps/web/src/pages/AccountClaim.tsx +++ b/apps/web/src/pages/AccountClaim.tsx @@ -105,8 +105,12 @@ export function AccountClaim() { if (loading || authLoading) { return ( -
-
+
+ ); } diff --git a/apps/web/src/pages/LoginPlaceholder.tsx b/apps/web/src/pages/LoginPlaceholder.tsx index 581185a..0b376b6 100644 --- a/apps/web/src/pages/LoginPlaceholder.tsx +++ b/apps/web/src/pages/LoginPlaceholder.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type FormEvent } from 'react'; +import { useEffect, useId, useState, type FormEvent } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; import { Card, @@ -49,6 +49,7 @@ const ERROR_MESSAGES: Record = { function WhyGitHub() { const [open, setOpen] = useState(false); + const panelId = useId(); return (
@@ -57,11 +58,15 @@ function WhyGitHub() { onClick={() => setOpen((v) => !v)} className="text-sm text-muted-foreground hover:text-foreground underline-offset-2 hover:underline" aria-expanded={open} + aria-controls={open ? panelId : undefined} > Why GitHub? {open && ( -
+
We chose GitHub as the sole identity provider for three reasons: (1) the civic-tech community already lives there, (2) it filters spam and scam accounts more effectively than email-only sign-ups, and (3) most @@ -100,8 +105,12 @@ export function LoginPlaceholder() { if (loading) { return ( -
-
+
+ ); } diff --git a/apps/web/src/screens/Account.tsx b/apps/web/src/screens/Account.tsx index 87a05b2..a9260ca 100644 --- a/apps/web/src/screens/Account.tsx +++ b/apps/web/src/screens/Account.tsx @@ -255,10 +255,10 @@ export function Account() { - - - - + + + + diff --git a/apps/web/src/screens/HelpWantedIndex.tsx b/apps/web/src/screens/HelpWantedIndex.tsx index 0fd54ea..ae916f7 100644 --- a/apps/web/src/screens/HelpWantedIndex.tsx +++ b/apps/web/src/screens/HelpWantedIndex.tsx @@ -173,6 +173,7 @@ export function HelpWantedIndex() { p.delete('commitmentMax'); }) } + aria-label={`Remove filter: ≤ ${commitmentMax} hrs/week`} className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-0.5 text-xs hover:bg-accent" > ≤ {commitmentMax} hrs/week × diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index 6b6b9eb..1b6e56e 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -156,6 +156,7 @@ export function Home() { key={f} type="button" onClick={() => setActivityFilter(f)} + aria-pressed={activityFilter === f} className={cn( 'text-xs font-medium px-3 py-1 rounded-full border transition-colors capitalize', activityFilter === f diff --git a/apps/web/src/screens/ProfileEdit.tsx b/apps/web/src/screens/ProfileEdit.tsx index 3e92c61..465757c 100644 --- a/apps/web/src/screens/ProfileEdit.tsx +++ b/apps/web/src/screens/ProfileEdit.tsx @@ -182,7 +182,9 @@ export function ProfileEdit() {
- +
{person.avatarUrl ? ( )} -
@@ -219,9 +226,13 @@ export function ProfileEdit() { value={form.fullName} onChange={(e) => setForm((f) => ({ ...f, fullName: e.target.value }))} required + aria-invalid={fieldErrors['fullName'] ? 'true' : 'false'} + aria-describedby={fieldErrors['fullName'] ? 'fullName-error' : undefined} /> {fieldErrors['fullName'] && ( -

{fieldErrors['fullName']}

+

+ {fieldErrors['fullName']} +

)} @@ -260,10 +271,14 @@ export function ProfileEdit() { value={form.slug} onChange={(e) => setForm((f) => ({ ...f, slug: slugify(e.target.value) }))} pattern="^[a-z0-9][a-z0-9-_]{1,79}$" + aria-invalid={fieldErrors['slug'] ? 'true' : 'false'} + aria-describedby={fieldErrors['slug'] ? 'slug-error' : undefined} />

URL: /members/{form.slug}

{fieldErrors['slug'] && ( -

{fieldErrors['slug']}

+

+ {fieldErrors['slug']} +

)} )} diff --git a/apps/web/src/screens/ProjectBuzzNew.tsx b/apps/web/src/screens/ProjectBuzzNew.tsx index cb4c3b5..6ed3c7f 100644 --- a/apps/web/src/screens/ProjectBuzzNew.tsx +++ b/apps/web/src/screens/ProjectBuzzNew.tsx @@ -117,9 +117,12 @@ export function ProjectBuzzNew() { required placeholder="The Inquirer praises Project X" aria-invalid={fieldErrors['headline'] ? 'true' : 'false'} + aria-describedby={fieldErrors['headline'] ? 'headline-error' : undefined} /> {fieldErrors['headline'] && ( -

{fieldErrors['headline']}

+

+ {fieldErrors['headline']} +

)} @@ -135,9 +138,12 @@ export function ProjectBuzzNew() { required placeholder="https://www.inquirer.com/…" aria-invalid={fieldErrors['url'] ? 'true' : 'false'} + aria-describedby={fieldErrors['url'] ? 'url-error' : undefined} /> {fieldErrors['url'] && ( -

{fieldErrors['url']}

+

+ {fieldErrors['url']} +

)}

Must be HTTPS. Each URL can only be logged once per project. @@ -156,9 +162,14 @@ export function ProjectBuzzNew() { required max={todayIso()} aria-invalid={fieldErrors['publishedAt'] ? 'true' : 'false'} + aria-describedby={ + fieldErrors['publishedAt'] ? 'publishedAt-error' : undefined + } /> {fieldErrors['publishedAt'] && ( -

{fieldErrors['publishedAt']}

+

+ {fieldErrors['publishedAt']} +

)} @@ -171,12 +182,16 @@ export function ProjectBuzzNew() { maxLength={2000} rows={4} placeholder="Optional excerpt or quote. Markdown supported." + aria-invalid={fieldErrors['summary'] ? 'true' : 'false'} + aria-describedby={fieldErrors['summary'] ? 'summary-error' : undefined} />

{form.summary.length} / 2000

{fieldErrors['summary'] && ( -

{fieldErrors['summary']}

+

+ {fieldErrors['summary']} +

)} diff --git a/apps/web/src/screens/ProjectEdit.tsx b/apps/web/src/screens/ProjectEdit.tsx index 0dc8fc6..0330557 100644 --- a/apps/web/src/screens/ProjectEdit.tsx +++ b/apps/web/src/screens/ProjectEdit.tsx @@ -295,9 +295,12 @@ export function ProjectEdit({ mode }: ProjectEditProps) { maxLength={200} required aria-invalid={fieldErrors['title'] ? 'true' : 'false'} + aria-describedby={fieldErrors['title'] ? 'title-error' : undefined} /> {fieldErrors['title'] && ( -

{fieldErrors['title']}

+

+ {fieldErrors['title']} +

)} @@ -317,8 +320,16 @@ export function ProjectEdit({ mode }: ProjectEditProps) { pattern="^[a-z0-9][a-z0-9-_]{1,79}$" required className="flex-1" + aria-invalid={fieldErrors['slug'] ? 'true' : 'false'} + aria-describedby={ + fieldErrors['slug'] ? 'slug-status slug-error' : 'slug-status' + } /> + {/* role="status" so the debounced availability check is announced + — it is otherwise a purely visual ✓/✗ next to the field. */} {form.slug || 'your-slug'}

{fieldErrors['slug'] && ( -

{fieldErrors['slug']}

+

+ {fieldErrors['slug']} +

)} )} @@ -393,9 +406,13 @@ export function ProjectEdit({ mode }: ProjectEditProps) { value={form.usersUrl} onChange={(e) => setForm((f) => ({ ...f, usersUrl: e.target.value }))} placeholder="https://" + aria-invalid={fieldErrors['usersUrl'] ? 'true' : 'false'} + aria-describedby={fieldErrors['usersUrl'] ? 'usersUrl-error' : undefined} /> {fieldErrors['usersUrl'] && ( -

{fieldErrors['usersUrl']}

+

+ {fieldErrors['usersUrl']} +

)}
@@ -406,9 +423,15 @@ export function ProjectEdit({ mode }: ProjectEditProps) { value={form.developersUrl} onChange={(e) => setForm((f) => ({ ...f, developersUrl: e.target.value }))} placeholder="https://" + aria-invalid={fieldErrors['developersUrl'] ? 'true' : 'false'} + aria-describedby={ + fieldErrors['developersUrl'] ? 'developersUrl-error' : undefined + } /> {fieldErrors['developersUrl'] && ( -

{fieldErrors['developersUrl']}

+

+ {fieldErrors['developersUrl']} +

)}
@@ -422,10 +445,16 @@ export function ProjectEdit({ mode }: ProjectEditProps) { value={form.chatChannel} onChange={(e) => setForm((f) => ({ ...f, chatChannel: e.target.value }))} placeholder="my-channel" + aria-invalid={fieldErrors['chatChannel'] ? 'true' : 'false'} + aria-describedby={ + fieldErrors['chatChannel'] ? 'chatChannel-error' : undefined + } /> {fieldErrors['chatChannel'] && ( -

{fieldErrors['chatChannel']}

+

+ {fieldErrors['chatChannel']} +

)} diff --git a/apps/web/src/screens/ProjectsIndex.tsx b/apps/web/src/screens/ProjectsIndex.tsx index 805f407..26cfcb2 100644 --- a/apps/web/src/screens/ProjectsIndex.tsx +++ b/apps/web/src/screens/ProjectsIndex.tsx @@ -200,16 +200,20 @@ export function ProjectsIndex() { /> ); })} - {stages.map((s) => ( - - ))} + {stages.map((s) => { + const stageLabel = STAGES[s as Stage]?.label ?? s; + return ( + + ); + })} + ); +} + +describe('NetworkErrorBanner', () => { + it('offers Retry that re-runs the failed work and dismisses', async () => { + const user = userEvent.setup(); + const retry = vi.fn(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Break' })); + const banner = screen.getByRole('alert'); + expect(banner).toHaveTextContent('Something broke.'); + + await user.click(screen.getByRole('button', { name: 'Retry' })); + + expect(retry).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('reads Dismiss when there is nothing to retry', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Break' })); + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/tests/SearchBox.test.tsx b/apps/web/tests/SearchBox.test.tsx new file mode 100644 index 0000000..45402c6 --- /dev/null +++ b/apps/web/tests/SearchBox.test.tsx @@ -0,0 +1,173 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useLocation } from 'react-router'; +import { renderWithRouter, mockPaginated } from './test-utils.js'; +import { SearchBox } from '../src/components/SearchBox.js'; +import { NetworkErrorProvider } from '../src/components/NetworkErrorBanner.js'; + +/** Surfaces the router location so we can assert on in-SPA navigation. */ +function LocationProbe() { + const loc = useLocation(); + return
{`${loc.pathname}${loc.search}`}
; +} + +function Wrapped() { + return ( + + + + + ); +} + +/** Type a query and wait for the debounced results to land. */ +async function openWithResults(user: ReturnType) { + const input = screen.getByRole('combobox', { name: 'Search the site' }); + await user.type(input, 'react'); + await waitFor( + () => { + // 3 results + the trailing "See all results" option + expect(screen.getAllByRole('option')).toHaveLength(4); + }, + { timeout: 3000 }, + ); + return input; +} + +describe('SearchBox', () => { + beforeEach(() => { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/projects')) { + return Promise.resolve( + new Response(JSON.stringify(mockPaginated([{ slug: 'p1', title: 'Project One' }])), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + if (input.startsWith('/api/people')) { + return Promise.resolve( + new Response(JSON.stringify(mockPaginated([{ slug: 'm1', fullName: 'Member One' }])), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + if (input.startsWith('/api/tags')) { + return Promise.resolve( + new Response( + JSON.stringify( + mockPaginated([ + { slug: 'react', namespace: 'tech', handle: 'tech.react', title: 'React' }, + ]), + ), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exposes the APG combobox attributes', async () => { + const user = userEvent.setup(); + renderWithRouter(); + + const input = screen.getByRole('combobox', { name: 'Search the site' }); + expect(input).toHaveAttribute('aria-autocomplete', 'list'); + expect(input).toHaveAttribute('aria-expanded', 'false'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await openWithResults(user); + + expect(input).toHaveAttribute('aria-expanded', 'true'); + const listbox = screen.getByRole('listbox', { name: 'Search results' }); + expect(input).toHaveAttribute('aria-controls', listbox.id); + }, 20000); + + it('owns only groups and options inside the listbox', async () => { + const user = userEvent.setup(); + renderWithRouter(); + await openWithResults(user); + + // Group headers are exposed as labelled groups, not stray divs. + expect(screen.getByRole('group', { name: 'Projects' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'Members' })).toBeInTheDocument(); + expect(screen.getByRole('group', { name: 'Tags' })).toBeInTheDocument(); + + // The status region sits outside the listbox. + const listbox = screen.getByRole('listbox', { name: 'Search results' }); + for (const child of Array.from(listbox.children)) { + expect(['group', 'option']).toContain(child.getAttribute('role')); + } + }, 20000); + + it('moves aria-activedescendant with ArrowDown and navigates on Enter', async () => { + const user = userEvent.setup(); + renderWithRouter(); + const input = await openWithResults(user); + + await user.keyboard('{ArrowDown}'); + + const options = screen.getAllByRole('option'); + expect(input).toHaveAttribute('aria-activedescendant', options[0]!.id); + expect(options[0]).toHaveAttribute('aria-selected', 'true'); + expect(options[1]).toHaveAttribute('aria-selected', 'false'); + + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[1]!.id); + + await user.keyboard('{ArrowUp}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0]!.id); + + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.getByTestId('location')).toHaveTextContent('/projects/p1'); + }); + }, 20000); + + it('wraps from the last option back to the first', async () => { + const user = userEvent.setup(); + renderWithRouter(); + const input = await openWithResults(user); + + const options = screen.getAllByRole('option'); + await user.keyboard('{ArrowUp}'); + expect(input).toHaveAttribute('aria-activedescendant', options[3]!.id); + + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0]!.id); + }, 20000); + + it('closes the popup on Escape', async () => { + const user = userEvent.setup(); + renderWithRouter(); + const input = await openWithResults(user); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + expect(input).toHaveAttribute('aria-expanded', 'false'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + }, 20000); + + it('falls back to the all-results route when no option is active', async () => { + const user = userEvent.setup(); + renderWithRouter(); + await openWithResults(user); + + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.getByTestId('location')).toHaveTextContent('/projects?q=react'); + }); + }, 20000); +}); diff --git a/apps/web/tests/TagPicker.test.tsx b/apps/web/tests/TagPicker.test.tsx new file mode 100644 index 0000000..c233cbf --- /dev/null +++ b/apps/web/tests/TagPicker.test.tsx @@ -0,0 +1,218 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { renderScreen, mockPaginated } from './test-utils.js'; +import { TagPicker } from '../src/components/TagPicker.js'; + +const TAGS = [ + { + id: 't1', + handle: 'topic.civic-tech', + namespace: 'topic', + slug: 'civic-tech', + title: 'Civic Tech', + projectCount: 3, + personCount: 2, + helpWantedCount: 0, + }, + { + id: 't2', + handle: 'topic.housing', + namespace: 'topic', + slug: 'housing', + title: 'Housing', + projectCount: 1, + personCount: 0, + helpWantedCount: 0, + }, +]; + +/** Drives TagPicker as a real consumer would — controlled `value`. */ +function Harness({ allowCreate = false }: { allowCreate?: boolean }) { + const [value, setValue] = useState([]); + return ( + + ); +} + +async function findCombobox() { + return waitFor(() => screen.getByRole('combobox', { name: 'Topics' })); +} + +describe('TagPicker', () => { + beforeEach(() => { + vi.spyOn(globalThis, 'fetch').mockImplementation(((input: string) => { + if (input.startsWith('/api/tags')) { + return Promise.resolve( + new Response(JSON.stringify(mockPaginated(TAGS)), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) as typeof fetch); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('associates its label with the combobox input', async () => { + renderScreen(); + + // getByLabelText only resolves if
DeviceIPIssuedStatusDeviceIPIssuedStatus