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];