integration: disable the contact mailto until required fields are complete

Keep the submit control focusable with a disabled semantic while the briefing is incomplete, mark invalid fields only after interaction, and leave the encoded mailto path unchanged once the form is filled.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 19:32:22 +02:00
parent 1ff26be61b
commit 575ba06bb9
5 changed files with 115 additions and 6 deletions

View File

@@ -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');

View File

@@ -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)"
></textarea>
}
@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)"
>
<option value=""></option>
@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)"
/>
}
}
</div>
}
<p class="incomplete" aria-live="polite">{{ incompleteMessage() }}</p>
<p class="incomplete" [id]="incompleteId" aria-live="polite">{{ incompleteMessage() }}</p>
<p class="note">{{ copy().noBackendNote }}</p>
<p class="cluster actions">
<a class="submit" [href]="mailtoHref()">{{ copy().submitLabel }}</a>
@if (isComplete()) {
<a class="submit" [href]="mailtoHref()">{{ copy().submitLabel }}</a>
} @else {
<button
type="button"
class="submit"
aria-disabled="true"
[attr.aria-describedby]="incompleteId"
(click)="onDisabledSubmit($event)"
>
{{ copy().submitLabel }}
</button>
}
<a [href]="'mailto:' + contactEmail">{{ copy().directEmailLabel }}</a>
@if (showCalendar()) {
<a [href]="calendarUrl">{{ copy().calendarLabel }}</a>

View File

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

View File

@@ -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<HTMLButtonElement>('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<HTMLInputElement>('#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<HTMLButtonElement>('button.submit');
expect(submit?.getAttribute('aria-disabled')).toBe('true');
submit?.click();
fixture.detectChanges();
expect(root.querySelector('a.submit')).toBeNull();
});
});

View File

@@ -25,8 +25,10 @@ export class ContactBriefing {
readonly copy = input.required<ContactPageCopy>();
protected readonly values = signal<Record<ContactFieldId, string>>({ ...EMPTY_VALUES });
protected readonly touched = signal<Partial<Record<ContactFieldId, boolean>>>({});
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<ContactFieldId, string>): string {
const lines = copy.fields.flatMap((field) => {
const raw = values[field.id];