Skip to content

Commit 5e1721d

Browse files
committed
fix(core): refactor to use form control mixins
Signed-off-by: Cory Rylan <crylan@nvidia.com>
1 parent fc88b3a commit 5e1721d

29 files changed

Lines changed: 556 additions & 622 deletions

projects/core/src/button/button.test.lighthouse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ describe('button lighthouse report', () => {
1818
expect(report.scores.performance).toBe(100);
1919
expect(report.scores.accessibility).toBe(100);
2020
expect(report.scores.bestPractices).toBe(100);
21-
expect(report.payload.javascript.kb).toBeLessThan(13.6);
21+
expect(report.payload.javascript.kb).toBeLessThan(14.2);
2222
});
2323
});

projects/core/src/button/button.test.ts

Lines changed: 351 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { html } from 'lit';
5-
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
6-
import { createFixture, elementIsStable, emulateClick, removeFixture } from '@internals/testing';
5+
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
6+
import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing';
77
import { Button } from '@nvidia-elements/core/button';
88
import '@nvidia-elements/core/button/define.js';
99

@@ -115,3 +115,352 @@ describe(`${Button.metadata.tag} - submit`, () => {
115115
expect(called).toBe(false);
116116
});
117117
});
118+
119+
describe('dynamic form reference', () => {
120+
it('should gracefully fall back to null if created and referencing a form not yet created', async () => {
121+
const button = document.createElement('nve-button') as Button;
122+
button.setAttribute('form', 'test-form');
123+
expect(button.form).toBe(null);
124+
125+
const form = document.createElement('form');
126+
form.id = 'test-form';
127+
document.body.appendChild(form);
128+
expect(button.form).toBe(null); // form is not available until form and button are attached to the DOM
129+
130+
document.body.appendChild(button);
131+
expect(button.form).toBe(form); // form is now available after button and form are attached to the DOM
132+
133+
document.body.removeChild(form);
134+
document.body.removeChild(button);
135+
});
136+
});
137+
138+
describe(`${Button.metadata.tag} - button semantics`, () => {
139+
let element: Button;
140+
let fixture: HTMLElement;
141+
let buttonInForm: Button;
142+
let submitButtonInForm: Button;
143+
let form: HTMLFormElement;
144+
let otherForm: HTMLFormElement;
145+
146+
beforeEach(async () => {
147+
fixture = await createFixture(html`
148+
<form id="other"></form>
149+
<nve-button></nve-button>
150+
<form id="main">
151+
<nve-button type="button"></nve-button>
152+
<nve-button></nve-button>
153+
</form>`);
154+
155+
element = fixture.querySelectorAll<Button>('nve-button')[0];
156+
buttonInForm = fixture.querySelectorAll<Button>('nve-button')[1];
157+
submitButtonInForm = fixture.querySelectorAll<Button>('nve-button')[2];
158+
form = fixture.querySelector('form[id=main]');
159+
form.addEventListener('submit', e => e.preventDefault());
160+
otherForm = fixture.querySelector('form[id=other]');
161+
otherForm.addEventListener('submit', e => e.preventDefault());
162+
buttonInForm.type = 'button';
163+
});
164+
165+
afterEach(() => {
166+
removeFixture(fixture);
167+
});
168+
169+
it('should add active state on mousedown', async () => {
170+
expect(element.matches(':state(active)')).toBe(false);
171+
172+
element.dispatchEvent(new MouseEvent('mousedown'));
173+
expect(element.matches(':state(active)')).toBe(true);
174+
175+
element.dispatchEvent(new MouseEvent('mouseup'));
176+
expect(element.matches(':state(active)')).toBe(false);
177+
});
178+
179+
it('should not add active state if element is disabled', async () => {
180+
element.disabled = true;
181+
expect(element.matches(':state(active)')).toBe(false);
182+
183+
element.dispatchEvent(new MouseEvent('mousedown'));
184+
expect(element.matches(':state(active)')).toBe(false);
185+
});
186+
187+
it('should initialize aria-disabled', async () => {
188+
element.disabled = true;
189+
await elementIsStable(element);
190+
expect(element._internals.ariaDisabled).toBe('true');
191+
expect(element.matches(':state(disabled)')).toBe(true);
192+
});
193+
194+
it('should update aria-disabled when disabled API is updated', async () => {
195+
element.disabled = true;
196+
await elementIsStable(element);
197+
expect(element._internals.ariaDisabled).toBe('true');
198+
expect(element.matches(':state(disabled)')).toBe(true);
199+
200+
element.disabled = false;
201+
await elementIsStable(element);
202+
expect(element._internals.ariaDisabled).toBe('false');
203+
expect(element.matches(':state(disabled)')).toBe(false);
204+
});
205+
206+
it('should remove aria-disabled if readonly', async () => {
207+
element.readOnly = true;
208+
await elementIsStable(element);
209+
expect(element._internals.ariaDisabled).toBe(null);
210+
expect(element.matches(':state(disabled)')).toBe(false);
211+
});
212+
213+
it('should initialize aria-expanded as null', async () => {
214+
await elementIsStable(element);
215+
expect(element._internals.ariaExpanded).toBe(null);
216+
expect(element.matches(':state(expanded)')).toBe(false);
217+
});
218+
219+
it('should initialize aria-expanded as null if expanded not applied', async () => {
220+
await elementIsStable(element);
221+
expect(element._internals.ariaExpanded).toBe(null);
222+
expect(element.matches(':state(expanded)')).toBe(false);
223+
});
224+
225+
it('should initialize aria-expanded as true if expanded applied', async () => {
226+
element.expanded = true;
227+
await elementIsStable(element);
228+
expect(element._internals.ariaExpanded).toBe('true');
229+
expect(element.matches(':state(expanded)')).toBe(true);
230+
});
231+
232+
it('should initialize aria-expanded as false if expanded=false is applied', async () => {
233+
element.expanded = false;
234+
await elementIsStable(element);
235+
expect(element._internals.ariaExpanded).toBe('false');
236+
expect(element.matches(':state(expanded)')).toBe(false);
237+
});
238+
239+
it('should remove aria-expanded if readonly', async () => {
240+
element.expanded = true;
241+
await elementIsStable(element);
242+
expect(element._internals.ariaExpanded).toBe('true');
243+
expect(element.matches(':state(expanded)')).toBe(true);
244+
245+
element.readOnly = true;
246+
await elementIsStable(element);
247+
expect(element._internals.ariaExpanded).toBe(null);
248+
expect(element.matches(':state(expanded)')).toBe(false);
249+
});
250+
251+
it('should initialize aria-pressed as null', async () => {
252+
await elementIsStable(element);
253+
expect(element._internals.ariaPressed).toBe(null);
254+
expect(element.matches(':state(pressed)')).toBe(false);
255+
});
256+
257+
it('should initialize aria-pressed as null if pressed not applied', async () => {
258+
element.pressed = true;
259+
await elementIsStable(element);
260+
expect(element._internals.ariaPressed).toBe('true');
261+
expect(element.matches(':state(pressed)')).toBe(true);
262+
});
263+
264+
it('should initialize aria-pressed as false if pressed=false applied', async () => {
265+
element.pressed = false;
266+
await elementIsStable(element);
267+
expect(element._internals.ariaPressed).toBe('false');
268+
expect(element.matches(':state(pressed)')).toBe(false);
269+
});
270+
271+
it('should remove aria-pressed if readonly', async () => {
272+
element.pressed = true;
273+
await elementIsStable(element);
274+
expect(element._internals.ariaPressed).toBe('true');
275+
expect(element.matches(':state(pressed)')).toBe(true);
276+
277+
element.readOnly = true;
278+
await elementIsStable(element);
279+
expect(element._internals.ariaPressed).toBe(null);
280+
expect(element.matches(':state(pressed)')).toBe(false);
281+
});
282+
283+
it('should initialize tabindex 0 for focus behavior', async () => {
284+
await elementIsStable(element);
285+
expect(element.tabIndex).toBe(0);
286+
});
287+
288+
it('should initialize role button', async () => {
289+
await elementIsStable(element);
290+
expect(element._internals.role).toBe('button');
291+
});
292+
293+
it('should remove tabindex if disabled', async () => {
294+
element.disabled = true;
295+
await elementIsStable(element);
296+
expect(element.tabIndex).toBe(-1);
297+
});
298+
299+
it('should remove tabindex and role if readonly', async () => {
300+
element.readOnly = true;
301+
await elementIsStable(element);
302+
expect(element.tabIndex).toBe(-1);
303+
expect(element._internals.role).toBe('none');
304+
expect(element.getAttribute('role')).toBe(null);
305+
});
306+
307+
it('should map readonly attribute to readOnly property', async () => {
308+
element.setAttribute('readonly', '');
309+
await elementIsStable(element);
310+
expect(element.readOnly).toBe(true);
311+
});
312+
313+
it('should reflect readOnly property to readonly attribute', async () => {
314+
element.readOnly = true;
315+
await elementIsStable(element);
316+
expect(element.hasAttribute('readonly')).toBe(true);
317+
318+
element.readOnly = false;
319+
await elementIsStable(element);
320+
expect(element.hasAttribute('readonly')).toBe(false);
321+
});
322+
323+
it('should support deprecated readonly property alias', async () => {
324+
element.readonly = true;
325+
await elementIsStable(element);
326+
expect(element.readOnly).toBe(true);
327+
expect(element.hasAttribute('readonly')).toBe(true);
328+
});
329+
330+
it('should set the button type to submit if not defined within a form element', async () => {
331+
await elementIsStable(element);
332+
expect(element.type).toBe(undefined);
333+
expect(buttonInForm.type).toBe('button');
334+
expect(submitButtonInForm.type).toBe('submit');
335+
});
336+
337+
it('should add or remove button event listeners when readOnly updates', async () => {
338+
await elementIsStable(submitButtonInForm);
339+
expect(submitButtonInForm.readOnly).toBe(false);
340+
341+
vi.spyOn(submitButtonInForm, 'removeEventListener');
342+
submitButtonInForm.readOnly = true;
343+
await elementIsStable(submitButtonInForm);
344+
expect(submitButtonInForm.removeEventListener).toBeCalledTimes(3); // 2x button controller, 1x command controller
345+
346+
vi.spyOn(submitButtonInForm, 'addEventListener');
347+
submitButtonInForm.readOnly = false;
348+
await elementIsStable(submitButtonInForm);
349+
expect(submitButtonInForm.addEventListener).toBeCalledTimes(3); // 2x button controller, 1x command controller
350+
});
351+
352+
it('should trigger submit event when host exists within a form element', async () => {
353+
submitButtonInForm.type = 'submit';
354+
await elementIsStable(submitButtonInForm);
355+
const event = untilEvent(form, 'submit');
356+
form.dispatchEvent(new Event('submit')); // happy-dom does not emulate form submit behavior, so a manual dispatch is required
357+
emulateClick(submitButtonInForm);
358+
expect((await event).type).toBe('submit');
359+
});
360+
361+
it('should not interact with form elements if type button', async () => {
362+
submitButtonInForm.type = 'button';
363+
await elementIsStable(submitButtonInForm);
364+
const o = { f: () => null };
365+
vi.spyOn(o, 'f');
366+
367+
form.addEventListener('submit', o.f);
368+
emulateClick(submitButtonInForm);
369+
370+
const event = new KeyboardEvent('keyup', { key: 'enter' });
371+
submitButtonInForm.focus();
372+
submitButtonInForm.dispatchEvent(event);
373+
expect(o.f).not.toHaveBeenCalled();
374+
});
375+
376+
it('should handle dynamic changes for type', async () => {
377+
const o = { f: () => null };
378+
vi.spyOn(o, 'f');
379+
380+
// change default (implicit "submit") to type="button"
381+
submitButtonInForm.type = 'button';
382+
await elementIsStable(submitButtonInForm);
383+
form.addEventListener('submit', o.f);
384+
emulateClick(submitButtonInForm);
385+
expect(o.f).not.toHaveBeenCalled();
386+
387+
// change type="button" to type="submit"
388+
submitButtonInForm.type = 'submit';
389+
await elementIsStable(submitButtonInForm);
390+
form.removeEventListener('submit', o.f);
391+
emulateClick(submitButtonInForm);
392+
393+
// change from type="submit" to type="button"
394+
submitButtonInForm.type = 'button';
395+
await elementIsStable(submitButtonInForm);
396+
form.addEventListener('submit', o.f);
397+
emulateClick(submitButtonInForm);
398+
expect(o.f).not.toHaveBeenCalled();
399+
});
400+
401+
it('should not interact with form elements if disabled', async () => {
402+
submitButtonInForm.disabled = true;
403+
await elementIsStable(submitButtonInForm);
404+
405+
const o = { f: () => null };
406+
vi.spyOn(o, 'f');
407+
408+
form.addEventListener('submit', o.f);
409+
410+
emulateClick(submitButtonInForm);
411+
412+
expect(o.f).not.toHaveBeenCalled();
413+
});
414+
415+
it('should respect form attribute', async () => {
416+
submitButtonInForm.form = 'other';
417+
await elementIsStable(submitButtonInForm);
418+
419+
expect(submitButtonInForm.form).toBe(otherForm);
420+
421+
const f = { f: () => null };
422+
vi.spyOn(f, 'f');
423+
form.addEventListener('submit', f.f);
424+
425+
const o = { f: () => null };
426+
vi.spyOn(o, 'f');
427+
otherForm.addEventListener('submit', o.f);
428+
429+
emulateClick(submitButtonInForm);
430+
431+
expect(f.f).not.toHaveBeenCalled();
432+
expect(o.f).toHaveBeenCalled();
433+
});
434+
435+
it('should return associated form if in a form element', async () => {
436+
await elementIsStable(element);
437+
expect(buttonInForm.form).toBe(form);
438+
expect(submitButtonInForm.form).toBe(form);
439+
});
440+
441+
it('should accept a direct form element reference', async () => {
442+
submitButtonInForm.form = otherForm;
443+
await elementIsStable(submitButtonInForm);
444+
445+
expect(submitButtonInForm.form).toBe(otherForm);
446+
expect(submitButtonInForm.hasAttribute('form')).toBe(false);
447+
});
448+
449+
it('should be able to access form property from submit event even if form is not in the same document', async () => {
450+
element.form = 'main';
451+
element.type = 'submit';
452+
element.name = 'test-name';
453+
element.value = 'test-value';
454+
await elementIsStable(element);
455+
const submit = untilEvent(form, 'submit');
456+
emulateClick(element);
457+
const event = await submit;
458+
expect(event.target).toBe(form);
459+
expect((((await event) as SubmitEvent).submitter as HTMLButtonElement).name).toBe('test-name');
460+
expect(event.submitter.form).toBe(form);
461+
expect(event.submitter.name).toBe('test-name');
462+
expect(event.submitter.type).toBe('submit');
463+
expect(event.submitter.value).toBe('test-value');
464+
// expect(event.submitter).toBe(button); // https://github.com/WICG/webcomponents/issues/814
465+
});
466+
});

projects/core/src/button/button.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { html } from 'lit';
4+
import { html, LitElement } from 'lit';
55
import { property } from 'lit/decorators/property.js';
6+
import { ButtonFormControlMixin } from '@nvidia-elements/forms/mixins';
67
import type { Interaction, Inverse, FlatInteraction, Size } from '@nvidia-elements/core/internal';
7-
import { BaseButton, useStyles } from '@nvidia-elements/core/internal';
8+
import { useStyles } from '@nvidia-elements/core/internal';
89
import styles from './button.css?inline';
910

1011
/**
@@ -31,7 +32,7 @@ import styles from './button.css?inline';
3132
* @cssprop --min-width
3233
* @aria https://www.w3.org/WAI/ARIA/apg/patterns/button/
3334
*/
34-
export class Button extends BaseButton {
35+
export class Button extends ButtonFormControlMixin(LitElement) {
3536
static styles = useStyles([styles]);
3637

3738
static readonly metadata = {

0 commit comments

Comments
 (0)