Skip to content
Merged
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
1 change: 1 addition & 0 deletions .changelog/next/fixed-issue-4156.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Screen readers now announce the inline form controls on the Character Sheet, GitHub, Sharing, Browser, Mood Board and Video Timeline Editor pages, which previously had no accessible name
85 changes: 65 additions & 20 deletions client/src/a11yConventions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,70 @@ function isNestedInLabeledFormField(src, index) {
return false;
}

// Page-local field wrappers (PipelineSeries.jsx's `<Field label="…">`) render
// their children inside a real <label>, so the control they wrap is implicitly
// named — the <label> just lives in the component definition instead of at the
// call site. Recognise those wrappers from their own source so a correctly
// labeled control isn't forced to carry a redundant aria-label that would
// shadow the visible text. Only same-file definitions count; a wrapper imported
// from elsewhere stays unknown (FormField has its own dedicated check), and
// only `function` declarations are matched — a missed wrapper is a false
// negative that simply leaves its controls on the allowlist.
const labelWrapperNamesBySource = new Map();

function localLabelWrapperNames(src) {
const cached = labelWrapperNamesBySource.get(src);
if (cached) return cached;
const names = new Set();
const re = /function\s+([A-Z][\w]*)\s*\(/g;
let match;
while ((match = re.exec(src))) {
// Skip the parameter list with the string-aware scanner — a default value
// like `{ label = ')' }` would close the parens early on a naive count and
// point `bodyStart` at the destructuring instead of the body.
const parenIndex = match.index + match[0].length - 1;
const params = balancedCallAt(src, parenIndex);
if (!params) continue;
const bodyStart = src.indexOf('{', parenIndex + params.length);
if (bodyStart === -1) continue;
const bodyEnd = matchingBraceEnd(src, bodyStart);
if (bodyEnd === -1) continue;
const body = src.slice(bodyStart, bodyEnd);
// {children} must sit inside a <label> — no </label> may intervene, or a
// component rendering `<label>Header</label>{children}<label>Footer</label>`
// would register as a wrapper and exempt controls it never labels. The
// opening tag must also not be self-closing (`<label … />` wraps nothing,
// so anything after it is outside the label).
if (/<label\b(?:[^>]*[^/>])?>(?:(?!<\/label>)[\s\S])*?\{\s*children\s*\}/.test(body)) names.add(match[1]);
}
labelWrapperNamesBySource.set(src, names);
return names;
}

function isNestedInLabelWrappingComponent(src, index) {
for (const name of localLabelWrapperNames(src)) {
const re = new RegExp(`</?${name}\\b`, 'g');
// Keep the label of every wrapper instance still open at `index`, not just
// the outermost: an inner `<Field label="…">` nested in an unlabeled outer
// one still names the control.
const open = [];
let match;
while ((match = re.exec(src)) && match.index < index) {
if (match[0].startsWith('</')) {
open.pop();
continue;
}
const tag = openingTagAt(src, match.index, name.length + 1);
if (!tag) continue;
re.lastIndex = match.index + tag.length;
if (/\/\s*>$/.test(tag)) continue;
open.push(normalizedAttributeValue(attributeValue(tag, 'label')));
}
if (open.some((label) => label && !/^(?:undefined|null|false)$/i.test(label))) return true;
}
return false;
}

function hasUsableAriaLabelledByReference(src, tag) {
const raw = attributeValue(tag, 'aria-labelledby');
const value = normalizedAttributeValue(raw);
Expand All @@ -599,6 +663,7 @@ function hasAccessibleInputName(src, tag, index) {
if (hasUsableAccessibleNameAttribute(tag, 'aria-labelledby') && hasUsableAriaLabelledByReference(src, tag)) return true;
if (hasUsableNativeInputName(tag)) return true;
if (isNestedInLabel(src, index) || isNestedInLabeledFormField(src, index)) return true;
if (isNestedInLabelWrappingComponent(src, index)) return true;

const id = normalizedAttributeValue(attributeValue(tag, 'id'));
return id !== null && id !== '' && hasMatchingExplicitLabel(src, id);
Expand Down Expand Up @@ -836,25 +901,7 @@ const PREEXISTING_INPUT_NAME_ALLOWLIST = new Set([
"src/pages/AIProviders.jsx|type=newEnvSecret ? 'password' : 'text'|placeholder=value|value=newEnvValue",
"src/pages/Authors.jsx|placeholder=Jane Doe|value=form.name",
"src/pages/Authors.jsx|placeholder=/images/… or https://…|value=form.headshotImageUrl",
"src/pages/Browser.jsx|type=text|placeholder=https://example.com|value=navUrl",
"src/pages/CharacterSheet.jsx|value=classVal",
"src/pages/CharacterSheet.jsx|placeholder=1d8|value=dmgDice",
"src/pages/CharacterSheet.jsx|placeholder=Description (optional)|value=dmgDesc",
"src/pages/CharacterSheet.jsx|type=number|placeholder=XP amount|value=xpAmount",
"src/pages/CharacterSheet.jsx|placeholder=Description (optional)|value=xpDesc",
"src/pages/CharacterSheet.jsx|placeholder=What happened?|value=evtDesc",
"src/pages/CharacterSheet.jsx|type=number|placeholder=XP (optional)|value=evtXp",
"src/pages/CharacterSheet.jsx|placeholder=Dice (e.g. 2d6)|value=evtDice",
"src/pages/GitHub.jsx|type=text|placeholder=Search repos...|value=search",
"src/pages/GitHub.jsx|type=text|placeholder=SECRET_NAME|value=newSecretName",
"src/pages/GitHub.jsx|type=password|placeholder=Secret value|value=newSecretValue",
"src/pages/MediaCollectionDetail.jsx|type=text|value=nameDraft",
"src/pages/MoodBoardDetail.jsx|type=text|placeholder=Add a caption…",
"src/pages/PipelineSeries.jsx|value=series.name || ''",
"src/pages/PipelineSeries.jsx|placeholder=One-sentence pitch|value=series.logline || ''",
"src/pages/PipelineSeries.jsx|type=number|value=series.issueCountTarget || 0|min=0|max=999",
"src/pages/Sharing.jsx|type=text|placeholder=Display name (e.g. atomantic)|value=sharingDisplayName",
"src/pages/Sharing.jsx|type=text|placeholder=Optional bio / contact note (visible to recipients)|value=sharingBio",
"src/components/meatspace/post/PostDrillConfig.jsx|type=number|value=drillConfig[field.key] ?? ''|min=field.min|max=field.max",
"src/pages/AIProviders.jsx|type=text|placeholder=claude-sonnet-4-20250514|value=formData.defaultModel",
"src/pages/AIProviders.jsx|type=text|placeholder=haiku|value=formData.lightModel",
Expand All @@ -875,8 +922,6 @@ const PREEXISTING_INPUT_NAME_ALLOWLIST = new Set([
"src/pages/StackerNews.jsx|id=`${prefix}-api-key`|type=password|value=form.apiKey",
"src/pages/StackerNews.jsx|id=`${prefix}-slug`|value=form.slug",
"src/pages/VideoTimeline.jsx|type=text|placeholder=New project name…|value=name",
"src/pages/VideoTimelineEditor.jsx|type=text|value=nameDraft",
"src/pages/VideoTimelineEditor.jsx|type=range|value=Math.min(t, total)|min=0|max=Math.max(0.01, total)|step=0.01",
"src/pages/DataDog.jsx|name=site|type=text|placeholder=e.g., api.custom-datadog.com|value=formData.site",
]);

Expand Down
1 change: 1 addition & 0 deletions client/src/pages/Browser.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ export default function BrowserPage() {
>
<input
type="text"
aria-label="URL to open"
value={navUrl}
onChange={e => setNavUrl(e.target.value)}
placeholder="https://example.com"
Expand Down
8 changes: 8 additions & 0 deletions client/src/pages/CharacterSheet.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ export default function CharacterSheet() {
{editingClass ? (
<input
autoFocus
aria-label="Character title"
value={classVal}
onChange={e => setClassVal(e.target.value)}
onBlur={handleClassSave}
Expand Down Expand Up @@ -736,13 +737,15 @@ export default function CharacterSheet() {
<div className="flex items-center gap-2">
<Dices className="w-4 h-4 text-gray-400" />
<input
aria-label="Damage dice"
value={dmgDice}
onChange={e => setDmgDice(e.target.value)}
placeholder="1d8"
className="bg-port-card border border-port-border rounded px-2 py-1.5 text-sm text-white w-24"
/>
</div>
<input
aria-label="Damage description"
value={dmgDesc}
onChange={e => setDmgDesc(e.target.value)}
placeholder="Description (optional)"
Expand All @@ -769,12 +772,14 @@ export default function CharacterSheet() {
<div className="flex flex-col sm:flex-row gap-2">
<input
type="number"
aria-label="XP amount"
value={xpAmount}
onChange={e => setXpAmount(e.target.value)}
placeholder="XP amount"
className="bg-port-card border border-port-border rounded px-2 py-1.5 text-sm text-white w-28"
/>
<input
aria-label="XP description"
value={xpDesc}
onChange={e => setXpDesc(e.target.value)}
placeholder="Description (optional)"
Expand All @@ -800,6 +805,7 @@ export default function CharacterSheet() {
</div>
<div className="flex flex-col gap-2">
<input
aria-label="Event description"
value={evtDesc}
onChange={e => setEvtDesc(e.target.value)}
placeholder="What happened?"
Expand All @@ -808,6 +814,7 @@ export default function CharacterSheet() {
<div className="flex flex-col sm:flex-row gap-2">
<input
type="number"
aria-label="Event XP"
value={evtXp}
onChange={e => setEvtXp(e.target.value)}
placeholder="XP (optional)"
Expand All @@ -816,6 +823,7 @@ export default function CharacterSheet() {
<div className="flex items-center gap-2">
<Dices className="w-4 h-4 text-gray-400" />
<input
aria-label="Event dice"
value={evtDice}
onChange={e => setEvtDice(e.target.value)}
placeholder="Dice (e.g. 2d6)"
Expand Down
34 changes: 20 additions & 14 deletions client/src/pages/GitHub.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { timeAgo } from '../utils/formatters';
import PageSkeleton from '../components/ui/PageSkeleton';
import Modal from '../components/ui/Modal';
import { FormField } from '../components/ui/FormField';

const FILTERS = ['all', 'npm', 'secrets', 'archived'];

Expand Down Expand Up @@ -228,6 +229,7 @@ export default function GitHub() {
<div className="flex flex-col sm:flex-row gap-2 mb-4">
<input
type="text"
aria-label="Search repositories"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search repos..."
Expand Down Expand Up @@ -385,20 +387,24 @@ export default function GitHub() {
)}

<div className="flex flex-col gap-2">
<input
type="text"
value={newSecretName}
onChange={(e) => setNewSecretName(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
placeholder="SECRET_NAME"
className="px-3 py-2 bg-port-bg border border-port-border rounded text-white font-mono text-sm"
/>
<input
type="password"
value={newSecretValue}
onChange={(e) => setNewSecretValue(e.target.value)}
placeholder="Secret value"
className="px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm"
/>
<FormField label="Secret name">
<input
type="text"
value={newSecretName}
onChange={(e) => setNewSecretName(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
placeholder="SECRET_NAME"
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white font-mono text-sm"
/>
</FormField>
<FormField label="Secret value">
<input
type="password"
value={newSecretValue}
onChange={(e) => setNewSecretValue(e.target.value)}
placeholder="Secret value"
className="w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm"
/>
</FormField>
<button
onClick={handleSaveSecret}
disabled={savingSecret || !newSecretName.trim() || !newSecretValue}
Expand Down
1 change: 1 addition & 0 deletions client/src/pages/MoodBoardDetail.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,7 @@ export default function MoodBoardDetail() {
<div className="p-2 flex flex-col gap-1">
<input
type="text"
aria-label="Item caption"
defaultValue={item.caption || ''}
placeholder="Add a caption…"
maxLength={2000}
Expand Down
2 changes: 2 additions & 0 deletions client/src/pages/Sharing.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ function SharingBuckets({ selectedId }) {
<div className="grid grid-cols-1 md:grid-cols-[1fr_2fr_auto] gap-3 items-start">
<input
type="text"
aria-label="Your display name"
value={sharingDisplayName}
onChange={(e) => setSharingDisplayName(e.target.value)}
placeholder="Display name (e.g. atomantic)"
Expand All @@ -324,6 +325,7 @@ function SharingBuckets({ selectedId }) {
/>
<input
type="text"
aria-label="Your bio / contact note"
value={sharingBio}
onChange={(e) => setSharingBio(e.target.value)}
placeholder="Optional bio / contact note (visible to recipients)"
Expand Down
2 changes: 2 additions & 0 deletions client/src/pages/VideoTimelineEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ export default function VideoTimelineEditor() {
</button>
<input
type="text"
aria-label="Project name"
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onBlur={async (e) => {
Expand Down Expand Up @@ -626,6 +627,7 @@ export default function VideoTimelineEditor() {
</button>
<input
type="range"
aria-label="Playhead position"
min={0}
max={Math.max(0.01, total)}
step={0.01}
Expand Down