Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe('StateCurrentController', () => {
element.current = null;
element.sync();

expect(element._internals!.ariaCurrent).toBe(null);
expect(element.matches(':state(current)')).toBe(false);
});

Expand All @@ -86,6 +87,17 @@ describe('StateCurrentController', () => {
expect(element.matches(':state(current)')).toBe(false);
});

it('should preserve current value on anchor aria-current', () => {
const anchor = document.createElement('a');
element.append(anchor);
element._internals!.states.add('anchor');

element.current = 'step';
element.sync();

expect(anchor.getAttribute('aria-current')).toBe('step');
});

it('should remove anchor aria-current while readonly', () => {
const anchor = document.createElement('a');
element.append(anchor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export class StateCurrentController<T extends Current> implements ReactiveContro

hostUpdated() {
if (this.host.readOnly) {
this.host._internals!.ariaCurrent = null;
toggleState(this.host._internals!, 'current', false);
this.#syncAnchorCurrentAttribute();
return;
}
Expand All @@ -35,9 +37,8 @@ export class StateCurrentController<T extends Current> implements ReactiveContro
return;
}

if (this.host.current !== null && this.host.current !== undefined) {
this.host._internals!.ariaCurrent = `${this.host.current}`;
}
this.host._internals!.ariaCurrent =
this.host.current === null || this.host.current === undefined ? null : `${this.host.current}`;

toggleState(this.host._internals!, 'current', Boolean(this.host.current));
}
Expand All @@ -51,7 +52,7 @@ export class StateCurrentController<T extends Current> implements ReactiveContro

if (anchor && isCurrent) {
this.#anchorCurrentTarget?.removeAttribute('aria-current');
anchor.setAttribute('aria-current', 'page');
anchor.setAttribute('aria-current', this.host.current ?? 'page');
this.#anchorCurrentTarget = anchor;
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ describe('StateExpandedController', () => {
});

it('should leave aria-expanded unset for absent values', () => {
element.expanded = true;
element.sync();

element.expanded = null;
element.sync();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ export class StateExpandedController<T extends Expanded> implements ReactiveCont
}

hostUpdated() {
if (this.host.expanded !== null && this.host.expanded !== undefined) {
this.host._internals!.ariaExpanded = `${this.host.expanded}`;
}
this.host._internals!.ariaExpanded =
this.host.expanded === null || this.host.expanded === undefined ? null : `${this.host.expanded}`;

toggleState(this.host._internals!, 'expanded', Boolean(this.host.expanded));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ describe('StatePressedController', () => {
});

it('should leave aria-pressed unset for absent values', () => {
element.pressed = true;
element.sync();

element.pressed = undefined;
element.sync();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ export class StatePressedController<T extends Pressed> implements ReactiveContro
}

hostUpdated() {
if (this.host.pressed !== null && this.host.pressed !== undefined) {
this.host._internals!.ariaPressed = `${this.host.pressed}`;
}
this.host._internals!.ariaPressed =
this.host.pressed === null || this.host.pressed === undefined ? null : `${this.host.pressed}`;

toggleState(this.host._internals!, 'pressed', Boolean(this.host.pressed));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ describe('StateSelectedController', () => {

expect(element._internals!.ariaSelected).toBe('false');
expect(element.matches(':state(selected)')).toBe(false);

element.selected = null;
element.sync();

expect(element._internals!.ariaSelected).toBe(null);
});

it('should move selected state to anchor aria-current for anchor hosts', () => {
Expand All @@ -87,6 +92,18 @@ describe('StateSelectedController', () => {
expect(element.matches(':state(selected)')).toBe(false);
});

it('should preserve current value on selected anchor aria-current', () => {
const anchor = document.createElement('a');
element.append(anchor);
element._internals!.states.add('anchor');

element.current = 'step';
element.selected = true;
element.sync();

expect(anchor.getAttribute('aria-current')).toBe('step');
});

it('should remove anchor aria-current while readonly', () => {
const anchor = document.createElement('a');
element.append(anchor);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export class StateSelectedController<T extends Selected> implements ReactiveCont

hostUpdated() {
if (this.host.readOnly) {
this.host._internals!.ariaSelected = null;
toggleState(this.host._internals!, 'selected', false);
this.#syncAnchorCurrentAttribute();
return;
}
Expand All @@ -35,9 +37,8 @@ export class StateSelectedController<T extends Selected> implements ReactiveCont
return;
}

if (this.host.selected !== null && this.host.selected !== undefined) {
this.host._internals!.ariaSelected = `${this.host.selected}`;
}
this.host._internals!.ariaSelected =
this.host.selected === null || this.host.selected === undefined ? null : `${this.host.selected}`;

toggleState(this.host._internals!, 'selected', Boolean(this.host.selected));
}
Expand All @@ -51,7 +52,7 @@ export class StateSelectedController<T extends Selected> implements ReactiveCont

if (anchor && isCurrent) {
this.#anchorCurrentTarget?.removeAttribute('aria-current');
anchor.setAttribute('aria-current', 'page');
anchor.setAttribute('aria-current', this.host.current ?? 'page');
this.#anchorCurrentTarget = anchor;
return;
}
Expand Down
4 changes: 2 additions & 2 deletions projects/forms/src/internal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export interface FormControlMetadata {
strict?: boolean;
}

interface FormControlInstance extends HTMLElement {
export interface FormControlInstance extends HTMLElement {
value: FormControlValue | undefined;
defaultValue: string;
form: HTMLFormElement | null;
Expand Down Expand Up @@ -102,4 +102,4 @@ export interface ValidatorResult {
message?: string;
}

export type Validator = (value: unknown, element: FormControl) => ValidatorResult;
export type Validator = (value: unknown, element: FormControlInstance) => ValidatorResult;
11 changes: 11 additions & 0 deletions projects/forms/src/mixins/checkbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ describe('CheckboxFormControlMixin', () => {
expect(new FormData(form).get('accepted')).toBe(null);
});

it('should exclude disabled checked values from form data', () => {
element.checked = true;
expect(new FormData(form).get('accepted')).toBe('on');

element.disabled = true;
expect(new FormData(form).get('accepted')).toBe(null);

element.disabled = false;
expect(new FormData(form).get('accepted')).toBe('on');
});

it('should support custom submitted values', () => {
element.value = 'yes';
element.checked = true;
Expand Down
13 changes: 9 additions & 4 deletions projects/forms/src/mixins/control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe('FormControlMixin', () => {

afterEach(() => {
removeFixture(fixture);
vi.restoreAllMocks();
});

it('should define element', () => {
Expand Down Expand Up @@ -253,10 +254,14 @@ describe('FormControlMixin', () => {
expect((element.form!.elements as unknown as Record<string, HTMLInputElement>)['test']!.value).toBe('test');
});

it('should throw an error if value is set to a non-string value', () => {
expect(() => {
element.valueAsNumber = 10;
}).toThrowError('(ui-test-element): cannot set number value on non-number type');
it('should warn and no-op when valueAsNumber is set on a non-number value', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

element.value = 'test';
element.valueAsNumber = 10;

expect(element.value).toBe('test');
expect(warn).toHaveBeenCalledWith('(ui-test-element): cannot set number value on non-number type');
});

it('should dispatch input event when value changes', async () => {
Expand Down
12 changes: 9 additions & 3 deletions projects/forms/src/mixins/control.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { FormControl, FormControlMetadata, FormControlValue, ValidatorResult } from '../internal/types.js';
import type {
FormControl,
FormControlInstance,
FormControlMetadata,
FormControlValue,
ValidatorResult
} from '../internal/types.js';
import { FormControlError } from '../internal/errors.js';
import { parseValueSchema } from '../internal/schema.js';
import { attachInternals, isObjectLiteral, toggleState } from '../internal/utils.js';
Expand Down Expand Up @@ -438,7 +444,7 @@ export function FormControlMixin<TBase extends Constructor, T extends FormContro
if (typeof this._value === 'number' && typeof value === 'number') {
this.value = value as T;
} else {
throw new FormControlError(this.localName, 'cannot set number value on non-number type');
console.warn(new FormControlError(this.localName, 'cannot set number value on non-number type').message);
}
}

Expand Down Expand Up @@ -631,7 +637,7 @@ export function FormControlMixin<TBase extends Constructor, T extends FormContro
}

for (const validator of this._validators) {
const result = validator(this.value, this as unknown as FormControl);
const result = validator(this.value, this as FormControlInstance);
if (!result.validity.valid) {
return result;
}
Expand Down
46 changes: 46 additions & 0 deletions projects/forms/src/mixins/slider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ class SliderTestElement extends SliderFormControlMixin<typeof HTMLElement>(HTMLE

customElements.define('ui-slider-test-element', SliderTestElement);

class CustomSliderDefaultsTestElement extends SliderFormControlMixin<typeof HTMLElement>(HTMLElement) {
static readonly metadata = {
version: '0.0.0',
tag: 'ui-custom-slider-defaults-test-element',
valueSchema: {
type: 'number' as const
}
};

static readonly sliderDefaults = {
max: 20,
min: 10,
step: 2,
value: 14
};
}

customElements.define('ui-custom-slider-defaults-test-element', CustomSliderDefaultsTestElement);
Comment thread
coryrylan marked this conversation as resolved.

describe('SliderFormControlMixin', () => {
let fixture: HTMLElement;
let element: SliderTestElement;
Expand Down Expand Up @@ -50,6 +69,33 @@ describe('SliderFormControlMixin', () => {
expect(element.step).toBe(1);
});

it('should support custom slider defaults', async () => {
const customFixture = await createFixture(html`
<form>
<ui-custom-slider-defaults-test-element name="level"></ui-custom-slider-defaults-test-element>
</form>
`);
const customElement = customFixture.querySelector<CustomSliderDefaultsTestElement>(
'ui-custom-slider-defaults-test-element'
)!;
const customForm = customFixture.querySelector<HTMLFormElement>('form')!;

expect(customElement.min).toBe(10);
expect(customElement.max).toBe(20);
expect(customElement.step).toBe(2);
expect(customElement.value).toBe(14);
expect(getInternals(customElement).ariaValueMin).toBe('10');
expect(getInternals(customElement).ariaValueMax).toBe('20');
expect(getInternals(customElement).ariaValueNow).toBe('14');
expect(new FormData(customForm).get('level')).toBe('14');

customElement.valueAsNumber = 18;
customElement.formResetCallback();
expect(customElement.valueAsNumber).toBe(14);

removeFixture(customFixture);
});
Comment on lines +72 to +97

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Guard fixture cleanup with try/finally in the async test.

If any assertion fails before Line 96, customFixture is never removed, which can leak DOM state into later tests.

As per coding guidelines, tests should follow stable fixture lifecycle patterns from /projects/site/src/docs/internal/guidelines/testing-unit.md (including reliable fixture handling).

Proposed fix
   it('should support custom slider defaults', async () => {
     const customFixture = await createFixture(html`
       <form>
         <ui-custom-slider-defaults-test-element name="level"></ui-custom-slider-defaults-test-element>
       </form>
     `);
-    const customElement = customFixture.querySelector<CustomSliderDefaultsTestElement>(
-      'ui-custom-slider-defaults-test-element'
-    )!;
-    const customForm = customFixture.querySelector<HTMLFormElement>('form')!;
-
-    expect(customElement.min).toBe(10);
-    expect(customElement.max).toBe(20);
-    expect(customElement.step).toBe(2);
-    expect(customElement.value).toBe(14);
-    expect(getInternals(customElement).ariaValueMin).toBe('10');
-    expect(getInternals(customElement).ariaValueMax).toBe('20');
-    expect(getInternals(customElement).ariaValueNow).toBe('14');
-    expect(new FormData(customForm).get('level')).toBe('14');
-
-    customElement.valueAsNumber = 18;
-    customElement.formResetCallback();
-    expect(customElement.valueAsNumber).toBe(14);
-
-    removeFixture(customFixture);
+    try {
+      const customElement = customFixture.querySelector<CustomSliderDefaultsTestElement>(
+        'ui-custom-slider-defaults-test-element'
+      )!;
+      const customForm = customFixture.querySelector<HTMLFormElement>('form')!;
+
+      expect(customElement.min).toBe(10);
+      expect(customElement.max).toBe(20);
+      expect(customElement.step).toBe(2);
+      expect(customElement.value).toBe(14);
+      expect(getInternals(customElement).ariaValueMin).toBe('10');
+      expect(getInternals(customElement).ariaValueMax).toBe('20');
+      expect(getInternals(customElement).ariaValueNow).toBe('14');
+      expect(new FormData(customForm).get('level')).toBe('14');
+
+      customElement.valueAsNumber = 18;
+      customElement.formResetCallback();
+      expect(customElement.valueAsNumber).toBe(14);
+    } finally {
+      removeFixture(customFixture);
+    }
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should support custom slider defaults', async () => {
const customFixture = await createFixture(html`
<form>
<ui-custom-slider-defaults-test-element name="level"></ui-custom-slider-defaults-test-element>
</form>
`);
const customElement = customFixture.querySelector<CustomSliderDefaultsTestElement>(
'ui-custom-slider-defaults-test-element'
)!;
const customForm = customFixture.querySelector<HTMLFormElement>('form')!;
expect(customElement.min).toBe(10);
expect(customElement.max).toBe(20);
expect(customElement.step).toBe(2);
expect(customElement.value).toBe(14);
expect(getInternals(customElement).ariaValueMin).toBe('10');
expect(getInternals(customElement).ariaValueMax).toBe('20');
expect(getInternals(customElement).ariaValueNow).toBe('14');
expect(new FormData(customForm).get('level')).toBe('14');
customElement.valueAsNumber = 18;
customElement.formResetCallback();
expect(customElement.valueAsNumber).toBe(14);
removeFixture(customFixture);
});
it('should support custom slider defaults', async () => {
const customFixture = await createFixture(html`
<form>
<ui-custom-slider-defaults-test-element name="level"></ui-custom-slider-defaults-test-element>
</form>
`);
try {
const customElement = customFixture.querySelector<CustomSliderDefaultsTestElement>(
'ui-custom-slider-defaults-test-element'
)!;
const customForm = customFixture.querySelector<HTMLFormElement>('form')!;
expect(customElement.min).toBe(10);
expect(customElement.max).toBe(20);
expect(customElement.step).toBe(2);
expect(customElement.value).toBe(14);
expect(getInternals(customElement).ariaValueMin).toBe('10');
expect(getInternals(customElement).ariaValueMax).toBe('20');
expect(getInternals(customElement).ariaValueNow).toBe('14');
expect(new FormData(customForm).get('level')).toBe('14');
customElement.valueAsNumber = 18;
customElement.formResetCallback();
expect(customElement.valueAsNumber).toBe(14);
} finally {
removeFixture(customFixture);
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@projects/forms/src/mixins/slider.test.ts` around lines 72 - 97, The test's
fixture cleanup can leak when an assertion fails because
removeFixture(customFixture) is only called at the end; wrap the async test body
so that after awaiting createFixture(...) and assigning customFixture you run
your assertions inside a try block and call removeFixture(customFixture) in a
finally block (so the fixture is always removed). Locate the test named "should
support custom slider defaults" and modify the structure around createFixture,
customFixture, and removeFixture to ensure removeFixture(customFixture) executes
in finally; keep existing assertions and operations on customElement (min, max,
step, value, getInternals checks, FormData check, valueAsNumber change and
formResetCallback) inside the try.

Source: Coding guidelines


it('should submit numeric form data', () => {
element.value = 10;

Expand Down
31 changes: 22 additions & 9 deletions projects/forms/src/validators/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,34 @@

import { describe, it, expect } from 'vitest';
import { requiredValidator, valueSchemaValidator } from './index.js';
import type { FormControl } from '../internal/types.js';
import type { FormControlInstance } from '../internal/types.js';

describe('requiredValidator', () => {
it('should return valid when value exists', () => {
const result = requiredValidator('test', { required: true } as unknown as FormControl & { required: boolean });
const result = requiredValidator('test', { required: true } as FormControlInstance);
expect(result.validity.valueMissing).toBe(undefined);
expect(result.message).toBe('');
expect(result.validity.valid).toBe(true);
});

it('should return invalid when value is empty', () => {
const result = requiredValidator('', { required: true } as unknown as FormControl & { required: boolean });
expect(result.validity.valueMissing).toBe(true);
expect(result.message).toBe('This field is required');
expect(result.validity.valid).toBe(false);
it('should return invalid when required values are empty', () => {
[undefined, null, ''].forEach(value => {
const result = requiredValidator(value, { required: true } as FormControlInstance);

expect(result.validity.valueMissing).toBe(true);
expect(result.message).toBe('This field is required');
expect(result.validity.valid).toBe(false);
});
});

it('should return valid when empty values are not required', () => {
[undefined, null, ''].forEach(value => {
const result = requiredValidator(value, { required: false } as FormControlInstance);

expect(result.validity.valueMissing).toBe(undefined);
expect(result.message).toBe('');
expect(result.validity.valid).toBe(true);
});
});
});

Expand All @@ -29,7 +42,7 @@ describe('valueSchemaValidator', () => {
};
}

const result = valueSchemaValidator('test', new Test() as unknown as FormControl);
const result = valueSchemaValidator('test', new Test() as FormControlInstance);
expect(result.validity.valid).toBe(true);
expect(result.message).toBe('');
});
Expand All @@ -41,7 +54,7 @@ describe('valueSchemaValidator', () => {
};
}

const result = valueSchemaValidator('test', new Test() as unknown as FormControl);
const result = valueSchemaValidator('test', new Test() as FormControlInstance);
expect(result.validity.valid).toBe(false);
expect(result.message).toBe('expected type number, received type string');
});
Expand Down
Loading