Skip to content
Open
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
29 changes: 24 additions & 5 deletions apps/web/src/app/(app)/wasteland/new/NewWastelandWizardClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -661,14 +661,22 @@ function IntentStep({
onChange={e => setName(e.target.value)}
maxLength={NAME_MAX_LENGTH}
autoFocus
aria-invalid={Boolean(nameError)}
aria-describedby={nameError ? 'wasteland-name-error' : undefined}
/>
{nameError && <p className="text-xs text-destructive">{nameError}</p>}
{nameError && (
<p id="wasteland-name-error" role="alert" className="text-xs text-destructive">
{nameError}
</p>
)}
</div>

{/* Ownership */}
<div className="space-y-2">
<Label>Ownership</Label>
<Label id="wasteland-ownership-label">Ownership</Label>
<RadioGroup
role="radiogroup"
aria-labelledby="wasteland-ownership-label"
value={ownership}
onValueChange={v => {
if (lockedOrgId) return;
Expand Down Expand Up @@ -701,7 +709,11 @@ function IntentStep({
</p>
) : (
<Select value={selectedOrgId} onValueChange={setSelectedOrgId}>
<SelectTrigger className="w-full">
<SelectTrigger
className="w-full"
aria-invalid={Boolean(orgError)}
aria-describedby={orgError ? 'wasteland-org-error' : undefined}
>
<SelectValue placeholder="Select an organization" />
</SelectTrigger>
<SelectContent>
Expand All @@ -725,7 +737,11 @@ function IntentStep({
</SelectContent>
</Select>
)}
{orgError && <p className="mt-1 text-xs text-destructive">{orgError}</p>}
{orgError && (
<p id="wasteland-org-error" role="alert" className="mt-1 text-xs text-destructive">
{orgError}
</p>
)}
</div>
)}
</div>
Expand Down Expand Up @@ -1092,7 +1108,10 @@ function PreviewStep({
</div>

{error && (
<div className="space-y-2 rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3">
<div
role="alert"
className="space-y-2 rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3"
>
<p className="text-sm font-medium text-destructive">We couldn&apos;t finish the setup.</p>
<p className="text-xs text-destructive/90">{error}</p>
<p className="text-xs text-muted-foreground">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export function TtfbAlertingContent() {
<Search className="text-muted-foreground absolute top-2.5 left-2 h-4 w-4" />
<Input
placeholder="Search by name or OpenRouter ID..."
aria-label="Search models"
value={searchTerm}
onChange={e => handleSearchChange(e.target.value)}
className="pl-8"
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/admin/alerting/AddModelDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function AddModelDialog({
<div className="flex flex-col gap-3">
<Input
placeholder="Search models..."
aria-label="Search models"
value={searchTerm}
onChange={e => onSearchChange(e.target.value)}
/>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/app-builder/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ const BottomBar = memo(function BottomBar({
'h-9 cursor-help border border-amber-500/50 bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 hover:text-amber-400',
!isLanding && 'w-9'
)}
aria-label={isLanding ? undefined : 'Submit with warning'}
>
<AlertTriangle className="h-4 w-4" />
{isLanding && <span>Submit</span>}
Expand Down Expand Up @@ -162,6 +163,7 @@ const BottomBar = memo(function BottomBar({
onClick={onInterrupt}
disabled={isInterrupting}
className="h-9 w-9"
aria-label={isInterrupting ? 'Stopping' : 'Stop generating'}
>
<Square className="h-4 w-4" />
</Button>
Expand All @@ -173,6 +175,7 @@ const BottomBar = memo(function BottomBar({
onClick={onSubmit}
disabled={isSubmitDisabled}
className="h-9 w-9"
aria-label="Send message"
>
<Send className="h-4 w-4" />
</Button>
Expand Down Expand Up @@ -341,6 +344,7 @@ export function PromptInput({
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={effectivePlaceholder}
aria-label={isLanding ? 'App prompt' : 'Message'}
disabled={disabled || isSubmitting}
className={cn(
'resize-none border-none bg-transparent px-0 shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0',
Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/components/deployments/PasswordFormFields.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from '@jest/globals';
import { PasswordProtection } from './PasswordFormFields';

function render(enabled: boolean) {
return renderToStaticMarkup(
React.createElement(PasswordProtection, {
value: { password: '', confirmPassword: '', enabled },
onChange: () => undefined,
})
);
}

function visibilityToggles(html: string): string[] {
return html.match(/<button type="button" aria-label="Show password"[^>]*>/g) ?? [];
}

describe('PasswordProtection accessibility', () => {
it('keeps every password visibility toggle keyboard reachable and state-labeled', () => {
const toggles = visibilityToggles(render(true));

expect(toggles).toHaveLength(2);
for (const toggle of toggles) {
expect(toggle).not.toContain('tabindex="-1"');
expect(toggle).toContain('aria-pressed="false"');
expect(toggle).toContain('aria-controls="password confirm-password"');
}
});

it('associates the password requirements hint with the password input', () => {
const html = render(true);

expect(html).toContain('id="password-requirements"');
expect(html).toContain('aria-describedby="password-requirements"');
});

it('does not render password fields while protection is disabled', () => {
const html = render(false);

expect(html).not.toContain('id="password"');
});
});
16 changes: 12 additions & 4 deletions apps/web/src/components/deployments/PasswordFormFields.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { useState } from 'react';
// React must be in scope for the classic JSX runtime used by the jest transform.
import React, { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
Expand Down Expand Up @@ -102,17 +103,22 @@ export function PasswordProtection({
disabled={disabled || isSaving}
className="pr-10 pl-10"
autoComplete="new-password"
aria-describedby="password-requirements"
/>
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? 'Hide password' : 'Show password'}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Keep the toggle button's accessible name constant when using aria-pressed

aria-pressed already exposes the toggled state, and the WAI-ARIA APG recommends keeping a toggle button's accessible name constant. Changing the name to "Hide password" while aria-pressed="true" makes screen readers announce "Hide password, pressed", which is contradictory. Keep the name as "Show password" on both visibility toggles (this line and line 141) and let aria-pressed convey the state.

Suggested change
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-label="Show password"

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

aria-pressed={showPassword}
aria-controls="password confirm-password"
className="absolute top-1/2 right-3 -translate-y-1/2 text-gray-500 hover:text-gray-400"
>
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
<p className="text-xs text-gray-500">Minimum 8 characters</p>
<p id="password-requirements" className="text-xs text-gray-500">
Minimum 8 characters
</p>
</div>

<div className="space-y-2">
Expand All @@ -131,8 +137,10 @@ export function PasswordProtection({
/>
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? 'Hide password' : 'Show password'}
aria-pressed={showPassword}
aria-controls="password confirm-password"
className="absolute top-1/2 right-3 -translate-y-1/2 text-gray-500 hover:text-gray-400"
>
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/gastown/CreateTownDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,17 @@ export function CreateTownDialog({ isOpen, onClose }: CreateTownDialogProps) {
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="py-4">
<label className="mb-2 block text-sm font-medium text-white/70">Town Name</label>
<label htmlFor="town-name" className="mb-2 block text-sm font-medium text-white/70">
Town Name
</label>
<Input
id="town-name"
name="town-name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="My Town"
autoFocus
required
className="border-white/10 bg-black/25"
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ export function InviteMemberDialog({
handleInviteMember();
}
}}
aria-invalid={shouldShowEmailError || emailDomainMatchesDirectSSODomain}
aria-describedby={
shouldShowEmailError || emailDomainMatchesDirectSSODomain
? 'invite-email-error'
: undefined
}
className={
shouldShowEmailError || emailDomainMatchesDirectSSODomain
? 'border-red-500 focus:border-red-500'
Expand All @@ -267,10 +273,12 @@ export function InviteMemberDialog({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
id="role"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: id="role" overrides Radix's generated trigger id, breaking the menu's accessible name

Radix's DropdownMenuTrigger assigns the trigger a generated id, and DropdownMenuContent renders aria-labelledby={context.triggerId} pointing back at that trigger. Because asChild merges the child's props last, this explicit id="role" replaces the generated id, so the popup's aria-labelledby references an id that is no longer in the DOM, stripping the role="menu" popup of its accessible name. The trigger already has an aria-label, so this id is unnecessary; remove it (or point aria-labelledby at a dedicated label element) instead of overriding Radix's internal id.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

variant="outline"
size="sm"
className="flex h-10 items-center justify-between gap-2 px-3"
disabled={inviteMemberMutation.isPending}
aria-label={`Role: ${ROLE_LABELS[role]}`}
>
{ROLE_LABELS[role]}
<ChevronDown className="h-3 w-3" />
Expand Down Expand Up @@ -316,7 +324,7 @@ export function InviteMemberDialog({
clipRule="evenodd"
/>
</svg>
<p className="text-sm text-red-300" role="alert">
<p id="invite-email-error" className="text-sm text-red-300" role="alert">
{shouldShowEmailError && 'Please enter a valid email address'}
{emailDomainMatchesDirectSSODomain && ssoErrorText}
</p>
Expand Down
16 changes: 11 additions & 5 deletions apps/web/src/components/ui/radio-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,28 @@ import * as React from 'react';
import { Circle } from 'lucide-react';
import { cn } from '@/lib/utils';

type RadioGroupProps = {
type RadioGroupProps = React.ComponentProps<'div'> & {
value?: string;
onValueChange?: (value: string) => void;
className?: string;
children: React.ReactNode;
};

const RadioGroupContext = React.createContext<{
value?: string;
onValueChange?: (value: string) => void;
}>({});

export function RadioGroup({ value, onValueChange, className, children }: RadioGroupProps) {
export function RadioGroup({
value,
onValueChange,
className,
children,
...props
}: RadioGroupProps) {
return (
<RadioGroupContext.Provider value={{ value, onValueChange }}>
<div className={cn('grid gap-2', className)}>{children}</div>
<div className={cn('grid gap-2', className)} {...props}>
{children}
</div>
</RadioGroupContext.Provider>
);
}
Expand Down