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:
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
@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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user