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..3ff7053 100644 --- a/apps/web/src/components/NetworkErrorBanner.tsx +++ b/apps/web/src/components/NetworkErrorBanner.tsx @@ -33,11 +33,7 @@ export function NetworkErrorProvider({ children }: { children: ReactNode }) { data-testid="network-error-banner" > {error} -
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..0d6cdab 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 /> ) : ( ({ 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(); + }, + [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 === 'Home' && showDropdown && len > 0) { + e.preventDefault(); + setActiveIndex(0); + return; + } + if (e.key === 'End' && showDropdown && len > 0) { + e.preventDefault(); + setActiveIndex(len - 1); + return; + } + if (e.key === 'Enter') { + const target = showDropdown && activeIdx >= 0 ? optionUrls[activeIdx] : undefined; + if (target) { + e.preventDefault(); + activate(target); + } else if (trimmed) { + void navigate(`/projects?q=${encodeURIComponent(trimmed)}`); + clear(); + close(); + inputRef.current?.blur(); + } + return; } if (e.key === 'Escape') { clear(); - setOpen(false); + close(); inputRef.current?.blur(); } }, - [navigate, query, clear], + [optionUrls, activeIdx, showDropdown, trimmed, activate, navigate, 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 && ( )}
diff --git a/apps/web/src/components/StageBadge.tsx b/apps/web/src/components/StageBadge.tsx index bb103db..7d8a958 100644 --- a/apps/web/src/components/StageBadge.tsx +++ b/apps/web/src/components/StageBadge.tsx @@ -92,7 +92,10 @@ export function StageBadge({ stage, className }: StageBadgeProps) { return ( + {/* tabIndex makes the trigger focusable so the tooltip — which carries + the stage description — is reachable without a pointer. */} -
+ {/* When the badge is shown it is its own focusable tooltip trigger, so + the wrapper stays out of the tab order to avoid two adjacent stops + opening the same tooltip. */} +
diff --git a/apps/web/src/components/TagChip.tsx b/apps/web/src/components/TagChip.tsx index fcfa602..3e22f61 100644 --- a/apps/web/src/components/TagChip.tsx +++ b/apps/web/src/components/TagChip.tsx @@ -38,7 +38,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..494b300 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 { useEffect, 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,8 +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 baseId = useId(); + const inputId = `${baseId}-input`; + const listboxId = `${baseId}-listbox`; + const optionId = (i: number) => `${baseId}-option-${i}`; + const tagsQ = useQuery({ queryKey: ['tag-picker', namespace], queryFn: () => api.tags.list({ namespace, perPage: 100 }), @@ -50,14 +65,23 @@ 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), ); + 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; + useEffect(() => { const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); + setActiveIndex(-1); } }; document.addEventListener('mousedown', handler); @@ -68,6 +92,7 @@ export function TagPicker({ if (!value.includes(slug)) onChange([...value, slug]); setQuery(''); setOpen(false); + setActiveIndex(-1); }; const removeTag = (slug: string) => { @@ -79,26 +104,74 @@ export function TagPicker({ return found?.title ?? slug; }; + /** Activate the option at `i`: an existing tag, or the trailing create entry. */ + const selectOption = (i: number) => { + 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') { + setOpen(false); + setActiveIndex(-1); + 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,10 +195,20 @@ export function TagPicker({
= 0 ? optionId(activeIdx) : undefined + } onChange={(e) => { setQuery(e.target.value); setOpen(true); + setActiveIndex(-1); }} onFocus={() => setOpen(true)} onKeyDown={handleKeyDown} @@ -135,41 +218,43 @@ export function TagPicker({ : `Add ${namespace} tag — type to search…` } /> - {open && (filtered.length > 0 || (allowCreate && query.trim())) && ( + {showList && (
    e.preventDefault()} className="absolute z-20 mt-1 w-full max-h-56 overflow-auto rounded-md border border-border bg-popover shadow-md" > - {filtered.map((t: TagResponse) => ( -
  • - + {filtered.map((t: TagResponse, i) => ( +
  • selectOption(i)} + onMouseEnter={() => 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)} + onMouseEnter={() => 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/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 5b82177..23f14f3 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, @@ -63,6 +63,7 @@ function GitHubIcon() { function WhyGitHub() { const [open, setOpen] = useState(false); + const panelId = useId(); return (
@@ -71,11 +72,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 @@ -114,8 +119,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 ( + + ); + })}
DeviceIPIssuedStatusDeviceIPIssuedStatus