Skip to content
Draft
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
2 changes: 1 addition & 1 deletion apps/web/src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function AppShell() {
{/* Skip to main content — must be the first focusable element */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded focus:shadow-lg focus:outline-none"
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded focus:shadow-lg"
>
Skip to main content
</a>
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/components/ConnectGitHubBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ export function ConnectGitHubBanner() {
variant="ghost"
size="sm"
onClick={() => setDismissed(true)}
aria-label="Dismiss"
>
Dismiss
</Button>
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/MarkdownEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function MarkdownEditor({
required,
}: MarkdownEditorProps) {
const id = useId();
const errorId = `${id}-error`;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewLoading, setPreviewLoading] = useState(false);
Expand Down Expand Up @@ -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. */}
<div
className="p-3 bg-background text-sm overflow-auto"
style={{ minHeight }}
aria-live="polite"
>
{previewError ? (
<p className="text-xs text-destructive">{previewError}</p>
Expand All @@ -192,7 +196,9 @@ export function MarkdownEditor({
</div>
<div className="flex items-center justify-between text-xs">
{error ? (
<span className="text-destructive">{error}</span>
<span id={errorId} className="text-destructive">
{error}
</span>
) : (
<span className="text-muted-foreground">Markdown · supports GFM</span>
)}
Expand Down
6 changes: 1 addition & 5 deletions apps/web/src/components/NetworkErrorBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,7 @@ export function NetworkErrorProvider({ children }: { children: ReactNode }) {
data-testid="network-error-banner"
>
<span>{error}</span>
<button
onClick={clearError}
className="ml-4 underline hover:no-underline"
aria-label="Dismiss error"
>
<button onClick={clearError} className="ml-4 underline hover:no-underline">
Retry
</button>
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/PersonAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function PersonAvatar({ person, size = 32, asLink = true, className, titl
/>
) : (
<span
role="img"
title={title ?? person.fullName}
className={cn(
'inline-flex items-center justify-center rounded-full font-medium',
Expand Down
248 changes: 186 additions & 62 deletions apps/web/src/components/SearchBox.tsx
Original file line number Diff line number Diff line change
@@ -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 */
Expand All @@ -22,47 +23,138 @@ 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 `<a href>` — `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<HTMLInputElement>(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<HTMLInputElement>) => {
setQuery(e.target.value);
setOpen(true);
setActiveIndex(-1);
},
[setQuery],
);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
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<HTMLAnchorElement>, 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 (
<div
Expand All @@ -71,69 +163,101 @@ export function SearchBox({ inline = false }: SearchBoxProps) {
<Input
ref={inputRef}
type="search"
role="combobox"
placeholder="Search projects, members, tags..."
value={query}
autoComplete="off"
aria-label="Search the site"
aria-expanded={showDropdown}
aria-controls="search-results-dropdown"
aria-controls={showDropdown ? listboxId : undefined}
aria-autocomplete="list"
aria-activedescendant={activeDescendant}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onBlur={close}
onKeyDown={handleKeyDown}
className="h-8 text-sm"
/>

{showDropdown && (
<div
id="search-results-dropdown"
role="listbox"
aria-label="Search results"
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()}
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 py-1 max-h-[28rem] overflow-y-auto"
>
{loading && results.length === 0 && (
<p className="px-3 py-2 text-sm text-muted-foreground">Searching…</p>
)}
{!loading && results.length === 0 && (
<p className="px-3 py-2 text-sm text-muted-foreground">
No results for &ldquo;{query}&rdquo;
</p>
)}

{grouped.map((group) => (
<div key={group.type}>
<div className="px-3 pt-2 pb-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{GROUP_LABELS[group.type]}
</div>
{group.items.map((r) => (
<a
key={`${r.type}-${r.slug}`}
href={r.url}
role="option"
aria-selected={false}
className="block px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
onClick={() => {
clear();
setOpen(false);
}}
{/* Status lives outside the listbox — a listbox may only own
options, groups and presentational content. */}
<div role="status" className="empty:hidden">
{loading && results.length === 0 && (
<p className="px-3 py-2 text-sm text-muted-foreground">Searching…</p>
)}
{!loading && results.length === 0 && (
<p className="px-3 py-2 text-sm text-muted-foreground">
No results for &ldquo;{query}&rdquo;
</p>
)}
</div>

<div id={listboxId} role="listbox" aria-label="Search results">
{grouped.map((group, gi) => {
const offset = grouped
.slice(0, gi)
.reduce((n, g) => n + g.items.length, 0);
return (
<div
key={group.type}
role="group"
aria-labelledby={groupHeaderId(group.type)}
>
{r.title}
</a>
))}
</div>
))}

{query.trim() && (
<a
href={`/projects?q=${encodeURIComponent(query.trim())}`}
className="block px-3 py-2 text-sm border-t border-border hover:bg-accent hover:text-accent-foreground text-primary"
onClick={() => {
clear();
setOpen(false);
}}
>
See all results for &ldquo;{query}&rdquo;
</a>
)}
<div
id={groupHeaderId(group.type)}
role="presentation"
className="px-3 pt-2 pb-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide"
>
{GROUP_LABELS[group.type]}
</div>
{group.items.map((r, j) => {
const i = offset + j;
return (
<a
key={`${r.type}-${r.slug}`}
id={optionId(i)}
href={r.url}
role="option"
aria-selected={i === activeIdx}
tabIndex={-1}
className={optionClass(i)}
onMouseEnter={() => setActiveIndex(i)}
onClick={(e) => handleOptionClick(e, r.url)}
>
{r.title}
</a>
);
})}
</div>
);
})}

{seeAllUrl && (
<a
id={optionId(flat.length)}
href={seeAllUrl}
role="option"
aria-selected={flat.length === activeIdx}
tabIndex={-1}
className={cn(
'block px-3 py-2 text-sm border-t border-border hover:bg-accent hover:text-accent-foreground text-primary',
flat.length === activeIdx && 'bg-accent text-accent-foreground',
)}
onMouseEnter={() => setActiveIndex(flat.length)}
onClick={(e) => handleOptionClick(e, seeAllUrl)}
>
See all results for &ldquo;{query}&rdquo;
</a>
)}
</div>
</div>
)}
</div>
Expand Down
Loading