)}
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 (
-
+
{inner}
);
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 (
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 (
-