Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(a11y): add required/optional indicators to all form fields (#169)

* feat(a11y): add required/optional indicators to all form fields

Create shared FormLabel component with aria-hidden asterisks for required
fields and "(optional)" text for optional fields. Replaces 13 raw <label>
elements across all forms. Adds HTML required attribute to required inputs.
Fixes accessibility bug in onboarding-field-input.tsx where asterisk
lacked aria-hidden. Updates test queries to use getByRole (which respects
aria-hidden) instead of getByLabelText.

* style: format with prettier

authored by

Guido X Jansen and committed by
GitHub
06bfdd55 a5a7bcc2

+227 -174
+23 -14
src/app/admin/onboarding/page.test.tsx
··· 100 100 const user = userEvent.setup() 101 101 render(<AdminOnboardingPage />) 102 102 await user.click(screen.getByRole('button', { name: /add field/i })) 103 - expect(screen.getByLabelText(/field type/i)).toBeInTheDocument() 104 - expect(screen.getByLabelText(/^label$/i)).toBeInTheDocument() 103 + expect(screen.getByRole('combobox', { name: /field type/i })).toBeInTheDocument() 104 + expect(screen.getByRole('textbox', { name: /^label$/i })).toBeInTheDocument() 105 105 }) 106 106 107 107 it('shows edit form when edit button is clicked', async () => { ··· 112 112 }) 113 113 const editButtons = screen.getAllByRole('button', { name: /edit/i }) 114 114 await user.click(editButtons[0]!) 115 - expect(screen.getByLabelText(/^label$/i)).toBeInTheDocument() 115 + expect(screen.getByRole('textbox', { name: /^label$/i })).toBeInTheDocument() 116 116 }) 117 117 118 118 it('hides field type selector when editing existing field', async () => { ··· 123 123 }) 124 124 const editButtons = screen.getAllByRole('button', { name: /edit/i }) 125 125 await user.click(editButtons[0]!) 126 - expect(screen.queryByLabelText(/field type/i)).not.toBeInTheDocument() 126 + expect(screen.queryByRole('combobox', { name: /field type/i })).not.toBeInTheDocument() 127 127 }) 128 128 129 129 it('shows validation error when saving with empty label', async () => { ··· 138 138 const user = userEvent.setup() 139 139 render(<AdminOnboardingPage />) 140 140 await user.click(screen.getByRole('button', { name: /add field/i })) 141 - expect(screen.getByLabelText(/^label$/i)).toBeInTheDocument() 141 + expect(screen.getByRole('textbox', { name: /^label$/i })).toBeInTheDocument() 142 142 await user.click(screen.getByRole('button', { name: /cancel/i })) 143 - expect(screen.queryByLabelText(/^label$/i)).not.toBeInTheDocument() 143 + expect(screen.queryByRole('textbox', { name: /^label$/i })).not.toBeInTheDocument() 144 144 }) 145 145 146 146 it('renders reorder buttons for each field', async () => { ··· 234 234 render(<AdminOnboardingPage />) 235 235 await user.click(screen.getByRole('button', { name: /add field/i })) 236 236 // Default type is custom_text -- no ToS URL input yet 237 - expect(screen.queryByLabelText(/terms of service url/i)).not.toBeInTheDocument() 237 + expect(screen.queryByRole('textbox', { name: /terms of service url/i })).not.toBeInTheDocument() 238 238 // Switch to tos_acceptance 239 - await user.selectOptions(screen.getByLabelText(/field type/i), 'tos_acceptance') 240 - expect(screen.getByLabelText(/terms of service url/i)).toBeInTheDocument() 239 + await user.selectOptions( 240 + screen.getByRole('combobox', { name: /field type/i }), 241 + 'tos_acceptance' 242 + ) 243 + expect(screen.getByRole('textbox', { name: /terms of service url/i })).toBeInTheDocument() 241 244 }) 242 245 243 246 it('hides ToS URL input for other field types', async () => { 244 247 const user = userEvent.setup() 245 248 render(<AdminOnboardingPage />) 246 249 await user.click(screen.getByRole('button', { name: /add field/i })) 247 - await user.selectOptions(screen.getByLabelText(/field type/i), 'custom_text') 248 - expect(screen.queryByLabelText(/terms of service url/i)).not.toBeInTheDocument() 250 + await user.selectOptions(screen.getByRole('combobox', { name: /field type/i }), 'custom_text') 251 + expect(screen.queryByRole('textbox', { name: /terms of service url/i })).not.toBeInTheDocument() 249 252 }) 250 253 251 254 it('saves tosUrl in config when provided', async () => { ··· 274 277 const user = userEvent.setup() 275 278 render(<AdminOnboardingPage />) 276 279 await user.click(screen.getByRole('button', { name: /add field/i })) 277 - await user.selectOptions(screen.getByLabelText(/field type/i), 'tos_acceptance') 278 - await user.type(screen.getByLabelText(/^label$/i), 'Accept ToS') 279 - await user.type(screen.getByLabelText(/terms of service url/i), 'https://example.com/tos') 280 + await user.selectOptions( 281 + screen.getByRole('combobox', { name: /field type/i }), 282 + 'tos_acceptance' 283 + ) 284 + await user.type(screen.getByRole('textbox', { name: /^label$/i }), 'Accept ToS') 285 + await user.type( 286 + screen.getByRole('textbox', { name: /terms of service url/i }), 287 + 'https://example.com/tos' 288 + ) 280 289 await user.click(screen.getByRole('button', { name: /^save$/i })) 281 290 await waitFor(() => { 282 291 expect(capturedBody).not.toBeNull()
+4 -2
src/app/login/page.tsx
··· 14 14 import { useAuth } from '@/hooks/use-auth' 15 15 import { ApiError } from '@/lib/api/client' 16 16 import { cn } from '@/lib/utils' 17 + import { FormLabel } from '@/components/ui/form-label' 17 18 18 19 function LoginContent() { 19 20 const { login, isAuthenticated, isLoading } = useAuth() ··· 110 111 )} 111 112 112 113 <div className="space-y-1"> 113 - <label htmlFor="handle" className="block text-sm font-medium text-foreground"> 114 + <FormLabel htmlFor="handle" required> 114 115 Handle 115 - </label> 116 + </FormLabel> 116 117 <input 117 118 id="handle" 118 119 name="handle" ··· 121 122 onChange={(e) => setHandle(e.target.value)} 122 123 placeholder="jay.bsky.team" 123 124 autoComplete="username" 125 + required 124 126 disabled={submitting} 125 127 className={cn( 126 128 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground',
+2 -2
src/app/new/page.test.tsx
··· 76 76 77 77 it('renders topic form', () => { 78 78 render(<NewTopicPage />) 79 - expect(screen.getByLabelText('Title')).toBeInTheDocument() 80 - expect(screen.getByLabelText('Content')).toBeInTheDocument() 79 + expect(screen.getByRole('textbox', { name: 'Title' })).toBeInTheDocument() 80 + expect(screen.getByRole('textbox', { name: 'Content' })).toBeInTheDocument() 81 81 expect(screen.getByRole('button', { name: 'Create Topic' })).toBeInTheDocument() 82 82 }) 83 83
+12 -8
src/components/admin/categories/category-form.tsx
··· 4 4 */ 5 5 6 6 import type { MaturityRating } from '@/lib/api/types' 7 + import { FormLabel } from '@/components/ui/form-label' 7 8 8 9 export interface EditingCategory { 9 10 id: string | null ··· 29 30 </h2> 30 31 <div className="space-y-4"> 31 32 <div> 32 - <label htmlFor="cat-name" className="block text-sm font-medium text-foreground"> 33 + <FormLabel htmlFor="cat-name" required> 33 34 Category Name 34 - </label> 35 + </FormLabel> 35 36 <input 36 37 id="cat-name" 37 38 type="text" 38 39 value={editing.name} 39 40 onChange={(e) => onChange({ ...editing, name: e.target.value })} 41 + required 40 42 className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 41 43 /> 42 44 </div> 43 45 <div> 44 - <label htmlFor="cat-slug" className="block text-sm font-medium text-foreground"> 46 + <FormLabel htmlFor="cat-slug" required> 45 47 Slug 46 - </label> 48 + </FormLabel> 47 49 <input 48 50 id="cat-slug" 49 51 type="text" 50 52 value={editing.slug} 51 53 onChange={(e) => onChange({ ...editing, slug: e.target.value })} 54 + required 52 55 className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 53 56 /> 54 57 </div> 55 58 <div> 56 - <label htmlFor="cat-desc" className="block text-sm font-medium text-foreground"> 59 + <FormLabel htmlFor="cat-desc" optional> 57 60 Description 58 - </label> 61 + </FormLabel> 59 62 <textarea 60 63 id="cat-desc" 61 64 value={editing.description} ··· 65 68 /> 66 69 </div> 67 70 <div> 68 - <label htmlFor="cat-maturity" className="block text-sm font-medium text-foreground"> 71 + <FormLabel htmlFor="cat-maturity" required> 69 72 Maturity Rating 70 - </label> 73 + </FormLabel> 71 74 <select 72 75 id="cat-maturity" 73 76 value={editing.maturityRating} 74 77 onChange={(e) => 75 78 onChange({ ...editing, maturityRating: e.target.value as MaturityRating }) 76 79 } 80 + required 77 81 className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 78 82 > 79 83 <option value="safe">Safe</option>
+13 -10
src/components/admin/onboarding/onboarding-field-form.tsx
··· 4 4 */ 5 5 6 6 import type { OnboardingFieldType } from '@/lib/api/types' 7 + import { FormLabel } from '@/components/ui/form-label' 7 8 8 9 const FIELD_TYPE_LABELS: Record<OnboardingFieldType, string> = { 9 10 age_confirmation: 'Age Confirmation', ··· 57 58 <div className="space-y-4"> 58 59 {!editing.id && ( 59 60 <div> 60 - <label htmlFor="field-type" className="block text-sm font-medium text-foreground"> 61 + <FormLabel htmlFor="field-type" required> 61 62 Field Type 62 - </label> 63 + </FormLabel> 63 64 <select 64 65 id="field-type" 65 66 value={editing.fieldType} 66 67 onChange={(e) => 67 68 onChange({ ...editing, fieldType: e.target.value as OnboardingFieldType }) 68 69 } 70 + required 69 71 className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 70 72 > 71 73 {Object.entries(FIELD_TYPE_LABELS).map(([value, label]) => ( ··· 77 79 </div> 78 80 )} 79 81 <div> 80 - <label htmlFor="field-label" className="block text-sm font-medium text-foreground"> 82 + <FormLabel htmlFor="field-label" required> 81 83 Label 82 - </label> 84 + </FormLabel> 83 85 <input 84 86 id="field-label" 85 87 type="text" 86 88 value={editing.label} 87 89 onChange={(e) => onChange({ ...editing, label: e.target.value })} 90 + required 88 91 className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 89 92 placeholder="e.g., Accept our community rules" 90 93 /> 91 94 </div> 92 95 <div> 93 - <label htmlFor="field-description" className="block text-sm font-medium text-foreground"> 94 - Description (optional) 95 - </label> 96 + <FormLabel htmlFor="field-description" optional> 97 + Description 98 + </FormLabel> 96 99 <textarea 97 100 id="field-description" 98 101 value={editing.description} ··· 104 107 </div> 105 108 {editing.fieldType === 'tos_acceptance' && ( 106 109 <div> 107 - <label htmlFor="field-tos-url" className="block text-sm font-medium text-foreground"> 108 - Terms of Service URL (optional) 109 - </label> 110 + <FormLabel htmlFor="field-tos-url" optional> 111 + Terms of Service URL 112 + </FormLabel> 110 113 <input 111 114 id="field-tos-url" 112 115 type="url"
+15 -14
src/components/admin/pages/page-form.tsx
··· 5 5 */ 6 6 7 7 import { TopicContentEditor } from '@/components/topic-content-editor' 8 + import { FormLabel } from '@/components/ui/form-label' 8 9 import type { PageStatus, PageTreeNode } from '@/lib/api/types' 9 10 10 11 export interface PageFormProps { ··· 52 53 <> 53 54 <div className="space-y-4"> 54 55 <div> 55 - <label htmlFor="page-title" className="block text-sm font-medium text-foreground"> 56 + <FormLabel htmlFor="page-title" required> 56 57 Title 57 - </label> 58 + </FormLabel> 58 59 <input 59 60 id="page-title" 60 61 type="text" 61 62 value={title} 62 63 onChange={(e) => onTitleChange(e.target.value)} 64 + required 63 65 className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 64 66 placeholder="Page title" 65 67 /> 66 68 </div> 67 69 68 70 <div> 69 - <label htmlFor="page-slug" className="block text-sm font-medium text-foreground"> 71 + <FormLabel htmlFor="page-slug" required> 70 72 Slug 71 - </label> 73 + </FormLabel> 72 74 <input 73 75 id="page-slug" 74 76 type="text" 75 77 value={slug} 76 78 onChange={(e) => onSlugChange(e.target.value)} 79 + required 77 80 className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 78 81 placeholder="url-slug" 79 82 /> 80 83 </div> 81 84 82 85 <div> 83 - <label htmlFor="page-status" className="block text-sm font-medium text-foreground"> 86 + <FormLabel htmlFor="page-status" required> 84 87 Status 85 - </label> 88 + </FormLabel> 86 89 <select 87 90 id="page-status" 88 91 value={status} 89 92 onChange={(e) => onStatusChange(e.target.value as PageStatus)} 93 + required 90 94 className="mt-1 block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" 91 95 > 92 96 <option value="draft">Draft</option> ··· 95 99 </div> 96 100 97 101 <div> 98 - <label htmlFor="page-parent" className="block text-sm font-medium text-foreground"> 102 + <FormLabel htmlFor="page-parent" optional> 99 103 Parent Page 100 - </label> 104 + </FormLabel> 101 105 <select 102 106 id="page-parent" 103 107 value={parentId ?? ''} ··· 114 118 </div> 115 119 116 120 <div> 117 - <label 118 - htmlFor="page-meta-description" 119 - className="block text-sm font-medium text-foreground" 120 - > 121 + <FormLabel htmlFor="page-meta-description" optional> 121 122 Meta Description 122 - </label> 123 + </FormLabel> 123 124 <textarea 124 125 id="page-meta-description" 125 126 value={metaDescription} ··· 132 133 <p className="mt-1 text-xs text-muted-foreground">{metaDescription.length}/320</p> 133 134 </div> 134 135 135 - <TopicContentEditor content={content} onChange={onContentChange} /> 136 + <TopicContentEditor content={content} onChange={onContentChange} required /> 136 137 </div> 137 138 138 139 <div className="flex items-center gap-3">
+13 -13
src/components/admin/settings/community-settings-form.tsx
··· 6 6 'use client' 7 7 8 8 import { ErrorAlert } from '@/components/error-alert' 9 + import { FormLabel } from '@/components/ui/form-label' 9 10 import type { CommunitySettings, MaturityRating } from '@/lib/api/types' 10 11 11 12 interface CommunitySettingsFormProps { ··· 28 29 return ( 29 30 <div className="max-w-lg space-y-6"> 30 31 <div> 31 - <label htmlFor="settings-name" className="block text-sm font-medium text-foreground"> 32 + <FormLabel htmlFor="settings-name" required> 32 33 Community Name 33 - </label> 34 + </FormLabel> 34 35 <input 35 36 id="settings-name" 36 37 type="text" 37 38 value={settings.communityName} 38 39 onChange={(e) => onChange({ ...settings, communityName: e.target.value })} 40 + required 39 41 className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 40 42 /> 41 43 </div> 42 44 43 45 <div> 44 - <label htmlFor="settings-desc" className="block text-sm font-medium text-foreground"> 46 + <FormLabel htmlFor="settings-desc" optional> 45 47 Description 46 - </label> 48 + </FormLabel> 47 49 <textarea 48 50 id="settings-desc" 49 51 value={settings.communityDescription ?? ''} ··· 54 56 </div> 55 57 56 58 <div> 57 - <label htmlFor="settings-maturity" className="block text-sm font-medium text-foreground"> 59 + <FormLabel htmlFor="settings-maturity" required> 58 60 Community Maturity Rating 59 - </label> 61 + </FormLabel> 60 62 <select 61 63 id="settings-maturity" 62 64 value={settings.maturityRating} 63 65 onChange={(e) => 64 66 onChange({ ...settings, maturityRating: e.target.value as MaturityRating }) 65 67 } 68 + required 66 69 className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground" 67 70 > 68 71 <option value="safe">Safe (default)</option> ··· 75 78 </div> 76 79 77 80 <div> 78 - <label htmlFor="settings-reactions" className="block text-sm font-medium text-foreground"> 81 + <FormLabel htmlFor="settings-reactions" optional> 79 82 Reaction Set 80 - </label> 83 + </FormLabel> 81 84 <input 82 85 id="settings-reactions" 83 86 type="text" ··· 99 102 </div> 100 103 101 104 <div> 102 - <label 103 - htmlFor="settings-max-reply-depth" 104 - className="block text-sm font-medium text-foreground" 105 - > 105 + <FormLabel htmlFor="settings-max-reply-depth" optional> 106 106 Max Reply Depth 107 - </label> 107 + </FormLabel> 108 108 <input 109 109 id="settings-max-reply-depth" 110 110 type="number"
+5 -4
src/components/admin/settings/pds-override-dialog.tsx
··· 7 7 8 8 import { useState, useEffect, useRef } from 'react' 9 9 import { cn } from '@/lib/utils' 10 + import { FormLabel } from '@/components/ui/form-label' 10 11 11 12 interface PdsOverrideDialogProps { 12 13 open: boolean ··· 65 66 </h3> 66 67 <form onSubmit={handleSubmit} className="mt-4 space-y-4"> 67 68 <div> 68 - <label htmlFor="pds-hostname" className="block text-sm font-medium text-foreground"> 69 + <FormLabel htmlFor="pds-hostname" required> 69 70 PDS Hostname 70 - </label> 71 + </FormLabel> 71 72 <input 72 73 ref={hostnameRef} 73 74 id="pds-hostname" ··· 84 85 /> 85 86 </div> 86 87 <div> 87 - <label htmlFor="pds-trust-factor" className="block text-sm font-medium text-foreground"> 88 + <FormLabel htmlFor="pds-trust-factor" required> 88 89 Trust Factor: {trustFactor.toFixed(1)} 89 - </label> 90 + </FormLabel> 90 91 <input 91 92 id="pds-trust-factor" 92 93 type="range"
+6 -5
src/components/admin/trust-seeds/add-seed-dialog.tsx
··· 6 6 'use client' 7 7 8 8 import { useState, useEffect, useRef } from 'react' 9 + import { FormLabel } from '@/components/ui/form-label' 9 10 10 11 interface AddSeedDialogProps { 11 12 open: boolean ··· 55 56 <h3 className="text-lg font-semibold text-foreground">Add trust seed</h3> 56 57 <form onSubmit={handleSubmit} className="mt-4 space-y-4"> 57 58 <div> 58 - <label htmlFor="seed-handle" className="block text-sm font-medium text-foreground"> 59 + <FormLabel htmlFor="seed-handle" required> 59 60 Handle 60 - </label> 61 + </FormLabel> 61 62 <input 62 63 ref={handleRef} 63 64 id="seed-handle" ··· 70 71 /> 71 72 </div> 72 73 <div> 73 - <label htmlFor="seed-reason" className="block text-sm font-medium text-foreground"> 74 + <FormLabel htmlFor="seed-reason" optional> 74 75 Reason 75 - </label> 76 + </FormLabel> 76 77 <input 77 78 id="seed-reason" 78 79 type="text" 79 80 value={reason} 80 81 onChange={(e) => setReason(e.target.value)} 81 - placeholder="Optional: why this account is trusted" 82 + placeholder="Why this account is trusted" 82 83 className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground" 83 84 /> 84 85 </div>
+5 -7
src/components/community-profile-form-fields.tsx
··· 4 4 */ 5 5 6 6 import { cn } from '@/lib/utils' 7 + import { FormLabel } from '@/components/ui/form-label' 7 8 8 9 const DISPLAY_NAME_MAX = 256 9 10 const BIO_MAX = 2048 ··· 28 29 return ( 29 30 <> 30 31 <div className="space-y-1"> 31 - <label 32 - htmlFor="community-display-name" 33 - className="block text-sm font-medium text-foreground" 34 - > 32 + <FormLabel htmlFor="community-display-name" optional> 35 33 Display name 36 - </label> 34 + </FormLabel> 37 35 <input 38 36 id="community-display-name" 39 37 type="text" ··· 53 51 </div> 54 52 55 53 <div className="space-y-1"> 56 - <label htmlFor="community-bio" className="block text-sm font-medium text-foreground"> 54 + <FormLabel htmlFor="community-bio" optional> 57 55 Bio 58 - </label> 56 + </FormLabel> 59 57 <textarea 60 58 id="community-bio" 61 59 value={bio}
+8 -2
src/components/markdown-editor.tsx
··· 9 9 10 10 import { useRef, useState, useCallback } from 'react' 11 11 import { cn } from '@/lib/utils' 12 + import { FormLabel } from '@/components/ui/form-label' 12 13 import { TOOLBAR_ACTIONS } from '@/components/markdown-toolbar-actions' 13 14 import type { ToolbarAction } from '@/components/markdown-toolbar-actions' 14 15 ··· 17 18 onChange: (value: string) => void 18 19 id: string 19 20 label: string 21 + required?: boolean 22 + optional?: boolean 20 23 error?: string 21 24 className?: string 22 25 placeholder?: string ··· 27 30 onChange, 28 31 id, 29 32 label, 33 + required, 34 + optional, 30 35 error, 31 36 className, 32 37 placeholder, ··· 87 92 88 93 return ( 89 94 <div className={cn('space-y-1', className)}> 90 - <label htmlFor={id} className="block text-sm font-medium text-foreground"> 95 + <FormLabel htmlFor={id} required={required} optional={optional}> 91 96 {label} 92 - </label> 97 + </FormLabel> 93 98 94 99 <div 95 100 ref={toolbarRef} ··· 123 128 value={value} 124 129 onChange={(e) => onChange(e.target.value)} 125 130 placeholder={placeholder ?? 'Write your content using Markdown...'} 131 + required={required} 126 132 aria-invalid={error ? 'true' : undefined} 127 133 aria-describedby={errorId} 128 134 className={cn(
+43 -65
src/components/onboarding-field-input.tsx
··· 5 5 6 6 import type { OnboardingField } from '@/lib/api/types' 7 7 import { AGE_OPTIONS } from '@/lib/constants' 8 + import { FormLabel } from '@/components/ui/form-label' 8 9 9 10 const INPUT_CLASS = 10 11 'mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground' 11 12 12 - function FieldLabel({ 13 - htmlFor, 14 - label, 15 - required, 16 - description, 17 - block = true, 18 - }: { 19 - htmlFor: string 20 - label: string 21 - required: boolean 22 - description?: string | null 23 - block?: boolean 24 - }) { 25 - return ( 26 - <> 27 - <label 28 - htmlFor={htmlFor} 29 - className={`${block ? 'block ' : ''}text-sm font-medium text-foreground`} 30 - > 31 - {label} 32 - {required && <span className="ml-1 text-destructive">*</span>} 33 - </label> 34 - {description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>} 35 - </> 36 - ) 37 - } 38 - 39 13 interface OnboardingFieldInputProps { 40 14 field: OnboardingField 41 15 value: unknown ··· 50 24 case 'age_confirmation': 51 25 return ( 52 26 <div> 53 - <FieldLabel 54 - htmlFor={labelId} 55 - label={field.label} 56 - required={required} 57 - description={field.description} 58 - /> 27 + <FormLabel htmlFor={labelId} required={required} optional={!required}> 28 + {field.label} 29 + </FormLabel> 30 + {field.description && ( 31 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 32 + )} 59 33 <select 60 34 id={labelId} 61 35 value={value !== undefined ? String(value) : ''} 62 36 onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))} 37 + required={required} 63 38 className={INPUT_CLASS} 64 39 > 65 40 <option value="">Select age bracket...</option> ··· 81 56 type="checkbox" 82 57 checked={value === true} 83 58 onChange={(e) => onChange(e.target.checked)} 59 + required={required} 84 60 className="mt-1 h-4 w-4 rounded border-border" 85 61 /> 86 62 <div> 87 - <FieldLabel 88 - htmlFor={labelId} 89 - label={field.label} 90 - required={required} 91 - description={field.description} 92 - block={false} 93 - /> 63 + <FormLabel htmlFor={labelId} required={required} optional={!required} block={false}> 64 + {field.label} 65 + </FormLabel> 66 + {field.description && ( 67 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 68 + )} 94 69 {tosUrl && ( 95 70 <a 96 71 href={tosUrl} ··· 114 89 type="checkbox" 115 90 checked={value === true} 116 91 onChange={(e) => onChange(e.target.checked)} 92 + required={required} 117 93 className="mt-1 h-4 w-4 rounded border-border" 118 94 /> 119 95 <div> 120 - <FieldLabel 121 - htmlFor={labelId} 122 - label={field.label} 123 - required={required} 124 - description={field.description} 125 - block={false} 126 - /> 96 + <FormLabel htmlFor={labelId} required={required} optional={!required} block={false}> 97 + {field.label} 98 + </FormLabel> 99 + {field.description && ( 100 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 101 + )} 127 102 </div> 128 103 </div> 129 104 ) ··· 131 106 case 'newsletter_email': 132 107 return ( 133 108 <div> 134 - <FieldLabel 135 - htmlFor={labelId} 136 - label={field.label} 137 - required={required} 138 - description={field.description} 139 - /> 109 + <FormLabel htmlFor={labelId} required={required} optional={!required}> 110 + {field.label} 111 + </FormLabel> 112 + {field.description && ( 113 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 114 + )} 140 115 <input 141 116 id={labelId} 142 117 type="email" 143 118 value={typeof value === 'string' ? value : ''} 144 119 onChange={(e) => onChange(e.target.value || undefined)} 145 120 placeholder="your@email.com" 121 + required={required} 146 122 className={INPUT_CLASS} 147 123 /> 148 124 </div> ··· 151 127 case 'custom_text': 152 128 return ( 153 129 <div> 154 - <FieldLabel 155 - htmlFor={labelId} 156 - label={field.label} 157 - required={required} 158 - description={field.description} 159 - /> 130 + <FormLabel htmlFor={labelId} required={required} optional={!required}> 131 + {field.label} 132 + </FormLabel> 133 + {field.description && ( 134 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 135 + )} 160 136 <textarea 161 137 id={labelId} 162 138 value={typeof value === 'string' ? value : ''} 163 139 onChange={(e) => onChange(e.target.value || undefined)} 164 140 rows={3} 141 + required={required} 165 142 className={INPUT_CLASS} 166 143 /> 167 144 </div> ··· 171 148 const options = (field.config?.options ?? []) as string[] 172 149 return ( 173 150 <div> 174 - <FieldLabel 175 - htmlFor={labelId} 176 - label={field.label} 177 - required={required} 178 - description={field.description} 179 - /> 151 + <FormLabel htmlFor={labelId} required={required} optional={!required}> 152 + {field.label} 153 + </FormLabel> 154 + {field.description && ( 155 + <p className="mt-0.5 text-xs text-muted-foreground">{field.description}</p> 156 + )} 180 157 <select 181 158 id={labelId} 182 159 value={typeof value === 'string' ? value : ''} 183 160 onChange={(e) => onChange(e.target.value || undefined)} 161 + required={required} 184 162 className={INPUT_CLASS} 185 163 > 186 164 <option value="">Select...</option>
+10 -4
src/components/report-form-content.tsx
··· 4 4 */ 5 5 6 6 import { cn } from '@/lib/utils' 7 + import { FormLabel } from '@/components/ui/form-label' 7 8 8 9 const REPORT_REASONS = [ 9 10 { value: 'spam', label: 'Spam' }, ··· 45 46 46 47 <form onSubmit={onSubmit} className="mt-4 space-y-4" noValidate> 47 48 <fieldset disabled={submitting}> 48 - <legend className="text-sm font-medium text-foreground">Reason</legend> 49 + <legend className="text-sm font-medium text-foreground"> 50 + Reason 51 + <span aria-hidden="true" className="ml-1 text-destructive"> 52 + * 53 + </span> 54 + </legend> 49 55 <div className="mt-2 space-y-2"> 50 56 {REPORT_REASONS.map((r) => ( 51 57 <label key={r.value} className="flex items-center gap-2"> ··· 72 78 </fieldset> 73 79 74 80 <div className="space-y-1"> 75 - <label htmlFor="report-details" className="block text-sm font-medium text-foreground"> 81 + <FormLabel htmlFor="report-details" optional> 76 82 Additional details 77 - </label> 83 + </FormLabel> 78 84 <textarea 79 85 id="report-details" 80 86 value={details} 81 87 onChange={(e) => onDetailsChange(e.target.value)} 82 - placeholder="Optional: provide more context" 88 + placeholder="Provide more context" 83 89 rows={3} 84 90 disabled={submitting} 85 91 className={cn(
+7 -6
src/components/settings/report-appeal-form.tsx
··· 4 4 */ 5 5 6 6 import { cn } from '@/lib/utils' 7 + import { FormLabel } from '@/components/ui/form-label' 7 8 8 9 interface ReportAppealFormProps { 9 10 reportId: number ··· 27 28 return ( 28 29 <form onSubmit={onAppealSubmit} className="space-y-3" noValidate> 29 30 <div className="space-y-1"> 30 - <label 31 - htmlFor={`appeal-reason-${reportId}`} 32 - className="block text-sm font-medium text-foreground" 33 - > 31 + <FormLabel htmlFor={`appeal-reason-${reportId}`} required> 34 32 Reason for appeal 35 - </label> 33 + </FormLabel> 36 34 <textarea 37 35 id={`appeal-reason-${reportId}`} 38 36 value={appealReason} 39 37 onChange={(e) => onAppealReasonChange(e.target.value)} 40 38 placeholder="Explain why you believe this report should be reconsidered" 41 39 rows={3} 40 + required 42 41 disabled={appealSubmitting} 42 + aria-invalid={appealError ? 'true' : undefined} 43 + aria-describedby={appealError ? `appeal-error-${reportId}` : undefined} 43 44 className={cn( 44 45 'block w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground', 45 46 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', ··· 47 48 )} 48 49 /> 49 50 {appealError && ( 50 - <p className="text-sm text-destructive" role="alert"> 51 + <p id={`appeal-error-${reportId}`} className="text-sm text-destructive" role="alert"> 51 52 {appealError} 52 53 </p> 53 54 )}
+8 -1
src/components/topic-content-editor.tsx
··· 14 14 content: string 15 15 onChange: (content: string) => void 16 16 error?: string 17 + required?: boolean 17 18 } 18 19 19 - export function TopicContentEditor({ content, onChange, error }: TopicContentEditorProps) { 20 + export function TopicContentEditor({ 21 + content, 22 + onChange, 23 + error, 24 + required, 25 + }: TopicContentEditorProps) { 20 26 const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write') 21 27 22 28 return ( ··· 67 73 onChange={onChange} 68 74 id="topic-content" 69 75 label="Content" 76 + required={required} 70 77 error={error} 71 78 /> 72 79 </div>
+10 -10
src/components/topic-form.test.tsx
··· 26 26 describe('TopicForm', () => { 27 27 it('renders title input', () => { 28 28 render(<TopicForm onSubmit={vi.fn()} />) 29 - expect(screen.getByLabelText('Title')).toBeInTheDocument() 29 + expect(screen.getByRole('textbox', { name: 'Title' })).toBeInTheDocument() 30 30 }) 31 31 32 32 it('renders category select', () => { 33 33 render(<TopicForm onSubmit={vi.fn()} />) 34 - expect(screen.getByLabelText('Category')).toBeInTheDocument() 34 + expect(screen.getByRole('combobox', { name: 'Category' })).toBeInTheDocument() 35 35 }) 36 36 37 37 it('renders tag input', () => { 38 38 render(<TopicForm onSubmit={vi.fn()} />) 39 - expect(screen.getByLabelText('Tags')).toBeInTheDocument() 39 + expect(screen.getByRole('textbox', { name: /Tags/ })).toBeInTheDocument() 40 40 }) 41 41 42 42 it('renders content editor', () => { 43 43 render(<TopicForm onSubmit={vi.fn()} />) 44 - expect(screen.getByLabelText('Content')).toBeInTheDocument() 44 + expect(screen.getByRole('textbox', { name: 'Content' })).toBeInTheDocument() 45 45 }) 46 46 47 47 it('renders cross-post checkboxes', () => { ··· 95 95 const onSubmit = vi.fn() 96 96 render(<TopicForm onSubmit={onSubmit} />) 97 97 98 - await user.type(screen.getByLabelText('Title'), 'Test Title') 98 + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Test Title') 99 99 await user.click(screen.getByRole('button', { name: 'Create Topic' })) 100 100 expect(screen.getByText('Content is required')).toBeInTheDocument() 101 101 expect(onSubmit).not.toHaveBeenCalled() ··· 106 106 const onSubmit = vi.fn() 107 107 render(<TopicForm onSubmit={onSubmit} />) 108 108 109 - await user.type(screen.getByLabelText('Title'), 'Test Title') 110 - await user.type(screen.getByLabelText('Content'), 'Test content') 109 + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'Test Title') 110 + await user.type(screen.getByRole('textbox', { name: 'Content' }), 'Test content') 111 111 await user.click(screen.getByRole('button', { name: 'Create Topic' })) 112 112 expect(screen.getByText('Category is required')).toBeInTheDocument() 113 113 expect(onSubmit).not.toHaveBeenCalled() ··· 118 118 const onSubmit = vi.fn() 119 119 render(<TopicForm onSubmit={onSubmit} />) 120 120 121 - await user.type(screen.getByLabelText('Title'), 'AB') 121 + await user.type(screen.getByRole('textbox', { name: 'Title' }), 'AB') 122 122 await user.click(screen.getByRole('button', { name: 'Create Topic' })) 123 123 expect(screen.getByText('Title must be at least 3 characters')).toBeInTheDocument() 124 124 expect(onSubmit).not.toHaveBeenCalled() ··· 148 148 mode="edit" 149 149 /> 150 150 ) 151 - expect(screen.getByLabelText('Title')).toHaveValue('Existing Topic') 152 - expect(screen.getByLabelText('Content')).toHaveValue('Existing content') 151 + expect(screen.getByRole('textbox', { name: 'Title' })).toHaveValue('Existing Topic') 152 + expect(screen.getByRole('textbox', { name: 'Content' })).toHaveValue('Existing content') 153 153 }) 154 154 155 155 it('passes axe accessibility check', async () => {
+1 -1
src/components/topic-form.tsx
··· 103 103 onTagInputChange={setTagInput} 104 104 /> 105 105 106 - <TopicContentEditor content={content} onChange={setContent} error={errors.content} /> 106 + <TopicContentEditor content={content} onChange={setContent} error={errors.content} required /> 107 107 108 108 {mode === 'create' && ( 109 109 <TopicCrossPostSection
+9 -6
src/components/topic-meta-fields.tsx
··· 4 4 */ 5 5 6 6 import { cn } from '@/lib/utils' 7 + import { FormLabel } from '@/components/ui/form-label' 7 8 8 9 interface TopicMetaFieldsProps { 9 10 title: string ··· 29 30 return ( 30 31 <> 31 32 <div className="space-y-1"> 32 - <label htmlFor="topic-title" className="block text-sm font-medium text-foreground"> 33 + <FormLabel htmlFor="topic-title" required> 33 34 Title 34 - </label> 35 + </FormLabel> 35 36 <input 36 37 id="topic-title" 37 38 type="text" 38 39 value={title} 39 40 onChange={(e) => onTitleChange(e.target.value)} 40 41 placeholder="Enter a descriptive title" 42 + required 41 43 aria-invalid={errors.title ? 'true' : undefined} 42 44 aria-describedby={errors.title ? 'topic-title-error' : undefined} 43 45 className={cn( ··· 54 56 </div> 55 57 56 58 <div className="space-y-1"> 57 - <label htmlFor="topic-category" className="block text-sm font-medium text-foreground"> 59 + <FormLabel htmlFor="topic-category" required> 58 60 Category 59 - </label> 61 + </FormLabel> 60 62 <select 61 63 id="topic-category" 62 64 value={category} 63 65 onChange={(e) => onCategoryChange(e.target.value)} 66 + required 64 67 aria-invalid={errors.category ? 'true' : undefined} 65 68 aria-describedby={errors.category ? 'topic-category-error' : undefined} 66 69 className={cn( ··· 84 87 </div> 85 88 86 89 <div className="space-y-1"> 87 - <label htmlFor="topic-tags" className="block text-sm font-medium text-foreground"> 90 + <FormLabel htmlFor="topic-tags" optional> 88 91 Tags 89 - </label> 92 + </FormLabel> 90 93 <input 91 94 id="topic-tags" 92 95 type="text"
+33
src/components/ui/form-label.tsx
··· 1 + import { cn } from '@/lib/utils' 2 + 3 + interface FormLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> { 4 + required?: boolean 5 + optional?: boolean 6 + block?: boolean 7 + } 8 + 9 + export function FormLabel({ 10 + required, 11 + optional, 12 + block = true, 13 + children, 14 + className, 15 + ...props 16 + }: FormLabelProps) { 17 + return ( 18 + <label 19 + className={cn(block && 'block', 'text-sm font-medium text-foreground', className)} 20 + {...props} 21 + > 22 + {children} 23 + {required && ( 24 + <span aria-hidden="true" className="ml-1 text-destructive"> 25 + * 26 + </span> 27 + )} 28 + {optional && ( 29 + <span className="ml-1.5 text-xs font-normal text-muted-foreground">(optional)</span> 30 + )} 31 + </label> 32 + ) 33 + }