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' });
|
await page.goto(pagePath('contact', 'de'), { waitUntil: 'networkidle' });
|
||||||
extras.length = 0;
|
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-name').fill('Ada');
|
||||||
await page.locator('#contact-email').fill('ada@example.com');
|
await page.locator('#contact-email').fill('ada@example.com');
|
||||||
await page.locator('#contact-projectType').selectOption('software');
|
await page.locator('#contact-projectType').selectOption('software');
|
||||||
|
|||||||
@@ -18,9 +18,11 @@
|
|||||||
[value]="values()[field.id]"
|
[value]="values()[field.id]"
|
||||||
[required]="field.required"
|
[required]="field.required"
|
||||||
[attr.aria-required]="field.required ? 'true' : null"
|
[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"
|
rows="6"
|
||||||
(input)="onInput(field.id, $event)"
|
(input)="onInput(field.id, $event)"
|
||||||
|
(blur)="onBlur(field.id)"
|
||||||
></textarea>
|
></textarea>
|
||||||
}
|
}
|
||||||
@case ('select') {
|
@case ('select') {
|
||||||
@@ -29,8 +31,10 @@
|
|||||||
[value]="values()[field.id]"
|
[value]="values()[field.id]"
|
||||||
[required]="field.required"
|
[required]="field.required"
|
||||||
[attr.aria-required]="field.required ? 'true' : null"
|
[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)"
|
(input)="onInput(field.id, $event)"
|
||||||
|
(blur)="onBlur(field.id)"
|
||||||
>
|
>
|
||||||
<option value=""></option>
|
<option value=""></option>
|
||||||
@for (option of field.options ?? []; track option.value) {
|
@for (option of field.options ?? []; track option.value) {
|
||||||
@@ -45,20 +49,34 @@
|
|||||||
[value]="values()[field.id]"
|
[value]="values()[field.id]"
|
||||||
[required]="field.required"
|
[required]="field.required"
|
||||||
[attr.aria-required]="field.required ? 'true' : null"
|
[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)"
|
(input)="onInput(field.id, $event)"
|
||||||
|
(blur)="onBlur(field.id)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</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="note">{{ copy().noBackendNote }}</p>
|
||||||
|
|
||||||
<p class="cluster actions">
|
<p class="cluster actions">
|
||||||
|
@if (isComplete()) {
|
||||||
<a class="submit" [href]="mailtoHref()">{{ copy().submitLabel }}</a>
|
<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>
|
<a [href]="'mailto:' + contactEmail">{{ copy().directEmailLabel }}</a>
|
||||||
@if (showCalendar()) {
|
@if (showCalendar()) {
|
||||||
<a [href]="calendarUrl">{{ copy().calendarLabel }}</a>
|
<a [href]="calendarUrl">{{ copy().calendarLabel }}</a>
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ textarea {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions a {
|
.actions a,
|
||||||
|
button.submit {
|
||||||
color: var(--color-accent-cool);
|
color: var(--color-accent-cool);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,21 @@ textarea {
|
|||||||
font-weight: 600;
|
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) {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
.actions a:hover {
|
.actions a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ describe('ContactBriefing', () => {
|
|||||||
expect(root.querySelector('form[action]')).toBeNull();
|
expect(root.querySelector('form[action]')).toBeNull();
|
||||||
expect(root.textContent).toContain(copy.incompleteHint);
|
expect(root.textContent).toContain(copy.incompleteHint);
|
||||||
expect(root.querySelector('[aria-live="polite"]')).toBeTruthy();
|
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.querySelector(`a[href="${SITE_CONFIG.calendarUrl}"]`)).toBeNull();
|
||||||
expect(root.textContent).not.toContain(copy.calendarLabel);
|
expect(root.textContent).not.toContain(copy.calendarLabel);
|
||||||
|
|
||||||
@@ -85,4 +91,33 @@ describe('ContactBriefing', () => {
|
|||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
expect(xhrSpy).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>();
|
readonly copy = input.required<ContactPageCopy>();
|
||||||
|
|
||||||
protected readonly values = signal<Record<ContactFieldId, string>>({ ...EMPTY_VALUES });
|
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 contactEmail = SITE_CONFIG.contactEmail;
|
||||||
protected readonly calendarUrl = SITE_CONFIG.calendarUrl;
|
protected readonly calendarUrl = SITE_CONFIG.calendarUrl;
|
||||||
|
protected readonly incompleteId = 'contact-incomplete';
|
||||||
|
|
||||||
protected readonly missingLabels = computed(() => {
|
protected readonly missingLabels = computed(() => {
|
||||||
const copy = this.copy();
|
const copy = this.copy();
|
||||||
@@ -36,6 +38,8 @@ export class ContactBriefing {
|
|||||||
.map((field) => field.label);
|
.map((field) => field.label);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
protected readonly isComplete = computed(() => this.missingLabels().length === 0);
|
||||||
|
|
||||||
protected readonly incompleteMessage = computed(() => {
|
protected readonly incompleteMessage = computed(() => {
|
||||||
const missing = this.missingLabels();
|
const missing = this.missingLabels();
|
||||||
if (missing.length === 0) {
|
if (missing.length === 0) {
|
||||||
@@ -60,12 +64,44 @@ export class ContactBriefing {
|
|||||||
protected onInput(fieldId: ContactFieldId, event: Event): void {
|
protected onInput(fieldId: ContactFieldId, event: Event): void {
|
||||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
const target = event.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
|
||||||
this.values.update((current) => ({ ...current, [fieldId]: target.value }));
|
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 {
|
protected controlId(field: ContactFieldCopy): string {
|
||||||
return `contact-${field.id}`;
|
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 {
|
private assembleBody(copy: ContactPageCopy, values: Record<ContactFieldId, string>): string {
|
||||||
const lines = copy.fields.flatMap((field) => {
|
const lines = copy.fields.flatMap((field) => {
|
||||||
const raw = values[field.id];
|
const raw = values[field.id];
|
||||||
|
|||||||
Reference in New Issue
Block a user