diff --git a/e2e/contact.e2e.ts b/e2e/contact.e2e.ts index 01757f7..7be2e8d 100644 --- a/e2e/contact.e2e.ts +++ b/e2e/contact.e2e.ts @@ -18,6 +18,10 @@ test.describe('contact briefing', () => { await page.goto(pagePath('contact', 'de'), { waitUntil: 'networkidle' }); extras.length = 0; + const emptySubmit = page.locator('.submit'); + await expect(emptySubmit).toHaveAttribute('aria-disabled', 'true'); + expect(await emptySubmit.getAttribute('href')).toBeNull(); + await page.locator('#contact-name').fill('Ada'); await page.locator('#contact-email').fill('ada@example.com'); await page.locator('#contact-projectType').selectOption('software'); diff --git a/src/app/shared/contact-briefing/contact-briefing.html b/src/app/shared/contact-briefing/contact-briefing.html index a3b962b..6668621 100644 --- a/src/app/shared/contact-briefing/contact-briefing.html +++ b/src/app/shared/contact-briefing/contact-briefing.html @@ -18,9 +18,11 @@ [value]="values()[field.id]" [required]="field.required" [attr.aria-required]="field.required ? 'true' : null" - [attr.aria-describedby]="field.hint ? controlId(field) + '-hint' : null" + [attr.aria-invalid]="showInvalid(field) ? 'true' : null" + [attr.aria-describedby]="describedBy(field)" rows="6" (input)="onInput(field.id, $event)" + (blur)="onBlur(field.id)" > } @case ('select') { @@ -29,8 +31,10 @@ [value]="values()[field.id]" [required]="field.required" [attr.aria-required]="field.required ? 'true' : null" - [attr.aria-describedby]="field.hint ? controlId(field) + '-hint' : null" + [attr.aria-invalid]="showInvalid(field) ? 'true' : null" + [attr.aria-describedby]="describedBy(field)" (input)="onInput(field.id, $event)" + (blur)="onBlur(field.id)" > @for (option of field.options ?? []; track option.value) { @@ -45,20 +49,34 @@ [value]="values()[field.id]" [required]="field.required" [attr.aria-required]="field.required ? 'true' : null" - [attr.aria-describedby]="field.hint ? controlId(field) + '-hint' : null" + [attr.aria-invalid]="showInvalid(field) ? 'true' : null" + [attr.aria-describedby]="describedBy(field)" (input)="onInput(field.id, $event)" + (blur)="onBlur(field.id)" /> } } } -

{{ incompleteMessage() }}

+

{{ incompleteMessage() }}

{{ copy().noBackendNote }}

- {{ copy().submitLabel }} + @if (isComplete()) { + {{ copy().submitLabel }} + } @else { + + } {{ copy().directEmailLabel }} @if (showCalendar()) { {{ copy().calendarLabel }} diff --git a/src/app/shared/contact-briefing/contact-briefing.scss b/src/app/shared/contact-briefing/contact-briefing.scss index 67790f2..c6a75a7 100644 --- a/src/app/shared/contact-briefing/contact-briefing.scss +++ b/src/app/shared/contact-briefing/contact-briefing.scss @@ -31,7 +31,8 @@ textarea { font: inherit; } -.actions a { +.actions a, +button.submit { color: var(--color-accent-cool); text-decoration: none; } @@ -40,6 +41,21 @@ textarea { font-weight: 600; } +button.submit { + appearance: none; + border: 0; + padding: 0; + background: transparent; + font: inherit; + font-weight: 600; + min-height: 2.75rem; +} + +button.submit[aria-disabled='true'] { + color: var(--color-text-muted); + cursor: not-allowed; +} + @media (hover: hover) and (pointer: fine) { .actions a:hover { text-decoration: underline; diff --git a/src/app/shared/contact-briefing/contact-briefing.spec.ts b/src/app/shared/contact-briefing/contact-briefing.spec.ts index a7f1075..3f14fa4 100644 --- a/src/app/shared/contact-briefing/contact-briefing.spec.ts +++ b/src/app/shared/contact-briefing/contact-briefing.spec.ts @@ -39,6 +39,12 @@ describe('ContactBriefing', () => { expect(root.querySelector('form[action]')).toBeNull(); expect(root.textContent).toContain(copy.incompleteHint); expect(root.querySelector('[aria-live="polite"]')).toBeTruthy(); + expect(root.querySelector('a.submit')).toBeNull(); + const disabledSubmit = root.querySelector('button.submit'); + expect(disabledSubmit?.getAttribute('aria-disabled')).toBe('true'); + expect(disabledSubmit?.getAttribute('aria-describedby')).toBe('contact-incomplete'); + expect(disabledSubmit?.textContent).toContain(copy.submitLabel); + expect(root.querySelector('[aria-invalid="true"]')).toBeNull(); expect(root.querySelector(`a[href="${SITE_CONFIG.calendarUrl}"]`)).toBeNull(); expect(root.textContent).not.toContain(copy.calendarLabel); @@ -85,4 +91,33 @@ describe('ContactBriefing', () => { expect(fetchSpy).not.toHaveBeenCalled(); expect(xhrSpy).not.toHaveBeenCalled(); }); + + it('marks required fields invalid only after interaction', async () => { + await TestBed.configureTestingModule({ + imports: [ContactBriefing], + }).compileComponents(); + + const fixture = TestBed.createComponent(ContactBriefing); + const copy = SITE_CONTENT_DATA.de.contact; + fixture.componentRef.setInput('copy', copy); + await fixture.whenStable(); + + const root = fixture.nativeElement as HTMLElement; + const name = root.querySelector('#contact-name'); + expect(name).toBeTruthy(); + expect(name?.getAttribute('aria-invalid')).toBeNull(); + + name!.dispatchEvent(new Event('blur', { bubbles: true })); + fixture.detectChanges(); + + expect(name?.getAttribute('aria-invalid')).toBe('true'); + expect(name?.getAttribute('aria-describedby')?.includes('contact-incomplete')).toBe(true); + expect(root.querySelector('#contact-email')?.getAttribute('aria-invalid')).toBeNull(); + + const submit = root.querySelector('button.submit'); + expect(submit?.getAttribute('aria-disabled')).toBe('true'); + submit?.click(); + fixture.detectChanges(); + expect(root.querySelector('a.submit')).toBeNull(); + }); }); diff --git a/src/app/shared/contact-briefing/contact-briefing.ts b/src/app/shared/contact-briefing/contact-briefing.ts index dac1b65..3c47087 100644 --- a/src/app/shared/contact-briefing/contact-briefing.ts +++ b/src/app/shared/contact-briefing/contact-briefing.ts @@ -25,8 +25,10 @@ export class ContactBriefing { readonly copy = input.required(); protected readonly values = signal>({ ...EMPTY_VALUES }); + protected readonly touched = signal>>({}); protected readonly contactEmail = SITE_CONFIG.contactEmail; protected readonly calendarUrl = SITE_CONFIG.calendarUrl; + protected readonly incompleteId = 'contact-incomplete'; protected readonly missingLabels = computed(() => { const copy = this.copy(); @@ -36,6 +38,8 @@ export class ContactBriefing { .map((field) => field.label); }); + protected readonly isComplete = computed(() => this.missingLabels().length === 0); + protected readonly incompleteMessage = computed(() => { const missing = this.missingLabels(); if (missing.length === 0) { @@ -60,12 +64,44 @@ export class ContactBriefing { protected onInput(fieldId: ContactFieldId, event: Event): void { const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; this.values.update((current) => ({ ...current, [fieldId]: target.value })); + this.markTouched(fieldId); + } + + protected onBlur(fieldId: ContactFieldId): void { + this.markTouched(fieldId); + } + + protected onDisabledSubmit(event: Event): void { + event.preventDefault(); } protected controlId(field: ContactFieldCopy): string { return `contact-${field.id}`; } + protected showInvalid(field: ContactFieldCopy): boolean { + return ( + field.required && + this.touched()[field.id] === true && + this.values()[field.id].trim().length === 0 + ); + } + + protected describedBy(field: ContactFieldCopy): string | null { + const ids: string[] = []; + if (field.hint) { + ids.push(`${this.controlId(field)}-hint`); + } + if (this.showInvalid(field)) { + ids.push(this.incompleteId); + } + return ids.length > 0 ? ids.join(' ') : null; + } + + private markTouched(fieldId: ContactFieldId): void { + this.touched.update((current) => ({ ...current, [fieldId]: true })); + } + private assembleBody(copy: ContactPageCopy, values: Record): string { const lines = copy.fields.flatMap((field) => { const raw = values[field.id];